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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user