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

View File

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

View File

@@ -1,9 +1,18 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Prisma } from "@prisma/client";
import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
type StatsData = {
ibuHamilAktif: number;
balitaTerdaftar: number;
alertStunting: number;
imunisasiLengkapPct: number;
pemeriksaanRutinPct: number;
giziBaikPct: number;
targetStuntingPct: number;
};
const intPct = z
.number({ invalid_type_error: "Harus berupa angka" })
.int({ message: "Harus bilangan bulat" })
@@ -36,10 +45,35 @@ const defaultForm = {
};
const ringkasanKesehatanState = proxy({
// Derived stats aggregated from IbuHamil + Balita tables
findStats: {
data: null as StatsData | null,
loading: false,
async load() {
try {
ringkasanKesehatanState.findStats.loading = true;
const res = await fetch(`/api/kesehatan/ringkasankesehatan/stats`);
if (res.ok) {
const result = await res.json();
ringkasanKesehatanState.findStats.data = result?.data ?? null;
if (result?.data) {
ringkasanKesehatanState.update.form.targetStuntingPct = result.data.targetStuntingPct;
}
} else {
ringkasanKesehatanState.findStats.data = null;
}
} catch (error) {
console.error("Error fetching ringkasan stats:", error);
ringkasanKesehatanState.findStats.data = null;
} finally {
ringkasanKesehatanState.findStats.loading = false;
}
},
},
// Kept for backward-compat — now only used internally for targetStuntingPct config
findUnique: {
data: null as Prisma.RingkasanKesehatanDesaGetPayload<{
omit: { isActive: true };
}> | null,
data: null as Prisma.RingkasanKesehatanDesaGetPayload<object> | null,
loading: false,
async load() {
try {
@@ -70,9 +104,41 @@ const ringkasanKesehatanState = proxy({
}
},
},
update: {
form: { ...defaultForm },
loading: false,
async submitTarget() {
const pct = ringkasanKesehatanState.update.form.targetStuntingPct;
const cek = intPct.safeParse(pct);
if (!cek.success) {
toast.error("Target stunting harus 0-100");
return false;
}
try {
ringkasanKesehatanState.update.loading = true;
const response = await fetch(`/api/kesehatan/ringkasankesehatan/update`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(ringkasanKesehatanState.update.form),
});
const result = await response.json();
if (result.success) {
toast.success("Target stunting berhasil disimpan");
await ringkasanKesehatanState.findStats.load();
return true;
}
toast.error(result.message || "Gagal menyimpan");
return false;
} catch (error) {
console.error("Error saving target stunting:", error);
toast.error("Gagal menyimpan target stunting");
return false;
} finally {
ringkasanKesehatanState.update.loading = false;
}
},
// Kept for backward-compat (full update)
async submit() {
const cek = templateForm.safeParse(ringkasanKesehatanState.update.form);
if (!cek.success) {
@@ -82,7 +148,6 @@ const ringkasanKesehatanState = proxy({
toast.error(err);
return false;
}
try {
ringkasanKesehatanState.update.loading = true;
const response = await fetch(`/api/kesehatan/ringkasankesehatan/update`, {
@@ -90,26 +155,16 @@ const ringkasanKesehatanState = proxy({
headers: { "Content-Type": "application/json" },
body: JSON.stringify(ringkasanKesehatanState.update.form),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.message || `HTTP error! status: ${response.status}`
);
}
const result = await response.json();
if (result.success) {
toast.success(result.message || "Ringkasan kesehatan berhasil disimpan");
toast.success(result.message || "Berhasil disimpan");
await ringkasanKesehatanState.findUnique.load();
return true;
}
throw new Error(result.message || "Gagal menyimpan ringkasan kesehatan");
throw new Error(result.message || "Gagal menyimpan");
} catch (error) {
console.error("Error updating ringkasan kesehatan:", error);
toast.error(
error instanceof Error ? error.message : "Gagal menyimpan ringkasan kesehatan"
);
toast.error(error instanceof Error ? error.message : "Gagal menyimpan");
return false;
} finally {
ringkasanKesehatanState.update.loading = false;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import colors from '@/con/colors';
import {
Box,
Button,
Card,
Divider,
Group,
Loader,
@@ -14,181 +15,210 @@ import {
Text,
Title,
} 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 { useEffect } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
import ringkasanKesehatanState from '../../_state/kesehatan/ringkasan-kesehatan/ringkasanKesehatan';
type StatCardProps = {
label: string;
value: string | number;
icon: React.ReactNode;
color?: string;
suffix?: string;
};
function StatCard({ label, value, icon, color = 'blue', suffix }: StatCardProps) {
return (
<Card withBorder radius="md" p="md">
<Group gap="sm" align="flex-start">
<Box
style={{
width: 40,
height: 40,
borderRadius: 8,
background: `var(--mantine-color-${color}-1)`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: `var(--mantine-color-${color}-6)`,
flexShrink: 0,
}}
>
{icon}
</Box>
<Box>
<Text fz="xs" c="dimmed" fw={500}>{label}</Text>
<Text fz="xl" fw={700}>
{value}{suffix && <Text component="span" fz="sm" c="dimmed" fw={400}> {suffix}</Text>}
</Text>
</Box>
</Group>
</Card>
);
}
export default function RingkasanKesehatanPage() {
const router = useRouter();
const state = useProxy(ringkasanKesehatanState);
const stats = state.findStats.data;
useEffect(() => {
state.findUnique.load();
state.findStats.load();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const setField = (key: keyof typeof state.update.form, value: number) => {
state.update.form[key] = Number.isFinite(value) ? value : 0;
const handleSaveTarget = async () => {
await state.update.submitTarget();
};
const handleSubmit = async () => {
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;
const isLoading = state.findStats.loading;
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} stroke={2} />
</Button>
<Title order={3} c="black">Ringkasan Kesehatan Desa</Title>
</Group>
<Title order={3} c="black" mb="md">Ringkasan Kesehatan Desa</Title>
<Paper
withBorder
w={{ base: '100%', md: '80%' }}
p="lg"
radius="md"
shadow="xl"
bg="white"
>
{isLoading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : (
<Stack gap="lg">
<Box>
<Text fw={600} mb="xs">KPI Utama</Text>
<SimpleGrid cols={{ base: 1, md: 3 }} spacing="md">
<NumberInput
label="Ibu Hamil Aktif"
description="Jumlah ibu hamil yang aktif tercatat"
min={0}
value={state.update.form.ibuHamilAkh}
onChange={(v) => setField('ibuHamilAkh', Number(v))}
radius="md"
/>
<NumberInput
label="Balita Terdaftar"
description="Total balita terdaftar di posyandu"
min={0}
value={state.update.form.balitaTerdaftar}
onChange={(v) => setField('balitaTerdaftar', Number(v))}
radius="md"
/>
<NumberInput
label="Alert Stunting"
description="Jumlah balita kategori alert stunting"
min={0}
value={state.update.form.alertStunting}
onChange={(v) => setField('alertStunting', Number(v))}
radius="md"
/>
</SimpleGrid>
</Box>
{isLoading ? (
<Group justify="center" py="xl"><Loader /></Group>
) : (
<Stack gap="lg">
{/* KPI Utama */}
<Box>
<Text fw={600} mb="sm" c="dark">KPI Utama</Text>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
<StatCard
label="Ibu Hamil Aktif"
value={stats?.ibuHamilAktif ?? 0}
icon={<IconUser size={20} />}
color="pink"
suffix="orang"
/>
<StatCard
label="Balita Terdaftar"
value={stats?.balitaTerdaftar ?? 0}
icon={<IconMoodBoy size={20} />}
color="blue"
suffix="anak"
/>
<StatCard
label="Alert Stunting"
value={stats?.alertStunting ?? 0}
icon={<IconAlertTriangle size={20} />}
color="red"
suffix="anak"
/>
</SimpleGrid>
</Box>
<Divider />
<Divider />
<Box>
<Text fw={600} mb="xs">Statistik Kesehatan (%)</Text>
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
<NumberInput
label="Imunisasi Lengkap"
description="Persentase balita imunisasi lengkap (0-100)"
min={0}
max={100}
suffix="%"
value={state.update.form.imunisasiLengkapPct}
onChange={(v) => setField('imunisasiLengkapPct', Number(v))}
radius="md"
/>
<NumberInput
label="Pemeriksaan Rutin"
description="Persentase warga pemeriksaan rutin (0-100)"
min={0}
max={100}
suffix="%"
value={state.update.form.pemeriksaanRutinPct}
onChange={(v) => setField('pemeriksaanRutinPct', Number(v))}
radius="md"
/>
<NumberInput
label="Gizi Baik"
description="Persentase balita dengan status gizi baik (0-100)"
min={0}
max={100}
suffix="%"
value={state.update.form.giziBaikPct}
onChange={(v) => setField('giziBaikPct', Number(v))}
radius="md"
/>
<NumberInput
label="Target Stunting"
description="Target penurunan stunting (0-100)"
min={0}
max={100}
suffix="%"
value={state.update.form.targetStuntingPct}
onChange={(v) => setField('targetStuntingPct', Number(v))}
radius="md"
/>
</SimpleGrid>
</Box>
{/* Statistik % */}
<Box>
<Text fw={600} mb="sm" c="dark">Statistik Kesehatan Balita</Text>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md">
<StatCard
label="Imunisasi Lengkap"
value={stats?.imunisasiLengkapPct ?? 0}
icon={<IconHeartbeat size={20} />}
color="teal"
suffix="%"
/>
<StatCard
label="Pemeriksaan Rutin"
value={stats?.pemeriksaanRutinPct ?? 0}
icon={<IconHeartbeat size={20} />}
color="green"
suffix="%"
/>
<StatCard
label="Gizi Baik"
value={stats?.giziBaikPct ?? 0}
icon={<IconHeartbeat size={20} />}
color="lime"
suffix="%"
/>
<StatCard
label="Target Penurunan Stunting"
value={stats?.targetStuntingPct ?? 0}
icon={<IconPercentage size={20} />}
color="orange"
suffix="%"
/>
</SimpleGrid>
</Box>
<Group justify="right">
<Button
variant="outline"
color="gray"
<Divider />
{/* Target Stunting Config */}
<Paper withBorder p="md" radius="md" maw={400}>
<Text fw={600} mb="sm" c="dark">Atur Target Stunting</Text>
<Text fz="xs" c="dimmed" mb="sm">
Target penurunan angka stunting adalah nilai kebijakan yang ditentukan
oleh admin, bukan turunan dari data.
</Text>
<Group align="flex-end" gap="sm">
<NumberInput
label="Target (%)"
min={0}
max={100}
suffix="%"
value={state.update.form.targetStuntingPct}
onChange={(v) => { state.update.form.targetStuntingPct = Number(v) || 0; }}
radius="md"
size="md"
onClick={handleReset}
disabled={isSubmitting}
>
Batal
</Button>
style={{ flex: 1 }}
/>
<Button
onClick={handleSubmit}
onClick={handleSaveTarget}
radius="md"
size="md"
disabled={isSubmitting}
disabled={state.update.loading}
style={{
background: isSubmitting
? 'linear-gradient(135deg, #cccccc, #eeeeee)'
background: state.update.loading
? 'linear-gradient(135deg, #ccc, #eee)'
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
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>
</Group>
</Stack>
)}
</Paper>
</Paper>
<Divider />
{/* Kelola Data */}
<Box>
<Text fw={600} mb="sm" c="dark">Kelola Data</Text>
<Group gap="md">
<Button
variant="light"
color="pink"
radius="md"
rightSection={<IconArrowRight size={16} />}
onClick={() => router.push('/admin/kesehatan/ibu-hamil')}
>
Kelola Ibu Hamil
</Button>
<Button
variant="light"
color="blue"
radius="md"
rightSection={<IconArrowRight size={16} />}
onClick={() => router.push('/admin/kesehatan/balita')}
>
Kelola Balita
</Button>
</Group>
</Box>
</Stack>
)}
</Box>
);
}