Compare commits

...

10 Commits

Author SHA1 Message Date
dccba1f82b feat(kesehatan): refactor ringkasan kesehatan to auto-derived stats
- Add IbuHamil and Balita models to schema.prisma
- Implement IbuHamil and Balita API modules (CRUD)
- Implement /stats endpoint for aggregated health KPIs
- Refactor ringkasan-kesehatan admin page to dashboard-style UI
- Update sidebar with Ibu Hamil and Balita entries
- Fix type errors and icon exports in admin UI
- Bump version to 0.1.52
2026-05-04 16:52:14 +08:00
fc6846f7a1 feat(kesehatan): admin page ringkasan-kesehatan + sidebar entry - bump ke 0.1.51
- New page src/app/admin/(dashboard)/kesehatan/ringkasan-kesehatan/page.tsx
  konsumsi ringkasanKesehatanState (load + submit) dengan 7 NumberInput
  (3 count + 4 pct).
- Tambah Kesehatan_8 "Ringkasan Kesehatan" di 3 instance sidebar
  (list_PageAdmin.tsx).

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 14:27:28 +08:00
23c955597e feat(api): add KategoriKegiatan CRUD API and register module - bump to 0.1.46
- Add KategoriKegiatan CRUD (create, findMany, findUnique, update, del)
- Register KategoriKegiatan in Desa API router
- Support soft delete for categories
2026-04-30 11:33:29 +08:00
70 changed files with 7192 additions and 13 deletions

View File

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

321
AI-CONTRACT.md Normal file
View File

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

View File

@@ -30,6 +30,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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)
}

View File

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

View File

@@ -0,0 +1,153 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
import { Prisma } from "@prisma/client";
const lambangDesaForm = z.object({
judul: z.string().min(3, "Judul minimal 3 karakter"),
deskripsi: z.string().min(3, "Deskripsi minimal 3 karakter"),
});
const lambangDesaDefaultForm = {
judul: "",
deskripsi: "",
};
type LambangDesaForm = Prisma.LambangDesaGetPayload<{
select: {
id: true;
judul: true;
deskripsi: true;
};
}>;
const lambangDesa = proxy({
findUnique: {
data: null as LambangDesaForm | null,
loading: false,
error: null as string | null,
async load(id: string) {
if (!id) {
toast.warn("ID tidak valid");
return null;
}
this.loading = true;
this.error = null;
try {
const response = await fetch(`/api/desa/profile/lambang/${id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result.success) {
this.data = result.data;
return result.data;
} else {
throw new Error(
result.message || "Gagal mengambil data lambang desa"
);
}
} catch (error) {
const msg = (error as Error).message;
this.error = msg;
console.error("Load lambang desa error:", msg);
toast.error("Terjadi kesalahan saat mengambil data lambang desa");
return null;
} finally {
this.loading = false;
}
},
reset() {
this.data = null;
this.error = null;
this.loading = false;
},
},
update: {
id: "",
form: { ...lambangDesaDefaultForm },
loading: false,
error: null as string | null,
isReadOnly: false,
initialize(lambangDesaData: LambangDesaForm) {
this.id = lambangDesaData.id;
this.isReadOnly = false;
this.form = {
judul: lambangDesaData.judul || "",
deskripsi: lambangDesaData.deskripsi || "",
};
},
updateField(field: keyof typeof lambangDesaDefaultForm, value: string) {
this.form[field] = value;
},
async submit() {
const validation = lambangDesaForm.safeParse(this.form);
if (!validation.success) {
const errors = validation.error.issues
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
.join(", ");
toast.error(`Form tidak valid: ${errors}`);
return false;
}
this.loading = true;
this.error = null;
try {
const response = await fetch(`/api/desa/profile/lambang/${this.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(this.form),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.message || `HTTP error! status: ${response.status}`
);
}
const result = await response.json();
if (result.success) {
toast.success("Berhasil update lambang desa");
await lambangDesa.findUnique.load(this.id);
return true;
} else {
throw new Error(result.message || "Gagal update lambang desa");
}
} catch (error) {
const errorMessage = (error as Error).message;
this.error = errorMessage;
console.error("Update lambang desa error:", errorMessage);
toast.error("Terjadi kesalahan saat update lambang desa");
return false;
} finally {
this.loading = false;
}
},
reset() {
this.id = "";
this.form = { ...lambangDesaDefaultForm };
this.error = null;
this.loading = false;
this.isReadOnly = false;
},
},
});
export default lambangDesa;

View File

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

View File

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

View File

@@ -0,0 +1,185 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
import { Prisma } from "@prisma/client";
const profilPerbekelForm = z.object({
biodata: z.string().min(3, "Biodata minimal 3 karakter"),
pengalaman: z.string().min(3, "Pengalaman minimal 3 karakter"),
pengalamanOrganisasi: z
.string()
.min(3, "Pengalaman Organisasi minimal 3 karakter"),
programUnggulan: z.string().min(3, "Program Unggulan minimal 3 karakter"),
imageId: z.string().min(1, "Gambar wajib dipilih"),
});
const profilPerbekelDefaultForm = {
biodata: "",
pengalaman: "",
pengalamanOrganisasi: "",
programUnggulan: "",
imageId: "",
};
type ProfilPerbekelForm = Prisma.ProfilPerbekelGetPayload<{
select: {
id: true;
biodata: true;
pengalaman: true;
pengalamanOrganisasi: true;
programUnggulan: true;
imageId: true;
image?: {
select: {
link: true;
};
};
};
}>;
const profilPerbekel = proxy({
findUnique: {
data: null as ProfilPerbekelForm | null,
loading: false,
error: null as string | null,
async load(id: string) {
if (!id) {
toast.warn("ID tidak valid");
return null;
}
this.loading = true;
this.error = null;
try {
const response = await fetch(`/api/desa/profile/profileperbekel/${id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result.success) {
this.data = result.data;
return result.data;
} else {
throw new Error(
result.message || "Gagal mengambil data profil perbekel"
);
}
} catch (error) {
const msg = (error as Error).message;
this.error = msg;
toast.error("Terjadi kesalahan saat mengambil data profil perbekel");
return null;
} finally {
this.loading = false;
}
},
reset() {
this.data = null;
this.error = null;
this.loading = false;
},
},
edit: {
id: "",
form: { ...profilPerbekelDefaultForm },
loading: false,
error: null as string | null,
isReadOnly: false,
initialize(profilData: ProfilPerbekelForm) {
this.id = profilData.id;
this.isReadOnly = false;
this.form = {
biodata: profilData.biodata || "",
pengalaman: profilData.pengalaman || "",
pengalamanOrganisasi: profilData.pengalamanOrganisasi || "",
programUnggulan: profilData.programUnggulan || "",
imageId: profilData.imageId || "",
};
},
updateField(field: keyof typeof profilPerbekelDefaultForm, value: string) {
this.form[field] = value;
},
async submit() {
const validation = profilPerbekelForm.safeParse(this.form);
if (!validation.success) {
const errors = validation.error.issues
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
.join(", ");
toast.error(`Form tidak valid: ${errors}`);
return false;
}
this.loading = true;
this.error = null;
try {
const response = await fetch(
`/api/desa/profile/profileperbekel/${this.id}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(this.form),
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.message || `HTTP error! status: ${response.status}`
);
}
const result = await response.json();
if (result.success) {
toast.success("Berhasil update profil perbekel");
await profilPerbekel.findUnique.load(this.id);
return true;
} else {
throw new Error(result.message || "Gagal update profil perbekel");
}
} catch (error) {
const msg = (error as Error).message;
this.error = msg;
toast.error("Terjadi kesalahan saat update profil perbekel");
return false;
} finally {
this.loading = false;
}
},
reset() {
this.id = "";
this.form = { ...profilPerbekelDefaultForm };
this.error = null;
this.loading = false;
this.isReadOnly = false;
},
},
async loadForEdit(id: string) {
const profileData = await this.findUnique.load(id);
if (profileData) {
this.edit.initialize(profileData);
}
return profileData;
},
reset() {
this.findUnique.reset();
this.edit.reset();
},
});
export default profilPerbekel;

View File

@@ -0,0 +1,153 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
import { Prisma } from "@prisma/client";
const sejarahDesaForm = z.object({
judul: z.string().min(3, "Judul minimal 3 karakter"),
deskripsi: z.string().min(3, "Deskripsi minimal 3 karakter"),
});
const sejarahDesaDefaultForm = {
judul: "",
deskripsi: "",
};
type SejarahDesaForm = Prisma.SejarahDesaGetPayload<{
select: {
id: true;
judul: true;
deskripsi: true;
};
}>;
const sejarahDesa = proxy({
findUnique: {
data: null as SejarahDesaForm | null,
loading: false,
error: null as string | null,
async load(id: string) {
if (!id) {
toast.warn("ID tidak valid");
return null;
}
this.loading = true;
this.error = null;
try {
const response = await fetch(`/api/desa/profile/sejarah/${id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result.success) {
this.data = result.data;
return result.data;
} else {
throw new Error(
result.message || "Gagal mengambil data sejarah desa"
);
}
} catch (error) {
const msg = (error as Error).message;
this.error = msg;
console.error("Load sejarah desa error:", msg);
toast.error("Terjadi kesalahan saat mengambil data sejarah desa");
return null;
} finally {
this.loading = false;
}
},
reset() {
this.data = null;
this.error = null;
this.loading = false;
},
},
update: {
id: "",
form: { ...sejarahDesaDefaultForm },
loading: false,
error: null as string | null,
isReadOnly: false,
initialize(sejarahData: SejarahDesaForm) {
this.id = sejarahData.id;
this.isReadOnly = false;
this.form = {
judul: sejarahData.judul || "",
deskripsi: sejarahData.deskripsi || "",
};
},
updateField(field: keyof typeof sejarahDesaDefaultForm, value: string) {
this.form[field] = value;
},
async submit() {
const validation = sejarahDesaForm.safeParse(this.form);
if (!validation.success) {
const errors = validation.error.issues
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
.join(", ");
toast.error(`Form tidak valid: ${errors}`);
return false;
}
this.loading = true;
this.error = null;
try {
const response = await fetch(`/api/desa/profile/sejarah/${this.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(this.form),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.message || `HTTP error! status: ${response.status}`
);
}
const result = await response.json();
if (result.success) {
toast.success("Berhasil update profile");
await sejarahDesa.findUnique.load(this.id);
return true;
} else {
throw new Error(result.message || "Gagal update profile");
}
} catch (error) {
const errorMessage = (error as Error).message;
this.error = errorMessage;
console.error("Update profile error:", errorMessage);
toast.error("Terjadi kesalahan saat update profile");
return false;
} finally {
this.loading = false;
}
},
reset() {
this.id = "";
this.form = { ...sejarahDesaDefaultForm };
this.error = null;
this.loading = false;
this.isReadOnly = false;
},
},
});
export default sejarahDesa;

View File

@@ -0,0 +1,153 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
import { Prisma } from "@prisma/client";
const visiMisiDesaForm = z.object({
visi: z.string().min(3, "Visi minimal 3 karakter"),
misi: z.string().min(3, "Misi minimal 3 karakter"),
});
const visiMisiDesaDefaultForm = {
visi: "",
misi: "",
};
type VisiMisiDesaForm = Prisma.VisiMisiDesaGetPayload<{
select: {
id: true;
visi: true;
misi: true;
};
}>;
const visiMisiDesa = proxy({
findUnique: {
data: null as VisiMisiDesaForm | null,
loading: false,
error: null as string | null,
async load(id: string) {
if (!id) {
toast.warn("ID tidak valid");
return null;
}
this.loading = true;
this.error = null;
try {
const response = await fetch(`/api/desa/profile/visi-misi/${id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result.success) {
this.data = result.data;
return result.data;
} else {
throw new Error(
result.message || "Gagal mengambil data visi misi desa"
);
}
} catch (error) {
const msg = (error as Error).message;
this.error = msg;
console.error("Load visi misi desa error:", msg);
toast.error("Terjadi kesalahan saat mengambil data visi misi desa");
return null;
} finally {
this.loading = false;
}
},
reset() {
this.data = null;
this.error = null;
this.loading = false;
},
},
update: {
id: "",
form: { ...visiMisiDesaDefaultForm },
loading: false,
error: null as string | null,
isReadOnly: false,
initialize(visiMisiData: VisiMisiDesaForm) {
this.id = visiMisiData.id;
this.isReadOnly = false;
this.form = {
visi: visiMisiData.visi || "",
misi: visiMisiData.misi || "",
};
},
updateField(field: keyof typeof visiMisiDesaDefaultForm, value: string) {
this.form[field] = value;
},
async submit() {
const validation = visiMisiDesaForm.safeParse(this.form);
if (!validation.success) {
const errors = validation.error.issues
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
.join(", ");
toast.error(`Form tidak valid: ${errors}`);
return false;
}
this.loading = true;
this.error = null;
try {
const response = await fetch(`/api/desa/profile/visi-misi/${this.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(this.form),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.message || `HTTP error! status: ${response.status}`
);
}
const result = await response.json();
if (result.success) {
toast.success("Berhasil update visi misi desa");
await visiMisiDesa.findUnique.load(this.id);
return true;
} else {
throw new Error(result.message || "Gagal update visi misi desa");
}
} catch (error) {
const errorMessage = (error as Error).message;
this.error = errorMessage;
console.error("Update visi misi desa error:", errorMessage);
toast.error("Terjadi kesalahan saat update visi misi desa");
return false;
} finally {
this.loading = false;
}
},
reset() {
this.id = "";
this.form = { ...visiMisiDesaDefaultForm };
this.error = null;
this.loading = false;
this.isReadOnly = false;
},
},
});
export default visiMisiDesa;

View File

@@ -0,0 +1,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;

View 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;

View File

@@ -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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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"
}
]
},

View File

@@ -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;

View File

@@ -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;

View File

@@ -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(),

View File

@@ -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);
}
}

View File

@@ -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 });
}
}

View File

@@ -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;

View File

@@ -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'),
}
}
}

View File

@@ -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;

View File

@@ -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);
}
}

View 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 };
}

View 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" };
}

View 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" },
});
}
}

View 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" };
}
}

View 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;

View 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" },
});
}
}

View 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 };
}

View 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" };
}

View File

@@ -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" },
});
}
}

View File

@@ -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" };
}
}

View 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;

View 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" },
});
}
}

View File

@@ -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;

View File

@@ -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 }),
}),
});

View File

@@ -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" };
}
}

View File

@@ -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),
},
});

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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;

View 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>;
}

View 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;

View File

@@ -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"
}
]