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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" },
|
||||
|
||||
19
src/app/api/[[...slugs]]/_lib/kesehatan/banjar/find-many.ts
Normal file
19
src/app/api/[[...slugs]]/_lib/kesehatan/banjar/find-many.ts
Normal 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;
|
||||
9
src/app/api/[[...slugs]]/_lib/kesehatan/banjar/index.ts
Normal file
9
src/app/api/[[...slugs]]/_lib/kesehatan/banjar/index.ts
Normal 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;
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 } },
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ async function posyanduFindMany(context: Context) {
|
||||
where,
|
||||
include: {
|
||||
image: true,
|
||||
banjar: { select: { id: true, name: true } },
|
||||
},
|
||||
skip,
|
||||
take: limit,
|
||||
|
||||
@@ -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()),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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" } }),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user