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:
2026-05-04 16:52:14 +08:00
parent fc6846f7a1
commit dccba1f82b
30 changed files with 2706 additions and 197 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,11 @@
import Elysia, { t } from "elysia";
import ringkasanKesehatanFindUnique from "./findUnique";
import ringkasanKesehatanUpdate from "./updt";
import ringkasanKesehatanStats from "./stats";
const RingkasanKesehatan = new Elysia({ prefix: "/ringkasankesehatan", tags: ["Kesehatan/Ringkasan"] })
.get("/find", ringkasanKesehatanFindUnique)
.get("/stats", ringkasanKesehatanStats)
.put("/update", ringkasanKesehatanUpdate, {
body: t.Object({
ibuHamilAkh: t.Number(),

View File

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