diff --git a/app/dashboard/kelola-data/[id]/CetakPDFButton.tsx b/app/dashboard/kelola-data/[id]/CetakPDFButton.tsx index 8e25688..434b73f 100644 --- a/app/dashboard/kelola-data/[id]/CetakPDFButton.tsx +++ b/app/dashboard/kelola-data/[id]/CetakPDFButton.tsx @@ -25,6 +25,8 @@ interface Pengguna { nama_anak: string jenis_kelamin: string | null tanggal_lahir: string | null + username?: string | null + password?: string | null } interface Props { @@ -139,12 +141,12 @@ export function CetakPDFButton({ row, allData, pengguna }: Props) { backgroundColor: '#ffffff', fontFamily: 'Arial, Helvetica, sans-serif', color: '#111111', - padding: '48px 56px', + padding: '32px 48px', boxSizing: 'border-box', }} > {/* ── Header ── */} -
+
Sistem Informasi Posyandu @@ -155,18 +157,18 @@ export function CetakPDFButton({ row, allData, pengguna }: Props) {
Tanggal Cetak
-
{tanggalCetak}
+
{tanggalCetak}
Tgl Pemeriksaan
-
{tanggalUpload}
+
{tanggalUpload}
{/* ── Identitas ── */} -
-
+
+
Identitas
-
+
{[ ['Nama Ibu / Orang Tua', pengguna.nama_orang_tua], ['Nama Anak', pengguna.nama_anak], @@ -176,9 +178,9 @@ export function CetakPDFButton({ row, allData, pengguna }: Props) { ['Tanggal Lahir', tanggalLahir], ].map(([label, value], i) => ( label ? ( -
-
{label}
-
{value}
+
+
{label}
+
{value}
) :
))} @@ -186,15 +188,15 @@ export function CetakPDFButton({ row, allData, pengguna }: Props) {
{/* ── Charts ── */} -
-
+
+
Grafik Perkembangan Balita (5 Bulan Terakhir)
-
+
{/* Tinggi */} -
-
📏 Tinggi Badan (cm)
- +
+
📏 Tinggi Badan (cm)
+ @@ -203,19 +205,19 @@ export function CetakPDFButton({ row, allData, pengguna }: Props) { - - + +
{/* Berat */} -
-
⚖️ Berat Badan (kg)
- +
+
⚖️ Berat Badan (kg)
+ @@ -224,10 +226,10 @@ export function CetakPDFButton({ row, allData, pengguna }: Props) { - - + + @@ -237,28 +239,28 @@ export function CetakPDFButton({ row, allData, pengguna }: Props) {
{/* ── Data Pemeriksaan ── */} -
-
+
+
Data Pemeriksaan
- +
{['Tinggi Badan', 'Berat Badan', 'Status Stunting', 'Posyandu', 'Tgl Pemeriksaan'].map(h => ( - + ))} - - - + + - - + +
{h}{h}
{row.tinggi_badan ?? '-'} {row.tinggi_badan ? 'cm' : ''}{row.berat_badan ?? '-'} {row.berat_badan ? 'kg' : ''} + {row.tinggi_badan ?? '-'} {row.tinggi_badan ? 'cm' : ''}{row.berat_badan ?? '-'} {row.berat_badan ? 'kg' : ''} {row.nama_posyandu ?? '-'}{tanggalUpload}{row.nama_posyandu ?? '-'}{tanggalUpload}
{/* Pesan AI */} {row.pesan_ai && ( -
-
- Pesan Kecerdasan Buatan (AI) +
+
+ Rekomendasi / Pesan AI
-
{row.pesan_ai}
+
{row.pesan_ai}
)}
- {/* ── Footer / Tanda Tangan ── */} -
+ {/* ── WhatsApp Info Box ── */} +
+
📱
-
Mengetahui,
-
-
Kepala Puskesmas / Supervisor
+
Layanan Informasi WhatsApp
+
+ Untuk orang tua yang tidak memiliki akun WhatsApp, yuk segera buat akun karena kami melayani layanan penyampaian informasi hasil stunting dengan menggunakan WhatsApp agar mendapatkan informasi lebih cepat.
-
-
Petugas Posyandu,
-
-
Nama & Tanda Tangan
+
+ + {/* ── Portal Access Info Box ── */} +
+
+ 🌐 Akses Portal Online Orang Tua +
+
+ * Yuk kunjungi website Bapak/Ibu agar mendapatkan informasi perkembangan buah hati Anda. +
+
+
+
Alamat Website (URL)
+
https://website-cloud-stunting.vercel.app/
+
+
+
+
Username
+
{pengguna.username || '-'}
+
+
+
Password
+
{pengguna.password || '-'}
+
{/* ── Doc footer ── */} -
+
Dicetak oleh Sistem Informasi Posyandu Dokumen ini sah tanpa tanda tangan basah apabila dicetak dari sistem
diff --git a/app/dashboard/kelola-data/[id]/page.tsx b/app/dashboard/kelola-data/[id]/page.tsx index bb1f1b1..0e9424c 100644 --- a/app/dashboard/kelola-data/[id]/page.tsx +++ b/app/dashboard/kelola-data/[id]/page.tsx @@ -48,7 +48,7 @@ export default async function DetailPenggunaKelolaPage({ params }: Props) { const { data: pengguna, error } = await supabase .from('akun_balita') - .select('id, nama_orang_tua, alamat, no_whatsapp, nama_anak, jenis_kelamin, tanggal_lahir') + .select('id, nama_orang_tua, alamat, no_whatsapp, nama_anak, jenis_kelamin, tanggal_lahir, username, password') .eq('id', id) .single() diff --git a/app/page.tsx b/app/page.tsx index bdf1950..9c8c91e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -28,7 +28,7 @@ export default function LoginPage() {

HealthPortal

-

SISTEM INFORMASI KESEHATAN

+

Panduan Login

diff --git a/app/user-dashboard/page.tsx b/app/user-dashboard/page.tsx index 1717f25..8abd777 100644 --- a/app/user-dashboard/page.tsx +++ b/app/user-dashboard/page.tsx @@ -21,6 +21,24 @@ export default async function UserDashboardPage() { redirect('/dashboard') } + // Helper to calculate age in Months and Days + const calculateAge = (birthDateStr: string) => { + if (!birthDateStr) return null + const birthDate = new Date(birthDateStr) + const today = new Date() // Will use server time + + let months = (today.getFullYear() - birthDate.getFullYear()) * 12 + (today.getMonth() - birthDate.getMonth()) + let days = today.getDate() - birthDate.getDate() + + if (days < 0) { + months -= 1 + const lastMonth = new Date(today.getFullYear(), today.getMonth(), 0) + days += lastMonth.getDate() + } + + return { months, days } + } + // Fetch User Data from akun_balita const { data: userResult, error } = await supabase .from('akun_balita') @@ -60,7 +78,7 @@ export default async function UserDashboardPage() { Selamat Datang!

- Bunda {userResult.nama_orang_tua} + Bapak/Ibu {userResult.nama_orang_tua}

@@ -123,7 +141,17 @@ export default async function UserDashboardPage() { Tanggal Lahir
-
{userResult.tanggal_lahir || '-'}
+
+ {userResult.tanggal_lahir || '-'} + {userResult.tanggal_lahir && ( + + {(() => { + const age = calculateAge(userResult.tanggal_lahir) + return age ? `${age.months} Bln ${age.days} Hari` : '' + })()} + + )} +
@@ -155,7 +183,7 @@ export default async function UserDashboardPage() { title="Trend & Statistik Stunting" description="Lihat data statistik dan trend stunting di wilayah anda." icon={TrendingUp} - href="#" + href="/user-dashboard/trend-stunting" color="blue" /> @@ -164,7 +192,7 @@ export default async function UserDashboardPage() { title="Perkembangan Balita" description="Pantau grafik pertumbuhan dan perkembangan anak." icon={Baby} - href="#" + href="/user-dashboard/perkembangan" color="green" /> diff --git a/app/user-dashboard/perkembangan/ExportPDFButton.tsx b/app/user-dashboard/perkembangan/ExportPDFButton.tsx new file mode 100644 index 0000000..3627b5d --- /dev/null +++ b/app/user-dashboard/perkembangan/ExportPDFButton.tsx @@ -0,0 +1,341 @@ +'use client' + +import { useRef, useMemo, useState } from 'react' +import { Printer } from 'lucide-react' +import { + AreaChart, Area, + XAxis, YAxis, CartesianGrid, + ResponsiveContainer, +} from 'recharts' +import { showSwal } from '@/lib/swal' + +interface HasilItem { + id: number + tinggi_badan: number | null + berat_badan: number | null + status_stunting: boolean | null + pesan_ai: string | null + tanggal_upload: string | null + nama_posyandu: string | null +} + +interface Pengguna { + nama_orang_tua: string + alamat: string | null + nama_anak: string + jenis_kelamin: string | null + tanggal_lahir: string | null + username?: string | null + password?: string | null +} + +interface Props { + row: HasilItem + allData: HasilItem[] + pengguna: Pengguna +} + +function formatTgl(d: string | null, style: 'long' | 'short' = 'long') { + if (!d) return '-' + return new Date(d).toLocaleDateString('id-ID', { + day: 'numeric', + month: style === 'long' ? 'long' : 'short', + year: 'numeric', + }) +} + +function build5MonthData(allData: HasilItem[], rowDate: Date) { + const slots = [] + for (let i = 4; i >= 0; i--) { + const d = new Date(rowDate.getFullYear(), rowDate.getMonth() - i, 1) + slots.push({ year: d.getFullYear(), month: d.getMonth() + 1 }) + } + + return slots.map(slot => { + const match = allData.find(item => { + if (!item.tanggal_upload) return false + const id = new Date(item.tanggal_upload) + return ( + id.getFullYear() === slot.year && + id.getMonth() + 1 === slot.month && + id <= rowDate + ) + }) + const label = new Date(slot.year, slot.month - 1, 1) + .toLocaleDateString('id-ID', { month: 'short', year: '2-digit' }) + return { + label, + tinggi: match?.tinggi_badan ?? null, + berat: match?.berat_badan ?? null, + } + }) +} + +export function ExportPDFButton({ row, allData, pengguna }: Props) { + const templateRef = useRef(null) + const [loading, setLoading] = useState(false) + + const rowDate = row.tanggal_upload ? new Date(row.tanggal_upload) : new Date() + const chartData = useMemo(() => build5MonthData(allData, rowDate), [allData, row.tanggal_upload]) + + const isStunting = row.status_stunting === true + const tanggalCetak = new Date().toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' }) + const tanggalUpload = formatTgl(row.tanggal_upload, 'long') + const tanggalLahir = formatTgl(pengguna.tanggal_lahir, 'long') + + const handlePrint = async () => { + if (!templateRef.current || loading) return + setLoading(true) + try { + const { default: html2canvas } = await import('html2canvas') + const { default: jsPDF } = await import('jspdf') + + const canvas = await html2canvas(templateRef.current, { + scale: 2, + useCORS: true, + backgroundColor: '#ffffff', + logging: false, + }) + + const imgData = canvas.toDataURL('image/png') + const pdf = new jsPDF('p', 'mm', 'a4') + const pageW = pdf.internal.pageSize.getWidth() + const pageH = pdf.internal.pageSize.getHeight() + const imgH = (canvas.height * pageW) / canvas.width + + if (imgH <= pageH) { + pdf.addImage(imgData, 'PNG', 0, 0, pageW, imgH) + } else { + let yPos = 0 + const sliceH = canvas.width * (pageH / pageW) + while (yPos < canvas.height) { + const sliceCanvas = document.createElement('canvas') + sliceCanvas.width = canvas.width + sliceCanvas.height = Math.min(sliceH, canvas.height - yPos) + const ctx = sliceCanvas.getContext('2d')! + ctx.drawImage(canvas, 0, -yPos) + if (yPos > 0) pdf.addPage() + pdf.addImage(sliceCanvas.toDataURL('image/png'), 'PNG', 0, 0, pageW, pageH) + yPos += sliceH + } + } + + pdf.save(`Laporan_Perkembangan_${pengguna.nama_anak}_${tanggalUpload.replace(/ /g, '_')}.pdf`) + } catch (err: any) { + showSwal.error('Gagal!', `Gagal mencetak PDF: ${err.message}`) + } finally { + setLoading(false) + } + } + + return ( + <> + {/* ─── Hidden PDF Template ─── */} +
+
+ {/* ── Header ── */} +
+
+
+ Sistem Informasi Posyandu +
+
+ Laporan Perkembangan Anak +
+
+
+
Tanggal Cetak
+
{tanggalCetak}
+
Sesi Pemeriksaan
+
{tanggalUpload}
+
+
+ + {/* ── Identitas ── */} +
+
+ Identitas Balita +
+
+ {[ + ['Nama Ibu / Orang Tua', pengguna.nama_orang_tua], + ['Nama Anak', pengguna.nama_anak], + ['Alamat', pengguna.alamat ?? '-'], + ['Jenis Kelamin', pengguna.jenis_kelamin ?? '-'], + ['', ''], + ['Tanggal Lahir', tanggalLahir], + ].map(([label, value], i) => ( + label ? ( +
+
{label}
+
{value}
+
+ ) :
+ ))} +
+
+ + {/* ── Charts ── */} +
+
+ Grafik Pertumbuhan (5 Bulan Terakhir) +
+
+ {/* Tinggi */} +
+
📏 Tinggi Badan (cm)
+ + + + + + + + + + + + + + +
+ {/* Berat */} +
+
⚖️ Berat Badan (kg)
+ + + + + + + + + + + + + + +
+
+
+ + {/* ── Data Pemeriksaan ── */} +
+
+ Hasil Pengukuran Sesi Ini +
+ + + + {['Tinggi Badan', 'Berat Badan', 'Status Stunting', 'Posyandu', 'Tgl Pemeriksaan'].map(h => ( + + ))} + + + + + + + + + + + +
{h}
{row.tinggi_badan ?? '-'} {row.tinggi_badan ? 'cm' : ''}{row.berat_badan ?? '-'} {row.berat_badan ? 'kg' : ''} + + {isStunting ? '⚠ Stunting' : '✓ Normal'} + + {row.nama_posyandu ?? '-'}{tanggalUpload}
+ + {/* Pesan AI */} + {row.pesan_ai && ( +
+
+ Rekomendasi / Pesan AI +
+
{row.pesan_ai}
+
+ )} +
+ + {/* ── WhatsApp Info Box ── */} +
+
📱
+
+
Layanan Informasi WhatsApp
+
+ Untuk orang tua yang tidak memiliki akun WhatsApp, yuk segera buat akun karena kami melayani layanan penyampaian informasi hasil stunting dengan menggunakan WhatsApp agar mendapatkan informasi lebih cepat. +
+
+
+ + {/* ── Portal Access Info Box ── */} +
+
+ 🌐 Akses Portal Online Orang Tua +
+
+ * Yuk kunjungi website Bapak/Ibu agar mendapatkan informasi perkembangan buah hati Anda. +
+
+
+
Alamat Website (URL)
+
https://website-cloud-stunting.vercel.app/
+
+
+
+
Username
+
{pengguna.username || '-'}
+
+
+
Password
+
{pengguna.password || '-'}
+
+
+
+
+ +
+ Dicetak oleh Dashboard Pengguna StuntiScan + Dokumen ini diterbitkan secara otomatis oleh sistem +
+
+
+ + + + ) +} diff --git a/app/user-dashboard/perkembangan/GrowthChart.tsx b/app/user-dashboard/perkembangan/GrowthChart.tsx new file mode 100644 index 0000000..1ce86e6 --- /dev/null +++ b/app/user-dashboard/perkembangan/GrowthChart.tsx @@ -0,0 +1,206 @@ +'use client' + +import { useState, useMemo } from 'react' +import { + AreaChart, Area, + XAxis, YAxis, CartesianGrid, Tooltip, + ResponsiveContainer, +} from 'recharts' +import { Ruler, Weight, ChevronDown } from 'lucide-react' + +interface HasilItem { + tinggi_badan: number | null + berat_badan: number | null + tanggal_upload: string | null +} + +interface Props { + data: HasilItem[] +} + +const START_YEAR = 2026 + +function MiniTooltip({ active, payload, label, unit, color }: any) { + if (!active || !payload?.length) return null + return ( +
+

{label}

+

+ {payload[0]?.value ?? '-'} + {unit} +

+
+ ) +} + +export function GrowthChart({ data }: Props) { + const availableYears = useMemo(() => { + const currentYear = new Date().getFullYear() + const dataYears = Array.from( + new Set( + data + .map(d => d.tanggal_upload ? new Date(d.tanggal_upload).getFullYear() : null) + .filter(Boolean) + ) + ) as number[] + const maxYear = Math.max(currentYear, ...dataYears, START_YEAR) + return Array.from( + new Set([ + ...Array.from({ length: maxYear - START_YEAR + 1 }, (_, i) => START_YEAR + i), + ...dataYears, + ]) + ).filter(y => y >= START_YEAR).sort((a, b) => a - b) + }, [data]) + + const [selectedYear, setSelectedYear] = useState(availableYears[availableYears.length - 1] ?? START_YEAR) + + const chartData = useMemo(() => { + return data + .filter(d => { + if (!d.tanggal_upload) return false + return new Date(d.tanggal_upload).getFullYear() === selectedYear + }) + .map(d => ({ + label: new Date(d.tanggal_upload!).toLocaleDateString('id-ID', { day: 'numeric', month: 'short' }), + _date: new Date(d.tanggal_upload!).getTime(), + tinggi: d.tinggi_badan, + berat: d.berat_badan, + })) + .sort((a, b) => a._date - b._date) + }, [data, selectedYear]) + + const hasData = chartData.length > 0 + + return ( +
+
+
+
+
+ +
+
+ +
+
+
+

+ Grafik Pertumbuhan Anak +

+

+ Statistik Tinggi & Berat {selectedYear} +

+
+
+
+ Pilih Tahun: +
+ + +
+
+
+ +
+
+
+
+
+ +
+
+

Tinggi Badan

+

Satuan Centimeter (cm)

+
+
+
+ + {!hasData ? ( +
+ +

Data belum tersedia

+
+ ) : ( + + + + + + + + + + + + } /> + + + + )} +
+ +
+
+
+
+ +
+
+

Berat Badan

+

Satuan Kilogram (kg)

+
+
+
+ + {!hasData ? ( +
+ +

Data belum tersedia

+
+ ) : ( + + + + + + + + + + + + } /> + + + + )} +
+
+
+ ) +} diff --git a/app/user-dashboard/perkembangan/StuntingTable.tsx b/app/user-dashboard/perkembangan/StuntingTable.tsx new file mode 100644 index 0000000..43d48a0 --- /dev/null +++ b/app/user-dashboard/perkembangan/StuntingTable.tsx @@ -0,0 +1,177 @@ +'use client' + +import { useState, useMemo } from 'react' +import { Activity, ChevronDown } from 'lucide-react' +import { ExportPDFButton } from './ExportPDFButton' + +interface HasilStunting { + id: number + tinggi_badan: number | null + berat_badan: number | null + status_stunting: boolean | null + pesan_ai: string | null + tanggal_upload: string | null + nama_posyandu: string | null +} + +interface Pengguna { + nama_orang_tua: string + alamat: string | null + nama_anak: string + jenis_kelamin: string | null + tanggal_lahir: string | null +} + +interface Props { + data: HasilStunting[] + pengguna: Pengguna +} + +const START_YEAR = 2026 + +export function StuntingTable({ data, pengguna }: Props) { + const availableYears = useMemo(() => { + const currentYear = new Date().getFullYear() + const dataYears = Array.from( + new Set(data.map(d => d.tanggal_upload ? new Date(d.tanggal_upload).getFullYear() : null).filter(Boolean)) + ) as number[] + const maxYear = Math.max(currentYear, ...dataYears, START_YEAR) + return Array.from( + new Set([ + ...Array.from({ length: maxYear - START_YEAR + 1 }, (_, i) => START_YEAR + i), + ...dataYears, + ]) + ).filter(y => y >= START_YEAR).sort((a, b) => a - b) + }, [data]) + + const [selectedYear, setSelectedYear] = useState(availableYears[availableYears.length - 1] ?? START_YEAR) + + const filtered = useMemo(() => { + return data.filter(d => { + if (!d.tanggal_upload) return false + return new Date(d.tanggal_upload).getFullYear() === selectedYear + }) + }, [data, selectedYear]) + + const formatDate = (d: string | null) => { + if (!d) return '-' + return new Date(d).toLocaleDateString('id-ID', { + day: 'numeric', month: 'short', year: 'numeric' + }) + } + + return ( +
+
+
+ +

Riwayat Pengukuran

+
+
+ Periode: +
+ + +
+
+
+ +
+
+
+ {/* Header */} +
+ Tinggi + Berat + Status + Pesan / Rekomendasi + Posyandu + Tanggal + Laporan +
+ + {filtered.length === 0 ? ( +
+ +

Data belum tersedia tahun {selectedYear}

+
+ ) : ( +
+ {filtered.map((row) => { + const isStunting = row.status_stunting === true + + return ( +
+ {/* Tinggi Badan */} +
+ {row.tinggi_badan ?? '-'} + {row.tinggi_badan && cm} +
+ + {/* Berat Badan */} +
+ {row.berat_badan ?? '-'} + {row.berat_badan && kg} +
+ + {/* Status Stunting */} +
+ {row.status_stunting === null ? ( + + ) : ( + + {isStunting ? '⚠ Stunting' : '✓ Normal'} + + )} +
+ + {/* Pesan AI — truncated */} +
+ {row.pesan_ai ? ( +

+ {row.pesan_ai} +

+ ) : ( + Data sedang diproses... + )} +
+ + {/* Nama Posyandu */} +
+ {row.nama_posyandu ?? '-'} +
+ + {/* Tanggal Upload */} +
+ {formatDate(row.tanggal_upload)} +
+ + {/* Aksi: Cetak PDF */} +
+ +
+
+ ) + })} +
+ )} +
+
+
+
+ ) +} diff --git a/app/user-dashboard/perkembangan/page.tsx b/app/user-dashboard/perkembangan/page.tsx new file mode 100644 index 0000000..54a2125 --- /dev/null +++ b/app/user-dashboard/perkembangan/page.tsx @@ -0,0 +1,204 @@ +import { cookies } from 'next/headers' +import { redirect } from 'next/navigation' +import { supabase } from '@/lib/supabase' +import { LogoutButton } from '@/components/logout-button' +import { ArrowLeft, User, MapPin, Phone, Baby, Calendar, Mars, Venus } from 'lucide-react' +import Link from 'next/link' +import { StuntingTable } from './StuntingTable' +import { GrowthChart } from './GrowthChart' + +function ReadField({ + icon, + label, + value, + accent, +}: { + icon: React.ReactNode + label: string + value: string | null | undefined + accent?: 'blue' | 'pink' +}) { + return ( +
+
+ {icon} + {label} +
+

+ {value || Belum diisi} +

+
+ ) +} + +export default async function UserPerkembanganPage() { + const cookieStore = await cookies() + const sessionCookie = cookieStore.get('user_session') + if (!sessionCookie) redirect('/') + + const session = JSON.parse(sessionCookie.value) + if (session.role !== 'user' && session.role !== 'admin') redirect('/dashboard') + + // Fetch this specific user's child data + const { data: pengguna, error } = await supabase + .from('akun_balita') + .select('id, nama_orang_tua, alamat, no_whatsapp, nama_anak, jenis_kelamin, tanggal_lahir, username, password') + .eq('id', session.id) + .single() + + if (error || !pengguna) { + return ( +
+
+ +
+

Data Tidak Ditemukan

+

Maaf, kami tidak dapat menemukan profil balita Anda. Silakan hubungi admin di posyandu terdekat.

+ Kembali ke Dashboard +
+ ) + } + + // Fetch measurement history + const { data: hasilData } = await supabase + .from('hasil_stunting_balita') + .select('id, tinggi_badan, berat_badan, status_stunting, pesan_ai, tanggal_upload, nama_posyandu') + .eq('id_balita', pengguna.id) + .order('tanggal_upload', { ascending: false }) + + const formatDate = (d: string | null) => { + if (!d) return null + return new Date(d).toLocaleDateString('id-ID', { + day: 'numeric', month: 'long', year: 'numeric' + }) + } + + const isLaki = pengguna.jenis_kelamin?.toLowerCase().includes('laki') + + const calculateAge = (birthDateStr: string | null) => { + if (!birthDateStr) return null + const birthDate = new Date(birthDateStr) + const today = new Date() + + let months = (today.getFullYear() - birthDate.getFullYear()) * 12 + (today.getMonth() - birthDate.getMonth()) + let days = today.getDate() - birthDate.getDate() + + if (days < 0) { + months -= 1 + const lastMonth = new Date(today.getFullYear(), today.getMonth(), 0) + days += lastMonth.getDate() + } + + return { months, days } + } + + const age = calculateAge(pengguna.tanggal_lahir) + + return ( +
+ {/* Header */} +
+
+ +
+ +
+ Dashboard + +
+
+

Perkembangan Anak

+

Laporan Rutin Posyandu

+
+
+ +
+ +
+ + {/* Hero Profile Section */} +
+ {/* Background Decorative Element */} +
+ +
+
+ {pengguna.nama_anak?.charAt(0).toUpperCase() ?? '?'} +
+
+

{pengguna.nama_anak}

+
+ + {isLaki ? : } + {pengguna.jenis_kelamin} + + + + Lahir {formatDate(pengguna.tanggal_lahir)} + + {age && ( + + + Umur {age.months} Bulan {age.days} Hari + + )} +
+
+
+ +
+
+

Total Cek

+

{hasilData?.length ?? 0}

+
+
+

Status

+

AKTIF

+
+
+
+ +
+ {/* Top Row: Info & Chart */} +
+ {/* Info Column */} +
+
+ +

Informasi Keluarga

+
+
+ } label="Nama Ibu / Orang Tua" value={pengguna.nama_orang_tua} /> + } label="Alamat Domisili" value={pengguna.alamat} /> + } label="Kontak WhatsApp" value={pengguna.no_whatsapp} /> +
+ +
+

Tips Sehat ✨

+

Pastikan si kecil mendapatkan asupan gizi seimbang dan rutin mengikuti pemeriksaan di posyandu setiap bulan.

+
+
+ + {/* Chart Column */} +
+
+ +
+
+
+ + {/* Bottom Row: Full Width Table */} +
+
+
+ +
+
+
+
+
+ + +
+ ) +} diff --git a/app/user-dashboard/trend-stunting/StuntingChart.tsx b/app/user-dashboard/trend-stunting/StuntingChart.tsx new file mode 100644 index 0000000..a1e1f26 --- /dev/null +++ b/app/user-dashboard/trend-stunting/StuntingChart.tsx @@ -0,0 +1,307 @@ +'use client' + +import { useState, useMemo } from 'react' +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, + LineChart, + Line, + Area, + AreaChart, +} from 'recharts' +import { TrendingUp, TrendingDown, Minus, BarChart2, Activity, Percent } from 'lucide-react' + +interface RawData { + status_stunting: boolean + tanggal_upload: string + nama_posyandu: string +} + +interface Props { + data: RawData[] + availableYears: number[] +} + +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Ags', 'Sep', 'Okt', 'Nov', 'Des'] + +const CustomTooltip = ({ active, payload, label }: any) => { + if (active && payload && payload.length) { + const stunting = payload.find((p: any) => p.dataKey === 'stunting')?.value ?? 0 + const normal = payload.find((p: any) => p.dataKey === 'normal')?.value ?? 0 + const total = stunting + normal + const pct = total > 0 ? ((stunting / total) * 100).toFixed(1) : '0.0' + + return ( +
+

{label}

+
+
+ + + Stunting + + {stunting} +
+
+ + + Normal + + {normal} +
+
+ Prevalensi + {pct}% +
+
+
+ ) + } + return null +} + +export function StuntingChart({ data, availableYears }: Props) { + const [selectedYear, setSelectedYear] = useState(availableYears[availableYears.length - 1] ?? 2026) + const [chartType, setChartType] = useState<'bar' | 'area'>('bar') + const [selectedPosyandu, setSelectedPosyandu] = useState('all') + + const posyanduList = useMemo(() => { + return Array.from(new Set(data.map(d => d.nama_posyandu).filter(Boolean))).sort() + }, [data]) + + const filteredData = useMemo(() => { + if (selectedPosyandu === 'all') return data + return data.filter(d => d.nama_posyandu === selectedPosyandu) + }, [data, selectedPosyandu]) + + const chartData = useMemo(() => { + return MONTHS.map((month, idx) => { + const monthNum = idx + 1 + const filtered = filteredData.filter(d => { + if (!d.tanggal_upload) return false + const date = new Date(d.tanggal_upload) + return date.getFullYear() === selectedYear && date.getMonth() + 1 === monthNum + }) + const stunting = filtered.filter(d => d.status_stunting === true).length + const normal = filtered.filter(d => d.status_stunting === false).length + const total = stunting + normal + const prevalensi = total > 0 ? parseFloat(((stunting / total) * 100).toFixed(1)) : 0 + + return { month, stunting, normal, total, prevalensi } + }) + }, [filteredData, selectedYear]) + + const totalStunting = chartData.reduce((s, d) => s + d.stunting, 0) + const totalNormal = chartData.reduce((s, d) => s + d.normal, 0) + const totalAll = totalStunting + totalNormal + const prevalensiTotal = totalAll > 0 ? ((totalStunting / totalAll) * 100).toFixed(1) : '0.0' + + const prevYearData = filteredData.filter(d => { + if (!d.tanggal_upload) return false + return new Date(d.tanggal_upload).getFullYear() === selectedYear - 1 + }) + const prevStunting = prevYearData.filter(d => d.status_stunting === true).length + const prevAll = prevYearData.length + const hasPrevData = prevAll > 0 + const prevPrevalensi = hasPrevData ? (prevStunting / prevAll) * 100 : null + const currPrevalensiNum = totalAll > 0 ? (totalStunting / totalAll) * 100 : null + const trend = hasPrevData && currPrevalensiNum !== null + ? currPrevalensiNum > prevPrevalensi! ? 'up' : currPrevalensiNum < prevPrevalensi! ? 'down' : 'stable' + : null + + return ( +
+
+
+ +
+
+

+ {selectedPosyandu === 'all' ? 'Data Tren Stunting Wilayah' : `Tren Stunting — ${selectedPosyandu}`} +

+

+ Pantau statistik prevalensi stunting untuk memahami kondisi kesehatan anak di daerah Anda. +

+
+
+ +
+
+
+ Periode: + +
+ +
+ Posyandu: + +
+
+ +
+ + +
+
+ +
+
+

Total Pemeriksaan

+

{totalAll}

+

data balita

+
+
+

Stunting

+

{totalStunting}

+

kasus

+
+
+

Normal

+

{totalNormal}

+

anak

+
+
+

Prevalensi

+

{prevalensiTotal}%

+ {trend !== null && ( +
+ {trend === 'up' && <>Naik vs {selectedYear - 1}} + {trend === 'down' && <>Turun vs {selectedYear - 1}} + {trend === 'stable' && <>Stabil} +
+ )} +
+
+ +
+

Statistik Pertumbuhan Anak — {selectedYear}

+

Perbandingan Balita Stunting & Normal

+ + {totalAll === 0 ? ( +
+ +

Data belum tersedia untuk tahun {selectedYear}

+
+ ) : ( + + {chartType === 'bar' ? ( + + + + + } cursor={{ fill: '#f8fafc' }} /> + + + + + ) : ( + + + + + + + + + + + + + + + } /> + + + + + )} + + )} +
+ +
+
+ +

Prevalensi Bulanan (%)

+
+

Ambang Batas Keamanan WHO: 20%

+ + {totalAll === 0 ? ( +
+

Belum ada data prevalensi

+
+ ) : ( + + + + + + { + if (active && payload && payload.length) { + const val = payload[0].value as number + return ( +
+

{label} {selectedYear}

+
+ = 20 ? 'bg-red-500' : 'bg-emerald-500'}`} /> + {val}% +
+

+ {val >= 20 ? '🛑 Tinggi' : '✅ Aman'} +

+
+ ) + } + return null + }} + /> + ( + = 20 ? '#ef4444' : '#10b981'} + stroke="white" + strokeWidth={2} + /> + )} + /> +
+
+ )} +
+
+ ) +} diff --git a/app/user-dashboard/trend-stunting/page.tsx b/app/user-dashboard/trend-stunting/page.tsx new file mode 100644 index 0000000..f2148b0 --- /dev/null +++ b/app/user-dashboard/trend-stunting/page.tsx @@ -0,0 +1,75 @@ +import { cookies } from 'next/headers' +import { redirect } from 'next/navigation' +import { supabase } from '@/lib/supabase' +import { LogoutButton } from '@/components/logout-button' +import { ArrowLeft } from 'lucide-react' +import Link from 'next/link' +import { StuntingChart } from './StuntingChart' + +const START_YEAR = 2026 + +export default async function UserTrendStuntingPage() { + const cookieStore = await cookies() + const sessionCookie = cookieStore.get('user_session') + if (!sessionCookie) redirect('/') + + const session = JSON.parse(sessionCookie.value) + if (session.role !== 'user' && session.role !== 'admin') redirect('/dashboard') + + // Fetch all stunting data (all posyandu) for the community trend + const { data: rawData, error } = await supabase + .from('hasil_stunting_balita') + .select('status_stunting, tanggal_upload, nama_posyandu') + .not('tanggal_upload', 'is', null) + .order('tanggal_upload', { ascending: true }) + + if (error) { + return ( +
+ Gagal memuat data stunting. +
+ ) + } + + const currentYear = new Date().getFullYear() + const dataYears = Array.from( + new Set((rawData ?? []).map(d => new Date(d.tanggal_upload).getFullYear())) + ) + const allYears = Array.from( + new Set([ + ...Array.from({ length: Math.max(currentYear, ...dataYears) - START_YEAR + 1 }, (_, i) => START_YEAR + i), + ...dataYears, + ]) + ).filter(y => y >= START_YEAR).sort((a, b) => a - b) + + return ( +
+ {/* Header */} +
+
+ +
+ +
+ Kembali + +
+
+

Trend stunting Daerah

+

Analisis Data Semua Posyandu

+
+
+ +
+ +
+
+ +
+
+
+ ) +}