add user features access

This commit is contained in:
panggilsajarey 2026-02-26 00:37:46 +07:00
parent 10f1739944
commit 878f2994be
10 changed files with 1417 additions and 56 deletions

View File

@ -25,6 +25,8 @@ interface Pengguna {
nama_anak: string nama_anak: string
jenis_kelamin: string | null jenis_kelamin: string | null
tanggal_lahir: string | null tanggal_lahir: string | null
username?: string | null
password?: string | null
} }
interface Props { interface Props {
@ -139,12 +141,12 @@ export function CetakPDFButton({ row, allData, pengguna }: Props) {
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
fontFamily: 'Arial, Helvetica, sans-serif', fontFamily: 'Arial, Helvetica, sans-serif',
color: '#111111', color: '#111111',
padding: '48px 56px', padding: '32px 48px',
boxSizing: 'border-box', boxSizing: 'border-box',
}} }}
> >
{/* ── Header ── */} {/* ── Header ── */}
<div style={{ borderBottom: '3px solid #111', paddingBottom: 20, marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}> <div style={{ borderBottom: '3px solid #111', paddingBottom: 16, marginBottom: 20, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div> <div>
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: 3, textTransform: 'uppercase', color: '#555', marginBottom: 6 }}> <div style={{ fontSize: 11, fontWeight: 700, letterSpacing: 3, textTransform: 'uppercase', color: '#555', marginBottom: 6 }}>
Sistem Informasi Posyandu Sistem Informasi Posyandu
@ -155,18 +157,18 @@ export function CetakPDFButton({ row, allData, pengguna }: Props) {
</div> </div>
<div style={{ textAlign: 'right' }}> <div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 10, color: '#888', marginBottom: 3 }}>Tanggal Cetak</div> <div style={{ fontSize: 10, color: '#888', marginBottom: 3 }}>Tanggal Cetak</div>
<div style={{ fontSize: 13, fontWeight: 700 }}>{tanggalCetak}</div> <div style={{ fontSize: 12, fontWeight: 700 }}>{tanggalCetak}</div>
<div style={{ marginTop: 6, fontSize: 10, color: '#888' }}>Tgl Pemeriksaan</div> <div style={{ marginTop: 6, fontSize: 10, color: '#888' }}>Tgl Pemeriksaan</div>
<div style={{ fontSize: 13, fontWeight: 700 }}>{tanggalUpload}</div> <div style={{ fontSize: 12, fontWeight: 700 }}>{tanggalUpload}</div>
</div> </div>
</div> </div>
{/* ── Identitas ── */} {/* ── Identitas ── */}
<div style={{ marginBottom: 28 }}> <div style={{ marginBottom: 20 }}>
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: 2, textTransform: 'uppercase', color: '#888', marginBottom: 10 }}> <div style={{ fontSize: 10, fontWeight: 700, letterSpacing: 2, textTransform: 'uppercase', color: '#888', marginBottom: 6 }}>
Identitas Identitas
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px 32px' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px 32px' }}>
{[ {[
['Nama Ibu / Orang Tua', pengguna.nama_orang_tua], ['Nama Ibu / Orang Tua', pengguna.nama_orang_tua],
['Nama Anak', pengguna.nama_anak], ['Nama Anak', pengguna.nama_anak],
@ -176,9 +178,9 @@ export function CetakPDFButton({ row, allData, pengguna }: Props) {
['Tanggal Lahir', tanggalLahir], ['Tanggal Lahir', tanggalLahir],
].map(([label, value], i) => ( ].map(([label, value], i) => (
label ? ( label ? (
<div key={i} style={{ borderBottom: '1px solid #eee', paddingBottom: 8 }}> <div key={i} style={{ borderBottom: '1px solid #eee', paddingBottom: 6 }}>
<div style={{ fontSize: 9, color: '#888', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 1, marginBottom: 3 }}>{label}</div> <div style={{ fontSize: 8, color: '#888', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 1, marginBottom: 2 }}>{label}</div>
<div style={{ fontSize: 13, fontWeight: 600 }}>{value}</div> <div style={{ fontSize: 12, fontWeight: 600 }}>{value}</div>
</div> </div>
) : <div key={i} /> ) : <div key={i} />
))} ))}
@ -186,15 +188,15 @@ export function CetakPDFButton({ row, allData, pengguna }: Props) {
</div> </div>
{/* ── Charts ── */} {/* ── Charts ── */}
<div style={{ marginBottom: 28 }}> <div style={{ marginBottom: 20 }}>
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: 2, textTransform: 'uppercase', color: '#888', marginBottom: 10 }}> <div style={{ fontSize: 10, fontWeight: 700, letterSpacing: 2, textTransform: 'uppercase', color: '#888', marginBottom: 6 }}>
Grafik Perkembangan Balita (5 Bulan Terakhir) Grafik Perkembangan Balita (5 Bulan Terakhir)
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
{/* Tinggi */} {/* Tinggi */}
<div style={{ border: '1.5px solid #dbeafe', borderRadius: 12, padding: '14px 14px 4px', background: '#eff6ff' }}> <div style={{ border: '1.5px solid #dbeafe', borderRadius: 10, padding: '12px 12px 2px', background: '#eff6ff' }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#1d4ed8', marginBottom: 8 }}>📏 Tinggi Badan (cm)</div> <div style={{ fontSize: 10, fontWeight: 700, color: '#1d4ed8', marginBottom: 6 }}>📏 Tinggi Badan (cm)</div>
<ResponsiveContainer width="100%" height={140}> <ResponsiveContainer width="100%" height={120}>
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}> <AreaChart data={chartData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
<defs> <defs>
<linearGradient id="pdfTinggiGrad" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="pdfTinggiGrad" x1="0" y1="0" x2="0" y2="1">
@ -203,19 +205,19 @@ export function CetakPDFButton({ row, allData, pengguna }: Props) {
</linearGradient> </linearGradient>
</defs> </defs>
<CartesianGrid strokeDasharray="3 3" stroke="#bfdbfe" /> <CartesianGrid strokeDasharray="3 3" stroke="#bfdbfe" />
<XAxis dataKey="label" tick={{ fontSize: 9, fontWeight: 600 }} axisLine={false} tickLine={false} /> <XAxis dataKey="label" tick={{ fontSize: 8, fontWeight: 600 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 9 }} axisLine={false} tickLine={false} /> <YAxis tick={{ fontSize: 8 }} axisLine={false} tickLine={false} />
<Area type="monotone" dataKey="tinggi" stroke="#3b82f6" strokeWidth={2} fill="url(#pdfTinggiGrad)" <Area type="monotone" dataKey="tinggi" stroke="#3b82f6" strokeWidth={2} fill="url(#pdfTinggiGrad)"
dot={{ r: 4, fill: '#3b82f6', stroke: 'white', strokeWidth: 2 }} dot={{ r: 3, fill: '#3b82f6', stroke: 'white', strokeWidth: 2 }}
connectNulls={false} connectNulls={false}
/> />
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
{/* Berat */} {/* Berat */}
<div style={{ border: '1.5px solid #d1fae5', borderRadius: 12, padding: '14px 14px 4px', background: '#f0fdf4' }}> <div style={{ border: '1.5px solid #d1fae5', borderRadius: 10, padding: '12px 12px 2px', background: '#f0fdf4' }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#059669', marginBottom: 8 }}> Berat Badan (kg)</div> <div style={{ fontSize: 10, fontWeight: 700, color: '#059669', marginBottom: 6 }}> Berat Badan (kg)</div>
<ResponsiveContainer width="100%" height={140}> <ResponsiveContainer width="100%" height={120}>
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}> <AreaChart data={chartData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
<defs> <defs>
<linearGradient id="pdfBeratGrad" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="pdfBeratGrad" x1="0" y1="0" x2="0" y2="1">
@ -224,10 +226,10 @@ export function CetakPDFButton({ row, allData, pengguna }: Props) {
</linearGradient> </linearGradient>
</defs> </defs>
<CartesianGrid strokeDasharray="3 3" stroke="#a7f3d0" /> <CartesianGrid strokeDasharray="3 3" stroke="#a7f3d0" />
<XAxis dataKey="label" tick={{ fontSize: 9, fontWeight: 600 }} axisLine={false} tickLine={false} /> <XAxis dataKey="label" tick={{ fontSize: 8, fontWeight: 600 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 9 }} axisLine={false} tickLine={false} /> <YAxis tick={{ fontSize: 8 }} axisLine={false} tickLine={false} />
<Area type="monotone" dataKey="berat" stroke="#10b981" strokeWidth={2} fill="url(#pdfBeratGrad)" <Area type="monotone" dataKey="berat" stroke="#10b981" strokeWidth={2} fill="url(#pdfBeratGrad)"
dot={{ r: 4, fill: '#10b981', stroke: 'white', strokeWidth: 2 }} dot={{ r: 3, fill: '#10b981', stroke: 'white', strokeWidth: 2 }}
connectNulls={false} connectNulls={false}
/> />
</AreaChart> </AreaChart>
@ -237,28 +239,28 @@ export function CetakPDFButton({ row, allData, pengguna }: Props) {
</div> </div>
{/* ── Data Pemeriksaan ── */} {/* ── Data Pemeriksaan ── */}
<div style={{ marginBottom: 32 }}> <div style={{ marginBottom: 20 }}>
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: 2, textTransform: 'uppercase', color: '#888', marginBottom: 10 }}> <div style={{ fontSize: 10, fontWeight: 700, letterSpacing: 2, textTransform: 'uppercase', color: '#888', marginBottom: 6 }}>
Data Pemeriksaan Data Pemeriksaan
</div> </div>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}> <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11 }}>
<thead> <thead>
<tr style={{ background: '#111', color: '#fff' }}> <tr style={{ background: '#111', color: '#fff' }}>
{['Tinggi Badan', 'Berat Badan', 'Status Stunting', 'Posyandu', 'Tgl Pemeriksaan'].map(h => ( {['Tinggi Badan', 'Berat Badan', 'Status Stunting', 'Posyandu', 'Tgl Pemeriksaan'].map(h => (
<th key={h} style={{ padding: '10px 12px', fontWeight: 700, fontSize: 10, letterSpacing: 1, textTransform: 'uppercase', textAlign: 'left' }}>{h}</th> <th key={h} style={{ padding: '8px 10px', fontWeight: 700, fontSize: 9, letterSpacing: 1, textTransform: 'uppercase', textAlign: 'left' }}>{h}</th>
))} ))}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr style={{ background: '#f9fafb' }}> <tr style={{ background: '#f9fafb' }}>
<td style={{ padding: '12px', fontWeight: 700 }}>{row.tinggi_badan ?? '-'} {row.tinggi_badan ? 'cm' : ''}</td> <td style={{ padding: '10px', fontWeight: 700 }}>{row.tinggi_badan ?? '-'} {row.tinggi_badan ? 'cm' : ''}</td>
<td style={{ padding: '12px', fontWeight: 700 }}>{row.berat_badan ?? '-'} {row.berat_badan ? 'kg' : ''}</td> <td style={{ padding: '10px', fontWeight: 700 }}>{row.berat_badan ?? '-'} {row.berat_badan ? 'kg' : ''}</td>
<td style={{ padding: '12px' }}> <td style={{ padding: '10px' }}>
<span style={{ <span style={{
display: 'inline-block', display: 'inline-block',
padding: '3px 10px', padding: '2px 8px',
borderRadius: 20, borderRadius: 20,
fontSize: 11, fontSize: 10,
fontWeight: 700, fontWeight: 700,
background: isStunting ? '#fef2f2' : '#f0fdf4', background: isStunting ? '#fef2f2' : '#f0fdf4',
color: isStunting ? '#b91c1c' : '#15803d', color: isStunting ? '#b91c1c' : '#15803d',
@ -267,41 +269,62 @@ export function CetakPDFButton({ row, allData, pengguna }: Props) {
{isStunting ? '⚠ Stunting' : '✓ Normal'} {isStunting ? '⚠ Stunting' : '✓ Normal'}
</span> </span>
</td> </td>
<td style={{ padding: '12px' }}>{row.nama_posyandu ?? '-'}</td> <td style={{ padding: '10px' }}>{row.nama_posyandu ?? '-'}</td>
<td style={{ padding: '12px' }}>{tanggalUpload}</td> <td style={{ padding: '10px' }}>{tanggalUpload}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
{/* Pesan AI */} {/* Pesan AI */}
{row.pesan_ai && ( {row.pesan_ai && (
<div style={{ marginTop: 12, border: '1.5px solid #fde68a', borderRadius: 10, padding: '12px 16px', background: '#fffbeb' }}> <div style={{ marginTop: 8, border: '1.5px solid #fde68a', borderRadius: 8, padding: '10px 14px', background: '#fffbeb' }}>
<div style={{ fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 1.5, color: '#92400e', marginBottom: 5 }}> <div style={{ fontSize: 8, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 1.5, color: '#92400e', marginBottom: 3 }}>
Pesan Kecerdasan Buatan (AI) Rekomendasi / Pesan AI
</div> </div>
<div style={{ fontSize: 12, lineHeight: 1.6, color: '#78350f' }}>{row.pesan_ai}</div> <div style={{ fontSize: 11, lineHeight: 1.5, color: '#78350f' }}>{row.pesan_ai}</div>
</div> </div>
)} )}
</div> </div>
{/* ── Footer / Tanda Tangan ── */} {/* ── WhatsApp Info Box ── */}
<div style={{ borderTop: '1.5px solid #e5e7eb', paddingTop: 24, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 40 }}> <div style={{ marginTop: 16, padding: '10px 14px', borderRadius: 10, backgroundColor: '#f0fdf4', border: '1px solid #bbf7d0', display: 'flex', alignItems: 'flex-start', gap: 10 }}>
<div style={{ fontSize: 16 }}>📱</div>
<div> <div>
<div style={{ fontSize: 10, color: '#888', marginBottom: 48 }}>Mengetahui,</div> <div style={{ fontSize: 9, fontWeight: 800, color: '#166534', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 2 }}>Layanan Informasi WhatsApp</div>
<div style={{ borderTop: '1.5px solid #333', paddingTop: 6 }}> <div style={{ fontSize: 10, lineHeight: 1.5, color: '#14532d' }}>
<div style={{ fontSize: 10, color: '#555' }}>Kepala Puskesmas / Supervisor</div> 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.
</div> </div>
</div> </div>
<div> </div>
<div style={{ fontSize: 10, color: '#888', marginBottom: 48 }}>Petugas Posyandu,</div>
<div style={{ borderTop: '1.5px solid #333', paddingTop: 6 }}> {/* ── Portal Access Info Box ── */}
<div style={{ fontSize: 10, color: '#555' }}>Nama &amp; Tanda Tangan</div> <div style={{ marginTop: 16, padding: '14px', border: '2px dashed #000', borderRadius: 12, backgroundColor: '#fdfcf0' }}>
<div style={{ fontSize: 10, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 2, color: '#854d0e', display: 'flex', alignItems: 'center', gap: 6 }}>
🌐 Akses Portal Online Orang Tua
</div>
<div style={{ fontSize: 9, color: '#a16207', fontWeight: 500, fontStyle: 'italic', marginBottom: 10 }}>
* Yuk kunjungi website Bapak/Ibu agar mendapatkan informasi perkembangan buah hati Anda.
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16 }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 8, color: '#a16207', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 2 }}>Alamat Website (URL)</div>
<div style={{ fontSize: 11, fontWeight: 800, color: '#000' }}>https://website-cloud-stunting.vercel.app/</div>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<div>
<div style={{ fontSize: 8, color: '#a16207', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 2 }}>Username</div>
<div style={{ fontSize: 11, fontWeight: 800, color: '#000' }}>{pengguna.username || '-'}</div>
</div>
<div>
<div style={{ fontSize: 8, color: '#a16207', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 2 }}>Password</div>
<div style={{ fontSize: 11, fontWeight: 800, color: '#000' }}>{pengguna.password || '-'}</div>
</div>
</div> </div>
</div> </div>
</div> </div>
{/* ── Doc footer ── */} {/* ── Doc footer ── */}
<div style={{ marginTop: 24, paddingTop: 12, borderTop: '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', fontSize: 9, color: '#bbb' }}> <div style={{ marginTop: 16, paddingTop: 8, borderTop: '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', fontSize: 8, color: '#bbb' }}>
<span>Dicetak oleh Sistem Informasi Posyandu</span> <span>Dicetak oleh Sistem Informasi Posyandu</span>
<span>Dokumen ini sah tanpa tanda tangan basah apabila dicetak dari sistem</span> <span>Dokumen ini sah tanpa tanda tangan basah apabila dicetak dari sistem</span>
</div> </div>

View File

@ -48,7 +48,7 @@ export default async function DetailPenggunaKelolaPage({ params }: Props) {
const { data: pengguna, error } = await supabase const { data: pengguna, error } = await supabase
.from('akun_balita') .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) .eq('id', id)
.single() .single()

View File

@ -28,7 +28,7 @@ export default function LoginPage() {
</div> </div>
<h1 className="text-2xl font-bold tracking-tight">HealthPortal</h1> <h1 className="text-2xl font-bold tracking-tight">HealthPortal</h1>
</div> </div>
<p className="text-gray-400 text-sm tracking-widest uppercase">SISTEM INFORMASI KESEHATAN</p>
</div> </div>
<h2 className="text-4xl font-bold mb-8">Panduan Login</h2> <h2 className="text-4xl font-bold mb-8">Panduan Login</h2>

View File

@ -21,6 +21,24 @@ export default async function UserDashboardPage() {
redirect('/dashboard') 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 // Fetch User Data from akun_balita
const { data: userResult, error } = await supabase const { data: userResult, error } = await supabase
.from('akun_balita') .from('akun_balita')
@ -60,7 +78,7 @@ export default async function UserDashboardPage() {
Selamat Datang! Selamat Datang!
</h2> </h2>
<p className="text-gray-600"> <p className="text-gray-600">
Bunda <span className="font-bold text-black">{userResult.nama_orang_tua}</span> Bapak/Ibu <span className="font-bold text-black">{userResult.nama_orang_tua}</span>
</p> </p>
</div> </div>
@ -123,7 +141,17 @@ export default async function UserDashboardPage() {
<Calendar className="h-3 w-3" /> <Calendar className="h-3 w-3" />
Tanggal Lahir Tanggal Lahir
</div> </div>
<div className="text-lg font-bold">{userResult.tanggal_lahir || '-'}</div> <div className="text-lg font-bold flex flex-wrap items-baseline gap-2">
{userResult.tanggal_lahir || '-'}
{userResult.tanggal_lahir && (
<span className="text-xs bg-red-100 text-red-700 px-2 py-0.5 rounded-full font-black uppercase tracking-tight">
{(() => {
const age = calculateAge(userResult.tanggal_lahir)
return age ? `${age.months} Bln ${age.days} Hari` : ''
})()}
</span>
)}
</div>
</div> </div>
</div> </div>
@ -155,7 +183,7 @@ export default async function UserDashboardPage() {
title="Trend & Statistik Stunting" title="Trend & Statistik Stunting"
description="Lihat data statistik dan trend stunting di wilayah anda." description="Lihat data statistik dan trend stunting di wilayah anda."
icon={TrendingUp} icon={TrendingUp}
href="#" href="/user-dashboard/trend-stunting"
color="blue" color="blue"
/> />
@ -164,7 +192,7 @@ export default async function UserDashboardPage() {
title="Perkembangan Balita" title="Perkembangan Balita"
description="Pantau grafik pertumbuhan dan perkembangan anak." description="Pantau grafik pertumbuhan dan perkembangan anak."
icon={Baby} icon={Baby}
href="#" href="/user-dashboard/perkembangan"
color="green" color="green"
/> />

View File

@ -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<HTMLDivElement>(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 ─── */}
<div style={{ position: 'fixed', top: '-9999px', left: '-9999px', zIndex: -1 }}>
<div
ref={templateRef}
style={{
width: 794,
backgroundColor: '#ffffff',
fontFamily: 'Arial, Helvetica, sans-serif',
color: '#111111',
padding: '32px 48px',
boxSizing: 'border-box',
}}
>
{/* ── Header ── */}
<div style={{ borderBottom: '3px solid #111', paddingBottom: 16, marginBottom: 20, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: 3, textTransform: 'uppercase', color: '#555', marginBottom: 6 }}>
Sistem Informasi Posyandu
</div>
<div style={{ fontSize: 22, fontWeight: 900, letterSpacing: -0.5 }}>
Laporan Perkembangan Anak
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 10, color: '#888', marginBottom: 3 }}>Tanggal Cetak</div>
<div style={{ fontSize: 12, fontWeight: 700 }}>{tanggalCetak}</div>
<div style={{ marginTop: 6, fontSize: 10, color: '#888' }}>Sesi Pemeriksaan</div>
<div style={{ fontSize: 12, fontWeight: 700 }}>{tanggalUpload}</div>
</div>
</div>
{/* ── Identitas ── */}
<div style={{ marginBottom: 20 }}>
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: 2, textTransform: 'uppercase', color: '#888', marginBottom: 6 }}>
Identitas Balita
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px 32px' }}>
{[
['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 ? (
<div key={i} style={{ borderBottom: '1px solid #eee', paddingBottom: 6 }}>
<div style={{ fontSize: 8, color: '#888', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 1, marginBottom: 2 }}>{label}</div>
<div style={{ fontSize: 12, fontWeight: 600 }}>{value}</div>
</div>
) : <div key={i} />
))}
</div>
</div>
{/* ── Charts ── */}
<div style={{ marginBottom: 20 }}>
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: 2, textTransform: 'uppercase', color: '#888', marginBottom: 6 }}>
Grafik Pertumbuhan (5 Bulan Terakhir)
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
{/* Tinggi */}
<div style={{ border: '1.5 solid #dbeafe', borderRadius: 10, padding: '12px 12px 2px', background: '#eff6ff', borderStyle: 'solid', borderWidth: '1.5px', borderColor: '#dbeafe' }}>
<div style={{ fontSize: 10, fontWeight: 700, color: '#1d4ed8', marginBottom: 6 }}>📏 Tinggi Badan (cm)</div>
<ResponsiveContainer width="100%" height={120}>
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="pdfTinggiGradUser" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#bfdbfe" />
<XAxis dataKey="label" tick={{ fontSize: 8, fontWeight: 600 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 8 }} axisLine={false} tickLine={false} />
<Area type="monotone" dataKey="tinggi" stroke="#3b82f6" strokeWidth={2} fill="url(#pdfTinggiGradUser)"
dot={{ r: 3, fill: '#3b82f6', stroke: 'white', strokeWidth: 2 }}
connectNulls={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
{/* Berat */}
<div style={{ border: '1.5 solid #d1fae5', borderRadius: 10, padding: '12px 12px 2px', background: '#f0fdf4', borderStyle: 'solid', borderWidth: '1.5px', borderColor: '#d1fae5' }}>
<div style={{ fontSize: 10, fontWeight: 700, color: '#059669', marginBottom: 6 }}> Berat Badan (kg)</div>
<ResponsiveContainer width="100%" height={120}>
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="pdfBeratGradUser" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#a7f3d0" />
<XAxis dataKey="label" tick={{ fontSize: 8, fontWeight: 600 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 8 }} axisLine={false} tickLine={false} />
<Area type="monotone" dataKey="berat" stroke="#10b981" strokeWidth={2} fill="url(#pdfBeratGradUser)"
dot={{ r: 3, fill: '#10b981', stroke: 'white', strokeWidth: 2 }}
connectNulls={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
</div>
{/* ── Data Pemeriksaan ── */}
<div style={{ marginBottom: 20 }}>
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: 2, textTransform: 'uppercase', color: '#888', marginBottom: 6 }}>
Hasil Pengukuran Sesi Ini
</div>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11 }}>
<thead>
<tr style={{ background: '#111', color: '#fff' }}>
{['Tinggi Badan', 'Berat Badan', 'Status Stunting', 'Posyandu', 'Tgl Pemeriksaan'].map(h => (
<th key={h} style={{ padding: '8px 10px', fontWeight: 700, fontSize: 9, letterSpacing: 1, textTransform: 'uppercase', textAlign: 'left' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
<tr style={{ background: '#f9fafb' }}>
<td style={{ padding: '10px', fontWeight: 700 }}>{row.tinggi_badan ?? '-'} {row.tinggi_badan ? 'cm' : ''}</td>
<td style={{ padding: '10px', fontWeight: 700 }}>{row.berat_badan ?? '-'} {row.berat_badan ? 'kg' : ''}</td>
<td style={{ padding: '10px' }}>
<span style={{
display: 'inline-block',
padding: '2px 8px',
borderRadius: 20,
fontSize: 10,
fontWeight: 700,
background: isStunting ? '#fef2f2' : '#f0fdf4',
color: isStunting ? '#b91c1c' : '#15803d',
border: `1px solid ${isStunting ? '#fecaca' : '#bbf7d0'}`,
}}>
{isStunting ? '⚠ Stunting' : '✓ Normal'}
</span>
</td>
<td style={{ padding: '10px' }}>{row.nama_posyandu ?? '-'}</td>
<td style={{ padding: '10px' }}>{tanggalUpload}</td>
</tr>
</tbody>
</table>
{/* Pesan AI */}
{row.pesan_ai && (
<div style={{ marginTop: 8, border: '1.5px solid #fde68a', borderRadius: 8, padding: '10px 14px', background: '#fffbeb' }}>
<div style={{ fontSize: 8, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 1.5, color: '#92400e', marginBottom: 3 }}>
Rekomendasi / Pesan AI
</div>
<div style={{ fontSize: 11, lineHeight: 1.5, color: '#78350f' }}>{row.pesan_ai}</div>
</div>
)}
</div>
{/* ── WhatsApp Info Box ── */}
<div style={{ marginTop: 16, padding: '10px 14px', borderRadius: 10, backgroundColor: '#f0fdf4', border: '1px solid #bbf7d0', display: 'flex', alignItems: 'flex-start', gap: 10 }}>
<div style={{ fontSize: 16 }}>📱</div>
<div>
<div style={{ fontSize: 9, fontWeight: 800, color: '#166534', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 2 }}>Layanan Informasi WhatsApp</div>
<div style={{ fontSize: 10, lineHeight: 1.5, color: '#14532d' }}>
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.
</div>
</div>
</div>
{/* ── Portal Access Info Box ── */}
<div style={{ marginTop: 16, padding: '14px', border: '2px dashed #000', borderRadius: 12, backgroundColor: '#fdfcf0' }}>
<div style={{ fontSize: 10, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 2, color: '#854d0e', display: 'flex', alignItems: 'center', gap: 6 }}>
🌐 Akses Portal Online Orang Tua
</div>
<div style={{ fontSize: 9, color: '#a16207', fontWeight: 500, fontStyle: 'italic', marginBottom: 10 }}>
* Yuk kunjungi website Bapak/Ibu agar mendapatkan informasi perkembangan buah hati Anda.
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16 }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 8, color: '#a16207', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 2 }}>Alamat Website (URL)</div>
<div style={{ fontSize: 11, fontWeight: 800, color: '#000' }}>https://website-cloud-stunting.vercel.app/</div>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<div>
<div style={{ fontSize: 8, color: '#a16207', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 2 }}>Username</div>
<div style={{ fontSize: 11, fontWeight: 800, color: '#000' }}>{pengguna.username || '-'}</div>
</div>
<div>
<div style={{ fontSize: 8, color: '#a16207', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 2 }}>Password</div>
<div style={{ fontSize: 11, fontWeight: 800, color: '#000' }}>{pengguna.password || '-'}</div>
</div>
</div>
</div>
</div>
<div style={{ marginTop: 16, paddingTop: 8, borderTop: '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', fontSize: 8, color: '#bbb' }}>
<span>Dicetak oleh Dashboard Pengguna StuntiScan</span>
<span>Dokumen ini diterbitkan secara otomatis oleh sistem</span>
</div>
</div>
</div>
<button
onClick={handlePrint}
disabled={loading}
className="flex items-center gap-1.5 px-3 py-2 bg-black text-white text-xs font-black rounded-xl hover:bg-gray-800 transition-all shadow-[4px_4px_0px_0px_rgba(0,0,0,0.1)] hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px] disabled:opacity-50 disabled:cursor-not-allowed uppercase tracking-widest"
>
<Printer className="w-3.5 h-3.5" />
{loading ? 'Proses...' : 'Unduh PDF'}
</button>
</>
)
}

View File

@ -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 (
<div className="bg-white border-2 rounded-xl shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] px-3 py-2 text-xs"
style={{ borderColor: color }}>
<p className="font-black text-gray-700 mb-1">{label}</p>
<p className="font-black flex items-center gap-1" style={{ color }}>
<span className="text-lg">{payload[0]?.value ?? '-'}</span>
<span className="font-bold text-[10px] uppercase opacity-60">{unit}</span>
</p>
</div>
)
}
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<number>(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 (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between flex-wrap gap-3 border-b border-gray-100 pb-4">
<div className="flex items-center gap-3">
<div className="flex -space-x-2">
<div className="w-8 h-8 rounded-full bg-blue-500 border-2 border-white shadow-sm flex items-center justify-center">
<Ruler className="w-4 h-4 text-white" />
</div>
<div className="w-8 h-8 rounded-full bg-emerald-500 border-2 border-white shadow-sm flex items-center justify-center">
<Weight className="w-4 h-4 text-white" />
</div>
</div>
<div>
<p className="text-sm font-black uppercase tracking-tight text-gray-900 leading-none mb-1">
Grafik Pertumbuhan Anak
</p>
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-widest">
Statistik Tinggi & Berat {selectedYear}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-black uppercase tracking-widest text-gray-500">Pilih Tahun:</span>
<div className="relative">
<select
value={selectedYear}
onChange={e => setSelectedYear(Number(e.target.value))}
className="appearance-none border-2 border-black rounded-xl pl-4 pr-10 py-2 text-xs font-black bg-white focus:outline-none shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] cursor-pointer"
>
{availableYears.map(y => (
<option key={y} value={y}>{y}</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 pointer-events-none text-gray-900" />
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="rounded-2xl border-2 border-blue-100 bg-blue-50/20 p-6 flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-blue-100 border border-blue-200 flex items-center justify-center shadow-sm">
<Ruler className="w-5 h-5 text-blue-600" />
</div>
<div>
<p className="text-xs font-black text-blue-800 uppercase tracking-widest">Tinggi Badan</p>
<p className="text-[10px] text-blue-400 font-bold uppercase tracking-tighter">Satuan Centimeter (cm)</p>
</div>
</div>
</div>
{!hasData ? (
<div className="h-44 flex flex-col items-center justify-center text-gray-300 gap-2 border-2 border-dashed border-blue-100 rounded-xl">
<Ruler className="w-8 h-8 opacity-20" />
<p className="text-[10px] font-black uppercase">Data belum tersedia</p>
</div>
) : (
<ResponsiveContainer width="100%" height={180}>
<AreaChart data={chartData} margin={{ top: 8, right: 8, left: -25, bottom: 0 }}>
<defs>
<linearGradient id="tinggiGradUser" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.2} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#dbeafe" />
<XAxis dataKey="label" tick={{ fontSize: 10, fontWeight: 800, fill: '#3b82f6' }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 10, fontWeight: 700, fill: '#94a3b8' }} axisLine={false} tickLine={false} />
<Tooltip content={<MiniTooltip unit="cm" color="#3b82f6" />} />
<Area
type="monotone"
dataKey="tinggi"
stroke="#3b82f6"
strokeWidth={4}
fill="url(#tinggiGradUser)"
dot={{ r: 5, fill: '#3b82f6', stroke: 'white', strokeWidth: 2 }}
activeDot={{ r: 8, strokeWidth: 4 }}
/>
</AreaChart>
</ResponsiveContainer>
)}
</div>
<div className="rounded-2xl border-2 border-emerald-100 bg-emerald-50/20 p-6 flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-emerald-100 border border-emerald-200 flex items-center justify-center shadow-sm">
<Weight className="w-5 h-5 text-emerald-600" />
</div>
<div>
<p className="text-xs font-black text-emerald-800 uppercase tracking-widest">Berat Badan</p>
<p className="text-[10px] text-emerald-400 font-bold uppercase tracking-tighter">Satuan Kilogram (kg)</p>
</div>
</div>
</div>
{!hasData ? (
<div className="h-44 flex flex-col items-center justify-center text-gray-300 gap-2 border-2 border-dashed border-emerald-100 rounded-xl">
<Weight className="w-8 h-8 opacity-20" />
<p className="text-[10px] font-black uppercase">Data belum tersedia</p>
</div>
) : (
<ResponsiveContainer width="100%" height={180}>
<AreaChart data={chartData} margin={{ top: 8, right: 8, left: -25, bottom: 0 }}>
<defs>
<linearGradient id="beratGradUser" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.2} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#d1fae5" />
<XAxis dataKey="label" tick={{ fontSize: 10, fontWeight: 800, fill: '#10b981' }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 10, fontWeight: 700, fill: '#94a3b8' }} axisLine={false} tickLine={false} />
<Tooltip content={<MiniTooltip unit="kg" color="#10b981" />} />
<Area
type="monotone"
dataKey="berat"
stroke="#10b981"
strokeWidth={4}
fill="url(#beratGradUser)"
dot={{ r: 5, fill: '#10b981', stroke: 'white', strokeWidth: 2 }}
activeDot={{ r: 8, strokeWidth: 4 }}
/>
</AreaChart>
</ResponsiveContainer>
)}
</div>
</div>
</div>
)
}

View File

@ -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<number>(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 (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-2">
<Activity className="w-4 h-4 text-gray-500" />
<p className="text-xs font-bold uppercase tracking-widest text-gray-500">Riwayat Pengukuran</p>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-bold uppercase tracking-widest text-gray-400">Periode:</span>
<div className="relative">
<select
value={selectedYear}
onChange={e => setSelectedYear(Number(e.target.value))}
className="appearance-none border-2 border-black rounded-lg pl-3 pr-8 py-1.5 text-sm font-bold bg-white focus:outline-none shadow-[3px_3px_0px_0px_rgba(0,0,0,1)] cursor-pointer"
>
{availableYears.map(year => (
<option key={year} value={year}>{year}</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 pointer-events-none text-gray-500" />
</div>
</div>
</div>
<div className="rounded-xl border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] overflow-hidden">
<div className="overflow-x-auto">
<div className="min-w-[1000px]">
{/* Header */}
<div className="grid grid-cols-[100px_100px_140px_1fr_160px_160px_120px] bg-black text-white px-6 py-4 text-[10px] font-black uppercase tracking-widest">
<span className="text-center">Tinggi</span>
<span className="text-center">Berat</span>
<span className="text-center">Status</span>
<span>Pesan / Rekomendasi</span>
<span className="text-center">Posyandu</span>
<span className="text-center">Tanggal</span>
<span className="text-center">Laporan</span>
</div>
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 gap-3 text-gray-300 bg-white">
<Activity className="w-12 h-12 opacity-20" />
<p className="text-sm text-gray-400 font-black uppercase tracking-widest">Data belum tersedia tahun {selectedYear}</p>
</div>
) : (
<div className="divide-y-2 divide-gray-100 bg-white">
{filtered.map((row) => {
const isStunting = row.status_stunting === true
return (
<div
key={row.id}
className="grid grid-cols-[100px_100px_140px_1fr_160px_160px_120px] items-center px-6 py-6 transition-colors hover:bg-gray-50/50"
>
{/* Tinggi Badan */}
<div className="text-center">
<span className="font-black text-lg text-black">{row.tinggi_badan ?? '-'}</span>
{row.tinggi_badan && <span className="text-[10px] text-gray-400 ml-1 font-bold">cm</span>}
</div>
{/* Berat Badan */}
<div className="text-center">
<span className="font-black text-lg text-black">{row.berat_badan ?? '-'}</span>
{row.berat_badan && <span className="text-[10px] text-gray-400 ml-1 font-bold">kg</span>}
</div>
{/* Status Stunting */}
<div className="flex justify-center">
{row.status_stunting === null ? (
<span className="text-xs text-gray-300 font-bold"></span>
) : (
<span className={`inline-flex items-center px-4 py-1.5 rounded-full text-[10px] font-black border uppercase tracking-widest ${isStunting
? 'bg-red-50 border-red-200 text-red-700 shadow-[2px_2px_0px_0px_rgba(239,68,68,0.1)]'
: 'bg-emerald-50 border-emerald-200 text-emerald-700 shadow-[2px_2px_0px_0px_rgba(16,185,129,0.1)]'
}`}>
{isStunting ? '⚠ Stunting' : '✓ Normal'}
</span>
)}
</div>
{/* Pesan AI — truncated */}
<div className="pr-6">
{row.pesan_ai ? (
<p className="text-[11px] text-gray-600 leading-relaxed font-medium line-clamp-2">
{row.pesan_ai}
</p>
) : (
<span className="text-xs text-gray-300 italic font-medium">Data sedang diproses...</span>
)}
</div>
{/* Nama Posyandu */}
<div className="text-center">
<span className="text-[11px] text-gray-900 font-black uppercase tracking-tight">{row.nama_posyandu ?? '-'}</span>
</div>
{/* Tanggal Upload */}
<div className="text-center">
<span className="text-[11px] text-gray-500 font-bold">{formatDate(row.tanggal_upload)}</span>
</div>
{/* Aksi: Cetak PDF */}
<div className="flex justify-center">
<ExportPDFButton row={row} allData={data} pengguna={pengguna} />
</div>
</div>
)
})}
</div>
)}
</div>
</div>
</div>
</div>
)
}

View File

@ -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 (
<div className="flex flex-col gap-2 p-5 rounded-2xl border-2 border-gray-100 bg-gray-50/30 hover:border-black transition-all group">
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-gray-400 group-hover:text-black transition-colors">
<span className="opacity-50 group-hover:opacity-100">{icon}</span>
{label}
</div>
<p className={`text-base font-black ${accent === 'blue' ? 'text-blue-600' : accent === 'pink' ? 'text-pink-600' : 'text-black'}`}>
{value || <span className="text-gray-300 font-bold italic opacity-50">Belum diisi</span>}
</p>
</div>
)
}
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 (
<div className="min-h-screen flex flex-col items-center justify-center gap-4 bg-white text-black p-8 text-center">
<div className="w-20 h-20 rounded-full bg-red-50 flex items-center justify-center text-red-500 mb-2 border-2 border-red-100">
<User className="w-10 h-10" />
</div>
<h2 className="text-2xl font-black uppercase tracking-tight">Data Tidak Ditemukan</h2>
<p className="max-w-xs text-gray-500 text-sm font-medium">Maaf, kami tidak dapat menemukan profil balita Anda. Silakan hubungi admin di posyandu terdekat.</p>
<Link href="/user-dashboard" className="px-6 py-3 bg-black text-white text-xs font-black rounded-xl uppercase tracking-widest shadow-[4px_4px_0px_0px_rgba(0,0,0,0.1)]">Kembali ke Dashboard</Link>
</div>
)
}
// 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 (
<div className="min-h-screen bg-white font-sans text-black flex flex-col">
{/* Header */}
<header className="flex justify-between items-center px-8 py-6 border-b border-gray-100 bg-white">
<div className="flex items-center gap-4">
<Link href="/user-dashboard" className="group flex items-center gap-2 text-sm font-bold hover:text-gray-600 transition-colors">
<div className="p-2.5 rounded-full border-2 border-black flex items-center justify-center group-hover:bg-black group-hover:text-white transition-all shadow-sm">
<ArrowLeft className="h-4 w-4" />
</div>
<span className="hidden md:block uppercase tracking-widest text-[10px] font-black">Dashboard</span>
</Link>
<div className="h-8 w-px bg-gray-200 mx-1 hidden md:block"></div>
<div className="flex flex-col justify-center">
<h1 className="text-xl font-black leading-none uppercase tracking-tight">Perkembangan Anak</h1>
<p className="text-[10px] text-gray-400 font-bold tracking-widest uppercase mt-1">Laporan Rutin Posyandu</p>
</div>
</div>
<LogoutButton />
</header>
<main className="p-8 max-w-6xl mx-auto flex-1 w-full flex flex-col gap-10">
{/* Hero Profile Section */}
<section className="bg-black text-white rounded-3xl p-8 lg:p-10 flex flex-col lg:flex-row items-center lg:items-end justify-between gap-8 shadow-[12px_12px_0px_0px_rgba(0,0,0,0.1)] relative overflow-hidden">
{/* Background Decorative Element */}
<div className="absolute top-0 right-0 w-64 h-64 bg-white/5 rounded-full -mr-32 -mt-32 blur-3xl pointer-events-none" />
<div className="flex flex-col lg:flex-row items-center lg:items-center gap-6 relative z-10 w-full lg:w-auto">
<div className="w-24 h-24 rounded-full bg-white text-black flex items-center justify-center text-4xl font-black border-4 border-gray-800 shadow-xl overflow-hidden shrink-0">
{pengguna.nama_anak?.charAt(0).toUpperCase() ?? '?'}
</div>
<div className="text-center lg:text-left">
<h2 className="text-3xl lg:text-4xl font-black tracking-tight mb-2">{pengguna.nama_anak}</h2>
<div className="flex flex-wrap items-center justify-center lg:justify-start gap-2">
<span className={`flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest border border-white/20 bg-white/10 ${isLaki ? 'text-blue-200' : 'text-pink-200'}`}>
{isLaki ? <Mars className="w-3 h-3" /> : <Venus className="w-3 h-3" />}
{pengguna.jenis_kelamin}
</span>
<span className="flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest border border-white/20 bg-white/10 text-gray-300">
<Calendar className="w-3 h-3" />
Lahir {formatDate(pengguna.tanggal_lahir)}
</span>
{age && (
<span className="flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest border border-emerald-400/30 bg-emerald-400/10 text-emerald-400">
<Baby className="w-3 h-3" />
Umur {age.months} Bulan {age.days} Hari
</span>
)}
</div>
</div>
</div>
<div className="flex gap-4 w-full lg:w-auto">
<div className="flex-1 lg:flex-none bg-white/10 border border-white/20 rounded-2xl p-4 text-center min-w-[100px]">
<p className="text-[10px] font-black uppercase text-gray-400 tracking-tighter mb-1">Total Cek</p>
<p className="text-2xl font-black">{hasilData?.length ?? 0}</p>
</div>
<div className="flex-1 lg:flex-none bg-white/10 border border-white/20 rounded-2xl p-4 text-center min-w-[100px]">
<p className="text-[10px] font-black uppercase text-gray-400 tracking-tighter mb-1">Status</p>
<p className="text-2xl font-black text-emerald-400">AKTIF</p>
</div>
</div>
</section>
<div className="flex flex-col gap-10">
{/* Top Row: Info & Chart */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10">
{/* Info Column */}
<div className="lg:col-span-4 flex flex-col gap-6">
<div className="flex items-center gap-2">
<User className="w-4 h-4 text-black" />
<h3 className="text-xs font-black uppercase tracking-[0.2em] text-black">Informasi Keluarga</h3>
</div>
<div className="flex flex-col gap-4">
<ReadField icon={<User className="w-3.5 h-3.5" />} label="Nama Ibu / Orang Tua" value={pengguna.nama_orang_tua} />
<ReadField icon={<MapPin className="w-3.5 h-3.5" />} label="Alamat Domisili" value={pengguna.alamat} />
<ReadField icon={<Phone className="w-3.5 h-3.5" />} label="Kontak WhatsApp" value={pengguna.no_whatsapp} />
</div>
<div className="p-6 rounded-3xl bg-amber-50 border-2 border-amber-100">
<h4 className="text-xs font-black uppercase tracking-widest text-amber-700 mb-2 italic">Tips Sehat </h4>
<p className="text-[11px] text-amber-900 leading-relaxed font-medium">Pastikan si kecil mendapatkan asupan gizi seimbang dan rutin mengikuti pemeriksaan di posyandu setiap bulan.</p>
</div>
</div>
{/* Chart Column */}
<div className="lg:col-span-8">
<div className="bg-white rounded-3xl border-2 border-black p-8 shadow-[8px_8px_0px_0px_rgba(0,0,0,0.05)] h-full">
<GrowthChart data={hasilData ?? []} />
</div>
</div>
</div>
{/* Bottom Row: Full Width Table */}
<div className="flex flex-col gap-6">
<div className="bg-white rounded-3xl border-2 border-gray-100 p-1">
<div className="p-6 md:p-8">
<StuntingTable data={hasilData ?? []} pengguna={pengguna} />
</div>
</div>
</div>
</div>
</main>
</div>
)
}

View File

@ -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 (
<div className="bg-white border-2 border-black rounded-xl shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] px-4 py-3 text-sm">
<p className="font-bold text-black mb-2">{label}</p>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-6">
<span className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-red-500 inline-block" />
Stunting
</span>
<span className="font-bold text-red-600">{stunting}</span>
</div>
<div className="flex items-center justify-between gap-6">
<span className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500 inline-block" />
Normal
</span>
<span className="font-bold text-emerald-600">{normal}</span>
</div>
<div className="border-t border-gray-200 mt-1 pt-1 flex items-center justify-between">
<span className="text-gray-500">Prevalensi</span>
<span className="font-bold">{pct}%</span>
</div>
</div>
</div>
)
}
return null
}
export function StuntingChart({ data, availableYears }: Props) {
const [selectedYear, setSelectedYear] = useState<number>(availableYears[availableYears.length - 1] ?? 2026)
const [chartType, setChartType] = useState<'bar' | 'area'>('bar')
const [selectedPosyandu, setSelectedPosyandu] = useState<string>('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 (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-4 mb-2 pb-6 border-b border-gray-100">
<div className="w-14 h-14 rounded-full bg-blue-50 border-2 border-blue-200 flex items-center justify-center text-blue-600 flex-shrink-0">
<TrendingUp className="w-7 h-7" />
</div>
<div>
<h2 className="text-2xl font-bold">
{selectedPosyandu === 'all' ? 'Data Tren Stunting Wilayah' : `Tren Stunting — ${selectedPosyandu}`}
</h2>
<p className="text-sm text-gray-500">
Pantau statistik prevalensi stunting untuk memahami kondisi kesehatan anak di daerah Anda.
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-4 justify-between">
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<span className="text-xs font-bold uppercase tracking-widest text-gray-500">Periode:</span>
<select
value={selectedYear}
onChange={e => setSelectedYear(Number(e.target.value))}
className="border-2 border-black rounded-lg px-3 py-1.5 text-sm font-bold bg-white focus:outline-none shadow-[3px_3px_0px_0px_rgba(0,0,0,1)] cursor-pointer"
>
{availableYears.map(year => (
<option key={year} value={year}>{year}</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-bold uppercase tracking-widest text-gray-500">Posyandu:</span>
<select
value={selectedPosyandu}
onChange={e => setSelectedPosyandu(e.target.value)}
className="border-2 border-black rounded-lg px-3 py-1.5 text-sm font-bold bg-white focus:outline-none shadow-[3px_3px_0px_0px_rgba(0,0,0,1)] cursor-pointer max-w-[200px] truncate"
>
<option value="all">Semua Posyandu</option>
{posyanduList.map(name => (
<option key={name} value={name}>{name}</option>
))}
</select>
</div>
</div>
<div className="flex gap-1 bg-gray-100 p-1 rounded-lg border border-gray-200">
<button onClick={() => setChartType('bar')} className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-bold transition-all ${chartType === 'bar' ? 'bg-black text-white' : 'text-gray-600 hover:text-black'}`}>
<BarChart2 className="w-3.5 h-3.5" /> Bar
</button>
<button onClick={() => setChartType('area')} className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-bold transition-all ${chartType === 'area' ? 'bg-black text-white' : 'text-gray-600 hover:text-black'}`}>
<Activity className="w-3.5 h-3.5" /> Area
</button>
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="border-2 border-gray-200 rounded-xl p-4 bg-gray-50">
<p className="text-[10px] font-bold uppercase tracking-widest text-gray-400 mb-1">Total Pemeriksaan</p>
<p className="text-3xl font-black">{totalAll}</p>
<p className="text-xs text-gray-500 mt-1">data balita</p>
</div>
<div className="border-2 border-red-200 rounded-xl p-4 bg-red-50">
<p className="text-[10px] font-bold uppercase tracking-widest text-red-400 mb-1">Stunting</p>
<p className="text-3xl font-black text-red-600">{totalStunting}</p>
<p className="text-xs text-red-400 mt-1">kasus</p>
</div>
<div className="border-2 border-emerald-200 rounded-xl p-4 bg-emerald-50">
<p className="text-[10px] font-bold uppercase tracking-widest text-emerald-400 mb-1">Normal</p>
<p className="text-3xl font-black text-emerald-600">{totalNormal}</p>
<p className="text-xs text-emerald-400 mt-1">anak</p>
</div>
<div className="border-2 border-amber-200 rounded-xl p-4 bg-amber-50">
<p className="text-[10px] font-bold uppercase tracking-widest text-amber-500 mb-1">Prevalensi</p>
<p className="text-3xl font-black text-amber-600">{prevalensiTotal}%</p>
{trend !== null && (
<div className="flex items-center gap-1 mt-1">
{trend === 'up' && <><TrendingUp className="w-3 h-3 text-red-500" /><span className="text-xs text-red-500">Naik vs {selectedYear - 1}</span></>}
{trend === 'down' && <><TrendingDown className="w-3 h-3 text-emerald-500" /><span className="text-xs text-emerald-500">Turun vs {selectedYear - 1}</span></>}
{trend === 'stable' && <><Minus className="w-3 h-3 text-gray-400" /><span className="text-xs text-gray-400">Stabil</span></>}
</div>
)}
</div>
</div>
<div className="border-2 border-gray-100 rounded-xl p-6 bg-white overflow-hidden">
<p className="text-sm font-black mb-1">Statistik Pertumbuhan Anak {selectedYear}</p>
<p className="text-xs text-gray-400 mb-6 uppercase tracking-wider">Perbandingan Balita Stunting & Normal</p>
{totalAll === 0 ? (
<div className="flex flex-col items-center justify-center h-64 gap-3 text-gray-200">
<TrendingUp className="w-16 h-16 opacity-20" />
<p className="font-bold">Data belum tersedia untuk tahun {selectedYear}</p>
</div>
) : (
<ResponsiveContainer width="100%" height={320}>
{chartType === 'bar' ? (
<BarChart data={chartData} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
<XAxis dataKey="month" axisLine={false} tickLine={false} tick={{ fontSize: 11, fontWeight: 700 }} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 11 }} />
<Tooltip content={<CustomTooltip />} cursor={{ fill: '#f8fafc' }} />
<Legend wrapperStyle={{ paddingTop: 20, fontSize: 12, fontWeight: 800 }} iconType="circle" />
<Bar dataKey="stunting" name="Stunting" fill="#ef4444" radius={[4, 4, 0, 0]} />
<Bar dataKey="normal" name="Normal" fill="#10b981" radius={[4, 4, 0, 0]} />
</BarChart>
) : (
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="colorStunting" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.1} />
<stop offset="95%" stopColor="#ef4444" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorNormal" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.1} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
<XAxis dataKey="month" axisLine={false} tickLine={false} tick={{ fontSize: 11, fontWeight: 700 }} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 11 }} />
<Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ paddingTop: 20, fontSize: 12, fontWeight: 800 }} iconType="circle" />
<Area type="monotone" dataKey="stunting" name="Stunting" stroke="#ef4444" strokeWidth={3} fillOpacity={1} fill="url(#colorStunting)" />
<Area type="monotone" dataKey="normal" name="Normal" stroke="#10b981" strokeWidth={3} fillOpacity={1} fill="url(#colorNormal)" />
</AreaChart>
)}
</ResponsiveContainer>
)}
</div>
<div className="border-2 border-amber-100 rounded-xl p-6 bg-amber-50/20">
<div className="flex items-center gap-2 mb-1">
<Percent className="w-5 h-5 text-amber-600" />
<p className="text-sm font-black text-amber-900 uppercase">Prevalensi Bulanan (%)</p>
</div>
<p className="text-[10px] text-amber-600 mb-6 font-bold uppercase tracking-widest italic">Ambang Batas Keamanan WHO: 20%</p>
{totalAll === 0 ? (
<div className="h-32 flex items-center justify-center text-amber-200">
<p className="text-sm font-bold opacity-30">Belum ada data prevalensi</p>
</div>
) : (
<ResponsiveContainer width="100%" height={240}>
<LineChart data={chartData} margin={{ top: 10, right: 20, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#fef3c7" />
<XAxis dataKey="month" axisLine={false} tickLine={false} tick={{ fontSize: 11, fontWeight: 700 }} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 11 }} domain={[0, 100]} />
<Tooltip
content={({ active, payload, label }) => {
if (active && payload && payload.length) {
const val = payload[0].value as number
return (
<div className="bg-white border-2 border-amber-500 rounded-xl shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] px-4 py-3 text-sm">
<p className="font-black text-black mb-1">{label} {selectedYear}</p>
<div className="flex items-center gap-2">
<span className={`w-3 h-3 rounded-full ${val >= 20 ? 'bg-red-500' : 'bg-emerald-500'}`} />
<span className="font-black text-lg">{val}%</span>
</div>
<p className="text-[10px] font-bold mt-1 uppercase tracking-tight">
{val >= 20 ? '🛑 Tinggi' : '✅ Aman'}
</p>
</div>
)
}
return null
}}
/>
<Line
type="monotone"
dataKey="prevalensi"
stroke="#f59e0b"
strokeWidth={4}
dot={({ cx, cy, payload }) => (
<circle
key={`dot-${payload.month}`}
cx={cx} cy={cy} r={6}
fill={payload.prevalensi >= 20 ? '#ef4444' : '#10b981'}
stroke="white"
strokeWidth={2}
/>
)}
/>
</LineChart>
</ResponsiveContainer>
)}
</div>
</div>
)
}

View File

@ -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 (
<div className="min-h-screen flex items-center justify-center text-red-500 font-semibold">
Gagal memuat data stunting.
</div>
)
}
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 (
<div className="min-h-screen bg-white font-sans text-black flex flex-col">
{/* Header */}
<header className="flex justify-between items-center px-8 py-6 border-b border-gray-100">
<div className="flex items-center gap-4">
<Link href="/user-dashboard" className="group flex items-center gap-2 text-sm font-bold hover:text-gray-600 transition-colors">
<div className="p-2 rounded-full border border-black flex items-center justify-center group-hover:bg-black group-hover:text-white transition-all">
<ArrowLeft className="h-5 w-5" />
</div>
<span className="hidden md:block">Kembali</span>
</Link>
<div className="h-8 w-px bg-gray-200 mx-2 hidden md:block"></div>
<div className="flex flex-col justify-center">
<h1 className="text-xl font-bold leading-none">Trend stunting Daerah</h1>
<p className="text-[10px] text-gray-500 tracking-widest uppercase mt-0.5">Analisis Data Semua Posyandu</p>
</div>
</div>
<LogoutButton />
</header>
<main className="p-8 max-w-6xl mx-auto flex-1 w-full">
<div className="bg-white rounded-xl border border-black shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] p-8">
<StuntingChart
data={rawData ?? []}
availableYears={allYears}
/>
</div>
</main>
</div>
)
}