merge: refactor(ui) posyandu balita & ibu-hamil penghargaan pattern

This commit is contained in:
2026-05-05 16:12:51 +08:00
6 changed files with 873 additions and 620 deletions

View File

@@ -7,7 +7,7 @@
### Tech Stack
| Kategori | Teknologi |
|----------|-----------|
| -------------------- | ------------------------------------------ |
| **Framework** | Next.js 15 (App Router) |
| **Language** | TypeScript (strict mode) |
| **Runtime** | Bun |
@@ -195,10 +195,11 @@ Browser
## 4. Modul Domain
### A. PPID (Pejabat Pengelola Informasi dan Dokumentasi)
**Lokasi**: `src/app/admin/(dashboard)/ppid/` dan `src/app/darmasaba/(pages)/ppid/`
| Sub-modul | Deskripsi |
|-----------|-----------|
| --------------------------- | ---------------------------------------------- |
| Profil PPID | Profil pejabat pengelola informasi |
| Struktur PPID | Struktur organisasi PPID dengan hierarki |
| Visi & Misi PPID | Visi dan misi PPID desa |
@@ -209,10 +210,11 @@ Browser
| Indeks Kepuasan Masyarakat | Survey kepuasan dengan grafik demografis |
### B. Desa (Landing Page & Umum)
**Lokasi**: `src/app/admin/(dashboard)/desa/` dan `src/app/darmasaba/(pages)/desa/`
| Sub-modul | Deskripsi |
|-----------|-----------|
| -------------------------- | ---------------------------------------------- |
| Profil Desa | Sejarah, visi-misi, lambang, maskot |
| Profil Perbekel | Biodata, pengalaman, program unggulan perbekel |
| Perbekel dari Masa ke Masa | Historis perbekel per periode |
@@ -228,10 +230,11 @@ Browser
| Prestasi Desa | Katalog prestasi |
### C. Kesehatan
**Lokasi**: `src/app/admin/(dashboard)/kesehatan/` dan `src/app/darmasaba/(pages)/kesehatan/`
| Sub-modul | Deskripsi |
|-----------|-----------|
| -------------------- | ---------------------------------------------- |
| Fasilitas Kesehatan | Info rumah sakit/klinik (jam, dokter, tarif) |
| Puskesmas | Data puskesmas dengan jam operasional & kontak |
| Posyandu | Jadwal dan informasi posyandu |
@@ -245,10 +248,11 @@ Browser
| Grafik Kepuasan | Grafik kepuasan layanan kesehatan |
### D. Ekonomi
**Lokasi**: `src/app/admin/(dashboard)/ekonomi/` dan `src/app/darmasaba/(pages)/ekonomi/`
| Sub-modul | Deskripsi |
|-----------|-----------|
| ------------------------------ | ------------------------------------------ |
| Pasar Desa | Katalog pasar desa dengan produk & rating |
| Struktur BUMDes | Organisasi BUMDes dengan pengurus |
| APBDes (PADesa) | Pendapatan Asli Desa |
@@ -261,10 +265,11 @@ Browser
| Jumlah Penduduk Miskin | Tren kemiskinan tahunan |
### E. Kependudukan
**Lokasi**: `src/app/admin/(dashboard)/kependudukan/` dan `src/app/darmasaba/(pages)/kependudukan/`
| Sub-modul | Deskripsi |
|-----------|-----------|
| ----------------- | -------------------------------------- |
| Data Banjar | Data penduduk per banjar |
| Distribusi Agama | Statistik agama penduduk |
| Distribusi Umur | Piramida umur penduduk |
@@ -272,10 +277,11 @@ Browser
| Dinamika Penduduk | Kelahiran, kematian, migrasi per tahun |
### F. Pendidikan
**Lokasi**: `src/app/admin/(dashboard)/pendidikan/` dan `src/app/darmasaba/(pages)/pendidikan/`
| Sub-modul | Deskripsi |
|-----------|-----------|
| ----------------------- | ------------------------------------------- |
| Info Sekolah & PAUD | Data sekolah per jenjang (TK, SD, SMP, SMA) |
| Beasiswa Desa | Program beasiswa & pendaftar |
| Program Pendidikan Anak | Program pendidikan anak |
@@ -285,10 +291,11 @@ Browser
| Data Pendidikan | Statistik pendidikan |
### G. Keamanan
**Lokasi**: `src/app/admin/(dashboard)/keamanan/` dan `src/app/darmasaba/(pages)/keamanan/`
| Sub-modul | Deskripsi |
|-----------|-----------|
| ------------------------------------- | ----------------------------------------- |
| Keamanan Lingkungan (Pecalang/Patwal) | Sistem keamanan tradisional Bali |
| Polsek Terdekat | Data polsek dengan layanan & map |
| Kontak Darurat | Kontak darurat keamanan |
@@ -297,10 +304,11 @@ Browser
| Tips Keamanan | Tips dan panduan keamanan |
### H. Lingkungan
**Lokasi**: `src/app/admin/(dashboard)/lingkungan/` dan `src/app/darmasaba/(pages)/lingkungan/`
| Sub-modul | Deskripsi |
|-----------|-----------|
| -------------------- | --------------------------------- |
| Pengelolaan Sampah | Bank sampah & pengelolaan |
| Program Penghijauan | Program penghijauan desa |
| Data Lingkungan | Data lingkungan desa |
@@ -309,10 +317,11 @@ Browser
| Konservasi Adat Bali | Tri Hita Karana & konservasi adat |
### I. Inovasi
**Lokasi**: `src/app/admin/(dashboard)/inovasi/` dan `src/app/darmasaba/(pages)/inovasi/`
| Sub-modul | Deskripsi |
|-----------|-----------|
| ---------------------------- | ----------------------------- |
| Desa Digital (Smart Village) | Transformasi digital desa |
| Program Kreatif Desa | Program kreatif & inovatif |
| Kolaborasi Inovasi | Kolaborasi dengan mitra |
@@ -321,11 +330,13 @@ Browser
| Layanan Online Desa | Layanan administrasi online |
### J. Musik Desa
**Lokasi**: `src/app/admin/(dashboard)/musik/` dan `src/app/darmasaba/(pages)/musik/`
Model `MusikDesa` dengan audio file, cover image, genre, dan durasi. Dilengkapi dengan `FixedPlayerBar` di layout publik.
### K. User & Role (Admin)
**Lokasi**: `src/app/admin/(dashboard)/user&role/`
- **Role-based Access Control**: Role dengan permission JSON
@@ -342,7 +353,7 @@ Schema terdiri dari **2413 baris** dengan **100+ model** dan **berbagai enum**.
### Core Models
| Model | Keterangan |
|-------|-----------|
| -------------------------------------------------- | ----------------------------------------------- |
| `FileStorage` | Central file storage untuk semua uploaded files |
| `AppMenu` / `AppMenuChild` | Menu navigasi aplikasi |
| `User` / `Role` / `UserSession` / `UserMenuAccess` | Sistem autentikasi & otorisasi |
@@ -351,7 +362,7 @@ Schema terdiri dari **2413 baris** dengan **100+ model** dan **berbagai enum**.
### Landing Page & Desa
| Model | Keterangan |
|-------|-----------|
| --------------------------------------------- | ---------------------------------------------- |
| `PejabatDesa` | Pejabat desa dengan foto |
| `ProfilPerbekel` | Profil perbekel (biodata, pengalaman, program) |
| `PerbekelDariMasaKeMasa` | Historis perbekel |
@@ -369,7 +380,7 @@ Schema terdiri dari **2413 baris** dengan **100+ model** dan **berbagai enum**.
### PPID
| Model | Keterangan |
|-------|-----------|
| ------------------------------------------------------- | -------------------------- |
| `StrukturPPID` / `PosisiOrganisasiPPID` / `PegawaiPPID` | Struktur organisasi |
| `VisiMisiPPID` | Visi misi |
| `ProfilePPID` | Profil pejabat |
@@ -382,7 +393,7 @@ Schema terdiri dari **2413 baris** dengan **100+ model** dan **berbagai enum**.
### Kesehatan
| Model | Keterangan |
|-------|-----------|
| --------------------------------------------------- | ---------------------------------------------- |
| `FasilitasKesehatan` | Fasilitas lengkap (dokter, tarif, prosedur) |
| `Puskesmas` / `JamOperasional` / `KontakPuskesmas` | Puskesmas |
| `Posyandu` | Pos pelayanan terpadu |
@@ -396,7 +407,7 @@ Schema terdiri dari **2413 baris** dengan **100+ model** dan **berbagai enum**.
### Ekonomi
| Model | Keterangan |
|-------|-----------|
| ------------------------------------------------------------- | ------------------- |
| `PasarDesa` / `KategoriProduk` / `KategoriToPasar` | Pasar desa |
| `StrukturBumDes` / `PosisiOrganisasiBumDes` / `PegawaiBumDes` | BUMDes |
| `ProgramKemiskinan` / `StatistikKemiskinan` | Kemiskinan |
@@ -409,7 +420,7 @@ Schema terdiri dari **2413 baris** dengan **100+ model** dan **berbagai enum**.
### Kependudukan
| Model | Keterangan |
|-------|-----------|
| ------------------ | ---------------------- |
| `DataBanjar` | Data per banjar |
| `DistribusiAgama` | Distribusi agama |
| `DistribusiUmur` | Distribusi umur |
@@ -419,7 +430,7 @@ Schema terdiri dari **2413 baris** dengan **100+ model** dan **berbagai enum**.
### Pendidikan
| Model | Keterangan |
|-------|-----------|
| ------------------------------------------------------ | ------------------------------ |
| `JenjangPendidikan` / `Lembaga` / `Siswa` / `Pengajar` | Data sekolah |
| `BeasiswaPendaftar` | Beasiswa (dengan enum lengkap) |
| `DataPerpustakaan` / `KategoriBuku` / `PeminjamanBuku` | Perpustakaan |
@@ -428,7 +439,7 @@ Schema terdiri dari **2413 baris** dengan **100+ model** dan **berbagai enum**.
### Keamanan
| Model | Keterangan |
|-------|-----------|
| ---------------------------------------------------------------- | ------------------- |
| `KeamananLingkungan` | Keamanan lingkungan |
| `PolsekTerdekat` / `LayananPolsek` / `LayananToPolsek` | Polsek |
| `KontakDaruratKeamanan` / `KontakItem` | Kontak darurat |
@@ -440,7 +451,7 @@ Schema terdiri dari **2413 baris** dengan **100+ model** dan **berbagai enum**.
### Lingkungan
| Model | Keterangan |
|-------|-----------|
| ----------------------------------------------------- | ------------------ |
| `PengelolaanSampah` | Pengelolaan sampah |
| `KeteranganBankSampahTerdekat` | Bank sampah |
| `ProgramPenghijauan` | Penghijauan |
@@ -451,7 +462,7 @@ Schema terdiri dari **2413 baris** dengan **100+ model** dan **berbagai enum**.
### Inovasi
| Model | Keterangan |
|-------|-----------|
| ---------------------------------------- | -------------------- |
| `DesaDigital` | Smart village |
| `ProgramKreatif` | Program kreatif |
| `KolaborasiInovasi` / `MitraKolaborasi` | Kolaborasi |
@@ -467,7 +478,7 @@ Schema terdiri dari **2413 baris** dengan **100+ model** dan **berbagai enum**.
Semua API ditangani oleh **Elysia.js** di `src/app/api/[[...slugs]]/route.ts`:
| Endpoint Group | Prefix | Deskripsi |
|---------------|--------|-----------|
| ---------------- | -------------------- | ---------------------------------------------- |
| **File Storage** | `/api/file-storage` | CRUD file storage |
| **Landing Page** | `/api/landing-page` | Profil, prestasi, anti-korupsi, SDGs, APBDes |
| **Desa** | `/api/desa` | Berita, gallery, potensi, pengumuman, layanan |
@@ -487,7 +498,7 @@ Semua API ditangani oleh **Elysia.js** di `src/app/api/[[...slugs]]/route.ts`:
### Utility Endpoints
| Endpoint | Method | Deskripsi |
|----------|--------|-----------|
| --------------------- | ------ | ----------------------------- |
| `/api/img/:name` | GET | Serve image dengan resize |
| `/api/img/:name` | DELETE | Delete image |
| `/api/imgs` | GET | List images dengan pagination |
@@ -499,7 +510,7 @@ Semua API ditangani oleh **Elysia.js** di `src/app/api/[[...slugs]]/route.ts`:
### Auth Endpoints
| Endpoint | Method | Deskripsi |
|----------|--------|-----------|
| ------------------ | ------ | ---------------- |
| `/api/auth/login` | POST | Login dengan OTP |
| `/api/auth/logout` | POST | Logout |
| `/api/auth/me` | GET | Get current user |
@@ -515,7 +526,7 @@ Admin dashboard menggunakan **Mantine AppShell** dengan sidebar navigasi dinamis
### Route Group: `/admin`
| Section | Path | Deskripsi |
|---------|------|-----------|
| ---------------- | ---------------------- | ------------------------------------------------------------------ |
| **Landing Page** | `/admin/landing-page/` | Profil desa, prestasi, anti-korupsi, SDGs, media sosial |
| **Desa** | `/admin/desa/` | Berita, gallery, layanan, penghargaan, pengumuman, potensi, profil |
| **PPID** | `/admin/ppid/` | 8 sub-modul PPID lengkap |
@@ -530,6 +541,7 @@ Admin dashboard menggunakan **Mantine AppShell** dengan sidebar navigasi dinamis
| **User & Role** | `/admin/user&role/` | Manajemen user, role, menu access |
### Fitur Admin:
- **Role-based Dynamic Navbar**: Navbar berubah berdasarkan roleId user
- **Dark Mode Toggle**: Tema gelap/terang
- **OTP Login**: Login dengan nomor telepon + kode OTP
@@ -539,10 +551,11 @@ Admin dashboard menggunakan **Mantine AppShell** dengan sidebar navigasi dinamis
- **Rich Text Editor**: Tiptap untuk konten HTML
### Role-Based Redirect:
| roleId | Role | Default Redirect |
|--------|------|-----------------|
| ------- | ------------------------ | --------------------------------------------------- |
| 0, 1, 2 | Super Admin / Admin Desa | `/admin/landing-page/profil/program-inovasi` |
| 3 | Admin Kesehatan | `/admin/kesehatan/posyandu` |
| 3 | Admin Kesehatan | `/admin/kesehatan/posyandu/list-posyandu` |
| 4 | Admin Pendidikan | `/admin/pendidikan/info-sekolah/jenjang-pendidikan` |
---
@@ -554,7 +567,7 @@ Public website di `/darmasaba/` dengan layout yang mencakup **Navbar**, **Footer
### Route Group: `/darmasaba`
| Section | Path | Deskripsi |
|---------|------|-----------|
| ---------------- | ------------------------- | ----------------------------------------------------------------- |
| **Home** | `/darmasaba` | Landing page utama |
| **Desa** | `/darmasaba/desa` | Profil, berita, gallery, layanan, pengumuman, potensi |
| **PPID** | `/darmasaba/ppid` | 7 sub-halaman PPID publik |
@@ -569,6 +582,7 @@ Public website di `/darmasaba/` dengan layout yang mencakup **Navbar**, **Footer
| **Module** | `/darmasaba/module/*` | Link ke modul eksternal (DAVES, MANGAN, Bicara-Darma, BARES, dll) |
### Fitur Publik:
- **Fixed Music Player Bar**: Player musik yang selalu tampil di bottom
- **Global Search**: Pencarian global
- **News Reader**: Notifikasi berita modern
@@ -582,7 +596,7 @@ Public website di `/darmasaba/` dengan layout yang mencakup **Navbar**, **Footer
### Admin Components (`src/components/admin/`)
| Komponen | Deskripsi |
|----------|-----------|
| ------------------------ | --------------------------------- |
| `AdminThemeProvider.tsx` | Theme provider untuk admin |
| `DarkModeToggle.tsx` | Toggle dark/light mode |
| `UnifiedSurface.tsx` | Consistent surface/card component |
@@ -591,7 +605,7 @@ Public website di `/darmasaba/` dengan layout yang mencakup **Navbar**, **Footer
### Public Shared Components (`src/app/darmasaba/_com/`)
| Komponen | Deskripsi |
|----------|-----------|
| ---------------------------- | -------------------------------- |
| `Navbar.tsx` | Main navigation bar |
| `NavbarMainMenu.tsx` | Main menu dengan kategori |
| `NavbarSubMenu.tsx` | Submenu dropdown |
@@ -605,7 +619,7 @@ Public website di `/darmasaba/` dengan layout yang mencakup **Navbar**, **Footer
### Global Components (`src/app/_com/`)
| Komponen | Deskripsi |
|----------|-----------|
| ----------------- | --------------------- |
| `SpashScreen.tsx` | Splash screen on load |
| `WebVitals.tsx` | Web Vitals monitoring |
@@ -616,7 +630,7 @@ Public website di `/darmasaba/` dengan layout yang mencakup **Navbar**, **Footer
Proyek menggunakan **multi-layer state management**:
| Library | Penggunaan | Lokasi |
|---------|-----------|--------|
| ------------------ | ----------------------------------------- | ---------------------------------- |
| **Jotai** | Auth state (`authStore`) | `src/store/authStore.ts` |
| **Valtio** | Dark mode, layanan, image list, nav state | `src/state/*.ts` |
| **SWR** | Server state fetching & caching | Digunakan di components |
@@ -643,6 +657,7 @@ src/store/
Sistem autentikasi menggunakan **OTP (One-Time Password)** via WhatsApp/Telepon dengan **iron-session** untuk session management.
### Flow Autentikasi:
1. User memasukkan **nomor telepon** di `/login`
2. Sistem mengirim **kode OTP** via WhatsApp Server
3. OTP disimpan di model `KodeOtp`
@@ -651,6 +666,7 @@ Sistem autentikasi menggunakan **OTP (One-Time Password)** via WhatsApp/Telepon
6. Session disimpan di `UserSession` model dengan expiry
### Session Structure:
```typescript
// src/lib/session.ts
type SessionData = {
@@ -665,13 +681,15 @@ type SessionData = {
```
### Role-Based Access:
| roleId | Role | Default Redirect |
|--------|------|-----------------|
| ------- | ------------------------ | --------------------------------------------------- |
| 0, 1, 2 | Super Admin / Admin Desa | `/admin/landing-page/profil/program-inovasi` |
| 3 | Admin Kesehatan | `/admin/kesehatan/posyandu` |
| 3 | Admin Kesehatan | `/admin/kesehatan/posyandu/list-posyandu` |
| 4 | Admin Pendidikan | `/admin/pendidikan/info-sekolah/jenjang-pendidikan` |
### Authorization:
- **UserMenuAccess**: Mapping user ke menu yang boleh diakses
- **Dynamic Navbar**: Navbar dirender berdasarkan `menuIds` user
- **Inactive Users**: Dialihkan ke `/waiting-room`
@@ -698,6 +716,7 @@ Stage 2: Runner
```
### Entry Point (`docker-entrypoint.sh`):
```bash
bunx prisma migrate deploy # Run migrations
exec bun start # Start Next.js production server
@@ -708,7 +727,7 @@ exec bun start # Start Next.js production server
Terdapat **3 workflow**:
| Workflow | Trigger | Fungsi |
|----------|---------|--------|
| -------------------- | -------------------------- | ------------------------------------------------------------------ |
| `docker-publish.yml` | Push tag `v*` | Auto build & push ke GHCR |
| `publish.yml` | Manual (workflow_dispatch) | Build & push ke GHCR dengan input `stack_env` + `tag` |
| `re-pull.yml` | Manual (workflow_dispatch) | Re-pull image di Portainer dengan input `stack_name` + `stack_env` |
@@ -730,11 +749,13 @@ Terdapat **3 workflow**:
**PENTING**: `publish.yml` dan `re-pull.yml` TIDAK boleh dijalankan bersamaan (race condition).
### Environments:
- **dev**: Development
- **stg**: Staging (`desa-darmasaba-stg.wibudev.com`)
- **prod**: Production
### Notification:
- Telegram notification via `notify.sh` script setelah setiap workflow
---
@@ -742,7 +763,7 @@ Terdapat **3 workflow**:
## 13. Scripts
| Script | Command | Deskripsi |
|--------|---------|-----------|
| ----------------- | -------------------------------------- | -------------------------------- |
| `dev` | `next dev` | Development server |
| `build` | `next build` | Production build |
| `start` | `next start` | Production server |
@@ -753,9 +774,10 @@ Terdapat **3 workflow**:
| `prisma:generate` | `bunx prisma generate` | Generate Prisma client |
| `prisma:push` | `bunx prisma db push` | Push schema to database |
| `prisma:studio` | `bunx prisma studio` | Open Prisma Studio GUI |
| `gen:api` | *(empty)* | Generate API types (placeholder) |
| `gen:api` | _(empty)_ | Generate API types (placeholder) |
### Prisma Seed Configuration:
```json
// package.json
{
@@ -772,7 +794,7 @@ Terdapat **3 workflow**:
File: `.env.example`
| Variable | Deskripsi | Contoh |
|----------|-----------|--------|
| ---------------------------- | ------------------------------------ | ------------------------------------------------------ |
| `DATABASE_URL` | PostgreSQL connection string | `postgresql://user:pass@localhost:5432/desa-darmasaba` |
| `SEAFILE_TOKEN` | Seafile API token | `your_seafile_token` |
| `SEAFILE_REPO_ID` | Seafile repository ID | `your_repo_id` |
@@ -794,12 +816,14 @@ File: `.env.example`
## 15. Layanan Eksternal
### PostgreSQL
- **Provider**: PostgreSQL via Prisma ORM
- **Schema**: `public`
- **Connection**: Via `DATABASE_URL` environment variable
- **Migrations**: `prisma migrate deploy` di docker entrypoint
### Seafile (File Storage)
- **Tipe**: Self-hosted file sync & share
- **Penggunaan**: Storage untuk images, documents, audio files
- **Integrasi**: `src/lib/seafile-auth-service.ts`
@@ -807,19 +831,23 @@ File: `.env.example`
- **Config**: Token, repo ID, base URL
### WhatsApp Server
- **Penggunaan**: Kirim OTP codes saat login
- **Config**: `WA_SERVER_TOKEN`
### Telegram Bot
- **Penggunaan**: Notifikasi deployment & sistem
- **Config**: `BOT_TOKEN` + `CHAT_ID`
- **Integration**: `notify.sh` script di GitHub Actions
### ElevenLabs (Optional)
- **Penggunaan**: Text-to-Speech (TTS) features
- **Config**: `ELEVENLABS_API_KEY`
### Email (Nodemailer)
- **Penggunaan**: Notifikasi email untuk subscription/pengumuman
- **Config**: `EMAIL_USER` + `EMAIL_PASS`
- **Provider**: Gmail (app password)
@@ -829,7 +857,7 @@ File: `.env.example`
## Ringkasan Cepat
| Aspek | Detail |
|-------|--------|
| ------------- | ------------------------------------------ |
| **Framework** | Next.js 15 (App Router) + Elysia.js |
| **Database** | PostgreSQL + Prisma (100+ models) |
| **Auth** | OTP + iron-session + JWT |

View File

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

View File

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

View File

@@ -1,27 +1,34 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import ibuHamilState from '@/app/admin/(dashboard)/_state/kesehatan/ibu-hamil/ibuHamil';
import colors from '@/con/colors';
import {
ActionIcon,
Badge,
Box,
Button,
Center,
Group,
Loader,
Pagination,
Paper,
Select,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
TextInput,
Title,
} from '@mantine/core';
import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
import { useDebouncedValue } from '@mantine/hooks';
import { IconDeviceImacCog, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils';
import ibuHamilState from '../../../_state/kesehatan/ibu-hamil/ibuHamil';
import HeaderSearch from '../../../_com/header';
const STATUS_COLORS: Record<string, string> = {
AKTIF: 'green',
@@ -30,85 +37,65 @@ const STATUS_COLORS: Record<string, string> = {
NONAKTIF: 'red',
};
export default function IbuHamilPage() {
const router = useRouter();
const state = useProxy(ibuHamilState);
function IbuHamilPage() {
const [search, setSearch] = useState('');
return (
<Box>
<HeaderSearch
title="Ibu Hamil"
placeholder="Cari nama / NIK..."
searchIcon={<IconSearch size={20} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<ListIbuHamil search={search} />
</Box>
);
}
function ListIbuHamil({ search }: { search: string }) {
const state = useProxy(ibuHamilState);
const router = useRouter();
const [statusFilter, setStatusFilter] = useState('');
const [debouncedSearch] = useDebouncedValue(search, 1000);
const { data, page, totalPages, loading, load } = state.findMany;
useEffect(() => {
state.findMany.load(1, 10, search, statusFilter);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleSearch = () => {
state.findMany.load(1, 10, search, statusFilter);
};
load(page, 10, debouncedSearch, statusFilter);
}, [page, debouncedSearch, statusFilter]);
const handleDelete = async (id: string, nama: string) => {
if (!confirm(`Hapus data ibu hamil "${nama}"?`)) return;
await state.delete.byId(id);
};
const 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/posyandu/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>
));
const filteredData = data || [];
if (loading || !data) {
return (
<Stack py="md">
<Skeleton h={600} radius="md" />
</Stack>
);
}
return (
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Group justify="space-between" mb="md">
<Title order={3} c="black">Ibu Hamil</Title>
<Box py="md">
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Group justify="space-between" mb="lg">
<Title order={4}>List Ibu Hamil</Title>
<Button
leftSection={<IconPlus size={16} />}
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/kesehatan/posyandu/ibu-hamil/create')}
radius="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
}}
>
Tambah
Tambah Baru
</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 }}
/>
<Group mb="md">
<Select
placeholder="Filter status"
data={[
@@ -119,53 +106,173 @@ export default function IbuHamilPage() {
{ value: 'NONAKTIF', label: 'Nonaktif' },
]}
value={statusFilter}
onChange={(v) => {
setStatusFilter(v ?? '');
state.findMany.load(1, 10, search, v ?? '');
}}
onChange={(v) => setStatusFilter(v ?? '')}
radius="md"
clearable
/>
<Button onClick={handleSearch} radius="md" variant="light">Cari</Button>
</Group>
{state.findMany.loading ? (
<Group justify="center" py="xl"><Loader /></Group>
{/* Desktop Table */}
<Box visibleFrom="md" style={{ overflowX: 'auto' }}>
<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>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((d) => (
<TableTr key={d.id}>
<TableTd>{d.nama}</TableTd>
<TableTd>{d.nik || '-'}</TableTd>
<TableTd>{d.usiaKehamilan} minggu</TableTd>
<TableTd>{d.noHp || '-'}</TableTd>
<TableTd>
<Badge
color={STATUS_COLORS[d.status] ?? 'gray'}
variant="light"
>
{d.status}
</Badge>
</TableTd>
<TableTd>
<Group gap="xs">
<Button
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() =>
router.push(`/admin/kesehatan/posyandu/ibu-hamil/edit/${d.id}`)
}
>
Edit
</Button>
<Button
size="xs"
radius="md"
variant="light"
color="red"
leftSection={<IconTrash size={16} />}
onClick={() => handleDelete(d.id, d.nama)}
loading={state.delete.loading}
>
Hapus
</Button>
</Group>
</TableTd>
</TableTr>
))
) : (
<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>
<TableTr>
<TableTd colSpan={6}>
<Center py="xl">
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data ibu hamil yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
)}
</Table.Tbody>
</TableTbody>
</Table>
</Box>
{(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)}
/>
{/* Mobile Cards */}
<Stack gap="sm" hiddenFrom="md">
{filteredData.length > 0 ? (
filteredData.map((d) => (
<Paper key={d.id} withBorder p="md" radius="md">
<Box>
<Text fz="sm" fw={600} mb={4}>
{d.nama}
</Text>
<Group gap="xs" mb={6}>
<Text fz="xs" c="dimmed">
NIK: {d.nik || '-'}
</Text>
<Text fz="xs" c="dimmed">
·
</Text>
<Text fz="xs" c="dimmed">
{d.usiaKehamilan} minggu
</Text>
</Group>
<Group gap="xs" mb={8}>
<Badge
size="xs"
color={STATUS_COLORS[d.status] ?? 'gray'}
variant="light"
>
{d.status}
</Badge>
{d.noHp && (
<Text fz="xs" c="dimmed">
{d.noHp}
</Text>
)}
</Group>
<Group gap="xs">
<Button
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() =>
router.push(`/admin/kesehatan/posyandu/ibu-hamil/edit/${d.id}`)
}
>
Edit
</Button>
<Button
size="xs"
radius="md"
variant="light"
color="red"
leftSection={<IconTrash size={16} />}
onClick={() => handleDelete(d.id, d.nama)}
loading={state.delete.loading}
>
Hapus
</Button>
</Group>
</Box>
</Paper>
))
) : (
<Center py="xl">
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data ibu hamil yang cocok
</Text>
</Center>
)}
</Stack>
)}
</Paper>
<Center>
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10, search, statusFilter);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
mt="lg"
mb="md"
color="blue"
radius="md"
/>
</Center>
</Box>
);
}
export default IbuHamilPage;

View File

@@ -114,7 +114,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
case 2:
return '/admin/landing-page/profil/program-inovasi';
case 3:
return '/admin/kesehatan/posyandu';
return '/admin/kesehatan/posyandu/list-posyandu';
case 4:
return '/admin/pendidikan/info-sekolah/jenjang-pendidikan';
default:

View File

@@ -128,7 +128,7 @@ export default function WaitingRoom() {
redirectPath = '/admin/landing-page/profil/program-inovasi';
break;
case "3":
redirectPath = '/admin/kesehatan/posyandu';
redirectPath = '/admin/kesehatan/posyandu/list-posyandu';
break;
case "4":
redirectPath = '/admin/pendidikan/info-sekolah/jenjang-pendidikan';