refactor(ui): sesuaikan UI balita & ibu-hamil dengan pola penghargaan

- Gunakan HeaderSearch + dua-komponen pattern (outer + inner list)
- Ganti Loader → Skeleton h={600}, ActionIcon → Button size="xs" variant="light"
- Tambah Paper wrapper, layout="fixed" table, desktop/mobile responsive split
- Search debounce 1000ms via useDebouncedValue

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-05 16:12:46 +08:00
parent ff25ead2df
commit e71c938b2f
6 changed files with 873 additions and 620 deletions

View File

@@ -6,25 +6,25 @@
### Tech Stack ### Tech Stack
| Kategori | Teknologi | | Kategori | Teknologi |
|----------|-----------| | -------------------- | ------------------------------------------ |
| **Framework** | Next.js 15 (App Router) | | **Framework** | Next.js 15 (App Router) |
| **Language** | TypeScript (strict mode) | | **Language** | TypeScript (strict mode) |
| **Runtime** | Bun | | **Runtime** | Bun |
| **Backend API** | Elysia.js (high-performance HTTP server) | | **Backend API** | Elysia.js (high-performance HTTP server) |
| **Database** | PostgreSQL | | **Database** | PostgreSQL |
| **ORM** | Prisma 6.3.1 | | **ORM** | Prisma 6.3.1 |
| **UI Framework** | Mantine UI v7-v8 | | **UI Framework** | Mantine UI v7-v8 |
| **State Management** | Jotai + Valtio + SWR | | **State Management** | Jotai + Valtio + SWR |
| **Authentication** | iron-session + JWT (@elysiajs/jwt) | | **Authentication** | iron-session + JWT (@elysiajs/jwt) |
| **File Storage** | Seafile (self-hosted) | | **File Storage** | Seafile (self-hosted) |
| **Text Editor** | Tiptap (Rich text editor) | | **Text Editor** | Tiptap (Rich text editor) |
| **Charts** | Recharts + Chart.js | | **Charts** | Recharts + Chart.js |
| **Maps** | Leaflet + react-leaflet | | **Maps** | Leaflet + react-leaflet |
| **Testing** | Vitest (unit) + Playwright (E2E) | | **Testing** | Vitest (unit) + Playwright (E2E) |
| **Styling** | Mantine + PostCSS + Framer Motion | | **Styling** | Mantine + PostCSS + Framer Motion |
| **Deployment** | Docker + GHCR + Portainer + GitHub Actions | | **Deployment** | Docker + GHCR + Portainer + GitHub Actions |
| **Version** | 0.1.11 | | **Version** | 0.1.11 |
--- ---
@@ -195,137 +195,148 @@ Browser
## 4. Modul Domain ## 4. Modul Domain
### A. PPID (Pejabat Pengelola Informasi dan Dokumentasi) ### A. PPID (Pejabat Pengelola Informasi dan Dokumentasi)
**Lokasi**: `src/app/admin/(dashboard)/ppid/` dan `src/app/darmasaba/(pages)/ppid/` **Lokasi**: `src/app/admin/(dashboard)/ppid/` dan `src/app/darmasaba/(pages)/ppid/`
| Sub-modul | Deskripsi | | Sub-modul | Deskripsi |
|-----------|-----------| | --------------------------- | ---------------------------------------------- |
| Profil PPID | Profil pejabat pengelola informasi | | Profil PPID | Profil pejabat pengelola informasi |
| Struktur PPID | Struktur organisasi PPID dengan hierarki | | Struktur PPID | Struktur organisasi PPID dengan hierarki |
| Visi & Misi PPID | Visi dan misi PPID desa | | Visi & Misi PPID | Visi dan misi PPID desa |
| Daftar Informasi Publik | Katalog informasi publik yang tersedia | | Daftar Informasi Publik | Katalog informasi publik yang tersedia |
| Dasar Hukum | Regulasi dan dasar hukum PPID | | Dasar Hukum | Regulasi dan dasar hukum PPID |
| Permohonan Informasi Publik | Form permohonan informasi (NIK, kontak, jenis) | | Permohonan Informasi Publik | Form permohonan informasi (NIK, kontak, jenis) |
| Permohonan Keberatan | Formulir keberatan informasi | | Permohonan Keberatan | Formulir keberatan informasi |
| Indeks Kepuasan Masyarakat | Survey kepuasan dengan grafik demografis | | Indeks Kepuasan Masyarakat | Survey kepuasan dengan grafik demografis |
### B. Desa (Landing Page & Umum) ### B. Desa (Landing Page & Umum)
**Lokasi**: `src/app/admin/(dashboard)/desa/` dan `src/app/darmasaba/(pages)/desa/` **Lokasi**: `src/app/admin/(dashboard)/desa/` dan `src/app/darmasaba/(pages)/desa/`
| Sub-modul | Deskripsi | | Sub-modul | Deskripsi |
|-----------|-----------| | -------------------------- | ---------------------------------------------- |
| Profil Desa | Sejarah, visi-misi, lambang, maskot | | Profil Desa | Sejarah, visi-misi, lambang, maskot |
| Profil Perbekel | Biodata, pengalaman, program unggulan perbekel | | Profil Perbekel | Biodata, pengalaman, program unggulan perbekel |
| Perbekel dari Masa ke Masa | Historis perbekel per periode | | Perbekel dari Masa ke Masa | Historis perbekel per periode |
| Berita | Artikel berita dengan kategori & multi-image | | Berita | Artikel berita dengan kategori & multi-image |
| Gallery | Foto dan video galeri | | Gallery | Foto dan video galeri |
| Pengumuman | Pengumuman desa dengan kategori | | Pengumuman | Pengumuman desa dengan kategori |
| Potensi Desa | Potensi desa dengan kategori | | Potensi Desa | Potensi desa dengan kategori |
| Layanan Desa | Surat keterangan, ajukan permohonan | | Layanan Desa | Surat keterangan, ajukan permohonan |
| Penghargaan | Prestasi dan penghargaan desa | | Penghargaan | Prestasi dan penghargaan desa |
| Desa Anti Korupsi | Transparansi anti-korupsi | | Desa Anti Korupsi | Transparansi anti-korupsi |
| SDGs Desa | Sustainable Development Goals desa | | SDGs Desa | Sustainable Development Goals desa |
| APBDes | Anggaran desa dengan hierarki item & realisasi | | APBDes | Anggaran desa dengan hierarki item & realisasi |
| Prestasi Desa | Katalog prestasi | | Prestasi Desa | Katalog prestasi |
### C. Kesehatan ### C. Kesehatan
**Lokasi**: `src/app/admin/(dashboard)/kesehatan/` dan `src/app/darmasaba/(pages)/kesehatan/` **Lokasi**: `src/app/admin/(dashboard)/kesehatan/` dan `src/app/darmasaba/(pages)/kesehatan/`
| Sub-modul | Deskripsi | | Sub-modul | Deskripsi |
|-----------|-----------| | -------------------- | ---------------------------------------------- |
| Fasilitas Kesehatan | Info rumah sakit/klinik (jam, dokter, tarif) | | Fasilitas Kesehatan | Info rumah sakit/klinik (jam, dokter, tarif) |
| Puskesmas | Data puskesmas dengan jam operasional & kontak | | Puskesmas | Data puskesmas dengan jam operasional & kontak |
| Posyandu | Jadwal dan informasi posyandu | | Posyandu | Jadwal dan informasi posyandu |
| Program Kesehatan | Program-program kesehatan desa | | Program Kesehatan | Program-program kesehatan desa |
| Penanganan Darurat | Prosedur penanganan darurat | | Penanganan Darurat | Prosedur penanganan darurat |
| Kontak Darurat | Kontak emergency dengan WhatsApp | | Kontak Darurat | Kontak emergency dengan WhatsApp |
| Info Wabah Penyakit | Informasi wabah penyakit | | Info Wabah Penyakit | Informasi wabah penyakit |
| Artikel Kesehatan | Artikel kesehatan lengkap | | Artikel Kesehatan | Artikel kesehatan lengkap |
| Data Kesehatan Warga | Statistik kesehatan warga | | Data Kesehatan Warga | Statistik kesehatan warga |
| Kelahiran & Kematian | Data vital statistik | | Kelahiran & Kematian | Data vital statistik |
| Grafik Kepuasan | Grafik kepuasan layanan kesehatan | | Grafik Kepuasan | Grafik kepuasan layanan kesehatan |
### D. Ekonomi ### D. Ekonomi
**Lokasi**: `src/app/admin/(dashboard)/ekonomi/` dan `src/app/darmasaba/(pages)/ekonomi/` **Lokasi**: `src/app/admin/(dashboard)/ekonomi/` dan `src/app/darmasaba/(pages)/ekonomi/`
| Sub-modul | Deskripsi | | Sub-modul | Deskripsi |
|-----------|-----------| | ------------------------------ | ------------------------------------------ |
| Pasar Desa | Katalog pasar desa dengan produk & rating | | Pasar Desa | Katalog pasar desa dengan produk & rating |
| Struktur BUMDes | Organisasi BUMDes dengan pengurus | | Struktur BUMDes | Organisasi BUMDes dengan pengurus |
| APBDes (PADesa) | Pendapatan Asli Desa | | APBDes (PADesa) | Pendapatan Asli Desa |
| Program Kemiskinan | Program dan statistik kemiskinan | | Program Kemiskinan | Program dan statistik kemiskinan |
| Sektor Unggulan | Sektor ekonomi unggulan desa | | Sektor Unggulan | Sektor ekonomi unggulan desa |
| Lowongan Kerja Lokal | Info lowongan pekerjaan | | Lowongan Kerja Lokal | Info lowongan pekerjaan |
| Demografi Pekerjaan | Distribusi pekerjaan penduduk | | Demografi Pekerjaan | Distribusi pekerjaan penduduk |
| Jumlah Pengangguran | Statistik pengangguran | | Jumlah Pengangguran | Statistik pengangguran |
| Penduduk Usia Kerja Menganggur | Analisis pengangguran by usia & pendidikan | | Penduduk Usia Kerja Menganggur | Analisis pengangguran by usia & pendidikan |
| Jumlah Penduduk Miskin | Tren kemiskinan tahunan | | Jumlah Penduduk Miskin | Tren kemiskinan tahunan |
### E. Kependudukan ### E. Kependudukan
**Lokasi**: `src/app/admin/(dashboard)/kependudukan/` dan `src/app/darmasaba/(pages)/kependudukan/` **Lokasi**: `src/app/admin/(dashboard)/kependudukan/` dan `src/app/darmasaba/(pages)/kependudukan/`
| Sub-modul | Deskripsi | | Sub-modul | Deskripsi |
|-----------|-----------| | ----------------- | -------------------------------------- |
| Data Banjar | Data penduduk per banjar | | Data Banjar | Data penduduk per banjar |
| Distribusi Agama | Statistik agama penduduk | | Distribusi Agama | Statistik agama penduduk |
| Distribusi Umur | Piramida umur penduduk | | Distribusi Umur | Piramida umur penduduk |
| Migrasi Penduduk | Data migrasi masuk/keluar | | Migrasi Penduduk | Data migrasi masuk/keluar |
| Dinamika Penduduk | Kelahiran, kematian, migrasi per tahun | | Dinamika Penduduk | Kelahiran, kematian, migrasi per tahun |
### F. Pendidikan ### F. Pendidikan
**Lokasi**: `src/app/admin/(dashboard)/pendidikan/` dan `src/app/darmasaba/(pages)/pendidikan/` **Lokasi**: `src/app/admin/(dashboard)/pendidikan/` dan `src/app/darmasaba/(pages)/pendidikan/`
| Sub-modul | Deskripsi | | Sub-modul | Deskripsi |
|-----------|-----------| | ----------------------- | ------------------------------------------- |
| Info Sekolah & PAUD | Data sekolah per jenjang (TK, SD, SMP, SMA) | | Info Sekolah & PAUD | Data sekolah per jenjang (TK, SD, SMP, SMA) |
| Beasiswa Desa | Program beasiswa & pendaftar | | Beasiswa Desa | Program beasiswa & pendaftar |
| Program Pendidikan Anak | Program pendidikan anak | | Program Pendidikan Anak | Program pendidikan anak |
| Bimbingan Belajar | Informasi bimbingan belajar | | Bimbingan Belajar | Informasi bimbingan belajar |
| Pendidikan Non Formal | Tempat & program non-formal | | Pendidikan Non Formal | Tempat & program non-formal |
| Perpustakaan Digital | Katalog buku & peminjaman | | Perpustakaan Digital | Katalog buku & peminjaman |
| Data Pendidikan | Statistik pendidikan | | Data Pendidikan | Statistik pendidikan |
### G. Keamanan ### G. Keamanan
**Lokasi**: `src/app/admin/(dashboard)/keamanan/` dan `src/app/darmasaba/(pages)/keamanan/` **Lokasi**: `src/app/admin/(dashboard)/keamanan/` dan `src/app/darmasaba/(pages)/keamanan/`
| Sub-modul | Deskripsi | | Sub-modul | Deskripsi |
|-----------|-----------| | ------------------------------------- | ----------------------------------------- |
| Keamanan Lingkungan (Pecalang/Patwal) | Sistem keamanan tradisional Bali | | Keamanan Lingkungan (Pecalang/Patwal) | Sistem keamanan tradisional Bali |
| Polsek Terdekat | Data polsek dengan layanan & map | | Polsek Terdekat | Data polsek dengan layanan & map |
| Kontak Darurat | Kontak darurat keamanan | | Kontak Darurat | Kontak darurat keamanan |
| Pencegahan Kriminalitas | Info pencegahan kriminal | | Pencegahan Kriminalitas | Info pencegahan kriminal |
| Laporan Publik | Laporan masyarakat dengan tracking status | | Laporan Publik | Laporan masyarakat dengan tracking status |
| Tips Keamanan | Tips dan panduan keamanan | | Tips Keamanan | Tips dan panduan keamanan |
### H. Lingkungan ### H. Lingkungan
**Lokasi**: `src/app/admin/(dashboard)/lingkungan/` dan `src/app/darmasaba/(pages)/lingkungan/` **Lokasi**: `src/app/admin/(dashboard)/lingkungan/` dan `src/app/darmasaba/(pages)/lingkungan/`
| Sub-modul | Deskripsi | | Sub-modul | Deskripsi |
|-----------|-----------| | -------------------- | --------------------------------- |
| Pengelolaan Sampah | Bank sampah & pengelolaan | | Pengelolaan Sampah | Bank sampah & pengelolaan |
| Program Penghijauan | Program penghijauan desa | | Program Penghijauan | Program penghijauan desa |
| Data Lingkungan | Data lingkungan desa | | Data Lingkungan | Data lingkungan desa |
| Gotong Royong | Kegiatan gotong royong | | Gotong Royong | Kegiatan gotong royong |
| Edukasi Lingkungan | Edukasi lingkungan hidup | | Edukasi Lingkungan | Edukasi lingkungan hidup |
| Konservasi Adat Bali | Tri Hita Karana & konservasi adat | | Konservasi Adat Bali | Tri Hita Karana & konservasi adat |
### I. Inovasi ### I. Inovasi
**Lokasi**: `src/app/admin/(dashboard)/inovasi/` dan `src/app/darmasaba/(pages)/inovasi/` **Lokasi**: `src/app/admin/(dashboard)/inovasi/` dan `src/app/darmasaba/(pages)/inovasi/`
| Sub-modul | Deskripsi | | Sub-modul | Deskripsi |
|-----------|-----------| | ---------------------------- | ----------------------------- |
| Desa Digital (Smart Village) | Transformasi digital desa | | Desa Digital (Smart Village) | Transformasi digital desa |
| Program Kreatif Desa | Program kreatif & inovatif | | Program Kreatif Desa | Program kreatif & inovatif |
| Kolaborasi Inovasi | Kolaborasi dengan mitra | | Kolaborasi Inovasi | Kolaborasi dengan mitra |
| Info Teknologi Tepat Guna | Info teknologi untuk desa | | Info Teknologi Tepat Guna | Info teknologi untuk desa |
| Ajukan Ide Inovatif | Form pengajuan ide dari warga | | Ajukan Ide Inovatif | Form pengajuan ide dari warga |
| Layanan Online Desa | Layanan administrasi online | | Layanan Online Desa | Layanan administrasi online |
### J. Musik Desa ### J. Musik Desa
**Lokasi**: `src/app/admin/(dashboard)/musik/` dan `src/app/darmasaba/(pages)/musik/` **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. Model `MusikDesa` dengan audio file, cover image, genre, dan durasi. Dilengkapi dengan `FixedPlayerBar` di layout publik.
### K. User & Role (Admin) ### K. User & Role (Admin)
**Lokasi**: `src/app/admin/(dashboard)/user&role/` **Lokasi**: `src/app/admin/(dashboard)/user&role/`
- **Role-based Access Control**: Role dengan permission JSON - **Role-based Access Control**: Role dengan permission JSON
@@ -341,124 +352,124 @@ Schema terdiri dari **2413 baris** dengan **100+ model** dan **berbagai enum**.
### Core Models ### Core Models
| Model | Keterangan | | Model | Keterangan |
|-------|-----------| | -------------------------------------------------- | ----------------------------------------------- |
| `FileStorage` | Central file storage untuk semua uploaded files | | `FileStorage` | Central file storage untuk semua uploaded files |
| `AppMenu` / `AppMenuChild` | Menu navigasi aplikasi | | `AppMenu` / `AppMenuChild` | Menu navigasi aplikasi |
| `User` / `Role` / `UserSession` / `UserMenuAccess` | Sistem autentikasi & otorisasi | | `User` / `Role` / `UserSession` / `UserMenuAccess` | Sistem autentikasi & otorisasi |
| `KodeOtp` | OTP codes untuk login | | `KodeOtp` | OTP codes untuk login |
### Landing Page & Desa ### Landing Page & Desa
| Model | Keterangan | | Model | Keterangan |
|-------|-----------| | --------------------------------------------- | ---------------------------------------------- |
| `PejabatDesa` | Pejabat desa dengan foto | | `PejabatDesa` | Pejabat desa dengan foto |
| `ProfilPerbekel` | Profil perbekel (biodata, pengalaman, program) | | `ProfilPerbekel` | Profil perbekel (biodata, pengalaman, program) |
| `PerbekelDariMasaKeMasa` | Historis perbekel | | `PerbekelDariMasaKeMasa` | Historis perbekel |
| `Berita` / `KategoriBerita` | Berita desa | | `Berita` / `KategoriBerita` | Berita desa |
| `PotensiDesa` / `KategoriPotensi` | Potensi desa | | `PotensiDesa` / `KategoriPotensi` | Potensi desa |
| `Pengumuman` / `CategoryPengumuman` | Pengumuman | | `Pengumuman` / `CategoryPengumuman` | Pengumuman |
| `GalleryFoto` / `GalleryVideo` | Gallery media | | `GalleryFoto` / `GalleryVideo` | Gallery media |
| `Penghargaan` | Penghargaan desa | | `Penghargaan` | Penghargaan desa |
| `APBDes` / `APBDesItem` / `RealisasiItem` | Anggaran dengan realisasi | | `APBDes` / `APBDesItem` / `RealisasiItem` | Anggaran dengan realisasi |
| `DesaAntiKorupsi` / `KategoriDesaAntiKorupsi` | Transparansi | | `DesaAntiKorupsi` / `KategoriDesaAntiKorupsi` | Transparansi |
| `SdgsDesa` | SDGs desa | | `SdgsDesa` | SDGs desa |
| `PrestasiDesa` / `KategoriPrestasiDesa` | Prestasi | | `PrestasiDesa` / `KategoriPrestasiDesa` | Prestasi |
| `MusikDesa` | Musik desa | | `MusikDesa` | Musik desa |
### PPID ### PPID
| Model | Keterangan | | Model | Keterangan |
|-------|-----------| | ------------------------------------------------------- | -------------------------- |
| `StrukturPPID` / `PosisiOrganisasiPPID` / `PegawaiPPID` | Struktur organisasi | | `StrukturPPID` / `PosisiOrganisasiPPID` / `PegawaiPPID` | Struktur organisasi |
| `VisiMisiPPID` | Visi misi | | `VisiMisiPPID` | Visi misi |
| `ProfilePPID` | Profil pejabat | | `ProfilePPID` | Profil pejabat |
| `DasarHukumPPID` | Regulasi | | `DasarHukumPPID` | Regulasi |
| `DaftarInformasiPublik` | Katalog informasi | | `DaftarInformasiPublik` | Katalog informasi |
| `PermohonanInformasiPublik` | Permohonan + lookup tables | | `PermohonanInformasiPublik` | Permohonan + lookup tables |
| `FormulirPermohonanKeberatan` | Keberatan | | `FormulirPermohonanKeberatan` | Keberatan |
| `IndeksKepuasanMasyarakat` + grafik breakdown | Survey kepuasan | | `IndeksKepuasanMasyarakat` + grafik breakdown | Survey kepuasan |
### Kesehatan ### Kesehatan
| Model | Keterangan | | Model | Keterangan |
|-------|-----------| | --------------------------------------------------- | ---------------------------------------------- |
| `FasilitasKesehatan` | Fasilitas lengkap (dokter, tarif, prosedur) | | `FasilitasKesehatan` | Fasilitas lengkap (dokter, tarif, prosedur) |
| `Puskesmas` / `JamOperasional` / `KontakPuskesmas` | Puskesmas | | `Puskesmas` / `JamOperasional` / `KontakPuskesmas` | Puskesmas |
| `Posyandu` | Pos pelayanan terpadu | | `Posyandu` | Pos pelayanan terpadu |
| `ProgramKesehatan` | Program kesehatan | | `ProgramKesehatan` | Program kesehatan |
| `ArtikelKesehatan` | Artikel lengkap (gejala, pencegahan, P3K, dll) | | `ArtikelKesehatan` | Artikel lengkap (gejala, pencegahan, P3K, dll) |
| `PenangananDarurat` / `KontakDarurat` | Darurat | | `PenangananDarurat` / `KontakDarurat` | Darurat |
| `InfoWabahPenyakit` | Wabah | | `InfoWabahPenyakit` | Wabah |
| `DataKematian_Kelahiran` / `Kelahiran` / `Kematian` | Vital statistik | | `DataKematian_Kelahiran` / `Kelahiran` / `Kematian` | Vital statistik |
| `GrafikKepuasan` | Kepuasan | | `GrafikKepuasan` | Kepuasan |
### Ekonomi ### Ekonomi
| Model | Keterangan | | Model | Keterangan |
|-------|-----------| | ------------------------------------------------------------- | ------------------- |
| `PasarDesa` / `KategoriProduk` / `KategoriToPasar` | Pasar desa | | `PasarDesa` / `KategoriProduk` / `KategoriToPasar` | Pasar desa |
| `StrukturBumDes` / `PosisiOrganisasiBumDes` / `PegawaiBumDes` | BUMDes | | `StrukturBumDes` / `PosisiOrganisasiBumDes` / `PegawaiBumDes` | BUMDes |
| `ProgramKemiskinan` / `StatistikKemiskinan` | Kemiskinan | | `ProgramKemiskinan` / `StatistikKemiskinan` | Kemiskinan |
| `SektorUnggulanDesa` | Sektor unggulan | | `SektorUnggulanDesa` | Sektor unggulan |
| `LowonganPekerjaan` | Lowongan | | `LowonganPekerjaan` | Lowongan |
| `DataDemografiPekerjaan` | Demografi pekerjaan | | `DataDemografiPekerjaan` | Demografi pekerjaan |
| `DetailDataPengangguran` | Pengangguran | | `DetailDataPengangguran` | Pengangguran |
| `GrafikJumlahPendudukMiskin` | Tren kemiskinan | | `GrafikJumlahPendudukMiskin` | Tren kemiskinan |
### Kependudukan ### Kependudukan
| Model | Keterangan | | Model | Keterangan |
|-------|-----------| | ------------------ | ---------------------- |
| `DataBanjar` | Data per banjar | | `DataBanjar` | Data per banjar |
| `DistribusiAgama` | Distribusi agama | | `DistribusiAgama` | Distribusi agama |
| `DistribusiUmur` | Distribusi umur | | `DistribusiUmur` | Distribusi umur |
| `MigrasiPenduduk` | Migrasi (MASUK/KELUAR) | | `MigrasiPenduduk` | Migrasi (MASUK/KELUAR) |
| `DinamikaPenduduk` | Dinamika tahunan | | `DinamikaPenduduk` | Dinamika tahunan |
### Pendidikan ### Pendidikan
| Model | Keterangan | | Model | Keterangan |
|-------|-----------| | ------------------------------------------------------ | ------------------------------ |
| `JenjangPendidikan` / `Lembaga` / `Siswa` / `Pengajar` | Data sekolah | | `JenjangPendidikan` / `Lembaga` / `Siswa` / `Pengajar` | Data sekolah |
| `BeasiswaPendaftar` | Beasiswa (dengan enum lengkap) | | `BeasiswaPendaftar` | Beasiswa (dengan enum lengkap) |
| `DataPerpustakaan` / `KategoriBuku` / `PeminjamanBuku` | Perpustakaan | | `DataPerpustakaan` / `KategoriBuku` / `PeminjamanBuku` | Perpustakaan |
| `DataPendidikan` | Statistik | | `DataPendidikan` | Statistik |
### Keamanan ### Keamanan
| Model | Keterangan | | Model | Keterangan |
|-------|-----------| | ---------------------------------------------------------------- | ------------------- |
| `KeamananLingkungan` | Keamanan lingkungan | | `KeamananLingkungan` | Keamanan lingkungan |
| `PolsekTerdekat` / `LayananPolsek` / `LayananToPolsek` | Polsek | | `PolsekTerdekat` / `LayananPolsek` / `LayananToPolsek` | Polsek |
| `KontakDaruratKeamanan` / `KontakItem` | Kontak darurat | | `KontakDaruratKeamanan` / `KontakItem` | Kontak darurat |
| `PencegahanKriminalitas` | Pencegahan | | `PencegahanKriminalitas` | Pencegahan |
| `LaporanPublik` / `PenangananLaporanPublik` (enum StatusLaporan) | Laporan | | `LaporanPublik` / `PenangananLaporanPublik` (enum StatusLaporan) | Laporan |
| `Pelapor` | Pelapor | | `Pelapor` | Pelapor |
| `MenuTipsKeamanan` | Tips | | `MenuTipsKeamanan` | Tips |
### Lingkungan ### Lingkungan
| Model | Keterangan | | Model | Keterangan |
|-------|-----------| | ----------------------------------------------------- | ------------------ |
| `PengelolaanSampah` | Pengelolaan sampah | | `PengelolaanSampah` | Pengelolaan sampah |
| `KeteranganBankSampahTerdekat` | Bank sampah | | `KeteranganBankSampahTerdekat` | Bank sampah |
| `ProgramPenghijauan` | Penghijauan | | `ProgramPenghijauan` | Penghijauan |
| `DataLingkunganDesa` | Data lingkungan | | `DataLingkunganDesa` | Data lingkungan |
| `KegiatanDesa` / `KategoriKegiatan` | Gotong royong | | `KegiatanDesa` / `KategoriKegiatan` | Gotong royong |
| `FilosofiTriHita` / `BentukKonservasiBerdasarkanAdat` | Konservasi Bali | | `FilosofiTriHita` / `BentukKonservasiBerdasarkanAdat` | Konservasi Bali |
### Inovasi ### Inovasi
| Model | Keterangan | | Model | Keterangan |
|-------|-----------| | ---------------------------------------- | -------------------- |
| `DesaDigital` | Smart village | | `DesaDigital` | Smart village |
| `ProgramKreatif` | Program kreatif | | `ProgramKreatif` | Program kreatif |
| `KolaborasiInovasi` / `MitraKolaborasi` | Kolaborasi | | `KolaborasiInovasi` / `MitraKolaborasi` | Kolaborasi |
| `InfoTekno` | Teknologi tepat guna | | `InfoTekno` | Teknologi tepat guna |
| `AjukanIdeInovatif` | Ide dari warga | | `AjukanIdeInovatif` | Ide dari warga |
| `AdministrasiOnline` / `JenisLayanan` | Layanan online | | `AdministrasiOnline` / `JenisLayanan` | Layanan online |
| `PengaduanMasyarakat` / `JenisPengaduan` | Pengaduan | | `PengaduanMasyarakat` / `JenisPengaduan` | Pengaduan |
--- ---
@@ -466,43 +477,43 @@ Schema terdiri dari **2413 baris** dengan **100+ model** dan **berbagai enum**.
Semua API ditangani oleh **Elysia.js** di `src/app/api/[[...slugs]]/route.ts`: Semua API ditangani oleh **Elysia.js** di `src/app/api/[[...slugs]]/route.ts`:
| Endpoint Group | Prefix | Deskripsi | | Endpoint Group | Prefix | Deskripsi |
|---------------|--------|-----------| | ---------------- | -------------------- | ---------------------------------------------- |
| **File Storage** | `/api/file-storage` | CRUD file storage | | **File Storage** | `/api/file-storage` | CRUD file storage |
| **Landing Page** | `/api/landing-page` | Profil, prestasi, anti-korupsi, SDGs, APBDes | | **Landing Page** | `/api/landing-page` | Profil, prestasi, anti-korupsi, SDGs, APBDes |
| **Desa** | `/api/desa` | Berita, gallery, potensi, pengumuman, layanan | | **Desa** | `/api/desa` | Berita, gallery, potensi, pengumuman, layanan |
| **PPID** | `/api/ppid` | Semua endpoint PPID | | **PPID** | `/api/ppid` | Semua endpoint PPID |
| **Kesehatan** | `/api/kesehatan` | Fasilitas, puskesmas, posyandu, artikel, wabah | | **Kesehatan** | `/api/kesehatan` | Fasilitas, puskesmas, posyandu, artikel, wabah |
| **Ekonomi** | `/api/ekonomi` | Pasar desa, BUMDes, APBDes, pengangguran | | **Ekonomi** | `/api/ekonomi` | Pasar desa, BUMDes, APBDes, pengangguran |
| **Keamanan** | `/api/keamanan` | Keamanan, polsek, laporan, kriminalitas | | **Keamanan** | `/api/keamanan` | Keamanan, polsek, laporan, kriminalitas |
| **Lingkungan** | `/api/lingkungan` | Sampah, penghijauan, gotong royong | | **Lingkungan** | `/api/lingkungan` | Sampah, penghijauan, gotong royong |
| **Pendidikan** | `/api/pendidikan` | Sekolah, beasiswa, perpustakaan | | **Pendidikan** | `/api/pendidikan` | Sekolah, beasiswa, perpustakaan |
| **Kependudukan** | `/api/kependudukan` | Banjar, agama, umur, migrasi | | **Kependudukan** | `/api/kependudukan` | Banjar, agama, umur, migrasi |
| **Inovasi** | `/api/inovasi` | Desa digital, kolaborasi, pengaduan | | **Inovasi** | `/api/inovasi` | Desa digital, kolaborasi, pengaduan |
| **User** | `/api/admin/user` | CRUD user | | **User** | `/api/admin/user` | CRUD user |
| **Role** | `/api/admin/role` | CRUD role | | **Role** | `/api/admin/role` | CRUD role |
| **Search** | `/api/search` | Global search | | **Search** | `/api/search` | Global search |
| **Utils** | `/api/utils/version` | Version info | | **Utils** | `/api/utils/version` | Version info |
### Utility Endpoints ### Utility Endpoints
| Endpoint | Method | Deskripsi | | Endpoint | Method | Deskripsi |
|----------|--------|-----------| | --------------------- | ------ | ----------------------------- |
| `/api/img/:name` | GET | Serve image dengan resize | | `/api/img/:name` | GET | Serve image dengan resize |
| `/api/img/:name` | DELETE | Delete image | | `/api/img/:name` | DELETE | Delete image |
| `/api/imgs` | GET | List images dengan pagination | | `/api/imgs` | GET | List images dengan pagination |
| `/api/upl-img` | POST | Upload multiple images | | `/api/upl-img` | POST | Upload multiple images |
| `/api/upl-img-single` | POST | Upload single image | | `/api/upl-img-single` | POST | Upload single image |
| `/api/upl-csv` | POST | Upload CSV multiple | | `/api/upl-csv` | POST | Upload CSV multiple |
| `/api/upl-csv-single` | POST | Upload single CSV | | `/api/upl-csv-single` | POST | Upload single CSV |
### Auth Endpoints ### Auth Endpoints
| Endpoint | Method | Deskripsi | | Endpoint | Method | Deskripsi |
|----------|--------|-----------| | ------------------ | ------ | ---------------- |
| `/api/auth/login` | POST | Login dengan OTP | | `/api/auth/login` | POST | Login dengan OTP |
| `/api/auth/logout` | POST | Logout | | `/api/auth/logout` | POST | Logout |
| `/api/auth/me` | GET | Get current user | | `/api/auth/me` | GET | Get current user |
**Swagger Documentation**: Tersedia di `/api/docs` **Swagger Documentation**: Tersedia di `/api/docs`
@@ -514,22 +525,23 @@ Admin dashboard menggunakan **Mantine AppShell** dengan sidebar navigasi dinamis
### Route Group: `/admin` ### Route Group: `/admin`
| Section | Path | Deskripsi | | Section | Path | Deskripsi |
|---------|------|-----------| | ---------------- | ---------------------- | ------------------------------------------------------------------ |
| **Landing Page** | `/admin/landing-page/` | Profil desa, prestasi, anti-korupsi, SDGs, media sosial | | **Landing Page** | `/admin/landing-page/` | Profil desa, prestasi, anti-korupsi, SDGs, media sosial |
| **Desa** | `/admin/desa/` | Berita, gallery, layanan, penghargaan, pengumuman, potensi, profil | | **Desa** | `/admin/desa/` | Berita, gallery, layanan, penghargaan, pengumuman, potensi, profil |
| **PPID** | `/admin/ppid/` | 8 sub-modul PPID lengkap | | **PPID** | `/admin/ppid/` | 8 sub-modul PPID lengkap |
| **Kesehatan** | `/admin/kesehatan/` | 8 sub-modul kesehatan | | **Kesehatan** | `/admin/kesehatan/` | 8 sub-modul kesehatan |
| **Ekonomi** | `/admin/ekonomi/` | 10 sub-modul ekonomi | | **Ekonomi** | `/admin/ekonomi/` | 10 sub-modul ekonomi |
| **Kependudukan** | `/admin/kependudukan/` | 4 sub-modul kependudukan | | **Kependudukan** | `/admin/kependudukan/` | 4 sub-modul kependudukan |
| **Pendidikan** | `/admin/pendidikan/` | 7 sub-modul pendidikan | | **Pendidikan** | `/admin/pendidikan/` | 7 sub-modul pendidikan |
| **Keamanan** | `/admin/keamanan/` | 6 sub-modul keamanan | | **Keamanan** | `/admin/keamanan/` | 6 sub-modul keamanan |
| **Lingkungan** | `/admin/lingkungan/` | 6 sub-modul lingkungan | | **Lingkungan** | `/admin/lingkungan/` | 6 sub-modul lingkungan |
| **Inovasi** | `/admin/inovasi/` | 6 sub-modul inovasi | | **Inovasi** | `/admin/inovasi/` | 6 sub-modul inovasi |
| **Musik** | `/admin/musik/` | Manajemen musik desa | | **Musik** | `/admin/musik/` | Manajemen musik desa |
| **User & Role** | `/admin/user&role/` | Manajemen user, role, menu access | | **User & Role** | `/admin/user&role/` | Manajemen user, role, menu access |
### Fitur Admin: ### Fitur Admin:
- **Role-based Dynamic Navbar**: Navbar berubah berdasarkan roleId user - **Role-based Dynamic Navbar**: Navbar berubah berdasarkan roleId user
- **Dark Mode Toggle**: Tema gelap/terang - **Dark Mode Toggle**: Tema gelap/terang
- **OTP Login**: Login dengan nomor telepon + kode OTP - **OTP Login**: Login dengan nomor telepon + kode OTP
@@ -539,11 +551,12 @@ Admin dashboard menggunakan **Mantine AppShell** dengan sidebar navigasi dinamis
- **Rich Text Editor**: Tiptap untuk konten HTML - **Rich Text Editor**: Tiptap untuk konten HTML
### Role-Based Redirect: ### Role-Based Redirect:
| roleId | Role | Default Redirect |
|--------|------|-----------------| | roleId | Role | Default Redirect |
| 0, 1, 2 | Super Admin / Admin Desa | `/admin/landing-page/profil/program-inovasi` | | ------- | ------------------------ | --------------------------------------------------- |
| 3 | Admin Kesehatan | `/admin/kesehatan/posyandu` | | 0, 1, 2 | Super Admin / Admin Desa | `/admin/landing-page/profil/program-inovasi` |
| 4 | Admin Pendidikan | `/admin/pendidikan/info-sekolah/jenjang-pendidikan` | | 3 | Admin Kesehatan | `/admin/kesehatan/posyandu/list-posyandu` |
| 4 | Admin Pendidikan | `/admin/pendidikan/info-sekolah/jenjang-pendidikan` |
--- ---
@@ -553,22 +566,23 @@ Public website di `/darmasaba/` dengan layout yang mencakup **Navbar**, **Footer
### Route Group: `/darmasaba` ### Route Group: `/darmasaba`
| Section | Path | Deskripsi | | Section | Path | Deskripsi |
|---------|------|-----------| | ---------------- | ------------------------- | ----------------------------------------------------------------- |
| **Home** | `/darmasaba` | Landing page utama | | **Home** | `/darmasaba` | Landing page utama |
| **Desa** | `/darmasaba/desa` | Profil, berita, gallery, layanan, pengumuman, potensi | | **Desa** | `/darmasaba/desa` | Profil, berita, gallery, layanan, pengumuman, potensi |
| **PPID** | `/darmasaba/ppid` | 7 sub-halaman PPID publik | | **PPID** | `/darmasaba/ppid` | 7 sub-halaman PPID publik |
| **Kesehatan** | `/darmasaba/kesehatan` | Info kesehatan publik | | **Kesehatan** | `/darmasaba/kesehatan` | Info kesehatan publik |
| **Ekonomi** | `/darmasaba/ekonomi` | Info ekonomi desa | | **Ekonomi** | `/darmasaba/ekonomi` | Info ekonomi desa |
| **Kependudukan** | `/darmasaba/kependudukan` | Data kependudukan | | **Kependudukan** | `/darmasaba/kependudukan` | Data kependudukan |
| **Pendidikan** | `/darmasaba/pendidikan` | Info pendidikan | | **Pendidikan** | `/darmasaba/pendidikan` | Info pendidikan |
| **Keamanan** | `/darmasaba/keamanan` | Info keamanan | | **Keamanan** | `/darmasaba/keamanan` | Info keamanan |
| **Lingkungan** | `/darmasaba/lingkungan` | Info lingkungan | | **Lingkungan** | `/darmasaba/lingkungan` | Info lingkungan |
| **Inovasi** | `/darmasaba/inovasi` | Info inovasi | | **Inovasi** | `/darmasaba/inovasi` | Info inovasi |
| **Musik** | `/darmasaba/musik` | Musik desa | | **Musik** | `/darmasaba/musik` | Musik desa |
| **Module** | `/darmasaba/module/*` | Link ke modul eksternal (DAVES, MANGAN, Bicara-Darma, BARES, dll) | | **Module** | `/darmasaba/module/*` | Link ke modul eksternal (DAVES, MANGAN, Bicara-Darma, BARES, dll) |
### Fitur Publik: ### Fitur Publik:
- **Fixed Music Player Bar**: Player musik yang selalu tampil di bottom - **Fixed Music Player Bar**: Player musik yang selalu tampil di bottom
- **Global Search**: Pencarian global - **Global Search**: Pencarian global
- **News Reader**: Notifikasi berita modern - **News Reader**: Notifikasi berita modern
@@ -581,33 +595,33 @@ Public website di `/darmasaba/` dengan layout yang mencakup **Navbar**, **Footer
### Admin Components (`src/components/admin/`) ### Admin Components (`src/components/admin/`)
| Komponen | Deskripsi | | Komponen | Deskripsi |
|----------|-----------| | ------------------------ | --------------------------------- |
| `AdminThemeProvider.tsx` | Theme provider untuk admin | | `AdminThemeProvider.tsx` | Theme provider untuk admin |
| `DarkModeToggle.tsx` | Toggle dark/light mode | | `DarkModeToggle.tsx` | Toggle dark/light mode |
| `UnifiedSurface.tsx` | Consistent surface/card component | | `UnifiedSurface.tsx` | Consistent surface/card component |
| `UnifiedTypography.tsx` | Consistent typography system | | `UnifiedTypography.tsx` | Consistent typography system |
### Public Shared Components (`src/app/darmasaba/_com/`) ### Public Shared Components (`src/app/darmasaba/_com/`)
| Komponen | Deskripsi | | Komponen | Deskripsi |
|----------|-----------| | ---------------------------- | -------------------------------- |
| `Navbar.tsx` | Main navigation bar | | `Navbar.tsx` | Main navigation bar |
| `NavbarMainMenu.tsx` | Main menu dengan kategori | | `NavbarMainMenu.tsx` | Main menu dengan kategori |
| `NavbarSubMenu.tsx` | Submenu dropdown | | `NavbarSubMenu.tsx` | Submenu dropdown |
| `Footer.tsx` | Footer dengan info desa | | `Footer.tsx` | Footer dengan info desa |
| `FixedPlayerBar.tsx` | Music player bar fixed di bottom | | `FixedPlayerBar.tsx` | Music player bar fixed di bottom |
| `LoadDataFirstClient.tsx` | Client-side data preloader | | `LoadDataFirstClient.tsx` | Client-side data preloader |
| `globalSearch.tsx` | Global search component | | `globalSearch.tsx` | Global search component |
| `NewsReader.tsx` | News notification reader | | `NewsReader.tsx` | News notification reader |
| `ModernNewsNotification.tsx` | News toast notifications | | `ModernNewsNotification.tsx` | News toast notifications |
### Global Components (`src/app/_com/`) ### Global Components (`src/app/_com/`)
| Komponen | Deskripsi | | Komponen | Deskripsi |
|----------|-----------| | ----------------- | --------------------- |
| `SpashScreen.tsx` | Splash screen on load | | `SpashScreen.tsx` | Splash screen on load |
| `WebVitals.tsx` | Web Vitals monitoring | | `WebVitals.tsx` | Web Vitals monitoring |
--- ---
@@ -615,13 +629,13 @@ Public website di `/darmasaba/` dengan layout yang mencakup **Navbar**, **Footer
Proyek menggunakan **multi-layer state management**: Proyek menggunakan **multi-layer state management**:
| Library | Penggunaan | Lokasi | | Library | Penggunaan | Lokasi |
|---------|-----------|--------| | ------------------ | ----------------------------------------- | ---------------------------------- |
| **Jotai** | Auth state (`authStore`) | `src/store/authStore.ts` | | **Jotai** | Auth state (`authStore`) | `src/store/authStore.ts` |
| **Valtio** | Dark mode, layanan, image list, nav state | `src/state/*.ts` | | **Valtio** | Dark mode, layanan, image list, nav state | `src/state/*.ts` |
| **SWR** | Server state fetching & caching | Digunakan di components | | **SWR** | Server state fetching & caching | Digunakan di components |
| **React Context** | Music player context | `src/app/context/MusicContext.tsx` | | **React Context** | Music player context | `src/app/context/MusicContext.tsx` |
| **React useState** | Local component state | Di components | | **React useState** | Local component state | Di components |
### State Files: ### State Files:
@@ -643,6 +657,7 @@ src/store/
Sistem autentikasi menggunakan **OTP (One-Time Password)** via WhatsApp/Telepon dengan **iron-session** untuk session management. Sistem autentikasi menggunakan **OTP (One-Time Password)** via WhatsApp/Telepon dengan **iron-session** untuk session management.
### Flow Autentikasi: ### Flow Autentikasi:
1. User memasukkan **nomor telepon** di `/login` 1. User memasukkan **nomor telepon** di `/login`
2. Sistem mengirim **kode OTP** via WhatsApp Server 2. Sistem mengirim **kode OTP** via WhatsApp Server
3. OTP disimpan di model `KodeOtp` 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 6. Session disimpan di `UserSession` model dengan expiry
### Session Structure: ### Session Structure:
```typescript ```typescript
// src/lib/session.ts // src/lib/session.ts
type SessionData = { type SessionData = {
@@ -665,13 +681,15 @@ type SessionData = {
``` ```
### Role-Based Access: ### Role-Based Access:
| roleId | Role | Default Redirect |
|--------|------|-----------------| | roleId | Role | Default Redirect |
| 0, 1, 2 | Super Admin / Admin Desa | `/admin/landing-page/profil/program-inovasi` | | ------- | ------------------------ | --------------------------------------------------- |
| 3 | Admin Kesehatan | `/admin/kesehatan/posyandu` | | 0, 1, 2 | Super Admin / Admin Desa | `/admin/landing-page/profil/program-inovasi` |
| 4 | Admin Pendidikan | `/admin/pendidikan/info-sekolah/jenjang-pendidikan` | | 3 | Admin Kesehatan | `/admin/kesehatan/posyandu/list-posyandu` |
| 4 | Admin Pendidikan | `/admin/pendidikan/info-sekolah/jenjang-pendidikan` |
### Authorization: ### Authorization:
- **UserMenuAccess**: Mapping user ke menu yang boleh diakses - **UserMenuAccess**: Mapping user ke menu yang boleh diakses
- **Dynamic Navbar**: Navbar dirender berdasarkan `menuIds` user - **Dynamic Navbar**: Navbar dirender berdasarkan `menuIds` user
- **Inactive Users**: Dialihkan ke `/waiting-room` - **Inactive Users**: Dialihkan ke `/waiting-room`
@@ -698,6 +716,7 @@ Stage 2: Runner
``` ```
### Entry Point (`docker-entrypoint.sh`): ### Entry Point (`docker-entrypoint.sh`):
```bash ```bash
bunx prisma migrate deploy # Run migrations bunx prisma migrate deploy # Run migrations
exec bun start # Start Next.js production server exec bun start # Start Next.js production server
@@ -707,11 +726,11 @@ exec bun start # Start Next.js production server
Terdapat **3 workflow**: Terdapat **3 workflow**:
| Workflow | Trigger | Fungsi | | Workflow | Trigger | Fungsi |
|----------|---------|--------| | -------------------- | -------------------------- | ------------------------------------------------------------------ |
| `docker-publish.yml` | Push tag `v*` | Auto build & push ke GHCR | | `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` | | `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` | | `re-pull.yml` | Manual (workflow_dispatch) | Re-pull image di Portainer dengan input `stack_name` + `stack_env` |
### Deployment Workflow (Sequential): ### Deployment Workflow (Sequential):
@@ -730,32 +749,35 @@ Terdapat **3 workflow**:
**PENTING**: `publish.yml` dan `re-pull.yml` TIDAK boleh dijalankan bersamaan (race condition). **PENTING**: `publish.yml` dan `re-pull.yml` TIDAK boleh dijalankan bersamaan (race condition).
### Environments: ### Environments:
- **dev**: Development - **dev**: Development
- **stg**: Staging (`desa-darmasaba-stg.wibudev.com`) - **stg**: Staging (`desa-darmasaba-stg.wibudev.com`)
- **prod**: Production - **prod**: Production
### Notification: ### Notification:
- Telegram notification via `notify.sh` script setelah setiap workflow - Telegram notification via `notify.sh` script setelah setiap workflow
--- ---
## 13. Scripts ## 13. Scripts
| Script | Command | Deskripsi | | Script | Command | Deskripsi |
|--------|---------|-----------| | ----------------- | -------------------------------------- | -------------------------------- |
| `dev` | `next dev` | Development server | | `dev` | `next dev` | Development server |
| `build` | `next build` | Production build | | `build` | `next build` | Production build |
| `start` | `next start` | Production server | | `start` | `next start` | Production server |
| `test:api` | `vitest run` | Run API unit tests | | `test:api` | `vitest run` | Run API unit tests |
| `test:e2e` | `playwright test` | Run E2E tests | | `test:e2e` | `playwright test` | Run E2E tests |
| `test` | `bun run test:api && bun run test:e2e` | Run all tests | | `test` | `bun run test:api && bun run test:e2e` | Run all tests |
| `seed` | `bun run prisma/seed.ts` | Seed database | | `seed` | `bun run prisma/seed.ts` | Seed database |
| `prisma:generate` | `bunx prisma generate` | Generate Prisma client | | `prisma:generate` | `bunx prisma generate` | Generate Prisma client |
| `prisma:push` | `bunx prisma db push` | Push schema to database | | `prisma:push` | `bunx prisma db push` | Push schema to database |
| `prisma:studio` | `bunx prisma studio` | Open Prisma Studio GUI | | `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: ### Prisma Seed Configuration:
```json ```json
// package.json // package.json
{ {
@@ -771,35 +793,37 @@ Terdapat **3 workflow**:
File: `.env.example` File: `.env.example`
| Variable | Deskripsi | Contoh | | Variable | Deskripsi | Contoh |
|----------|-----------|--------| | ---------------------------- | ------------------------------------ | ------------------------------------------------------ |
| `DATABASE_URL` | PostgreSQL connection string | `postgresql://user:pass@localhost:5432/desa-darmasaba` | | `DATABASE_URL` | PostgreSQL connection string | `postgresql://user:pass@localhost:5432/desa-darmasaba` |
| `SEAFILE_TOKEN` | Seafile API token | `your_seafile_token` | | `SEAFILE_TOKEN` | Seafile API token | `your_seafile_token` |
| `SEAFILE_REPO_ID` | Seafile repository ID | `your_repo_id` | | `SEAFILE_REPO_ID` | Seafile repository ID | `your_repo_id` |
| `SEAFILE_URL` | Seafile instance URL | `https://seafile.example.com` | | `SEAFILE_URL` | Seafile instance URL | `https://seafile.example.com` |
| `SEAFILE_PUBLIC_SHARE_TOKEN` | Token untuk public share | `your_share_token` | | `SEAFILE_PUBLIC_SHARE_TOKEN` | Token untuk public share | `your_share_token` |
| `WIBU_UPLOAD_DIR` | Upload directory path | `uploads` | | `WIBU_UPLOAD_DIR` | Upload directory path | `uploads` |
| `WA_SERVER_TOKEN` | WhatsApp server token | `your_wa_token` | | `WA_SERVER_TOKEN` | WhatsApp server token | `your_wa_token` |
| `NEXT_PUBLIC_BASE_URL` | Base URL aplikasi | `/` (relative) | | `NEXT_PUBLIC_BASE_URL` | Base URL aplikasi | `/` (relative) |
| `EMAIL_USER` | Email untuk notifikasi | `your_email@gmail.com` | | `EMAIL_USER` | Email untuk notifikasi | `your_email@gmail.com` |
| `EMAIL_PASS` | Email app password | `your_app_password` | | `EMAIL_PASS` | Email app password | `your_app_password` |
| `BASE_TOKEN_KEY` | JWT secret key | `your_jwt_secret` | | `BASE_TOKEN_KEY` | JWT secret key | `your_jwt_secret` |
| `BOT_TOKEN` | Telegram bot token | `your_bot_token` | | `BOT_TOKEN` | Telegram bot token | `your_bot_token` |
| `CHAT_ID` | Telegram chat ID | `your_chat_id` | | `CHAT_ID` | Telegram chat ID | `your_chat_id` |
| `SESSION_PASSWORD` | iron-session password (min 32 chars) | `secure_32_char_password` | | `SESSION_PASSWORD` | iron-session password (min 32 chars) | `secure_32_char_password` |
| `ELEVENLABS_API_KEY` | ElevenLabs API (TTS - optional) | `your_elevenlabs_key` | | `ELEVENLABS_API_KEY` | ElevenLabs API (TTS - optional) | `your_elevenlabs_key` |
--- ---
## 15. Layanan Eksternal ## 15. Layanan Eksternal
### PostgreSQL ### PostgreSQL
- **Provider**: PostgreSQL via Prisma ORM - **Provider**: PostgreSQL via Prisma ORM
- **Schema**: `public` - **Schema**: `public`
- **Connection**: Via `DATABASE_URL` environment variable - **Connection**: Via `DATABASE_URL` environment variable
- **Migrations**: `prisma migrate deploy` di docker entrypoint - **Migrations**: `prisma migrate deploy` di docker entrypoint
### Seafile (File Storage) ### Seafile (File Storage)
- **Tipe**: Self-hosted file sync & share - **Tipe**: Self-hosted file sync & share
- **Penggunaan**: Storage untuk images, documents, audio files - **Penggunaan**: Storage untuk images, documents, audio files
- **Integrasi**: `src/lib/seafile-auth-service.ts` - **Integrasi**: `src/lib/seafile-auth-service.ts`
@@ -807,19 +831,23 @@ File: `.env.example`
- **Config**: Token, repo ID, base URL - **Config**: Token, repo ID, base URL
### WhatsApp Server ### WhatsApp Server
- **Penggunaan**: Kirim OTP codes saat login - **Penggunaan**: Kirim OTP codes saat login
- **Config**: `WA_SERVER_TOKEN` - **Config**: `WA_SERVER_TOKEN`
### Telegram Bot ### Telegram Bot
- **Penggunaan**: Notifikasi deployment & sistem - **Penggunaan**: Notifikasi deployment & sistem
- **Config**: `BOT_TOKEN` + `CHAT_ID` - **Config**: `BOT_TOKEN` + `CHAT_ID`
- **Integration**: `notify.sh` script di GitHub Actions - **Integration**: `notify.sh` script di GitHub Actions
### ElevenLabs (Optional) ### ElevenLabs (Optional)
- **Penggunaan**: Text-to-Speech (TTS) features - **Penggunaan**: Text-to-Speech (TTS) features
- **Config**: `ELEVENLABS_API_KEY` - **Config**: `ELEVENLABS_API_KEY`
### Email (Nodemailer) ### Email (Nodemailer)
- **Penggunaan**: Notifikasi email untuk subscription/pengumuman - **Penggunaan**: Notifikasi email untuk subscription/pengumuman
- **Config**: `EMAIL_USER` + `EMAIL_PASS` - **Config**: `EMAIL_USER` + `EMAIL_PASS`
- **Provider**: Gmail (app password) - **Provider**: Gmail (app password)
@@ -828,15 +856,15 @@ File: `.env.example`
## Ringkasan Cepat ## Ringkasan Cepat
| Aspek | Detail | | Aspek | Detail |
|-------|--------| | ------------- | ------------------------------------------ |
| **Framework** | Next.js 15 (App Router) + Elysia.js | | **Framework** | Next.js 15 (App Router) + Elysia.js |
| **Database** | PostgreSQL + Prisma (100+ models) | | **Database** | PostgreSQL + Prisma (100+ models) |
| **Auth** | OTP + iron-session + JWT | | **Auth** | OTP + iron-session + JWT |
| **Storage** | Seafile + local uploads | | **Storage** | Seafile + local uploads |
| **UI** | Mantine UI + Tiptap + Framer Motion | | **UI** | Mantine UI + Tiptap + Framer Motion |
| **State** | Jotai + Valtio + SWR | | **State** | Jotai + Valtio + SWR |
| **Deploy** | Docker + GHCR + Portainer + GitHub Actions | | **Deploy** | Docker + GHCR + Portainer + GitHub Actions |
| **Runtime** | Bun | | **Runtime** | Bun |
| **Testing** | Vitest + Playwright | | **Testing** | Vitest + Playwright |
| **Version** | 0.1.11 | | **Version** | 0.1.11 |

View File

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

View File

@@ -1,27 +1,34 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'; 'use client';
import balitaState from '@/app/admin/(dashboard)/_state/kesehatan/balita/balita';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { import {
ActionIcon,
Badge, Badge,
Box, Box,
Button, Button,
Center,
Group, Group,
Loader,
Pagination, Pagination,
Paper,
Select, Select,
Skeleton,
Stack, Stack,
Table, Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text, Text,
TextInput,
Title, Title,
} from '@mantine/core'; } 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 { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import balitaState from '../../../_state/kesehatan/balita/balita'; import HeaderSearch from '../../../_com/header';
const STUNTING_COLORS: Record<string, string> = { const STUNTING_COLORS: Record<string, string> = {
NORMAL: 'green', NORMAL: 'green',
@@ -29,161 +36,272 @@ const STUNTING_COLORS: Record<string, string> = {
STUNTING: 'red', STUNTING: 'red',
}; };
export default function BalitaPage() { function BalitaPage() {
const router = useRouter();
const state = useProxy(balitaState);
const [search, setSearch] = useState(''); 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 [statusFilter, setStatusFilter] = useState('');
const [debouncedSearch] = useDebouncedValue(search, 1000);
const { data, page, totalPages, loading, load } = state.findMany;
useEffect(() => { useEffect(() => {
state.findMany.load(1, 10, search, statusFilter); load(page, 10, debouncedSearch, statusFilter);
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, [page, debouncedSearch, statusFilter]);
const handleSearch = () => {
state.findMany.load(1, 10, search, statusFilter);
};
const handleDelete = async (id: string, nama: string) => { const handleDelete = async (id: string, nama: string) => {
if (!confirm(`Hapus data balita "${nama}"?`)) return; if (!confirm(`Hapus data balita "${nama}"?`)) return;
await state.delete.byId(id); await state.delete.byId(id);
}; };
const rows = state.findMany.data?.map((d) => ( const filteredData = data || [];
<Table.Tr key={d.id}>
<Table.Td>{d.nama}</Table.Td> if (loading || !data) {
<Table.Td>{d.jenisKelamin}</Table.Td> return (
<Table.Td> <Stack py="md">
{d.tanggalLahir <Skeleton h={600} radius="md" />
? new Date(d.tanggalLahir).toLocaleDateString('id-ID') </Stack>
: '-'} );
</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>
));
return ( return (
<Box px={{ base: 0, md: 'lg' }} py="xs"> <Box py="md">
<Group justify="space-between" mb="md"> <Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Title order={3} c="black">Balita Terdaftar</Title> <Group justify="space-between" mb="lg">
<Button <Title order={4}>List Balita</Title>
leftSection={<IconPlus size={16} />} <Button
onClick={() => router.push('/admin/kesehatan/posyandu/balita/create')} leftSection={<IconPlus size={18} />}
radius="md" color="blue"
style={{ variant="light"
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`, onClick={() => router.push('/admin/kesehatan/posyandu/balita/create')}
color: '#fff', >
}} Tambah Baru
> </Button>
Tambah </Group>
</Button>
</Group>
<Group mb="md" gap="sm"> <Group mb="md">
<TextInput <Select
placeholder="Cari nama / NIK / ortu..." placeholder="Filter stunting"
leftSection={<IconSearch size={16} />} data={[
value={search} { value: '', label: 'Semua' },
onChange={(e) => setSearch(e.currentTarget.value)} { value: 'NORMAL', label: 'Normal' },
onKeyDown={(e) => e.key === 'Enter' && handleSearch()} { value: 'ALERT', label: 'Alert' },
radius="md" { value: 'STUNTING', label: 'Stunting' },
style={{ flex: 1, maxWidth: 300 }} ]}
/> value={statusFilter}
<Select onChange={(v) => setStatusFilter(v ?? '')}
placeholder="Filter stunting" radius="md"
data={[ clearable
{ value: '', label: 'Semua' }, />
{ value: 'NORMAL', label: 'Normal' }, </Group>
{ value: 'ALERT', label: 'Alert' },
{ value: 'STUNTING', label: 'Stunting' },
]}
value={statusFilter}
onChange={(v) => {
setStatusFilter(v ?? '');
state.findMany.load(1, 10, search, v ?? '');
}}
radius="md"
clearable
/>
<Button onClick={handleSearch} radius="md" variant="light">Cari</Button>
</Group>
{state.findMany.loading ? ( {/* Desktop Table */}
<Group justify="center" py="xl"><Loader /></Group> <Box visibleFrom="md" style={{ overflowX: 'auto' }}>
) : ( <Table highlightOnHover layout="fixed" withColumnBorders={false}>
<Stack gap="md"> <TableThead>
<Table striped highlightOnHover withTableBorder> <TableTr>
<Table.Thead> <TableTh w="22%">Nama</TableTh>
<Table.Tr> <TableTh w="7%">JK</TableTh>
<Table.Th>Nama</Table.Th> <TableTh w="12%">Tgl Lahir</TableTh>
<Table.Th>JK</Table.Th> <TableTh w="12%">Imunisasi</TableTh>
<Table.Th>Tgl Lahir</Table.Th> <TableTh w="10%">Gizi</TableTh>
<Table.Th>Imunisasi</Table.Th> <TableTh w="12%">Pemeriksaan</TableTh>
<Table.Th>Gizi</Table.Th> <TableTh w="11%">Stunting</TableTh>
<Table.Th>Pemeriksaan</Table.Th> <TableTh w="14%">Aksi</TableTh>
<Table.Th>Stunting</Table.Th> </TableTr>
<Table.Th>Aksi</Table.Th> </TableThead>
</Table.Tr> <TableTbody>
</Table.Thead> {filteredData.length > 0 ? (
<Table.Tbody> filteredData.map((d) => (
{rows && rows.length > 0 ? rows : ( <TableTr key={d.id}>
<Table.Tr> <TableTd>{d.nama}</TableTd>
<Table.Td colSpan={8}> <TableTd>{d.jenisKelamin}</TableTd>
<Text c="dimmed" ta="center" py="md">Tidak ada data</Text> <TableTd>
</Table.Td> {d.tanggalLahir
</Table.Tr> ? 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>
))
) : (
<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> </Table>
</Box>
{(state.findMany.totalPages ?? 1) > 1 && ( {/* Mobile Cards */}
<Group justify="center"> <Stack gap="sm" hiddenFrom="md">
<Pagination {filteredData.length > 0 ? (
total={state.findMany.totalPages} filteredData.map((d) => (
value={state.findMany.page} <Paper key={d.id} withBorder p="md" radius="md">
onChange={(p) => state.findMany.load(p, 10, search, statusFilter)} <Box>
/> <Text fz="sm" fw={600} mb={4}>
</Group> {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> </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> </Box>
); );
} }
export default BalitaPage;

View File

@@ -1,27 +1,34 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'; 'use client';
import ibuHamilState from '@/app/admin/(dashboard)/_state/kesehatan/ibu-hamil/ibuHamil';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { import {
ActionIcon,
Badge, Badge,
Box, Box,
Button, Button,
Center,
Group, Group,
Loader,
Pagination, Pagination,
Paper,
Select, Select,
Skeleton,
Stack, Stack,
Table, Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text, Text,
TextInput,
Title, Title,
} from '@mantine/core'; } 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 { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import ibuHamilState from '../../../_state/kesehatan/ibu-hamil/ibuHamil'; import HeaderSearch from '../../../_com/header';
const STATUS_COLORS: Record<string, string> = { const STATUS_COLORS: Record<string, string> = {
AKTIF: 'green', AKTIF: 'green',
@@ -30,142 +37,242 @@ const STATUS_COLORS: Record<string, string> = {
NONAKTIF: 'red', NONAKTIF: 'red',
}; };
export default function IbuHamilPage() { function IbuHamilPage() {
const router = useRouter();
const state = useProxy(ibuHamilState);
const [search, setSearch] = useState(''); 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 [statusFilter, setStatusFilter] = useState('');
const [debouncedSearch] = useDebouncedValue(search, 1000);
const { data, page, totalPages, loading, load } = state.findMany;
useEffect(() => { useEffect(() => {
state.findMany.load(1, 10, search, statusFilter); load(page, 10, debouncedSearch, statusFilter);
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, [page, debouncedSearch, statusFilter]);
const handleSearch = () => {
state.findMany.load(1, 10, search, statusFilter);
};
const handleDelete = async (id: string, nama: string) => { const handleDelete = async (id: string, nama: string) => {
if (!confirm(`Hapus data ibu hamil "${nama}"?`)) return; if (!confirm(`Hapus data ibu hamil "${nama}"?`)) return;
await state.delete.byId(id); await state.delete.byId(id);
}; };
const rows = state.findMany.data?.map((d) => ( const filteredData = data || [];
<Table.Tr key={d.id}>
<Table.Td>{d.nama}</Table.Td> if (loading || !data) {
<Table.Td>{d.nik || '-'}</Table.Td> return (
<Table.Td>{d.usiaKehamilan} minggu</Table.Td> <Stack py="md">
<Table.Td>{d.noHp || '-'}</Table.Td> <Skeleton h={600} radius="md" />
<Table.Td> </Stack>
<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>
));
return ( return (
<Box px={{ base: 0, md: 'lg' }} py="xs"> <Box py="md">
<Group justify="space-between" mb="md"> <Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Title order={3} c="black">Ibu Hamil</Title> <Group justify="space-between" mb="lg">
<Button <Title order={4}>List Ibu Hamil</Title>
leftSection={<IconPlus size={16} />} <Button
onClick={() => router.push('/admin/kesehatan/posyandu/ibu-hamil/create')} leftSection={<IconPlus size={18} />}
radius="md" color="blue"
style={{ variant="light"
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`, onClick={() => router.push('/admin/kesehatan/posyandu/ibu-hamil/create')}
color: '#fff', >
}} Tambah Baru
> </Button>
Tambah </Group>
</Button>
</Group>
<Group mb="md" gap="sm"> <Group mb="md">
<TextInput <Select
placeholder="Cari nama / NIK..." placeholder="Filter status"
leftSection={<IconSearch size={16} />} data={[
value={search} { value: '', label: 'Semua Status' },
onChange={(e) => setSearch(e.currentTarget.value)} { value: 'AKTIF', label: 'Aktif' },
onKeyDown={(e) => e.key === 'Enter' && handleSearch()} { value: 'MELAHIRKAN', label: 'Melahirkan' },
radius="md" { value: 'KEGUGURAN', label: 'Keguguran' },
style={{ flex: 1, maxWidth: 300 }} { value: 'NONAKTIF', label: 'Nonaktif' },
/> ]}
<Select value={statusFilter}
placeholder="Filter status" onChange={(v) => setStatusFilter(v ?? '')}
data={[ radius="md"
{ value: '', label: 'Semua Status' }, clearable
{ value: 'AKTIF', label: 'Aktif' }, />
{ value: 'MELAHIRKAN', label: 'Melahirkan' }, </Group>
{ value: 'KEGUGURAN', label: 'Keguguran' },
{ value: 'NONAKTIF', label: 'Nonaktif' },
]}
value={statusFilter}
onChange={(v) => {
setStatusFilter(v ?? '');
state.findMany.load(1, 10, search, v ?? '');
}}
radius="md"
clearable
/>
<Button onClick={handleSearch} radius="md" variant="light">Cari</Button>
</Group>
{state.findMany.loading ? ( {/* Desktop Table */}
<Group justify="center" py="xl"><Loader /></Group> <Box visibleFrom="md" style={{ overflowX: 'auto' }}>
) : ( <Table highlightOnHover layout="fixed" withColumnBorders={false}>
<Stack gap="md"> <TableThead>
<Table striped highlightOnHover withTableBorder> <TableTr>
<Table.Thead> <TableTh w="25%">Nama</TableTh>
<Table.Tr> <TableTh w="18%">NIK</TableTh>
<Table.Th>Nama</Table.Th> <TableTh w="17%">Usia Kehamilan</TableTh>
<Table.Th>NIK</Table.Th> <TableTh w="15%">No. HP</TableTh>
<Table.Th>Usia Kehamilan</Table.Th> <TableTh w="12%">Status</TableTh>
<Table.Th>No. HP</Table.Th> <TableTh w="13%">Aksi</TableTh>
<Table.Th>Status</Table.Th> </TableTr>
<Table.Th>Aksi</Table.Th> </TableThead>
</Table.Tr> <TableTbody>
</Table.Thead> {filteredData.length > 0 ? (
<Table.Tbody> filteredData.map((d) => (
{rows && rows.length > 0 ? rows : ( <TableTr key={d.id}>
<Table.Tr> <TableTd>{d.nama}</TableTd>
<Table.Td colSpan={6}> <TableTd>{d.nik || '-'}</TableTd>
<Text c="dimmed" ta="center" py="md">Tidak ada data</Text> <TableTd>{d.usiaKehamilan} minggu</TableTd>
</Table.Td> <TableTd>{d.noHp || '-'}</TableTd>
</Table.Tr> <TableTd>
<Badge
color={STATUS_COLORS[d.status] ?? 'gray'}
variant="light"
>
{d.status}
</Badge>
</TableTd>
<TableTd>
<Group gap="xs">
<Button
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() =>
router.push(`/admin/kesehatan/posyandu/ibu-hamil/edit/${d.id}`)
}
>
Edit
</Button>
<Button
size="xs"
radius="md"
variant="light"
color="red"
leftSection={<IconTrash size={16} />}
onClick={() => handleDelete(d.id, d.nama)}
loading={state.delete.loading}
>
Hapus
</Button>
</Group>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={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> </Table>
</Box>
{(state.findMany.totalPages ?? 1) > 1 && ( {/* Mobile Cards */}
<Group justify="center"> <Stack gap="sm" hiddenFrom="md">
<Pagination {filteredData.length > 0 ? (
total={state.findMany.totalPages} filteredData.map((d) => (
value={state.findMany.page} <Paper key={d.id} withBorder p="md" radius="md">
onChange={(p) => state.findMany.load(p, 10, search, statusFilter)} <Box>
/> <Text fz="sm" fw={600} mb={4}>
</Group> {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> </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> </Box>
); );
} }
export default IbuHamilPage;

View File

@@ -37,7 +37,7 @@ import { getNavbar } from "./(dashboard)/user&role/_com/dynamicNavbar";
export default function Layout({ children }: { children: React.ReactNode }) { export default function Layout({ children }: { children: React.ReactNode }) {
const { isDark } = useDarkMode(); const { isDark } = useDarkMode();
const tokens = themeTokens(isDark); const tokens = themeTokens(isDark);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [opened, { toggle, close }] = useDisclosure(); const [opened, { toggle, close }] = useDisclosure();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -114,7 +114,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
case 2: case 2:
return '/admin/landing-page/profil/program-inovasi'; return '/admin/landing-page/profil/program-inovasi';
case 3: case 3:
return '/admin/kesehatan/posyandu'; return '/admin/kesehatan/posyandu/list-posyandu';
case 4: case 4:
return '/admin/pendidikan/info-sekolah/jenjang-pendidikan'; return '/admin/pendidikan/info-sekolah/jenjang-pendidikan';
default: default:

View File

@@ -47,7 +47,7 @@ export default function WaitingRoom() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isRedirecting, setIsRedirecting] = useState(false); const [isRedirecting, setIsRedirecting] = useState(false);
const [retryCount, setRetryCount] = useState(0); const [retryCount, setRetryCount] = useState(0);
// ⏱️ Countdown timer // ⏱️ Countdown timer
const [timeLeft, setTimeLeft] = useState(CONFIG.TIMEOUT_DURATION / 1000); // dalam detik const [timeLeft, setTimeLeft] = useState(CONFIG.TIMEOUT_DURATION / 1000); // dalam detik
const [hasTimedOut, setHasTimedOut] = useState(false); const [hasTimedOut, setHasTimedOut] = useState(false);
@@ -128,7 +128,7 @@ export default function WaitingRoom() {
redirectPath = '/admin/landing-page/profil/program-inovasi'; redirectPath = '/admin/landing-page/profil/program-inovasi';
break; break;
case "3": case "3":
redirectPath = '/admin/kesehatan/posyandu'; redirectPath = '/admin/kesehatan/posyandu/list-posyandu';
break; break;
case "4": case "4":
redirectPath = '/admin/pendidikan/info-sekolah/jenjang-pendidikan'; redirectPath = '/admin/pendidikan/info-sekolah/jenjang-pendidikan';
@@ -200,9 +200,9 @@ export default function WaitingRoom() {
Silakan hubungi Superadmin atau coba login ulang nanti. Silakan hubungi Superadmin atau coba login ulang nanti.
</Text> </Text>
<Group gap="sm" w="100%"> <Group gap="sm" w="100%">
<Button <Button
fullWidth fullWidth
variant="outline" variant="outline"
onClick={handleLogout} onClick={handleLogout}
> >
Kembali ke Login Kembali ke Login
@@ -243,11 +243,11 @@ export default function WaitingRoom() {
<Title order={2} c={colors['blue-button']} ta="center"> <Title order={2} c={colors['blue-button']} ta="center">
Menunggu Persetujuan Menunggu Persetujuan
</Title> </Title>
<Text ta="center" c="dimmed"> <Text ta="center" c="dimmed">
Akun Anda sedang dalam proses verifikasi oleh Superadmin. Akun Anda sedang dalam proses verifikasi oleh Superadmin.
</Text> </Text>
<Text ta="center" size="sm" fw={500}> <Text ta="center" size="sm" fw={500}>
Nomor: {user?.nomor || '...'} Nomor: {user?.nomor || '...'}
</Text> </Text>
@@ -260,8 +260,8 @@ export default function WaitingRoom() {
{formatTime(timeLeft)} {formatTime(timeLeft)}
</Text> </Text>
</Group> </Group>
<Progress <Progress
value={progressValue} value={progressValue}
color={timeLeft < 60 ? 'red' : colors['blue-button']} color={timeLeft < 60 ? 'red' : colors['blue-button']}
size="sm" size="sm"
animated animated
@@ -269,15 +269,15 @@ export default function WaitingRoom() {
</Stack> </Stack>
<Loader size="sm" color={colors['blue-button']} /> <Loader size="sm" color={colors['blue-button']} />
<Text ta="center" size="xs" c="dimmed"> <Text ta="center" size="xs" c="dimmed">
Jangan tutup halaman ini. Anda akan dialihkan otomatis setelah disetujui. Jangan tutup halaman ini. Anda akan dialihkan otomatis setelah disetujui.
</Text> </Text>
{/* 🚪 Tombol Keluar */} {/* 🚪 Tombol Keluar */}
<Button <Button
variant="subtle" variant="subtle"
size="xs" size="xs"
onClick={handleLogout} onClick={handleLogout}
c="dimmed" c="dimmed"
> >