TKK_E32231405/app/dashboard/kelola-jadwal/CetakBatchJadwalModal.tsx

435 lines
26 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useState, useRef, useEffect } from 'react'
import { FileDown, FolderOpen, X, Loader2, CheckCircle2, ChevronDown, Calendar, Printer } from 'lucide-react'
import { supabase } from '@/lib/supabase'
import { showSwal } from '@/lib/swal'
import { getPetugasLokalByPosyandu } from './action-jadwal'
// ─── TYPES ────────────────────────────────────────────────────────
interface JadwalItem {
id: string
tanggal: string
jam_mulai: string
jam_selesai: string
diedit_oleh: string
posyandu_id: string
detail_posyandu: {
nama_posyandu: string
alamat: string
}
}
interface PetugasLokal {
nama_petugas: string
nomor_hp: string
jabatan: 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'
interface Props {
adminName: string
}
export function CetakBatchJadwalModal({ adminName }: Props) {
const [open, setOpen] = useState(false)
const [year, setYear] = useState(START_YEAR)
const [month, setMonth] = useState(new Date().getMonth() + 1)
const [dirHandle, setDirHandle] = useState<FileSystemDirectoryHandle | null>(null)
const [step, setStep] = useState<Step>('config')
const [progress, setProgress] = useState({ current: 0, total: 0, name: '' })
const [elapsed, setElapsed] = useState(0)
const [errorMsg, setErrorMsg] = useState('')
// Template state for batch processing
const [activeJadwal, setActiveJadwal] = useState<JadwalItem | null>(null)
const [activePetugas, setActivePetugas] = useState<PetugasLokal[]>([])
const templateRef = useRef<HTMLDivElement>(null)
const monthName = MONTHS.find(m => m.num === month)?.name ?? 'Bulan'
const folderName = `jadwal_${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: '' })
setElapsed(0)
setErrorMsg('')
setActiveJadwal(null)
setActivePetugas([])
}
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 schedules for the period
const startOfMonth = `${year}-${String(month).padStart(2, '0')}-01`
const endOfMonth = new Date(year, month, 0).toISOString().split('T')[0]
const { data: schedules, error: eJ } = await supabase
.from('jadwal_posyandu')
.select(`
id, tanggal, jam_mulai, jam_selesai, diedit_oleh, posyandu_id,
detail_posyandu ( nama_posyandu, alamat )
`)
.gte('tanggal', startOfMonth)
.lte('tanggal', endOfMonth)
.order('tanggal', { ascending: true })
if (eJ) throw new Error(eJ.message)
if (!schedules || schedules.length === 0) {
setErrorMsg(`Tidak ada jadwal ditemukan untuk ${monthName} ${year}`)
setStep('error')
clearInterval(timer)
return
}
setProgress({ current: 0, total: schedules.length, name: '' })
// 2. Prepare folder
let folderHandle: FileSystemDirectoryHandle | null = null
if (dirHandle) {
folderHandle = await dirHandle.getDirectoryHandle(folderName, { create: true })
}
// 3. Loop and Generate
for (let i = 0; i < schedules.length; i++) {
const j = schedules[i] as any as JadwalItem
setProgress({ current: i + 1, total: schedules.length, name: j.detail_posyandu.nama_posyandu })
// Fetch local officers for this Posyandu
const resP = await getPetugasLokalByPosyandu(j.posyandu_id)
const petugas = resP.success ? resP.petugas : []
// Update template and wait for render
setActiveJadwal(j)
setActivePetugas(petugas || [])
await new Promise(r => setTimeout(r, 400)) // Stable render buffer
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 imgH = (canvas.height * pageW) / canvas.width
pdf.addImage(imgData, 'PNG', 0, 0, pageW, imgH)
const fileName = `Jadwal_${j.detail_posyandu.nama_posyandu.replace(/\s+/g, '_')}_${j.tanggal}.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 ${schedules.length} file PDF jadwal.`)
handleClose()
} catch (err: any) {
clearInterval(timer)
setErrorMsg(err?.message ?? 'Gagal memproses batch PDF.')
setStep('error')
} finally {
setActiveJadwal(null)
setActivePetugas([])
}
}
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 now = new Date()
const tglCetak = now.toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' })
const jamCetak = now.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' })
return (
<>
<button
onClick={() => { setOpen(true); reset() }}
className="flex items-center justify-center gap-2 px-5 py-2.5 bg-white text-gray-700 border-2 border-gray-100 font-black rounded-xl hover:border-black hover:text-black transition-all text-xs"
>
<Printer className="w-4 h-4" />
Cetak Semua Jadwal
</button>
{open && (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" onClick={step === 'config' ? handleClose : undefined}>
<div className="bg-white rounded-2xl border-2 border-black shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] w-full max-w-md overflow-hidden" onClick={e => e.stopPropagation()}>
{/* Header */}
<div className="flex items-center justify-between px-6 py-5 border-b-2 border-black bg-black text-white">
<div className="flex items-center gap-3">
<Printer className="w-5 h-5 text-red-500" />
<div>
<p className="font-black text-lg leading-none">Cetak Batch Jadwal</p>
<p className="text-[10px] text-gray-400 mt-0.5 uppercase tracking-widest">Generate PDF Kolektif Per Periode</p>
</div>
</div>
{step !== 'generating' && (
<button onClick={handleClose} className="p-1.5 rounded-full hover:bg-white/10 transition-colors">
<X className="w-4 h-4" />
</button>
)}
</div>
{step === 'config' && (
<div className="p-6 flex flex-col gap-6">
<div className="grid grid-cols-2 gap-3">
<div className="relative">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-1 block">Tahun</label>
<select value={year} onChange={e => setYear(Number(e.target.value))} className="w-full appearance-none border-2 border-gray-200 focus:border-black rounded-lg pl-3 pr-8 py-2.5 text-sm font-bold bg-white focus:outline-none transition-colors">
{YEARS.map(y => <option key={y} value={y}>{y}</option>)}
</select>
<ChevronDown className="absolute right-3 bottom-2.5 w-4 h-4 text-gray-400 pointer-events-none" />
</div>
<div className="relative">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-1 block">Bulan</label>
<select value={month} onChange={e => setMonth(Number(e.target.value))} className="w-full appearance-none border-2 border-gray-200 focus:border-black rounded-lg pl-3 pr-8 py-2.5 text-sm font-bold bg-white focus:outline-none transition-colors">
{MONTHS.map(m => <option key={m.num} value={m.num}>{m.name}</option>)}
</select>
<ChevronDown className="absolute right-3 bottom-2.5 w-4 h-4 text-gray-400 pointer-events-none" />
</div>
</div>
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-2">Lokasi Penyimpanan</p>
{supportsFileSys ? (
<button onClick={pickDir} className="w-full flex items-center justify-between px-4 py-3 border-2 border-dashed border-gray-200 hover:border-black rounded-xl transition-all group">
<div className="flex items-center gap-3">
<FolderOpen className="w-5 h-5 text-gray-400 group-hover:text-black" />
<span className="text-sm font-bold text-gray-500 group-hover:text-black">{dirHandle ? dirHandle.name : 'Pilih Folder Tujuan'}</span>
</div>
{dirHandle && <CheckCircle2 className="w-4 h-4 text-emerald-500" />}
</button>
) : (
<div className="p-3 bg-amber-50 border border-amber-100 rounded-xl text-[10px] font-bold text-amber-700 leading-relaxed uppercase">
Browser Anda tidak mendukung akses folder. File akan diunduh satu per satu secara otomatis.
</div>
)}
</div>
<button onClick={handleGenerate} className="w-full py-4 bg-red-600 text-white font-black text-xs uppercase tracking-widest rounded-xl hover:bg-red-700 transition-all shadow-[4px_4px_0px_0px_rgba(0,0,0,0.2)] hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px] flex items-center justify-center gap-2">
<FileDown className="w-4 h-4" />
Mulai Cetak Batch
</button>
</div>
)}
{step === 'generating' && (
<div className="p-6 flex flex-col gap-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-red-50 rounded-lg flex items-center justify-center flex-shrink-0 animate-pulse">
<Loader2 className="w-5 h-5 text-red-600 animate-spin" />
</div>
<div>
<p className="font-black text-base leading-tight">Sedang Memproses PDF...</p>
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-1">Jangan tutup halaman ini</p>
</div>
</div>
<div>
<div className="flex justify-between items-end mb-1.5">
<span className="text-[10px] font-black uppercase text-gray-400">{progress.current} / {progress.total} POSYANDU</span>
<span className="text-lg font-black">{pct}%</span>
</div>
<div className="w-full h-3 bg-gray-100 rounded-full overflow-hidden border border-gray-100">
<div className="h-full bg-red-600 rounded-full transition-all duration-300" style={{ width: `${pct}%` }} />
</div>
</div>
{progress.name && (
<div className="p-3 bg-gray-50 border border-gray-100 rounded-xl">
<p className="text-[10px] font-black text-gray-400 uppercase mb-0.5">Memproses:</p>
<p className="text-sm font-black uppercase truncate">{progress.name}</p>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div className="p-3 bg-gray-50 border border-gray-100 rounded-xl text-center">
<p className="text-[9px] font-black text-gray-400 uppercase mb-1">Berjalan</p>
<p className="text-xl font-black font-mono">{String(Math.floor(elapsed / 60)).padStart(2, '0')}:{String(elapsed % 60).padStart(2, '0')}</p>
</div>
<div className="p-3 bg-gray-50 border border-gray-100 rounded-xl text-center">
<p className="text-[9px] font-black text-gray-400 uppercase mb-1">Estimasi</p>
<p className="text-xl font-black font-mono text-emerald-600">{estRemaining !== null ? `${String(Math.floor(estRemaining / 60)).padStart(2, '0')}:${String(estRemaining % 60).padStart(2, '0')}` : '--:--'}</p>
</div>
</div>
</div>
)}
{step === 'error' && (
<div className="p-8 flex flex-col items-center text-center gap-4">
<div className="w-16 h-16 bg-red-50 rounded-full flex items-center justify-center">
<X className="w-8 h-8 text-red-500" />
</div>
<div>
<p className="font-black text-xl mb-1 uppercase">Gagal Memproses</p>
<p className="text-xs text-gray-500 font-semibold">{errorMsg}</p>
</div>
<button onClick={reset} className="w-full py-3 bg-black text-white font-black text-xs uppercase tracking-widest rounded-xl">Coba Lagi</button>
</div>
)}
</div>
</div>
)}
{/* ─── Hidden PDF Template (Same as CetakPDFJadwal) ─── */}
{activeJadwal && (
<div style={{ position: 'fixed', top: '-9999px', left: '-9999px', zIndex: -1 }}>
<div ref={templateRef} style={{ width: '794px', backgroundColor: '#ffffff', fontFamily: "'Inter', 'Arial', sans-serif", color: '#111111', padding: '60px 70px', boxSizing: 'border-box' }}>
<div style={{ borderBottom: '4px solid #000', paddingBottom: '20px', marginBottom: '30px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '12px', fontWeight: 800, letterSpacing: '4px', textTransform: 'uppercase', color: '#666', marginBottom: '8px' }}>Pemerintah Kabupaten / Kota</div>
<div style={{ fontSize: '26px', fontWeight: 900, letterSpacing: '-1px', color: '#000' }}>DINAS KESEHATAN & POSYANDU</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: '10px', fontWeight: 700, color: '#888', textTransform: 'uppercase' }}>Nomor Dokumen</div>
<div style={{ fontSize: '14px', fontWeight: 800 }}>JDWL-{activeJadwal.id.slice(0, 8).toUpperCase()}</div>
</div>
</div>
</div>
<div style={{ textAlign: 'center', marginBottom: '40px' }}>
<h1 style={{ fontSize: '20px', fontWeight: 900, textTransform: 'uppercase', margin: 0, letterSpacing: '2px' }}>SURAT PEMBERITAHUAN JADWAL PELAKSANAAN</h1>
<div style={{ width: '60px', height: '3px', background: '#000', margin: '15px auto' }}></div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '40px', marginBottom: '40px' }}>
<div>
<div style={{ fontSize: '9px', fontWeight: 800, textTransform: 'uppercase', color: '#888', letterSpacing: '1px', marginBottom: '15px' }}>DETAIL POSYANDU</div>
<div style={{ marginBottom: '15px' }}>
<div style={{ fontSize: '10px', color: '#888', fontWeight: 600, marginBottom: '4px' }}>Nama Posyandu</div>
<div style={{ fontSize: '14px', fontWeight: 800, color: '#000', textTransform: 'uppercase' }}>{activeJadwal.detail_posyandu.nama_posyandu}</div>
</div>
<div>
<div style={{ fontSize: '10px', color: '#888', fontWeight: 600, marginBottom: '4px' }}>Alamat Lokasi</div>
<div style={{ fontSize: '12px', fontWeight: 600, lineHeight: '1.5', color: '#333' }}>{activeJadwal.detail_posyandu.alamat}</div>
</div>
</div>
<div>
<div style={{ fontSize: '9px', fontWeight: 800, textTransform: 'uppercase', color: '#888', letterSpacing: '1px', marginBottom: '15px' }}>WAKTU PELAKSANAAN</div>
<div style={{ marginBottom: '15px' }}>
<div style={{ fontSize: '10px', color: '#888', fontWeight: 600, marginBottom: '4px' }}>Hari & Tanggal</div>
<div style={{ fontSize: '14px', fontWeight: 800, color: '#000' }}>{new Date(activeJadwal.tanggal).toLocaleDateString('id-ID', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}</div>
</div>
<div>
<div style={{ fontSize: '10px', color: '#888', fontWeight: 600, marginBottom: '4px' }}>Sesi Operasional</div>
<div style={{ fontSize: '18px', fontWeight: 900, color: '#e11d48' }}>{activeJadwal.jam_mulai.slice(0, 5)} - {activeJadwal.jam_selesai.slice(0, 5)} WIB</div>
</div>
</div>
</div>
<div style={{ marginBottom: '40px' }}>
<div style={{ fontSize: '9px', fontWeight: 800, textTransform: 'uppercase', color: '#888', letterSpacing: '1px', marginBottom: '15px' }}>DAFTAR PETUGAS LOKAL BERTUGAS</div>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid #000' }}>
<th style={{ textAlign: 'left', padding: '12px 8px', fontSize: '11px', fontWeight: 800, textTransform: 'uppercase' }}>Nama Petugas</th>
<th style={{ textAlign: 'left', padding: '12px 8px', fontSize: '11px', fontWeight: 800, textTransform: 'uppercase' }}>Nomor Telepon</th>
<th style={{ textAlign: 'left', padding: '12px 8px', fontSize: '11px', fontWeight: 800, textTransform: 'uppercase' }}>Jabatan</th>
</tr>
</thead>
<tbody>
{activePetugas.length > 0 ? activePetugas.map((p, i) => (
<tr key={i} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: '12px 8px', fontSize: '12px', fontWeight: 700 }}>{p.nama_petugas}</td>
<td style={{ padding: '12px 8px', fontSize: '12px', fontWeight: 600, color: '#666' }}>{p.nomor_hp}</td>
<td style={{ padding: '12px 8px', fontSize: '11px', fontWeight: 700, color: '#e11d48', textTransform: 'uppercase' }}>{p.jabatan}</td>
</tr>
)) : (
<tr><td colSpan={3} style={{ padding: '30px', textAlign: 'center', fontSize: '12px', color: '#888', fontStyle: 'italic', background: '#f9f9f9' }}>Belum ada petugas lokal yang terdaftar.</td></tr>
)}
</tbody>
</table>
</div>
<div style={{ background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: '12px', padding: '20px', marginBottom: '50px' }}>
<div style={{ fontSize: '10px', fontWeight: 800, color: '#334155', textTransform: 'uppercase', marginBottom: '8px' }}> CATATAN SISTEM</div>
<p style={{ fontSize: '11px', lineHeight: '1.7', color: '#475569', margin: 0, fontWeight: 500 }}>
"Dimohon agar aktivitas program pengecekan stunting di setiap Posyandu dijalankan sesuai dengan waktu dan sesi yang tertera dalam jadwal, guna mendukung pemeliharaan sistem yang baik."
</p>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '40px', borderTop: '1px solid #eee', paddingTop: '30px' }}>
<div style={{ fontSize: '10px', color: '#888', lineHeight: '1.6' }}>
<div style={{ fontWeight: 700, color: '#555', marginBottom: '4px' }}>Metode Penjadwalan:</div>
Sistem Penjadwalan Otomatis (Algoritma Random Batch)<br />
Ditentukan oleh Admin: <span style={{ fontWeight: 700, color: '#000' }}>{activeJadwal.diedit_oleh.replace('[HISTORY] ', '')}</span>
</div>
<div style={{ textAlign: 'right', fontSize: '10px', color: '#888', lineHeight: '1.6' }}>
<div style={{ fontWeight: 700, color: '#555', marginBottom: '4px' }}>Detail Pengunduhan:</div>
Dicetak oleh Admin: <span style={{ fontWeight: 700, color: '#000' }}>{adminName}</span><br />
Waktu Cetak: <span style={{ fontWeight: 700, color: '#000' }}>{tglCetak}, {jamCetak} WIB</span>
</div>
</div>
<div style={{ marginTop: '40px', textAlign: 'center', fontSize: '9px', color: '#ccc', letterSpacing: '1px' }}>DOKUMEN INI DIHASILKAN SECARA OTOMATIS OLEH SISTEM CLOUD STUNTING</div>
</div>
</div>
)}
</>
)
}