Compare commits
10 Commits
28a22e8d77
...
tasks/stat
| Author | SHA1 | Date | |
|---|---|---|---|
| dccba1f82b | |||
| fc6846f7a1 | |||
| 9ef5773cc2 | |||
| 68a2a6390b | |||
| ba2b90be75 | |||
| 3cc30bf0ff | |||
| 0a5d17f45e | |||
| 83a2dece57 | |||
| e0a5177257 | |||
| 23c955597e |
@@ -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 |
|
||||
|
||||
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,7 @@ 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
|
||||
- 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
|
||||
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.
|
||||
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
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "desa-darmasaba",
|
||||
"version": "0.1.45",
|
||||
"version": "0.1.52",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -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;
|
||||
@@ -1159,6 +1159,8 @@ model Posyandu {
|
||||
jadwalPelayanan String
|
||||
image FileStorage? @relation(fields: [imageId], references: [id])
|
||||
imageId String?
|
||||
ibuHamil IbuHamil[]
|
||||
balita Balita[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime @default(now())
|
||||
@@ -2481,14 +2483,79 @@ 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())
|
||||
ibuHamilAkh Int @default(0)
|
||||
balitaTerdaftar Int @default(0)
|
||||
alertStunting Int @default(0)
|
||||
imunisasiLengkapPct Int @default(0)
|
||||
pemeriksaanRutinPct Int @default(0)
|
||||
giziBaikPct Int @default(0)
|
||||
targetStuntingPct Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
218
src/app/admin/(dashboard)/_state/kesehatan/balita/balita.ts
Normal file
218
src/app/admin/(dashboard)/_state/kesehatan/balita/balita.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
/* 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 } } };
|
||||
}>[]
|
||||
| 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;
|
||||
203
src/app/admin/(dashboard)/_state/kesehatan/ibu-hamil/ibuHamil.ts
Normal file
203
src/app/admin/(dashboard)/_state/kesehatan/ibu-hamil/ibuHamil.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/* 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 } } };
|
||||
}>[]
|
||||
| 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;
|
||||
@@ -0,0 +1,179 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
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" });
|
||||
|
||||
const intCount = z
|
||||
.number({ invalid_type_error: "Harus berupa angka" })
|
||||
.int({ message: "Harus bilangan bulat" })
|
||||
.min(0, { message: "Minimal 0" });
|
||||
|
||||
const templateForm = z.object({
|
||||
ibuHamilAkh: intCount,
|
||||
balitaTerdaftar: intCount,
|
||||
alertStunting: intCount,
|
||||
imunisasiLengkapPct: intPct,
|
||||
pemeriksaanRutinPct: intPct,
|
||||
giziBaikPct: intPct,
|
||||
targetStuntingPct: intPct,
|
||||
});
|
||||
|
||||
const defaultForm = {
|
||||
ibuHamilAkh: 0,
|
||||
balitaTerdaftar: 0,
|
||||
alertStunting: 0,
|
||||
imunisasiLengkapPct: 0,
|
||||
pemeriksaanRutinPct: 0,
|
||||
giziBaikPct: 0,
|
||||
targetStuntingPct: 0,
|
||||
};
|
||||
|
||||
const ringkasanKesehatanState = proxy({
|
||||
// Derived stats aggregated from IbuHamil + Balita tables
|
||||
findStats: {
|
||||
data: null as StatsData | null,
|
||||
loading: false,
|
||||
async load() {
|
||||
try {
|
||||
ringkasanKesehatanState.findStats.loading = true;
|
||||
const res = await fetch(`/api/kesehatan/ringkasankesehatan/stats`);
|
||||
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;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Kept for backward-compat — now only used internally for targetStuntingPct config
|
||||
findUnique: {
|
||||
data: null as Prisma.RingkasanKesehatanDesaGetPayload<object> | null,
|
||||
loading: false,
|
||||
async load() {
|
||||
try {
|
||||
ringkasanKesehatanState.findUnique.loading = true;
|
||||
const res = await fetch(`/api/kesehatan/ringkasankesehatan/find`);
|
||||
if (res.ok) {
|
||||
const result = await res.json();
|
||||
ringkasanKesehatanState.findUnique.data = result?.data ?? null;
|
||||
if (result?.data) {
|
||||
ringkasanKesehatanState.update.form = {
|
||||
ibuHamilAkh: result.data.ibuHamilAkh,
|
||||
balitaTerdaftar: result.data.balitaTerdaftar,
|
||||
alertStunting: result.data.alertStunting,
|
||||
imunisasiLengkapPct: result.data.imunisasiLengkapPct,
|
||||
pemeriksaanRutinPct: result.data.pemeriksaanRutinPct,
|
||||
giziBaikPct: result.data.giziBaikPct,
|
||||
targetStuntingPct: result.data.targetStuntingPct,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
ringkasanKesehatanState.findUnique.data = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching ringkasan kesehatan:", error);
|
||||
ringkasanKesehatanState.findUnique.data = null;
|
||||
} finally {
|
||||
ringkasanKesehatanState.findUnique.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
update: {
|
||||
form: { ...defaultForm },
|
||||
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(ringkasanKesehatanState.update.form),
|
||||
});
|
||||
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;
|
||||
}
|
||||
},
|
||||
// Kept for backward-compat (full update)
|
||||
async submit() {
|
||||
const cek = templateForm.safeParse(ringkasanKesehatanState.update.form);
|
||||
if (!cek.success) {
|
||||
const err = `[${cek.error.issues
|
||||
.map((v) => `${v.path.join(".")}`)
|
||||
.join("\n")}] invalid`;
|
||||
toast.error(err);
|
||||
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(ringkasanKesehatanState.update.form),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
toast.success(result.message || "Berhasil disimpan");
|
||||
await ringkasanKesehatanState.findUnique.load();
|
||||
return true;
|
||||
}
|
||||
throw new Error(result.message || "Gagal menyimpan");
|
||||
} catch (error) {
|
||||
console.error("Error updating ringkasan kesehatan:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Gagal menyimpan");
|
||||
return false;
|
||||
} finally {
|
||||
ringkasanKesehatanState.update.loading = false;
|
||||
}
|
||||
},
|
||||
reset() {
|
||||
ringkasanKesehatanState.update.form = { ...defaultForm };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default ringkasanKesehatanState;
|
||||
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;
|
||||
186
src/app/admin/(dashboard)/kesehatan/balita/create/page.tsx
Normal file
186
src/app/admin/(dashboard)/kesehatan/balita/create/page.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
'use client';
|
||||
|
||||
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';
|
||||
import balitaState from '../../../_state/kesehatan/balita/balita';
|
||||
|
||||
export default function BalitaCreatePage() {
|
||||
const router = useRouter();
|
||||
const state = useProxy(balitaState);
|
||||
const form = state.create.form;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const ok = await state.create.submit();
|
||||
if (ok) {
|
||||
state.create.reset();
|
||||
router.push('/admin/kesehatan/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) => { form.nama = e.currentTarget.value; }}
|
||||
radius="md"
|
||||
/>
|
||||
<TextInput
|
||||
label="NIK"
|
||||
placeholder="Nomor Induk Kependudukan"
|
||||
value={form.nik}
|
||||
onChange={(e) => { form.nik = e.currentTarget.value; }}
|
||||
radius="md"
|
||||
/>
|
||||
<TextInput
|
||||
label="Tanggal Lahir"
|
||||
required
|
||||
type="date"
|
||||
value={form.tanggalLahir}
|
||||
onChange={(e) => { 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) 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) => { 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) => { form.tinggiBadanCm = v === '' ? undefined : Number(v); }}
|
||||
radius="md"
|
||||
/>
|
||||
<TextInput
|
||||
label="Nama Orang Tua"
|
||||
placeholder="Nama ayah/ibu"
|
||||
value={form.namaOrtu}
|
||||
onChange={(e) => { form.namaOrtu = e.currentTarget.value; }}
|
||||
radius="md"
|
||||
/>
|
||||
<TextInput
|
||||
label="No. HP Orang Tua"
|
||||
placeholder="08xx-xxxx-xxxx"
|
||||
value={form.noHpOrtu}
|
||||
onChange={(e) => { 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) form.statusStunting = v as typeof form.statusStunting; }}
|
||||
radius="md"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<TextInput
|
||||
label="Alamat"
|
||||
placeholder="Alamat lengkap"
|
||||
value={form.alamat}
|
||||
onChange={(e) => { form.alamat = e.currentTarget.value; }}
|
||||
radius="md"
|
||||
/>
|
||||
|
||||
<Group gap="xl">
|
||||
<Checkbox
|
||||
label="Imunisasi Lengkap"
|
||||
checked={form.imunisasiLengkap}
|
||||
onChange={(e) => { form.imunisasiLengkap = e.currentTarget.checked; }}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Gizi Baik"
|
||||
checked={form.giziBaik}
|
||||
onChange={(e) => { form.giziBaik = e.currentTarget.checked; }}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Pemeriksaan Rutin"
|
||||
checked={form.pemeriksaanRutin}
|
||||
onChange={(e) => { form.pemeriksaanRutin = e.currentTarget.checked; }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Textarea
|
||||
label="Catatan"
|
||||
placeholder="Catatan tambahan"
|
||||
value={form.catatan}
|
||||
onChange={(e) => { 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>
|
||||
);
|
||||
}
|
||||
190
src/app/admin/(dashboard)/kesehatan/balita/edit/[id]/page.tsx
Normal file
190
src/app/admin/(dashboard)/kesehatan/balita/edit/[id]/page.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
'use client';
|
||||
|
||||
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';
|
||||
import balitaState from '../../../../_state/kesehatan/balita/balita';
|
||||
|
||||
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) state.edit.load(id);
|
||||
}, [id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const ok = await state.edit.update();
|
||||
if (ok) router.push('/admin/kesehatan/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) => { form.nama = e.currentTarget.value; }}
|
||||
radius="md"
|
||||
/>
|
||||
<TextInput
|
||||
label="NIK"
|
||||
placeholder="Nomor Induk Kependudukan"
|
||||
value={form.nik}
|
||||
onChange={(e) => { form.nik = e.currentTarget.value; }}
|
||||
radius="md"
|
||||
/>
|
||||
<TextInput
|
||||
label="Tanggal Lahir"
|
||||
required
|
||||
type="date"
|
||||
value={form.tanggalLahir}
|
||||
onChange={(e) => { 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) 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) => { 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) => { form.tinggiBadanCm = v === '' ? undefined : Number(v); }}
|
||||
radius="md"
|
||||
/>
|
||||
<TextInput
|
||||
label="Nama Orang Tua"
|
||||
placeholder="Nama ayah/ibu"
|
||||
value={form.namaOrtu}
|
||||
onChange={(e) => { form.namaOrtu = e.currentTarget.value; }}
|
||||
radius="md"
|
||||
/>
|
||||
<TextInput
|
||||
label="No. HP Orang Tua"
|
||||
placeholder="08xx-xxxx-xxxx"
|
||||
value={form.noHpOrtu}
|
||||
onChange={(e) => { 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) form.statusStunting = v as typeof form.statusStunting; }}
|
||||
radius="md"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<TextInput
|
||||
label="Alamat"
|
||||
placeholder="Alamat lengkap"
|
||||
value={form.alamat}
|
||||
onChange={(e) => { form.alamat = e.currentTarget.value; }}
|
||||
radius="md"
|
||||
/>
|
||||
|
||||
<Group gap="xl">
|
||||
<Checkbox
|
||||
label="Imunisasi Lengkap"
|
||||
checked={form.imunisasiLengkap}
|
||||
onChange={(e) => { form.imunisasiLengkap = e.currentTarget.checked; }}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Gizi Baik"
|
||||
checked={form.giziBaik}
|
||||
onChange={(e) => { form.giziBaik = e.currentTarget.checked; }}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Pemeriksaan Rutin"
|
||||
checked={form.pemeriksaanRutin}
|
||||
onChange={(e) => { form.pemeriksaanRutin = e.currentTarget.checked; }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Textarea
|
||||
label="Catatan"
|
||||
placeholder="Catatan tambahan"
|
||||
value={form.catatan}
|
||||
onChange={(e) => { 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>
|
||||
);
|
||||
}
|
||||
188
src/app/admin/(dashboard)/kesehatan/balita/page.tsx
Normal file
188
src/app/admin/(dashboard)/kesehatan/balita/page.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
'use client';
|
||||
|
||||
import colors from '@/con/colors';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Pagination,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
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 balitaState from '../../_state/kesehatan/balita/balita';
|
||||
|
||||
const STUNTING_COLORS: Record<string, string> = {
|
||||
NORMAL: 'green',
|
||||
ALERT: 'yellow',
|
||||
STUNTING: 'red',
|
||||
};
|
||||
|
||||
export default function BalitaPage() {
|
||||
const router = useRouter();
|
||||
const state = useProxy(balitaState);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
state.findMany.load(1, 10, search, statusFilter);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleSearch = () => {
|
||||
state.findMany.load(1, 10, search, statusFilter);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string, nama: string) => {
|
||||
if (!confirm(`Hapus data balita "${nama}"?`)) return;
|
||||
await state.delete.byId(id);
|
||||
};
|
||||
|
||||
const rows = state.findMany.data?.map((d) => (
|
||||
<Table.Tr key={d.id}>
|
||||
<Table.Td>{d.nama}</Table.Td>
|
||||
<Table.Td>{d.jenisKelamin}</Table.Td>
|
||||
<Table.Td>
|
||||
{d.tanggalLahir
|
||||
? new Date(d.tanggalLahir).toLocaleDateString('id-ID')
|
||||
: '-'}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={d.imunisasiLengkap ? 'green' : 'red'} variant="light">
|
||||
{d.imunisasiLengkap ? 'Lengkap' : 'Belum'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={d.giziBaik ? 'green' : 'orange'} variant="light">
|
||||
{d.giziBaik ? 'Baik' : 'Kurang'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={d.pemeriksaanRutin ? 'green' : 'orange'} variant="light">
|
||||
{d.pemeriksaanRutin ? 'Rutin' : 'Tidak'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={STUNTING_COLORS[d.statusStunting] ?? 'gray'} variant="light">
|
||||
{d.statusStunting}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="blue"
|
||||
onClick={() => router.push(`/admin/kesehatan/balita/edit/${d.id}`)}
|
||||
>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={() => handleDelete(d.id, d.nama)}
|
||||
loading={state.delete.loading}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
));
|
||||
|
||||
return (
|
||||
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Title order={3} c="black">Balita Terdaftar</Title>
|
||||
<Button
|
||||
leftSection={<IconPlus size={16} />}
|
||||
onClick={() => router.push('/admin/kesehatan/balita/create')}
|
||||
radius="md"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
Tambah
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Group mb="md" gap="sm">
|
||||
<TextInput
|
||||
placeholder="Cari nama / NIK / ortu..."
|
||||
leftSection={<IconSearch size={16} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
radius="md"
|
||||
style={{ flex: 1, maxWidth: 300 }}
|
||||
/>
|
||||
<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 ?? '');
|
||||
state.findMany.load(1, 10, search, v ?? '');
|
||||
}}
|
||||
radius="md"
|
||||
clearable
|
||||
/>
|
||||
<Button onClick={handleSearch} radius="md" variant="light">Cari</Button>
|
||||
</Group>
|
||||
|
||||
{state.findMany.loading ? (
|
||||
<Group justify="center" py="xl"><Loader /></Group>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
<Table striped highlightOnHover withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Nama</Table.Th>
|
||||
<Table.Th>JK</Table.Th>
|
||||
<Table.Th>Tgl Lahir</Table.Th>
|
||||
<Table.Th>Imunisasi</Table.Th>
|
||||
<Table.Th>Gizi</Table.Th>
|
||||
<Table.Th>Pemeriksaan</Table.Th>
|
||||
<Table.Th>Stunting</Table.Th>
|
||||
<Table.Th>Aksi</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows && rows.length > 0 ? rows : (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={8}>
|
||||
<Text c="dimmed" ta="center" py="md">Tidak ada data</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
{(state.findMany.totalPages ?? 1) > 1 && (
|
||||
<Group justify="center">
|
||||
<Pagination
|
||||
total={state.findMany.totalPages}
|
||||
value={state.findMany.page}
|
||||
onChange={(p) => state.findMany.load(p, 10, search, statusFilter)}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
145
src/app/admin/(dashboard)/kesehatan/ibu-hamil/create/page.tsx
Normal file
145
src/app/admin/(dashboard)/kesehatan/ibu-hamil/create/page.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
'use client';
|
||||
|
||||
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';
|
||||
import ibuHamilState from '../../../_state/kesehatan/ibu-hamil/ibuHamil';
|
||||
|
||||
export default function IbuHamilCreatePage() {
|
||||
const router = useRouter();
|
||||
const state = useProxy(ibuHamilState);
|
||||
const form = state.create.form;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const ok = await state.create.submit();
|
||||
if (ok) {
|
||||
state.create.reset();
|
||||
router.push('/admin/kesehatan/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) => { form.nama = e.currentTarget.value; }}
|
||||
radius="md"
|
||||
/>
|
||||
<TextInput
|
||||
label="NIK"
|
||||
placeholder="Nomor Induk Kependudukan"
|
||||
value={form.nik}
|
||||
onChange={(e) => { form.nik = e.currentTarget.value; }}
|
||||
radius="md"
|
||||
/>
|
||||
<TextInput
|
||||
label="Usia Kehamilan (minggu)"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
value={String(form.usiaKehamilan)}
|
||||
onChange={(e) => { form.usiaKehamilan = Number(e.currentTarget.value) || 0; }}
|
||||
radius="md"
|
||||
/>
|
||||
<TextInput
|
||||
label="No. HP"
|
||||
placeholder="08xx-xxxx-xxxx"
|
||||
value={form.noHp}
|
||||
onChange={(e) => { form.noHp = e.currentTarget.value; }}
|
||||
radius="md"
|
||||
/>
|
||||
<TextInput
|
||||
label="HPHT (Hari Pertama Haid Terakhir)"
|
||||
type="date"
|
||||
value={form.hpht}
|
||||
onChange={(e) => { form.hpht = e.currentTarget.value; }}
|
||||
radius="md"
|
||||
/>
|
||||
<TextInput
|
||||
label="Taksiran Persalinan"
|
||||
type="date"
|
||||
value={form.taksiranLahir}
|
||||
onChange={(e) => { 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) form.status = v as typeof form.status; }}
|
||||
radius="md"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<TextInput
|
||||
label="Alamat"
|
||||
placeholder="Alamat lengkap"
|
||||
value={form.alamat}
|
||||
onChange={(e) => { form.alamat = e.currentTarget.value; }}
|
||||
radius="md"
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Catatan"
|
||||
placeholder="Catatan tambahan"
|
||||
value={form.catatan}
|
||||
onChange={(e) => { 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>
|
||||
);
|
||||
}
|
||||
149
src/app/admin/(dashboard)/kesehatan/ibu-hamil/edit/[id]/page.tsx
Normal file
149
src/app/admin/(dashboard)/kesehatan/ibu-hamil/edit/[id]/page.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
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';
|
||||
import ibuHamilState from '../../../../_state/kesehatan/ibu-hamil/ibuHamil';
|
||||
|
||||
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) state.edit.load(id);
|
||||
}, [id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const ok = await state.edit.update();
|
||||
if (ok) router.push('/admin/kesehatan/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) => { form.nama = e.currentTarget.value; }}
|
||||
radius="md"
|
||||
/>
|
||||
<TextInput
|
||||
label="NIK"
|
||||
placeholder="Nomor Induk Kependudukan"
|
||||
value={form.nik}
|
||||
onChange={(e) => { form.nik = e.currentTarget.value; }}
|
||||
radius="md"
|
||||
/>
|
||||
<TextInput
|
||||
label="Usia Kehamilan (minggu)"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
value={String(form.usiaKehamilan)}
|
||||
onChange={(e) => { form.usiaKehamilan = Number(e.currentTarget.value) || 0; }}
|
||||
radius="md"
|
||||
/>
|
||||
<TextInput
|
||||
label="No. HP"
|
||||
placeholder="08xx-xxxx-xxxx"
|
||||
value={form.noHp}
|
||||
onChange={(e) => { form.noHp = e.currentTarget.value; }}
|
||||
radius="md"
|
||||
/>
|
||||
<TextInput
|
||||
label="HPHT"
|
||||
type="date"
|
||||
value={form.hpht}
|
||||
onChange={(e) => { form.hpht = e.currentTarget.value; }}
|
||||
radius="md"
|
||||
/>
|
||||
<TextInput
|
||||
label="Taksiran Persalinan"
|
||||
type="date"
|
||||
value={form.taksiranLahir}
|
||||
onChange={(e) => { 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) form.status = v as typeof form.status; }}
|
||||
radius="md"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<TextInput
|
||||
label="Alamat"
|
||||
placeholder="Alamat lengkap"
|
||||
value={form.alamat}
|
||||
onChange={(e) => { form.alamat = e.currentTarget.value; }}
|
||||
radius="md"
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Catatan"
|
||||
placeholder="Catatan tambahan"
|
||||
value={form.catatan}
|
||||
onChange={(e) => { 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>
|
||||
);
|
||||
}
|
||||
170
src/app/admin/(dashboard)/kesehatan/ibu-hamil/page.tsx
Normal file
170
src/app/admin/(dashboard)/kesehatan/ibu-hamil/page.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
'use client';
|
||||
|
||||
import colors from '@/con/colors';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Pagination,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
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 ibuHamilState from '../../_state/kesehatan/ibu-hamil/ibuHamil';
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
AKTIF: 'green',
|
||||
MELAHIRKAN: 'blue',
|
||||
KEGUGURAN: 'gray',
|
||||
NONAKTIF: 'red',
|
||||
};
|
||||
|
||||
export default function IbuHamilPage() {
|
||||
const router = useRouter();
|
||||
const state = useProxy(ibuHamilState);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
state.findMany.load(1, 10, search, statusFilter);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleSearch = () => {
|
||||
state.findMany.load(1, 10, search, statusFilter);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string, nama: string) => {
|
||||
if (!confirm(`Hapus data ibu hamil "${nama}"?`)) return;
|
||||
await state.delete.byId(id);
|
||||
};
|
||||
|
||||
const rows = state.findMany.data?.map((d) => (
|
||||
<Table.Tr key={d.id}>
|
||||
<Table.Td>{d.nama}</Table.Td>
|
||||
<Table.Td>{d.nik || '-'}</Table.Td>
|
||||
<Table.Td>{d.usiaKehamilan} minggu</Table.Td>
|
||||
<Table.Td>{d.noHp || '-'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={STATUS_COLORS[d.status] ?? 'gray'} variant="light">
|
||||
{d.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="blue"
|
||||
onClick={() => router.push(`/admin/kesehatan/ibu-hamil/edit/${d.id}`)}
|
||||
>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={() => handleDelete(d.id, d.nama)}
|
||||
loading={state.delete.loading}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
));
|
||||
|
||||
return (
|
||||
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Title order={3} c="black">Ibu Hamil</Title>
|
||||
<Button
|
||||
leftSection={<IconPlus size={16} />}
|
||||
onClick={() => router.push('/admin/kesehatan/ibu-hamil/create')}
|
||||
radius="md"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
Tambah
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Group mb="md" gap="sm">
|
||||
<TextInput
|
||||
placeholder="Cari nama / NIK..."
|
||||
leftSection={<IconSearch size={16} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
radius="md"
|
||||
style={{ flex: 1, maxWidth: 300 }}
|
||||
/>
|
||||
<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 ?? '');
|
||||
state.findMany.load(1, 10, search, v ?? '');
|
||||
}}
|
||||
radius="md"
|
||||
clearable
|
||||
/>
|
||||
<Button onClick={handleSearch} radius="md" variant="light">Cari</Button>
|
||||
</Group>
|
||||
|
||||
{state.findMany.loading ? (
|
||||
<Group justify="center" py="xl"><Loader /></Group>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
<Table striped highlightOnHover withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Nama</Table.Th>
|
||||
<Table.Th>NIK</Table.Th>
|
||||
<Table.Th>Usia Kehamilan</Table.Th>
|
||||
<Table.Th>No. HP</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Aksi</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows && rows.length > 0 ? rows : (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={6}>
|
||||
<Text c="dimmed" ta="center" py="md">Tidak ada data</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
{(state.findMany.totalPages ?? 1) > 1 && (
|
||||
<Group justify="center">
|
||||
<Pagination
|
||||
total={state.findMany.totalPages}
|
||||
value={state.findMany.page}
|
||||
onChange={(p) => state.findMany.load(p, 10, search, statusFilter)}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
224
src/app/admin/(dashboard)/kesehatan/ringkasan-kesehatan/page.tsx
Normal file
224
src/app/admin/(dashboard)/kesehatan/ringkasan-kesehatan/page.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
'use client';
|
||||
|
||||
import colors from '@/con/colors';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
NumberInput,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconMoodBoy,
|
||||
IconHeartbeat,
|
||||
IconPercentage,
|
||||
IconUser,
|
||||
IconAlertTriangle,
|
||||
} from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import ringkasanKesehatanState from '../../_state/kesehatan/ringkasan-kesehatan/ringkasanKesehatan';
|
||||
|
||||
type StatCardProps = {
|
||||
label: string;
|
||||
value: string | number;
|
||||
icon: React.ReactNode;
|
||||
color?: string;
|
||||
suffix?: string;
|
||||
};
|
||||
|
||||
function StatCard({ label, value, icon, color = 'blue', suffix }: StatCardProps) {
|
||||
return (
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Group gap="sm" align="flex-start">
|
||||
<Box
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 8,
|
||||
background: `var(--mantine-color-${color}-1)`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: `var(--mantine-color-${color}-6)`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz="xs" c="dimmed" fw={500}>{label}</Text>
|
||||
<Text fz="xl" fw={700}>
|
||||
{value}{suffix && <Text component="span" fz="sm" c="dimmed" fw={400}> {suffix}</Text>}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RingkasanKesehatanPage() {
|
||||
const router = useRouter();
|
||||
const state = useProxy(ringkasanKesehatanState);
|
||||
const stats = state.findStats.data;
|
||||
|
||||
useEffect(() => {
|
||||
state.findStats.load();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleSaveTarget = async () => {
|
||||
await state.update.submitTarget();
|
||||
};
|
||||
|
||||
const isLoading = state.findStats.loading;
|
||||
|
||||
return (
|
||||
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||
<Title order={3} c="black" mb="md">Ringkasan Kesehatan Desa</Title>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl"><Loader /></Group>
|
||||
) : (
|
||||
<Stack gap="lg">
|
||||
{/* KPI Utama */}
|
||||
<Box>
|
||||
<Text fw={600} mb="sm" c="dark">KPI Utama</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
|
||||
<StatCard
|
||||
label="Ibu Hamil Aktif"
|
||||
value={stats?.ibuHamilAktif ?? 0}
|
||||
icon={<IconUser size={20} />}
|
||||
color="pink"
|
||||
suffix="orang"
|
||||
/>
|
||||
<StatCard
|
||||
label="Balita Terdaftar"
|
||||
value={stats?.balitaTerdaftar ?? 0}
|
||||
icon={<IconMoodBoy size={20} />}
|
||||
color="blue"
|
||||
suffix="anak"
|
||||
/>
|
||||
<StatCard
|
||||
label="Alert Stunting"
|
||||
value={stats?.alertStunting ?? 0}
|
||||
icon={<IconAlertTriangle size={20} />}
|
||||
color="red"
|
||||
suffix="anak"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Statistik % */}
|
||||
<Box>
|
||||
<Text fw={600} mb="sm" c="dark">Statistik Kesehatan Balita</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md">
|
||||
<StatCard
|
||||
label="Imunisasi Lengkap"
|
||||
value={stats?.imunisasiLengkapPct ?? 0}
|
||||
icon={<IconHeartbeat size={20} />}
|
||||
color="teal"
|
||||
suffix="%"
|
||||
/>
|
||||
<StatCard
|
||||
label="Pemeriksaan Rutin"
|
||||
value={stats?.pemeriksaanRutinPct ?? 0}
|
||||
icon={<IconHeartbeat size={20} />}
|
||||
color="green"
|
||||
suffix="%"
|
||||
/>
|
||||
<StatCard
|
||||
label="Gizi Baik"
|
||||
value={stats?.giziBaikPct ?? 0}
|
||||
icon={<IconHeartbeat size={20} />}
|
||||
color="lime"
|
||||
suffix="%"
|
||||
/>
|
||||
<StatCard
|
||||
label="Target Penurunan Stunting"
|
||||
value={stats?.targetStuntingPct ?? 0}
|
||||
icon={<IconPercentage size={20} />}
|
||||
color="orange"
|
||||
suffix="%"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Target Stunting Config */}
|
||||
<Paper withBorder p="md" radius="md" maw={400}>
|
||||
<Text fw={600} mb="sm" c="dark">Atur Target Stunting</Text>
|
||||
<Text fz="xs" c="dimmed" mb="sm">
|
||||
Target penurunan angka stunting adalah nilai kebijakan yang ditentukan
|
||||
oleh admin, bukan turunan dari data.
|
||||
</Text>
|
||||
<Group align="flex-end" gap="sm">
|
||||
<NumberInput
|
||||
label="Target (%)"
|
||||
min={0}
|
||||
max={100}
|
||||
suffix="%"
|
||||
value={state.update.form.targetStuntingPct}
|
||||
onChange={(v) => { state.update.form.targetStuntingPct = Number(v) || 0; }}
|
||||
radius="md"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSaveTarget}
|
||||
radius="md"
|
||||
disabled={state.update.loading}
|
||||
style={{
|
||||
background: state.update.loading
|
||||
? 'linear-gradient(135deg, #ccc, #eee)'
|
||||
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||
color: '#fff',
|
||||
marginBottom: 1,
|
||||
}}
|
||||
>
|
||||
{state.update.loading ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Kelola Data */}
|
||||
<Box>
|
||||
<Text fw={600} mb="sm" c="dark">Kelola Data</Text>
|
||||
<Group gap="md">
|
||||
<Button
|
||||
variant="light"
|
||||
color="pink"
|
||||
radius="md"
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={() => router.push('/admin/kesehatan/ibu-hamil')}
|
||||
>
|
||||
Kelola Ibu Hamil
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
radius="md"
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={() => router.push('/admin/kesehatan/balita')}
|
||||
>
|
||||
Kelola Balita
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -118,6 +118,11 @@ export const devBar = [
|
||||
id: "Desa_7",
|
||||
name: "Penghargaan",
|
||||
path: "/admin/desa/penghargaan"
|
||||
},
|
||||
{
|
||||
id: "Desa_8",
|
||||
name: "Kegiatan Desa",
|
||||
path: "/admin/desa/kegiatan-desa/list-kegiatan-desa"
|
||||
}
|
||||
|
||||
]
|
||||
@@ -161,6 +166,21 @@ export const devBar = [
|
||||
id: "Kesehatan_7",
|
||||
name: "Info Wabah/Penyakit",
|
||||
path: "/admin/kesehatan/info-wabah-penyakit"
|
||||
},
|
||||
{
|
||||
id: "Kesehatan_8",
|
||||
name: "Ringkasan Kesehatan",
|
||||
path: "/admin/kesehatan/ringkasan-kesehatan"
|
||||
},
|
||||
{
|
||||
id: "Kesehatan_9",
|
||||
name: "Ibu Hamil",
|
||||
path: "/admin/kesehatan/ibu-hamil"
|
||||
},
|
||||
{
|
||||
id: "Kesehatan_10",
|
||||
name: "Balita",
|
||||
path: "/admin/kesehatan/balita"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -549,6 +569,11 @@ export const navBar = [
|
||||
id: "Desa_7",
|
||||
name: "Penghargaan",
|
||||
path: "/admin/desa/penghargaan"
|
||||
},
|
||||
{
|
||||
id: "Desa_8",
|
||||
name: "Kegiatan Desa",
|
||||
path: "/admin/desa/kegiatan-desa/list-kegiatan-desa"
|
||||
}
|
||||
|
||||
]
|
||||
@@ -592,6 +617,21 @@ export const navBar = [
|
||||
id: "Kesehatan_7",
|
||||
name: "Info Wabah/Penyakit",
|
||||
path: "/admin/kesehatan/info-wabah-penyakit"
|
||||
},
|
||||
{
|
||||
id: "Kesehatan_8",
|
||||
name: "Ringkasan Kesehatan",
|
||||
path: "/admin/kesehatan/ringkasan-kesehatan"
|
||||
},
|
||||
{
|
||||
id: "Kesehatan_9",
|
||||
name: "Ibu Hamil",
|
||||
path: "/admin/kesehatan/ibu-hamil"
|
||||
},
|
||||
{
|
||||
id: "Kesehatan_10",
|
||||
name: "Balita",
|
||||
path: "/admin/kesehatan/balita"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -995,6 +1035,11 @@ export const role1 = [
|
||||
id: "Desa_7",
|
||||
name: "Penghargaan",
|
||||
path: "/admin/desa/penghargaan"
|
||||
},
|
||||
{
|
||||
id: "Desa_8",
|
||||
name: "Kegiatan Desa",
|
||||
path: "/admin/desa/kegiatan-desa/list-kegiatan-desa"
|
||||
}
|
||||
|
||||
]
|
||||
@@ -1257,6 +1302,21 @@ export const role2 = [
|
||||
id: "Kesehatan_7",
|
||||
name: "Info Wabah/Penyakit",
|
||||
path: "/admin/kesehatan/info-wabah-penyakit"
|
||||
},
|
||||
{
|
||||
id: "Kesehatan_8",
|
||||
name: "Ringkasan Kesehatan",
|
||||
path: "/admin/kesehatan/ringkasan-kesehatan"
|
||||
},
|
||||
{
|
||||
id: "Kesehatan_9",
|
||||
name: "Ibu Hamil",
|
||||
path: "/admin/kesehatan/ibu-hamil"
|
||||
},
|
||||
{
|
||||
id: "Kesehatan_10",
|
||||
name: "Balita",
|
||||
path: "/admin/kesehatan/balita"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ import MantanPerbekel from "./profile/profile-mantan-perbekel";
|
||||
import AjukanPermohonan from "./layanan/ajukan_permohonan";
|
||||
import Musik from "./musik";
|
||||
import KegiatanDesa from "./kegiatan-desa";
|
||||
import KategoriKegiatan from "./kegiatan-desa/kategori-kegiatan";
|
||||
|
||||
|
||||
const Desa = new Elysia({ prefix: "/desa", tags: ["Desa"] })
|
||||
@@ -32,6 +33,7 @@ const Desa = new Elysia({ prefix: "/desa", tags: ["Desa"] })
|
||||
.use(AjukanPermohonan)
|
||||
.use(Musik)
|
||||
.use(KegiatanDesa)
|
||||
.use(KategoriKegiatan)
|
||||
|
||||
|
||||
export default Desa;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
async function kegiatanDesaFindUnique(context: Context) {
|
||||
const { id } = context.params as { id: string };
|
||||
|
||||
if (!id) {
|
||||
return { success: false, message: "ID is required" };
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await prisma.kegiatanDesa.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
kategoriKegiatan: true,
|
||||
image: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
return { success: false, message: "Data tidak ditemukan" };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Berhasil ambil kegiatan desa",
|
||||
data,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Error di kegiatanDesaFindUnique:", e);
|
||||
return { success: false, message: "Gagal mengambil data kegiatan desa" };
|
||||
}
|
||||
}
|
||||
|
||||
export default kegiatanDesaFindUnique;
|
||||
@@ -1,11 +1,13 @@
|
||||
import Elysia, { t } from "elysia";
|
||||
import kegiatanDesaFindMany from "./find-many";
|
||||
import kegiatanDesaFindUnique from "./findUnique";
|
||||
import kegiatanDesaCreate from "./create";
|
||||
import kegiatanDesaDelete from "./del";
|
||||
import kegiatanDesaUpdate from "./updt";
|
||||
|
||||
const KegiatanDesa = new Elysia({ prefix: "/kegiatandesa", tags: ["Desa/Kegiatan Desa"] })
|
||||
.get("/find-many", kegiatanDesaFindMany)
|
||||
.get("/:id", kegiatanDesaFindUnique)
|
||||
.post("/create", kegiatanDesaCreate, {
|
||||
body: t.Object({
|
||||
judul: t.String(),
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
type FormCreate = {
|
||||
nama: string;
|
||||
}
|
||||
|
||||
export default async function kategoriKegiatanCreate(context: Context) {
|
||||
const body = (await context.body) as FormCreate;
|
||||
|
||||
try {
|
||||
const result = await prisma.kategoriKegiatan.create({
|
||||
data: {
|
||||
nama: body.nama,
|
||||
},
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
message: "Berhasil membuat kategori kegiatan",
|
||||
data: result,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error creating kategori kegiatan:", error);
|
||||
throw new Error("Gagal membuat kategori kegiatan: " + (error as Error).message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
export default async function kategoriKegiatanDelete(context: Context) {
|
||||
try {
|
||||
const id = context.params?.id as string;
|
||||
|
||||
if (!id) {
|
||||
return Response.json({
|
||||
success: false,
|
||||
message: "ID tidak boleh kosong",
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// ✅ Cek apakah kategori masih digunakan oleh kegiatan
|
||||
const kegiatanCount = await prisma.kegiatanDesa.count({
|
||||
where: {
|
||||
kategoriKegiatanId: id,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (kegiatanCount > 0) {
|
||||
return Response.json({
|
||||
success: false,
|
||||
message: `Kategori tidak dapat dihapus karena masih digunakan oleh ${kegiatanCount} kegiatan`,
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// ✅ Soft delete (bukan hard delete)
|
||||
await prisma.kategoriKegiatan.update({
|
||||
where: { id },
|
||||
data: {
|
||||
deletedAt: new Date(),
|
||||
isActive: false,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Kategori kegiatan berhasil dihapus",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Delete kategori error:", error);
|
||||
return Response.json({
|
||||
success: false,
|
||||
message: "Gagal menghapus kategori: " + (error instanceof Error ? error.message : 'Unknown error'),
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
async function kategoriKegiatanFindMany(context: Context) {
|
||||
// Ambil parameter dari query
|
||||
const page = Number(context.query.page) || 1;
|
||||
const limit = Number(context.query.limit) || 10;
|
||||
const search = (context.query.search as string) || '';
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// Buat where clause
|
||||
const where: any = { isActive: true };
|
||||
|
||||
// Tambahkan pencarian (jika ada)
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ nama: { contains: search, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
// Ambil data dan total count secara paralel
|
||||
const [data, total] = await Promise.all([
|
||||
prisma.kategoriKegiatan.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'asc' },
|
||||
}),
|
||||
prisma.kategoriKegiatan.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Berhasil ambil kategori kegiatan dengan pagination",
|
||||
data,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Error di findMany paginated:", e);
|
||||
return {
|
||||
success: false,
|
||||
message: "Gagal mengambil data kategori kegiatan",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default kategoriKegiatanFindMany;
|
||||
@@ -0,0 +1,46 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
export default async function kategoriKegiatanFindUnique(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
const pathSegments = url.pathname.split('/');
|
||||
const id = pathSegments[pathSegments.length - 1];
|
||||
|
||||
if (!id) {
|
||||
return {
|
||||
success: false,
|
||||
message: "ID is required",
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof id !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
message: "ID is required",
|
||||
}
|
||||
}
|
||||
|
||||
const data = await prisma.kategoriKegiatan.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Data not found",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Success get kategori kegiatan",
|
||||
data,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Find by ID error:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "Gagal mengambil data: " + (error instanceof Error ? error.message : 'Unknown error'),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import Elysia, { t } from "elysia";
|
||||
import kategoriKegiatanCreate from "./create";
|
||||
import kategoriKegiatanDelete from "./del";
|
||||
import kategoriKegiatanFindMany from "./findMany";
|
||||
import kategoriKegiatanFindUnique from "./findUnique";
|
||||
import kategoriKegiatanUpdate from "./updt";
|
||||
|
||||
const KategoriKegiatan = new Elysia({
|
||||
prefix: "/kategorikegiatan",
|
||||
tags: ["Desa / Kegiatan Desa / Kategori Kegiatan"],
|
||||
})
|
||||
|
||||
.post("/create", kategoriKegiatanCreate, {
|
||||
body: t.Object({
|
||||
nama: t.String(),
|
||||
}),
|
||||
})
|
||||
|
||||
.get("/findMany", kategoriKegiatanFindMany)
|
||||
.get("/:id", async (context) => {
|
||||
const response = await kategoriKegiatanFindUnique(
|
||||
new Request(context.request)
|
||||
);
|
||||
return response;
|
||||
})
|
||||
.put("/:id", kategoriKegiatanUpdate, {
|
||||
body: t.Object({
|
||||
nama: t.String(),
|
||||
}),
|
||||
})
|
||||
.delete("/del/:id", kategoriKegiatanDelete);
|
||||
|
||||
export default KategoriKegiatan;
|
||||
@@ -0,0 +1,28 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
type FormUpdate = {
|
||||
nama: string;
|
||||
}
|
||||
|
||||
export default async function kategoriKegiatanUpdate(context: Context) {
|
||||
const body = (await context.body) as FormUpdate;
|
||||
const id = context.params.id as string;
|
||||
|
||||
try {
|
||||
const result = await prisma.kategoriKegiatan.update({
|
||||
where: { id },
|
||||
data: {
|
||||
nama: body.nama,
|
||||
},
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
message: "Berhasil mengupdate kategori kegiatan",
|
||||
data: result,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error updating kategori kegiatan:", error);
|
||||
throw new Error("Gagal mengupdate kategori kegiatan: " + (error as Error).message);
|
||||
}
|
||||
}
|
||||
47
src/app/api/[[...slugs]]/_lib/kesehatan/balita/create.ts
Normal file
47
src/app/api/[[...slugs]]/_lib/kesehatan/balita/create.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { JenisKelaminBalita, StatusStunting } from "@prisma/client";
|
||||
import { Context } from "elysia";
|
||||
|
||||
type FormCreate = {
|
||||
nama: string;
|
||||
nik?: string;
|
||||
tanggalLahir: string;
|
||||
jenisKelamin: JenisKelaminBalita;
|
||||
beratBadanKg?: number;
|
||||
tinggiBadanCm?: number;
|
||||
namaOrtu?: string;
|
||||
alamat?: string;
|
||||
noHpOrtu?: string;
|
||||
posyanduId?: string;
|
||||
imunisasiLengkap: boolean;
|
||||
giziBaik: boolean;
|
||||
pemeriksaanRutin: boolean;
|
||||
statusStunting: StatusStunting;
|
||||
catatan?: string;
|
||||
};
|
||||
|
||||
export default async function balitaCreate(context: Context) {
|
||||
const body = context.body as FormCreate;
|
||||
|
||||
const data = await prisma.balita.create({
|
||||
data: {
|
||||
nama: body.nama,
|
||||
nik: body.nik,
|
||||
tanggalLahir: new Date(body.tanggalLahir),
|
||||
jenisKelamin: body.jenisKelamin,
|
||||
beratBadanKg: body.beratBadanKg,
|
||||
tinggiBadanCm: body.tinggiBadanCm,
|
||||
namaOrtu: body.namaOrtu,
|
||||
alamat: body.alamat,
|
||||
noHpOrtu: body.noHpOrtu,
|
||||
posyanduId: body.posyanduId || null,
|
||||
imunisasiLengkap: body.imunisasiLengkap,
|
||||
giziBaik: body.giziBaik,
|
||||
pemeriksaanRutin: body.pemeriksaanRutin,
|
||||
statusStunting: body.statusStunting ?? "NORMAL",
|
||||
catatan: body.catatan,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, message: "Balita berhasil ditambahkan", data };
|
||||
}
|
||||
22
src/app/api/[[...slugs]]/_lib/kesehatan/balita/del.ts
Normal file
22
src/app/api/[[...slugs]]/_lib/kesehatan/balita/del.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
export default async function balitaDelete(context: Context) {
|
||||
const id = context.params?.id as string;
|
||||
|
||||
if (!id) {
|
||||
return { success: false, message: "ID tidak diberikan" };
|
||||
}
|
||||
|
||||
const existing = await prisma.balita.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return new Response(JSON.stringify({ success: false, message: "Data tidak ditemukan" }), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.balita.update({ where: { id }, data: { isActive: false } });
|
||||
|
||||
return { success: true, message: "Balita berhasil dihapus" };
|
||||
}
|
||||
38
src/app/api/[[...slugs]]/_lib/kesehatan/balita/find-by-id.ts
Normal file
38
src/app/api/[[...slugs]]/_lib/kesehatan/balita/find-by-id.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
export default async function balitaFindById(context: Context) {
|
||||
const id = context.params?.id as string;
|
||||
|
||||
if (!id) {
|
||||
return new Response(JSON.stringify({ success: false, message: "ID tidak boleh kosong" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await prisma.balita.findUnique({
|
||||
where: { id },
|
||||
include: { posyandu: { select: { id: true, name: true } } },
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
return new Response(JSON.stringify({ success: false, message: "Data tidak ditemukan" }), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ success: true, data }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("balitaFindById error:", error);
|
||||
return new Response(JSON.stringify({ success: false, message: "Gagal mengambil data" }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
52
src/app/api/[[...slugs]]/_lib/kesehatan/balita/find-many.ts
Normal file
52
src/app/api/[[...slugs]]/_lib/kesehatan/balita/find-many.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
export default async function balitaFindMany(context: Context) {
|
||||
const page = Number(context.query.page) || 1;
|
||||
const limit = Number(context.query.limit) || 10;
|
||||
const search = (context.query.search as string) || "";
|
||||
const statusStunting = (context.query.statusStunting as string) || "";
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: any = { isActive: true };
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ nama: { contains: search, mode: "insensitive" } },
|
||||
{ nik: { contains: search, mode: "insensitive" } },
|
||||
{ namaOrtu: { contains: search, mode: "insensitive" } },
|
||||
{ alamat: { contains: search, mode: "insensitive" } },
|
||||
];
|
||||
}
|
||||
|
||||
if (statusStunting) {
|
||||
where.statusStunting = statusStunting;
|
||||
}
|
||||
|
||||
try {
|
||||
const [data, total] = await Promise.all([
|
||||
prisma.balita.findMany({
|
||||
where,
|
||||
include: { posyandu: { select: { id: true, name: true } } },
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
prisma.balita.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Berhasil ambil data balita",
|
||||
data,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Error balitaFindMany:", e);
|
||||
return { success: false, message: "Gagal mengambil data balita" };
|
||||
}
|
||||
}
|
||||
63
src/app/api/[[...slugs]]/_lib/kesehatan/balita/index.ts
Normal file
63
src/app/api/[[...slugs]]/_lib/kesehatan/balita/index.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import Elysia, { t } from "elysia";
|
||||
import balitaCreate from "./create";
|
||||
import balitaDelete from "./del";
|
||||
import balitaFindById from "./find-by-id";
|
||||
import balitaFindMany from "./find-many";
|
||||
import balitaUpdate from "./updt";
|
||||
|
||||
const Balita = new Elysia({ prefix: "/balita", tags: ["Kesehatan/Balita"] })
|
||||
.post("/create", balitaCreate, {
|
||||
body: t.Object({
|
||||
nama: t.String(),
|
||||
nik: t.Optional(t.String()),
|
||||
tanggalLahir: t.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()),
|
||||
}),
|
||||
})
|
||||
.get("/find-many", balitaFindMany)
|
||||
.delete("/del/:id", balitaDelete)
|
||||
.get("/:id", balitaFindById)
|
||||
.put(
|
||||
"/:id",
|
||||
balitaUpdate,
|
||||
{
|
||||
body: t.Object({
|
||||
nama: t.String(),
|
||||
nik: t.Optional(t.String()),
|
||||
tanggalLahir: t.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()),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
export default Balita;
|
||||
75
src/app/api/[[...slugs]]/_lib/kesehatan/balita/updt.ts
Normal file
75
src/app/api/[[...slugs]]/_lib/kesehatan/balita/updt.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { JenisKelaminBalita, StatusStunting } from "@prisma/client";
|
||||
import { Context } from "elysia";
|
||||
|
||||
type FormUpdate = {
|
||||
nama: string;
|
||||
nik?: string;
|
||||
tanggalLahir: string;
|
||||
jenisKelamin: JenisKelaminBalita;
|
||||
beratBadanKg?: number;
|
||||
tinggiBadanCm?: number;
|
||||
namaOrtu?: string;
|
||||
alamat?: string;
|
||||
noHpOrtu?: string;
|
||||
posyanduId?: string;
|
||||
imunisasiLengkap: boolean;
|
||||
giziBaik: boolean;
|
||||
pemeriksaanRutin: boolean;
|
||||
statusStunting: StatusStunting;
|
||||
catatan?: string;
|
||||
};
|
||||
|
||||
export default async function balitaUpdate(context: Context) {
|
||||
const id = context.params?.id as string;
|
||||
const body = context.body as FormUpdate;
|
||||
|
||||
if (!id) {
|
||||
return new Response(JSON.stringify({ success: false, message: "ID tidak boleh kosong" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await prisma.balita.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return new Response(JSON.stringify({ success: false, message: "Data tidak ditemukan" }), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await prisma.balita.update({
|
||||
where: { id },
|
||||
data: {
|
||||
nama: body.nama,
|
||||
nik: body.nik,
|
||||
tanggalLahir: new Date(body.tanggalLahir),
|
||||
jenisKelamin: body.jenisKelamin,
|
||||
beratBadanKg: body.beratBadanKg,
|
||||
tinggiBadanCm: body.tinggiBadanCm,
|
||||
namaOrtu: body.namaOrtu,
|
||||
alamat: body.alamat,
|
||||
noHpOrtu: body.noHpOrtu,
|
||||
posyanduId: body.posyanduId || null,
|
||||
imunisasiLengkap: body.imunisasiLengkap,
|
||||
giziBaik: body.giziBaik,
|
||||
pemeriksaanRutin: body.pemeriksaanRutin,
|
||||
statusStunting: body.statusStunting,
|
||||
catatan: body.catatan,
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({ success: true, message: "Balita berhasil diperbarui", data: updated }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("balitaUpdate error:", error);
|
||||
return new Response(JSON.stringify({ success: false, message: "Gagal memperbarui data" }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
37
src/app/api/[[...slugs]]/_lib/kesehatan/ibu-hamil/create.ts
Normal file
37
src/app/api/[[...slugs]]/_lib/kesehatan/ibu-hamil/create.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { IbuHamilStatus } from "@prisma/client";
|
||||
import { Context } from "elysia";
|
||||
|
||||
type FormCreate = {
|
||||
nama: string;
|
||||
nik?: string;
|
||||
usiaKehamilan: number;
|
||||
hpht?: string;
|
||||
taksiranLahir?: string;
|
||||
alamat?: string;
|
||||
noHp?: string;
|
||||
catatan?: string;
|
||||
posyanduId?: string;
|
||||
status: IbuHamilStatus;
|
||||
};
|
||||
|
||||
export default async function ibuHamilCreate(context: Context) {
|
||||
const body = context.body as FormCreate;
|
||||
|
||||
const data = await prisma.ibuHamil.create({
|
||||
data: {
|
||||
nama: body.nama,
|
||||
nik: body.nik,
|
||||
usiaKehamilan: body.usiaKehamilan ?? 0,
|
||||
hpht: body.hpht ? new Date(body.hpht) : undefined,
|
||||
taksiranLahir: body.taksiranLahir ? new Date(body.taksiranLahir) : undefined,
|
||||
alamat: body.alamat,
|
||||
noHp: body.noHp,
|
||||
catatan: body.catatan,
|
||||
posyanduId: body.posyanduId || null,
|
||||
status: body.status ?? "AKTIF",
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, message: "Ibu hamil berhasil ditambahkan", data };
|
||||
}
|
||||
22
src/app/api/[[...slugs]]/_lib/kesehatan/ibu-hamil/del.ts
Normal file
22
src/app/api/[[...slugs]]/_lib/kesehatan/ibu-hamil/del.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
export default async function ibuHamilDelete(context: Context) {
|
||||
const id = context.params?.id as string;
|
||||
|
||||
if (!id) {
|
||||
return { success: false, message: "ID tidak diberikan" };
|
||||
}
|
||||
|
||||
const existing = await prisma.ibuHamil.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return new Response(JSON.stringify({ success: false, message: "Data tidak ditemukan" }), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.ibuHamil.update({ where: { id }, data: { isActive: false } });
|
||||
|
||||
return { success: true, message: "Ibu hamil berhasil dihapus" };
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
export default async function ibuHamilFindById(context: Context) {
|
||||
const id = context.params?.id as string;
|
||||
|
||||
if (!id) {
|
||||
return new Response(JSON.stringify({ success: false, message: "ID tidak boleh kosong" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await prisma.ibuHamil.findUnique({
|
||||
where: { id },
|
||||
include: { posyandu: { select: { id: true, name: true } } },
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
return new Response(JSON.stringify({ success: false, message: "Data tidak ditemukan" }), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ success: true, data }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("ibuHamilFindById error:", error);
|
||||
return new Response(JSON.stringify({ success: false, message: "Gagal mengambil data" }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
export default async function ibuHamilFindMany(context: Context) {
|
||||
const page = Number(context.query.page) || 1;
|
||||
const limit = Number(context.query.limit) || 10;
|
||||
const search = (context.query.search as string) || "";
|
||||
const status = (context.query.status as string) || "";
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: any = { isActive: true };
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ nama: { contains: search, mode: "insensitive" } },
|
||||
{ nik: { contains: search, mode: "insensitive" } },
|
||||
{ alamat: { contains: search, mode: "insensitive" } },
|
||||
];
|
||||
}
|
||||
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
try {
|
||||
const [data, total] = await Promise.all([
|
||||
prisma.ibuHamil.findMany({
|
||||
where,
|
||||
include: { posyandu: { select: { id: true, name: true } } },
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
prisma.ibuHamil.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Berhasil ambil data ibu hamil",
|
||||
data,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Error ibuHamilFindMany:", e);
|
||||
return { success: false, message: "Gagal mengambil data ibu hamil" };
|
||||
}
|
||||
}
|
||||
55
src/app/api/[[...slugs]]/_lib/kesehatan/ibu-hamil/index.ts
Normal file
55
src/app/api/[[...slugs]]/_lib/kesehatan/ibu-hamil/index.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import Elysia, { t } from "elysia";
|
||||
import ibuHamilCreate from "./create";
|
||||
import ibuHamilDelete from "./del";
|
||||
import ibuHamilFindById from "./find-by-id";
|
||||
import ibuHamilFindMany from "./find-many";
|
||||
import ibuHamilUpdate from "./updt";
|
||||
|
||||
const IbuHamil = new Elysia({ prefix: "/ibuhamil", tags: ["Kesehatan/IbuHamil"] })
|
||||
.post("/create", ibuHamilCreate, {
|
||||
body: t.Object({
|
||||
nama: t.String(),
|
||||
nik: t.Optional(t.String()),
|
||||
usiaKehamilan: t.Number({ minimum: 0 }),
|
||||
hpht: t.Optional(t.String()),
|
||||
taksiranLahir: t.Optional(t.String()),
|
||||
alamat: t.Optional(t.String()),
|
||||
noHp: t.Optional(t.String()),
|
||||
catatan: t.Optional(t.String()),
|
||||
posyanduId: t.Optional(t.String()),
|
||||
status: t.Union([
|
||||
t.Literal("AKTIF"),
|
||||
t.Literal("MELAHIRKAN"),
|
||||
t.Literal("KEGUGURAN"),
|
||||
t.Literal("NONAKTIF"),
|
||||
]),
|
||||
}),
|
||||
})
|
||||
.get("/find-many", ibuHamilFindMany)
|
||||
.delete("/del/:id", ibuHamilDelete)
|
||||
.get("/:id", ibuHamilFindById)
|
||||
.put(
|
||||
"/:id",
|
||||
ibuHamilUpdate,
|
||||
{
|
||||
body: t.Object({
|
||||
nama: t.String(),
|
||||
nik: t.Optional(t.String()),
|
||||
usiaKehamilan: t.Number({ minimum: 0 }),
|
||||
hpht: t.Optional(t.String()),
|
||||
taksiranLahir: t.Optional(t.String()),
|
||||
alamat: t.Optional(t.String()),
|
||||
noHp: t.Optional(t.String()),
|
||||
catatan: t.Optional(t.String()),
|
||||
posyanduId: t.Optional(t.String()),
|
||||
status: t.Union([
|
||||
t.Literal("AKTIF"),
|
||||
t.Literal("MELAHIRKAN"),
|
||||
t.Literal("KEGUGURAN"),
|
||||
t.Literal("NONAKTIF"),
|
||||
]),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
export default IbuHamil;
|
||||
65
src/app/api/[[...slugs]]/_lib/kesehatan/ibu-hamil/updt.ts
Normal file
65
src/app/api/[[...slugs]]/_lib/kesehatan/ibu-hamil/updt.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { IbuHamilStatus } from "@prisma/client";
|
||||
import { Context } from "elysia";
|
||||
|
||||
type FormUpdate = {
|
||||
nama: string;
|
||||
nik?: string;
|
||||
usiaKehamilan: number;
|
||||
hpht?: string;
|
||||
taksiranLahir?: string;
|
||||
alamat?: string;
|
||||
noHp?: string;
|
||||
catatan?: string;
|
||||
posyanduId?: string;
|
||||
status: IbuHamilStatus;
|
||||
};
|
||||
|
||||
export default async function ibuHamilUpdate(context: Context) {
|
||||
const id = context.params?.id as string;
|
||||
const body = context.body as FormUpdate;
|
||||
|
||||
if (!id) {
|
||||
return new Response(JSON.stringify({ success: false, message: "ID tidak boleh kosong" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await prisma.ibuHamil.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return new Response(JSON.stringify({ success: false, message: "Data tidak ditemukan" }), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await prisma.ibuHamil.update({
|
||||
where: { id },
|
||||
data: {
|
||||
nama: body.nama,
|
||||
nik: body.nik,
|
||||
usiaKehamilan: body.usiaKehamilan ?? 0,
|
||||
hpht: body.hpht ? new Date(body.hpht) : null,
|
||||
taksiranLahir: body.taksiranLahir ? new Date(body.taksiranLahir) : null,
|
||||
alamat: body.alamat,
|
||||
noHp: body.noHp,
|
||||
catatan: body.catatan,
|
||||
posyanduId: body.posyanduId || null,
|
||||
status: body.status,
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({ success: true, message: "Ibu hamil berhasil diperbarui", data: updated }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("ibuHamilUpdate error:", error);
|
||||
return new Response(JSON.stringify({ success: false, message: "Gagal memperbarui data" }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import DokterTenagaMedis from "./data_kesehatan_warga/fasilitas_kesehatan/dokter
|
||||
import PendaftaranJadwalKegiatan from "./data_kesehatan_warga/jadwal_kegiatan/pendaftaran";
|
||||
import TarifLayanan from "./data_kesehatan_warga/fasilitas_kesehatan/tarif-layanan";
|
||||
import RingkasanKesehatan from "./ringkasan-kesehatan";
|
||||
import IbuHamil from "./ibu-hamil";
|
||||
import Balita from "./balita";
|
||||
|
||||
|
||||
const Kesehatan = new Elysia({
|
||||
@@ -51,4 +53,6 @@ const Kesehatan = new Elysia({
|
||||
.use(TarifLayanan)
|
||||
.use(PendaftaranJadwalKegiatan)
|
||||
.use(RingkasanKesehatan)
|
||||
.use(IbuHamil)
|
||||
.use(Balita)
|
||||
export default Kesehatan;
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import Elysia, { t } from "elysia";
|
||||
import ringkasanKesehatanFindUnique from "./findUnique";
|
||||
import ringkasanKesehatanUpdate from "./updt";
|
||||
import ringkasanKesehatanStats from "./stats";
|
||||
|
||||
const RingkasanKesehatan = new Elysia({ prefix: "/ringkasankesehatan", tags: ["Kesehatan/Ringkasan"] })
|
||||
.get("/find", ringkasanKesehatanFindUnique)
|
||||
.get("/stats", ringkasanKesehatanStats)
|
||||
.put("/update", ringkasanKesehatanUpdate, {
|
||||
body: t.Object({
|
||||
ibuHamilAkh: t.Number(),
|
||||
balitaTerdaftar: t.Number(),
|
||||
alertStunting: t.Number(),
|
||||
imunisasiLengkapPct: t.Number({ minimum: 0, maximum: 100 }),
|
||||
pemeriksaanRutinPct: t.Number({ minimum: 0, maximum: 100 }),
|
||||
giziBaikPct: t.Number({ minimum: 0, maximum: 100 }),
|
||||
targetStuntingPct: t.Number({ minimum: 0, maximum: 100 }),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
type StatsResult = {
|
||||
ibuHamilAktif: number;
|
||||
balitaTerdaftar: number;
|
||||
alertStunting: number;
|
||||
imunisasiLengkapPct: number;
|
||||
pemeriksaanRutinPct: number;
|
||||
giziBaikPct: number;
|
||||
targetStuntingPct: number;
|
||||
};
|
||||
|
||||
export default async function ringkasanKesehatanStats(): Promise<{ success: boolean; data?: StatsResult; message?: string }> {
|
||||
try {
|
||||
const [
|
||||
ibuHamilAktif,
|
||||
balitaTotal,
|
||||
alertStunting,
|
||||
imunisasiLengkap,
|
||||
pemeriksaanRutin,
|
||||
giziBaik,
|
||||
config,
|
||||
] = await Promise.all([
|
||||
prisma.ibuHamil.count({ where: { status: "AKTIF", isActive: true } }),
|
||||
prisma.balita.count({ where: { isActive: true } }),
|
||||
prisma.balita.count({ where: { isActive: true, statusStunting: { in: ["ALERT", "STUNTING"] } } }),
|
||||
prisma.balita.count({ where: { isActive: true, imunisasiLengkap: true } }),
|
||||
prisma.balita.count({ where: { isActive: true, pemeriksaanRutin: true } }),
|
||||
prisma.balita.count({ where: { isActive: true, giziBaik: true } }),
|
||||
prisma.ringkasanKesehatanDesa.findFirst({ where: { isActive: true }, orderBy: { createdAt: "desc" } }),
|
||||
]);
|
||||
|
||||
const pct = (n: number, total: number) =>
|
||||
total === 0 ? 0 : Math.round((n / total) * 100);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
ibuHamilAktif,
|
||||
balitaTerdaftar: balitaTotal,
|
||||
alertStunting,
|
||||
imunisasiLengkapPct: pct(imunisasiLengkap, balitaTotal),
|
||||
pemeriksaanRutinPct: pct(pemeriksaanRutin, balitaTotal),
|
||||
giziBaikPct: pct(giziBaik, balitaTotal),
|
||||
targetStuntingPct: config?.targetStuntingPct ?? 0,
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("ringkasanKesehatanStats error:", e);
|
||||
return { success: false, message: "Gagal menghitung statistik kesehatan" };
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,10 @@ async function ringkasanKesehatanUpdate(context: Context) {
|
||||
ibuHamilAkh: Number(body.ibuHamilAkh),
|
||||
balitaTerdaftar: Number(body.balitaTerdaftar),
|
||||
alertStunting: Number(body.alertStunting),
|
||||
imunisasiLengkapPct: Number(body.imunisasiLengkapPct),
|
||||
pemeriksaanRutinPct: Number(body.pemeriksaanRutinPct),
|
||||
giziBaikPct: Number(body.giziBaikPct),
|
||||
targetStuntingPct: Number(body.targetStuntingPct),
|
||||
},
|
||||
})
|
||||
: await prisma.ringkasanKesehatanDesa.create({
|
||||
@@ -25,6 +29,10 @@ async function ringkasanKesehatanUpdate(context: Context) {
|
||||
ibuHamilAkh: Number(body.ibuHamilAkh),
|
||||
balitaTerdaftar: Number(body.balitaTerdaftar),
|
||||
alertStunting: Number(body.alertStunting),
|
||||
imunisasiLengkapPct: Number(body.imunisasiLengkapPct),
|
||||
pemeriksaanRutinPct: Number(body.pemeriksaanRutinPct),
|
||||
giziBaikPct: Number(body.giziBaikPct),
|
||||
targetStuntingPct: Number(body.targetStuntingPct),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
'use client'
|
||||
import stateDashboardKegiatan from '@/app/admin/(dashboard)/_state/desa/kegiatanDesa';
|
||||
import colors from '@/con/colors';
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Center,
|
||||
Container,
|
||||
Divider,
|
||||
Group,
|
||||
Image,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconCalendar, IconMapPin, IconUsers } from '@tabler/icons-react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
|
||||
const formatTanggal = (val: string) =>
|
||||
val
|
||||
? new Date(val).toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
: '-';
|
||||
|
||||
function Page() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const id = Array.isArray(params.id) ? params.id[0] : params.id;
|
||||
const state = useProxy(stateDashboardKegiatan.kegiatan.findUnique);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) state.load(id);
|
||||
}, [id]);
|
||||
|
||||
if (state.loading) {
|
||||
return (
|
||||
<Box bg={colors.Bg} py={40}>
|
||||
<Container size="lg">
|
||||
<Stack gap="md">
|
||||
<Skeleton h={400} radius="lg" />
|
||||
<Skeleton h={30} w="60%" />
|
||||
<Skeleton h={20} w="40%" />
|
||||
<Skeleton h={200} />
|
||||
</Stack>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const data = state.data;
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<Center h="60vh" bg={colors.Bg}>
|
||||
<Text fz="xl" c="dimmed">Data kegiatan tidak ditemukan</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box bg={colors.Bg} py={40}>
|
||||
<Container size="lg" px={{ base: 'md', md: 'xl' }}>
|
||||
<Paper shadow="sm" radius="lg" withBorder p={{ base: 'md', md: 'xl' }}>
|
||||
<Stack gap="lg">
|
||||
{data.image?.link && (
|
||||
<Box
|
||||
w="100%"
|
||||
h={{ base: 240, md: 420 }}
|
||||
style={{ overflow: 'hidden', borderRadius: 'var(--mantine-radius-lg)' }}
|
||||
>
|
||||
<Image
|
||||
src={data.image.link}
|
||||
alt={data.judul}
|
||||
fit="cover"
|
||||
w="100%"
|
||||
h="100%"
|
||||
fallbackSrc="https://placehold.co/800x400?text=Gambar+tidak+tersedia"
|
||||
loading="lazy"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Stack gap="xs">
|
||||
<Badge color="blue" variant="light" size="md" radius="md" w="fit-content">
|
||||
{data.kategoriKegiatan?.nama || 'Kegiatan Desa'}
|
||||
</Badge>
|
||||
|
||||
<Title order={2} lh={1.3}>
|
||||
{data.judul}
|
||||
</Title>
|
||||
|
||||
<Group gap="xl" wrap="wrap">
|
||||
<Group gap={6}>
|
||||
<IconCalendar size={16} color="gray" />
|
||||
<Text fz="sm" c="dimmed">{formatTanggal(data.tanggal)}</Text>
|
||||
</Group>
|
||||
<Group gap={6}>
|
||||
<IconMapPin size={16} color="gray" />
|
||||
<Text fz="sm" c="dimmed">{data.lokasi || '-'}</Text>
|
||||
</Group>
|
||||
<Group gap={6}>
|
||||
<IconUsers size={16} color="gray" />
|
||||
<Text fz="sm" c="dimmed">{data.partisipan ?? 0} partisipan</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{data.deskripsiSingkat && (
|
||||
<Text fz={{ base: 'sm', md: 'md' }} c="dimmed" fw={500} fs="italic">
|
||||
{data.deskripsiSingkat}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text
|
||||
fz={{ base: 'sm', md: 'md' }}
|
||||
lh={{ base: 1.7, md: 1.9 }}
|
||||
ta="justify"
|
||||
dangerouslySetInnerHTML={{ __html: data.deskripsiLengkap || '' }}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default Page;
|
||||
@@ -0,0 +1,102 @@
|
||||
'use client'
|
||||
import colors from '@/con/colors';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Flex,
|
||||
Group,
|
||||
Image,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconArrowRight, IconCalendar, IconMapPin, IconUsers } from '@tabler/icons-react';
|
||||
|
||||
const formatTanggal = (val: string) =>
|
||||
val
|
||||
? new Date(val).toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
: '-';
|
||||
|
||||
export function KegiatanCard({
|
||||
item,
|
||||
onNavigate,
|
||||
}: {
|
||||
item: {
|
||||
id: string;
|
||||
judul: string;
|
||||
deskripsiSingkat: string;
|
||||
tanggal: string;
|
||||
lokasi?: string;
|
||||
partisipan?: number;
|
||||
image?: { link: string } | null;
|
||||
kategoriKegiatan?: { nama: string } | null;
|
||||
};
|
||||
onNavigate: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Card
|
||||
shadow="sm"
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{ cursor: 'pointer', transition: 'box-shadow 0.2s' }}
|
||||
onClick={onNavigate}
|
||||
>
|
||||
{item.image?.link && (
|
||||
<Card.Section>
|
||||
<Image
|
||||
src={item.image.link}
|
||||
height={200}
|
||||
alt={item.judul}
|
||||
fit="cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</Card.Section>
|
||||
)}
|
||||
|
||||
<Stack mt="md" gap="xs">
|
||||
<Badge color="blue" variant="light" size="sm" radius="md" w="fit-content">
|
||||
{item.kategoriKegiatan?.nama || 'Kegiatan'}
|
||||
</Badge>
|
||||
|
||||
<Title order={4} lineClamp={2} lh={1.35} fz={{ base: 'sm', md: 'md' }}>
|
||||
{item.judul}
|
||||
</Title>
|
||||
|
||||
<Text c="dimmed" lineClamp={2} fz={{ base: 'xs', md: 'sm' }} lh={1.55}>
|
||||
{item.deskripsiSingkat}
|
||||
</Text>
|
||||
|
||||
<Divider my={4} />
|
||||
|
||||
<Stack gap={4}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<IconCalendar size={13} color="gray" />
|
||||
<Text fz="xs" c="dimmed">{formatTanggal(item.tanggal)}</Text>
|
||||
</Group>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<IconMapPin size={13} color="gray" />
|
||||
<Text fz="xs" c="dimmed" lineClamp={1}>{item.lokasi || '-'}</Text>
|
||||
</Group>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<IconUsers size={13} color="gray" />
|
||||
<Text fz="xs" c="dimmed">{item.partisipan ?? 0} partisipan</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Flex justify="flex-end" mt="xs">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color={colors['blue-button']}
|
||||
rightSection={<IconArrowRight size={14} />}
|
||||
radius="md"
|
||||
>
|
||||
Lihat Detail
|
||||
</Button>
|
||||
</Flex>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
'use client'
|
||||
import stateDashboardKegiatan from '@/app/admin/(dashboard)/_state/desa/kegiatanDesa';
|
||||
import BackButton from '@/app/darmasaba/(pages)/desa/layanan/_com/BackButto';
|
||||
import colors from '@/con/colors';
|
||||
import { Box, Group, Select, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { IconSearch } from '@tabler/icons-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
|
||||
function LayoutTabsKegiatanDesa({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const kategoriState = useProxy(stateDashboardKegiatan.kategoriKegiatan);
|
||||
|
||||
const [searchValue, setSearchValue] = useState(searchParams.get('search') || '');
|
||||
const [debouncedSearch] = useDebouncedValue(searchValue, 500);
|
||||
|
||||
useEffect(() => {
|
||||
kategoriState.findMany.load(1, 100);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (debouncedSearch) params.set('search', debouncedSearch);
|
||||
else params.delete('search');
|
||||
params.delete('page');
|
||||
router.push(`/darmasaba/desa/kegiatan-desa/semua?${params.toString()}`);
|
||||
}, [debouncedSearch]);
|
||||
|
||||
const handleKategoriChange = (value: string | null) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value) params.set('kategori', value);
|
||||
else params.delete('kategori');
|
||||
params.delete('page');
|
||||
router.push(`/darmasaba/desa/kegiatan-desa/semua?${params.toString()}`);
|
||||
};
|
||||
|
||||
const kategoriOptions = (kategoriState.findMany.data || []).map((k: any) => ({
|
||||
value: k.nama,
|
||||
label: k.nama,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Stack pos="relative" bg={colors.Bg} py="xl" gap="22">
|
||||
<Box px={{ base: 'md', md: 100 }}>
|
||||
<BackButton />
|
||||
</Box>
|
||||
|
||||
<Box px={{ base: 'md', md: 100 }}>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<Stack gap={4}>
|
||||
<Text fz={{ base: '2rem', md: '3.4rem' }} c={colors['blue-button']} fw="bold" lh={1.2}>
|
||||
Kegiatan Desa Darmasaba
|
||||
</Text>
|
||||
<Text fz={{ base: 'sm', md: 'md' }} c="dimmed">
|
||||
Temukan berbagai kegiatan dan program yang dilaksanakan Desa Darmasaba
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Group gap="sm" wrap="nowrap" align="center">
|
||||
<Select
|
||||
radius="lg"
|
||||
placeholder="Semua Kategori"
|
||||
data={kategoriOptions}
|
||||
value={searchParams.get('kategori') || null}
|
||||
onChange={handleKategoriChange}
|
||||
clearable
|
||||
w={180}
|
||||
/>
|
||||
<TextInput
|
||||
radius="lg"
|
||||
placeholder="Cari kegiatan..."
|
||||
leftSection={<IconSearch size={18} />}
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
w={220}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default LayoutTabsKegiatanDesa;
|
||||
35
src/app/darmasaba/(pages)/desa/kegiatan-desa/layout.tsx
Normal file
35
src/app/darmasaba/(pages)/desa/kegiatan-desa/layout.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
'use client'
|
||||
import colors from '@/con/colors';
|
||||
import { Box } from '@mantine/core';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ReactNode } from 'react';
|
||||
import BackButton from '../layanan/_com/BackButto';
|
||||
|
||||
const LayoutTabsKegiatanDesa = dynamic(
|
||||
() => import('./_lib/layoutTabs'),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
export default function KegiatanDesaLayout({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
// /darmasaba/desa/kegiatan-desa/semua → 4 segments → list
|
||||
// /darmasaba/desa/kegiatan-desa/[kategori] → 4 segments → list
|
||||
// /darmasaba/desa/kegiatan-desa/[kategori]/[id]→ 5 segments → detail
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
const isDetailPage = segments.length === 5;
|
||||
|
||||
if (isDetailPage) {
|
||||
return (
|
||||
<Box bg={colors.Bg}>
|
||||
<Box pt={33} px={{ base: 'md', md: 100 }}>
|
||||
<BackButton />
|
||||
</Box>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return <LayoutTabsKegiatanDesa>{children}</LayoutTabsKegiatanDesa>;
|
||||
}
|
||||
101
src/app/darmasaba/(pages)/desa/kegiatan-desa/semua/page.tsx
Normal file
101
src/app/darmasaba/(pages)/desa/kegiatan-desa/semua/page.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
/* 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, Center, Image, Pagination, Paper, SimpleGrid, Skeleton, Stack, Text, Title } from '@mantine/core';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import { useTransitionRouter } from 'next-view-transitions';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
|
||||
function Semua() {
|
||||
const router = useTransitionRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const search = searchParams.get('search') || '';
|
||||
const kategori = searchParams.get('kategori') || '';
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
|
||||
const state = useProxy(stateDashboardKegiatan.kegiatan);
|
||||
const loading = state.findMany.loading;
|
||||
const items = state.findMany.data || [];
|
||||
const totalPages = state.findMany.totalPages || 1;
|
||||
|
||||
useShallowEffect(() => {
|
||||
state.findMany.load(page, 9, search, kategori);
|
||||
}, [page, search, kategori]);
|
||||
|
||||
const toDetail = (item: { id: string; kategoriKegiatan?: { nama: string } | null }) =>
|
||||
`/darmasaba/desa/kegiatan-desa/${item.kategoriKegiatan?.nama?.toLowerCase() || 'semua'}/${item.id}`;
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (newPage > 1) params.set('page', newPage.toString());
|
||||
else params.delete('page');
|
||||
router.replace(`/darmasaba/desa/kegiatan-desa/semua?${params.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box px={{ base: 'md', md: 100 }} pb="xl">
|
||||
{loading ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="xl">
|
||||
{Array(6).fill(0).map((_, i) => <Skeleton key={i} h={400} radius="lg" />)}
|
||||
</SimpleGrid>
|
||||
) : items.length === 0 ? (
|
||||
<Center py="xl">
|
||||
<Text c="dimmed" fz="sm">Belum ada kegiatan desa.</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="xl">
|
||||
{items.map((item) => (
|
||||
<Paper key={item.id} shadow="sm" radius="lg" withBorder style={{ overflow: 'hidden' }}>
|
||||
{item.image?.link && (
|
||||
<Image
|
||||
src={item.image.link}
|
||||
height={220}
|
||||
alt={item.judul}
|
||||
fit="cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
<Stack p="md" gap="xs">
|
||||
<Title order={4} c={colors['blue-button']} ta="center" lineClamp={2}>
|
||||
{item.judul}
|
||||
</Title>
|
||||
<Text
|
||||
fz="sm"
|
||||
c="dark"
|
||||
ta="justify"
|
||||
lineClamp={3}
|
||||
lh={1.6}
|
||||
>
|
||||
{item.deskripsiSingkat}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Center pb="md">
|
||||
<Button variant="light" onClick={() => router.push(toDetail(item))}>
|
||||
Detail
|
||||
</Button>
|
||||
</Center>
|
||||
</Paper>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
<Center mt="xl">
|
||||
<Pagination
|
||||
value={page}
|
||||
onChange={handlePageChange}
|
||||
total={totalPages}
|
||||
size="lg"
|
||||
radius="xl"
|
||||
styles={{
|
||||
control: { border: `1px solid ${colors['blue-button']}` },
|
||||
}}
|
||||
/>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default Semua;
|
||||
@@ -85,6 +85,11 @@ const navbarListMenu = [
|
||||
id: "2.7",
|
||||
name: "Penghargaan",
|
||||
href: "/darmasaba/penghargaan"
|
||||
},
|
||||
{
|
||||
id: "2.8",
|
||||
name: "Kegiatan Desa",
|
||||
href: "/darmasaba/desa/kegiatan-desa/semua"
|
||||
}
|
||||
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user