Compare commits

...

6 Commits

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 14:19:27 +08:00
3cc30bf0ff docs(mind): update AI collaboration contract plan and summary 2026-05-04 14:13:54 +08:00
35 changed files with 3004 additions and 13 deletions

View File

@@ -6,6 +6,6 @@
- [x] Fix `KegiatanCard.tsx` image rendering logic
- [x] Bump version in `package.json` to 0.1.48
- [x] Verify build successful
- [ ] Commit changes
- [ ] Create branch and push
- [ ] Merge to `stg`
- [x] Commit changes
- [x] Create branch and push
- [x] Merge to `stg`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1159,6 +1159,8 @@ model Posyandu {
jadwalPelayanan String
image FileStorage? @relation(fields: [imageId], references: [id])
imageId String?
ibuHamil IbuHamil[]
balita Balita[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
@@ -2481,14 +2483,79 @@ model BeasiswaConfig {
isActive Boolean @default(true)
}
// ========================================= RINGKASAN KESEHATAN DESA ========================================= //
model RingkasanKesehatanDesa {
id String @id @default(cuid())
ibuHamilAkh Int @default(0)
balitaTerdaftar Int @default(0)
alertStunting Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
isActive Boolean @default(true)
// ========================================= IBU HAMIL ========================================= //
enum IbuHamilStatus {
AKTIF
MELAHIRKAN
KEGUGURAN
NONAKTIF
}
model IbuHamil {
id String @id @default(cuid())
nama String
nik String?
usiaKehamilan Int @default(0)
hpht DateTime?
taksiranLahir DateTime?
alamat String?
noHp String?
catatan String?
posyanduId String?
posyandu Posyandu? @relation(fields: [posyanduId], references: [id], onDelete: SetNull)
status IbuHamilStatus @default(AKTIF)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
isActive Boolean @default(true)
}
// ========================================= BALITA ========================================= //
enum JenisKelaminBalita {
L
P
}
enum StatusStunting {
NORMAL
ALERT
STUNTING
}
model Balita {
id String @id @default(cuid())
nama String
nik String?
tanggalLahir DateTime
jenisKelamin JenisKelaminBalita
beratBadanKg Float?
tinggiBadanCm Float?
namaOrtu String?
alamat String?
noHpOrtu String?
posyanduId String?
posyandu Posyandu? @relation(fields: [posyanduId], references: [id], onDelete: SetNull)
imunisasiLengkap Boolean @default(false)
giziBaik Boolean @default(true)
pemeriksaanRutin Boolean @default(true)
statusStunting StatusStunting @default(NORMAL)
catatan String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
isActive Boolean @default(true)
}
// ========================================= RINGKASAN KESEHATAN DESA ========================================= //
model RingkasanKesehatanDesa {
id String @id @default(cuid())
ibuHamilAkh Int @default(0)
balitaTerdaftar Int @default(0)
alertStunting Int @default(0)
imunisasiLengkapPct Int @default(0)
pemeriksaanRutinPct Int @default(0)
giziBaikPct Int @default(0)
targetStuntingPct Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
isActive Boolean @default(true)
}

View File

@@ -0,0 +1,218 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { JenisKelaminBalita, Prisma, StatusStunting } from "@prisma/client";
import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
const formSchema = z.object({
nama: z.string().min(1, { message: "Nama wajib diisi" }),
nik: z.string().optional(),
tanggalLahir: z.string().min(1, { message: "Tanggal lahir wajib diisi" }),
jenisKelamin: z.enum(["L", "P"]),
beratBadanKg: z.number().optional(),
tinggiBadanCm: z.number().optional(),
namaOrtu: z.string().optional(),
alamat: z.string().optional(),
noHpOrtu: z.string().optional(),
posyanduId: z.string().optional(),
imunisasiLengkap: z.boolean(),
giziBaik: z.boolean(),
pemeriksaanRutin: z.boolean(),
statusStunting: z.enum(["NORMAL", "ALERT", "STUNTING"]),
catatan: z.string().optional(),
});
const defaultForm = {
nama: "",
nik: "",
tanggalLahir: "",
jenisKelamin: "L" as JenisKelaminBalita,
beratBadanKg: undefined as number | undefined,
tinggiBadanCm: undefined as number | undefined,
namaOrtu: "",
alamat: "",
noHpOrtu: "",
posyanduId: "",
imunisasiLengkap: false,
giziBaik: true,
pemeriksaanRutin: true,
statusStunting: "NORMAL" as StatusStunting,
catatan: "",
};
const balitaState = proxy({
create: {
form: { ...defaultForm },
loading: false,
async submit() {
const cek = formSchema.safeParse(balitaState.create.form);
if (!cek.success) {
const err = cek.error.issues.map((v) => v.message).join(", ");
toast.error(err);
return false;
}
try {
balitaState.create.loading = true;
const res = await fetch("/api/kesehatan/balita/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(cek.data),
});
const result = await res.json();
if (result.success) {
toast.success("Balita berhasil ditambahkan");
balitaState.findMany.load();
return true;
}
toast.error(result.message || "Gagal menambahkan data");
return false;
} catch (e) {
console.error(e);
toast.error("Terjadi kesalahan");
return false;
} finally {
balitaState.create.loading = false;
}
},
reset() {
balitaState.create.form = { ...defaultForm };
},
},
findMany: {
data: null as
| Prisma.BalitaGetPayload<{
include: { posyandu: { select: { id: true; name: true } } };
}>[]
| null,
page: 1,
totalPages: 1,
total: 0,
loading: false,
search: "",
statusStuntingFilter: "",
async load(page = 1, limit = 10, search = "", statusStuntingFilter = "") {
balitaState.findMany.loading = true;
balitaState.findMany.page = page;
balitaState.findMany.search = search;
balitaState.findMany.statusStuntingFilter = statusStuntingFilter;
try {
const query = new URLSearchParams({ page: String(page), limit: String(limit) });
if (search) query.set("search", search);
if (statusStuntingFilter) query.set("statusStunting", statusStuntingFilter);
const res = await fetch(`/api/kesehatan/balita/find-many?${query}`);
const result = await res.json();
if (result.success) {
balitaState.findMany.data = result.data ?? [];
balitaState.findMany.totalPages = result.totalPages ?? 1;
balitaState.findMany.total = result.total ?? 0;
} else {
balitaState.findMany.data = [];
}
} catch (e) {
console.error("balitaFindMany error:", e);
balitaState.findMany.data = [];
} finally {
balitaState.findMany.loading = false;
}
},
},
edit: {
id: "",
form: { ...defaultForm },
loading: false,
async load(id: string) {
try {
const res = await fetch(`/api/kesehatan/balita/${id}`);
const result = await res.json();
if (result.success) {
const d = result.data;
balitaState.edit.id = d.id;
balitaState.edit.form = {
nama: d.nama,
nik: d.nik ?? "",
tanggalLahir: d.tanggalLahir ? d.tanggalLahir.slice(0, 10) : "",
jenisKelamin: d.jenisKelamin,
beratBadanKg: d.beratBadanKg ?? undefined,
tinggiBadanCm: d.tinggiBadanCm ?? undefined,
namaOrtu: d.namaOrtu ?? "",
alamat: d.alamat ?? "",
noHpOrtu: d.noHpOrtu ?? "",
posyanduId: d.posyanduId ?? "",
imunisasiLengkap: d.imunisasiLengkap,
giziBaik: d.giziBaik,
pemeriksaanRutin: d.pemeriksaanRutin,
statusStunting: d.statusStunting,
catatan: d.catatan ?? "",
};
return d;
}
toast.error("Gagal memuat data");
return null;
} catch (e) {
console.error(e);
toast.error("Gagal memuat data");
return null;
}
},
async update() {
const cek = formSchema.safeParse(balitaState.edit.form);
if (!cek.success) {
const err = cek.error.issues.map((v) => v.message).join(", ");
toast.error(err);
return false;
}
try {
balitaState.edit.loading = true;
const res = await fetch(`/api/kesehatan/balita/${balitaState.edit.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(cek.data),
});
const result = await res.json();
if (result.success) {
toast.success("Data balita berhasil diperbarui");
balitaState.findMany.load();
return true;
}
toast.error(result.message || "Gagal memperbarui data");
return false;
} catch (e) {
console.error(e);
toast.error("Terjadi kesalahan");
return false;
} finally {
balitaState.edit.loading = false;
}
},
reset() {
balitaState.edit.id = "";
balitaState.edit.form = { ...defaultForm };
},
},
delete: {
loading: false,
async byId(id: string) {
try {
balitaState.delete.loading = true;
const res = await fetch(`/api/kesehatan/balita/del/${id}`, { method: "DELETE" });
const result = await res.json();
if (result.success) {
toast.success(result.message || "Data berhasil dihapus");
await balitaState.findMany.load();
} else {
toast.error(result.message || "Gagal menghapus data");
}
} catch (e) {
console.error(e);
toast.error("Terjadi kesalahan saat menghapus");
} finally {
balitaState.delete.loading = false;
}
},
},
});
export default balitaState;

View File

@@ -0,0 +1,203 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { IbuHamilStatus, Prisma } from "@prisma/client";
import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
const formSchema = z.object({
nama: z.string().min(1, { message: "Nama wajib diisi" }),
nik: z.string().optional(),
usiaKehamilan: z.number().min(0),
hpht: z.string().optional(),
taksiranLahir: z.string().optional(),
alamat: z.string().optional(),
noHp: z.string().optional(),
catatan: z.string().optional(),
posyanduId: z.string().optional(),
status: z.enum(["AKTIF", "MELAHIRKAN", "KEGUGURAN", "NONAKTIF"]),
});
const defaultForm = {
nama: "",
nik: "",
usiaKehamilan: 0,
hpht: "",
taksiranLahir: "",
alamat: "",
noHp: "",
catatan: "",
posyanduId: "",
status: "AKTIF" as IbuHamilStatus,
};
const ibuHamilState = proxy({
create: {
form: { ...defaultForm },
loading: false,
async submit() {
const cek = formSchema.safeParse(ibuHamilState.create.form);
if (!cek.success) {
const err = cek.error.issues.map((v) => v.message).join(", ");
toast.error(err);
return false;
}
try {
ibuHamilState.create.loading = true;
const res = await fetch("/api/kesehatan/ibuhamil/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(cek.data),
});
const result = await res.json();
if (result.success) {
toast.success("Ibu hamil berhasil ditambahkan");
ibuHamilState.findMany.load();
return true;
}
toast.error(result.message || "Gagal menambahkan data");
return false;
} catch (e) {
console.error(e);
toast.error("Terjadi kesalahan");
return false;
} finally {
ibuHamilState.create.loading = false;
}
},
reset() {
ibuHamilState.create.form = { ...defaultForm };
},
},
findMany: {
data: null as
| Prisma.IbuHamilGetPayload<{
include: { posyandu: { select: { id: true; name: true } } };
}>[]
| null,
page: 1,
totalPages: 1,
total: 0,
loading: false,
search: "",
statusFilter: "",
async load(page = 1, limit = 10, search = "", statusFilter = "") {
ibuHamilState.findMany.loading = true;
ibuHamilState.findMany.page = page;
ibuHamilState.findMany.search = search;
ibuHamilState.findMany.statusFilter = statusFilter;
try {
const query = new URLSearchParams({ page: String(page), limit: String(limit) });
if (search) query.set("search", search);
if (statusFilter) query.set("status", statusFilter);
const res = await fetch(`/api/kesehatan/ibuhamil/find-many?${query}`);
const result = await res.json();
if (result.success) {
ibuHamilState.findMany.data = result.data ?? [];
ibuHamilState.findMany.totalPages = result.totalPages ?? 1;
ibuHamilState.findMany.total = result.total ?? 0;
} else {
ibuHamilState.findMany.data = [];
}
} catch (e) {
console.error("ibuHamilFindMany error:", e);
ibuHamilState.findMany.data = [];
} finally {
ibuHamilState.findMany.loading = false;
}
},
},
edit: {
id: "",
form: { ...defaultForm },
loading: false,
async load(id: string) {
try {
const res = await fetch(`/api/kesehatan/ibuhamil/${id}`);
const result = await res.json();
if (result.success) {
const d = result.data;
ibuHamilState.edit.id = d.id;
ibuHamilState.edit.form = {
nama: d.nama,
nik: d.nik ?? "",
usiaKehamilan: d.usiaKehamilan,
hpht: d.hpht ? d.hpht.slice(0, 10) : "",
taksiranLahir: d.taksiranLahir ? d.taksiranLahir.slice(0, 10) : "",
alamat: d.alamat ?? "",
noHp: d.noHp ?? "",
catatan: d.catatan ?? "",
posyanduId: d.posyanduId ?? "",
status: d.status,
};
return d;
}
toast.error("Gagal memuat data");
return null;
} catch (e) {
console.error(e);
toast.error("Gagal memuat data");
return null;
}
},
async update() {
const cek = formSchema.safeParse(ibuHamilState.edit.form);
if (!cek.success) {
const err = cek.error.issues.map((v) => v.message).join(", ");
toast.error(err);
return false;
}
try {
ibuHamilState.edit.loading = true;
const res = await fetch(`/api/kesehatan/ibuhamil/${ibuHamilState.edit.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(cek.data),
});
const result = await res.json();
if (result.success) {
toast.success("Ibu hamil berhasil diperbarui");
ibuHamilState.findMany.load();
return true;
}
toast.error(result.message || "Gagal memperbarui data");
return false;
} catch (e) {
console.error(e);
toast.error("Terjadi kesalahan");
return false;
} finally {
ibuHamilState.edit.loading = false;
}
},
reset() {
ibuHamilState.edit.id = "";
ibuHamilState.edit.form = { ...defaultForm };
},
},
delete: {
loading: false,
async byId(id: string) {
try {
ibuHamilState.delete.loading = true;
const res = await fetch(`/api/kesehatan/ibuhamil/del/${id}`, { method: "DELETE" });
const result = await res.json();
if (result.success) {
toast.success(result.message || "Data berhasil dihapus");
await ibuHamilState.findMany.load();
} else {
toast.error(result.message || "Gagal menghapus data");
}
} catch (e) {
console.error(e);
toast.error("Terjadi kesalahan saat menghapus");
} finally {
ibuHamilState.delete.loading = false;
}
},
},
});
export default ibuHamilState;

View File

@@ -0,0 +1,179 @@
import { Prisma } from "@prisma/client";
import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
type StatsData = {
ibuHamilAktif: number;
balitaTerdaftar: number;
alertStunting: number;
imunisasiLengkapPct: number;
pemeriksaanRutinPct: number;
giziBaikPct: number;
targetStuntingPct: number;
};
const intPct = z
.number({ invalid_type_error: "Harus berupa angka" })
.int({ message: "Harus bilangan bulat" })
.min(0, { message: "Minimal 0" })
.max(100, { message: "Maksimal 100" });
const intCount = z
.number({ invalid_type_error: "Harus berupa angka" })
.int({ message: "Harus bilangan bulat" })
.min(0, { message: "Minimal 0" });
const templateForm = z.object({
ibuHamilAkh: intCount,
balitaTerdaftar: intCount,
alertStunting: intCount,
imunisasiLengkapPct: intPct,
pemeriksaanRutinPct: intPct,
giziBaikPct: intPct,
targetStuntingPct: intPct,
});
const defaultForm = {
ibuHamilAkh: 0,
balitaTerdaftar: 0,
alertStunting: 0,
imunisasiLengkapPct: 0,
pemeriksaanRutinPct: 0,
giziBaikPct: 0,
targetStuntingPct: 0,
};
const ringkasanKesehatanState = proxy({
// Derived stats aggregated from IbuHamil + Balita tables
findStats: {
data: null as StatsData | null,
loading: false,
async load() {
try {
ringkasanKesehatanState.findStats.loading = true;
const res = await fetch(`/api/kesehatan/ringkasankesehatan/stats`);
if (res.ok) {
const result = await res.json();
ringkasanKesehatanState.findStats.data = result?.data ?? null;
if (result?.data) {
ringkasanKesehatanState.update.form.targetStuntingPct = result.data.targetStuntingPct;
}
} else {
ringkasanKesehatanState.findStats.data = null;
}
} catch (error) {
console.error("Error fetching ringkasan stats:", error);
ringkasanKesehatanState.findStats.data = null;
} finally {
ringkasanKesehatanState.findStats.loading = false;
}
},
},
// Kept for backward-compat — now only used internally for targetStuntingPct config
findUnique: {
data: null as Prisma.RingkasanKesehatanDesaGetPayload<object> | null,
loading: false,
async load() {
try {
ringkasanKesehatanState.findUnique.loading = true;
const res = await fetch(`/api/kesehatan/ringkasankesehatan/find`);
if (res.ok) {
const result = await res.json();
ringkasanKesehatanState.findUnique.data = result?.data ?? null;
if (result?.data) {
ringkasanKesehatanState.update.form = {
ibuHamilAkh: result.data.ibuHamilAkh,
balitaTerdaftar: result.data.balitaTerdaftar,
alertStunting: result.data.alertStunting,
imunisasiLengkapPct: result.data.imunisasiLengkapPct,
pemeriksaanRutinPct: result.data.pemeriksaanRutinPct,
giziBaikPct: result.data.giziBaikPct,
targetStuntingPct: result.data.targetStuntingPct,
};
}
} else {
ringkasanKesehatanState.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching ringkasan kesehatan:", error);
ringkasanKesehatanState.findUnique.data = null;
} finally {
ringkasanKesehatanState.findUnique.loading = false;
}
},
},
update: {
form: { ...defaultForm },
loading: false,
async submitTarget() {
const pct = ringkasanKesehatanState.update.form.targetStuntingPct;
const cek = intPct.safeParse(pct);
if (!cek.success) {
toast.error("Target stunting harus 0-100");
return false;
}
try {
ringkasanKesehatanState.update.loading = true;
const response = await fetch(`/api/kesehatan/ringkasankesehatan/update`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(ringkasanKesehatanState.update.form),
});
const result = await response.json();
if (result.success) {
toast.success("Target stunting berhasil disimpan");
await ringkasanKesehatanState.findStats.load();
return true;
}
toast.error(result.message || "Gagal menyimpan");
return false;
} catch (error) {
console.error("Error saving target stunting:", error);
toast.error("Gagal menyimpan target stunting");
return false;
} finally {
ringkasanKesehatanState.update.loading = false;
}
},
// Kept for backward-compat (full update)
async submit() {
const cek = templateForm.safeParse(ringkasanKesehatanState.update.form);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] invalid`;
toast.error(err);
return false;
}
try {
ringkasanKesehatanState.update.loading = true;
const response = await fetch(`/api/kesehatan/ringkasankesehatan/update`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(ringkasanKesehatanState.update.form),
});
const result = await response.json();
if (result.success) {
toast.success(result.message || "Berhasil disimpan");
await ringkasanKesehatanState.findUnique.load();
return true;
}
throw new Error(result.message || "Gagal menyimpan");
} catch (error) {
console.error("Error updating ringkasan kesehatan:", error);
toast.error(error instanceof Error ? error.message : "Gagal menyimpan");
return false;
} finally {
ringkasanKesehatanState.update.loading = false;
}
},
reset() {
ringkasanKesehatanState.update.form = { ...defaultForm };
},
},
});
export default ringkasanKesehatanState;

View File

@@ -0,0 +1,186 @@
'use client';
import colors from '@/con/colors';
import {
Box,
Button,
Checkbox,
Group,
Loader,
NumberInput,
Paper,
Select,
SimpleGrid,
Stack,
Textarea,
TextInput,
Title,
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useProxy } from 'valtio/utils';
import balitaState from '../../../_state/kesehatan/balita/balita';
export default function BalitaCreatePage() {
const router = useRouter();
const state = useProxy(balitaState);
const form = state.create.form;
const handleSubmit = async () => {
const ok = await state.create.submit();
if (ok) {
state.create.reset();
router.push('/admin/kesehatan/balita');
}
};
return (
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Group mb="md" gap="sm">
<Button variant="subtle" onClick={() => router.back()} radius="md">
<IconArrowBack size={20} />
</Button>
<Title order={3} c="black">Tambah Balita</Title>
</Group>
<Paper withBorder w={{ base: '100%', md: '80%' }} p="lg" radius="md" shadow="xl" bg="white">
<Stack gap="md">
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
<TextInput
label="Nama Lengkap"
required
placeholder="Nama balita"
value={form.nama}
onChange={(e) => { form.nama = e.currentTarget.value; }}
radius="md"
/>
<TextInput
label="NIK"
placeholder="Nomor Induk Kependudukan"
value={form.nik}
onChange={(e) => { form.nik = e.currentTarget.value; }}
radius="md"
/>
<TextInput
label="Tanggal Lahir"
required
type="date"
value={form.tanggalLahir}
onChange={(e) => { form.tanggalLahir = e.currentTarget.value; }}
radius="md"
/>
<Select
label="Jenis Kelamin"
required
data={[
{ value: 'L', label: 'Laki-laki' },
{ value: 'P', label: 'Perempuan' },
]}
value={form.jenisKelamin}
onChange={(v) => { if (v) form.jenisKelamin = v as typeof form.jenisKelamin; }}
radius="md"
/>
<NumberInput
label="Berat Badan (kg)"
placeholder="0.0"
min={0}
decimalScale={1}
value={form.beratBadanKg ?? ''}
onChange={(v) => { form.beratBadanKg = v === '' ? undefined : Number(v); }}
radius="md"
/>
<NumberInput
label="Tinggi Badan (cm)"
placeholder="0.0"
min={0}
decimalScale={1}
value={form.tinggiBadanCm ?? ''}
onChange={(v) => { form.tinggiBadanCm = v === '' ? undefined : Number(v); }}
radius="md"
/>
<TextInput
label="Nama Orang Tua"
placeholder="Nama ayah/ibu"
value={form.namaOrtu}
onChange={(e) => { form.namaOrtu = e.currentTarget.value; }}
radius="md"
/>
<TextInput
label="No. HP Orang Tua"
placeholder="08xx-xxxx-xxxx"
value={form.noHpOrtu}
onChange={(e) => { form.noHpOrtu = e.currentTarget.value; }}
radius="md"
/>
<Select
label="Status Stunting"
required
data={[
{ value: 'NORMAL', label: 'Normal' },
{ value: 'ALERT', label: 'Alert (Berisiko)' },
{ value: 'STUNTING', label: 'Stunting' },
]}
value={form.statusStunting}
onChange={(v) => { if (v) form.statusStunting = v as typeof form.statusStunting; }}
radius="md"
/>
</SimpleGrid>
<TextInput
label="Alamat"
placeholder="Alamat lengkap"
value={form.alamat}
onChange={(e) => { form.alamat = e.currentTarget.value; }}
radius="md"
/>
<Group gap="xl">
<Checkbox
label="Imunisasi Lengkap"
checked={form.imunisasiLengkap}
onChange={(e) => { form.imunisasiLengkap = e.currentTarget.checked; }}
/>
<Checkbox
label="Gizi Baik"
checked={form.giziBaik}
onChange={(e) => { form.giziBaik = e.currentTarget.checked; }}
/>
<Checkbox
label="Pemeriksaan Rutin"
checked={form.pemeriksaanRutin}
onChange={(e) => { form.pemeriksaanRutin = e.currentTarget.checked; }}
/>
</Group>
<Textarea
label="Catatan"
placeholder="Catatan tambahan"
value={form.catatan}
onChange={(e) => { form.catatan = e.currentTarget.value; }}
radius="md"
rows={3}
/>
<Group justify="right">
<Button variant="outline" color="gray" radius="md" onClick={() => router.back()}>
Batal
</Button>
<Button
onClick={handleSubmit}
radius="md"
disabled={state.create.loading}
style={{
background: state.create.loading
? 'linear-gradient(135deg, #ccc, #eee)'
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
}}
>
{state.create.loading ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}

View File

@@ -0,0 +1,190 @@
'use client';
import colors from '@/con/colors';
import {
Box,
Button,
Checkbox,
Group,
Loader,
NumberInput,
Paper,
Select,
SimpleGrid,
Stack,
Textarea,
TextInput,
Title,
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { useProxy } from 'valtio/utils';
import balitaState from '../../../../_state/kesehatan/balita/balita';
export default function BalitaEditPage() {
const router = useRouter();
const params = useParams();
const id = params.id as string;
const state = useProxy(balitaState);
const form = state.edit.form;
useEffect(() => {
if (id) state.edit.load(id);
}, [id]); // eslint-disable-line react-hooks/exhaustive-deps
const handleSubmit = async () => {
const ok = await state.edit.update();
if (ok) router.push('/admin/kesehatan/balita');
};
return (
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Group mb="md" gap="sm">
<Button variant="subtle" onClick={() => router.back()} radius="md">
<IconArrowBack size={20} />
</Button>
<Title order={3} c="black">Edit Balita</Title>
</Group>
<Paper withBorder w={{ base: '100%', md: '80%' }} p="lg" radius="md" shadow="xl" bg="white">
<Stack gap="md">
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
<TextInput
label="Nama Lengkap"
required
placeholder="Nama balita"
value={form.nama}
onChange={(e) => { form.nama = e.currentTarget.value; }}
radius="md"
/>
<TextInput
label="NIK"
placeholder="Nomor Induk Kependudukan"
value={form.nik}
onChange={(e) => { form.nik = e.currentTarget.value; }}
radius="md"
/>
<TextInput
label="Tanggal Lahir"
required
type="date"
value={form.tanggalLahir}
onChange={(e) => { form.tanggalLahir = e.currentTarget.value; }}
radius="md"
/>
<Select
label="Jenis Kelamin"
required
data={[
{ value: 'L', label: 'Laki-laki' },
{ value: 'P', label: 'Perempuan' },
]}
value={form.jenisKelamin}
onChange={(v) => { if (v) form.jenisKelamin = v as typeof form.jenisKelamin; }}
radius="md"
/>
<NumberInput
label="Berat Badan (kg)"
placeholder="0.0"
min={0}
decimalScale={1}
value={form.beratBadanKg ?? ''}
onChange={(v) => { form.beratBadanKg = v === '' ? undefined : Number(v); }}
radius="md"
/>
<NumberInput
label="Tinggi Badan (cm)"
placeholder="0.0"
min={0}
decimalScale={1}
value={form.tinggiBadanCm ?? ''}
onChange={(v) => { form.tinggiBadanCm = v === '' ? undefined : Number(v); }}
radius="md"
/>
<TextInput
label="Nama Orang Tua"
placeholder="Nama ayah/ibu"
value={form.namaOrtu}
onChange={(e) => { form.namaOrtu = e.currentTarget.value; }}
radius="md"
/>
<TextInput
label="No. HP Orang Tua"
placeholder="08xx-xxxx-xxxx"
value={form.noHpOrtu}
onChange={(e) => { form.noHpOrtu = e.currentTarget.value; }}
radius="md"
/>
<Select
label="Status Stunting"
required
data={[
{ value: 'NORMAL', label: 'Normal' },
{ value: 'ALERT', label: 'Alert (Berisiko)' },
{ value: 'STUNTING', label: 'Stunting' },
]}
value={form.statusStunting}
onChange={(v) => { if (v) form.statusStunting = v as typeof form.statusStunting; }}
radius="md"
/>
</SimpleGrid>
<TextInput
label="Alamat"
placeholder="Alamat lengkap"
value={form.alamat}
onChange={(e) => { form.alamat = e.currentTarget.value; }}
radius="md"
/>
<Group gap="xl">
<Checkbox
label="Imunisasi Lengkap"
checked={form.imunisasiLengkap}
onChange={(e) => { form.imunisasiLengkap = e.currentTarget.checked; }}
/>
<Checkbox
label="Gizi Baik"
checked={form.giziBaik}
onChange={(e) => { form.giziBaik = e.currentTarget.checked; }}
/>
<Checkbox
label="Pemeriksaan Rutin"
checked={form.pemeriksaanRutin}
onChange={(e) => { form.pemeriksaanRutin = e.currentTarget.checked; }}
/>
</Group>
<Textarea
label="Catatan"
placeholder="Catatan tambahan"
value={form.catatan}
onChange={(e) => { form.catatan = e.currentTarget.value; }}
radius="md"
rows={3}
/>
<Group justify="right">
<Button variant="outline" color="gray" radius="md" onClick={() => router.back()}>
Batal
</Button>
<Button
onClick={handleSubmit}
radius="md"
disabled={state.edit.loading}
style={{
background: state.edit.loading
? 'linear-gradient(135deg, #ccc, #eee)'
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
}}
>
{state.edit.loading ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}

View File

@@ -0,0 +1,188 @@
'use client';
import colors from '@/con/colors';
import {
ActionIcon,
Badge,
Box,
Button,
Group,
Loader,
Pagination,
Select,
Stack,
Table,
Text,
TextInput,
Title,
} from '@mantine/core';
import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils';
import balitaState from '../../_state/kesehatan/balita/balita';
const STUNTING_COLORS: Record<string, string> = {
NORMAL: 'green',
ALERT: 'yellow',
STUNTING: 'red',
};
export default function BalitaPage() {
const router = useRouter();
const state = useProxy(balitaState);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('');
useEffect(() => {
state.findMany.load(1, 10, search, statusFilter);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleSearch = () => {
state.findMany.load(1, 10, search, statusFilter);
};
const handleDelete = async (id: string, nama: string) => {
if (!confirm(`Hapus data balita "${nama}"?`)) return;
await state.delete.byId(id);
};
const rows = state.findMany.data?.map((d) => (
<Table.Tr key={d.id}>
<Table.Td>{d.nama}</Table.Td>
<Table.Td>{d.jenisKelamin}</Table.Td>
<Table.Td>
{d.tanggalLahir
? new Date(d.tanggalLahir).toLocaleDateString('id-ID')
: '-'}
</Table.Td>
<Table.Td>
<Badge color={d.imunisasiLengkap ? 'green' : 'red'} variant="light">
{d.imunisasiLengkap ? 'Lengkap' : 'Belum'}
</Badge>
</Table.Td>
<Table.Td>
<Badge color={d.giziBaik ? 'green' : 'orange'} variant="light">
{d.giziBaik ? 'Baik' : 'Kurang'}
</Badge>
</Table.Td>
<Table.Td>
<Badge color={d.pemeriksaanRutin ? 'green' : 'orange'} variant="light">
{d.pemeriksaanRutin ? 'Rutin' : 'Tidak'}
</Badge>
</Table.Td>
<Table.Td>
<Badge color={STUNTING_COLORS[d.statusStunting] ?? 'gray'} variant="light">
{d.statusStunting}
</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs">
<ActionIcon
variant="light"
color="blue"
onClick={() => router.push(`/admin/kesehatan/balita/edit/${d.id}`)}
>
<IconEdit size={16} />
</ActionIcon>
<ActionIcon
variant="light"
color="red"
onClick={() => handleDelete(d.id, d.nama)}
loading={state.delete.loading}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
));
return (
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Group justify="space-between" mb="md">
<Title order={3} c="black">Balita Terdaftar</Title>
<Button
leftSection={<IconPlus size={16} />}
onClick={() => router.push('/admin/kesehatan/balita/create')}
radius="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
}}
>
Tambah
</Button>
</Group>
<Group mb="md" gap="sm">
<TextInput
placeholder="Cari nama / NIK / ortu..."
leftSection={<IconSearch size={16} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
radius="md"
style={{ flex: 1, maxWidth: 300 }}
/>
<Select
placeholder="Filter stunting"
data={[
{ value: '', label: 'Semua' },
{ value: 'NORMAL', label: 'Normal' },
{ value: 'ALERT', label: 'Alert' },
{ value: 'STUNTING', label: 'Stunting' },
]}
value={statusFilter}
onChange={(v) => {
setStatusFilter(v ?? '');
state.findMany.load(1, 10, search, v ?? '');
}}
radius="md"
clearable
/>
<Button onClick={handleSearch} radius="md" variant="light">Cari</Button>
</Group>
{state.findMany.loading ? (
<Group justify="center" py="xl"><Loader /></Group>
) : (
<Stack gap="md">
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Nama</Table.Th>
<Table.Th>JK</Table.Th>
<Table.Th>Tgl Lahir</Table.Th>
<Table.Th>Imunisasi</Table.Th>
<Table.Th>Gizi</Table.Th>
<Table.Th>Pemeriksaan</Table.Th>
<Table.Th>Stunting</Table.Th>
<Table.Th>Aksi</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows && rows.length > 0 ? rows : (
<Table.Tr>
<Table.Td colSpan={8}>
<Text c="dimmed" ta="center" py="md">Tidak ada data</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
{(state.findMany.totalPages ?? 1) > 1 && (
<Group justify="center">
<Pagination
total={state.findMany.totalPages}
value={state.findMany.page}
onChange={(p) => state.findMany.load(p, 10, search, statusFilter)}
/>
</Group>
)}
</Stack>
)}
</Box>
);
}

View File

@@ -0,0 +1,145 @@
'use client';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Select,
SimpleGrid,
Stack,
Textarea,
TextInput,
Title,
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useProxy } from 'valtio/utils';
import ibuHamilState from '../../../_state/kesehatan/ibu-hamil/ibuHamil';
export default function IbuHamilCreatePage() {
const router = useRouter();
const state = useProxy(ibuHamilState);
const form = state.create.form;
const handleSubmit = async () => {
const ok = await state.create.submit();
if (ok) {
state.create.reset();
router.push('/admin/kesehatan/ibu-hamil');
}
};
return (
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Group mb="md" gap="sm">
<Button variant="subtle" onClick={() => router.back()} radius="md">
<IconArrowBack size={20} />
</Button>
<Title order={3} c="black">Tambah Ibu Hamil</Title>
</Group>
<Paper withBorder w={{ base: '100%', md: '80%' }} p="lg" radius="md" shadow="xl" bg="white">
<Stack gap="md">
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
<TextInput
label="Nama Lengkap"
required
placeholder="Nama ibu hamil"
value={form.nama}
onChange={(e) => { form.nama = e.currentTarget.value; }}
radius="md"
/>
<TextInput
label="NIK"
placeholder="Nomor Induk Kependudukan"
value={form.nik}
onChange={(e) => { form.nik = e.currentTarget.value; }}
radius="md"
/>
<TextInput
label="Usia Kehamilan (minggu)"
type="number"
placeholder="0"
value={String(form.usiaKehamilan)}
onChange={(e) => { form.usiaKehamilan = Number(e.currentTarget.value) || 0; }}
radius="md"
/>
<TextInput
label="No. HP"
placeholder="08xx-xxxx-xxxx"
value={form.noHp}
onChange={(e) => { form.noHp = e.currentTarget.value; }}
radius="md"
/>
<TextInput
label="HPHT (Hari Pertama Haid Terakhir)"
type="date"
value={form.hpht}
onChange={(e) => { form.hpht = e.currentTarget.value; }}
radius="md"
/>
<TextInput
label="Taksiran Persalinan"
type="date"
value={form.taksiranLahir}
onChange={(e) => { form.taksiranLahir = e.currentTarget.value; }}
radius="md"
/>
<Select
label="Status"
required
data={[
{ value: 'AKTIF', label: 'Aktif' },
{ value: 'MELAHIRKAN', label: 'Melahirkan' },
{ value: 'KEGUGURAN', label: 'Keguguran' },
{ value: 'NONAKTIF', label: 'Nonaktif' },
]}
value={form.status}
onChange={(v) => { if (v) form.status = v as typeof form.status; }}
radius="md"
/>
</SimpleGrid>
<TextInput
label="Alamat"
placeholder="Alamat lengkap"
value={form.alamat}
onChange={(e) => { form.alamat = e.currentTarget.value; }}
radius="md"
/>
<Textarea
label="Catatan"
placeholder="Catatan tambahan"
value={form.catatan}
onChange={(e) => { form.catatan = e.currentTarget.value; }}
radius="md"
rows={3}
/>
<Group justify="right">
<Button variant="outline" color="gray" radius="md" onClick={() => router.back()}>
Batal
</Button>
<Button
onClick={handleSubmit}
radius="md"
disabled={state.create.loading}
style={{
background: state.create.loading
? 'linear-gradient(135deg, #ccc, #eee)'
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
}}
>
{state.create.loading ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}

View File

@@ -0,0 +1,149 @@
'use client';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Select,
SimpleGrid,
Stack,
Textarea,
TextInput,
Title,
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { useProxy } from 'valtio/utils';
import ibuHamilState from '../../../../_state/kesehatan/ibu-hamil/ibuHamil';
export default function IbuHamilEditPage() {
const router = useRouter();
const params = useParams();
const id = params.id as string;
const state = useProxy(ibuHamilState);
const form = state.edit.form;
useEffect(() => {
if (id) state.edit.load(id);
}, [id]); // eslint-disable-line react-hooks/exhaustive-deps
const handleSubmit = async () => {
const ok = await state.edit.update();
if (ok) router.push('/admin/kesehatan/ibu-hamil');
};
return (
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Group mb="md" gap="sm">
<Button variant="subtle" onClick={() => router.back()} radius="md">
<IconArrowBack size={20} />
</Button>
<Title order={3} c="black">Edit Ibu Hamil</Title>
</Group>
<Paper withBorder w={{ base: '100%', md: '80%' }} p="lg" radius="md" shadow="xl" bg="white">
<Stack gap="md">
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
<TextInput
label="Nama Lengkap"
required
placeholder="Nama ibu hamil"
value={form.nama}
onChange={(e) => { form.nama = e.currentTarget.value; }}
radius="md"
/>
<TextInput
label="NIK"
placeholder="Nomor Induk Kependudukan"
value={form.nik}
onChange={(e) => { form.nik = e.currentTarget.value; }}
radius="md"
/>
<TextInput
label="Usia Kehamilan (minggu)"
type="number"
placeholder="0"
value={String(form.usiaKehamilan)}
onChange={(e) => { form.usiaKehamilan = Number(e.currentTarget.value) || 0; }}
radius="md"
/>
<TextInput
label="No. HP"
placeholder="08xx-xxxx-xxxx"
value={form.noHp}
onChange={(e) => { form.noHp = e.currentTarget.value; }}
radius="md"
/>
<TextInput
label="HPHT"
type="date"
value={form.hpht}
onChange={(e) => { form.hpht = e.currentTarget.value; }}
radius="md"
/>
<TextInput
label="Taksiran Persalinan"
type="date"
value={form.taksiranLahir}
onChange={(e) => { form.taksiranLahir = e.currentTarget.value; }}
radius="md"
/>
<Select
label="Status"
required
data={[
{ value: 'AKTIF', label: 'Aktif' },
{ value: 'MELAHIRKAN', label: 'Melahirkan' },
{ value: 'KEGUGURAN', label: 'Keguguran' },
{ value: 'NONAKTIF', label: 'Nonaktif' },
]}
value={form.status}
onChange={(v) => { if (v) form.status = v as typeof form.status; }}
radius="md"
/>
</SimpleGrid>
<TextInput
label="Alamat"
placeholder="Alamat lengkap"
value={form.alamat}
onChange={(e) => { form.alamat = e.currentTarget.value; }}
radius="md"
/>
<Textarea
label="Catatan"
placeholder="Catatan tambahan"
value={form.catatan}
onChange={(e) => { form.catatan = e.currentTarget.value; }}
radius="md"
rows={3}
/>
<Group justify="right">
<Button variant="outline" color="gray" radius="md" onClick={() => router.back()}>
Batal
</Button>
<Button
onClick={handleSubmit}
radius="md"
disabled={state.edit.loading}
style={{
background: state.edit.loading
? 'linear-gradient(135deg, #ccc, #eee)'
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
}}
>
{state.edit.loading ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}

View File

@@ -0,0 +1,170 @@
'use client';
import colors from '@/con/colors';
import {
ActionIcon,
Badge,
Box,
Button,
Group,
Loader,
Pagination,
Select,
Stack,
Table,
Text,
TextInput,
Title,
} from '@mantine/core';
import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils';
import ibuHamilState from '../../_state/kesehatan/ibu-hamil/ibuHamil';
const STATUS_COLORS: Record<string, string> = {
AKTIF: 'green',
MELAHIRKAN: 'blue',
KEGUGURAN: 'gray',
NONAKTIF: 'red',
};
export default function IbuHamilPage() {
const router = useRouter();
const state = useProxy(ibuHamilState);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('');
useEffect(() => {
state.findMany.load(1, 10, search, statusFilter);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleSearch = () => {
state.findMany.load(1, 10, search, statusFilter);
};
const handleDelete = async (id: string, nama: string) => {
if (!confirm(`Hapus data ibu hamil "${nama}"?`)) return;
await state.delete.byId(id);
};
const rows = state.findMany.data?.map((d) => (
<Table.Tr key={d.id}>
<Table.Td>{d.nama}</Table.Td>
<Table.Td>{d.nik || '-'}</Table.Td>
<Table.Td>{d.usiaKehamilan} minggu</Table.Td>
<Table.Td>{d.noHp || '-'}</Table.Td>
<Table.Td>
<Badge color={STATUS_COLORS[d.status] ?? 'gray'} variant="light">
{d.status}
</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs">
<ActionIcon
variant="light"
color="blue"
onClick={() => router.push(`/admin/kesehatan/ibu-hamil/edit/${d.id}`)}
>
<IconEdit size={16} />
</ActionIcon>
<ActionIcon
variant="light"
color="red"
onClick={() => handleDelete(d.id, d.nama)}
loading={state.delete.loading}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
));
return (
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Group justify="space-between" mb="md">
<Title order={3} c="black">Ibu Hamil</Title>
<Button
leftSection={<IconPlus size={16} />}
onClick={() => router.push('/admin/kesehatan/ibu-hamil/create')}
radius="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
}}
>
Tambah
</Button>
</Group>
<Group mb="md" gap="sm">
<TextInput
placeholder="Cari nama / NIK..."
leftSection={<IconSearch size={16} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
radius="md"
style={{ flex: 1, maxWidth: 300 }}
/>
<Select
placeholder="Filter status"
data={[
{ value: '', label: 'Semua Status' },
{ value: 'AKTIF', label: 'Aktif' },
{ value: 'MELAHIRKAN', label: 'Melahirkan' },
{ value: 'KEGUGURAN', label: 'Keguguran' },
{ value: 'NONAKTIF', label: 'Nonaktif' },
]}
value={statusFilter}
onChange={(v) => {
setStatusFilter(v ?? '');
state.findMany.load(1, 10, search, v ?? '');
}}
radius="md"
clearable
/>
<Button onClick={handleSearch} radius="md" variant="light">Cari</Button>
</Group>
{state.findMany.loading ? (
<Group justify="center" py="xl"><Loader /></Group>
) : (
<Stack gap="md">
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Nama</Table.Th>
<Table.Th>NIK</Table.Th>
<Table.Th>Usia Kehamilan</Table.Th>
<Table.Th>No. HP</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Aksi</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows && rows.length > 0 ? rows : (
<Table.Tr>
<Table.Td colSpan={6}>
<Text c="dimmed" ta="center" py="md">Tidak ada data</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
{(state.findMany.totalPages ?? 1) > 1 && (
<Group justify="center">
<Pagination
total={state.findMany.totalPages}
value={state.findMany.page}
onChange={(p) => state.findMany.load(p, 10, search, statusFilter)}
/>
</Group>
)}
</Stack>
)}
</Box>
);
}

View File

@@ -0,0 +1,224 @@
'use client';
import colors from '@/con/colors';
import {
Box,
Button,
Card,
Divider,
Group,
Loader,
NumberInput,
Paper,
SimpleGrid,
Stack,
Text,
Title,
} from '@mantine/core';
import {
IconArrowRight,
IconMoodBoy,
IconHeartbeat,
IconPercentage,
IconUser,
IconAlertTriangle,
} from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { useProxy } from 'valtio/utils';
import ringkasanKesehatanState from '../../_state/kesehatan/ringkasan-kesehatan/ringkasanKesehatan';
type StatCardProps = {
label: string;
value: string | number;
icon: React.ReactNode;
color?: string;
suffix?: string;
};
function StatCard({ label, value, icon, color = 'blue', suffix }: StatCardProps) {
return (
<Card withBorder radius="md" p="md">
<Group gap="sm" align="flex-start">
<Box
style={{
width: 40,
height: 40,
borderRadius: 8,
background: `var(--mantine-color-${color}-1)`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: `var(--mantine-color-${color}-6)`,
flexShrink: 0,
}}
>
{icon}
</Box>
<Box>
<Text fz="xs" c="dimmed" fw={500}>{label}</Text>
<Text fz="xl" fw={700}>
{value}{suffix && <Text component="span" fz="sm" c="dimmed" fw={400}> {suffix}</Text>}
</Text>
</Box>
</Group>
</Card>
);
}
export default function RingkasanKesehatanPage() {
const router = useRouter();
const state = useProxy(ringkasanKesehatanState);
const stats = state.findStats.data;
useEffect(() => {
state.findStats.load();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleSaveTarget = async () => {
await state.update.submitTarget();
};
const isLoading = state.findStats.loading;
return (
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Title order={3} c="black" mb="md">Ringkasan Kesehatan Desa</Title>
{isLoading ? (
<Group justify="center" py="xl"><Loader /></Group>
) : (
<Stack gap="lg">
{/* KPI Utama */}
<Box>
<Text fw={600} mb="sm" c="dark">KPI Utama</Text>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
<StatCard
label="Ibu Hamil Aktif"
value={stats?.ibuHamilAktif ?? 0}
icon={<IconUser size={20} />}
color="pink"
suffix="orang"
/>
<StatCard
label="Balita Terdaftar"
value={stats?.balitaTerdaftar ?? 0}
icon={<IconMoodBoy size={20} />}
color="blue"
suffix="anak"
/>
<StatCard
label="Alert Stunting"
value={stats?.alertStunting ?? 0}
icon={<IconAlertTriangle size={20} />}
color="red"
suffix="anak"
/>
</SimpleGrid>
</Box>
<Divider />
{/* Statistik % */}
<Box>
<Text fw={600} mb="sm" c="dark">Statistik Kesehatan Balita</Text>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md">
<StatCard
label="Imunisasi Lengkap"
value={stats?.imunisasiLengkapPct ?? 0}
icon={<IconHeartbeat size={20} />}
color="teal"
suffix="%"
/>
<StatCard
label="Pemeriksaan Rutin"
value={stats?.pemeriksaanRutinPct ?? 0}
icon={<IconHeartbeat size={20} />}
color="green"
suffix="%"
/>
<StatCard
label="Gizi Baik"
value={stats?.giziBaikPct ?? 0}
icon={<IconHeartbeat size={20} />}
color="lime"
suffix="%"
/>
<StatCard
label="Target Penurunan Stunting"
value={stats?.targetStuntingPct ?? 0}
icon={<IconPercentage size={20} />}
color="orange"
suffix="%"
/>
</SimpleGrid>
</Box>
<Divider />
{/* Target Stunting Config */}
<Paper withBorder p="md" radius="md" maw={400}>
<Text fw={600} mb="sm" c="dark">Atur Target Stunting</Text>
<Text fz="xs" c="dimmed" mb="sm">
Target penurunan angka stunting adalah nilai kebijakan yang ditentukan
oleh admin, bukan turunan dari data.
</Text>
<Group align="flex-end" gap="sm">
<NumberInput
label="Target (%)"
min={0}
max={100}
suffix="%"
value={state.update.form.targetStuntingPct}
onChange={(v) => { state.update.form.targetStuntingPct = Number(v) || 0; }}
radius="md"
style={{ flex: 1 }}
/>
<Button
onClick={handleSaveTarget}
radius="md"
disabled={state.update.loading}
style={{
background: state.update.loading
? 'linear-gradient(135deg, #ccc, #eee)'
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
marginBottom: 1,
}}
>
{state.update.loading ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Paper>
<Divider />
{/* Kelola Data */}
<Box>
<Text fw={600} mb="sm" c="dark">Kelola Data</Text>
<Group gap="md">
<Button
variant="light"
color="pink"
radius="md"
rightSection={<IconArrowRight size={16} />}
onClick={() => router.push('/admin/kesehatan/ibu-hamil')}
>
Kelola Ibu Hamil
</Button>
<Button
variant="light"
color="blue"
radius="md"
rightSection={<IconArrowRight size={16} />}
onClick={() => router.push('/admin/kesehatan/balita')}
>
Kelola Balita
</Button>
</Group>
</Box>
</Stack>
)}
</Box>
);
}

View File

@@ -166,6 +166,21 @@ export const devBar = [
id: "Kesehatan_7",
name: "Info Wabah/Penyakit",
path: "/admin/kesehatan/info-wabah-penyakit"
},
{
id: "Kesehatan_8",
name: "Ringkasan Kesehatan",
path: "/admin/kesehatan/ringkasan-kesehatan"
},
{
id: "Kesehatan_9",
name: "Ibu Hamil",
path: "/admin/kesehatan/ibu-hamil"
},
{
id: "Kesehatan_10",
name: "Balita",
path: "/admin/kesehatan/balita"
}
]
},
@@ -602,6 +617,21 @@ export const navBar = [
id: "Kesehatan_7",
name: "Info Wabah/Penyakit",
path: "/admin/kesehatan/info-wabah-penyakit"
},
{
id: "Kesehatan_8",
name: "Ringkasan Kesehatan",
path: "/admin/kesehatan/ringkasan-kesehatan"
},
{
id: "Kesehatan_9",
name: "Ibu Hamil",
path: "/admin/kesehatan/ibu-hamil"
},
{
id: "Kesehatan_10",
name: "Balita",
path: "/admin/kesehatan/balita"
}
]
},
@@ -1272,6 +1302,21 @@ export const role2 = [
id: "Kesehatan_7",
name: "Info Wabah/Penyakit",
path: "/admin/kesehatan/info-wabah-penyakit"
},
{
id: "Kesehatan_8",
name: "Ringkasan Kesehatan",
path: "/admin/kesehatan/ringkasan-kesehatan"
},
{
id: "Kesehatan_9",
name: "Ibu Hamil",
path: "/admin/kesehatan/ibu-hamil"
},
{
id: "Kesehatan_10",
name: "Balita",
path: "/admin/kesehatan/balita"
}
]
},

View File

@@ -0,0 +1,47 @@
import prisma from "@/lib/prisma";
import { JenisKelaminBalita, StatusStunting } from "@prisma/client";
import { Context } from "elysia";
type FormCreate = {
nama: string;
nik?: string;
tanggalLahir: string;
jenisKelamin: JenisKelaminBalita;
beratBadanKg?: number;
tinggiBadanCm?: number;
namaOrtu?: string;
alamat?: string;
noHpOrtu?: string;
posyanduId?: string;
imunisasiLengkap: boolean;
giziBaik: boolean;
pemeriksaanRutin: boolean;
statusStunting: StatusStunting;
catatan?: string;
};
export default async function balitaCreate(context: Context) {
const body = context.body as FormCreate;
const data = await prisma.balita.create({
data: {
nama: body.nama,
nik: body.nik,
tanggalLahir: new Date(body.tanggalLahir),
jenisKelamin: body.jenisKelamin,
beratBadanKg: body.beratBadanKg,
tinggiBadanCm: body.tinggiBadanCm,
namaOrtu: body.namaOrtu,
alamat: body.alamat,
noHpOrtu: body.noHpOrtu,
posyanduId: body.posyanduId || null,
imunisasiLengkap: body.imunisasiLengkap,
giziBaik: body.giziBaik,
pemeriksaanRutin: body.pemeriksaanRutin,
statusStunting: body.statusStunting ?? "NORMAL",
catatan: body.catatan,
},
});
return { success: true, message: "Balita berhasil ditambahkan", data };
}

View File

@@ -0,0 +1,22 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function balitaDelete(context: Context) {
const id = context.params?.id as string;
if (!id) {
return { success: false, message: "ID tidak diberikan" };
}
const existing = await prisma.balita.findUnique({ where: { id } });
if (!existing) {
return new Response(JSON.stringify({ success: false, message: "Data tidak ditemukan" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
await prisma.balita.update({ where: { id }, data: { isActive: false } });
return { success: true, message: "Balita berhasil dihapus" };
}

View File

@@ -0,0 +1,38 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function balitaFindById(context: Context) {
const id = context.params?.id as string;
if (!id) {
return new Response(JSON.stringify({ success: false, message: "ID tidak boleh kosong" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
try {
const data = await prisma.balita.findUnique({
where: { id },
include: { posyandu: { select: { id: true, name: true } } },
});
if (!data) {
return new Response(JSON.stringify({ success: false, message: "Data tidak ditemukan" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ success: true, data }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("balitaFindById error:", error);
return new Response(JSON.stringify({ success: false, message: "Gagal mengambil data" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
}

View File

@@ -0,0 +1,52 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function balitaFindMany(context: Context) {
const page = Number(context.query.page) || 1;
const limit = Number(context.query.limit) || 10;
const search = (context.query.search as string) || "";
const statusStunting = (context.query.statusStunting as string) || "";
const skip = (page - 1) * limit;
const where: any = { isActive: true };
if (search) {
where.OR = [
{ nama: { contains: search, mode: "insensitive" } },
{ nik: { contains: search, mode: "insensitive" } },
{ namaOrtu: { contains: search, mode: "insensitive" } },
{ alamat: { contains: search, mode: "insensitive" } },
];
}
if (statusStunting) {
where.statusStunting = statusStunting;
}
try {
const [data, total] = await Promise.all([
prisma.balita.findMany({
where,
include: { posyandu: { select: { id: true, name: true } } },
skip,
take: limit,
orderBy: { createdAt: "desc" },
}),
prisma.balita.count({ where }),
]);
return {
success: true,
message: "Berhasil ambil data balita",
data,
page,
limit,
total,
totalPages: Math.ceil(total / limit),
};
} catch (e) {
console.error("Error balitaFindMany:", e);
return { success: false, message: "Gagal mengambil data balita" };
}
}

View File

@@ -0,0 +1,63 @@
import Elysia, { t } from "elysia";
import balitaCreate from "./create";
import balitaDelete from "./del";
import balitaFindById from "./find-by-id";
import balitaFindMany from "./find-many";
import balitaUpdate from "./updt";
const Balita = new Elysia({ prefix: "/balita", tags: ["Kesehatan/Balita"] })
.post("/create", balitaCreate, {
body: t.Object({
nama: t.String(),
nik: t.Optional(t.String()),
tanggalLahir: t.String(),
jenisKelamin: t.Union([t.Literal("L"), t.Literal("P")]),
beratBadanKg: t.Optional(t.Number()),
tinggiBadanCm: t.Optional(t.Number()),
namaOrtu: t.Optional(t.String()),
alamat: t.Optional(t.String()),
noHpOrtu: t.Optional(t.String()),
posyanduId: t.Optional(t.String()),
imunisasiLengkap: t.Boolean(),
giziBaik: t.Boolean(),
pemeriksaanRutin: t.Boolean(),
statusStunting: t.Union([
t.Literal("NORMAL"),
t.Literal("ALERT"),
t.Literal("STUNTING"),
]),
catatan: t.Optional(t.String()),
}),
})
.get("/find-many", balitaFindMany)
.delete("/del/:id", balitaDelete)
.get("/:id", balitaFindById)
.put(
"/:id",
balitaUpdate,
{
body: t.Object({
nama: t.String(),
nik: t.Optional(t.String()),
tanggalLahir: t.String(),
jenisKelamin: t.Union([t.Literal("L"), t.Literal("P")]),
beratBadanKg: t.Optional(t.Number()),
tinggiBadanCm: t.Optional(t.Number()),
namaOrtu: t.Optional(t.String()),
alamat: t.Optional(t.String()),
noHpOrtu: t.Optional(t.String()),
posyanduId: t.Optional(t.String()),
imunisasiLengkap: t.Boolean(),
giziBaik: t.Boolean(),
pemeriksaanRutin: t.Boolean(),
statusStunting: t.Union([
t.Literal("NORMAL"),
t.Literal("ALERT"),
t.Literal("STUNTING"),
]),
catatan: t.Optional(t.String()),
}),
}
);
export default Balita;

View File

@@ -0,0 +1,75 @@
import prisma from "@/lib/prisma";
import { JenisKelaminBalita, StatusStunting } from "@prisma/client";
import { Context } from "elysia";
type FormUpdate = {
nama: string;
nik?: string;
tanggalLahir: string;
jenisKelamin: JenisKelaminBalita;
beratBadanKg?: number;
tinggiBadanCm?: number;
namaOrtu?: string;
alamat?: string;
noHpOrtu?: string;
posyanduId?: string;
imunisasiLengkap: boolean;
giziBaik: boolean;
pemeriksaanRutin: boolean;
statusStunting: StatusStunting;
catatan?: string;
};
export default async function balitaUpdate(context: Context) {
const id = context.params?.id as string;
const body = context.body as FormUpdate;
if (!id) {
return new Response(JSON.stringify({ success: false, message: "ID tidak boleh kosong" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
try {
const existing = await prisma.balita.findUnique({ where: { id } });
if (!existing) {
return new Response(JSON.stringify({ success: false, message: "Data tidak ditemukan" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
const updated = await prisma.balita.update({
where: { id },
data: {
nama: body.nama,
nik: body.nik,
tanggalLahir: new Date(body.tanggalLahir),
jenisKelamin: body.jenisKelamin,
beratBadanKg: body.beratBadanKg,
tinggiBadanCm: body.tinggiBadanCm,
namaOrtu: body.namaOrtu,
alamat: body.alamat,
noHpOrtu: body.noHpOrtu,
posyanduId: body.posyanduId || null,
imunisasiLengkap: body.imunisasiLengkap,
giziBaik: body.giziBaik,
pemeriksaanRutin: body.pemeriksaanRutin,
statusStunting: body.statusStunting,
catatan: body.catatan,
},
});
return new Response(JSON.stringify({ success: true, message: "Balita berhasil diperbarui", data: updated }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("balitaUpdate error:", error);
return new Response(JSON.stringify({ success: false, message: "Gagal memperbarui data" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
}

View File

@@ -0,0 +1,37 @@
import prisma from "@/lib/prisma";
import { IbuHamilStatus } from "@prisma/client";
import { Context } from "elysia";
type FormCreate = {
nama: string;
nik?: string;
usiaKehamilan: number;
hpht?: string;
taksiranLahir?: string;
alamat?: string;
noHp?: string;
catatan?: string;
posyanduId?: string;
status: IbuHamilStatus;
};
export default async function ibuHamilCreate(context: Context) {
const body = context.body as FormCreate;
const data = await prisma.ibuHamil.create({
data: {
nama: body.nama,
nik: body.nik,
usiaKehamilan: body.usiaKehamilan ?? 0,
hpht: body.hpht ? new Date(body.hpht) : undefined,
taksiranLahir: body.taksiranLahir ? new Date(body.taksiranLahir) : undefined,
alamat: body.alamat,
noHp: body.noHp,
catatan: body.catatan,
posyanduId: body.posyanduId || null,
status: body.status ?? "AKTIF",
},
});
return { success: true, message: "Ibu hamil berhasil ditambahkan", data };
}

View File

@@ -0,0 +1,22 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function ibuHamilDelete(context: Context) {
const id = context.params?.id as string;
if (!id) {
return { success: false, message: "ID tidak diberikan" };
}
const existing = await prisma.ibuHamil.findUnique({ where: { id } });
if (!existing) {
return new Response(JSON.stringify({ success: false, message: "Data tidak ditemukan" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
await prisma.ibuHamil.update({ where: { id }, data: { isActive: false } });
return { success: true, message: "Ibu hamil berhasil dihapus" };
}

View File

@@ -0,0 +1,38 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function ibuHamilFindById(context: Context) {
const id = context.params?.id as string;
if (!id) {
return new Response(JSON.stringify({ success: false, message: "ID tidak boleh kosong" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
try {
const data = await prisma.ibuHamil.findUnique({
where: { id },
include: { posyandu: { select: { id: true, name: true } } },
});
if (!data) {
return new Response(JSON.stringify({ success: false, message: "Data tidak ditemukan" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ success: true, data }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("ibuHamilFindById error:", error);
return new Response(JSON.stringify({ success: false, message: "Gagal mengambil data" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
}

View File

@@ -0,0 +1,51 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function ibuHamilFindMany(context: Context) {
const page = Number(context.query.page) || 1;
const limit = Number(context.query.limit) || 10;
const search = (context.query.search as string) || "";
const status = (context.query.status as string) || "";
const skip = (page - 1) * limit;
const where: any = { isActive: true };
if (search) {
where.OR = [
{ nama: { contains: search, mode: "insensitive" } },
{ nik: { contains: search, mode: "insensitive" } },
{ alamat: { contains: search, mode: "insensitive" } },
];
}
if (status) {
where.status = status;
}
try {
const [data, total] = await Promise.all([
prisma.ibuHamil.findMany({
where,
include: { posyandu: { select: { id: true, name: true } } },
skip,
take: limit,
orderBy: { createdAt: "desc" },
}),
prisma.ibuHamil.count({ where }),
]);
return {
success: true,
message: "Berhasil ambil data ibu hamil",
data,
page,
limit,
total,
totalPages: Math.ceil(total / limit),
};
} catch (e) {
console.error("Error ibuHamilFindMany:", e);
return { success: false, message: "Gagal mengambil data ibu hamil" };
}
}

View File

@@ -0,0 +1,55 @@
import Elysia, { t } from "elysia";
import ibuHamilCreate from "./create";
import ibuHamilDelete from "./del";
import ibuHamilFindById from "./find-by-id";
import ibuHamilFindMany from "./find-many";
import ibuHamilUpdate from "./updt";
const IbuHamil = new Elysia({ prefix: "/ibuhamil", tags: ["Kesehatan/IbuHamil"] })
.post("/create", ibuHamilCreate, {
body: t.Object({
nama: t.String(),
nik: t.Optional(t.String()),
usiaKehamilan: t.Number({ minimum: 0 }),
hpht: t.Optional(t.String()),
taksiranLahir: t.Optional(t.String()),
alamat: t.Optional(t.String()),
noHp: t.Optional(t.String()),
catatan: t.Optional(t.String()),
posyanduId: t.Optional(t.String()),
status: t.Union([
t.Literal("AKTIF"),
t.Literal("MELAHIRKAN"),
t.Literal("KEGUGURAN"),
t.Literal("NONAKTIF"),
]),
}),
})
.get("/find-many", ibuHamilFindMany)
.delete("/del/:id", ibuHamilDelete)
.get("/:id", ibuHamilFindById)
.put(
"/:id",
ibuHamilUpdate,
{
body: t.Object({
nama: t.String(),
nik: t.Optional(t.String()),
usiaKehamilan: t.Number({ minimum: 0 }),
hpht: t.Optional(t.String()),
taksiranLahir: t.Optional(t.String()),
alamat: t.Optional(t.String()),
noHp: t.Optional(t.String()),
catatan: t.Optional(t.String()),
posyanduId: t.Optional(t.String()),
status: t.Union([
t.Literal("AKTIF"),
t.Literal("MELAHIRKAN"),
t.Literal("KEGUGURAN"),
t.Literal("NONAKTIF"),
]),
}),
}
);
export default IbuHamil;

View File

@@ -0,0 +1,65 @@
import prisma from "@/lib/prisma";
import { IbuHamilStatus } from "@prisma/client";
import { Context } from "elysia";
type FormUpdate = {
nama: string;
nik?: string;
usiaKehamilan: number;
hpht?: string;
taksiranLahir?: string;
alamat?: string;
noHp?: string;
catatan?: string;
posyanduId?: string;
status: IbuHamilStatus;
};
export default async function ibuHamilUpdate(context: Context) {
const id = context.params?.id as string;
const body = context.body as FormUpdate;
if (!id) {
return new Response(JSON.stringify({ success: false, message: "ID tidak boleh kosong" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
try {
const existing = await prisma.ibuHamil.findUnique({ where: { id } });
if (!existing) {
return new Response(JSON.stringify({ success: false, message: "Data tidak ditemukan" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
const updated = await prisma.ibuHamil.update({
where: { id },
data: {
nama: body.nama,
nik: body.nik,
usiaKehamilan: body.usiaKehamilan ?? 0,
hpht: body.hpht ? new Date(body.hpht) : null,
taksiranLahir: body.taksiranLahir ? new Date(body.taksiranLahir) : null,
alamat: body.alamat,
noHp: body.noHp,
catatan: body.catatan,
posyanduId: body.posyanduId || null,
status: body.status,
},
});
return new Response(JSON.stringify({ success: true, message: "Ibu hamil berhasil diperbarui", data: updated }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("ibuHamilUpdate error:", error);
return new Response(JSON.stringify({ success: false, message: "Gagal memperbarui data" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
}

View File

@@ -22,6 +22,8 @@ import DokterTenagaMedis from "./data_kesehatan_warga/fasilitas_kesehatan/dokter
import PendaftaranJadwalKegiatan from "./data_kesehatan_warga/jadwal_kegiatan/pendaftaran";
import TarifLayanan from "./data_kesehatan_warga/fasilitas_kesehatan/tarif-layanan";
import RingkasanKesehatan from "./ringkasan-kesehatan";
import IbuHamil from "./ibu-hamil";
import Balita from "./balita";
const Kesehatan = new Elysia({
@@ -51,4 +53,6 @@ const Kesehatan = new Elysia({
.use(TarifLayanan)
.use(PendaftaranJadwalKegiatan)
.use(RingkasanKesehatan)
.use(IbuHamil)
.use(Balita)
export default Kesehatan;

View File

@@ -1,14 +1,20 @@
import Elysia, { t } from "elysia";
import ringkasanKesehatanFindUnique from "./findUnique";
import ringkasanKesehatanUpdate from "./updt";
import ringkasanKesehatanStats from "./stats";
const RingkasanKesehatan = new Elysia({ prefix: "/ringkasankesehatan", tags: ["Kesehatan/Ringkasan"] })
.get("/find", ringkasanKesehatanFindUnique)
.get("/stats", ringkasanKesehatanStats)
.put("/update", ringkasanKesehatanUpdate, {
body: t.Object({
ibuHamilAkh: t.Number(),
balitaTerdaftar: t.Number(),
alertStunting: t.Number(),
imunisasiLengkapPct: t.Number({ minimum: 0, maximum: 100 }),
pemeriksaanRutinPct: t.Number({ minimum: 0, maximum: 100 }),
giziBaikPct: t.Number({ minimum: 0, maximum: 100 }),
targetStuntingPct: t.Number({ minimum: 0, maximum: 100 }),
}),
});

View File

@@ -0,0 +1,52 @@
import prisma from "@/lib/prisma";
type StatsResult = {
ibuHamilAktif: number;
balitaTerdaftar: number;
alertStunting: number;
imunisasiLengkapPct: number;
pemeriksaanRutinPct: number;
giziBaikPct: number;
targetStuntingPct: number;
};
export default async function ringkasanKesehatanStats(): Promise<{ success: boolean; data?: StatsResult; message?: string }> {
try {
const [
ibuHamilAktif,
balitaTotal,
alertStunting,
imunisasiLengkap,
pemeriksaanRutin,
giziBaik,
config,
] = await Promise.all([
prisma.ibuHamil.count({ where: { status: "AKTIF", isActive: true } }),
prisma.balita.count({ where: { isActive: true } }),
prisma.balita.count({ where: { isActive: true, statusStunting: { in: ["ALERT", "STUNTING"] } } }),
prisma.balita.count({ where: { isActive: true, imunisasiLengkap: true } }),
prisma.balita.count({ where: { isActive: true, pemeriksaanRutin: true } }),
prisma.balita.count({ where: { isActive: true, giziBaik: true } }),
prisma.ringkasanKesehatanDesa.findFirst({ where: { isActive: true }, orderBy: { createdAt: "desc" } }),
]);
const pct = (n: number, total: number) =>
total === 0 ? 0 : Math.round((n / total) * 100);
return {
success: true,
data: {
ibuHamilAktif,
balitaTerdaftar: balitaTotal,
alertStunting,
imunisasiLengkapPct: pct(imunisasiLengkap, balitaTotal),
pemeriksaanRutinPct: pct(pemeriksaanRutin, balitaTotal),
giziBaikPct: pct(giziBaik, balitaTotal),
targetStuntingPct: config?.targetStuntingPct ?? 0,
},
};
} catch (e) {
console.error("ringkasanKesehatanStats error:", e);
return { success: false, message: "Gagal menghitung statistik kesehatan" };
}
}

View File

@@ -18,6 +18,10 @@ async function ringkasanKesehatanUpdate(context: Context) {
ibuHamilAkh: Number(body.ibuHamilAkh),
balitaTerdaftar: Number(body.balitaTerdaftar),
alertStunting: Number(body.alertStunting),
imunisasiLengkapPct: Number(body.imunisasiLengkapPct),
pemeriksaanRutinPct: Number(body.pemeriksaanRutinPct),
giziBaikPct: Number(body.giziBaikPct),
targetStuntingPct: Number(body.targetStuntingPct),
},
})
: await prisma.ringkasanKesehatanDesa.create({
@@ -25,6 +29,10 @@ async function ringkasanKesehatanUpdate(context: Context) {
ibuHamilAkh: Number(body.ibuHamilAkh),
balitaTerdaftar: Number(body.balitaTerdaftar),
alertStunting: Number(body.alertStunting),
imunisasiLengkapPct: Number(body.imunisasiLengkapPct),
pemeriksaanRutinPct: Number(body.pemeriksaanRutinPct),
giziBaikPct: Number(body.giziBaikPct),
targetStuntingPct: Number(body.targetStuntingPct),
},
});