'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 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 } 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 } }) } 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, 600)) // 600ms buffer for Recharts animations/stable DOM 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') 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 ${progress.total} 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' }) 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
{formatTgl(activePrintData.row.tanggal_upload)}
{/* 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', formatTgl(activePrintData.pengguna.tanggal_lahir)], ].map(([label, value], i) => (
{label}
{value}
))}
{/* Charts */}
Grafik Perkembangan (5 Bulan)
📏 Tinggi Badan (cm)
⚖️ Berat Badan (kg)
{/* Table */}
Data Pemeriksaan
{['Tinggi', 'Berat', 'Status', 'Posyandu', 'Tgl Upload'].map(h => ( ))}
{h}
{activePrintData.row.tinggi_badan} cm {activePrintData.row.berat_badan} kg {isStunting ? 'Stunting' : 'Normal'} {activePrintData.row.nama_posyandu} {formatTgl(activePrintData.row.tanggal_upload)}
{activePrintData.row.pesan_ai && (
PESAN AI
{activePrintData.row.pesan_ai}
)}
{/* Signatures */}
Mengetahui,
Supervisor
Petugas Posyandu,
Nama & Tanda Tangan
)} ) }