diff --git a/MIND/PLAN/task-statistik-kesehatan-ringkasan.md b/MIND/PLAN/task-statistik-kesehatan-ringkasan.md index 9a7118fc..8394f813 100644 --- a/MIND/PLAN/task-statistik-kesehatan-ringkasan.md +++ b/MIND/PLAN/task-statistik-kesehatan-ringkasan.md @@ -1,46 +1,246 @@ -# Task: Tambah Statistik Persentase ke RingkasanKesehatanDesa +# Task: Refactor Ringkasan Kesehatan → Dashboard Auto-Derived -## Tujuan -Lengkapi schema + API `RingkasanKesehatanDesa` dengan 4 field persentase -agar dashboard Statistik Kesehatan (Imunisasi, Pemeriksaan Rutin, Gizi Baik, -Target Stunting) punya backend yang bisa di-fetch. +## Latar Belakang -## Field Baru -Tambah ke model `RingkasanKesehatanDesa` (additive, tidak ubah field lama): +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. -| Field | Tipe | Default | Keterangan | -|---|---|---|---| -| `imunisasiLengkapPct` | Int | 0 | Persentase imunisasi lengkap (0-100) | -| `pemeriksaanRutinPct` | Int | 0 | Persentase pemeriksaan rutin (0-100) | -| `giziBaikPct` | Int | 0 | Persentase gizi baik (0-100) | -| `targetStuntingPct` | Int | 0 | Persentase target stunting (0-100) | +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". -## Step A — Schema + API +## Mapping Field → Sumber Data -- [x] 1. Edit `prisma/schema.prisma` — tambah 4 field -- [x] 2. Buat migration manual `20260504000000_add_statistik_pct_ringkasan_kesehatan/migration.sql` (mode interaktif tidak tersedia di sandbox) -- [x] 3. `findUnique.ts` tidak perlu diubah — `findFirst` tanpa `select` otomatis bawa field baru -- [x] 4. `updt.ts` + `index.ts` — handler create/update + Elysia body validation (range 0-100) -- [x] 5. `bun run build` — exit 0 -- [x] 6. Bump `0.1.48 → 0.1.49` -- [x] 7. Commit `feat(kesehatan): tambah 4 field statistik pct...` + push branch `tasks/statistik-kesehatan-ringkasan/add-pct-fields/20260504` + merge ke `stg` +| 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) | -## Step B — State File Admin +## Kontrak Lama (jaga, AI-CONTRACT §10) -- [x] 1. Bikin `_state/kesehatan/ringkasan-kesehatan/ringkasanKesehatan.ts` (Valtio + zod) -- [x] 2. `findUnique.load()` — GET + sync ke form -- [x] 3. `update.submit()` — zod validate (count ≥ 0, Pct 0-100) + PUT + refresh -- [x] 4. `update.reset()` -- [x] 5. `bun run build` — exit 0 -- [x] 6. Bump `0.1.49 → 0.1.50` -- [x] 7. Commit + push ke `stg` (2 remote) +- `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 terminal lokal — apply 4 kolom baru ke DB -- [ ] Trigger GitHub Workflow (publish + re-pull) bila mau deploy ke STG +- [ ] User jalankan `bunx prisma migrate deploy` di lokal setelah Step A & B +- [ ] User trigger publish + re-pull workflow bila mau deploy STG ## Prinsip -- Additive only — field lama (`ibuHamilAkh`, `balitaTerdaftar`, `alertStunting`) tidak disentuh -- Validasi range 0-100 di Elysia `updt.ts` -- Tidak ada breaking change kontrak + +- **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 diff --git a/package.json b/package.json index 5c656cfd..95ff8a01 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "desa-darmasaba", - "version": "0.1.51", + "version": "0.1.52", "private": true, "scripts": { "dev": "next dev", diff --git a/prisma/migrations/20260504100000_add_ibu_hamil_balita/migration.sql b/prisma/migrations/20260504100000_add_ibu_hamil_balita/migration.sql new file mode 100644 index 00000000..af56dac5 --- /dev/null +++ b/prisma/migrations/20260504100000_add_ibu_hamil_balita/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index eeb4a9b0..672b7925 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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,6 +2483,67 @@ model BeasiswaConfig { 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()) diff --git a/src/app/admin/(dashboard)/_state/kesehatan/balita/balita.ts b/src/app/admin/(dashboard)/_state/kesehatan/balita/balita.ts new file mode 100644 index 00000000..e7f1a0c1 --- /dev/null +++ b/src/app/admin/(dashboard)/_state/kesehatan/balita/balita.ts @@ -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; diff --git a/src/app/admin/(dashboard)/_state/kesehatan/ibu-hamil/ibuHamil.ts b/src/app/admin/(dashboard)/_state/kesehatan/ibu-hamil/ibuHamil.ts new file mode 100644 index 00000000..906541f4 --- /dev/null +++ b/src/app/admin/(dashboard)/_state/kesehatan/ibu-hamil/ibuHamil.ts @@ -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; diff --git a/src/app/admin/(dashboard)/_state/kesehatan/ringkasan-kesehatan/ringkasanKesehatan.ts b/src/app/admin/(dashboard)/_state/kesehatan/ringkasan-kesehatan/ringkasanKesehatan.ts index afff564d..6fa5b2d1 100644 --- a/src/app/admin/(dashboard)/_state/kesehatan/ringkasan-kesehatan/ringkasanKesehatan.ts +++ b/src/app/admin/(dashboard)/_state/kesehatan/ringkasan-kesehatan/ringkasanKesehatan.ts @@ -1,9 +1,18 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ 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" }) @@ -36,10 +45,35 @@ const defaultForm = { }; 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<{ - omit: { isActive: true }; - }> | null, + data: null as Prisma.RingkasanKesehatanDesaGetPayload | null, loading: false, async load() { try { @@ -70,9 +104,41 @@ const ringkasanKesehatanState = proxy({ } }, }, + 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) { @@ -82,7 +148,6 @@ const ringkasanKesehatanState = proxy({ toast.error(err); return false; } - try { ringkasanKesehatanState.update.loading = true; const response = await fetch(`/api/kesehatan/ringkasankesehatan/update`, { @@ -90,26 +155,16 @@ const ringkasanKesehatanState = proxy({ headers: { "Content-Type": "application/json" }, body: JSON.stringify(ringkasanKesehatanState.update.form), }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error( - errorData.message || `HTTP error! status: ${response.status}` - ); - } - const result = await response.json(); if (result.success) { - toast.success(result.message || "Ringkasan kesehatan berhasil disimpan"); + toast.success(result.message || "Berhasil disimpan"); await ringkasanKesehatanState.findUnique.load(); return true; } - throw new Error(result.message || "Gagal menyimpan ringkasan kesehatan"); + 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 ringkasan kesehatan" - ); + toast.error(error instanceof Error ? error.message : "Gagal menyimpan"); return false; } finally { ringkasanKesehatanState.update.loading = false; diff --git a/src/app/admin/(dashboard)/kesehatan/balita/create/page.tsx b/src/app/admin/(dashboard)/kesehatan/balita/create/page.tsx new file mode 100644 index 00000000..23dbe4c6 --- /dev/null +++ b/src/app/admin/(dashboard)/kesehatan/balita/create/page.tsx @@ -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 ( + + + + Tambah Balita + + + + + + { form.nama = e.currentTarget.value; }} + radius="md" + /> + { form.nik = e.currentTarget.value; }} + radius="md" + /> + { form.tanggalLahir = e.currentTarget.value; }} + radius="md" + /> + { if (v) form.statusStunting = v as typeof form.statusStunting; }} + radius="md" + /> + + + { form.alamat = e.currentTarget.value; }} + radius="md" + /> + + + { form.imunisasiLengkap = e.currentTarget.checked; }} + /> + { form.giziBaik = e.currentTarget.checked; }} + /> + { form.pemeriksaanRutin = e.currentTarget.checked; }} + /> + + +