feat(kesehatan): posyandu banjar relation, redesign halaman publik, fix tips keamanan image

- Tambah model Banjar + relasi ke Posyandu (migration + seeder)
- Update API posyandu (create/update/find) untuk support banjarId
- Tambah endpoint banjar di kesehatan API
- Redesign halaman publik posyandu dengan tabs: ringkasan, data posyandu, balita, ibu hamil
- Update halaman admin posyandu list/create/edit/detail untuk banjar
- Fix image ketukar pada seed tips keamanan
- Hapus seeder core yang sudah tidak dipakai

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 15:51:32 +08:00
parent 9de32e5f12
commit 97d08734c5
38 changed files with 1126 additions and 454 deletions

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
- UI/UX design system, tokens, komponen, pola halaman: @.claude/DESIGN.md
- AI collaboration contract, rules, and guidelines: @AI-CONTRACT.md
### Workflow for Code Changes

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1150,6 +1150,16 @@ model DoctorSign {
ArtikelKesehatan ArtikelKesehatan[]
}
// ========================================= BANJAR ========================================= //
model Banjar {
id String @id @default(cuid())
name String
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
posyandus Posyandu[]
}
// ========================================= POSYANDU ========================================= //
model Posyandu {
id String @id @default(cuid())
@@ -1159,6 +1169,8 @@ model Posyandu {
jadwalPelayanan String
image FileStorage? @relation(fields: [imageId], references: [id])
imageId String?
banjar Banjar? @relation(fields: [banjarId], references: [id])
banjarId String?
ibuHamil IbuHamil[]
balita Balita[]
createdAt DateTime @default(now())

View File

@@ -38,6 +38,7 @@ import { seedKontakDaruratKeamanan } from "./_seeder_list/keamanan/seed_kontak_d
import { seedLaporanPublik } from "./_seeder_list/keamanan/seed_laporan_publik";
import { seedPencegahanKriminalitas } from "./_seeder_list/keamanan/seed_pencegahan_kriminalitas";
import { seedPolsekTerdekat } from "./_seeder_list/keamanan/seed_polsek_terdekat";
import { seedTipsKeamanan } from "./_seeder_list/keamanan/seed_tips_keamanan";
import { seedArtikelKesehatan } from "./_seeder_list/kesehatan/artikel-kesehatan/seed_artikel_kesehatan";
import { seedFasilitasKesehatan } from "./_seeder_list/kesehatan/fasilitas-kesehatan/seed_fasilitas_kesehatan";
import { seedInfoWabahPenyakit } from "./_seeder_list/kesehatan/info-wabah-penyakit/seed_info_wabah_penyakit";
@@ -49,6 +50,7 @@ import { seedProgramKesehatan } from "./_seeder_list/kesehatan/program-kesehatan
import { seedPuskesmas } from "./_seeder_list/kesehatan/puskesmas/seed_puskesmas";
import { seedGrafikKepuasan } from "./_seeder_list/kesehatan/seed_grafik_kepuasan";
import { seedKelahiranKematian } from "./_seeder_list/kesehatan/seed_kelahiran_kematian";
import { seedBanjar } from "./_seeder_list/kesehatan/seed_banjar";
import { seedRingkasanKesehatan } from "./_seeder_list/kesehatan/seed_ringkasan_kesehatan";
import { seedIbuHamil } from "./_seeder_list/kesehatan/seed_ibu_hamil";
import { seedBalita } from "./_seeder_list/kesehatan/seed_balita";
@@ -84,6 +86,7 @@ import { seedIkmPpid } from "./_seeder_list/ppid/ikm/seed_ikm";
import { seedProfilPpd } from "./_seeder_list/ppid/profil-ppid/seed_profil_ppd";
import { seedPegawaiPpid } from "./_seeder_list/ppid/struktur-ppid/seed_struktur_ppid";
import { seedVisiMisiPpid } from "./_seeder_list/ppid/visi-misi-ppid/seed_visi_misi_ppid";
import { seedStrukturOrganisasiPpid, seedFormulirPermohonanKeberatan, seedIndeksKepuasanMasyarakat, seedGrafikBerdasarkanJenisKelamin, seedGrafikBerdasarkanResponden, seedGrafikBerdasarkanUmur } from "./_seeder_list/ppid/seed_ppid_extra";
import roles from "./data/user/roles.json";
import users from "./data/user/users.json";
import { safeSeedUnique } from "./safeseedUnique";
@@ -219,6 +222,12 @@ import seedAssets from "./seed_assets";
// // =========== SUBMENU INDEKS KEPUASAN MASYARAKAT ===========
await seedIkmPpid();
await seedStrukturOrganisasiPpid();
await seedFormulirPermohonanKeberatan();
await seedIndeksKepuasanMasyarakat();
await seedGrafikBerdasarkanJenisKelamin();
await seedGrafikBerdasarkanResponden();
await seedGrafikBerdasarkanUmur();
// // =========== MENU DESA ===========
// // =========== SUBMENU PROFILE ===========
@@ -245,6 +254,9 @@ import seedAssets from "./seed_assets";
await seedPenghargaan();
// // ====================== MENU KESEHATAN ========================
// // ==================== SUBMENU BANJAR =========================
await seedBanjar();
// // ==================== SUBMENU POSYANDU =========================
await seedPosyandu();
@@ -285,7 +297,7 @@ import seedAssets from "./seed_assets";
await seedCctv();
// // ==================== SUBMENU TIPS KEAMANAN ==================
await seedKeamananLingkungan();
await seedTipsKeamanan();
// // ====================== MENU EKONOMI ========================
// // ==================== SUBMENU UMKM ==========================

View File

@@ -82,7 +82,15 @@ const balitaState = proxy({
findMany: {
data: null as
| Prisma.BalitaGetPayload<{
include: { posyandu: { select: { id: true; name: true } } };
include: {
posyandu: {
select: {
id: true;
name: true;
banjar: { select: { id: true; name: true } };
};
};
};
}>[]
| null,
page: 1,

View File

@@ -72,7 +72,15 @@ const ibuHamilState = proxy({
findMany: {
data: null as
| Prisma.IbuHamilGetPayload<{
include: { posyandu: { select: { id: true; name: true } } };
include: {
posyandu: {
select: {
id: true;
name: true;
banjar: { select: { id: true; name: true } };
};
};
};
}>[]
| null,
page: 1,

View File

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

View File

@@ -18,14 +18,39 @@ const intPct = z
.min(0, { message: "Minimal 0" })
.max(100, { message: "Maksimal 100" });
type BanjarOption = { id: string; name: string };
const ringkasanKesehatanState = proxy({
banjarId: "" as string,
findBanjar: {
data: [] as BanjarOption[],
loading: false,
async load() {
try {
ringkasanKesehatanState.findBanjar.loading = true;
const res = await fetch(`/api/kesehatan/banjar/find-many`);
if (res.ok) {
const result = await res.json();
ringkasanKesehatanState.findBanjar.data = result?.data ?? [];
}
} catch (error) {
console.error("Error fetching banjar:", error);
} finally {
ringkasanKesehatanState.findBanjar.loading = false;
}
},
},
findStats: {
data: null as StatsData | null,
loading: false,
async load() {
try {
ringkasanKesehatanState.findStats.loading = true;
const res = await fetch(`/api/kesehatan/ringkasankesehatan/stats`);
const banjarId = ringkasanKesehatanState.banjarId;
const params = banjarId ? `?banjarId=${encodeURIComponent(banjarId)}` : "";
const res = await fetch(`/api/kesehatan/ringkasankesehatan/stats${params}`);
if (res.ok) {
const result = await res.json();
ringkasanKesehatanState.findStats.data = result?.data ?? null;

View File

@@ -115,13 +115,14 @@ function ListBalita({ search }: { search: string }) {
<Table highlightOnHover layout="fixed" withColumnBorders={false}>
<TableThead>
<TableTr>
<TableTh w="22%">Nama</TableTh>
<TableTh w="7%">JK</TableTh>
<TableTh w="12%">Tgl Lahir</TableTh>
<TableTh w="12%">Imunisasi</TableTh>
<TableTh w="10%">Gizi</TableTh>
<TableTh w="12%">Pemeriksaan</TableTh>
<TableTh w="11%">Stunting</TableTh>
<TableTh w="18%">Nama</TableTh>
<TableTh w="6%">JK</TableTh>
<TableTh w="11%">Tgl Lahir</TableTh>
<TableTh w="13%">Banjar</TableTh>
<TableTh w="10%">Imunisasi</TableTh>
<TableTh w="8%">Gizi</TableTh>
<TableTh w="10%">Pemeriksaan</TableTh>
<TableTh w="10%">Stunting</TableTh>
<TableTh w="14%">Aksi</TableTh>
</TableTr>
</TableThead>
@@ -136,6 +137,7 @@ function ListBalita({ search }: { search: string }) {
? new Date(d.tanggalLahir).toLocaleDateString('id-ID')
: '-'}
</TableTd>
<TableTd>{d.posyandu?.banjar?.name ?? '-'}</TableTd>
<TableTd>
<Badge color={d.imunisasiLengkap ? 'green' : 'red'} variant="light">
{d.imunisasiLengkap ? 'Lengkap' : 'Belum'}
@@ -190,7 +192,7 @@ function ListBalita({ search }: { search: string }) {
))
) : (
<TableTr>
<TableTd colSpan={8}>
<TableTd colSpan={9}>
<Center py="xl">
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data balita yang cocok
@@ -212,19 +214,22 @@ function ListBalita({ search }: { search: string }) {
<Text fz="sm" fw={600} mb={4}>
{d.nama}
</Text>
<Group gap="xs" mb={6}>
<Group gap="xs" mb={4}>
<Text fz="xs" c="dimmed">
{d.jenisKelamin}
</Text>
<Text fz="xs" c="dimmed">
·
</Text>
<Text fz="xs" c="dimmed">·</Text>
<Text fz="xs" c="dimmed">
{d.tanggalLahir
? new Date(d.tanggalLahir).toLocaleDateString('id-ID')
: '-'}
</Text>
</Group>
{d.posyandu?.banjar?.name && (
<Text fz="xs" c="dimmed" mb={4}>
Banjar: {d.posyandu.banjar.name}
</Text>
)}
<Group gap="xs" mb={8}>
<Badge
size="xs"

View File

@@ -117,12 +117,13 @@ function ListIbuHamil({ search }: { search: string }) {
<Table highlightOnHover layout="fixed" withColumnBorders={false}>
<TableThead>
<TableTr>
<TableTh w="25%">Nama</TableTh>
<TableTh w="18%">NIK</TableTh>
<TableTh w="17%">Usia Kehamilan</TableTh>
<TableTh w="15%">No. HP</TableTh>
<TableTh w="12%">Status</TableTh>
<TableTh w="13%">Aksi</TableTh>
<TableTh w="20%">Nama</TableTh>
<TableTh w="15%">NIK</TableTh>
<TableTh w="15%">Usia Kehamilan</TableTh>
<TableTh w="13%">No. HP</TableTh>
<TableTh w="15%">Banjar</TableTh>
<TableTh w="10%">Status</TableTh>
<TableTh w="12%">Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
@@ -133,6 +134,7 @@ function ListIbuHamil({ search }: { search: string }) {
<TableTd>{d.nik || '-'}</TableTd>
<TableTd>{d.usiaKehamilan} minggu</TableTd>
<TableTd>{d.noHp || '-'}</TableTd>
<TableTd>{d.posyandu?.banjar?.name ?? '-'}</TableTd>
<TableTd>
<Badge
color={STATUS_COLORS[d.status] ?? 'gray'}
@@ -172,7 +174,7 @@ function ListIbuHamil({ search }: { search: string }) {
))
) : (
<TableTr>
<TableTd colSpan={6}>
<TableTd colSpan={7}>
<Center py="xl">
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data ibu hamil yang cocok
@@ -194,17 +196,20 @@ function ListIbuHamil({ search }: { search: string }) {
<Text fz="sm" fw={600} mb={4}>
{d.nama}
</Text>
<Group gap="xs" mb={6}>
<Group gap="xs" mb={4}>
<Text fz="xs" c="dimmed">
NIK: {d.nik || '-'}
</Text>
<Text fz="xs" c="dimmed">
·
</Text>
<Text fz="xs" c="dimmed">·</Text>
<Text fz="xs" c="dimmed">
{d.usiaKehamilan} minggu
</Text>
</Group>
{d.posyandu?.banjar?.name && (
<Text fz="xs" c="dimmed" mb={4}>
Banjar: {d.posyandu.banjar.name}
</Text>
)}
<Group gap="xs" mb={8}>
<Badge
size="xs"

View File

@@ -2,6 +2,7 @@
'use client';
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
import posyandustate from '@/app/admin/(dashboard)/_state/kesehatan/posyandu/posyandu';
import ringkasanKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/ringkasan-kesehatan/ringkasanKesehatan';
import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch';
import {
@@ -12,6 +13,7 @@ import {
Image,
Loader,
Paper,
Select,
Stack,
Text,
TextInput,
@@ -26,6 +28,7 @@ import { useProxy } from 'valtio/utils';
function EditPosyandu() {
const statePosyandu = useProxy(posyandustate);
const stateBanjar = useProxy(ringkasanKesehatanState);
const router = useRouter();
const params = useParams();
@@ -57,6 +60,7 @@ function EditPosyandu() {
deskripsi: '',
imageId: '',
jadwalPelayanan: '',
banjarId: '',
});
const [originalData, setOriginalData] = useState({
name: "",
@@ -64,11 +68,13 @@ function EditPosyandu() {
deskripsi: "",
imageId: "",
jadwalPelayanan: "",
banjarId: "",
imageUrl: ""
});
// Load data posyandu
// Load data posyandu dan banjar
useEffect(() => {
ringkasanKesehatanState.findBanjar.load();
const loadPosyandu = async () => {
const id = params?.id as string;
if (!id) return;
@@ -82,6 +88,7 @@ function EditPosyandu() {
deskripsi: data.deskripsi || '',
imageId: data.imageId || '',
jadwalPelayanan: data.jadwalPelayanan || '',
banjarId: data.banjarId || '',
});
setOriginalData({
name: data.name || '',
@@ -89,6 +96,7 @@ function EditPosyandu() {
deskripsi: data.deskripsi || '',
imageId: data.imageId || '',
jadwalPelayanan: data.jadwalPelayanan || '',
banjarId: data.banjarId || '',
imageUrl: data.image?.link || '',
});
if (data?.image?.link) setPreviewImage(data.image.link);
@@ -129,7 +137,7 @@ function EditPosyandu() {
try {
setIsSubmitting(true);
const updatedForm = { ...statePosyandu.edit.form, ...formData };
const updatedForm = { ...statePosyandu.edit.form, ...formData, banjarId: formData.banjarId };
// Upload file jika ada
if (file) {
@@ -160,6 +168,7 @@ function EditPosyandu() {
deskripsi: originalData.deskripsi,
imageId: originalData.imageId,
jadwalPelayanan: originalData.jadwalPelayanan,
banjarId: originalData.banjarId,
});
setPreviewImage(originalData.imageUrl || null);
setFile(null);
@@ -282,6 +291,15 @@ function EditPosyandu() {
required
/>
<Select
label="Banjar"
placeholder="Pilih banjar"
clearable
data={stateBanjar.findBanjar.data.map((b) => ({ value: b.id, label: b.name }))}
value={formData.banjarId || null}
onChange={(val) => setFormData({ ...formData, banjarId: val ?? '' })}
/>
<Box>
<Text fw="bold" fz="sm" mb={6}>
Deskripsi Posyandu

View File

@@ -82,6 +82,11 @@ function DetailPosyandu() {
</Box>
<Box>
<Text fz="lg" fw="bold">Banjar</Text>
<Text fz="md" c="dimmed">{data.banjar?.name || '-'}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Nomor Posyandu</Text>
<Text fz="md" c="dimmed">{data.nomor || '-'}</Text>

View File

@@ -11,6 +11,7 @@ import {
Image,
Loader,
Paper,
Select,
Stack,
Text,
TextInput,
@@ -19,19 +20,23 @@ import {
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
import ringkasanKesehatanState from '../../../../_state/kesehatan/ringkasan-kesehatan/ringkasanKesehatan';
function CreatePosyandu() {
const statePosyandu = useProxy(posyandustate);
const stateBanjar = useProxy(ringkasanKesehatanState);
const router = useRouter();
const [file, setFile] = useState<File | null>(null);
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => { ringkasanKesehatanState.findBanjar.load(); }, []);
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
@@ -57,6 +62,7 @@ function CreatePosyandu() {
deskripsi: '',
imageId: '',
jadwalPelayanan: '',
banjarId: '',
};
setFile(null);
setPreviewImage(null);
@@ -223,6 +229,14 @@ function CreatePosyandu() {
onChange={(e) => (statePosyandu.create.form.nomor = e.target.value)}
required
/>
<Select
label="Banjar"
placeholder="Pilih banjar"
clearable
data={stateBanjar.findBanjar.data.map((b) => ({ value: b.id, label: b.name }))}
value={statePosyandu.create.form.banjarId || null}
onChange={(val) => { statePosyandu.create.form.banjarId = val ?? ''; }}
/>
<Box>
<Text fw="bold" fz="sm" mb={6}>
Deskripsi Posyandu

View File

@@ -96,27 +96,33 @@ function ListPosyandu({ search }: { search: string }) {
>
<TableThead>
<TableTr>
<TableTh w={220} fz="sm" fw={600} ta="left" lh={1.4}>Nama Posyandu</TableTh>
<TableTh w={220} fz="sm" fw={600} ta="left" lh={1.4}>Nomor Posyandu</TableTh>
<TableTh w={220} fz="sm" fw={600} ta="left" lh={1.4}>Deskripsi</TableTh>
<TableTh w={150} fz="sm" fw={600} ta="left" lh={1.4}>Aksi</TableTh>
<TableTh w={200} fz="sm" fw={600} ta="left" lh={1.4}>Nama Posyandu</TableTh>
<TableTh w={160} fz="sm" fw={600} ta="left" lh={1.4}>Banjar</TableTh>
<TableTh w={160} fz="sm" fw={600} ta="left" lh={1.4}>Nomor Posyandu</TableTh>
<TableTh w={200} fz="sm" fw={600} ta="left" lh={1.4}>Deskripsi</TableTh>
<TableTh w={120} fz="sm" fw={600} ta="left" lh={1.4}>Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd w={220}>
<TableTd w={200}>
<Text fz="md" fw={500} lh={1.5} truncate="end" lineClamp={1}>
{item.name}
</Text>
</TableTd>
<TableTd w={220}>
<TableTd w={160}>
<Text fz="sm" c={item.banjar ? undefined : 'dimmed'} lh={1.5}>
{item.banjar?.name || '-'}
</Text>
</TableTd>
<TableTd w={160}>
<Text fz="sm" c="dimmed" lh={1.5}>
{item.nomor || '-'}
</Text>
</TableTd>
<TableTd w={220}>
<TableTd w={200}>
<Text
fz="sm"
lh={1.5}
@@ -125,7 +131,7 @@ function ListPosyandu({ search }: { search: string }) {
dangerouslySetInnerHTML={{ __html: item.deskripsi }}
/>
</TableTd>
<TableTd w={220}>
<TableTd w={120}>
<Button
size="xs"
radius="md"
@@ -141,7 +147,7 @@ function ListPosyandu({ search }: { search: string }) {
))
) : (
<TableTr>
<TableTd colSpan={4}>
<TableTd colSpan={5}>
<Center py={20}>
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data posyandu yang cocok
@@ -169,6 +175,14 @@ function ListPosyandu({ search }: { search: string }) {
{item.name}
</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Banjar
</Text>
<Text fz="sm" fw={500} lh={1.5} c={item.banjar ? undefined : 'dimmed'}>
{item.banjar?.name || '-'}
</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Nomor Posyandu

View File

@@ -10,6 +10,7 @@ import {
Loader,
NumberInput,
Paper,
Select,
SimpleGrid,
Stack,
Text,
@@ -73,13 +74,29 @@ export default function RingkasanKesehatanPage() {
const stats = state.findStats.data;
const loadStats = useCallback(() => { ringkasanKesehatanState.findStats.load(); }, []);
useEffect(() => { loadStats(); }, [loadStats]);
useEffect(() => {
ringkasanKesehatanState.findBanjar.load();
loadStats();
}, [loadStats]);
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>
<Group justify="space-between" align="flex-end" mb="md" wrap="wrap" gap="sm">
<Title order={3} c="black">Ringkasan Kesehatan Desa</Title>
<Select
placeholder="Semua Banjar"
clearable
data={state.findBanjar.data.map((b) => ({ value: b.id, label: b.name }))}
value={state.banjarId || null}
onChange={(val) => {
ringkasanKesehatanState.banjarId = val ?? "";
ringkasanKesehatanState.findStats.load();
}}
style={{ minWidth: 200 }}
/>
</Group>
{isLoading ? (
<Group justify="center" py="xl"><Loader /></Group>

View File

@@ -28,7 +28,15 @@ export default async function balitaFindMany(context: Context) {
const [data, total] = await Promise.all([
prisma.balita.findMany({
where,
include: { posyandu: { select: { id: true, name: true } } },
include: {
posyandu: {
select: {
id: true,
name: true,
banjar: { select: { id: true, name: true } },
},
},
},
skip,
take: limit,
orderBy: { createdAt: "desc" },

View File

@@ -0,0 +1,19 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
async function banjarFindMany(context: Context) {
try {
const data = await prisma.banjar.findMany({
where: { isActive: true },
select: { id: true, name: true },
orderBy: { name: "asc" },
});
return { success: true, data };
} catch (e) {
console.error("Error di banjarFindMany:", e);
return { success: false, message: "Gagal mengambil data banjar" };
}
}
export default banjarFindMany;

View File

@@ -0,0 +1,9 @@
import Elysia from "elysia";
import banjarFindMany from "./find-many";
const Banjar = new Elysia({
prefix: "/banjar",
tags: ["Kesehatan/Banjar"],
}).get("/find-many", banjarFindMany);
export default Banjar;

View File

@@ -27,7 +27,15 @@ export default async function ibuHamilFindMany(context: Context) {
const [data, total] = await Promise.all([
prisma.ibuHamil.findMany({
where,
include: { posyandu: { select: { id: true, name: true } } },
include: {
posyandu: {
select: {
id: true,
name: true,
banjar: { select: { id: true, name: true } },
},
},
},
skip,
take: limit,
orderBy: { createdAt: "desc" },

View File

@@ -24,6 +24,7 @@ import TarifLayanan from "./data_kesehatan_warga/fasilitas_kesehatan/tarif-layan
import RingkasanKesehatan from "./ringkasan-kesehatan";
import IbuHamil from "./ibu-hamil";
import Balita from "./balita";
import Banjar from "./banjar";
const Kesehatan = new Elysia({
@@ -55,4 +56,5 @@ const Kesehatan = new Elysia({
.use(RingkasanKesehatan)
.use(IbuHamil)
.use(Balita)
.use(Banjar)
export default Kesehatan;

View File

@@ -2,15 +2,14 @@ import prisma from "@/lib/prisma";
import { Prisma } from "@prisma/client";
import { Context } from "elysia";
type FormCreate = Prisma.PosyanduGetPayload<{
select: {
name: true;
nomor: true;
deskripsi: true;
imageId: true;
jadwalPelayanan: true;
};
}>;
type FormCreate = {
name: string;
nomor: string;
deskripsi: string;
imageId: string;
jadwalPelayanan: string;
banjarId?: string;
};
export default async function posyanduCreate(context: Context) {
const body = context.body as FormCreate;
@@ -21,6 +20,7 @@ export default async function posyanduCreate(context: Context) {
deskripsi: body.deskripsi,
imageId: body.imageId,
jadwalPelayanan: body.jadwalPelayanan,
banjarId: body.banjarId || null,
}
})
return {

View File

@@ -23,7 +23,8 @@ export default async function findPosyanduById(request: Request) {
const data = await prisma.posyandu.findUnique({
where: {id},
include: {
image: true
image: true,
banjar: { select: { id: true, name: true } },
}
})

View File

@@ -30,6 +30,7 @@ async function posyanduFindMany(context: Context) {
where,
include: {
image: true,
banjar: { select: { id: true, name: true } },
},
skip,
take: limit,

View File

@@ -16,6 +16,7 @@ const Posyandu = new Elysia({
deskripsi: t.String(),
imageId: t.String(),
jadwalPelayanan: t.String(),
banjarId: t.Optional(t.String()),
})
})
.get("/find-many", posyanduFindMany)
@@ -37,6 +38,7 @@ const Posyandu = new Elysia({
deskripsi: t.String(),
imageId: t.String(),
jadwalPelayanan: t.String(),
banjarId: t.Optional(t.String()),
})
}
)

View File

@@ -1,23 +1,20 @@
import prisma from "@/lib/prisma";
import { Prisma } from "@prisma/client";
import { Context } from "elysia";
import minio, { MINIO_BUCKET } from "@/lib/minio";
type FormUpdate = Prisma.PosyanduGetPayload<{
select: {
id: true;
name: true;
nomor: true;
deskripsi: true;
imageId: true;
jadwalPelayanan: true;
}
}>
type FormUpdate = {
name: string;
nomor: string;
deskripsi: string;
imageId: string;
jadwalPelayanan: string;
banjarId?: string;
};
export default async function posyanduUpdate(context: Context) {
try {
const id = context.params?.id as string;
const body = (await context.body) as Omit<FormUpdate, "id">;
const body = (await context.body) as FormUpdate;
const {
name,
@@ -25,6 +22,7 @@ export default async function posyanduUpdate(context: Context) {
deskripsi,
imageId,
jadwalPelayanan,
banjarId,
} = body;
if(!id) {
@@ -80,6 +78,7 @@ export default async function posyanduUpdate(context: Context) {
deskripsi,
imageId,
jadwalPelayanan,
banjarId: banjarId || null,
}
})

View File

@@ -5,7 +5,10 @@ import ringkasanKesehatanStats from "./stats";
const RingkasanKesehatan = new Elysia({ prefix: "/ringkasankesehatan", tags: ["Kesehatan/Ringkasan"] })
.get("/find", ringkasanKesehatanFindUnique)
.get("/stats", ringkasanKesehatanStats)
.get("/stats", (context) => {
const banjarId = (context.query.banjarId as string) || undefined;
return ringkasanKesehatanStats(banjarId);
})
.put("/update", ringkasanKesehatanUpdate, {
body: t.Object({
targetStuntingPct: t.Number({ minimum: 0, maximum: 100 }),

View File

@@ -10,8 +10,12 @@ type StatsResult = {
targetStuntingPct: number;
};
export default async function ringkasanKesehatanStats(): Promise<{ success: boolean; data?: StatsResult; message?: string }> {
export default async function ringkasanKesehatanStats(
banjarId?: string
): Promise<{ success: boolean; data?: StatsResult; message?: string }> {
try {
const posyanduFilter = banjarId ? { posyandu: { banjarId } } : {};
const [
ibuHamilAktif,
balitaTotal,
@@ -21,12 +25,12 @@ export default async function ringkasanKesehatanStats(): Promise<{ success: bool
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.ibuHamil.count({ where: { status: "AKTIF", isActive: true, ...posyanduFilter } }),
prisma.balita.count({ where: { isActive: true, ...posyanduFilter } }),
prisma.balita.count({ where: { isActive: true, statusStunting: { in: ["ALERT", "STUNTING"] }, ...posyanduFilter } }),
prisma.balita.count({ where: { isActive: true, imunisasiLengkap: true, ...posyanduFilter } }),
prisma.balita.count({ where: { isActive: true, pemeriksaanRutin: true, ...posyanduFilter } }),
prisma.balita.count({ where: { isActive: true, giziBaik: true, ...posyanduFilter } }),
prisma.ringkasanKesehatanDesa.findFirst({ where: { isActive: true }, orderBy: { createdAt: "desc" } }),
]);

View File

@@ -2,19 +2,14 @@
import colors from '@/con/colors';
import {
Button,
Center,
Flex,
Group,
Image,
Paper,
Skeleton,
Stack,
Text,
Title,
Box, Button, Flex, Group, Image,
Paper, Skeleton, Stack, Text, Title,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconCalendar, IconInfoCircle, IconPhone } from '@tabler/icons-react';
import {
IconArrowBack, IconCalendar, IconInfoCircle,
IconMapPin, IconPhone,
} from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useProxy } from 'valtio/utils';
import posyanduState from '@/app/admin/(dashboard)/_state/kesehatan/posyandu/posyandu';
@@ -30,8 +25,8 @@ export default function DetailPosyanduUser() {
if (!statePosyandu.findUnique.data) {
return (
<Stack py="xl" px={{ base: 'md', md: 100 }}>
<Skeleton height={500} radius="md" />
<Stack py="xl" px={{ base: 'md', md: 100 }} bg={colors.Bg} mih="100vh">
<Skeleton height={500} radius="lg" />
</Stack>
);
}
@@ -39,97 +34,104 @@ export default function DetailPosyanduUser() {
const data = statePosyandu.findUnique.data;
return (
<Stack pos="relative" bg={colors.Bg} py="xl" px={{ base: 'md', md: 100 }} gap="xl">
{/* Tombol Kembali */}
<Stack pos="relative" bg={colors.Bg} py="xl" px={{ base: 'md', md: 100 }} gap="xl" mih="100vh">
<Group>
<Button
variant="subtle"
onClick={() => router.back()}
leftSection={<IconArrowBack size={22} color={colors['blue-button']} />}
mb="sm"
c={colors['blue-button']}
>
Kembali
</Button>
</Group>
{/* Konten utama */}
<Paper
withBorder
p="xl"
radius="lg"
shadow="md"
bg={colors['white-trans-1']}
bg="white"
maw={800}
mx="auto"
w="100%"
>
<Stack gap="md">
{/* Header — Dijadikan Title */}
<Title
ta="center"
order={1}
c={colors['blue-button']}
>
<Stack gap="lg">
<Title ta="center" order={2} c={colors['blue-button']}>
{data.name || 'Posyandu Desa'}
</Title>
{/* Gambar */}
{data.image?.link ? (
<Center>
<Image
src={data.image?.link ?? "/no-image.jpg"}
alt={`Gambar ${data.name}`}
w="100%"
h={300}
radius="md"
fit="cover"
loading="lazy"
/>
</Center>
<Image
src={data.image.link}
alt={`Gambar ${data.name}`}
w="100%"
h={300}
radius="md"
fit="cover"
loading="lazy"
/>
) : (
<Center>
<Text
fz={{ base: 'xs', md: 'sm' }}
c="dimmed"
ta="center"
>
Tidak ada gambar
</Text>
</Center>
<Box
h={200}
style={{
background: '#f4f5f6',
borderRadius: 12,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text fz="sm" c="dimmed">Tidak ada gambar</Text>
</Box>
)}
{/* Info utama */}
<Stack gap="sm" mt="md">
<Stack gap="sm" mt="sm">
{data.banjar && (
<Flex align="center" gap="xs">
<IconMapPin size={18} stroke={1.5} color={colors['blue-button']} />
<Text fz={{ base: 'sm', md: 'md' }} c="black">
Banjar {data.banjar.name}
</Text>
</Flex>
)}
<Flex align="center" gap="xs">
<IconPhone size={18} stroke={1.5} />
<Text
fz={{ base: 'sm', md: 'md' }}
c="black"
lh={{ base: 1.5, md: 1.6 }}
>
<IconPhone size={18} stroke={1.5} color="gray" />
<Text fz={{ base: 'sm', md: 'md' }} c="black">
{data.nomor || 'Nomor tidak tersedia'}
</Text>
</Flex>
<Flex align="center" gap="xs">
<IconCalendar size={18} stroke={1.5} />
<Flex align="flex-start" gap="xs">
<IconCalendar
size={18}
stroke={1.5}
color="gray"
style={{ marginTop: 2, flexShrink: 0 }}
/>
<Text
fz={{ base: 'sm', md: 'md' }}
c="black"
lh={{ base: 1.5, md: 1.6 }}
dangerouslySetInnerHTML={{ __html: data.jadwalPelayanan || '-' }}
style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}
lh={1.6}
dangerouslySetInnerHTML={{ __html: data.jadwalPelayanan || '' }}
style={{ wordBreak: 'break-word' }}
/>
</Flex>
<Flex align="center" gap="xs">
<IconInfoCircle size={18} stroke={1.5} />
<Flex align="flex-start" gap="xs">
<IconInfoCircle
size={18}
stroke={1.5}
color="gray"
style={{ marginTop: 2, flexShrink: 0 }}
/>
<Text
fz={{ base: 'sm', md: 'md' }}
c="black"
lh={{ base: 1.5, md: 1.6 }}
dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }}
style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}
lh={1.6}
dangerouslySetInnerHTML={{ __html: data.deskripsi || '' }}
style={{ wordBreak: 'break-word' }}
/>
</Flex>
</Stack>
@@ -137,4 +139,4 @@ export default function DetailPosyanduUser() {
</Paper>
</Stack>
);
}
}

View File

@@ -1,213 +1,651 @@
'use client'
import balitaState from "@/app/admin/(dashboard)/_state/kesehatan/balita/balita";
import ibuHamilState from "@/app/admin/(dashboard)/_state/kesehatan/ibu-hamil/ibuHamil";
import posyandustate from "@/app/admin/(dashboard)/_state/kesehatan/posyandu/posyandu";
import ringkasanState from "@/app/admin/(dashboard)/_state/kesehatan/ringkasan-kesehatan/ringkasanKesehatan";
import colors from "@/con/colors";
import { Badge, Box, Button, Center, Flex, Group, Image, List, ListItem, Pagination, Paper, SimpleGrid, Skeleton, Stack, Text, TextInput, Title } from "@mantine/core";
import {
Badge, Box, Button, Center, Flex, Group, Image,
Pagination, Paper, Progress, ScrollArea, Select,
SimpleGrid, Skeleton, Stack, Tabs, Text, TextInput,
ThemeIcon, Title,
} from "@mantine/core";
import { useDebouncedValue, useShallowEffect } from "@mantine/hooks";
import { IconCalendar, IconInfoCircle, IconPhone, IconSearch } from "@tabler/icons-react";
import {
IconActivity, IconCalendar, IconChartBar, IconHeart,
IconMapPin, IconPhone, IconSearch, IconShieldCheck,
IconUsers,
} from "@tabler/icons-react";
import { useState } from "react";
import { useProxy } from "valtio/utils";
import BackButton from "../../desa/layanan/_com/BackButto";
import { useTransitionRouter } from "next-view-transitions";
const stuntingBadgeColor = (s: string) =>
s === "STUNTING" ? "red" : s === "ALERT" ? "yellow" : "green";
const ibuHamilBadgeColor = (s: string) => {
if (s === "AKTIF") return "green";
if (s === "MELAHIRKAN") return "blue";
if (s === "KEGUGURAN") return "gray";
return "red";
};
export default function Page() {
const state = useProxy(posyandustate);
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 1000);
const [activeTab, setActiveTab] = useState<string | null>("ringkasan");
const stPosyandu = useProxy(posyandustate);
const stBalita = useProxy(balitaState);
const stIbuHamil = useProxy(ibuHamilState);
const stRingkasan = useProxy(ringkasanState);
const [searchPosyandu, setSearchPosyandu] = useState("");
const [debouncedSearchPosyandu] = useDebouncedValue(searchPosyandu, 800);
const [searchBalita, setSearchBalita] = useState("");
const [debouncedSearchBalita] = useDebouncedValue(searchBalita, 800);
const [filterStunting, setFilterStunting] = useState<string | null>(null);
const [searchIbuHamil, setSearchIbuHamil] = useState("");
const [debouncedSearchIbuHamil] = useDebouncedValue(searchIbuHamil, 800);
const [filterStatusIbuHamil, setFilterStatusIbuHamil] = useState<string | null>(null);
const router = useTransitionRouter();
const { data, page, totalPages, loading, load } = state.findMany;
// Initial load: ringkasan
useShallowEffect(() => {
ringkasanState.findStats.load();
}, []);
// Load data sesuai tab aktif
useShallowEffect(() => {
if (activeTab === "ringkasan") {
ringkasanState.findStats.load();
} else if (activeTab === "posyandu") {
posyandustate.findMany.load(1, 6, debouncedSearchPosyandu);
} else if (activeTab === "balita") {
balitaState.findMany.load(1, 10, debouncedSearchBalita, filterStunting ?? "");
} else if (activeTab === "ibu-hamil") {
ibuHamilState.findMany.load(1, 10, debouncedSearchIbuHamil, filterStatusIbuHamil ?? "");
}
}, [activeTab]);
useShallowEffect(() => {
load(page, 6, debouncedSearch);
}, [page, debouncedSearch]);
if (activeTab === "posyandu") {
posyandustate.findMany.load(1, 6, debouncedSearchPosyandu);
}
}, [debouncedSearchPosyandu]);
if (loading || !data) {
return (
<Box py="xl" px={{ base: "md", md: 100 }}>
<Skeleton h={500} radius="lg" />
</Box>
);
}
useShallowEffect(() => {
if (activeTab === "balita") {
balitaState.findMany.load(1, 10, debouncedSearchBalita, filterStunting ?? "");
}
}, [debouncedSearchBalita, filterStunting]);
if (data.length === 0) {
return (
<Stack pos="relative" bg={colors.Bg} py="xl" gap="xl">
<Box px={{ base: "md", md: 100 }}>
<BackButton />
<Flex mt="md" justify="space-between" align="center" wrap="wrap" gap="md">
<Title
order={1}
ta="left"
c={colors["blue-button"]}
>
Posyandu Desa Darmasaba
</Title>
<TextInput
placeholder="Cari posyandu berdasarkan nama..."
aria-label="Pencarian Posyandu"
radius="xl"
size="md"
leftSection={<IconSearch size={20} />}
w={{ base: "100%", md: "35%" }}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
</Flex>
</Box>
<Title c={"dimmed"} order={3} ta={"center"}>Belum ada posyandu yang terdaftar</Title>
</Stack>
);
}
useShallowEffect(() => {
if (activeTab === "ibu-hamil") {
ibuHamilState.findMany.load(1, 10, debouncedSearchIbuHamil, filterStatusIbuHamil ?? "");
}
}, [debouncedSearchIbuHamil, filterStatusIbuHamil]);
const stats = stRingkasan.findStats.data;
return (
<Stack pos="relative" bg={colors.Bg} py="xl" gap="xl">
<Stack pos="relative" bg={colors.Bg} py="xl" gap="xl" mih="100vh">
{/* Header */}
<Box px={{ base: "md", md: 100 }}>
<BackButton />
<Flex mt="md" justify="space-between" align="center" wrap="wrap" gap="md">
<Title
order={1}
ta="left"
c={colors["blue-button"]}
>
Posyandu Desa Darmasaba
<Stack gap={4} mt="md">
<Title order={1} c={colors["blue-button"]}>
Kesehatan Posyandu
</Title>
<TextInput
placeholder="Cari posyandu berdasarkan nama..."
aria-label="Pencarian Posyandu"
radius="xl"
size="md"
leftSection={<IconSearch size={20} />}
w={{ base: "100%", md: "35%" }}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
</Flex>
<Text fz={{ base: "sm", md: "md" }} c="dimmed">
Informasi posyandu, data balita, ibu hamil, dan ringkasan kesehatan Desa Darmasaba
</Text>
</Stack>
</Box>
{/* Tabs */}
<Box px={{ base: "md", md: 100 }}>
<Stack gap="xl">
<SimpleGrid
pb="lg"
cols={{ base: 1, sm: 2, md: 3 }}
spacing="lg"
<Tabs
variant="pills"
color={colors["blue-button"]}
radius="lg"
value={activeTab}
onChange={setActiveTab}
>
<Box mb="md"
style={{
overflowX: 'auto',
whiteSpace: 'nowrap',
WebkitOverflowScrolling: 'touch', // ✅ smooth scroll di iOS
msOverflowStyle: 'none', // ✅ sembunyikan scrollbar di IE/Edge
scrollbarWidth: 'none', // ✅ sembunyikan scrollbar di Firefox
}}
>
{data?.map((v, k) => (
<Paper
key={k}
p="xl"
radius="lg"
shadow="md"
withBorder
bg={colors["white-trans-1"]}
style={{
transition: "transform 0.2s ease, box-shadow 0.2s ease",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.transform = "translateY(-4px)";
(e.currentTarget as HTMLElement).style.boxShadow =
"0 8px 24px rgba(0,0,0,0.12)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.transform = "translateY(0)";
(e.currentTarget as HTMLElement).style.boxShadow =
"0 4px 12px rgba(0,0,0,0.08)";
}}
>
<Stack gap="sm">
<Group justify="space-between" align="center">
<Title order={3} c={colors["blue-button"]} fw="bold" lineClamp={1}>
{v.name}
</Title>
<Badge color="blue" variant="light" size="sm" radius="sm">
Aktif
</Badge>
</Group>
<Tabs.List
style={{
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem",
padding: "0.25rem",
display: 'inline-flex', // ✅ lebih tepat dari 'flex' untuk nowrap
flexWrap: 'nowrap',
gap: '0.5rem',
}}
>
<Tabs.Tab value="ringkasan" leftSection={<IconChartBar size={16} />}>
Ringkasan
</Tabs.Tab>
<Tabs.Tab value="posyandu" leftSection={<IconMapPin size={16} />}>
Data Posyandu
</Tabs.Tab>
<Tabs.Tab value="balita" leftSection={<IconUsers size={16} />}>
Balita
</Tabs.Tab>
<Tabs.Tab value="ibu-hamil" leftSection={<IconHeart size={16} />}>
Ibu Hamil
</Tabs.Tab>
</Tabs.List>
</Box>
{/* ===== RINGKASAN ===== */}
<Tabs.Panel value="ringkasan" pt="md">
{stRingkasan.findStats.loading ? (
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} h={120} radius="lg" />
))}
</SimpleGrid>
) : !stats ? (
<Paper p="xl" radius="lg" withBorder bg="white" ta="center">
<Text c="dimmed">Data ringkasan belum tersedia</Text>
</Paper>
) : (
<Stack gap="lg">
{/* KPI utama */}
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
{[
{
label: "Ibu Hamil Aktif",
value: stats.ibuHamilAktif,
color: colors["blue-button"],
icon: <IconHeart size={28} />,
},
{
label: "Balita Terdaftar",
value: stats.balitaTerdaftar,
color: "#22c55e",
icon: <IconUsers size={28} />,
},
{
label: "Alert Stunting",
value: stats.alertStunting,
color: "#ef4444",
icon: <IconActivity size={28} />,
},
].map((s, i) => (
<Paper key={i} p="xl" radius="lg" withBorder bg="white" shadow="sm">
<Group gap="md">
<ThemeIcon size={52} radius="lg" color={s.color} variant="light">
{s.icon}
</ThemeIcon>
<Stack gap={4}>
<Text fz="2rem" fw={700} c={s.color} lh={1}>
{s.value}
</Text>
<Text fz="sm" c="dimmed">{s.label}</Text>
</Stack>
</Group>
</Paper>
))}
</SimpleGrid>
{/* Persentase */}
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
{[
{
label: "Imunisasi Lengkap",
pct: stats.imunisasiLengkapPct,
color: colors["blue-button"],
icon: <IconShieldCheck size={20} />,
},
{
label: "Gizi Baik",
pct: stats.giziBaikPct,
color: "#22c55e",
icon: <IconHeart size={20} />,
},
{
label: "Pemeriksaan Rutin",
pct: stats.pemeriksaanRutinPct,
color: "#f59e0b",
icon: <IconActivity size={20} />,
},
].map((s, i) => (
<Paper key={i} p="lg" radius="lg" withBorder bg="white" shadow="sm">
<Stack gap="sm">
<Group gap="xs">
<ThemeIcon size={32} radius="md" color={s.color} variant="light">
{s.icon}
</ThemeIcon>
<Text fz="sm" fw={600}>{s.label}</Text>
</Group>
<Group gap="xs" align="center">
<Progress
value={s.pct}
color={s.color}
radius="xl"
size="md"
style={{ flex: 1 }}
/>
<Text fz="sm" fw={700} c={s.color} w={40} ta="right">
{s.pct}%
</Text>
</Group>
</Stack>
</Paper>
))}
</SimpleGrid>
</Stack>
)}
</Tabs.Panel>
{/* ===== DATA POSYANDU ===== */}
<Tabs.Panel value="posyandu" pt="md">
<Stack gap="md">
<TextInput
placeholder="Cari nama posyandu..."
radius="xl"
leftSection={<IconSearch size={18} />}
value={searchPosyandu}
onChange={(e) => setSearchPosyandu(e.currentTarget.value)}
w={{ base: "100%", md: "40%" }}
/>
{stPosyandu.findMany.loading ? (
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="lg">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} h={300} radius="lg" />
))}
</SimpleGrid>
) : !stPosyandu.findMany.data?.length ? (
<Paper p="xl" radius="lg" withBorder bg="white" ta="center">
<Text c="dimmed">Tidak ada posyandu ditemukan</Text>
</Paper>
) : (
<>
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="lg">
{stPosyandu.findMany.data.map((v, k) => (
<Paper
key={k}
p="xl"
radius="lg"
shadow="sm"
withBorder
bg="white"
style={{ transition: "transform 0.2s, box-shadow 0.2s" }}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.transform = "translateY(-4px)";
(e.currentTarget as HTMLElement).style.boxShadow =
"0 8px 24px rgba(0,0,0,0.12)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.transform = "translateY(0)";
(e.currentTarget as HTMLElement).style.boxShadow = "";
}}
>
<Stack gap="sm">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Title
order={4}
c={colors["blue-button"]}
lineClamp={1}
style={{ flex: 1 }}
>
{v.name}
</Title>
<Badge
color="green"
variant="light"
size="sm"
radius="sm"
style={{ flexShrink: 0 }}
>
Aktif
</Badge>
</Group>
<Image
src={v.image?.link ?? "/no-image.jpg"}
alt={`Gambar ${v.name}`}
radius="md"
h={160}
fit="cover"
/>
{v.banjar && (
<Flex align="center" gap="xs">
<IconMapPin size={16} stroke={1.5} color={colors["blue-button"]} />
<Text fz="sm" c="dimmed">Banjar {v.banjar.name}</Text>
</Flex>
)}
<Flex align="center" gap="xs">
<IconPhone size={16} stroke={1.5} color="gray" />
<Text fz="sm" c="dimmed">{v.nomor || ""}</Text>
</Flex>
<Flex align="flex-start" gap="xs">
<IconCalendar
size={16}
stroke={1.5}
color="gray"
style={{ marginTop: 2, flexShrink: 0 }}
/>
<Text
fz="sm"
c="dimmed"
lh={1.5}
lineClamp={2}
dangerouslySetInnerHTML={{ __html: v.jadwalPelayanan }}
style={{ wordBreak: "break-word" }}
/>
</Flex>
<Button
radius="lg"
variant="outline"
color={colors["blue-button"]}
fullWidth
mt="xs"
onClick={() =>
router.push(`/darmasaba/kesehatan/posyandu/${v.id}`)
}
>
Lihat Detail
</Button>
</Stack>
</Paper>
))}
</SimpleGrid>
<Center>
<Image
src={v.image?.link ?? "/no-image.jpg"}
alt={`Gambar ${v.name}`}
radius="md"
w="100%"
h={180}
fit="cover"
loading="lazy"
<Pagination
value={stPosyandu.findMany.page}
onChange={(p) =>
posyandustate.findMany.load(p, 6, searchPosyandu)
}
total={stPosyandu.findMany.totalPages}
radius="lg"
color={colors["blue-button"]}
/>
</Center>
<Flex align="flex-start" gap="xs">
<IconPhone size={18} stroke={1.5} style={{ marginTop: 3 }} />
<Text
fz={{ base: "sm", md: "md" }}
c="black"
lh={{ base: 1.4, md: 1.5 }}
>
{v.nomor || "Tidak tersedia"}
</Text>
</Flex>
</>
)}
</Stack>
</Tabs.Panel>
<Flex align="flex-start" gap="xs">
<IconCalendar size={18} stroke={1.5} style={{ marginTop: 3 }} />
<Text
fz={{ base: "sm", md: "md" }}
c="black"
lh={{ base: 1.4, md: 1.5 }}
>
<strong>Jadwal:</strong>{" "}
<span
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
dangerouslySetInnerHTML={{ __html: v.jadwalPelayanan }}
/>
</Text>
</Flex>
{/* ===== BALITA ===== */}
<Tabs.Panel value="balita" pt="md">
<Stack gap="md">
<Group wrap="wrap">
<TextInput
placeholder="Cari nama, orang tua..."
radius="xl"
leftSection={<IconSearch size={18} />}
value={searchBalita}
onChange={(e) => setSearchBalita(e.currentTarget.value)}
style={{ flex: 1, minWidth: 200 }}
/>
<Select
placeholder="Semua status stunting"
clearable
radius="xl"
data={[
{ value: "NORMAL", label: "Normal" },
{ value: "ALERT", label: "Alert" },
{ value: "STUNTING", label: "Stunting" },
]}
value={filterStunting}
onChange={setFilterStunting}
w={{ base: "100%", sm: 200 }}
/>
</Group>
<Flex align="flex-start" gap="xs">
<IconInfoCircle size={18} stroke={1.5} style={{ marginTop: 3 }} />
<Text
fz={{ base: "sm", md: "md" }}
lh={{ base: 1.4, md: 1.5 }}
c="black"
dangerouslySetInnerHTML={{ __html: v.deskripsi }}
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
lineClamp={3}
truncate="end"
/>
</Flex>
<Button radius="lg" size="md" variant="outline" onClick={() => router.push(`/darmasaba/kesehatan/posyandu/${v.id}`)}>
Detail
</Button>
{stBalita.findMany.loading ? (
<Stack gap="sm">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} h={100} radius="lg" />
))}
</Stack>
</Paper>
))}
</SimpleGrid>
) : !stBalita.findMany.data?.length ? (
<Paper p="xl" radius="lg" withBorder bg="white" ta="center">
<Text c="dimmed">Tidak ada data balita ditemukan</Text>
</Paper>
) : (
<>
<Text fz="sm" c="dimmed">
Total: <strong>{stBalita.findMany.total}</strong> balita terdaftar
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{stBalita.findMany.data.map((v, i) => (
<Paper key={i} p="md" radius="lg" withBorder bg="white" shadow="xs">
<Stack gap="xs">
<Group
justify="space-between"
align="flex-start"
wrap="nowrap"
>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text fw={600} fz="sm" lineClamp={1}>{v.nama}</Text>
<Text fz="xs" c="dimmed">
{v.jenisKelamin === "L" ? "Laki-laki" : "Perempuan"}
{v.tanggalLahir
? ` · ${new Date(v.tanggalLahir).toLocaleDateString("id-ID", {
day: "numeric",
month: "long",
year: "numeric",
})}`
: ""}
</Text>
</Stack>
<Badge
color={stuntingBadgeColor(v.statusStunting)}
variant="light"
size="sm"
radius="sm"
style={{ flexShrink: 0 }}
>
{v.statusStunting}
</Badge>
</Group>
<Center>
<Pagination
value={page}
onChange={(newPage) => load(newPage)}
total={totalPages}
radius="lg"
size="md"
withControls
mt="md"
/>
</Center>
{v.posyandu && (
<Flex align="center" gap={4}>
<IconMapPin size={13} color="gray" />
<Text fz="xs" c="dimmed">
{v.posyandu.name}
{v.posyandu.banjar
? ` · Banjar ${v.posyandu.banjar.name}`
: ""}
</Text>
</Flex>
)}
<Stack gap="sm">
<Flex align="center" gap="xs">
<IconInfoCircle size={22} color={colors["blue-button"]} />
<Title order={2} c={colors["blue-button"]}>
Layanan Utama Posyandu
</Title>
</Flex>
<List spacing="xs" fz={{ base: "sm", md: "md" }} lh={{ base: 1.4, md: 1.5 }} c="black">
<ListItem>Penimbangan bayi dan balita</ListItem>
<ListItem>Pemantauan status gizi</ListItem>
<ListItem>Imunisasi dasar lengkap</ListItem>
<ListItem>Konseling kesehatan</ListItem>
<ListItem>Pemantauan kesehatan ibu hamil</ListItem>
</List>
</Stack>
</Stack>
<Group gap="xs">
<Badge
color={v.imunisasiLengkap ? "blue" : "gray"}
variant="dot"
size="xs"
>
{v.imunisasiLengkap
? "Imunisasi Lengkap"
: "Imunisasi Belum Lengkap"}
</Badge>
<Badge
color={v.giziBaik ? "green" : "orange"}
variant="dot"
size="xs"
>
{v.giziBaik ? "Gizi Baik" : "Gizi Kurang"}
</Badge>
<Badge
color={v.pemeriksaanRutin ? "teal" : "gray"}
variant="dot"
size="xs"
>
{v.pemeriksaanRutin ? "Periksa Rutin" : "Tidak Rutin"}
</Badge>
</Group>
</Stack>
</Paper>
))}
</SimpleGrid>
<Center>
<Pagination
value={stBalita.findMany.page}
onChange={(p) =>
balitaState.findMany.load(
p,
10,
searchBalita,
filterStunting ?? ""
)
}
total={stBalita.findMany.totalPages}
radius="lg"
color={colors["blue-button"]}
/>
</Center>
</>
)}
</Stack>
</Tabs.Panel>
{/* ===== IBU HAMIL ===== */}
<Tabs.Panel value="ibu-hamil" pt="md">
<Stack gap="md">
<Group wrap="wrap">
<TextInput
placeholder="Cari nama..."
radius="xl"
leftSection={<IconSearch size={18} />}
value={searchIbuHamil}
onChange={(e) => setSearchIbuHamil(e.currentTarget.value)}
style={{ flex: 1, minWidth: 200 }}
/>
<Select
placeholder="Semua status"
clearable
radius="xl"
data={[
{ value: "AKTIF", label: "Aktif" },
{ value: "MELAHIRKAN", label: "Melahirkan" },
{ value: "KEGUGURAN", label: "Keguguran" },
{ value: "NONAKTIF", label: "Nonaktif" },
]}
value={filterStatusIbuHamil}
onChange={setFilterStatusIbuHamil}
w={{ base: "100%", sm: 200 }}
/>
</Group>
{stIbuHamil.findMany.loading ? (
<Stack gap="sm">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} h={100} radius="lg" />
))}
</Stack>
) : !stIbuHamil.findMany.data?.length ? (
<Paper p="xl" radius="lg" withBorder bg="white" ta="center">
<Text c="dimmed">Tidak ada data ibu hamil ditemukan</Text>
</Paper>
) : (
<>
<Text fz="sm" c="dimmed">
Total: <strong>{stIbuHamil.findMany.total}</strong> data ibu hamil
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{stIbuHamil.findMany.data.map((v, i) => (
<Paper key={i} p="md" radius="lg" withBorder bg="white" shadow="xs">
<Stack gap="xs">
<Group
justify="space-between"
align="flex-start"
wrap="nowrap"
>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text fw={600} fz="sm" lineClamp={1}>{v.nama}</Text>
<Text fz="xs" c="dimmed">
Usia kehamilan: {v.usiaKehamilan} minggu
</Text>
</Stack>
<Badge
color={ibuHamilBadgeColor(v.status)}
variant="light"
size="sm"
radius="sm"
style={{ flexShrink: 0 }}
>
{v.status}
</Badge>
</Group>
{v.posyandu && (
<Flex align="center" gap={4}>
<IconMapPin size={13} color="gray" />
<Text fz="xs" c="dimmed">
{v.posyandu.name}
{v.posyandu.banjar
? ` · Banjar ${v.posyandu.banjar.name}`
: ""}
</Text>
</Flex>
)}
{v.taksiranLahir && (
<Flex align="center" gap={4}>
<IconCalendar size={13} color="gray" />
<Text fz="xs" c="dimmed">
Taksiran lahir:{" "}
{new Date(v.taksiranLahir).toLocaleDateString("id-ID", {
day: "numeric",
month: "long",
year: "numeric",
})}
</Text>
</Flex>
)}
</Stack>
</Paper>
))}
</SimpleGrid>
<Center>
<Pagination
value={stIbuHamil.findMany.page}
onChange={(p) =>
ibuHamilState.findMany.load(
p,
10,
searchIbuHamil,
filterStatusIbuHamil ?? ""
)
}
total={stIbuHamil.findMany.totalPages}
radius="lg"
color={colors["blue-button"]}
/>
</Center>
</>
)}
</Stack>
</Tabs.Panel>
</Tabs>
</Box>
</Stack>
);
}
}