Compare commits

..

62 Commits

Author SHA1 Message Date
f2a37115a3 Merge tasks/auth/fix-session-password-fallback/20260608 into stg (fix SESSION_PASSWORD hardcoded fallback) 2026-06-08 16:30:12 +08:00
bf9c37ff98 fix(auth): hapus fallback SESSION_PASSWORD yang hardcoded, throw error saat startup jika tidak di-set
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 16:30:01 +08:00
8f2081cc77 Merge tasks/auth/fix-otp-csprng/20260608 into stg (fix OTP CSPRNG) 2026-06-08 16:15:42 +08:00
c5bbafa20a fix(auth): ganti Math.random() dengan crypto.randomInt untuk OTP
Math.random() bukan CSPRNG sehingga OTP dapat diprediksi. Diganti dengan
crypto.randomInt(1000, 10000) dari Node.js built-in crypto module.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 16:15:39 +08:00
b0a020871f chore: bump version to 0.1.69 for stg deploy 2026-05-30 15:05:13 +08:00
f35833078b feat(seeder): distribusi banjarId merata di grafik kepuasan, tambah 3 data baru
- Perbaiki duplikasi banjar_pudak_amara_001 (item 004 → banjar_anggrek_001)
- Tambah item 006-008 agar semua 8 banjar terwakili
  - cmk-kepuasan-006: Ni Nyoman Sari / Hipertensi → banjar_kamboja_001
  - cmk-kepuasan-007: I Made Darmawan / Diabetes → banjar_melur_001
  - cmk-kepuasan-008: Ni Kadek Ayu Lestari / Asma → banjar_kenanga_001

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 15:00:12 +08:00
04c8ce81c1 chore: bump version to 0.1.68 for stg deploy 2026-05-30 11:22:19 +08:00
2b1dd5fb71 Merge tasks/kesehatan/banjar-penderita-penyakit/20260530 into stg (seeder update) 2026-05-30 11:13:05 +08:00
4c45c679ce feat(seeder): tambah banjarId ke data dan seeder grafik kepuasan
- Update grafik-kepuasan.json: tambah banjarId ke setiap item
- Update seed_grafik_kepuasan.ts: upsert banjarId dari JSON

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 11:13:01 +08:00
0f5c905392 Merge tasks/kesehatan/banjar-penderita-penyakit/20260530 into stg 2026-05-30 11:09:02 +08:00
a67c192c83 feat(kesehatan): tambah relasi banjar ke penderita penyakit dan CRUD banjar admin
- Tambah field banjarId (optional) ke GrafikKepuasan + migration
- API CRUD banjar baru di /api/desa/banjar/*
- Update API grafik_kepuasan: create, find-many (filter by banjar), findUnique, updt - semua include banjar
- State: tambah banjarId ke form, banjarList proxy, filter by banjarId
- UI list: kolom Banjar di tabel desktop, kartu mobile, Select filter by banjar
- UI create/edit: Select banjar (opsional), load banjarList on mount
- UI detail: tampilkan field Banjar
- Admin banjar: halaman list, create, detail
- Sidebar: menu Banjar di domain Desa

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 11:08:58 +08:00
be3365c9e3 chore: bump version to 0.1.67 for stg deploy 2026-05-28 16:23:04 +08:00
d0b559f774 Merge tasks/kesehatan/posyandu-banjar-publik-fix-tips-keamanan/20260528 into stg 2026-05-28 15:51:36 +08:00
97d08734c5 feat(kesehatan): posyandu banjar relation, redesign halaman publik, fix tips keamanan image
- Tambah model Banjar + relasi ke Posyandu (migration + seeder)
- Update API posyandu (create/update/find) untuk support banjarId
- Tambah endpoint banjar di kesehatan API
- Redesign halaman publik posyandu dengan tabs: ringkasan, data posyandu, balita, ibu hamil
- Update halaman admin posyandu list/create/edit/detail untuk banjar
- Fix image ketukar pada seed tips keamanan
- Hapus seeder core yang sudah tidak dipakai

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 15:51:32 +08:00
9de32e5f12 chore: bump version to 0.1.66 for stg deploy 2026-05-21 16:34:56 +08:00
3a837e1fdc Merge tasks/event-budaya/feat-seeder-pagination-fix-duplicate/20260521 into stg 2026-05-21 16:28:18 +08:00
e9e7c17ee3 feat(desa): Kalender Event Budaya — fitur admin/public, seeder, pagination, fix duplicate options
- Tambah fitur Kalender Event Budaya di admin CMS (list, detail, edit, hapus)
- Tambah state Valtio (create, findMany, findUnique, edit, delete, findUpcoming)
- Tambah endpoint API /find-upcoming untuk event mendatang
- Tambah halaman public /darmasaba/desa/event-budaya dengan pagination 5 data/halaman
- Switch public page dari findUpcoming ke findMany agar pagination berjalan
- Tambah menu "Kalender Event Budaya" di navbar (id: 2.9)
- Perluas seeder event budaya: 8 → 34 events mencakup 2025-2026
- Fix: deduplikasi kategoriOptions di kegiatan-desa public page (Mantine Select error)
- Hapus STRUKTUR.md yang sudah tidak relevan

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 16:28:13 +08:00
0a3dc1dc43 chore: bump version to 0.1.65 for stg deploy 2026-05-19 11:28:58 +08:00
7200caa125 Disable Dark Mode 2026-05-19 11:23:49 +08:00
4ad86b4cad chore: bump version to 0.1.64 for stg deploy 2026-05-19 10:23:07 +08:00
45cd1ca6f8 Fix Seeder APBDes V3 2026-05-19 10:19:35 +08:00
ce3ac75529 chore: bump version to 0.1.63 for stg deploy 2026-05-18 17:09:36 +08:00
d716a59ca5 Tambah Seeder V2 2026-05-18 16:57:13 +08:00
13b08aa057 chore: bump version to 0.1.61 for stg deploy 2026-05-18 15:34:48 +08:00
3e5cf9e2fb Ubah Seeder 2026-05-18 15:30:08 +08:00
71562961e8 chore: bump version to 0.1.60 for stg deploy 2026-05-07 15:37:12 +08:00
f52981c9b0 merge: feat(keamanan) tambah halaman publik CCTV dengan peta interaktif 2026-05-07 15:33:38 +08:00
20d1b9aa4b feat(keamanan): tambah halaman publik CCTV dengan peta interaktif
- Buat halaman publik /darmasaba/keamanan/cctv dengan grid card + search + pagination
- Tambah CctvMapSection (Leaflet, SSR=false) — marker hijau=Online, merah=Offline
- Popup marker menampilkan kode, nama, lokasi, dan status CCTV
- Tambah submenu CCTV (id 4.7) di navbar menu Keamanan

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 15:33:35 +08:00
5e1b913e04 chore: bump version to 0.1.59 for stg deploy 2026-05-07 12:41:14 +08:00
aef07fab8e merge: fix(pendidikan) perbaiki jumlahPengajar & jumlahSiswa per jenjang di stats 2026-05-07 12:34:30 +08:00
27a42f7ff0 fix(pendidikan): perbaiki jumlahPengajar & jumlahSiswa per jenjang di stats
- Fix sintaks _count.select: gabungkan siswa & pengajar dalam satu object select
- Tambah jumlahPengajar per jenjang via reduce _count.pengajar
- Update type PerJenjang di stats.ts & ringkasan-pendidikan.ts
- Tambah tasks ke tasks-sample.csv

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 12:34:20 +08:00
9e213ade8e merge: feat(beasiswa) dynamic stats penerima & dana tersalurkan + formatDana 2026-05-07 11:35:12 +08:00
ea70f6f6e9 feat(beasiswa): dynamic stats penerima & dana tersalurkan dari API + formatDana helper
- Ganti static dataBeasiswa dengan data live dari ringkasanBeasiswaState.findStats
- Tambah formatDana() untuk konversi angka ke Rb/Jt/M/T
- Tambah tasks ke tasks-sample.csv

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 11:34:59 +08:00
da0a98d989 tasks/fix-findUnique-beasiswa-config 2026-05-06 17:01:01 +08:00
7bdf1357ad merge: feat(keamanan) tambah modul CCTV — schema, API, admin UI, seeder 2026-05-06 16:40:35 +08:00
936dd14ca9 feat(keamanan): tambah modul CCTV — schema, API, admin UI, seeder
- Tambah model CctvKeamanan + enum StatusCctv ke prisma schema
- Tambah status Baru ke enum StatusLaporan
- Migration: add_cctv_keamanan_model
- API CRUD + stats endpoint di /api/keamanan/cctv/...
- Admin state (valtio proxy) dengan create/findMany/edit/delete/stats
- Admin pages: list, create, detail (peta Leaflet), edit (peta picker)
- Seeder 8 data CCTV lokasi Darmasaba
- Tambah submenu CCTV di sidebar nav keamanan
- Bump version 0.1.57 → 0.1.58

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 16:40:31 +08:00
60841039dd chore: bump version to 0.1.57 for stg deploy 2026-05-06 12:08:19 +08:00
16b9b74a73 merge: feat(beasiswa) tambah UI konfigurasi beasiswa di admin 2026-05-06 11:52:02 +08:00
c0b08f4f69 feat(beasiswa): tambah UI konfigurasi beasiswa di admin pendidikan
- Tambah tab "Konfigurasi Beasiswa" di layoutTabs beasiswa-desa
- Buat halaman beasiswa-config/page.tsx dengan stats card (penerima,
  dana, tahun ajaran) + form edit tahunAjaran & danaTersalurkan

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 11:51:57 +08:00
7b14923620 merge: fix(beasiswa) BigInt serialization error pada ringkasan stats 2026-05-06 11:17:52 +08:00
3cc09c83d8 fix(beasiswa): konversi danaTersalurkan BigInt ke string sebelum JSON serialize
Elysia tidak bisa serialize BigInt ke JSON — ubah return type danaTersalurkan
dari bigint ke string dengan .toString() di beasiswaRingkasanStats.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 11:17:48 +08:00
5658063f68 merge: feat(pendidikan) tambah state ringkasan & expand seeder beasiswa 2026-05-06 11:03:20 +08:00
d7e1192ab0 feat(pendidikan): tambah state ringkasan pendidikan & beasiswa + expand seeder beasiswa 45 entry
- Tambah ringkasan-pendidikan.ts: state valtio fetch GET /api/pendidikan/ringkasan/stats
- Tambah ringkasan-beasiswa.ts: state valtio fetch ringkasan stats + beasiswaConfig find/update
- Expand beasiswa-pendaftar.json dari 3 → 45 entry (nama Bali, NIK unik, enum valid)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 11:03:11 +08:00
8857853baf ci: tambah stack_name input opsional di publish.yml 2026-05-05 16:34:49 +08:00
ce26bc7cc8 chore: bump version to 0.1.56 for stg deploy 2026-05-05 16:25:25 +08:00
b479991c27 merge: refactor(ui) posyandu balita & ibu-hamil penghargaan pattern 2026-05-05 16:12:51 +08:00
e71c938b2f refactor(ui): sesuaikan UI balita & ibu-hamil dengan pola penghargaan
- Gunakan HeaderSearch + dua-komponen pattern (outer + inner list)
- Ganti Loader → Skeleton h={600}, ActionIcon → Button size="xs" variant="light"
- Tambah Paper wrapper, layout="fixed" table, desktop/mobile responsive split
- Search debounce 1000ms via useDebouncedValue

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 16:12:46 +08:00
ff25ead2df feat(sosial-dashboard): tambah API ringkasan pendidikan & beasiswa + CRUD event budaya - bump 0.1.55
- API GET /api/pendidikan/ringkasan/stats: siswa per jenjang, jumlah lembaga & pengajar
- API GET /api/pendidikan/beasiswa/ringkasan/stats: jumlah penerima, dana, tahun ajaran
- Schema + migration: model EventBudaya (nama, tanggal, lokasi, deskripsi)
- API CRUD /api/desa/eventbudaya: create, find-many, findUnique, updt, del
- State admin: eventBudaya.ts (valtio proxy, create/findMany/edit/delete)
- Admin CMS: /admin/desa/event-budaya (list, create, edit)
- Navbar: tambah entry Desa_9 Event Budaya di semua role
- Seeder: 8 event budaya Bali untuk Desa Darmasaba

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 15:25:34 +08:00
2497298703 chore: bump version to 0.1.54 for stg deploy 2026-05-05 14:14:19 +08:00
ba632f9d39 merge: fix(kesehatan) konsolidasi posyandu tabs - bump 0.1.53 2026-05-05 12:26:23 +08:00
f1ee53a7b9 fix(kesehatan): konsolidasi balita, ibu-hamil, ringkasan-kesehatan ke dalam posyandu tabs + fix semua routing path - bump 0.1.53
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 12:26:18 +08:00
fd2060405f feat(kesehatan): slim ringkasan kesehatan schema + tambah seeder balita & ibu hamil - bump 0.1.52
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 10:31:40 +08:00
afe0d9d04b fix(kesehatan): solve valtio mutation error and sync db
- Fix 'Cannot assign to read only property' by mutating original Valtio proxy in create/edit pages for IbuHamil and Balita
- Sync database schema with 'prisma db push' to create IbuHamil and Balita tables
- Verify build success
2026-05-04 17:04:44 +08:00
dccba1f82b feat(kesehatan): refactor ringkasan kesehatan to auto-derived stats
- Add IbuHamil and Balita models to schema.prisma
- Implement IbuHamil and Balita API modules (CRUD)
- Implement /stats endpoint for aggregated health KPIs
- Refactor ringkasan-kesehatan admin page to dashboard-style UI
- Update sidebar with Ibu Hamil and Balita entries
- Fix type errors and icon exports in admin UI
- Bump version to 0.1.52
2026-05-04 16:52:14 +08:00
fc6846f7a1 feat(kesehatan): admin page ringkasan-kesehatan + sidebar entry - bump ke 0.1.51
- New page src/app/admin/(dashboard)/kesehatan/ringkasan-kesehatan/page.tsx
  konsumsi ringkasanKesehatanState (load + submit) dengan 7 NumberInput
  (3 count + 4 pct).
- Tambah Kesehatan_8 "Ringkasan Kesehatan" di 3 instance sidebar
  (list_PageAdmin.tsx).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 15:20:39 +08:00
9ef5773cc2 docs(mind): update plan + tambah summary statistik kesehatan ringkasan
Tandai Step A & B selesai, catat decision log dan pending manual
(bunx prisma migrate deploy + trigger GH workflow).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 14:35:42 +08:00
68a2a6390b feat(kesehatan): tambah state file ringkasanKesehatan + bump ke 0.1.50
State Valtio untuk RingkasanKesehatanDesa: findUnique (load + sync form)
dan update (zod validation, PUT, refresh). Cover 3 KPI + 4 field Pct baru.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 14:30:55 +08:00
ba2b90be75 feat(kesehatan): tambah 4 field statistik pct ke RingkasanKesehatanDesa - bump ke 0.1.49
Tambah imunisasiLengkapPct, pemeriksaanRutinPct, giziBaikPct, targetStuntingPct
ke schema + migration + Elysia body validation (range 0-100) + updt handler.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 14:19:27 +08:00
3cc30bf0ff docs(mind): update AI collaboration contract plan and summary 2026-05-04 14:13:54 +08:00
0a5d17f45e docs: add AI collaboration contract and fix KegiatanCard image handling - bump to 0.1.48 2026-04-30 15:30:24 +08:00
83a2dece57 refactor(kegiatan-desa): redesign public list page to card grid + kategori filter
- Remove hero/featured section and tab navigation
- Redesign to pecalang-style: 3-col card grid (image, title, desc, Detail button)
- Replace tabs with Select dropdown filter by kategori
- Search + kategori filter use query params, stay on /semua route
- Image hidden when empty (no placeholder)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 15:09:33 +08:00
e0a5177257 feat(kegiatan-desa): add full CRUD frontend + public detail page - bump to 0.1.47
- API: add GET /:id endpoint (findUnique) for KegiatanDesa
- Admin CMS: add pages for list-kegiatan-desa and kategori-kegiatan-desa (list, create, detail, edit)
- Public: add detail page at /desa/kegiatan-desa/[kategori]/[id]
- Refactor: move KegiatanCard to _com to fix Next.js page export constraint
- Nav: register kegiatan-desa in navbar and admin page list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 14:27:28 +08:00
182 changed files with 15369 additions and 1639 deletions

View File

@@ -8,7 +8,7 @@
- **UI**: Mantine UI v7-8
- **State**: Jotai (atoms), Valtio (proxies), SWR (data fetching)
- **Auth**: iron-session + JWT
- **File storage**: Local uploads + Seafile (self-hosted)
- **File storage**: Local uploads + MinIO (object storage) + Seafile (self-hosted fallback)
## Request Flow
@@ -20,14 +20,16 @@ Browser → Next.js middleware (src/middleware.ts)
└── _lib/*.ts (domain modules)
```
The Elysia server is a single entry point with domain-specific modules: `desa.ts`, `kesehatan.ts`, `ekonomi.ts`, `keamanan.ts`, `lingkungan.ts`, `pendidikan.ts`, `kependudukan.ts`, `ppid.ts`, `inovasi.ts`, `auth/`, `user/`, `fileStorage/`. Swagger docs are auto-generated at `/api/docs`.
The Elysia server is a single entry point with domain-specific modules: `desa/`, `kesehatan/`, `ekonomi/`, `keamanan/`, `lingkungan/`, `pendidikan/`, `kependudukan/`, `ppid/`, `inovasi/`, `landing_page/`, `search/`, `auth/`, `user/`, `fileStorage/`. Swagger docs are auto-generated at `/api/docs`.
## Domain Modules
Each domain (desa, kesehatan, ekonomi, etc.) has:
- API handler in `src/app/api/[[...slugs]]/_lib/<domain>.ts`
- API handler in `src/app/api/[[...slugs]]/_lib/<domain>/`
- Admin CMS pages in `src/app/admin/(dashboard)/<domain>/`
- Public pages in `src/app/darmasaba/(pages)/<domain>/`
Active domains: `desa`, `ekonomi`, `inovasi`, `keamanan`, `kependudukan`, `kesehatan`, `lingkungan`, `musik`, `pendidikan`, `ppid` — plus `landing_page` and `search` (API-only, no public/admin pages).
## Key Files
| File | Purpose |

View File

@@ -16,6 +16,10 @@ on:
description: "Image tag (e.g. 1.0.0)"
required: true
default: "1.0.0"
stack_name:
description: "Stack name (optional, ignored)"
required: false
default: ""
env:

321
AI-CONTRACT.md Normal file
View File

@@ -0,0 +1,321 @@
# AI-CONTRACT.md
Kontrak kerja antara **manusia (developer)** dan **AI assistant** (Claude Code,
Cursor, Copilot, atau agent coding lainnya) di repo ini. Tujuannya satu:
mencegah perbaikan 1 bug berubah jadi 3 bug baru (bug eksponensial). AI
**wajib** baca file ini sebelum menulis/menghapus kode.
---
## 1. Prinsip Dasar
1. **Minimal diff, maximal pemahaman.** Baca kode sebelum ubah. Jangan
refactor yang tidak diminta. Jangan "rapikan" kode di sekitar bug.
2. **Fix akar, bukan gejala.** Kalau error muncul di layer A tapi penyebab
di layer B, perbaiki B. Jangan tambal di A.
3. **Satu masalah = satu perubahan logis.** Jangan campur fix bug dengan
refactor, rename, atau fitur baru dalam satu sesi tanpa izin.
4. **Tidak ada asumsi diam-diam.** Kalau butuh info (nama field, endpoint,
flow, schema), tanya atau baca kode — jangan tebak.
5. **Setiap perubahan harus reversible.** Diff kecil, commit jelas, bisa
di-revert tanpa efek samping.
---
## 2. Sebelum Menulis Kode
Checklist wajib sebelum edit file:
- [ ] Sudah baca file target (bukan cuma potongan)
- [ ] Tahu siapa yang memanggil fungsi/komponen yang akan diubah
- [ ] Tahu apakah ada test/konsumer lain yang bergantung padanya
- [ ] Tahu layer yang benar (route / controller / component / hook /
service / repository / lib / util — sesuai arsitektur project)
- [ ] Cek dokumen panduan project (mis. `CLAUDE.md`, `CONTRIBUTING.md`,
`ARCHITECTURE.md`, ADR) untuk aturan spesifik
- [ ] Kalau ubah tipe/kontrak (API, function signature, schema), cek
semua pemakai
Jika salah satu tidak jelas: **berhenti, baca lagi, atau tanya user.**
---
## 3. Saat Fix Bug
1. **Reproduksi dulu di kepala.** Jelaskan (minimal ke diri sendiri)
kenapa bug terjadi sebelum menyentuh kode.
2. **Temukan akar sebenarnya.** "Karena field X `undefined`/`null`/empty"
bukan akar — akarnya kenapa X bisa kosong.
3. **Perbaiki sekecil mungkin.** Kalau cukup 3 baris, jangan ubah 30.
4. **Jangan tambah try/catch hanya untuk menyembunyikan error** — itu
melahirkan bug baru yang lebih sulit dilacak.
5. **Jangan tambah fallback/default value spekulatif.** Kalau field
seharusnya selalu ada, perbaiki kenapa bisa kosong.
6. **Jangan rename, reorder, atau reformat** di file yang sama kecuali
langsung terkait fix.
7. **Setelah fix, verifikasi**: minimal jalankan typecheck/lint sesuai
tooling project (mis. `tsc`, `eslint`, `ruff`, `mypy`, `cargo check`,
`go vet`, `rspec`, dll). Idealnya jalankan test suite yang relevan.
---
## 4. Yang Dilarang (Akar Bug Eksponensial)
-**Silent catch**: `catch (e) {}`, `except: pass`, `_ = err`, atau
pola serupa — tanpa alasan yang didokumentasi di komentar.
-**Comment-out kode** sebagai "backup". Hapus atau kembalikan, jangan
biarkan mayat — git sudah jadi backup.
-**Copy-paste antar file**. Extract ke shared module/util/helper.
-**Duplikasi util/helper/hook/service** yang sudah ada — cek dulu
sebelum bikin baru.
-**Tambah flag/opsi/parameter baru** hanya untuk menghindari break
konsumer lama — fix konsumernya sekalian.
-**Destructive git command** (`reset --hard`, `push --force`,
`branch -D`, `clean -fdx`) tanpa instruksi eksplisit.
-**Skip hook** (`--no-verify`, `--no-gpg-sign`) tanpa izin.
-**Ubah schema/migrasi database** tanpa migration file yang sesuai.
-**Tambah dependency baru** tanpa izin user.
-**Hardcode credential, secret, URL produksi, atau data user**.
-**Ubah konfigurasi CI/CD, environment, atau infra** tanpa diskusi.
---
## 5. Saat Menambah Fitur
- Baca panduan arsitektur project sebelum mulai.
- Tentukan layer sebelum menulis. Jangan taruh bisnis logika di route,
controller, atau komponen presentasi.
- Jangan buat abstraksi untuk kebutuhan hipotetis. Tulis kode yang
diminta sekarang (YAGNI — *You Aren't Gonna Need It*).
- Hormati batas ukuran file yang sudah disepakati di project. Kalau
belum ada, gunakan rule of thumb: file >500 baris = sinyal untuk
pisah; fungsi >50 baris = sinyal untuk extract.
- Ikuti konvensi naming, struktur folder, dan pattern yang sudah ada —
konsistensi lebih penting dari preferensi pribadi.
---
## 6. Saat Ragu
Urutan tindakan:
1. Baca kode terkait lebih dalam.
2. Cek dokumen panduan project (`CLAUDE.md`, `README.md`, ADR, dll).
3. Cek git history (`git log -p`, `git blame`) kalau pertanyaannya soal
"kenapa ini begini".
4. **Tanya user** — lebih baik tanya 1 pertanyaan daripada menulis 100
baris yang harus dibuang.
Jangan pernah "pokoknya coba dulu, kalau salah revert". Revert itu murah
di local, tapi mahal kalau sudah merusak state (DB, session, file
sistem, deployment, dll).
---
## 7. Saat Selesai
- Jelaskan perubahan **secara singkat**: apa, di mana (file:line), kenapa.
- Sebutkan efek samping kalau ada (perubahan kontrak, breaking change,
perlu migrasi, perlu restart service, dll).
- Jangan ringkas diff yang user sudah lihat — user baca kode langsung.
- Kalau project punya channel notifikasi atau workflow report
(Slack/Discord/Telegram/email), kirim sesuai konvensi.
---
## 8. Eskalasi
Hentikan pekerjaan dan tanya user kalau:
- Fix butuh ubah >5 file untuk bug yang kelihatannya kecil.
- Ketemu bug lain di tengah jalan yang tidak diminta.
- Perubahan berpotensi mengenai data produksi, session aktif, atau
user nyata.
- User memberi instruksi yang bertentangan dengan dokumen panduan
project — konfirmasi dulu sebelum melanggar aturan.
---
## 9. Tools sebagai Mata dan Tangan AI
AI **wajib** memakai tools yang tersedia (MCP server, CLI commands,
debugger, browser automation, log inspector, DB query tool, dll) sebagai
**mata dan tangan**-nya.
- **Mata**: sebelum menebak state sistem, AI harus lihat langsung. Cek
log, query DB read-only, baca file config, jalankan health check, atau
pakai tool inspeksi yang relevan. Jangan berasumsi tentang data,
konfigurasi, atau tampilan — **cek dulu**.
- **Tangan**: gunakan tools untuk verifikasi end-to-end setelah
perubahan. Contoh: setelah fix UI, jalankan/preview halamannya dan
pastikan render + console bersih. Setelah fix logic, jalankan test
atau panggil endpoint yang relevan.
- **Maksimalkan pemakaian.** Kalau ada tool yang relevan, pakai — jangan
memilih jalan manual yang lebih rapuh. Semakin sering tools dipakai
untuk verifikasi, semakin solid project ini.
- **Ajukan tool baru kalau perlu.** Kalau AI merasa butuh tool yang
belum ada, AI **boleh dan didorong** untuk mengajukan pembuatannya
ke user. Format pengajuan:
1. Nama tool + signature (input/output)
2. Kenapa dibutuhkan (masalah konkret yang sedang dihadapi)
3. Sumber data (tabel DB / cache key / endpoint / file system)
4. Estimasi dampak ke kualitas investigasi/perbaikan
- **Jangan buat tool baru tanpa izin.** Ajukan dulu, tunggu persetujuan
user, baru implementasi (+ update dokumentasi).
- **Tools adalah sumber kebenaran runtime.** Kalau memory/log mengatakan
X tapi tool inspeksi langsung mengatakan Y, percayai tool.
Tujuan: AI tidak buta terhadap state sistem nyata, dan setiap perbaikan
diverifikasi secara nyata — bukan "harusnya sudah jalan".
---
## 10. Kontrak Public API / Interface (Wajib Dijaga)
Setiap interface yang dipakai oleh konsumen eksternal — REST/GraphQL
endpoint, MCP tool, library export, CLI command, webhook payload, event
schema, dll — adalah **kontrak publik**. Begitu konsumen (termasuk AI
agent dengan memory) tahu bentuk kontraknya, perubahan diam-diam bisa
bikin mereka bertindak berdasarkan asumsi yang sudah tidak valid — dan
kamu **tidak akan tahu** sampai terjadi kejadian aneh di prod.
### Apa yang dianggap kontrak (freeze)
| Kategori | Contoh | Aturan |
| ----------------- | --------------------------------------- | -------------------------------------------------- |
| Nama interface | endpoint path, tool name, function name | Tidak boleh rename tanpa bump versi |
| Parameter input | nama field, tipe, posisi | Nama & tipe tidak boleh berubah |
| Required flag | field wajib | Tidak boleh naik (optional → required) tanpa versi |
| Enum values | nilai yang valid | Tidak boleh dihapus/diganti |
| Error mode | format error response, exception type | Pola error harus konsisten |
| Field output | bentuk response | Tidak boleh dihapus/diganti tipenya |
### Apa yang boleh berubah (additive)
- Tambah interface baru
- Tambah parameter **optional** baru
- Tambah field output baru (konsumen lama akan mengabaikan yang tidak
mereka tahu, asal parsing-nya tolerant)
- Perbaiki pesan error (tanpa ubah polanya)
- Refactor implementasi internal (query, helper, dll)
### Cara kerja penjaga kontrak
1. **Contract test**: snapshot bentuk kontrak (nama, required,
properties, enum) untuk setiap interface publik. Letakkan di folder
khusus mis. `tests/contract/`.
2. **Kalau contract test merah karena perubahan yang disengaja**:
1. Update dokumentasi kontrak
2. Bump versi (semver, tag, atau version field)
3. Update snapshot di contract test
4. Jelaskan migrasinya di commit message + changelog
3. **Kalau contract test merah karena refactor yang tidak disengaja**:
**Jangan update snapshot untuk menghijaukan test.** Balikkan refactor
atau perbaiki supaya kontrak tetap sama. Snapshot bukan sampah yang
bisa di-regenerate seenaknya — dia alarm kebakaran.
### Larangan spesifik
-**Jangan rename** interface publik tanpa migration plan + bump versi
-**Jangan hapus enum value** — konsumen bisa punya kode/memory yang
memanggil nilai itu
-**Jangan naikkan param dari optional → required** tanpa bump versi
-**Jangan ubah bentuk error** (format response ↔ throw exception) —
ini mengubah handler logic di sisi konsumen
-**Jangan update snapshot contract test** tanpa update dokumentasi
### Apa yang BUKAN tugas contract test
- Memverifikasi logika bisnis (itu unit test biasa)
- Memverifikasi integrasi DB/external service (itu integration test)
- Memastikan data yang di-return benar (itu QA / staging)
Contract test **hanya** menjaga bentuk kontrak — cepat, deterministic,
tanpa dependency eksternal.
---
## 11. Hygiene Dokumen Panduan AI
Dokumen panduan AI (`CLAUDE.md`, `AI-CONTRACT.md`, `.cursorrules`,
`.github/copilot-instructions.md`, dll) di-load **setiap turn** percakapan.
Semakin gemuk file utamanya, semakin banyak token terbuang setiap turn —
dan ironisnya, AI jadi lebih sulit menemukan info penting karena tertimbun
detail. File panduan yang gemuk **bukan** tanda dokumentasi yang baik;
sering justru sebaliknya.
### Pecah, jangan tumpuk
Gunakan **referensi file** alih-alih menumpuk semua di satu file. Banyak
AI agent (termasuk Claude Code) auto-load file yang di-reference dengan
sintaks `@path/to/file.md`. Contoh struktur `CLAUDE.md` yang sehat:
````markdown
## Architecture
See @docs/ARCHITECTURE.md
## Agent Specs
See @docs/AGENTIC_OVERVIEW.md
## ADR History
See @docs/adr/README.md
````
`CLAUDE.md` utama tetap ramping, tapi info detail tetap accessible saat
dibutuhkan.
### Apa yang WAJIB tetap di CLAUDE.md (load setiap turn)
- Konvensi coding inti (naming, formatting, import order)
- Perintah build/test/lint yang sering dipakai
- Aturan komunikasi (bahasa, gaya, format response)
- Struktur folder high-level (1-2 level)
- Larangan absolut (jangan commit ke main, jangan touch folder X, dll)
- **Pointer** (`@path/...`) ke file detail lainnya
### Apa yang DIPINDAH ke file terpisah
- Spec arsitektur lengkap → `docs/ARCHITECTURE.md`
- Detail flow / sequence diagram → `docs/flows/*.md`
- ADR history (Architecture Decision Records) → `docs/adr/`
- Contoh kode panjang → `docs/examples/`
- API/interface reference lengkap → `docs/api/`
- Onboarding & setup detail → `docs/SETUP.md`
- Glossary domain terminology → `docs/GLOSSARY.md`
- Catatan investigasi/post-mortem → `docs/incidents/`
Lokasi alternatif: `.claude/` atau `.ai/` kalau tim ingin memisahkan
khusus untuk AI tooling, di luar dokumentasi developer biasa.
### Cek duplikasi secara rutin
Info yang sama sering muncul di beberapa section seiring waktu — biasanya
karena ditambahkan saat debugging tanpa cek file dulu. Audit berkala:
- [ ] Sama-sama dijelaskan di `README.md` dan `CLAUDE.md`? Pilih satu,
yang lain referensikan.
- [ ] Aturan yang sama disebut di 2-3 section? Konsolidasi ke satu section
kanonik, section lain tinggal pointer.
- [ ] Contoh kode panjang muncul inline? Pindah ke `docs/examples/`.
- [ ] Konvensi yang sudah jadi default di linter/formatter masih ditulis
manual? Hapus — biarkan tooling yang jaga, dokumen tidak perlu
mengulang.
- [ ] Info yang sudah usang (refer ke file/fitur yang dihapus)? Bersihkan
— dokumen yang setengah benar lebih merusak daripada tidak ada.
### Rule of thumb ukuran
Kalau `CLAUDE.md` (atau equivalent) sudah > 300 baris, itu **sinyal kuat**
untuk pecah file. Dokumen panduan AI yang ideal: cukup pendek untuk dibaca
ulang dalam 1 menit oleh manusia, dengan pointer ke detail untuk AI yang
butuh konteks lebih dalam.
---
## 12. Aturan Emas
> **Lebih baik tidak melakukan apa-apa daripada memperburuk kode.**
>
> Kalau setelah 2 kali percobaan fix masih memunculkan bug baru, **stop**.
> Laporkan ke user, jelaskan apa yang sudah dicoba dan kenapa gagal.
> Jangan tambal terus — itu cara bug beranak eksponensial.

View File

@@ -30,6 +30,8 @@ bun eslint . --fix
- Architecture, request flow, domain modules, key files: @.claude/ARCHITECTURE.md
- Database conventions, auth flow, file handling: @.claude/DATABASE.md
- Env vars, Docker, CI/CD, releasing: @.claude/DEPLOYMENT.md
- UI/UX design system, tokens, komponen, pola halaman: @.claude/DESIGN.md
- AI collaboration contract, rules, and guidelines: @AI-CONTRACT.md
### Workflow for Code Changes
1. **Commit** existing changes before starting new work

View File

@@ -0,0 +1,19 @@
# Plan: Add AI Collaboration Contract and Fix UI Issues
## Background
- Need a clear contract for AI collaboration to prevent "exponential bugs".
- UI fix for `KegiatanCard` to handle missing images gracefully.
## Objectives
- Add `AI-CONTRACT.md` with guidelines.
- Link `AI-CONTRACT.md` in `CLAUDE.md`.
- Fix `KegiatanCard.tsx` image rendering.
- Bump version to 0.1.48.
## Implementation Steps
1. Create `AI-CONTRACT.md`.
2. Update `CLAUDE.md`.
3. Update `KegiatanCard.tsx`.
4. Bump version in `package.json`.
5. Verify build.
6. Commit and push.

View File

@@ -0,0 +1,11 @@
# Task: Add AI Collaboration Contract and Fix UI Issues
## Status
- [x] Create `AI-CONTRACT.md`
- [x] Update `CLAUDE.md` to reference the contract
- [x] Fix `KegiatanCard.tsx` image rendering logic
- [x] Bump version in `package.json` to 0.1.48
- [x] Verify build successful
- [x] Commit changes
- [x] Create branch and push
- [x] Merge to `stg`

View File

@@ -0,0 +1,246 @@
# Task: Refactor Ringkasan Kesehatan → Dashboard Auto-Derived
## Latar Belakang
Page `/admin/kesehatan/ringkasan-kesehatan` saat ini = form `NumberInput`
manual (7 angka diketik operator). Itu **salah konsep**: "Ringkasan" harus
**hasil agregasi** dari data sumber, bukan input bebas.
Solusi: bikin 2 entitas data sumber (`IbuHamil`, `Balita`), lalu derive
KPI + statistik kesehatan dari query agregat. Page ringkasan jadi
**dashboard read-only** + tombol "Kelola Ibu Hamil" / "Kelola Balita".
## Mapping Field → Sumber Data
| Field di Ringkasan | Sumber |
|---|---|
| `ibuHamilAktif` | `count(IbuHamil where status=AKTIF, isActive=true)` |
| `balitaTerdaftar` | `count(Balita where isActive=true)` |
| `alertStunting` | `count(Balita where statusStunting IN [ALERT, STUNTING])` |
| `imunisasiLengkapPct` | `count(imunisasiLengkap=true) / total balita * 100` |
| `pemeriksaanRutinPct` | `count(pemeriksaanRutin=true) / total balita * 100` |
| `giziBaikPct` | `count(giziBaik=true) / total balita * 100` |
| `targetStuntingPct` | tetap di `RingkasanKesehatanDesa` (policy target, bukan derived) |
## Kontrak Lama (jaga, AI-CONTRACT §10)
- `GET /api/kesehatan/ringkasankesehatan/find` — tetap return shape lama
- `PUT /api/kesehatan/ringkasankesehatan/update` — tetap menerima 7 field lama
- Tambah baru: `GET /api/kesehatan/ringkasankesehatan/stats` (agregat)
- Tambah baru: `/api/kesehatan/ibuhamil/*`, `/api/kesehatan/balita/*`
> Alasan: ada konsumer eksternal (mobile/landing) yang mungkin sudah
> baca shape lama. Refactor admin tidak boleh memutus kontrak existing.
> Field manual lama akan ditandai deprecated di komentar schema saat
> tidak lagi dipakai admin UI.
---
## Step A — Model `IbuHamil`
### Schema
```prisma
enum IbuHamilStatus {
AKTIF
MELAHIRKAN
KEGUGURAN
NONAKTIF
}
model IbuHamil {
id String @id @default(cuid())
nama String
nik String?
usiaKehamilan Int @default(0) // minggu
hpht DateTime? // hari pertama haid terakhir
taksiranLahir DateTime?
alamat String?
noHp String?
catatan String?
posyanduId String?
posyandu Posyandu? @relation(fields: [posyanduId], references: [id], onDelete: SetNull)
status IbuHamilStatus @default(AKTIF)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
isActive Boolean @default(true)
}
```
### API `src/app/api/[[...slugs]]/_lib/kesehatan/ibu-hamil/`
Pattern mirror `posyandu/`:
- `index.ts` — Elysia routes (prefix `/ibuhamil`)
- `create.ts` — POST `/create`
- `find-many.ts` — GET `/find-many?page=&limit=&search=&status=`
- `find-by-id.ts` — GET `/:id`
- `updt.ts` — PUT `/:id`
- `del.ts` — DELETE `/del/:id` (soft delete `isActive=false`)
Daftarkan di `kesehatan/index.ts`: `.use(IbuHamil)`
### Checklist
- [ ] 1. Tambah enum `IbuHamilStatus` + model `IbuHamil` ke `prisma/schema.prisma`
- [ ] 2. Tambah `@relation` reverse di model `Posyandu` (`ibuHamil IbuHamil[]`)
- [ ] 3. Generate migration `add_ibu_hamil` (manual SQL bila sandbox)
- [ ] 4. Buat 6 file API (index/create/find-many/find-by-id/updt/del)
- [ ] 5. Register di `kesehatan/index.ts`
- [ ] 6. State file `src/app/admin/(dashboard)/_state/kesehatan/ibu-hamil/ibuHamil.ts`
pattern Valtio + zod (mirror `posyandu/posyandu.ts`)
- [ ] 7. Admin pages:
- `src/app/admin/(dashboard)/kesehatan/ibu-hamil/page.tsx` (list)
- `src/app/admin/(dashboard)/kesehatan/ibu-hamil/create/page.tsx`
- `src/app/admin/(dashboard)/kesehatan/ibu-hamil/edit/[id]/page.tsx`
- [ ] 8. Sidebar entry: tambah "Ibu Hamil" di group Kesehatan
- [ ] 9. `bun run build` exit 0
- [ ] 10. Bump version + commit + push
---
## Step B — Model `Balita`
### Schema
```prisma
enum JenisKelaminBalita {
L
P
}
enum StatusStunting {
NORMAL
ALERT
STUNTING
}
model Balita {
id String @id @default(cuid())
nama String
nik String?
tanggalLahir DateTime
jenisKelamin JenisKelaminBalita
beratBadanKg Float?
tinggiBadanCm Float?
namaOrtu String?
alamat String?
noHpOrtu String?
posyanduId String?
posyandu Posyandu? @relation(fields: [posyanduId], references: [id], onDelete: SetNull)
imunisasiLengkap Boolean @default(false)
giziBaik Boolean @default(true)
pemeriksaanRutin Boolean @default(true)
statusStunting StatusStunting @default(NORMAL)
catatan String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
isActive Boolean @default(true)
}
```
### API `src/app/api/[[...slugs]]/_lib/kesehatan/balita/`
Sama pattern dengan ibu-hamil (6 file). Body validator Elysia:
```ts
t.Object({
nama: t.String(),
nik: t.Optional(t.String()),
tanggalLahir: t.String(), // ISO string
jenisKelamin: t.Union([t.Literal('L'), t.Literal('P')]),
beratBadanKg: t.Optional(t.Number()),
tinggiBadanCm: t.Optional(t.Number()),
namaOrtu: t.Optional(t.String()),
alamat: t.Optional(t.String()),
noHpOrtu: t.Optional(t.String()),
posyanduId: t.Optional(t.String()),
imunisasiLengkap: t.Boolean(),
giziBaik: t.Boolean(),
pemeriksaanRutin: t.Boolean(),
statusStunting: t.Union([t.Literal('NORMAL'), t.Literal('ALERT'), t.Literal('STUNTING')]),
catatan: t.Optional(t.String()),
})
```
### Checklist
- [ ] 1. Tambah enum + model `Balita` ke schema
- [ ] 2. `@relation` reverse di Posyandu (`balita Balita[]`)
- [ ] 3. Migration `add_balita`
- [ ] 4. 6 file API
- [ ] 5. Register di `kesehatan/index.ts`
- [ ] 6. State file `_state/kesehatan/balita/balita.ts`
- [ ] 7. Admin pages list / create / edit (form dengan 4 toggle:
`imunisasiLengkap`, `giziBaik`, `pemeriksaanRutin` + Select
`statusStunting`)
- [ ] 8. Sidebar entry "Balita" di group Kesehatan
- [ ] 9. `bun run build` exit 0
- [ ] 10. Bump version + commit + push
---
## Step C — Endpoint Stats Agregat
### File baru `src/app/api/[[...slugs]]/_lib/kesehatan/ringkasan-kesehatan/stats.ts`
Return:
```ts
{
ibuHamilAktif: number,
balitaTerdaftar: number,
alertStunting: number,
imunisasiLengkapPct: number, // round
pemeriksaanRutinPct: number,
giziBaikPct: number,
targetStuntingPct: number, // dari RingkasanKesehatanDesa
}
```
Implementasi (Prisma):
- 1 `count` IbuHamil + 1 `groupBy` Balita (status flags)
- Hitung pct di JS: `Math.round((n / total) * 100)`
- Edge case `total=0` → semua pct = 0
### Checklist
- [ ] 1. Bikin `stats.ts` handler
- [ ] 2. Daftarkan route `GET /stats` di `ringkasan-kesehatan/index.ts`
- [ ] 3. Update state `ringkasanKesehatan.ts`:
- tambah `findStats: { data, loading, load() }`
- method lama `findUnique` + `update` → tetap (kontrak)
- [ ] 4. Refactor `page.tsx` ringkasan:
- section atas: 7 stat card read-only dari `findStats.data`
- section bawah: form kecil hanya `targetStuntingPct` (policy target)
- 2 tombol: "Kelola Ibu Hamil" → push `/admin/kesehatan/ibu-hamil`
"Kelola Balita" → push `/admin/kesehatan/balita`
- [ ] 5. `bun run build` exit 0
- [ ] 6. Bump version + commit + push
---
## Step D — Cleanup (opsional, tunggu user konfirmasi)
Field manual `ibuHamilAkh`, `balitaTerdaftar`, `alertStunting`,
`imunisasiLengkapPct`, `pemeriksaanRutinPct`, `giziBaikPct` di
`RingkasanKesehatanDesa` jadi **deprecated**.
- Opsi 1 (aman): tinggalkan, tandai `// DEPRECATED — derived from /stats`
- Opsi 2 (bersih): hapus + bump major version + update kontrak konsumer
**Tunggu input user** — jangan eksekusi tanpa izin.
---
## Pending Manual
- [ ] User jalankan `bunx prisma migrate deploy` di lokal setelah Step A & B
- [ ] User trigger publish + re-pull workflow bila mau deploy STG
## Prinsip
- **Additive**: kontrak `/find` + `/update` ringkasankesehatan tetap
- **Single source of truth**: KPI = derived dari IbuHamil + Balita
- **Soft delete** pakai `isActive=false`, jangan hard delete
- **YAGNI**: belum bikin chart/grafik tren; cukup angka snapshot
- **No breaking change**: konsumer landing/mobile aman selama migrasi

View File

@@ -0,0 +1,50 @@
# Summary: Fix OTP — Ganti Math.random() dengan CSPRNG
**Tanggal**: 2026-06-08
**Branch**: tasks/auth/fix-otp-csprng/20260608
---
## Apa yang Diubah
**File**: `src/app/api/auth/_lib/randomOTP.ts`
### Sebelum
```ts
export function randomOTP() {
const random = Math.floor(Math.random() * (9000 - 1000)) + 1000
return random;
}
```
### Sesudah
```ts
import { randomInt } from "crypto";
export function randomOTP() {
return randomInt(1000, 10000);
}
```
---
## Mengapa Diubah
`Math.random()` adalah PRNG (Pseudorandom Number Generator) yang **tidak kriptografis aman**. Output-nya dapat diprediksi jika penyerang mengetahui seed atau pola output sebelumnya. OTP yang dapat diprediksi membuka celah serangan brute-force yang terarah atau bahkan prediksi langsung.
`crypto.randomInt` dari Node.js built-in `crypto` module menggunakan CSPRNG (Cryptographically Secure Pseudorandom Number Generator) yang sesuai standar keamanan untuk keperluan otentikasi.
---
## Dampak
- Tidak ada perubahan antarmuka/kontrak fungsi — return type tetap `number`, range tetap 4 digit (10009999).
- Tidak ada breaking change pada konsumen `randomOTP()`.
- Peningkatan keamanan pada alur OTP login pengguna.
---
## Referensi
- Node.js docs: [`crypto.randomInt(min, max)`](https://nodejs.org/api/crypto.html#cryptorandomintmin-max-callback)
- OWASP: [Insecure Randomness](https://owasp.org/www-community/vulnerabilities/Insecure_Randomness)

View File

@@ -0,0 +1,66 @@
# Summary: Fix SESSION_PASSWORD Fallback — Security Hardening
**Tanggal**: 8 Juni 2026
**Branch**: `tasks/auth/fix-session-password-fallback/20260608`
**File diubah**: `src/lib/session.ts`
---
## Permasalahan
Baris 33 di `src/lib/session.ts` menggunakan operator `||` sebagai fallback:
```ts
password: process.env.SESSION_PASSWORD || 'default-password-change-in-production',
```
Jika env var `SESSION_PASSWORD` tidak di-set, iron-session **diam-diam** memakai string `'default-password-change-in-production'` sebagai kunci enkripsi session cookie. String ini diketahui publik karena ada di source code. Siapa pun yang mengetahui nilai tersebut dapat memalsukan session cookie dan masuk sebagai user mana pun tanpa autentikasi.
Risikonya:
- Aplikasi tetap berjalan normal tanpa peringatan apapun
- Deployment yang lupa set env var langsung rentan session forgery
- Tidak ada fail-fast — masalah baru terdeteksi setelah ada insiden
---
## Perubahan
**File**: `src/lib/session.ts` (baris 3135)
**Sebelum**:
```ts
const SESSION_OPTIONS = {
cookieName: 'desa-session',
password: process.env.SESSION_PASSWORD || 'default-password-change-in-production',
...
};
```
**Sesudah**:
```ts
const sessionPassword = process.env.SESSION_PASSWORD;
if (!sessionPassword) {
throw new Error('SESSION_PASSWORD env var is not set. Set it to a random string of at least 32 characters.');
}
const SESSION_OPTIONS = {
cookieName: 'desa-session',
password: sessionPassword,
...
};
```
---
## Dampak
- Jika `SESSION_PASSWORD` tidak di-set, server **langsung crash** saat startup dengan pesan error yang jelas.
- Tidak ada deployment yang bisa berjalan tanpa password yang valid.
- Tidak ada perubahan fungsional untuk environment yang sudah set env var dengan benar.
---
## Kategori
- **Security fix** — menghilangkan hardcoded fallback password di session config
- **Fail-fast pattern** — error eksplisit saat startup lebih aman dari silent default

View File

@@ -0,0 +1,19 @@
# Summary: Add AI Collaboration Contract and Fix UI Issues
## Work Completed
- **AI Collaboration Contract**: Added `AI-CONTRACT.md` which defines the working agreement between human developers and AI assistants to prevent exponential bugs.
- **Documentation Update**: Linked `AI-CONTRACT.md` in `CLAUDE.md`.
- **UI Fix**: Modified `KegiatanCard.tsx` to handle cases where an activity item might not have an image, preventing layout issues or broken image icons.
- **State Management**: Committed several new state files for Desa Profile (Lambang, Mantan Perbekel, Maskot, Profil Perbekel, Sejarah, Visi Misi).
- **Version Bump**: Incremented project version to `0.1.48`.
- **Verification**: Ran `bun run build` successfully to ensure no regressions.
## Branching & Deployment
- Created branch: `tasks/ai-collaboration-contract/add-contract-and-fix-ui/2026-04-30-15-45`
- Pushed to remotes: `origin` and `deploy`.
- Merged to `stg` branch and pushed to all remotes.
## Impact
- Better AI-Human collaboration guidelines to ensure code quality.
- Improved robustness of the public `Kegiatan Desa` page.
- Completed several state management modules for the admin panel.

View File

@@ -0,0 +1,66 @@
# Summary: Banjar Integration untuk Penderita Penyakit (GrafikKepuasan)
**Tanggal:** 2026-05-30
**Branch:** `tasks/kesehatan/banjar-penderita-penyakit/20260530`
---
## Apa yang Dikerjakan
Menambahkan relasi **Banjar** ke modul Penderita Penyakit (model `GrafikKepuasan`) secara end-to-end: dari schema database, API, state management, hingga UI admin.
---
## Perubahan per Layer
### 1. Database — `prisma/schema.prisma` + Migration
- Tambah field `banjarId String?` (optional) ke model `GrafikKepuasan`
- Tambah relasi ke model `Banjar` dengan `onDelete: SetNull`
- Migration: `20260530025023_add_banjar_to_grafik_kepuasan`
### 2. API Banjar (baru) — `src/app/api/[[...slugs]]/_lib/desa/banjar/`
- CRUD lengkap: `create.ts`, `findMany.ts`, `findUnique.ts`, `updt.ts`, `del.ts`, `index.ts`
- Didaftarkan di `src/app/api/[[...slugs]]/_lib/desa/index.ts`
- Endpoint: `/api/desa/banjar/*`
### 3. API GrafikKepuasan (update)
- `create.ts` — terima `banjarId` opsional, include `banjar` di response
- `find-many.ts` — filter by `banjarId` via query param, include `banjar`
- `findUnique.ts` — include `banjar` di response
- `updt.ts` — terima dan simpan `banjarId`, include `banjar`
- `index.ts` — daftarkan route yang diperlukan
### 4. State — `grafikKepuasan.ts`
- Tambah `banjarId` ke `defaultForm`, schema Zod, `create.form`, `update.form`
- Tambah `banjarList` proxy: fetch dari `/api/desa/banjar/findMany?limit=100`
- `findMany.load()` terima parameter `banjarId` untuk filter
- Type `findMany.data` update ke include `banjar: { id, name }`
### 5. State Banjar (baru) — `src/app/admin/(dashboard)/_state/desa/banjar.ts`
- Proxy Valtio untuk CRUD Banjar (create, findMany, findUnique, update, delete)
### 6. Admin UI Banjar (baru) — `src/app/admin/(dashboard)/desa/banjar/`
- `page.tsx` — tabel list banjar + kartu mobile + pagination
- `create/page.tsx` — form tambah banjar
- `[id]/page.tsx` — detail + hapus + edit
### 7. Admin UI Penderita Penyakit (update)
- `page.tsx` (list) — tambah kolom Banjar di tabel desktop, field Banjar di kartu mobile, filter Select by banjar
- `create/page.tsx` — tambah `Select` banjar (opsional), load `banjarList` on mount
- `[id]/page.tsx` (detail) — tambah baris Banjar
- `[id]/edit/page.tsx` — tambah `banjarId` ke `formData`, load `banjarList`, tambah `Select` banjar
### 8. Sidebar Admin — `list_PageAdmin.tsx`
- Tambah menu "Banjar" di bawah domain Desa
---
## Keputusan Desain
- `banjarId` dibuat **opsional** agar data lama tidak terpengaruh
- Filter banjar di list page menggunakan Select + clear, tidak memerlukan halaman terpisah
- `banjarList` di-load sekali on mount, bukan di-refetch tiap render
---
## Verifikasi
- `bun run build` ✅ sukses tanpa error TypeScript

View File

@@ -0,0 +1,67 @@
# Summary: Event Budaya — Seeder, Pagination & Fix Duplicate Options
**Tanggal:** 2026-05-21
**Branch:** `tasks/event-budaya/feat-seeder-pagination-fix-duplicate/20260521`
**Scope:** Fitur Kalender Event Budaya (admin + public) + fix bug + seeder
---
## Perubahan yang Dilakukan
### 1. Fix Bug: Duplicate Options di Halaman Kegiatan Desa (Public)
**File:** `src/app/darmasaba/(pages)/desa/kegiatan-desa/_lib/layoutTabs.tsx`
- **Masalah:** Mantine `<Select>` error `Duplicate options are not supported` untuk value "Sosial" karena data kategori di DB memiliki nama duplikat.
- **Fix:** Deduplikasi `kategoriOptions` menggunakan `Map` sebelum data masuk ke `<Select>`.
- **Root cause:** Data duplikat di tabel kategori kegiatan desa — perlu dibersihkan dari sisi DB.
### 2. Fitur Kalender Event Budaya (Admin CMS)
**Files:**
- `src/app/admin/(dashboard)/desa/event-budaya/page.tsx` — List event dengan table, aksi edit/hapus/lihat
- `src/app/admin/(dashboard)/desa/event-budaya/[id]/page.tsx` — Detail & edit event
- `src/app/admin/(dashboard)/desa/event-budaya/layout.tsx` — Layout wrapper admin
- `src/app/admin/(dashboard)/_state/desa/eventBudaya.ts` — State Valtio: create, findMany, findUnique, edit, delete, findUpcoming
- `src/app/api/[[...slugs]]/_lib/desa/event-budaya/index.ts` — Elysia router: tambah endpoint `/find-upcoming`
- `src/app/api/[[...slugs]]/_lib/desa/event-budaya/find-upcoming.ts` — Query event mendatang (tanggal >= hari ini, max 20)
### 3. Fitur Kalender Event Budaya (Public)
**File:** `src/app/darmasaba/(pages)/desa/event-budaya/page.tsx`
- Menampilkan list event budaya dengan pagination **5 data per halaman**
- Menggunakan `eventBudayaState.findMany` (bukan `findUpcoming`) agar pagination berjalan
- Komponen `Pagination` dari Mantine di bawah list
- Skeleton loading saat data belum tersedia
### 4. Navigasi Navbar
**File:** `src/con/navbar-list-menu.ts`
- Tambah menu **"Kalender Event Budaya"** (id: 2.9) di bawah "Kegiatan Desa" dengan href `/darmasaba/desa/event-budaya`
### 5. Seeder: Data Event Budaya
**Files:**
- `prisma/data/desa/event-budaya/event-budaya.json` — Diperluas dari **8 → 34 events**
- `prisma/_seeder_list/desa/event-budaya/seed_event_budaya.ts` — Tidak diubah (sudah kompatibel)
**Cakupan seeder (20252026):**
- Hari Raya Hindu Bali: Galungan (×5), Kuningan (×5), Nyepi, Melasti, Saraswati, Pagerwesi
- Tumpek: Landep, Uduh, Krulut
- Upacara desa: Ngusaba Desa, Pujawali Pura Puseh, Melaspas
- Event nasional: HUT RI ke-80 & ke-81, Hari Kesaktian Pancasila
- Event budaya: Festival Budaya Desa, Parade Ogoh-Ogoh, Pementasan Wayang Kulit
---
## Arsitektur yang Diikuti
- **State management:** Valtio proxy di `_state/desa/eventBudaya.ts`
- **API:** Elysia.js endpoint di `/api/desa/eventbudaya/*`
- **Pagination:** Server-side via `findMany` (skip/take Prisma), state sudah punya `page`, `totalPages`, `total`
- **Seeder:** JSON data + `upsert` Prisma (aman dijalankan berulang)
---
## Catatan
- `STRUKTUR.md` dihapus (file lama tidak relevan)
- Seeder bisa dijalankan dengan `bun run prisma/seed.ts`
- Bug duplicate kategori di DB belum dibersihkan dari sisi data — hanya di-guard di UI

View File

@@ -0,0 +1,98 @@
# Summary: Posyandu Banjar + Halaman Publik + Fix Tips Keamanan
**Tanggal:** 2026-05-28
**Branch:** tasks/kesehatan/posyandu-banjar-publik-fix-tips-keamanan/20260528
---
## Apa yang Berubah
### 1. Fitur Banjar pada Posyandu
**Problem:** Posyandu tidak memiliki relasi ke wilayah banjar, sehingga tidak bisa dikelompokkan per banjar.
**Perubahan:**
- `prisma/schema.prisma` — Tambah model `Banjar` baru dan field `banjarId` (optional FK) pada model `Posyandu`
- `prisma/migrations/20260528100000_add_banjar_to_posyandu/migration.sql` — Migration: CREATE TABLE `Banjar`, ALTER TABLE `Posyandu` ADD COLUMN `banjarId`
- `prisma/_seeder_list/kesehatan/seed_banjar.ts` — Seeder baru untuk data banjar
- `prisma/data/kesehatan/banjar/banjar.json` — Data seed 16 banjar Desa Darmasaba
- `prisma/seed.ts` — Tambah `seedBanjar()` sebelum `seedTipsKeamanan()`
**API changes:**
- `src/app/api/[[...slugs]]/_lib/kesehatan/posyandu/create.ts` — Terima `banjarId` optional saat create
- `src/app/api/[[...slugs]]/_lib/kesehatan/posyandu/updt.ts` — Terima `banjarId` optional saat update
- `src/app/api/[[...slugs]]/_lib/kesehatan/posyandu/find-by-id.ts` — Include relasi `banjar`
- `src/app/api/[[...slugs]]/_lib/kesehatan/posyandu/find-many.ts` — Include relasi `banjar`
- `src/app/api/[[...slugs]]/_lib/kesehatan/posyandu/index.ts` — Tambah endpoint banjar
- `src/app/api/[[...slugs]]/_lib/kesehatan/index.ts` — Daftarkan endpoint banjar baru
- `src/app/api/[[...slugs]]/_lib/kesehatan/banjar/` — Module API baru untuk list banjar
- `src/app/api/[[...slugs]]/_lib/kesehatan/balita/find-many.ts` — Support filter per posyandu
- `src/app/api/[[...slugs]]/_lib/kesehatan/ibu-hamil/find-many.ts` — Support filter per posyandu
**Admin CMS changes:**
- `src/app/admin/(dashboard)/kesehatan/posyandu/list-posyandu/page.tsx` — Tampilkan info banjar di tabel
- `src/app/admin/(dashboard)/kesehatan/posyandu/list-posyandu/create/page.tsx` — Tambah Select banjar
- `src/app/admin/(dashboard)/kesehatan/posyandu/list-posyandu/[id]/edit/page.tsx` — Tambah Select banjar
- `src/app/admin/(dashboard)/kesehatan/posyandu/list-posyandu/[id]/page.tsx` — Tampilkan nama banjar di detail
- `src/app/admin/(dashboard)/_state/kesehatan/posyandu/posyandu.ts` — Include `banjar` di state
---
### 2. Redesign Halaman Publik Posyandu
**Problem:** Halaman publik `/darmasaba/kesehatan/posyandu` hanya menampilkan daftar posyandu saja, belum menampilkan data balita, ibu hamil, dan ringkasan statistik.
**Perubahan:**
- `src/app/darmasaba/(pages)/kesehatan/posyandu/page.tsx` — Redesign total dengan sistem Tab:
- **Tab Ringkasan** — Statistik kesehatan (total posyandu, balita, ibu hamil, angka stunting)
- **Tab Data Posyandu** — List posyandu dengan filter search dan info banjar
- **Tab Data Balita** — Tabel data balita dengan filter search + filter status stunting
- **Tab Ibu Hamil** — Tabel data ibu hamil dengan filter search + filter status
- `src/app/darmasaba/(pages)/kesehatan/posyandu/[id]/page.tsx` — Halaman detail posyandu dengan tampilan tab balita & ibu hamil per posyandu
- `src/app/admin/(dashboard)/_state/kesehatan/balita/balita.ts` — Tambah state `findMany` untuk halaman publik
- `src/app/admin/(dashboard)/_state/kesehatan/ibu-hamil/ibuHamil.ts` — Tambah state `findMany` untuk halaman publik
- `src/app/admin/(dashboard)/_state/kesehatan/ringkasan-kesehatan/ringkasanKesehatan.ts` — Refactor state ringkasan
- `src/app/admin/(dashboard)/kesehatan/posyandu/balita/page.tsx` — Update admin balita page
- `src/app/admin/(dashboard)/kesehatan/posyandu/ibu-hamil/page.tsx` — Update admin ibu hamil page
- `src/app/admin/(dashboard)/kesehatan/posyandu/ringkasan-kesehatan/page.tsx` — Update admin ringkasan
- `src/app/api/[[...slugs]]/_lib/kesehatan/ringkasan-kesehatan/stats.ts` — Perbaiki kalkulasi statistik
- `src/app/api/[[...slugs]]/_lib/kesehatan/ringkasan-kesehatan/index.ts` — Update endpoint
---
### 3. Fix Image Tips Keamanan (Ketuker)
**Problem:** Gambar pada data Tips Keamanan tertukar antara "Keamanan Rumah" dan "Keamanan Lingkungan Tanggungjawab Bersama".
**Root cause:** Nilai `imageName` di `tips-keamanan.json` salah assign — nama file gambar di-swap antara 2 record.
**Perubahan:**
- `prisma/data/keamanan/tips-keamanan/tips-keamanan.json` — Tukar nilai `imageName`:
- "Keamanan Rumah" → `vwZsaxcoFWDlxG1PW7FC0-mobile.webp` (sebelumnya `dSe0xyvNLkP2t2f6iq-Hk-mobile.webp`)
- "Keamanan Lingkungan..." → `dSe0xyvNLkP2t2f6iq-Hk-mobile.webp` (sebelumnya `vwZsaxcoFWDlxG1PW7FC0-mobile.webp`)
**Catatan:** `imageId` akan null di lokal karena MinIO lokal tidak punya file tersebut. Di STG, MinIO sudah punya kedua file — seed akan resolve dengan benar setelah deploy.
---
### 4. Cleanup Seed Core
- `prisma/_seeder_list/core/seed_app_menu.ts` — Dihapus (sudah tidak dipakai)
- `prisma/_seeder_list/core/seed_core.ts` — Dihapus (sudah tidak dipakai)
- `prisma/seed.ts` — Hapus import + call ke seed core, tambah `seedBanjar` dan `seedTipsKeamanan`
---
### 5. Update Seed PPID & Data
- `prisma/data/ppid/struktur-organisasi-ppid/struktur-organisasi-ppid.json` — Update data struktur organisasi PPID
- `prisma/_seeder_list/kesehatan/posyandu/seed_posyandu.ts` — Update seeder posyandu untuk include `banjarId`
- `prisma/data/kesehatan/posyandu/posyandu.json` — Update data seed posyandu dengan `banjarId`
---
## Catatan Penting
- **Migration wajib dijalankan** saat deploy: `prisma migrate deploy` sudah otomatis via `docker-entrypoint.sh`
- **Seed harus dijalankan ulang** di STG setelah deploy agar data banjar terisi dan imageId tips keamanan terkoreksi
- Gambar tips keamanan akan tetap null di lokal (MinIO lokal tidak punya file), tapi akan resolve di STG

View File

@@ -0,0 +1,62 @@
# Summary: Statistik Persentase Kesehatan di RingkasanKesehatanDesa
## Konteks
Dashboard admin punya 4 progress bar (Imunisasi Lengkap, Pemeriksaan Rutin,
Gizi Baik, Target Stunting) yang sebelumnya belum ada backend-nya. Schema
`RingkasanKesehatanDesa` hanya menyimpan 3 field count (`ibuHamilAkh`,
`balitaTerdaftar`, `alertStunting`).
## Yang Dikerjakan
### Step A — Schema + API (v0.1.49)
1. **Schema** — tambah 4 field `Int @default(0)` di `RingkasanKesehatanDesa`:
`imunisasiLengkapPct`, `pemeriksaanRutinPct`, `giziBaikPct`, `targetStuntingPct`.
2. **Migration** — buat manual SQL di
`prisma/migrations/20260504000000_add_statistik_pct_ringkasan_kesehatan/migration.sql`
(sandbox non-interaktif, jadi `migrate dev` di-bypass dengan SQL langsung).
3. **API**
- `kesehatan/ringkasan-kesehatan/updt.ts` — handler create + update terima 4 field baru.
- `kesehatan/ringkasan-kesehatan/index.ts` — Elysia body validation `t.Number({ minimum: 0, maximum: 100 })`.
- `findUnique.ts` tidak perlu diubah — `findFirst` tanpa `select` otomatis bawa field baru.
4. Build clean, push ke 2 remote (bipprojectbali + nicoarya20), merge ke `stg`.
### Step B — State Admin (v0.1.50)
- File baru `_state/kesehatan/ringkasan-kesehatan/ringkasanKesehatan.ts` (Valtio proxy):
- `findUnique.load()` — GET `/api/kesehatan/ringkasankesehatan/find`, isi `data` + sync ke `update.form`.
- `update.submit()` — zod validate (count ≥ 0, Pct 0-100) → PUT `/api/kesehatan/ringkasankesehatan/update` → refresh.
- `update.reset()` — reset form ke default.
## Decision Log
- **Gabung ke `RingkasanKesehatanDesa`** (bukan model baru `StatistikKesehatan`)
alasan: domain sama (ringkasan kesehatan desa), pattern existing single-row config
cocok, single fetch untuk dashboard, migration ringan additive.
- **Tipe `Int` (0100)** — UI render integer %, tidak butuh desimal.
- **Suffix `Pct`** — eksplisit semantik persentase (count vs persentase di model yang sama).
- **Field lama tidak disentuh** — additive only, sesuai AI-CONTRACT §10.
## Pending Manual
- Jalankan `bunx prisma migrate deploy` di terminal lokal → apply kolom baru ke DB.
- Trigger GitHub Workflow (publish + re-pull) bila mau deploy STG.
## Affected Files
```
prisma/schema.prisma
prisma/migrations/20260504000000_add_statistik_pct_ringkasan_kesehatan/migration.sql [NEW]
src/app/api/[[...slugs]]/_lib/kesehatan/ringkasan-kesehatan/updt.ts
src/app/api/[[...slugs]]/_lib/kesehatan/ringkasan-kesehatan/index.ts
src/app/admin/(dashboard)/_state/kesehatan/ringkasan-kesehatan/ringkasanKesehatan.ts [NEW]
src/app/admin/(dashboard)/kesehatan/ringkasan-kesehatan/page.tsx [NEW]
src/app/admin/_com/list_PageAdmin.tsx (3 sidebar instances → tambah Kesehatan_8)
package.json (0.1.48 → 0.1.51)
MIND/PLAN/task-statistik-kesehatan-ringkasan.md [NEW]
```
## Commits
- `ba2b90be` feat(kesehatan): tambah 4 field statistik pct ke RingkasanKesehatanDesa - bump ke 0.1.49
- `68a2a639` feat(kesehatan): tambah state file ringkasanKesehatan + bump ke 0.1.50
- (next) feat(kesehatan): admin page ringkasan-kesehatan + sidebar entry - bump ke 0.1.51

View File

@@ -1,842 +0,0 @@
# Dokumentasi Struktur Proyek Desa Darmasaba
## 1. Ringkasan Proyek
**Desa Darmasaba** adalah aplikasi web manajemen desa digital untuk Desa Darmasaba, Kabupaten Badung, Bali. Aplikasi ini berfungsi sebagai platform layanan publik digital yang mencakup informasi pemerintahan, layanan kesehatan, keamanan, pendidikan, ekonomi, lingkungan, dan inovasi desa.
### Tech Stack
| Kategori | Teknologi |
|----------|-----------|
| **Framework** | Next.js 15 (App Router) |
| **Language** | TypeScript (strict mode) |
| **Runtime** | Bun |
| **Backend API** | Elysia.js (high-performance HTTP server) |
| **Database** | PostgreSQL |
| **ORM** | Prisma 6.3.1 |
| **UI Framework** | Mantine UI v7-v8 |
| **State Management** | Jotai + Valtio + SWR |
| **Authentication** | iron-session + JWT (@elysiajs/jwt) |
| **File Storage** | Seafile (self-hosted) |
| **Text Editor** | Tiptap (Rich text editor) |
| **Charts** | Recharts + Chart.js |
| **Maps** | Leaflet + react-leaflet |
| **Testing** | Vitest (unit) + Playwright (E2E) |
| **Styling** | Mantine + PostCSS + Framer Motion |
| **Deployment** | Docker + GHCR + Portainer + GitHub Actions |
| **Version** | 0.1.11 |
---
## 2. Struktur Direktori
```
desa-darmasaba/
├── .github/workflows/ # GitHub Actions CI/CD
│ ├── docker-publish.yml # Auto build & push saat tag v*
│ ├── publish.yml # Manual build & push ke GHCR
│ ├── re-pull.yml # Manual re-pull di Portainer
│ └── script/ # Shell scripts untuk deploy
├── prisma/
│ ├── schema.prisma # Database schema (2413 baris, 100+ model)
│ └── seed.ts # Database seeder (400+ baris)
│ └── _seeder_list/ # Seed data per modul
├── public/ # Static assets
│ └── assets/
│ └── images/
├── src/
│ ├── app/ # Next.js App Router
│ │ ├── _com/ # Global components (SplashScreen, WebVitals)
│ │ ├── admin/ # ADMIN DASHBOARD
│ │ │ ├── (dashboard)/ # Route group dashboard
│ │ │ │ ├── desa/ # - Berita, Gallery, Layanan, dll
│ │ │ │ ├── ppid/ # - Informasi publik, struktur, dasar hukum
│ │ │ │ ├── kesehatan/ # - Fasilitas, posyandu, puskesmas, wabah
│ │ │ │ ├── ekonomi/ # - APBDes, pasar desa, BUMDes, dll
│ │ │ │ ├── kependudukan/ # - Banjar, agama, umur, migrasi
│ │ │ │ ├── pendidikan/ # - Sekolah, beasiswa, perpustakaan
│ │ │ │ ├── keamanan/ # - Keamanan lingkungan, polsek, dll
│ │ │ │ ├── lingkungan/ # - Sampah, penghijauan, gotong royong
│ │ │ │ ├── inovasi/ # - Desa digital, kolaborasi, dll
│ │ │ │ ├── landing-page/ # - Profil, prestasi, anti-korupsi
│ │ │ │ ├── musik/ # - Musik desa
│ │ │ │ ├── user&role/ # - Manajemen user & role
│ │ │ │ └── _com/ # - Shared admin components
│ │ │ ├── auth/ # Login OTP untuk admin
│ │ │ ├── csv/ # Demo CSV upload
│ │ │ └── layout.tsx # Admin shell (AppShell Mantine)
│ │ ├── api/ # ELYSIA.JS API SERVER
│ │ │ ├── [[...slugs]]/ # Catch-all route -> Elysia handler
│ │ │ │ ├── route.ts # - Main Elysia server export
│ │ │ │ └── _lib/ # - Domain route modules
│ │ │ │ ├── desa.ts
│ │ │ │ ├── ppid.ts
│ │ │ │ ├── kesehatan.ts
│ │ │ │ ├── ekonomi.ts
│ │ │ │ ├── keamanan.ts
│ │ │ │ ├── inovasi.ts
│ │ │ │ ├── lingkungan.ts
│ │ │ │ ├── pendidikan.ts
│ │ │ │ ├── kependudukan.ts
│ │ │ │ ├── landing_page.ts
│ │ │ │ ├── user/ # - User & Role management
│ │ │ │ ├── fileStorage/
│ │ │ │ ├── search/
│ │ │ │ ├── auth/
│ │ │ │ ├── upl-img.ts, upl-img-single.ts
│ │ │ │ ├── upl-csv.ts, upl-csv-single.ts
│ │ │ │ └── img.ts, img-del.ts, imgs.ts
│ │ │ ├── auth/ # Auth endpoints (login, logout, me)
│ │ │ └── ... # Other API routes
│ │ ├── darmasaba/ # PUBLIC-FACING WEBSITE
│ │ │ ├── _com/ # Shared components (Navbar, Footer, etc)
│ │ │ ├── (pages)/ # Public pages route group
│ │ │ │ ├── desa/ # - Profil, berita, gallery, layanan
│ │ │ │ ├── ppid/ # - PPID public pages
│ │ │ │ ├── kesehatan/ # - Health info pages
│ │ │ │ ├── ekonomi/ # - Economy pages
│ │ │ │ ├── kependudukan/
│ │ │ │ ├── pendidikan/
│ │ │ │ ├── keamanan/
│ │ │ │ ├── lingkungan/
│ │ │ │ ├── inovasi/
│ │ │ │ ├── musik/
│ │ │ │ └── module/ # - External module links
│ │ │ └── (tambahan)/ # Additional pages
│ │ ├── login/ # Login page
│ │ ├── registrasi/ # Registration page
│ │ ├── waiting-room/ # Waiting room (inactive users)
│ │ ├── terms-of-service/
│ │ ├── layout.tsx # Root layout (MantineProvider, ViewTransitions)
│ │ └── page.tsx # Homepage redirect
│ ├── components/
│ │ └── admin/ # Admin shared components
│ │ ├── AdminThemeProvider.tsx
│ │ ├── DarkModeToggle.tsx
│ │ ├── UnifiedSurface.tsx
│ │ └── UnifiedTypography.tsx
│ ├── con/ # Constants & configuration
│ │ ├── colors.ts # Color palette definitions
│ │ ├── images.ts
│ │ ├── navbar-list-menu.ts
│ │ ├── router.ts # Route mapping
│ │ └── sosmed.ts
│ ├── context/ # React contexts
│ │ └── MusicContext.tsx # Music player context
│ ├── hooks/ # Custom React hooks
│ ├── lib/ # Utility libraries
│ │ ├── router/
│ │ ├── api-auth.ts # API authentication helpers
│ │ ├── api-fetch.ts # API fetch wrapper
│ │ ├── EnvStringParse.ts
│ │ ├── prisma.ts # Prisma client singleton
│ │ ├── seafile-auth-service.ts
│ │ └── session.ts # iron-session helper
│ ├── state/ # Global state (Jotai/Valtio)
│ │ ├── darkModeStore.ts
│ │ ├── state-layanan.ts
│ │ ├── state-list-image.ts
│ │ └── state-nav.ts
│ ├── store/ # Additional stores
│ │ └── authStore.ts # Auth state (Jotai)
│ ├── types/ # TypeScript type definitions
│ └── utils/ # Utility functions
│ └── themeTokens.ts # Dark/light theme tokens
├── uploads/ # Local upload directory (images/files)
├── Dockerfile # Multi-stage Docker build (Bun)
├── docker-entrypoint.sh # Entry script (migrate + start)
├── next.config.ts # Next.js configuration
├── package.json # Dependencies & scripts
├── tsconfig.json # TypeScript configuration
├── biome.json # Biome linter config
├── eslint.config.mjs # ESLint config
├── NOTE.md # Deployment notes
├── QWEN.md # Project memory & workflow
└── AGENTS.md # Agent coding guidelines
```
---
## 3. Arsitektur
### Pola Arsitektur: Full-Stack Monolith dengan App Router
```
Browser
|
+-- Next.js 15 (App Router) -- Server Components + Client Components
|
+-- /darmasaba/* -> Public pages (SSR/CSR)
+-- /admin/* -> Admin dashboard (protected)
+-- /api/* -> Elysia.js API server
|
+-- Elysia Server (src/app/api/[[...slugs]]/route.ts)
|
+-- CORS enabled
+-- Swagger docs di /api/docs
+-- Static file serving (/api/uploads)
+-- Domain modules: Desa, PPID, Kesehatan, Ekonomi, dll
+-- Image upload handlers
|
+-- Prisma ORM --> PostgreSQL
+-- Seafile API --> File Storage
```
### Key Architectural Decisions:
1. **Next.js 15 App Router**: Menggunakan React Server Components sebagai default, dengan `"use client"` untuk interaktivitas
2. **Elysia.js di dalam API Routes**: Catch-all route `[[...slugs]]` meneruskan semua request ke Elysia handler
3. **Route Groups**: `(dashboard)` dan `(pages)` untuk organisasi tanpa mempengaruhi URL path
4. **Multi-tenant Ready**: Role-based access control dengan dynamic navbar berdasarkan roleId
5. **File Uploads**: Local uploads + Seafile integration untuk distributed storage
---
## 4. Modul Domain
### A. PPID (Pejabat Pengelola Informasi dan Dokumentasi)
**Lokasi**: `src/app/admin/(dashboard)/ppid/` dan `src/app/darmasaba/(pages)/ppid/`
| Sub-modul | Deskripsi |
|-----------|-----------|
| Profil PPID | Profil pejabat pengelola informasi |
| Struktur PPID | Struktur organisasi PPID dengan hierarki |
| Visi & Misi PPID | Visi dan misi PPID desa |
| Daftar Informasi Publik | Katalog informasi publik yang tersedia |
| Dasar Hukum | Regulasi dan dasar hukum PPID |
| Permohonan Informasi Publik | Form permohonan informasi (NIK, kontak, jenis) |
| Permohonan Keberatan | Formulir keberatan informasi |
| Indeks Kepuasan Masyarakat | Survey kepuasan dengan grafik demografis |
### B. Desa (Landing Page & Umum)
**Lokasi**: `src/app/admin/(dashboard)/desa/` dan `src/app/darmasaba/(pages)/desa/`
| Sub-modul | Deskripsi |
|-----------|-----------|
| Profil Desa | Sejarah, visi-misi, lambang, maskot |
| Profil Perbekel | Biodata, pengalaman, program unggulan perbekel |
| Perbekel dari Masa ke Masa | Historis perbekel per periode |
| Berita | Artikel berita dengan kategori & multi-image |
| Gallery | Foto dan video galeri |
| Pengumuman | Pengumuman desa dengan kategori |
| Potensi Desa | Potensi desa dengan kategori |
| Layanan Desa | Surat keterangan, ajukan permohonan |
| Penghargaan | Prestasi dan penghargaan desa |
| Desa Anti Korupsi | Transparansi anti-korupsi |
| SDGs Desa | Sustainable Development Goals desa |
| APBDes | Anggaran desa dengan hierarki item & realisasi |
| Prestasi Desa | Katalog prestasi |
### C. Kesehatan
**Lokasi**: `src/app/admin/(dashboard)/kesehatan/` dan `src/app/darmasaba/(pages)/kesehatan/`
| Sub-modul | Deskripsi |
|-----------|-----------|
| Fasilitas Kesehatan | Info rumah sakit/klinik (jam, dokter, tarif) |
| Puskesmas | Data puskesmas dengan jam operasional & kontak |
| Posyandu | Jadwal dan informasi posyandu |
| Program Kesehatan | Program-program kesehatan desa |
| Penanganan Darurat | Prosedur penanganan darurat |
| Kontak Darurat | Kontak emergency dengan WhatsApp |
| Info Wabah Penyakit | Informasi wabah penyakit |
| Artikel Kesehatan | Artikel kesehatan lengkap |
| Data Kesehatan Warga | Statistik kesehatan warga |
| Kelahiran & Kematian | Data vital statistik |
| Grafik Kepuasan | Grafik kepuasan layanan kesehatan |
### D. Ekonomi
**Lokasi**: `src/app/admin/(dashboard)/ekonomi/` dan `src/app/darmasaba/(pages)/ekonomi/`
| Sub-modul | Deskripsi |
|-----------|-----------|
| Pasar Desa | Katalog pasar desa dengan produk & rating |
| Struktur BUMDes | Organisasi BUMDes dengan pengurus |
| APBDes (PADesa) | Pendapatan Asli Desa |
| Program Kemiskinan | Program dan statistik kemiskinan |
| Sektor Unggulan | Sektor ekonomi unggulan desa |
| Lowongan Kerja Lokal | Info lowongan pekerjaan |
| Demografi Pekerjaan | Distribusi pekerjaan penduduk |
| Jumlah Pengangguran | Statistik pengangguran |
| Penduduk Usia Kerja Menganggur | Analisis pengangguran by usia & pendidikan |
| Jumlah Penduduk Miskin | Tren kemiskinan tahunan |
### E. Kependudukan
**Lokasi**: `src/app/admin/(dashboard)/kependudukan/` dan `src/app/darmasaba/(pages)/kependudukan/`
| Sub-modul | Deskripsi |
|-----------|-----------|
| Data Banjar | Data penduduk per banjar |
| Distribusi Agama | Statistik agama penduduk |
| Distribusi Umur | Piramida umur penduduk |
| Migrasi Penduduk | Data migrasi masuk/keluar |
| Dinamika Penduduk | Kelahiran, kematian, migrasi per tahun |
### F. Pendidikan
**Lokasi**: `src/app/admin/(dashboard)/pendidikan/` dan `src/app/darmasaba/(pages)/pendidikan/`
| Sub-modul | Deskripsi |
|-----------|-----------|
| Info Sekolah & PAUD | Data sekolah per jenjang (TK, SD, SMP, SMA) |
| Beasiswa Desa | Program beasiswa & pendaftar |
| Program Pendidikan Anak | Program pendidikan anak |
| Bimbingan Belajar | Informasi bimbingan belajar |
| Pendidikan Non Formal | Tempat & program non-formal |
| Perpustakaan Digital | Katalog buku & peminjaman |
| Data Pendidikan | Statistik pendidikan |
### G. Keamanan
**Lokasi**: `src/app/admin/(dashboard)/keamanan/` dan `src/app/darmasaba/(pages)/keamanan/`
| Sub-modul | Deskripsi |
|-----------|-----------|
| Keamanan Lingkungan (Pecalang/Patwal) | Sistem keamanan tradisional Bali |
| Polsek Terdekat | Data polsek dengan layanan & map |
| Kontak Darurat | Kontak darurat keamanan |
| Pencegahan Kriminalitas | Info pencegahan kriminal |
| Laporan Publik | Laporan masyarakat dengan tracking status |
| Tips Keamanan | Tips dan panduan keamanan |
### H. Lingkungan
**Lokasi**: `src/app/admin/(dashboard)/lingkungan/` dan `src/app/darmasaba/(pages)/lingkungan/`
| Sub-modul | Deskripsi |
|-----------|-----------|
| Pengelolaan Sampah | Bank sampah & pengelolaan |
| Program Penghijauan | Program penghijauan desa |
| Data Lingkungan | Data lingkungan desa |
| Gotong Royong | Kegiatan gotong royong |
| Edukasi Lingkungan | Edukasi lingkungan hidup |
| Konservasi Adat Bali | Tri Hita Karana & konservasi adat |
### I. Inovasi
**Lokasi**: `src/app/admin/(dashboard)/inovasi/` dan `src/app/darmasaba/(pages)/inovasi/`
| Sub-modul | Deskripsi |
|-----------|-----------|
| Desa Digital (Smart Village) | Transformasi digital desa |
| Program Kreatif Desa | Program kreatif & inovatif |
| Kolaborasi Inovasi | Kolaborasi dengan mitra |
| Info Teknologi Tepat Guna | Info teknologi untuk desa |
| Ajukan Ide Inovatif | Form pengajuan ide dari warga |
| Layanan Online Desa | Layanan administrasi online |
### J. Musik Desa
**Lokasi**: `src/app/admin/(dashboard)/musik/` dan `src/app/darmasaba/(pages)/musik/`
Model `MusikDesa` dengan audio file, cover image, genre, dan durasi. Dilengkapi dengan `FixedPlayerBar` di layout publik.
### K. User & Role (Admin)
**Lokasi**: `src/app/admin/(dashboard)/user&role/`
- **Role-based Access Control**: Role dengan permission JSON
- **User Session Management**: Multiple sessions per user dengan JWT
- **OTP Authentication**: Login dengan nomor telepon + OTP
- **Menu Access Control**: Dynamic navbar berdasarkan menu akses user
---
## 5. Database Schema (Prisma)
Schema terdiri dari **2413 baris** dengan **100+ model** dan **berbagai enum**. Berikut model-model utama:
### Core Models
| Model | Keterangan |
|-------|-----------|
| `FileStorage` | Central file storage untuk semua uploaded files |
| `AppMenu` / `AppMenuChild` | Menu navigasi aplikasi |
| `User` / `Role` / `UserSession` / `UserMenuAccess` | Sistem autentikasi & otorisasi |
| `KodeOtp` | OTP codes untuk login |
### Landing Page & Desa
| Model | Keterangan |
|-------|-----------|
| `PejabatDesa` | Pejabat desa dengan foto |
| `ProfilPerbekel` | Profil perbekel (biodata, pengalaman, program) |
| `PerbekelDariMasaKeMasa` | Historis perbekel |
| `Berita` / `KategoriBerita` | Berita desa |
| `PotensiDesa` / `KategoriPotensi` | Potensi desa |
| `Pengumuman` / `CategoryPengumuman` | Pengumuman |
| `GalleryFoto` / `GalleryVideo` | Gallery media |
| `Penghargaan` | Penghargaan desa |
| `APBDes` / `APBDesItem` / `RealisasiItem` | Anggaran dengan realisasi |
| `DesaAntiKorupsi` / `KategoriDesaAntiKorupsi` | Transparansi |
| `SdgsDesa` | SDGs desa |
| `PrestasiDesa` / `KategoriPrestasiDesa` | Prestasi |
| `MusikDesa` | Musik desa |
### PPID
| Model | Keterangan |
|-------|-----------|
| `StrukturPPID` / `PosisiOrganisasiPPID` / `PegawaiPPID` | Struktur organisasi |
| `VisiMisiPPID` | Visi misi |
| `ProfilePPID` | Profil pejabat |
| `DasarHukumPPID` | Regulasi |
| `DaftarInformasiPublik` | Katalog informasi |
| `PermohonanInformasiPublik` | Permohonan + lookup tables |
| `FormulirPermohonanKeberatan` | Keberatan |
| `IndeksKepuasanMasyarakat` + grafik breakdown | Survey kepuasan |
### Kesehatan
| Model | Keterangan |
|-------|-----------|
| `FasilitasKesehatan` | Fasilitas lengkap (dokter, tarif, prosedur) |
| `Puskesmas` / `JamOperasional` / `KontakPuskesmas` | Puskesmas |
| `Posyandu` | Pos pelayanan terpadu |
| `ProgramKesehatan` | Program kesehatan |
| `ArtikelKesehatan` | Artikel lengkap (gejala, pencegahan, P3K, dll) |
| `PenangananDarurat` / `KontakDarurat` | Darurat |
| `InfoWabahPenyakit` | Wabah |
| `DataKematian_Kelahiran` / `Kelahiran` / `Kematian` | Vital statistik |
| `GrafikKepuasan` | Kepuasan |
### Ekonomi
| Model | Keterangan |
|-------|-----------|
| `PasarDesa` / `KategoriProduk` / `KategoriToPasar` | Pasar desa |
| `StrukturBumDes` / `PosisiOrganisasiBumDes` / `PegawaiBumDes` | BUMDes |
| `ProgramKemiskinan` / `StatistikKemiskinan` | Kemiskinan |
| `SektorUnggulanDesa` | Sektor unggulan |
| `LowonganPekerjaan` | Lowongan |
| `DataDemografiPekerjaan` | Demografi pekerjaan |
| `DetailDataPengangguran` | Pengangguran |
| `GrafikJumlahPendudukMiskin` | Tren kemiskinan |
### Kependudukan
| Model | Keterangan |
|-------|-----------|
| `DataBanjar` | Data per banjar |
| `DistribusiAgama` | Distribusi agama |
| `DistribusiUmur` | Distribusi umur |
| `MigrasiPenduduk` | Migrasi (MASUK/KELUAR) |
| `DinamikaPenduduk` | Dinamika tahunan |
### Pendidikan
| Model | Keterangan |
|-------|-----------|
| `JenjangPendidikan` / `Lembaga` / `Siswa` / `Pengajar` | Data sekolah |
| `BeasiswaPendaftar` | Beasiswa (dengan enum lengkap) |
| `DataPerpustakaan` / `KategoriBuku` / `PeminjamanBuku` | Perpustakaan |
| `DataPendidikan` | Statistik |
### Keamanan
| Model | Keterangan |
|-------|-----------|
| `KeamananLingkungan` | Keamanan lingkungan |
| `PolsekTerdekat` / `LayananPolsek` / `LayananToPolsek` | Polsek |
| `KontakDaruratKeamanan` / `KontakItem` | Kontak darurat |
| `PencegahanKriminalitas` | Pencegahan |
| `LaporanPublik` / `PenangananLaporanPublik` (enum StatusLaporan) | Laporan |
| `Pelapor` | Pelapor |
| `MenuTipsKeamanan` | Tips |
### Lingkungan
| Model | Keterangan |
|-------|-----------|
| `PengelolaanSampah` | Pengelolaan sampah |
| `KeteranganBankSampahTerdekat` | Bank sampah |
| `ProgramPenghijauan` | Penghijauan |
| `DataLingkunganDesa` | Data lingkungan |
| `KegiatanDesa` / `KategoriKegiatan` | Gotong royong |
| `FilosofiTriHita` / `BentukKonservasiBerdasarkanAdat` | Konservasi Bali |
### Inovasi
| Model | Keterangan |
|-------|-----------|
| `DesaDigital` | Smart village |
| `ProgramKreatif` | Program kreatif |
| `KolaborasiInovasi` / `MitraKolaborasi` | Kolaborasi |
| `InfoTekno` | Teknologi tepat guna |
| `AjukanIdeInovatif` | Ide dari warga |
| `AdministrasiOnline` / `JenisLayanan` | Layanan online |
| `PengaduanMasyarakat` / `JenisPengaduan` | Pengaduan |
---
## 6. API Routes
Semua API ditangani oleh **Elysia.js** di `src/app/api/[[...slugs]]/route.ts`:
| Endpoint Group | Prefix | Deskripsi |
|---------------|--------|-----------|
| **File Storage** | `/api/file-storage` | CRUD file storage |
| **Landing Page** | `/api/landing-page` | Profil, prestasi, anti-korupsi, SDGs, APBDes |
| **Desa** | `/api/desa` | Berita, gallery, potensi, pengumuman, layanan |
| **PPID** | `/api/ppid` | Semua endpoint PPID |
| **Kesehatan** | `/api/kesehatan` | Fasilitas, puskesmas, posyandu, artikel, wabah |
| **Ekonomi** | `/api/ekonomi` | Pasar desa, BUMDes, APBDes, pengangguran |
| **Keamanan** | `/api/keamanan` | Keamanan, polsek, laporan, kriminalitas |
| **Lingkungan** | `/api/lingkungan` | Sampah, penghijauan, gotong royong |
| **Pendidikan** | `/api/pendidikan` | Sekolah, beasiswa, perpustakaan |
| **Kependudukan** | `/api/kependudukan` | Banjar, agama, umur, migrasi |
| **Inovasi** | `/api/inovasi` | Desa digital, kolaborasi, pengaduan |
| **User** | `/api/admin/user` | CRUD user |
| **Role** | `/api/admin/role` | CRUD role |
| **Search** | `/api/search` | Global search |
| **Utils** | `/api/utils/version` | Version info |
### Utility Endpoints
| Endpoint | Method | Deskripsi |
|----------|--------|-----------|
| `/api/img/:name` | GET | Serve image dengan resize |
| `/api/img/:name` | DELETE | Delete image |
| `/api/imgs` | GET | List images dengan pagination |
| `/api/upl-img` | POST | Upload multiple images |
| `/api/upl-img-single` | POST | Upload single image |
| `/api/upl-csv` | POST | Upload CSV multiple |
| `/api/upl-csv-single` | POST | Upload single CSV |
### Auth Endpoints
| Endpoint | Method | Deskripsi |
|----------|--------|-----------|
| `/api/auth/login` | POST | Login dengan OTP |
| `/api/auth/logout` | POST | Logout |
| `/api/auth/me` | GET | Get current user |
**Swagger Documentation**: Tersedia di `/api/docs`
---
## 7. Halaman Admin
Admin dashboard menggunakan **Mantine AppShell** dengan sidebar navigasi dinamis berbasis role.
### Route Group: `/admin`
| Section | Path | Deskripsi |
|---------|------|-----------|
| **Landing Page** | `/admin/landing-page/` | Profil desa, prestasi, anti-korupsi, SDGs, media sosial |
| **Desa** | `/admin/desa/` | Berita, gallery, layanan, penghargaan, pengumuman, potensi, profil |
| **PPID** | `/admin/ppid/` | 8 sub-modul PPID lengkap |
| **Kesehatan** | `/admin/kesehatan/` | 8 sub-modul kesehatan |
| **Ekonomi** | `/admin/ekonomi/` | 10 sub-modul ekonomi |
| **Kependudukan** | `/admin/kependudukan/` | 4 sub-modul kependudukan |
| **Pendidikan** | `/admin/pendidikan/` | 7 sub-modul pendidikan |
| **Keamanan** | `/admin/keamanan/` | 6 sub-modul keamanan |
| **Lingkungan** | `/admin/lingkungan/` | 6 sub-modul lingkungan |
| **Inovasi** | `/admin/inovasi/` | 6 sub-modul inovasi |
| **Musik** | `/admin/musik/` | Manajemen musik desa |
| **User & Role** | `/admin/user&role/` | Manajemen user, role, menu access |
### Fitur Admin:
- **Role-based Dynamic Navbar**: Navbar berubah berdasarkan roleId user
- **Dark Mode Toggle**: Tema gelap/terang
- **OTP Login**: Login dengan nomor telepon + kode OTP
- **Session Management**: Multiple sessions per user dengan JWT tokens
- **CSV Upload**: Import data via CSV
- **Image Upload**: Upload dengan preview dan management
- **Rich Text Editor**: Tiptap untuk konten HTML
### Role-Based Redirect:
| roleId | Role | Default Redirect |
|--------|------|-----------------|
| 0, 1, 2 | Super Admin / Admin Desa | `/admin/landing-page/profil/program-inovasi` |
| 3 | Admin Kesehatan | `/admin/kesehatan/posyandu` |
| 4 | Admin Pendidikan | `/admin/pendidikan/info-sekolah/jenjang-pendidikan` |
---
## 8. Halaman Publik
Public website di `/darmasaba/` dengan layout yang mencakup **Navbar**, **Footer**, dan **Fixed Music Player Bar**.
### Route Group: `/darmasaba`
| Section | Path | Deskripsi |
|---------|------|-----------|
| **Home** | `/darmasaba` | Landing page utama |
| **Desa** | `/darmasaba/desa` | Profil, berita, gallery, layanan, pengumuman, potensi |
| **PPID** | `/darmasaba/ppid` | 7 sub-halaman PPID publik |
| **Kesehatan** | `/darmasaba/kesehatan` | Info kesehatan publik |
| **Ekonomi** | `/darmasaba/ekonomi` | Info ekonomi desa |
| **Kependudukan** | `/darmasaba/kependudukan` | Data kependudukan |
| **Pendidikan** | `/darmasaba/pendidikan` | Info pendidikan |
| **Keamanan** | `/darmasaba/keamanan` | Info keamanan |
| **Lingkungan** | `/darmasaba/lingkungan` | Info lingkungan |
| **Inovasi** | `/darmasaba/inovasi` | Info inovasi |
| **Musik** | `/darmasaba/musik` | Musik desa |
| **Module** | `/darmasaba/module/*` | Link ke modul eksternal (DAVES, MANGAN, Bicara-Darma, BARES, dll) |
### Fitur Publik:
- **Fixed Music Player Bar**: Player musik yang selalu tampil di bottom
- **Global Search**: Pencarian global
- **News Reader**: Notifikasi berita modern
- **View Transitions**: Smooth page transitions
- **Responsive Design**: Mobile-first dengan Mantine breakpoints
---
## 9. Komponen Utama
### Admin Components (`src/components/admin/`)
| Komponen | Deskripsi |
|----------|-----------|
| `AdminThemeProvider.tsx` | Theme provider untuk admin |
| `DarkModeToggle.tsx` | Toggle dark/light mode |
| `UnifiedSurface.tsx` | Consistent surface/card component |
| `UnifiedTypography.tsx` | Consistent typography system |
### Public Shared Components (`src/app/darmasaba/_com/`)
| Komponen | Deskripsi |
|----------|-----------|
| `Navbar.tsx` | Main navigation bar |
| `NavbarMainMenu.tsx` | Main menu dengan kategori |
| `NavbarSubMenu.tsx` | Submenu dropdown |
| `Footer.tsx` | Footer dengan info desa |
| `FixedPlayerBar.tsx` | Music player bar fixed di bottom |
| `LoadDataFirstClient.tsx` | Client-side data preloader |
| `globalSearch.tsx` | Global search component |
| `NewsReader.tsx` | News notification reader |
| `ModernNewsNotification.tsx` | News toast notifications |
### Global Components (`src/app/_com/`)
| Komponen | Deskripsi |
|----------|-----------|
| `SpashScreen.tsx` | Splash screen on load |
| `WebVitals.tsx` | Web Vitals monitoring |
---
## 10. State Management
Proyek menggunakan **multi-layer state management**:
| Library | Penggunaan | Lokasi |
|---------|-----------|--------|
| **Jotai** | Auth state (`authStore`) | `src/store/authStore.ts` |
| **Valtio** | Dark mode, layanan, image list, nav state | `src/state/*.ts` |
| **SWR** | Server state fetching & caching | Digunakan di components |
| **React Context** | Music player context | `src/app/context/MusicContext.tsx` |
| **React useState** | Local component state | Di components |
### State Files:
```
src/state/
darkModeStore.ts -- Valtio proxy untuk dark mode
state-layanan.ts -- State layanan desa
state-list-image.ts -- State list image untuk upload
state-nav.ts -- State navigasi
src/store/
authStore.ts -- Jotai atom untuk auth user state
```
---
## 11. Autentikasi
Sistem autentikasi menggunakan **OTP (One-Time Password)** via WhatsApp/Telepon dengan **iron-session** untuk session management.
### Flow Autentikasi:
1. User memasukkan **nomor telepon** di `/login`
2. Sistem mengirim **kode OTP** via WhatsApp Server
3. OTP disimpan di model `KodeOtp`
4. User memasukkan kode OTP
5. Jika valid, session dibuat dengan **iron-session** + **JWT token**
6. Session disimpan di `UserSession` model dengan expiry
### Session Structure:
```typescript
// src/lib/session.ts
type SessionData = {
user?: {
id: string;
name: string;
roleId: number;
menuIds?: string[] | null;
isActive?: boolean;
};
};
```
### Role-Based Access:
| roleId | Role | Default Redirect |
|--------|------|-----------------|
| 0, 1, 2 | Super Admin / Admin Desa | `/admin/landing-page/profil/program-inovasi` |
| 3 | Admin Kesehatan | `/admin/kesehatan/posyandu` |
| 4 | Admin Pendidikan | `/admin/pendidikan/info-sekolah/jenjang-pendidikan` |
### Authorization:
- **UserMenuAccess**: Mapping user ke menu yang boleh diakses
- **Dynamic Navbar**: Navbar dirender berdasarkan `menuIds` user
- **Inactive Users**: Dialihkan ke `/waiting-room`
---
## 12. Deployment
### Docker Setup
**Dockerfile** menggunakan **multi-stage build** dengan base image `oven/bun:1-debian`:
```
Stage 1: Builder
- Install dependencies (bun install --frozen-lockfile)
- Generate Prisma client
- Build Next.js (bun run build)
Stage 2: Runner
- Copy .next, node_modules, public, prisma, src/lib, tsconfig.json
- Non-root user (nextjs:nodejs)
- Volume /app/uploads untuk file uploads
- Port 3000
```
### Entry Point (`docker-entrypoint.sh`):
```bash
bunx prisma migrate deploy # Run migrations
exec bun start # Start Next.js production server
```
### CI/CD dengan GitHub Actions
Terdapat **3 workflow**:
| Workflow | Trigger | Fungsi |
|----------|---------|--------|
| `docker-publish.yml` | Push tag `v*` | Auto build & push ke GHCR |
| `publish.yml` | Manual (workflow_dispatch) | Build & push ke GHCR dengan input `stack_env` + `tag` |
| `re-pull.yml` | Manual (workflow_dispatch) | Re-pull image di Portainer dengan input `stack_name` + `stack_env` |
### Deployment Workflow (Sequential):
```
1. Update version di package.json (semver)
2. Commit perubahan
3. Push ke branch target (stg/prod)
4. Trigger publish.yml:
gh workflow run publish.yml --ref main -f stack_env=stg -f tag=<version>
5. Tunggu sampai publish selesai (status: completed)
6. Trigger re-pull.yml:
gh workflow run re-pull.yml --ref main -f stack_name=desa-darmasaba -f stack_env=stg
7. Verifikasi di Portainer
```
**PENTING**: `publish.yml` dan `re-pull.yml` TIDAK boleh dijalankan bersamaan (race condition).
### Environments:
- **dev**: Development
- **stg**: Staging (`desa-darmasaba-stg.wibudev.com`)
- **prod**: Production
### Notification:
- Telegram notification via `notify.sh` script setelah setiap workflow
---
## 13. Scripts
| Script | Command | Deskripsi |
|--------|---------|-----------|
| `dev` | `next dev` | Development server |
| `build` | `next build` | Production build |
| `start` | `next start` | Production server |
| `test:api` | `vitest run` | Run API unit tests |
| `test:e2e` | `playwright test` | Run E2E tests |
| `test` | `bun run test:api && bun run test:e2e` | Run all tests |
| `seed` | `bun run prisma/seed.ts` | Seed database |
| `prisma:generate` | `bunx prisma generate` | Generate Prisma client |
| `prisma:push` | `bunx prisma db push` | Push schema to database |
| `prisma:studio` | `bunx prisma studio` | Open Prisma Studio GUI |
| `gen:api` | *(empty)* | Generate API types (placeholder) |
### Prisma Seed Configuration:
```json
// package.json
{
"prisma": {
"seed": "bun run prisma/seed.ts"
}
}
```
---
## 14. Environment Variables
File: `.env.example`
| Variable | Deskripsi | Contoh |
|----------|-----------|--------|
| `DATABASE_URL` | PostgreSQL connection string | `postgresql://user:pass@localhost:5432/desa-darmasaba` |
| `SEAFILE_TOKEN` | Seafile API token | `your_seafile_token` |
| `SEAFILE_REPO_ID` | Seafile repository ID | `your_repo_id` |
| `SEAFILE_URL` | Seafile instance URL | `https://seafile.example.com` |
| `SEAFILE_PUBLIC_SHARE_TOKEN` | Token untuk public share | `your_share_token` |
| `WIBU_UPLOAD_DIR` | Upload directory path | `uploads` |
| `WA_SERVER_TOKEN` | WhatsApp server token | `your_wa_token` |
| `NEXT_PUBLIC_BASE_URL` | Base URL aplikasi | `/` (relative) |
| `EMAIL_USER` | Email untuk notifikasi | `your_email@gmail.com` |
| `EMAIL_PASS` | Email app password | `your_app_password` |
| `BASE_TOKEN_KEY` | JWT secret key | `your_jwt_secret` |
| `BOT_TOKEN` | Telegram bot token | `your_bot_token` |
| `CHAT_ID` | Telegram chat ID | `your_chat_id` |
| `SESSION_PASSWORD` | iron-session password (min 32 chars) | `secure_32_char_password` |
| `ELEVENLABS_API_KEY` | ElevenLabs API (TTS - optional) | `your_elevenlabs_key` |
---
## 15. Layanan Eksternal
### PostgreSQL
- **Provider**: PostgreSQL via Prisma ORM
- **Schema**: `public`
- **Connection**: Via `DATABASE_URL` environment variable
- **Migrations**: `prisma migrate deploy` di docker entrypoint
### Seafile (File Storage)
- **Tipe**: Self-hosted file sync & share
- **Penggunaan**: Storage untuk images, documents, audio files
- **Integrasi**: `src/lib/seafile-auth-service.ts`
- **CDN**: URL generation untuk public sharing
- **Config**: Token, repo ID, base URL
### WhatsApp Server
- **Penggunaan**: Kirim OTP codes saat login
- **Config**: `WA_SERVER_TOKEN`
### Telegram Bot
- **Penggunaan**: Notifikasi deployment & sistem
- **Config**: `BOT_TOKEN` + `CHAT_ID`
- **Integration**: `notify.sh` script di GitHub Actions
### ElevenLabs (Optional)
- **Penggunaan**: Text-to-Speech (TTS) features
- **Config**: `ELEVENLABS_API_KEY`
### Email (Nodemailer)
- **Penggunaan**: Notifikasi email untuk subscription/pengumuman
- **Config**: `EMAIL_USER` + `EMAIL_PASS`
- **Provider**: Gmail (app password)
---
## Ringkasan Cepat
| Aspek | Detail |
|-------|--------|
| **Framework** | Next.js 15 (App Router) + Elysia.js |
| **Database** | PostgreSQL + Prisma (100+ models) |
| **Auth** | OTP + iron-session + JWT |
| **Storage** | Seafile + local uploads |
| **UI** | Mantine UI + Tiptap + Framer Motion |
| **State** | Jotai + Valtio + SWR |
| **Deploy** | Docker + GHCR + Portainer + GitHub Actions |
| **Runtime** | Bun |
| **Testing** | Vitest + Playwright |
| **Version** | 0.1.11 |

View File

@@ -1,6 +1,6 @@
{
"name": "desa-darmasaba",
"version": "0.1.46",
"version": "0.1.69",
"private": true,
"scripts": {
"dev": "next dev",

View File

@@ -1,57 +0,0 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../load-json";
const appMenuJson = loadJsonData("core/app-menu.json");
const appMenuChildJson = loadJsonData("core/app-menu-child.json");
export async function seedAppMenu() {
console.log("🔄 Seeding AppMenu...");
for (const item of appMenuJson) {
await prisma.appMenu.upsert({
where: { id: item.id },
update: {
name: item.name,
link: item.link,
isActive: item.isActive,
},
create: {
id: item.id,
name: item.name,
link: item.link,
isActive: item.isActive,
},
});
console.log(`✅ AppMenu seeded: ${item.name}`);
}
console.log("🎉 AppMenu seed selesai");
}
export async function seedAppMenuChild() {
console.log("🔄 Seeding AppMenuChild...");
for (const item of appMenuChildJson) {
await prisma.appMenuChild.upsert({
where: { id: item.id },
update: {
name: item.name,
link: item.link,
isActive: item.isActive,
appMenuId: item.appMenuId,
},
create: {
id: item.id,
name: item.name,
link: item.link,
isActive: item.isActive,
appMenuId: item.appMenuId,
},
});
console.log(`✅ AppMenuChild seeded: ${item.name}`);
}
console.log("🎉 AppMenuChild seed selesai");
}

View File

@@ -1,69 +0,0 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../load-json";
const layananJson = loadJsonData("core/layanan.json");
const potensiJson = loadJsonData("core/potensi.json");
const landingPageLayananJson = loadJsonData("core/landingpage-layanan.json");
export async function seedLayananCore() {
console.log("🔄 Seeding Layanan...");
for (const item of layananJson) {
await prisma.layanan.upsert({
where: { id: item.id },
update: {
name: item.name,
},
create: {
id: item.id,
name: item.name,
},
});
console.log(`✅ Layanan seeded: ${item.name}`);
}
console.log("🎉 Layanan seed selesai");
}
export async function seedPotensiCore() {
console.log("🔄 Seeding Potensi...");
for (const item of potensiJson) {
await prisma.potensi.upsert({
where: { id: item.id },
update: {
name: item.name,
},
create: {
id: item.id,
name: item.name,
},
});
console.log(`✅ Potensi seeded: ${item.name}`);
}
console.log("🎉 Potensi seed selesai");
}
export async function seedLandingPageLayanan() {
console.log("🔄 Seeding LandingPage_Layanan...");
for (const item of landingPageLayananJson) {
await prisma.landingPage_Layanan.upsert({
where: { id: item.id },
update: {
deksripsi: item.deksripsi,
},
create: {
id: item.id,
deksripsi: item.deksripsi,
},
});
console.log(`✅ LandingPage_Layanan seeded: ${item.id}`);
}
console.log("🎉 LandingPage_Layanan seed selesai");
}

View File

@@ -0,0 +1,30 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../../load-json";
const eventBudayaJson = loadJsonData("desa/event-budaya/event-budaya.json");
export async function seedEventBudaya() {
console.log("🔄 Seeding Event Budaya...");
for (const item of eventBudayaJson) {
await prisma.eventBudaya.upsert({
where: { id: item.id },
update: {
nama: item.nama,
tanggal: new Date(item.tanggal),
lokasi: item.lokasi,
deskripsi: item.deskripsi,
},
create: {
id: item.id,
nama: item.nama,
tanggal: new Date(item.tanggal),
lokasi: item.lokasi,
deskripsi: item.deskripsi,
},
});
console.log(` ✅ Event: ${item.nama}`);
}
console.log("🎉 Event Budaya seed selesai");
}

View File

@@ -27,6 +27,8 @@ export async function seedAPBDes() {
jumlah: item.jumlah,
imageId,
fileId,
isActive: true,
deletedAt: null,
},
create: {
id: item.id,

View File

@@ -7,8 +7,9 @@ const realisasiJson = loadJsonData("ekonomi/apbdes/realisasi-items.json");
export async function seedAPBDesItem() {
console.log("Seeding APBDes Items...");
// Seed items first (sorted by level to ensure parents exist)
const sortedItems = [...itemsJson].sort((a, b) => a.level - b.level);
// Flatten items from nested APBDes objects, sorted by level to ensure parents exist
const allItems = itemsJson.flatMap((apbdes: any) => apbdes.items ?? []);
const sortedItems = [...allItems].sort((a: any, b: any) => a.level - b.level);
for (const item of sortedItems) {
await prisma.aPBDesItem.upsert({
@@ -21,6 +22,8 @@ export async function seedAPBDesItem() {
level: item.level,
parentId: item.parentId,
apbdesId: item.apbdesId,
isActive: true,
deletedAt: null,
},
create: {
id: item.id,

View File

@@ -0,0 +1,35 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../load-json";
const cctvData = loadJsonData("keamanan/cctv/cctv.json");
export async function seedCctv() {
console.log("🔄 Seeding CCTV Keamanan...");
for (const c of cctvData) {
await prisma.cctvKeamanan.upsert({
where: { id: c.id },
update: {
kode: c.kode,
nama: c.nama,
lokasi: c.lokasi,
latitude: c.latitude ?? null,
longitude: c.longitude ?? null,
status: c.status,
lastActive: new Date(c.lastActive),
},
create: {
id: c.id,
kode: c.kode,
nama: c.nama,
lokasi: c.lokasi,
latitude: c.latitude ?? null,
longitude: c.longitude ?? null,
status: c.status,
lastActive: new Date(c.lastActive),
},
});
}
console.log(`✅ CCTV Keamanan seeded: ${cctvData.length} data`);
}

View File

@@ -32,6 +32,7 @@ export async function seedPosyandu() {
deskripsi: p.deskripsi,
jadwalPelayanan: p.jadwalPelayanan,
imageId,
banjarId: p.banjarId || null,
},
create: {
id: p.id,
@@ -40,6 +41,7 @@ export async function seedPosyandu() {
deskripsi: p.deskripsi,
jadwalPelayanan: p.jadwalPelayanan,
imageId,
banjarId: p.banjarId || null,
},
});

View File

@@ -0,0 +1,502 @@
import prisma from "@/lib/prisma";
import { JenisKelaminBalita, StatusStunting } from "@prisma/client";
// Fokus data: proporsi stunting realistis untuk simulasi dashboard
// 10 STUNTING, 7 ALERT, 8 NORMAL dari 25 total
const BALITA_DATA = [
// ===== STUNTING (TB/U < -2 SD dari median WHO) =====
{
id: "balita_001",
nama: "Wayan Aditya Pratama",
nik: "5101014505230001",
tanggalLahir: new Date("2023-05-04"), // 36 bulan
jenisKelamin: JenisKelaminBalita.L,
beratBadanKg: 11.5,
tinggiBadanCm: 83.0, // median 96cm, -2SD ~89cm
namaOrtu: "I Wayan Suardika",
alamat: "Banjar Pudak, Desa Darmasaba",
noHpOrtu: "08123456801",
posyanduId: "cmkanjnmx0006vntz1cn7owpb",
imunisasiLengkap: true,
giziBaik: false,
pemeriksaanRutin: true,
statusStunting: StatusStunting.STUNTING,
catatan: "TB/U -2.8 SD. Dalam program PMT (Pemberian Makanan Tambahan). Orang tua sudah mendapat konseling gizi.",
},
{
id: "balita_002",
nama: "Ni Kadek Mira Sari",
nik: "5101014501240002",
tanggalLahir: new Date("2024-01-04"), // 16 bulan
jenisKelamin: JenisKelaminBalita.P,
beratBadanKg: 7.8,
tinggiBadanCm: 70.5, // median ~78cm
namaOrtu: "I Kadek Suartha",
alamat: "Banjar Dahlia, Desa Darmasaba",
noHpOrtu: "08123456802",
posyanduId: "posyandu_dahlia_001",
imunisasiLengkap: false,
giziBaik: false,
pemeriksaanRutin: false,
statusStunting: StatusStunting.STUNTING,
catatan: "TB/U -3.1 SD. Imunisasi belum lengkap. Ibu bekerja, kunjungan posyandu tidak rutin. Perlu pendampingan kader.",
},
{
id: "balita_003",
nama: "Putu Rian Saputra",
nik: "5101014501220003",
tanggalLahir: new Date("2022-01-04"), // 40 bulan
jenisKelamin: JenisKelaminBalita.L,
beratBadanKg: 12.5,
tinggiBadanCm: 89.0, // median 103cm
namaOrtu: "Ni Putu Sumiati",
alamat: "Banjar Dahlia, Desa Darmasaba",
noHpOrtu: "08123456803",
posyanduId: "posyandu_dahlia_001",
imunisasiLengkap: true,
giziBaik: false,
pemeriksaanRutin: true,
statusStunting: StatusStunting.STUNTING,
catatan: "TB/U -3.2 SD. Sudah dirujuk ke Puskesmas Abiansemal 3 untuk pemeriksaan lebih lanjut.",
},
{
id: "balita_004",
nama: "Ni Komang Ayu Lestari",
nik: "5101014507230004",
tanggalLahir: new Date("2023-07-04"), // 22 bulan
jenisKelamin: JenisKelaminBalita.P,
beratBadanKg: 8.2,
tinggiBadanCm: 74.0, // median ~84cm
namaOrtu: "I Komang Sudiarta",
alamat: "Banjar Anggrek, Desa Darmasaba",
noHpOrtu: "08123456804",
posyanduId: "posyandu_anggrek_001",
imunisasiLengkap: true,
giziBaik: false,
pemeriksaanRutin: true,
statusStunting: StatusStunting.STUNTING,
catatan: "TB/U -2.5 SD. Riwayat BBLR (berat lahir rendah) 2.3 kg.",
},
{
id: "balita_005",
nama: "Ketut Agus Pratama",
nik: "5101014507240005",
tanggalLahir: new Date("2024-07-04"), // 10 bulan
jenisKelamin: JenisKelaminBalita.L,
beratBadanKg: 6.9,
tinggiBadanCm: 66.0, // median ~72cm
namaOrtu: "Ni Ketut Sariani",
alamat: "Banjar Kamboja, Desa Darmasaba",
noHpOrtu: "08123456805",
posyanduId: "posyandu_kamboja_001",
imunisasiLengkap: false,
giziBaik: false,
pemeriksaanRutin: false,
statusStunting: StatusStunting.STUNTING,
catatan: "TB/U -2.3 SD. Lahir prematur 35 minggu. Keluarga prasejahtera, masuk program PKH.",
},
{
id: "balita_006",
nama: "Ni Made Sinta Dewi",
nik: "5101014507220006",
tanggalLahir: new Date("2022-07-04"), // 34 bulan
jenisKelamin: JenisKelaminBalita.P,
beratBadanKg: 11.0,
tinggiBadanCm: 84.5, // median ~94cm
namaOrtu: "I Made Sudarsana",
alamat: "Banjar Melur, Desa Darmasaba",
noHpOrtu: "08123456806",
posyanduId: "posyandu_melur_001",
imunisasiLengkap: true,
giziBaik: false,
pemeriksaanRutin: true,
statusStunting: StatusStunting.STUNTING,
catatan: "TB/U -2.6 SD. Nafsu makan rendah, sedang dalam pantauan ahli gizi Puskesmas.",
},
{
id: "balita_007",
nama: "Made Dani Putra",
nik: "5101014501250007",
tanggalLahir: new Date("2025-01-04"), // 4 bulan
jenisKelamin: JenisKelaminBalita.L,
beratBadanKg: 5.0,
tinggiBadanCm: 57.0, // median ~63cm
namaOrtu: "Ni Made Suparni",
alamat: "Banjar Kenanga, Desa Darmasaba",
noHpOrtu: "08123456807",
posyanduId: "posyandu_kenanga_001",
imunisasiLengkap: false,
giziBaik: false,
pemeriksaanRutin: true,
statusStunting: StatusStunting.STUNTING,
catatan: "TB/U -2.4 SD. BBLR 2.1 kg, ibu ASI eksklusif. Kunjungan rutin ke posyandu.",
},
{
id: "balita_008",
nama: "Ni Putu Ratna Sari",
nik: "5101014507210008",
tanggalLahir: new Date("2021-07-04"), // 46 bulan
jenisKelamin: JenisKelaminBalita.P,
beratBadanKg: 13.0,
tinggiBadanCm: 92.0, // median ~106cm
namaOrtu: "I Putu Suarjana",
alamat: "Banjar Mawar, Desa Darmasaba",
noHpOrtu: "08123456808",
posyanduId: "posyandu_mawar_001",
imunisasiLengkap: true,
giziBaik: false,
pemeriksaanRutin: true,
statusStunting: StatusStunting.STUNTING,
catatan: "TB/U -2.9 SD. Sudah 6 bulan dalam program intervensi stunting desa.",
},
{
id: "balita_009",
nama: "Gede Yoga Pratama",
nik: "5101014505210009",
tanggalLahir: new Date("2021-05-04"), // 48 bulan
jenisKelamin: JenisKelaminBalita.L,
beratBadanKg: 13.5,
tinggiBadanCm: 91.0, // median ~108cm
namaOrtu: "Ni Wayan Suarningsih",
alamat: "Banjar Pudak, Desa Darmasaba",
noHpOrtu: "08123456809",
posyanduId: "cmkanjnmx0006vntz1cn7owpb",
imunisasiLengkap: true,
giziBaik: false,
pemeriksaanRutin: false,
statusStunting: StatusStunting.STUNTING,
catatan: "TB/U -3.0 SD. Keluarga baru pindah dari luar desa. Sedang proses pendataan ulang.",
},
{
id: "balita_010",
nama: "Ni Nyoman Sari Utami",
nik: "5101014505230010",
tanggalLahir: new Date("2023-05-04"), // 24 bulan
jenisKelamin: JenisKelaminBalita.P,
beratBadanKg: 9.5,
tinggiBadanCm: 80.0, // median ~87cm
namaOrtu: "I Nyoman Sueca",
alamat: "Banjar Melati, Desa Darmasaba",
noHpOrtu: "08123456810",
posyanduId: "posyandu_melati_001",
imunisasiLengkap: true,
giziBaik: false,
pemeriksaanRutin: true,
statusStunting: StatusStunting.STUNTING,
catatan: "TB/U -2.2 SD. Batas bawah stunting. Perlu dipantau ketat tiap bulan.",
},
// ===== ALERT (TB/U antara -1 SD dan -2 SD) =====
{
id: "balita_011",
nama: "Wayan Krisna Dewa",
nik: "5101014501240011",
tanggalLahir: new Date("2024-01-04"), // 16 bulan
jenisKelamin: JenisKelaminBalita.L,
beratBadanKg: 9.8,
tinggiBadanCm: 74.0, // median ~78cm, -1SD ~75cm
namaOrtu: "I Wayan Artana",
alamat: "Banjar Mawar, Desa Darmasaba",
noHpOrtu: "08123456811",
posyanduId: "posyandu_mawar_001",
imunisasiLengkap: true,
giziBaik: true,
pemeriksaanRutin: true,
statusStunting: StatusStunting.ALERT,
catatan: "TB/U -1.5 SD. Perlu pemantauan lebih lanjut, gizi cukup baik.",
},
{
id: "balita_012",
nama: "Ni Wayan Novi Andriani",
nik: "5101014505230012",
tanggalLahir: new Date("2023-05-04"), // 24 bulan
jenisKelamin: JenisKelaminBalita.P,
beratBadanKg: 10.8,
tinggiBadanCm: 83.0, // median ~87cm
namaOrtu: "Ni Wayan Artini",
alamat: "Banjar Melati, Desa Darmasaba",
noHpOrtu: "08123456812",
posyanduId: "posyandu_melati_001",
imunisasiLengkap: true,
giziBaik: true,
pemeriksaanRutin: true,
statusStunting: StatusStunting.ALERT,
catatan: "TB/U -1.8 SD. Nafsu makan baik, BB naik konsisten.",
},
{
id: "balita_013",
nama: "Putu Deva Mahendra",
nik: "5101014511240013",
tanggalLahir: new Date("2024-11-04"), // 6 bulan
jenisKelamin: JenisKelaminBalita.L,
beratBadanKg: 6.8,
tinggiBadanCm: 63.5, // median ~67cm
namaOrtu: "I Putu Ariana",
alamat: "Banjar Anggrek, Desa Darmasaba",
noHpOrtu: "08123456813",
posyanduId: "posyandu_anggrek_001",
imunisasiLengkap: false,
giziBaik: true,
pemeriksaanRutin: true,
statusStunting: StatusStunting.ALERT,
catatan: "TB/U -1.6 SD. ASI eksklusif. Jadwal imunisasi DPT ketiga belum terlaksana.",
},
{
id: "balita_014",
nama: "Ni Komang Dewi Lestari",
nik: "5101014501220014",
tanggalLahir: new Date("2022-01-04"), // 40 bulan
jenisKelamin: JenisKelaminBalita.P,
beratBadanKg: 14.0,
tinggiBadanCm: 96.0, // median ~103cm
namaOrtu: "I Komang Wirawan",
alamat: "Banjar Kamboja, Desa Darmasaba",
noHpOrtu: "08123456814",
posyanduId: "posyandu_kamboja_001",
imunisasiLengkap: true,
giziBaik: true,
pemeriksaanRutin: true,
statusStunting: StatusStunting.ALERT,
catatan: "TB/U -1.4 SD. Konsumsi protein hewani belum cukup, edukasi diberikan.",
},
{
id: "balita_015",
nama: "Made Surya Darma",
nik: "5101014511230015",
tanggalLahir: new Date("2023-11-04"), // 18 bulan
jenisKelamin: JenisKelaminBalita.L,
beratBadanKg: 10.2,
tinggiBadanCm: 76.0, // median ~82cm
namaOrtu: "Ni Made Sudarmi",
alamat: "Banjar Melur, Desa Darmasaba",
noHpOrtu: "08123456815",
posyanduId: "posyandu_melur_001",
imunisasiLengkap: true,
giziBaik: false,
pemeriksaanRutin: true,
statusStunting: StatusStunting.ALERT,
catatan: "TB/U -1.9 SD. Sedang mendapat PMT (makanan tambahan) dari desa.",
},
{
id: "balita_016",
nama: "Ni Kadek Ayu Purnami",
nik: "5101014505250016",
tanggalLahir: new Date("2025-05-04"), // 0 bulan (baru lahir - 1 bulan)
jenisKelamin: JenisKelaminBalita.P,
beratBadanKg: 3.5,
tinggiBadanCm: 49.0, // median ~52cm
namaOrtu: "I Kadek Suartha",
alamat: "Banjar Kenanga, Desa Darmasaba",
noHpOrtu: "08123456816",
posyanduId: "posyandu_kenanga_001",
imunisasiLengkap: false,
giziBaik: true,
pemeriksaanRutin: true,
statusStunting: StatusStunting.ALERT,
catatan: "TB/U -1.7 SD. Bayi baru, lahir 2.8 kg. Dipantau dari awal.",
},
{
id: "balita_017",
nama: "Ketut Bayu Setiawan",
nik: "5101014511220017",
tanggalLahir: new Date("2022-11-04"), // 30 bulan
jenisKelamin: JenisKelaminBalita.L,
beratBadanKg: 13.2,
tinggiBadanCm: 88.0, // median ~93cm
namaOrtu: "Ni Ketut Suarni",
alamat: "Banjar Dahlia, Desa Darmasaba",
noHpOrtu: "08123456817",
posyanduId: "posyandu_dahlia_001",
imunisasiLengkap: true,
giziBaik: true,
pemeriksaanRutin: true,
statusStunting: StatusStunting.ALERT,
catatan: "TB/U -1.3 SD. Tumbuh kembang membaik dalam 3 bulan terakhir.",
},
// ===== NORMAL =====
{
id: "balita_018",
nama: "Ni Made Intan Permata",
nik: "5101014501240018",
tanggalLahir: new Date("2024-01-04"), // 16 bulan
jenisKelamin: JenisKelaminBalita.P,
beratBadanKg: 10.5,
tinggiBadanCm: 78.0,
namaOrtu: "I Made Sudiarsa",
alamat: "Banjar Mawar, Desa Darmasaba",
noHpOrtu: "08123456818",
posyanduId: "posyandu_mawar_001",
imunisasiLengkap: true,
giziBaik: true,
pemeriksaanRutin: true,
statusStunting: StatusStunting.NORMAL,
},
{
id: "balita_019",
nama: "Wayan Arya Nugraha",
nik: "5101014505230019",
tanggalLahir: new Date("2023-05-04"), // 24 bulan
jenisKelamin: JenisKelaminBalita.L,
beratBadanKg: 12.0,
tinggiBadanCm: 87.0,
namaOrtu: "Ni Wayan Suarni",
alamat: "Banjar Pudak, Desa Darmasaba",
noHpOrtu: "08123456819",
posyanduId: "cmkanjnmx0006vntz1cn7owpb",
imunisasiLengkap: true,
giziBaik: true,
pemeriksaanRutin: true,
statusStunting: StatusStunting.NORMAL,
},
{
id: "balita_020",
nama: "Ni Putu Cantika Dewi",
nik: "5101014501220020",
tanggalLahir: new Date("2022-01-04"), // 40 bulan
jenisKelamin: JenisKelaminBalita.P,
beratBadanKg: 15.5,
tinggiBadanCm: 103.0,
namaOrtu: "I Putu Sudiarta",
alamat: "Banjar Melati, Desa Darmasaba",
noHpOrtu: "08123456820",
posyanduId: "posyandu_melati_001",
imunisasiLengkap: true,
giziBaik: true,
pemeriksaanRutin: true,
statusStunting: StatusStunting.NORMAL,
},
{
id: "balita_021",
nama: "Komang Danu Mahesa",
nik: "5101014505250021",
tanggalLahir: new Date("2025-05-04"), // 0 bulan (newborn)
jenisKelamin: JenisKelaminBalita.L,
beratBadanKg: 3.8,
tinggiBadanCm: 52.0,
namaOrtu: "Ni Komang Artini",
alamat: "Banjar Anggrek, Desa Darmasaba",
noHpOrtu: "08123456821",
posyanduId: "posyandu_anggrek_001",
imunisasiLengkap: false,
giziBaik: true,
pemeriksaanRutin: true,
statusStunting: StatusStunting.NORMAL,
},
{
id: "balita_022",
nama: "Ni Nyoman Suka Rani",
nik: "5101014505210022",
tanggalLahir: new Date("2021-05-04"), // 48 bulan
jenisKelamin: JenisKelaminBalita.P,
beratBadanKg: 16.5,
tinggiBadanCm: 105.0,
namaOrtu: "I Nyoman Suarman",
alamat: "Banjar Kamboja, Desa Darmasaba",
noHpOrtu: "08123456822",
posyanduId: "posyandu_kamboja_001",
imunisasiLengkap: true,
giziBaik: true,
pemeriksaanRutin: true,
statusStunting: StatusStunting.NORMAL,
},
{
id: "balita_023",
nama: "Made Giri Putra Santosa",
nik: "5101014511240023",
tanggalLahir: new Date("2024-11-04"), // 6 bulan
jenisKelamin: JenisKelaminBalita.L,
beratBadanKg: 8.1,
tinggiBadanCm: 67.5,
namaOrtu: "Ni Made Suciati",
alamat: "Banjar Melur, Desa Darmasaba",
noHpOrtu: "08123456823",
posyanduId: "posyandu_melur_001",
imunisasiLengkap: true,
giziBaik: true,
pemeriksaanRutin: true,
statusStunting: StatusStunting.NORMAL,
},
{
id: "balita_024",
nama: "Ni Wayan Arta Yanti",
nik: "5101014511230024",
tanggalLahir: new Date("2023-11-04"), // 18 bulan
jenisKelamin: JenisKelaminBalita.P,
beratBadanKg: 11.0,
tinggiBadanCm: 82.0,
namaOrtu: "I Wayan Suarsa",
alamat: "Banjar Kenanga, Desa Darmasaba",
noHpOrtu: "08123456824",
posyanduId: "posyandu_kenanga_001",
imunisasiLengkap: true,
giziBaik: true,
pemeriksaanRutin: true,
statusStunting: StatusStunting.NORMAL,
},
{
id: "balita_025",
nama: "Kadek Dika Permana",
nik: "5101014511220025",
tanggalLahir: new Date("2022-11-04"), // 30 bulan
jenisKelamin: JenisKelaminBalita.L,
beratBadanKg: 14.0,
tinggiBadanCm: 93.0,
namaOrtu: "Ni Kadek Suriati",
alamat: "Banjar Dahlia, Desa Darmasaba",
noHpOrtu: "08123456825",
posyanduId: "posyandu_dahlia_001",
imunisasiLengkap: true,
giziBaik: true,
pemeriksaanRutin: true,
statusStunting: StatusStunting.NORMAL,
},
];
export async function seedBalita() {
console.log("🔄 Seeding Balita...");
for (const d of BALITA_DATA) {
await prisma.balita.upsert({
where: { id: d.id },
update: {
nama: d.nama,
nik: d.nik,
tanggalLahir: d.tanggalLahir,
jenisKelamin: d.jenisKelamin,
beratBadanKg: d.beratBadanKg,
tinggiBadanCm: d.tinggiBadanCm,
namaOrtu: d.namaOrtu,
alamat: d.alamat,
noHpOrtu: d.noHpOrtu,
posyanduId: d.posyanduId,
imunisasiLengkap: d.imunisasiLengkap,
giziBaik: d.giziBaik,
pemeriksaanRutin: d.pemeriksaanRutin,
statusStunting: d.statusStunting,
catatan: d.catatan ?? null,
},
create: {
id: d.id,
nama: d.nama,
nik: d.nik,
tanggalLahir: d.tanggalLahir,
jenisKelamin: d.jenisKelamin,
beratBadanKg: d.beratBadanKg,
tinggiBadanCm: d.tinggiBadanCm,
namaOrtu: d.namaOrtu,
alamat: d.alamat,
noHpOrtu: d.noHpOrtu,
posyanduId: d.posyanduId,
imunisasiLengkap: d.imunisasiLengkap,
giziBaik: d.giziBaik,
pemeriksaanRutin: d.pemeriksaanRutin,
statusStunting: d.statusStunting,
catatan: d.catatan ?? null,
},
});
console.log(`✅ Balita seeded: ${d.nama} (${d.statusStunting})`);
}
console.log("🎉 Balita seed selesai");
}

View File

@@ -0,0 +1,19 @@
import prisma from "@/lib/prisma";
import { loadJsonData } from "../../load-json";
const banjarJson = loadJsonData("kesehatan/banjar/banjar.json");
export async function seedBanjar() {
console.log("🔄 Seeding Banjar...");
for (const b of banjarJson) {
await prisma.banjar.upsert({
where: { id: b.id },
update: { name: b.name },
create: { id: b.id, name: b.name },
});
console.log(`✅ Banjar seeded: ${b.name}`);
}
console.log("🎉 Banjar seed selesai");
}

View File

@@ -15,6 +15,7 @@ export async function seedGrafikKepuasan() {
jenisKelamin: item.jenisKelamin,
alamat: item.alamat,
penyakit: item.penyakit,
banjarId: item.banjarId ?? null,
},
create: {
id: item.id,
@@ -23,6 +24,7 @@ export async function seedGrafikKepuasan() {
jenisKelamin: item.jenisKelamin,
alamat: item.alamat,
penyakit: item.penyakit,
banjarId: item.banjarId ?? null,
},
});
console.log(` Grafik Kepuasan: ${item.nama}`);

View File

@@ -0,0 +1,222 @@
import prisma from "@/lib/prisma";
import { IbuHamilStatus } from "@prisma/client";
const IBU_HAMIL_DATA = [
{
id: "ibu_hamil_001",
nama: "Ni Wayan Sari Dewi",
nik: "5101014504960001",
usiaKehamilan: 28,
hpht: new Date("2025-10-20"),
taksiranLahir: new Date("2026-07-26"),
alamat: "Banjar Pudak, Desa Darmasaba",
noHp: "08123456701",
posyanduId: "cmkanjnmx0006vntz1cn7owpb",
status: IbuHamilStatus.AKTIF,
},
{
id: "ibu_hamil_002",
nama: "Ni Made Artini",
nik: "5101012808980002",
usiaKehamilan: 16,
hpht: new Date("2026-01-13"),
taksiranLahir: new Date("2026-10-20"),
alamat: "Banjar Mawar, Desa Darmasaba",
noHp: "08123456702",
posyanduId: "posyandu_mawar_001",
status: IbuHamilStatus.AKTIF,
},
{
id: "ibu_hamil_003",
nama: "Ni Putu Rahayu",
nik: "5101010109000003",
usiaKehamilan: 32,
hpht: new Date("2025-09-22"),
taksiranLahir: new Date("2026-06-29"),
alamat: "Banjar Melati, Desa Darmasaba",
noHp: "08123456703",
posyanduId: "posyandu_melati_001",
status: IbuHamilStatus.AKTIF,
},
{
id: "ibu_hamil_004",
nama: "Ni Komang Lestari",
nik: "5101011505010004",
usiaKehamilan: 8,
hpht: new Date("2026-03-10"),
taksiranLahir: new Date("2026-12-14"),
alamat: "Banjar Dahlia, Desa Darmasaba",
noHp: "08123456704",
posyanduId: "posyandu_dahlia_001",
status: IbuHamilStatus.AKTIF,
},
{
id: "ibu_hamil_005",
nama: "Ni Nyoman Suartini",
nik: "5101012012990005",
usiaKehamilan: 24,
hpht: new Date("2025-11-17"),
taksiranLahir: new Date("2026-08-24"),
alamat: "Banjar Anggrek, Desa Darmasaba",
noHp: "08123456705",
posyanduId: "posyandu_anggrek_001",
status: IbuHamilStatus.AKTIF,
},
{
id: "ibu_hamil_006",
nama: "Ni Ketut Suriani",
nik: "5101010307970006",
usiaKehamilan: 20,
hpht: new Date("2025-12-15"),
taksiranLahir: new Date("2026-09-21"),
alamat: "Banjar Kamboja, Desa Darmasaba",
noHp: "08123456706",
posyanduId: "posyandu_kamboja_001",
status: IbuHamilStatus.AKTIF,
},
{
id: "ibu_hamil_007",
nama: "Ni Wayan Rustini",
nik: "5101011806960007",
usiaKehamilan: 36,
hpht: new Date("2025-08-25"),
taksiranLahir: new Date("2026-06-01"),
alamat: "Banjar Melur, Desa Darmasaba",
noHp: "08123456707",
posyanduId: "posyandu_melur_001",
status: IbuHamilStatus.AKTIF,
catatan: "Tekanan darah perlu dipantau rutin",
},
{
id: "ibu_hamil_008",
nama: "Ni Made Sudiani",
nik: "5101010202020008",
usiaKehamilan: 12,
hpht: new Date("2026-02-10"),
taksiranLahir: new Date("2026-11-17"),
alamat: "Banjar Kenanga, Desa Darmasaba",
noHp: "08123456708",
posyanduId: "posyandu_kenanga_001",
status: IbuHamilStatus.AKTIF,
},
{
id: "ibu_hamil_009",
nama: "Ni Putu Yuliani",
nik: "5101011507980009",
usiaKehamilan: 28,
hpht: new Date("2025-10-20"),
taksiranLahir: new Date("2026-07-26"),
alamat: "Banjar Pudak, Desa Darmasaba",
noHp: "08123456709",
posyanduId: "cmkanjnmx0006vntz1cn7owpb",
status: IbuHamilStatus.AKTIF,
},
{
id: "ibu_hamil_010",
nama: "Ni Nyoman Darmayanti",
nik: "5101012309010010",
usiaKehamilan: 16,
hpht: new Date("2026-01-13"),
taksiranLahir: new Date("2026-10-20"),
alamat: "Banjar Mawar, Desa Darmasaba",
noHp: "08123456710",
posyanduId: "posyandu_mawar_001",
status: IbuHamilStatus.AKTIF,
catatan: "Anemia ringan, konsumsi suplemen zat besi",
},
{
id: "ibu_hamil_011",
nama: "Ni Wayan Purwati",
nik: "5101010905950011",
usiaKehamilan: 40,
hpht: new Date("2025-07-28"),
taksiranLahir: new Date("2026-05-04"),
alamat: "Banjar Melati, Desa Darmasaba",
noHp: "08123456711",
posyanduId: "posyandu_melati_001",
status: IbuHamilStatus.MELAHIRKAN,
catatan: "Melahirkan normal di Puskesmas Abiansemal 3",
},
{
id: "ibu_hamil_012",
nama: "Ni Made Suarningsih",
nik: "5101011403930012",
usiaKehamilan: 39,
hpht: new Date("2025-08-04"),
taksiranLahir: new Date("2026-05-11"),
alamat: "Banjar Dahlia, Desa Darmasaba",
noHp: "08123456712",
posyanduId: "posyandu_dahlia_001",
status: IbuHamilStatus.MELAHIRKAN,
},
{
id: "ibu_hamil_013",
nama: "Ni Komang Sugiantari",
nik: "5101012706010013",
usiaKehamilan: 10,
alamat: "Banjar Anggrek, Desa Darmasaba",
noHp: "08123456713",
posyanduId: "posyandu_anggrek_001",
status: IbuHamilStatus.KEGUGURAN,
catatan: "Keguguran pada usia kehamilan 10 minggu, sudah ditangani",
},
{
id: "ibu_hamil_014",
nama: "Ni Putu Aryanti",
nik: "5101010508940014",
usiaKehamilan: 0,
alamat: "Banjar Kamboja, Desa Darmasaba",
noHp: "08123456714",
posyanduId: "posyandu_kamboja_001",
status: IbuHamilStatus.NONAKTIF,
catatan: "Data lama, tidak aktif terdaftar",
},
{
id: "ibu_hamil_015",
nama: "Ni Ketut Suparmi",
nik: "5101011912920015",
usiaKehamilan: 0,
alamat: "Banjar Melur, Desa Darmasaba",
noHp: "08123456715",
posyanduId: "posyandu_melur_001",
status: IbuHamilStatus.NONAKTIF,
},
];
export async function seedIbuHamil() {
console.log("🔄 Seeding Ibu Hamil...");
for (const d of IBU_HAMIL_DATA) {
await prisma.ibuHamil.upsert({
where: { id: d.id },
update: {
nama: d.nama,
nik: d.nik,
usiaKehamilan: d.usiaKehamilan,
hpht: d.hpht ?? null,
taksiranLahir: d.taksiranLahir ?? null,
alamat: d.alamat,
noHp: d.noHp,
posyanduId: d.posyanduId,
status: d.status,
catatan: d.catatan ?? null,
},
create: {
id: d.id,
nama: d.nama,
nik: d.nik,
usiaKehamilan: d.usiaKehamilan,
hpht: d.hpht ?? null,
taksiranLahir: d.taksiranLahir ?? null,
alamat: d.alamat,
noHp: d.noHp,
posyanduId: d.posyanduId,
status: d.status,
catatan: d.catatan ?? null,
},
});
console.log(`✅ Ibu hamil seeded: ${d.nama} (${d.status})`);
}
console.log("🎉 Ibu Hamil seed selesai");
}

View File

@@ -7,17 +7,8 @@ export async function seedRingkasanKesehatan() {
await prisma.ringkasanKesehatanDesa.upsert({
where: { id: SINGLETON_ID },
update: {
ibuHamilAkh: 87,
balitaTerdaftar: 342,
alertStunting: 12,
},
create: {
id: SINGLETON_ID,
ibuHamilAkh: 87,
balitaTerdaftar: 342,
alertStunting: 12,
},
update: { targetStuntingPct: 10 },
create: { id: SINGLETON_ID, targetStuntingPct: 10 },
});
console.log("✅ Ringkasan Kesehatan Desa seeded");

View File

@@ -0,0 +1,240 @@
[
{
"id": "event-budaya-1",
"nama": "Hari Raya Galungan",
"tanggal": "2025-01-15T06:00:00.000Z",
"lokasi": "Seluruh wilayah Desa Darmasaba",
"deskripsi": "Hari Raya Galungan adalah perayaan kemenangan dharma melawan adharma. Warga Desa Darmasaba merayakannya dengan memasang penjor di depan rumah, sembahyang di pura, dan berkumpul bersama keluarga."
},
{
"id": "event-budaya-2",
"nama": "Hari Raya Kuningan",
"tanggal": "2025-01-25T06:00:00.000Z",
"lokasi": "Seluruh wilayah Desa Darmasaba",
"deskripsi": "Hari Raya Kuningan menandai akhir perayaan Galungan. Umat Hindu di Desa Darmasaba melaksanakan persembahyangan terakhir sebagai tanda pamitan para leluhur kembali ke alam nirwana."
},
{
"id": "event-budaya-3",
"nama": "Upacara Melasti",
"tanggal": "2025-03-27T05:00:00.000Z",
"lokasi": "Pantai dan Sumber Air Suci, Badung",
"deskripsi": "Upacara Melasti dilaksanakan menjelang Hari Raya Nyepi sebagai ritual penyucian diri dan alam semesta. Masyarakat Desa Darmasaba bersama-sama melakukan persembahyangan dan membersihkan pratima pura ke sumber air suci."
},
{
"id": "event-budaya-4",
"nama": "Parade Ogoh-Ogoh",
"tanggal": "2025-03-28T15:00:00.000Z",
"lokasi": "Jalan Utama Desa Darmasaba",
"deskripsi": "Parade ogoh-ogoh antar banjar se-Desa Darmasaba dalam rangka menyambut Hari Raya Nyepi. Ogoh-ogoh melambangkan kekuatan negatif yang kemudian dibakar sebagai simbol penyucian."
},
{
"id": "event-budaya-5",
"nama": "Hari Raya Nyepi",
"tanggal": "2025-03-29T00:00:00.000Z",
"lokasi": "Seluruh wilayah Desa Darmasaba",
"deskripsi": "Hari Raya Nyepi adalah Tahun Baru Saka bagi umat Hindu. Seluruh warga Desa Darmasaba melaksanakan Catur Brata Penyepian: amati geni, amati karya, amati lelungan, dan amati lelanguan."
},
{
"id": "event-budaya-6",
"nama": "Hari Raya Saraswati",
"tanggal": "2025-04-05T06:00:00.000Z",
"lokasi": "Pura dan Sekolah se-Desa Darmasaba",
"deskripsi": "Hari Raya Saraswati adalah hari turunnya ilmu pengetahuan. Warga Desa Darmasaba, khususnya pelajar dan akademisi, melaksanakan persembahyangan sebagai rasa syukur atas anugerah ilmu pengetahuan."
},
{
"id": "event-budaya-7",
"nama": "Hari Raya Pagerwesi",
"tanggal": "2025-04-09T06:00:00.000Z",
"lokasi": "Pura Puseh Desa Darmasaba",
"deskripsi": "Hari Raya Pagerwesi adalah hari untuk memperkuat keimanan dan menghalau pengaruh negatif. Umat Hindu Desa Darmasaba melaksanakan persembahyangan dan meditasi bersama."
},
{
"id": "event-budaya-8",
"nama": "Hari Raya Galungan",
"tanggal": "2025-05-14T06:00:00.000Z",
"lokasi": "Seluruh wilayah Desa Darmasaba",
"deskripsi": "Hari Raya Galungan periode kedua tahun 2025. Kemenangan dharma melawan adharma dirayakan dengan pemasangan penjor, persembahyangan di pura, dan acara adat bersama keluarga besar."
},
{
"id": "event-budaya-9",
"nama": "Hari Raya Kuningan",
"tanggal": "2025-05-24T06:00:00.000Z",
"lokasi": "Seluruh wilayah Desa Darmasaba",
"deskripsi": "Hari Raya Kuningan periode kedua tahun 2025. Warga Desa Darmasaba melaksanakan persembahyangan akhir Galungan sebagai tanda pamitan para leluhur."
},
{
"id": "event-budaya-10",
"nama": "Festival Budaya Desa Darmasaba",
"tanggal": "2025-06-15T09:00:00.000Z",
"lokasi": "Lapangan Desa Darmasaba",
"deskripsi": "Festival tahunan menampilkan kesenian tradisional Bali seperti tari kecak, legong, dan barong oleh sanggar seni dari Desa Darmasaba. Festival ini terbuka untuk umum dan menjadi ajang pelestarian budaya."
},
{
"id": "event-budaya-11",
"nama": "Tumpek Landep",
"tanggal": "2025-06-28T06:00:00.000Z",
"lokasi": "Pura Desa Darmasaba",
"deskripsi": "Tumpek Landep adalah hari persembahan kepada benda-benda tajam dan peralatan yang menggunakan logam. Warga Desa Darmasaba melakukan pembersihan dan pemujaan terhadap peralatan kerja, kendaraan, dan senjata."
},
{
"id": "event-budaya-12",
"nama": "Upacara Ngusaba Desa",
"tanggal": "2025-08-10T08:00:00.000Z",
"lokasi": "Pura Puseh Desa Darmasaba",
"deskripsi": "Upacara adat tahunan Ngusaba Desa sebagai bentuk rasa syukur kepada Ida Sang Hyang Widhi Wasa atas keselamatan dan kemakmuran desa sepanjang tahun."
},
{
"id": "event-budaya-13",
"nama": "Perayaan HUT RI ke-80",
"tanggal": "2025-08-17T07:30:00.000Z",
"lokasi": "Balai Desa Darmasaba",
"deskripsi": "Peringatan Hari Ulang Tahun Kemerdekaan Republik Indonesia ke-80. Warga Desa Darmasaba melaksanakan upacara bendera, lomba-lomba tradisional, dan pertunjukan budaya."
},
{
"id": "event-budaya-14",
"nama": "Hari Raya Galungan",
"tanggal": "2025-09-10T06:00:00.000Z",
"lokasi": "Seluruh wilayah Desa Darmasaba",
"deskripsi": "Hari Raya Galungan periode ketiga tahun 2025. Seluruh umat Hindu Desa Darmasaba merayakan kemenangan kebaikan dengan berbagai rangkaian upacara adat dan kegiatan budaya."
},
{
"id": "event-budaya-15",
"nama": "Hari Raya Kuningan",
"tanggal": "2025-09-20T06:00:00.000Z",
"lokasi": "Seluruh wilayah Desa Darmasaba",
"deskripsi": "Hari Raya Kuningan periode ketiga tahun 2025. Rangkaian Galungan-Kuningan ditutup dengan persembahyangan dan acara kekeluargaan bersama warga Desa Darmasaba."
},
{
"id": "event-budaya-16",
"nama": "Hari Kesaktian Pancasila",
"tanggal": "2025-10-01T07:00:00.000Z",
"lokasi": "Balai Desa Darmasaba",
"deskripsi": "Peringatan Hari Kesaktian Pancasila diikuti seluruh perangkat desa dan warga Desa Darmasaba dengan upacara bendera dan kegiatan budaya sebagai wujud rasa nasionalisme."
},
{
"id": "event-budaya-17",
"nama": "Pementasan Wayang Kulit",
"tanggal": "2025-10-25T19:00:00.000Z",
"lokasi": "Wantilan Desa Darmasaba",
"deskripsi": "Pementasan wayang kulit semalam suntuk oleh dalang ternama dari Desa Darmasaba sebagai bagian dari pelestarian seni budaya Bali dan perayaan ulang tahun pura banjar."
},
{
"id": "event-budaya-18",
"nama": "Upacara Ngusaba Desa",
"tanggal": "2025-11-15T08:00:00.000Z",
"lokasi": "Pura Puseh Desa Darmasaba",
"deskripsi": "Upacara adat tahunan Ngusaba Desa sebagai bentuk rasa syukur kepada Ida Sang Hyang Widhi Wasa atas keselamatan dan kemakmuran desa."
},
{
"id": "event-budaya-19",
"nama": "Tumpek Uduh",
"tanggal": "2025-11-22T06:00:00.000Z",
"lokasi": "Seluruh wilayah Desa Darmasaba",
"deskripsi": "Tumpek Uduh adalah hari pemujaan kepada tumbuh-tumbuhan. Warga Desa Darmasaba melakukan persembahan kepada pepohonan dan tanaman sebagai bentuk syukur atas anugerah alam."
},
{
"id": "event-budaya-20",
"nama": "Pujawali Pura Puseh",
"tanggal": "2025-12-10T06:00:00.000Z",
"lokasi": "Pura Puseh Desa Darmasaba",
"deskripsi": "Upacara pujawali (ulang tahun pura) di Pura Puseh Desa Darmasaba. Seluruh krama desa bersama-sama melaksanakan persembahyangan dan menampilkan berbagai kesenian sakral."
},
{
"id": "event-budaya-21",
"nama": "Perayaan Galungan dan Kuningan",
"tanggal": "2026-03-04T06:00:00.000Z",
"lokasi": "Seluruh wilayah Desa Darmasaba",
"deskripsi": "Rangkaian perayaan Hari Raya Galungan dan Kuningan sebagai hari kemenangan dharma melawan adharma, dirayakan seluruh umat Hindu di Desa Darmasaba."
},
{
"id": "event-budaya-22",
"nama": "Upacara Melasti",
"tanggal": "2026-03-17T05:00:00.000Z",
"lokasi": "Pantai dan Sumber Air Suci, Badung",
"deskripsi": "Ritual penyucian diri dan benda sakral sebelum Nyepi 2026. Seluruh krama Desa Darmasaba beriringan membawa pratima pura menuju sumber air suci."
},
{
"id": "event-budaya-23",
"nama": "Lomba Ogoh-Ogoh Desa",
"tanggal": "2026-03-18T15:00:00.000Z",
"lokasi": "Lapangan Desa Darmasaba",
"deskripsi": "Lomba pembuatan dan parade ogoh-ogoh antar banjar se-Desa Darmasaba dalam rangka menyambut Hari Raya Nyepi 2026."
},
{
"id": "event-budaya-24",
"nama": "Hari Raya Nyepi",
"tanggal": "2026-03-19T00:00:00.000Z",
"lokasi": "Seluruh wilayah Desa Darmasaba",
"deskripsi": "Tahun Baru Saka 1948. Seluruh warga Desa Darmasaba melaksanakan Catur Brata Penyepian dalam keheningan dan introspeksi diri."
},
{
"id": "event-budaya-25",
"nama": "Hari Raya Kuningan",
"tanggal": "2026-03-14T06:00:00.000Z",
"lokasi": "Seluruh wilayah Desa Darmasaba",
"deskripsi": "Hari Raya Kuningan 2026. Warga Desa Darmasaba melaksanakan persembahyangan akhir Galungan sebagai tanda pamitan para leluhur kembali ke nirwana."
},
{
"id": "event-budaya-26",
"nama": "Festival Budaya Desa Darmasaba",
"tanggal": "2026-05-20T09:00:00.000Z",
"lokasi": "Lapangan Desa Darmasaba",
"deskripsi": "Festival tahunan menampilkan kesenian tradisional Bali seperti tari kecak, legong, dan barong oleh sanggar seni dari Desa Darmasaba."
},
{
"id": "event-budaya-27",
"nama": "Tumpek Krulut",
"tanggal": "2026-05-30T06:00:00.000Z",
"lokasi": "Pura Desa Darmasaba",
"deskripsi": "Tumpek Krulut adalah hari pemujaan kepada alat musik dan kesenian. Seniman dan pengrawit di Desa Darmasaba melaksanakan persembahan kepada gamelan dan alat musik tradisional."
},
{
"id": "event-budaya-28",
"nama": "Pementasan Wayang Kulit",
"tanggal": "2026-06-10T19:00:00.000Z",
"lokasi": "Wantilan Desa Darmasaba",
"deskripsi": "Pementasan wayang kulit semalam suntuk oleh dalang dari Desa Darmasaba sebagai bagian dari pelestarian seni budaya Bali."
},
{
"id": "event-budaya-29",
"nama": "Hari Raya Galungan",
"tanggal": "2026-08-01T06:00:00.000Z",
"lokasi": "Seluruh wilayah Desa Darmasaba",
"deskripsi": "Hari Raya Galungan 2026. Umat Hindu Desa Darmasaba merayakan kemenangan kebenaran dengan upacara adat, penjor, dan kegiatan budaya bersama."
},
{
"id": "event-budaya-30",
"nama": "Perayaan HUT RI ke-81",
"tanggal": "2026-08-17T07:30:00.000Z",
"lokasi": "Balai Desa Darmasaba",
"deskripsi": "Peringatan Hari Ulang Tahun Kemerdekaan Republik Indonesia ke-81 sekaligus hari jadi Desa Darmasaba dengan berbagai lomba dan pertunjukan budaya."
},
{
"id": "event-budaya-31",
"nama": "Upacara Melaspas Gedung Balai Banjar",
"tanggal": "2026-09-05T08:00:00.000Z",
"lokasi": "Banjar Desa Darmasaba",
"deskripsi": "Upacara Melaspas sebagai ritual penyucian bangunan baru balai banjar agar membawa keselamatan dan kesejahteraan bagi krama banjar."
},
{
"id": "event-budaya-32",
"nama": "Hari Raya Kuningan",
"tanggal": "2026-08-11T06:00:00.000Z",
"lokasi": "Seluruh wilayah Desa Darmasaba",
"deskripsi": "Hari Raya Kuningan 2026. Warga Desa Darmasaba bersama keluarga besar melaksanakan persembahyangan penutup rangkaian Galungan-Kuningan."
},
{
"id": "event-budaya-33",
"nama": "Hari Raya Saraswati",
"tanggal": "2026-10-03T06:00:00.000Z",
"lokasi": "Pura dan Sekolah se-Desa Darmasaba",
"deskripsi": "Hari Raya Saraswati 2026. Warga Desa Darmasaba, terutama pelajar dan pendidik, melaksanakan persembahyangan dan puja saraswati sebagai rasa syukur atas ilmu pengetahuan."
},
{
"id": "event-budaya-34",
"nama": "Pujawali Pura Puseh",
"tanggal": "2026-11-20T06:00:00.000Z",
"lokasi": "Pura Puseh Desa Darmasaba",
"deskripsi": "Upacara pujawali tahunan di Pura Puseh Desa Darmasaba disertai pertunjukan topeng, gambuh, dan kesenian sakral lainnya."
}
]

View File

@@ -1,252 +1,503 @@
[
{
"id": "cmk-apbdes-item-001",
"kode": "4",
"uraian": "Pendapatan Desa",
"anggaran": 1500000000,
"tipe": "pendapatan",
"level": 1,
"parentId": null,
"apbdesId": "cmk-apbdes-001"
"id": "cmk-apbdes-001",
"tahun": 2025,
"name": "APBDes Desa Darmasaba Tahun 2025",
"deskripsi": "Anggaran Pendapatan dan Belanja Desa Darmasaba Tahun Anggaran 2025",
"jumlah": "1.850.000.000",
"imageId": null,
"imageName": "TDQReg1lQ73s39crXW0ra-mobile.webp",
"fileId": null,
"createdAt": "2026-05-18T07:12:48.524Z",
"updatedAt": "2026-05-18T07:12:48.524Z",
"deletedAt": null,
"isActive": true,
"image": null,
"file": null,
"items": [
{
"id": "cmk-apbdes-item-001",
"kode": "4",
"uraian": "Pendapatan Desa",
"anggaran": 1500000000,
"tipe": "pendapatan",
"level": 1,
"parentId": null,
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-002",
"kode": "4.1",
"uraian": "Pendapatan Asli Desa",
"anggaran": 350000000,
"tipe": "pendapatan",
"level": 2,
"parentId": "cmk-apbdes-item-001",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-003",
"kode": "4.1.1",
"uraian": "Hasil Usaha Desa",
"anggaran": 150000000,
"tipe": "pendapatan",
"level": 3,
"parentId": "cmk-apbdes-item-002",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-004",
"kode": "4.1.2",
"uraian": "Hasil Aset Desa",
"anggaran": 100000000,
"tipe": "pendapatan",
"level": 3,
"parentId": "cmk-apbdes-item-002",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-005",
"kode": "4.1.3",
"uraian": "Swa Daya dan Partisipasi",
"anggaran": 100000000,
"tipe": "pendapatan",
"level": 3,
"parentId": "cmk-apbdes-item-002",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-006",
"kode": "4.2",
"uraian": "Dana Desa (APBN)",
"anggaran": 800000000,
"tipe": "pendapatan",
"level": 2,
"parentId": "cmk-apbdes-item-001",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-007",
"kode": "4.3",
"uraian": "Bagi Hasil Pajak dan Retribusi",
"anggaran": 200000000,
"tipe": "pendapatan",
"level": 2,
"parentId": "cmk-apbdes-item-001",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-008",
"kode": "4.4",
"uraian": "Bantuan Keuangan Provinsi/Kabupaten",
"anggaran": 150000000,
"tipe": "pendapatan",
"level": 2,
"parentId": "cmk-apbdes-item-001",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-010",
"kode": "5",
"uraian": "Belanja Desa",
"anggaran": 1500000000,
"tipe": "belanja",
"level": 1,
"parentId": null,
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-011",
"kode": "5.1",
"uraian": "Bidang Penyelenggaraan Pemerintahan Desa",
"anggaran": 400000000,
"tipe": "belanja",
"level": 2,
"parentId": "cmk-apbdes-item-010",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-012",
"kode": "5.1.1",
"uraian": "Penyelenggaraan Kegiatan Pemerintahan Desa",
"anggaran": 250000000,
"tipe": "belanja",
"level": 3,
"parentId": "cmk-apbdes-item-011",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-013",
"kode": "5.1.2",
"uraian": "Penghasilan Tetap Perbekel dan Perangkat Desa",
"anggaran": 150000000,
"tipe": "belanja",
"level": 3,
"parentId": "cmk-apbdes-item-011",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-014",
"kode": "5.2",
"uraian": "Bidang Pelaksanaan Pembangunan Desa",
"anggaran": 500000000,
"tipe": "belanja",
"level": 2,
"parentId": "cmk-apbdes-item-010",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-015",
"kode": "5.2.1",
"uraian": "Pembangunan Infrastruktur Desa",
"anggaran": 300000000,
"tipe": "belanja",
"level": 3,
"parentId": "cmk-apbdes-item-014",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-016",
"kode": "5.2.2",
"uraian": "Pembangunan Gedung dan Bangunan",
"anggaran": 200000000,
"tipe": "belanja",
"level": 3,
"parentId": "cmk-apbdes-item-014",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-017",
"kode": "5.3",
"uraian": "Bidang Pembinaan Kemasyarakatan",
"anggaran": 300000000,
"tipe": "belanja",
"level": 2,
"parentId": "cmk-apbdes-item-010",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-018",
"kode": "5.3.1",
"uraian": "Kegiatan Kesehatan Masyarakat",
"anggaran": 100000000,
"tipe": "belanja",
"level": 3,
"parentId": "cmk-apbdes-item-017",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-019",
"kode": "5.3.2",
"uraian": "Kegiatan Pendidikan dan Kebudayaan",
"anggaran": 120000000,
"tipe": "belanja",
"level": 3,
"parentId": "cmk-apbdes-item-017",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-020",
"kode": "5.3.3",
"uraian": "Kegiatan Keagamaan dan Adat",
"anggaran": 80000000,
"tipe": "belanja",
"level": 3,
"parentId": "cmk-apbdes-item-017",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-021",
"kode": "5.4",
"uraian": "Bidang Pemberdayaan Masyarakat",
"anggaran": 300000000,
"tipe": "belanja",
"level": 2,
"parentId": "cmk-apbdes-item-010",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-022",
"kode": "5.4.1",
"uraian": "Pelatihan dan Pengembangan UMKM",
"anggaran": 150000000,
"tipe": "belanja",
"level": 3,
"parentId": "cmk-apbdes-item-021",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-023",
"kode": "5.4.2",
"uraian": "Program Ketahanan Pangan",
"anggaran": 150000000,
"tipe": "belanja",
"level": 3,
"parentId": "cmk-apbdes-item-021",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-030",
"kode": "6",
"uraian": "Pembiayaan Desa",
"anggaran": 350000000,
"tipe": "pembiayaan",
"level": 1,
"parentId": null,
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-031",
"kode": "6.1",
"uraian": "SILPA Tahun Sebelumnya",
"anggaran": 200000000,
"tipe": "pembiayaan",
"level": 2,
"parentId": "cmk-apbdes-item-030",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-032",
"kode": "6.2",
"uraian": "Pencairan Dana Cadangan",
"anggaran": 150000000,
"tipe": "pembiayaan",
"level": 2,
"parentId": "cmk-apbdes-item-030",
"apbdesId": "cmk-apbdes-001"
}
]
},
{
"id": "cmk-apbdes-item-002",
"kode": "4.1",
"uraian": "Pendapatan Asli Desa",
"anggaran": 350000000,
"tipe": "pendapatan",
"level": 2,
"parentId": "cmk-apbdes-item-001",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-003",
"kode": "4.1.1",
"uraian": "Hasil Usaha Desa",
"anggaran": 150000000,
"tipe": "pendapatan",
"level": 3,
"parentId": "cmk-apbdes-item-002",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-004",
"kode": "4.1.2",
"uraian": "Hasil Aset Desa",
"anggaran": 100000000,
"tipe": "pendapatan",
"level": 3,
"parentId": "cmk-apbdes-item-002",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-005",
"kode": "4.1.3",
"uraian": "Swa Daya dan Partisipasi",
"anggaran": 100000000,
"tipe": "pendapatan",
"level": 3,
"parentId": "cmk-apbdes-item-002",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-006",
"kode": "4.2",
"uraian": "Dana Desa (APBN)",
"anggaran": 800000000,
"tipe": "pendapatan",
"level": 2,
"parentId": "cmk-apbdes-item-001",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-007",
"kode": "4.3",
"uraian": "Bagi Hasil Pajak dan Retribusi",
"anggaran": 200000000,
"tipe": "pendapatan",
"level": 2,
"parentId": "cmk-apbdes-item-001",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-008",
"kode": "4.4",
"uraian": "Bantuan Keuangan Provinsi/Kabupaten",
"anggaran": 150000000,
"tipe": "pendapatan",
"level": 2,
"parentId": "cmk-apbdes-item-001",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-010",
"kode": "5",
"uraian": "Belanja Desa",
"anggaran": 1500000000,
"tipe": "belanja",
"level": 1,
"parentId": null,
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-011",
"kode": "5.1",
"uraian": "Bidang Penyelenggaraan Pemerintahan Desa",
"anggaran": 400000000,
"tipe": "belanja",
"level": 2,
"parentId": "cmk-apbdes-item-010",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-012",
"kode": "5.1.1",
"uraian": "Penyelenggaraan Kegiatan Pemerintahan Desa",
"anggaran": 250000000,
"tipe": "belanja",
"level": 3,
"parentId": "cmk-apbdes-item-011",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-013",
"kode": "5.1.2",
"uraian": "Penghasilan Tetap Perbekel dan Perangkat Desa",
"anggaran": 150000000,
"tipe": "belanja",
"level": 3,
"parentId": "cmk-apbdes-item-011",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-014",
"kode": "5.2",
"uraian": "Bidang Pelaksanaan Pembangunan Desa",
"anggaran": 500000000,
"tipe": "belanja",
"level": 2,
"parentId": "cmk-apbdes-item-010",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-015",
"kode": "5.2.1",
"uraian": "Pembangunan Infrastruktur Desa",
"anggaran": 300000000,
"tipe": "belanja",
"level": 3,
"parentId": "cmk-apbdes-item-014",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-016",
"kode": "5.2.2",
"uraian": "Pembangunan Gedung dan Bangunan",
"anggaran": 200000000,
"tipe": "belanja",
"level": 3,
"parentId": "cmk-apbdes-item-014",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-017",
"kode": "5.3",
"uraian": "Bidang Pembinaan Kemasyarakatan",
"anggaran": 300000000,
"tipe": "belanja",
"level": 2,
"parentId": "cmk-apbdes-item-010",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-018",
"kode": "5.3.1",
"uraian": "Kegiatan Kesehatan Masyarakat",
"anggaran": 100000000,
"tipe": "belanja",
"level": 3,
"parentId": "cmk-apbdes-item-017",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-019",
"kode": "5.3.2",
"uraian": "Kegiatan Pendidikan dan Kebudayaan",
"anggaran": 120000000,
"tipe": "belanja",
"level": 3,
"parentId": "cmk-apbdes-item-017",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-020",
"kode": "5.3.3",
"uraian": "Kegiatan Keagamaan dan Adat",
"anggaran": 80000000,
"tipe": "belanja",
"level": 3,
"parentId": "cmk-apbdes-item-017",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-021",
"kode": "5.4",
"uraian": "Bidang Pemberdayaan Masyarakat",
"anggaran": 300000000,
"tipe": "belanja",
"level": 2,
"parentId": "cmk-apbdes-item-010",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-022",
"kode": "5.4.1",
"uraian": "Pelatihan dan Pengembangan UMKM",
"anggaran": 150000000,
"tipe": "belanja",
"level": 3,
"parentId": "cmk-apbdes-item-021",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-023",
"kode": "5.4.2",
"uraian": "Program Ketahanan Pangan",
"anggaran": 150000000,
"tipe": "belanja",
"level": 3,
"parentId": "cmk-apbdes-item-021",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-030",
"kode": "6",
"uraian": "Pembiayaan Desa",
"anggaran": 350000000,
"tipe": "pembiayaan",
"level": 1,
"parentId": null,
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-031",
"kode": "6.1",
"uraian": "SILPA Tahun Sebelumnya",
"anggaran": 200000000,
"tipe": "pembiayaan",
"level": 2,
"parentId": "cmk-apbdes-item-030",
"apbdesId": "cmk-apbdes-001"
},
{
"id": "cmk-apbdes-item-032",
"kode": "6.2",
"uraian": "Pencairan Dana Cadangan",
"anggaran": 150000000,
"tipe": "pembiayaan",
"level": 2,
"parentId": "cmk-apbdes-item-030",
"apbdesId": "cmk-apbdes-001"
"id": "cmk-apbdes",
"tahun": 2026,
"name": "APBDes Tahun 2026",
"deskripsi": "",
"jumlah": "",
"imageId": null,
"fileId": null,
"createdAt": "2026-05-18T07:12:48.524Z",
"updatedAt": "2026-05-18T07:12:48.524Z",
"deletedAt": null,
"isActive": true,
"image": null,
"file": null,
"items": [
{
"id": "cmk-apbdes-item-050",
"kode": "4.1.1",
"uraian": "Hasil Usaha Desa",
"anggaran": 20000000,
"tipe": "pendapatan",
"level": 3,
"parentId": null,
"apbdesId": "cmk-apbdes",
"totalRealisasi": 0,
"selisih": 20000000,
"persentase": 0,
"createdAt": "2026-05-18T07:12:48.529Z",
"updatedAt": "2026-05-18T07:12:48.569Z",
"deletedAt": null,
"isActive": true,
"realisasiItems": []
},
{
"id": "cmk-apbdes-item-051",
"kode": "4.2.1",
"uraian": "Dana Desa",
"anggaran": 457952000,
"tipe": "pendapatan",
"level": 3,
"parentId": null,
"apbdesId": "cmk-apbdes",
"totalRealisasi": 0,
"selisih": 457952000,
"persentase": 0,
"createdAt": "2026-05-18T07:12:48.529Z",
"updatedAt": "2026-05-18T07:12:48.569Z",
"deletedAt": null,
"isActive": true,
"realisasiItems": []
},
{
"id": "cmk-apbdes-item-052",
"kode": "4.2.2",
"uraian": "Bagi Hasil Pajak dan Retribusi",
"anggaran": 21424289948,
"tipe": "pendapatan",
"level": 3,
"parentId": null,
"apbdesId": "cmk-apbdes",
"totalRealisasi": 0,
"selisih": 21424289948,
"persentase": 0,
"createdAt": "2026-05-18T07:12:48.529Z",
"updatedAt": "2026-05-18T07:12:48.569Z",
"deletedAt": null,
"isActive": true,
"realisasiItems": []
},
{
"id": "cmk-apbdes-item-053",
"kode": "4.2.3",
"uraian": "Alokasi Dana Desa",
"anggaran": 940248688,
"tipe": "pendapatan",
"level": 3,
"parentId": null,
"apbdesId": "cmk-apbdes",
"totalRealisasi": 0,
"selisih": 940248688,
"persentase": 0,
"createdAt": "2026-05-18T07:12:48.529Z",
"updatedAt": "2026-05-18T07:12:48.570Z",
"deletedAt": null,
"isActive": true,
"realisasiItems": []
},
{
"id": "cmk-apbdes-item-054",
"kode": "4.2.4",
"uraian": "Bantuan Keuangan Provinsi",
"anggaran": 148800000,
"tipe": "pendapatan",
"level": 3,
"parentId": null,
"apbdesId": "cmk-apbdes",
"totalRealisasi": 0,
"selisih": 148800000,
"persentase": 0,
"createdAt": "2026-05-18T07:12:48.529Z",
"updatedAt": "2026-05-18T07:12:48.571Z",
"deletedAt": null,
"isActive": true,
"realisasiItems": []
},
{
"id": "cmk-apbdes-item-055",
"kode": "4.3.6",
"uraian": "Pendapatan Lain-Lain (Bunga Bank)",
"anggaran": 150000000,
"tipe": "pendapatan",
"level": 3,
"parentId": null,
"apbdesId": "cmk-apbdes",
"totalRealisasi": 0,
"selisih": 150000000,
"persentase": 0,
"createdAt": "2026-05-18T07:12:48.529Z",
"updatedAt": "2026-05-18T07:12:48.571Z",
"deletedAt": null,
"isActive": true,
"realisasiItems": []
},
{
"id": "cmk-apbdes-item-056",
"kode": "5.1.1",
"uraian": "Bidang Penyelenggaraan Pemerintahan Desa",
"anggaran": 8000089216,
"tipe": "belanja",
"level": 3,
"parentId": null,
"apbdesId": "cmk-apbdes",
"totalRealisasi": 0,
"selisih": 8000089216,
"persentase": 0,
"createdAt": "2026-05-18T07:12:48.529Z",
"updatedAt": "2026-05-18T07:12:48.572Z",
"deletedAt": null,
"isActive": true,
"realisasiItems": []
},
{
"id": "cmk-apbdes-item-057",
"kode": "5.1.2",
"uraian": "Bidang Pelaksanaan Pembangunan Desa",
"anggaran": 12479605032,
"tipe": "belanja",
"level": 3,
"parentId": null,
"apbdesId": "cmk-apbdes",
"totalRealisasi": 0,
"selisih": 12479605032,
"persentase": 0,
"createdAt": "2026-05-18T07:12:48.529Z",
"updatedAt": "2026-05-18T07:12:48.572Z",
"deletedAt": null,
"isActive": true,
"realisasiItems": []
},
{
"id": "cmk-apbdes-item-058",
"kode": "5.1.3",
"uraian": "Bidang Pembinaan Kemasyarakatan Desa",
"anggaran": 6335909763,
"tipe": "belanja",
"level": 3,
"parentId": null,
"apbdesId": "cmk-apbdes",
"totalRealisasi": 0,
"selisih": 6335909763,
"persentase": 0,
"createdAt": "2026-05-18T07:12:48.529Z",
"updatedAt": "2026-05-18T07:12:48.572Z",
"deletedAt": null,
"isActive": true,
"realisasiItems": []
},
{
"id": "cmk-apbdes-item-059",
"kode": "5.1.4",
"uraian": "Bidang Pemberdayaan Masyarakat Desa",
"anggaran": 983380361,
"tipe": "belanja",
"level": 3,
"parentId": null,
"apbdesId": "cmk-apbdes",
"totalRealisasi": 0,
"selisih": 983380361,
"persentase": 0,
"createdAt": "2026-05-18T07:12:48.529Z",
"updatedAt": "2026-05-18T07:12:48.573Z",
"deletedAt": null,
"isActive": true,
"realisasiItems": []
},
{
"id": "cmk-apbdes-item-060",
"kode": "5.1.5",
"uraian": "Bidang Peneanggulangan Bencana, Darurat, dan Mendesak Desa",
"anggaran": 3188549498,
"tipe": "belanja",
"level": 3,
"parentId": null,
"apbdesId": "cmk-apbdes",
"totalRealisasi": 0,
"selisih": 3188549498,
"persentase": 0,
"createdAt": "2026-05-18T07:12:48.529Z",
"updatedAt": "2026-05-18T07:12:48.574Z",
"deletedAt": null,
"isActive": true,
"realisasiItems": []
},
{
"id": "cmk-apbdes-item-061",
"kode": "6.1.1",
"uraian": "Penerimaan Pembiayaan",
"anggaran": 7854243235,
"tipe": "pembiayaan",
"level": 3,
"parentId": null,
"apbdesId": "cmk-apbdes",
"totalRealisasi": 0,
"selisih": 7854243235,
"persentase": 0,
"createdAt": "2026-05-18T07:12:48.529Z",
"updatedAt": "2026-05-18T07:12:48.574Z",
"deletedAt": null,
"isActive": true,
"realisasiItems": []
}
]
}
]

View File

@@ -8,5 +8,15 @@
"imageId": null,
"imageName": "TDQReg1lQ73s39crXW0ra-mobile.webp",
"fileId": null
},
{
"id": "cmk-apbdes",
"tahun": 2026,
"name": "APBDes Tahun 2026",
"deskripsi": "",
"jumlah": "",
"imageId": null,
"imageName": null,
"fileId": null
}
]

View File

@@ -0,0 +1,82 @@
[
{
"id": "cctv_darmasaba_01",
"kode": "CCTV-01",
"nama": "Balai Desa",
"lokasi": "Jl. Raya Darmasaba, Depan Balai Desa",
"latitude": -8.5712,
"longitude": 115.1923,
"status": "Online",
"lastActive": "2026-02-12T14:30:00.000Z"
},
{
"id": "cctv_darmasaba_02",
"kode": "CCTV-02",
"nama": "Pintu Masuk Desa Utara",
"lokasi": "Jl. Raya Darmasaba, Pintu Masuk Utara",
"latitude": -8.5685,
"longitude": 115.1917,
"status": "Online",
"lastActive": "2026-02-12T13:45:00.000Z"
},
{
"id": "cctv_darmasaba_03",
"kode": "CCTV-03",
"nama": "Taman Desa",
"lokasi": "Area Taman Desa Darmasaba",
"latitude": -8.5730,
"longitude": 115.1935,
"status": "Offline",
"lastActive": "2026-02-11T09:00:00.000Z"
},
{
"id": "cctv_darmasaba_04",
"kode": "CCTV-04",
"nama": "Pasar Desa",
"lokasi": "Pasar Tradisional Darmasaba",
"latitude": -8.5698,
"longitude": 115.1945,
"status": "Online",
"lastActive": "2026-02-12T15:00:00.000Z"
},
{
"id": "cctv_darmasaba_05",
"kode": "CCTV-05",
"nama": "Pintu Masuk Desa Selatan",
"lokasi": "Jl. Raya Darmasaba, Pintu Masuk Selatan",
"latitude": -8.5755,
"longitude": 115.1920,
"status": "Online",
"lastActive": "2026-02-12T14:55:00.000Z"
},
{
"id": "cctv_darmasaba_06",
"kode": "CCTV-06",
"nama": "SD Negeri Darmasaba",
"lokasi": "Depan SD Negeri 1 Darmasaba",
"latitude": -8.5720,
"longitude": 115.1910,
"status": "Online",
"lastActive": "2026-02-12T12:30:00.000Z"
},
{
"id": "cctv_darmasaba_07",
"kode": "CCTV-07",
"nama": "Pura Desa",
"lokasi": "Area Pura Desa Darmasaba",
"latitude": -8.5708,
"longitude": 115.1950,
"status": "Offline",
"lastActive": "2026-02-10T18:00:00.000Z"
},
{
"id": "cctv_darmasaba_08",
"kode": "CCTV-08",
"nama": "Persimpangan Utama",
"lokasi": "Persimpangan Jl. Raya Darmasaba - Jl. Abiansemal",
"latitude": -8.5695,
"longitude": 115.1930,
"status": "Online",
"lastActive": "2026-02-12T15:10:00.000Z"
}
]

View File

@@ -3,12 +3,12 @@
"id": "cmkp70zau0002vnu9o1jtpi1i",
"judul": "Keamanan Rumah",
"deskripsi": "<p><ul><li><p>Pastikan pintu dan jendela selalu terkunci saat meninggalkan rumah</p></li><li><p>Pasang lampu penerangan di halaman dan area sekitar rumah untuk mencegah tindak kejahatan.</p></li><li><p>Jangan mudah memberikan akses masuk ke orang yang tidak dikenal.</p></li></ul></p>",
"imageName": "dSe0xyvNLkP2t2f6iq-Hk-mobile.webp"
"imageName": "vwZsaxcoFWDlxG1PW7FC0-mobile.webp"
},
{
"id": "cmkp71pzo0005vnu9p3n9646d",
"judul": "Keamanan Lingkungan Tanggungjawab Bersama",
"deskripsi": "<p>Pemerintah Desa Darmasaba melaksanakan sosialisasi dan pembinaan tentang keamanan dan ketertiban lingkungan kepada warga Perumahan Darmasaba Permai. Warga diajak berperan aktif dalam menjaga keamanan lingkungan serta mendukung penyediaan lampu penerangan jalan untuk mencegah tindak kriminal dan kecelakaan. Bhabinkamtibmas dan Babinsa turut memberikan materi keamanan dan ketertiban kepada warga, menekankan pentingnya partisipasi masyarakat dalam menjaga keamanan desa.</p>",
"imageName": "vwZsaxcoFWDlxG1PW7FC0-mobile.webp"
"imageName": "dSe0xyvNLkP2t2f6iq-Hk-mobile.webp"
}
]

View File

@@ -0,0 +1,10 @@
[
{ "id": "banjar_pudak_amara_001", "name": "Banjar Pudak Amara" },
{ "id": "banjar_mawar_001", "name": "Banjar Mawar" },
{ "id": "banjar_melati_001", "name": "Banjar Melati" },
{ "id": "banjar_dahlia_001", "name": "Banjar Dahlia" },
{ "id": "banjar_anggrek_001", "name": "Banjar Anggrek" },
{ "id": "banjar_kamboja_001", "name": "Banjar Kamboja" },
{ "id": "banjar_melur_001", "name": "Banjar Melur" },
{ "id": "banjar_kenanga_001", "name": "Banjar Kenanga" }
]

View File

@@ -5,7 +5,8 @@
"tanggal": "2025-03-15",
"jenisKelamin": "Laki-laki",
"alamat": "Banjar Adat Kauh, Desa Darmasaba",
"penyakit": "Hipertensi"
"penyakit": "Hipertensi",
"banjarId": "banjar_pudak_amara_001"
},
{
"id": "cmk-kepuasan-002",
@@ -13,7 +14,8 @@
"tanggal": "2025-03-18",
"jenisKelamin": "Perempuan",
"alamat": "Banjar Adat Kangin, Desa Darmasaba",
"penyakit": "Diabetes Tipe 2"
"penyakit": "Diabetes Tipe 2",
"banjarId": "banjar_mawar_001"
},
{
"id": "cmk-kepuasan-003",
@@ -21,7 +23,8 @@
"tanggal": "2025-03-20",
"jenisKelamin": "Laki-laki",
"alamat": "Banjar Adat Tengah, Desa Darmasaba",
"penyakit": "ISPA"
"penyakit": "ISPA",
"banjarId": "banjar_melati_001"
},
{
"id": "cmk-kepuasan-004",
@@ -29,7 +32,8 @@
"tanggal": "2025-04-02",
"jenisKelamin": "Perempuan",
"alamat": "Banjar Adat Kauh, Desa Darmasaba",
"penyakit": "Rematik"
"penyakit": "Rematik",
"banjarId": "banjar_anggrek_001"
},
{
"id": "cmk-kepuasan-005",
@@ -37,6 +41,34 @@
"tanggal": "2025-04-10",
"jenisKelamin": "Laki-laki",
"alamat": "Banjar Adat Kangin, Desa Darmasaba",
"penyakit": "Asam Urat"
"penyakit": "Asam Urat",
"banjarId": "banjar_dahlia_001"
},
{
"id": "cmk-kepuasan-006",
"nama": "Ni Nyoman Sari",
"tanggal": "2025-04-15",
"jenisKelamin": "Perempuan",
"alamat": "Banjar Dinas Tengah, Desa Darmasaba",
"penyakit": "Hipertensi",
"banjarId": "banjar_kamboja_001"
},
{
"id": "cmk-kepuasan-007",
"nama": "I Made Darmawan",
"tanggal": "2025-04-20",
"jenisKelamin": "Laki-laki",
"alamat": "Banjar Dinas Gulingan, Desa Darmasaba",
"penyakit": "Diabetes Tipe 2",
"banjarId": "banjar_melur_001"
},
{
"id": "cmk-kepuasan-008",
"nama": "Ni Kadek Ayu Lestari",
"tanggal": "2025-05-01",
"jenisKelamin": "Perempuan",
"alamat": "Banjar Dinas Umahanyar, Desa Darmasaba",
"penyakit": "Asma",
"banjarId": "banjar_kenanga_001"
}
]

View File

@@ -5,55 +5,63 @@
"nomor": "(0361) 8463263",
"deskripsi": "<p>Posyandu Pudak Amara merupakan salah satu posyandu aktif di Desa Darmasaba dan pernah berkompetisi dalam lomba kader dan posyandu berprestasi tingkat Provinsi Bali tahun 2025.</p>",
"jadwalPelayanan": "Senin, 10 Feb 2026, 08:00 - 11:00 WITA",
"imageName": "TDQReg1lQ73s39crXW0ra-mobile.webp"
"imageName": "TDQReg1lQ73s39crXW0ra-mobile.webp",
"banjarId": "banjar_pudak_amara_001"
},
{
"id": "posyandu_mawar_001",
"name": "Posyandu Mawar",
"nomor": "(0361) 8463264",
"deskripsi": "<p>Posyandu Mawar melayani kesehatan ibu dan anak di wilayah Banjar Mawar, Desa Darmasaba, dengan fokus pada pemantauan tumbuh kembang balita dan kesehatan ibu hamil.</p>",
"jadwalPelayanan": "Senin, 15 Feb 2026, 08:00 - 11:00 WITA"
"jadwalPelayanan": "Senin, 15 Feb 2026, 08:00 - 11:00 WITA",
"banjarId": "banjar_mawar_001"
},
{
"id": "posyandu_melati_001",
"name": "Posyandu Melati",
"nomor": "(0361) 8463265",
"deskripsi": "<p>Posyandu Melati berperan aktif dalam pelayanan kesehatan dasar masyarakat di Banjar Melati, meliputi imunisasi, penimbangan balita, dan konsultasi gizi.</p>",
"jadwalPelayanan": "Selasa, 16 Feb 2026, 08:00 - 11:00 WITA"
"jadwalPelayanan": "Selasa, 16 Feb 2026, 08:00 - 11:00 WITA",
"banjarId": "banjar_melati_001"
},
{
"id": "posyandu_dahlia_001",
"name": "Posyandu Dahlia",
"nomor": "(0361) 8463266",
"deskripsi": "<p>Posyandu Dahlia aktif melayani masyarakat Banjar Dahlia dengan program unggulan pemantauan stunting dan pemberian makanan tambahan bagi balita berisiko.</p>",
"jadwalPelayanan": "Rabu, 17 Feb 2026, 08:00 - 11:00 WITA"
"jadwalPelayanan": "Rabu, 17 Feb 2026, 08:00 - 11:00 WITA",
"banjarId": "banjar_dahlia_001"
},
{
"id": "posyandu_anggrek_001",
"name": "Posyandu Anggrek",
"nomor": "(0361) 8463267",
"deskripsi": "<p>Posyandu Anggrek melayani ibu hamil, ibu menyusui, dan balita di wilayah Banjar Anggrek dengan dukungan tenaga kesehatan dari Puskesmas Abiansemal 3.</p>",
"jadwalPelayanan": "Kamis, 18 Feb 2026, 08:00 - 11:00 WITA"
"jadwalPelayanan": "Kamis, 18 Feb 2026, 08:00 - 11:00 WITA",
"banjarId": "banjar_anggrek_001"
},
{
"id": "posyandu_kamboja_001",
"name": "Posyandu Kamboja",
"nomor": "(0361) 8463268",
"deskripsi": "<p>Posyandu Kamboja hadir untuk mendukung kesehatan masyarakat Banjar Kamboja melalui layanan pemeriksaan rutin, imunisasi lengkap, dan edukasi gizi keluarga.</p>",
"jadwalPelayanan": "Jumat, 19 Feb 2026, 08:00 - 11:00 WITA"
"jadwalPelayanan": "Jumat, 19 Feb 2026, 08:00 - 11:00 WITA",
"banjarId": "banjar_kamboja_001"
},
{
"id": "posyandu_melur_001",
"name": "Posyandu Melur",
"nomor": "(0361) 8463269",
"deskripsi": "<p>Posyandu Melur aktif memberikan layanan kesehatan preventif bagi ibu dan anak di Banjar Melur, termasuk deteksi dini stunting dan pemantauan gizi balita.</p>",
"jadwalPelayanan": "Sabtu, 20 Feb 2026, 08:00 - 11:00 WITA"
"jadwalPelayanan": "Sabtu, 20 Feb 2026, 08:00 - 11:00 WITA",
"banjarId": "banjar_melur_001"
},
{
"id": "posyandu_kenanga_001",
"name": "Posyandu Kenanga",
"nomor": "(0361) 8463270",
"deskripsi": "<p>Posyandu Kenanga melayani masyarakat Banjar Kenanga dengan program kesehatan ibu dan anak, pemberian vitamin A, dan konseling laktasi bagi ibu menyusui.</p>",
"jadwalPelayanan": "Senin, 23 Feb 2026, 08:00 - 11:00 WITA"
"jadwalPelayanan": "Senin, 23 Feb 2026, 08:00 - 11:00 WITA",
"banjarId": "banjar_kenanga_001"
}
]

View File

@@ -9,14 +9,14 @@
"tempatLahir": "Badung",
"tanggalLahir": "2009-05-15",
"namaOrtu": "I Ketut Pratama",
"nik": "5106123456780001",
"nik": "5106121505090001",
"pekerjaanOrtu": "Petani",
"penghasilan": "Rp 1.500.000/bulan",
"noHp": "081234567891",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Kauh, Desa Darmasaba, Badung, Bali",
"email": "komang.wahyu@email.com",
"alamatKTP": "Banjar Adat Kauh, Desa Darmasaba, Abiansemal, Badung",
"email": "komang.wahyu001@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "M"
},
@@ -30,14 +30,14 @@
"tempatLahir": "Badung",
"tanggalLahir": "2008-08-22",
"namaOrtu": "Ni Made Dewi",
"nik": "5106123456780002",
"nik": "5106126208080002",
"pekerjaanOrtu": "Pedagang",
"penghasilan": "Rp 2.000.000/bulan",
"noHp": "081234567892",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Kangin, Desa Darmasaba, Badung, Bali",
"email": "niluh.dw@email.com",
"alamatKTP": "Banjar Adat Kangin, Desa Darmasaba, Abiansemal, Badung",
"email": "niluh.ayu002@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "S"
},
@@ -51,14 +51,896 @@
"tempatLahir": "Badung",
"tanggalLahir": "2011-03-10",
"namaOrtu": "I Wayan Setiawan",
"nik": "5106123456780003",
"nik": "5106121003110003",
"pekerjaanOrtu": "Buruh",
"penghasilan": "Rp 1.200.000/bulan",
"noHp": "081234567893",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Tengah, Desa Darmasaba, Badung, Bali",
"email": "made.agung@email.com",
"alamatKTP": "Banjar Adat Tengah, Desa Darmasaba, Abiansemal, Badung",
"email": "made.agung003@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "M"
},
{
"id": "cmk-beasiswa-004",
"namaLengkap": "Ni Ketut Sari Utami",
"nis": "2024004",
"kelas": "XII IPA",
"jenisKelamin": "PEREMPUAN",
"alamatDomisili": "Banjar Adat Kaja, Desa Darmasaba",
"tempatLahir": "Badung",
"tanggalLahir": "2007-11-05",
"namaOrtu": "I Nyoman Utama",
"nik": "5106124511070004",
"pekerjaanOrtu": "Nelayan",
"penghasilan": "Rp 1.800.000/bulan",
"noHp": "081234567894",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Kaja, Desa Darmasaba, Abiansemal, Badung",
"email": "niketut.sari004@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "S"
},
{
"id": "cmk-beasiswa-005",
"namaLengkap": "I Wayan Dharma Putra",
"nis": "2024005",
"kelas": "VIII",
"jenisKelamin": "LAKI_LAKI",
"alamatDomisili": "Banjar Adat Kelod, Desa Darmasaba",
"tempatLahir": "Gianyar",
"tanggalLahir": "2011-07-20",
"namaOrtu": "I Made Dharma",
"nik": "5106122007110005",
"pekerjaanOrtu": "Petani",
"penghasilan": "Rp 1.300.000/bulan",
"noHp": "081234567895",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Kelod, Desa Darmasaba, Abiansemal, Badung",
"email": "wayan.dharma005@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "M"
},
{
"id": "cmk-beasiswa-006",
"namaLengkap": "Ni Putu Lestari Wulandari",
"nis": "2024006",
"kelas": "X IPS",
"jenisKelamin": "PEREMPUAN",
"alamatDomisili": "Banjar Adat Kauh, Desa Darmasaba",
"tempatLahir": "Badung",
"tanggalLahir": "2009-02-14",
"namaOrtu": "Ni Made Lestari",
"nik": "5106125402090006",
"pekerjaanOrtu": "Ibu Rumah Tangga",
"penghasilan": "Rp 900.000/bulan",
"noHp": "081234567896",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Kauh, Desa Darmasaba, Abiansemal, Badung",
"email": "niputu.lestari006@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "S"
},
{
"id": "cmk-beasiswa-007",
"namaLengkap": "I Nyoman Surya Budiana",
"nis": "2024007",
"kelas": "XI IPA",
"jenisKelamin": "LAKI_LAKI",
"alamatDomisili": "Banjar Adat Kangin, Desa Darmasaba",
"tempatLahir": "Badung",
"tanggalLahir": "2008-09-30",
"namaOrtu": "I Ketut Budiana",
"nik": "5106123009080007",
"pekerjaanOrtu": "Tukang Bangunan",
"penghasilan": "Rp 2.500.000/bulan",
"noHp": "081234567897",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Kangin, Desa Darmasaba, Abiansemal, Badung",
"email": "nyoman.surya007@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "L"
},
{
"id": "cmk-beasiswa-008",
"namaLengkap": "Ni Made Indah Suryani",
"nis": "2024008",
"kelas": "VII",
"jenisKelamin": "PEREMPUAN",
"alamatDomisili": "Banjar Adat Tengah, Desa Darmasaba",
"tempatLahir": "Badung",
"tanggalLahir": "2012-01-18",
"namaOrtu": "I Wayan Suryani",
"nik": "5106125801120008",
"pekerjaanOrtu": "Buruh Tani",
"penghasilan": "Rp 1.100.000/bulan",
"noHp": "081234567898",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Tengah, Desa Darmasaba, Abiansemal, Badung",
"email": "nimade.indah008@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "S"
},
{
"id": "cmk-beasiswa-009",
"namaLengkap": "I Gede Mahendra Yudha",
"nis": "2024009",
"kelas": "XII IPS",
"jenisKelamin": "LAKI_LAKI",
"alamatDomisili": "Banjar Adat Kaja, Desa Darmasaba",
"tempatLahir": "Denpasar",
"tanggalLahir": "2007-06-12",
"namaOrtu": "I Made Mahendra",
"nik": "5106121206070009",
"pekerjaanOrtu": "Sopir",
"penghasilan": "Rp 2.200.000/bulan",
"noHp": "081234567899",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Kaja, Desa Darmasaba, Abiansemal, Badung",
"email": "gede.mahendra009@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "L"
},
{
"id": "cmk-beasiswa-010",
"namaLengkap": "Ni Wayan Artini Padmini",
"nis": "2024010",
"kelas": "X IPA",
"jenisKelamin": "PEREMPUAN",
"alamatDomisili": "Banjar Adat Kelod, Desa Darmasaba",
"tempatLahir": "Badung",
"tanggalLahir": "2009-04-25",
"namaOrtu": "Ni Ketut Artini",
"nik": "5106126504090010",
"pekerjaanOrtu": "Pedagang Kecil",
"penghasilan": "Rp 1.600.000/bulan",
"noHp": "081234567900",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Kelod, Desa Darmasaba, Abiansemal, Badung",
"email": "wayan.artini010@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "S"
},
{
"id": "cmk-beasiswa-011",
"namaLengkap": "I Putu Arnawa Santosa",
"nis": "2024011",
"kelas": "IX",
"jenisKelamin": "LAKI_LAKI",
"alamatDomisili": "Banjar Adat Kauh, Desa Darmasaba",
"tempatLahir": "Badung",
"tanggalLahir": "2010-10-08",
"namaOrtu": "I Komang Arnawa",
"nik": "5106120810100011",
"pekerjaanOrtu": "Petani",
"penghasilan": "Rp 1.400.000/bulan",
"noHp": "081234567901",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Kauh, Desa Darmasaba, Abiansemal, Badung",
"email": "putu.arnawa011@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "M"
},
{
"id": "cmk-beasiswa-012",
"namaLengkap": "Ni Komang Rini Listiani",
"nis": "2024012",
"kelas": "XI IPS",
"jenisKelamin": "PEREMPUAN",
"alamatDomisili": "Banjar Adat Kangin, Desa Darmasaba",
"tempatLahir": "Badung",
"tanggalLahir": "2008-12-03",
"namaOrtu": "I Wayan Listia",
"nik": "5106124312080012",
"pekerjaanOrtu": "Buruh",
"penghasilan": "Rp 1.000.000/bulan",
"noHp": "081234567902",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Kangin, Desa Darmasaba, Abiansemal, Badung",
"email": "komang.rini012@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "S"
},
{
"id": "cmk-beasiswa-013",
"namaLengkap": "I Ketut Wirawan Sumerta",
"nis": "2024013",
"kelas": "VIII",
"jenisKelamin": "LAKI_LAKI",
"alamatDomisili": "Banjar Adat Tengah, Desa Darmasaba",
"tempatLahir": "Badung",
"tanggalLahir": "2011-08-16",
"namaOrtu": "I Made Sumerta",
"nik": "5106121608110013",
"pekerjaanOrtu": "Kuli Bangunan",
"penghasilan": "Rp 1.700.000/bulan",
"noHp": "081234567903",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Tengah, Desa Darmasaba, Abiansemal, Badung",
"email": "ketut.wirawan013@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "M"
},
{
"id": "cmk-beasiswa-014",
"namaLengkap": "Ni Nyoman Wahyuni Damayanti",
"nis": "2024014",
"kelas": "X IPA",
"jenisKelamin": "PEREMPUAN",
"alamatDomisili": "Banjar Adat Kaja, Desa Darmasaba",
"tempatLahir": "Gianyar",
"tanggalLahir": "2009-03-28",
"namaOrtu": "Ni Ketut Wahyuni",
"nik": "5106126803090014",
"pekerjaanOrtu": "Penjahit",
"penghasilan": "Rp 1.500.000/bulan",
"noHp": "081234567904",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Kaja, Desa Darmasaba, Abiansemal, Badung",
"email": "nyoman.wahyuni014@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "S"
},
{
"id": "cmk-beasiswa-015",
"namaLengkap": "I Made Prabawa Artana",
"nis": "2024015",
"kelas": "XII IPA",
"jenisKelamin": "LAKI_LAKI",
"alamatDomisili": "Banjar Adat Kelod, Desa Darmasaba",
"tempatLahir": "Badung",
"tanggalLahir": "2007-05-09",
"namaOrtu": "I Nyoman Artana",
"nik": "5106120905070015",
"pekerjaanOrtu": "Petani",
"penghasilan": "Rp 1.200.000/bulan",
"noHp": "081234567905",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Kelod, Desa Darmasaba, Abiansemal, Badung",
"email": "made.prabawa015@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "L"
},
{
"id": "cmk-beasiswa-016",
"namaLengkap": "Ni Gede Putri Sukma",
"nis": "2024016",
"kelas": "VII",
"jenisKelamin": "PEREMPUAN",
"alamatDomisili": "Banjar Adat Kauh, Desa Darmasaba",
"tempatLahir": "Badung",
"tanggalLahir": "2012-07-17",
"namaOrtu": "I Wayan Sukma",
"nik": "5106125707120016",
"pekerjaanOrtu": "Buruh Tani",
"penghasilan": "Rp 950.000/bulan",
"noHp": "081234567906",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Kauh, Desa Darmasaba, Abiansemal, Badung",
"email": "gede.putri016@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "S"
},
{
"id": "cmk-beasiswa-017",
"namaLengkap": "I Wayan Adnyana Gunawan",
"nis": "2024017",
"kelas": "XI IPA",
"jenisKelamin": "LAKI_LAKI",
"alamatDomisili": "Banjar Adat Kangin, Desa Darmasaba",
"tempatLahir": "Badung",
"tanggalLahir": "2008-01-22",
"namaOrtu": "I Ketut Gunawan",
"nik": "5106122201080017",
"pekerjaanOrtu": "Pedagang",
"penghasilan": "Rp 2.000.000/bulan",
"noHp": "081234567907",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Kangin, Desa Darmasaba, Abiansemal, Badung",
"email": "wayan.adnyana017@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "M"
},
{
"id": "cmk-beasiswa-018",
"namaLengkap": "Ni Putu Sartini Wati",
"nis": "2024018",
"kelas": "IX",
"jenisKelamin": "PEREMPUAN",
"alamatDomisili": "Banjar Adat Tengah, Desa Darmasaba",
"tempatLahir": "Tabanan",
"tanggalLahir": "2010-09-11",
"namaOrtu": "I Made Wati",
"nik": "5106125109100018",
"pekerjaanOrtu": "Peternak",
"penghasilan": "Rp 1.600.000/bulan",
"noHp": "081234567908",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Tengah, Desa Darmasaba, Abiansemal, Badung",
"email": "putu.sartini018@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "S"
},
{
"id": "cmk-beasiswa-019",
"namaLengkap": "I Komang Arta Wira",
"nis": "2024019",
"kelas": "X IPS",
"jenisKelamin": "LAKI_LAKI",
"alamatDomisili": "Banjar Adat Kaja, Desa Darmasaba",
"tempatLahir": "Badung",
"tanggalLahir": "2009-11-04",
"namaOrtu": "I Nyoman Arta",
"nik": "5106120411090019",
"pekerjaanOrtu": "Tukang Ojek",
"penghasilan": "Rp 1.800.000/bulan",
"noHp": "081234567909",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Kaja, Desa Darmasaba, Abiansemal, Badung",
"email": "komang.arta019@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "L"
},
{
"id": "cmk-beasiswa-020",
"namaLengkap": "Ni Made Yani Astawa",
"nis": "2024020",
"kelas": "XII IPS",
"jenisKelamin": "PEREMPUAN",
"alamatDomisili": "Banjar Adat Kelod, Desa Darmasaba",
"tempatLahir": "Badung",
"tanggalLahir": "2007-02-19",
"namaOrtu": "I Wayan Astawa",
"nik": "5106125902070020",
"pekerjaanOrtu": "Petani",
"penghasilan": "Rp 1.300.000/bulan",
"noHp": "081234567910",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Kelod, Desa Darmasaba, Abiansemal, Badung",
"email": "made.yani020@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "S"
},
{
"id": "cmk-beasiswa-021",
"namaLengkap": "I Nyoman Suharta Antara",
"nis": "2024021",
"kelas": "VIII",
"jenisKelamin": "LAKI_LAKI",
"alamatDomisili": "Banjar Adat Kauh, Desa Darmasaba",
"tempatLahir": "Badung",
"tanggalLahir": "2011-06-07",
"namaOrtu": "I Ketut Antara",
"nik": "5106120706110021",
"pekerjaanOrtu": "Buruh",
"penghasilan": "Rp 1.100.000/bulan",
"noHp": "081234567911",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Kauh, Desa Darmasaba, Abiansemal, Badung",
"email": "nyoman.suharta021@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "M"
},
{
"id": "cmk-beasiswa-022",
"namaLengkap": "Ni Ketut Suryani Arnawa",
"nis": "2024022",
"kelas": "XI IPA",
"jenisKelamin": "PEREMPUAN",
"alamatDomisili": "Banjar Adat Kangin, Desa Darmasaba",
"tempatLahir": "Badung",
"tanggalLahir": "2008-04-13",
"namaOrtu": "Ni Made Arnawa",
"nik": "5106125304080022",
"pekerjaanOrtu": "Ibu Rumah Tangga",
"penghasilan": "Rp 800.000/bulan",
"noHp": "081234567912",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Kangin, Desa Darmasaba, Abiansemal, Badung",
"email": "ketut.suryani022@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "S"
},
{
"id": "cmk-beasiswa-023",
"namaLengkap": "I Gede Sudirman Wirawan",
"nis": "2024023",
"kelas": "IX",
"jenisKelamin": "LAKI_LAKI",
"alamatDomisili": "Banjar Adat Tengah, Desa Darmasaba",
"tempatLahir": "Klungkung",
"tanggalLahir": "2010-12-25",
"namaOrtu": "I Wayan Wirawan",
"nik": "5106122512100023",
"pekerjaanOrtu": "Petani",
"penghasilan": "Rp 1.400.000/bulan",
"noHp": "081234567913",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Tengah, Desa Darmasaba, Abiansemal, Badung",
"email": "gede.sudirman023@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "L"
},
{
"id": "cmk-beasiswa-024",
"namaLengkap": "Ni Wayan Padmini Sutari",
"nis": "2024024",
"kelas": "X IPA",
"jenisKelamin": "PEREMPUAN",
"alamatDomisili": "Banjar Adat Kaja, Desa Darmasaba",
"tempatLahir": "Badung",
"tanggalLahir": "2009-08-31",
"namaOrtu": "I Nyoman Sutari",
"nik": "5106127108090024",
"pekerjaanOrtu": "Pedagang Sayur",
"penghasilan": "Rp 1.700.000/bulan",
"noHp": "081234567914",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Kaja, Desa Darmasaba, Abiansemal, Badung",
"email": "wayan.padmini024@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "S"
},
{
"id": "cmk-beasiswa-025",
"namaLengkap": "I Putu Yudha Saputra",
"nis": "2024025",
"kelas": "XII IPA",
"jenisKelamin": "LAKI_LAKI",
"alamatDomisili": "Banjar Adat Kelod, Desa Darmasaba",
"tempatLahir": "Badung",
"tanggalLahir": "2007-10-16",
"namaOrtu": "I Made Saputra",
"nik": "5106121610070025",
"pekerjaanOrtu": "Buruh Pabrik",
"penghasilan": "Rp 2.100.000/bulan",
"noHp": "081234567915",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Kelod, Desa Darmasaba, Abiansemal, Badung",
"email": "putu.yudha025@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "M"
},
{
"id": "cmk-beasiswa-026",
"namaLengkap": "Ni Komang Ayu Widiastuti",
"nis": "2024026",
"kelas": "VII",
"jenisKelamin": "PEREMPUAN",
"alamatDomisili": "Banjar Adat Kauh, Desa Darmasaba",
"tempatLahir": "Badung",
"tanggalLahir": "2012-05-02",
"namaOrtu": "I Wayan Widiastuti",
"nik": "5106124205120026",
"pekerjaanOrtu": "Petani",
"penghasilan": "Rp 1.000.000/bulan",
"noHp": "081234567916",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Kauh, Desa Darmasaba, Abiansemal, Badung",
"email": "komang.ayu026@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "S"
},
{
"id": "cmk-beasiswa-027",
"namaLengkap": "I Made Bayu Permana",
"nis": "2024027",
"kelas": "XI IPS",
"jenisKelamin": "LAKI_LAKI",
"alamatDomisili": "Banjar Adat Kangin, Desa Darmasaba",
"tempatLahir": "Badung",
"tanggalLahir": "2008-07-27",
"namaOrtu": "I Nyoman Permana",
"nik": "5106122707080027",
"pekerjaanOrtu": "Tukang Kayu",
"penghasilan": "Rp 2.300.000/bulan",
"noHp": "081234567917",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Kangin, Desa Darmasaba, Abiansemal, Badung",
"email": "made.bayu027@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "L"
},
{
"id": "cmk-beasiswa-028",
"namaLengkap": "Ni Nyoman Diah Permatasari",
"nis": "2024028",
"kelas": "X IPS",
"jenisKelamin": "PEREMPUAN",
"alamatDomisili": "Banjar Adat Tengah, Desa Darmasaba",
"tempatLahir": "Badung",
"tanggalLahir": "2009-01-06",
"namaOrtu": "I Ketut Permata",
"nik": "5106124601090028",
"pekerjaanOrtu": "Buruh Tani",
"penghasilan": "Rp 1.100.000/bulan",
"noHp": "081234567918",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Tengah, Desa Darmasaba, Abiansemal, Badung",
"email": "nyoman.diah028@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "S"
},
{
"id": "cmk-beasiswa-029",
"namaLengkap": "I Ketut Dipa Darma",
"nis": "2024029",
"kelas": "VIII",
"jenisKelamin": "LAKI_LAKI",
"alamatDomisili": "Banjar Adat Kaja, Desa Darmasaba",
"tempatLahir": "Badung",
"tanggalLahir": "2011-04-14",
"namaOrtu": "I Made Darma",
"nik": "5106121404110029",
"pekerjaanOrtu": "Petani",
"penghasilan": "Rp 1.300.000/bulan",
"noHp": "081234567919",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Kaja, Desa Darmasaba, Abiansemal, Badung",
"email": "ketut.dipa029@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "M"
},
{
"id": "cmk-beasiswa-030",
"namaLengkap": "Ni Putu Ratna Sari",
"nis": "2024030",
"kelas": "XII IPS",
"jenisKelamin": "PEREMPUAN",
"alamatDomisili": "Banjar Adat Kelod, Desa Darmasaba",
"tempatLahir": "Badung",
"tanggalLahir": "2007-09-23",
"namaOrtu": "I Wayan Ratna",
"nik": "5106126309070030",
"pekerjaanOrtu": "Ibu Rumah Tangga",
"penghasilan": "Rp 850.000/bulan",
"noHp": "081234567920",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Kelod, Desa Darmasaba, Abiansemal, Badung",
"email": "putu.ratna030@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "S"
},
{
"id": "cmk-beasiswa-031",
"namaLengkap": "I Wayan Eka Prasetya",
"nis": "2024031",
"kelas": "IX",
"jenisKelamin": "LAKI_LAKI",
"alamatDomisili": "Banjar Adat Kauh, Desa Darmasaba",
"tempatLahir": "Badung",
"tanggalLahir": "2010-02-28",
"namaOrtu": "I Nyoman Prasetya",
"nik": "5106122802100031",
"pekerjaanOrtu": "Nelayan",
"penghasilan": "Rp 1.900.000/bulan",
"noHp": "081234567921",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Kauh, Desa Darmasaba, Abiansemal, Badung",
"email": "wayan.eka031@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "M"
},
{
"id": "cmk-beasiswa-032",
"namaLengkap": "Ni Made Sintya Dewi",
"nis": "2024032",
"kelas": "X IPA",
"jenisKelamin": "PEREMPUAN",
"alamatDomisili": "Banjar Adat Kangin, Desa Darmasaba",
"tempatLahir": "Badung",
"tanggalLahir": "2009-06-19",
"namaOrtu": "I Ketut Sintya",
"nik": "5106125906090032",
"pekerjaanOrtu": "Pedagang",
"penghasilan": "Rp 1.600.000/bulan",
"noHp": "081234567922",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Kangin, Desa Darmasaba, Abiansemal, Badung",
"email": "made.sintya032@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "S"
},
{
"id": "cmk-beasiswa-033",
"namaLengkap": "I Komang Dika Pranata",
"nis": "2024033",
"kelas": "XI IPA",
"jenisKelamin": "LAKI_LAKI",
"alamatDomisili": "Banjar Adat Tengah, Desa Darmasaba",
"tempatLahir": "Badung",
"tanggalLahir": "2008-10-11",
"namaOrtu": "I Wayan Pranata",
"nik": "5106121110080033",
"pekerjaanOrtu": "Kuli Bangunan",
"penghasilan": "Rp 2.000.000/bulan",
"noHp": "081234567923",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Tengah, Desa Darmasaba, Abiansemal, Badung",
"email": "komang.dika033@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "L"
},
{
"id": "cmk-beasiswa-034",
"namaLengkap": "Ni Gede Wulandari Nirmala",
"nis": "2024034",
"kelas": "VII",
"jenisKelamin": "PEREMPUAN",
"alamatDomisili": "Banjar Adat Kaja, Desa Darmasaba",
"tempatLahir": "Badung",
"tanggalLahir": "2012-03-07",
"namaOrtu": "Ni Made Nirmala",
"nik": "5106124703120034",
"pekerjaanOrtu": "Buruh",
"penghasilan": "Rp 1.050.000/bulan",
"noHp": "081234567924",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Kaja, Desa Darmasaba, Abiansemal, Badung",
"email": "gede.wulandari034@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "S"
},
{
"id": "cmk-beasiswa-035",
"namaLengkap": "I Nyoman Rian Kusuma",
"nis": "2024035",
"kelas": "XII IPA",
"jenisKelamin": "LAKI_LAKI",
"alamatDomisili": "Banjar Adat Kelod, Desa Darmasaba",
"tempatLahir": "Badung",
"tanggalLahir": "2007-08-04",
"namaOrtu": "I Ketut Kusuma",
"nik": "5106120408070035",
"pekerjaanOrtu": "Sopir",
"penghasilan": "Rp 2.400.000/bulan",
"noHp": "081234567925",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Kelod, Desa Darmasaba, Abiansemal, Badung",
"email": "nyoman.rian035@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "M"
},
{
"id": "cmk-beasiswa-036",
"namaLengkap": "Ni Ketut Mira Astuti",
"nis": "2024036",
"kelas": "VIII",
"jenisKelamin": "PEREMPUAN",
"alamatDomisili": "Banjar Adat Kauh, Desa Darmasaba",
"tempatLahir": "Badung",
"tanggalLahir": "2011-11-29",
"namaOrtu": "I Made Astuti",
"nik": "5106126911110036",
"pekerjaanOrtu": "Petani",
"penghasilan": "Rp 1.250.000/bulan",
"noHp": "081234567926",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Kauh, Desa Darmasaba, Abiansemal, Badung",
"email": "ketut.mira036@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "S"
},
{
"id": "cmk-beasiswa-037",
"namaLengkap": "I Putu Galih Satriana",
"nis": "2024037",
"kelas": "X IPS",
"jenisKelamin": "LAKI_LAKI",
"alamatDomisili": "Banjar Adat Kangin, Desa Darmasaba",
"tempatLahir": "Bangli",
"tanggalLahir": "2009-04-18",
"namaOrtu": "I Wayan Satriana",
"nik": "5106121804090037",
"pekerjaanOrtu": "Petani",
"penghasilan": "Rp 1.350.000/bulan",
"noHp": "081234567927",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Kangin, Desa Darmasaba, Abiansemal, Badung",
"email": "putu.galih037@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "M"
},
{
"id": "cmk-beasiswa-038",
"namaLengkap": "Ni Wayan Eka Pratiwi",
"nis": "2024038",
"kelas": "XI IPS",
"jenisKelamin": "PEREMPUAN",
"alamatDomisili": "Banjar Adat Tengah, Desa Darmasaba",
"tempatLahir": "Badung",
"tanggalLahir": "2008-06-03",
"namaOrtu": "Ni Ketut Pratiwi",
"nik": "5106124306080038",
"pekerjaanOrtu": "Penjual Canang",
"penghasilan": "Rp 1.200.000/bulan",
"noHp": "081234567928",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Tengah, Desa Darmasaba, Abiansemal, Badung",
"email": "wayan.eka038@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "S"
},
{
"id": "cmk-beasiswa-039",
"namaLengkap": "I Made Wahyu Artha",
"nis": "2024039",
"kelas": "IX",
"jenisKelamin": "LAKI_LAKI",
"alamatDomisili": "Banjar Adat Kaja, Desa Darmasaba",
"tempatLahir": "Badung",
"tanggalLahir": "2010-08-21",
"namaOrtu": "I Nyoman Artha",
"nik": "5106122108100039",
"pekerjaanOrtu": "Tukang Batu",
"penghasilan": "Rp 1.800.000/bulan",
"noHp": "081234567929",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Kaja, Desa Darmasaba, Abiansemal, Badung",
"email": "made.wahyu039@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "M"
},
{
"id": "cmk-beasiswa-040",
"namaLengkap": "Ni Putu Dwi Cahyani",
"nis": "2024040",
"kelas": "X IPA",
"jenisKelamin": "PEREMPUAN",
"alamatDomisili": "Banjar Adat Kelod, Desa Darmasaba",
"tempatLahir": "Badung",
"tanggalLahir": "2009-12-10",
"namaOrtu": "I Wayan Cahyani",
"nik": "5106125012090040",
"pekerjaanOrtu": "Buruh Tani",
"penghasilan": "Rp 1.100.000/bulan",
"noHp": "081234567930",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Kelod, Desa Darmasaba, Abiansemal, Badung",
"email": "putu.dwi040@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "S"
},
{
"id": "cmk-beasiswa-041",
"namaLengkap": "I Gede Arsa Wijaya",
"nis": "2024041",
"kelas": "XII IPS",
"jenisKelamin": "LAKI_LAKI",
"alamatDomisili": "Banjar Adat Kauh, Desa Darmasaba",
"tempatLahir": "Badung",
"tanggalLahir": "2007-03-14",
"namaOrtu": "I Ketut Wijaya",
"nik": "5106121403070041",
"pekerjaanOrtu": "Pedagang",
"penghasilan": "Rp 2.200.000/bulan",
"noHp": "081234567931",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Kauh, Desa Darmasaba, Abiansemal, Badung",
"email": "gede.arsa041@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "L"
},
{
"id": "cmk-beasiswa-042",
"namaLengkap": "Ni Komang Trisna Yanti",
"nis": "2024042",
"kelas": "VII",
"jenisKelamin": "PEREMPUAN",
"alamatDomisili": "Banjar Adat Kangin, Desa Darmasaba",
"tempatLahir": "Badung",
"tanggalLahir": "2012-09-06",
"namaOrtu": "Ni Made Yanti",
"nik": "5106124609120042",
"pekerjaanOrtu": "Petani",
"penghasilan": "Rp 1.050.000/bulan",
"noHp": "081234567932",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Kangin, Desa Darmasaba, Abiansemal, Badung",
"email": "komang.trisna042@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "S"
},
{
"id": "cmk-beasiswa-043",
"namaLengkap": "I Wayan Surya Negara",
"nis": "2024043",
"kelas": "XI IPA",
"jenisKelamin": "LAKI_LAKI",
"alamatDomisili": "Banjar Adat Tengah, Desa Darmasaba",
"tempatLahir": "Badung",
"tanggalLahir": "2008-05-26",
"namaOrtu": "I Nyoman Negara",
"nik": "5106122605080043",
"pekerjaanOrtu": "Buruh",
"penghasilan": "Rp 1.500.000/bulan",
"noHp": "081234567933",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Tengah, Desa Darmasaba, Abiansemal, Badung",
"email": "wayan.surya043@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "M"
},
{
"id": "cmk-beasiswa-044",
"namaLengkap": "Ni Made Juniari Santi",
"nis": "2024044",
"kelas": "IX",
"jenisKelamin": "PEREMPUAN",
"alamatDomisili": "Banjar Adat Kaja, Desa Darmasaba",
"tempatLahir": "Badung",
"tanggalLahir": "2010-07-13",
"namaOrtu": "I Wayan Santi",
"nik": "5106125307100044",
"pekerjaanOrtu": "Ibu Rumah Tangga",
"penghasilan": "Rp 900.000/bulan",
"noHp": "081234567934",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Kaja, Desa Darmasaba, Abiansemal, Badung",
"email": "made.juniari044@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "S"
},
{
"id": "cmk-beasiswa-045",
"namaLengkap": "I Nyoman Krisna Mahardika",
"nis": "2024045",
"kelas": "X IPS",
"jenisKelamin": "LAKI_LAKI",
"alamatDomisili": "Banjar Adat Kelod, Desa Darmasaba",
"tempatLahir": "Badung",
"tanggalLahir": "2009-10-01",
"namaOrtu": "I Made Mahardika",
"nik": "5106120110090045",
"pekerjaanOrtu": "Kuli Bangunan",
"penghasilan": "Rp 2.000.000/bulan",
"noHp": "081234567935",
"kewarganegaraan": "Indonesia",
"agama": "HINDU",
"alamatKTP": "Banjar Adat Kelod, Desa Darmasaba, Abiansemal, Badung",
"email": "nyoman.krisna045@email.com",
"statusPernikahan": "BELUM_MENIKAH",
"ukuranBaju": "M"
}

View File

@@ -1,8 +1,8 @@
[
{
"id": "struktur-org-ppid-001",
"posisiOrganisasiId": "posisi-001",
"pegawaiId": "pegawai-001",
"posisiOrganisasiId": "kepala_desa",
"pegawaiId": "cmgewz4gt000704ib91i3f169",
"hubunganOrganisasiId": "hubungan-001"
}
]

View File

@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "RingkasanKesehatanDesa"
ADD COLUMN "imunisasiLengkapPct" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "pemeriksaanRutinPct" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "giziBaikPct" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "targetStuntingPct" INTEGER NOT NULL DEFAULT 0;

View File

@@ -0,0 +1,59 @@
-- CreateEnum
CREATE TYPE "IbuHamilStatus" AS ENUM ('AKTIF', 'MELAHIRKAN', 'KEGUGURAN', 'NONAKTIF');
-- CreateEnum
CREATE TYPE "JenisKelaminBalita" AS ENUM ('L', 'P');
-- CreateEnum
CREATE TYPE "StatusStunting" AS ENUM ('NORMAL', 'ALERT', 'STUNTING');
-- CreateTable
CREATE TABLE "IbuHamil" (
"id" TEXT NOT NULL,
"nama" TEXT NOT NULL,
"nik" TEXT,
"usiaKehamilan" INTEGER NOT NULL DEFAULT 0,
"hpht" TIMESTAMP(3),
"taksiranLahir" TIMESTAMP(3),
"alamat" TEXT,
"noHp" TEXT,
"catatan" TEXT,
"posyanduId" TEXT,
"status" "IbuHamilStatus" NOT NULL DEFAULT 'AKTIF',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "IbuHamil_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Balita" (
"id" TEXT NOT NULL,
"nama" TEXT NOT NULL,
"nik" TEXT,
"tanggalLahir" TIMESTAMP(3) NOT NULL,
"jenisKelamin" "JenisKelaminBalita" NOT NULL,
"beratBadanKg" DOUBLE PRECISION,
"tinggiBadanCm" DOUBLE PRECISION,
"namaOrtu" TEXT,
"alamat" TEXT,
"noHpOrtu" TEXT,
"posyanduId" TEXT,
"imunisasiLengkap" BOOLEAN NOT NULL DEFAULT false,
"giziBaik" BOOLEAN NOT NULL DEFAULT true,
"pemeriksaanRutin" BOOLEAN NOT NULL DEFAULT true,
"statusStunting" "StatusStunting" NOT NULL DEFAULT 'NORMAL',
"catatan" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "Balita_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "IbuHamil" ADD CONSTRAINT "IbuHamil_posyanduId_fkey" FOREIGN KEY ("posyanduId") REFERENCES "Posyandu"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Balita" ADD CONSTRAINT "Balita_posyanduId_fkey" FOREIGN KEY ("posyanduId") REFERENCES "Posyandu"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,9 @@
-- Drop redundant columns from RingkasanKesehatanDesa.
-- These values are auto-derived live from IbuHamil + Balita tables (see stats endpoint).
-- Only targetStuntingPct is a policy config that needs to be stored.
ALTER TABLE "RingkasanKesehatanDesa" DROP COLUMN "ibuHamilAkh";
ALTER TABLE "RingkasanKesehatanDesa" DROP COLUMN "balitaTerdaftar";
ALTER TABLE "RingkasanKesehatanDesa" DROP COLUMN "alertStunting";
ALTER TABLE "RingkasanKesehatanDesa" DROP COLUMN "imunisasiLengkapPct";
ALTER TABLE "RingkasanKesehatanDesa" DROP COLUMN "pemeriksaanRutinPct";
ALTER TABLE "RingkasanKesehatanDesa" DROP COLUMN "giziBaikPct";

View File

@@ -0,0 +1,22 @@
-- DropForeignKey
ALTER TABLE "PasarDesa" DROP CONSTRAINT "PasarDesa_kategoriProdukId_fkey";
-- AlterTable
ALTER TABLE "KategoriProdukUmkm" ALTER COLUMN "updatedAt" DROP DEFAULT;
-- CreateTable
CREATE TABLE "EventBudaya" (
"id" TEXT NOT NULL,
"nama" TEXT NOT NULL,
"tanggal" TIMESTAMP(3) NOT NULL,
"lokasi" TEXT NOT NULL,
"deskripsi" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "EventBudaya_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "PasarDesa" ADD CONSTRAINT "PasarDesa_kategoriProdukId_fkey" FOREIGN KEY ("kategoriProdukId") REFERENCES "KategoriProdukUmkm"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,23 @@
-- CreateEnum
CREATE TYPE "StatusCctv" AS ENUM ('Online', 'Offline');
-- AlterEnum
ALTER TYPE "StatusLaporan" ADD VALUE 'Baru';
-- CreateTable
CREATE TABLE "CctvKeamanan" (
"id" TEXT NOT NULL,
"kode" TEXT NOT NULL,
"nama" TEXT NOT NULL,
"lokasi" TEXT NOT NULL,
"latitude" DOUBLE PRECISION,
"longitude" DOUBLE PRECISION,
"status" "StatusCctv" NOT NULL DEFAULT 'Online',
"lastActive" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deletedAt" TIMESTAMP(3),
"isActive" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "CctvKeamanan_pkey" PRIMARY KEY ("id")
);

View File

@@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "Banjar" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Banjar_pkey" PRIMARY KEY ("id")
);
-- AlterTable
ALTER TABLE "Posyandu" ADD COLUMN "banjarId" TEXT;
-- AddForeignKey
ALTER TABLE "Posyandu" ADD CONSTRAINT "Posyandu_banjarId_fkey" FOREIGN KEY ("banjarId") REFERENCES "Banjar"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "GrafikKepuasan" ADD COLUMN "banjarId" TEXT;
-- AddForeignKey
ALTER TABLE "GrafikKepuasan" ADD CONSTRAINT "GrafikKepuasan_banjarId_fkey" FOREIGN KEY ("banjarId") REFERENCES "Banjar"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -1050,6 +1050,8 @@ model GrafikKepuasan {
jenisKelamin String
alamat String
penyakit String
banjar Banjar? @relation(fields: [banjarId], references: [id])
banjarId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
@@ -1150,6 +1152,17 @@ model DoctorSign {
ArtikelKesehatan ArtikelKesehatan[]
}
// ========================================= BANJAR ========================================= //
model Banjar {
id String @id @default(cuid())
name String
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
posyandus Posyandu[]
grafikKepuasans GrafikKepuasan[]
}
// ========================================= POSYANDU ========================================= //
model Posyandu {
id String @id @default(cuid())
@@ -1159,6 +1172,10 @@ model Posyandu {
jadwalPelayanan String
image FileStorage? @relation(fields: [imageId], references: [id])
imageId String?
banjar Banjar? @relation(fields: [banjarId], references: [id])
banjarId String?
ibuHamil IbuHamil[]
balita Balita[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
@@ -1393,6 +1410,7 @@ model PenangananLaporanPublik {
}
enum StatusLaporan {
Baru
Selesai
Proses
Gagal
@@ -1407,6 +1425,27 @@ model Pelapor {
imageId String
}
// ========================================= CCTV KEAMANAN ========================================= //
enum StatusCctv {
Online
Offline
}
model CctvKeamanan {
id String @id @default(cuid())
kode String // e.g. "CCTV-01"
nama String // e.g. "Balai Desa"
lokasi String
latitude Float?
longitude Float?
status StatusCctv @default(Online)
lastActive DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
isActive Boolean @default(true)
}
// ========================================= TIPS KEAMANAN ========================================= //
model MenuTipsKeamanan {
id String @id @default(cuid())
@@ -2481,14 +2520,85 @@ model BeasiswaConfig {
isActive Boolean @default(true)
}
// ========================================= RINGKASAN KESEHATAN DESA ========================================= //
model RingkasanKesehatanDesa {
id String @id @default(cuid())
ibuHamilAkh Int @default(0)
balitaTerdaftar Int @default(0)
alertStunting Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
isActive Boolean @default(true)
// ========================================= IBU HAMIL ========================================= //
enum IbuHamilStatus {
AKTIF
MELAHIRKAN
KEGUGURAN
NONAKTIF
}
model IbuHamil {
id String @id @default(cuid())
nama String
nik String?
usiaKehamilan Int @default(0)
hpht DateTime?
taksiranLahir DateTime?
alamat String?
noHp String?
catatan String?
posyanduId String?
posyandu Posyandu? @relation(fields: [posyanduId], references: [id], onDelete: SetNull)
status IbuHamilStatus @default(AKTIF)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
isActive Boolean @default(true)
}
// ========================================= BALITA ========================================= //
enum JenisKelaminBalita {
L
P
}
enum StatusStunting {
NORMAL
ALERT
STUNTING
}
model Balita {
id String @id @default(cuid())
nama String
nik String?
tanggalLahir DateTime
jenisKelamin JenisKelaminBalita
beratBadanKg Float?
tinggiBadanCm Float?
namaOrtu String?
alamat String?
noHpOrtu String?
posyanduId String?
posyandu Posyandu? @relation(fields: [posyanduId], references: [id], onDelete: SetNull)
imunisasiLengkap Boolean @default(false)
giziBaik Boolean @default(true)
pemeriksaanRutin Boolean @default(true)
statusStunting StatusStunting @default(NORMAL)
catatan String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
isActive Boolean @default(true)
}
// ========================================= RINGKASAN KESEHATAN DESA ========================================= //
model RingkasanKesehatanDesa {
id String @id @default(cuid())
targetStuntingPct Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
isActive Boolean @default(true)
}
// ========================================= EVENT BUDAYA ========================================= //
model EventBudaya {
id String @id @default(cuid())
nama String
tanggal DateTime
lokasi String
deskripsi String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
isActive Boolean @default(true)
}

View File

@@ -3,6 +3,7 @@
import prisma from "@/lib/prisma";
import { seedBerita } from "./_seeder_list/desa/berita/seed_berita";
import { seedKegiatanDesa } from "./_seeder_list/desa/seed_kegiatan_desa";
import { seedEventBudaya } from "./_seeder_list/desa/event-budaya/seed_event_budaya";
import { seedFoto } from "./_seeder_list/desa/gallery/foto/seed_foto";
import { seedVideo } from "./_seeder_list/desa/gallery/video/seed_video";
import { seedLayanan } from "./_seeder_list/desa/layanan/seed_layanan";
@@ -31,11 +32,13 @@ import { seedInfoTeknologi } from "./_seeder_list/inovasi/seed_info_teknologi";
import { seedKolaborasiInovasi } from "./_seeder_list/inovasi/seed_kolaborasi_inovasi";
import { seedLayananOnlineDesa } from "./_seeder_list/inovasi/seed_layanan_online_desa";
import { seedProgramKreatifDesa } from "./_seeder_list/inovasi/seed_program_kreatif_desa";
import { seedCctv } from "./_seeder_list/keamanan/seed_cctv";
import { seedKeamananLingkungan } from "./_seeder_list/keamanan/seed_keamanan_lingkungan";
import { seedKontakDaruratKeamanan } from "./_seeder_list/keamanan/seed_kontak_darurat";
import { seedLaporanPublik } from "./_seeder_list/keamanan/seed_laporan_publik";
import { seedPencegahanKriminalitas } from "./_seeder_list/keamanan/seed_pencegahan_kriminalitas";
import { seedPolsekTerdekat } from "./_seeder_list/keamanan/seed_polsek_terdekat";
import { seedTipsKeamanan } from "./_seeder_list/keamanan/seed_tips_keamanan";
import { seedArtikelKesehatan } from "./_seeder_list/kesehatan/artikel-kesehatan/seed_artikel_kesehatan";
import { seedFasilitasKesehatan } from "./_seeder_list/kesehatan/fasilitas-kesehatan/seed_fasilitas_kesehatan";
import { seedInfoWabahPenyakit } from "./_seeder_list/kesehatan/info-wabah-penyakit/seed_info_wabah_penyakit";
@@ -47,7 +50,10 @@ import { seedProgramKesehatan } from "./_seeder_list/kesehatan/program-kesehatan
import { seedPuskesmas } from "./_seeder_list/kesehatan/puskesmas/seed_puskesmas";
import { seedGrafikKepuasan } from "./_seeder_list/kesehatan/seed_grafik_kepuasan";
import { seedKelahiranKematian } from "./_seeder_list/kesehatan/seed_kelahiran_kematian";
import { seedBanjar } from "./_seeder_list/kesehatan/seed_banjar";
import { seedRingkasanKesehatan } from "./_seeder_list/kesehatan/seed_ringkasan_kesehatan";
import { seedIbuHamil } from "./_seeder_list/kesehatan/seed_ibu_hamil";
import { seedBalita } from "./_seeder_list/kesehatan/seed_balita";
import { seedDesaAntiKorupsi } from "./_seeder_list/landing-page/desa-anti-korupsi/seed_desa_anti_korupsi";
import { seedPrestasiDesa } from "./_seeder_list/landing-page/prestasi-desa/seed_prestasi_desa";
import { seedMediaSosial } from "./_seeder_list/landing-page/profil_landing_page/seed_media_sosial";
@@ -80,6 +86,7 @@ import { seedIkmPpid } from "./_seeder_list/ppid/ikm/seed_ikm";
import { seedProfilPpd } from "./_seeder_list/ppid/profil-ppid/seed_profil_ppd";
import { seedPegawaiPpid } from "./_seeder_list/ppid/struktur-ppid/seed_struktur_ppid";
import { seedVisiMisiPpid } from "./_seeder_list/ppid/visi-misi-ppid/seed_visi_misi_ppid";
import { seedStrukturOrganisasiPpid, seedFormulirPermohonanKeberatan, seedIndeksKepuasanMasyarakat, seedGrafikBerdasarkanJenisKelamin, seedGrafikBerdasarkanResponden, seedGrafikBerdasarkanUmur } from "./_seeder_list/ppid/seed_ppid_extra";
import roles from "./data/user/roles.json";
import users from "./data/user/users.json";
import { safeSeedUnique } from "./safeseedUnique";
@@ -215,6 +222,12 @@ import seedAssets from "./seed_assets";
// // =========== SUBMENU INDEKS KEPUASAN MASYARAKAT ===========
await seedIkmPpid();
await seedStrukturOrganisasiPpid();
await seedFormulirPermohonanKeberatan();
await seedIndeksKepuasanMasyarakat();
await seedGrafikBerdasarkanJenisKelamin();
await seedGrafikBerdasarkanResponden();
await seedGrafikBerdasarkanUmur();
// // =========== MENU DESA ===========
// // =========== SUBMENU PROFILE ===========
@@ -241,9 +254,16 @@ import seedAssets from "./seed_assets";
await seedPenghargaan();
// // ====================== MENU KESEHATAN ========================
// // ==================== SUBMENU BANJAR =========================
await seedBanjar();
// // ==================== SUBMENU POSYANDU =========================
await seedPosyandu();
// // ==================== SUBMENU IBU HAMIL + BALITA =========================
await seedIbuHamil();
await seedBalita();
// // ==================== SUBMENU PUSKESMAS =========================
await seedPuskesmas();
@@ -273,9 +293,11 @@ import seedAssets from "./seed_assets";
await seedPencegahanKriminalitas();
// // ==================== SUBMENU LAPORAN PUBLIK =================
await seedLaporanPublik();
// // ==================== SUBMENU CCTV KEAMANAN ==================
await seedCctv();
// // ==================== SUBMENU TIPS KEAMANAN ==================
await seedKeamananLingkungan();
await seedTipsKeamanan();
// // ====================== MENU EKONOMI ========================
// // ==================== SUBMENU UMKM ==========================
@@ -386,6 +408,7 @@ import seedAssets from "./seed_assets";
// ===== SOSIAL DASHBOARD =====
await seedRingkasanKesehatan();
await seedKegiatanDesa();
await seedEventBudaya();
// ===== DESA =====
await seedMusikDesa();

View File

@@ -0,0 +1,160 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Prisma } from "@prisma/client";
import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
const templateBanjar = z.object({
name: z.string().min(1, "Nama banjar wajib diisi"),
});
const defaultBanjar = { name: "" };
const banjar = proxy({
create: {
form: { ...defaultBanjar },
loading: false,
async create() {
const cek = templateBanjar.safeParse(banjar.create.form);
if (!cek.success) {
const err = `[${cek.error.issues.map((v) => v.path.join(".")).join("\n")}] required`;
return toast.error(err);
}
try {
banjar.create.loading = true;
const res = await fetch("/api/desa/banjar/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(banjar.create.form),
});
const result = await res.json();
if (res.ok && result?.success) {
banjar.findMany.load();
return toast.success("Banjar berhasil dibuat");
}
return toast.error(result?.message || "Gagal membuat banjar");
} catch (error) {
console.error(error);
return toast.error("Gagal membuat banjar");
} finally {
banjar.create.loading = false;
}
},
},
findMany: {
data: [] as Prisma.BanjarGetPayload<object>[],
page: 1,
totalPages: 1,
loading: false,
search: "",
load: async (page = 1, limit = 10, search = "") => {
banjar.findMany.loading = true;
banjar.findMany.page = page;
banjar.findMany.search = search;
try {
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
if (search) params.set("search", search);
const res = await fetch(`/api/desa/banjar/findMany?${params.toString()}`);
const result = await res.json();
if (res.ok && result?.success) {
banjar.findMany.data = result.data ?? [];
banjar.findMany.totalPages = result.totalPages ?? 1;
} else {
banjar.findMany.data = [];
banjar.findMany.totalPages = 1;
}
} catch (err) {
console.error("Gagal fetch banjar:", err);
banjar.findMany.data = [];
banjar.findMany.totalPages = 1;
} finally {
banjar.findMany.loading = false;
}
},
},
delete: {
loading: false,
async delete(id: string) {
if (!id) return toast.warn("ID tidak valid");
try {
banjar.delete.loading = true;
const res = await fetch(`/api/desa/banjar/del/${id}`, { method: "DELETE" });
const result = await res.json();
if (res.ok && result?.success) {
toast.success(result.message || "Banjar berhasil dihapus");
await banjar.findMany.load();
} else {
toast.error(result?.message || "Gagal menghapus banjar");
}
} catch (error) {
console.error("Gagal delete banjar:", error);
toast.error("Terjadi kesalahan saat menghapus banjar");
} finally {
banjar.delete.loading = false;
}
},
},
update: {
id: "",
form: { ...defaultBanjar },
loading: false,
async load(id: string) {
if (!id) { toast.warn("ID tidak valid"); return null; }
try {
const res = await fetch(`/api/desa/banjar/${id}`);
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
const result = await res.json();
if (result?.success) {
banjar.update.id = result.data.id;
banjar.update.form = { name: result.data.name };
return result.data;
}
throw new Error(result?.message || "Gagal memuat data");
} catch (error) {
console.error("Error loading banjar:", error);
toast.error(error instanceof Error ? error.message : "Gagal memuat data");
return null;
}
},
async update() {
const cek = templateBanjar.safeParse(banjar.update.form);
if (!cek.success) {
const err = `[${cek.error.issues.map((v) => v.path.join(".")).join("\n")}] required`;
return toast.error(err);
}
try {
banjar.update.loading = true;
const res = await fetch(`/api/desa/banjar/${banjar.update.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(banjar.update.form),
});
const result = await res.json();
if (res.ok && result?.success) {
return toast.success("Banjar berhasil diperbarui");
}
return toast.error(result?.message || "Gagal mengupdate banjar");
} catch (error) {
console.error("Error updating banjar:", error);
toast.error("Gagal mengupdate banjar");
} finally {
banjar.update.loading = false;
}
},
},
});
export default banjar;

View File

@@ -0,0 +1,233 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import ApiFetch from "@/lib/api-fetch";
import { Prisma } from "@prisma/client";
import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
const templateForm = z.object({
nama: z.string().min(1, "Nama event harus diisi"),
tanggal: z.string().min(1, "Tanggal harus diisi"),
lokasi: z.string().min(1, "Lokasi harus diisi"),
deskripsi: z.string().optional(),
});
const defaultForm = {
nama: "",
tanggal: "",
lokasi: "",
deskripsi: "",
};
const eventBudayaState = proxy({
create: {
form: { ...defaultForm },
loading: false,
async create() {
const cek = templateForm.safeParse(eventBudayaState.create.form);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
return toast.error(err);
}
try {
eventBudayaState.create.loading = true;
const res = await ApiFetch.api.desa["eventbudaya"]["create"].post(
eventBudayaState.create.form
);
if (res.status === 200 && res.data?.success) {
eventBudayaState.findMany.load();
toast.success("Event budaya berhasil disimpan!");
eventBudayaState.create.form = { ...defaultForm };
return true;
}
toast.error(res.data?.message || "Gagal menyimpan event budaya");
return false;
} catch (error) {
console.error(error);
toast.error("Gagal menyimpan event budaya");
return false;
} finally {
eventBudayaState.create.loading = false;
}
},
},
findMany: {
data: null as Prisma.EventBudayaGetPayload<object>[] | null,
page: 1,
totalPages: 1,
total: 0,
loading: false,
search: "",
load: async (page = 1, limit = 10, search = "") => {
eventBudayaState.findMany.loading = true;
eventBudayaState.findMany.page = page;
eventBudayaState.findMany.search = search;
try {
const query: any = { page, limit };
if (search) query.search = search;
const res = await ApiFetch.api.desa["eventbudaya"]["find-many"].get({ query });
if (res.status === 200 && res.data?.success) {
eventBudayaState.findMany.data = res.data.data ?? [];
eventBudayaState.findMany.total = res.data.total ?? 0;
eventBudayaState.findMany.totalPages = res.data.totalPages ?? 1;
} else {
eventBudayaState.findMany.data = [];
eventBudayaState.findMany.total = 0;
eventBudayaState.findMany.totalPages = 1;
}
} catch (error) {
console.error("Error loading event budaya:", error);
eventBudayaState.findMany.data = [];
} finally {
eventBudayaState.findMany.loading = false;
}
},
},
findUnique: {
data: null as Prisma.EventBudayaGetPayload<object> | null,
loading: false,
async load(id: string) {
if (!id) return;
this.loading = true;
try {
const res = await fetch(`/api/desa/eventbudaya/${id}`);
if (res.ok) {
const result = await res.json();
eventBudayaState.findUnique.data = result.data ?? null;
}
} catch (error) {
console.error("Error fetching event budaya:", error);
} finally {
this.loading = false;
}
},
},
edit: {
id: "",
form: { ...defaultForm },
loading: false,
async load(id: string) {
if (!id) return;
try {
eventBudayaState.edit.loading = true;
const res = await fetch(`/api/desa/eventbudaya/${id}`);
const result = await res.json();
if (result?.success) {
const data = result.data;
eventBudayaState.edit.id = data.id;
eventBudayaState.edit.form = {
nama: data.nama,
tanggal: data.tanggal
? new Date(data.tanggal).toISOString().split("T")[0]
: "",
lokasi: data.lokasi,
deskripsi: data.deskripsi ?? "",
};
}
} catch (error) {
console.error("Error loading event budaya for edit:", error);
} finally {
eventBudayaState.edit.loading = false;
}
},
async save() {
const cek = templateForm.safeParse(eventBudayaState.edit.form);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
toast.error(err);
return false;
}
try {
eventBudayaState.edit.loading = true;
const res = await fetch(
`/api/desa/eventbudaya/${eventBudayaState.edit.id}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(eventBudayaState.edit.form),
}
);
const result = await res.json();
if (result.success) {
toast.success("Event budaya berhasil diupdate");
eventBudayaState.findMany.load();
return true;
}
toast.error(result.message);
return false;
} catch (error) {
console.error(error);
return false;
} finally {
eventBudayaState.edit.loading = false;
}
},
reset() {
eventBudayaState.edit.id = "";
eventBudayaState.edit.form = { ...defaultForm };
},
},
findUpcoming: {
data: null as Prisma.EventBudayaGetPayload<object>[] | null,
loading: false,
async load() {
eventBudayaState.findUpcoming.loading = true;
try {
const res = await fetch("/api/desa/eventbudaya/find-upcoming");
const result = await res.json();
if (result?.success) {
eventBudayaState.findUpcoming.data = result.data ?? [];
} else {
eventBudayaState.findUpcoming.data = [];
}
} catch (error) {
console.error("Error loading upcoming events:", error);
eventBudayaState.findUpcoming.data = [];
} finally {
eventBudayaState.findUpcoming.loading = false;
}
},
},
delete: {
loading: false,
async byId(id: string) {
if (!id) return toast.warn("ID tidak valid");
try {
eventBudayaState.delete.loading = true;
const res = await fetch(`/api/desa/eventbudaya/del/${id}`, {
method: "DELETE",
});
const result = await res.json();
if (res.ok && result?.success) {
toast.success(result.message || "Event budaya berhasil dihapus");
await eventBudayaState.findMany.load();
} else {
toast.error(result?.message || "Gagal menghapus event budaya");
}
} catch (error) {
console.error(error);
toast.error("Gagal menghapus event budaya");
} finally {
eventBudayaState.delete.loading = false;
}
},
},
});
export default eventBudayaState;

View File

@@ -0,0 +1,409 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import ApiFetch from "@/lib/api-fetch";
import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
// ========================================= SCHEMAS ========================================= //
const templateForm = z.object({
judul: z.string().min(3, "Judul minimal 3 karakter"),
deskripsiSingkat: z.string().min(3, "Deskripsi singkat minimal 3 karakter"),
deskripsiLengkap: z.string().min(3, "Deskripsi lengkap minimal 3 karakter"),
tanggal: z.string().nonempty("Tanggal harus diisi"),
lokasi: z.string().min(3, "Lokasi minimal 3 karakter"),
partisipan: z.number().optional().default(0),
kategoriKegiatanId: z.string().nonempty("Kategori kegiatan harus dipilih"),
imageId: z.string().optional(),
});
const defaultForm = {
judul: "",
deskripsiSingkat: "",
deskripsiLengkap: "",
tanggal: "",
lokasi: "",
partisipan: 0,
kategoriKegiatanId: "",
imageId: "" as string | undefined,
};
const templateKategori = z.object({
nama: z.string().min(1, "Nama kategori harus diisi"),
});
const defaultKategori = {
nama: "",
};
// ========================================= KEGIATAN DESA ========================================= //
const kegiatan = proxy({
create: {
form: { ...defaultForm },
loading: false,
async create() {
const cek = templateForm.safeParse(kegiatan.create.form);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
return toast.error(err);
}
try {
kegiatan.create.loading = true;
const res = await ApiFetch.api.desa["kegiatandesa"]["create"].post(
kegiatan.create.form
);
if (res.status === 200 && res.data?.success) {
kegiatan.findMany.load();
toast.success("Kegiatan desa berhasil disimpan!");
return true;
}
toast.error(res.data?.message || "Gagal menyimpan kegiatan desa");
return false;
} catch (error) {
console.error("Error creating kegiatan:", error);
toast.error("Terjadi kesalahan saat menyimpan kegiatan");
return false;
} finally {
kegiatan.create.loading = false;
}
},
resetForm() {
kegiatan.create.form = { ...defaultForm };
},
},
findMany: {
data: null as any[] | null,
page: 1,
totalPages: 1,
loading: false,
search: "",
load: async (page = 1, limit = 10, search = "", kategori = "") => {
const startTime = Date.now();
kegiatan.findMany.loading = true;
kegiatan.findMany.page = page;
kegiatan.findMany.search = search;
try {
const query: any = { page, limit };
if (search) query.search = search;
if (kategori) query.kategori = kategori;
const res = await ApiFetch.api.desa["kegiatandesa"]["find-many"].get({ query });
if (res.status === 200 && res.data?.success) {
kegiatan.findMany.data = res.data.data ?? [];
kegiatan.findMany.totalPages = res.data.totalPages ?? 1;
} else {
kegiatan.findMany.data = [];
kegiatan.findMany.totalPages = 1;
}
} catch (err) {
console.error("Gagal fetch kegiatan paginated:", err);
kegiatan.findMany.data = [];
kegiatan.findMany.totalPages = 1;
} finally {
const elapsed = Date.now() - startTime;
const minDelay = 300;
const delay = elapsed < minDelay ? minDelay - elapsed : 0;
setTimeout(() => {
kegiatan.findMany.loading = false;
}, delay);
}
},
},
findUnique: {
data: null as any | null,
loading: false,
async load(id: string) {
if (!id) return;
this.loading = true;
try {
const res = await fetch(`/api/desa/kegiatandesa/${id}`); // Assuming unique endpoint follows standard
if (res.ok) {
const result = await res.json();
kegiatan.findUnique.data = result.data ?? null;
}
} catch (error) {
console.error("Error fetching unique kegiatan:", error);
} finally {
this.loading = false;
}
},
},
delete: {
loading: false,
async byId(id: string) {
if (!id) return toast.warn("ID tidak valid");
try {
kegiatan.delete.loading = true;
const response = await fetch(`/api/desa/kegiatandesa/del/${id}`, {
method: "DELETE",
});
const result = await response.json();
if (response.ok && result?.success) {
toast.success(result.message || "Kegiatan berhasil dihapus");
await kegiatan.findMany.load();
} else {
toast.error(result?.message || "Gagal menghapus kegiatan");
}
} catch (error) {
console.error("Gagal delete:", error);
toast.error("Terjadi kesalahan saat menghapus kegiatan");
} finally {
kegiatan.delete.loading = false;
}
},
},
edit: {
id: "",
form: { ...defaultForm },
loading: false,
async load(id: string) {
if (!id) return null;
try {
kegiatan.edit.loading = true;
const response = await fetch(`/api/desa/kegiatandesa/${id}`);
const result = await response.json();
if (result?.success) {
const data = result.data;
this.id = data.id;
this.form = {
judul: data.judul,
deskripsiSingkat: data.deskripsiSingkat,
deskripsiLengkap: data.deskripsiLengkap,
tanggal: data.tanggal ? new Date(data.tanggal).toISOString().split('T')[0] : "",
lokasi: data.lokasi,
partisipan: data.partisipan || 0,
kategoriKegiatanId: data.kategoriKegiatanId || "",
imageId: data.imageId || undefined,
};
return data;
}
} catch (error) {
console.error("Error loading kegiatan for edit:", error);
} finally {
kegiatan.edit.loading = false;
}
return null;
},
async update() {
const cek = templateForm.safeParse(kegiatan.edit.form);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
toast.error(err);
return false;
}
try {
kegiatan.edit.loading = true;
const response = await fetch(`/api/desa/kegiatandesa/${this.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(kegiatan.edit.form),
});
const result = await response.json();
if (result.success) {
toast.success("Berhasil update kegiatan");
await kegiatan.findMany.load();
return true;
}
throw new Error(result.message || "Gagal update kegiatan");
} catch (error) {
toast.error(error instanceof Error ? error.message : "Terjadi kesalahan saat update");
return false;
} finally {
kegiatan.edit.loading = false;
}
},
reset() {
kegiatan.edit.id = "";
kegiatan.edit.form = { ...defaultForm };
},
},
});
// ========================================= KATEGORI KEGIATAN ========================================= //
const kategoriKegiatan = proxy({
create: {
form: { ...defaultKategori },
loading: false,
async create() {
const cek = templateKategori.safeParse(kategoriKegiatan.create.form);
if (!cek.success) {
return toast.error("Nama kategori harus diisi");
}
try {
kategoriKegiatan.create.loading = true;
const res = await ApiFetch.api.desa["kategorikegiatan"]["create"].post(
kategoriKegiatan.create.form
);
if (res.status === 200 && res.data?.success) {
kategoriKegiatan.findMany.load();
toast.success("Kategori kegiatan berhasil dibuat");
return true;
}
toast.error(res.data?.message || "Gagal membuat kategori");
return false;
} catch (error) {
console.error(error);
toast.error("Terjadi kesalahan");
return false;
} finally {
kategoriKegiatan.create.loading = false;
}
},
resetForm() {
kategoriKegiatan.create.form = { ...defaultKategori };
},
},
findMany: {
data: [] as any[],
page: 1,
totalPages: 1,
loading: false,
search: "",
load: async (page = 1, limit = 10, search = "") => {
kategoriKegiatan.findMany.loading = true;
kategoriKegiatan.findMany.page = page;
kategoriKegiatan.findMany.search = search;
try {
const query: any = { page, limit };
if (search) query.search = search;
const res = await ApiFetch.api.desa["kategorikegiatan"]["findMany"].get({ query });
if (res.status === 200 && res.data?.success) {
kategoriKegiatan.findMany.data = res.data.data ?? [];
kategoriKegiatan.findMany.totalPages = res.data.totalPages ?? 1;
} else {
kategoriKegiatan.findMany.data = [];
}
} catch (err) {
console.error("Gagal fetch kategori kegiatan:", err);
} finally {
kategoriKegiatan.findMany.loading = false;
}
},
},
findUnique: {
data: null as any | null,
loading: false,
async load(id: string) {
try {
const res = await fetch(`/api/desa/kategorikegiatan/${id}`);
if (res.ok) {
const result = await res.json();
kategoriKegiatan.findUnique.data = result.data ?? null;
}
} catch (error) {
console.error(error);
}
},
},
delete: {
loading: false,
async byId(id: string) {
if (!id) return;
try {
kategoriKegiatan.delete.loading = true;
const res = await fetch(`/api/desa/kategorikegiatan/del/${id}`, { method: "DELETE" });
const result = await res.json();
if (result.success) {
toast.success(result.message);
kategoriKegiatan.findMany.load();
} else {
toast.error(result.message);
}
} catch (error) {
console.error(error);
} finally {
kategoriKegiatan.delete.loading = false;
}
},
},
update: {
id: "",
form: { ...defaultKategori },
loading: false,
async load(id: string) {
if (!id) return;
try {
this.loading = true;
const res = await fetch(`/api/desa/kategorikegiatan/${id}`);
const result = await res.json();
if (result.success) {
this.id = result.data.id;
this.form = { nama: result.data.nama };
}
} catch (error) {
console.error(error);
} finally {
this.loading = false;
}
},
async update() {
const cek = templateKategori.safeParse(kategoriKegiatan.update.form);
if (!cek.success) return toast.error("Nama kategori harus diisi");
try {
this.loading = true;
const res = await fetch(`/api/desa/kategorikegiatan/${this.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(this.form),
});
const result = await res.json();
if (result.success) {
toast.success("Berhasil update kategori");
kategoriKegiatan.findMany.load();
return true;
}
toast.error(result.message);
} catch (error) {
console.error(error);
} finally {
this.loading = false;
}
return false;
},
reset() {
this.id = "";
this.form = { ...defaultKategori };
},
},
});
// ========================================= GLOBAL STATE ========================================= //
const stateDashboardKegiatan = proxy({
kegiatan,
kategoriKegiatan,
});
export default stateDashboardKegiatan;

View File

@@ -0,0 +1,153 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
import { Prisma } from "@prisma/client";
const lambangDesaForm = z.object({
judul: z.string().min(3, "Judul minimal 3 karakter"),
deskripsi: z.string().min(3, "Deskripsi minimal 3 karakter"),
});
const lambangDesaDefaultForm = {
judul: "",
deskripsi: "",
};
type LambangDesaForm = Prisma.LambangDesaGetPayload<{
select: {
id: true;
judul: true;
deskripsi: true;
};
}>;
const lambangDesa = proxy({
findUnique: {
data: null as LambangDesaForm | 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/desa/profile/lambang/${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 lambang desa"
);
}
} catch (error) {
const msg = (error as Error).message;
this.error = msg;
console.error("Load lambang desa error:", msg);
toast.error("Terjadi kesalahan saat mengambil data lambang desa");
return null;
} finally {
this.loading = false;
}
},
reset() {
this.data = null;
this.error = null;
this.loading = false;
},
},
update: {
id: "",
form: { ...lambangDesaDefaultForm },
loading: false,
error: null as string | null,
isReadOnly: false,
initialize(lambangDesaData: LambangDesaForm) {
this.id = lambangDesaData.id;
this.isReadOnly = false;
this.form = {
judul: lambangDesaData.judul || "",
deskripsi: lambangDesaData.deskripsi || "",
};
},
updateField(field: keyof typeof lambangDesaDefaultForm, value: string) {
this.form[field] = value;
},
async submit() {
const validation = lambangDesaForm.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/desa/profile/lambang/${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 lambang desa");
await lambangDesa.findUnique.load(this.id);
return true;
} else {
throw new Error(result.message || "Gagal update lambang desa");
}
} catch (error) {
const errorMessage = (error as Error).message;
this.error = errorMessage;
console.error("Update lambang desa error:", errorMessage);
toast.error("Terjadi kesalahan saat update lambang desa");
return false;
} finally {
this.loading = false;
}
},
reset() {
this.id = "";
this.form = { ...lambangDesaDefaultForm };
this.error = null;
this.loading = false;
this.isReadOnly = false;
},
},
});
export default lambangDesa;

View File

@@ -0,0 +1,241 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
import { Prisma } from "@prisma/client";
import ApiFetch from "@/lib/api-fetch";
const mantanPerbekelForm = z.object({
nama: z.string().min(3, "Nama minimal 3 karakter"),
daerah: z.string().min(3, "Daerah minimal 3 karakter"),
periode: z.string().min(3, "Periode minimal 3 karakter"),
imageId: z.string().min(1, "Gambar wajib dipilih"),
});
const mantanPerbekelDefaultForm = {
nama: "",
daerah: "",
periode: "",
imageId: "",
};
const mantanPerbekel = proxy({
create: {
form: { ...mantanPerbekelDefaultForm },
loading: false,
async create() {
const cek = mantanPerbekelForm.safeParse(mantanPerbekel.create.form);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
return toast.error(err);
}
try {
mantanPerbekel.create.loading = true;
const res = await ApiFetch.api.desa.mantanperbekel["create"].post(
mantanPerbekel.create.form
);
if (res.status === 200) {
mantanPerbekel.findMany.load();
return toast.success("Foto berhasil disimpan!");
}
return toast.error("Gagal menyimpan foto");
} catch (error) {
console.log((error as Error).message);
} finally {
mantanPerbekel.create.loading = false;
}
},
resetForm() {
mantanPerbekel.create.form = { ...mantanPerbekelDefaultForm };
},
},
findMany: {
data: null as
| Prisma.PerbekelDariMasaKeMasaGetPayload<{
include: {
image: true;
};
}>[]
| null,
page: 1,
totalPages: 1,
loading: false,
search: "",
load: async (page = 1, limit = 10, search = "") => {
mantanPerbekel.findMany.loading = true;
mantanPerbekel.findMany.page = page;
mantanPerbekel.findMany.search = search;
try {
const query: any = { page, limit };
if (search) query.search = search;
const res = await ApiFetch.api.desa.mantanperbekel["findMany"].get({
query,
});
if (res.status === 200 && res.data?.success) {
mantanPerbekel.findMany.data = res.data.data ?? [];
mantanPerbekel.findMany.totalPages = res.data.totalPages ?? 1;
} else {
mantanPerbekel.findMany.data = [];
mantanPerbekel.findMany.totalPages = 1;
}
} catch (err) {
console.error("Gagal fetch mantan perbekel paginated:", err);
mantanPerbekel.findMany.data = [];
mantanPerbekel.findMany.totalPages = 1;
} finally {
mantanPerbekel.findMany.loading = false;
}
},
},
findUnique: {
data: null as Prisma.PerbekelDariMasaKeMasaGetPayload<{
include: {
image: true;
};
}> | null,
async load(id: string) {
try {
const res = await fetch(`/api/desa/mantanperbekel/${id}`);
if (res.ok) {
const data = await res.json();
mantanPerbekel.findUnique.data = data.data ?? null;
} else {
console.error("Failed to fetch mantan perbekel:", res.statusText);
mantanPerbekel.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching mantan perbekel:", error);
mantanPerbekel.findUnique.data = null;
}
},
},
delete: {
loading: false,
async byId(id: string) {
if (!id) return toast.warn("ID tidak valid");
try {
mantanPerbekel.delete.loading = true;
const response = await fetch(`/api/desa/mantanperbekel/del/${id}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
});
const result = await response.json();
if (response.ok) {
toast.success(result.message || "Mantan perbekel berhasil dihapus");
await mantanPerbekel.findMany.load();
} else {
toast.error(result.message || "Gagal menghapus mantan perbekel");
}
} catch (error) {
console.error("Gagal delete:", error);
toast.error("Terjadi kesalahan saat menghapus mantan perbekel");
} finally {
mantanPerbekel.delete.loading = false;
}
},
},
update: {
id: "",
form: { ...mantanPerbekelDefaultForm },
loading: false,
async load(id: string) {
if (!id) {
toast.warn("ID tidak valid");
return null;
}
try {
const response = await fetch(`/api/desa/mantanperbekel/${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,
daerah: data.daerah,
periode: data.periode,
imageId: data.imageId || "",
};
return data;
} else {
throw new Error(result.message || "Gagal memuat data");
}
} catch (error) {
console.error("Error loading foto:", error);
toast.error(
error instanceof Error ? error.message : "Gagal memuat data"
);
return null;
}
},
async update() {
const cek = mantanPerbekelForm.safeParse(mantanPerbekel.update.form);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
toast.error(err);
return false;
}
try {
mantanPerbekel.update.loading = true;
const response = await fetch(`/api/desa/mantanperbekel/${this.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
nama: this.form.nama,
daerah: this.form.daerah,
periode: this.form.periode,
imageId: this.form.imageId,
}),
});
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(result.message || "Mantan perbekel berhasil diupdate");
await mantanPerbekel.findMany.load();
return true;
} else {
throw new Error(result.message || "Gagal mengupdate mantan perbekel");
}
} catch (error) {
console.error("Error updating mantan perbekel:", error);
toast.error(
error instanceof Error
? error.message
: "Gagal mengupdate mantan perbekel"
);
return false;
} finally {
mantanPerbekel.update.loading = false;
}
},
reset() {
mantanPerbekel.update.id = "";
mantanPerbekel.update.form = { ...mantanPerbekelDefaultForm };
},
},
});
export default mantanPerbekel;

View File

@@ -0,0 +1,183 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
import { Prisma } from "@prisma/client";
const maskotForm = z.object({
judul: z.string().min(3, "Judul minimal 3 karakter"),
deskripsi: z.string().min(3, "Deskripsi minimal 3 karakter"),
images: z
.array(
z.object({
label: z.string().min(1, "Label wajib"),
imageId: z.string().min(1, "Image ID wajib"),
})
)
.min(1, "Minimal 1 gambar harus diisi"),
});
const maskotDefaultForm = {
judul: "",
deskripsi: "",
images: [] as { label: string; imageId: string }[],
};
type FormData = typeof maskotDefaultForm;
type MaskotDesaForm = Prisma.MaskotDesaGetPayload<{
include: {
images: {
include: {
image: {
select: {
id: true;
name: true;
path: true;
link: true;
};
};
};
};
};
}>;
const maskotDesa = proxy({
findUnique: {
data: null as MaskotDesaForm | 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/desa/profile/maskot/${id}`);
const result = await response.json();
if (response.ok && result.success) {
this.data = result.data;
return result.data;
} else {
throw new Error(result.message || "Gagal mengambil data profile");
}
} catch (error) {
const msg = (error as Error).message;
this.error = msg;
console.error("Load profile error:", msg);
toast.error("Terjadi kesalahan saat mengambil data profile");
return null;
} finally {
this.loading = false;
}
},
reset() {
this.data = null;
this.error = null;
this.loading = false;
},
},
update: {
id: "",
form: { ...maskotDefaultForm },
loading: false,
error: null as string | null,
isReadOnly: false,
initialize(profileData: MaskotDesaForm) {
this.id = profileData.id;
this.isReadOnly = false;
this.form = {
judul: profileData.judul || "",
deskripsi: profileData.deskripsi || "",
images: (profileData.images || []).map((img) => ({
label: img.label,
imageId: img?.image?.id || "",
})),
};
},
updateField<K extends keyof FormData>(field: K, value: FormData[K]) {
this.form[field] = value;
},
addImage() {
this.form.images.push({ label: "", imageId: "" });
},
removeImage(index: number) {
this.form.images.splice(index, 1);
},
async submit() {
const validation = maskotForm.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/desa/profile/maskot/${this.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(this.form),
});
const result = await response.json();
if (response.ok && result.success) {
toast.success("Berhasil update profile");
await maskotDesa.findUnique.load(this.id);
return true;
} else {
throw new Error(result.message || "Gagal update profile");
}
} catch (error) {
const msg = (error as Error).message;
this.error = msg;
toast.error("Terjadi kesalahan saat update profile");
return false;
} finally {
this.loading = false;
}
},
reset() {
this.id = "";
this.form = { ...maskotDefaultForm };
this.error = null;
this.loading = false;
this.isReadOnly = false;
},
},
async loadForEdit(id: string) {
const data = await this.findUnique.load(id);
if (data) {
this.update.initialize(data);
}
return data;
},
reset() {
this.findUnique.reset();
this.update.reset();
},
});
export default maskotDesa;

View File

@@ -0,0 +1,185 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
import { Prisma } from "@prisma/client";
const profilPerbekelForm = z.object({
biodata: z.string().min(3, "Biodata minimal 3 karakter"),
pengalaman: z.string().min(3, "Pengalaman minimal 3 karakter"),
pengalamanOrganisasi: z
.string()
.min(3, "Pengalaman Organisasi minimal 3 karakter"),
programUnggulan: z.string().min(3, "Program Unggulan minimal 3 karakter"),
imageId: z.string().min(1, "Gambar wajib dipilih"),
});
const profilPerbekelDefaultForm = {
biodata: "",
pengalaman: "",
pengalamanOrganisasi: "",
programUnggulan: "",
imageId: "",
};
type ProfilPerbekelForm = Prisma.ProfilPerbekelGetPayload<{
select: {
id: true;
biodata: true;
pengalaman: true;
pengalamanOrganisasi: true;
programUnggulan: true;
imageId: true;
image?: {
select: {
link: true;
};
};
};
}>;
const profilPerbekel = proxy({
findUnique: {
data: null as ProfilPerbekelForm | 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/desa/profile/profileperbekel/${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 profil perbekel"
);
}
} catch (error) {
const msg = (error as Error).message;
this.error = msg;
toast.error("Terjadi kesalahan saat mengambil data profil perbekel");
return null;
} finally {
this.loading = false;
}
},
reset() {
this.data = null;
this.error = null;
this.loading = false;
},
},
edit: {
id: "",
form: { ...profilPerbekelDefaultForm },
loading: false,
error: null as string | null,
isReadOnly: false,
initialize(profilData: ProfilPerbekelForm) {
this.id = profilData.id;
this.isReadOnly = false;
this.form = {
biodata: profilData.biodata || "",
pengalaman: profilData.pengalaman || "",
pengalamanOrganisasi: profilData.pengalamanOrganisasi || "",
programUnggulan: profilData.programUnggulan || "",
imageId: profilData.imageId || "",
};
},
updateField(field: keyof typeof profilPerbekelDefaultForm, value: string) {
this.form[field] = value;
},
async submit() {
const validation = profilPerbekelForm.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/desa/profile/profileperbekel/${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 profil perbekel");
await profilPerbekel.findUnique.load(this.id);
return true;
} else {
throw new Error(result.message || "Gagal update profil perbekel");
}
} catch (error) {
const msg = (error as Error).message;
this.error = msg;
toast.error("Terjadi kesalahan saat update profil perbekel");
return false;
} finally {
this.loading = false;
}
},
reset() {
this.id = "";
this.form = { ...profilPerbekelDefaultForm };
this.error = null;
this.loading = false;
this.isReadOnly = false;
},
},
async loadForEdit(id: string) {
const profileData = await this.findUnique.load(id);
if (profileData) {
this.edit.initialize(profileData);
}
return profileData;
},
reset() {
this.findUnique.reset();
this.edit.reset();
},
});
export default profilPerbekel;

View File

@@ -0,0 +1,153 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
import { Prisma } from "@prisma/client";
const sejarahDesaForm = z.object({
judul: z.string().min(3, "Judul minimal 3 karakter"),
deskripsi: z.string().min(3, "Deskripsi minimal 3 karakter"),
});
const sejarahDesaDefaultForm = {
judul: "",
deskripsi: "",
};
type SejarahDesaForm = Prisma.SejarahDesaGetPayload<{
select: {
id: true;
judul: true;
deskripsi: true;
};
}>;
const sejarahDesa = proxy({
findUnique: {
data: null as SejarahDesaForm | 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/desa/profile/sejarah/${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 sejarah desa"
);
}
} catch (error) {
const msg = (error as Error).message;
this.error = msg;
console.error("Load sejarah desa error:", msg);
toast.error("Terjadi kesalahan saat mengambil data sejarah desa");
return null;
} finally {
this.loading = false;
}
},
reset() {
this.data = null;
this.error = null;
this.loading = false;
},
},
update: {
id: "",
form: { ...sejarahDesaDefaultForm },
loading: false,
error: null as string | null,
isReadOnly: false,
initialize(sejarahData: SejarahDesaForm) {
this.id = sejarahData.id;
this.isReadOnly = false;
this.form = {
judul: sejarahData.judul || "",
deskripsi: sejarahData.deskripsi || "",
};
},
updateField(field: keyof typeof sejarahDesaDefaultForm, value: string) {
this.form[field] = value;
},
async submit() {
const validation = sejarahDesaForm.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/desa/profile/sejarah/${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 profile");
await sejarahDesa.findUnique.load(this.id);
return true;
} else {
throw new Error(result.message || "Gagal update profile");
}
} catch (error) {
const errorMessage = (error as Error).message;
this.error = errorMessage;
console.error("Update profile error:", errorMessage);
toast.error("Terjadi kesalahan saat update profile");
return false;
} finally {
this.loading = false;
}
},
reset() {
this.id = "";
this.form = { ...sejarahDesaDefaultForm };
this.error = null;
this.loading = false;
this.isReadOnly = false;
},
},
});
export default sejarahDesa;

View File

@@ -0,0 +1,153 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
import { Prisma } from "@prisma/client";
const visiMisiDesaForm = z.object({
visi: z.string().min(3, "Visi minimal 3 karakter"),
misi: z.string().min(3, "Misi minimal 3 karakter"),
});
const visiMisiDesaDefaultForm = {
visi: "",
misi: "",
};
type VisiMisiDesaForm = Prisma.VisiMisiDesaGetPayload<{
select: {
id: true;
visi: true;
misi: true;
};
}>;
const visiMisiDesa = proxy({
findUnique: {
data: null as VisiMisiDesaForm | 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/desa/profile/visi-misi/${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 visi misi desa"
);
}
} catch (error) {
const msg = (error as Error).message;
this.error = msg;
console.error("Load visi misi desa error:", msg);
toast.error("Terjadi kesalahan saat mengambil data visi misi desa");
return null;
} finally {
this.loading = false;
}
},
reset() {
this.data = null;
this.error = null;
this.loading = false;
},
},
update: {
id: "",
form: { ...visiMisiDesaDefaultForm },
loading: false,
error: null as string | null,
isReadOnly: false,
initialize(visiMisiData: VisiMisiDesaForm) {
this.id = visiMisiData.id;
this.isReadOnly = false;
this.form = {
visi: visiMisiData.visi || "",
misi: visiMisiData.misi || "",
};
},
updateField(field: keyof typeof visiMisiDesaDefaultForm, value: string) {
this.form[field] = value;
},
async submit() {
const validation = visiMisiDesaForm.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/desa/profile/visi-misi/${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 visi misi desa");
await visiMisiDesa.findUnique.load(this.id);
return true;
} else {
throw new Error(result.message || "Gagal update visi misi desa");
}
} catch (error) {
const errorMessage = (error as Error).message;
this.error = errorMessage;
console.error("Update visi misi desa error:", errorMessage);
toast.error("Terjadi kesalahan saat update visi misi desa");
return false;
} finally {
this.loading = false;
}
},
reset() {
this.id = "";
this.form = { ...visiMisiDesaDefaultForm };
this.error = null;
this.loading = false;
this.isReadOnly = false;
},
},
});
export default visiMisiDesa;

View File

@@ -0,0 +1,239 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import ApiFetch from "@/lib/api-fetch";
import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
export type StatusCctv = "Online" | "Offline";
export interface CctvData {
id: string;
kode: string;
nama: string;
lokasi: string;
latitude: number | null;
longitude: number | null;
status: StatusCctv;
lastActive: string;
createdAt: string;
updatedAt: string;
isActive: boolean;
}
const templateForm = z.object({
kode: z.string().min(1, "Kode CCTV wajib diisi"),
nama: z.string().min(1, "Nama CCTV wajib diisi"),
lokasi: z.string().min(1, "Lokasi wajib diisi"),
});
interface FormData {
kode: string;
nama: string;
lokasi: string;
latitude: string;
longitude: string;
status: StatusCctv;
lastActive: string;
}
const defaultForm: FormData = {
kode: "",
nama: "",
lokasi: "",
latitude: "",
longitude: "",
status: "Online",
lastActive: new Date().toISOString(),
};
const cctvState = proxy({
create: {
form: { ...defaultForm },
loading: false,
async create() {
const cek = templateForm.safeParse(cctvState.create.form);
if (!cek.success) {
const err = `[${cek.error.issues.map((v) => v.path.join(".")).join("\n")}] required`;
return toast.error(err);
}
try {
cctvState.create.loading = true;
const form = cctvState.create.form;
const res = await ApiFetch.api.keamanan.cctv["create"].post({
kode: form.kode,
nama: form.nama,
lokasi: form.lokasi,
latitude: form.latitude ? Number(form.latitude) : undefined,
longitude: form.longitude ? Number(form.longitude) : undefined,
status: form.status,
lastActive: form.lastActive,
});
if (res.error) throw new Error("Failed to create CCTV");
if (res.status === 200) {
await cctvState.findMany.load();
return toast.success("CCTV berhasil ditambahkan");
}
return toast.error("Gagal menambahkan CCTV");
} catch (error) {
toast.error(error instanceof Error ? error.message : "Gagal membuat CCTV");
} finally {
cctvState.create.loading = false;
}
},
resetForm() {
cctvState.create.form = { ...defaultForm };
},
},
findMany: {
data: null as CctvData[] | null,
loading: false,
page: 1,
limit: 10,
totalPages: 1,
search: "",
async load() {
try {
cctvState.findMany.loading = true;
const res = await ApiFetch.api.keamanan.cctv["find-many"].get({
query: {
page: String(cctvState.findMany.page),
limit: String(cctvState.findMany.limit),
search: cctvState.findMany.search,
},
});
if (res.data?.success) {
cctvState.findMany.data = (res.data.data as any) ?? [];
cctvState.findMany.totalPages = res.data.totalPages ?? 1;
} else {
cctvState.findMany.data = [];
cctvState.findMany.totalPages = 1;
}
} catch (err) {
console.error("Gagal fetch CCTV:", err);
cctvState.findMany.data = [];
cctvState.findMany.totalPages = 1;
} finally {
cctvState.findMany.loading = false;
}
},
},
findUnique: {
data: null as CctvData | null,
loading: false,
async load(id: string) {
if (!id) return null;
try {
cctvState.findUnique.loading = true;
const res = await ApiFetch.api.keamanan.cctv({ id }).get();
if (res.data?.success) {
cctvState.findUnique.data = res.data.data as any;
}
return res.data?.data ?? null;
} catch (err) {
console.error("Gagal fetch CCTV by id:", err);
return null;
} finally {
cctvState.findUnique.loading = false;
}
},
},
delete: {
loading: false,
async remove(id: string) {
try {
cctvState.delete.loading = true;
const response = await fetch(`/api/keamanan/cctv/del/${id}`, {
method: "DELETE",
});
const result = await response.json();
if (response.ok && result?.success) {
toast.success(result.message || "CCTV berhasil dihapus");
await cctvState.findMany.load();
} else {
toast.error(result?.message || "Gagal menghapus CCTV");
}
} catch (error) {
console.error("Gagal delete CCTV:", error);
toast.error("Terjadi kesalahan saat menghapus CCTV");
} finally {
cctvState.delete.loading = false;
}
},
},
edit: {
id: "",
form: { ...defaultForm },
loading: false,
async load(id: string) {
if (!id) return null;
const data = await cctvState.findUnique.load(id);
if (data) {
cctvState.edit.id = id;
cctvState.edit.form = {
kode: (data as any).kode ?? "",
nama: (data as any).nama ?? "",
lokasi: (data as any).lokasi ?? "",
latitude: (data as any).latitude != null ? String((data as any).latitude) : "",
longitude: (data as any).longitude != null ? String((data as any).longitude) : "",
status: (data as any).status ?? "Online",
lastActive: (data as any).lastActive ?? new Date().toISOString(),
};
}
return data;
},
async update() {
const cek = templateForm.safeParse(cctvState.edit.form);
if (!cek.success) {
const err = `[${cek.error.issues.map((v) => v.path.join(".")).join("\n")}] required`;
return toast.error(err);
}
try {
cctvState.edit.loading = true;
const form = cctvState.edit.form;
const res = await ApiFetch.api.keamanan.cctv({ id: cctvState.edit.id }).put({
kode: form.kode,
nama: form.nama,
lokasi: form.lokasi,
latitude: form.latitude ? Number(form.latitude) : undefined,
longitude: form.longitude ? Number(form.longitude) : undefined,
status: form.status,
lastActive: form.lastActive,
});
if (res.error) throw new Error("Failed to update CCTV");
if (res.status === 200) {
await cctvState.findMany.load();
return toast.success("CCTV berhasil diperbarui");
}
return toast.error("Gagal memperbarui CCTV");
} catch (error) {
toast.error(error instanceof Error ? error.message : "Gagal update CCTV");
} finally {
cctvState.edit.loading = false;
}
},
},
stats: {
data: null as { cctvOnline: number; laporanMingguIni: number } | null,
loading: false,
async load() {
try {
cctvState.stats.loading = true;
const res = await ApiFetch.api.keamanan.cctv["stats"].get();
if (res.data?.success) {
cctvState.stats.data = res.data.data as any;
}
} catch (err) {
console.error("Gagal fetch CCTV stats:", err);
} finally {
cctvState.stats.loading = false;
}
},
},
});
export default cctvState;

View File

@@ -0,0 +1,226 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { JenisKelaminBalita, Prisma, StatusStunting } from "@prisma/client";
import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
const formSchema = z.object({
nama: z.string().min(1, { message: "Nama wajib diisi" }),
nik: z.string().optional(),
tanggalLahir: z.string().min(1, { message: "Tanggal lahir wajib diisi" }),
jenisKelamin: z.enum(["L", "P"]),
beratBadanKg: z.number().optional(),
tinggiBadanCm: z.number().optional(),
namaOrtu: z.string().optional(),
alamat: z.string().optional(),
noHpOrtu: z.string().optional(),
posyanduId: z.string().optional(),
imunisasiLengkap: z.boolean(),
giziBaik: z.boolean(),
pemeriksaanRutin: z.boolean(),
statusStunting: z.enum(["NORMAL", "ALERT", "STUNTING"]),
catatan: z.string().optional(),
});
const defaultForm = {
nama: "",
nik: "",
tanggalLahir: "",
jenisKelamin: "L" as JenisKelaminBalita,
beratBadanKg: undefined as number | undefined,
tinggiBadanCm: undefined as number | undefined,
namaOrtu: "",
alamat: "",
noHpOrtu: "",
posyanduId: "",
imunisasiLengkap: false,
giziBaik: true,
pemeriksaanRutin: true,
statusStunting: "NORMAL" as StatusStunting,
catatan: "",
};
const balitaState = proxy({
create: {
form: { ...defaultForm },
loading: false,
async submit() {
const cek = formSchema.safeParse(balitaState.create.form);
if (!cek.success) {
const err = cek.error.issues.map((v) => v.message).join(", ");
toast.error(err);
return false;
}
try {
balitaState.create.loading = true;
const res = await fetch("/api/kesehatan/balita/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(cek.data),
});
const result = await res.json();
if (result.success) {
toast.success("Balita berhasil ditambahkan");
balitaState.findMany.load();
return true;
}
toast.error(result.message || "Gagal menambahkan data");
return false;
} catch (e) {
console.error(e);
toast.error("Terjadi kesalahan");
return false;
} finally {
balitaState.create.loading = false;
}
},
reset() {
balitaState.create.form = { ...defaultForm };
},
},
findMany: {
data: null as
| Prisma.BalitaGetPayload<{
include: {
posyandu: {
select: {
id: true;
name: true;
banjar: { select: { id: true; name: true } };
};
};
};
}>[]
| null,
page: 1,
totalPages: 1,
total: 0,
loading: false,
search: "",
statusStuntingFilter: "",
async load(page = 1, limit = 10, search = "", statusStuntingFilter = "") {
balitaState.findMany.loading = true;
balitaState.findMany.page = page;
balitaState.findMany.search = search;
balitaState.findMany.statusStuntingFilter = statusStuntingFilter;
try {
const query = new URLSearchParams({ page: String(page), limit: String(limit) });
if (search) query.set("search", search);
if (statusStuntingFilter) query.set("statusStunting", statusStuntingFilter);
const res = await fetch(`/api/kesehatan/balita/find-many?${query}`);
const result = await res.json();
if (result.success) {
balitaState.findMany.data = result.data ?? [];
balitaState.findMany.totalPages = result.totalPages ?? 1;
balitaState.findMany.total = result.total ?? 0;
} else {
balitaState.findMany.data = [];
}
} catch (e) {
console.error("balitaFindMany error:", e);
balitaState.findMany.data = [];
} finally {
balitaState.findMany.loading = false;
}
},
},
edit: {
id: "",
form: { ...defaultForm },
loading: false,
async load(id: string) {
try {
const res = await fetch(`/api/kesehatan/balita/${id}`);
const result = await res.json();
if (result.success) {
const d = result.data;
balitaState.edit.id = d.id;
balitaState.edit.form = {
nama: d.nama,
nik: d.nik ?? "",
tanggalLahir: d.tanggalLahir ? d.tanggalLahir.slice(0, 10) : "",
jenisKelamin: d.jenisKelamin,
beratBadanKg: d.beratBadanKg ?? undefined,
tinggiBadanCm: d.tinggiBadanCm ?? undefined,
namaOrtu: d.namaOrtu ?? "",
alamat: d.alamat ?? "",
noHpOrtu: d.noHpOrtu ?? "",
posyanduId: d.posyanduId ?? "",
imunisasiLengkap: d.imunisasiLengkap,
giziBaik: d.giziBaik,
pemeriksaanRutin: d.pemeriksaanRutin,
statusStunting: d.statusStunting,
catatan: d.catatan ?? "",
};
return d;
}
toast.error("Gagal memuat data");
return null;
} catch (e) {
console.error(e);
toast.error("Gagal memuat data");
return null;
}
},
async update() {
const cek = formSchema.safeParse(balitaState.edit.form);
if (!cek.success) {
const err = cek.error.issues.map((v) => v.message).join(", ");
toast.error(err);
return false;
}
try {
balitaState.edit.loading = true;
const res = await fetch(`/api/kesehatan/balita/${balitaState.edit.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(cek.data),
});
const result = await res.json();
if (result.success) {
toast.success("Data balita berhasil diperbarui");
balitaState.findMany.load();
return true;
}
toast.error(result.message || "Gagal memperbarui data");
return false;
} catch (e) {
console.error(e);
toast.error("Terjadi kesalahan");
return false;
} finally {
balitaState.edit.loading = false;
}
},
reset() {
balitaState.edit.id = "";
balitaState.edit.form = { ...defaultForm };
},
},
delete: {
loading: false,
async byId(id: string) {
try {
balitaState.delete.loading = true;
const res = await fetch(`/api/kesehatan/balita/del/${id}`, { method: "DELETE" });
const result = await res.json();
if (result.success) {
toast.success(result.message || "Data berhasil dihapus");
await balitaState.findMany.load();
} else {
toast.error(result.message || "Gagal menghapus data");
}
} catch (e) {
console.error(e);
toast.error("Terjadi kesalahan saat menghapus");
} finally {
balitaState.delete.loading = false;
}
},
},
});
export default balitaState;

View File

@@ -11,6 +11,7 @@ const templateGrafikKepuasan = z.object({
jenisKelamin: z.string().min(1, "Jenis kelamin harus diisi"),
alamat: z.string().min(1, "Alamat harus diisi"),
penyakit: z.string().min(1, "Penyakit harus diisi"),
banjarId: z.string().optional(),
});
const defaultForm = {
@@ -19,6 +20,7 @@ const defaultForm = {
jenisKelamin: "",
alamat: "",
penyakit: "",
banjarId: "",
};
const grafikkepuasan = proxy({
@@ -62,23 +64,24 @@ const grafikkepuasan = proxy({
findMany: {
data: null as
| Prisma.GrafikKepuasanGetPayload<{
omit: {
isActive: true;
};
include: { banjar: { select: { id: true; name: true } } };
}>[]
| null,
page: 1,
totalPages: 1,
loading: false,
search: "",
load: async (page = 1, limit = 10, search = "") => {
grafikkepuasan.findMany.loading = true; // ✅ Akses langsung via nama path
banjarId: "",
load: async (page = 1, limit = 10, search = "", banjarId = "") => {
grafikkepuasan.findMany.loading = true;
grafikkepuasan.findMany.page = page;
grafikkepuasan.findMany.search = search;
grafikkepuasan.findMany.banjarId = banjarId;
try {
const query: any = { page, limit };
if (search) query.search = search;
if (banjarId) query.banjarId = banjarId;
const res = await ApiFetch.api.kesehatan.grafikkepuasan[
"find-many"
@@ -153,6 +156,7 @@ const grafikkepuasan = proxy({
jenisKelamin: data.jenisKelamin,
alamat: data.alamat,
penyakit: data.penyakit,
banjarId: data.banjarId || "",
};
return data; // Return the loaded data
} else {
@@ -179,6 +183,7 @@ const grafikkepuasan = proxy({
jenisKelamin: this.form.jenisKelamin,
alamat: this.form.alamat,
penyakit: this.form.penyakit,
banjarId: this.form.banjarId || undefined,
};
const cek = templateGrafikKepuasan.safeParse(formData);
@@ -253,6 +258,24 @@ const grafikkepuasan = proxy({
}
},
},
banjarList: {
data: [] as { id: string; name: string }[],
loading: false,
async load() {
try {
grafikkepuasan.banjarList.loading = true;
const res = await fetch("/api/desa/banjar/findMany?limit=100");
const result = await res.json();
if (res.ok && result?.success) {
grafikkepuasan.banjarList.data = result.data ?? [];
}
} catch (err) {
console.error("Gagal fetch banjar list:", err);
} finally {
grafikkepuasan.banjarList.loading = false;
}
},
},
});
export default grafikkepuasan;

View File

@@ -0,0 +1,211 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { IbuHamilStatus, Prisma } from "@prisma/client";
import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
const formSchema = z.object({
nama: z.string().min(1, { message: "Nama wajib diisi" }),
nik: z.string().optional(),
usiaKehamilan: z.number().min(0),
hpht: z.string().optional(),
taksiranLahir: z.string().optional(),
alamat: z.string().optional(),
noHp: z.string().optional(),
catatan: z.string().optional(),
posyanduId: z.string().optional(),
status: z.enum(["AKTIF", "MELAHIRKAN", "KEGUGURAN", "NONAKTIF"]),
});
const defaultForm = {
nama: "",
nik: "",
usiaKehamilan: 0,
hpht: "",
taksiranLahir: "",
alamat: "",
noHp: "",
catatan: "",
posyanduId: "",
status: "AKTIF" as IbuHamilStatus,
};
const ibuHamilState = proxy({
create: {
form: { ...defaultForm },
loading: false,
async submit() {
const cek = formSchema.safeParse(ibuHamilState.create.form);
if (!cek.success) {
const err = cek.error.issues.map((v) => v.message).join(", ");
toast.error(err);
return false;
}
try {
ibuHamilState.create.loading = true;
const res = await fetch("/api/kesehatan/ibuhamil/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(cek.data),
});
const result = await res.json();
if (result.success) {
toast.success("Ibu hamil berhasil ditambahkan");
ibuHamilState.findMany.load();
return true;
}
toast.error(result.message || "Gagal menambahkan data");
return false;
} catch (e) {
console.error(e);
toast.error("Terjadi kesalahan");
return false;
} finally {
ibuHamilState.create.loading = false;
}
},
reset() {
ibuHamilState.create.form = { ...defaultForm };
},
},
findMany: {
data: null as
| Prisma.IbuHamilGetPayload<{
include: {
posyandu: {
select: {
id: true;
name: true;
banjar: { select: { id: true; name: true } };
};
};
};
}>[]
| null,
page: 1,
totalPages: 1,
total: 0,
loading: false,
search: "",
statusFilter: "",
async load(page = 1, limit = 10, search = "", statusFilter = "") {
ibuHamilState.findMany.loading = true;
ibuHamilState.findMany.page = page;
ibuHamilState.findMany.search = search;
ibuHamilState.findMany.statusFilter = statusFilter;
try {
const query = new URLSearchParams({ page: String(page), limit: String(limit) });
if (search) query.set("search", search);
if (statusFilter) query.set("status", statusFilter);
const res = await fetch(`/api/kesehatan/ibuhamil/find-many?${query}`);
const result = await res.json();
if (result.success) {
ibuHamilState.findMany.data = result.data ?? [];
ibuHamilState.findMany.totalPages = result.totalPages ?? 1;
ibuHamilState.findMany.total = result.total ?? 0;
} else {
ibuHamilState.findMany.data = [];
}
} catch (e) {
console.error("ibuHamilFindMany error:", e);
ibuHamilState.findMany.data = [];
} finally {
ibuHamilState.findMany.loading = false;
}
},
},
edit: {
id: "",
form: { ...defaultForm },
loading: false,
async load(id: string) {
try {
const res = await fetch(`/api/kesehatan/ibuhamil/${id}`);
const result = await res.json();
if (result.success) {
const d = result.data;
ibuHamilState.edit.id = d.id;
ibuHamilState.edit.form = {
nama: d.nama,
nik: d.nik ?? "",
usiaKehamilan: d.usiaKehamilan,
hpht: d.hpht ? d.hpht.slice(0, 10) : "",
taksiranLahir: d.taksiranLahir ? d.taksiranLahir.slice(0, 10) : "",
alamat: d.alamat ?? "",
noHp: d.noHp ?? "",
catatan: d.catatan ?? "",
posyanduId: d.posyanduId ?? "",
status: d.status,
};
return d;
}
toast.error("Gagal memuat data");
return null;
} catch (e) {
console.error(e);
toast.error("Gagal memuat data");
return null;
}
},
async update() {
const cek = formSchema.safeParse(ibuHamilState.edit.form);
if (!cek.success) {
const err = cek.error.issues.map((v) => v.message).join(", ");
toast.error(err);
return false;
}
try {
ibuHamilState.edit.loading = true;
const res = await fetch(`/api/kesehatan/ibuhamil/${ibuHamilState.edit.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(cek.data),
});
const result = await res.json();
if (result.success) {
toast.success("Ibu hamil berhasil diperbarui");
ibuHamilState.findMany.load();
return true;
}
toast.error(result.message || "Gagal memperbarui data");
return false;
} catch (e) {
console.error(e);
toast.error("Terjadi kesalahan");
return false;
} finally {
ibuHamilState.edit.loading = false;
}
},
reset() {
ibuHamilState.edit.id = "";
ibuHamilState.edit.form = { ...defaultForm };
},
},
delete: {
loading: false,
async byId(id: string) {
try {
ibuHamilState.delete.loading = true;
const res = await fetch(`/api/kesehatan/ibuhamil/del/${id}`, { method: "DELETE" });
const result = await res.json();
if (result.success) {
toast.success(result.message || "Data berhasil dihapus");
await ibuHamilState.findMany.load();
} else {
toast.error(result.message || "Gagal menghapus data");
}
} catch (e) {
console.error(e);
toast.error("Terjadi kesalahan saat menghapus");
} finally {
ibuHamilState.delete.loading = false;
}
},
},
});
export default ibuHamilState;

View File

@@ -19,6 +19,7 @@ const defaultForm = {
deskripsi: "",
imageId: "",
jadwalPelayanan: "",
banjarId: "",
};
const posyandustate = proxy({
@@ -57,6 +58,7 @@ const posyandustate = proxy({
| Prisma.PosyanduGetPayload<{
include: {
image: true;
banjar: { select: { id: true; name: true } };
};
}>[]
| null,
@@ -92,10 +94,11 @@ const posyandustate = proxy({
},
},
findUnique: {
data: null as
data: null as
| Prisma.PosyanduGetPayload<{
include: {
image: true;
banjar: { select: { id: true; name: true } };
}
}> | null,
async load(id: string) {
@@ -176,6 +179,7 @@ const posyandustate = proxy({
deskripsi: data.deskripsi,
imageId: data.imageId || "",
jadwalPelayanan: data.jadwalPelayanan || "",
banjarId: data.banjarId || "",
};
return data;
} else {
@@ -210,6 +214,7 @@ const posyandustate = proxy({
deskripsi: this.form.deskripsi,
imageId: this.form.imageId,
jadwalPelayanan: this.form.jadwalPelayanan,
banjarId: this.form.banjarId || undefined,
}),
});

View File

@@ -0,0 +1,109 @@
import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
type StatsData = {
ibuHamilAktif: number;
balitaTerdaftar: number;
alertStunting: number;
imunisasiLengkapPct: number;
pemeriksaanRutinPct: number;
giziBaikPct: number;
targetStuntingPct: number;
};
const intPct = z
.number({ invalid_type_error: "Harus berupa angka" })
.int({ message: "Harus bilangan bulat" })
.min(0, { message: "Minimal 0" })
.max(100, { message: "Maksimal 100" });
type BanjarOption = { id: string; name: string };
const ringkasanKesehatanState = proxy({
banjarId: "" as string,
findBanjar: {
data: [] as BanjarOption[],
loading: false,
async load() {
try {
ringkasanKesehatanState.findBanjar.loading = true;
const res = await fetch(`/api/kesehatan/banjar/find-many`);
if (res.ok) {
const result = await res.json();
ringkasanKesehatanState.findBanjar.data = result?.data ?? [];
}
} catch (error) {
console.error("Error fetching banjar:", error);
} finally {
ringkasanKesehatanState.findBanjar.loading = false;
}
},
},
findStats: {
data: null as StatsData | null,
loading: false,
async load() {
try {
ringkasanKesehatanState.findStats.loading = true;
const banjarId = ringkasanKesehatanState.banjarId;
const params = banjarId ? `?banjarId=${encodeURIComponent(banjarId)}` : "";
const res = await fetch(`/api/kesehatan/ringkasankesehatan/stats${params}`);
if (res.ok) {
const result = await res.json();
ringkasanKesehatanState.findStats.data = result?.data ?? null;
if (result?.data) {
ringkasanKesehatanState.update.form.targetStuntingPct =
result.data.targetStuntingPct;
}
} else {
ringkasanKesehatanState.findStats.data = null;
}
} catch (error) {
console.error("Error fetching ringkasan stats:", error);
ringkasanKesehatanState.findStats.data = null;
} finally {
ringkasanKesehatanState.findStats.loading = false;
}
},
},
update: {
form: { targetStuntingPct: 0 },
loading: false,
async submitTarget() {
const pct = ringkasanKesehatanState.update.form.targetStuntingPct;
const cek = intPct.safeParse(pct);
if (!cek.success) {
toast.error("Target stunting harus 0-100");
return false;
}
try {
ringkasanKesehatanState.update.loading = true;
const response = await fetch(`/api/kesehatan/ringkasankesehatan/update`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetStuntingPct: pct }),
});
const result = await response.json();
if (result.success) {
toast.success("Target stunting berhasil disimpan");
await ringkasanKesehatanState.findStats.load();
return true;
}
toast.error(result.message || "Gagal menyimpan");
return false;
} catch (error) {
console.error("Error saving target stunting:", error);
toast.error("Gagal menyimpan target stunting");
return false;
} finally {
ringkasanKesehatanState.update.loading = false;
}
},
},
});
export default ringkasanKesehatanState;

View File

@@ -0,0 +1,91 @@
import { toast } from "react-toastify";
import { proxy } from "valtio";
type StatsBeasiswa = {
jumlahPenerima: number;
danaTersalurkan: string;
tahunAjaran: string;
};
type BeasiswaConfig = {
id: string;
tahunAjaran: string;
danaTersalurkan: string;
};
const ringkasanBeasiswaState = proxy({
findStats: {
data: null as StatsBeasiswa | null,
loading: false,
async load() {
try {
ringkasanBeasiswaState.findStats.loading = true;
const res = await fetch(`/api/pendidikan/beasiswa/ringkasan/stats`);
if (res.ok) {
const result = await res.json();
ringkasanBeasiswaState.findStats.data = result?.data ?? null;
} else {
ringkasanBeasiswaState.findStats.data = null;
}
} catch (error) {
console.error("Error fetching ringkasan beasiswa:", error);
ringkasanBeasiswaState.findStats.data = null;
} finally {
ringkasanBeasiswaState.findStats.loading = false;
}
},
},
beasiswaConfig: {
data: null as BeasiswaConfig | null,
loading: false,
async find() {
try {
ringkasanBeasiswaState.beasiswaConfig.loading = true;
const res = await fetch(`/api/pendidikan/beasiswa/beasiswaconfig/find`);
if (res.ok) {
const result = await res.json();
ringkasanBeasiswaState.beasiswaConfig.data = result?.data ?? null;
} else {
ringkasanBeasiswaState.beasiswaConfig.data = null;
}
} catch (error) {
console.error("Error fetching beasiswa config:", error);
ringkasanBeasiswaState.beasiswaConfig.data = null;
} finally {
ringkasanBeasiswaState.beasiswaConfig.loading = false;
}
},
update: {
loading: false,
async submit(tahunAjaran: string, danaTersalurkan: string) {
try {
ringkasanBeasiswaState.beasiswaConfig.update.loading = true;
const res = await fetch(`/api/pendidikan/beasiswa/beasiswaconfig/update`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tahunAjaran, danaTersalurkan }),
});
const result = await res.json();
if (result.success) {
toast.success("Konfigurasi beasiswa berhasil disimpan");
await ringkasanBeasiswaState.beasiswaConfig.find();
await ringkasanBeasiswaState.findStats.load();
return true;
}
toast.error(result.message || "Gagal menyimpan konfigurasi");
return false;
} catch (error) {
console.error("Error updating beasiswa config:", error);
toast.error("Gagal menyimpan konfigurasi beasiswa");
return false;
} finally {
ringkasanBeasiswaState.beasiswaConfig.update.loading = false;
}
},
},
},
});
export default ringkasanBeasiswaState;

View File

@@ -0,0 +1,35 @@
import { proxy } from "valtio";
type PerJenjang = { nama: string; jumlahSiswa: number; jumlahPengajar: number };
type StatsPendidikan = {
perJenjang: PerJenjang[];
jumlahLembaga: number;
jumlahPengajar: number;
};
const ringkasanPendidikanState = proxy({
findStats: {
data: null as StatsPendidikan | null,
loading: false,
async load() {
try {
ringkasanPendidikanState.findStats.loading = true;
const res = await fetch(`/api/pendidikan/ringkasan/stats`);
if (res.ok) {
const result = await res.json();
ringkasanPendidikanState.findStats.data = result?.data ?? null;
} else {
ringkasanPendidikanState.findStats.data = null;
}
} catch (error) {
console.error("Error fetching ringkasan pendidikan:", error);
ringkasanPendidikanState.findStats.data = null;
} finally {
ringkasanPendidikanState.findStats.loading = false;
}
},
},
});
export default ringkasanPendidikanState;

View File

@@ -190,7 +190,7 @@ export default function Validasi() {
case 2:
return '/admin/landing-page/profil/program-inovasi';
case 3:
return '/admin/kesehatan/posyandu';
return '/admin/kesehatan/posyandu/list-posyandu';
case 4:
return '/admin/pendidikan/info-sekolah/jenjang-pendidikan';
default:

View File

@@ -0,0 +1,123 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import stateBanjar from '@/app/admin/(dashboard)/_state/desa/banjar';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
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 EditBanjar() {
const state = useProxy(stateBanjar);
const router = useRouter();
const params = useParams();
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalName, setOriginalName] = useState('');
const [formData, setFormData] = useState({ name: '' });
const isFormValid = () => formData.name?.trim() !== '';
useEffect(() => {
const id = params?.id as string;
if (!id) return;
state.update.load(id).then((data) => {
if (data) {
setFormData({ name: data.name });
setOriginalName(data.name);
} else {
toast.error('Gagal memuat data banjar');
}
});
}, [params?.id]);
const handleResetForm = () => {
setFormData({ name: originalName });
toast.info('Form dikembalikan ke data awal');
};
const handleSubmit = async () => {
if (!formData.name?.trim()) {
toast.error('Nama banjar wajib diisi');
return;
}
try {
setIsSubmitting(true);
state.update.form = { name: formData.name };
await state.update.update();
router.push('/admin/desa/banjar');
} catch (error) {
console.error('Error updating banjar:', error);
toast.error('Terjadi kesalahan saat memperbarui banjar');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 0, md: 'lg' }} py="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 Banjar</Title>
</Group>
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput
name="name"
label="Nama Banjar"
placeholder="Masukkan nama banjar"
value={formData.name}
onChange={(e) => setFormData({ name: e.target.value })}
required
/>
<Group justify="right">
<Button variant="outline" color="gray" radius="md" size="md" onClick={handleResetForm}>
Batal
</Button>
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background:
!isFormValid() || isSubmitting
? 'linear-gradient(135deg, #cccccc, #eeeeee)'
: `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>
</Box>
);
}
export default EditBanjar;

View File

@@ -0,0 +1,103 @@
'use client';
import stateBanjar from '@/app/admin/(dashboard)/_state/desa/banjar';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
Title,
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
function CreateBanjar() {
const state = useProxy(stateBanjar);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const isFormValid = () => state.create.form.name?.trim() !== '';
const resetForm = () => {
state.create.form = { name: '' };
};
const handleSubmit = async () => {
if (!state.create.form.name?.trim()) {
toast.error('Nama banjar wajib diisi');
return;
}
setIsSubmitting(true);
try {
await state.create.create();
resetForm();
router.push('/admin/desa/banjar');
} catch (error) {
console.error('Error creating banjar:', error);
toast.error('Gagal menambahkan banjar');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 0, md: 'lg' }} py="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">Tambah Banjar</Title>
</Group>
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput
label="Nama Banjar"
placeholder="Masukkan nama banjar"
value={state.create.form.name || ''}
onChange={(e) => (state.create.form.name = e.target.value)}
required
/>
<Group justify="right">
<Button variant="outline" color="gray" radius="md" size="md" onClick={resetForm}>
Reset
</Button>
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background:
!isFormValid() || isSubmitting
? 'linear-gradient(135deg, #cccccc, #eeeeee)'
: `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>
</Box>
);
}
export default CreateBanjar;

View File

@@ -0,0 +1,211 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import colors from '@/con/colors';
import {
Box,
Button,
Center,
Group,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title,
} from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../_com/header';
import { ModalKonfirmasiHapus } from '../../_com/modalKonfirmasiHapus';
import stateBanjar from '../../_state/desa/banjar';
function Banjar() {
const [search, setSearch] = useState('');
return (
<Box>
<HeaderSearch
title="Data Banjar"
placeholder="Cari nama banjar..."
searchIcon={<IconSearch size={20} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<ListBanjar search={search} />
</Box>
);
}
function ListBanjar({ search }: { search: string }) {
const state = useProxy(stateBanjar);
const router = useRouter();
const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [debouncedSearch] = useDebouncedValue(search, 1000);
const { data, loading, load, page, totalPages } = state.findMany;
useEffect(() => {
load(page, 10, debouncedSearch);
}, [page, debouncedSearch]);
const handleDelete = () => {
if (selectedId) {
state.delete.delete(selectedId);
setModalHapus(false);
setSelectedId(null);
}
};
if (loading || !data) {
return (
<Stack py={10}>
<Skeleton height={600} radius="md" />
</Stack>
);
}
return (
<Box py={{ base: 'sm', md: 'lg' }}>
<Paper withBorder bg={colors['white-1']} p={{ base: 'md', md: 'lg' }} shadow="md" radius="md">
<Group justify="space-between" mb={{ base: 'md', md: 'lg' }}>
<Title order={4} lh={1.2}>Daftar Banjar</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/desa/banjar/create')}
>
Tambah Baru
</Button>
</Group>
{/* Desktop Table */}
<Box visibleFrom="md" style={{ overflowX: 'auto' }}>
<Table highlightOnHover layout="fixed" withColumnBorders={false} miw={0}>
<TableThead>
<TableTr>
<TableTh w="60%">
<Text fz="sm" fw={600} lh={1.4}>Nama Banjar</Text>
</TableTh>
<TableTh w="20%">
<Text fz="sm" fw={600} lh={1.4} ta="center">Edit</Text>
</TableTh>
<TableTh w="20%">
<Text fz="sm" fw={600} lh={1.4} ta="center">Hapus</Text>
</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{data.length > 0 ? (
data.map((item) => (
<TableTr key={item.id}>
<TableTd>
<Text fz="sm" fw={500} lh={1.45} truncate="end">{item.name}</Text>
</TableTd>
<TableTd ta="center">
<Button
variant="light"
color="green"
size="compact-sm"
onClick={() => router.push(`/admin/desa/banjar/${item.id}`)}
>
<IconEdit size={16} />
</Button>
</TableTd>
<TableTd ta="center">
<Button
variant="light"
color="red"
size="compact-sm"
disabled={state.delete.loading}
onClick={() => { setSelectedId(item.id); setModalHapus(true); }}
>
<IconTrash size={16} />
</Button>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={3}>
<Center py={24}>
<Text c="dimmed" fz="sm">Tidak ada data banjar</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
{/* Mobile Cards */}
<Stack hiddenFrom="md" gap="xs" mt="md">
{data.length > 0 ? (
data.map((item) => (
<Paper key={item.id} withBorder radius="md" p="sm" bg="white">
<Box>
<Text fz="sm" fw={600} lh={1.4}>Nama Banjar</Text>
<Text fz="sm" fw={500} lh={1.45}>{item.name}</Text>
</Box>
<Group mt="sm" justify="flex-end" gap="xs">
<Button
variant="light"
color="green"
size="compact-xs"
onClick={() => router.push(`/admin/desa/banjar/${item.id}`)}
>
<IconEdit size={14} />
</Button>
<Button
variant="light"
color="red"
size="compact-xs"
disabled={state.delete.loading}
onClick={() => { setSelectedId(item.id); setModalHapus(true); }}
>
<IconTrash size={14} />
</Button>
</Group>
</Paper>
))
) : (
<Center py={32}>
<Text c="dimmed" fz="sm">Tidak ada data banjar</Text>
</Center>
)}
</Stack>
</Paper>
<Center mt={{ base: 'lg', md: 'xl' }}>
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10, search);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
color="blue"
radius="md"
/>
</Center>
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleDelete}
text="Apakah anda yakin ingin menghapus banjar ini? Banjar yang masih digunakan posyandu tidak bisa dihapus."
/>
</Box>
);
}
export default Banjar;

View File

@@ -0,0 +1,110 @@
'use client';
import eventBudayaState from '@/app/admin/(dashboard)/_state/desa/eventBudaya';
import colors from '@/con/colors';
import {
ActionIcon,
Box,
Button,
Group,
Paper,
Skeleton,
Stack,
Textarea,
TextInput,
Title,
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { useProxy } from 'valtio/utils';
function EditEventBudayaPage() {
const state = useProxy(eventBudayaState);
const router = useRouter();
const params = useParams();
const id = params.id as string;
useEffect(() => {
if (id) state.edit.load(id);
return () => state.edit.reset();
}, [id]);
const handleSave = async () => {
const ok = await state.edit.save();
if (ok) router.push('/admin/desa/event-budaya');
};
if (state.edit.loading && !state.edit.form.nama) {
return <Skeleton h={400} radius="md" />;
}
return (
<Box>
<Group mb="lg">
<ActionIcon
variant="light"
size="lg"
onClick={() => router.push('/admin/desa/event-budaya')}
>
<IconArrowBack size={18} />
</ActionIcon>
<Title order={4}>Edit Event Budaya</Title>
</Group>
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Stack gap="md">
<TextInput
label="Nama Event"
placeholder="Contoh: Festival Budaya Desa"
required
value={state.edit.form.nama}
onChange={(e) => (state.edit.form.nama = e.currentTarget.value)}
/>
<TextInput
label="Tanggal"
type="date"
required
value={state.edit.form.tanggal}
onChange={(e) =>
(state.edit.form.tanggal = e.currentTarget.value)
}
/>
<TextInput
label="Lokasi"
placeholder="Contoh: Balai Desa Darmasaba"
required
value={state.edit.form.lokasi}
onChange={(e) => (state.edit.form.lokasi = e.currentTarget.value)}
/>
<Textarea
label="Deskripsi"
placeholder="Deskripsi singkat event (opsional)"
rows={4}
value={state.edit.form.deskripsi}
onChange={(e) =>
(state.edit.form.deskripsi = e.currentTarget.value)
}
/>
<Group justify="flex-end" mt="md">
<Button
variant="default"
onClick={() => router.push('/admin/desa/event-budaya')}
>
Batal
</Button>
<Button
color="blue"
loading={state.edit.loading}
onClick={handleSave}
>
Update
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}
export default EditEventBudayaPage;

View File

@@ -0,0 +1,141 @@
'use client';
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import eventBudayaState from '@/app/admin/(dashboard)/_state/desa/eventBudaya';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Paper,
Skeleton,
Stack,
Text,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconCalendarEvent, IconEdit, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
export default function DetailEventBudaya() {
const state = useProxy(eventBudayaState);
const [modalHapus, setModalHapus] = useState(false);
const params = useParams();
const router = useRouter();
const id = params.id as string;
useShallowEffect(() => {
state.findUnique.load(id);
}, [id]);
const handleHapus = async () => {
await state.delete.byId(id);
setModalHapus(false);
router.push('/admin/desa/event-budaya');
};
if (state.findUnique.loading || !state.findUnique.data) {
return (
<Stack py={10}>
<Skeleton height={400} radius="md" />
</Stack>
);
}
const data = state.findUnique.data;
return (
<Box px={{ base: 0, md: 'xs' }} py="xs">
<Button
variant="subtle"
onClick={() => router.push('/admin/desa/event-budaya')}
leftSection={<IconArrowBack size={20} color={colors['blue-button']} />}
mb={15}
>
Kembali
</Button>
<Paper
withBorder
w={{ base: '100%', md: '70%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
>
<Stack gap="md">
<Group gap="sm">
<IconCalendarEvent size={22} color={colors['blue-button']} />
<Text fz="xl" fw="bold" c={colors['blue-button']}>
Detail Event Budaya
</Text>
</Group>
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
<Stack gap="sm">
<Box>
<Text fz="sm" fw={600} c="dimmed">Nama Event</Text>
<Text fz="md" fw={500}>{data.nama || '-'}</Text>
</Box>
<Box>
<Text fz="sm" fw={600} c="dimmed">Tanggal</Text>
<Text fz="md">
{new Date(data.tanggal).toLocaleDateString('id-ID', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</Text>
</Box>
<Box>
<Text fz="sm" fw={600} c="dimmed">Lokasi</Text>
<Text fz="md">{data.lokasi || '-'}</Text>
</Box>
{data.deskripsi && (
<Box>
<Text fz="sm" fw={600} c="dimmed">Deskripsi</Text>
<Text fz="md" style={{ wordBreak: 'break-word', whiteSpace: 'pre-wrap' }}>
{data.deskripsi}
</Text>
</Box>
)}
<Group gap="sm" mt="xs">
<Button
color="red"
variant="light"
radius="md"
leftSection={<IconTrash size={16} />}
loading={state.delete.loading}
onClick={() => setModalHapus(true)}
>
Hapus
</Button>
<Button
color="blue"
variant="light"
radius="md"
leftSection={<IconEdit size={16} />}
onClick={() => router.push(`/admin/desa/event-budaya/${id}/edit`)}
>
Edit
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Paper>
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleHapus}
text="Apakah anda yakin ingin menghapus event budaya ini?"
/>
</Box>
);
}

View File

@@ -0,0 +1,95 @@
'use client';
import eventBudayaState from '@/app/admin/(dashboard)/_state/desa/eventBudaya';
import colors from '@/con/colors';
import {
ActionIcon,
Box,
Button,
Group,
Paper,
Stack,
Textarea,
TextInput,
Title,
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useProxy } from 'valtio/utils';
function CreateEventBudayaPage() {
const state = useProxy(eventBudayaState);
const router = useRouter();
const handleSubmit = async () => {
const ok = await state.create.create();
if (ok) router.push('/admin/desa/event-budaya');
};
return (
<Box>
<Group mb="lg">
<ActionIcon
variant="light"
size="lg"
onClick={() => router.push('/admin/desa/event-budaya')}
>
<IconArrowBack size={18} />
</ActionIcon>
<Title order={4}>Tambah Event Budaya</Title>
</Group>
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Stack gap="md">
<TextInput
label="Nama Event"
placeholder="Contoh: Festival Budaya Desa"
required
value={state.create.form.nama}
onChange={(e) => (state.create.form.nama = e.currentTarget.value)}
/>
<TextInput
label="Tanggal"
type="date"
required
value={state.create.form.tanggal}
onChange={(e) => (state.create.form.tanggal = e.currentTarget.value)}
/>
<TextInput
label="Lokasi"
placeholder="Contoh: Balai Desa Darmasaba"
required
value={state.create.form.lokasi}
onChange={(e) => (state.create.form.lokasi = e.currentTarget.value)}
/>
<Textarea
label="Deskripsi"
placeholder="Deskripsi singkat event (opsional)"
rows={4}
value={state.create.form.deskripsi}
onChange={(e) =>
(state.create.form.deskripsi = e.currentTarget.value)
}
/>
<Group justify="flex-end" mt="md">
<Button
variant="default"
onClick={() => router.push('/admin/desa/event-budaya')}
>
Batal
</Button>
<Button
color="blue"
loading={state.create.loading}
onClick={handleSubmit}
>
Simpan
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}
export default CreateEventBudayaPage;

View File

@@ -0,0 +1,16 @@
'use client';
import { Stack, Title } from '@mantine/core';
import React from 'react';
function Layout({ children }: { children: React.ReactNode }) {
return (
<Stack gap="lg">
<Title order={3} fw={700} style={{ color: '#1A1B1E' }}>
Kalender Event Budaya
</Title>
{children}
</Stack>
);
}
export default Layout;

View File

@@ -0,0 +1,199 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import eventBudayaState from '@/app/admin/(dashboard)/_state/desa/eventBudaya';
import colors from '@/con/colors';
import {
ActionIcon,
Badge,
Box,
Button,
Group,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title,
} from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { IconCalendarEvent, IconEdit, IconEye, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../_com/header';
function EventBudayaPage() {
const [search, setSearch] = useState('');
return (
<Box>
<HeaderSearch
title="Event Budaya"
placeholder="Cari nama atau lokasi..."
searchIcon={<IconSearch size={20} />}
value={search}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.currentTarget.value)}
/>
<ListEventBudaya search={search} />
</Box>
);
}
function ListEventBudaya({ search }: { search: string }) {
const state = useProxy(eventBudayaState);
const router = useRouter();
const [debouncedSearch] = useDebouncedValue(search, 500);
const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const { data, page, totalPages, loading, load } = state.findMany;
useEffect(() => {
load(page, 10, debouncedSearch);
}, [page, debouncedSearch]);
const handleHapus = async () => {
if (selectedId) {
await state.delete.byId(selectedId);
setModalHapus(false);
setSelectedId(null);
}
};
if (loading || !data) {
return (
<Stack py="md">
<Skeleton h={400} radius="md" />
</Stack>
);
}
return (
<Box py="md">
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Group justify="space-between" mb="lg">
<Title order={4}>List Event Budaya</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/desa/event-budaya/create')}
>
Tambah Event
</Button>
</Group>
<Box style={{ overflowX: 'auto' }}>
<Table highlightOnHover layout="fixed" withColumnBorders={false}>
<TableThead>
<TableTr>
<TableTh w="35%">Nama Event</TableTh>
<TableTh w="20%">Tanggal</TableTh>
<TableTh w="25%">Lokasi</TableTh>
<TableTh w="20%">Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{data.length > 0 ? (
data.map((item) => (
<TableTr
key={item.id}
style={{ cursor: 'pointer' }}
onClick={() => router.push(`/admin/desa/event-budaya/${item.id}`)}
>
<TableTd>
<Group gap="xs">
<IconCalendarEvent size={16} color={colors['blue-button-5']} />
<Text fz="sm" fw={500} truncate="end" lineClamp={1}>
{item.nama}
</Text>
</Group>
</TableTd>
<TableTd>
<Badge variant="light" color="indigo">
{new Date(item.tanggal).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</Badge>
</TableTd>
<TableTd>
<Text fz="sm" c="dimmed" truncate="end" lineClamp={1}>
{item.lokasi}
</Text>
</TableTd>
<TableTd onClick={(e) => e.stopPropagation()}>
<Group gap="xs">
<ActionIcon
variant="light"
color="teal"
onClick={() => router.push(`/admin/desa/event-budaya/${item.id}`)}
>
<IconEye size={16} />
</ActionIcon>
<ActionIcon
variant="light"
color="blue"
onClick={() => router.push(`/admin/desa/event-budaya/${item.id}/edit`)}
>
<IconEdit size={16} />
</ActionIcon>
<ActionIcon
variant="light"
color="red"
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={4}>
<Text ta="center" c="dimmed" py="xl">
Belum ada data event budaya
</Text>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
{totalPages > 1 && (
<Group justify="center" mt="lg">
<Pagination
total={totalPages}
value={page}
onChange={(p) => load(p, 10, search)}
/>
</Group>
)}
</Paper>
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => {
setModalHapus(false);
setSelectedId(null);
}}
onConfirm={handleHapus}
text="Apakah anda yakin ingin menghapus event budaya ini?"
/>
</Box>
);
}
export default EventBudayaPage;

View File

@@ -0,0 +1,106 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import colors from '@/con/colors';
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core';
import { IconCalendarEvent, IconCategory } from '@tabler/icons-react';
import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react';
function LayoutTabsKegiatanDesa({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const tabs = [
{
label: "List Kegiatan",
value: "list_kegiatan",
href: "/admin/desa/kegiatan-desa/list-kegiatan-desa",
icon: <IconCalendarEvent size={18} stroke={1.8} />
},
{
label: "Kategori Kegiatan",
value: "kategori_kegiatan",
href: "/admin/desa/kegiatan-desa/kategori-kegiatan-desa",
icon: <IconCategory size={18} stroke={1.8} />
},
];
const currentTab = tabs.find(tab => tab.href === pathname);
const [activeTab, setActiveTab] = useState<string | null>(currentTab?.value || tabs[0].value);
const handleTabChange = (value: string | null) => {
const tab = tabs.find(t => t.value === value);
if (tab) {
router.push(tab.href);
}
setActiveTab(value);
};
useEffect(() => {
const match = tabs.find(tab => tab.href === pathname);
if (match) {
setActiveTab(match.value);
}
}, [pathname]);
return (
<Stack gap="lg">
<Title order={3} fw={700} style={{ color: "#1A1B1E" }}>Kegiatan Desa</Title>
<Tabs
color={colors["blue-button"]}
variant="pills"
value={activeTab}
onChange={handleTabChange}
radius="lg"
keepMounted={false}
>
<ScrollArea type="auto" offsetScrollbars>
<TabsList
p="sm"
style={{
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem",
boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)",
display: "flex",
flexWrap: "nowrap",
gap: "0.5rem",
paddingInline: "0.5rem",
}}
>
{tabs.map((tab, i) => (
<TabsTab
key={i}
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
transition: "all 0.2s ease",
}}
>
{tab.label}
</TabsTab>
))}
</TabsList>
</ScrollArea>
{tabs.map((tab, i) => (
<TabsPanel
key={i}
value={tab.value}
style={{
padding: "1.5rem",
background: "linear-gradient(180deg, #ffffff, #f5f6fa)",
borderRadius: "1rem",
boxShadow: "0 4px 16px rgba(0,0,0,0.05)",
}}
>
<>{children}</>
</TabsPanel>
))}
</Tabs>
</Stack>
);
}
export default LayoutTabsKegiatanDesa;

View File

@@ -0,0 +1,137 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import stateDashboardKegiatan from '@/app/admin/(dashboard)/_state/desa/kegiatanDesa';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
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 EditKategoriKegiatan() {
const editState = useProxy(stateDashboardKegiatan.kategoriKegiatan);
const router = useRouter();
const params = useParams();
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState({ nama: '' });
const [originalData, setOriginalData] = useState({ nama: '' });
const isFormValid = () => formData.nama?.trim() !== '';
useEffect(() => {
const loadKategori = async () => {
const id = params?.id as string;
if (!id) return;
try {
await stateDashboardKegiatan.kategoriKegiatan.update.load(id);
const nama = stateDashboardKegiatan.kategoriKegiatan.update.form.nama || '';
setFormData({ nama });
setOriginalData({ nama });
} catch (error) {
console.error('Error loading kategori kegiatan:', error);
toast.error('Gagal memuat data kategori kegiatan');
}
};
loadKategori();
}, [params?.id]);
const handleResetForm = () => {
setFormData({ nama: originalData.nama });
toast.info('Form dikembalikan ke data awal');
};
const handleSubmit = async () => {
if (!formData.nama?.trim()) {
toast.error('Nama kategori kegiatan wajib diisi');
return;
}
try {
setIsSubmitting(true);
editState.update.form = {
...editState.update.form,
nama: formData.nama,
};
const success = await editState.update.update();
if (success) {
router.push('/admin/desa/kegiatan-desa/kategori-kegiatan-desa');
}
} catch (error) {
console.error('Error updating kategori kegiatan:', error);
toast.error('Terjadi kesalahan saat memperbarui kategori kegiatan');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 0, md: 'lg' }} py="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 Kategori Kegiatan
</Title>
</Group>
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput
label="Nama Kategori Kegiatan"
placeholder="Masukkan nama kategori kegiatan"
value={formData.nama}
onChange={(e) => setFormData((prev) => ({ ...prev, nama: e.target.value }))}
required
/>
<Group justify="right">
<Button variant="outline" color="gray" radius="md" size="md" onClick={handleResetForm}>
Batal
</Button>
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background:
!isFormValid() || isSubmitting
? 'linear-gradient(135deg, #cccccc, #eeeeee)'
: `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>
</Box>
);
}
export default EditKategoriKegiatan;

View File

@@ -0,0 +1,107 @@
'use client'
import stateDashboardKegiatan from '@/app/admin/(dashboard)/_state/desa/kegiatanDesa';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
Title,
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
function CreateKategoriKegiatan() {
const createState = useProxy(stateDashboardKegiatan.kategoriKegiatan);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const isFormValid = () => createState.create.form.nama?.trim() !== '';
const resetForm = () => {
createState.create.resetForm();
};
const handleSubmit = async () => {
if (!createState.create.form.nama?.trim()) {
toast.error('Nama kategori kegiatan wajib diisi');
return;
}
setIsSubmitting(true);
try {
const success = await createState.create.create();
if (success) {
resetForm();
router.push('/admin/desa/kegiatan-desa/kategori-kegiatan-desa');
}
} catch (error) {
console.error('Error creating kategori kegiatan:', error);
toast.error('Gagal menambahkan kategori kegiatan');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 0, md: 'lg' }} py="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">
Tambah Kategori Kegiatan
</Title>
</Group>
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput
label="Nama Kategori Kegiatan"
placeholder="Masukkan nama kategori kegiatan"
value={createState.create.form.nama || ''}
onChange={(e) => (createState.create.form.nama = e.target.value)}
required
/>
<Group justify="right">
<Button variant="outline" color="gray" radius="md" size="md" onClick={resetForm}>
Reset
</Button>
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background:
!isFormValid() || isSubmitting
? 'linear-gradient(135deg, #cccccc, #eeeeee)'
: `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>
</Box>
);
}
export default CreateKategoriKegiatan;

View File

@@ -0,0 +1,239 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import colors from '@/con/colors';
import {
Box,
Button,
Center,
Group,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title
} from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header';
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
import stateDashboardKegiatan from '../../../_state/desa/kegiatanDesa';
function KategoriKegiatanDesa() {
const [search, setSearch] = useState('');
return (
<Box>
<HeaderSearch
title="Kategori Kegiatan"
placeholder="Cari nama kategori kegiatan..."
searchIcon={<IconSearch size={20} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<ListKategoriKegiatan search={search} />
</Box>
);
}
function ListKategoriKegiatan({ search }: { search: string }) {
const listDataState = useProxy(stateDashboardKegiatan.kategoriKegiatan);
const router = useRouter();
const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [debouncedSearch] = useDebouncedValue(search, 1000);
const { data, loading, load, page, totalPages } = listDataState.findMany;
useEffect(() => {
load(page, 10, debouncedSearch);
}, [page, debouncedSearch]);
const handleDelete = () => {
if (selectedId) {
listDataState.delete.byId(selectedId);
setModalHapus(false);
setSelectedId(null);
}
};
const filteredData = data || [];
if (loading || !data) {
return (
<Stack py={10}>
<Skeleton height={600} radius="md" />
</Stack>
);
}
return (
<Box py={{ base: 'sm', md: 'lg' }}>
<Paper withBorder bg={colors['white-1']} p={{ base: 'md', md: 'lg' }} shadow="md" radius="md">
<Group justify="space-between" mb={{ base: 'md', md: 'lg' }}>
<Title order={4} lh={1.2}>
Daftar Kategori Kegiatan
</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() =>
router.push('/admin/desa/kegiatan-desa/kategori-kegiatan-desa/create')
}
>
Tambah Baru
</Button>
</Group>
{/* Desktop Table */}
<Box visibleFrom="md" style={{ overflowX: 'auto' }}>
<Table highlightOnHover layout="fixed" withColumnBorders={false} miw={0}>
<TableThead>
<TableTr>
<TableTh w="60%">
<Text fz="sm" fw={600} lh={1.4}>Kategori</Text>
</TableTh>
<TableTh w="20%">
<Text fz="sm" fw={600} lh={1.4} ta="center">Edit</Text>
</TableTh>
<TableTh w="20%">
<Text fz="sm" fw={600} lh={1.4} ta="center">Hapus</Text>
</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd>
<Text fz="sm" fw={500} lh={1.45} truncate="end">
{item.nama}
</Text>
</TableTd>
<TableTd ta="center">
<Button
variant="light"
color="green"
onClick={() =>
router.push(
`/admin/desa/kegiatan-desa/kategori-kegiatan-desa/${item.id}`
)
}
size="compact-sm"
>
<IconEdit size={16} />
</Button>
</TableTd>
<TableTd ta="center">
<Button
variant="light"
color="red"
disabled={listDataState.delete.loading}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
size="compact-sm"
>
<IconTrash size={16} />
</Button>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={3}>
<Center py={24}>
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data kategori kegiatan yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
{/* Mobile Cards */}
<Stack hiddenFrom="md" gap="xs" mt="md">
{filteredData.length > 0 ? (
filteredData.map((item) => (
<Paper key={item.id} withBorder radius="md" p="sm" bg="white">
<Box flex={1} ml="md">
<Text fz="sm" fw={600} lh={1.4}>Kategori</Text>
<Text fz="sm" fw={500} lh={1.45} truncate>
{item.nama}
</Text>
</Box>
<Group mt="sm" justify="flex-end" gap="xs">
<Button
variant="light"
color="green"
size="compact-xs"
onClick={() =>
router.push(
`/admin/desa/kegiatan-desa/kategori-kegiatan-desa/${item.id}`
)
}
>
<IconEdit size={14} />
</Button>
<Button
variant="light"
color="red"
size="compact-xs"
disabled={listDataState.delete.loading}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
>
<IconTrash size={14} />
</Button>
</Group>
</Paper>
))
) : (
<Center py={32}>
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data kategori kegiatan yang cocok
</Text>
</Center>
)}
</Stack>
</Paper>
<Center mt={{ base: 'lg', md: 'xl' }}>
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10, search);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
color="blue"
radius="md"
/>
</Center>
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleDelete}
text="Apakah anda yakin ingin menghapus kategori kegiatan ini?"
/>
</Box>
);
}
export default KategoriKegiatanDesa;

View File

@@ -0,0 +1,28 @@
'use client'
import React from 'react';
import LayoutTabsKegiatanDesa from './_com/layoutTabs';
import { usePathname } from 'next/navigation';
import { Box } from '@mantine/core';
function Layout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const segments = pathname.split('/').filter(Boolean);
const isDetailPage = segments.length >= 5;
if (isDetailPage) {
return (
<Box>
{children}
</Box>
);
}
return (
<LayoutTabsKegiatanDesa>
{children}
</LayoutTabsKegiatanDesa>
);
}
export default Layout;

View File

@@ -0,0 +1,335 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
import stateDashboardKegiatan from '@/app/admin/(dashboard)/_state/desa/kegiatanDesa';
import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch';
import {
ActionIcon,
Box,
Button,
Group,
Image,
Loader,
NumberInput,
Paper,
Select,
Stack,
Text,
Textarea,
TextInput,
Title,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } 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';
const isHtmlEmpty = (html: string) => html.replace(/<[^>]*>/g, '').trim() === '';
interface KegiatanData {
id: string;
judul: string;
deskripsiSingkat: string;
deskripsiLengkap: string;
tanggal: string;
lokasi: string;
partisipan: number;
kategoriKegiatanId: string | null;
imageId: string | null;
image?: { link: string } | null;
}
function EditKegiatanDesa() {
const kegiatanState = useProxy(stateDashboardKegiatan);
const router = useRouter();
const params = useParams();
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const emptyForm = {
judul: '',
deskripsiSingkat: '',
deskripsiLengkap: '',
tanggal: '',
lokasi: '',
partisipan: 0,
kategoriKegiatanId: '',
imageId: '',
};
const [formData, setFormData] = useState(emptyForm);
const [originalData, setOriginalData] = useState({ ...emptyForm, imageUrl: '' });
const isFormValid = () =>
formData.judul.trim() !== '' &&
formData.deskripsiSingkat.trim() !== '' &&
!isHtmlEmpty(formData.deskripsiLengkap) &&
formData.tanggal !== '' &&
formData.lokasi.trim() !== '' &&
formData.kategoriKegiatanId !== '';
useEffect(() => {
kegiatanState.kategoriKegiatan.findMany.load();
const load = async () => {
const id = params?.id as string;
if (!id) return;
try {
const data = await stateDashboardKegiatan.kegiatan.edit.load(id) as KegiatanData | null;
if (data) {
const tanggal = data.tanggal
? new Date(data.tanggal).toISOString().split('T')[0]
: '';
const form = {
judul: data.judul || '',
deskripsiSingkat: data.deskripsiSingkat || '',
deskripsiLengkap: data.deskripsiLengkap || '',
tanggal,
lokasi: data.lokasi || '',
partisipan: data.partisipan || 0,
kategoriKegiatanId: data.kategoriKegiatanId || '',
imageId: data.imageId || '',
};
setFormData(form);
setOriginalData({ ...form, imageUrl: data.image?.link || '' });
if (data.image?.link) setPreviewImage(data.image.link);
}
} catch (error) {
console.error('Error loading kegiatan:', error);
toast.error('Gagal memuat data kegiatan');
}
};
load();
}, [params?.id]);
const handleChange = (field: string, value: string | number) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleSubmit = async () => {
if (!formData.judul.trim()) return toast.error('Judul wajib diisi');
if (!formData.deskripsiSingkat.trim()) return toast.error('Deskripsi singkat wajib diisi');
if (isHtmlEmpty(formData.deskripsiLengkap)) return toast.error('Deskripsi lengkap wajib diisi');
if (!formData.tanggal) return toast.error('Tanggal wajib diisi');
if (!formData.lokasi.trim()) return toast.error('Lokasi wajib diisi');
if (!formData.kategoriKegiatanId) return toast.error('Kategori wajib dipilih');
try {
setIsSubmitting(true);
kegiatanState.kegiatan.edit.form = {
...kegiatanState.kegiatan.edit.form,
...formData,
};
if (file) {
const res = await ApiFetch.api.fileStorage.create.post({ file, name: file.name });
const uploaded = res.data?.data;
if (!uploaded?.id) return toast.error('Gagal upload gambar');
kegiatanState.kegiatan.edit.form.imageId = uploaded.id;
}
const success = await kegiatanState.kegiatan.edit.update();
if (success) {
router.push('/admin/desa/kegiatan-desa/list-kegiatan-desa');
}
} catch (error) {
console.error('Error updating kegiatan:', error);
toast.error('Terjadi kesalahan saat memperbarui kegiatan');
} finally {
setIsSubmitting(false);
}
};
const handleReset = () => {
setFormData({
judul: originalData.judul,
deskripsiSingkat: originalData.deskripsiSingkat,
deskripsiLengkap: originalData.deskripsiLengkap,
tanggal: originalData.tanggal,
lokasi: originalData.lokasi,
partisipan: originalData.partisipan,
kategoriKegiatanId: originalData.kategoriKegiatanId,
imageId: originalData.imageId,
});
setPreviewImage(originalData.imageUrl || null);
setFile(null);
toast.info('Form dikembalikan ke data awal');
};
return (
<Box px={{ base: 0, md: 'lg' }} py="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 Kegiatan Desa</Title>
</Group>
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput
label="Judul"
placeholder="Masukkan judul kegiatan"
value={formData.judul}
onChange={(e) => handleChange('judul', e.target.value)}
required
/>
<Select
label="Kategori Kegiatan"
placeholder="Pilih kategori"
data={kegiatanState.kategoriKegiatan.findMany.data.map((item) => ({
label: item.nama,
value: item.id,
}))}
value={formData.kategoriKegiatanId || null}
onChange={(val) => handleChange('kategoriKegiatanId', val || '')}
searchable
clearable
nothingFoundMessage="Tidak ditemukan"
required
/>
<TextInput
label="Tanggal"
type="date"
value={formData.tanggal}
onChange={(e) => handleChange('tanggal', e.target.value)}
required
/>
<TextInput
label="Lokasi"
placeholder="Masukkan lokasi kegiatan"
value={formData.lokasi}
onChange={(e) => handleChange('lokasi', e.target.value)}
required
/>
<NumberInput
label="Jumlah Partisipan"
placeholder="Masukkan jumlah partisipan"
value={formData.partisipan}
onChange={(val) => handleChange('partisipan', Number(val) || 0)}
min={0}
/>
<Textarea
label="Deskripsi Singkat"
placeholder="Masukkan deskripsi singkat kegiatan"
value={formData.deskripsiSingkat}
onChange={(e) => handleChange('deskripsiSingkat', e.target.value)}
minRows={3}
autosize
required
/>
<Box>
<Text fz="sm" fw="bold" mb={6}>Deskripsi Lengkap</Text>
<EditEditor
value={formData.deskripsiLengkap}
onChange={(html) => setFormData((prev) => ({ ...prev, deskripsiLengkap: html }))}
/>
</Box>
<Box>
<Text fw="bold" fz="sm" mb={6}>Gambar (Opsional)</Text>
<Dropzone
onDrop={(files) => {
const f = files[0];
if (f) { setFile(f); setPreviewImage(URL.createObjectURL(f)); }
}}
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={160}>
<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>
</Group>
</Dropzone>
{previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview"
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>
<Group justify="right">
<Button variant="outline" color="gray" radius="md" size="md" onClick={handleReset}>
Batal
</Button>
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background:
!isFormValid() || isSubmitting
? 'linear-gradient(135deg, #cccccc, #eeeeee)'
: `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>
</Box>
);
}
export default EditKegiatanDesa;

View File

@@ -0,0 +1,189 @@
'use client'
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import stateDashboardKegiatan from '@/app/admin/(dashboard)/_state/desa/kegiatanDesa';
import colors from '@/con/colors';
import { Box, Button, Group, Image, Paper, Skeleton, Stack, Text } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
interface KegiatanDetail {
id: string;
judul: string;
deskripsiSingkat: string;
deskripsiLengkap: string;
tanggal: string;
lokasi: string;
partisipan: number;
kategoriKegiatan?: { nama: string } | null;
image?: { link: string } | null;
}
function DetailKegiatanDesa() {
const kegiatanState = useProxy(stateDashboardKegiatan);
const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const params = useParams();
const router = useRouter();
useShallowEffect(() => {
kegiatanState.kegiatan.findUnique.load(params?.id as string);
}, []);
const handleHapus = () => {
if (selectedId) {
kegiatanState.kegiatan.delete.byId(selectedId);
setModalHapus(false);
setSelectedId(null);
router.push('/admin/desa/kegiatan-desa/list-kegiatan-desa');
}
};
if (!kegiatanState.kegiatan.findUnique.data) {
return (
<Stack py={10}>
<Skeleton height={500} radius="md" />
</Stack>
);
}
const data = kegiatanState.kegiatan.findUnique.data as unknown as KegiatanDetail;
const formatTanggal = (val: string) => {
if (!val) return '-';
return new Date(val).toLocaleDateString('id-ID', {
day: '2-digit',
month: 'long',
year: 'numeric',
});
};
return (
<Box px={{ base: 0, md: 'xs' }} py="xs">
<Button
variant="subtle"
onClick={() => router.back()}
leftSection={<IconArrowBack size={24} color={colors['blue-button']} />}
mb={15}
>
Kembali
</Button>
<Paper
withBorder
w={{ base: '100%', md: '70%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
>
<Stack gap="md">
<Text fz="2xl" fw="bold" c={colors['blue-button']}>
Detail Kegiatan Desa
</Text>
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
<Stack gap="sm">
<Box>
<Text fz="lg" fw="bold">Kategori</Text>
<Text fz="md" c="dimmed">{data.kategoriKegiatan?.nama || '-'}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Judul</Text>
<Text fz="md" c="dimmed">{data.judul || '-'}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Tanggal</Text>
<Text fz="md" c="dimmed">{formatTanggal(data.tanggal)}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Lokasi</Text>
<Text fz="md" c="dimmed">{data.lokasi || '-'}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Partisipan</Text>
<Text fz="md" c="dimmed">{data.partisipan ?? 0} orang</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Deskripsi Singkat</Text>
<Text fz="md" c="dimmed" style={{ whiteSpace: 'pre-wrap' }}>
{data.deskripsiSingkat || '-'}
</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Deskripsi Lengkap</Text>
<Paper bg="white" p="md" radius="md" mt="xs">
<Text
fz="md"
c="dimmed"
dangerouslySetInnerHTML={{ __html: data.deskripsiLengkap || '-' }}
/>
</Paper>
</Box>
{data.image?.link && (
<Box>
<Text fz="lg" fw="bold">Gambar</Text>
<Image
src={data.image.link}
alt={data.judul || 'Gambar Kegiatan'}
w={{ base: '100%', md: 400 }}
h={300}
radius="md"
fit="cover"
loading="lazy"
mt="xs"
/>
</Box>
)}
<Group gap="sm" mt="md">
<Button
color="red"
onClick={() => { setSelectedId(data.id); setModalHapus(true); }}
variant="light"
radius="md"
size="md"
leftSection={<IconTrash size={20} />}
>
Hapus
</Button>
<Button
color="green"
onClick={() =>
router.push(
`/admin/desa/kegiatan-desa/list-kegiatan-desa/${data.id}/edit`
)
}
variant="light"
radius="md"
size="md"
leftSection={<IconEdit size={20} />}
>
Edit
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Paper>
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleHapus}
text="Apakah Anda yakin ingin menghapus kegiatan desa ini?"
/>
</Box>
);
}
export default DetailKegiatanDesa;

View File

@@ -0,0 +1,258 @@
'use client'
import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor';
import stateDashboardKegiatan from '@/app/admin/(dashboard)/_state/desa/kegiatanDesa';
import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch';
import {
ActionIcon,
Box,
Button,
Group,
Image,
Loader,
NumberInput,
Paper,
Select,
Stack,
Text,
Textarea,
TextInput,
Title,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
const isHtmlEmpty = (html: string) => html.replace(/<[^>]*>/g, '').trim() === '';
export default function CreateKegiatanDesa() {
const kegiatanState = useProxy(stateDashboardKegiatan);
const router = useRouter();
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
useShallowEffect(() => {
kegiatanState.kategoriKegiatan.findMany.load();
}, []);
const isFormValid = () => {
const f = kegiatanState.kegiatan.create.form;
return (
f.judul.trim() !== '' &&
f.deskripsiSingkat.trim() !== '' &&
!isHtmlEmpty(f.deskripsiLengkap) &&
f.tanggal !== '' &&
f.lokasi.trim() !== '' &&
f.kategoriKegiatanId !== ''
);
};
const resetForm = () => {
kegiatanState.kegiatan.create.resetForm();
setPreviewImage(null);
setFile(null);
};
const handleSubmit = async () => {
const f = kegiatanState.kegiatan.create.form;
if (!f.judul.trim()) return toast.error('Judul wajib diisi');
if (!f.deskripsiSingkat.trim()) return toast.error('Deskripsi singkat wajib diisi');
if (isHtmlEmpty(f.deskripsiLengkap)) return toast.error('Deskripsi lengkap wajib diisi');
if (!f.tanggal) return toast.error('Tanggal wajib diisi');
if (!f.lokasi.trim()) return toast.error('Lokasi wajib diisi');
if (!f.kategoriKegiatanId) return toast.error('Kategori wajib dipilih');
try {
setIsSubmitting(true);
if (file) {
const res = await ApiFetch.api.fileStorage.create.post({ file, name: file.name });
const uploaded = res.data?.data;
if (!uploaded?.id) return toast.error('Gagal mengunggah gambar');
kegiatanState.kegiatan.create.form.imageId = uploaded.id;
}
const success = await kegiatanState.kegiatan.create.create();
if (success) {
resetForm();
router.push('/admin/desa/kegiatan-desa/list-kegiatan-desa');
}
} catch (error) {
console.error('Error creating kegiatan:', error);
toast.error('Terjadi kesalahan saat membuat kegiatan');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 0, md: 'lg' }} py="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">Tambah Kegiatan Desa</Title>
</Group>
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput
label="Judul"
placeholder="Masukkan judul kegiatan"
value={kegiatanState.kegiatan.create.form.judul}
onChange={(e) => (kegiatanState.kegiatan.create.form.judul = e.target.value)}
required
/>
<Select
label="Kategori Kegiatan"
placeholder="Pilih kategori"
data={kegiatanState.kategoriKegiatan.findMany.data.map((item) => ({
label: item.nama,
value: item.id,
}))}
value={kegiatanState.kegiatan.create.form.kategoriKegiatanId || null}
onChange={(val) => {
kegiatanState.kegiatan.create.form.kategoriKegiatanId = val || '';
}}
searchable
clearable
nothingFoundMessage="Tidak ditemukan"
required
/>
<TextInput
label="Tanggal"
type="date"
value={kegiatanState.kegiatan.create.form.tanggal}
onChange={(e) => (kegiatanState.kegiatan.create.form.tanggal = e.target.value)}
required
/>
<TextInput
label="Lokasi"
placeholder="Masukkan lokasi kegiatan"
value={kegiatanState.kegiatan.create.form.lokasi}
onChange={(e) => (kegiatanState.kegiatan.create.form.lokasi = e.target.value)}
required
/>
<NumberInput
label="Jumlah Partisipan"
placeholder="Masukkan jumlah partisipan"
value={kegiatanState.kegiatan.create.form.partisipan}
onChange={(val) => (kegiatanState.kegiatan.create.form.partisipan = Number(val) || 0)}
min={0}
/>
<Textarea
label="Deskripsi Singkat"
placeholder="Masukkan deskripsi singkat kegiatan"
value={kegiatanState.kegiatan.create.form.deskripsiSingkat}
onChange={(e) => (kegiatanState.kegiatan.create.form.deskripsiSingkat = e.target.value)}
minRows={3}
autosize
required
/>
<Box>
<Text fz="sm" fw="bold" mb={6}>Deskripsi Lengkap</Text>
<CreateEditor
value={kegiatanState.kegiatan.create.form.deskripsiLengkap}
onChange={(html) => { kegiatanState.kegiatan.create.form.deskripsiLengkap = html; }}
/>
</Box>
<Box>
<Text fw="bold" fz="sm" mb={6}>Gambar (Opsional)</Text>
<Dropzone
onDrop={(files) => {
const f = files[0];
if (f) { setFile(f); setPreviewImage(URL.createObjectURL(f)); }
}}
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={160}>
<Dropzone.Accept>
<IconUpload size={48} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="var(--mantine-color-red-6)" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={48} color="var(--mantine-color-dimmed)" stroke={1.5} />
</Dropzone.Idle>
</Group>
<Text ta="center" mt="sm" size="sm" color="dimmed">
Seret gambar atau klik untuk memilih (maks 5MB)
</Text>
</Dropzone>
{previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview"
radius="md"
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
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>
<Group justify="right">
<Button variant="outline" color="gray" radius="md" size="md" onClick={resetForm}>
Reset
</Button>
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background:
!isFormValid() || isSubmitting
? 'linear-gradient(135deg, #cccccc, #eeeeee)'
: `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>
</Box>
);
}

View File

@@ -0,0 +1,203 @@
'use client'
import colors from '@/con/colors';
import {
Box,
Button,
Center,
Group,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title
} from '@mantine/core';
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
import { IconPlus, IconDeviceImacCog, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header';
import stateDashboardKegiatan from '../../../_state/desa/kegiatanDesa';
function KegiatanDesa() {
const [search, setSearch] = useState("");
return (
<Box>
<HeaderSearch
title="Kegiatan Desa"
placeholder="Cari judul atau lokasi kegiatan..."
searchIcon={<IconSearch size={20} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<ListKegiatanDesa search={search} />
</Box>
);
}
function ListKegiatanDesa({ search }: { search: string }) {
const kegiatanState = useProxy(stateDashboardKegiatan);
const router = useRouter();
const [debouncedSearch] = useDebouncedValue(search, 1000);
const { data, page, totalPages, loading, load } = kegiatanState.kegiatan.findMany;
useShallowEffect(() => {
load(page, 10, debouncedSearch);
}, [page, debouncedSearch]);
if (loading || !data) {
return (
<Stack py="md">
<Skeleton height={600} radius="md" />
</Stack>
);
}
const filteredData = data || [];
return (
<Box py="md">
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Group justify="space-between" mb="md">
<Title order={4}>Daftar Kegiatan Desa</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/desa/kegiatan-desa/list-kegiatan-desa/create')}
>
Tambah Baru
</Button>
</Group>
{/* Desktop Table */}
<Box visibleFrom="md" style={{ overflowX: 'auto' }}>
<Table highlightOnHover layout="fixed" withColumnBorders={false} miw={0}>
<TableThead>
<TableTr>
<TableTh w="35%">Judul</TableTh>
<TableTh w="25%">Kategori</TableTh>
<TableTh w="20%">Lokasi</TableTh>
<TableTh w="20%">Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd>
<Text fz="md" fw={600} lh={1.45} truncate="end">
{item.judul}
</Text>
</TableTd>
<TableTd>
<Text fz="sm" c="dimmed" lh={1.45}>
{item.kategoriKegiatan?.nama || '-'}
</Text>
</TableTd>
<TableTd>
<Text fz="sm" c="dimmed" lh={1.45} truncate="end">
{item.lokasi || '-'}
</Text>
</TableTd>
<TableTd>
<Button
variant="light"
color="blue"
onClick={() =>
router.push(`/admin/desa/kegiatan-desa/list-kegiatan-desa/${item.id}`)
}
fz="sm"
px="sm"
h={36}
>
<IconDeviceImacCog size={18} />
<Text ml="xs">Detail</Text>
</Button>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={4}>
<Center py="xl">
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data kegiatan yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
{/* Mobile Cards */}
<Stack hiddenFrom="md" gap="sm" mt="sm">
{filteredData.length > 0 ? (
filteredData.map((item) => (
<Paper key={item.id} withBorder p="md" radius="md">
<Stack gap={"xs"}>
<Text fz="sm" fw={600} lh={1.4} c="dimmed">Judul</Text>
<Text fz="sm" fw={500} lh={1.45}>{item.judul}</Text>
<Text fz="sm" fw={600} lh={1.4} c="dimmed" mt="xs">Kategori</Text>
<Text fz="sm" lh={1.45} fw={500}>{item.kategoriKegiatan?.nama || '-'}</Text>
<Text fz="sm" fw={600} lh={1.4} c="dimmed" mt="xs">Lokasi</Text>
<Text fz="sm" lh={1.45} fw={500}>{item.lokasi || '-'}</Text>
<Button
variant="light"
color="blue"
fullWidth
mt="sm"
onClick={() =>
router.push(`/admin/desa/kegiatan-desa/list-kegiatan-desa/${item.id}`)
}
fz="sm"
h={36}
>
<IconDeviceImacCog size={18} />
<Text ml="xs">Detail</Text>
</Button>
</Stack>
</Paper>
))
) : (
<Center py="xl">
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data kegiatan yang cocok
</Text>
</Center>
)}
</Stack>
</Paper>
<Center>
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10, debouncedSearch);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
mt="md"
mb="md"
color="blue"
radius="md"
/>
</Center>
</Box>
);
}
export default KegiatanDesa;

View File

@@ -0,0 +1,190 @@
'use client'
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Select,
Skeleton,
Stack,
Text,
TextInput,
Title,
} from '@mantine/core';
import { DateTimePicker } from '@mantine/dates';
import { IconArrowBack, IconMapPin } from '@tabler/icons-react';
import dynamic from 'next/dynamic';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
import cctvState from '../../../../_state/keamanan/cctv';
const DEFAULT_CENTER = { lat: -8.5712, lng: 115.1923 };
const LeafletMapEdit = dynamic(
() => import('../../../../_com/leafletMapEdit'),
{ ssr: false, loading: () => <Skeleton height={300} radius="md" /> }
);
function EditCctv() {
const router = useRouter();
const state = useProxy(cctvState);
const params = useParams();
const [isSubmitting, setIsSubmitting] = useState(false);
const [loaded, setLoaded] = useState(false);
if (!loaded) {
setLoaded(true);
cctvState.edit.load(params?.id as string);
}
const isFormValid = () => {
const f = state.edit.form;
return f.kode.trim() !== '' && f.nama.trim() !== '' && f.lokasi.trim() !== '';
};
const mapCenter = {
lat: state.edit.form.latitude ? Number(state.edit.form.latitude) : DEFAULT_CENTER.lat,
lng: state.edit.form.longitude ? Number(state.edit.form.longitude) : DEFAULT_CENTER.lng,
};
const hasCoord = !!state.edit.form.latitude && !!state.edit.form.longitude;
const handleMapChange = (pos: { lat: number; lng: number }) => {
cctvState.edit.form.latitude = String(pos.lat);
cctvState.edit.form.longitude = String(pos.lng);
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
cctvState.edit.id = params?.id as string;
await cctvState.edit.update();
router.push(`/admin/keamanan/cctv/${params?.id}`);
} catch (error) {
console.error('Gagal update CCTV:', error);
toast.error('Gagal memperbarui CCTV');
} finally {
setIsSubmitting(false);
}
};
if (state.edit.loading && !state.edit.form.kode) {
return (
<Stack py={10}>
<Skeleton height={500} radius="md" />
</Stack>
);
}
return (
<Box px={{ base: 0, md: 'xs' }} py="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 CCTV</Title>
</Group>
<Paper
w={{ base: '100%', md: '55%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput
label={<Text fw="bold" fz="sm">Kode CCTV</Text>}
placeholder="Contoh: CCTV-01"
value={state.edit.form.kode}
onChange={(e) => { cctvState.edit.form.kode = e.currentTarget.value; }}
required
/>
<TextInput
label={<Text fw="bold" fz="sm">Nama / Deskripsi</Text>}
placeholder="Contoh: Balai Desa"
value={state.edit.form.nama}
onChange={(e) => { cctvState.edit.form.nama = e.currentTarget.value; }}
required
/>
<TextInput
label={<Text fw="bold" fz="sm">Lokasi</Text>}
placeholder="Contoh: Jl. Raya Darmasaba No. 1"
value={state.edit.form.lokasi}
onChange={(e) => { cctvState.edit.form.lokasi = e.currentTarget.value; }}
required
/>
<Box>
<Group mb={6} gap={6}>
<Text fw="bold" fz="sm">Titik Lokasi di Peta</Text>
<Text fz="xs" c="dimmed">(klik pada peta untuk memindahkan posisi)</Text>
</Group>
<Box style={{ height: 300, borderRadius: 8, overflow: 'hidden', border: '1px solid #e0e0e0' }}>
<LeafletMapEdit
initialPosition={mapCenter}
onChange={handleMapChange}
/>
</Box>
{hasCoord && (
<Group mt={6} gap={4}>
<IconMapPin size={14} color="green" />
<Text fz="xs" c="green">
Posisi: {Number(state.edit.form.latitude).toFixed(6)}, {Number(state.edit.form.longitude).toFixed(6)}
</Text>
</Group>
)}
</Box>
<Select
label={<Text fw="bold" fz="sm">Status</Text>}
value={state.edit.form.status}
onChange={(val) => { cctvState.edit.form.status = (val as 'Online' | 'Offline') ?? 'Online'; }}
data={[
{ value: 'Online', label: 'Online' },
{ value: 'Offline', label: 'Offline' },
]}
required
/>
<DateTimePicker
label={<Text fw="bold" fz="sm">Terakhir Aktif</Text>}
value={state.edit.form.lastActive ? new Date(state.edit.form.lastActive) : new Date()}
onChange={(val) => {
cctvState.edit.form.lastActive = val ? new Date(val).toISOString() : new Date().toISOString();
}}
/>
<Group justify="right">
<Button variant="outline" color="gray" radius="md" onClick={() => router.back()}>
Batal
</Button>
<Button
onClick={handleSubmit}
radius="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: !isFormValid() || isSubmitting
? 'linear-gradient(135deg, #cccccc, #eeeeee)'
: `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>
</Box>
);
}
export default EditCctv;

View File

@@ -0,0 +1,160 @@
'use client'
import colors from '@/con/colors';
import {
Badge,
Box,
Button,
Group,
Paper,
Skeleton,
Stack,
Text,
Title,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import dynamic from 'next/dynamic';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
import cctvState from '../../../_state/keamanan/cctv';
const LeafletMap = dynamic(
() => import('../../../_com/leafletMapCreate'),
{ ssr: false, loading: () => <Skeleton height={260} radius="md" /> }
);
function DetailCctv() {
const [modalHapus, setModalHapus] = useState(false);
const state = useProxy(cctvState);
const params = useParams();
const router = useRouter();
useShallowEffect(() => {
cctvState.findUnique.load(params?.id as string);
}, []);
const handleDelete = async () => {
if (params?.id) {
await cctvState.delete.remove(params.id as string);
setModalHapus(false);
router.push('/admin/keamanan/cctv');
}
};
if (state.findUnique.loading || !state.findUnique.data) {
return (
<Stack py={10}>
<Skeleton height={400} radius="md" />
</Stack>
);
}
const data = state.findUnique.data;
return (
<Box px={{ base: 0, md: 'xs' }} py="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">Detail CCTV</Title>
</Group>
<Paper
w={{ base: '100%', md: '55%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<Group justify="space-between">
<Text fz="xl" fw="bold">{data.kode}</Text>
<Badge
color={data.status === 'Online' ? 'green' : 'red'}
variant="light"
size="lg"
radius="sm"
>
{data.status}
</Badge>
</Group>
<Box>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} mb={2}>Nama</Text>
<Text fz="md">{data.nama}</Text>
</Box>
<Box>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} mb={2}>Lokasi</Text>
<Text fz="md">{data.lokasi}</Text>
</Box>
{data.latitude != null && data.longitude != null && (
<Box>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} mb={6}>Lokasi di Peta</Text>
<Box style={{ height: 260, borderRadius: 8, overflow: 'hidden', border: '1px solid #e0e0e0' }}>
<LeafletMap
defaultCenter={{ lat: data.latitude, lng: data.longitude }}
readOnly
/>
</Box>
</Box>
)}
<Box>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} mb={2}>Terakhir Aktif</Text>
<Text fz="md">
{new Date(data.lastActive).toLocaleString('id-ID', {
weekday: 'long', day: '2-digit', month: 'long',
year: 'numeric', hour: '2-digit', minute: '2-digit',
})}
</Text>
</Box>
<Box>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} mb={2}>Dibuat</Text>
<Text fz="sm" c="dimmed">
{new Date(data.createdAt).toLocaleDateString('id-ID', {
day: '2-digit', month: 'long', year: 'numeric',
})}
</Text>
</Box>
<Group gap="sm" mt="sm">
<Button
color="red"
variant="light"
radius="md"
onClick={() => setModalHapus(true)}
loading={state.delete.loading}
>
<IconTrash size={20} />
</Button>
<Button
color="green"
variant="light"
radius="md"
onClick={() => router.push(`/admin/keamanan/cctv/${data.id}/edit`)}
>
<IconEdit size={20} />
</Button>
</Group>
</Stack>
</Paper>
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleDelete}
loading={state.delete.loading}
text="Apakah anda yakin ingin menghapus CCTV ini?"
/>
</Box>
);
}
export default DetailCctv;

View File

@@ -0,0 +1,177 @@
'use client'
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Select,
Skeleton,
Stack,
Text,
TextInput,
Title,
} from '@mantine/core';
import { DateTimePicker } from '@mantine/dates';
import { IconArrowBack, IconMapPin } from '@tabler/icons-react';
import dynamic from 'next/dynamic';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
import cctvState from '../../../_state/keamanan/cctv';
// Darmasaba default center
const DEFAULT_CENTER = { lat: -8.5712, lng: 115.1923 };
const LeafletMap = dynamic(
() => import('../../../_com/leafletMapCreate'),
{ ssr: false, loading: () => <Skeleton height={300} radius="md" /> }
);
function CreateCctv() {
const router = useRouter();
const state = useProxy(cctvState);
const [isSubmitting, setIsSubmitting] = useState(false);
const [markerSet, setMarkerSet] = useState(false);
const isFormValid = () => {
const f = state.create.form;
return f.kode.trim() !== '' && f.nama.trim() !== '' && f.lokasi.trim() !== '';
};
const handleMapSelect = (pos: { lat: number; lng: number }) => {
cctvState.create.form.latitude = String(pos.lat);
cctvState.create.form.longitude = String(pos.lng);
setMarkerSet(true);
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
await cctvState.create.create();
cctvState.create.resetForm();
setMarkerSet(false);
router.push('/admin/keamanan/cctv');
} catch (error) {
console.error('Gagal menambahkan CCTV:', error);
toast.error('Gagal menambahkan CCTV');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 0, md: 'xs' }} py="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">Tambah CCTV</Title>
</Group>
<Paper
w={{ base: '100%', md: '55%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput
label={<Text fw="bold" fz="sm">Kode CCTV</Text>}
placeholder="Contoh: CCTV-01"
value={state.create.form.kode}
onChange={(e) => { cctvState.create.form.kode = e.currentTarget.value; }}
required
/>
<TextInput
label={<Text fw="bold" fz="sm">Nama / Deskripsi</Text>}
placeholder="Contoh: Balai Desa"
value={state.create.form.nama}
onChange={(e) => { cctvState.create.form.nama = e.currentTarget.value; }}
required
/>
<TextInput
label={<Text fw="bold" fz="sm">Lokasi</Text>}
placeholder="Contoh: Jl. Raya Darmasaba No. 1"
value={state.create.form.lokasi}
onChange={(e) => { cctvState.create.form.lokasi = e.currentTarget.value; }}
required
/>
<Box>
<Group mb={6} gap={6}>
<Text fw="bold" fz="sm">Titik Lokasi di Peta</Text>
<Text fz="xs" c="dimmed">(klik pada peta untuk menentukan posisi)</Text>
</Group>
<Box style={{ height: 300, borderRadius: 8, overflow: 'hidden', border: '1px solid #e0e0e0' }}>
<LeafletMap
defaultCenter={DEFAULT_CENTER}
onSelect={handleMapSelect}
/>
</Box>
{markerSet && (
<Group mt={6} gap={4}>
<IconMapPin size={14} color="green" />
<Text fz="xs" c="green">
Posisi dipilih: {Number(state.create.form.latitude).toFixed(6)}, {Number(state.create.form.longitude).toFixed(6)}
</Text>
</Group>
)}
</Box>
<Select
label={<Text fw="bold" fz="sm">Status</Text>}
value={state.create.form.status}
onChange={(val) => { cctvState.create.form.status = (val as 'Online' | 'Offline') ?? 'Online'; }}
data={[
{ value: 'Online', label: 'Online' },
{ value: 'Offline', label: 'Offline' },
]}
required
/>
<DateTimePicker
label={<Text fw="bold" fz="sm">Terakhir Aktif</Text>}
value={state.create.form.lastActive ? new Date(state.create.form.lastActive) : new Date()}
onChange={(val) => {
cctvState.create.form.lastActive = val ? new Date(val).toISOString() : new Date().toISOString();
}}
/>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
onClick={() => { cctvState.create.resetForm(); setMarkerSet(false); }}
>
Reset
</Button>
<Button
onClick={handleSubmit}
radius="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: !isFormValid() || isSubmitting
? 'linear-gradient(135deg, #cccccc, #eeeeee)'
: `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>
</Box>
);
}
export default CreateCctv;

View File

@@ -0,0 +1,215 @@
'use client'
import colors from '@/con/colors';
import {
Badge,
Box,
Button,
Center,
Group,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title,
} from '@mantine/core';
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../_com/header';
import cctvState from '../../_state/keamanan/cctv';
function CctvPage() {
const [search, setSearch] = useState('');
return (
<Box>
<HeaderSearch
title="CCTV Keamanan"
placeholder="Cari kode, nama, atau lokasi..."
searchIcon={<IconSearch size={20} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<ListCctv search={search} />
</Box>
);
}
function ListCctv({ search }: { search: string }) {
const state = useProxy(cctvState);
const router = useRouter();
const [debouncedSearch] = useDebouncedValue(search, 500);
const { data, page, totalPages, loading } = state.findMany;
useShallowEffect(() => {
cctvState.findMany.search = debouncedSearch;
cctvState.findMany.load();
}, [page, debouncedSearch]);
if (loading || !data) {
return (
<Stack py={{ base: 'sm', md: 'md' }}>
<Skeleton height={600} radius="md" />
</Stack>
);
}
return (
<Stack py={{ base: 'sm', md: 'md' }}>
<Paper withBorder bg={colors['white-1']} p={{ base: 'sm', md: 'lg' }} shadow="md" radius="md">
<Group justify="space-between" mb={{ base: 'sm', md: 'md' }}>
<Title order={4}>Daftar CCTV</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/keamanan/cctv/create')}
>
Tambah CCTV
</Button>
</Group>
{/* Desktop Table */}
<Box visibleFrom="md" style={{ overflowX: 'auto' }}>
<Table highlightOnHover style={{ tableLayout: 'fixed', width: '100%' }}>
<TableThead>
<TableTr>
<TableTh style={{ width: '15%' }}>Kode</TableTh>
<TableTh style={{ width: '20%' }}>Nama</TableTh>
<TableTh style={{ width: '25%' }}>Lokasi</TableTh>
<TableTh style={{ width: '15%' }}>Status</TableTh>
<TableTh style={{ width: '15%' }}>Terakhir Aktif</TableTh>
<TableTh style={{ width: '10%' }}>Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{data.length > 0 ? (
data.map((item) => (
<TableTr key={item.id}>
<TableTd>
<Text fz="sm" fw={600}>{item.kode}</Text>
</TableTd>
<TableTd>
<Text fz="sm" fw={500} lineClamp={1}>{item.nama}</Text>
</TableTd>
<TableTd>
<Text fz="sm" c="dimmed" lineClamp={1}>{item.lokasi}</Text>
</TableTd>
<TableTd>
<Badge
color={item.status === 'Online' ? 'green' : 'red'}
variant="light"
radius="sm"
>
{item.status}
</Badge>
</TableTd>
<TableTd>
<Text fz="xs" c="dimmed">
{new Date(item.lastActive).toLocaleString('id-ID', {
day: '2-digit', month: 'short', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})}
</Text>
</TableTd>
<TableTd>
<Button
variant="light"
color="blue"
size="xs"
onClick={() => router.push(`/admin/keamanan/cctv/${item.id}`)}
>
<IconDeviceImacCog size={16} />
<Text ml={4} fz="xs" fw={500}>Detail</Text>
</Button>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={6}>
<Center py={20}>
<Text c="dimmed" fz="sm">Tidak ada data CCTV</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
{/* Mobile Card */}
<Stack hiddenFrom="md" gap="xs">
{data.length > 0 ? (
data.map((item) => (
<Paper key={item.id} withBorder p="sm" radius="sm">
<Stack gap="xs">
<Group justify="space-between">
<Text fz="sm" fw={700}>{item.kode}</Text>
<Badge
color={item.status === 'Online' ? 'green' : 'red'}
variant="light"
radius="sm"
>
{item.status}
</Badge>
</Group>
<Text fz="sm" fw={500}>{item.nama}</Text>
<Text fz="xs" c="dimmed">{item.lokasi}</Text>
<Text fz="xs" c="dimmed">
Terakhir aktif:{' '}
{new Date(item.lastActive).toLocaleString('id-ID', {
day: '2-digit', month: 'short', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})}
</Text>
<Button
variant="light"
color="blue"
fullWidth
size="xs"
onClick={() => router.push(`/admin/keamanan/cctv/${item.id}`)}
>
<IconDeviceImacCog size={16} />
<Text ml={4} fz="xs" fw={500}>Detail</Text>
</Button>
</Stack>
</Paper>
))
) : (
<Center py={20}>
<Text c="dimmed" fz="sm">Tidak ada data CCTV</Text>
</Center>
)}
</Stack>
</Paper>
<Center>
<Pagination
value={page}
onChange={(newPage) => {
cctvState.findMany.page = newPage;
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
mt="md"
mb="md"
color="blue"
radius="md"
/>
</Center>
</Stack>
);
}
export default CctvPage;

View File

@@ -9,10 +9,12 @@ import {
Group,
Loader,
Paper,
Select,
Stack,
TextInput,
Title
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
@@ -43,6 +45,7 @@ function EditGrafikHasilKepuasan() {
jenisKelamin: '',
alamat: '',
penyakit: '',
banjarId: '',
});
const [originalData, setOriginalData] = useState({
@@ -51,8 +54,13 @@ function EditGrafikHasilKepuasan() {
jenisKelamin: '',
alamat: '',
penyakit: '',
banjarId: '',
});
useShallowEffect(() => {
editState.banjarList.load();
}, []);
// Load data once
useEffect(() => {
const loadData = async () => {
@@ -70,6 +78,7 @@ function EditGrafikHasilKepuasan() {
jenisKelamin: data.jenisKelamin || '',
alamat: data.alamat || '',
penyakit: data.penyakit || '',
banjarId: data.banjarId || '',
});
setOriginalData({
@@ -78,6 +87,7 @@ function EditGrafikHasilKepuasan() {
jenisKelamin: data.jenisKelamin || '',
alamat: data.alamat || '',
penyakit: data.penyakit || '',
banjarId: data.banjarId || '',
});
}
} catch (err) {
@@ -95,13 +105,7 @@ function EditGrafikHasilKepuasan() {
};
const handleResetForm = () => {
setFormData({
nama: originalData.nama,
tanggal: originalData.tanggal,
jenisKelamin: originalData.jenisKelamin,
alamat: originalData.alamat,
penyakit: originalData.penyakit,
});
setFormData({ ...originalData });
toast.info("Form dikembalikan ke data awal");
};
@@ -183,6 +187,15 @@ function EditGrafikHasilKepuasan() {
required
/>
))}
<Select
label="Banjar"
placeholder="Pilih banjar (opsional)"
data={editState.banjarList.data.map((b) => ({ value: b.id, label: b.name }))}
value={formData.banjarId || null}
onChange={(val) => handleChange('banjarId', val ?? '')}
clearable
searchable
/>
<Group justify="right">
<Button

View File

@@ -99,6 +99,11 @@ function DetailGrafikHasilKepuasan() {
<Text fz="md" c="dimmed">{data.penyakit || '-'}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Banjar</Text>
<Text fz="md" c="dimmed">{(data as any).banjar?.name || '-'}</Text>
</Box>
{/* Aksi */}
<Group gap="sm">
<Button

View File

@@ -9,10 +9,12 @@ import {
Group,
Loader,
Paper,
Select,
Stack,
TextInput,
Title
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
@@ -25,6 +27,10 @@ function CreateGrafikHasilKepuasanMasyarakat() {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
useShallowEffect(() => {
stateGrafikKepuasan.banjarList.load();
}, []);
// Check if form is valid
const isFormValid = () => {
return (
@@ -43,6 +49,7 @@ function CreateGrafikHasilKepuasanMasyarakat() {
jenisKelamin: "",
alamat: "",
penyakit: "",
banjarId: "",
};
};
@@ -148,6 +155,15 @@ function CreateGrafikHasilKepuasanMasyarakat() {
onChange={(e) => (stateGrafikKepuasan.create.form.penyakit = e.target.value)}
required
/>
<Select
label="Banjar"
placeholder="Pilih banjar (opsional)"
data={stateGrafikKepuasan.banjarList.data.map((b) => ({ value: b.id, label: b.name }))}
value={stateGrafikKepuasan.create.form.banjarId || null}
onChange={(val) => (stateGrafikKepuasan.create.form.banjarId = val ?? '')}
clearable
searchable
/>
<Group justify="right">
<Button

View File

@@ -9,6 +9,7 @@ import {
Group,
Pagination,
Paper,
Select,
Skeleton,
Stack,
Table,
@@ -62,21 +63,28 @@ function ListGrafikHasilKepuasanMasyarakat({ search }: { search: string }) {
jenisKelamin: string;
alamat: string;
penyakit: string;
banjar?: { id: string; name: string } | null;
};
const stateGrafikKepuasan = useProxy(grafikkepuasan);
const [debouncedSearch] = useDebouncedValue(search, 1000);
const [chartData, setChartData] = useState<PDKMGrafik[]>([]);
const [mounted, setMounted] = useState(false);
const [selectedBanjar, setSelectedBanjar] = useState<string | null>(null);
const isMobile = useMediaQuery('(max-width: 768px)');
const router = useRouter();
const { data, page, totalPages, loading, load } = stateGrafikKepuasan.findMany;
const { data: banjarList } = stateGrafikKepuasan.banjarList;
useShallowEffect(() => {
stateGrafikKepuasan.banjarList.load();
}, []);
useShallowEffect(() => {
setMounted(true);
load(page, 10, debouncedSearch);
}, [page, debouncedSearch]);
load(page, 10, debouncedSearch, selectedBanjar ?? '');
}, [page, debouncedSearch, selectedBanjar]);
useEffect(() => {
if (data) {
@@ -113,6 +121,11 @@ function ListGrafikHasilKepuasanMasyarakat({ search }: { search: string }) {
const filteredData = data || [];
const banjarOptions = [
{ value: '', label: 'Semua Banjar' },
...banjarList.map((b) => ({ value: b.id, label: b.name })),
];
if (loading || !data) {
return (
<Stack py={{ base: 'md', md: 'lg' }}>
@@ -146,16 +159,32 @@ function ListGrafikHasilKepuasanMasyarakat({ search }: { search: string }) {
</Button>
</Group>
{/* Filter Banjar */}
<Box mb="sm" maw={280}>
<Select
placeholder="Filter berdasarkan banjar"
data={banjarOptions}
value={selectedBanjar ?? ''}
onChange={(val) => {
setSelectedBanjar(val || null);
load(1, 10, debouncedSearch, val ?? '');
}}
clearable
searchable
/>
</Box>
{/* Desktop Table */}
<Box visibleFrom="md" style={{ overflowX: 'auto' }}>
<Table highlightOnHover
layout="fixed" // 🔥 PENTING
layout="fixed"
withColumnBorders={false}>
<TableThead>
<TableTr>
<TableTh>Nama</TableTh>
<TableTh>Tanggal</TableTh>
<TableTh>Jenis Kelamin</TableTh>
<TableTh>Banjar</TableTh>
<TableTh>Penyakit</TableTh>
<TableTh>Aksi</TableTh>
</TableTr>
@@ -177,6 +206,9 @@ function ListGrafikHasilKepuasanMasyarakat({ search }: { search: string }) {
<TableTd fz="md" fw={500} lh={1.5}>
{item.jenisKelamin}
</TableTd>
<TableTd fz="md" fw={500} lh={1.5}>
{item.banjar?.name ?? '-'}
</TableTd>
<TableTd fz="md" fw={500} lh={1.5}>
{item.penyakit}
</TableTd>
@@ -200,7 +232,7 @@ function ListGrafikHasilKepuasanMasyarakat({ search }: { search: string }) {
))
) : (
<TableTr>
<TableTd colSpan={5}>
<TableTd colSpan={6}>
<Center py={20}>
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data kepuasan masyarakat yang cocok
@@ -244,6 +276,13 @@ function ListGrafikHasilKepuasanMasyarakat({ search }: { search: string }) {
{item.jenisKelamin}
</Text>
<Text fz="sm" fw={600} lh={1.4}>
Banjar
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.banjar?.name ?? '-'}
</Text>
<Text fz="sm" fw={600} lh={1.4}>
Penyakit
</Text>
@@ -285,7 +324,7 @@ function ListGrafikHasilKepuasanMasyarakat({ search }: { search: string }) {
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10);
load(newPage, 10, debouncedSearch, selectedBanjar ?? '');
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
@@ -339,4 +378,4 @@ function ListGrafikHasilKepuasanMasyarakat({ search }: { search: string }) {
);
}
export default GrafikHasilKepuasanMasyarakat;
export default GrafikHasilKepuasanMasyarakat;

View File

@@ -0,0 +1,161 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import colors from '@/con/colors';
import { Box, ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core';
import { IconBabyCarriage, IconCategory, IconClipboard, IconClipboardText, IconGenderDemigirl, IconNews } from '@tabler/icons-react';
import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react';
function LayoutTabsPosyandu({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const tabs = [
{
label: "List Posyandu",
value: "list_posyandu",
href: "/admin/kesehatan/posyandu/list-posyandu",
icon: <IconNews size={18} stroke={1.8} />
},
{
label: "Balita",
value: "balita",
href: "/admin/kesehatan/posyandu/balita",
icon: <IconBabyCarriage size={18} stroke={1.8} />
},
{
label: "Ibu Hamil",
value: "ibu_hamil",
href: "/admin/kesehatan/posyandu/ibu-hamil",
icon: <IconGenderDemigirl size={18} stroke={1.8} />
},
{
label: "Ringkasan Kesehatan",
value: "ringkasan_kesehatan",
href: "/admin/kesehatan/posyandu/ringkasan-kesehatan",
icon: <IconClipboardText size={18} stroke={1.8} />
}
];
const currentTab = tabs.find(tab => tab.href === pathname)
const [activeTab, setActiveTab] = useState<string | null>(currentTab?.value || tabs[0].value);
const handleTabChange = (value: string | null) => {
const tab = tabs.find(t => t.value === value)
if (tab) {
router.push(tab.href)
}
setActiveTab(value)
}
useEffect(() => {
const match = tabs.find(tab => tab.href === pathname)
if (match) {
setActiveTab(match.value)
}
}, [pathname])
return (
<Stack gap="lg">
<Title order={3} fw={700} style={{ color: "#1A1B1E" }}>Layanan</Title>
<Tabs
color={colors["blue-button"]}
variant="pills"
value={activeTab}
onChange={handleTabChange}
radius="lg"
keepMounted={false}
>
{/* ✅ Scroll horizontal wrapper */}
<Box visibleFrom='md' pb={10}>
<ScrollArea type="auto" offsetScrollbars w="100%">
<TabsList
p="sm"
style={{
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem",
display: "flex",
flexWrap: "nowrap",
gap: "0.5rem",
width: "max-content", // ⬅️ kunci
maxWidth: "100%",
}}
>
{tabs.map((tab, i) => (
<TabsTab
key={i}
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
transition: "all 0.2s ease",
flexShrink: 0, // ✅ jangan mengecil aneh-aneh
}}
>
{tab.label}
</TabsTab>
))}
</TabsList>
</ScrollArea>
</Box>
<Box hiddenFrom='md' pb={10}>
<ScrollArea
type="auto"
offsetScrollbars={false}
w="100%"
>
<TabsList
p="xs" // lebih kecil
style={{
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem",
display: "flex",
flexWrap: "nowrap",
gap: "0.5rem",
width: "max-content", // ⬅️ kunci
maxWidth: "100%", // ⬅️ penting
}}
>
{tabs.map((tab, i) => (
<TabsTab
key={i}
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
paddingInline: "0.75rem", // ⬅️ lebih ramping
flexShrink: 0, // ✅ jangan mengecil aneh-aneh
}}
>
{tab.label}
</TabsTab>
))}
</TabsList>
</ScrollArea>
</Box>
{tabs.map((tab, i) => (
<TabsPanel
key={i}
value={tab.value}
style={{
padding: "1.5rem",
background: "linear-gradient(180deg, #ffffff, #f5f6fa)",
borderRadius: "1rem",
boxShadow: "0 4px 16px rgba(0,0,0,0.05)",
}}
>
{/* Konten dummy, bisa diganti sesuai routing */}
<>{children}</>
</TabsPanel>
))}
</Tabs>
</Stack>
);
}
export default LayoutTabsPosyandu;

View File

@@ -0,0 +1,187 @@
'use client';
import balitaState from '@/app/admin/(dashboard)/_state/kesehatan/balita/balita';
import colors from '@/con/colors';
import {
Box,
Button,
Checkbox,
Group,
Loader,
NumberInput,
Paper,
Select,
SimpleGrid,
Stack,
Textarea,
TextInput,
Title,
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useProxy } from 'valtio/utils';
export default function BalitaCreatePage() {
const router = useRouter();
const state = useProxy(balitaState);
const form = state.create.form;
const handleSubmit = async () => {
const ok = await balitaState.create.submit();
if (ok) {
balitaState.create.reset();
router.push('/admin/kesehatan/posyandu/balita');
}
};
return (
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Group mb="md" gap="sm">
<Button variant="subtle" onClick={() => router.back()} radius="md">
<IconArrowBack size={20} />
</Button>
<Title order={3} c="black">Tambah Balita</Title>
</Group>
<Paper withBorder w={{ base: '100%', md: '80%' }} p="lg" radius="md" shadow="xl" bg="white">
<Stack gap="md">
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
<TextInput
label="Nama Lengkap"
required
placeholder="Nama balita"
value={form.nama}
onChange={(e) => { balitaState.create.form.nama = e.currentTarget.value; }}
radius="md"
/>
<TextInput
label="NIK"
placeholder="Nomor Induk Kependudukan"
value={form.nik}
onChange={(e) => { balitaState.create.form.nik = e.currentTarget.value; }}
radius="md"
/>
<TextInput
label="Tanggal Lahir"
required
type="date"
value={form.tanggalLahir}
onChange={(e) => { balitaState.create.form.tanggalLahir = e.currentTarget.value; }}
radius="md"
/>
<Select
label="Jenis Kelamin"
required
data={[
{ value: 'L', label: 'Laki-laki' },
{ value: 'P', label: 'Perempuan' },
]}
value={form.jenisKelamin}
onChange={(v) => { if (v) balitaState.create.form.jenisKelamin = v as typeof form.jenisKelamin; }}
radius="md"
/>
<NumberInput
label="Berat Badan (kg)"
placeholder="0.0"
min={0}
decimalScale={1}
value={form.beratBadanKg ?? ''}
onChange={(v) => { balitaState.create.form.beratBadanKg = v === '' ? undefined : Number(v); }}
radius="md"
/>
<NumberInput
label="Tinggi Badan (cm)"
placeholder="0.0"
min={0}
decimalScale={1}
value={form.tinggiBadanCm ?? ''}
onChange={(v) => { balitaState.create.form.tinggiBadanCm = v === '' ? undefined : Number(v); }}
radius="md"
/>
<TextInput
label="Nama Orang Tua"
placeholder="Nama ayah/ibu"
value={form.namaOrtu}
onChange={(e) => { balitaState.create.form.namaOrtu = e.currentTarget.value; }}
radius="md"
/>
<TextInput
label="No. HP Orang Tua"
placeholder="08xx-xxxx-xxxx"
value={form.noHpOrtu}
onChange={(e) => { balitaState.create.form.noHpOrtu = e.currentTarget.value; }}
radius="md"
/>
<Select
label="Status Stunting"
required
data={[
{ value: 'NORMAL', label: 'Normal' },
{ value: 'ALERT', label: 'Alert (Berisiko)' },
{ value: 'STUNTING', label: 'Stunting' },
]}
value={form.statusStunting}
onChange={(v) => { if (v) balitaState.create.form.statusStunting = v as typeof form.statusStunting; }}
radius="md"
/>
</SimpleGrid>
<TextInput
label="Alamat"
placeholder="Alamat lengkap"
value={form.alamat}
onChange={(e) => { balitaState.create.form.alamat = e.currentTarget.value; }}
radius="md"
/>
<Group gap="xl">
<Checkbox
label="Imunisasi Lengkap"
checked={form.imunisasiLengkap}
onChange={(e) => { balitaState.create.form.imunisasiLengkap = e.currentTarget.checked; }}
/>
<Checkbox
label="Gizi Baik"
checked={form.giziBaik}
onChange={(e) => { balitaState.create.form.giziBaik = e.currentTarget.checked; }}
/>
<Checkbox
label="Pemeriksaan Rutin"
checked={form.pemeriksaanRutin}
onChange={(e) => { balitaState.create.form.pemeriksaanRutin = e.currentTarget.checked; }}
/>
</Group>
<Textarea
label="Catatan"
placeholder="Catatan tambahan"
value={form.catatan}
onChange={(e) => { balitaState.create.form.catatan = e.currentTarget.value; }}
radius="md"
rows={3}
/>
<Group justify="right">
<Button variant="outline" color="gray" radius="md" onClick={() => router.back()}>
Batal
</Button>
<Button
onClick={handleSubmit}
radius="md"
disabled={state.create.loading}
style={{
background: state.create.loading
? 'linear-gradient(135deg, #ccc, #eee)'
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
}}
>
{state.create.loading ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}

View File

@@ -0,0 +1,191 @@
'use client';
import balitaState from '@/app/admin/(dashboard)/_state/kesehatan/balita/balita';
import colors from '@/con/colors';
import {
Box,
Button,
Checkbox,
Group,
Loader,
NumberInput,
Paper,
Select,
SimpleGrid,
Stack,
Textarea,
TextInput,
Title,
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { useProxy } from 'valtio/utils';
export default function BalitaEditPage() {
const router = useRouter();
const params = useParams();
const id = params.id as string;
const state = useProxy(balitaState);
const form = state.edit.form;
useEffect(() => {
if (id) balitaState.edit.load(id);
}, [id]); // eslint-disable-line react-hooks/exhaustive-deps
const handleSubmit = async () => {
const ok = await balitaState.edit.update();
if (ok) router.push('/admin/kesehatan/posyandu/balita');
};
return (
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Group mb="md" gap="sm">
<Button variant="subtle" onClick={() => router.back()} radius="md">
<IconArrowBack size={20} />
</Button>
<Title order={3} c="black">Edit Balita</Title>
</Group>
<Paper withBorder w={{ base: '100%', md: '80%' }} p="lg" radius="md" shadow="xl" bg="white">
<Stack gap="md">
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
<TextInput
label="Nama Lengkap"
required
placeholder="Nama balita"
value={form.nama}
onChange={(e) => { balitaState.edit.form.nama = e.currentTarget.value; }}
radius="md"
/>
<TextInput
label="NIK"
placeholder="Nomor Induk Kependudukan"
value={form.nik}
onChange={(e) => { balitaState.edit.form.nik = e.currentTarget.value; }}
radius="md"
/>
<TextInput
label="Tanggal Lahir"
required
type="date"
value={form.tanggalLahir}
onChange={(e) => { balitaState.edit.form.tanggalLahir = e.currentTarget.value; }}
radius="md"
/>
<Select
label="Jenis Kelamin"
required
data={[
{ value: 'L', label: 'Laki-laki' },
{ value: 'P', label: 'Perempuan' },
]}
value={form.jenisKelamin}
onChange={(v) => { if (v) balitaState.edit.form.jenisKelamin = v as typeof form.jenisKelamin; }}
radius="md"
/>
<NumberInput
label="Berat Badan (kg)"
placeholder="0.0"
min={0}
decimalScale={1}
value={form.beratBadanKg ?? ''}
onChange={(v) => { balitaState.edit.form.beratBadanKg = v === '' ? undefined : Number(v); }}
radius="md"
/>
<NumberInput
label="Tinggi Badan (cm)"
placeholder="0.0"
min={0}
decimalScale={1}
value={form.tinggiBadanCm ?? ''}
onChange={(v) => { balitaState.edit.form.tinggiBadanCm = v === '' ? undefined : Number(v); }}
radius="md"
/>
<TextInput
label="Nama Orang Tua"
placeholder="Nama ayah/ibu"
value={form.namaOrtu}
onChange={(e) => { balitaState.edit.form.namaOrtu = e.currentTarget.value; }}
radius="md"
/>
<TextInput
label="No. HP Orang Tua"
placeholder="08xx-xxxx-xxxx"
value={form.noHpOrtu}
onChange={(e) => { balitaState.edit.form.noHpOrtu = e.currentTarget.value; }}
radius="md"
/>
<Select
label="Status Stunting"
required
data={[
{ value: 'NORMAL', label: 'Normal' },
{ value: 'ALERT', label: 'Alert (Berisiko)' },
{ value: 'STUNTING', label: 'Stunting' },
]}
value={form.statusStunting}
onChange={(v) => { if (v) balitaState.edit.form.statusStunting = v as typeof form.statusStunting; }}
radius="md"
/>
</SimpleGrid>
<TextInput
label="Alamat"
placeholder="Alamat lengkap"
value={form.alamat}
onChange={(e) => { balitaState.edit.form.alamat = e.currentTarget.value; }}
radius="md"
/>
<Group gap="xl">
<Checkbox
label="Imunisasi Lengkap"
checked={form.imunisasiLengkap}
onChange={(e) => { balitaState.edit.form.imunisasiLengkap = e.currentTarget.checked; }}
/>
<Checkbox
label="Gizi Baik"
checked={form.giziBaik}
onChange={(e) => { balitaState.edit.form.giziBaik = e.currentTarget.checked; }}
/>
<Checkbox
label="Pemeriksaan Rutin"
checked={form.pemeriksaanRutin}
onChange={(e) => { balitaState.edit.form.pemeriksaanRutin = e.currentTarget.checked; }}
/>
</Group>
<Textarea
label="Catatan"
placeholder="Catatan tambahan"
value={form.catatan}
onChange={(e) => { balitaState.edit.form.catatan = e.currentTarget.value; }}
radius="md"
rows={3}
/>
<Group justify="right">
<Button variant="outline" color="gray" radius="md" onClick={() => router.back()}>
Batal
</Button>
<Button
onClick={handleSubmit}
radius="md"
disabled={state.edit.loading}
style={{
background: state.edit.loading
? 'linear-gradient(135deg, #ccc, #eee)'
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
}}
>
{state.edit.loading ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}

View File

@@ -0,0 +1,312 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import balitaState from '@/app/admin/(dashboard)/_state/kesehatan/balita/balita';
import colors from '@/con/colors';
import {
Badge,
Box,
Button,
Center,
Group,
Pagination,
Paper,
Select,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title,
} from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { IconDeviceImacCog, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header';
const STUNTING_COLORS: Record<string, string> = {
NORMAL: 'green',
ALERT: 'yellow',
STUNTING: 'red',
};
function BalitaPage() {
const [search, setSearch] = useState('');
return (
<Box>
<HeaderSearch
title="Balita Terdaftar"
placeholder="Cari nama / NIK / ortu..."
searchIcon={<IconSearch size={20} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<ListBalita search={search} />
</Box>
);
}
function ListBalita({ search }: { search: string }) {
const state = useProxy(balitaState);
const router = useRouter();
const [statusFilter, setStatusFilter] = useState('');
const [debouncedSearch] = useDebouncedValue(search, 1000);
const { data, page, totalPages, loading, load } = state.findMany;
useEffect(() => {
load(page, 10, debouncedSearch, statusFilter);
}, [page, debouncedSearch, statusFilter]);
const handleDelete = async (id: string, nama: string) => {
if (!confirm(`Hapus data balita "${nama}"?`)) return;
await state.delete.byId(id);
};
const filteredData = data || [];
if (loading || !data) {
return (
<Stack py="md">
<Skeleton h={600} radius="md" />
</Stack>
);
}
return (
<Box py="md">
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Group justify="space-between" mb="lg">
<Title order={4}>List Balita</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/kesehatan/posyandu/balita/create')}
>
Tambah Baru
</Button>
</Group>
<Group mb="md">
<Select
placeholder="Filter stunting"
data={[
{ value: '', label: 'Semua' },
{ value: 'NORMAL', label: 'Normal' },
{ value: 'ALERT', label: 'Alert' },
{ value: 'STUNTING', label: 'Stunting' },
]}
value={statusFilter}
onChange={(v) => setStatusFilter(v ?? '')}
radius="md"
clearable
/>
</Group>
{/* Desktop Table */}
<Box visibleFrom="md" style={{ overflowX: 'auto' }}>
<Table highlightOnHover layout="fixed" withColumnBorders={false}>
<TableThead>
<TableTr>
<TableTh w="18%">Nama</TableTh>
<TableTh w="6%">JK</TableTh>
<TableTh w="11%">Tgl Lahir</TableTh>
<TableTh w="13%">Banjar</TableTh>
<TableTh w="10%">Imunisasi</TableTh>
<TableTh w="8%">Gizi</TableTh>
<TableTh w="10%">Pemeriksaan</TableTh>
<TableTh w="10%">Stunting</TableTh>
<TableTh w="14%">Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((d) => (
<TableTr key={d.id}>
<TableTd>{d.nama}</TableTd>
<TableTd>{d.jenisKelamin}</TableTd>
<TableTd>
{d.tanggalLahir
? new Date(d.tanggalLahir).toLocaleDateString('id-ID')
: '-'}
</TableTd>
<TableTd>{d.posyandu?.banjar?.name ?? '-'}</TableTd>
<TableTd>
<Badge color={d.imunisasiLengkap ? 'green' : 'red'} variant="light">
{d.imunisasiLengkap ? 'Lengkap' : 'Belum'}
</Badge>
</TableTd>
<TableTd>
<Badge color={d.giziBaik ? 'green' : 'orange'} variant="light">
{d.giziBaik ? 'Baik' : 'Kurang'}
</Badge>
</TableTd>
<TableTd>
<Badge color={d.pemeriksaanRutin ? 'green' : 'orange'} variant="light">
{d.pemeriksaanRutin ? 'Rutin' : 'Tidak'}
</Badge>
</TableTd>
<TableTd>
<Badge
color={STUNTING_COLORS[d.statusStunting] ?? 'gray'}
variant="light"
>
{d.statusStunting}
</Badge>
</TableTd>
<TableTd>
<Group gap="xs">
<Button
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() =>
router.push(`/admin/kesehatan/posyandu/balita/edit/${d.id}`)
}
>
Edit
</Button>
<Button
size="xs"
radius="md"
variant="light"
color="red"
leftSection={<IconTrash size={16} />}
onClick={() => handleDelete(d.id, d.nama)}
loading={state.delete.loading}
>
Hapus
</Button>
</Group>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={9}>
<Center py="xl">
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data balita yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
{/* Mobile Cards */}
<Stack gap="sm" hiddenFrom="md">
{filteredData.length > 0 ? (
filteredData.map((d) => (
<Paper key={d.id} withBorder p="md" radius="md">
<Box>
<Text fz="sm" fw={600} mb={4}>
{d.nama}
</Text>
<Group gap="xs" mb={4}>
<Text fz="xs" c="dimmed">
{d.jenisKelamin}
</Text>
<Text fz="xs" c="dimmed">·</Text>
<Text fz="xs" c="dimmed">
{d.tanggalLahir
? new Date(d.tanggalLahir).toLocaleDateString('id-ID')
: '-'}
</Text>
</Group>
{d.posyandu?.banjar?.name && (
<Text fz="xs" c="dimmed" mb={4}>
Banjar: {d.posyandu.banjar.name}
</Text>
)}
<Group gap="xs" mb={8}>
<Badge
size="xs"
color={d.imunisasiLengkap ? 'green' : 'red'}
variant="light"
>
{d.imunisasiLengkap ? 'Imunisasi Lengkap' : 'Imunisasi Belum'}
</Badge>
<Badge
size="xs"
color={d.giziBaik ? 'green' : 'orange'}
variant="light"
>
Gizi {d.giziBaik ? 'Baik' : 'Kurang'}
</Badge>
<Badge
size="xs"
color={STUNTING_COLORS[d.statusStunting] ?? 'gray'}
variant="light"
>
{d.statusStunting}
</Badge>
</Group>
<Group gap="xs">
<Button
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() =>
router.push(`/admin/kesehatan/posyandu/balita/edit/${d.id}`)
}
>
Edit
</Button>
<Button
size="xs"
radius="md"
variant="light"
color="red"
leftSection={<IconTrash size={16} />}
onClick={() => handleDelete(d.id, d.nama)}
loading={state.delete.loading}
>
Hapus
</Button>
</Group>
</Box>
</Paper>
))
) : (
<Center py="xl">
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data balita yang cocok
</Text>
</Center>
)}
</Stack>
</Paper>
<Center>
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10, search, statusFilter);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
mt="lg"
mb="md"
color="blue"
radius="md"
/>
</Center>
</Box>
);
}
export default BalitaPage;

View File

@@ -0,0 +1,146 @@
'use client';
import ibuHamilState from '@/app/admin/(dashboard)/_state/kesehatan/ibu-hamil/ibuHamil';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Select,
SimpleGrid,
Stack,
Textarea,
TextInput,
Title,
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useProxy } from 'valtio/utils';
export default function IbuHamilCreatePage() {
const router = useRouter();
const state = useProxy(ibuHamilState);
const form = state.create.form;
const handleSubmit = async () => {
const ok = await ibuHamilState.create.submit();
if (ok) {
ibuHamilState.create.reset();
router.push('/admin/kesehatan/posyandu/ibu-hamil');
}
};
return (
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Group mb="md" gap="sm">
<Button variant="subtle" onClick={() => router.back()} radius="md">
<IconArrowBack size={20} />
</Button>
<Title order={3} c="black">Tambah Ibu Hamil</Title>
</Group>
<Paper withBorder w={{ base: '100%', md: '80%' }} p="lg" radius="md" shadow="xl" bg="white">
<Stack gap="md">
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
<TextInput
label="Nama Lengkap"
required
placeholder="Nama ibu hamil"
value={form.nama}
onChange={(e) => { ibuHamilState.create.form.nama = e.currentTarget.value; }}
radius="md"
/>
<TextInput
label="NIK"
placeholder="Nomor Induk Kependudukan"
value={form.nik}
onChange={(e) => { ibuHamilState.create.form.nik = e.currentTarget.value; }}
radius="md"
/>
<TextInput
label="Usia Kehamilan (minggu)"
type="number"
placeholder="0"
value={String(form.usiaKehamilan)}
onChange={(e) => { ibuHamilState.create.form.usiaKehamilan = Number(e.currentTarget.value) || 0; }}
radius="md"
/>
<TextInput
label="No. HP"
placeholder="08xx-xxxx-xxxx"
value={form.noHp}
onChange={(e) => { ibuHamilState.create.form.noHp = e.currentTarget.value; }}
radius="md"
/>
<TextInput
label="HPHT (Hari Pertama Haid Terakhir)"
type="date"
value={form.hpht}
onChange={(e) => { ibuHamilState.create.form.hpht = e.currentTarget.value; }}
radius="md"
/>
<TextInput
label="Taksiran Persalinan"
type="date"
value={form.taksiranLahir}
onChange={(e) => { ibuHamilState.create.form.taksiranLahir = e.currentTarget.value; }}
radius="md"
/>
<Select
label="Status"
required
data={[
{ value: 'AKTIF', label: 'Aktif' },
{ value: 'MELAHIRKAN', label: 'Melahirkan' },
{ value: 'KEGUGURAN', label: 'Keguguran' },
{ value: 'NONAKTIF', label: 'Nonaktif' },
]}
value={form.status}
onChange={(v) => { if (v) ibuHamilState.create.form.status = v as typeof form.status; }}
radius="md"
/>
</SimpleGrid>
<TextInput
label="Alamat"
placeholder="Alamat lengkap"
value={form.alamat}
onChange={(e) => { ibuHamilState.create.form.alamat = e.currentTarget.value; }}
radius="md"
/>
<Textarea
label="Catatan"
placeholder="Catatan tambahan"
value={form.catatan}
onChange={(e) => { ibuHamilState.create.form.catatan = e.currentTarget.value; }}
radius="md"
rows={3}
/>
<Group justify="right">
<Button variant="outline" color="gray" radius="md" onClick={() => router.back()}>
Batal
</Button>
<Button
onClick={handleSubmit}
radius="md"
disabled={state.create.loading}
style={{
background: state.create.loading
? 'linear-gradient(135deg, #ccc, #eee)'
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
}}
>
{state.create.loading ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}

View File

@@ -0,0 +1,149 @@
'use client';
import ibuHamilState from '@/app/admin/(dashboard)/_state/kesehatan/ibu-hamil/ibuHamil';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Select,
SimpleGrid,
Stack,
Textarea,
TextInput,
Title,
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { useProxy } from 'valtio/utils';
export default function IbuHamilEditPage() {
const router = useRouter();
const params = useParams();
const id = params.id as string;
const state = useProxy(ibuHamilState);
const form = state.edit.form;
useEffect(() => {
if (id) ibuHamilState.edit.load(id);
}, [id]); // eslint-disable-line react-hooks/exhaustive-deps
const handleSubmit = async () => {
const ok = await ibuHamilState.edit.update();
if (ok) router.push('/admin/kesehatan/posyandu/ibu-hamil');
};
return (
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Group mb="md" gap="sm">
<Button variant="subtle" onClick={() => router.back()} radius="md">
<IconArrowBack size={20} />
</Button>
<Title order={3} c="black">Edit Ibu Hamil</Title>
</Group>
<Paper withBorder w={{ base: '100%', md: '80%' }} p="lg" radius="md" shadow="xl" bg="white">
<Stack gap="md">
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
<TextInput
label="Nama Lengkap"
required
placeholder="Nama ibu hamil"
value={form.nama}
onChange={(e) => { ibuHamilState.edit.form.nama = e.currentTarget.value; }}
radius="md"
/>
<TextInput
label="NIK"
placeholder="Nomor Induk Kependudukan"
value={form.nik}
onChange={(e) => { ibuHamilState.edit.form.nik = e.currentTarget.value; }}
radius="md"
/>
<TextInput
label="Usia Kehamilan (minggu)"
type="number"
placeholder="0"
value={String(form.usiaKehamilan)}
onChange={(e) => { ibuHamilState.edit.form.usiaKehamilan = Number(e.currentTarget.value) || 0; }}
radius="md"
/>
<TextInput
label="No. HP"
placeholder="08xx-xxxx-xxxx"
value={form.noHp}
onChange={(e) => { ibuHamilState.edit.form.noHp = e.currentTarget.value; }}
radius="md"
/>
<TextInput
label="HPHT"
type="date"
value={form.hpht}
onChange={(e) => { ibuHamilState.edit.form.hpht = e.currentTarget.value; }}
radius="md"
/>
<TextInput
label="Taksiran Persalinan"
type="date"
value={form.taksiranLahir}
onChange={(e) => { ibuHamilState.edit.form.taksiranLahir = e.currentTarget.value; }}
radius="md"
/>
<Select
label="Status"
required
data={[
{ value: 'AKTIF', label: 'Aktif' },
{ value: 'MELAHIRKAN', label: 'Melahirkan' },
{ value: 'KEGUGURAN', label: 'Keguguran' },
{ value: 'NONAKTIF', label: 'Nonaktif' },
]}
value={form.status}
onChange={(v) => { if (v) ibuHamilState.edit.form.status = v as typeof form.status; }}
radius="md"
/>
</SimpleGrid>
<TextInput
label="Alamat"
placeholder="Alamat lengkap"
value={form.alamat}
onChange={(e) => { ibuHamilState.edit.form.alamat = e.currentTarget.value; }}
radius="md"
/>
<Textarea
label="Catatan"
placeholder="Catatan tambahan"
value={form.catatan}
onChange={(e) => { ibuHamilState.edit.form.catatan = e.currentTarget.value; }}
radius="md"
rows={3}
/>
<Group justify="right">
<Button variant="outline" color="gray" radius="md" onClick={() => router.back()}>
Batal
</Button>
<Button
onClick={handleSubmit}
radius="md"
disabled={state.edit.loading}
style={{
background: state.edit.loading
? 'linear-gradient(135deg, #ccc, #eee)'
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
}}
>
{state.edit.loading ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}

View File

@@ -0,0 +1,283 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import ibuHamilState from '@/app/admin/(dashboard)/_state/kesehatan/ibu-hamil/ibuHamil';
import colors from '@/con/colors';
import {
Badge,
Box,
Button,
Center,
Group,
Pagination,
Paper,
Select,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title,
} from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { IconDeviceImacCog, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header';
const STATUS_COLORS: Record<string, string> = {
AKTIF: 'green',
MELAHIRKAN: 'blue',
KEGUGURAN: 'gray',
NONAKTIF: 'red',
};
function IbuHamilPage() {
const [search, setSearch] = useState('');
return (
<Box>
<HeaderSearch
title="Ibu Hamil"
placeholder="Cari nama / NIK..."
searchIcon={<IconSearch size={20} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<ListIbuHamil search={search} />
</Box>
);
}
function ListIbuHamil({ search }: { search: string }) {
const state = useProxy(ibuHamilState);
const router = useRouter();
const [statusFilter, setStatusFilter] = useState('');
const [debouncedSearch] = useDebouncedValue(search, 1000);
const { data, page, totalPages, loading, load } = state.findMany;
useEffect(() => {
load(page, 10, debouncedSearch, statusFilter);
}, [page, debouncedSearch, statusFilter]);
const handleDelete = async (id: string, nama: string) => {
if (!confirm(`Hapus data ibu hamil "${nama}"?`)) return;
await state.delete.byId(id);
};
const filteredData = data || [];
if (loading || !data) {
return (
<Stack py="md">
<Skeleton h={600} radius="md" />
</Stack>
);
}
return (
<Box py="md">
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Group justify="space-between" mb="lg">
<Title order={4}>List Ibu Hamil</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/kesehatan/posyandu/ibu-hamil/create')}
>
Tambah Baru
</Button>
</Group>
<Group mb="md">
<Select
placeholder="Filter status"
data={[
{ value: '', label: 'Semua Status' },
{ value: 'AKTIF', label: 'Aktif' },
{ value: 'MELAHIRKAN', label: 'Melahirkan' },
{ value: 'KEGUGURAN', label: 'Keguguran' },
{ value: 'NONAKTIF', label: 'Nonaktif' },
]}
value={statusFilter}
onChange={(v) => setStatusFilter(v ?? '')}
radius="md"
clearable
/>
</Group>
{/* Desktop Table */}
<Box visibleFrom="md" style={{ overflowX: 'auto' }}>
<Table highlightOnHover layout="fixed" withColumnBorders={false}>
<TableThead>
<TableTr>
<TableTh w="20%">Nama</TableTh>
<TableTh w="15%">NIK</TableTh>
<TableTh w="15%">Usia Kehamilan</TableTh>
<TableTh w="13%">No. HP</TableTh>
<TableTh w="15%">Banjar</TableTh>
<TableTh w="10%">Status</TableTh>
<TableTh w="12%">Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((d) => (
<TableTr key={d.id}>
<TableTd>{d.nama}</TableTd>
<TableTd>{d.nik || '-'}</TableTd>
<TableTd>{d.usiaKehamilan} minggu</TableTd>
<TableTd>{d.noHp || '-'}</TableTd>
<TableTd>{d.posyandu?.banjar?.name ?? '-'}</TableTd>
<TableTd>
<Badge
color={STATUS_COLORS[d.status] ?? 'gray'}
variant="light"
>
{d.status}
</Badge>
</TableTd>
<TableTd>
<Group gap="xs">
<Button
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() =>
router.push(`/admin/kesehatan/posyandu/ibu-hamil/edit/${d.id}`)
}
>
Edit
</Button>
<Button
size="xs"
radius="md"
variant="light"
color="red"
leftSection={<IconTrash size={16} />}
onClick={() => handleDelete(d.id, d.nama)}
loading={state.delete.loading}
>
Hapus
</Button>
</Group>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={7}>
<Center py="xl">
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data ibu hamil yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
{/* Mobile Cards */}
<Stack gap="sm" hiddenFrom="md">
{filteredData.length > 0 ? (
filteredData.map((d) => (
<Paper key={d.id} withBorder p="md" radius="md">
<Box>
<Text fz="sm" fw={600} mb={4}>
{d.nama}
</Text>
<Group gap="xs" mb={4}>
<Text fz="xs" c="dimmed">
NIK: {d.nik || '-'}
</Text>
<Text fz="xs" c="dimmed">·</Text>
<Text fz="xs" c="dimmed">
{d.usiaKehamilan} minggu
</Text>
</Group>
{d.posyandu?.banjar?.name && (
<Text fz="xs" c="dimmed" mb={4}>
Banjar: {d.posyandu.banjar.name}
</Text>
)}
<Group gap="xs" mb={8}>
<Badge
size="xs"
color={STATUS_COLORS[d.status] ?? 'gray'}
variant="light"
>
{d.status}
</Badge>
{d.noHp && (
<Text fz="xs" c="dimmed">
{d.noHp}
</Text>
)}
</Group>
<Group gap="xs">
<Button
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() =>
router.push(`/admin/kesehatan/posyandu/ibu-hamil/edit/${d.id}`)
}
>
Edit
</Button>
<Button
size="xs"
radius="md"
variant="light"
color="red"
leftSection={<IconTrash size={16} />}
onClick={() => handleDelete(d.id, d.nama)}
loading={state.delete.loading}
>
Hapus
</Button>
</Group>
</Box>
</Paper>
))
) : (
<Center py="xl">
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data ibu hamil yang cocok
</Text>
</Center>
)}
</Stack>
</Paper>
<Center>
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10, search, statusFilter);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
mt="lg"
mb="md"
color="blue"
radius="md"
/>
</Center>
</Box>
);
}
export default IbuHamilPage;

View File

@@ -0,0 +1,35 @@
'use client'
import { Box } from '@mantine/core';
import { usePathname } from 'next/navigation';
import React from 'react';
import LayoutTabsPosyandu from './_com/layoutTabs';
function Layout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
// Contoh path:
// - /darmasaba/desa/berita/semua → panjang 5 → list
// - /darmasaba/desa/berita/Pemerintahan → panjang 5 → list
// - /darmasaba/desa/berita/Pemerintahan/123 → panjang 6 → detail
const segments = pathname.split('/').filter(Boolean);
const isDetailPage = segments.length >= 5;
if (isDetailPage) {
// Tampilkan tanpa tab menu
return (
<Box>
{children}
</Box>
);
}
return (
<LayoutTabsPosyandu>
{children}
</LayoutTabsPosyandu>
);
}
export default Layout;

View File

@@ -1,8 +1,8 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
import posyandustate from '@/app/admin/(dashboard)/_state/kesehatan/posyandu/posyandu';
import ringkasanKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/ringkasan-kesehatan/ringkasanKesehatan';
import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch';
import {
@@ -13,6 +13,7 @@ import {
Image,
Loader,
Paper,
Select,
Stack,
Text,
TextInput,
@@ -27,6 +28,7 @@ import { useProxy } from 'valtio/utils';
function EditPosyandu() {
const statePosyandu = useProxy(posyandustate);
const stateBanjar = useProxy(ringkasanKesehatanState);
const router = useRouter();
const params = useParams();
@@ -58,6 +60,7 @@ function EditPosyandu() {
deskripsi: '',
imageId: '',
jadwalPelayanan: '',
banjarId: '',
});
const [originalData, setOriginalData] = useState({
name: "",
@@ -65,11 +68,13 @@ function EditPosyandu() {
deskripsi: "",
imageId: "",
jadwalPelayanan: "",
banjarId: "",
imageUrl: ""
});
// Load data posyandu
// Load data posyandu dan banjar
useEffect(() => {
ringkasanKesehatanState.findBanjar.load();
const loadPosyandu = async () => {
const id = params?.id as string;
if (!id) return;
@@ -83,6 +88,7 @@ function EditPosyandu() {
deskripsi: data.deskripsi || '',
imageId: data.imageId || '',
jadwalPelayanan: data.jadwalPelayanan || '',
banjarId: data.banjarId || '',
});
setOriginalData({
name: data.name || '',
@@ -90,6 +96,7 @@ function EditPosyandu() {
deskripsi: data.deskripsi || '',
imageId: data.imageId || '',
jadwalPelayanan: data.jadwalPelayanan || '',
banjarId: data.banjarId || '',
imageUrl: data.image?.link || '',
});
if (data?.image?.link) setPreviewImage(data.image.link);
@@ -130,7 +137,7 @@ function EditPosyandu() {
try {
setIsSubmitting(true);
const updatedForm = { ...statePosyandu.edit.form, ...formData };
const updatedForm = { ...statePosyandu.edit.form, ...formData, banjarId: formData.banjarId };
// Upload file jika ada
if (file) {
@@ -145,7 +152,7 @@ function EditPosyandu() {
await statePosyandu.edit.update();
toast.success('Posyandu berhasil diperbarui!');
router.push('/admin/kesehatan/posyandu');
router.push('/admin/kesehatan/posyandu/list-posyandu');
} catch (error) {
console.error('Error updating posyandu:', error);
toast.error('Terjadi kesalahan saat memperbarui posyandu');
@@ -161,6 +168,7 @@ function EditPosyandu() {
deskripsi: originalData.deskripsi,
imageId: originalData.imageId,
jadwalPelayanan: originalData.jadwalPelayanan,
banjarId: originalData.banjarId,
});
setPreviewImage(originalData.imageUrl || null);
setFile(null);
@@ -168,7 +176,7 @@ function EditPosyandu() {
};
return (
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Box px={{ base: 0, md: 'lg' }} py="xs">
{/* Tombol Back */}
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
@@ -283,6 +291,15 @@ function EditPosyandu() {
required
/>
<Select
label="Banjar"
placeholder="Pilih banjar"
clearable
data={stateBanjar.findBanjar.data.map((b) => ({ value: b.id, label: b.name }))}
value={formData.banjarId || null}
onChange={(val) => setFormData({ ...formData, banjarId: val ?? '' })}
/>
<Box>
<Text fw="bold" fz="sm" mb={6}>
Deskripsi Posyandu

View File

@@ -1,4 +1,6 @@
'use client'
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import posyandustate from '@/app/admin/(dashboard)/_state/kesehatan/posyandu/posyandu';
import colors from '@/con/colors';
import { Box, Button, Group, Image, Paper, Skeleton, Stack, Text } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
@@ -6,12 +8,11 @@ import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
import posyanduState from '../../../_state/kesehatan/posyandu/posyandu';
function DetailPosyandu() {
const statePosyandu = useProxy(posyanduState);
const statePosyandu = useProxy(posyandustate);
const params = useParams();
const router = useRouter();
const [modalHapus, setModalHapus] = useState(false);
@@ -28,7 +29,7 @@ function DetailPosyandu() {
statePosyandu.delete.byId(selectedId);
setModalHapus(false);
setSelectedId(null);
router.push("/admin/kesehatan/posyandu");
router.push("/admin/kesehatan/posyandu/list-posyandu");
}
};
@@ -81,6 +82,11 @@ function DetailPosyandu() {
</Box>
<Box>
<Text fz="lg" fw="bold">Banjar</Text>
<Text fz="md" c="dimmed">{data.banjar?.name || '-'}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Nomor Posyandu</Text>
<Text fz="md" c="dimmed">{data.nomor || '-'}</Text>
@@ -147,7 +153,7 @@ function DetailPosyandu() {
<Button
color="green"
onClick={() => router.push(`/admin/kesehatan/posyandu/${data.id}/edit`)}
onClick={() => router.push(`/admin/kesehatan/posyandu/list-posyandu/${data.id}/edit`)}
variant="light"
radius="md"
size="md"

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