'use client' import { useState, useRef, useMemo, useEffect } from 'react' import { FileDown, FolderOpen, X, Loader2, CheckCircle2, ChevronDown } from 'lucide-react' import { supabase } from '@/lib/supabase' import { showSwal } from '@/lib/swal' import { AreaChart, Area, XAxis, YAxis, CartesianGrid, ResponsiveContainer, } from 'recharts' // ─── TYPES ──────────────────────────────────────────────────────── interface HasilItem { id: number id_balita: number tinggi_badan: number | null berat_badan: number | null z_score: number | null status_stunting: boolean | null pesan_ai: string | null tanggal_upload: string | null nama_posyandu: string | null } interface PenggunaData { id: number nama_orang_tua: string alamat: string | null nama_anak: string jenis_kelamin: string | null tanggal_lahir: string | null username?: string password?: string } const MONTHS = [ { num: 1, name: 'Januari' }, { num: 2, name: 'Februari' }, { num: 3, name: 'Maret' }, { num: 4, name: 'April' }, { num: 5, name: 'Mei' }, { num: 6, name: 'Juni' }, { num: 7, name: 'Juli' }, { num: 8, name: 'Agustus' }, { num: 9, name: 'September' }, { num: 10, name: 'Oktober' }, { num: 11, name: 'November' }, { num: 12, name: 'Desember' }, ] const START_YEAR = 2026 const currentYear = new Date().getFullYear() const YEARS = Array.from( { length: Math.max(currentYear, START_YEAR) - START_YEAR + 1 }, (_, i) => START_YEAR + i, ) type Step = 'config' | 'generating' | 'done' | 'error' // ─── HELPERS ────────────────────────────────────────────────────── 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, zscore: match?.z_score ?? null, } }) } export function CetakInstanModal() { const [open, setOpen] = useState(false) const [year, setYear] = useState(START_YEAR) const [month, setMonth] = useState(new Date().getMonth() + 1) const [dirHandle, setDirHandle] = useState(null) const [step, setStep] = useState('config') const [progress, setProgress] = useState({ current: 0, total: 0, name: '', mama: '' }) const [elapsed, setElapsed] = useState(0) const [errorMsg, setErrorMsg] = useState('') // Template state for batch processing const [activePrintData, setActivePrintData] = useState<{ pengguna: PenggunaData row: HasilItem allHasil: HasilItem[] } | null>(null) const templateRef = useRef(null) const monthName = MONTHS.find(m => m.num === month)?.name ?? 'Bulan' const folderName = `${monthName.toLowerCase()}_${year}` const supportsFileSys = typeof window !== 'undefined' && 'showDirectoryPicker' in window const pickDir = async () => { try { const handle = await (window as any).showDirectoryPicker({ mode: 'readwrite' }) setDirHandle(handle) } catch { /* user cancelled */ } } const reset = () => { setStep('config') setProgress({ current: 0, total: 0, name: '', mama: '' }) setElapsed(0) setErrorMsg('') setActivePrintData(null) } const handleClose = () => { setOpen(false) setTimeout(reset, 300) } const handleGenerate = async () => { setStep('generating') const startTime = Date.now() const timer = setInterval(() => setElapsed(Math.floor((Date.now() - startTime) / 1000)), 500) try { const { default: html2canvas } = await import('html2canvas') const { default: jsPDF } = await import('jspdf') // 1. Fetch data const { data: balitaList, error: eB } = await supabase .from('akun_balita') .select('*') .order('nama_orang_tua', { ascending: true }) if (eB) throw new Error(eB.message) const fromDate = new Date(year - 1, month - 2, 1).toISOString().split('T')[0] const toDate = new Date(year, month - 1, 31).toISOString().split('T')[0] const { data: hasilAll, error: eH } = await supabase .from('hasil_stunting_balita') .select('*') .gte('tanggal_upload', fromDate) .lte('tanggal_upload', toDate) .order('tanggal_upload', { ascending: true }) if (eH) throw new Error(eH.message) const targets = (balitaList ?? []).filter(b => (hasilAll ?? []).some(h => { if (h.id_balita !== b.id || !h.tanggal_upload) return false const d = new Date(h.tanggal_upload) return d.getFullYear() === year && d.getMonth() + 1 === month }) ) if (targets.length === 0) { setErrorMsg(`Tidak ada data balita untuk ${monthName} ${year}`) setStep('error') clearInterval(timer) return } setProgress({ current: 0, total: targets.length, name: '', mama: '' }) // 2. Prepare folder let folderHandle: FileSystemDirectoryHandle | null = null if (dirHandle) { folderHandle = await dirHandle.getDirectoryHandle(folderName, { create: true }) } // 3. Generation Loop for (let i = 0; i < targets.length; i++) { const b = targets[i] as PenggunaData const balitaHasil = (hasilAll ?? []).filter(h => h.id_balita === b.id) as HasilItem[] const rowForMonth = balitaHasil.find(h => { if (!h.tanggal_upload) return false const d = new Date(h.tanggal_upload) return d.getFullYear() === year && d.getMonth() + 1 === month }) if (!rowForMonth) continue setProgress({ current: i + 1, total: targets.length, name: b.nama_anak, mama: b.nama_orang_tua }) // --- Update template and wait for render --- setActivePrintData({ pengguna: b, row: rowForMonth, allHasil: balitaHasil }) // Give React and Recharts some time to finish rendering the hidden template await new Promise(r => setTimeout(r, 1000)) // 1s buffer for stable DOM & Recharts if (!templateRef.current) continue // --- Capture --- const canvas = await html2canvas(templateRef.current, { scale: 2, useCORS: true, backgroundColor: '#ffffff', logging: false, }) const imgData = canvas.toDataURL('image/png') // Safety check: ensure imgData is a valid Data URI if (!imgData || !imgData.startsWith('data:image/')) { console.error('Invalid image data generated for', b.nama_anak) continue } 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 } } // --- Save --- const fileName = `Laporan_${b.nama_anak.replace(/\s+/g, '_')}.pdf` const blob = pdf.output('blob') if (folderHandle) { const fh = await folderHandle.getFileHandle(fileName, { create: true }) const writable = await fh.createWritable() await writable.write(blob) await writable.close() } else { const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = fileName a.click() URL.revokeObjectURL(url) await new Promise(r => setTimeout(r, 200)) } } clearInterval(timer) setStep('done') await showSwal.success('Selesai!', `Berhasil mencetak ${targets.length} file PDF.`) handleClose() } catch (err: any) { clearInterval(timer) setErrorMsg(err?.message ?? 'Terjadi kesalahan saat mencetak.') setStep('error') showSwal.error('Gagal!', err?.message ?? 'Terjadi kesalahan saat mencetak.') } finally { setActivePrintData(null) } } const pct = progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0 const estRemaining = progress.current > 0 ? Math.round((elapsed / progress.current) * (progress.total - progress.current)) : null // ── Template helper variables ────────────────────────────────── const chartData = useMemo(() => { if (!activePrintData) return [] const rowDate = activePrintData.row.tanggal_upload ? new Date(activePrintData.row.tanggal_upload) : new Date() return build5MonthData(activePrintData.allHasil, rowDate) }, [activePrintData]) const isStunting = activePrintData?.row.status_stunting === true const tanggalCetak = new Date().toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' }) const tanggalUpload = activePrintData ? formatTgl(activePrintData.row.tanggal_upload) : '-' const tanggalLahir = activePrintData ? formatTgl(activePrintData.pengguna.tanggal_lahir) : '-' return ( <> {/* Trigger button */} {/* Backdrop + Modal */} {open && (
e.stopPropagation()} > {/* Header */}

Cetak Data Instan

Generate PDF semua balita per periode

{step !== 'generating' && ( )}
{/* ── Steps: Config, Generating, Done, Error ── */} {step === 'config' && (

Pilih Periode

Lokasi Penyimpanan

{supportsFileSys ? (
{dirHandle &&
Folder dibuat: {folderName}
}
) : (
Browser tidak mendukung pemilihan direktori. PDF akan diunduh satu per satu.
)}
)} {step === 'generating' && (

Sedang mencetak PDF...

Harap tunggu, jangan tutup halaman ini.

{progress.current} / {progress.total} balita {pct}%
{progress.name && (

{progress.name}

Ibu: {progress.mama}

)}

{String(Math.floor(elapsed / 60)).padStart(2, '0')}:{String(elapsed % 60).padStart(2, '0')}

{estRemaining !== null ? `${String(Math.floor(estRemaining / 60)).padStart(2, '0')}:${String(estRemaining % 60).padStart(2, '0')}` : '--:--'}

)} {step === 'done' && (

Selesai! {progress.total} file dicetak.

)} {step === 'error' && (

Gagal

{errorMsg}

)}
)} {/* ─── HIDDEN PDF TEMPLATE (Rich HTML) ─── */} {activePrintData && (
{/* Header */}
Sistem Informasi Posyandu
Laporan Pemeriksaan Balita
Tanggal Cetak
{tanggalCetak}
Tgl Pemeriksaan
{tanggalUpload}
{/* Identitas */}
Identitas
{[ ['Nama Ibu / Orang Tua', activePrintData.pengguna.nama_orang_tua], ['Nama Anak', activePrintData.pengguna.nama_anak], ['Alamat', activePrintData.pengguna.alamat ?? '-'], ['Jenis Kelamin', activePrintData.pengguna.jenis_kelamin ?? '-'], ['', ''], ['Tanggal Lahir', tanggalLahir], ].map(([label, value], i) => ( label ? (
{label}
{value}
) :
))}
{/* Charts */}
Grafik Perkembangan Balita (5 Bulan Terakhir)
{/* Tinggi */}
📏 Tinggi Badan (cm)
{/* Berat */}
⚖️ Berat Badan (kg)
{/* Z-Score PDF Chart */}
📈 Z-Score (SD)
{/* Table */}
Data Pemeriksaan
{['Tinggi', 'Berat', 'Z-Score', 'Status Stunting', 'Posyandu', 'Tgl Pemeriksaan'].map(h => ( ))}
{h}
{activePrintData.row.tinggi_badan ?? '-'} cm {activePrintData.row.berat_badan ?? '-'} kg {activePrintData.row.z_score ?? '-'} SD {isStunting ? '⚠ Stunting' : '✓ Normal'} {activePrintData.row.nama_posyandu ?? '-'} {tanggalUpload}
{activePrintData.row.pesan_ai && (
Rekomendasi / Pesan AI
{activePrintData.row.pesan_ai}
)}
{/* ── WhatsApp Info Box ── */}
📱
Layanan Informasi WhatsApp
Untuk orang tua yang tidak memiliki akun WhatsApp, yuk segera buat akun karena kami melayani layanan penyampaian informasi hasil stunting dengan menggunakan WhatsApp agar mendapatkan informasi lebih cepat.
{/* ── Portal Access Info Box ── */}
🌐 Akses Portal Online Orang Tua
* Yuk kunjungi website Bapak/Ibu agar mendapatkan informasi perkembangan buah hati Anda.
Alamat Website (URL)
https://website-cloud-stunting.vercel.app/
Username
{activePrintData.pengguna.username || '-'}
Password
{activePrintData.pengguna.password || '-'}
{/* Footer */}
Dicetak oleh Sistem Informasi Posyandu Dokumen ini sah tanpa tanda tangan basah apabila dicetak dari sistem
)} ) }