add user features access
This commit is contained in:
parent
10f1739944
commit
878f2994be
|
|
@ -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 ── */}
|
||||
<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 style={{ fontSize: 11, fontWeight: 700, letterSpacing: 3, textTransform: 'uppercase', color: '#555', marginBottom: 6 }}>
|
||||
Sistem Informasi Posyandu
|
||||
|
|
@ -155,18 +157,18 @@ export function CetakPDFButton({ row, allData, pengguna }: Props) {
|
|||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<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={{ fontSize: 13, fontWeight: 700 }}>{tanggalUpload}</div>
|
||||
<div style={{ fontSize: 12, fontWeight: 700 }}>{tanggalUpload}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Identitas ── */}
|
||||
<div style={{ marginBottom: 28 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: 2, textTransform: 'uppercase', color: '#888', marginBottom: 10 }}>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: 2, textTransform: 'uppercase', color: '#888', marginBottom: 6 }}>
|
||||
Identitas
|
||||
</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 Anak', pengguna.nama_anak],
|
||||
|
|
@ -176,9 +178,9 @@ export function CetakPDFButton({ row, allData, pengguna }: Props) {
|
|||
['Tanggal Lahir', tanggalLahir],
|
||||
].map(([label, value], i) => (
|
||||
label ? (
|
||||
<div key={i} style={{ borderBottom: '1px solid #eee', paddingBottom: 8 }}>
|
||||
<div style={{ fontSize: 9, color: '#888', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 1, marginBottom: 3 }}>{label}</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>{value}</div>
|
||||
<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} />
|
||||
))}
|
||||
|
|
@ -186,15 +188,15 @@ export function CetakPDFButton({ row, allData, pengguna }: Props) {
|
|||
</div>
|
||||
|
||||
{/* ── Charts ── */}
|
||||
<div style={{ marginBottom: 28 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: 2, textTransform: 'uppercase', color: '#888', marginBottom: 10 }}>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: 2, textTransform: 'uppercase', color: '#888', marginBottom: 6 }}>
|
||||
Grafik Perkembangan Balita (5 Bulan Terakhir)
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||
{/* Tinggi */}
|
||||
<div style={{ border: '1.5px solid #dbeafe', borderRadius: 12, padding: '14px 14px 4px', background: '#eff6ff' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#1d4ed8', marginBottom: 8 }}>📏 Tinggi Badan (cm)</div>
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<div style={{ border: '1.5px solid #dbeafe', borderRadius: 10, padding: '12px 12px 2px', background: '#eff6ff' }}>
|
||||
<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="pdfTinggiGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
|
|
@ -203,19 +205,19 @@ export function CetakPDFButton({ row, allData, pengguna }: Props) {
|
|||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#bfdbfe" />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 9, fontWeight: 600 }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 9 }} axisLine={false} tickLine={false} />
|
||||
<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(#pdfTinggiGrad)"
|
||||
dot={{ r: 4, fill: '#3b82f6', stroke: 'white', strokeWidth: 2 }}
|
||||
dot={{ r: 3, fill: '#3b82f6', stroke: 'white', strokeWidth: 2 }}
|
||||
connectNulls={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
{/* Berat */}
|
||||
<div style={{ border: '1.5px solid #d1fae5', borderRadius: 12, padding: '14px 14px 4px', background: '#f0fdf4' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#059669', marginBottom: 8 }}>⚖️ Berat Badan (kg)</div>
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<div style={{ border: '1.5px solid #d1fae5', borderRadius: 10, padding: '12px 12px 2px', background: '#f0fdf4' }}>
|
||||
<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="pdfBeratGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
|
|
@ -224,10 +226,10 @@ export function CetakPDFButton({ row, allData, pengguna }: Props) {
|
|||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#a7f3d0" />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 9, fontWeight: 600 }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 9 }} axisLine={false} tickLine={false} />
|
||||
<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(#pdfBeratGrad)"
|
||||
dot={{ r: 4, fill: '#10b981', stroke: 'white', strokeWidth: 2 }}
|
||||
dot={{ r: 3, fill: '#10b981', stroke: 'white', strokeWidth: 2 }}
|
||||
connectNulls={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
|
|
@ -237,28 +239,28 @@ export function CetakPDFButton({ row, allData, pengguna }: Props) {
|
|||
</div>
|
||||
|
||||
{/* ── Data Pemeriksaan ── */}
|
||||
<div style={{ marginBottom: 32 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: 2, textTransform: 'uppercase', color: '#888', marginBottom: 10 }}>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: 2, textTransform: 'uppercase', color: '#888', marginBottom: 6 }}>
|
||||
Data Pemeriksaan
|
||||
</div>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
|
||||
<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: '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>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style={{ background: '#f9fafb' }}>
|
||||
<td style={{ padding: '12px', 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: '12px' }}>
|
||||
<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: '3px 10px',
|
||||
padding: '2px 8px',
|
||||
borderRadius: 20,
|
||||
fontSize: 11,
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
background: isStunting ? '#fef2f2' : '#f0fdf4',
|
||||
color: isStunting ? '#b91c1c' : '#15803d',
|
||||
|
|
@ -267,41 +269,62 @@ export function CetakPDFButton({ row, allData, pengguna }: Props) {
|
|||
{isStunting ? '⚠ Stunting' : '✓ Normal'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px' }}>{row.nama_posyandu ?? '-'}</td>
|
||||
<td style={{ padding: '12px' }}>{tanggalUpload}</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: 12, border: '1.5px solid #fde68a', borderRadius: 10, padding: '12px 16px', background: '#fffbeb' }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 1.5, color: '#92400e', marginBottom: 5 }}>
|
||||
Pesan Kecerdasan Buatan (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: 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>
|
||||
|
||||
{/* ── Footer / Tanda Tangan ── */}
|
||||
<div style={{ borderTop: '1.5px solid #e5e7eb', paddingTop: 24, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 40 }}>
|
||||
{/* ── 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: 10, color: '#888', marginBottom: 48 }}>Mengetahui,</div>
|
||||
<div style={{ borderTop: '1.5px solid #333', paddingTop: 6 }}>
|
||||
<div style={{ fontSize: 10, color: '#555' }}>Kepala Puskesmas / Supervisor</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>
|
||||
<div style={{ fontSize: 10, color: '#888', marginBottom: 48 }}>Petugas Posyandu,</div>
|
||||
<div style={{ borderTop: '1.5px solid #333', paddingTop: 6 }}>
|
||||
<div style={{ fontSize: 10, color: '#555' }}>Nama & Tanda Tangan</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>
|
||||
|
||||
{/* ── 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>Dokumen ini sah tanpa tanda tangan basah apabila dicetak dari sistem</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export default function LoginPage() {
|
|||
</div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">HealthPortal</h1>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm tracking-widest uppercase">SISTEM INFORMASI KESEHATAN</p>
|
||||
|
||||
</div>
|
||||
|
||||
<h2 className="text-4xl font-bold mb-8">Panduan Login</h2>
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
</h2>
|
||||
<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>
|
||||
|
||||
</div>
|
||||
|
|
@ -123,7 +141,17 @@ export default async function UserDashboardPage() {
|
|||
<Calendar className="h-3 w-3" />
|
||||
Tanggal Lahir
|
||||
</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>
|
||||
|
|
@ -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"
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue