feat(kesehatan): posyandu banjar relation, redesign halaman publik, fix tips keamanan image

- Tambah model Banjar + relasi ke Posyandu (migration + seeder)
- Update API posyandu (create/update/find) untuk support banjarId
- Tambah endpoint banjar di kesehatan API
- Redesign halaman publik posyandu dengan tabs: ringkasan, data posyandu, balita, ibu hamil
- Update halaman admin posyandu list/create/edit/detail untuk banjar
- Fix image ketukar pada seed tips keamanan
- Hapus seeder core yang sudah tidak dipakai

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 15:51:32 +08:00
parent 9de32e5f12
commit 97d08734c5
38 changed files with 1126 additions and 454 deletions

View File

@@ -82,7 +82,15 @@ const balitaState = proxy({
findMany: {
data: null as
| Prisma.BalitaGetPayload<{
include: { posyandu: { select: { id: true; name: true } } };
include: {
posyandu: {
select: {
id: true;
name: true;
banjar: { select: { id: true; name: true } };
};
};
};
}>[]
| null,
page: 1,

View File

@@ -72,7 +72,15 @@ const ibuHamilState = proxy({
findMany: {
data: null as
| Prisma.IbuHamilGetPayload<{
include: { posyandu: { select: { id: true; name: true } } };
include: {
posyandu: {
select: {
id: true;
name: true;
banjar: { select: { id: true; name: true } };
};
};
};
}>[]
| null,
page: 1,

View File

@@ -19,6 +19,7 @@ const defaultForm = {
deskripsi: "",
imageId: "",
jadwalPelayanan: "",
banjarId: "",
};
const posyandustate = proxy({
@@ -57,6 +58,7 @@ const posyandustate = proxy({
| Prisma.PosyanduGetPayload<{
include: {
image: true;
banjar: { select: { id: true; name: true } };
};
}>[]
| null,
@@ -92,10 +94,11 @@ const posyandustate = proxy({
},
},
findUnique: {
data: null as
data: null as
| Prisma.PosyanduGetPayload<{
include: {
image: true;
banjar: { select: { id: true; name: true } };
}
}> | null,
async load(id: string) {
@@ -176,6 +179,7 @@ const posyandustate = proxy({
deskripsi: data.deskripsi,
imageId: data.imageId || "",
jadwalPelayanan: data.jadwalPelayanan || "",
banjarId: data.banjarId || "",
};
return data;
} else {
@@ -210,6 +214,7 @@ const posyandustate = proxy({
deskripsi: this.form.deskripsi,
imageId: this.form.imageId,
jadwalPelayanan: this.form.jadwalPelayanan,
banjarId: this.form.banjarId || undefined,
}),
});

View File

@@ -18,14 +18,39 @@ const intPct = z
.min(0, { message: "Minimal 0" })
.max(100, { message: "Maksimal 100" });
type BanjarOption = { id: string; name: string };
const ringkasanKesehatanState = proxy({
banjarId: "" as string,
findBanjar: {
data: [] as BanjarOption[],
loading: false,
async load() {
try {
ringkasanKesehatanState.findBanjar.loading = true;
const res = await fetch(`/api/kesehatan/banjar/find-many`);
if (res.ok) {
const result = await res.json();
ringkasanKesehatanState.findBanjar.data = result?.data ?? [];
}
} catch (error) {
console.error("Error fetching banjar:", error);
} finally {
ringkasanKesehatanState.findBanjar.loading = false;
}
},
},
findStats: {
data: null as StatsData | null,
loading: false,
async load() {
try {
ringkasanKesehatanState.findStats.loading = true;
const res = await fetch(`/api/kesehatan/ringkasankesehatan/stats`);
const banjarId = ringkasanKesehatanState.banjarId;
const params = banjarId ? `?banjarId=${encodeURIComponent(banjarId)}` : "";
const res = await fetch(`/api/kesehatan/ringkasankesehatan/stats${params}`);
if (res.ok) {
const result = await res.json();
ringkasanKesehatanState.findStats.data = result?.data ?? null;

View File

@@ -115,13 +115,14 @@ function ListBalita({ search }: { search: string }) {
<Table highlightOnHover layout="fixed" withColumnBorders={false}>
<TableThead>
<TableTr>
<TableTh w="22%">Nama</TableTh>
<TableTh w="7%">JK</TableTh>
<TableTh w="12%">Tgl Lahir</TableTh>
<TableTh w="12%">Imunisasi</TableTh>
<TableTh w="10%">Gizi</TableTh>
<TableTh w="12%">Pemeriksaan</TableTh>
<TableTh w="11%">Stunting</TableTh>
<TableTh w="18%">Nama</TableTh>
<TableTh w="6%">JK</TableTh>
<TableTh w="11%">Tgl Lahir</TableTh>
<TableTh w="13%">Banjar</TableTh>
<TableTh w="10%">Imunisasi</TableTh>
<TableTh w="8%">Gizi</TableTh>
<TableTh w="10%">Pemeriksaan</TableTh>
<TableTh w="10%">Stunting</TableTh>
<TableTh w="14%">Aksi</TableTh>
</TableTr>
</TableThead>
@@ -136,6 +137,7 @@ function ListBalita({ search }: { search: string }) {
? new Date(d.tanggalLahir).toLocaleDateString('id-ID')
: '-'}
</TableTd>
<TableTd>{d.posyandu?.banjar?.name ?? '-'}</TableTd>
<TableTd>
<Badge color={d.imunisasiLengkap ? 'green' : 'red'} variant="light">
{d.imunisasiLengkap ? 'Lengkap' : 'Belum'}
@@ -190,7 +192,7 @@ function ListBalita({ search }: { search: string }) {
))
) : (
<TableTr>
<TableTd colSpan={8}>
<TableTd colSpan={9}>
<Center py="xl">
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data balita yang cocok
@@ -212,19 +214,22 @@ function ListBalita({ search }: { search: string }) {
<Text fz="sm" fw={600} mb={4}>
{d.nama}
</Text>
<Group gap="xs" mb={6}>
<Group gap="xs" mb={4}>
<Text fz="xs" c="dimmed">
{d.jenisKelamin}
</Text>
<Text fz="xs" c="dimmed">
·
</Text>
<Text fz="xs" c="dimmed">·</Text>
<Text fz="xs" c="dimmed">
{d.tanggalLahir
? new Date(d.tanggalLahir).toLocaleDateString('id-ID')
: '-'}
</Text>
</Group>
{d.posyandu?.banjar?.name && (
<Text fz="xs" c="dimmed" mb={4}>
Banjar: {d.posyandu.banjar.name}
</Text>
)}
<Group gap="xs" mb={8}>
<Badge
size="xs"

View File

@@ -117,12 +117,13 @@ function ListIbuHamil({ search }: { search: string }) {
<Table highlightOnHover layout="fixed" withColumnBorders={false}>
<TableThead>
<TableTr>
<TableTh w="25%">Nama</TableTh>
<TableTh w="18%">NIK</TableTh>
<TableTh w="17%">Usia Kehamilan</TableTh>
<TableTh w="15%">No. HP</TableTh>
<TableTh w="12%">Status</TableTh>
<TableTh w="13%">Aksi</TableTh>
<TableTh w="20%">Nama</TableTh>
<TableTh w="15%">NIK</TableTh>
<TableTh w="15%">Usia Kehamilan</TableTh>
<TableTh w="13%">No. HP</TableTh>
<TableTh w="15%">Banjar</TableTh>
<TableTh w="10%">Status</TableTh>
<TableTh w="12%">Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
@@ -133,6 +134,7 @@ function ListIbuHamil({ search }: { search: string }) {
<TableTd>{d.nik || '-'}</TableTd>
<TableTd>{d.usiaKehamilan} minggu</TableTd>
<TableTd>{d.noHp || '-'}</TableTd>
<TableTd>{d.posyandu?.banjar?.name ?? '-'}</TableTd>
<TableTd>
<Badge
color={STATUS_COLORS[d.status] ?? 'gray'}
@@ -172,7 +174,7 @@ function ListIbuHamil({ search }: { search: string }) {
))
) : (
<TableTr>
<TableTd colSpan={6}>
<TableTd colSpan={7}>
<Center py="xl">
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data ibu hamil yang cocok
@@ -194,17 +196,20 @@ function ListIbuHamil({ search }: { search: string }) {
<Text fz="sm" fw={600} mb={4}>
{d.nama}
</Text>
<Group gap="xs" mb={6}>
<Group gap="xs" mb={4}>
<Text fz="xs" c="dimmed">
NIK: {d.nik || '-'}
</Text>
<Text fz="xs" c="dimmed">
·
</Text>
<Text fz="xs" c="dimmed">·</Text>
<Text fz="xs" c="dimmed">
{d.usiaKehamilan} minggu
</Text>
</Group>
{d.posyandu?.banjar?.name && (
<Text fz="xs" c="dimmed" mb={4}>
Banjar: {d.posyandu.banjar.name}
</Text>
)}
<Group gap="xs" mb={8}>
<Badge
size="xs"

View File

@@ -2,6 +2,7 @@
'use client';
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
import posyandustate from '@/app/admin/(dashboard)/_state/kesehatan/posyandu/posyandu';
import ringkasanKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/ringkasan-kesehatan/ringkasanKesehatan';
import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch';
import {
@@ -12,6 +13,7 @@ import {
Image,
Loader,
Paper,
Select,
Stack,
Text,
TextInput,
@@ -26,6 +28,7 @@ import { useProxy } from 'valtio/utils';
function EditPosyandu() {
const statePosyandu = useProxy(posyandustate);
const stateBanjar = useProxy(ringkasanKesehatanState);
const router = useRouter();
const params = useParams();
@@ -57,6 +60,7 @@ function EditPosyandu() {
deskripsi: '',
imageId: '',
jadwalPelayanan: '',
banjarId: '',
});
const [originalData, setOriginalData] = useState({
name: "",
@@ -64,11 +68,13 @@ function EditPosyandu() {
deskripsi: "",
imageId: "",
jadwalPelayanan: "",
banjarId: "",
imageUrl: ""
});
// Load data posyandu
// Load data posyandu dan banjar
useEffect(() => {
ringkasanKesehatanState.findBanjar.load();
const loadPosyandu = async () => {
const id = params?.id as string;
if (!id) return;
@@ -82,6 +88,7 @@ function EditPosyandu() {
deskripsi: data.deskripsi || '',
imageId: data.imageId || '',
jadwalPelayanan: data.jadwalPelayanan || '',
banjarId: data.banjarId || '',
});
setOriginalData({
name: data.name || '',
@@ -89,6 +96,7 @@ function EditPosyandu() {
deskripsi: data.deskripsi || '',
imageId: data.imageId || '',
jadwalPelayanan: data.jadwalPelayanan || '',
banjarId: data.banjarId || '',
imageUrl: data.image?.link || '',
});
if (data?.image?.link) setPreviewImage(data.image.link);
@@ -129,7 +137,7 @@ function EditPosyandu() {
try {
setIsSubmitting(true);
const updatedForm = { ...statePosyandu.edit.form, ...formData };
const updatedForm = { ...statePosyandu.edit.form, ...formData, banjarId: formData.banjarId };
// Upload file jika ada
if (file) {
@@ -160,6 +168,7 @@ function EditPosyandu() {
deskripsi: originalData.deskripsi,
imageId: originalData.imageId,
jadwalPelayanan: originalData.jadwalPelayanan,
banjarId: originalData.banjarId,
});
setPreviewImage(originalData.imageUrl || null);
setFile(null);
@@ -282,6 +291,15 @@ function EditPosyandu() {
required
/>
<Select
label="Banjar"
placeholder="Pilih banjar"
clearable
data={stateBanjar.findBanjar.data.map((b) => ({ value: b.id, label: b.name }))}
value={formData.banjarId || null}
onChange={(val) => setFormData({ ...formData, banjarId: val ?? '' })}
/>
<Box>
<Text fw="bold" fz="sm" mb={6}>
Deskripsi Posyandu

View File

@@ -82,6 +82,11 @@ function DetailPosyandu() {
</Box>
<Box>
<Text fz="lg" fw="bold">Banjar</Text>
<Text fz="md" c="dimmed">{data.banjar?.name || '-'}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Nomor Posyandu</Text>
<Text fz="md" c="dimmed">{data.nomor || '-'}</Text>

View File

@@ -11,6 +11,7 @@ import {
Image,
Loader,
Paper,
Select,
Stack,
Text,
TextInput,
@@ -19,19 +20,23 @@ import {
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
import ringkasanKesehatanState from '../../../../_state/kesehatan/ringkasan-kesehatan/ringkasanKesehatan';
function CreatePosyandu() {
const statePosyandu = useProxy(posyandustate);
const stateBanjar = useProxy(ringkasanKesehatanState);
const router = useRouter();
const [file, setFile] = useState<File | null>(null);
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => { ringkasanKesehatanState.findBanjar.load(); }, []);
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
@@ -57,6 +62,7 @@ function CreatePosyandu() {
deskripsi: '',
imageId: '',
jadwalPelayanan: '',
banjarId: '',
};
setFile(null);
setPreviewImage(null);
@@ -223,6 +229,14 @@ function CreatePosyandu() {
onChange={(e) => (statePosyandu.create.form.nomor = e.target.value)}
required
/>
<Select
label="Banjar"
placeholder="Pilih banjar"
clearable
data={stateBanjar.findBanjar.data.map((b) => ({ value: b.id, label: b.name }))}
value={statePosyandu.create.form.banjarId || null}
onChange={(val) => { statePosyandu.create.form.banjarId = val ?? ''; }}
/>
<Box>
<Text fw="bold" fz="sm" mb={6}>
Deskripsi Posyandu

View File

@@ -96,27 +96,33 @@ function ListPosyandu({ search }: { search: string }) {
>
<TableThead>
<TableTr>
<TableTh w={220} fz="sm" fw={600} ta="left" lh={1.4}>Nama Posyandu</TableTh>
<TableTh w={220} fz="sm" fw={600} ta="left" lh={1.4}>Nomor Posyandu</TableTh>
<TableTh w={220} fz="sm" fw={600} ta="left" lh={1.4}>Deskripsi</TableTh>
<TableTh w={150} fz="sm" fw={600} ta="left" lh={1.4}>Aksi</TableTh>
<TableTh w={200} fz="sm" fw={600} ta="left" lh={1.4}>Nama Posyandu</TableTh>
<TableTh w={160} fz="sm" fw={600} ta="left" lh={1.4}>Banjar</TableTh>
<TableTh w={160} fz="sm" fw={600} ta="left" lh={1.4}>Nomor Posyandu</TableTh>
<TableTh w={200} fz="sm" fw={600} ta="left" lh={1.4}>Deskripsi</TableTh>
<TableTh w={120} fz="sm" fw={600} ta="left" lh={1.4}>Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd w={220}>
<TableTd w={200}>
<Text fz="md" fw={500} lh={1.5} truncate="end" lineClamp={1}>
{item.name}
</Text>
</TableTd>
<TableTd w={220}>
<TableTd w={160}>
<Text fz="sm" c={item.banjar ? undefined : 'dimmed'} lh={1.5}>
{item.banjar?.name || '-'}
</Text>
</TableTd>
<TableTd w={160}>
<Text fz="sm" c="dimmed" lh={1.5}>
{item.nomor || '-'}
</Text>
</TableTd>
<TableTd w={220}>
<TableTd w={200}>
<Text
fz="sm"
lh={1.5}
@@ -125,7 +131,7 @@ function ListPosyandu({ search }: { search: string }) {
dangerouslySetInnerHTML={{ __html: item.deskripsi }}
/>
</TableTd>
<TableTd w={220}>
<TableTd w={120}>
<Button
size="xs"
radius="md"
@@ -141,7 +147,7 @@ function ListPosyandu({ search }: { search: string }) {
))
) : (
<TableTr>
<TableTd colSpan={4}>
<TableTd colSpan={5}>
<Center py={20}>
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data posyandu yang cocok
@@ -169,6 +175,14 @@ function ListPosyandu({ search }: { search: string }) {
{item.name}
</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Banjar
</Text>
<Text fz="sm" fw={500} lh={1.5} c={item.banjar ? undefined : 'dimmed'}>
{item.banjar?.name || '-'}
</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Nomor Posyandu

View File

@@ -10,6 +10,7 @@ import {
Loader,
NumberInput,
Paper,
Select,
SimpleGrid,
Stack,
Text,
@@ -73,13 +74,29 @@ export default function RingkasanKesehatanPage() {
const stats = state.findStats.data;
const loadStats = useCallback(() => { ringkasanKesehatanState.findStats.load(); }, []);
useEffect(() => { loadStats(); }, [loadStats]);
useEffect(() => {
ringkasanKesehatanState.findBanjar.load();
loadStats();
}, [loadStats]);
const isLoading = state.findStats.loading;
return (
<Box px={{ base: 0, md: 'lg' }} py="xs">
<Title order={3} c="black" mb="md">Ringkasan Kesehatan Desa</Title>
<Group justify="space-between" align="flex-end" mb="md" wrap="wrap" gap="sm">
<Title order={3} c="black">Ringkasan Kesehatan Desa</Title>
<Select
placeholder="Semua Banjar"
clearable
data={state.findBanjar.data.map((b) => ({ value: b.id, label: b.name }))}
value={state.banjarId || null}
onChange={(val) => {
ringkasanKesehatanState.banjarId = val ?? "";
ringkasanKesehatanState.findStats.load();
}}
style={{ minWidth: 200 }}
/>
</Group>
{isLoading ? (
<Group justify="center" py="xl"><Loader /></Group>

View File

@@ -28,7 +28,15 @@ export default async function balitaFindMany(context: Context) {
const [data, total] = await Promise.all([
prisma.balita.findMany({
where,
include: { posyandu: { select: { id: true, name: true } } },
include: {
posyandu: {
select: {
id: true,
name: true,
banjar: { select: { id: true, name: true } },
},
},
},
skip,
take: limit,
orderBy: { createdAt: "desc" },

View File

@@ -0,0 +1,19 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
async function banjarFindMany(context: Context) {
try {
const data = await prisma.banjar.findMany({
where: { isActive: true },
select: { id: true, name: true },
orderBy: { name: "asc" },
});
return { success: true, data };
} catch (e) {
console.error("Error di banjarFindMany:", e);
return { success: false, message: "Gagal mengambil data banjar" };
}
}
export default banjarFindMany;

View File

@@ -0,0 +1,9 @@
import Elysia from "elysia";
import banjarFindMany from "./find-many";
const Banjar = new Elysia({
prefix: "/banjar",
tags: ["Kesehatan/Banjar"],
}).get("/find-many", banjarFindMany);
export default Banjar;

View File

@@ -27,7 +27,15 @@ export default async function ibuHamilFindMany(context: Context) {
const [data, total] = await Promise.all([
prisma.ibuHamil.findMany({
where,
include: { posyandu: { select: { id: true, name: true } } },
include: {
posyandu: {
select: {
id: true,
name: true,
banjar: { select: { id: true, name: true } },
},
},
},
skip,
take: limit,
orderBy: { createdAt: "desc" },

View File

@@ -24,6 +24,7 @@ import TarifLayanan from "./data_kesehatan_warga/fasilitas_kesehatan/tarif-layan
import RingkasanKesehatan from "./ringkasan-kesehatan";
import IbuHamil from "./ibu-hamil";
import Balita from "./balita";
import Banjar from "./banjar";
const Kesehatan = new Elysia({
@@ -55,4 +56,5 @@ const Kesehatan = new Elysia({
.use(RingkasanKesehatan)
.use(IbuHamil)
.use(Balita)
.use(Banjar)
export default Kesehatan;

View File

@@ -2,15 +2,14 @@ import prisma from "@/lib/prisma";
import { Prisma } from "@prisma/client";
import { Context } from "elysia";
type FormCreate = Prisma.PosyanduGetPayload<{
select: {
name: true;
nomor: true;
deskripsi: true;
imageId: true;
jadwalPelayanan: true;
};
}>;
type FormCreate = {
name: string;
nomor: string;
deskripsi: string;
imageId: string;
jadwalPelayanan: string;
banjarId?: string;
};
export default async function posyanduCreate(context: Context) {
const body = context.body as FormCreate;
@@ -21,6 +20,7 @@ export default async function posyanduCreate(context: Context) {
deskripsi: body.deskripsi,
imageId: body.imageId,
jadwalPelayanan: body.jadwalPelayanan,
banjarId: body.banjarId || null,
}
})
return {

View File

@@ -23,7 +23,8 @@ export default async function findPosyanduById(request: Request) {
const data = await prisma.posyandu.findUnique({
where: {id},
include: {
image: true
image: true,
banjar: { select: { id: true, name: true } },
}
})

View File

@@ -30,6 +30,7 @@ async function posyanduFindMany(context: Context) {
where,
include: {
image: true,
banjar: { select: { id: true, name: true } },
},
skip,
take: limit,

View File

@@ -16,6 +16,7 @@ const Posyandu = new Elysia({
deskripsi: t.String(),
imageId: t.String(),
jadwalPelayanan: t.String(),
banjarId: t.Optional(t.String()),
})
})
.get("/find-many", posyanduFindMany)
@@ -37,6 +38,7 @@ const Posyandu = new Elysia({
deskripsi: t.String(),
imageId: t.String(),
jadwalPelayanan: t.String(),
banjarId: t.Optional(t.String()),
})
}
)

View File

@@ -1,23 +1,20 @@
import prisma from "@/lib/prisma";
import { Prisma } from "@prisma/client";
import { Context } from "elysia";
import minio, { MINIO_BUCKET } from "@/lib/minio";
type FormUpdate = Prisma.PosyanduGetPayload<{
select: {
id: true;
name: true;
nomor: true;
deskripsi: true;
imageId: true;
jadwalPelayanan: true;
}
}>
type FormUpdate = {
name: string;
nomor: string;
deskripsi: string;
imageId: string;
jadwalPelayanan: string;
banjarId?: string;
};
export default async function posyanduUpdate(context: Context) {
try {
const id = context.params?.id as string;
const body = (await context.body) as Omit<FormUpdate, "id">;
const body = (await context.body) as FormUpdate;
const {
name,
@@ -25,6 +22,7 @@ export default async function posyanduUpdate(context: Context) {
deskripsi,
imageId,
jadwalPelayanan,
banjarId,
} = body;
if(!id) {
@@ -80,6 +78,7 @@ export default async function posyanduUpdate(context: Context) {
deskripsi,
imageId,
jadwalPelayanan,
banjarId: banjarId || null,
}
})

View File

@@ -5,7 +5,10 @@ import ringkasanKesehatanStats from "./stats";
const RingkasanKesehatan = new Elysia({ prefix: "/ringkasankesehatan", tags: ["Kesehatan/Ringkasan"] })
.get("/find", ringkasanKesehatanFindUnique)
.get("/stats", ringkasanKesehatanStats)
.get("/stats", (context) => {
const banjarId = (context.query.banjarId as string) || undefined;
return ringkasanKesehatanStats(banjarId);
})
.put("/update", ringkasanKesehatanUpdate, {
body: t.Object({
targetStuntingPct: t.Number({ minimum: 0, maximum: 100 }),

View File

@@ -10,8 +10,12 @@ type StatsResult = {
targetStuntingPct: number;
};
export default async function ringkasanKesehatanStats(): Promise<{ success: boolean; data?: StatsResult; message?: string }> {
export default async function ringkasanKesehatanStats(
banjarId?: string
): Promise<{ success: boolean; data?: StatsResult; message?: string }> {
try {
const posyanduFilter = banjarId ? { posyandu: { banjarId } } : {};
const [
ibuHamilAktif,
balitaTotal,
@@ -21,12 +25,12 @@ export default async function ringkasanKesehatanStats(): Promise<{ success: bool
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.ibuHamil.count({ where: { status: "AKTIF", isActive: true, ...posyanduFilter } }),
prisma.balita.count({ where: { isActive: true, ...posyanduFilter } }),
prisma.balita.count({ where: { isActive: true, statusStunting: { in: ["ALERT", "STUNTING"] }, ...posyanduFilter } }),
prisma.balita.count({ where: { isActive: true, imunisasiLengkap: true, ...posyanduFilter } }),
prisma.balita.count({ where: { isActive: true, pemeriksaanRutin: true, ...posyanduFilter } }),
prisma.balita.count({ where: { isActive: true, giziBaik: true, ...posyanduFilter } }),
prisma.ringkasanKesehatanDesa.findFirst({ where: { isActive: true }, orderBy: { createdAt: "desc" } }),
]);

View File

@@ -2,19 +2,14 @@
import colors from '@/con/colors';
import {
Button,
Center,
Flex,
Group,
Image,
Paper,
Skeleton,
Stack,
Text,
Title,
Box, Button, Flex, Group, Image,
Paper, Skeleton, Stack, Text, Title,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconCalendar, IconInfoCircle, IconPhone } from '@tabler/icons-react';
import {
IconArrowBack, IconCalendar, IconInfoCircle,
IconMapPin, IconPhone,
} from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useProxy } from 'valtio/utils';
import posyanduState from '@/app/admin/(dashboard)/_state/kesehatan/posyandu/posyandu';
@@ -30,8 +25,8 @@ export default function DetailPosyanduUser() {
if (!statePosyandu.findUnique.data) {
return (
<Stack py="xl" px={{ base: 'md', md: 100 }}>
<Skeleton height={500} radius="md" />
<Stack py="xl" px={{ base: 'md', md: 100 }} bg={colors.Bg} mih="100vh">
<Skeleton height={500} radius="lg" />
</Stack>
);
}
@@ -39,97 +34,104 @@ export default function DetailPosyanduUser() {
const data = statePosyandu.findUnique.data;
return (
<Stack pos="relative" bg={colors.Bg} py="xl" px={{ base: 'md', md: 100 }} gap="xl">
{/* Tombol Kembali */}
<Stack pos="relative" bg={colors.Bg} py="xl" px={{ base: 'md', md: 100 }} gap="xl" mih="100vh">
<Group>
<Button
variant="subtle"
onClick={() => router.back()}
leftSection={<IconArrowBack size={22} color={colors['blue-button']} />}
mb="sm"
c={colors['blue-button']}
>
Kembali
</Button>
</Group>
{/* Konten utama */}
<Paper
withBorder
p="xl"
radius="lg"
shadow="md"
bg={colors['white-trans-1']}
bg="white"
maw={800}
mx="auto"
w="100%"
>
<Stack gap="md">
{/* Header — Dijadikan Title */}
<Title
ta="center"
order={1}
c={colors['blue-button']}
>
<Stack gap="lg">
<Title ta="center" order={2} c={colors['blue-button']}>
{data.name || 'Posyandu Desa'}
</Title>
{/* Gambar */}
{data.image?.link ? (
<Center>
<Image
src={data.image?.link ?? "/no-image.jpg"}
alt={`Gambar ${data.name}`}
w="100%"
h={300}
radius="md"
fit="cover"
loading="lazy"
/>
</Center>
<Image
src={data.image.link}
alt={`Gambar ${data.name}`}
w="100%"
h={300}
radius="md"
fit="cover"
loading="lazy"
/>
) : (
<Center>
<Text
fz={{ base: 'xs', md: 'sm' }}
c="dimmed"
ta="center"
>
Tidak ada gambar
</Text>
</Center>
<Box
h={200}
style={{
background: '#f4f5f6',
borderRadius: 12,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text fz="sm" c="dimmed">Tidak ada gambar</Text>
</Box>
)}
{/* Info utama */}
<Stack gap="sm" mt="md">
<Stack gap="sm" mt="sm">
{data.banjar && (
<Flex align="center" gap="xs">
<IconMapPin size={18} stroke={1.5} color={colors['blue-button']} />
<Text fz={{ base: 'sm', md: 'md' }} c="black">
Banjar {data.banjar.name}
</Text>
</Flex>
)}
<Flex align="center" gap="xs">
<IconPhone size={18} stroke={1.5} />
<Text
fz={{ base: 'sm', md: 'md' }}
c="black"
lh={{ base: 1.5, md: 1.6 }}
>
<IconPhone size={18} stroke={1.5} color="gray" />
<Text fz={{ base: 'sm', md: 'md' }} c="black">
{data.nomor || 'Nomor tidak tersedia'}
</Text>
</Flex>
<Flex align="center" gap="xs">
<IconCalendar size={18} stroke={1.5} />
<Flex align="flex-start" gap="xs">
<IconCalendar
size={18}
stroke={1.5}
color="gray"
style={{ marginTop: 2, flexShrink: 0 }}
/>
<Text
fz={{ base: 'sm', md: 'md' }}
c="black"
lh={{ base: 1.5, md: 1.6 }}
dangerouslySetInnerHTML={{ __html: data.jadwalPelayanan || '-' }}
style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}
lh={1.6}
dangerouslySetInnerHTML={{ __html: data.jadwalPelayanan || '' }}
style={{ wordBreak: 'break-word' }}
/>
</Flex>
<Flex align="center" gap="xs">
<IconInfoCircle size={18} stroke={1.5} />
<Flex align="flex-start" gap="xs">
<IconInfoCircle
size={18}
stroke={1.5}
color="gray"
style={{ marginTop: 2, flexShrink: 0 }}
/>
<Text
fz={{ base: 'sm', md: 'md' }}
c="black"
lh={{ base: 1.5, md: 1.6 }}
dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }}
style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}
lh={1.6}
dangerouslySetInnerHTML={{ __html: data.deskripsi || '' }}
style={{ wordBreak: 'break-word' }}
/>
</Flex>
</Stack>
@@ -137,4 +139,4 @@ export default function DetailPosyanduUser() {
</Paper>
</Stack>
);
}
}

View File

@@ -1,213 +1,651 @@
'use client'
import balitaState from "@/app/admin/(dashboard)/_state/kesehatan/balita/balita";
import ibuHamilState from "@/app/admin/(dashboard)/_state/kesehatan/ibu-hamil/ibuHamil";
import posyandustate from "@/app/admin/(dashboard)/_state/kesehatan/posyandu/posyandu";
import ringkasanState from "@/app/admin/(dashboard)/_state/kesehatan/ringkasan-kesehatan/ringkasanKesehatan";
import colors from "@/con/colors";
import { Badge, Box, Button, Center, Flex, Group, Image, List, ListItem, Pagination, Paper, SimpleGrid, Skeleton, Stack, Text, TextInput, Title } from "@mantine/core";
import {
Badge, Box, Button, Center, Flex, Group, Image,
Pagination, Paper, Progress, ScrollArea, Select,
SimpleGrid, Skeleton, Stack, Tabs, Text, TextInput,
ThemeIcon, Title,
} from "@mantine/core";
import { useDebouncedValue, useShallowEffect } from "@mantine/hooks";
import { IconCalendar, IconInfoCircle, IconPhone, IconSearch } from "@tabler/icons-react";
import {
IconActivity, IconCalendar, IconChartBar, IconHeart,
IconMapPin, IconPhone, IconSearch, IconShieldCheck,
IconUsers,
} from "@tabler/icons-react";
import { useState } from "react";
import { useProxy } from "valtio/utils";
import BackButton from "../../desa/layanan/_com/BackButto";
import { useTransitionRouter } from "next-view-transitions";
const stuntingBadgeColor = (s: string) =>
s === "STUNTING" ? "red" : s === "ALERT" ? "yellow" : "green";
const ibuHamilBadgeColor = (s: string) => {
if (s === "AKTIF") return "green";
if (s === "MELAHIRKAN") return "blue";
if (s === "KEGUGURAN") return "gray";
return "red";
};
export default function Page() {
const state = useProxy(posyandustate);
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 1000);
const [activeTab, setActiveTab] = useState<string | null>("ringkasan");
const stPosyandu = useProxy(posyandustate);
const stBalita = useProxy(balitaState);
const stIbuHamil = useProxy(ibuHamilState);
const stRingkasan = useProxy(ringkasanState);
const [searchPosyandu, setSearchPosyandu] = useState("");
const [debouncedSearchPosyandu] = useDebouncedValue(searchPosyandu, 800);
const [searchBalita, setSearchBalita] = useState("");
const [debouncedSearchBalita] = useDebouncedValue(searchBalita, 800);
const [filterStunting, setFilterStunting] = useState<string | null>(null);
const [searchIbuHamil, setSearchIbuHamil] = useState("");
const [debouncedSearchIbuHamil] = useDebouncedValue(searchIbuHamil, 800);
const [filterStatusIbuHamil, setFilterStatusIbuHamil] = useState<string | null>(null);
const router = useTransitionRouter();
const { data, page, totalPages, loading, load } = state.findMany;
// Initial load: ringkasan
useShallowEffect(() => {
ringkasanState.findStats.load();
}, []);
// Load data sesuai tab aktif
useShallowEffect(() => {
if (activeTab === "ringkasan") {
ringkasanState.findStats.load();
} else if (activeTab === "posyandu") {
posyandustate.findMany.load(1, 6, debouncedSearchPosyandu);
} else if (activeTab === "balita") {
balitaState.findMany.load(1, 10, debouncedSearchBalita, filterStunting ?? "");
} else if (activeTab === "ibu-hamil") {
ibuHamilState.findMany.load(1, 10, debouncedSearchIbuHamil, filterStatusIbuHamil ?? "");
}
}, [activeTab]);
useShallowEffect(() => {
load(page, 6, debouncedSearch);
}, [page, debouncedSearch]);
if (activeTab === "posyandu") {
posyandustate.findMany.load(1, 6, debouncedSearchPosyandu);
}
}, [debouncedSearchPosyandu]);
if (loading || !data) {
return (
<Box py="xl" px={{ base: "md", md: 100 }}>
<Skeleton h={500} radius="lg" />
</Box>
);
}
useShallowEffect(() => {
if (activeTab === "balita") {
balitaState.findMany.load(1, 10, debouncedSearchBalita, filterStunting ?? "");
}
}, [debouncedSearchBalita, filterStunting]);
if (data.length === 0) {
return (
<Stack pos="relative" bg={colors.Bg} py="xl" gap="xl">
<Box px={{ base: "md", md: 100 }}>
<BackButton />
<Flex mt="md" justify="space-between" align="center" wrap="wrap" gap="md">
<Title
order={1}
ta="left"
c={colors["blue-button"]}
>
Posyandu Desa Darmasaba
</Title>
<TextInput
placeholder="Cari posyandu berdasarkan nama..."
aria-label="Pencarian Posyandu"
radius="xl"
size="md"
leftSection={<IconSearch size={20} />}
w={{ base: "100%", md: "35%" }}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
</Flex>
</Box>
<Title c={"dimmed"} order={3} ta={"center"}>Belum ada posyandu yang terdaftar</Title>
</Stack>
);
}
useShallowEffect(() => {
if (activeTab === "ibu-hamil") {
ibuHamilState.findMany.load(1, 10, debouncedSearchIbuHamil, filterStatusIbuHamil ?? "");
}
}, [debouncedSearchIbuHamil, filterStatusIbuHamil]);
const stats = stRingkasan.findStats.data;
return (
<Stack pos="relative" bg={colors.Bg} py="xl" gap="xl">
<Stack pos="relative" bg={colors.Bg} py="xl" gap="xl" mih="100vh">
{/* Header */}
<Box px={{ base: "md", md: 100 }}>
<BackButton />
<Flex mt="md" justify="space-between" align="center" wrap="wrap" gap="md">
<Title
order={1}
ta="left"
c={colors["blue-button"]}
>
Posyandu Desa Darmasaba
<Stack gap={4} mt="md">
<Title order={1} c={colors["blue-button"]}>
Kesehatan Posyandu
</Title>
<TextInput
placeholder="Cari posyandu berdasarkan nama..."
aria-label="Pencarian Posyandu"
radius="xl"
size="md"
leftSection={<IconSearch size={20} />}
w={{ base: "100%", md: "35%" }}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
</Flex>
<Text fz={{ base: "sm", md: "md" }} c="dimmed">
Informasi posyandu, data balita, ibu hamil, dan ringkasan kesehatan Desa Darmasaba
</Text>
</Stack>
</Box>
{/* Tabs */}
<Box px={{ base: "md", md: 100 }}>
<Stack gap="xl">
<SimpleGrid
pb="lg"
cols={{ base: 1, sm: 2, md: 3 }}
spacing="lg"
<Tabs
variant="pills"
color={colors["blue-button"]}
radius="lg"
value={activeTab}
onChange={setActiveTab}
>
<Box mb="md"
style={{
overflowX: 'auto',
whiteSpace: 'nowrap',
WebkitOverflowScrolling: 'touch', // ✅ smooth scroll di iOS
msOverflowStyle: 'none', // ✅ sembunyikan scrollbar di IE/Edge
scrollbarWidth: 'none', // ✅ sembunyikan scrollbar di Firefox
}}
>
{data?.map((v, k) => (
<Paper
key={k}
p="xl"
radius="lg"
shadow="md"
withBorder
bg={colors["white-trans-1"]}
style={{
transition: "transform 0.2s ease, box-shadow 0.2s ease",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.transform = "translateY(-4px)";
(e.currentTarget as HTMLElement).style.boxShadow =
"0 8px 24px rgba(0,0,0,0.12)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.transform = "translateY(0)";
(e.currentTarget as HTMLElement).style.boxShadow =
"0 4px 12px rgba(0,0,0,0.08)";
}}
>
<Stack gap="sm">
<Group justify="space-between" align="center">
<Title order={3} c={colors["blue-button"]} fw="bold" lineClamp={1}>
{v.name}
</Title>
<Badge color="blue" variant="light" size="sm" radius="sm">
Aktif
</Badge>
</Group>
<Tabs.List
style={{
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem",
padding: "0.25rem",
display: 'inline-flex', // ✅ lebih tepat dari 'flex' untuk nowrap
flexWrap: 'nowrap',
gap: '0.5rem',
}}
>
<Tabs.Tab value="ringkasan" leftSection={<IconChartBar size={16} />}>
Ringkasan
</Tabs.Tab>
<Tabs.Tab value="posyandu" leftSection={<IconMapPin size={16} />}>
Data Posyandu
</Tabs.Tab>
<Tabs.Tab value="balita" leftSection={<IconUsers size={16} />}>
Balita
</Tabs.Tab>
<Tabs.Tab value="ibu-hamil" leftSection={<IconHeart size={16} />}>
Ibu Hamil
</Tabs.Tab>
</Tabs.List>
</Box>
{/* ===== RINGKASAN ===== */}
<Tabs.Panel value="ringkasan" pt="md">
{stRingkasan.findStats.loading ? (
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} h={120} radius="lg" />
))}
</SimpleGrid>
) : !stats ? (
<Paper p="xl" radius="lg" withBorder bg="white" ta="center">
<Text c="dimmed">Data ringkasan belum tersedia</Text>
</Paper>
) : (
<Stack gap="lg">
{/* KPI utama */}
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
{[
{
label: "Ibu Hamil Aktif",
value: stats.ibuHamilAktif,
color: colors["blue-button"],
icon: <IconHeart size={28} />,
},
{
label: "Balita Terdaftar",
value: stats.balitaTerdaftar,
color: "#22c55e",
icon: <IconUsers size={28} />,
},
{
label: "Alert Stunting",
value: stats.alertStunting,
color: "#ef4444",
icon: <IconActivity size={28} />,
},
].map((s, i) => (
<Paper key={i} p="xl" radius="lg" withBorder bg="white" shadow="sm">
<Group gap="md">
<ThemeIcon size={52} radius="lg" color={s.color} variant="light">
{s.icon}
</ThemeIcon>
<Stack gap={4}>
<Text fz="2rem" fw={700} c={s.color} lh={1}>
{s.value}
</Text>
<Text fz="sm" c="dimmed">{s.label}</Text>
</Stack>
</Group>
</Paper>
))}
</SimpleGrid>
{/* Persentase */}
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
{[
{
label: "Imunisasi Lengkap",
pct: stats.imunisasiLengkapPct,
color: colors["blue-button"],
icon: <IconShieldCheck size={20} />,
},
{
label: "Gizi Baik",
pct: stats.giziBaikPct,
color: "#22c55e",
icon: <IconHeart size={20} />,
},
{
label: "Pemeriksaan Rutin",
pct: stats.pemeriksaanRutinPct,
color: "#f59e0b",
icon: <IconActivity size={20} />,
},
].map((s, i) => (
<Paper key={i} p="lg" radius="lg" withBorder bg="white" shadow="sm">
<Stack gap="sm">
<Group gap="xs">
<ThemeIcon size={32} radius="md" color={s.color} variant="light">
{s.icon}
</ThemeIcon>
<Text fz="sm" fw={600}>{s.label}</Text>
</Group>
<Group gap="xs" align="center">
<Progress
value={s.pct}
color={s.color}
radius="xl"
size="md"
style={{ flex: 1 }}
/>
<Text fz="sm" fw={700} c={s.color} w={40} ta="right">
{s.pct}%
</Text>
</Group>
</Stack>
</Paper>
))}
</SimpleGrid>
</Stack>
)}
</Tabs.Panel>
{/* ===== DATA POSYANDU ===== */}
<Tabs.Panel value="posyandu" pt="md">
<Stack gap="md">
<TextInput
placeholder="Cari nama posyandu..."
radius="xl"
leftSection={<IconSearch size={18} />}
value={searchPosyandu}
onChange={(e) => setSearchPosyandu(e.currentTarget.value)}
w={{ base: "100%", md: "40%" }}
/>
{stPosyandu.findMany.loading ? (
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="lg">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} h={300} radius="lg" />
))}
</SimpleGrid>
) : !stPosyandu.findMany.data?.length ? (
<Paper p="xl" radius="lg" withBorder bg="white" ta="center">
<Text c="dimmed">Tidak ada posyandu ditemukan</Text>
</Paper>
) : (
<>
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="lg">
{stPosyandu.findMany.data.map((v, k) => (
<Paper
key={k}
p="xl"
radius="lg"
shadow="sm"
withBorder
bg="white"
style={{ transition: "transform 0.2s, box-shadow 0.2s" }}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.transform = "translateY(-4px)";
(e.currentTarget as HTMLElement).style.boxShadow =
"0 8px 24px rgba(0,0,0,0.12)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.transform = "translateY(0)";
(e.currentTarget as HTMLElement).style.boxShadow = "";
}}
>
<Stack gap="sm">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Title
order={4}
c={colors["blue-button"]}
lineClamp={1}
style={{ flex: 1 }}
>
{v.name}
</Title>
<Badge
color="green"
variant="light"
size="sm"
radius="sm"
style={{ flexShrink: 0 }}
>
Aktif
</Badge>
</Group>
<Image
src={v.image?.link ?? "/no-image.jpg"}
alt={`Gambar ${v.name}`}
radius="md"
h={160}
fit="cover"
/>
{v.banjar && (
<Flex align="center" gap="xs">
<IconMapPin size={16} stroke={1.5} color={colors["blue-button"]} />
<Text fz="sm" c="dimmed">Banjar {v.banjar.name}</Text>
</Flex>
)}
<Flex align="center" gap="xs">
<IconPhone size={16} stroke={1.5} color="gray" />
<Text fz="sm" c="dimmed">{v.nomor || ""}</Text>
</Flex>
<Flex align="flex-start" gap="xs">
<IconCalendar
size={16}
stroke={1.5}
color="gray"
style={{ marginTop: 2, flexShrink: 0 }}
/>
<Text
fz="sm"
c="dimmed"
lh={1.5}
lineClamp={2}
dangerouslySetInnerHTML={{ __html: v.jadwalPelayanan }}
style={{ wordBreak: "break-word" }}
/>
</Flex>
<Button
radius="lg"
variant="outline"
color={colors["blue-button"]}
fullWidth
mt="xs"
onClick={() =>
router.push(`/darmasaba/kesehatan/posyandu/${v.id}`)
}
>
Lihat Detail
</Button>
</Stack>
</Paper>
))}
</SimpleGrid>
<Center>
<Image
src={v.image?.link ?? "/no-image.jpg"}
alt={`Gambar ${v.name}`}
radius="md"
w="100%"
h={180}
fit="cover"
loading="lazy"
<Pagination
value={stPosyandu.findMany.page}
onChange={(p) =>
posyandustate.findMany.load(p, 6, searchPosyandu)
}
total={stPosyandu.findMany.totalPages}
radius="lg"
color={colors["blue-button"]}
/>
</Center>
<Flex align="flex-start" gap="xs">
<IconPhone size={18} stroke={1.5} style={{ marginTop: 3 }} />
<Text
fz={{ base: "sm", md: "md" }}
c="black"
lh={{ base: 1.4, md: 1.5 }}
>
{v.nomor || "Tidak tersedia"}
</Text>
</Flex>
</>
)}
</Stack>
</Tabs.Panel>
<Flex align="flex-start" gap="xs">
<IconCalendar size={18} stroke={1.5} style={{ marginTop: 3 }} />
<Text
fz={{ base: "sm", md: "md" }}
c="black"
lh={{ base: 1.4, md: 1.5 }}
>
<strong>Jadwal:</strong>{" "}
<span
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
dangerouslySetInnerHTML={{ __html: v.jadwalPelayanan }}
/>
</Text>
</Flex>
{/* ===== BALITA ===== */}
<Tabs.Panel value="balita" pt="md">
<Stack gap="md">
<Group wrap="wrap">
<TextInput
placeholder="Cari nama, orang tua..."
radius="xl"
leftSection={<IconSearch size={18} />}
value={searchBalita}
onChange={(e) => setSearchBalita(e.currentTarget.value)}
style={{ flex: 1, minWidth: 200 }}
/>
<Select
placeholder="Semua status stunting"
clearable
radius="xl"
data={[
{ value: "NORMAL", label: "Normal" },
{ value: "ALERT", label: "Alert" },
{ value: "STUNTING", label: "Stunting" },
]}
value={filterStunting}
onChange={setFilterStunting}
w={{ base: "100%", sm: 200 }}
/>
</Group>
<Flex align="flex-start" gap="xs">
<IconInfoCircle size={18} stroke={1.5} style={{ marginTop: 3 }} />
<Text
fz={{ base: "sm", md: "md" }}
lh={{ base: 1.4, md: 1.5 }}
c="black"
dangerouslySetInnerHTML={{ __html: v.deskripsi }}
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
lineClamp={3}
truncate="end"
/>
</Flex>
<Button radius="lg" size="md" variant="outline" onClick={() => router.push(`/darmasaba/kesehatan/posyandu/${v.id}`)}>
Detail
</Button>
{stBalita.findMany.loading ? (
<Stack gap="sm">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} h={100} radius="lg" />
))}
</Stack>
</Paper>
))}
</SimpleGrid>
) : !stBalita.findMany.data?.length ? (
<Paper p="xl" radius="lg" withBorder bg="white" ta="center">
<Text c="dimmed">Tidak ada data balita ditemukan</Text>
</Paper>
) : (
<>
<Text fz="sm" c="dimmed">
Total: <strong>{stBalita.findMany.total}</strong> balita terdaftar
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{stBalita.findMany.data.map((v, i) => (
<Paper key={i} p="md" radius="lg" withBorder bg="white" shadow="xs">
<Stack gap="xs">
<Group
justify="space-between"
align="flex-start"
wrap="nowrap"
>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text fw={600} fz="sm" lineClamp={1}>{v.nama}</Text>
<Text fz="xs" c="dimmed">
{v.jenisKelamin === "L" ? "Laki-laki" : "Perempuan"}
{v.tanggalLahir
? ` · ${new Date(v.tanggalLahir).toLocaleDateString("id-ID", {
day: "numeric",
month: "long",
year: "numeric",
})}`
: ""}
</Text>
</Stack>
<Badge
color={stuntingBadgeColor(v.statusStunting)}
variant="light"
size="sm"
radius="sm"
style={{ flexShrink: 0 }}
>
{v.statusStunting}
</Badge>
</Group>
<Center>
<Pagination
value={page}
onChange={(newPage) => load(newPage)}
total={totalPages}
radius="lg"
size="md"
withControls
mt="md"
/>
</Center>
{v.posyandu && (
<Flex align="center" gap={4}>
<IconMapPin size={13} color="gray" />
<Text fz="xs" c="dimmed">
{v.posyandu.name}
{v.posyandu.banjar
? ` · Banjar ${v.posyandu.banjar.name}`
: ""}
</Text>
</Flex>
)}
<Stack gap="sm">
<Flex align="center" gap="xs">
<IconInfoCircle size={22} color={colors["blue-button"]} />
<Title order={2} c={colors["blue-button"]}>
Layanan Utama Posyandu
</Title>
</Flex>
<List spacing="xs" fz={{ base: "sm", md: "md" }} lh={{ base: 1.4, md: 1.5 }} c="black">
<ListItem>Penimbangan bayi dan balita</ListItem>
<ListItem>Pemantauan status gizi</ListItem>
<ListItem>Imunisasi dasar lengkap</ListItem>
<ListItem>Konseling kesehatan</ListItem>
<ListItem>Pemantauan kesehatan ibu hamil</ListItem>
</List>
</Stack>
</Stack>
<Group gap="xs">
<Badge
color={v.imunisasiLengkap ? "blue" : "gray"}
variant="dot"
size="xs"
>
{v.imunisasiLengkap
? "Imunisasi Lengkap"
: "Imunisasi Belum Lengkap"}
</Badge>
<Badge
color={v.giziBaik ? "green" : "orange"}
variant="dot"
size="xs"
>
{v.giziBaik ? "Gizi Baik" : "Gizi Kurang"}
</Badge>
<Badge
color={v.pemeriksaanRutin ? "teal" : "gray"}
variant="dot"
size="xs"
>
{v.pemeriksaanRutin ? "Periksa Rutin" : "Tidak Rutin"}
</Badge>
</Group>
</Stack>
</Paper>
))}
</SimpleGrid>
<Center>
<Pagination
value={stBalita.findMany.page}
onChange={(p) =>
balitaState.findMany.load(
p,
10,
searchBalita,
filterStunting ?? ""
)
}
total={stBalita.findMany.totalPages}
radius="lg"
color={colors["blue-button"]}
/>
</Center>
</>
)}
</Stack>
</Tabs.Panel>
{/* ===== IBU HAMIL ===== */}
<Tabs.Panel value="ibu-hamil" pt="md">
<Stack gap="md">
<Group wrap="wrap">
<TextInput
placeholder="Cari nama..."
radius="xl"
leftSection={<IconSearch size={18} />}
value={searchIbuHamil}
onChange={(e) => setSearchIbuHamil(e.currentTarget.value)}
style={{ flex: 1, minWidth: 200 }}
/>
<Select
placeholder="Semua status"
clearable
radius="xl"
data={[
{ value: "AKTIF", label: "Aktif" },
{ value: "MELAHIRKAN", label: "Melahirkan" },
{ value: "KEGUGURAN", label: "Keguguran" },
{ value: "NONAKTIF", label: "Nonaktif" },
]}
value={filterStatusIbuHamil}
onChange={setFilterStatusIbuHamil}
w={{ base: "100%", sm: 200 }}
/>
</Group>
{stIbuHamil.findMany.loading ? (
<Stack gap="sm">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} h={100} radius="lg" />
))}
</Stack>
) : !stIbuHamil.findMany.data?.length ? (
<Paper p="xl" radius="lg" withBorder bg="white" ta="center">
<Text c="dimmed">Tidak ada data ibu hamil ditemukan</Text>
</Paper>
) : (
<>
<Text fz="sm" c="dimmed">
Total: <strong>{stIbuHamil.findMany.total}</strong> data ibu hamil
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{stIbuHamil.findMany.data.map((v, i) => (
<Paper key={i} p="md" radius="lg" withBorder bg="white" shadow="xs">
<Stack gap="xs">
<Group
justify="space-between"
align="flex-start"
wrap="nowrap"
>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text fw={600} fz="sm" lineClamp={1}>{v.nama}</Text>
<Text fz="xs" c="dimmed">
Usia kehamilan: {v.usiaKehamilan} minggu
</Text>
</Stack>
<Badge
color={ibuHamilBadgeColor(v.status)}
variant="light"
size="sm"
radius="sm"
style={{ flexShrink: 0 }}
>
{v.status}
</Badge>
</Group>
{v.posyandu && (
<Flex align="center" gap={4}>
<IconMapPin size={13} color="gray" />
<Text fz="xs" c="dimmed">
{v.posyandu.name}
{v.posyandu.banjar
? ` · Banjar ${v.posyandu.banjar.name}`
: ""}
</Text>
</Flex>
)}
{v.taksiranLahir && (
<Flex align="center" gap={4}>
<IconCalendar size={13} color="gray" />
<Text fz="xs" c="dimmed">
Taksiran lahir:{" "}
{new Date(v.taksiranLahir).toLocaleDateString("id-ID", {
day: "numeric",
month: "long",
year: "numeric",
})}
</Text>
</Flex>
)}
</Stack>
</Paper>
))}
</SimpleGrid>
<Center>
<Pagination
value={stIbuHamil.findMany.page}
onChange={(p) =>
ibuHamilState.findMany.load(
p,
10,
searchIbuHamil,
filterStatusIbuHamil ?? ""
)
}
total={stIbuHamil.findMany.totalPages}
radius="lg"
color={colors["blue-button"]}
/>
</Center>
</>
)}
</Stack>
</Tabs.Panel>
</Tabs>
</Box>
</Stack>
);
}
}