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
This commit is contained in:
@@ -1,46 +1,246 @@
|
|||||||
# Task: Tambah Statistik Persentase ke RingkasanKesehatanDesa
|
# Task: Refactor Ringkasan Kesehatan → Dashboard Auto-Derived
|
||||||
|
|
||||||
## Tujuan
|
## Latar Belakang
|
||||||
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.
|
|
||||||
|
|
||||||
## Field Baru
|
Page `/admin/kesehatan/ringkasan-kesehatan` saat ini = form `NumberInput`
|
||||||
Tambah ke model `RingkasanKesehatanDesa` (additive, tidak ubah field lama):
|
manual (7 angka diketik operator). Itu **salah konsep**: "Ringkasan" harus
|
||||||
|
**hasil agregasi** dari data sumber, bukan input bebas.
|
||||||
|
|
||||||
| Field | Tipe | Default | Keterangan |
|
Solusi: bikin 2 entitas data sumber (`IbuHamil`, `Balita`), lalu derive
|
||||||
|---|---|---|---|
|
KPI + statistik kesehatan dari query agregat. Page ringkasan jadi
|
||||||
| `imunisasiLengkapPct` | Int | 0 | Persentase imunisasi lengkap (0-100) |
|
**dashboard read-only** + tombol "Kelola Ibu Hamil" / "Kelola Balita".
|
||||||
| `pemeriksaanRutinPct` | Int | 0 | Persentase pemeriksaan rutin (0-100) |
|
|
||||||
| `giziBaikPct` | Int | 0 | Persentase gizi baik (0-100) |
|
|
||||||
| `targetStuntingPct` | Int | 0 | Persentase target stunting (0-100) |
|
|
||||||
|
|
||||||
## Step A — Schema + API
|
## Mapping Field → Sumber Data
|
||||||
|
|
||||||
- [x] 1. Edit `prisma/schema.prisma` — tambah 4 field
|
| Field di Ringkasan | Sumber |
|
||||||
- [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
|
| `ibuHamilAktif` | `count(IbuHamil where status=AKTIF, isActive=true)` |
|
||||||
- [x] 4. `updt.ts` + `index.ts` — handler create/update + Elysia body validation (range 0-100)
|
| `balitaTerdaftar` | `count(Balita where isActive=true)` |
|
||||||
- [x] 5. `bun run build` — exit 0
|
| `alertStunting` | `count(Balita where statusStunting IN [ALERT, STUNTING])` |
|
||||||
- [x] 6. Bump `0.1.48 → 0.1.49`
|
| `imunisasiLengkapPct` | `count(imunisasiLengkap=true) / total balita * 100` |
|
||||||
- [x] 7. Commit `feat(kesehatan): tambah 4 field statistik pct...` + push branch `tasks/statistik-kesehatan-ringkasan/add-pct-fields/20260504` + merge ke `stg`
|
| `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)
|
- `GET /api/kesehatan/ringkasankesehatan/find` — tetap return shape lama
|
||||||
- [x] 2. `findUnique.load()` — GET + sync ke form
|
- `PUT /api/kesehatan/ringkasankesehatan/update` — tetap menerima 7 field lama
|
||||||
- [x] 3. `update.submit()` — zod validate (count ≥ 0, Pct 0-100) + PUT + refresh
|
- Tambah baru: `GET /api/kesehatan/ringkasankesehatan/stats` (agregat)
|
||||||
- [x] 4. `update.reset()`
|
- Tambah baru: `/api/kesehatan/ibuhamil/*`, `/api/kesehatan/balita/*`
|
||||||
- [x] 5. `bun run build` — exit 0
|
|
||||||
- [x] 6. Bump `0.1.49 → 0.1.50`
|
> Alasan: ada konsumer eksternal (mobile/landing) yang mungkin sudah
|
||||||
- [x] 7. Commit + push ke `stg` (2 remote)
|
> 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
|
## Pending Manual
|
||||||
|
|
||||||
- [ ] User jalankan `bunx prisma migrate deploy` di terminal lokal — apply 4 kolom baru ke DB
|
- [ ] User jalankan `bunx prisma migrate deploy` di lokal setelah Step A & B
|
||||||
- [ ] Trigger GitHub Workflow (publish + re-pull) bila mau deploy ke STG
|
- [ ] User trigger publish + re-pull workflow bila mau deploy STG
|
||||||
|
|
||||||
## Prinsip
|
## Prinsip
|
||||||
- Additive only — field lama (`ibuHamilAkh`, `balitaTerdaftar`, `alertStunting`) tidak disentuh
|
|
||||||
- Validasi range 0-100 di Elysia `updt.ts`
|
- **Additive**: kontrak `/find` + `/update` ringkasankesehatan tetap
|
||||||
- Tidak ada breaking change kontrak
|
- **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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "desa-darmasaba",
|
"name": "desa-darmasaba",
|
||||||
"version": "0.1.51",
|
"version": "0.1.52",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1159,6 +1159,8 @@ model Posyandu {
|
|||||||
jadwalPelayanan String
|
jadwalPelayanan String
|
||||||
image FileStorage? @relation(fields: [imageId], references: [id])
|
image FileStorage? @relation(fields: [imageId], references: [id])
|
||||||
imageId String?
|
imageId String?
|
||||||
|
ibuHamil IbuHamil[]
|
||||||
|
balita Balita[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime @default(now())
|
||||||
@@ -2481,6 +2483,67 @@ model BeasiswaConfig {
|
|||||||
isActive Boolean @default(true)
|
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 ========================================= //
|
// ========================================= RINGKASAN KESEHATAN DESA ========================================= //
|
||||||
model RingkasanKesehatanDesa {
|
model RingkasanKesehatanDesa {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
|||||||
218
src/app/admin/(dashboard)/_state/kesehatan/balita/balita.ts
Normal file
218
src/app/admin/(dashboard)/_state/kesehatan/balita/balita.ts
Normal 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;
|
||||||
203
src/app/admin/(dashboard)/_state/kesehatan/ibu-hamil/ibuHamil.ts
Normal file
203
src/app/admin/(dashboard)/_state/kesehatan/ibu-hamil/ibuHamil.ts
Normal 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;
|
||||||
@@ -1,9 +1,18 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { proxy } from "valtio";
|
import { proxy } from "valtio";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
type StatsData = {
|
||||||
|
ibuHamilAktif: number;
|
||||||
|
balitaTerdaftar: number;
|
||||||
|
alertStunting: number;
|
||||||
|
imunisasiLengkapPct: number;
|
||||||
|
pemeriksaanRutinPct: number;
|
||||||
|
giziBaikPct: number;
|
||||||
|
targetStuntingPct: number;
|
||||||
|
};
|
||||||
|
|
||||||
const intPct = z
|
const intPct = z
|
||||||
.number({ invalid_type_error: "Harus berupa angka" })
|
.number({ invalid_type_error: "Harus berupa angka" })
|
||||||
.int({ message: "Harus bilangan bulat" })
|
.int({ message: "Harus bilangan bulat" })
|
||||||
@@ -36,10 +45,35 @@ const defaultForm = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ringkasanKesehatanState = proxy({
|
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: {
|
findUnique: {
|
||||||
data: null as Prisma.RingkasanKesehatanDesaGetPayload<{
|
data: null as Prisma.RingkasanKesehatanDesaGetPayload<object> | null,
|
||||||
omit: { isActive: true };
|
|
||||||
}> | null,
|
|
||||||
loading: false,
|
loading: false,
|
||||||
async load() {
|
async load() {
|
||||||
try {
|
try {
|
||||||
@@ -70,9 +104,41 @@ const ringkasanKesehatanState = proxy({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
update: {
|
update: {
|
||||||
form: { ...defaultForm },
|
form: { ...defaultForm },
|
||||||
loading: false,
|
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() {
|
async submit() {
|
||||||
const cek = templateForm.safeParse(ringkasanKesehatanState.update.form);
|
const cek = templateForm.safeParse(ringkasanKesehatanState.update.form);
|
||||||
if (!cek.success) {
|
if (!cek.success) {
|
||||||
@@ -82,7 +148,6 @@ const ringkasanKesehatanState = proxy({
|
|||||||
toast.error(err);
|
toast.error(err);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ringkasanKesehatanState.update.loading = true;
|
ringkasanKesehatanState.update.loading = true;
|
||||||
const response = await fetch(`/api/kesehatan/ringkasankesehatan/update`, {
|
const response = await fetch(`/api/kesehatan/ringkasankesehatan/update`, {
|
||||||
@@ -90,26 +155,16 @@ const ringkasanKesehatanState = proxy({
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(ringkasanKesehatanState.update.form),
|
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();
|
const result = await response.json();
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message || "Ringkasan kesehatan berhasil disimpan");
|
toast.success(result.message || "Berhasil disimpan");
|
||||||
await ringkasanKesehatanState.findUnique.load();
|
await ringkasanKesehatanState.findUnique.load();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
throw new Error(result.message || "Gagal menyimpan ringkasan kesehatan");
|
throw new Error(result.message || "Gagal menyimpan");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating ringkasan kesehatan:", error);
|
console.error("Error updating ringkasan kesehatan:", error);
|
||||||
toast.error(
|
toast.error(error instanceof Error ? error.message : "Gagal menyimpan");
|
||||||
error instanceof Error ? error.message : "Gagal menyimpan ringkasan kesehatan"
|
|
||||||
);
|
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
ringkasanKesehatanState.update.loading = false;
|
ringkasanKesehatanState.update.loading = false;
|
||||||
|
|||||||
186
src/app/admin/(dashboard)/kesehatan/balita/create/page.tsx
Normal file
186
src/app/admin/(dashboard)/kesehatan/balita/create/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
190
src/app/admin/(dashboard)/kesehatan/balita/edit/[id]/page.tsx
Normal file
190
src/app/admin/(dashboard)/kesehatan/balita/edit/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
188
src/app/admin/(dashboard)/kesehatan/balita/page.tsx
Normal file
188
src/app/admin/(dashboard)/kesehatan/balita/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
src/app/admin/(dashboard)/kesehatan/ibu-hamil/create/page.tsx
Normal file
145
src/app/admin/(dashboard)/kesehatan/ibu-hamil/create/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
149
src/app/admin/(dashboard)/kesehatan/ibu-hamil/edit/[id]/page.tsx
Normal file
149
src/app/admin/(dashboard)/kesehatan/ibu-hamil/edit/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
170
src/app/admin/(dashboard)/kesehatan/ibu-hamil/page.tsx
Normal file
170
src/app/admin/(dashboard)/kesehatan/ibu-hamil/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import colors from '@/con/colors';
|
|||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
Card,
|
||||||
Divider,
|
Divider,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
@@ -14,181 +15,210 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Title,
|
Title,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconArrowBack } from '@tabler/icons-react';
|
import {
|
||||||
|
IconArrowRight,
|
||||||
|
IconMoodBoy,
|
||||||
|
IconHeartbeat,
|
||||||
|
IconPercentage,
|
||||||
|
IconUser,
|
||||||
|
IconAlertTriangle,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { toast } from 'react-toastify';
|
|
||||||
import { useProxy } from 'valtio/utils';
|
import { useProxy } from 'valtio/utils';
|
||||||
import ringkasanKesehatanState from '../../_state/kesehatan/ringkasan-kesehatan/ringkasanKesehatan';
|
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() {
|
export default function RingkasanKesehatanPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const state = useProxy(ringkasanKesehatanState);
|
const state = useProxy(ringkasanKesehatanState);
|
||||||
|
const stats = state.findStats.data;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
state.findUnique.load();
|
state.findStats.load();
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const setField = (key: keyof typeof state.update.form, value: number) => {
|
const handleSaveTarget = async () => {
|
||||||
state.update.form[key] = Number.isFinite(value) ? value : 0;
|
await state.update.submitTarget();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const isLoading = state.findStats.loading;
|
||||||
const ok = await state.update.submit();
|
|
||||||
if (ok) router.refresh();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
state.update.reset();
|
|
||||||
if (state.findUnique.data) {
|
|
||||||
const d = state.findUnique.data;
|
|
||||||
state.update.form = {
|
|
||||||
ibuHamilAkh: d.ibuHamilAkh,
|
|
||||||
balitaTerdaftar: d.balitaTerdaftar,
|
|
||||||
alertStunting: d.alertStunting,
|
|
||||||
imunisasiLengkapPct: d.imunisasiLengkapPct,
|
|
||||||
pemeriksaanRutinPct: d.pemeriksaanRutinPct,
|
|
||||||
giziBaikPct: d.giziBaikPct,
|
|
||||||
targetStuntingPct: d.targetStuntingPct,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
toast.info('Form dikembalikan ke data awal');
|
|
||||||
};
|
|
||||||
|
|
||||||
const isLoading = state.findUnique.loading;
|
|
||||||
const isSubmitting = state.update.loading;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||||
<Group mb="md" gap="sm">
|
<Title order={3} c="black" mb="md">Ringkasan Kesehatan Desa</Title>
|
||||||
<Button variant="subtle" onClick={() => router.back()} radius="md">
|
|
||||||
<IconArrowBack size={20} stroke={2} />
|
|
||||||
</Button>
|
|
||||||
<Title order={3} c="black">Ringkasan Kesehatan Desa</Title>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Paper
|
|
||||||
withBorder
|
|
||||||
w={{ base: '100%', md: '80%' }}
|
|
||||||
p="lg"
|
|
||||||
radius="md"
|
|
||||||
shadow="xl"
|
|
||||||
bg="white"
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Group justify="center" py="xl">
|
<Group justify="center" py="xl"><Loader /></Group>
|
||||||
<Loader />
|
|
||||||
</Group>
|
|
||||||
) : (
|
) : (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
|
{/* KPI Utama */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fw={600} mb="xs">KPI Utama</Text>
|
<Text fw={600} mb="sm" c="dark">KPI Utama</Text>
|
||||||
<SimpleGrid cols={{ base: 1, md: 3 }} spacing="md">
|
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
|
||||||
<NumberInput
|
<StatCard
|
||||||
label="Ibu Hamil Aktif"
|
label="Ibu Hamil Aktif"
|
||||||
description="Jumlah ibu hamil yang aktif tercatat"
|
value={stats?.ibuHamilAktif ?? 0}
|
||||||
min={0}
|
icon={<IconUser size={20} />}
|
||||||
value={state.update.form.ibuHamilAkh}
|
color="pink"
|
||||||
onChange={(v) => setField('ibuHamilAkh', Number(v))}
|
suffix="orang"
|
||||||
radius="md"
|
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<StatCard
|
||||||
label="Balita Terdaftar"
|
label="Balita Terdaftar"
|
||||||
description="Total balita terdaftar di posyandu"
|
value={stats?.balitaTerdaftar ?? 0}
|
||||||
min={0}
|
icon={<IconMoodBoy size={20} />}
|
||||||
value={state.update.form.balitaTerdaftar}
|
color="blue"
|
||||||
onChange={(v) => setField('balitaTerdaftar', Number(v))}
|
suffix="anak"
|
||||||
radius="md"
|
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<StatCard
|
||||||
label="Alert Stunting"
|
label="Alert Stunting"
|
||||||
description="Jumlah balita kategori alert stunting"
|
value={stats?.alertStunting ?? 0}
|
||||||
min={0}
|
icon={<IconAlertTriangle size={20} />}
|
||||||
value={state.update.form.alertStunting}
|
color="red"
|
||||||
onChange={(v) => setField('alertStunting', Number(v))}
|
suffix="anak"
|
||||||
radius="md"
|
|
||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
|
{/* Statistik % */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fw={600} mb="xs">Statistik Kesehatan (%)</Text>
|
<Text fw={600} mb="sm" c="dark">Statistik Kesehatan Balita</Text>
|
||||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
|
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md">
|
||||||
<NumberInput
|
<StatCard
|
||||||
label="Imunisasi Lengkap"
|
label="Imunisasi Lengkap"
|
||||||
description="Persentase balita imunisasi lengkap (0-100)"
|
value={stats?.imunisasiLengkapPct ?? 0}
|
||||||
min={0}
|
icon={<IconHeartbeat size={20} />}
|
||||||
max={100}
|
color="teal"
|
||||||
suffix="%"
|
suffix="%"
|
||||||
value={state.update.form.imunisasiLengkapPct}
|
|
||||||
onChange={(v) => setField('imunisasiLengkapPct', Number(v))}
|
|
||||||
radius="md"
|
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<StatCard
|
||||||
label="Pemeriksaan Rutin"
|
label="Pemeriksaan Rutin"
|
||||||
description="Persentase warga pemeriksaan rutin (0-100)"
|
value={stats?.pemeriksaanRutinPct ?? 0}
|
||||||
min={0}
|
icon={<IconHeartbeat size={20} />}
|
||||||
max={100}
|
color="green"
|
||||||
suffix="%"
|
suffix="%"
|
||||||
value={state.update.form.pemeriksaanRutinPct}
|
|
||||||
onChange={(v) => setField('pemeriksaanRutinPct', Number(v))}
|
|
||||||
radius="md"
|
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<StatCard
|
||||||
label="Gizi Baik"
|
label="Gizi Baik"
|
||||||
description="Persentase balita dengan status gizi baik (0-100)"
|
value={stats?.giziBaikPct ?? 0}
|
||||||
min={0}
|
icon={<IconHeartbeat size={20} />}
|
||||||
max={100}
|
color="lime"
|
||||||
suffix="%"
|
suffix="%"
|
||||||
value={state.update.form.giziBaikPct}
|
|
||||||
onChange={(v) => setField('giziBaikPct', Number(v))}
|
|
||||||
radius="md"
|
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<StatCard
|
||||||
label="Target Stunting"
|
label="Target Penurunan Stunting"
|
||||||
description="Target penurunan stunting (0-100)"
|
value={stats?.targetStuntingPct ?? 0}
|
||||||
min={0}
|
icon={<IconPercentage size={20} />}
|
||||||
max={100}
|
color="orange"
|
||||||
suffix="%"
|
suffix="%"
|
||||||
value={state.update.form.targetStuntingPct}
|
|
||||||
onChange={(v) => setField('targetStuntingPct', Number(v))}
|
|
||||||
radius="md"
|
|
||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Group justify="right">
|
<Divider />
|
||||||
<Button
|
|
||||||
variant="outline"
|
{/* Target Stunting Config */}
|
||||||
color="gray"
|
<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"
|
radius="md"
|
||||||
size="md"
|
style={{ flex: 1 }}
|
||||||
onClick={handleReset}
|
/>
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
Batal
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSaveTarget}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
disabled={state.update.loading}
|
||||||
disabled={isSubmitting}
|
|
||||||
style={{
|
style={{
|
||||||
background: isSubmitting
|
background: state.update.loading
|
||||||
? 'linear-gradient(135deg, #cccccc, #eeeeee)'
|
? 'linear-gradient(135deg, #ccc, #eee)'
|
||||||
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
marginBottom: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
{state.update.loading ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</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>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Paper>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,6 +171,16 @@ export const devBar = [
|
|||||||
id: "Kesehatan_8",
|
id: "Kesehatan_8",
|
||||||
name: "Ringkasan Kesehatan",
|
name: "Ringkasan Kesehatan",
|
||||||
path: "/admin/kesehatan/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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -612,6 +622,16 @@ export const navBar = [
|
|||||||
id: "Kesehatan_8",
|
id: "Kesehatan_8",
|
||||||
name: "Ringkasan Kesehatan",
|
name: "Ringkasan Kesehatan",
|
||||||
path: "/admin/kesehatan/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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1287,6 +1307,16 @@ export const role2 = [
|
|||||||
id: "Kesehatan_8",
|
id: "Kesehatan_8",
|
||||||
name: "Ringkasan Kesehatan",
|
name: "Ringkasan Kesehatan",
|
||||||
path: "/admin/kesehatan/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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
47
src/app/api/[[...slugs]]/_lib/kesehatan/balita/create.ts
Normal file
47
src/app/api/[[...slugs]]/_lib/kesehatan/balita/create.ts
Normal 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 };
|
||||||
|
}
|
||||||
22
src/app/api/[[...slugs]]/_lib/kesehatan/balita/del.ts
Normal file
22
src/app/api/[[...slugs]]/_lib/kesehatan/balita/del.ts
Normal 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" };
|
||||||
|
}
|
||||||
38
src/app/api/[[...slugs]]/_lib/kesehatan/balita/find-by-id.ts
Normal file
38
src/app/api/[[...slugs]]/_lib/kesehatan/balita/find-by-id.ts
Normal 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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/app/api/[[...slugs]]/_lib/kesehatan/balita/find-many.ts
Normal file
52
src/app/api/[[...slugs]]/_lib/kesehatan/balita/find-many.ts
Normal 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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/app/api/[[...slugs]]/_lib/kesehatan/balita/index.ts
Normal file
63
src/app/api/[[...slugs]]/_lib/kesehatan/balita/index.ts
Normal 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;
|
||||||
75
src/app/api/[[...slugs]]/_lib/kesehatan/balita/updt.ts
Normal file
75
src/app/api/[[...slugs]]/_lib/kesehatan/balita/updt.ts
Normal 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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/app/api/[[...slugs]]/_lib/kesehatan/ibu-hamil/create.ts
Normal file
37
src/app/api/[[...slugs]]/_lib/kesehatan/ibu-hamil/create.ts
Normal 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 };
|
||||||
|
}
|
||||||
22
src/app/api/[[...slugs]]/_lib/kesehatan/ibu-hamil/del.ts
Normal file
22
src/app/api/[[...slugs]]/_lib/kesehatan/ibu-hamil/del.ts
Normal 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" };
|
||||||
|
}
|
||||||
@@ -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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/app/api/[[...slugs]]/_lib/kesehatan/ibu-hamil/index.ts
Normal file
55
src/app/api/[[...slugs]]/_lib/kesehatan/ibu-hamil/index.ts
Normal 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;
|
||||||
65
src/app/api/[[...slugs]]/_lib/kesehatan/ibu-hamil/updt.ts
Normal file
65
src/app/api/[[...slugs]]/_lib/kesehatan/ibu-hamil/updt.ts
Normal 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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,8 @@ import DokterTenagaMedis from "./data_kesehatan_warga/fasilitas_kesehatan/dokter
|
|||||||
import PendaftaranJadwalKegiatan from "./data_kesehatan_warga/jadwal_kegiatan/pendaftaran";
|
import PendaftaranJadwalKegiatan from "./data_kesehatan_warga/jadwal_kegiatan/pendaftaran";
|
||||||
import TarifLayanan from "./data_kesehatan_warga/fasilitas_kesehatan/tarif-layanan";
|
import TarifLayanan from "./data_kesehatan_warga/fasilitas_kesehatan/tarif-layanan";
|
||||||
import RingkasanKesehatan from "./ringkasan-kesehatan";
|
import RingkasanKesehatan from "./ringkasan-kesehatan";
|
||||||
|
import IbuHamil from "./ibu-hamil";
|
||||||
|
import Balita from "./balita";
|
||||||
|
|
||||||
|
|
||||||
const Kesehatan = new Elysia({
|
const Kesehatan = new Elysia({
|
||||||
@@ -51,4 +53,6 @@ const Kesehatan = new Elysia({
|
|||||||
.use(TarifLayanan)
|
.use(TarifLayanan)
|
||||||
.use(PendaftaranJadwalKegiatan)
|
.use(PendaftaranJadwalKegiatan)
|
||||||
.use(RingkasanKesehatan)
|
.use(RingkasanKesehatan)
|
||||||
|
.use(IbuHamil)
|
||||||
|
.use(Balita)
|
||||||
export default Kesehatan;
|
export default Kesehatan;
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import Elysia, { t } from "elysia";
|
import Elysia, { t } from "elysia";
|
||||||
import ringkasanKesehatanFindUnique from "./findUnique";
|
import ringkasanKesehatanFindUnique from "./findUnique";
|
||||||
import ringkasanKesehatanUpdate from "./updt";
|
import ringkasanKesehatanUpdate from "./updt";
|
||||||
|
import ringkasanKesehatanStats from "./stats";
|
||||||
|
|
||||||
const RingkasanKesehatan = new Elysia({ prefix: "/ringkasankesehatan", tags: ["Kesehatan/Ringkasan"] })
|
const RingkasanKesehatan = new Elysia({ prefix: "/ringkasankesehatan", tags: ["Kesehatan/Ringkasan"] })
|
||||||
.get("/find", ringkasanKesehatanFindUnique)
|
.get("/find", ringkasanKesehatanFindUnique)
|
||||||
|
.get("/stats", ringkasanKesehatanStats)
|
||||||
.put("/update", ringkasanKesehatanUpdate, {
|
.put("/update", ringkasanKesehatanUpdate, {
|
||||||
body: t.Object({
|
body: t.Object({
|
||||||
ibuHamilAkh: t.Number(),
|
ibuHamilAkh: t.Number(),
|
||||||
|
|||||||
@@ -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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user