feat(kesehatan): refactor ringkasan kesehatan to auto-derived stats

- Add IbuHamil and Balita models to schema.prisma
- Implement IbuHamil and Balita API modules (CRUD)
- Implement /stats endpoint for aggregated health KPIs
- Refactor ringkasan-kesehatan admin page to dashboard-style UI
- Update sidebar with Ibu Hamil and Balita entries
- Fix type errors and icon exports in admin UI
- Bump version to 0.1.52
This commit is contained in:
2026-05-04 16:52:14 +08:00
parent fc6846f7a1
commit dccba1f82b
30 changed files with 2706 additions and 197 deletions

View File

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