Compare commits
62 Commits
23c955597e
...
stg
| Author | SHA1 | Date | |
|---|---|---|---|
| f2a37115a3 | |||
| bf9c37ff98 | |||
| 8f2081cc77 | |||
| c5bbafa20a | |||
| b0a020871f | |||
| f35833078b | |||
| 04c8ce81c1 | |||
| 2b1dd5fb71 | |||
| 4c45c679ce | |||
| 0f5c905392 | |||
| a67c192c83 | |||
| be3365c9e3 | |||
| d0b559f774 | |||
| 97d08734c5 | |||
| 9de32e5f12 | |||
| 3a837e1fdc | |||
| e9e7c17ee3 | |||
| 0a3dc1dc43 | |||
| 7200caa125 | |||
| 4ad86b4cad | |||
| 45cd1ca6f8 | |||
| ce3ac75529 | |||
| d716a59ca5 | |||
| 13b08aa057 | |||
| 3e5cf9e2fb | |||
| 71562961e8 | |||
| f52981c9b0 | |||
| 20d1b9aa4b | |||
| 5e1b913e04 | |||
| aef07fab8e | |||
| 27a42f7ff0 | |||
| 9e213ade8e | |||
| ea70f6f6e9 | |||
| da0a98d989 | |||
| 7bdf1357ad | |||
| 936dd14ca9 | |||
| 60841039dd | |||
| 16b9b74a73 | |||
| c0b08f4f69 | |||
| 7b14923620 | |||
| 3cc09c83d8 | |||
| 5658063f68 | |||
| d7e1192ab0 | |||
| 8857853baf | |||
| ce26bc7cc8 | |||
| b479991c27 | |||
| e71c938b2f | |||
| ff25ead2df | |||
| 2497298703 | |||
| ba632f9d39 | |||
| f1ee53a7b9 | |||
| fd2060405f | |||
| afe0d9d04b | |||
| dccba1f82b | |||
| fc6846f7a1 | |||
| 9ef5773cc2 | |||
| 68a2a6390b | |||
| ba2b90be75 | |||
| 3cc30bf0ff | |||
| 0a5d17f45e | |||
| 83a2dece57 | |||
| e0a5177257 |
@@ -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 |
|
||||
|
||||
4
.github/workflows/publish.yml
vendored
4
.github/workflows/publish.yml
vendored
@@ -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
321
AI-CONTRACT.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
19
MIND/PLAN/ai-collaboration-contract.md
Normal file
19
MIND/PLAN/ai-collaboration-contract.md
Normal 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.
|
||||
11
MIND/PLAN/task-ai-collaboration-contract.md
Normal file
11
MIND/PLAN/task-ai-collaboration-contract.md
Normal 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`
|
||||
246
MIND/PLAN/task-statistik-kesehatan-ringkasan.md
Normal file
246
MIND/PLAN/task-statistik-kesehatan-ringkasan.md
Normal 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
|
||||
50
MIND/SUMMARY/8-Jun-26/fix-otp-csprng-summary.md
Normal file
50
MIND/SUMMARY/8-Jun-26/fix-otp-csprng-summary.md
Normal 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 (1000–9999).
|
||||
- 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)
|
||||
@@ -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 31–35)
|
||||
|
||||
**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
|
||||
19
MIND/SUMMARY/ai-collaboration-contract-summary.md
Normal file
19
MIND/SUMMARY/ai-collaboration-contract-summary.md
Normal 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.
|
||||
66
MIND/SUMMARY/banjar-grafik-kepuasan-integration-summary.md
Normal file
66
MIND/SUMMARY/banjar-grafik-kepuasan-integration-summary.md
Normal 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
|
||||
@@ -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 (2025–2026):**
|
||||
- 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
|
||||
@@ -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
|
||||
62
MIND/SUMMARY/statistik-kesehatan-ringkasan-summary.md
Normal file
62
MIND/SUMMARY/statistik-kesehatan-ringkasan-summary.md
Normal 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` (0–100)** — 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
|
||||
842
STRUKTUR.md
842
STRUKTUR.md
@@ -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 |
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "desa-darmasaba",
|
||||
"version": "0.1.46",
|
||||
"version": "0.1.69",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
30
prisma/_seeder_list/desa/event-budaya/seed_event_budaya.ts
Normal file
30
prisma/_seeder_list/desa/event-budaya/seed_event_budaya.ts
Normal 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");
|
||||
}
|
||||
@@ -27,6 +27,8 @@ export async function seedAPBDes() {
|
||||
jumlah: item.jumlah,
|
||||
imageId,
|
||||
fileId,
|
||||
isActive: true,
|
||||
deletedAt: null,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
|
||||
@@ -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,
|
||||
|
||||
35
prisma/_seeder_list/keamanan/seed_cctv.ts
Normal file
35
prisma/_seeder_list/keamanan/seed_cctv.ts
Normal 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`);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
502
prisma/_seeder_list/kesehatan/seed_balita.ts
Normal file
502
prisma/_seeder_list/kesehatan/seed_balita.ts
Normal 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");
|
||||
}
|
||||
19
prisma/_seeder_list/kesehatan/seed_banjar.ts
Normal file
19
prisma/_seeder_list/kesehatan/seed_banjar.ts
Normal 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");
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
222
prisma/_seeder_list/kesehatan/seed_ibu_hamil.ts
Normal file
222
prisma/_seeder_list/kesehatan/seed_ibu_hamil.ts
Normal 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");
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
240
prisma/data/desa/event-budaya/event-budaya.json
Normal file
240
prisma/data/desa/event-budaya/event-budaya.json
Normal 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."
|
||||
}
|
||||
]
|
||||
@@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
82
prisma/data/keamanan/cctv/cctv.json
Normal file
82
prisma/data/keamanan/cctv/cctv.json
Normal 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"
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
10
prisma/data/kesehatan/banjar/banjar.json
Normal file
10
prisma/data/kesehatan/banjar/banjar.json
Normal 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" }
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[
|
||||
{
|
||||
"id": "struktur-org-ppid-001",
|
||||
"posisiOrganisasiId": "posisi-001",
|
||||
"pegawaiId": "pegawai-001",
|
||||
"posisiOrganisasiId": "kepala_desa",
|
||||
"pegawaiId": "cmgewz4gt000704ib91i3f169",
|
||||
"hubunganOrganisasiId": "hubungan-001"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
160
src/app/admin/(dashboard)/_state/desa/banjar.ts
Normal file
160
src/app/admin/(dashboard)/_state/desa/banjar.ts
Normal 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;
|
||||
233
src/app/admin/(dashboard)/_state/desa/eventBudaya.ts
Normal file
233
src/app/admin/(dashboard)/_state/desa/eventBudaya.ts
Normal 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;
|
||||
409
src/app/admin/(dashboard)/_state/desa/kegiatanDesa.ts
Normal file
409
src/app/admin/(dashboard)/_state/desa/kegiatanDesa.ts
Normal 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;
|
||||
153
src/app/admin/(dashboard)/_state/desa/profile/lambangDesa.ts
Normal file
153
src/app/admin/(dashboard)/_state/desa/profile/lambangDesa.ts
Normal 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;
|
||||
241
src/app/admin/(dashboard)/_state/desa/profile/mantanPerbekel.ts
Normal file
241
src/app/admin/(dashboard)/_state/desa/profile/mantanPerbekel.ts
Normal 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;
|
||||
183
src/app/admin/(dashboard)/_state/desa/profile/maskotDesa.ts
Normal file
183
src/app/admin/(dashboard)/_state/desa/profile/maskotDesa.ts
Normal 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;
|
||||
185
src/app/admin/(dashboard)/_state/desa/profile/profilPerbekel.ts
Normal file
185
src/app/admin/(dashboard)/_state/desa/profile/profilPerbekel.ts
Normal 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;
|
||||
153
src/app/admin/(dashboard)/_state/desa/profile/sejarahDesa.ts
Normal file
153
src/app/admin/(dashboard)/_state/desa/profile/sejarahDesa.ts
Normal 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;
|
||||
153
src/app/admin/(dashboard)/_state/desa/profile/visiMisiDesa.ts
Normal file
153
src/app/admin/(dashboard)/_state/desa/profile/visiMisiDesa.ts
Normal 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;
|
||||
239
src/app/admin/(dashboard)/_state/keamanan/cctv.ts
Normal file
239
src/app/admin/(dashboard)/_state/keamanan/cctv.ts
Normal 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;
|
||||
226
src/app/admin/(dashboard)/_state/kesehatan/balita/balita.ts
Normal file
226
src/app/admin/(dashboard)/_state/kesehatan/balita/balita.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
211
src/app/admin/(dashboard)/_state/kesehatan/ibu-hamil/ibuHamil.ts
Normal file
211
src/app/admin/(dashboard)/_state/kesehatan/ibu-hamil/ibuHamil.ts
Normal 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;
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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:
|
||||
|
||||
123
src/app/admin/(dashboard)/desa/banjar/[id]/page.tsx
Normal file
123
src/app/admin/(dashboard)/desa/banjar/[id]/page.tsx
Normal 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;
|
||||
103
src/app/admin/(dashboard)/desa/banjar/create/page.tsx
Normal file
103
src/app/admin/(dashboard)/desa/banjar/create/page.tsx
Normal 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;
|
||||
211
src/app/admin/(dashboard)/desa/banjar/page.tsx
Normal file
211
src/app/admin/(dashboard)/desa/banjar/page.tsx
Normal 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;
|
||||
110
src/app/admin/(dashboard)/desa/event-budaya/[id]/edit/page.tsx
Normal file
110
src/app/admin/(dashboard)/desa/event-budaya/[id]/edit/page.tsx
Normal 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;
|
||||
141
src/app/admin/(dashboard)/desa/event-budaya/[id]/page.tsx
Normal file
141
src/app/admin/(dashboard)/desa/event-budaya/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
src/app/admin/(dashboard)/desa/event-budaya/create/page.tsx
Normal file
95
src/app/admin/(dashboard)/desa/event-budaya/create/page.tsx
Normal 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;
|
||||
16
src/app/admin/(dashboard)/desa/event-budaya/layout.tsx
Normal file
16
src/app/admin/(dashboard)/desa/event-budaya/layout.tsx
Normal 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;
|
||||
199
src/app/admin/(dashboard)/desa/event-budaya/page.tsx
Normal file
199
src/app/admin/(dashboard)/desa/event-budaya/page.tsx
Normal 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;
|
||||
106
src/app/admin/(dashboard)/desa/kegiatan-desa/_com/layoutTabs.tsx
Normal file
106
src/app/admin/(dashboard)/desa/kegiatan-desa/_com/layoutTabs.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
28
src/app/admin/(dashboard)/desa/kegiatan-desa/layout.tsx
Normal file
28
src/app/admin/(dashboard)/desa/kegiatan-desa/layout.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
190
src/app/admin/(dashboard)/keamanan/cctv/[id]/edit/page.tsx
Normal file
190
src/app/admin/(dashboard)/keamanan/cctv/[id]/edit/page.tsx
Normal 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;
|
||||
160
src/app/admin/(dashboard)/keamanan/cctv/[id]/page.tsx
Normal file
160
src/app/admin/(dashboard)/keamanan/cctv/[id]/page.tsx
Normal 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;
|
||||
177
src/app/admin/(dashboard)/keamanan/cctv/create/page.tsx
Normal file
177
src/app/admin/(dashboard)/keamanan/cctv/create/page.tsx
Normal 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;
|
||||
215
src/app/admin/(dashboard)/keamanan/cctv/page.tsx
Normal file
215
src/app/admin/(dashboard)/keamanan/cctv/page.tsx
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
161
src/app/admin/(dashboard)/kesehatan/posyandu/_com/layoutTabs.tsx
Normal file
161
src/app/admin/(dashboard)/kesehatan/posyandu/_com/layoutTabs.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
312
src/app/admin/(dashboard)/kesehatan/posyandu/balita/page.tsx
Normal file
312
src/app/admin/(dashboard)/kesehatan/posyandu/balita/page.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
283
src/app/admin/(dashboard)/kesehatan/posyandu/ibu-hamil/page.tsx
Normal file
283
src/app/admin/(dashboard)/kesehatan/posyandu/ibu-hamil/page.tsx
Normal 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;
|
||||
35
src/app/admin/(dashboard)/kesehatan/posyandu/layout.tsx
Normal file
35
src/app/admin/(dashboard)/kesehatan/posyandu/layout.tsx
Normal 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;
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user