Merge branch 'nico/28-nov-25' into staging

This commit is contained in:
2025-11-28 11:45:12 +08:00
869 changed files with 45387 additions and 21112 deletions

3
.gitignore vendored
View File

@@ -41,6 +41,9 @@ next-env.d.ts
# uploads # uploads
/uploads /uploads
# download
/download
# cache # cache
/cache /cache

BIN
bun.lockb

Binary file not shown.

View File

@@ -3,9 +3,9 @@
"version": "0.1.5", "version": "0.1.5",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "bun --bun next dev --hostname 0.0.0.0", "dev": "next dev",
"build": "bun --bun next build", "build": "next build",
"start": "bun --bun next start" "start": "next start"
}, },
"prisma": { "prisma": {
"seed": "bun run prisma/seed.ts" "seed": "bun run prisma/seed.ts"
@@ -19,6 +19,7 @@
"@elysiajs/static": "^1.3.0", "@elysiajs/static": "^1.3.0",
"@elysiajs/stream": "^1.1.0", "@elysiajs/stream": "^1.1.0",
"@elysiajs/swagger": "^1.2.0", "@elysiajs/swagger": "^1.2.0",
"@emotion/react": "^11.14.0",
"@mantine/carousel": "^7.16.2", "@mantine/carousel": "^7.16.2",
"@mantine/charts": "^7.17.1", "@mantine/charts": "^7.17.1",
"@mantine/core": "^7.17.4", "@mantine/core": "^7.17.4",
@@ -26,6 +27,7 @@
"@mantine/dropzone": "^8.1.1", "@mantine/dropzone": "^8.1.1",
"@mantine/form": "^8.1.0", "@mantine/form": "^8.1.0",
"@mantine/hooks": "^7.17.4", "@mantine/hooks": "^7.17.4",
"@mantine/modals": "^8.3.6",
"@mantine/tiptap": "^7.17.4", "@mantine/tiptap": "^7.17.4",
"@paljs/types": "^8.1.0", "@paljs/types": "^8.1.0",
"@prisma/client": "^6.3.1", "@prisma/client": "^6.3.1",
@@ -39,19 +41,27 @@
"@tiptap/pm": "^2.11.7", "@tiptap/pm": "^2.11.7",
"@tiptap/react": "^2.11.7", "@tiptap/react": "^2.11.7",
"@tiptap/starter-kit": "^2.11.7", "@tiptap/starter-kit": "^2.11.7",
"@types/adm-zip": "^0.5.7",
"@types/bun": "^1.2.2", "@types/bun": "^1.2.2",
"@types/leaflet": "^1.9.20", "@types/leaflet": "^1.9.20",
"@types/lodash": "^4.17.16", "@types/lodash": "^4.17.16",
"@types/nodemailer": "^7.0.2",
"add": "^2.0.6", "add": "^2.0.6",
"adm-zip": "^0.5.16",
"animate.css": "^4.1.1", "animate.css": "^4.1.1",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"bun": "^1.2.2", "bun": "^1.2.2",
"chart.js": "^4.4.8", "chart.js": "^4.4.8",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"colors": "^1.4.0",
"date-fns": "^4.1.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dotenv": "^17.2.3",
"elysia": "^1.3.5", "elysia": "^1.3.5",
"embla-carousel-autoplay": "^8.5.2", "embla-carousel": "^8.6.0",
"embla-carousel-react": "^7.1.0", "embla-carousel-autoplay": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"extract-zip": "^2.0.1",
"form-data": "^4.0.2", "form-data": "^4.0.2",
"framer-motion": "^12.23.5", "framer-motion": "^12.23.5",
"get-port": "^7.1.0", "get-port": "^7.1.0",
@@ -67,17 +77,20 @@
"next": "^15.5.2", "next": "^15.5.2",
"next-view-transitions": "^0.3.4", "next-view-transitions": "^0.3.4",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"nodemailer": "^7.0.10",
"p-limit": "^6.2.0", "p-limit": "^6.2.0",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primereact": "^10.9.6", "primereact": "^10.9.6",
"prisma": "^6.3.1", "prisma": "^6.3.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-exif-orientation-img": "^0.1.5",
"react-international-phone": "^4.6.0", "react-international-phone": "^4.6.0",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
"react-simple-toasts": "^6.1.0", "react-simple-toasts": "^6.1.0",
"react-toastify": "^11.0.5", "react-toastify": "^11.0.5",
"react-transition-group": "^4.4.5", "react-transition-group": "^4.4.5",
"react-zoom-pan-pinch": "^3.7.0",
"readdirp": "^4.1.1", "readdirp": "^4.1.1",
"recharts": "^2.15.3", "recharts": "^2.15.3",
"sharp": "^0.34.3", "sharp": "^0.34.3",

View File

@@ -1,5 +1,4 @@
[ [
{ "name": "Semua" },
{ "name": "Pemerintahan" }, { "name": "Pemerintahan" },
{ "name": "Pembangunan" }, { "name": "Pembangunan" },
{ "name": "Ekonomi" }, { "name": "Ekonomi" },

View File

@@ -1,6 +1,6 @@
[ [
{ {
"id": "1", "id": "edit",
"name": "Pelayanan Penduduk Non-Permanent", "name": "Pelayanan Penduduk Non-Permanent",
"deskripsi": "<p>Surat Keterangan Penduduk Non-Permanent adalah dokumen yang dikeluarkan oleh pihak berwenang untuk memberikan keterangan bahwa seseorang atau kelompok orang memiliki status penduduk non-permanent di suatu wilayah. Dokumen ini biasanya digunakan untuk keperluan administratif atau legal, seperti mendapatkan akses ke layanan kesehatan, pendidikan, atau pelayanan publik lainnya.</p>" "deskripsi": "<p>Surat Keterangan Penduduk Non-Permanent adalah dokumen yang dikeluarkan oleh pihak berwenang untuk memberikan keterangan bahwa seseorang atau kelompok orang memiliki status penduduk non-permanent di suatu wilayah. Dokumen ini biasanya digunakan untuk keperluan administratif atau legal, seperti mendapatkan akses ke layanan kesehatan, pendidikan, atau pelayanan publik lainnya.</p>"
} }

View File

@@ -1,6 +1,6 @@
[ [
{ {
"id": "1", "id": "edit",
"name": "Pelayanan Perizinan Berusaha Berbasis Risiko Melalui Sistem ONLINE SINGLE SUBMISSION (OSS)", "name": "Pelayanan Perizinan Berusaha Berbasis Risiko Melalui Sistem ONLINE SINGLE SUBMISSION (OSS)",
"deskripsi": "<p>Penyelenggaraan Perizinan Berusaha Berbasis Risiko melalui Sistem Online Single Submission (OSS) merupakan pelaksanaan Undang-Undang Nomor 11 Tahun 2020 Tentang Cipta Kerja. OSS Berbasis Risiko wajib digunakan oleh Pelaku Usaha, Kementerian/Lembaga, Pemerintah Daerah, Administrator Kawasan Ekonomi Khusus (KEK), dan Badan Pengusahaan Kawasan Perdagangan Bebas Pelabuhan Bebas (KPBPB).Berdasarkan Peraturan Pemerintah Nomor 5 Tahun 2021 terdapat 1.702 kegiatan usaha yang terdiri atas 1.349 Klasifikasi Baku Lapangan Usaha Indonesia (KBLI) yang sudah diimplementasikan dalam Sistem OSS Berbasis Risiko.</p>", "deskripsi": "<p>Penyelenggaraan Perizinan Berusaha Berbasis Risiko melalui Sistem Online Single Submission (OSS) merupakan pelaksanaan Undang-Undang Nomor 11 Tahun 2020 Tentang Cipta Kerja. OSS Berbasis Risiko wajib digunakan oleh Pelaku Usaha, Kementerian/Lembaga, Pemerintah Daerah, Administrator Kawasan Ekonomi Khusus (KEK), dan Badan Pengusahaan Kawasan Perdagangan Bebas Pelabuhan Bebas (KPBPB).Berdasarkan Peraturan Pemerintah Nomor 5 Tahun 2021 terdapat 1.702 kegiatan usaha yang terdiri atas 1.349 Klasifikasi Baku Lapangan Usaha Indonesia (KBLI) yang sudah diimplementasikan dalam Sistem OSS Berbasis Risiko.</p>",
"link" : "https://oss.go.id/" "link" : "https://oss.go.id/"

View File

@@ -1,8 +0,0 @@
[
{
"id": "650e8400-e29b-41d4-a716-446655440001",
"atasanId": "550e8400-e29b-41d4-a716-446655440001",
"bawahanId": "550e8400-e29b-41d4-a716-446655440002",
"tipe": "Langsung Melapor"
}
]

View File

@@ -0,0 +1,91 @@
[
{
"id": "cmgewz4gt000704ib91i3f169",
"namaLengkap": "Ida Bagus Surya Prabhawa Manuaba, S.H.,M.H., NL.P.",
"gelarAkademik": "S.H.,M.H.,NL.P.",
"tanggalMasuk": "2020-01-01T00:00:00.000Z",
"email": "bagus@desa.id",
"telepon": "081234567891",
"alamat": "Jl. Raya Desa No. 1",
"posisiId": "kepala_desa",
"isActive": true
},
{
"id": "cmgewxfvw000004ibee5013f4",
"namaLengkap": "I Ketut Suwanta",
"gelarAkademik": "S.Pt",
"tanggalMasuk": "2020-02-01T00:00:00.000Z",
"email": "suwanta@desa.id",
"telepon": "081234567892",
"alamat": "Jl. Raya Desa No. 2",
"posisiId": "sekretaris_desa",
"isActive": true
},
{
"id": "cmgewxvqw000104ibgm5l8fzs",
"namaLengkap": "Ni Wayan Supardiati",
"gelarAkademik": "S.Pd",
"tanggalMasuk": "2020-02-01T00:00:00.000Z",
"email": "supardiati@desa.id",
"telepon": "081234567892",
"alamat": "Jl. Raya Desa No. 2",
"posisiId": "kaur_keuangan",
"isActive": true
},
{
"id": "cmgewy1g9000204ib2n7hbx0i",
"namaLengkap": "I Wayan Agus Juni Artha Saputra",
"gelarAkademik": "S.T.",
"tanggalMasuk": "2020-02-01T00:00:00.000Z",
"email": "agus@desa.id",
"telepon": "081234567892",
"alamat": "Jl. Raya Desa No. 2",
"posisiId": "kadus_banjar_dinas_menesa",
"isActive": true
},
{
"id": "cmgewybah000304ibgqhn1gm2",
"namaLengkap": "I Wayan Sueca",
"gelarAkademik": "S.H.",
"tanggalMasuk": "2020-02-01T00:00:00.000Z",
"email": "sueca@desa.id",
"telepon": "081234567893",
"alamat": "Jl. Raya Desa No. 2",
"posisiId": "kadus_banjar_dinas_darmasaba",
"isActive": true
},
{
"id": "cmgewygqz000404ib20sv8nvg",
"namaLengkap": "Si Gede Ketut Astawa",
"gelarAkademik": "S.T.",
"tanggalMasuk": "2020-02-01T00:00:00.000Z",
"email": "astawa@desa.id",
"telepon": "081234567893",
"alamat": "Jl. Raya Desa No. 2",
"posisiId": "kadus_banjar_dinas_bucu",
"isActive": true
},
{
"id": "cmgewyos1000504ibcu8o2gyk",
"namaLengkap": "I Kadek Arya Minarta",
"gelarAkademik": "S.T.",
"tanggalMasuk": "2020-02-01T00:00:00.000Z",
"email": "minarta@desa.id",
"telepon": "081234567893",
"alamat": "Jl. Raya Desa No. 2",
"posisiId": "kadus_banjar_dinas_gulingan",
"isActive": true
},
{
"id": "cmgewyxk7000604ib8djs3i6c",
"namaLengkap": "I Gede Andika Pradnya Diputra",
"gelarAkademik": "S.E.",
"tanggalMasuk": "2020-02-01T00:00:00.000Z",
"email": "diputra@desa.id",
"telepon": "081234567893",
"alamat": "Jl. Raya Desa No. 2",
"posisiId": "kadus_banjar_dinas_taman",
"isActive": true
}
]

View File

@@ -1,24 +0,0 @@
[
{
"id": "550e8400-e29b-41d4-a716-446655440001",
"namaLengkap": "Budi Santoso",
"gelarAkademik": "S.IP",
"tanggalMasuk": "2020-01-01T00:00:00.000Z",
"email": "budi@desa.id",
"telepon": "081234567891",
"alamat": "Jl. Raya Desa No. 1",
"posisiId": "kepala_desa",
"isActive": true
},
{
"id": "550e8400-e29b-41d4-a716-446655440002",
"namaLengkap": "Ani Lestari",
"gelarAkademik": "S.Pd",
"tanggalMasuk": "2020-02-01T00:00:00.000Z",
"email": "ani@desa.id",
"telepon": "081234567892",
"alamat": "Jl. Raya Desa No. 2",
"posisiId": "sekretaris_desa",
"isActive": true
}
]

View File

@@ -0,0 +1,159 @@
[
[
{
"id": "kepala_desa",
"nama": "Kepala Desa",
"deskripsi": "Pemimpin desa Darmasaba",
"hierarki": 1,
"parentId": null
},
{
"id": "kepala_urusan",
"nama": "Kepala Urusan",
"deskripsi": "Pemimpin urusan desa Darmasaba",
"hierarki": 2,
"parentId": "kepala_desa"
},
{
"id": "sekretaris_desa",
"nama": "Sekretaris Desa",
"deskripsi": "Pengelola administrasi desa",
"hierarki": 2,
"parentId": "kepala_desa"
},
{
"id": "kaur_keuangan",
"nama": "Kaur Keuangan",
"deskripsi": "Pengelola keuangan desa",
"hierarki": 3,
"parentId": "kaur_umum"
},
{
"id": "kaur_perencanaan",
"nama": "Kaur Perencanaan",
"deskripsi": "Penyusun program kerja desa",
"hierarki": 3,
"parentId": "kaur_umum"
},
{
"id": "kaur_umum",
"nama": "Kaur Umum & TU",
"deskripsi": "Pelayanan umum dan administrasi",
"hierarki": 2,
"parentId": "kepala_desa"
},
{
"id": "kasi_pemerintahan",
"nama": "Kasi Pemerintahan",
"deskripsi": "Urusan pemerintahan dan keamanan",
"hierarki": 2,
"parentId": "kepala_desa"
},
{
"id": "kasi_pelayanan",
"nama": "Kasi Pelayanan",
"deskripsi": "Urusan pelayanan masyarakat",
"hierarki": 2,
"parentId": "kepala_desa"
},
{
"id": "kasi_kesejahteraan",
"nama": "Kasi Kesejahteraan",
"deskripsi": "Urusan sosial dan kesejahteraan",
"hierarki": 2,
"parentId": "kepala_desa"
},
{
"id": "kadus_banjar_dinas_cabe",
"nama": "Kepala Dusun Banjar Dinas Cabe",
"deskripsi": "Pimpinan wilayah Banjar Dinas Cabe",
"hierarki": 3,
"parentId": "sekretaris_desa"
},
{
"id": "kadus_banjar_dinas_menesa",
"nama": "Kepala Dusun Banjar Dinas Menesa",
"deskripsi": "Pimpinan wilayah Banjar Menesa",
"hierarki": 3,
"parentId": "sekretaris_desa"
},
{
"id": "kadus_banjar_dinas_penenjoan",
"nama": "Kepala Dusun Banjar Dinas Penenjoan",
"deskripsi": "Pimpinan wilayah Banjar Dinas Penenjoan",
"hierarki": 3,
"parentId": "sekretaris_desa"
},
{
"id": "kadus_banjar_dinas_telanga",
"nama": "Kepala Dusun Banjar Dinas Telanga",
"deskripsi": "Pimpinan wilayah Banjar Dinas Telanga",
"hierarki": 3,
"parentId": "sekretaris_desa"
},
{
"id": "kadus_banjar_dinas_tengah",
"nama": "Kepala Dusun Banjar Dinas Tengah",
"deskripsi": "Pimpinan wilayah Banjar Dinas Tengah",
"hierarki": 3,
"parentId": "sekretaris_desa"
},
{
"id": "kadus_banjar_dinas_baler_pasar",
"nama": "Kepala Dusun Banjar Dinas Baler Pasar",
"deskripsi": "Pimpinan wilayah Banjar Dinas Baler Pasar",
"hierarki": 3,
"parentId": "sekretaris_desa"
},
{
"id": "kadus_banjar_dinas_bucu",
"nama": "Kepala Dusun Banjar Dinas Bucu",
"deskripsi": "Pimpinan wilayah Banjar Dinas Bucu",
"hierarki": 3,
"parentId": "sekretaris_desa"
},
{
"id": "kadus_banjar_dinas_gulingan",
"nama": "Kepala Dusun Banjar Dinas Gulingan",
"deskripsi": "Pimpinan wilayah Banjar Dinas Gulingan",
"hierarki": 3,
"parentId": "sekretaris_desa"
},
{
"id": "kadus_banjar_dinas_bersih",
"nama": "Kepala Dusun Banjar Dinas Bersih",
"deskripsi": "Pimpinan wilayah Banjar Dinas Bersih",
"hierarki": 3,
"parentId": "sekretaris_desa"
},
{
"id": "kadus_banjar_dinas_umahanyar",
"nama": "Kepala Dusun Banjar Dinas Umahanyar",
"deskripsi": "Pimpinan wilayah Banjar Dinas Umahanyar",
"hierarki": 3,
"parentId": "sekretaris_desa"
},
{
"id": "kadus_banjar_dinas_taman",
"nama": "Kepala Dusun Banjar Dinas Taman",
"deskripsi": "Pimpinan wilayah Banjar Dinas Taman",
"hierarki": 3,
"parentId": "sekretaris_desa"
},
{
"id": "kadus_banjar_dinas_darmasaba",
"nama": "Kepala Dusun Banjar Dinas Darmasaba",
"deskripsi": "Pimpinan wilayah Banjar Dinas Darmasaba",
"hierarki": 3,
"parentId": "sekretaris_desa"
},
{
"id": "staf_desa",
"nama": "Staf Desa",
"deskripsi": "Staf Desa",
"hierarki": 3,
"parentId": "sekretaris_desa"
}
]
]

View File

@@ -1,27 +0,0 @@
[
{
"id": "kepala_desa",
"nama": "Kepala Desa",
"deskripsi": "Kepala Desa",
"hierarki": 1
},
{
"id": "sekretaris_desa",
"nama": "Sekretaris Desa",
"deskripsi": "Sekretaris Desa",
"hierarki": 2
},
{
"id": "bendahara_desa",
"nama": "Bendahara Desa",
"deskripsi": "Bendahara Desa",
"hierarki": 3
},
{
"id": "staff_umum",
"nama": "Staff Umum",
"deskripsi": "Staff Umum",
"hierarki": 4
}
]

View File

@@ -1,16 +1,4 @@
[ [
{
"id": "cmds8w2q60002vnbe6i8qhkuo",
"name": "Telephone Desa Darmasaba",
"iconUrl": "081239580000",
"imageId": "cmff3nv180003vn6h5jvedidq"
},
{
"id": "cmds8z7u20005vnbegyyvnbk0",
"name": "Email Desa Darmasaba",
"iconUrl": "desadarmasaba@badungkab.go.id",
"imageId": "cmff3ll130001vn6hkhls3f5y"
},
{ {
"id": "cmds9023u0008vnbe3oxmhwyf", "id": "cmds9023u0008vnbe3oxmhwyf",
"name": "Desa Darmasaba", "name": "Desa Darmasaba",

View File

@@ -0,0 +1,6 @@
[
{ "nama": "Kebersihan" },
{ "nama": "Infrastruktur" },
{ "nama": "Sosial" },
{ "nama": "Lingkungan" }
]

View File

@@ -0,0 +1,9 @@
[
{ "id": "cmghqwjs4000404l8c5uvc300", "nama": "PAUD" },
{ "id": "cmghqwjs4000404l8c5uvc301", "nama": "TK" },
{ "id": "cmghqwjs4000404l8c5uvc302", "nama": "SD" },
{ "id": "cmghqwjs4000404l8c5uvc303", "nama": "SMP" },
{ "id": "cmghqwjs4000404l8c5uvc304", "nama": "SMA" },
{ "id": "cmghqwjs4000404l8c5uvc305", "nama": "SMK" }
]

View File

@@ -1,6 +1,6 @@
[ [
{ {
"id": "550e8400-e29b-41d4-a716-446655440001", "id": "cmgewz4gt000704ib91i3f169",
"namaLengkap": "Ida Bagus Surya Prabhawa Manuaba, S.H.,M.H., NL.P.", "namaLengkap": "Ida Bagus Surya Prabhawa Manuaba, S.H.,M.H., NL.P.",
"gelarAkademik": "S.H.,M.H.,NL.P.", "gelarAkademik": "S.H.,M.H.,NL.P.",
"tanggalMasuk": "2020-01-01T00:00:00.000Z", "tanggalMasuk": "2020-01-01T00:00:00.000Z",
@@ -11,7 +11,7 @@
"isActive": true "isActive": true
}, },
{ {
"id": "550e8400-e29b-41d4-a716-446655440002", "id": "cmgewxfvw000004ibee5013f4",
"namaLengkap": "I Ketut Suwanta", "namaLengkap": "I Ketut Suwanta",
"gelarAkademik": "S.Pt", "gelarAkademik": "S.Pt",
"tanggalMasuk": "2020-02-01T00:00:00.000Z", "tanggalMasuk": "2020-02-01T00:00:00.000Z",
@@ -22,7 +22,7 @@
"isActive": true "isActive": true
}, },
{ {
"id": "550e8400-e29b-41d4-a716-446655440006", "id": "cmgewxvqw000104ibgm5l8fzs",
"namaLengkap": "Ni Wayan Supardiati", "namaLengkap": "Ni Wayan Supardiati",
"gelarAkademik": "S.Pd", "gelarAkademik": "S.Pd",
"tanggalMasuk": "2020-02-01T00:00:00.000Z", "tanggalMasuk": "2020-02-01T00:00:00.000Z",
@@ -33,7 +33,7 @@
"isActive": true "isActive": true
}, },
{ {
"id": "550e8400-e29b-41d4-a716-446655440011", "id": "cmgewy1g9000204ib2n7hbx0i",
"namaLengkap": "I Wayan Agus Juni Artha Saputra", "namaLengkap": "I Wayan Agus Juni Artha Saputra",
"gelarAkademik": "S.T.", "gelarAkademik": "S.T.",
"tanggalMasuk": "2020-02-01T00:00:00.000Z", "tanggalMasuk": "2020-02-01T00:00:00.000Z",
@@ -44,7 +44,7 @@
"isActive": true "isActive": true
}, },
{ {
"id": "550e8400-e29b-41d4-a716-446655440012", "id": "cmgewybah000304ibgqhn1gm2",
"namaLengkap": "I Wayan Sueca", "namaLengkap": "I Wayan Sueca",
"gelarAkademik": "S.H.", "gelarAkademik": "S.H.",
"tanggalMasuk": "2020-02-01T00:00:00.000Z", "tanggalMasuk": "2020-02-01T00:00:00.000Z",
@@ -55,7 +55,7 @@
"isActive": true "isActive": true
}, },
{ {
"id": "550e8400-e29b-41d4-a716-446655440017", "id": "cmgewygqz000404ib20sv8nvg",
"namaLengkap": "Si Gede Ketut Astawa", "namaLengkap": "Si Gede Ketut Astawa",
"gelarAkademik": "S.T.", "gelarAkademik": "S.T.",
"tanggalMasuk": "2020-02-01T00:00:00.000Z", "tanggalMasuk": "2020-02-01T00:00:00.000Z",
@@ -66,7 +66,7 @@
"isActive": true "isActive": true
}, },
{ {
"id": "550e8400-e29b-41d4-a716-446655440018", "id": "cmgewyos1000504ibcu8o2gyk",
"namaLengkap": "I Kadek Arya Minarta", "namaLengkap": "I Kadek Arya Minarta",
"gelarAkademik": "S.T.", "gelarAkademik": "S.T.",
"tanggalMasuk": "2020-02-01T00:00:00.000Z", "tanggalMasuk": "2020-02-01T00:00:00.000Z",
@@ -77,7 +77,7 @@
"isActive": true "isActive": true
}, },
{ {
"id": "550e8400-e29b-41d4-a716-446655440021", "id": "cmgewyxk7000604ib8djs3i6c",
"namaLengkap": "I Gede Andika Pradnya Diputra", "namaLengkap": "I Gede Andika Pradnya Diputra",
"gelarAkademik": "S.E.", "gelarAkademik": "S.E.",
"tanggalMasuk": "2020-02-01T00:00:00.000Z", "tanggalMasuk": "2020-02-01T00:00:00.000Z",

View File

@@ -1,29 +1,32 @@
[ [
{
"id": "0",
"name": "DEVELOPER",
"description": "Developer",
"isActive": true
},
{ {
"id": "1", "id": "1",
"name": "ADMIN DESA", "name": "SUPER ADMIN",
"description": "Administrator Desa", "description": "Administrator",
"permissions": ["manage_users", "manage_content", "view_reports"], "isActive": true
"isActive": true,
"createdAt": "2025-09-01T00:00:00.000Z",
"updatedAt": "2025-09-01T00:00:00.000Z"
}, },
{ {
"id": "2", "id": "2",
"name": "ADMIN KESEHATAN", "name": "ADMIN DESA",
"description": "Administrator Bidang Kesehatan", "description": "Administrator Desa",
"permissions": ["manage_health_data", "view_reports"], "isActive": true
"isActive": true,
"createdAt": "2025-09-01T00:00:00.000Z",
"updatedAt": "2025-09-01T00:00:00.000Z"
}, },
{ {
"id": "3", "id": "3",
"name": "ADMIN SEKOLAH", "name": "ADMIN KESEHATAN",
"description": "Administrator Sekolah", "description": "Administrator Bidang Kesehatan",
"permissions": ["manage_school_data", "view_reports"], "isActive": true
"isActive": true, },
"createdAt": "2025-09-01T00:00:00.000Z", {
"updatedAt": "2025-09-01T00:00:00.000Z" "id": "4",
"name": "ADMIN PENDIDIKAN",
"description": "Administrator Bidang Pendidikan",
"isActive": true
} }
] ]

View File

@@ -1,32 +1,10 @@
[ [
{ {
"id": "1", "id": "cmie1o0zh0002vn132vtzg7hh",
"nama": "Admin Desa", "username": "SuperAdmin-Nico",
"nomor": "089647037426", "nomor": "6289647037426",
"roleId": "1", "roleId": 0,
"isActive": true, "isActive": true,
"lastLogin": "2025-08-31T10:00:00.000Z", "sessionInvalid": false
"createdAt": "2025-09-01T00:00:00.000Z",
"updatedAt": "2025-09-01T00:00:00.000Z"
},
{
"id": "2",
"nama": "Admin Kesehatan",
"nomor": "082339004198",
"roleId": "2",
"isActive": true,
"lastLogin": null,
"createdAt": "2025-09-01T00:00:00.000Z",
"updatedAt": "2025-09-01T00:00:00.000Z"
},
{
"id": "3",
"nama": "Admin Sekolah",
"nomor": "085237157222",
"roleId": "3",
"isActive": true,
"lastLogin": null,
"createdAt": "2025-09-01T00:00:00.000Z",
"updatedAt": "2025-09-01T00:00:00.000Z"
} }
] ]

File diff suppressed because it is too large Load Diff

30
prisma/safeseedUnique.ts Normal file
View File

@@ -0,0 +1,30 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// helpers/safeSeedUnique.ts
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
/**
* Helper generic buat seed dengan upsert aman
*/
export async function safeSeedUnique<T extends keyof PrismaClient>(
model: T,
where: Record<string, any>,
data: Record<string, any>
) {
const m = prisma[model];
if (!m) throw new Error(`Model ${String(model)} tidak ditemukan di PrismaClient`);
try {
// @ts-expect-error upsert dynamic
await m.upsert({
where,
update: data,
create: { ...where, ...data },
});
console.log(`✅ Seeded ${String(model)} -> ${JSON.stringify(where)}`);
} catch (err) {
console.error(`❌ Gagal seed ${String(model)} -> ${JSON.stringify(where)}`, err);
}
}

View File

@@ -81,7 +81,7 @@ model FileStorage {
PelayananSuratKeteranganImage PelayananSuratKeterangan[] @relation("PelayananSuratKeteranganImage") PelayananSuratKeteranganImage PelayananSuratKeterangan[] @relation("PelayananSuratKeteranganImage")
PelayananSuratKeteranganImage2 PelayananSuratKeterangan[] @relation("PelayananSuratKeteranganImage2") PelayananSuratKeteranganImage2 PelayananSuratKeterangan[] @relation("PelayananSuratKeteranganImage2")
PasarDesa PasarDesa[] PasarDesa PasarDesa[]
Pegawai Pegawai[] PegawaiBumDes PegawaiBumDes[]
DesaDigital DesaDigital[] DesaDigital DesaDigital[]
InfoTekno InfoTekno[] InfoTekno InfoTekno[]
PengaduanMasyarakat PengaduanMasyarakat[] PengaduanMasyarakat PengaduanMasyarakat[]
@@ -101,6 +101,7 @@ model FileStorage {
MitraKolaborasi MitraKolaborasi[] MitraKolaborasi MitraKolaborasi[]
ArtikelKesehatan ArtikelKesehatan[] ArtikelKesehatan ArtikelKesehatan[]
StrukturBumDes StrukturBumDes[]
} }
//========================================= MENU LANDING PAGE ========================================= // //========================================= MENU LANDING PAGE ========================================= //
@@ -142,7 +143,7 @@ model MediaSosial {
isActive Boolean @default(true) isActive Boolean @default(true)
} }
//========================================= PROFILE ========================================= // //========================================= DESA ANTI KORUPSI ========================================= //
model DesaAntiKorupsi { model DesaAntiKorupsi {
id String @id @default(cuid()) id String @id @default(cuid())
name String @unique name String @unique
@@ -183,18 +184,46 @@ model SdgsDesa {
//========================================= APBDes ========================================= // //========================================= APBDes ========================================= //
model APBDes { model APBDes {
id String @id @default(cuid()) id String @id @default(cuid())
name String tahun Int?
jumlah String name String? // misalnya: "APBDes Tahun 2025"
deskripsi String?
jumlah String? // total keseluruhan (opsional, bisa juga dihitung dari items)
items APBDesItem[]
image FileStorage? @relation("APBDesImage", fields: [imageId], references: [id]) image FileStorage? @relation("APBDesImage", fields: [imageId], references: [id])
imageId String? imageId String?
file FileStorage? @relation("APBDesFile", fields: [fileId], references: [id]) file FileStorage? @relation("APBDesFile", fields: [fileId], references: [id])
fileId String? fileId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime @default(now()) deletedAt DateTime? // opsional, tidak perlu default now()
isActive Boolean @default(true) isActive Boolean @default(true)
} }
model APBDesItem {
id String @id @default(cuid())
kode String // contoh: "4", "4.1", "4.1.2"
uraian String // nama item, contoh: "Pendapatan Asli Desa", "Hasil Usaha"
anggaran Float // dalam satuan Rupiah (bisa DECIMAL di DB, tapi Float umum di TS/JS)
realisasi Float
selisih Float // realisasi - anggaran
persentase Float
tipe String? // (realisasi / anggaran) * 100
level Int // 1 = kelompok utama, 2 = sub-kelompok, 3 = detail
parentId String? // untuk relasi hierarki
parent APBDesItem? @relation("APBDesItemParent", fields: [parentId], references: [id])
children APBDesItem[] @relation("APBDesItemParent")
apbdesId String
apbdes APBDes @relation(fields: [apbdesId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
isActive Boolean @default(true)
@@index([kode])
@@index([level])
@@index([apbdesId])
}
//========================================= PRESTASI DESA ========================================= // //========================================= PRESTASI DESA ========================================= //
model PrestasiDesa { model PrestasiDesa {
id String @id @default(cuid()) id String @id @default(cuid())
@@ -298,6 +327,7 @@ model PosisiOrganisasiPPID {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
parent PosisiOrganisasiPPID? @relation("Parent", fields: [parentId], references: [id]) parent PosisiOrganisasiPPID? @relation("Parent", fields: [parentId], references: [id])
children PosisiOrganisasiPPID[] @relation("Parent") children PosisiOrganisasiPPID[] @relation("Parent")
StrukturOrganisasiPPID StrukturOrganisasiPPID[]
} }
model PegawaiPPID { model PegawaiPPID {
@@ -316,15 +346,16 @@ model PegawaiPPID {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
posisi PosisiOrganisasiPPID @relation(fields: [posisiId], references: [id]) posisi PosisiOrganisasiPPID @relation(fields: [posisiId], references: [id])
strukturOrganisasi StrukturPPID[] // Relasi balik strukturOrganisasi StrukturPPID[] // Relasi balik
StrukturOrganisasiPPID StrukturOrganisasiPPID[]
} }
model StrukturOrganisasiPPID { model StrukturOrganisasiPPID {
id String @id @default(uuid()) id String @id @default(uuid())
posisiOrganisasiId String @db.VarChar(50) posisiOrganisasiId String @db.VarChar(50)
pegawaiId String @db.Uuid pegawaiId String
hubunganOrganisasiId String @db.Uuid hubunganOrganisasiId String
posisiOrganisasi PosisiOrganisasi @relation(fields: [posisiOrganisasiId], references: [id]) posisiOrganisasi PosisiOrganisasiPPID @relation(fields: [posisiOrganisasiId], references: [id])
pegawai Pegawai @relation(fields: [pegawaiId], references: [id]) pegawai PegawaiPPID @relation(fields: [pegawaiId], references: [id])
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime?
@@ -1167,6 +1198,7 @@ model KontakDarurat {
deskripsi String deskripsi String
image FileStorage @relation(fields: [imageId], references: [id]) image FileStorage @relation(fields: [imageId], references: [id])
imageId String imageId String
whatsapp String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime @default(now()) deletedAt DateTime @default(now())
@@ -1340,6 +1372,7 @@ model PasarDesa {
harga Int harga Int
rating Float rating Float
alamatUsaha String alamatUsaha String
kontak String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime @default(now()) deletedAt DateTime @default(now())
@@ -1382,6 +1415,7 @@ model LowonganPekerjaan {
gaji String gaji String
deskripsi String deskripsi String
kualifikasi String kualifikasi String
notelp String
tanggalPosting DateTime @default(now()) tanggalPosting DateTime @default(now())
isActive Boolean @default(true) isActive Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -1391,24 +1425,39 @@ model LowonganPekerjaan {
// ========================================= STRUKTUR ORGANISASI ========================================= // // ========================================= STRUKTUR ORGANISASI ========================================= //
model PosisiOrganisasi { model StrukturBumDes {
id String @id @default(uuid()) @db.VarChar(50) id String @id @default(cuid())
name String @db.Text
image FileStorage? @relation(fields: [imageId], references: [id])
imageId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
isActive Boolean @default(true)
PosisiOrganisasiBumDes PosisiOrganisasiBumDes? @relation(fields: [posisiOrganisasiBumDesId], references: [id])
posisiOrganisasiBumDesId String?
PegawaiBumDes PegawaiBumDes? @relation(fields: [pegawaiBumDesId], references: [id])
pegawaiBumDesId String?
}
model PosisiOrganisasiBumDes {
id String @id @default(cuid())
nama String @db.VarChar(100) nama String @db.VarChar(100)
deskripsi String? @db.Text deskripsi String? @db.Text
hierarki Int hierarki Int
pegawai PegawaiBumDes[]
pegawai Pegawai[] strukturOrganisasi StrukturBumDes[] // Relasi balik
strukturOrganisasi StrukturOrganisasi[] // Relasi balik parentId String?
StrukturOrganisasiPPID StrukturOrganisasiPPID[]
isActive Boolean @default(true) isActive Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
parent PosisiOrganisasiBumDes? @relation("Parent", fields: [parentId], references: [id])
@@map("posisi_organisasi") children PosisiOrganisasiBumDes[] @relation("Parent")
StrukturOrganisasiBumDes StrukturOrganisasiBumDes[]
} }
model Pegawai { model PegawaiBumDes {
id String @id @default(uuid()) @db.Uuid id String @id @default(cuid())
namaLengkap String @db.VarChar(255) namaLengkap String @db.VarChar(255)
gelarAkademik String? @db.VarChar(100) gelarAkademik String? @db.VarChar(100)
image FileStorage? @relation(fields: [imageId], references: [id]) image FileStorage? @relation(fields: [imageId], references: [id])
@@ -1421,49 +1470,22 @@ model Pegawai {
isActive Boolean @default(true) isActive Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
posisi PosisiOrganisasiBumDes @relation(fields: [posisiId], references: [id])
posisi PosisiOrganisasi @relation(fields: [posisiId], references: [id]) strukturOrganisasi StrukturBumDes[] // Relasi balik
StrukturOrganisasiBumDes StrukturOrganisasiBumDes[]
sebagaiAtasan HubunganOrganisasi[] @relation("AtasanToBawahan")
sebagaiBawahan HubunganOrganisasi[] @relation("BawahanToAtasan")
strukturOrganisasi StrukturOrganisasi[] // Relasi balik
StrukturOrganisasiPPID StrukturOrganisasiPPID[]
@@map("pegawai")
} }
model HubunganOrganisasi { model StrukturOrganisasiBumDes {
id String @id @default(uuid()) @db.Uuid
atasanId String @db.Uuid
bawahanId String @db.Uuid
tipe String? @db.VarChar(50)
atasan Pegawai @relation("AtasanToBawahan", fields: [atasanId], references: [id])
bawahan Pegawai @relation("BawahanToAtasan", fields: [bawahanId], references: [id])
strukturOrganisasi StrukturOrganisasi[] // Relasi balik
@@unique([atasanId, bawahanId])
@@map("hubungan_organisasi")
}
model StrukturOrganisasi {
id String @id @default(uuid()) id String @id @default(uuid())
posisiOrganisasiId String @db.VarChar(50) posisiOrganisasiId String @db.VarChar(50)
pegawaiId String @db.Uuid pegawaiId String
hubunganOrganisasiId String @db.Uuid hubunganOrganisasiId String
posisiOrganisasi PosisiOrganisasiBumDes @relation(fields: [posisiOrganisasiId], references: [id])
posisiOrganisasi PosisiOrganisasi @relation(fields: [posisiOrganisasiId], references: [id]) pegawai PegawaiBumDes @relation(fields: [pegawaiId], references: [id])
pegawai Pegawai @relation(fields: [pegawaiId], references: [id])
hubunganOrganisasi HubunganOrganisasi @relation(fields: [hubunganOrganisasiId], references: [id])
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime?
isActive Boolean @default(true) isActive Boolean @default(true)
@@map("struktur_organisasi")
} }
// ========================================= PROGRAM KEMISKINAN ========================================= // // ========================================= PROGRAM KEMISKINAN ========================================= //
@@ -1612,7 +1634,7 @@ model Pembiayaan {
ApbDesa ApbDesa[] @relation("ApbDesaPembiayaan") ApbDesa ApbDesa[] @relation("ApbDesaPembiayaan")
} }
// ========================================= INOVASI ========================================= // // ========================================= MENU INOVASI ========================================= //
// ========================================= DESA DIGITAL / SMART VILLAGE ========================================= // // ========================================= DESA DIGITAL / SMART VILLAGE ========================================= //
model DesaDigital { model DesaDigital {
id String @id @default(cuid()) id String @id @default(cuid())
@@ -1950,17 +1972,22 @@ model KeunggulanProgram {
model BeasiswaPendaftar { model BeasiswaPendaftar {
id String @id @default(cuid()) id String @id @default(cuid())
namaLengkap String namaLengkap String
nik String @unique nis String?
kelas String?
jenisKelamin JenisKelamin
alamatDomisili String?
tempatLahir String tempatLahir String
tanggalLahir DateTime tanggalLahir DateTime
jenisKelamin JenisKelamin namaOrtu String?
kewarganegaraan String nik String @unique
agama Agama pekerjaanOrtu String?
alamatKTP String penghasilan String?
alamatDomisili String?
noHp String noHp String
email String @unique kewarganegaraan String?
statusPernikahan StatusPernikahan agama Agama?
alamatKTP String?
email String? @unique
statusPernikahan StatusPernikahan?
ukuranBaju UkuranBaju? ukuranBaju UkuranBaju?
isActive Boolean @default(true) isActive Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -2093,6 +2120,9 @@ model DataPerpustakaan {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime @default(now()) deletedAt DateTime @default(now())
isActive Boolean @default(true) isActive Boolean @default(true)
// relasi baru ke peminjaman
peminjamanBuku PeminjamanBuku[]
} }
model KategoriBuku { model KategoriBuku {
@@ -2105,28 +2135,56 @@ model KategoriBuku {
DataPerpustakaan DataPerpustakaan[] DataPerpustakaan DataPerpustakaan[]
} }
model PeminjamanBuku {
id String @id @default(cuid())
nama String
noTelp String
alamat String
bukuId String
tanggalPinjam DateTime @default(now())
batasKembali DateTime // tenggat waktu pengembalian
tanggalKembali DateTime? // diisi saat dikembalikan
status StatusPeminjaman @default(Dipinjam)
catatan String? // opsional, misal: kondisi buku
buku DataPerpustakaan @relation(fields: [bukuId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
isActive Boolean @default(true)
}
enum StatusPeminjaman {
Dipinjam
Dikembalikan
Terlambat
Dibatalkan
}
// ========================================= USER ========================================= // // ========================================= USER ========================================= //
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
username String username String
nomor String @unique nomor String @unique
role Role @relation(fields: [roleId], references: [id]) roleId String @default("2")
roleId String @default("1") isActive Boolean @default(false)
instansi String? sessionInvalid Boolean @default(false)
UserSession UserSession? // Nama instansi (Puskesmas, Sekolah, dll)
isActive Boolean @default(true)
lastLogin DateTime? lastLogin DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @default(now()) @updatedAt
deletedAt DateTime? permissions Json?
sessions UserSession[] // ✅ Relasi one-to-many
role Role @relation(fields: [roleId], references: [id])
menuAccesses UserMenuAccess[]
@@map("users")
} }
model Role { model Role {
id String @id @default(cuid()) id String @id @default(cuid())
name String @unique // ADMIN_DESA, ADMIN_KESEHATAN, ADMIN_SEKOLAH name String @unique // ADMIN_DESA, ADMIN_KESEHATAN, ADMIN_SEKOLAH
description String? description String?
permissions Json // Menyimpan permission dalam format JSON permissions Json?
isActive Boolean @default(true) isActive Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -2145,26 +2203,32 @@ model KodeOtp {
otp Int otp Int
} }
// Tabel untuk menyimpan permission
model Permission {
id String @id @default(cuid())
name String @unique
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("permissions")
}
model UserSession { model UserSession {
id String @id @default(cuid()) id String @id @default(cuid())
token String token String @db.Text // ✅ JWT bisa panjang
expires DateTime? expiresAt DateTime // ✅ Ubah jadi expiresAt (konsisten)
active Boolean @default(true) active Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
User User @relation(fields: [userId], references: [id])
userId String @unique user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String // ✅ HAPUS @unique - user bisa punya multiple sessions
@@index([userId]) // ✅ Index untuk query cepat
@@index([token]) // ✅ Index untuk verify cepat
@@map("user_sessions")
}
model UserMenuAccess {
id String @id @default(cuid())
userId String
menuId String // ID menu (misal: "Landing Page", "Kesehatan")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
@@unique([userId, menuId]) // Satu user tidak bisa punya akses menu yang sama dua kali
} }
// ========================================= DATA PENDIDIKAN ========================================= // // ========================================= DATA PENDIDIKAN ========================================= //

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import profilePejabatDesa from "./data/landing-page/profile/profile.json"; import profilePejabatDesa from "./data/landing-page/profile/profile.json";
@@ -31,14 +32,14 @@ import sejarahDesa from "./data/desa/profile/sejarah_desa.json";
import visiMisiDesa from "./data/desa/profile/visi_misi_desa.json"; import visiMisiDesa from "./data/desa/profile/visi_misi_desa.json";
import detailDataPengangguran from "./data/ekonomi/jumlah-pengangguran/detail-data-pengangguran.json"; import detailDataPengangguran from "./data/ekonomi/jumlah-pengangguran/detail-data-pengangguran.json";
import kategoriProduk from "./data/ekonomi/pasar-desa/kategori-produk.json"; import kategoriProduk from "./data/ekonomi/pasar-desa/kategori-produk.json";
import hubunganOrganisasi from "./data/ekonomi/struktur-organisasi/hubungan-organisasi.json"; import pegawai from "./data/ekonomi/struktur-organisasi/pegawai-bumdes.json";
import pegawai from "./data/ekonomi/struktur-organisasi/pegawai.json"; import posisiOrganisasi from "./data/ekonomi/struktur-organisasi/posisi-organisasi-bumdes.json";
import posisiOrganisasi from "./data/ekonomi/struktur-organisasi/posisi-organisasi.json"; import kategoriBerita from "./data/desa/berita/kategori-berita.json";
import kategoriBerita from "./data/kategori-berita.json";
import contohEdukasiLingkungan from "./data/lingkungan/edukasi-lingkungan/contoh-kegiatan-di-desa-darmasaba.json"; import contohEdukasiLingkungan from "./data/lingkungan/edukasi-lingkungan/contoh-kegiatan-di-desa-darmasaba.json";
import materiEdukasiLingkungan from "./data/lingkungan/edukasi-lingkungan/materi-edukasi-yang-diberikan.json"; import materiEdukasiLingkungan from "./data/lingkungan/edukasi-lingkungan/materi-edukasi-yang-diberikan.json";
import tujuanEdukasiLingkungan from "./data/lingkungan/edukasi-lingkungan/tujuan-edukasi-lingkungan.json"; import tujuanEdukasiLingkungan from "./data/lingkungan/edukasi-lingkungan/tujuan-edukasi-lingkungan.json";
import bentukKonservasiBerdasarkanAdat from "./data/lingkungan/konservasi-adat-bali/bentuk-konservasi.json"; import bentukKonservasiBerdasarkanAdat from "./data/lingkungan/konservasi-adat-bali/bentuk-konservasi.json";
import kategoriKegiatanData from "./data/lingkungan/gotong-royong/kategori-gotong-royong.json";
import filosofiTriHita from "./data/lingkungan/konservasi-adat-bali/filosofi-tri-hita.json"; import filosofiTriHita from "./data/lingkungan/konservasi-adat-bali/filosofi-tri-hita.json";
import nilaiKonservasiAdat from "./data/lingkungan/konservasi-adat-bali/nilai-konservasi-adat.json"; import nilaiKonservasiAdat from "./data/lingkungan/konservasi-adat-bali/nilai-konservasi-adat.json";
import caraMemperolehInformasi from "./data/list-caraMemperolehInformasi.json"; import caraMemperolehInformasi from "./data/list-caraMemperolehInformasi.json";
@@ -54,69 +55,87 @@ import tujuanProgram2 from "./data/pendidikan/pendidikan-non-formal/tujuan-progr
import programUnggulan from "./data/pendidikan/program-pendidikan-anak/program-unggulan.json"; import programUnggulan from "./data/pendidikan/program-pendidikan-anak/program-unggulan.json";
import tujuanProgram from "./data/pendidikan/program-pendidikan-anak/tujuan-program.json"; import tujuanProgram from "./data/pendidikan/program-pendidikan-anak/tujuan-program.json";
import roles from "./data/user/roles.json"; import roles from "./data/user/roles.json";
import users from "./data/user/users.json";
import fileStorage from "./data/file-storage.json"; import fileStorage from "./data/file-storage.json";
import jenjangPendidikan from "./data/pendidikan/info-sekolah/jenjang-pendidikan.json";
import seedAssets from "./seed_assets";
import users from "./data/user/users.json";
import { safeSeedUnique } from "./safeseedUnique";
(async () => { (async () => {
// =========== USER & ROLE ===========
// In your seed.ts
// =========== ROLES ===========
console.log("🔄 Seeding roles..."); console.log("🔄 Seeding roles...");
for (const r of roles) {
await prisma.role.upsert({
where: { id: r.id },
update: {
name: r.name,
description: r.description,
permissions: r.permissions,
isActive: r.isActive,
},
create: {
id: r.id,
name: r.name,
description: r.description,
permissions: r.permissions,
isActive: r.isActive,
},
});
}
console.log("✅ Roles seeded");
// =========== USERS =========== for (const r of roles) {
try {
// ✅ Destructure to remove permissions if exists
const { permissions, ...roleData } = r as any;
await safeSeedUnique(
"role",
{ name: roleData.name },
{
id: roleData.id,
name: roleData.name,
description: roleData.description,
permissions: roleData.permissions || {}, // ✅ Include permissions
isActive: roleData.isActive,
}
);
console.log(`✅ Seeded role -> ${roleData.name}`);
} catch (error: any) {
if (error.code === "P2002") {
console.warn(`⚠️ Role already exists (skipping): ${r.name}`);
} else {
console.error(`❌ Failed to seed role ${r.name}:`, error.message);
}
}
}
console.log("✅ Roles seeding completed");
// =========== USER ===========
console.log("🔄 Seeding users..."); console.log("🔄 Seeding users...");
for (const u of users) { for (const u of users) {
// First verify the role exists try {
// Verify role exists first
const roleExists = await prisma.role.findUnique({ const roleExists = await prisma.role.findUnique({
where: { id: u.roleId }, where: { id: u.roleId.toString() },
select: { id: true }, // Only select id to minimize query
}); });
if (!roleExists) { if (!roleExists) {
console.error(`❌ Role with id ${u.roleId} not found for user ${u.nama}`); console.error(
`❌ Role with id ${u.roleId} not found for user ${u.username}`
);
continue; continue;
} }
await prisma.user.upsert({ await safeSeedUnique(
where: { id: u.id }, "user",
update: { { id: u.id },
username: u.nama, {
username: u.username,
nomor: u.nomor, nomor: u.nomor,
roleId: u.roleId, roleId: u.roleId.toString(),
isActive: u.isActive, isActive: u.isActive,
}, sessionInvalid: false,
create: {
id: u.id,
username: u.nama,
nomor: u.nomor,
roleId: u.roleId,
isActive: u.isActive,
},
});
} }
console.log("✅ Users seeded"); );
console.log(`✅ Seeded user -> ${u.username}`);
} catch (error: any) {
if (error.code === "P2003") {
console.error(
`❌ Foreign key constraint failed for user ${u.username}: Role ${u.roleId} does not exist`
);
} else {
console.error(`❌ Failed to seed user ${u.username}:`, error.message);
}
}
}
console.log("✅ Users seeding completed");
// =========== FILE STORAGE =========== // =========== FILE STORAGE ===========
console.log("🔄 Seeding file storage..."); console.log("🔄 Seeding file storage...");
for (const f of fileStorage) { for (const f of fileStorage) {
try {
await prisma.fileStorage.upsert({ await prisma.fileStorage.upsert({
where: { id: f.id }, where: { id: f.id },
update: { update: {
@@ -137,6 +156,9 @@ import fileStorage from "./data/file-storage.json";
category: f.category, category: f.category,
}, },
}); });
} catch (error: any) {
console.error(`❌ Failed to seed file storage ${f.name}:`, error.message);
}
} }
console.log("✅ File storage seeded"); console.log("✅ File storage seeded");
// =========== LANDING PAGE =========== // =========== LANDING PAGE ===========
@@ -364,6 +386,7 @@ import fileStorage from "./data/file-storage.json";
jumlah: l.jumlah, jumlah: l.jumlah,
}, },
create: { create: {
id: l.id,
name: l.name, name: l.name,
jumlah: l.jumlah, jumlah: l.jumlah,
}, },
@@ -554,15 +577,40 @@ import fileStorage from "./data/file-storage.json";
console.log("posisi organisasi berhasil"); console.log("posisi organisasi berhasil");
// =========== PEGAWAI PPID =========== // =========== PEGAWAI PPID ===========
console.log("🔄 Seeding pegawai PPID...");
const flattenedPegawai = pegawaiPPID.flat(); const flattenedPegawai = pegawaiPPID.flat();
// Check for duplicate emails
const emails = new Set();
for (const p of flattenedPegawai) { for (const p of flattenedPegawai) {
if (emails.has(p.email)) {
console.warn(`⚠️ Duplicate email found in pegawaiPPID: ${p.email}`);
}
emails.add(p.email);
}
for (const p of flattenedPegawai) {
try {
await prisma.pegawaiPPID.upsert({ await prisma.pegawaiPPID.upsert({
where: { id: p.id }, where: { id: p.id },
update: p, update: p,
create: p, create: p,
}); });
console.log(`✅ Seeded pegawai PPID -> ${p.namaLengkap}`);
} catch (error: any) {
if (error.code === "P2002") {
console.warn(
`⚠️ Pegawai PPID with duplicate email (skipping): ${p.email}`
);
} else {
console.error(
`❌ Failed to seed pegawai PPID ${p.namaLengkap}:`,
error.message
);
} }
console.log("pegawai berhasil"); }
}
console.log("✅ pegawai PPID seeding completed");
// =========== SUBMENU VISI MISI PPID =========== // =========== SUBMENU VISI MISI PPID ===========
@@ -823,28 +871,36 @@ import fileStorage from "./data/file-storage.json";
} }
console.log("kategori produk success ..."); console.log("kategori produk success ...");
for (const p of posisiOrganisasi) { const flattenedPosisiBumdes = posisiOrganisasi.flat();
await prisma.posisiOrganisasi.upsert({
where: { // ✅ Urutkan berdasarkan hierarki
id: p.id, const sortedPosisiBumdes = flattenedPosisiBumdes.sort(
}, (a, b) => a.hierarki - b.hierarki
update: { );
nama: p.nama,
deskripsi: p.deskripsi, for (const p of sortedPosisiBumdes) {
hierarki: p.hierarki, console.log(`Seeding: ${p.nama} (id: ${p.id}, parent: ${p.parentId})`);
},
create: { if (p.parentId) {
id: p.id, const parentExists = flattenedPosisi.some((pos) => pos.id === p.parentId);
nama: p.nama, if (!parentExists) {
deskripsi: p.deskripsi, console.warn(
hierarki: p.hierarki, `⚠️ Parent tidak ditemukan: ${p.parentId} untuk ${p.nama}`
}, );
continue;
}
}
await prisma.posisiOrganisasiBumDes.upsert({
where: { id: p.id },
update: p,
create: p,
}); });
} }
console.log("posisi organisasi success ..."); console.log("posisi organisasi berhasil");
for (const p of pegawai) { for (const p of pegawai) {
await prisma.pegawai.upsert({ await prisma.pegawaiBumDes.upsert({
where: { where: {
id: p.id, id: p.id,
}, },
@@ -873,26 +929,6 @@ import fileStorage from "./data/file-storage.json";
} }
console.log("pegawai success ..."); console.log("pegawai success ...");
for (const p of hubunganOrganisasi) {
await prisma.hubunganOrganisasi.upsert({
where: {
atasanId_bawahanId: {
atasanId: p.atasanId,
bawahanId: p.bawahanId,
},
},
update: {
tipe: p.tipe,
},
create: {
atasanId: p.atasanId,
bawahanId: p.bawahanId,
tipe: p.tipe,
},
});
}
console.log("hubungan organisasi success ...");
for (const d of detailDataPengangguran) { for (const d of detailDataPengangguran) {
await prisma.detailDataPengangguran.upsert({ await prisma.detailDataPengangguran.upsert({
where: { where: {
@@ -916,6 +952,30 @@ import fileStorage from "./data/file-storage.json";
} }
console.log("📊 detailDataPengangguran success ..."); console.log("📊 detailDataPengangguran success ...");
// =========== KATEGORI GOTONG ROYONG ===========
// Add IDs to the kategoriKegiatan data
const kategoriKegiatan = kategoriKegiatanData.map((k, index) => ({
...k,
id: `kategori-${index + 1}`,
}));
for (const k of kategoriKegiatan) {
await prisma.kategoriKegiatan.upsert({
where: {
id: k.id,
},
update: {
nama: k.nama,
},
create: {
id: k.id,
nama: k.nama,
},
});
}
console.log("kategori kegiatan success ...");
for (const e of tujuanEdukasiLingkungan) { for (const e of tujuanEdukasiLingkungan) {
await prisma.tujuanEdukasiLingkungan.upsert({ await prisma.tujuanEdukasiLingkungan.upsert({
where: { where: {
@@ -1169,6 +1229,25 @@ import fileStorage from "./data/file-storage.json";
console.log( console.log(
"✅ fasilitas bimbingan belajar desa seeded (editable later via UI)" "✅ fasilitas bimbingan belajar desa seeded (editable later via UI)"
); );
for (const j of jenjangPendidikan) {
await prisma.jenjangPendidikan.upsert({
where: {
id: j.id || undefined,
},
update: {
nama: j.nama,
},
create: {
nama: j.nama,
},
});
}
console.log("✅ Jenjang Pendidikan seeded successfully");
// seed assets
await seedAssets();
})() })()
.then(() => prisma.$disconnect()) .then(() => prisma.$disconnect())
.catch((e) => { .catch((e) => {

118
prisma/seed_assets.ts Normal file
View File

@@ -0,0 +1,118 @@
// prisma/seedAssets.ts
import fs from "fs/promises";
import path from "path";
import sharp from "sharp";
import fetch from "node-fetch";
import AdmZip from "adm-zip";
import prisma from "@/lib/prisma";
const UPLOADS_DIR =
process.env.WIBU_UPLOAD_DIR || path.join(process.cwd(), "uploads");
// --- Helper: deteksi kategori file ---
function detectCategory(filename: string): "image" | "document" | "other" {
const ext = path.extname(filename).toLowerCase();
if ([".jpg", ".jpeg", ".png", ".webp"].includes(ext)) return "image";
if ([".pdf", ".doc", ".docx"].includes(ext)) return "document";
return "other";
}
// --- Helper: recursive walk dir ---
async function walkDir(dir: string, fileList: string[] = []): Promise<string[]> {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === "__MACOSX") continue; // skip folder sampah
await walkDir(fullPath, fileList);
} else {
if (entry.name.startsWith(".") || entry.name === ".DS_Store") continue; // skip file sampah
fileList.push(fullPath);
}
}
return fileList;
}
export default async function seedAssets() {
console.log("🚀 Seeding assets...");
// 1. Download zip
const url =
"https://cld-dkr-makuro-seafile.wibudev.com/f/ffd5a548a04f47939474/?dl=1";
const res = await fetch(url);
if (!res.ok) throw new Error(`Gagal download assets: ${res.statusText}`);
const buffer = Buffer.from(await res.arrayBuffer());
// 2. Extract zip ke folder tmp
const extractDir = path.join(process.cwd(), "tmp_assets");
await fs.rm(extractDir, { recursive: true, force: true });
await fs.mkdir(extractDir, { recursive: true });
const zip = new AdmZip(buffer);
zip.extractAllTo(extractDir, true);
// 3. Cari semua file valid (recursive)
const files = await walkDir(extractDir);
// 4. Loop tiap file & simpan
for (const filePath of files) {
const entryName = path.basename(filePath);
const category = detectCategory(entryName);
let finalName = entryName;
let mimeType = "application/octet-stream";
let targetPath = "";
if (category === "image") {
const fileBaseName = path.parse(entryName).name;
finalName = `${fileBaseName}.webp`;
targetPath = path.join(UPLOADS_DIR, "images", finalName);
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await sharp(filePath).webp({ quality: 80 }).toFile(targetPath);
mimeType = "image/webp";
} else if (category === "document") {
targetPath = path.join(UPLOADS_DIR, "documents", entryName);
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.copyFile(filePath, targetPath);
mimeType = "application/pdf";
} else {
targetPath = path.join(UPLOADS_DIR, "other", entryName);
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.copyFile(filePath, targetPath);
}
// 5. Simpan ke DB
await prisma.fileStorage.create({
data: {
name: finalName,
realName: entryName,
path: targetPath,
mimeType,
link: `/uploads/${category}/${finalName}`,
category,
},
});
console.log(`📂 saved: ${category}/${finalName}`);
}
// 6. Cleanup
await fs.rm(extractDir, { recursive: true, force: true });
console.log("✅ Selesai seed assets!");
}
// --- Auto run kalau dipanggil langsung ---
if (import.meta.main) {
seedAssets()
.catch((err) => {
console.error("❌ Error seeding assets:", err);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
}

BIN
public/beasiswa-siswa.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

After

Width:  |  Height:  |  Size: 378 KiB

View File

@@ -7,6 +7,7 @@ import Underline from '@tiptap/extension-underline';
import TextAlign from '@tiptap/extension-text-align'; import TextAlign from '@tiptap/extension-text-align';
import Superscript from '@tiptap/extension-superscript'; import Superscript from '@tiptap/extension-superscript';
import SubScript from '@tiptap/extension-subscript'; import SubScript from '@tiptap/extension-subscript';
import { useEffect } from 'react';
type CreateEditorProps = { type CreateEditorProps = {
value: string; value: string;
@@ -32,6 +33,13 @@ export default function CreateEditor({ value, onChange }: CreateEditorProps) {
}, },
}); });
// 👇 Tambahkan efek untuk sinkronisasi value dari luar (resetForm)
useEffect(() => {
if (editor && value !== editor.getHTML()) {
editor.commands.setContent(value || '');
}
}, [value, editor]);
return ( return (
<RichTextEditor editor={editor}> <RichTextEditor editor={editor}>
<RichTextEditor.Toolbar sticky stickyOffset="var(--docs-header-height)"> <RichTextEditor.Toolbar sticky stickyOffset="var(--docs-header-height)">

View File

@@ -48,6 +48,7 @@ export default function EditEditor({ value, onChange }: EditEditorProps) {
}; };
}, [editor, onChange]); }, [editor, onChange]);
return ( return (
<RichTextEditor editor={editor}> <RichTextEditor editor={editor}>
<RichTextEditor.Toolbar sticky stickyOffset="var(--docs-header-height)"> <RichTextEditor.Toolbar sticky stickyOffset="var(--docs-header-height)">

View File

@@ -1,6 +1,6 @@
'use client' 'use client';
import { Box, rem, Select } from '@mantine/core'; import { Box, Group, rem, Select, SelectProps } from '@mantine/core';
import { import {
IconAmbulance, IconAmbulance,
IconCash, IconCash,
@@ -25,7 +25,7 @@ import {
IconTrophy, IconTrophy,
IconTruckFilled, IconTruckFilled,
IconBuilding, IconBuilding,
IconAlertTriangle IconAlertTriangle,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
const iconMap = { const iconMap = {
@@ -57,7 +57,7 @@ const iconMap = {
darurat: { label: 'Darurat', icon: IconAlertTriangle }, darurat: { label: 'Darurat', icon: IconAlertTriangle },
}; };
type IconKey = keyof typeof iconMap; export type IconKey = keyof typeof iconMap;
const iconList = Object.entries(iconMap).map(([value, data]) => ({ const iconList = Object.entries(iconMap).map(([value, data]) => ({
value, value,
@@ -67,44 +67,52 @@ const iconList = Object.entries(iconMap).map(([value, data]) => ({
export default function SelectIconProgramEdit({ export default function SelectIconProgramEdit({
onChange, onChange,
value, value,
...props
}: { }: {
onChange: (value: IconKey) => void; onChange: (value: IconKey | '') => void;
value: IconKey; value: IconKey | '';
}) { } & Omit<SelectProps, 'onChange' | 'value' | 'data'>) {
const IconComponent = iconMap[value]?.icon || null;
return ( return (
<Box maw={300}> <Box maw={300}>
<Select <Select
placeholder="Pilih ikon" placeholder="Pilih ikon"
value={value} value={value || ''}
onChange={(value) => { onChange={(val: string | null) => {
if (value) onChange(value as IconKey); if (val) {
onChange(val as IconKey);
} else {
onChange('');
}
}} }}
data={iconList} data={iconList}
renderOption={({ option }) => {
const Icon = iconMap[option.value as IconKey]?.icon;
return (
<Group gap="sm">
{Icon && <Icon size={18} stroke={1.5} />}
{option.label}
</Group>
);
}}
leftSection={ leftSection={
IconComponent && ( value && iconMap[value as IconKey] ? (
<Box> <Box ml={-4}>
<IconComponent size={24} stroke={1.5} /> {(() => {
const Icon = iconMap[value as IconKey].icon;
return <Icon size={20} stroke={1.5} />;
})()}
</Box> </Box>
) ) : null
} }
withCheckIcon={false} searchable
searchable={false}
rightSectionWidth={0}
styles={{ styles={{
input: { input: {
textAlign: 'left',
fontSize: rem(16),
paddingLeft: 40, paddingLeft: 40,
}, fontSize: rem(16),
section: {
left: 10,
right: 'auto',
}, },
}} }}
{...props}
/> />
</Box> </Box>
); );
} }

View File

@@ -75,7 +75,8 @@ const berita = proxy({
loading: false, loading: false,
search: "", search: "",
load: async (page = 1, limit = 10, search = "", kategori = "") => { load: async (page = 1, limit = 10, search = "", kategori = "") => {
berita.findMany.loading = true; // ✅ Akses langsung via nama path const startTime = Date.now();
berita.findMany.loading = true;
berita.findMany.page = page; berita.findMany.page = page;
berita.findMany.search = search; berita.findMany.search = search;
@@ -98,7 +99,14 @@ const berita = proxy({
berita.findMany.data = []; berita.findMany.data = [];
berita.findMany.totalPages = 1; berita.findMany.totalPages = 1;
} finally { } finally {
// pastikan minimal 300ms sebelum loading = false (biar UX smooth)
const elapsed = Date.now() - startTime;
const minDelay = 300;
const delay = elapsed < minDelay ? minDelay - elapsed : 0;
setTimeout(() => {
berita.findMany.loading = false; berita.findMany.loading = false;
}, delay);
} }
}, },
}, },

View File

@@ -581,33 +581,24 @@ const pelayananPerizinanBerusaha = proxy({
findById: { findById: {
data: null as pelayananPerizinanBerusahaForm | null, data: null as pelayananPerizinanBerusahaForm | null,
loading: false, loading: false,
initialize() {
pelayananPerizinanBerusaha.findById.data = {
id: "",
name: "",
deskripsi: "",
link: "",
} as pelayananPerizinanBerusahaForm;
},
async load(id: string) { async load(id: string) {
try { try {
pelayananPerizinanBerusaha.findById.loading = true; this.loading = true;
const res = await fetch( const response = await fetch(`/api/desa/layanan/pelayananperizinanberusaha/${id}`);
`/api/desa/layanan/pelayananperizinanberusaha/${id}` if (!response.ok) {
); throw new Error(`HTTP error! status: ${response.status}`);
if (res.ok) {
const data = await res.json();
pelayananPerizinanBerusaha.findById.data = data.data ?? null;
} else {
console.error(
"Failed to fetch pelayanan perizinan berusaha:",
res.statusText
);
pelayananPerizinanBerusaha.findById.data = null;
} }
const result = await response.json();
if (result?.success) {
this.data = result.data; // Make sure this matches your API response structure
}
return result?.data || null;
} catch (error) { } catch (error) {
console.error("Error fetching pelayanan perizinan berusaha:", error); console.error('Error loading data:', error);
pelayananPerizinanBerusaha.findById.data = null; toast.error('Gagal memuat data');
return null;
} finally {
this.loading = false;
} }
}, },
}, },

View File

@@ -39,7 +39,7 @@ const penghargaanState = proxy({
); );
if (res.status === 200) { if (res.status === 200) {
penghargaanState.findMany.load(); penghargaanState.findMany.load();
return toast.success("success create"); return toast.success("Sukses menambahkan");
} }
console.log(res); console.log(res);
return toast.error("failed create"); return toast.error("failed create");

View File

@@ -287,7 +287,7 @@ const pengumuman = proxy({
); );
if (res.status === 200) { if (res.status === 200) {
pengumuman.findMany.load(); pengumuman.findMany.load();
return toast.success("success create"); return toast.success("Sukses menambahkan");
} }
console.log(res); console.log(res);
return toast.error("failed create"); return toast.error("failed create");

View File

@@ -101,6 +101,38 @@ const ApbDesa = proxy({
} }
}, },
}, },
findFirst: {
data: null as Prisma.ApbDesaGetPayload<{
include: { pendapatan: true; belanja: true; pembiayaan: true };
}> | null,
loading: false,
async load(params?: Record<string, any>) {
try {
this.loading = true;
// ✅ request ke endpoint find-first
const res = await ApiFetch.api.ekonomi.pendapatanaslidesa.apbdesa[
"find-first"
].get({ query: params || {} });
if (res.status === 200 && res.data?.success) {
this.data = res.data.data ?? null;
} else {
this.data = null;
toast.error(res.data?.message || "Gagal memuat data pertama APB Desa");
}
} catch (error) {
console.error("Error findFirst APB Desa:", error);
toast.error("Gagal memuat data APB Desa pertama");
this.data = null;
} finally {
this.loading = false;
}
},
reset() {
this.data = null;
},
},
update: { update: {
id: "", id: "",
form: { ...ApbDesaDefaultForm }, form: { ...ApbDesaDefaultForm },

View File

@@ -49,7 +49,7 @@ const demografiPekerjaan = proxy({
if (res.status === 200) { if (res.status === 200) {
const id = res.data?.data?.id; const id = res.data?.data?.id;
if (id) { if (id) {
toast.success("Success create"); toast.success("Sukses menambahkan");
demografiPekerjaan.create.form = { ...defaultForm }; demografiPekerjaan.create.form = { ...defaultForm };
demografiPekerjaan.findMany.load(); demografiPekerjaan.findMany.load();
return id; return id;

View File

@@ -47,7 +47,7 @@ const jumlahPendudukMiskin = proxy({
if (res.status === 200) { if (res.status === 200) {
const id = res.data?.data?.id; const id = res.data?.data?.id;
if (id) { if (id) {
toast.success("Success create"); toast.success("Sukses menambahkan");
jumlahPendudukMiskin.create.form = { jumlahPendudukMiskin.create.form = {
year: 0, year: 0,
totalPoorPopulation: 0, totalPoorPopulation: 0,

View File

@@ -89,7 +89,7 @@ const jumlahPengangguran = proxy({
if (res.status === 200) { if (res.status === 200) {
const id = res.data?.id; const id = res.data?.id;
if (id) { if (id) {
toast.success("Success create"); toast.success("Sukses menambahkan");
jumlahPengangguran.create.form = { ...jumlahPengangguranForm }; jumlahPengangguran.create.form = { ...jumlahPengangguranForm };
jumlahPengangguran.findMany.load(); jumlahPengangguran.findMany.load();
return id; return id;

View File

@@ -13,6 +13,7 @@ const templateForm = z.object({
gaji: z.string(), gaji: z.string(),
deskripsi: z.string(), deskripsi: z.string(),
kualifikasi: z.string(), kualifikasi: z.string(),
notelp: z.string(),
}); });
const defaultForm = { const defaultForm = {
@@ -23,6 +24,7 @@ const defaultForm = {
gaji: "", gaji: "",
deskripsi: "", deskripsi: "",
kualifikasi: "", kualifikasi: "",
notelp: "",
}; };
const lowonganKerjaState = proxy({ const lowonganKerjaState = proxy({
@@ -45,7 +47,7 @@ const lowonganKerjaState = proxy({
); );
if (res.status === 200) { if (res.status === 200) {
lowonganKerjaState.create.loading = false; lowonganKerjaState.create.loading = false;
return toast.success("success create"); return toast.success("Sukses menambahkan");
} }
console.log(res); console.log(res);
return toast.error("failed create"); return toast.error("failed create");
@@ -179,6 +181,7 @@ const lowonganKerjaState = proxy({
gaji: data.gaji, gaji: data.gaji,
deskripsi: data.deskripsi, deskripsi: data.deskripsi,
kualifikasi: data.kualifikasi, kualifikasi: data.kualifikasi,
notelp: data.notelp,
}; };
return data; return data;
} else { } else {
@@ -218,6 +221,7 @@ const lowonganKerjaState = proxy({
gaji: this.form.gaji, gaji: this.form.gaji,
deskripsi: this.form.deskripsi, deskripsi: this.form.deskripsi,
kualifikasi: this.form.kualifikasi, kualifikasi: this.form.kualifikasi,
notelp: this.form.notelp,
}), }),
}); });
if (!response.ok) { if (!response.ok) {

View File

@@ -12,6 +12,7 @@ const templatePasarDesaForm = z.object({
imageId: z.string().min(1, "Gambar wajib dipilih"), imageId: z.string().min(1, "Gambar wajib dipilih"),
rating: z.number().min(1, "Rating minimal 1"), rating: z.number().min(1, "Rating minimal 1"),
kategoriId: z.array(z.string()).min(1, "Minimal pilih satu kategori"), kategoriId: z.array(z.string()).min(1, "Minimal pilih satu kategori"),
kontak: z.string().min(1, "Kontak wajib diisi"),
}); });
const defaultPasarDesaForm = { const defaultPasarDesaForm = {
@@ -21,6 +22,7 @@ const defaultPasarDesaForm = {
imageId: "", imageId: "",
rating: 0, rating: 0,
kategoriId: [] as string[], kategoriId: [] as string[],
kontak: "",
}; };
const pasarDesa = proxy({ const pasarDesa = proxy({
@@ -188,6 +190,7 @@ const pasarDesa = proxy({
imageId: data.imageId, imageId: data.imageId,
rating: data.rating, rating: data.rating,
kategoriId: data.kategoriId, kategoriId: data.kategoriId,
kontak: data.kontak,
}; };
return data; return data;
} else { } else {
@@ -225,6 +228,7 @@ const pasarDesa = proxy({
imageId: this.form.imageId, imageId: this.form.imageId,
rating: this.form.rating, rating: this.form.rating,
kategoriId: this.form.kategoriId, kategoriId: this.form.kategoriId,
kontak: this.form.kontak,
}), }),
}); });
if (!response.ok) { if (!response.ok) {
@@ -336,6 +340,40 @@ const kategoriProduk = proxy({
} }
}, },
}, },
// ✅ Versi findManyAll (ambil semua tanpa pagination)
findManyAll: {
data: null as
| Prisma.KategoriProdukGetPayload<{
omit: { isActive: true };
}>[]
| null,
loading: false,
search: "",
load: async (search = "") => {
kategoriProduk.findManyAll.loading = true;
kategoriProduk.findManyAll.search = search;
try {
const query: any = {};
if (search) query.search = search;
const res = await ApiFetch.api.ekonomi.kategoriproduk["find-many-all"].get({
query,
});
if (res.status === 200 && res.data?.success) {
kategoriProduk.findManyAll.data = res.data.data ?? [];
} else {
kategoriProduk.findManyAll.data = [];
}
} catch (err) {
console.error("Gagal fetch kategori produk (all):", err);
kategoriProduk.findManyAll.data = [];
} finally {
kategoriProduk.findManyAll.loading = false;
}
},
},
findUnique: { findUnique: {
data: null as Prisma.KategoriProdukGetPayload<{ data: null as Prisma.KategoriProdukGetPayload<{
omit: { isActive: true }; omit: { isActive: true };

View File

@@ -45,7 +45,7 @@ const programKemiskinanState = proxy({
); );
if (res.status === 200) { if (res.status === 200) {
programKemiskinanState.findMany.load(); programKemiskinanState.findMany.load();
return toast.success("success create"); return toast.success("Sukses menambahkan");
} }
console.log(res); console.log(res);
return toast.error("failed create"); return toast.error("failed create");

View File

@@ -46,7 +46,7 @@ const grafikSektorUnggulan = proxy({
if (res.status === 200) { if (res.status === 200) {
const id = res.data?.data?.id; const id = res.data?.data?.id;
if (id) { if (id) {
toast.success("Success create"); toast.success("Sukses menambahkan");
grafikSektorUnggulan.create.form = { grafikSektorUnggulan.create.form = {
name: "", name: "",
description: "", description: "",

View File

@@ -1,9 +1,173 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { proxy } from "valtio";
import { z } from "zod";
import { toast } from "react-toastify";
import ApiFetch from "@/lib/api-fetch"; import ApiFetch from "@/lib/api-fetch";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
const templateForm = z.object({
name: z.string().min(3, "Nama minimal 3 karakter"),
imageId: z.string().min(1, "Gambar wajib dipilih"),
});
const defaultForm = {
name: "",
imageId: "",
};
type StrukturBumDesForm = Prisma.StrukturBumDesGetPayload<{
select: {
id: true;
name: true;
imageId: true;
image?: {
select: {
link: true;
};
};
};
}>;
const stateStruktur = proxy({
struktur: {
data: null as StrukturBumDesForm | null,
loading: false,
error: null as string | null,
async load(id: string) {
if (!id) {
toast.warn("ID tidak valid");
return null;
}
this.loading = true;
this.error = null;
try {
const response = await fetch(`/api/ekonomi/struktur-organisasi/${id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result.success) {
this.data = result.data;
return result.data;
} else {
throw new Error(result.message || "Gagal mengambil data struktur");
}
} catch (error) {
const errorMessage = (error as Error).message;
this.error = errorMessage;
console.error("Load struktur error:", errorMessage);
toast.error("Terjadi kesalahan saat mengambil data struktur");
return null;
} finally {
this.loading = false;
}
},
reset() {
this.data = null;
this.error = null;
this.loading = false;
},
},
editStruktur: {
id: "",
form: { ...defaultForm },
loading: false,
error: null as string | null,
isReadOnly: false,
initialize(strukturData: StrukturBumDesForm) {
this.id = strukturData.id;
this.isReadOnly = false;
this.form = {
name: strukturData.name || "",
imageId: strukturData.imageId || "",
};
},
updateField(field: keyof typeof defaultForm, value: string) {
this.form[field] = value;
},
async submit() {
const validation = templateForm.safeParse(this.form);
if (!validation.success) {
const errors = validation.error.issues
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
.join(", ");
toast.error(`Form tidak valid: ${errors}`);
return false;
}
this.loading = true;
this.error = null;
try {
const response = await fetch(`/api/ekonomi/struktur-organisasi/${this.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(this.form),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.message || `HTTP error! status: ${response.status}`
);
}
const result = await response.json();
if (result.success) {
toast.success("Berhasil update struktur");
await stateStruktur.struktur.load(this.id);
return true;
} else {
throw new Error(result.message || "Gagal update struktur");
}
} catch (error) {
const errorMessage = (error as Error).message;
this.error = errorMessage;
console.error("Update struktur error:", errorMessage);
toast.error("Terjadi kesalahan saat update struktur");
return false;
} finally {
this.loading = false;
}
},
reset() {
this.id = "";
this.form = { ...defaultForm };
this.error = null;
this.loading = false;
this.isReadOnly = false;
},
},
async loadForEdit(id: string) {
const strukturData = await this.struktur.load(id);
if (strukturData) {
this.editStruktur.initialize(strukturData);
}
return strukturData;
},
reset() {
this.struktur.reset();
this.editStruktur.reset();
},
});
const templatePosisiOrganisasi = z.object({ const templatePosisiOrganisasi = z.object({
nama: z.string().min(1, "Nama harus diisi"), nama: z.string().min(1, "Nama harus diisi"),
@@ -30,9 +194,7 @@ const posisiOrganisasi = proxy({
try { try {
this.loading = true; this.loading = true;
const res = await ApiFetch.api.ekonomi["struktur-organisasi"][ const res = await ApiFetch.api.ekonomi['struktur-organisasi']['posisi-organisasi']['create'].post(this.form);
"posisi-organisasi"
]["create"].post(this.form);
if (res.status === 200) { if (res.status === 200) {
toast.success("Berhasil menambahkan posisi organisasi"); toast.success("Berhasil menambahkan posisi organisasi");
posisiOrganisasi.findMany.load(); posisiOrganisasi.findMany.load();
@@ -52,6 +214,29 @@ const posisiOrganisasi = proxy({
}, },
}, },
findUnique: {
data: null as Prisma.StrukturOrganisasiBumDesGetPayload<{
omit: { isActive: true };
}> | null,
async load(id: string) {
try {
const res = await fetch(
`/api/ekonomi/struktur-organisasi/posisi-organisasi/${id}`
);
if (res.ok) {
const data = await res.json();
posisiOrganisasi.findUnique.data = data.data ?? null;
} else {
console.error("Failed to fetch posisiOrganisasi:", res.statusText);
posisiOrganisasi.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching posisiOrganisasi:", error);
posisiOrganisasi.findUnique.data = null;
}
},
},
edit: { edit: {
id: "", id: "",
form: { ...posisiOrganisasiDefaultForm }, form: { ...posisiOrganisasiDefaultForm },
@@ -165,16 +350,16 @@ const posisiOrganisasi = proxy({
totalPages: 1, totalPages: 1,
loading: false, loading: false,
search: "", search: "",
load: async (page = 1, limit = 10, search = "") => { load: async (page = 1, limit?: number, search = "") => {
posisiOrganisasi.findMany.loading = true; // ✅ Akses langsung via nama path const appliedLimit = limit ?? 10;
posisiOrganisasi.findMany.page = page; posisiOrganisasi.findMany.page = page;
posisiOrganisasi.findMany.search = search; posisiOrganisasi.findMany.search = search;
try { try {
const query: any = { page, limit }; const query: any = { page, limit: appliedLimit };
if (search) query.search = search; if (search) query.search = search;
const res = await ApiFetch.api.ekonomi["struktur-organisasi"]["posisi-organisasi"]["find-many"].get({ query }); const res = await ApiFetch.api.ekonomi['struktur-organisasi']['posisi-organisasi']['find-many'].get({ query });
if (res.status === 200 && res.data?.success) { if (res.status === 200 && res.data?.success) {
posisiOrganisasi.findMany.data = res.data.data ?? []; posisiOrganisasi.findMany.data = res.data.data ?? [];
@@ -192,7 +377,42 @@ const posisiOrganisasi = proxy({
} }
}, },
}, },
findManyAll: {
data: [] as Array<{
id: string;
nama: string;
deskripsi: string | null;
hierarki: number;
}>,
loading: false,
search: "",
load: async (search = "") => {
// Change to arrow function
posisiOrganisasi.findManyAll.loading = true; // Use the full path to access the property
posisiOrganisasi.findManyAll.search = search;
try {
const query: any = { search };
if (search) query.search = search;
const res = await ApiFetch.api.ekonomi['struktur-organisasi']['posisi-organisasi']['find-many-all'].get({
query,
});
if (res.status === 200 && res.data?.success) {
posisiOrganisasi.findManyAll.data = res.data.data || [];
} else {
console.error("Failed to load posisiOrganisasi:", res.data?.message);
posisiOrganisasi.findManyAll.data = [];
}
} catch (error) {
console.error("Error loading posisiOrganisasi:", error);
posisiOrganisasi.findManyAll.data = [];
} finally {
posisiOrganisasi.findManyAll.loading = false;
}
},
},
delete: { delete: {
loading: false, loading: false,
async byId(id: string) { async byId(id: string) {
@@ -231,12 +451,12 @@ const posisiOrganisasi = proxy({
const templatePegawai = z.object({ const templatePegawai = z.object({
namaLengkap: z.string().min(1, "Nama wajib diisi"), namaLengkap: z.string().min(1, "Nama wajib diisi"),
gelarAkademik: z.string().optional(), gelarAkademik: z.string().min(1, "Gelar Akademik wajib diisi"),
imageId: z.string().nullable().optional(), imageId: z.string().min(1, "Gambar wajib dipilih"),
tanggalMasuk: z.string().optional(), // ISO format tanggalMasuk: z.string().min(1, "Tanggal masuk wajib diisi"), // ISO format
email: z.string().email("Email tidak valid").optional(), email: z.string().email("Email tidak valid").optional(),
telepon: z.string().optional(), telepon: z.string().min(1, "Telepom wajib diisi"),
alamat: z.string().optional(), alamat: z.string().min(1, "Alamat wajib diisi"),
posisiId: z.string().min(1, "Posisi wajib diisi"), posisiId: z.string().min(1, "Posisi wajib diisi"),
isActive: z.boolean().default(true), isActive: z.boolean().default(true),
}); });
@@ -267,9 +487,9 @@ const pegawai = proxy({
try { try {
pegawai.create.loading = true; pegawai.create.loading = true;
const res = await ApiFetch.api.ekonomi["struktur-organisasi"][ const res = await ApiFetch.api.ekonomi['struktur-organisasi'].pegawai['create'].post(
"pegawai" pegawai.create.form
]["create"].post(pegawai.create.form); );
if (res.status === 200) { if (res.status === 200) {
toast.success("Pegawai berhasil ditambahkan"); toast.success("Pegawai berhasil ditambahkan");
await pegawai.findMany.load(); await pegawai.findMany.load();
@@ -287,19 +507,30 @@ const pegawai = proxy({
// In struktur-organisasi.ts // In struktur-organisasi.ts
findMany: { findMany: {
data: null as any[] | null, data: null as
| Prisma.PegawaiBumDesGetPayload<{
include: {
image: true;
posisi: true;
};
}>[]
| null,
page: 1, page: 1,
totalPages: 1, totalPages: 1,
total: 0, total: 0,
loading: false, loading: false,
load: async (page = 1, limit = 10) => { // Change to arrow function search: "",
load: async (page = 1, limit = 10, search = "") => {
// Change to arrow function
pegawai.findMany.loading = true; // Use the full path to access the property pegawai.findMany.loading = true; // Use the full path to access the property
pegawai.findMany.page = page; pegawai.findMany.page = page;
pegawai.findMany.search = search;
try { try {
const res = await ApiFetch.api.ekonomi["struktur-organisasi"][ const query: any = { page, limit };
"pegawai" if (search) query.search = search;
]["find-many"].get({
query: { page, limit }, const res = await ApiFetch.api.ekonomi['struktur-organisasi'].pegawai['find-many'].get({
query,
}); });
if (res.status === 200 && res.data?.success) { if (res.status === 200 && res.data?.success) {
@@ -324,7 +555,7 @@ findMany: {
}, },
findUnique: { findUnique: {
data: null as data: null as
| (Prisma.PegawaiGetPayload<{ | (Prisma.PegawaiBumDesGetPayload<{
include: { posisi: true; image: true }; include: { posisi: true; image: true };
}> & { isActive: boolean }) }> & { isActive: boolean })
| null, | null,
@@ -350,12 +581,9 @@ findMany: {
if (!id) return toast.warn("ID tidak valid"); if (!id) return toast.warn("ID tidak valid");
try { try {
pegawai.delete.loading = true; pegawai.delete.loading = true;
const res = await fetch( const res = await fetch(`/api/ekonomi/struktur-organisasi/pegawai/del/${id}`, {
`/api/ekonomi/struktur-organisasi/pegawai/del/${id}`,
{
method: "DELETE", method: "DELETE",
} });
);
const json = await res.json(); const json = await res.json();
if (res.ok) { if (res.ok) {
toast.success(json.message ?? "Berhasil hapus pegawai"); toast.success(json.message ?? "Berhasil hapus pegawai");
@@ -372,6 +600,31 @@ findMany: {
}, },
}, },
nonActive: {
loading: false,
async byId(id: string) {
if (!id) return toast.warn("ID tidak valid");
try {
pegawai.nonActive.loading = true;
const res = await fetch(`/api/ekonomi/struktur-organisasi/pegawai/non-active/${id}`, {
method: "DELETE", // biasanya nonActive pakai PATCH
});
const json = await res.json();
if (res.ok) {
toast.success(json.message ?? "Pegawai berhasil dinonaktifkan");
await pegawai.findMany.load(); // refresh data
} else {
toast.error(json.message ?? "Gagal menonaktifkan pegawai");
}
} catch (error) {
console.error("Gagal nonActive:", error);
toast.error("Terjadi kesalahan saat menonaktifkan pegawai");
} finally {
pegawai.nonActive.loading = false;
}
},
},
edit: { edit: {
id: "", id: "",
form: { ...pegawaiDefaultForm }, form: { ...pegawaiDefaultForm },
@@ -384,15 +637,12 @@ findMany: {
} }
try { try {
const response = await fetch( const response = await fetch(`/api/ekonomi/struktur-organisasi/pegawai/${id}`, {
`/api/ekonomi/struktur-organisasi/pegawai/${id}`,
{
method: "GET", method: "GET",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
} });
);
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);
@@ -503,299 +753,10 @@ findMany: {
}, },
}); });
// Schema Zod untuk form validasi const stateStrukturBumDes = proxy({
const templateHubunganOrganisasiForm = z.object({ stateStruktur,
atasanId: z.string().min(1, "Atasan wajib dipilih"),
bawahanId: z.string().min(1, "Bawahan wajib dipilih"),
tipe: z.string().optional(),
});
// Default form state
const defaultHubunganOrganisasiForm = {
atasanId: "",
bawahanId: "",
tipe: "",
};
// ====================== STATE ===========================
const hubunganOrganisasi = proxy({
create: {
form: { ...defaultHubunganOrganisasiForm },
loading: false,
async create() {
const cek = templateHubunganOrganisasiForm.safeParse(
hubunganOrganisasi.create.form
);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}: ${v.message}`)
.join("\n")}]`;
return toast.error(err);
}
try {
hubunganOrganisasi.create.loading = true;
const res = await ApiFetch.api.ekonomi["struktur-organisasi"][
"hubungan-organisasi"
]["create"].post(hubunganOrganisasi.create.form);
if (res.status === 200 && res.data?.success) {
hubunganOrganisasi.findMany.load();
return toast.success("Berhasil menambahkan hubungan organisasi");
} else {
return toast.error(res.data?.message || "Gagal menambahkan data");
}
} catch (error) {
console.error("Gagal create:", error);
toast.error("Terjadi kesalahan saat menambahkan");
} finally {
hubunganOrganisasi.create.loading = false;
}
},
},
findMany: {
data: null as Array<{
id: string;
atasanId: string;
bawahanId: string;
tipe?: string | null;
atasan: {
id: string;
namaLengkap: string;
gelarAkademik: string | null;
imageId: string | null;
tanggalMasuk: Date | null;
email: string | null;
telepon: string | null;
alamat: string | null;
posisiId: string;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
};
bawahan: {
id: string;
namaLengkap: string;
gelarAkademik: string | null;
imageId: string | null;
tanggalMasuk: Date | null;
email: string | null;
telepon: string | null;
alamat: string | null;
posisiId: string;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
};
}> | null,
async load() {
try {
const res = await ApiFetch.api.ekonomi["struktur-organisasi"][
"hubungan-organisasi"
]["find-many"].get();
if (res.status === 200) {
hubunganOrganisasi.findMany.data = (res.data?.data ?? []).map(
(item: any) => ({
...item,
atasan: item.atasan
? {
...item.atasan,
isActive: item.atasan.isActive ?? item.atasan.aktif ?? true,
}
: null,
bawahan: item.bawahan
? {
...item.bawahan,
isActive:
item.bawahan.isActive ?? item.bawahan.aktif ?? true,
}
: null,
})
);
} else {
hubunganOrganisasi.findMany.data = [];
}
} catch (error) {
console.error("Fetch list error:", error);
toast.error("Gagal memuat data hubungan organisasi");
hubunganOrganisasi.findMany.data = [];
}
},
},
findUnique: {
data: null as {
id: string;
atasanId: string;
bawahanId: string;
tipe?: string | null;
atasan?: {
id: string;
namaLengkap: string;
gelarAkademik: string | null;
imageId: string;
tanggalMasuk: Date | null;
email: string | null;
telepon: string | null;
alamat: string | null;
posisiId: string;
aktif: boolean;
createdAt: Date;
updatedAt: Date;
};
bawahan?: {
id: string;
namaLengkap: string;
gelarAkademik: string | null;
imageId: string;
tanggalMasuk: Date | null;
email: string | null;
telepon: string | null;
alamat: string | null;
posisiId: string;
aktif: boolean;
createdAt: Date;
updatedAt: Date;
};
} | null,
async load(id: string) {
try {
const res = await fetch(
`/api/ekonomi/struktur-organisasi/hubungan-organisasi/${id}`
);
const result = await res.json();
if (res.ok && result?.success) {
hubunganOrganisasi.findUnique.data = result.data;
} else {
hubunganOrganisasi.findUnique.data = null;
toast.error(result?.message || "Gagal mengambil data");
}
} catch (error) {
console.error("Find unique error:", error);
hubunganOrganisasi.findUnique.data = null;
}
},
},
edit: {
id: "",
form: { ...defaultHubunganOrganisasiForm },
loading: false,
async load(id: string) {
if (!id) return toast.warn("ID tidak valid");
try {
const res = await fetch(
`/api/ekonomi/struktur-organisasi/hubungan-organisasi/${id}`
);
const result = await res.json();
if (res.ok && result?.success) {
const data = result.data;
this.id = data.id;
this.form = {
atasanId: data.atasanId,
bawahanId: data.bawahanId,
tipe: data.tipe || "",
};
return data;
} else {
throw new Error(result?.message || "Gagal memuat data");
}
} catch (error) {
console.error("Error loading:", error);
toast.error(
error instanceof Error ? error.message : "Gagal memuat data"
);
return null;
}
},
async update() {
const cek = templateHubunganOrganisasiForm.safeParse(this.form);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}: ${v.message}`)
.join("\n")}]`;
return toast.error(err);
}
try {
this.loading = true;
const res = await fetch(
`/api/ekonomi/struktur-organisasi/hubungan-organisasi/${this.id}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(this.form),
}
);
const result = await res.json();
if (res.ok && result.success) {
await hubunganOrganisasi.findMany.load();
toast.success("Berhasil mengupdate hubungan organisasi");
return true;
} else {
throw new Error(result?.message || "Gagal mengupdate");
}
} catch (error) {
console.error("Update error:", error);
toast.error(error instanceof Error ? error.message : "Gagal update");
return false;
} finally {
this.loading = false;
}
},
reset() {
hubunganOrganisasi.edit.id = "";
hubunganOrganisasi.edit.form = { ...defaultHubunganOrganisasiForm };
},
},
delete: {
loading: false,
async byId(id: string) {
if (!id) return toast.warn("ID tidak valid");
try {
hubunganOrganisasi.delete.loading = true;
const res = await fetch(
`/api/ekonomi/struktur-organisasi/hubungan-organisasi/del/${id}`,
{
method: "DELETE",
}
);
const result = await res.json();
if (res.ok && result?.success) {
toast.success("Hubungan organisasi berhasil dihapus");
hubunganOrganisasi.findMany.load();
} else {
toast.error(result?.message || "Gagal menghapus hubungan organisasi");
}
} catch (error) {
console.error("Delete error:", error);
toast.error("Terjadi kesalahan saat menghapus");
} finally {
hubunganOrganisasi.delete.loading = false;
}
},
},
});
const strukturorganisasiState = proxy({
posisiOrganisasi, posisiOrganisasi,
pegawai, pegawai,
hubunganOrganisasi,
}); });
export default strukturorganisasiState; export default stateStrukturBumDes;

View File

@@ -51,7 +51,7 @@ const grafikBerdasarkanUsiaKerjaNganggur = proxy({
if (res.status === 200) { if (res.status === 200) {
const id = res.data?.data?.id; const id = res.data?.data?.id;
if (id) { if (id) {
toast.success("Success create"); toast.success("Sukses menambahkan");
grafikBerdasarkanUsiaKerjaNganggur.create.form = { grafikBerdasarkanUsiaKerjaNganggur.create.form = {
usia18_25: "", usia18_25: "",
usia26_35: "", usia26_35: "",
@@ -255,7 +255,7 @@ const grafikBerdasarkanPendidikan = proxy({
if (res.status === 200) { if (res.status === 200) {
const id = res.data?.data?.id; const id = res.data?.data?.id;
if (id) { if (id) {
toast.success("Success create"); toast.success("Sukses menambahkan");
grafikBerdasarkanPendidikan.create.form = { grafikBerdasarkanPendidikan.create.form = {
SD: "", SD: "",
SMP: "", SMP: "",

View File

@@ -37,7 +37,7 @@ const desaDigitalState = proxy({
); );
if (res.status === 200) { if (res.status === 200) {
desaDigitalState.findMany.load(); desaDigitalState.findMany.load();
return toast.success("success create"); return toast.success("Sukses menambahkan");
} }
console.log(res); console.log(res);
return toast.error("failed create"); return toast.error("failed create");

View File

@@ -37,7 +37,7 @@ const infoTeknoState = proxy({
); );
if (res.status === 200) { if (res.status === 200) {
infoTeknoState.findMany.load(); infoTeknoState.findMany.load();
return toast.success("success create"); return toast.success("Sukses menambahkan");
} }
console.log(res); console.log(res);
return toast.error("failed create"); return toast.error("failed create");

View File

@@ -6,9 +6,9 @@ import { proxy } from "valtio";
import { z } from "zod"; import { z } from "zod";
const templateForm = z.object({ const templateForm = z.object({
name: z.string().min(1, "Nama minimal 1 karakter"), name: z.string().min(5, "Nama minimal 5 karakter"),
deskripsi: z.string().min(1, "Deskripsi minimal 1 karakter"), deskripsi: z.string().min(5, "Deskripsi minimal 5 karakter"),
slug: z.string().min(1, "Deskripsi singkat minimal 1 karakter"), slug: z.string().min(5, "Deskripsi singkat minimal 5 karakter"),
icon: z.string().min(1, "Icon minimal 1 karakter"), icon: z.string().min(1, "Icon minimal 1 karakter"),
}); });
@@ -29,7 +29,8 @@ const programKreatifState = proxy({
const err = `[${cek.error.issues const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`) .map((v) => `${v.path.join(".")}`)
.join("\n")}] required`; .join("\n")}] required`;
return toast.error(err); toast.error(err);
return false; // ⬅️ ini penting
} }
try { try {
@@ -37,18 +38,24 @@ const programKreatifState = proxy({
const res = await ApiFetch.api.inovasi.programkreatif["create"].post( const res = await ApiFetch.api.inovasi.programkreatif["create"].post(
programKreatifState.create.form programKreatifState.create.form
); );
if (res.status === 200) { if (res.status === 200) {
programKreatifState.findMany.load(); programKreatifState.findMany.load();
return toast.success("success create"); toast.success("Sukses menambahkan");
return true;
} }
console.log(res);
return toast.error("failed create"); toast.error("failed create");
return false;
} catch (error) { } catch (error) {
console.log((error as Error).message); console.error((error as Error).message);
toast.error("Terjadi kesalahan saat create");
return false;
} finally { } finally {
programKreatifState.create.loading = false; programKreatifState.create.loading = false;
} }
}, }
}, },
findMany: { findMany: {
data: null as any[] | null, data: null as any[] | null,

View File

@@ -37,7 +37,7 @@ const keamananLingkunganState = proxy({
].post(keamananLingkunganState.create.form); ].post(keamananLingkunganState.create.form);
if (res.status === 200) { if (res.status === 200) {
keamananLingkunganState.findMany.load(); keamananLingkunganState.findMany.load();
return toast.success("success create"); return toast.success("Sukses menambahkan");
} }
console.log(res); console.log(res);
return toast.error("failed create"); return toast.error("failed create");

View File

@@ -38,7 +38,7 @@ const kontakDaruratKeamananState = proxy({
].post(kontakDaruratKeamananState.create.form); ].post(kontakDaruratKeamananState.create.form);
if (res.status === 200) { if (res.status === 200) {
kontakDaruratKeamananState.findMany.load(); kontakDaruratKeamananState.findMany.load();
return toast.success("success create"); return toast.success("Sukses menambahkan");
} }
console.log(res); console.log(res);
return toast.error("failed create"); return toast.error("failed create");
@@ -294,7 +294,7 @@ const kontakDaruratItem = proxy({
); );
if (res.status === 200) { if (res.status === 200) {
kontakDaruratItem.findMany.load(); kontakDaruratItem.findMany.load();
return toast.success("success create"); return toast.success("Sukses menambahkan");
} }
console.log(res); console.log(res);
return toast.error("failed create"); return toast.error("failed create");

View File

@@ -88,7 +88,7 @@ const laporanPublikState = proxy({
if (res.status === 200) { if (res.status === 200) {
laporanPublikState.findMany.load(); laporanPublikState.findMany.load();
return toast.success("success create"); return toast.success("Sukses menambahkan");
} }
console.log(res); console.log(res);

View File

@@ -40,7 +40,7 @@ const pencegahanKriminalitasState = proxy({
].post(pencegahanKriminalitasState.create.form); ].post(pencegahanKriminalitasState.create.form);
if (res.status === 200) { if (res.status === 200) {
pencegahanKriminalitasState.findMany.load(); pencegahanKriminalitasState.findMany.load();
return toast.success("success create"); return toast.success("Sukses menambahkan");
} }
console.log(res); console.log(res);
return toast.error("failed create"); return toast.error("failed create");

View File

@@ -37,7 +37,7 @@ const tipsKeamananState = proxy({
); );
if (res.status === 200) { if (res.status === 200) {
tipsKeamananState.findMany.load(); tipsKeamananState.findMany.load();
return toast.success("success create"); return toast.success("Sukses menambahkan");
} }
console.log(res); console.log(res);
return toast.error("failed create"); return toast.error("failed create");

View File

@@ -351,7 +351,7 @@ const dokter = proxy({
if (res.status === 200) { if (res.status === 200) {
const id = res.data?.data; const id = res.data?.data;
if (id) { if (id) {
toast.success("Success create"); toast.success("Sukses menambahkan");
dokter.create.create.form = { ...defaultDokterForm }; dokter.create.create.form = { ...defaultDokterForm };
dokter.findMany.load(); dokter.findMany.load();
return id; return id;

View File

@@ -43,7 +43,7 @@ const grafikkepuasan = proxy({
if (res.status === 200) { if (res.status === 200) {
const id = res.data?.data; const id = res.data?.data;
if (id) { if (id) {
toast.success("Success create"); toast.success("Sukses menambahkan");
grafikkepuasan.create.form = { ...defaultForm }; grafikkepuasan.create.form = { ...defaultForm };
grafikkepuasan.findMany.load(); grafikkepuasan.findMany.load();
return id; return id;

View File

@@ -50,7 +50,7 @@ const persentasekelahiran = proxy({
if (res.status === 200) { if (res.status === 200) {
const id = res.data?.data; const id = res.data?.data;
if (id) { if (id) {
toast.success("Success create"); toast.success("Sukses menambahkan");
persentasekelahiran.create.form = { ...defaultForm }; persentasekelahiran.create.form = { ...defaultForm };
persentasekelahiran.findMany.load(); persentasekelahiran.findMany.load();
return id; return id;

View File

@@ -9,12 +9,14 @@ const templateForm = z.object({
name: z.string().min(3, "Judul minimal 3 karakter"), name: z.string().min(3, "Judul minimal 3 karakter"),
deskripsi: z.string().min(3, "Deskripsi minimal 3 karakter"), deskripsi: z.string().min(3, "Deskripsi minimal 3 karakter"),
imageId: z.string().nonempty(), imageId: z.string().nonempty(),
whatsapp: z.string().min(10, "Whatsapp minimal 10 karakter"),
}); });
const defaultForm = { const defaultForm = {
name: "", name: "",
deskripsi: "", deskripsi: "",
imageId: "", imageId: "",
whatsapp: "",
}; };
const kontakDarurat = proxy({ const kontakDarurat = proxy({
@@ -171,6 +173,7 @@ const kontakDarurat = proxy({
name: data.name, name: data.name,
deskripsi: data.deskripsi, deskripsi: data.deskripsi,
imageId: data.imageId, imageId: data.imageId,
whatsapp: data.whatsapp,
}; };
return data; // Return the loaded data return data; // Return the loaded data
} else { } else {
@@ -207,6 +210,7 @@ const kontakDarurat = proxy({
name: this.form.name, name: this.form.name,
deskripsi: this.form.deskripsi, deskripsi: this.form.deskripsi,
imageId: this.form.imageId, imageId: this.form.imageId,
whatsapp: this.form.whatsapp,
}), }),
} }
); );

View File

@@ -5,58 +5,117 @@ import { toast } from "react-toastify";
import { proxy } from "valtio"; import { proxy } from "valtio";
import { z } from "zod"; import { z } from "zod";
const templateapbDesaForm = z.object({ // --- Zod Schema ---
name: z.string().min(1, "Judul minimal 1 karakter"), const ApbdesItemSchema = z.object({
jumlah: z.string().min(1, "Deskripsi minimal 1 karakter"), kode: z.string().min(1, "Kode wajib diisi"),
imageId: z.string().min(1, "File minimal 1"), uraian: z.string().min(1, "Uraian wajib diisi"),
fileId: z.string().min(1, "File minimal 1"), anggaran: z.number().min(0),
realisasi: z.number().min(0),
selisih: z.number(),
persentase: z.number(),
level: z.number().int().min(1).max(3),
tipe: z.enum(['pendapatan', 'belanja', 'pembiayaan']).nullable().optional(),
}); });
const defaultapbdesForm = { const ApbdesFormSchema = z.object({
name: "", tahun: z.number().int().min(2000, "Tahun tidak valid"),
jumlah: "", imageId: z.string().min(1, "Gambar wajib diunggah"),
fileId: z.string().min(1, "File wajib diunggah"),
items: z.array(ApbdesItemSchema).min(1, "Minimal ada 1 item"),
});
// --- Default Form ---
const defaultApbdesForm = {
tahun: new Date().getFullYear(),
imageId: "", imageId: "",
fileId: "", fileId: "",
items: [] as z.infer<typeof ApbdesItemSchema>[],
}; };
// --- Helper: hitung selisih & persentase otomatis (opsional di frontend) ---
// --- Helper: hitung selisih & persentase otomatis (opsional di frontend) ---
function normalizeItem(item: Partial<z.infer<typeof ApbdesItemSchema>>): z.infer<typeof ApbdesItemSchema> {
const anggaran = item.anggaran ?? 0;
const realisasi = item.realisasi ?? 0;
// ✅ Formula yang benar
const selisih = anggaran - realisasi; // positif = sisa anggaran, negatif = over budget
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0; // persentase realisasi terhadap anggaran
return {
kode: item.kode || "",
uraian: item.uraian || "",
anggaran,
realisasi,
selisih,
persentase,
level: item.level || 1,
tipe: item.tipe, // biarkan null jika memang null
};
}
// --- State Utama ---
const apbdes = proxy({ const apbdes = proxy({
create: { create: {
form: { ...defaultapbdesForm }, form: { ...defaultApbdesForm },
loading: false, loading: false,
async create() {
const cek = templateapbDesaForm.safeParse(apbdes.create.form);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
return toast.error(err);
}
try {
apbdes.create.loading = true;
const res = await ApiFetch.api.landingpage.apbdes["create"].post({
...apbdes.create.form,
});
if (res.status === 200) { addItem(item: Partial<z.infer<typeof ApbdesItemSchema>>) {
const normalized = normalizeItem(item);
this.form.items.push(normalized);
},
removeItem(index: number) {
this.form.items.splice(index, 1);
},
updateItem(index: number, updates: Partial<z.infer<typeof ApbdesItemSchema>>) {
const current = this.form.items[index];
if (current) {
const updated = normalizeItem({ ...current, ...updates });
this.form.items[index] = updated;
}
},
reset() {
this.form = { ...defaultApbdesForm };
},
async create() {
const parsed = ApbdesFormSchema.safeParse(this.form);
if (!parsed.success) {
const errors = parsed.error.issues.map((issue) => `${issue.path.join(".")} - ${issue.message}`);
toast.error(`Validasi gagal:\n${errors.join("\n")}`);
return;
}
try {
this.loading = true;
const res = await ApiFetch.api.landingpage.apbdes["create"].post(parsed.data);
if (res.data?.success) {
toast.success("APBDes berhasil dibuat");
apbdes.findMany.load(); apbdes.findMany.load();
return toast.success("Data berhasil ditambahkan"); this.reset();
} else {
toast.error(res.data?.message || "Gagal membuat APBDes");
} }
return toast.error("Gagal menambahkan data"); } catch (error: any) {
} catch (error) { console.error("Create APBDes error:", error);
console.log(error); toast.error(error?.message || "Terjadi kesalahan saat membuat APBDes");
toast.error("Gagal menambahkan data");
} finally { } finally {
apbdes.create.loading = false; this.loading = false;
} }
}, },
}, },
findMany: { findMany: {
data: null as data: null as
| Prisma.APBDesGetPayload<{ | Prisma.APBDesGetPayload<{
include: { include: { image: true; file: true; items: true };
image: true;
file: true;
};
}>[] }>[]
| null, | null,
page: 1, page: 1,
@@ -64,192 +123,200 @@ const apbdes = proxy({
total: 0, total: 0,
loading: false, loading: false,
search: "", search: "",
load: async (page = 1, limit = 10, search = "") => { // Change to arrow function
apbdes.findMany.loading = true; // Use the full path to access the property load: async (page = 1, limit = 10, search = "") => {
apbdes.findMany.loading = true;
apbdes.findMany.page = page; apbdes.findMany.page = page;
apbdes.findMany.search = search; apbdes.findMany.search = search;
try { try {
const query: any = { page, limit }; const query: Record<string, string> = { page: String(page), limit: String(limit) };
if (search) query.search = search; if (search) query.search = search;
const res = await ApiFetch.api.landingpage.apbdes[ const res = await ApiFetch.api.landingpage.apbdes["findMany"].get({ query });
"findMany"
].get({
query
});
if (res.status === 200 && res.data?.success) { if (res.data?.success) {
apbdes.findMany.data = res.data.data || []; apbdes.findMany.data = res.data.data || [];
apbdes.findMany.total = res.data.total || 0; apbdes.findMany.total = res.data.meta?.total || 0;
apbdes.findMany.totalPages = res.data.totalPages || 1; apbdes.findMany.totalPages = res.data.meta?.totalPages || 1;
} else { } else {
console.error("Failed to load pegawai:", res.data?.message);
apbdes.findMany.data = []; apbdes.findMany.data = [];
apbdes.findMany.total = 0; apbdes.findMany.total = 0;
apbdes.findMany.totalPages = 1; apbdes.findMany.totalPages = 1;
toast.error(res.data?.message || "Gagal memuat data");
} }
} catch (error) { } catch (error) {
console.error("Error loading pegawai:", error); console.error("FindMany error:", error);
apbdes.findMany.data = []; apbdes.findMany.data = [];
apbdes.findMany.total = 0; apbdes.findMany.total = 0;
apbdes.findMany.totalPages = 1; apbdes.findMany.totalPages = 1;
toast.error("Gagal memuat daftar APBDes");
} finally { } finally {
apbdes.findMany.loading = false; apbdes.findMany.loading = false;
} }
}, },
}, },
findUnique: { findUnique: {
data: null as Prisma.APBDesGetPayload<{ data: null as
include: { | Prisma.APBDesGetPayload<{
image: true; include: { image: true; file: true; items: true };
file: true; }>
}; | null,
}> | null, loading: false,
error: null as string | null,
async load(id: string) { async load(id: string) {
if (!id || id.trim() === '') {
this.data = null;
this.error = "ID tidak valid";
return;
}
this.loading = true;
this.error = null;
try { try {
const res = await fetch(`/api/landingpage/apbdes/${id}`); // Pastikan URL-nya benar
if (res.ok) { const url = `/api/landingpage/apbdes/${id}`;
const data = await res.json(); console.log("🌐 Fetching:", url);
apbdes.findUnique.data = data.data ?? null;
// Gunakan fetch biasa atau ApiFetch dengan cara yang benar
const response = await fetch(url);
const res = await response.json();
console.log("📦 Response:", res);
if (res.success && res.data) {
this.data = res.data;
} else { } else {
console.error("Failed to fetch data", res.status, res.statusText); this.data = null;
apbdes.findUnique.data = null; this.error = res.message || "Gagal memuat detail APBDes";
toast.error(this.error);
} }
} catch (error) { } catch (error) {
console.error("Error fetching data:", error); console.error("❌ FindUnique error:", error);
apbdes.findUnique.data = null; this.data = null;
this.error = "Gagal memuat detail APBDes";
toast.error(this.error);
} finally {
this.loading = false;
}
} }
}, },
},
delete: { delete: {
loading: false, loading: false,
async byId(id: string) { async byId(id: string) {
if (!id) return toast.warn("ID tidak valid"); if (!id) return toast.warn("ID tidak valid");
try { try {
apbdes.delete.loading = true; this.loading = true;
const res = await (ApiFetch.api.landingpage.apbdes as any)["del"][id].delete();
const response = await fetch(`/api/landingpage/apbdes/del/${id}`, { if (res.data?.success) {
method: "DELETE", toast.success("APBDes berhasil dihapus");
headers: { apbdes.findMany.load();
"Content-Type": "application/json",
},
});
const result = await response.json();
if (response.ok && result?.success) {
toast.success(result.message || "apbdes berhasil dihapus");
await apbdes.findMany.load(); // refresh list
} else { } else {
toast.error(result?.message || "Gagal menghapus apbdes"); toast.error(res.data?.message || "Gagal menghapus APBDes");
} }
} catch (error) { } catch (error: any) {
console.error("Gagal delete:", error); console.error("Delete error:", error);
toast.error("Terjadi kesalahan saat menghapus apbdes"); toast.error(error?.message || "Terjadi kesalahan saat menghapus");
} finally { } finally {
apbdes.delete.loading = false; this.loading = false;
} }
}, },
}, },
edit: { edit: {
id: "", id: "",
form: { ...defaultapbdesForm }, form: { ...defaultApbdesForm },
loading: false, loading: false,
async load(id: string) { async load(id: string) {
if (!id) { if (!id) return toast.warn("ID tidak valid");
toast.warn("ID tidak valid");
return null;
}
try { try {
apbdes.edit.loading = true; this.loading = true;
const res = await (ApiFetch.api.landingpage.apbdes as any)[id].get();
const response = await fetch(`/api/landingpage/apbdes/${id}`, { if (res.data?.success) {
method: "GET", const data = res.data.data;
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result?.success) {
const data = result.data;
this.id = data.id; this.id = data.id;
this.form = { this.form = {
name: data.name, tahun: data.tahun || new Date().getFullYear(),
jumlah: data.jumlah, imageId: data.imageId || "",
imageId: data.imageId, fileId: data.fileId || "",
fileId: data.fileId, items: (data.items || []).map((item: any) => ({
kode: item.kode,
uraian: item.uraian,
anggaran: item.anggaran,
realisasi: item.realisasi,
selisih: item.selisih,
persentase: item.persentase,
level: item.level,
tipe: item.tipe || 'pendapatan',
})),
}; };
return data; return data;
} else { } else {
throw new Error(result?.message || "Gagal memuat data"); throw new Error(res.data?.message || "Gagal memuat data");
} }
} catch (error) { } catch (error: any) {
console.error("Error loading apbdes:", error); console.error("Edit load error:", error);
toast.error( toast.error(error.message || "Gagal memuat data untuk diedit");
error instanceof Error ? error.message : "Gagal memuat data"
);
return null;
} finally { } finally {
apbdes.edit.loading = false; this.loading = false;
} }
}, },
async update() { async update() {
const cek = templateapbDesaForm.safeParse(apbdes.edit.form); const parsed = ApbdesFormSchema.safeParse(this.form);
if (!cek.success) { if (!parsed.success) {
const err = `[${cek.error.issues const errors = parsed.error.issues.map((issue) => `${issue.path.join(".")} - ${issue.message}`);
.map((v) => `${v.path.join(".")}`) toast.error(`Validasi gagal:\n${errors.join("\n")}`);
.join("\n")}] required`; return false;
return toast.error(err);
} }
try { try {
apbdes.edit.loading = true; this.loading = true;
const response = await fetch(`/api/landingpage/apbdes/${this.id}`, { // Include the ID in the request body
method: "PUT", const requestData = {
headers: { ...parsed.data,
"Content-Type": "application/json", id: this.id, // Add the ID to the request body
}, };
body: JSON.stringify({
name: this.form.name, const res = await (ApiFetch.api.landingpage.apbdes as any)[this.id].put(requestData);
jumlah: this.form.jumlah,
imageId: this.form.imageId, if (res.data?.success) {
fileId: this.form.fileId, toast.success("APBDes berhasil diperbarui");
}), apbdes.findMany.load();
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.message || `HTTP error! status: ${response.status}`
);
}
const result = await response.json();
if (result.success) {
toast.success("Berhasil update apbdes");
await apbdes.findMany.load(); // refresh list
return true; return true;
} else { } else {
throw new Error(result.message || "Gagal mengupdate apbdes"); throw new Error(res.data?.message || "Gagal memperbarui APBDes");
} }
} catch (error) { } catch (error: any) {
console.error("Error updating apbdes:", error); console.error("Update error:", error);
toast.error( toast.error(error.message || "Gagal memperbarui APBDes");
error instanceof Error ? error.message : "Gagal mengupdate apbdes"
);
return false; return false;
} finally { } finally {
apbdes.edit.loading = false; this.loading = false;
} }
}, },
addItem(item: Partial<z.infer<typeof ApbdesItemSchema>>) {
const normalized = normalizeItem(item);
this.form.items.push(normalized);
},
removeItem(index: number) {
this.form.items.splice(index, 1);
},
reset() { reset() {
apbdes.edit.id = ""; this.id = "";
apbdes.edit.form = { ...defaultapbdesForm }; this.form = { ...defaultApbdesForm };
}, },
}, },
}); });

View File

@@ -53,7 +53,7 @@ const programInovasi = proxy({
].post(formData); ].post(formData);
if (res.status === 200) { if (res.status === 200) {
programInovasi.findMany.load(); programInovasi.findMany.load();
return toast.success("success create"); return toast.success("Sukses menambahkan");
} }
console.log(res); console.log(res);
return toast.error("failed create"); return toast.error("failed create");
@@ -388,16 +388,15 @@ const pejabatDesa = proxy({
this.error = null; this.error = null;
try { try {
const response = await fetch( // Ensure ID is properly encoded in the URL
`/api/landingpage/pejabatdesa/${this.id}`, const url = new URL(`/api/landingpage/pejabatdesa/${encodeURIComponent(this.id)}`, window.location.origin);
{ const response = await fetch(url.toString(), {
method: "PUT", method: "PUT",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify(this.form), body: JSON.stringify(this.form),
} });
);
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));
@@ -475,7 +474,7 @@ const mediaSosial = proxy({
); );
if (res.status === 200) { if (res.status === 200) {
mediaSosial.findMany.load(); mediaSosial.findMany.load();
return toast.success("success create"); return toast.success("Sukses menambahkan");
} }
console.log(res); console.log(res);
return toast.error("failed create"); return toast.error("failed create");

View File

@@ -93,6 +93,34 @@ const sdgsDesa = proxy({
} }
}, },
}, },
findManyAll: {
data: null as any[] | null,
loading: false,
load: async () => { // Change to arrow function
sdgsDesa.findManyAll.loading = true; // Use the full path to access the property
try {
const query: any = {};
const res = await ApiFetch.api.landingpage.sdgsdesa[
"findManyAll"
].get({
query,
});
if (res.status === 200 && res.data?.success) {
sdgsDesa.findManyAll.data = res.data.data || [];
} else {
console.error("Failed to load media sosial:", res.data?.message);
sdgsDesa.findManyAll.data = [];
}
} catch (error) {
console.error("Error loading media sosial:", error);
sdgsDesa.findManyAll.data = [];
} finally {
sdgsDesa.findManyAll.loading = false;
}
},
},
findUnique: { findUnique: {
data: null as Prisma.SdgsDesaGetPayload<{ data: null as Prisma.SdgsDesaGetPayload<{
include: { include: {

View File

@@ -39,7 +39,7 @@ const dataLingkunganDesaState = proxy({
); );
if (res.status === 200) { if (res.status === 200) {
dataLingkunganDesaState.findMany.load(); dataLingkunganDesaState.findMany.load();
return toast.success("success create"); return toast.success("Sukses menambahkan");
} }
console.log(res); console.log(res);
return toast.error("failed create"); return toast.error("failed create");

View File

@@ -35,7 +35,7 @@ const pengelolaanSampah = proxy({
].post(pengelolaanSampah.create.form); ].post(pengelolaanSampah.create.form);
if (res.status === 200) { if (res.status === 200) {
pengelolaanSampah.findMany.load(); pengelolaanSampah.findMany.load();
return toast.success("success create"); return toast.success("Sukses menambahkan");
} }
console.log(res); console.log(res);
return toast.error("failed create"); return toast.error("failed create");

View File

@@ -39,7 +39,7 @@ const programPenghijauanState = proxy({
); );
if (res.status === 200) { if (res.status === 200) {
programPenghijauanState.findMany.load(); programPenghijauanState.findMany.load();
return toast.success("success create"); return toast.success("Sukses menambahkan");
} }
console.log(res); console.log(res);
return toast.error("failed create"); return toast.error("failed create");

View File

@@ -9,34 +9,32 @@ import { z } from "zod";
const templateBeasiswaPendaftar = z.object({ const templateBeasiswaPendaftar = z.object({
namaLengkap: z.string().min(1, "Nama harus diisi"), namaLengkap: z.string().min(1, "Nama harus diisi"),
nik: z.string().min(1, "NIK harus diisi"), nis: z.string().min(1, "NIS harus diisi"),
kelas: z.string().min(1, "Kelas harus diisi"),
jenisKelamin: z.string().min(1, "Jenis kelamin harus diisi"),
alamatDomisili: z.string().min(1, "Alamat domisili harus diisi"),
tempatLahir: z.string().min(1, "Tempat lahir harus diisi"), tempatLahir: z.string().min(1, "Tempat lahir harus diisi"),
tanggalLahir: z.string().min(1, "Tanggal lahir harus diisi"), tanggalLahir: z.string().min(1, "Tanggal lahir harus diisi"),
jenisKelamin: z.string().min(1, "Jenis kelamin harus diisi"), namaOrtu: z.string().min(1, "Nama ortu harus diisi"),
kewarganegaraan: z.string().min(1, "Kewarganegaraan harus diisi"), nik: z.string().min(1, "NIK harus diisi"),
agama: z.string().min(1, "Agama harus diisi"), pekerjaanOrtu: z.string().min(1, "Pekerjaan ortu harus diisi"),
alamatKTP: z.string().min(1, "Alamat KTP harus diisi"), penghasilan: z.string().min(1, "Penghasilan ortu harus diisi"),
alamatDomisili: z.string().min(1, "Alamat domisili harus diisi"),
noHp: z.string().min(1, "No HP harus diisi"), noHp: z.string().min(1, "No HP harus diisi"),
email: z.string().min(1, "Email harus diisi"),
statusPernikahan: z.string().min(1, "Status pernikahan harus diisi"),
ukuranBaju: z.string().min(1, "Ukuran baju harus diisi"),
}); });
const defaultBeasiswaPendaftar = { const defaultBeasiswaPendaftar = {
namaLengkap: "", namaLengkap: "",
nik: "", nis: "",
kelas: "",
jenisKelamin: "",
alamatDomisili: "",
tempatLahir: "", tempatLahir: "",
tanggalLahir: "", tanggalLahir: "",
jenisKelamin: "", namaOrtu: "",
kewarganegaraan: "", nik: "",
agama: "", pekerjaanOrtu: "",
alamatKTP: "", penghasilan: "",
alamatDomisili: "",
noHp: "", noHp: "",
email: "",
statusPernikahan: "",
ukuranBaju: "",
}; };
const beasiswaPendaftar = proxy({ const beasiswaPendaftar = proxy({
@@ -200,18 +198,17 @@ const beasiswaPendaftar = proxy({
this.id = data.id; this.id = data.id;
this.form = { this.form = {
namaLengkap: data.namaLengkap, namaLengkap: data.namaLengkap,
nik: data.nik, nis: data.nis,
kelas: data.kelas,
jenisKelamin: data.jenisKelamin,
alamatDomisili: data.alamatDomisili,
tempatLahir: data.tempatLahir, tempatLahir: data.tempatLahir,
tanggalLahir: data.tanggalLahir, tanggalLahir: data.tanggalLahir,
jenisKelamin: data.jenisKelamin, namaOrtu: data.namaOrtu,
kewarganegaraan: data.kewarganegaraan, nik: data.nik,
agama: data.agama, pekerjaanOrtu: data.pekerjaanOrtu,
alamatKTP: data.alamatKTP, penghasilan: data.penghasilan,
alamatDomisili: data.alamatDomisili,
noHp: data.noHp, noHp: data.noHp,
email: data.email,
statusPernikahan: data.statusPernikahan,
ukuranBaju: data.ukuranBaju,
}; };
return data; // Return the loaded data return data; // Return the loaded data
} else { } else {
@@ -249,17 +246,17 @@ const beasiswaPendaftar = proxy({
}, },
body: JSON.stringify({ body: JSON.stringify({
namaLengkap: this.form.namaLengkap, namaLengkap: this.form.namaLengkap,
nik: this.form.nik, nis: this.form.nis,
tanggalLahir: this.form.tanggalLahir, kelas: this.form.kelas,
jenisKelamin: this.form.jenisKelamin, jenisKelamin: this.form.jenisKelamin,
kewarganegaraan: this.form.kewarganegaraan,
agama: this.form.agama,
alamatKTP: this.form.alamatKTP,
alamatDomisili: this.form.alamatDomisili, alamatDomisili: this.form.alamatDomisili,
tempatLahir: this.form.tempatLahir,
tanggalLahir: this.form.tanggalLahir,
namaOrtu: this.form.namaOrtu,
nik: this.form.nik,
pekerjaanOrtu: this.form.pekerjaanOrtu,
penghasilan: this.form.penghasilan,
noHp: this.form.noHp, noHp: this.form.noHp,
email: this.form.email,
statusPernikahan: this.form.statusPernikahan,
ukuranBaju: this.form.ukuranBaju,
}), }),
} }
); );
@@ -332,7 +329,7 @@ const keunggulanProgram = proxy({
].post(keunggulanProgram.create.form); ].post(keunggulanProgram.create.form);
if (res.status === 200) { if (res.status === 200) {
keunggulanProgram.findMany.load(); keunggulanProgram.findMany.load();
return toast.success("Data Berhasil Dibuat, Silahkan Menunggu Konfirmasi dari Admin di WhatsApp"); return toast.success("Data Berhasil Dibuat");
} }
console.log(res); console.log(res);
return toast.error("failed create"); return toast.error("failed create");

View File

@@ -42,7 +42,7 @@ const dataPendidikan = proxy({
if (res.status === 200) { if (res.status === 200) {
const id = res.data?.data?.id; const id = res.data?.data?.id;
if (id) { if (id) {
toast.success("Success create"); toast.success("Sukses menambahkan");
dataPendidikan.create.form = { dataPendidikan.create.form = {
name: "", name: "",
jumlah: "", jumlah: "",

View File

@@ -68,6 +68,7 @@ const dataPerpustakaan = proxy({
loading: false, loading: false,
search: "", search: "",
load: async (page = 1, limit = 10, search = "", kategori = "") => { load: async (page = 1, limit = 10, search = "", kategori = "") => {
const startTime = Date.now();
dataPerpustakaan.findMany.loading = true; // ✅ Akses langsung via nama path dataPerpustakaan.findMany.loading = true; // ✅ Akses langsung via nama path
dataPerpustakaan.findMany.page = page; dataPerpustakaan.findMany.page = page;
dataPerpustakaan.findMany.search = search; dataPerpustakaan.findMany.search = search;
@@ -77,7 +78,10 @@ const dataPerpustakaan = proxy({
if (search) query.search = search; if (search) query.search = search;
if (kategori) query.kategori = kategori; if (kategori) query.kategori = kategori;
const res = await ApiFetch.api.pendidikan.perpustakaandigital.dataperpustakaan["findMany"].get({ query }); const res =
await ApiFetch.api.pendidikan.perpustakaandigital.dataperpustakaan[
"findMany"
].get({ query });
if (res.status === 200 && res.data?.success) { if (res.status === 200 && res.data?.success) {
dataPerpustakaan.findMany.data = res.data.data ?? []; dataPerpustakaan.findMany.data = res.data.data ?? [];
@@ -91,7 +95,52 @@ const dataPerpustakaan = proxy({
dataPerpustakaan.findMany.data = []; dataPerpustakaan.findMany.data = [];
dataPerpustakaan.findMany.totalPages = 1; dataPerpustakaan.findMany.totalPages = 1;
} finally { } finally {
// pastikan minimal 300ms sebelum loading = false (biar UX smooth)
const elapsed = Date.now() - startTime;
const minDelay = 300;
const delay = elapsed < minDelay ? minDelay - elapsed : 0;
setTimeout(() => {
dataPerpustakaan.findMany.loading = false; dataPerpustakaan.findMany.loading = false;
}, delay);
}
},
},
findManyAll: {
data: null as
| Prisma.DataPerpustakaanGetPayload<{
include: {
image: true;
kategori: true;
};
}>[]
| null,
loading: false,
search: "",
load: async (search = "", kategori = "") => {
dataPerpustakaan.findMany.loading = true; // ✅ Akses langsung via nama path
dataPerpustakaan.findMany.search = search;
try {
const query: any = {};
if (search) query.search = search;
if (kategori) query.kategori = kategori;
const res =
await ApiFetch.api.pendidikan.perpustakaandigital.dataperpustakaan[
"findManyAll"
].get({ query });
if (res.status === 200 && res.data?.success) {
dataPerpustakaan.findManyAll.data = res.data.data ?? [];
} else {
dataPerpustakaan.findManyAll.data = [];
}
} catch (err) {
console.error("Gagal fetch data perpustakaan paginated:", err);
dataPerpustakaan.findManyAll.data = [];
} finally {
dataPerpustakaan.findManyAll.loading = false;
} }
}, },
}, },
@@ -330,7 +379,10 @@ const kategoriBuku = proxy({
const query: any = { page, limit }; const query: any = { page, limit };
if (search) query.search = search; if (search) query.search = search;
const res = await ApiFetch.api.pendidikan.perpustakaandigital.kategoribuku["findMany"].get({ query }); const res =
await ApiFetch.api.pendidikan.perpustakaandigital.kategoribuku[
"findMany"
].get({ query });
if (res.status === 200 && res.data?.success) { if (res.status === 200 && res.data?.success) {
kategoriBuku.findMany.data = res.data.data ?? []; kategoriBuku.findMany.data = res.data.data ?? [];
@@ -514,9 +566,319 @@ const kategoriBuku = proxy({
}, },
}); });
const templatePeminjamanBuku = z.object({
nama: z.string().min(1, "Nama harus diisi"),
noTelp: z.string().min(1, "No Telp harus diisi"),
alamat: z.string().min(1, "Alamat harus diisi"),
bukuId: z.string().min(1, "Buku ID harus diisi"),
tanggalPinjam: z.string().min(1, "Tanggal Pinjam harus diisi"),
batasKembali: z.string().min(1, "Batas Kembali harus diisi"),
tanggalKembali: z.string().min(1, "Tanggal Kembali harus diisi"),
catatan: z.string().min(1, "Catatan harus diisi"),
});
const defaultPeminjamanBuku = {
nama: "",
noTelp: "",
alamat: "",
bukuId: "",
tanggalPinjam: "",
batasKembali: "",
tanggalKembali: "",
catatan: "",
};
interface FormEditData {
nama: string;
noTelp: string;
alamat: string;
bukuId: string;
buku?: {
id: string;
judul: string;
};
tanggalPinjam: string;
batasKembali: string;
tanggalKembali: string;
catatan: string;
status: "Dipinjam" | "Dikembalikan" | "Terlambat" | "Dibatalkan";
}
const editForm: FormEditData = {
nama: "",
noTelp: "",
alamat: "",
bukuId: "",
tanggalPinjam: "",
batasKembali: "",
tanggalKembali: "",
catatan: "",
status: "Dipinjam",
};
const peminjamanBuku = proxy({
create: {
form: { ...defaultPeminjamanBuku },
loading: false,
async create() {
const cek = templatePeminjamanBuku.safeParse(peminjamanBuku.create.form);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
return toast.error(err);
}
try {
peminjamanBuku.create.loading = true;
const res =
await ApiFetch.api.pendidikan.perpustakaandigital.peminjamanbuku[
"create"
].post(peminjamanBuku.create.form);
if (res.status === 200) {
peminjamanBuku.findMany.load();
return toast.success("Data Peminjaman Buku Berhasil Dibuat");
}
console.log(res);
return toast.error("failed create");
} catch (error) {
console.log(error);
return toast.error("failed create");
} finally {
peminjamanBuku.create.loading = false;
}
},
},
findMany: {
data: [] as Prisma.PeminjamanBukuGetPayload<{
include: {
buku: true;
};
}>[],
page: 1,
totalPages: 1,
loading: false,
search: "",
load: async (page = 1, limit = 10, search = "") => {
peminjamanBuku.findMany.loading = true; // ✅ Akses langsung via nama path
peminjamanBuku.findMany.page = page;
peminjamanBuku.findMany.search = search;
try {
const query: any = { page, limit };
if (search) query.search = search;
const res =
await ApiFetch.api.pendidikan.perpustakaandigital.peminjamanbuku[
"findMany"
].get({ query });
if (res.status === 200 && res.data?.success) {
peminjamanBuku.findMany.data = res.data.data ?? [];
peminjamanBuku.findMany.totalPages = res.data.totalPages ?? 1;
} else {
peminjamanBuku.findMany.data = [];
peminjamanBuku.findMany.totalPages = 1;
}
} catch (err) {
console.error("Gagal fetch data peminjaman buku paginated:", err);
peminjamanBuku.findMany.data = [];
peminjamanBuku.findMany.totalPages = 1;
} finally {
peminjamanBuku.findMany.loading = false;
}
},
},
findUnique: {
data: null as Prisma.PeminjamanBukuGetPayload<{
include: {
buku: true;
};
}> | null,
loading: false,
async load(id: string) {
try {
const res = await fetch(
`/api/pendidikan/perpustakaandigital/peminjamanbuku/${id}`
);
if (res.ok) {
const data = await res.json();
peminjamanBuku.findUnique.data = data.data ?? null;
} else {
console.error("Failed to fetch data", res.status, res.statusText);
peminjamanBuku.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching data:", error);
peminjamanBuku.findUnique.data = null;
}
},
},
delete: {
loading: false,
async delete(id: string) {
if (!id) return toast.warn("ID tidak valid");
try {
peminjamanBuku.delete.loading = true;
const response = await fetch(
`/api/pendidikan/perpustakaandigital/peminjamanbuku/del/${id}`,
{
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
}
);
const result = await response.json();
if (response.ok && result?.success) {
toast.success(
result.message || "Data Peminjaman Buku berhasil dihapus"
);
await peminjamanBuku.findMany.load(); // refresh list
} else {
toast.error(
result?.message || "Gagal menghapus Data Peminjaman Buku"
);
}
} catch (error) {
console.error("Gagal delete:", error);
toast.error("Terjadi kesalahan saat menghapus Data Peminjaman Buku");
} finally {
peminjamanBuku.delete.loading = false;
}
},
},
update: {
id: "",
form: { ...editForm },
loading: false,
async load(id: string) {
if (!id) {
toast.warn("ID tidak valid");
return null;
}
try {
const response = await fetch(
`/api/pendidikan/perpustakaandigital/peminjamanbuku/${id}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
},
}
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result?.success) {
const data = result.data;
this.id = data.id;
this.form = {
nama: data.nama,
noTelp: data.noTelp,
alamat: data.alamat,
bukuId: data.bukuId,
tanggalPinjam: data.tanggalPinjam,
batasKembali: data.batasKembali,
tanggalKembali: data.tanggalKembali,
catatan: data.catatan,
status: data.status,
};
return data; // Return the loaded data
} else {
throw new Error(result?.message || "Gagal memuat data");
}
} catch (error) {
console.error("Error loading peminjaman buku:", error);
toast.error(
error instanceof Error ? error.message : "Gagal memuat data"
);
return null;
}
},
async update() {
const cek = templatePeminjamanBuku.safeParse(peminjamanBuku.update.form);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
toast.error(err);
return false;
}
try {
peminjamanBuku.update.loading = true;
const response = await fetch(
`/api/pendidikan/perpustakaandigital/peminjamanbuku/${this.id}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
nama: this.form.nama,
noTelp: this.form.noTelp,
alamat: this.form.alamat,
bukuId: this.form.bukuId,
tanggalPinjam: this.form.tanggalPinjam,
batasKembali: this.form.batasKembali,
tanggalKembali: this.form.tanggalKembali,
catatan: this.form.catatan,
status: this.form.status,
}),
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.message || `HTTP error! status: ${response.status}`
);
}
const result = await response.json();
if (result.success) {
toast.success("Berhasil update data peminjaman buku");
await peminjamanBuku.findMany.load(); // refresh list
return true;
} else {
throw new Error(
result.message || "Gagal update data peminjaman buku"
);
}
} catch (error) {
console.error("Error updating data peminjaman buku:", error);
toast.error(
error instanceof Error
? error.message
: "Terjadi kesalahan saat update data peminjaman buku"
);
return false;
} finally {
peminjamanBuku.update.loading = false;
}
},
reset() {
peminjamanBuku.update.id = "";
peminjamanBuku.update.form = { ...editForm };
},
},
});
const perpustakaanDigitalState = proxy({ const perpustakaanDigitalState = proxy({
dataPerpustakaan, dataPerpustakaan,
kategoriBuku, kategoriBuku,
peminjamanBuku,
}); });
export default perpustakaanDigitalState; export default perpustakaanDigitalState;

View File

@@ -38,7 +38,7 @@ const daftarInformasiPublik = proxy({
].post(daftarInformasiPublik.create.form); ].post(daftarInformasiPublik.create.form);
if (res.status === 200) { if (res.status === 200) {
daftarInformasiPublik.findMany.load(); daftarInformasiPublik.findMany.load();
return toast.success("success create"); return toast.success("Sukses menambahkan");
} }
return toast.error("failed create"); return toast.error("failed create");
} catch (error) { } catch (error) {

View File

@@ -41,7 +41,7 @@ const grafikBerdasarkanUmur = proxy({
if (res.status === 200) { if (res.status === 200) {
const id = res.data?.data?.id; const id = res.data?.data?.id;
if (id) { if (id) {
toast.success("Success create"); toast.success("Sukses menambahkan");
grafikBerdasarkanUmur.create.form = { grafikBerdasarkanUmur.create.form = {
remaja: "", remaja: "",
dewasa: "", dewasa: "",

View File

@@ -88,7 +88,7 @@ const statepermohonanInformasiPublik = proxy({
const res = await ApiFetch.api.ppid.permohonaninformasipublik["create"].post(statepermohonanInformasiPublik.create.form); const res = await ApiFetch.api.ppid.permohonaninformasipublik["create"].post(statepermohonanInformasiPublik.create.form);
if (res.status === 200) { if (res.status === 200) {
statepermohonanInformasiPublik.findMany.load(); statepermohonanInformasiPublik.findMany.load();
return toast.success("success create"); return toast.success("Sukses menambahkan");
} }
return toast.error("failed create"); return toast.error("failed create");
} catch (error) { } catch (error) {
@@ -112,7 +112,32 @@ const statepermohonanInformasiPublik = proxy({
statepermohonanInformasiPublik.findMany.data = res.data?.data ?? []; statepermohonanInformasiPublik.findMany.data = res.data?.data ?? [];
} }
} }
},
findUnique: {
data: null as Prisma.PermohonanInformasiPublikGetPayload<{
include: {
jenisInformasiDiminta: true,
caraMemperolehInformasi: true,
caraMemperolehSalinanInformasi: true,
};
}> | null,
async load(id: string) {
try {
const res = await fetch(`/api/ppid/permohonaninformasipublik/${id}`);
if (res.ok) {
const data = await res.json();
statepermohonanInformasiPublik.findUnique.data = data.data ?? null;
} else {
console.error("Failed to fetch program inovasi:", res.statusText);
statepermohonanInformasiPublik.findUnique.data = null;
} }
} catch (error) {
console.error("Error fetching program inovasi:", error);
statepermohonanInformasiPublik.findUnique.data = null;
}
},
},
}) })
const statepermohonanInformasiPublikForm = proxy({ const statepermohonanInformasiPublikForm = proxy({

View File

@@ -37,7 +37,7 @@ const permohonanKeberatanInformasi = proxy({
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik["create"].post(permohonanKeberatanInformasi.create.form); const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik["create"].post(permohonanKeberatanInformasi.create.form);
if (res.status === 200) { if (res.status === 200) {
permohonanKeberatanInformasi.findMany.load(); permohonanKeberatanInformasi.findMany.load();
return toast.success("success create"); return toast.success("Sukses menambahkan");
} }
return toast.error("failed create"); return toast.error("failed create");
} catch (error) { } catch (error) {
@@ -57,6 +57,28 @@ const permohonanKeberatanInformasi = proxy({
permohonanKeberatanInformasi.findMany.data = res.data?.data ?? []; permohonanKeberatanInformasi.findMany.data = res.data?.data ?? [];
} }
} }
},
findUnique: {
data: null as Prisma.FormulirPermohonanKeberatanGetPayload<{
omit: {
isActive: true;
};
}> | null,
async load(id: string) {
try {
const res = await fetch(`/api/ppid/permohonankeberataninformasipublik/${id}`);
if (res.ok) {
const data = await res.json();
permohonanKeberatanInformasi.findUnique.data = data.data ?? null;
} else {
console.error("Failed to fetch permohonan keberatan informasi:", res.statusText);
permohonanKeberatanInformasi.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching permohonan keberatan informasi:", error);
permohonanKeberatanInformasi.findUnique.data = null;
}
},
} }
}); });

View File

@@ -3,9 +3,6 @@ import { toast } from "react-toastify";
import { proxy } from "valtio"; import { proxy } from "valtio";
import { z } from "zod"; import { z } from "zod";
/**
* Schema validasi form ProfilePPID menggunakan Zod.
*/
const templateForm = z.object({ const templateForm = z.object({
name: z.string().min(3, "Nama minimal 3 karakter"), name: z.string().min(3, "Nama minimal 3 karakter"),
biodata: z.string().min(3, "Biodata minimal 3 karakter"), biodata: z.string().min(3, "Biodata minimal 3 karakter"),
@@ -33,25 +30,16 @@ type ProfilePPIDForm = Prisma.ProfilePPIDGetPayload<{
pengalaman: true; pengalaman: true;
unggulan: true; unggulan: true;
imageId: true; imageId: true;
image?: { image?: { select: { link: true } };
select: {
link: true;
};
};
}; };
}>; }>;
/**
* Improved State Management - Consolidated and more robust
*/
const stateProfilePPID = proxy({ const stateProfilePPID = proxy({
// Consolidated data management
profile: { profile: {
data: null as ProfilePPIDForm | null, data: null as ProfilePPIDForm | null,
loading: false, loading: false,
error: null as string | null, error: null as string | null,
// Single method to load profile data
async load(id: string) { async load(id: string) {
if (!id) { if (!id) {
toast.warn("ID tidak valid"); toast.warn("ID tidak valid");
@@ -62,52 +50,42 @@ const stateProfilePPID = proxy({
this.error = null; this.error = null;
try { try {
const response = await fetch(`/api/ppid/profileppid/${id}`); const res = await fetch(`/api/ppid/profileppid/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
const result = await res.json();
if (result.success) { if (result.success) {
this.data = result.data; this.data = result.data;
return result.data; return result.data;
} else { } else throw new Error(result.message || "Gagal memuat data profile");
throw new Error(result.message || "Gagal mengambil data profile"); } catch (err) {
} const msg = (err as Error).message;
} catch (error) { this.error = msg;
const errorMessage = (error as Error).message; console.error("Load profile error:", msg);
this.error = errorMessage; toast.error("Gagal memuat data profile");
console.error("Load profile error:", errorMessage);
toast.error("Terjadi kesalahan saat mengambil data profile");
return null; return null;
} finally { } finally {
this.loading = false; this.loading = false;
} }
}, },
// Reset profile data
reset() { reset() {
this.data = null; this.data = null;
this.error = null; this.error = null;
this.loading = false; this.loading = false;
} },
}, },
// Edit form management
editForm: { editForm: {
id: "", id: "",
form: { ...defaultForm }, form: { ...defaultForm },
originalForm: { ...defaultForm }, // ✅ Tambah field originalForm
loading: false, loading: false,
error: null as string | null, error: null as string | null,
isReadOnly: false, // Flag untuk data yang tidak bisa diedit
// Initialize form with profile data
initialize(profileData: ProfilePPIDForm) { initialize(profileData: ProfilePPIDForm) {
this.id = profileData.id; this.id = profileData.id;
this.isReadOnly = false; // Semua data bisa diedit const data = {
this.form = {
name: profileData.name || "", name: profileData.name || "",
biodata: profileData.biodata || "", biodata: profileData.biodata || "",
riwayat: profileData.riwayat || "", riwayat: profileData.riwayat || "",
@@ -115,23 +93,20 @@ const stateProfilePPID = proxy({
unggulan: profileData.unggulan || "", unggulan: profileData.unggulan || "",
imageId: profileData.imageId || "", imageId: profileData.imageId || "",
}; };
this.form = { ...data };
this.originalForm = { ...data }; // ✅ Simpan versi original
}, },
// Update form field
updateField(field: keyof typeof defaultForm, value: string) { updateField(field: keyof typeof defaultForm, value: string) {
this.form[field] = value; this.form[field] = value;
}, },
// Submit form
async submit() { async submit() {
// Validate form const check = templateForm.safeParse(this.form);
const validation = templateForm.safeParse(this.form); if (!check.success) {
toast.error(
if (!validation.success) { check.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(", ")
const errors = validation.error.issues );
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
.join(", ");
toast.error(`Form tidak valid: ${errors}`);
return false; return false;
} }
@@ -139,63 +114,54 @@ const stateProfilePPID = proxy({
this.error = null; this.error = null;
try { try {
const response = await fetch(`/api/ppid/profileppid/${this.id}`, { const res = await fetch(`/api/ppid/profileppid/${this.id}`, {
method: "PUT", method: "PUT",
headers: { headers: { "Content-Type": "application/json" },
"Content-Type": "application/json",
},
body: JSON.stringify(this.form), body: JSON.stringify(this.form),
}); });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (!response.ok) { const result = await res.json();
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result.success) { if (result.success) {
toast.success("Berhasil update profile"); toast.success("Berhasil update profile");
// Refresh profile data this.originalForm = { ...this.form }; // ✅ Update original setelah sukses
await stateProfilePPID.profile.load(this.id);
return true; return true;
} else { } else throw new Error(result.message || "Gagal update profile");
throw new Error(result.message || "Gagal update profile"); } catch (err) {
} const msg = (err as Error).message;
} catch (error) { this.error = msg;
const errorMessage = (error as Error).message; toast.error(msg);
this.error = errorMessage;
console.error("Update profile error:", errorMessage);
toast.error("Terjadi kesalahan saat update profile");
return false; return false;
} finally { } finally {
this.loading = false; this.loading = false;
} }
}, },
// Reset form // ✅ Tambahan reset ke original data
resetToOriginal() {
this.form = { ...this.originalForm };
toast.info("Data dikembalikan ke kondisi awal");
},
reset() { reset() {
this.id = ""; this.id = "";
this.form = { ...defaultForm }; this.form = { ...defaultForm };
this.originalForm = { ...defaultForm };
this.error = null; this.error = null;
this.loading = false; this.loading = false;
this.isReadOnly = false; },
}
}, },
// Helper methods
async loadForEdit(id: string) { async loadForEdit(id: string) {
const profileData = await this.profile.load(id); const data = await this.profile.load(id);
if (profileData) { if (data) this.editForm.initialize(data);
this.editForm.initialize(profileData); return data;
}
return profileData;
}, },
reset() { reset() {
this.profile.reset(); this.profile.reset();
this.editForm.reset(); this.editForm.reset();
} },
}); });
export default stateProfilePPID; export default stateProfilePPID;

View File

@@ -381,7 +381,44 @@ const posisiOrganisasi = proxy({
} }
}, },
}, },
findManyAll: {
data: [] as Array<{
id: string;
nama: string;
deskripsi: string | null;
hierarki: number;
}>,
loading: false,
search: "",
load: async (search = "") => {
// Change to arrow function
posisiOrganisasi.findManyAll.loading = true; // Use the full path to access the property
posisiOrganisasi.findManyAll.search = search;
try {
const query: any = { search };
if (search) query.search = search;
const res = await ApiFetch.api.ppid.strukturppid.posisiorganisasi[
"find-many-all"
].get({
query,
});
if (res.status === 200 && res.data?.success) {
posisiOrganisasi.findManyAll.data = res.data.data || [];
} else {
console.error("Failed to load posisiOrganisasi:", res.data?.message);
posisiOrganisasi.findManyAll.data = [];
}
} catch (error) {
console.error("Error loading posisiOrganisasi:", error);
posisiOrganisasi.findManyAll.data = [];
} finally {
posisiOrganisasi.findManyAll.loading = false;
}
},
},
delete: { delete: {
loading: false, loading: false,
async byId(id: string) { async byId(id: string) {
@@ -524,9 +561,48 @@ const pegawai = proxy({
} }
}, },
}, },
findManyAll: {
data: null as
| Prisma.PegawaiPPIDGetPayload<{
include: {
image: true;
posisi: true;
};
}>[]
| null,
loading: false,
search: "",
load: async (search = "") => {
// Change to arrow function
pegawai.findManyAll.loading = true; // Use the full path to access the property
pegawai.findManyAll.search = search;
try {
const query: any = { search };
if (search) query.search = search;
const res = await ApiFetch.api.ppid.strukturppid.pegawai[
"find-many-all"
].get({
query,
});
if (res.status === 200 && res.data?.success) {
pegawai.findManyAll.data = res.data.data || [];
} else {
console.error("Failed to load pegawai:", res.data?.message);
pegawai.findManyAll.data = [];
}
} catch (error) {
console.error("Error loading pegawai:", error);
pegawai.findManyAll.data = [];
} finally {
pegawai.findManyAll.loading = false;
}
},
},
findUnique: { findUnique: {
data: null as data: null as
| (Prisma.PegawaiGetPayload<{ | (Prisma.PegawaiPPIDGetPayload<{
include: { posisi: true; image: true }; include: { posisi: true; image: true };
}> & { isActive: boolean }) }> & { isActive: boolean })
| null, | null,
@@ -571,6 +647,31 @@ const pegawai = proxy({
}, },
}, },
nonActive: {
loading: false,
async byId(id: string) {
if (!id) return toast.warn("ID tidak valid");
try {
pegawai.nonActive.loading = true;
const res = await fetch(`/api/ppid/strukturppid/pegawai/non-active/${id}`, {
method: "DELETE", // biasanya nonActive pakai PATCH
});
const json = await res.json();
if (res.ok) {
toast.success(json.message ?? "Pegawai berhasil dinonaktifkan");
await pegawai.findMany.load(); // refresh data
} else {
toast.error(json.message ?? "Gagal menonaktifkan pegawai");
}
} catch (error) {
console.error("Gagal nonActive:", error);
toast.error("Terjadi kesalahan saat menonaktifkan pegawai");
} finally {
pegawai.nonActive.loading = false;
}
},
},
edit: { edit: {
id: "", id: "",
form: { ...pegawaiDefaultForm }, form: { ...pegawaiDefaultForm },

View File

@@ -90,27 +90,83 @@ const userState = proxy({
} }
}, },
}, },
updateActive: { deleteUser: {
loading: false, loading: false,
async submit(id: string, isActive: boolean) {
async delete(id: string) {
if (!id) return toast.warn("ID tidak valid");
try {
userState.deleteUser.loading = true;
const response = await fetch(`/api/user/delUser/${id}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
});
const result = await response.json();
if (response.ok && result?.success) {
toast.success(result.message || "User berhasil dihapus permanen");
await userState.findMany.load(); // refresh list user setelah delete
} else {
toast.error(result?.message || "Gagal menghapus user");
}
} catch (error) {
console.error("Gagal delete user:", error);
toast.error("Terjadi kesalahan saat menghapus user");
} finally {
userState.deleteUser.loading = false;
}
},
},
// Di file userState.ts atau dimana state user berada
update: {
loading: false,
async submit(payload: { id: string; isActive?: boolean; roleId?: string }) {
this.loading = true; this.loading = true;
try { try {
const res = await fetch(`/api/user/updt`, { const res = await fetch(`/api/user/updt`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, isActive }), body: JSON.stringify(payload),
}); });
const data = await res.json(); const data = await res.json();
if (res.status === 200 && data.success) { if (res.status === 200 && data.success) {
toast.success(data.message); // ✅ Tampilkan pesan yang berbeda jika role berubah
userState.findMany.load(userState.findMany.page, 10, userState.findMany.search); if (data.roleChanged) {
toast.success(
`${data.message}\n\nUser akan logout otomatis dalam beberapa detik.`,
{
autoClose: 5000,
}
);
} else { } else {
toast.error(data.message || "Gagal update status user"); toast.success(data.message);
}
// Refresh list
await userState.findMany.load(
userState.findMany.page,
10,
userState.findMany.search
);
return true; // ✅ Return success untuk handling di component
} else {
toast.error(data.message || "Gagal update user");
return false;
} }
} catch (e) { } catch (e) {
console.error(e); console.error("❌ Error update user:", e);
toast.error("Gagal update status user"); toast.error("Gagal update user");
return false;
} finally { } finally {
this.loading = false; this.loading = false;
} }
@@ -120,12 +176,10 @@ const userState = proxy({
const templateRole = z.object({ const templateRole = z.object({
name: z.string().min(1, "Nama harus diisi"), name: z.string().min(1, "Nama harus diisi"),
permissions: z.array(z.string()).min(1, "Permission harus diisi"),
}); });
const defaultRole = { const defaultRole = {
name: "", name: "",
permissions: [] as string[],
}; };
const roleState = proxy({ const roleState = proxy({
@@ -245,29 +299,23 @@ const roleState = proxy({
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
}); });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json(); const result = await response.json();
if (result?.success) { if (result?.success) {
const data = result.data; const data = result.data;
this.id = data.id;
this.form = { // langsung set melalui root state, bukan this
roleState.update.id = data.id;
roleState.update.form = {
name: data.name, name: data.name,
permissions: data.permissions,
}; };
return data; // Return the loaded data
} else { return data;
throw new Error(result?.message || "Gagal memuat data");
} }
} catch (error) { } catch (error) {
console.error("Error loading role:", error); console.error("Error loading role:", error);
toast.error( toast.error("Gagal memuat data");
error instanceof Error ? error.message : "Gagal memuat data"
);
return null;
} }
}, },
async update() { async update() {
@@ -290,7 +338,6 @@ const roleState = proxy({
}, },
body: JSON.stringify({ body: JSON.stringify({
name: this.form.name, name: this.form.name,
permissions: this.form.permissions,
}), }),
}); });

View File

@@ -1,104 +1,103 @@
'use client' 'use client';
import { apiFetchLogin } from '@/app/admin/auth/_lib/api_fetch_auth'; import { apiFetchLogin } from '@/app/api/auth/_lib/api_fetch_auth';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Flex, Image, Paper, Stack, Text, Title } from '@mantine/core'; import { Box, Button, Center, Image, Paper, Stack, Title } from '@mantine/core';
import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { PhoneInput } from "react-international-phone"; import { PhoneInput } from 'react-international-phone';
import "react-international-phone/style.css"; import 'react-international-phone/style.css';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
function Login() { function Login() {
const router = useRouter() const router = useRouter();
const [phone, setPhone] = useState("") const [phone, setPhone] = useState('');
const [isError, setError] = useState(false) const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(false)
// Login.tsx
async function onLogin() { async function onLogin() {
const nomor = phone.substring(1);
if (nomor.length <= 4) return setError(true)
const cleanPhone = phone.replace(/\D/g, '');
console.log(cleanPhone);
if (cleanPhone.length < 10) {
toast.error('Nomor telepon tidak valid');
return;
}
try { try {
setLoading(true); setLoading(true);
const response = await apiFetchLogin({ nomor: nomor }) const response = await apiFetchLogin({ nomor: cleanPhone });
if (response && response.success) {
localStorage.setItem("hipmi_auth_code_id", response.kodeId); console.log(response);
toast.success(response.message);
router.push("/validasi", { scroll: false }); if (!response.success) {
toast.error(response.message || 'Gagal memproses login');
return;
}
// Simpan nomor untuk register
localStorage.setItem('auth_nomor', cleanPhone);
if (response.isRegistered) {
// ✅ User lama: simpan kodeId
localStorage.setItem('auth_kodeId', response.kodeId);
// ✅ Cookie sudah di-set oleh API, langsung redirect
router.push('/validasi'); // Clean URL
} else { } else {
setLoading(false); // ❌ User baru: langsung ke registrasi (tanpa kodeId)
toast.error(response?.message); router.push('/registrasi');
} }
} catch (error) { } catch (error) {
setLoading(false) console.error('Error Login:', error);
console.log("Error Login", error) toast.error('Terjadi kesalahan saat login');
toast.error("Terjadi kesalahan saat login") } finally {
setLoading(false);
} }
} }
return ( return (
<Stack pos={"relative"} bg={colors.Bg}> <Stack pos="relative" bg={colors.Bg}>
<Box px={{ base: 'md', md: 100 }} pb={50}> <Box px={{ base: 'md', md: 100 }} pb={50}>
<Stack align='center' justify='center' h={"100vh"}> <Stack align="center" justify="center" h="100vh">
<Paper p={'xl'} radius={'md'} bg={colors['white-trans-1']}> <Paper p="xl" radius="md" bg={colors['white-trans-1']} w={{ base: '100%', sm: 400 }}>
<Stack align='center' gap={"lg"}> <Stack align="center" gap="lg">
<Box> <Box>
<Title ta={"center"} order={2} fw={'bold'} c={colors['blue-button']}> <Title ta="center" order={2} fw="bold" c={colors['blue-button']}>
Login Login
</Title> </Title>
<Center> <Center>
<Image loading="lazy" src={"/darmasaba-icon.png"} alt="" w={80} /> <Image
loading="lazy"
src="/darmasaba-icon.png"
alt="Logo"
w={80}
h={80}
/>
</Center> </Center>
</Box> </Box>
<Box> <Box w="100%">
{/* <Box mb={10}>
<Text c={colors['blue-button']} ta={"center"} fz={"sm"} fw={'bold'}>Masuk Untuk Akses Admin</Text>
<TextInput
label='Username'
placeholder='Username'
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
</Box> */}
<PhoneInput <PhoneInput
countrySelectorStyleProps={{ countrySelectorStyleProps={{
buttonStyle: { buttonStyle: {
backgroundColor: colors['blue-button'], backgroundColor: colors['blue-button'],
}, },
}} }}
inputStyle={{ width: "100%"}} inputStyle={{ width: '100%' }}
defaultCountry="id" defaultCountry="id"
onChange={(val) => { value={phone}
setPhone(val); onChange={(val) => setPhone(val)}
}}
/> />
{isError ? (
toast.error("Masukan nomor telepon anda")
) : (
""
)}
<Box py={20}> <Box py={20}>
<Button <Button
fullWidth fullWidth
bg={colors['blue-button']} bg={colors['blue-button']}
radius={'xl'} radius="xl"
onClick={onLogin} onClick={onLogin}
loading={loading ? true : false} loading={loading}
>Masuk >
Masuk
</Button> </Button>
</Box> </Box>
<Flex justify={'center'} align={'center'}>
<Text>Belum punya akun? </Text>
<Button variant='transparent' component={Link} href={'/registrasi'}>
<Text c={colors['blue-button']} fw={'bold'}>Registrasi</Text>
</Button>
</Flex>
</Box> </Box>
</Stack> </Stack>
</Paper> </Paper>

View File

@@ -1,113 +1,127 @@
/* eslint-disable @typescript-eslint/no-unused-expressions */ // app/registrasi/page.tsx
'use client' 'use client';
import { apiFetchRegister } from '@/app/admin/auth/_lib/api_fetch_auth';
import { apiFetchRegister } from '@/app/api/auth/_lib/api_fetch_auth';
import BackButton from '@/app/darmasaba/(pages)/desa/layanan/_com/BackButto'; import BackButton from '@/app/darmasaba/(pages)/desa/layanan/_com/BackButto';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Checkbox, Image, Paper, Stack, Text, TextInput, Title } from '@mantine/core'; import {
Box, Button, Center, Checkbox, Image, Paper, Stack, Text, TextInput, Title,
} from '@mantine/core';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { PhoneInput } from "react-international-phone"; import { PhoneInput } from 'react-international-phone';
import "react-international-phone/style.css"; import 'react-international-phone/style.css';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
function Registrasi() { export default function Registrasi() {
const [phone, setPhone] = useState("") const router = useRouter();
const router = useRouter() const [username, setUsername] = useState('');
const [value, setValue] = useState("")
const [isValue, setIsValue] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [phone, setPhone] = useState(''); // ✅ tambahkan state untuk phone
async function onRegistarsi() { // Ambil data dari localStorage (dari login)
if (value.length < 5) { useEffect(() => {
toast.error("Username minimal 5 karakter!"); const storedNomor = localStorage.getItem('auth_nomor');
if (!storedNomor) {
toast.error('Akses tidak valid');
router.push('/login');
return;
}
setPhone(storedNomor);
}, [router]);
const handleRegister = async () => {
if (!username || username.trim().length < 5) {
toast.error('Username minimal 5 karakter!');
return;
}
if (username.includes(' ')) {
toast.error('Username tidak boleh ada spasi!');
return; return;
} }
if (value.includes(" ")) { const cleanPhone = phone.replace(/\D/g, '');
toast.error("Username tidak boleh ada spasi!"); if (cleanPhone.length < 10) {
return; toast.error('Nomor tidak valid!');
}
if (!phone) {
toast.error("Nomor telepon wajib diisi!");
return; return;
} }
try { try {
setLoading(true); setLoading(true);
const respone = await apiFetchRegister({ nomor: phone, username: value }); // ✅ Hanya kirim username & nomor → dapat kodeId
const response = await apiFetchRegister({ username, nomor: cleanPhone });
if (respone.success) { if (response.success) {
router.push("/login", { scroll: false }); // Simpan sementara
toast.success(respone.message); localStorage.setItem('auth_kodeId', response.kodeId);
localStorage.setItem('auth_username', username); // simpan username
} else { toast.success('Kode verifikasi dikirim!');
setLoading(false); router.push('/validasi'); // ✅ ke halaman validasi
toast.error(respone.message);
} }
} catch (error) { } catch (error) {
console.error('Error Registrasi:', error);
toast.error('Gagal mengirim OTP');
} finally {
setLoading(false); setLoading(false);
console.log("Error Registrasi", error);
}
} }
};
return ( return (
<Stack pos={"relative"} bg={colors.Bg} gap={"22"} py={"xl"} h={"100vh"}> <Stack pos="relative" bg={colors.Bg} gap="22" py="xl" h="100vh">
<Box px={{ base: 'md', md: 100 }}> <Box px={{ base: 'md', md: 100 }}>
<BackButton /> <BackButton />
</Box> </Box>
<Box px={{ base: 'md', md: 100 }}> <Box px={{ base: 'md', md: 100 }}>
<Stack justify='center' align='center' h={"80vh"}> <Stack justify="center" align="center" h="80vh">
<Paper p={'xl'} radius={'md'} bg={colors['white-trans-1']}> <Paper p="xl" radius="md" bg={colors['white-trans-1']} w={{ base: '100%', sm: 400 }}>
<Stack align='center'> <Stack align="center">
<Title order={2} fw={'bold'} c={colors['blue-button']}> <Title order={2} fw="bold" c={colors['blue-button']}>
Registrasi Registrasi
</Title> </Title>
<Center> <Center>
<Image loading="lazy" src={"/darmasaba-icon.png"} alt="" w={80} /> <Image loading="lazy" src="/darmasaba-icon.png" alt="" w={80} />
</Center> </Center>
<Box> <Box w="100%">
<TextInput placeholder='Username' <TextInput
label='Username' label="Username"
maxLength={50} placeholder="Username"
value={username}
onChange={(e) => setUsername(e.currentTarget.value)}
error={ error={
value.length > 0 && value.length < 5 username.length > 0 && username.length < 5
? "Minimal 5 karakter !" ? 'Minimal 5 karakter!'
: value.includes(" ") : username.includes(' ')
? "Tidak boleh ada spasi" ? 'Tidak boleh ada spasi'
: isValue : ''
? "Masukan username anda"
: ""
} }
onChange={(val) => {
val.currentTarget.value.length > 0 ? setIsValue(false) : "";
setValue(val.currentTarget.value);
}}
required required
/>
/> <Box pt="md">
<Box py={10}> <Text fz="sm">Nomor Telepon</Text>
<Text fz={"sm"} >Nomor Telepon</Text>
<PhoneInput <PhoneInput
countrySelectorStyleProps={{
buttonStyle: {
backgroundColor: colors['blue-button'],
},
}}
inputStyle={{ width: "100%" }}
defaultCountry="id" defaultCountry="id"
onChange={(val) => { value={phone}
setPhone(val); disabled
}}
/> />
</Box> </Box>
<Box pb={10}>
<Checkbox <Box pt="md">
label="Saya menyetujui syarat dan ketentuan yang berlaku" <Checkbox label="Saya menyetujui syarat dan ketentuan" defaultChecked />
/>
</Box> </Box>
<Box pb={20} >
<Button fullWidth bg={colors['blue-button']} radius={'xl'} onClick={onRegistarsi} loading={loading ? true : false}>Daftar</Button> <Box pt="xl">
<Button
fullWidth
bg={colors['blue-button']}
radius="xl"
onClick={handleRegister}
loading={loading}
disabled={username.length < 5}
>
Kirim Kode Verifikasi
</Button>
</Box> </Box>
</Box> </Box>
</Stack> </Stack>
@@ -117,5 +131,3 @@ function Registrasi() {
</Stack> </Stack>
); );
} }
export default Registrasi;

View File

@@ -1,31 +1,306 @@
'use client' 'use client';
import colors from '@/con/colors';
import { Box, Button, Paper, PinInput, Stack, Text, Title } from '@mantine/core';
import { useRouter } from 'next/navigation';
function Validasi() { import colors from '@/con/colors';
const router = useRouter() import {
Box,
Button,
Center,
Loader,
Paper,
PinInput,
Stack,
Text,
Title,
} from '@mantine/core';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { authStore } from '@/store/authStore';
export default function Validasi() {
const router = useRouter();
const [nomor, setNomor] = useState<string | null>(null);
const [otp, setOtp] = useState('');
const [loading, setLoading] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [kodeId, setKodeId] = useState<string | null>(null);
const [isRegistrationFlow, setIsRegistrationFlow] = useState(false);
// ✅ Deteksi flow dari cookie via API
useEffect(() => {
const checkFlow = async () => {
try {
const res = await fetch('/api/auth/get-flow', {
credentials: 'include'
});
const data = await res.json();
if (data.success) {
setIsRegistrationFlow(data.flow === 'register');
console.log('🔍 Flow detected from cookie:', data.flow);
}
} catch (error) {
console.error('❌ Error getting flow:', error);
setIsRegistrationFlow(false);
}
};
checkFlow();
}, []);
useEffect(() => {
const storedKodeId = localStorage.getItem('auth_kodeId');
if (!storedKodeId) {
toast.error('Akses tidak valid');
router.replace('/login');
return;
}
setKodeId(storedKodeId);
const loadOtpData = async () => {
try {
const res = await fetch(`/api/auth/otp-data?kodeId=${encodeURIComponent(storedKodeId)}`);
const result = await res.json();
if (res.ok && result.data?.nomor) {
setNomor(result.data.nomor);
} else {
throw new Error('Data OTP tidak valid');
}
} catch (error) {
console.error('Gagal memuat data OTP:', error);
toast.error('Kode verifikasi tidak valid');
router.replace('/login');
} finally {
setIsLoading(false);
}
};
loadOtpData();
}, [router]);
const handleVerify = async () => {
if (!kodeId || !nomor || otp.length < 4) return;
setLoading(true);
try {
if (isRegistrationFlow) {
await handleRegistrationVerification();
} else {
await handleLoginVerification();
}
} catch (error) {
console.error('Error saat verifikasi:', error);
toast.error('Terjadi kesalahan sistem');
} finally {
setLoading(false);
}
};
const handleRegistrationVerification = async () => {
const username = localStorage.getItem('auth_username');
if (!username) {
toast.error('Data registrasi tidak ditemukan.');
return;
}
const cleanNomor = nomor?.replace(/\D/g, '') ?? '';
if (cleanNomor.length < 10 || username.trim().length < 5) {
toast.error('Data tidak valid');
return;
}
// ✅ Verify OTP
const verifyRes = await fetch('/api/auth/verify-otp-register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor: cleanNomor, otp, kodeId }),
credentials: 'include'
});
const verifyData = await verifyRes.json();
if (!verifyRes.ok) {
toast.error(verifyData.message || 'Verifikasi OTP gagal');
return;
}
// ✅ Finalize registration
const finalizeRes = await fetch('/api/auth/finalize-registration', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor: cleanNomor, username, kodeId }),
credentials: 'include'
});
const data = await finalizeRes.json();
// ✅ Check JSON response (bukan redirect)
if (data.success) {
toast.success('Registrasi berhasil! Menunggu persetujuan admin.');
await cleanupStorage();
// ✅ Client-side redirect
setTimeout(() => {
window.location.href = '/waiting-room';
}, 1000);
} else {
toast.error(data.message || 'Registrasi gagal');
}
};
const handleLoginVerification = async () => {
const loginRes = await fetch('/api/auth/verify-otp-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor, otp, kodeId }),
credentials: 'include'
});
const loginData = await loginRes.json();
if (!loginRes.ok) {
toast.error(loginData.message || 'Verifikasi gagal');
return;
}
const { id, name, roleId, isActive } = loginData.user;
authStore.setUser({
id,
name: name || 'User',
roleId: Number(roleId),
});
// ✅ Cleanup setelah login sukses
await cleanupStorage();
if (!isActive) {
window.location.href = '/waiting-room';
return;
}
const redirectPath = getRedirectPath(Number(roleId));
router.replace(redirectPath);
};
const getRedirectPath = (roleId: number): string => {
switch (roleId) {
case 0:
case 1:
case 2:
return '/admin/landing-page/profil/program-inovasi';
case 3:
return '/admin/kesehatan/posyandu';
case 4:
return '/admin/pendidikan/info-sekolah/jenjang-pendidikan';
default:
return '/admin';
}
};
// ✅ CLEANUP FUNCTION - Hapus localStorage + Cookie
const cleanupStorage = async () => {
// Clear localStorage
localStorage.removeItem('auth_kodeId');
localStorage.removeItem('auth_nomor');
localStorage.removeItem('auth_username');
// Clear cookie
try {
await fetch('/api/auth/clear-flow', {
method: 'POST',
credentials: 'include'
});
} catch (error) {
console.error('Error clearing flow cookie:', error);
}
};
const handleResend = async () => {
if (!nomor) return;
try {
const res = await fetch('/api/auth/resend', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor }),
});
const data = await res.json();
if (data.success) {
localStorage.setItem('auth_kodeId', data.kodeId);
toast.success('OTP baru dikirim');
} else {
toast.error(data.message || 'Gagal mengirim ulang OTP');
}
} catch {
toast.error('Gagal menghubungi server');
}
};
if (isLoading) {
return ( return (
<Stack pos={"relative"} bg={colors.Bg}> <Stack pos="relative" bg={colors.Bg} align="center" justify="center" h="100vh">
<Loader size="md" color={colors['blue-button']} />
</Stack>
);
}
if (!nomor) return null;
return (
<Stack pos="relative" bg={colors.Bg}>
<Box px={{ base: 'md', md: 100 }} pb={50}> <Box px={{ base: 'md', md: 100 }} pb={50}>
<Stack align='center' justify='center' h={"100vh"}> <Stack align="center" justify="center" h="100vh">
<Paper p={'xl'} radius={'md'} bg={colors['white-trans-1']}> <Paper p="xl" radius="md" bg={colors['white-trans-1']} w={{ base: '100%', sm: 400 }}>
<Stack align='center' gap={"lg"}> <Stack align="center" gap="lg">
<Box> <Box>
<Title ta={"center"} order={2} fw={'bold'} c={colors['blue-button']}> <Title ta="center" order={2} fw="bold" c={colors['blue-button']}>
Kode Verifikasi {isRegistrationFlow ? 'Verifikasi Registrasi' : 'Verifikasi Login'}
</Title> </Title>
<Text ta="center" size="sm" c="dimmed" mt="xs">
Kami telah mengirim kode ke nomor <strong>{nomor}</strong>
</Text>
</Box> </Box>
<Box> <Box w="100%">
<Box mb={10}> <Box mb={20}>
<Text c={colors['blue-button']} ta={"center"} fz={"sm"} fw={'bold'}>Masukkan Kode Verifikasi</Text> <Text c={colors['blue-button']} ta="center" fz="sm" fw="bold">
<PinInput type={/^[0-9]*$/} inputType="tel" inputMode="numeric" /> Masukkan Kode Verifikasi
</Text>
<Center>
<PinInput
length={4}
value={otp}
onChange={setOtp}
onComplete={handleVerify}
inputMode="numeric"
size="lg"
/>
</Center>
</Box> </Box>
<Box py={20} >
<Button onClick={() => router.push("/admin/landing-page/profile/program-inovasi")}> <Button
Page fullWidth
onClick={handleVerify}
loading={loading}
disabled={otp.length < 4}
bg={colors['blue-button']}
radius="xl"
>
Verifikasi
</Button> </Button>
</Box>
<Text ta="center" size="sm" mt="md">
Tidak menerima kode?{' '}
<Button
variant="subtle"
onClick={handleResend}
size="xs"
p={0}
h="auto"
color={colors['blue-button']}
>
Kirim Ulang
</Button>
</Text>
</Box> </Box>
</Stack> </Stack>
</Paper> </Paper>
@@ -34,5 +309,3 @@ function Validasi() {
</Stack> </Stack>
); );
} }
export default Validasi;

View File

@@ -1,10 +1,10 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title, Tooltip } from '@mantine/core'; import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core';
import { IconBuildingStore, IconFileText, IconSparkles, IconUsers, IconUsersPlus } from '@tabler/icons-react';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { IconFileText, IconBuildingStore, IconSparkles, IconUsers, IconUsersPlus } from '@tabler/icons-react';
function LayoutTabsLayanan({ children }: { children: React.ReactNode }) { function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
const router = useRouter() const router = useRouter()
@@ -14,36 +14,31 @@ function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
label: "Pelayanan Surat Keterangan", label: "Pelayanan Surat Keterangan",
value: "pelayanansuratketerangan", value: "pelayanansuratketerangan",
href: "/admin/desa/layanan/pelayanan_surat_keterangan", href: "/admin/desa/layanan/pelayanan_surat_keterangan",
icon: <IconFileText size={18} stroke={1.8} />, icon: <IconFileText size={18} stroke={1.8} />
tooltip: "Layanan terkait surat keterangan resmi desa"
}, },
{ {
label: "Pelayanan Perizinan Berusaha", label: "Pelayanan Perizinan Berusaha",
value: "pelayananperizinanusaha", value: "pelayananperizinanusaha",
href: "/admin/desa/layanan/pelayanan_perizinan_berusaha", href: "/admin/desa/layanan/pelayanan_perizinan_berusaha",
icon: <IconBuildingStore size={18} stroke={1.8} />, icon: <IconBuildingStore size={18} stroke={1.8} />
tooltip: "Layanan untuk izin usaha masyarakat"
}, },
{ {
label: "Pelayanan Telunjuk Sakti Desa", label: "Pelayanan Telunjuk Sakti Desa",
value: "pelayanantelunjuksaktidesa", value: "pelayanantelunjuksaktidesa",
href: "/admin/desa/layanan/pelayanan_telunjuk_sakti_desa", href: "/admin/desa/layanan/pelayanan_telunjuk_sakti_desa",
icon: <IconSparkles size={18} stroke={1.8} />, icon: <IconSparkles size={18} stroke={1.8} />
tooltip: "Layanan inovasi khusus desa"
}, },
{ {
label: "Pelayanan Penduduk Non-Permanent", label: "Pelayanan Penduduk Non-Permanent",
value: "pelayanannonpermanent", value: "pelayanannonpermanent",
href: "/admin/desa/layanan/pelayanan_penduduk_non_permanent", href: "/admin/desa/layanan/pelayanan_penduduk_non_permanent",
icon: <IconUsers size={18} stroke={1.8} />, icon: <IconUsers size={18} stroke={1.8} />
tooltip: "Pendataan penduduk non-permanent"
}, },
{ {
label: "Ajukan Permohonan", label: "Ajukan Permohonan",
value: "ajukanpermohonan", value: "ajukanpermohonan",
href: "/admin/desa/layanan/ajukan_permohonan", href: "/admin/desa/layanan/ajukan_permohonan",
icon: <IconUsersPlus size={18} stroke={1.8} />, icon: <IconUsersPlus size={18} stroke={1.8} />
tooltip: "Ajukan permohonan"
} }
]; ];
@@ -91,14 +86,8 @@ function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
}} }}
> >
{tabs.map((tab, i) => ( {tabs.map((tab, i) => (
<Tooltip
key={i}
label={tab.tooltip}
position="bottom"
withArrow
transitionProps={{ transition: 'pop', duration: 200 }}
>
<TabsTab <TabsTab
key={i}
value={tab.value} value={tab.value}
leftSection={tab.icon} leftSection={tab.icon}
style={{ style={{
@@ -109,7 +98,6 @@ function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
> >
{tab.label} {tab.label}
</TabsTab> </TabsTab>
</Tooltip>
))} ))}
</TabsList> </TabsList>
</ScrollArea> </ScrollArea>

View File

@@ -1,10 +1,10 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title, Tooltip } from '@mantine/core'; import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core';
import { IconCategory, IconNews } from '@tabler/icons-react';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { IconNews, IconCategory } from '@tabler/icons-react';
function LayoutTabsBerita({ children }: { children: React.ReactNode }) { function LayoutTabsBerita({ children }: { children: React.ReactNode }) {
const router = useRouter(); const router = useRouter();
@@ -15,15 +15,13 @@ function LayoutTabsBerita({ children }: { children: React.ReactNode }) {
label: "List Berita", label: "List Berita",
value: "list_berita", value: "list_berita",
href: "/admin/desa/berita/list-berita", href: "/admin/desa/berita/list-berita",
icon: <IconNews size={18} stroke={1.8} />, icon: <IconNews size={18} stroke={1.8} />
tooltip: "Lihat dan kelola semua berita desa"
}, },
{ {
label: "Kategori Berita", label: "Kategori Berita",
value: "kategori_berita", value: "kategori_berita",
href: "/admin/desa/berita/kategori-berita", href: "/admin/desa/berita/kategori-berita",
icon: <IconCategory size={18} stroke={1.8} />, icon: <IconCategory size={18} stroke={1.8} />
tooltip: "Kelola kategori berita desa"
}, },
]; ];
@@ -71,14 +69,8 @@ function LayoutTabsBerita({ children }: { children: React.ReactNode }) {
}} }}
> >
{tabs.map((tab, i) => ( {tabs.map((tab, i) => (
<Tooltip
key={i}
label={tab.tooltip}
position="bottom"
withArrow
transitionProps={{ transition: 'pop', duration: 200 }}
>
<TabsTab <TabsTab
key={i}
value={tab.value} value={tab.value}
leftSection={tab.icon} leftSection={tab.icon}
style={{ style={{
@@ -89,7 +81,6 @@ function LayoutTabsBerita({ children }: { children: React.ReactNode }) {
> >
{tab.label} {tab.label}
</TabsTab> </TabsTab>
</Tooltip>
))} ))}
</TabsList> </TabsList>
</ScrollArea> </ScrollArea>

View File

@@ -1,5 +1,6 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client'
import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita'; import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { import {
@@ -10,7 +11,7 @@ import {
Stack, Stack,
TextInput, TextInput,
Title, Title,
Tooltip Loader
} from '@mantine/core'; } from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react'; import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
@@ -22,9 +23,14 @@ function EditKategoriBerita() {
const editState = useProxy(stateDashboardBerita.kategoriBerita); const editState = useProxy(stateDashboardBerita.kategoriBerita);
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
name: '',
});
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: editState.update.form.name || '', name: '',
}); });
useEffect(() => { useEffect(() => {
@@ -38,6 +44,9 @@ function EditKategoriBerita() {
setFormData({ setFormData({
name: data.name || '', name: data.name || '',
}); });
setOriginalData({
name: data.name || '',
});
} }
} catch (error) { } catch (error) {
console.error('Error loading kategori Berita:', error); console.error('Error loading kategori Berita:', error);
@@ -48,8 +57,24 @@ function EditKategoriBerita() {
loadKategori(); loadKategori();
}, [params?.id]); }, [params?.id]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData((prev) => ({
...prev,
[e.target.name]: e.target.value,
}));
};
const handleResetForm = () => {
setFormData({
name: originalData.name,
});
toast.info('Form dikembalikan ke data awal');
};
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
setIsSubmitting(true);
// update global state hanya saat submit
editState.update.form = { editState.update.form = {
...editState.update.form, ...editState.update.form,
name: formData.name, name: formData.name,
@@ -61,6 +86,8 @@ function EditKategoriBerita() {
} catch (error) { } catch (error) {
console.error('Error updating kategori Berita:', error); console.error('Error updating kategori Berita:', error);
toast.error('Terjadi kesalahan saat memperbarui kategori Berita'); toast.error('Terjadi kesalahan saat memperbarui kategori Berita');
} finally {
setIsSubmitting(false);
} }
}; };
@@ -68,7 +95,6 @@ function EditKategoriBerita() {
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Back Button + Title */} {/* Back Button + Title */}
<Group mb="md"> <Group mb="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button <Button
variant="subtle" variant="subtle"
onClick={() => router.back()} onClick={() => router.back()}
@@ -77,7 +103,6 @@ function EditKategoriBerita() {
> >
<IconArrowBack color={colors['blue-button']} size={24} /> <IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
</Tooltip>
<Title order={4} ml="sm" c="dark"> <Title order={4} ml="sm" c="dark">
Edit Kategori Berita Edit Kategori Berita
</Title> </Title>
@@ -94,14 +119,26 @@ function EditKategoriBerita() {
> >
<Stack gap="md"> <Stack gap="md">
<TextInput <TextInput
name="name"
label="Nama Kategori Berita" label="Nama Kategori Berita"
placeholder="Masukkan nama kategori berita" placeholder="Masukkan nama kategori berita"
value={formData.name} value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })} onChange={handleChange}
required required
/> />
<Group justify="right"> <Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"
@@ -112,7 +149,7 @@ function EditKategoriBerita() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)', boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}} }}
> >
Simpan {isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -7,18 +7,20 @@ import {
Group, Group,
Paper, Paper,
Stack, Stack,
Text,
TextInput, TextInput,
Title, Title,
Tooltip, Loader
} from '@mantine/core'; } from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react'; import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
function CreateKategoriBerita() { function CreateKategoriBerita() {
const createState = useProxy(stateDashboardBerita.kategoriBerita); const createState = useProxy(stateDashboardBerita.kategoriBerita);
const router = useRouter(); const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => { const resetForm = () => {
createState.create.form = { createState.create.form = {
@@ -27,16 +29,23 @@ function CreateKategoriBerita() {
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
setIsSubmitting(true);
try {
await createState.create.create(); await createState.create.create();
resetForm(); resetForm();
router.push('/admin/desa/berita/kategori-berita'); router.push('/admin/desa/berita/kategori-berita');
} catch (error) {
console.error('Error creating kategori berita:', error);
toast.error('Gagal menambahkan kategori berita');
} finally {
setIsSubmitting(false);
}
}; };
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header dengan back button */} {/* Header dengan back button */}
<Group mb="md"> <Group mb="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button <Button
variant="subtle" variant="subtle"
onClick={() => router.back()} onClick={() => router.back()}
@@ -45,7 +54,6 @@ function CreateKategoriBerita() {
> >
<IconArrowBack color={colors['blue-button']} size={24} /> <IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
</Tooltip>
<Title order={4} ml="sm" c="dark"> <Title order={4} ml="sm" c="dark">
Tambah Kategori Berita Tambah Kategori Berita
</Title> </Title>
@@ -62,7 +70,7 @@ function CreateKategoriBerita() {
> >
<Stack gap="md"> <Stack gap="md">
<TextInput <TextInput
label={<Text fw="bold" fz="sm">Nama Kategori Berita</Text>} label="Nama Kategori Berita"
placeholder="Masukkan nama kategori berita" placeholder="Masukkan nama kategori berita"
value={createState.create.form.name || ''} value={createState.create.form.name || ''}
onChange={(e) => (createState.create.form.name = e.target.value)} onChange={(e) => (createState.create.form.name = e.target.value)}
@@ -70,6 +78,17 @@ function CreateKategoriBerita() {
/> />
<Group justify="right"> <Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"
@@ -80,7 +99,7 @@ function CreateKategoriBerita() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)', boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}} }}
> >
Simpan {isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -17,8 +17,7 @@ import {
TableThead, TableThead,
TableTr, TableTr,
Text, Text,
Title, Title
Tooltip
} from '@mantine/core'; } from '@mantine/core';
import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react'; import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
@@ -86,7 +85,6 @@ function ListKategoriBerita({ search }: { search: string }) {
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md"> <Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Group justify="space-between" mb="md"> <Group justify="space-between" mb="md">
<Title order={4}>Daftar Kategori Berita</Title> <Title order={4}>Daftar Kategori Berita</Title>
<Tooltip label="Tambah Kategori Berita" withArrow>
<Button <Button
leftSection={<IconPlus size={18} />} leftSection={<IconPlus size={18} />}
color="blue" color="blue"
@@ -97,7 +95,6 @@ function ListKategoriBerita({ search }: { search: string }) {
> >
Tambah Baru Tambah Baru
</Button> </Button>
</Tooltip>
</Group> </Group>
<Box style={{ overflowX: 'auto' }}> <Box style={{ overflowX: 'auto' }}>
@@ -123,7 +120,6 @@ function ListKategoriBerita({ search }: { search: string }) {
</Text> </Text>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Tooltip label="Edit Kategori Berita" withArrow>
<Button <Button
variant="light" variant="light"
color="green" color="green"
@@ -135,10 +131,8 @@ function ListKategoriBerita({ search }: { search: string }) {
> >
<IconEdit size={18} /> <IconEdit size={18} />
</Button> </Button>
</Tooltip>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Tooltip label="Hapus Kategori Berita" withArrow>
<Button <Button
variant="light" variant="light"
color="red" color="red"
@@ -150,7 +144,6 @@ function ListKategoriBerita({ search }: { search: string }) {
> >
<IconTrash size={18} /> <IconTrash size={18} />
</Button> </Button>
</Tooltip>
</TableTd> </TableTd>
</TableTr> </TableTr>
)) ))

View File

@@ -6,6 +6,7 @@ import stateDashboardBerita from "@/app/admin/(dashboard)/_state/desa/berita";
import colors from "@/con/colors"; import colors from "@/con/colors";
import ApiFetch from "@/lib/api-fetch"; import ApiFetch from "@/lib/api-fetch";
import { import {
ActionIcon,
Box, Box,
Button, Button,
Group, Group,
@@ -16,10 +17,15 @@ import {
Text, Text,
TextInput, TextInput,
Title, Title,
Tooltip, Loader
} from "@mantine/core"; } from "@mantine/core";
import { Dropzone } from "@mantine/dropzone"; import { Dropzone } from "@mantine/dropzone";
import { IconArrowBack, IconPhoto, IconUpload, IconX } from "@tabler/icons-react"; import {
IconArrowBack,
IconPhoto,
IconUpload,
IconX,
} from "@tabler/icons-react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
@@ -33,16 +39,28 @@ function EditBerita() {
const [previewImage, setPreviewImage] = useState<string | null>(null); const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
judul: beritaState.berita.edit.form.judul || "", judul: "",
deskripsi: beritaState.berita.edit.form.deskripsi || "", deskripsi: "",
kategoriBeritaId: beritaState.berita.edit.form.kategoriBeritaId || "", kategoriBeritaId: "",
content: beritaState.berita.edit.form.content || "", content: "",
imageId: beritaState.berita.edit.form.imageId || "", imageId: "",
}); });
// Load berita by id saat pertama kali const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
judul: "",
deskripsi: "",
kategoriBeritaId: "",
content: "",
imageId: "",
imageUrl: ""
});
// Load kategori + berita
useEffect(() => { useEffect(() => {
beritaState.kategoriBerita.findMany.load(); beritaState.kategoriBerita.findMany.load();
const loadBerita = async () => { const loadBerita = async () => {
const id = params?.id as string; const id = params?.id as string;
if (!id) return; if (!id) return;
@@ -58,6 +76,15 @@ function EditBerita() {
imageId: data.imageId || "", imageId: data.imageId || "",
}); });
setOriginalData({
judul: data.judul || "",
deskripsi: data.deskripsi || "",
kategoriBeritaId: data.kategoriBeritaId || "",
content: data.content || "",
imageId: data.imageId || "",
imageUrl: data.image?.link || ""
});
if (data?.image?.link) { if (data?.image?.link) {
setPreviewImage(data.image.link); setPreviewImage(data.image.link);
} }
@@ -71,8 +98,14 @@ function EditBerita() {
loadBerita(); loadBerita();
}, [params?.id]); }, [params?.id]);
const handleChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
setIsSubmitting(true);
// Update global state hanya sekali di sini
beritaState.berita.edit.form = { beritaState.berita.edit.form = {
...beritaState.berita.edit.form, ...beritaState.berita.edit.form,
...formData, ...formData,
@@ -98,13 +131,28 @@ function EditBerita() {
} catch (error) { } catch (error) {
console.error("Error updating berita:", error); console.error("Error updating berita:", error);
toast.error("Terjadi kesalahan saat memperbarui berita"); toast.error("Terjadi kesalahan saat memperbarui berita");
} finally {
setIsSubmitting(false);
} }
}; };
const handleResetForm = () => {
setFormData({
judul: originalData.judul,
deskripsi: originalData.deskripsi,
kategoriBeritaId: originalData.kategoriBeritaId,
content: originalData.content,
imageId: originalData.imageId,
});
setPreviewImage(originalData.imageUrl || null);
setFile(null);
toast.info("Form dikembalikan ke data awal");
};
return ( return (
<Box px={{ base: "sm", md: "lg" }} py="md"> <Box px={{ base: "sm", md: "lg" }} py="md">
{/* Header */}
<Group mb="md"> <Group mb="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button <Button
variant="subtle" variant="subtle"
onClick={() => router.back()} onClick={() => router.back()}
@@ -113,12 +161,12 @@ function EditBerita() {
> >
<IconArrowBack color={colors["blue-button"]} size={24} /> <IconArrowBack color={colors["blue-button"]} size={24} />
</Button> </Button>
</Tooltip>
<Title order={4} ml="sm" c="dark"> <Title order={4} ml="sm" c="dark">
Edit Berita Edit Berita
</Title> </Title>
</Group> </Group>
{/* Form */}
<Paper <Paper
w={{ base: "100%", md: "50%" }} w={{ base: "100%", md: "50%" }}
bg={colors["white-1"]} bg={colors["white-1"]}
@@ -132,96 +180,13 @@ function EditBerita() {
label="Judul" label="Judul"
placeholder="Masukkan judul" placeholder="Masukkan judul"
value={formData.judul} value={formData.judul}
onChange={(e) => onChange={(e) => handleChange("judul", e.target.value)}
setFormData({ ...formData, judul: e.target.value })
}
required required
/> />
<TextInput
label="Deskripsi"
placeholder="Masukkan deskripsi"
value={formData.deskripsi}
onChange={(e) =>
setFormData({ ...formData, deskripsi: e.target.value })
}
required
/>
<Box>
<Text fw="bold" fz="sm" mb={6}>
Gambar Berita
</Text>
<Dropzone
onDrop={(files) => {
const selectedFile = files[0];
if (selectedFile) {
setFile(selectedFile);
setPreviewImage(URL.createObjectURL(selectedFile));
}
}}
onReject={() => toast.error("File tidak valid, gunakan format gambar")}
maxSize={5 * 1024 ** 2}
accept={{ "image/*": [] }}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept>
<IconUpload size={48} color={colors["blue-button"]} stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="red" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle>
<Stack gap="xs" align="center">
<Text size="md" fw={500}>
Seret gambar atau klik untuk memilih file
</Text>
<Text size="sm" c="dimmed">
Maksimal 5MB, format gambar wajib
</Text>
</Stack>
</Group>
</Dropzone>
{previewImage && (
<Box mt="sm" style={{ display: "flex", justifyContent: "center" }}>
<Image
src={previewImage}
alt="Preview Gambar"
radius="md"
style={{
maxHeight: 220,
objectFit: "contain",
border: `1px solid ${colors["blue-button"]}`,
}}
loading="lazy"
/>
</Box>
)}
</Box>
<Box>
<Text fz="sm" fw="bold">
Konten
</Text>
<EditEditor
value={formData.content}
onChange={(htmlContent) => {
setFormData((prev) => ({ ...prev, content: htmlContent }));
beritaState.berita.edit.form.content = htmlContent;
}}
/>
</Box>
<Select <Select
value={formData.kategoriBeritaId} value={formData.kategoriBeritaId}
onChange={(val) => onChange={(val) => handleChange("kategoriBeritaId", val || "")}
setFormData({ ...formData, kategoriBeritaId: val || "" })
}
label="Kategori" label="Kategori"
placeholder="Pilih kategori" placeholder="Pilih kategori"
data={ data={
@@ -236,18 +201,138 @@ function EditBerita() {
error={!formData.kategoriBeritaId ? "Pilih kategori" : undefined} error={!formData.kategoriBeritaId ? "Pilih kategori" : undefined}
/> />
<Box>
<Text fz="sm" fw="bold">
Deskripsi Singkat
</Text>
<EditEditor
value={formData.deskripsi}
onChange={(htmlContent) =>
setFormData((prev) => ({ ...prev, deskripsi: htmlContent }))
}
/>
</Box>
{/* Upload Gambar */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Gambar Berita
</Text>
<Dropzone
onDrop={(files) => {
const selectedFile = files[0];
if (selectedFile) {
setFile(selectedFile);
setPreviewImage(URL.createObjectURL(selectedFile));
}
}}
onReject={() =>
toast.error("File tidak valid, gunakan format gambar")
}
maxSize={5 * 1024 ** 2}
accept={{ "image/*": [] }}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept>
<IconUpload
size={48}
color={colors["blue-button"]}
stroke={1.5}
/>
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="red" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle>
<Stack gap="xs" align="center">
<Text size="md" fw={500}>
Seret gambar atau klik untuk memilih file
</Text>
<Text size="sm" c="dimmed">
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp
</Text>
</Stack>
</Group>
</Dropzone>
{previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar"
radius="md"
style={{
maxHeight: 220,
objectFit: "contain",
border: `1px solid ${colors["blue-button"]}`,
}}
loading="lazy"
/>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
{/* Konten */}
<Box>
<Text fz="sm" fw="bold">
Konten
</Text>
<EditEditor
value={formData.content}
onChange={(htmlContent) =>
setFormData((prev) => ({ ...prev, content: htmlContent }))
}
/>
</Box>
{/* Action */}
<Group justify="right"> <Group justify="right">
{/* Tombol Batal */}
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"
size="md" size="md"
style={{ style={{
background: `linear-gradient(135deg, ${colors["blue-button"]}, #4facfe)`, background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: "#fff", color: '#fff',
boxShadow: "0 4px 15px rgba(79, 172, 254, 0.4)", boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}} }}
> >
Simpan {isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -1,14 +1,14 @@
'use client' 'use client'
import { useProxy } from 'valtio/utils'; import { Box, Button, Group, Image, Paper, Skeleton, Stack, Text } from '@mantine/core';
import { Box, Button, Group, Image, Paper, Skeleton, Stack, Text, Tooltip } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react'; import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import colors from '@/con/colors';
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus'; import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita'; import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita';
import colors from '@/con/colors';
function DetailBerita() { function DetailBerita() {
const beritaState = useProxy(stateDashboardBerita); const beritaState = useProxy(stateDashboardBerita);
@@ -80,7 +80,7 @@ function DetailBerita() {
<Box> <Box>
<Text fz="lg" fw="bold">Deskripsi</Text> <Text fz="lg" fw="bold">Deskripsi</Text>
<Text fz="md" c="dimmed">{data.deskripsi || '-'}</Text> <Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }} />
</Box> </Box>
<Box> <Box>
@@ -111,7 +111,6 @@ function DetailBerita() {
{/* Action Button */} {/* Action Button */}
<Group gap="sm"> <Group gap="sm">
<Tooltip label="Hapus Berita" withArrow position="top">
<Button <Button
color="red" color="red"
onClick={() => { onClick={() => {
@@ -124,9 +123,7 @@ function DetailBerita() {
> >
<IconTrash size={20} /> <IconTrash size={20} />
</Button> </Button>
</Tooltip>
<Tooltip label="Edit Berita" withArrow position="top">
<Button <Button
color="green" color="green"
onClick={() => router.push(`/admin/desa/berita/list-berita/${data.id}/edit`)} onClick={() => router.push(`/admin/desa/berita/list-berita/${data.id}/edit`)}
@@ -136,7 +133,6 @@ function DetailBerita() {
> >
<IconEdit size={20} /> <IconEdit size={20} />
</Button> </Button>
</Tooltip>
</Group> </Group>
</Stack> </Stack>
</Paper> </Paper>

View File

@@ -14,7 +14,8 @@ import {
Text, Text,
TextInput, TextInput,
Title, Title,
Tooltip, Loader,
ActionIcon
} from '@mantine/core'; } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone'; import { Dropzone } from '@mantine/dropzone';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
@@ -29,6 +30,7 @@ export default function CreateBerita() {
const [previewImage, setPreviewImage] = useState<string | null>(null); const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const router = useRouter(); const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
useShallowEffect(() => { useShallowEffect(() => {
beritaState.kategoriBerita.findMany.load(); beritaState.kategoriBerita.findMany.load();
@@ -47,6 +49,8 @@ export default function CreateBerita() {
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
try {
setIsSubmitting(true);
if (!file) { if (!file) {
return toast.warn('Silakan pilih file gambar terlebih dahulu'); return toast.warn('Silakan pilih file gambar terlebih dahulu');
} }
@@ -67,13 +71,18 @@ export default function CreateBerita() {
resetForm(); resetForm();
router.push('/admin/desa/berita/list-berita'); router.push('/admin/desa/berita/list-berita');
} catch (error) {
console.error('Error creating berita:', error);
toast.error('Terjadi kesalahan saat membuat berita');
} finally {
setIsSubmitting(false);
}
}; };
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header dengan tombol kembali */} {/* Header dengan tombol kembali */}
<Group mb="md"> <Group mb="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button <Button
variant="subtle" variant="subtle"
onClick={() => router.back()} onClick={() => router.back()}
@@ -82,7 +91,6 @@ export default function CreateBerita() {
> >
<IconArrowBack color={colors['blue-button']} size={24} /> <IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
</Tooltip>
<Title order={4} ml="sm" c="dark"> <Title order={4} ml="sm" c="dark">
Tambah Berita Tambah Berita
</Title> </Title>
@@ -131,12 +139,17 @@ export default function CreateBerita() {
required required
/> />
<TextInput <Box>
label="Deskripsi Singkat" <Text fz="sm" fw="bold" mb={6}>
placeholder="Masukkan deskripsi berita" Deskripsi Singkat
</Text>
<CreateEditor
value={beritaState.berita.create.form.deskripsi} value={beritaState.berita.create.form.deskripsi}
onChange={(e) => (beritaState.berita.create.form.deskripsi = e.target.value)} onChange={(htmlContent) => {
beritaState.berita.create.form.deskripsi = htmlContent;
}}
/> />
</Box>
<Box> <Box>
<Text fw="bold" fz="sm" mb={6}> <Text fw="bold" fz="sm" mb={6}>
@@ -152,7 +165,7 @@ export default function CreateBerita() {
}} }}
onReject={() => toast.error('File tidak valid, gunakan format gambar')} onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2} maxSize={5 * 1024 ** 2}
accept={{ 'image/*': [] }} accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md" radius="md"
p="xl" p="xl"
> >
@@ -173,7 +186,7 @@ export default function CreateBerita() {
</Dropzone> </Dropzone>
{previewImage && ( {previewImage && (
<Box mt="sm" style={{ textAlign: 'center' }}> <Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image <Image
src={previewImage} src={previewImage}
alt="Preview Gambar" alt="Preview Gambar"
@@ -185,6 +198,26 @@ export default function CreateBerita() {
}} }}
loading="lazy" loading="lazy"
/> />
{/* Tombol hapus (pojok kanan atas) */}
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box> </Box>
)} )}
</Box> </Box>
@@ -202,6 +235,17 @@ export default function CreateBerita() {
</Box> </Box>
<Group justify="right"> <Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"
@@ -212,7 +256,7 @@ export default function CreateBerita() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)', boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}} }}
> >
Simpan {isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -16,8 +16,7 @@ import {
TableThead, TableThead,
TableTr, TableTr,
Text, Text,
Title, Title
Tooltip
} from '@mantine/core'; } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconCircleDashedPlus, IconDeviceImacCog, IconSearch } from '@tabler/icons-react'; import { IconCircleDashedPlus, IconDeviceImacCog, IconSearch } from '@tabler/icons-react';
@@ -68,7 +67,6 @@ function ListBerita({ search }: { search: string }) {
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md"> <Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Group justify="space-between" mb="md"> <Group justify="space-between" mb="md">
<Title order={4}>Daftar Berita</Title> <Title order={4}>Daftar Berita</Title>
<Tooltip label="Tambah Berita" withArrow>
<Button <Button
leftSection={<IconCircleDashedPlus size={18} />} leftSection={<IconCircleDashedPlus size={18} />}
color="blue" color="blue"
@@ -77,7 +75,6 @@ function ListBerita({ search }: { search: string }) {
> >
Tambah Baru Tambah Baru
</Button> </Button>
</Tooltip>
</Group> </Group>
<Box style={{ overflowX: 'auto' }}> <Box style={{ overflowX: 'auto' }}>

View File

@@ -13,8 +13,7 @@ import {
Stack, Stack,
Text, Text,
TextInput, TextInput,
Title, Title
Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks"; import { useShallowEffect } from "@mantine/hooks";
import { IconSearch, IconTrash, IconX } from "@tabler/icons-react"; import { IconSearch, IconTrash, IconX } from "@tabler/icons-react";
@@ -103,7 +102,6 @@ export default function ListImage() {
</Box> </Box>
<Group justify="space-between" align="center" pt="xs"> <Group justify="space-between" align="center" pt="xs">
<Tooltip label="Hapus foto" withArrow>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color="red" color="red"
@@ -116,7 +114,6 @@ export default function ListImage() {
> >
<IconTrash size={18} /> <IconTrash size={18} />
</ActionIcon> </ActionIcon>
</Tooltip>
</Group> </Group>
</Stack> </Stack>
</Card> </Card>

View File

@@ -1,10 +1,10 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title, Tooltip } from '@mantine/core'; import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core';
import { IconPhoto, IconVideo } from '@tabler/icons-react';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { IconPhoto, IconVideo } from '@tabler/icons-react';
function LayoutTabsGallery({ children }: { children: React.ReactNode }) { function LayoutTabsGallery({ children }: { children: React.ReactNode }) {
const router = useRouter() const router = useRouter()
@@ -14,15 +14,13 @@ function LayoutTabsGallery({ children }: { children: React.ReactNode }) {
label: "Foto", label: "Foto",
value: "foto", value: "foto",
href: "/admin/desa/gallery/foto", href: "/admin/desa/gallery/foto",
icon: <IconPhoto size={18} stroke={1.8} />, icon: <IconPhoto size={18} stroke={1.8} />
tooltip: "Kelola foto-foto galeri desa"
}, },
{ {
label: "Video", label: "Video",
value: "video", value: "video",
href: "/admin/desa/gallery/video", href: "/admin/desa/gallery/video",
icon: <IconVideo size={18} stroke={1.8} />, icon: <IconVideo size={18} stroke={1.8} />
tooltip: "Kelola video galeri desa"
}, },
]; ];
@@ -70,14 +68,8 @@ function LayoutTabsGallery({ children }: { children: React.ReactNode }) {
}} }}
> >
{tabs.map((tab, i) => ( {tabs.map((tab, i) => (
<Tooltip
key={i}
label={tab.tooltip}
position="bottom"
withArrow
transitionProps={{ transition: 'pop', duration: 200 }}
>
<TabsTab <TabsTab
key={i}
value={tab.value} value={tab.value}
leftSection={tab.icon} leftSection={tab.icon}
style={{ style={{
@@ -88,7 +80,6 @@ function LayoutTabsGallery({ children }: { children: React.ReactNode }) {
> >
{tab.label} {tab.label}
</TabsTab> </TabsTab>
</Tooltip>
))} ))}
</TabsList> </TabsList>
</ScrollArea> </ScrollArea>

View File

@@ -1,31 +1,11 @@
// export function convertYoutubeUrlToEmbed(url: string) {
// const videoIdMatch = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
// return videoIdMatch ? `https://www.youtube.com/embed/${videoIdMatch[1]}` : null;
// }
export function convertYoutubeUrlToEmbed(url: string) { export function convertYoutubeUrlToEmbed(url: string) {
const videoIdMatch = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/); const videoIdMatch = url.match(
/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/
);
return videoIdMatch ? `https://www.youtube.com/embed/${videoIdMatch[1]}` : null; return videoIdMatch ? `https://www.youtube.com/embed/${videoIdMatch[1]}` : null;
} }
// (url: string): string | null {
// const watchRegex = /(?:https?:\/\/)?(?:www\.)?youtube\.com\/watch\?v=([^&]+)/;
// const shortRegex = /(?:https?:\/\/)?youtu\.be\/([^?]+)/;
// const matchWatch = url.match(watchRegex);
// const matchShort = url.match(shortRegex);
// if (matchWatch) {
// return `https://www.youtube.com/embed/${matchWatch[1]}`;
// }
// if (matchShort) {
// return `https://www.youtube.com/embed/${matchShort[1]}`;
// }
// return null;
// }

View File

@@ -4,6 +4,7 @@ import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery'; import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { import {
ActionIcon,
Box, Box,
Button, Button,
Group, Group,
@@ -11,11 +12,11 @@ import {
Stack, Stack,
TextInput, TextInput,
Title, Title,
Tooltip Loader
} from '@mantine/core'; } from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react'; import { IconArrowBack, IconX } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import { convertYoutubeUrlToEmbed } from '../../../lib/youtube-utils'; import { convertYoutubeUrlToEmbed } from '../../../lib/youtube-utils';
@@ -25,23 +26,38 @@ function EditVideo() {
const videoState = useProxy(stateGallery.video); const videoState = useProxy(stateGallery.video);
const params = useParams(); const params = useParams();
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
name: "",
deskripsi: "",
linkVideo: "",
});
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
deskripsi: '', deskripsi: '',
linkVideo: '', linkVideo: '',
}); });
// load data video sekali saat id ada
useEffect(() => { useEffect(() => {
const loadVideo = async () => { const loadVideo = async () => {
const id = params?.id as string; const id = params?.id as string;
if (!id) return; if (!id) return;
try { try {
const data = await videoState.update.load(id); const data = await videoState.update.load(id);
if (data) { if (data) {
setFormData({ setFormData({
name: data.name || '', name: data.name ?? '',
deskripsi: data.deskripsi || '', deskripsi: data.deskripsi ?? '',
linkVideo: data.linkVideo || '', linkVideo: data.linkVideo ?? '',
});
setOriginalData({
name: data.name ?? '',
deskripsi: data.deskripsi ?? '',
linkVideo: data.linkVideo ?? '',
}); });
} }
} catch (error) { } catch (error) {
@@ -49,12 +65,29 @@ function EditVideo() {
toast.error('Gagal memuat data video'); toast.error('Gagal memuat data video');
} }
}; };
loadVideo(); loadVideo();
}, [params?.id]); }, [params?.id]);
const embedLink = convertYoutubeUrlToEmbed(formData.linkVideo); const handleChange = useCallback(
(field: keyof typeof formData, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
},
[]
);
const handleResetForm = () => {
setFormData({
name: originalData.name,
deskripsi: originalData.deskripsi,
linkVideo: originalData.linkVideo,
});
toast.info("Form dikembalikan ke data awal");
};
const handleSubmit = async () => { const handleSubmit = async () => {
try {
setIsSubmitting(true);
const converted = convertYoutubeUrlToEmbed(formData.linkVideo); const converted = convertYoutubeUrlToEmbed(formData.linkVideo);
if (!converted) { if (!converted) {
toast.error("Link YouTube tidak valid. Pastikan formatnya benar."); toast.error("Link YouTube tidak valid. Pastikan formatnya benar.");
@@ -63,7 +96,6 @@ function EditVideo() {
try { try {
videoState.update.form = { videoState.update.form = {
...videoState.update.form,
name: formData.name, name: formData.name,
deskripsi: formData.deskripsi, deskripsi: formData.deskripsi,
linkVideo: formData.linkVideo, linkVideo: formData.linkVideo,
@@ -75,16 +107,27 @@ function EditVideo() {
console.error('Error updating video:', error); console.error('Error updating video:', error);
toast.error('Terjadi kesalahan saat memperbarui video'); toast.error('Terjadi kesalahan saat memperbarui video');
} }
} catch (error) {
console.error('Error updating video:', error);
toast.error('Terjadi kesalahan saat memperbarui video');
} finally {
setIsSubmitting(false);
}
}; };
const embedLink = convertYoutubeUrlToEmbed(formData.linkVideo);
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
<Group mb="md"> <Group mb="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow> <Button
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md"> variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} /> <IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
</Tooltip>
<Title order={4} ml="sm" c="dark"> <Title order={4} ml="sm" c="dark">
Edit Video Edit Video
</Title> </Title>
@@ -103,7 +146,7 @@ function EditVideo() {
label="Judul Video" label="Judul Video"
placeholder="Masukkan judul video" placeholder="Masukkan judul video"
value={formData.name} value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })} onChange={(e) => handleChange('name', e.currentTarget.value)}
required required
/> />
@@ -112,11 +155,11 @@ function EditVideo() {
label="Link Video YouTube" label="Link Video YouTube"
placeholder="https://www.youtube.com/watch?v=abc123" placeholder="https://www.youtube.com/watch?v=abc123"
value={formData.linkVideo} value={formData.linkVideo}
onChange={(e) => setFormData({ ...formData, linkVideo: e.currentTarget.value })} onChange={(e) => handleChange('linkVideo', e.currentTarget.value)}
required required
/> />
{embedLink && ( {embedLink && (
<Box mt="sm" style={{ display: 'flex', justifyContent: 'center' }}> <Box mt="sm" pos="relative" style={{ display: 'flex', justifyContent: 'center' }}>
<iframe <iframe
className="rounded" className="rounded"
width="100%" width="100%"
@@ -124,7 +167,27 @@ function EditVideo() {
src={embedLink} src={embedLink}
title="Preview Video" title="Preview Video"
allowFullScreen allowFullScreen
></iframe> />
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setFormData({
...formData,
linkVideo: '',
});
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box> </Box>
)} )}
</Box> </Box>
@@ -135,11 +198,22 @@ function EditVideo() {
</Title> </Title>
<EditEditor <EditEditor
value={formData.deskripsi} value={formData.deskripsi}
onChange={(val) => setFormData({ ...formData, deskripsi: val })} onChange={(val) => handleChange('deskripsi', val)}
/> />
</Box> </Box>
<Group justify="right"> <Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"
@@ -150,7 +224,7 @@ function EditVideo() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)', boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}} }}
> >
Simpan {isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -2,7 +2,7 @@
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus'; import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery'; import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Group, Paper, Skeleton, Stack, Text, Tooltip } from '@mantine/core'; import { Box, Button, Group, Paper, Skeleton, Stack, Text } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react'; import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
@@ -102,6 +102,7 @@ function DetailVideo() {
fz="md" fz="md"
c="dimmed" c="dimmed"
dangerouslySetInnerHTML={{ __html: data.deskripsi }} dangerouslySetInnerHTML={{ __html: data.deskripsi }}
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
/> />
) : ( ) : (
<Text fz="sm" c="dimmed">Tidak ada deskripsi</Text> <Text fz="sm" c="dimmed">Tidak ada deskripsi</Text>
@@ -110,7 +111,6 @@ function DetailVideo() {
{/* Tombol Aksi */} {/* Tombol Aksi */}
<Group gap="sm"> <Group gap="sm">
<Tooltip label="Hapus Video" withArrow position="top">
<Button <Button
color="red" color="red"
onClick={() => { onClick={() => {
@@ -123,9 +123,7 @@ function DetailVideo() {
> >
<IconTrash size={20} /> <IconTrash size={20} />
</Button> </Button>
</Tooltip>
<Tooltip label="Edit Video" withArrow position="top">
<Button <Button
color="green" color="green"
onClick={() => onClick={() =>
@@ -137,7 +135,6 @@ function DetailVideo() {
> >
<IconEdit size={20} /> <IconEdit size={20} />
</Button> </Button>
</Tooltip>
</Group> </Group>
</Stack> </Stack>
</Paper> </Paper>

View File

@@ -3,6 +3,7 @@ import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor';
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery'; import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { import {
ActionIcon,
Box, Box,
Button, Button,
Group, Group,
@@ -11,9 +12,9 @@ import {
Text, Text,
TextInput, TextInput,
Title, Title,
Tooltip, Loader
} from '@mantine/core'; } from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react'; import { IconArrowBack, IconX } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -25,6 +26,7 @@ function CreateVideo() {
const router = useRouter(); const router = useRouter();
const [link, setLink] = useState(''); const [link, setLink] = useState('');
const embedLink = convertYoutubeUrlToEmbed(link); const embedLink = convertYoutubeUrlToEmbed(link);
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => { const resetForm = () => {
videoState.create.form = { videoState.create.form = {
@@ -36,6 +38,8 @@ function CreateVideo() {
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
try {
setIsSubmitting(true);
if (!embedLink) { if (!embedLink) {
toast.error('Link YouTube tidak valid. Pastikan formatnya benar.'); toast.error('Link YouTube tidak valid. Pastikan formatnya benar.');
return; return;
@@ -45,13 +49,18 @@ function CreateVideo() {
await videoState.create.create(); await videoState.create.create();
resetForm(); resetForm();
router.push('/admin/desa/gallery/video'); router.push('/admin/desa/gallery/video');
} catch (error) {
console.error("Error creating video:", error);
toast.error("Terjadi kesalahan saat menambahkan video");
} finally {
setIsSubmitting(false);
}
}; };
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header Back Button + Title */} {/* Header Back Button + Title */}
<Group mb="md"> <Group mb="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button <Button
variant="subtle" variant="subtle"
onClick={() => router.back()} onClick={() => router.back()}
@@ -60,7 +69,6 @@ function CreateVideo() {
> >
<IconArrowBack color={colors['blue-button']} size={24} /> <IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
</Tooltip>
<Title order={4} ml="sm" c="dark"> <Title order={4} ml="sm" c="dark">
Tambah Video Tambah Video
</Title> </Title>
@@ -98,7 +106,7 @@ function CreateVideo() {
{/* Preview Video */} {/* Preview Video */}
{embedLink && ( {embedLink && (
<Box mt="sm"> <Box mt="sm" pos="relative">
<iframe <iframe
style={{ style={{
borderRadius: 10, borderRadius: 10,
@@ -109,7 +117,24 @@ function CreateVideo() {
src={embedLink} src={embedLink}
title="Preview Video" title="Preview Video"
allowFullScreen allowFullScreen
></iframe> />
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setLink('');
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box> </Box>
)} )}
@@ -128,6 +153,17 @@ function CreateVideo() {
{/* Button Submit */} {/* Button Submit */}
<Group justify="right"> <Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"
@@ -138,7 +174,7 @@ function CreateVideo() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)', boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}} }}
> >
Simpan {isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -16,8 +16,7 @@ import {
TableThead, TableThead,
TableTr, TableTr,
Text, Text,
Title, Title
Tooltip
} from '@mantine/core'; } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconDeviceImac, IconPlus, IconSearch } from '@tabler/icons-react'; import { IconDeviceImac, IconPlus, IconSearch } from '@tabler/icons-react';
@@ -74,7 +73,6 @@ function ListVideo({ search }: { search: string }) {
<Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md"> <Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
<Group justify="space-between" mb="md"> <Group justify="space-between" mb="md">
<Title order={4}>Daftar Video</Title> <Title order={4}>Daftar Video</Title>
<Tooltip label="Tambah Video Baru" withArrow>
<Button <Button
leftSection={<IconPlus size={18} />} leftSection={<IconPlus size={18} />}
color="blue" color="blue"
@@ -83,7 +81,6 @@ function ListVideo({ search }: { search: string }) {
> >
Tambah Baru Tambah Baru
</Button> </Button>
</Tooltip>
</Group> </Group>
<Box style={{ overflowX: "auto" }}> <Box style={{ overflowX: "auto" }}>
<Table highlightOnHover> <Table highlightOnHover>

View File

@@ -1,17 +1,17 @@
'use client'
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client'
import stateLayananDesa from '@/app/admin/(dashboard)/_state/desa/layananDesa'; import stateLayananDesa from '@/app/admin/(dashboard)/_state/desa/layananDesa';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { import {
Box, Box,
Button, Button,
Group, Group,
Loader,
Paper, Paper,
Select, Select,
Stack, Stack,
TextInput, TextInput,
Title, Title
Tooltip
} from '@mantine/core'; } from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react'; import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
@@ -24,16 +24,29 @@ function EditAjukanPermohonan() {
const params = useParams(); const params = useParams();
const stateAjukan = useProxy(stateLayananDesa.ajukanPermohonan); const stateAjukan = useProxy(stateLayananDesa.ajukanPermohonan);
const [formData, setFormData] = useState({ const [isSubmitting, setIsSubmitting] = useState(false);
nama: stateAjukan.edit.form.nama,
nik: stateAjukan.edit.form.nik, const [originalData, setOriginalData] = useState({
alamat: stateAjukan.edit.form.alamat, nama: "",
nomorKk: stateAjukan.edit.form.nomorKk, nik: "",
kategoriId: stateAjukan.edit.form.kategoriId, alamat: "",
nomorKk: "",
kategoriId: "",
}); });
// State lokal form
const [formData, setFormData] = useState({
nama: '',
nik: '',
alamat: '',
nomorKk: '',
kategoriId: '',
});
// Load data awal
useEffect(() => { useEffect(() => {
stateLayananDesa.suratKeterangan.findManyAll.load(); stateLayananDesa.suratKeterangan.findManyAll.load();
const loadAjukan = async () => { const loadAjukan = async () => {
const id = params?.id as string; const id = params?.id as string;
if (!id) return; if (!id) return;
@@ -48,6 +61,13 @@ function EditAjukanPermohonan() {
nomorKk: data.nomorKk || '', nomorKk: data.nomorKk || '',
kategoriId: data.kategoriId || '', kategoriId: data.kategoriId || '',
}); });
setOriginalData({
nama: data.nama || '',
nik: data.nik || '',
alamat: data.alamat || '',
nomorKk: data.nomorKk || '',
kategoriId: data.kategoriId || '',
});
} }
} catch (error) { } catch (error) {
console.error('Error loading ajukan:', error); console.error('Error loading ajukan:', error);
@@ -58,8 +78,28 @@ function EditAjukanPermohonan() {
loadAjukan(); loadAjukan();
}, [params?.id]); }, [params?.id]);
// Handler untuk input controlled
const handleChange = (field: string, value: string) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
};
const handleResetForm = () => {
setFormData({
nama: originalData.nama,
nik: originalData.nik,
alamat: originalData.alamat,
nomorKk: originalData.nomorKk,
kategoriId: originalData.kategoriId,
});
toast.info("Form dikembalikan ke data awal");
};
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
setIsSubmitting(true);
stateAjukan.edit.form = { stateAjukan.edit.form = {
...stateAjukan.edit.form, ...stateAjukan.edit.form,
...formData, ...formData,
@@ -69,6 +109,8 @@ function EditAjukanPermohonan() {
} catch (error) { } catch (error) {
console.error('Error updating ajukan:', error); console.error('Error updating ajukan:', error);
toast.error('Terjadi kesalahan saat memperbarui ajukan'); toast.error('Terjadi kesalahan saat memperbarui ajukan');
} finally {
setIsSubmitting(false);
} }
}; };
@@ -76,11 +118,9 @@ function EditAjukanPermohonan() {
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Back Button */} {/* Back Button */}
<Group mb="md"> <Group mb="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md"> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} /> <IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
</Tooltip>
<Title order={4} ml="sm" c="dark"> <Title order={4} ml="sm" c="dark">
Edit Ajukan Permohonan Edit Ajukan Permohonan
</Title> </Title>
@@ -99,7 +139,7 @@ function EditAjukanPermohonan() {
label="Nama" label="Nama"
placeholder="Masukkan nama" placeholder="Masukkan nama"
value={formData.nama} value={formData.nama}
onChange={(e) => setFormData({ ...formData, nama: e.target.value })} onChange={(e) => handleChange('nama', e.target.value)}
required required
/> />
@@ -108,7 +148,7 @@ function EditAjukanPermohonan() {
label="NIK" label="NIK"
placeholder="Masukkan NIK" placeholder="Masukkan NIK"
value={formData.nik} value={formData.nik}
onChange={(e) => setFormData({ ...formData, nik: e.target.value })} onChange={(e) => handleChange('nik', e.target.value)}
required required
/> />
@@ -116,7 +156,7 @@ function EditAjukanPermohonan() {
label="Alamat" label="Alamat"
placeholder="Masukkan alamat" placeholder="Masukkan alamat"
value={formData.alamat} value={formData.alamat}
onChange={(e) => setFormData({ ...formData, alamat: e.target.value })} onChange={(e) => handleChange('alamat', e.target.value)}
required required
/> />
@@ -125,7 +165,7 @@ function EditAjukanPermohonan() {
label="Nomor KK" label="Nomor KK"
placeholder="Masukkan nomor KK" placeholder="Masukkan nomor KK"
value={formData.nomorKk} value={formData.nomorKk}
onChange={(e) => setFormData({ ...formData, nomorKk: e.target.value })} onChange={(e) => handleChange('nomorKk', e.target.value)}
required required
/> />
@@ -137,18 +177,7 @@ function EditAjukanPermohonan() {
value: item.id, value: item.id,
}))} }))}
value={formData.kategoriId || null} value={formData.kategoriId || null}
onChange={(val: string | null) => { onChange={(val) => handleChange('kategoriId', val || '')}
if (val) {
const selected = stateLayananDesa.suratKeterangan.findMany.data?.find(
(item) => item.id === val
);
if (selected) {
stateAjukan.edit.form.kategoriId = selected.id;
}
} else {
stateAjukan.edit.form.kategoriId = '';
}
}}
searchable searchable
clearable clearable
nothingFoundMessage="Tidak ditemukan" nothingFoundMessage="Tidak ditemukan"
@@ -156,6 +185,17 @@ function EditAjukanPermohonan() {
/> />
<Group justify="right"> <Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"
@@ -166,7 +206,7 @@ function EditAjukanPermohonan() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)', boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}} }}
> >
Simpan {isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -9,8 +9,7 @@ import {
Paper, Paper,
Skeleton, Skeleton,
Stack, Stack,
Text, Text
Tooltip
} from '@mantine/core'; } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react'; import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
@@ -79,7 +78,7 @@ function DetailAjukanPermohonan() {
<Text fz="lg" fw="bold"> <Text fz="lg" fw="bold">
Nama Nama
</Text> </Text>
<Text fz="md" c="dimmed"> <Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }}>
{data?.nama || '-'} {data?.nama || '-'}
</Text> </Text>
</Box> </Box>
@@ -97,7 +96,7 @@ function DetailAjukanPermohonan() {
<Text fz="lg" fw="bold"> <Text fz="lg" fw="bold">
Alamat Alamat
</Text> </Text>
<Text fz="md" c="dimmed"> <Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }}>
{data?.alamat || '-'} {data?.alamat || '-'}
</Text> </Text>
</Box> </Box>
@@ -121,7 +120,6 @@ function DetailAjukanPermohonan() {
</Box> </Box>
<Group gap="sm"> <Group gap="sm">
<Tooltip label="Hapus Surat" withArrow position="top">
<Button <Button
color="red" color="red"
onClick={() => { onClick={() => {
@@ -135,9 +133,7 @@ function DetailAjukanPermohonan() {
> >
<IconTrash size={20} /> <IconTrash size={20} />
</Button> </Button>
</Tooltip>
<Tooltip label="Edit Surat" withArrow position="top">
<Button <Button
color="green" color="green"
onClick={() => onClick={() =>
@@ -151,7 +147,6 @@ function DetailAjukanPermohonan() {
> >
<IconEdit size={20} /> <IconEdit size={20} />
</Button> </Button>
</Tooltip>
</Group> </Group>
</Stack> </Stack>
</Paper> </Paper>

View File

@@ -0,0 +1,192 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
import stateLayananDesa from '@/app/admin/(dashboard)/_state/desa/layananDesa';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
Text,
TextInput,
Title
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
function EditPelayananPendudukNonPermanent() {
const router = useRouter();
const params = useParams();
const statePendudukNonPermanent = useProxy(
stateLayananDesa.pelayananPendudukNonPermanen
);
const [formData, setFormData] = useState({
name: '',
deskripsi: '',
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
name: '',
deskripsi: '',
});
// Load data sekali dari backend
useEffect(() => {
const loadData = async () => {
const id = params?.id as string;
if (!id) return;
try {
const data = await statePendudukNonPermanent.update.load(id);
if (data) {
setFormData({
name: data.name || '',
deskripsi: data.deskripsi || '',
});
setOriginalData({
name: data.name || '',
deskripsi: data.deskripsi || '',
});
}
} catch (error) {
console.error('Error loading data:', error);
toast.error('Gagal memuat data pelayanan penduduk non permanent');
}
};
loadData();
}, [params?.id]);
const handleChange =
(field: keyof typeof formData) =>
(value: string) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
};
const handleResetForm = () => {
setFormData({
name: originalData.name,
deskripsi: originalData.deskripsi,
});
toast.info("Form dikembalikan ke data awal");
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
if (!statePendudukNonPermanent.findById.data) return;
// Update global state hanya di submit
const updated = {
...statePendudukNonPermanent.findById.data,
name: formData.name,
deskripsi: formData.deskripsi,
};
await statePendudukNonPermanent.update.update(updated);
router.push('/admin/desa/layanan/pelayanan_penduduk_non_permanent');
} catch (error) {
console.error('Error updating data:', error);
toast.error('Gagal memuat data pelayanan penduduk non permanent');
} finally {
setIsSubmitting(false);
}
};
return (
<Box>
<Stack gap="xs">
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Edit Pelayanan Penduduk Non Permanent
</Title>
</Group>
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="md"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="xs">
<Title order={3}>Edit Pelayanan Penduduk Non Permanent</Title>
{/* Nama Field */}
<TextInput
label="Judul"
placeholder="Masukkan judul"
value={formData.name}
onChange={(e) => handleChange('name')(e.target.value)}
required
/>
{/* Deskripsi Field */}
<Box>
<Text fz="sm" fw="bold">
Deskripsi
</Text>
<EditEditor
value={formData.deskripsi}
onChange={handleChange('deskripsi')}
/>
</Box>
{/* Submit Button */}
<Group justify="right">
{/* Tombol Batal */}
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Box>
);
}
export default EditPelayananPendudukNonPermanent;

View File

@@ -1,125 +0,0 @@
'use client'
/* eslint-disable react-hooks/exhaustive-deps */
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
import stateLayananDesa from '@/app/admin/(dashboard)/_state/desa/layananDesa';
import colors from '@/con/colors';
import { Box, Button, Group, Paper, Stack, Text, TextInput, Title, Tooltip } from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
function EditPelayananPendudukNonPermanent() {
const router = useRouter();
const params = useParams()
const statePendudukNonPermanent = useProxy(stateLayananDesa.pelayananPendudukNonPermanen)
const [formData, setFormData] = useState({
name: statePendudukNonPermanent.findById.data?.name || '',
deskripsi: statePendudukNonPermanent.findById.data?.deskripsi || '',
})
useEffect(() => {
const loadPelayananPerizinan = async () => {
const id = params?.id as string;
if (!id) return;
try {
const data = await statePendudukNonPermanent.update.load(id);
if (data) {
setFormData({
name: data.name || '',
deskripsi: data.deskripsi || '',
});
}
} catch (error) {
console.error("Error loading pelayanan perizinan berusaha:", error);
toast.error("Gagal memuat data pelayanan perizinan berusaha");
}
};
loadPelayananPerizinan();
}, [params?.id]);
const handleSubmit = async () => {
if (statePendudukNonPermanent.findById.data) {
statePendudukNonPermanent.findById.data.name = formData.name;
statePendudukNonPermanent.findById.data.deskripsi = formData.deskripsi;
statePendudukNonPermanent.update.update(statePendudukNonPermanent.findById.data)
}
router.push('/admin/desa/layanan/pelayanan_penduduk_non_permanent')
}
return (
<Box>
<Stack gap="xs">
<Group mb="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">
Edit Pelayanan Penduduk Non Permanent
</Title>
</Group>
<Paper
w={{ base: "100%", md: "50%" }}
bg={colors['white-1']}
p="md"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="xs">
<Title order={3}>Edit Pelayanan Penduduk Non Permanent</Title>
{/* Nama Field */}
<TextInput
label="Judul"
placeholder="Masukkan judul"
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
required
/>
{/* Posisi Field */}
<Box>
<Text fz="sm" fw="bold">
Deskripsi
</Text>
<EditEditor
value={formData.deskripsi}
onChange={(htmlContent) => {
setFormData((prev) => ({ ...prev, deskripsi: htmlContent }));
}}
/>
</Box>
{/* Submit Button */}
<Group>
<Button
bg={colors['blue-button']}
onClick={handleSubmit}
loading={statePendudukNonPermanent.update.loading}
disabled={!formData.name}
>
{statePendudukNonPermanent.update.loading ? 'Menyimpan...' : 'Simpan Perubahan'}
</Button>
<Button
variant="outline"
onClick={() => router.back()}
disabled={statePendudukNonPermanent.update.loading}
>
Batal
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Box>
);
}
export default EditPelayananPendudukNonPermanent;

View File

@@ -11,8 +11,7 @@ import {
Skeleton, Skeleton,
Stack, Stack,
Text, Text,
Title, Title
Tooltip,
} from '@mantine/core'; } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconEdit } from '@tabler/icons-react'; import { IconEdit } from '@tabler/icons-react';
@@ -27,7 +26,7 @@ function PelayananPendudukNonPermanent() {
); );
useShallowEffect(() => { useShallowEffect(() => {
pelayananPendudukNonPermanen.findById.load('1'); pelayananPendudukNonPermanen.findById.load('edit');
}, []); }, []);
if (!pelayananPendudukNonPermanen.findById.data) { if (!pelayananPendudukNonPermanen.findById.data) {
@@ -51,7 +50,6 @@ function PelayananPendudukNonPermanent() {
</Title> </Title>
</GridCol> </GridCol>
<GridCol span={{ base: 12, md: 1 }}> <GridCol span={{ base: 12, md: 1 }}>
<Tooltip label="Edit Data Pelayanan" withArrow>
<Button <Button
c="green" c="green"
variant="light" variant="light"
@@ -59,13 +57,12 @@ function PelayananPendudukNonPermanent() {
radius="md" radius="md"
onClick={() => onClick={() =>
router.push( router.push(
'/admin/desa/layanan/pelayanan_penduduk_non_permanent/edit' `/admin/desa/layanan/pelayanan_penduduk_non_permanent/${data.id}`
) )
} }
> >
Edit Edit
</Button> </Button>
</Tooltip>
</GridCol> </GridCol>
</Grid> </Grid>
@@ -91,6 +88,7 @@ function PelayananPendudukNonPermanent() {
ta="justify" ta="justify"
fz={{ base: '1rem', md: '1.2rem' }} fz={{ base: '1rem', md: '1.2rem' }}
dangerouslySetInnerHTML={{ __html: data.deskripsi }} dangerouslySetInnerHTML={{ __html: data.deskripsi }}
style={{wordBreak: "break-word", whiteSpace: "normal"}}
/> />
</Box> </Box>
</Box> </Box>

View File

@@ -0,0 +1,211 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
import stateLayananDesa from '@/app/admin/(dashboard)/_state/desa/layananDesa';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Skeleton,
Stack,
TextInput,
Title
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
function EditPelayananPerizinanBerusaha() {
const router = useRouter();
const params = useParams<{ id: string }>();
const id = params?.id; // ini langsung string
const state = useProxy(stateLayananDesa.pelayananPerizinanBerusaha);
const [loading, setLoading] = useState(true);
const [formData, setFormData] = useState({
id: '',
name: '',
deskripsi: '',
link: '',
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
id: '',
name: '',
deskripsi: '',
link: '',
});
// Load data detail
useEffect(() => {
if (!id) {
toast.error("ID tidak valid");
return;
}
const loadData = async () => {
try {
setLoading(true);
const data = await state.findById.load(id);
if (data) {
setFormData({
id: data.id,
name: data.name || "",
deskripsi: data.deskripsi || "",
link: data.link || "",
});
setOriginalData({
id: data.id,
name: data.name || "",
deskripsi: data.deskripsi || "",
link: data.link || "",
});
} else {
toast.error("Data tidak ditemukan");
}
} catch (error) {
console.error("Error loading data:", error);
toast.error("Gagal memuat data");
} finally {
setLoading(false);
}
};
loadData();
}, [id]);
const handleChange =
(field: keyof typeof formData) =>
(value: string) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
};
const handleResetForm = () => {
setFormData({
id: originalData.id,
name: originalData.name,
deskripsi: originalData.deskripsi,
link: originalData.link,
});
toast.info("Form dikembalikan ke data awal");
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
await state.update.update(formData);
router.push('/admin/desa/layanan/pelayanan_perizinan_berusaha');
} catch (error) {
console.error('Error updating pelayanan perizinan berusaha:', error);
toast.error('Terjadi kesalahan saat update data');
} finally {
setIsSubmitting(false);
}
};
if (loading) {
return (
<Stack align="center" justify="center" py="xl">
<Skeleton height={800} radius="md" />
</Stack>
);
}
return (
<Box>
<Stack gap="xs">
{/* Header */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Edit Pelayanan Perizinan Berusaha
</Title>
</Group>
{/* Form */}
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="md"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="xs">
<Title order={3}>Edit Pelayanan Perizinan Berusaha</Title>
<TextInput
label="Judul"
placeholder="Masukkan judul"
value={formData.name}
onChange={(e) => handleChange('name')(e.target.value)}
required
/>
<TextInput
label="Link"
placeholder="Masukkan link terkait"
value={formData.link}
onChange={(e) => handleChange('link')(e.target.value)}
/>
<Box>
<Title order={6}>Deskripsi</Title>
<EditEditor
value={formData.deskripsi}
onChange={handleChange('deskripsi')}
/>
</Box>
<Group justify="right">
{/* Tombol Batal */}
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Box>
);
}
export default EditPelayananPerizinanBerusaha;

View File

@@ -1,134 +0,0 @@
'use client'
/* eslint-disable react-hooks/exhaustive-deps */
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
import stateLayananDesa from '@/app/admin/(dashboard)/_state/desa/layananDesa';
import colors from '@/con/colors';
import { Box, Button, Group, Paper, Stack, TextInput, Title, Tooltip } from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
function EditPelayananPerizinanBerusaha() {
const router = useRouter();
const params = useParams()
const statePerizinanBerusaha = useProxy(stateLayananDesa.pelayananPerizinanBerusaha)
const [formData, setFormData] = useState({
name: statePerizinanBerusaha.findById.data?.name || '',
deskripsi: statePerizinanBerusaha.findById.data?.deskripsi || '',
link: statePerizinanBerusaha.findById.data?.link || '',
})
useEffect(() => {
const loadPelayananPerizinan = async () => {
const id = params?.id as string;
if (!id) return;
try {
const data = await statePerizinanBerusaha.update.load(id);
if (data) {
setFormData({
name: data.name || '',
deskripsi: data.deskripsi || '',
link: data.link || '',
});
}
} catch (error) {
console.error("Error loading pelayanan perizinan berusaha:", error);
toast.error("Gagal memuat data pelayanan perizinan berusaha");
}
};
loadPelayananPerizinan();
}, [params?.id]);
const handleSubmit = async () => {
if (statePerizinanBerusaha.findById.data) {
statePerizinanBerusaha.findById.data.name = formData.name;
statePerizinanBerusaha.findById.data.deskripsi = formData.deskripsi;
statePerizinanBerusaha.findById.data.link = formData.link;
statePerizinanBerusaha.update.update(statePerizinanBerusaha.findById.data)
}
router.push('/admin/desa/layanan/pelayanan_perizinan_berusaha')
}
return (
<Box>
<Stack gap="xs">
{/* Header Section */}
<Group mb="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Tooltip>
<Title order={4} ml="sm" c="dark">
Edit Pelayanan Perizinan Berusaha
</Title>
</Group>
{/* Form Section */}
<Paper
w={{ base: "100%", md: "50%" }}
bg={colors['white-1']}
p="md"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="xs">
<Title order={3}>Edit Pelayanan Perizinan Berusaha</Title>
{/* Nama Field */}
<TextInput
label="Judul"
placeholder="Masukkan judul"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
{/* Link Field */}
<TextInput
label="Link"
placeholder="Masukkan link terkait"
value={formData.link}
onChange={(e) => setFormData({ ...formData, link: e.target.value })}
/>
{/* Deskripsi Field */}
<Box>
<Title order={6}>Deskripsi</Title>
<EditEditor
value={formData.deskripsi}
onChange={(val) => setFormData({ ...formData, deskripsi: val })}
/>
</Box>
{/* Action Buttons */}
<Group>
<Button
bg={colors['blue-button']}
onClick={handleSubmit}
loading={statePerizinanBerusaha.update.loading}
disabled={!formData.name}
>
{statePerizinanBerusaha.update.loading ? 'Menyimpan...' : 'Simpan Perubahan'}
</Button>
<Button
variant="outline"
onClick={() => router.back()}
disabled={statePerizinanBerusaha.update.loading}
>
Batal
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Box>
);
}
export default EditPelayananPerizinanBerusaha;

View File

@@ -1,3 +1,4 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { import {
@@ -15,39 +16,61 @@ import {
StepperCompleted, StepperCompleted,
StepperStep, StepperStep,
Text, Text,
Title, Title
Tooltip,
} from '@mantine/core'; } from '@mantine/core';
import { IconEdit } from '@tabler/icons-react'; import { IconEdit } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import stateLayananDesa from '../../../_state/desa/layananDesa';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import { useShallowEffect } from '@mantine/hooks'; import stateLayananDesa from '../../../_state/desa/layananDesa';
function PerizinanBerusaha() { function PerizinanBerusaha() {
const router = useRouter(); const router = useRouter();
const pelayananPerizinanBerusaha = useProxy( const pelayananPerizinanBerusaha = useProxy(
stateLayananDesa.pelayananPerizinanBerusaha stateLayananDesa.pelayananPerizinanBerusaha
); );
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [active, setActive] = useState(1); const [active, setActive] = useState(1);
const nextStep = () => const nextStep = () =>
setActive((current) => (current < 6 ? current + 1 : current)); setActive((current) => (current < 6 ? current + 1 : current));
const prevStep = () => const prevStep = () =>
setActive((current) => (current > 0 ? current - 1 : current)); setActive((current) => (current > 0 ? current - 1 : current));
useShallowEffect(() => { useEffect(() => {
pelayananPerizinanBerusaha.findById.load('1'); const loadData = async () => {
try {
setLoading(true);
// You should get the ID from your router query or params
const id = 'edit'; // Replace with actual ID or get from URL params
await pelayananPerizinanBerusaha.findById.load(id);
} catch (err) {
setError('Gagal memuat data');
console.error('Error:', err);
} finally {
setLoading(false);
}
};
loadData();
}, []); }, []);
if (!pelayananPerizinanBerusaha.findById.data) { if (loading) {
return ( return (
<Stack align="center" justify="center" py="xl"> <Stack align="center" justify="center" py="xl">
<Skeleton radius="md" height={800} /> <Skeleton height={800} radius="md" />
</Stack> </Stack>
); );
} }
if (error || !pelayananPerizinanBerusaha.findById.data) {
return (
<Center h={200}>
<Text>{error || 'Data tidak ditemukan'}</Text>
</Center>
);
}
const data = pelayananPerizinanBerusaha.findById.data; const data = pelayananPerizinanBerusaha.findById.data;
return ( return (
@@ -61,7 +84,6 @@ function PerizinanBerusaha() {
</Title> </Title>
</GridCol> </GridCol>
<GridCol span={{ base: 12, md: 1 }}> <GridCol span={{ base: 12, md: 1 }}>
<Tooltip label="Edit Data Perizinan" withArrow>
<Button <Button
c="green" c="green"
variant="light" variant="light"
@@ -69,13 +91,12 @@ function PerizinanBerusaha() {
radius="md" radius="md"
onClick={() => onClick={() =>
router.push( router.push(
'/admin/desa/layanan/pelayanan_perizinan_berusaha/edit' `/admin/desa/layanan/pelayanan_perizinan_berusaha/${data.id}`
) )
} }
> >
Edit Edit
</Button> </Button>
</Tooltip>
</GridCol> </GridCol>
</Grid> </Grid>
@@ -101,6 +122,7 @@ function PerizinanBerusaha() {
ta="justify" ta="justify"
fz={{ base: '1rem', md: '1.2rem' }} fz={{ base: '1rem', md: '1.2rem' }}
dangerouslySetInnerHTML={{ __html: data.deskripsi }} dangerouslySetInnerHTML={{ __html: data.deskripsi }}
style={{wordBreak: "break-word", whiteSpace: "normal"}}
/> />
<Text <Text
@@ -182,3 +204,4 @@ function PerizinanBerusaha() {
} }
export default PerizinanBerusaha; export default PerizinanBerusaha;

View File

@@ -1,62 +1,178 @@
'use client' /* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable react-hooks/exhaustive-deps */ 'use client';
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor'; import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
import stateLayananDesa from '@/app/admin/(dashboard)/_state/desa/layananDesa'; import stateLayananDesa from '@/app/admin/(dashboard)/_state/desa/layananDesa';
import colors from '@/con/colors'; import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch'; import ApiFetch from '@/lib/api-fetch';
import { import {
ActionIcon,
Box, Box,
Button, Button,
Group, Group,
Image, Image,
Loader,
Paper, Paper,
Stack, Stack,
Text, Text,
TextInput, TextInput,
Title, Title,
Tooltip,
} from '@mantine/core'; } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone'; import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react'; import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import * as React from 'react';
// 🔹 Types
interface FormData {
name: string;
deskripsi: string;
imageId: string;
image2Id: string;
imageUrl: string;
image2Url: string;
}
interface FileUploaderProps {
title: string;
file: File | null;
setFile: React.Dispatch<React.SetStateAction<File | null>>;
preview: string | null;
setPreview: React.Dispatch<React.SetStateAction<string | null>>;
}
// 🔹 File Uploader Component
const FileUploader: React.FC<FileUploaderProps> = ({
title,
file,
setFile,
preview,
setPreview
}) => {
const handleDrop = (files: File[]) => {
const selected = files[0];
if (selected) {
setFile(selected);
setPreview(URL.createObjectURL(selected));
}
};
const handleRemove = () => {
setPreview(null);
setFile(null);
};
return (
<Box>
<Text fw="bold" fz="sm" mb={6}>
{title}
</Text>
<Dropzone
onDrop={handleDrop}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept>
<IconUpload size={48} color={colors['blue-button']} stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="red" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle>
<Stack gap="xs" align="center">
<Text size="md" fw={500}>
Seret gambar atau klik untuk memilih file
</Text>
<Text size="sm" c="dimmed">
Maksimal 5MB, format .png, .jpg, .jpeg, .webp
</Text>
</Stack>
</Group>
</Dropzone>
{preview && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={preview}
alt="Preview Gambar"
radius="md"
style={{
maxHeight: 200,
objectFit: 'contain',
border: '1px solid #ddd',
}}
/>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={handleRemove}
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
);
};
// 🔹 Main Component
function EditSuratKeterangan() { function EditSuratKeterangan() {
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const stateSurat = useProxy(stateLayananDesa.suratKeterangan);
const [previewImage, setPreviewImage] = useState<string | null>(null); // 🧩 State
const [previewImage2, setPreviewImage2] = useState<string | null>(null); const [formData, setFormData] = useState<FormData>({
name: '',
deskripsi: '',
imageId: '',
image2Id: '',
imageUrl: '',
image2Url: '',
});
const [originalData, setOriginalData] = useState<FormData>(formData);
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [file2, setFile2] = useState<File | null>(null); const [file2, setFile2] = useState<File | null>(null);
const [formData, setFormData] = useState({ const [previewImage, setPreviewImage] = useState<string | null>(null);
name: stateSurat.edit.form.name, const [previewImage2, setPreviewImage2] = useState<string | null>(null);
deskripsi: stateSurat.edit.form.deskripsi, const [isSubmitting, setIsSubmitting] = useState(false);
imageId: stateSurat.edit.form.imageId,
image2Id: stateSurat.edit.form.image2Id,
});
// 🧭 Load Initial Data
useEffect(() => { useEffect(() => {
const loadSurat = async () => { const loadSurat = async () => {
const id = params?.id as string; const id = params?.id as string;
if (!id) return; if (!id) return;
try { try {
const data = await stateSurat.edit.load(id); const data = await stateLayananDesa.suratKeterangan.edit.load(id);
if (data) { if (!data) return;
setFormData({
const mapped: FormData = {
name: data.name || '', name: data.name || '',
deskripsi: data.deskripsi || '', deskripsi: data.deskripsi || '',
imageId: data.imageId || '', imageId: data.imageId || '',
image2Id: data.image2Id || '', image2Id: data.image2Id || '',
}); imageUrl: data.image?.link || '',
image2Url: data.image2?.link || ''
};
setPreviewImage(data.image?.link || null); setFormData(mapped);
setPreviewImage2(data.image2?.link || null); setOriginalData(mapped);
}
if (data.image?.link) setPreviewImage(data.image.link);
if (data.image2?.link) setPreviewImage2(data.image2.link);
} catch (error) { } catch (error) {
console.error('Error loading surat:', error); console.error('Error loading surat:', error);
toast.error('Gagal memuat data surat'); toast.error('Gagal memuat data surat');
@@ -66,50 +182,99 @@ function EditSuratKeterangan() {
loadSurat(); loadSurat();
}, [params?.id]); }, [params?.id]);
const handleSubmit = async () => { // 📤 Upload File Helper
const uploadFile = async (file: File): Promise<string | null> => {
try { try {
stateSurat.edit.form = { const res = await ApiFetch.api.fileStorage.create.post({
...stateSurat.edit.form, file,
...formData, name: file.name,
});
const uploaded = res.data?.data;
return uploaded?.id || null;
} catch (error) {
console.error('Error uploading file:', error);
return null;
}
}; };
// 🔁 Reset Form
const handleResetForm = () => {
setFormData(originalData);
setPreviewImage(originalData.imageUrl || null);
setPreviewImage2(originalData.image2Url || null);
setFile(null);
setFile2(null);
toast.info('Form dikembalikan ke data awal');
};
// 💾 Submit Handler
const handleSubmit = useCallback(async () => {
try {
setIsSubmitting(true);
// ✅ Access original state directly (not proxy)
const originalState = stateLayananDesa.suratKeterangan;
// Update form data properties individually
originalState.edit.form.name = formData.name;
originalState.edit.form.deskripsi = formData.deskripsi;
originalState.edit.form.imageId = formData.imageId;
originalState.edit.form.image2Id = formData.image2Id;
// Upload file 1 if exists
if (file) { if (file) {
const res = await ApiFetch.api.fileStorage.create.post({ file, name: file.name }); const uploadedId = await uploadFile(file);
const uploaded = res.data?.data; if (!uploadedId) {
if (!uploaded?.id) return toast.error('Gagal upload gambar'); toast.error('Gagal upload gambar pertama');
stateSurat.edit.form.imageId = uploaded.id; return;
}
originalState.edit.form.imageId = uploadedId;
} }
// Upload file 2 if exists
if (file2) { if (file2) {
const res = await ApiFetch.api.fileStorage.create.post({ file: file2, name: file2.name }); const uploadedId = await uploadFile(file2);
const uploaded = res.data?.data; if (!uploadedId) {
if (!uploaded?.id) return toast.error('Gagal upload gambar'); toast.error('Gagal upload gambar kedua');
stateSurat.edit.form.image2Id = uploaded.id; return;
}
originalState.edit.form.image2Id = uploadedId;
} }
await stateSurat.edit.update(); // Submit update
await originalState.edit.update();
toast.success('Surat berhasil diperbarui!'); toast.success('Surat berhasil diperbarui!');
router.push('/admin/desa/layanan/pelayanan_surat_keterangan'); router.push('/admin/desa/layanan/pelayanan_surat_keterangan');
} catch (error) { } catch (error) {
console.error('Error updating surat:', error); console.error('Error updating surat:', error);
toast.error('Terjadi kesalahan saat memperbarui surat'); toast.error('Terjadi kesalahan saat memperbarui surat');
} finally {
setIsSubmitting(false);
} }
}, [formData, file, file2, router]);
// 📝 Form Field Handlers
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData(prev => ({ ...prev, name: e.target.value }));
};
const handleDeskripsiChange = (html: string) => {
setFormData(prev => ({ ...prev, deskripsi: html }));
}; };
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Back Button */} {/* Header */}
<Group mb="md"> <Group mb="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md"> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} /> <IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
</Tooltip>
<Title order={4} ml="sm" c="dark"> <Title order={4} ml="sm" c="dark">
Edit Surat Keterangan Edit Surat Keterangan
</Title> </Title>
</Group> </Group>
{/* Form */}
<Paper <Paper
w={{ base: '100%', md: '50%' }} w={{ base: '100%', md: '50%' }}
bg={colors['white-1']} bg={colors['white-1']}
@@ -119,150 +284,66 @@ function EditSuratKeterangan() {
style={{ border: '1px solid #e0e0e0' }} style={{ border: '1px solid #e0e0e0' }}
> >
<Stack gap="md"> <Stack gap="md">
{/* Nama Surat */}
<TextInput <TextInput
label="Nama Surat Keterangan" label="Nama Surat Keterangan"
placeholder="Masukkan nama surat keterangan" placeholder="Masukkan nama surat keterangan"
value={formData.name} value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })} onChange={handleNameChange}
required required
/> />
{/* Deskripsi */}
<Box> <Box>
<Text fz="sm" fw="bold" mb={6}> <Text fz="sm" fw="bold" mb={6}>
Konten Konten
</Text> </Text>
<EditEditor <EditEditor
value={formData.deskripsi} value={formData.deskripsi}
onChange={(htmlContent) => setFormData({ ...formData, deskripsi: htmlContent })} onChange={handleDeskripsiChange}
/> />
</Box> </Box>
{/* Upload Gambar 1 */} {/* Gambar 1 */}
<Box> <FileUploader
<Text fw="bold" fz="sm" mb={6}> title="Gambar Konten Pelayanan"
Gambar Konten Pelayanan file={file}
</Text> setFile={setFile}
<Dropzone preview={previewImage}
onDrop={(files) => { setPreview={setPreviewImage}
const selectedFile = files[0];
if (selectedFile) {
setFile(selectedFile);
setPreviewImage(URL.createObjectURL(selectedFile));
}
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': [] }}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept>
<IconUpload size={48} color={colors['blue-button']} stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="red" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle>
<Stack gap="xs" align="center">
<Text size="md" fw={500}>
Seret gambar atau klik untuk memilih file
</Text>
<Text size="sm" c="dimmed">
Maksimal 5MB, format gambar wajib
</Text>
</Stack>
</Group>
</Dropzone>
{previewImage && (
<Box mt="sm" style={{ display: 'flex', justifyContent: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar 1"
radius="md"
style={{
maxHeight: 220,
objectFit: 'contain',
border: `1px solid ${colors['blue-button']}`,
}}
loading="lazy"
/> />
</Box>
)}
</Box>
{/* Upload Gambar 2 */} {/* Gambar 2 */}
<Box> <FileUploader
<Text fw="bold" fz="sm" mb={6}> title="Gambar Alur Pelayanan Surat"
Gambar Alur Pelayanan Surat file={file2}
</Text> setFile={setFile2}
<Dropzone preview={previewImage2}
onDrop={(files) => { setPreview={setPreviewImage2}
const selectedFile = files[0];
if (selectedFile) {
setFile2(selectedFile);
setPreviewImage2(URL.createObjectURL(selectedFile));
}
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': [] }}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept>
<IconUpload size={48} color={colors['blue-button']} stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="red" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle>
<Stack gap="xs" align="center">
<Text size="md" fw={500}>
Seret gambar atau klik untuk memilih file
</Text>
<Text size="sm" c="dimmed">
Maksimal 5MB, format gambar wajib
</Text>
</Stack>
</Group>
</Dropzone>
{previewImage2 && (
<Box mt="sm" style={{ display: 'flex', justifyContent: 'center' }}>
<Image
src={previewImage2}
alt="Preview Gambar 2"
radius="md"
style={{
maxHeight: 220,
objectFit: 'contain',
border: `1px solid ${colors['blue-button']}`,
}}
loading="lazy"
/> />
</Box>
)}
</Box>
{/* Action Buttons */}
<Group justify="right"> <Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
onClick={handleResetForm}
disabled={isSubmitting}
>
Batal
</Button>
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"
size="md" disabled={isSubmitting}
style={{ style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`, background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff', color: '#fff',
boxShadow: '0 4px 15px rgba(79,172,254,0.4)', boxShadow: '0 4px 15px rgba(79,172,254,0.4)',
}} }}
> >
Simpan {isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -10,8 +10,7 @@ import {
Paper, Paper,
Skeleton, Skeleton,
Stack, Stack,
Text, Text
Tooltip,
} from '@mantine/core'; } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react'; import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
@@ -95,6 +94,7 @@ function DetailSuratKeterangan() {
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: data?.deskripsi || '-', __html: data?.deskripsi || '-',
}} }}
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
/> />
</Box> </Box>
@@ -141,7 +141,6 @@ function DetailSuratKeterangan() {
</Box> </Box>
<Group gap="sm"> <Group gap="sm">
<Tooltip label="Hapus Surat" withArrow position="top">
<Button <Button
color="red" color="red"
onClick={() => { onClick={() => {
@@ -155,9 +154,7 @@ function DetailSuratKeterangan() {
> >
<IconTrash size={20} /> <IconTrash size={20} />
</Button> </Button>
</Tooltip>
<Tooltip label="Edit Surat" withArrow position="top">
<Button <Button
color="green" color="green"
onClick={() => onClick={() =>
@@ -171,7 +168,6 @@ function DetailSuratKeterangan() {
> >
<IconEdit size={20} /> <IconEdit size={20} />
</Button> </Button>
</Tooltip>
</Group> </Group>
</Stack> </Stack>
</Paper> </Paper>

View File

@@ -5,16 +5,17 @@ import stateLayananDesa from '@/app/admin/(dashboard)/_state/desa/layananDesa';
import colors from '@/con/colors'; import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch'; import ApiFetch from '@/lib/api-fetch';
import { import {
ActionIcon,
Box, Box,
Button, Button,
Group, Group,
Image, Image,
Loader,
Paper, Paper,
Stack, Stack,
Text, Text,
TextInput, TextInput,
Title, Title
Tooltip,
} from '@mantine/core'; } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone'; import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react'; import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
@@ -28,6 +29,7 @@ function CreateSuratKeterangan() {
const [previewImage2, setPreviewImage2] = useState<{ preview: string; file: File } | null>(null); const [previewImage2, setPreviewImage2] = useState<{ preview: string; file: File } | null>(null);
const [previewImage, setPreviewImage] = useState<{ preview: string; file: File } | null>(null); const [previewImage, setPreviewImage] = useState<{ preview: string; file: File } | null>(null);
const router = useRouter(); const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => { const resetForm = () => {
stateSurat.create.form = { stateSurat.create.form = {
@@ -46,6 +48,7 @@ function CreateSuratKeterangan() {
} }
try { try {
setIsSubmitting(true);
// Upload gambar utama // Upload gambar utama
const res1 = await ApiFetch.api.fileStorage.create.post({ const res1 = await ApiFetch.api.fileStorage.create.post({
file: previewImage.file, file: previewImage.file,
@@ -78,6 +81,8 @@ function CreateSuratKeterangan() {
} catch (error) { } catch (error) {
console.error('Error creating surat keterangan:', error); console.error('Error creating surat keterangan:', error);
toast.error('Terjadi kesalahan saat menambahkan surat keterangan'); toast.error('Terjadi kesalahan saat menambahkan surat keterangan');
} finally {
setIsSubmitting(false);
} }
}; };
@@ -85,11 +90,9 @@ function CreateSuratKeterangan() {
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header */} {/* Header */}
<Group mb="md"> <Group mb="md">
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md"> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} /> <IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
</Tooltip>
<Title order={4} ml="sm" c="dark"> <Title order={4} ml="sm" c="dark">
Tambah Surat Keterangan Tambah Surat Keterangan
</Title> </Title>
@@ -108,7 +111,7 @@ function CreateSuratKeterangan() {
<TextInput <TextInput
value={stateSurat.create.form.name} value={stateSurat.create.form.name}
onChange={(val) => (stateSurat.create.form.name = val.target.value)} onChange={(val) => (stateSurat.create.form.name = val.target.value)}
label={<Text fz="sm" fw="bold">Nama Surat Keterangan</Text>} label="Nama Surat Keterangan"
placeholder="Masukkan nama surat keterangan" placeholder="Masukkan nama surat keterangan"
required required
/> />
@@ -143,7 +146,7 @@ function CreateSuratKeterangan() {
}} }}
onReject={() => toast.error('File tidak valid, gunakan format gambar')} onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2} maxSize={5 * 1024 ** 2}
accept={{ 'image/*': [] }} accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md" radius="md"
p="xl" p="xl"
> >
@@ -164,7 +167,7 @@ function CreateSuratKeterangan() {
</Dropzone> </Dropzone>
{previewImage && ( {previewImage && (
<Box mt="sm" style={{ textAlign: 'center' }}> <Box pos={"relative"} mt="sm" style={{ textAlign: 'center' }}>
<Image <Image
src={previewImage.preview} src={previewImage.preview}
alt="Preview Gambar Utama" alt="Preview Gambar Utama"
@@ -172,6 +175,23 @@ function CreateSuratKeterangan() {
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }} style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
loading="lazy" loading="lazy"
/> />
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box> </Box>
)} )}
</Box> </Box>
@@ -193,7 +213,7 @@ function CreateSuratKeterangan() {
}} }}
onReject={() => toast.error('File tidak valid, gunakan format gambar')} onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2} maxSize={5 * 1024 ** 2}
accept={{ 'image/*': [] }} accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md" radius="md"
p="xl" p="xl"
> >
@@ -214,7 +234,7 @@ function CreateSuratKeterangan() {
</Dropzone> </Dropzone>
{previewImage2 ? ( {previewImage2 ? (
<Box mt="sm" style={{ textAlign: 'center' }}> <Box pos={"relative"} mt="sm" style={{ textAlign: 'center' }}>
<Image <Image
src={previewImage2.preview} src={previewImage2.preview}
alt="Preview Gambar Tambahan" alt="Preview Gambar Tambahan"
@@ -222,6 +242,23 @@ function CreateSuratKeterangan() {
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }} style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
loading="lazy" loading="lazy"
/> />
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage2(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box> </Box>
) : ( ) : (
<Text size="sm" c="dimmed" mt="sm" ta="center"> <Text size="sm" c="dimmed" mt="sm" ta="center">
@@ -232,6 +269,17 @@ function CreateSuratKeterangan() {
{/* Tombol Simpan */} {/* Tombol Simpan */}
<Group justify="right"> <Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"
@@ -242,7 +290,7 @@ function CreateSuratKeterangan() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)', boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}} }}
> >
Simpan {isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -17,8 +17,7 @@ import {
TableThead, TableThead,
TableTr, TableTr,
Text, Text,
Title, Title
Tooltip
} from '@mantine/core'; } from '@mantine/core';
import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react'; import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
@@ -82,7 +81,6 @@ function ListSuratKeterangan({ search }: { search: string }) {
<Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md"> <Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
<Group justify="space-between" mb="md"> <Group justify="space-between" mb="md">
<Title order={4}>List Surat Keterangan</Title> <Title order={4}>List Surat Keterangan</Title>
<Tooltip label="Tambah Surat Keterangan" withArrow>
<Button <Button
leftSection={<IconPlus size={18} />} leftSection={<IconPlus size={18} />}
color="blue" color="blue"
@@ -93,7 +91,6 @@ function ListSuratKeterangan({ search }: { search: string }) {
> >
Tambah Baru Tambah Baru
</Button> </Button>
</Tooltip>
</Group> </Group>
<Box style={{ overflowX: "auto" }}> <Box style={{ overflowX: "auto" }}>
<Table highlightOnHover> <Table highlightOnHover>
@@ -119,6 +116,7 @@ function ListSuratKeterangan({ search }: { search: string }) {
<Box w={200}> <Box w={200}>
<Text truncate="end" lineClamp={1} fz="sm" c="dimmed" <Text truncate="end" lineClamp={1} fz="sm" c="dimmed"
dangerouslySetInnerHTML={{ __html: item.deskripsi }} dangerouslySetInnerHTML={{ __html: item.deskripsi }}
style={{wordBreak: "break-word", whiteSpace: "normal"}}
/> />
</Box> </Box>
</TableTd> </TableTd>

Some files were not shown because too many files have changed in this diff Show More