add manajemen posyandu and scheduling
This commit is contained in:
parent
f6e65b5545
commit
10f1739944
|
|
@ -3,6 +3,7 @@
|
|||
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,
|
||||
|
|
@ -241,10 +242,13 @@ export function CetakInstanModal() {
|
|||
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
XAxis, YAxis, CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import { showSwal } from '@/lib/swal'
|
||||
|
||||
interface HasilItem {
|
||||
id: number
|
||||
|
|
@ -120,6 +121,8 @@ export function CetakPDFButton({ row, allData, pengguna }: Props) {
|
|||
}
|
||||
|
||||
pdf.save(`Laporan_${pengguna.nama_anak}_${tanggalUpload.replace(/ /g, '_')}.pdf`)
|
||||
} catch (err: any) {
|
||||
showSwal.error('Gagal!', `Gagal mencetak PDF: ${err.message}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,434 @@
|
|||
'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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,233 @@
|
|||
'use client'
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { FileText, Loader2, Printer } from 'lucide-react'
|
||||
import { getPetugasLokalByPosyandu } from './action-jadwal'
|
||||
import { showSwal } from '@/lib/swal'
|
||||
|
||||
interface PetugasLokal {
|
||||
nama_petugas: string
|
||||
nomor_hp: string
|
||||
jabatan: string
|
||||
}
|
||||
|
||||
interface JadwalData {
|
||||
id: string
|
||||
tanggal: string
|
||||
jam_mulai: string
|
||||
jam_selesai: string
|
||||
diedit_oleh: string
|
||||
posyandu_id: string
|
||||
detail_posyandu: {
|
||||
nama_posyandu: string
|
||||
alamat: string
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
jadwal: JadwalData
|
||||
currentAdmin: string
|
||||
}
|
||||
|
||||
export function CetakPDFJadwal({ jadwal, currentAdmin }: Props) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const templateRef = useRef<HTMLDivElement>(null)
|
||||
const [petugas, setPetugas] = useState<PetugasLokal[]>([])
|
||||
|
||||
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' })
|
||||
|
||||
const tglJadwal = new Date(jadwal.tanggal).toLocaleDateString('id-ID', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
})
|
||||
|
||||
const handlePrint = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// 1. Fetch Local Officers first
|
||||
const res = await getPetugasLokalByPosyandu(jadwal.posyandu_id)
|
||||
if (res.success) {
|
||||
setPetugas(res.petugas || [])
|
||||
}
|
||||
|
||||
// Small delay to ensure state update renders in the hidden div
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
|
||||
const { default: html2canvas } = await import('html2canvas')
|
||||
const { default: jsPDF } = await import('jspdf')
|
||||
|
||||
if (!templateRef.current) throw new Error('Template not found')
|
||||
|
||||
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)
|
||||
pdf.save(`Jadwal_${jadwal.detail_posyandu.nama_posyandu.replace(/ /g, '_')}_${jadwal.tanggal}.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: '794px', // A4 width at 96 DPI
|
||||
backgroundColor: '#ffffff',
|
||||
fontFamily: "'Inter', 'Arial', sans-serif",
|
||||
color: '#111111',
|
||||
padding: '60px 70px',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
{/* Header: Kop Surat Style */}
|
||||
<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-{jadwal.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>
|
||||
|
||||
{/* Informasi Utama */}
|
||||
<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' }}>
|
||||
A. 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' }}>{jadwal.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' }}>{jadwal.detail_posyandu.alamat}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '9px', fontWeight: 800, textTransform: 'uppercase', color: '#888', letterSpacing: '1px', marginBottom: '15px' }}>
|
||||
B. 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' }}>{tglJadwal}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '10px', color: '#888', fontWeight: 600, marginBottom: '4px' }}>Sesi Operasional</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: 900, color: '#e11d48' }}>{jadwal.jam_mulai.slice(0, 5)} - {jadwal.jam_selesai.slice(0, 5)} WIB</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabel Petugas */}
|
||||
<div style={{ marginBottom: '40px' }}>
|
||||
<div style={{ fontSize: '9px', fontWeight: 800, textTransform: 'uppercase', color: '#888', letterSpacing: '1px', marginBottom: '15px' }}>
|
||||
C. 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>
|
||||
{petugas.length > 0 ? petugas.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 untuk Posyandu ini.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Catatan Penting */}
|
||||
<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', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
⚠️ 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>
|
||||
|
||||
{/* Footer / Metadata */}
|
||||
<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' }}>{jadwal.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' }}>{currentAdmin}</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>
|
||||
|
||||
{/* ─── Visible Button ─── */}
|
||||
<button
|
||||
onClick={handlePrint}
|
||||
disabled={loading}
|
||||
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-all group relative"
|
||||
title="Cetak PDF Detail Jadwal"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<FileText className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useActionState, useEffect, useRef } from 'react'
|
||||
import { X, Calendar, Clock, Loader2, Sparkles, AlertCircle } from 'lucide-react'
|
||||
import { generateInstantSchedule } from './action-jadwal'
|
||||
import { showSwal } from '@/lib/swal'
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
adminName: string
|
||||
}
|
||||
|
||||
export function InstantScheduleModal({ isOpen, onClose, adminName }: Props) {
|
||||
const [state, formAction, isPending] = useActionState(generateInstantSchedule, null)
|
||||
const processedStateRef = useRef<any>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Detect NEW result from action
|
||||
if (state && state !== processedStateRef.current) {
|
||||
// 1. Immediately close the modal so it doesn't block the alert or persist
|
||||
onClose()
|
||||
|
||||
// 2. Show the alert after closing the modal
|
||||
if (state.success) {
|
||||
showSwal.success('Berhasil!', `${state.message} Silakan gunakan tombol "Cetak Semua Jadwal" untuk mengunduh seluruh laporan PDF.`)
|
||||
} else {
|
||||
showSwal.error('Gagal!', state.message)
|
||||
}
|
||||
|
||||
// 3. Mark this specific state object as processed
|
||||
processedStateRef.current = state
|
||||
}
|
||||
}, [state, onClose])
|
||||
|
||||
// Reset processed state when modal is opened for a fresh experience
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
processedStateRef.current = null
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const tomorrow = new Date()
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
const minDate = tomorrow.toISOString().split('T')[0]
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
|
||||
<div className="bg-white w-full max-w-md rounded-2xl border-2 border-black shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] overflow-hidden animate-in zoom-in duration-200">
|
||||
|
||||
<div className="p-5 bg-red-500 border-b-2 border-black flex justify-between items-center text-white">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-1.5 bg-white/20 rounded-lg backdrop-blur-sm">
|
||||
<Sparkles className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<h3 className="text-base font-black uppercase tracking-tight">Penjadwalan Instan</h3>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1.5 hover:bg-white/10 rounded-full transition-all">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form action={formAction} className="p-6 flex flex-col gap-5">
|
||||
<input type="hidden" name="edited_by" value={adminName} />
|
||||
<input type="hidden" name="jam_mulai" value="08:00" />
|
||||
<input type="hidden" name="jam_selesai" value="11:00" />
|
||||
|
||||
<div className="p-3 bg-blue-50 border border-blue-200 rounded-xl flex gap-x-2">
|
||||
<AlertCircle className="w-4 h-4 text-blue-600 flex-shrink-0" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-[10px] text-blue-800 leading-relaxed font-bold uppercase tracking-tight">
|
||||
Penjadwalan harus dilakukan minimal 1 hari sebelumnya.
|
||||
</p>
|
||||
<p className="text-[10px] text-blue-800 leading-relaxed font-bold uppercase tracking-tight">
|
||||
Sesi tetap: 08:00 - 11:00 WIB (Selingan 1 Jam).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tanggal */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-[9px] font-black uppercase tracking-widest text-gray-400 flex items-center gap-2">
|
||||
<Calendar className="w-3.5 h-3.5" /> Pilih Tanggal Pelaksanaan
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="tanggal"
|
||||
required
|
||||
min={minDate}
|
||||
defaultValue={minDate}
|
||||
className="w-full p-3 border-2 border-black rounded-xl font-bold focus:outline-none focus:ring-4 focus:ring-red-500/10 transition-all text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-gray-50 border-2 border-dashed border-gray-200 rounded-xl">
|
||||
<label className="text-[9px] font-black uppercase tracking-widest text-gray-400 flex items-center gap-2 mb-2">
|
||||
<Clock className="w-3.5 h-3.5" /> Waktu Sesi (Otomatis)
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="px-3 py-1.5 bg-black text-white text-sm font-black rounded-lg">
|
||||
08:00
|
||||
</div>
|
||||
<div className="h-0.5 w-4 bg-gray-300"></div>
|
||||
<div className="px-3 py-1.5 bg-black text-white text-sm font-black rounded-lg">
|
||||
11:00
|
||||
</div>
|
||||
<span className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-auto">Fixed Slot</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="w-full py-4 bg-black text-white font-black text-xs uppercase tracking-widest rounded-xl hover:bg-gray-800 transition-all shadow-[4px_4px_0px_0px_rgba(0,0,0,0.3)] hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px] flex items-center justify-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
Menghitung...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="w-5 h-5" />
|
||||
Buat Jadwal Sekarang
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
'use client'
|
||||
|
||||
import { useActionState, useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { X, Calendar, Clock, Loader2, Save, AlertCircle, CheckCircle2 } from 'lucide-react'
|
||||
import { updateJadwal, getOccupiedSlots } from './action-jadwal'
|
||||
import { showSwal } from '@/lib/swal'
|
||||
|
||||
interface JadwalData {
|
||||
id: string
|
||||
posyandu_id: string
|
||||
tanggal: string
|
||||
jam_mulai: string
|
||||
jam_selesai: string
|
||||
detail_posyandu: {
|
||||
nama_posyandu: string
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
data: JadwalData | null
|
||||
adminName: string
|
||||
}
|
||||
|
||||
const SESSIONS = [
|
||||
{ start: '08:00', end: '10:00' },
|
||||
{ start: '11:00', end: '13:00' },
|
||||
{ start: '14:00', end: '16:00' },
|
||||
]
|
||||
|
||||
export function JadwalFormModal({ isOpen, onClose, data, adminName }: Props) {
|
||||
const [state, formAction, isPending] = useActionState(updateJadwal, null)
|
||||
const [selectedDate, setSelectedDate] = useState('')
|
||||
const [occupiedSlots, setOccupiedSlots] = useState<{ start: string; end: string; posyandu_id: string }[]>([])
|
||||
const [isLoadingSlots, setIsLoadingSlots] = useState(false)
|
||||
const [selectedSession, setSelectedSession] = useState<{ start: string; end: string } | null>(null)
|
||||
const processedStateRef = useRef<any>(null)
|
||||
|
||||
const fetchSlots = useCallback(async (date: string) => {
|
||||
setIsLoadingSlots(true)
|
||||
const res = await getOccupiedSlots(date)
|
||||
if (res.success) {
|
||||
setOccupiedSlots(res.slots)
|
||||
}
|
||||
setIsLoadingSlots(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (data && isOpen) {
|
||||
setSelectedDate(data.tanggal)
|
||||
setSelectedSession({
|
||||
start: data.jam_mulai.slice(0, 5),
|
||||
end: data.jam_selesai.slice(0, 5)
|
||||
})
|
||||
fetchSlots(data.tanggal)
|
||||
}
|
||||
}, [data, isOpen, fetchSlots])
|
||||
|
||||
useEffect(() => {
|
||||
if (state && state !== processedStateRef.current) {
|
||||
onClose()
|
||||
if (state.success) {
|
||||
showSwal.success('Berhasil!', state.message)
|
||||
} else {
|
||||
showSwal.error('Gagal!', state.message)
|
||||
}
|
||||
processedStateRef.current = state
|
||||
}
|
||||
}, [state, onClose])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
processedStateRef.current = null
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
if (!isOpen || !data) return null
|
||||
|
||||
const allSlotsFull = SESSIONS.every(session =>
|
||||
occupiedSlots.some(occ =>
|
||||
occ.start === session.start &&
|
||||
occ.end === session.end &&
|
||||
occ.posyandu_id !== data.posyandu_id
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
|
||||
<div className="bg-white w-full max-w-md rounded-2xl border-2 border-black shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] overflow-hidden animate-in zoom-in duration-200">
|
||||
|
||||
{/* Header */}
|
||||
<div className="p-5 bg-black border-b-2 border-black flex justify-between items-center text-white">
|
||||
<div className="flex flex-col">
|
||||
<h3 className="text-base font-black uppercase tracking-tight">Atur Ulang Jadwal</h3>
|
||||
<p className="text-[9px] text-gray-400 font-bold uppercase tracking-widest">{data.detail_posyandu.nama_posyandu}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1.5 hover:bg-white/10 rounded-full transition-all">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form action={formAction} className="p-6 flex flex-col gap-6">
|
||||
<input type="hidden" name="id" value={data.id} />
|
||||
<input type="hidden" name="edited_by" value={adminName} />
|
||||
<input type="hidden" name="jam_mulai" value={selectedSession?.start || ''} />
|
||||
<input type="hidden" name="jam_selesai" value={selectedSession?.end || ''} />
|
||||
|
||||
{/* Tanggal */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-[9px] font-black uppercase tracking-widest text-gray-400 flex items-center gap-2">
|
||||
<Calendar className="w-3.5 h-3.5" /> Tanggal Pelaksanaan
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="tanggal"
|
||||
value={selectedDate}
|
||||
onChange={(e) => {
|
||||
setSelectedDate(e.target.value)
|
||||
fetchSlots(e.target.value)
|
||||
}}
|
||||
required
|
||||
className="w-full p-3 border-2 border-black rounded-xl font-bold text-sm focus:outline-none focus:ring-4 focus:ring-black/5 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sesi Section */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="text-[9px] font-black uppercase tracking-widest text-gray-400 flex items-center gap-2">
|
||||
<Clock className="w-3.5 h-3.5" /> Pilih Sesi Tersedia
|
||||
</label>
|
||||
|
||||
{allSlotsFull && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-xl flex gap-2 items-center">
|
||||
<AlertCircle className="w-4 h-4 text-red-600 flex-shrink-0" />
|
||||
<p className="text-[10px] font-bold text-red-700 uppercase tracking-tight leading-tight">
|
||||
Maaf, seluruh jadwal di hari ini sudah penuh.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{SESSIONS.map((session, i) => {
|
||||
const isOccupied = occupiedSlots.some(occ =>
|
||||
occ.start === session.start &&
|
||||
occ.end === session.end &&
|
||||
occ.posyandu_id !== data.posyandu_id
|
||||
)
|
||||
const isSelected = selectedSession?.start === session.start
|
||||
const isCurrent = data.jam_mulai.slice(0, 5) === session.start && data.tanggal === selectedDate
|
||||
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
disabled={isOccupied || isLoadingSlots}
|
||||
onClick={() => setSelectedSession(session)}
|
||||
className={`relative p-3.5 border-2 rounded-xl transition-all flex items-center justify-between group
|
||||
${isOccupied
|
||||
? 'bg-gray-50 border-gray-100 opacity-50 cursor-not-allowed'
|
||||
: isSelected
|
||||
? 'bg-black border-black text-white shadow-[3px_3px_0px_0px_rgba(0,0,0,0.2)]'
|
||||
: 'bg-white border-black hover:bg-gray-50'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-1.5 rounded-lg ${isSelected ? 'bg-white/20' : 'bg-gray-100 group-hover:bg-black group-hover:text-white transition-colors'}`}>
|
||||
<Clock className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="text-base font-black tracking-tight">{session.start} - {session.end}</span>
|
||||
<span className={`text-[8px] font-bold uppercase tracking-widest ${isSelected ? 'text-gray-400' : 'text-gray-400'}`}>
|
||||
{isOccupied ? 'Sesi Terpakai' : isCurrent ? 'Jadwal Saat Ini' : 'Sesi Tersedia'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && <CheckCircle2 className="w-5 h-5 text-emerald-400" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending || !selectedSession || allSlotsFull}
|
||||
className="w-full py-4 bg-black text-white font-black text-xs uppercase tracking-widest rounded-xl hover:bg-gray-800 transition-all shadow-[4px_4px_0px_0px_rgba(0,0,0,0.3)] hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px] flex items-center justify-center gap-2 disabled:opacity-50 disabled:grayscale"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-5 h-5" />
|
||||
)}
|
||||
{isPending ? 'Menyimpan...' : 'Update Jadwal'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,332 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useMemo, useActionState, useEffect, useRef } from 'react'
|
||||
import { Search, Calendar, Clock, Trash2, Building2, Sparkles, Filter, Edit3, Loader2, History, RotateCcw, Printer } from 'lucide-react'
|
||||
import { InstantScheduleModal } from './InstantScheduleModal'
|
||||
import { JadwalFormModal } from './JadwalFormModal'
|
||||
import { CetakPDFJadwal } from './CetakPDFJadwal'
|
||||
import { CetakBatchJadwalModal } from './CetakBatchJadwalModal'
|
||||
import { deleteSchedulesByDate, deleteJadwal, archiveSchedulesByMonth, deleteAllHistory } from './action-jadwal'
|
||||
import { showSwal } from '@/lib/swal'
|
||||
|
||||
interface JadwalWithPosyandu {
|
||||
id: string
|
||||
posyandu_id: string
|
||||
tanggal: string
|
||||
jam_mulai: string
|
||||
jam_selesai: string
|
||||
diedit_oleh: string
|
||||
detail_posyandu: {
|
||||
nama_posyandu: string
|
||||
alamat: string
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: JadwalWithPosyandu[]
|
||||
userName: string
|
||||
}
|
||||
|
||||
export function JadwalTable({ data, userName }: Props) {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [filterDate, setFilterDate] = useState('')
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [isInstantModalOpen, setIsInstantModalOpen] = useState(false)
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
|
||||
const [selectedJadwal, setSelectedJadwal] = useState<JadwalWithPosyandu | null>(null)
|
||||
const [isDeleting, setIsDeleting] = useState<string | null>(null)
|
||||
|
||||
const now = new Date()
|
||||
const currentMonth = now.getMonth()
|
||||
const currentYear = now.getFullYear()
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
return data.filter(j => {
|
||||
const isHistoryRecord = j.diedit_oleh.startsWith('[HISTORY]')
|
||||
|
||||
// History View Logic
|
||||
if (showHistory) {
|
||||
if (!isHistoryRecord) return false
|
||||
} else {
|
||||
if (isHistoryRecord) return false
|
||||
}
|
||||
|
||||
const dateObj = new Date(j.tanggal)
|
||||
const isCurrentMonth = dateObj.getMonth() === currentMonth && dateObj.getFullYear() === currentYear
|
||||
|
||||
// Monthly Filter Logic for Non-History
|
||||
if (!showHistory && !isCurrentMonth) return false
|
||||
|
||||
const searchTermClean = searchTerm.toLowerCase()
|
||||
const matchesSearch = j.detail_posyandu.nama_posyandu.toLowerCase().includes(searchTermClean) ||
|
||||
j.diedit_oleh.toLowerCase().includes(searchTermClean)
|
||||
const matchesDate = filterDate ? j.tanggal === filterDate : true
|
||||
|
||||
return matchesSearch && matchesDate
|
||||
})
|
||||
}, [data, searchTerm, filterDate, showHistory, currentMonth, currentYear])
|
||||
|
||||
const isScheduledThisMonth = useMemo(() => {
|
||||
return data.some(j => {
|
||||
const dateObj = new Date(j.tanggal)
|
||||
const isCurrentMonth = dateObj.getMonth() === currentMonth && dateObj.getFullYear() === currentYear
|
||||
return isCurrentMonth && !j.diedit_oleh.startsWith('[HISTORY]')
|
||||
})
|
||||
}, [data, currentMonth, currentYear])
|
||||
|
||||
const handleDeleteDate = async () => {
|
||||
if (!filterDate) {
|
||||
showSwal.error('Pilih Tanggal', 'Silakan pilih tanggal terlebih dahulu untuk menghapus jadwal pada hari tersebut.')
|
||||
return
|
||||
}
|
||||
|
||||
const confirm = await showSwal.confirm(
|
||||
'Hapus Jadwal Hari Ini?',
|
||||
`Seluruh jadwal untuk tanggal ${new Date(filterDate).toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' })} akan dihapus.`
|
||||
)
|
||||
|
||||
if (confirm.isConfirmed) {
|
||||
const res = await deleteSchedulesByDate(filterDate)
|
||||
if (res.success) showSwal.success('Berhasil!', res.message)
|
||||
else showSwal.error('Gagal!', res.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResetMonth = async () => {
|
||||
const confirm = await showSwal.confirm(
|
||||
'Arsipkan Jadwal Bulan Ini?',
|
||||
'Jadwal bulan ini akan dipindahkan ke Histori dan disembunyikan dari tabel utama. Anda bisa melihatnya kembali di fitur Lihat Histori.'
|
||||
)
|
||||
|
||||
if (confirm.isConfirmed) {
|
||||
const res = await archiveSchedulesByMonth(currentMonth, currentYear)
|
||||
if (res.success) showSwal.success('Berhasil!', res.message)
|
||||
else showSwal.error('Gagal!', res.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearHistory = async () => {
|
||||
const confirm = await showSwal.confirm(
|
||||
'Hapus Seluruh Histori?',
|
||||
'Tindakan ini akan menghapus SELURUH data di riwayat penjadwalan secara PERMANEN. Data tidak dapat dipulihkan.'
|
||||
)
|
||||
|
||||
if (confirm.isConfirmed) {
|
||||
const res = await deleteAllHistory()
|
||||
if (res.success) showSwal.success('Berhasil!', res.message)
|
||||
else showSwal.error('Gagal!', res.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteJadwal = async (id: string, name: string) => {
|
||||
const confirm = await showSwal.confirm(
|
||||
'Hapus Jadwal?',
|
||||
`Apakah Anda yakin ingin menghapus jadwal untuk ${name}?`
|
||||
)
|
||||
|
||||
if (confirm.isConfirmed) {
|
||||
setIsDeleting(id)
|
||||
const res = await deleteJadwal(id)
|
||||
if (res.success) showSwal.success('Berhasil!', res.message)
|
||||
else showSwal.error('Gagal!', res.message)
|
||||
setIsDeleting(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditJadwal = (jadwal: JadwalWithPosyandu) => {
|
||||
setSelectedJadwal(jadwal)
|
||||
setIsEditModalOpen(true)
|
||||
}
|
||||
|
||||
const monthNames = ["Januari", "Februari", "Maret", "April", "Mei", "Juni", "Juli", "Agustus", "September", "Oktober", "November", "Desember"]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Header / Action Bar */}
|
||||
<div className="flex flex-col xl:flex-row justify-between items-start xl:items-center gap-4">
|
||||
<div className="flex flex-col md:flex-row gap-3 w-full xl:w-auto">
|
||||
{/* Search */}
|
||||
<div className="relative w-full md:w-72">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Cari posyandu..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-gray-50 border-2 border-transparent focus:border-black rounded-xl text-xs font-bold outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
{/* Date Filter */}
|
||||
<div className="relative w-full md:w-48">
|
||||
<Filter className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="date"
|
||||
value={filterDate}
|
||||
onChange={(e) => setFilterDate(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-gray-50 border-2 border-transparent focus:border-black rounded-xl text-xs font-bold outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
className={`flex items-center justify-center gap-2 px-5 py-2.5 border-2 rounded-xl text-xs font-black transition-all
|
||||
${showHistory
|
||||
? 'bg-black text-white border-black'
|
||||
: 'bg-white text-gray-600 border-gray-100 hover:border-black hover:text-black'
|
||||
}`}
|
||||
>
|
||||
<History className="w-4 h-4" />
|
||||
{showHistory ? 'Sembunyikan Histori' : 'Lihat Histori'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap md:flex-nowrap items-center gap-3 w-full md:w-auto">
|
||||
<CetakBatchJadwalModal adminName={userName} />
|
||||
|
||||
{showHistory ? (
|
||||
<button
|
||||
onClick={handleClearHistory}
|
||||
disabled={filteredData.length === 0}
|
||||
className="flex-1 md:flex-none flex items-center justify-center gap-2 px-5 py-2.5 bg-white text-purple-600 border-2 border-purple-100 font-black rounded-xl hover:bg-purple-50 hover:border-purple-500 transition-all text-xs disabled:opacity-50 disabled:grayscale"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Hapus Semua Histori
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{isScheduledThisMonth && (
|
||||
<button
|
||||
onClick={handleResetMonth}
|
||||
className="flex-1 md:flex-none flex items-center justify-center gap-2 px-5 py-2.5 bg-white text-red-600 border-2 border-red-100 font-black rounded-xl hover:bg-red-50 hover:border-red-500 transition-all text-xs"
|
||||
title="Reset Seluruh Jadwal Bulan Ini"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Reset {monthNames[currentMonth]}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsInstantModalOpen(true)}
|
||||
disabled={isScheduledThisMonth}
|
||||
className={`flex-1 md:flex-none flex items-center justify-center gap-2 px-5 py-2.5 font-black rounded-xl transition-all shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px] text-xs uppercase tracking-widest
|
||||
${isScheduledThisMonth
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed shadow-none translate-x-0 translate-y-0 opacity-50 grayscale'
|
||||
: 'bg-red-600 text-white hover:bg-red-700'
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
{isScheduledThisMonth ? 'Terjadwal' : 'Penjadwalan Instan'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className={`overflow-hidden rounded-2xl border-2 border-black shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] transition-colors ${showHistory ? 'bg-slate-50' : 'bg-white'}`}>
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className={`${showHistory ? 'bg-purple-600' : 'bg-black'} text-white transition-colors`}>
|
||||
<tr>
|
||||
<th className="px-6 py-5 text-[10px] font-black uppercase tracking-widest text-center w-20">No</th>
|
||||
<th className="px-6 py-5 text-[10px] font-black uppercase tracking-widest">Waktu & Sesi</th>
|
||||
<th className="px-6 py-5 text-[10px] font-black uppercase tracking-widest">Detail Posyandu</th>
|
||||
<th className="px-6 py-5 text-[10px] font-black uppercase tracking-widest">Oleh Admin</th>
|
||||
<th className="px-6 py-5 text-[10px] font-black uppercase tracking-widest text-center">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className={`${showHistory ? 'bg-slate-50 divide-purple-100' : 'bg-white divide-gray-100'} divide-y-2`}>
|
||||
{filteredData.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-24 text-center">
|
||||
<div className="flex flex-col items-center gap-4 text-gray-300">
|
||||
<div className="p-4 bg-gray-50 rounded-full">
|
||||
<Calendar className="w-12 h-12 opacity-20" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-black text-gray-400">Belum ada jadwal ditemukan</p>
|
||||
<p className="text-xs font-semibold">Gunakan Penjadwalan Instan untuk membuat jadwal otomatis.</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredData.map((j, idx) => (
|
||||
<tr key={j.id} className={`${showHistory ? 'hover:bg-purple-100/50' : 'hover:bg-red-50/30'} transition-colors group`}>
|
||||
<td className={`px-6 py-6 text-center font-black ${showHistory ? 'text-purple-300' : 'text-gray-300'} group-hover:text-red-300 text-lg`}>
|
||||
{idx + 1}
|
||||
</td>
|
||||
<td className="px-6 py-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className={`flex items-center gap-2 ${showHistory ? 'text-purple-600' : 'text-red-600'} font-black`}>
|
||||
<Clock className="w-4 h-4" />
|
||||
<span className="text-lg">{j.jam_mulai.slice(0, 5)} - {j.jam_selesai.slice(0, 5)}</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold uppercase text-gray-400 tracking-widest">
|
||||
{new Date(j.tanggal).toLocaleDateString('id-ID', { weekday: 'long', day: 'numeric', month: 'long' })}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-6">
|
||||
<div className="flex flex-col">
|
||||
<span className={`font-black text-base ${showHistory ? 'text-purple-900' : 'text-gray-900'} group-hover:text-red-600 transition-colors uppercase tracking-tight`}>
|
||||
{j.detail_posyandu.nama_posyandu}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500 font-semibold mt-1">
|
||||
<Building2 className="w-3.5 h-3.5" />
|
||||
<span className="line-clamp-1">{j.detail_posyandu.alamat}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-8 h-8 rounded-full ${showHistory ? 'bg-purple-100' : 'bg-gray-100'} border-2 border-white shadow-sm flex items-center justify-center text-[10px] font-black group-hover:bg-red-100 transition-colors`}>
|
||||
{j.diedit_oleh.replace('[HISTORY] ', '').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="text-xs font-bold text-gray-700">{j.diedit_oleh.replace('[HISTORY] ', '')}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-6">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<CetakPDFJadwal jadwal={j} currentAdmin={userName} />
|
||||
<button
|
||||
onClick={() => handleEditJadwal(j)}
|
||||
className="p-2 text-gray-400 hover:text-black hover:bg-gray-100 rounded-lg transition-all"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteJadwal(j.id, j.detail_posyandu.nama_posyandu)}
|
||||
disabled={isDeleting === j.id}
|
||||
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-all disabled:opacity-50"
|
||||
title="Hapus"
|
||||
>
|
||||
{isDeleting === j.id ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<InstantScheduleModal
|
||||
isOpen={isInstantModalOpen}
|
||||
onClose={() => setIsInstantModalOpen(false)}
|
||||
adminName={userName}
|
||||
/>
|
||||
|
||||
<JadwalFormModal
|
||||
isOpen={isEditModalOpen}
|
||||
onClose={() => setIsEditModalOpen(false)}
|
||||
data={selectedJadwal}
|
||||
adminName={userName}
|
||||
/>
|
||||
</div >
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,243 @@
|
|||
'use server'
|
||||
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
export async function generateInstantSchedule(prevState: any, formData: FormData) {
|
||||
const tanggal = formData.get('tanggal') as string
|
||||
const startStr = (formData.get('jam_mulai') as string) || '08:00'
|
||||
const endStr = (formData.get('jam_selesai') as string) || '11:00'
|
||||
const editedBy = formData.get('edited_by') as string
|
||||
|
||||
if (!tanggal) {
|
||||
return { success: false, message: 'Tanggal pelaksanaan harus dipilih.' }
|
||||
}
|
||||
|
||||
// Lead time validation: Must be at least tomorrow
|
||||
const inputDate = new Date(tanggal)
|
||||
const tomorrow = new Date()
|
||||
tomorrow.setHours(0, 0, 0, 0)
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
|
||||
if (inputDate < tomorrow) {
|
||||
return { success: false, message: 'Penjadwalan harus dilakukan minimal 1 hari sebelumnya (Besok atau lusa).' }
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Fetch all registered Posyandu
|
||||
const { data: allPosyandu, error: fetchErr } = await supabase
|
||||
.from('detail_posyandu')
|
||||
.select('id, nama_posyandu')
|
||||
|
||||
if (fetchErr) throw fetchErr
|
||||
if (!allPosyandu || allPosyandu.length === 0) {
|
||||
return { success: false, message: 'Tidak ada data Posyandu terdaftar.' }
|
||||
}
|
||||
|
||||
// 2. Shuffle Posyandu randomly
|
||||
const shuffled = [...allPosyandu].sort(() => Math.random() - 0.5)
|
||||
|
||||
// 3. Prepare schedules
|
||||
// Calculate duration of the first session
|
||||
const [hStart, mStart] = startStr.split(':').map(Number)
|
||||
const [hEnd, mEnd] = endStr.split(':').map(Number)
|
||||
|
||||
const durationMinutes = (hEnd * 60 + mEnd) - (hStart * 60 + mStart)
|
||||
if (durationMinutes <= 0) {
|
||||
return { success: false, message: 'Jam selesai harus setelah jam mulai.' }
|
||||
}
|
||||
|
||||
const schedulePack = shuffled.map((posyandu, index) => {
|
||||
const dayOffset = Math.floor(index / 3)
|
||||
const positionInDay = index % 3
|
||||
|
||||
// Calculate date for this posyandu
|
||||
const dateObj = new Date(tanggal)
|
||||
dateObj.setDate(dateObj.getDate() + dayOffset)
|
||||
const currentTanggal = dateObj.toISOString().split('T')[0]
|
||||
|
||||
// Calculate time for this posyandu
|
||||
let startTime = new Date(`1970-01-01T${startStr}:00`)
|
||||
if (positionInDay > 0) {
|
||||
// Add (duration + gap) for each previous session in the same day
|
||||
startTime.setMinutes(startTime.getMinutes() + positionInDay * (durationMinutes + 60))
|
||||
}
|
||||
|
||||
const sessionEnd = new Date(startTime)
|
||||
sessionEnd.setMinutes(sessionEnd.getMinutes() + durationMinutes)
|
||||
|
||||
const pad = (n: number) => n.toString().padStart(2, '0')
|
||||
|
||||
const jam_mulai = `${pad(startTime.getHours())}:${pad(startTime.getMinutes())}:00`
|
||||
const jam_selesai = `${pad(sessionEnd.getHours())}:${pad(sessionEnd.getMinutes())}:00`
|
||||
|
||||
return {
|
||||
posyandu_id: posyandu.id,
|
||||
tanggal: currentTanggal,
|
||||
jam_mulai,
|
||||
jam_selesai,
|
||||
diedit_oleh: editedBy
|
||||
}
|
||||
})
|
||||
|
||||
// 4. Batch Insert
|
||||
const { error: insertErr } = await supabase
|
||||
.from('jadwal_posyandu')
|
||||
.insert(schedulePack)
|
||||
|
||||
if (insertErr) throw insertErr
|
||||
|
||||
revalidatePath('/dashboard/kelola-jadwal')
|
||||
return { success: true, message: `Berhasil menjadwalkan ${shuffled.length} Posyandu!` }
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Scheduling error:', error)
|
||||
return { success: false, message: error.message || 'Terjadi kesalahan saat membuat jadwal.' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSchedulesByDate(date: string) {
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('jadwal_posyandu')
|
||||
.delete()
|
||||
.eq('tanggal', date)
|
||||
|
||||
if (error) throw error
|
||||
revalidatePath('/dashboard/kelola-jadwal')
|
||||
return { success: true, message: 'Jadwal hari tersebut telah dihapus.' }
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteJadwal(id: string) {
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('jadwal_posyandu')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
|
||||
if (error) throw error
|
||||
revalidatePath('/dashboard/kelola-jadwal')
|
||||
return { success: true, message: 'Jadwal berhasil dihapus.' }
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateJadwal(prevState: any, formData: FormData) {
|
||||
const id = formData.get('id') as string
|
||||
const tanggal = formData.get('tanggal') as string
|
||||
const jam_mulai = formData.get('jam_mulai') as string
|
||||
const jam_selesai = formData.get('jam_selesai') as string
|
||||
const editedBy = formData.get('edited_by') as string
|
||||
|
||||
if (!id || !tanggal || !jam_mulai || !jam_selesai) {
|
||||
return { success: false, message: 'Field wajib tidak boleh kosong.' }
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('jadwal_posyandu')
|
||||
.update({
|
||||
tanggal,
|
||||
jam_mulai: jam_mulai.length === 5 ? `${jam_mulai}:00` : jam_mulai,
|
||||
jam_selesai: jam_selesai.length === 5 ? `${jam_selesai}:00` : jam_selesai,
|
||||
diedit_oleh: editedBy,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', id)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
revalidatePath('/dashboard/kelola-jadwal')
|
||||
return { success: true, message: 'Jadwal berhasil diperbarui!' }
|
||||
} catch (error: any) {
|
||||
console.error('Update error:', error)
|
||||
return { success: false, message: error.message || 'Gagal memperbarui jadwal.' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOccupiedSlots(date: string) {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('jadwal_posyandu')
|
||||
.select('jam_mulai, jam_selesai, posyandu_id')
|
||||
.eq('tanggal', date)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
return {
|
||||
success: true,
|
||||
slots: data.map(s => ({
|
||||
start: s.jam_mulai.slice(0, 5),
|
||||
end: s.jam_selesai.slice(0, 5),
|
||||
posyandu_id: s.posyandu_id
|
||||
}))
|
||||
}
|
||||
} catch (error: any) {
|
||||
return { success: false, slots: [] }
|
||||
}
|
||||
}
|
||||
|
||||
export async function archiveSchedulesByMonth(month: number, year: number) {
|
||||
try {
|
||||
const startOfMonth = `${year}-${String(month + 1).padStart(2, '0')}-01`
|
||||
const endOfMonth = new Date(year, month + 1, 0).toISOString().split('T')[0]
|
||||
|
||||
const { data: targets, error: fetchErr } = await supabase
|
||||
.from('jadwal_posyandu')
|
||||
.select('id, diedit_oleh')
|
||||
.gte('tanggal', startOfMonth)
|
||||
.lte('tanggal', endOfMonth)
|
||||
.not('diedit_oleh', 'ilike', '[HISTORY]%')
|
||||
|
||||
if (fetchErr) throw fetchErr
|
||||
if (!targets || targets.length === 0) return { success: true, message: 'Tidak ada jadwal aktif untuk diarsipkan.' }
|
||||
|
||||
// Update each target to have [HISTORY] prefix
|
||||
for (const item of targets) {
|
||||
const { error: updErr } = await supabase
|
||||
.from('jadwal_posyandu')
|
||||
.update({ diedit_oleh: `[HISTORY] ${item.diedit_oleh}` })
|
||||
.eq('id', item.id)
|
||||
if (updErr) throw updErr
|
||||
}
|
||||
|
||||
revalidatePath('/dashboard/kelola-jadwal')
|
||||
return { success: true, message: 'Seluruh jadwal bulan ini telah dipindahkan ke Histori!' }
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Gagal mengarsipkan jadwal.' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAllHistory() {
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('jadwal_posyandu')
|
||||
.delete()
|
||||
.ilike('diedit_oleh', '[HISTORY]%')
|
||||
|
||||
if (error) throw error
|
||||
|
||||
revalidatePath('/dashboard/kelola-jadwal')
|
||||
return { success: true, message: 'Seluruh riwayat jadwal berhasil dihapus permanen!' }
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Gagal menghapus histori.' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPetugasLokalByPosyandu(posyanduId: string) {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('petugas_posyandu_lokal')
|
||||
.select('nama_petugas, nomor_hp, jabatan')
|
||||
.eq('posyandu_id', posyanduId)
|
||||
|
||||
if (error) throw error
|
||||
return { success: true, petugas: data }
|
||||
} catch (error: any) {
|
||||
return { success: false, petugas: [] }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import { cookies } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { LogoutButton } from '@/components/logout-button'
|
||||
import { ArrowLeft, Calendar, Info } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { JadwalTable } from './JadwalTable'
|
||||
|
||||
export default async function KelolaJadwalPage() {
|
||||
const cookieStore = await cookies()
|
||||
const sessionCookie = cookieStore.get('user_session')
|
||||
|
||||
if (!sessionCookie) redirect('/')
|
||||
|
||||
const session = JSON.parse(sessionCookie.value)
|
||||
if (session.role !== 'admin') redirect('/dashboard')
|
||||
|
||||
const { data: jadwal, error } = await supabase
|
||||
.from('jadwal_posyandu')
|
||||
.select(`
|
||||
*,
|
||||
detail_posyandu (
|
||||
nama_posyandu,
|
||||
alamat
|
||||
)
|
||||
`)
|
||||
.order('tanggal', { ascending: true })
|
||||
.order('jam_mulai', { ascending: true })
|
||||
|
||||
if (error) {
|
||||
return <div className="p-8 text-red-500">Gagal memuat data jadwal.</div>
|
||||
}
|
||||
|
||||
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="/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 ke Dashboard</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 uppercase tracking-tighter">Kelola Jadwal Posyandu</h1>
|
||||
<p className="text-[10px] text-gray-500 tracking-widest uppercase mt-0.5">ADMINISTRASI PENJADWALAN</p>
|
||||
</div>
|
||||
</div>
|
||||
<LogoutButton />
|
||||
</header>
|
||||
|
||||
<main className="p-8 max-w-7xl mx-auto flex-1 w-full flex flex-col gap-8">
|
||||
|
||||
{/* Info Card */}
|
||||
<div className="bg-red-50 border-2 border-red-500 rounded-3xl p-6 md:p-8 relative overflow-hidden flex flex-col md:flex-row items-center gap-6 shadow-[8px_8px_0px_0px_rgba(239,68,68,0.2)]">
|
||||
<div className="w-16 h-16 bg-red-500 rounded-2xl flex items-center justify-center text-white shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] flex-shrink-0">
|
||||
<Calendar className="w-8 h-8" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<h2 className="text-xl font-black text-gray-900 uppercase tracking-tight">Optimasi Penjadwalan Bulanan</h2>
|
||||
<p className="text-sm font-semibold text-gray-600 leading-relaxed max-w-2xl">
|
||||
Kelola jadwal operasional seluruh Posyandu di wilayah Anda secara efisien. Gunakan fitur <span className="text-red-600 font-black">Penjadwalan Instan</span> untuk mengotomatisasi pembagian sesi dengan mempertimbangkan waktu istirahat (jeda 1 jam).
|
||||
</p>
|
||||
</div>
|
||||
<div className="hidden lg:block absolute -right-4 -bottom-4 opacity-5 pointer-events-none">
|
||||
<Calendar className="w-48 h-48 text-black" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table Section */}
|
||||
<JadwalTable data={jadwal || []} userName={session.name} />
|
||||
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -19,46 +19,18 @@ interface Props {
|
|||
pengguna: AkunBalita
|
||||
}
|
||||
|
||||
interface ToastProps {
|
||||
message: string
|
||||
type: 'success' | 'error'
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function Toast({ message, type, onClose }: ToastProps) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(onClose, 4000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed top-6 right-6 z-50 flex items-center gap-3 px-5 py-4 rounded-xl border-2 shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]
|
||||
${type === 'success'
|
||||
? 'bg-green-50 border-green-500 text-green-800'
|
||||
: 'bg-red-50 border-red-500 text-red-800'
|
||||
}`}
|
||||
style={{ animation: 'slideIn 0.35s cubic-bezier(0.34, 1.56, 0.64, 1)' }}
|
||||
>
|
||||
{type === 'success'
|
||||
? <CheckCircle className="w-5 h-5 text-green-600 flex-shrink-0" />
|
||||
: <XCircle className="w-5 h-5 text-red-600 flex-shrink-0" />
|
||||
}
|
||||
<span className="font-semibold text-sm">{message}</span>
|
||||
<button onClick={onClose} className="ml-2 p-1 rounded-full hover:bg-black/10 transition-colors">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { showSwal } from '@/lib/swal'
|
||||
|
||||
export function EditPenggunaForm({ pengguna }: Props) {
|
||||
const [state, formAction, isPending] = useActionState(updateAkunBalita, null)
|
||||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (state) {
|
||||
setToast({ message: state.message, type: state.success ? 'success' : 'error' })
|
||||
if (state.success) {
|
||||
showSwal.success('Berhasil!', state.message)
|
||||
} else {
|
||||
showSwal.error('Gagal!', state.message)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
|
||||
|
|
@ -67,14 +39,6 @@ export function EditPenggunaForm({ pengguna }: Props) {
|
|||
|
||||
return (
|
||||
<>
|
||||
{toast && <Toast message={toast.message} type={toast.type} onClose={() => setToast(null)} />}
|
||||
<style>{`
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(110%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<form action={formAction} className="flex flex-col gap-5">
|
||||
<input type="hidden" name="id" value={pengguna.id} />
|
||||
|
||||
|
|
|
|||
|
|
@ -16,73 +16,23 @@ interface Props {
|
|||
petugas: PetugasData
|
||||
}
|
||||
|
||||
interface ToastProps {
|
||||
message: string
|
||||
type: 'success' | 'error'
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function Toast({ message, type, onClose }: ToastProps) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(onClose, 4000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed top-6 right-6 z-50 flex items-center gap-3 px-5 py-4 rounded-xl border-2 shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] animate-slide-in
|
||||
${type === 'success'
|
||||
? 'bg-green-50 border-green-500 text-green-800'
|
||||
: 'bg-red-50 border-red-500 text-red-800'
|
||||
}`}
|
||||
style={{ animation: 'slideIn 0.3s ease-out' }}
|
||||
>
|
||||
{type === 'success'
|
||||
? <CheckCircle className="w-5 h-5 text-green-600 flex-shrink-0" />
|
||||
: <XCircle className="w-5 h-5 text-red-600 flex-shrink-0" />
|
||||
}
|
||||
<span className="font-semibold text-sm">{message}</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="ml-2 p-1 rounded-full hover:bg-black/10 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { showSwal } from '@/lib/swal'
|
||||
|
||||
export function EditPetugasForm({ petugas }: Props) {
|
||||
const [state, formAction, isPending] = useActionState(updatePetugas, null)
|
||||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (state) {
|
||||
setToast({
|
||||
message: state.message,
|
||||
type: state.success ? 'success' : 'error',
|
||||
})
|
||||
if (state.success) {
|
||||
showSwal.success('Berhasil!', state.message)
|
||||
} else {
|
||||
showSwal.error('Gagal!', state.message)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Toast Notification */}
|
||||
{toast && (
|
||||
<Toast
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={() => setToast(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(110%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<form action={formAction} className="flex flex-col gap-6">
|
||||
<input type="hidden" name="id" value={petugas.id} />
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Search, Plus, MapPin, Phone, User, Edit3, Trash2, Eye, ExternalLink, Bu
|
|||
import { PosyanduFormModal } from './PosyanduFormModal'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { showSwal } from '@/lib/swal'
|
||||
|
||||
interface PosyanduWithPetugas {
|
||||
id: string
|
||||
|
|
@ -36,7 +37,7 @@ export function ManajemenPosyanduTable({ data }: Props) {
|
|||
return data.filter(p =>
|
||||
p.nama_posyandu.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
p.alamat.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(p.petugas?.[0]?.nama_petugas?.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
p.petugas?.some(petugas => petugas.nama_petugas?.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
)
|
||||
}, [data, searchTerm])
|
||||
|
||||
|
|
@ -46,7 +47,12 @@ export function ManajemenPosyanduTable({ data }: Props) {
|
|||
}
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (!confirm(`Apakah Anda yakin ingin menghapus data Posyandu ${name}?`)) return
|
||||
const result = await showSwal.confirm(
|
||||
'Hapus Data?',
|
||||
`Apakah Anda yakin ingin menghapus data Posyandu ${name}? Seluruh data petugas terkait juga akan dihapus.`
|
||||
)
|
||||
|
||||
if (!result.isConfirmed) return
|
||||
|
||||
setIsDeleting(id)
|
||||
try {
|
||||
|
|
@ -56,11 +62,13 @@ export function ManajemenPosyanduTable({ data }: Props) {
|
|||
const { error } = await supabase.from('detail_posyandu').delete().eq('id', id)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
await showSwal.success('Terhapus!', `Data Posyandu ${name} telah berhasil dihapus.`)
|
||||
router.refresh()
|
||||
} catch (err: any) {
|
||||
alert('Gagal menghapus data: ' + err.message)
|
||||
showSwal.error('Gagal!', `Gagal menghapus data: ${err.message}`)
|
||||
} finally {
|
||||
setIsDeleting(null)
|
||||
setIsDeleting('')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -95,7 +103,7 @@ export function ManajemenPosyanduTable({ data }: Props) {
|
|||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-widest text-center w-16">No</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-widest">Informasi Posyandu</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-widest">Petugas & Kontak</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-widest">Lokasi</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-widest text-center">Lokasi</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-widest text-center w-40">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -125,19 +133,34 @@ export function ManajemenPosyanduTable({ data }: Props) {
|
|||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-5">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex flex-col gap-2 max-w-[250px]">
|
||||
{p.petugas && p.petugas.length > 0 ? (
|
||||
p.petugas.map((petugas, i) => (
|
||||
<div key={i} className="flex flex-col border-l-2 border-purple-100 pl-3 py-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1 rounded-md bg-purple-100 text-purple-600">
|
||||
<User className="w-3 h-3" />
|
||||
</div>
|
||||
<span className="text-sm font-bold text-gray-700">
|
||||
{p.petugas?.[0]?.nama_petugas || '-'}
|
||||
<span className="text-sm font-bold text-gray-700 line-clamp-1">
|
||||
{petugas.nama_petugas}
|
||||
</span>
|
||||
{petugas.jabatan && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-purple-50 text-purple-600 rounded font-black uppercase tracking-tighter">
|
||||
{petugas.jabatan}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<Phone className="w-3 h-3" />
|
||||
<span>{p.kontak || '-'}</span>
|
||||
{petugas.nomor_hp && (
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-gray-400">
|
||||
<Phone className="w-2.5 h-2.5" />
|
||||
<span>{petugas.nomor_hp}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-gray-400 italic text-xs">
|
||||
<User className="w-3 h-3" />
|
||||
<span>Belum ada petugas</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-5 text-center">
|
||||
|
|
|
|||
|
|
@ -1,9 +1,17 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X, Save, Building2, MapPin, Phone, User, Map as MapIcon, Loader2, Info } from 'lucide-react'
|
||||
import { X, Save, Building2, MapPin, Phone, User, Map as MapIcon, Loader2, Info, Plus, Trash2, Briefcase } from 'lucide-react'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { showSwal } from '@/lib/swal'
|
||||
|
||||
interface PetugasInput {
|
||||
id?: string
|
||||
nama_petugas: string
|
||||
nomor_hp: string
|
||||
jabatan: string
|
||||
}
|
||||
|
||||
interface PosyanduFormModalProps {
|
||||
isOpen: boolean
|
||||
|
|
@ -17,12 +25,12 @@ export function PosyanduFormModal({ isOpen, onClose, selectedData }: PosyanduFor
|
|||
const [formData, setFormData] = useState({
|
||||
nama_posyandu: '',
|
||||
alamat: '',
|
||||
kontak: '',
|
||||
nama_petugas: '', // From petugas_posyandu_lokal
|
||||
link_google_maps: '',
|
||||
latitude: '',
|
||||
longitude: ''
|
||||
kontak: '', // This stays as the Posyandu general contact if needed, but we'll prioritize petugas contacts
|
||||
link_google_maps: ''
|
||||
})
|
||||
const [petugas, setPetugas] = useState<PetugasInput[]>([
|
||||
{ nama_petugas: '', nomor_hp: '', jabatan: 'Koordinator' }
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedData) {
|
||||
|
|
@ -30,24 +38,48 @@ export function PosyanduFormModal({ isOpen, onClose, selectedData }: PosyanduFor
|
|||
nama_posyandu: selectedData.nama_posyandu || '',
|
||||
alamat: selectedData.alamat || '',
|
||||
kontak: selectedData.kontak || '',
|
||||
nama_petugas: selectedData.petugas?.[0]?.nama_petugas || '',
|
||||
link_google_maps: selectedData.link_google_maps || '',
|
||||
latitude: selectedData.latitude?.toString() || '',
|
||||
longitude: selectedData.longitude?.toString() || ''
|
||||
link_google_maps: selectedData.link_google_maps || ''
|
||||
})
|
||||
|
||||
if (selectedData.petugas && selectedData.petugas.length > 0) {
|
||||
setPetugas(selectedData.petugas.map((p: any) => ({
|
||||
id: p.id,
|
||||
nama_petugas: p.nama_petugas || '',
|
||||
nomor_hp: p.nomor_hp || '',
|
||||
jabatan: p.jabatan || ''
|
||||
})))
|
||||
} else {
|
||||
setPetugas([{ nama_petugas: '', nomor_hp: '', jabatan: 'Koordinator' }])
|
||||
}
|
||||
} else {
|
||||
setFormData({
|
||||
nama_posyandu: '',
|
||||
alamat: '',
|
||||
kontak: '',
|
||||
nama_petugas: '',
|
||||
link_google_maps: '',
|
||||
latitude: '',
|
||||
longitude: ''
|
||||
link_google_maps: ''
|
||||
})
|
||||
setPetugas([{ nama_petugas: '', nomor_hp: '', jabatan: 'Koordinator' }])
|
||||
}
|
||||
}, [selectedData])
|
||||
|
||||
const handleAddPetugas = () => {
|
||||
setPetugas([...petugas, { nama_petugas: '', nomor_hp: '', jabatan: '' }])
|
||||
}
|
||||
|
||||
const handleRemovePetugas = (index: number) => {
|
||||
if (petugas.length === 1) {
|
||||
setPetugas([{ nama_petugas: '', nomor_hp: '', jabatan: 'Koordinator' }])
|
||||
} else {
|
||||
setPetugas(petugas.filter((_, i) => i !== index))
|
||||
}
|
||||
}
|
||||
|
||||
const handlePetugasChange = (index: number, field: keyof PetugasInput, value: string) => {
|
||||
const updatedPetugas = [...petugas]
|
||||
updatedPetugas[index] = { ...updatedPetugas[index], [field]: value }
|
||||
setPetugas(updatedPetugas)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
|
|
@ -57,46 +89,64 @@ export function PosyanduFormModal({ isOpen, onClose, selectedData }: PosyanduFor
|
|||
nama_posyandu: formData.nama_posyandu,
|
||||
alamat: formData.alamat,
|
||||
kontak: formData.kontak,
|
||||
link_google_maps: formData.link_google_maps,
|
||||
latitude: formData.latitude ? parseFloat(formData.latitude) : null,
|
||||
longitude: formData.longitude ? parseFloat(formData.longitude) : null,
|
||||
link_google_maps: formData.link_google_maps
|
||||
}
|
||||
|
||||
let posyanduId = selectedData?.id
|
||||
|
||||
if (selectedData) {
|
||||
// Update
|
||||
// Update Posyandu
|
||||
const { error } = await supabase.from('detail_posyandu').update(payload).eq('id', selectedData.id)
|
||||
if (error) throw error
|
||||
} else {
|
||||
// Insert
|
||||
// Insert Posyandu
|
||||
const { data, error } = await supabase.from('detail_posyandu').insert(payload).select().single()
|
||||
if (error) throw error
|
||||
posyanduId = data.id
|
||||
}
|
||||
// ... [Officer sync logic remains same] ...
|
||||
// Sync Petugas
|
||||
// 1. Get current IDs in DB for this Posyandu
|
||||
const { data: existingInDB } = await supabase
|
||||
.from('petugas_posyandu_lokal')
|
||||
.select('id')
|
||||
.eq('posyandu_id', posyanduId)
|
||||
|
||||
const dbIds = existingInDB?.map(p => p.id) || []
|
||||
const currentFormIds = petugas.map(p => p.id).filter(Boolean) as string[]
|
||||
|
||||
// 2. Identify deleted ones
|
||||
const idsToDelete = dbIds.filter(id => !currentFormIds.includes(id))
|
||||
if (idsToDelete.length > 0) {
|
||||
await supabase.from('petugas_posyandu_lokal').delete().in('id', idsToDelete)
|
||||
}
|
||||
|
||||
// 3. Update or Insert
|
||||
for (const p of petugas) {
|
||||
// Skip if entirely empty
|
||||
if (!p.nama_petugas && !p.nomor_hp) continue
|
||||
|
||||
// Sync Petugas (Simple logic: one main officer for this Posyandu)
|
||||
if (formData.nama_petugas) {
|
||||
const petugasPayload = {
|
||||
posyandu_id: posyanduId,
|
||||
nama_petugas: formData.nama_petugas,
|
||||
jabatan: 'Koordinator'
|
||||
nama_petugas: p.nama_petugas,
|
||||
nomor_hp: p.nomor_hp,
|
||||
jabatan: p.jabatan
|
||||
}
|
||||
|
||||
if (selectedData?.petugas?.[0]?.id) {
|
||||
await supabase.from('petugas_posyandu_lokal')
|
||||
.update(petugasPayload)
|
||||
.eq('id', selectedData.petugas[0].id)
|
||||
if (p.id) {
|
||||
// Update
|
||||
await supabase.from('petugas_posyandu_lokal').update(petugasPayload).eq('id', p.id)
|
||||
} else {
|
||||
await supabase.from('petugas_posyandu_lokal')
|
||||
.insert(petugasPayload)
|
||||
// Insert
|
||||
await supabase.from('petugas_posyandu_lokal').insert(petugasPayload)
|
||||
}
|
||||
}
|
||||
|
||||
await showSwal.success('Berhasil!', 'Data Posyandu telah disimpan.')
|
||||
router.refresh()
|
||||
onClose()
|
||||
} catch (err: any) {
|
||||
alert('Gagal menyimpan data: ' + err.message)
|
||||
showSwal.error('Gagal!', err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
|
@ -106,7 +156,7 @@ export function PosyanduFormModal({ isOpen, onClose, selectedData }: PosyanduFor
|
|||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
||||
<div className="bg-white w-full max-w-xl rounded-2xl border-2 border-black shadow-[12px_12px_0px_0px_rgba(0,0,0,1)] flex flex-col max-h-[90vh]">
|
||||
<div className="bg-white w-full max-w-2xl rounded-2xl border-2 border-black shadow-[12px_12px_0px_0px_rgba(0,0,0,1)] flex flex-col max-h-[90vh]">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b-2 border-black bg-purple-600 text-white">
|
||||
|
|
@ -129,7 +179,9 @@ export function PosyanduFormModal({ isOpen, onClose, selectedData }: PosyanduFor
|
|||
{/* Form Body */}
|
||||
<form onSubmit={handleSubmit} className="p-6 overflow-y-auto flex flex-col gap-6">
|
||||
|
||||
{/* Nama Posyandu */}
|
||||
{/* Informasi Dasar */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="text-sm font-black uppercase tracking-widest text-purple-600 border-b-2 border-purple-100 pb-2">Informasi Dasar</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-black uppercase tracking-widest text-gray-500 flex items-center gap-2">
|
||||
<Building2 className="w-3.5 h-3.5" />
|
||||
|
|
@ -145,38 +197,6 @@ export function PosyanduFormModal({ isOpen, onClose, selectedData }: PosyanduFor
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Petugas & Kontak Section */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-black uppercase tracking-widest text-gray-500 flex items-center gap-2">
|
||||
<User className="w-3.5 h-3.5" />
|
||||
Nama Petugas
|
||||
</label>
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
placeholder="Nama Koordinator"
|
||||
value={formData.nama_petugas}
|
||||
onChange={e => setFormData({ ...formData, nama_petugas: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-gray-50 border-2 border-gray-100 focus:border-black rounded-xl text-sm font-bold outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-black uppercase tracking-widest text-gray-500 flex items-center gap-2">
|
||||
<Phone className="w-3.5 h-3.5" />
|
||||
Kontak / No HP
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="0812xxxx"
|
||||
value={formData.kontak}
|
||||
onChange={e => setFormData({ ...formData, kontak: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-gray-50 border-2 border-gray-100 focus:border-black rounded-xl text-sm font-bold outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alamat */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-black uppercase tracking-widest text-gray-500 flex items-center gap-2">
|
||||
<MapPin className="w-3.5 h-3.5" />
|
||||
|
|
@ -184,13 +204,84 @@ export function PosyanduFormModal({ isOpen, onClose, selectedData }: PosyanduFor
|
|||
</label>
|
||||
<textarea
|
||||
required
|
||||
rows={3}
|
||||
rows={2}
|
||||
placeholder="Jl. Raya No. 123..."
|
||||
value={formData.alamat}
|
||||
onChange={e => setFormData({ ...formData, alamat: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-gray-50 border-2 border-gray-100 focus:border-black rounded-xl text-sm font-bold outline-none transition-all resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section Petugas */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between border-b-2 border-purple-100 pb-2">
|
||||
<h3 className="text-sm font-black uppercase tracking-widest text-purple-600">Petugas Posyandu</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddPetugas}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-purple-50 text-purple-600 rounded-lg text-[10px] font-black hover:bg-purple-100 transition-all border border-purple-200"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
TAMBAH PETUGAS
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{petugas.map((p, index) => (
|
||||
<div key={index} className="p-4 bg-gray-50/50 rounded-2xl border-2 border-gray-100 relative group animate-in fade-in slide-in-from-top-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-black uppercase text-gray-400 tracking-wider flex items-center gap-1.5">
|
||||
<User className="w-3 h-3" /> Nama Petugas
|
||||
</label>
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
placeholder="Nama"
|
||||
value={p.nama_petugas}
|
||||
onChange={e => handlePetugasChange(index, 'nama_petugas', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-white border-2 border-gray-100 focus:border-purple-500 rounded-lg text-xs font-bold outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-black uppercase text-gray-400 tracking-wider flex items-center gap-1.5">
|
||||
<Phone className="w-3 h-3" /> No. HP
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="0812xxxx"
|
||||
value={p.nomor_hp}
|
||||
onChange={e => handlePetugasChange(index, 'nomor_hp', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-white border-2 border-gray-100 focus:border-purple-500 rounded-lg text-xs font-bold outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-black uppercase text-gray-400 tracking-wider flex items-center gap-1.5">
|
||||
<Briefcase className="w-3 h-3" /> Jabatan
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Contoh: Koordinator"
|
||||
value={p.jabatan}
|
||||
onChange={e => handlePetugasChange(index, 'jabatan', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-white border-2 border-gray-100 focus:border-purple-500 rounded-lg text-xs font-bold outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{petugas.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemovePetugas(index)}
|
||||
className="absolute -right-2 -top-2 p-1.5 bg-white border-2 border-red-100 text-red-500 rounded-full hover:bg-red-50 transition-all shadow-sm md:opacity-0 md:group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Google Maps Section */}
|
||||
<div className="p-4 bg-blue-50/50 rounded-2xl border-2 border-blue-100 flex flex-col gap-4">
|
||||
|
|
@ -200,42 +291,19 @@ export function PosyanduFormModal({ isOpen, onClose, selectedData }: PosyanduFor
|
|||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-[10px] font-black uppercase text-blue-400 tracking-wider">Link Google Maps (Share Link)</label>
|
||||
<label className="text-[10px] font-black uppercase text-blue-400 tracking-wider">Link Google Maps (Pasti kan link ini benar)</label>
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://maps.app.goo.gl/..."
|
||||
value={formData.link_google_maps}
|
||||
onChange={e => setFormData({ ...formData, link_google_maps: e.target.value })}
|
||||
className="w-full px-4 py-2.5 bg-white border-2 border-blue-100 focus:border-blue-500 rounded-lg text-xs font-semibold outline-none transition-all"
|
||||
className="w-full px-4 py-3 bg-white border-2 border-blue-100 focus:border-blue-500 rounded-xl text-xs font-bold outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-black uppercase text-blue-400 tracking-wider">Latitude</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="-6.12345"
|
||||
value={formData.latitude}
|
||||
onChange={e => setFormData({ ...formData, latitude: e.target.value })}
|
||||
className="w-full px-4 py-2 bg-white border-2 border-blue-100 focus:border-blue-500 rounded-lg text-xs font-semibold outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-black uppercase text-blue-400 tracking-wider">Longitude</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="106.12345"
|
||||
value={formData.longitude}
|
||||
onChange={e => setFormData({ ...formData, longitude: e.target.value })}
|
||||
className="w-full px-4 py-2 bg-white border-2 border-blue-100 focus:border-blue-500 rounded-lg text-xs font-semibold outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 text-[10px] text-blue-500 leading-relaxed italic">
|
||||
<Info className="w-3 h-3 mt-0.5 flex-shrink-0" />
|
||||
<span>Input koordinat (Latitude & Longitude) agar titik lokasi akurat di sistem peta.</span>
|
||||
<span>Cantumkan link Google Maps agar admin lain dapat melihat lokasi dengan mudah di web.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -38,9 +38,7 @@ export default async function ReviewPosyanduPage({ params }: Props) {
|
|||
)
|
||||
}
|
||||
|
||||
const mapSrc = posyandu.latitude && posyandu.longitude
|
||||
? `https://maps.google.com/maps?q=${posyandu.latitude},${posyandu.longitude}&z=15&output=embed`
|
||||
: null
|
||||
const mapSrc = `https://maps.google.com/maps?q=${encodeURIComponent(posyandu.alamat)}&z=15&output=embed`
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white font-sans text-black flex flex-col">
|
||||
|
|
@ -75,7 +73,7 @@ export default async function ReviewPosyanduPage({ params }: Props) {
|
|||
Informasi Utama
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-8 grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div className="p-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-[10px] font-black uppercase text-gray-400 tracking-widest flex items-center gap-2">
|
||||
<MapPin className="w-3 h-3" />
|
||||
|
|
@ -83,13 +81,6 @@ export default async function ReviewPosyanduPage({ params }: Props) {
|
|||
</span>
|
||||
<p className="text-lg font-bold leading-relaxed">{posyandu.alamat}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-[10px] font-black uppercase text-gray-400 tracking-widest flex items-center gap-2">
|
||||
<Phone className="w-3 h-3" />
|
||||
Nomor Kontak
|
||||
</span>
|
||||
<p className="text-lg font-bold">{posyandu.kontak || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -98,7 +89,7 @@ export default async function ReviewPosyanduPage({ params }: Props) {
|
|||
<div className="p-6 border-b border-gray-100 flex items-center justify-between">
|
||||
<h3 className="text-base font-black flex items-center gap-3">
|
||||
<User className="w-5 h-5 text-purple-600" />
|
||||
Daftar Petugas Bertugas
|
||||
Daftarkan Petugas Bertugas
|
||||
</h3>
|
||||
<span className="text-xs font-bold text-purple-600 bg-purple-50 px-3 py-1 rounded-full border border-purple-100 uppercase tracking-widest">
|
||||
{posyandu.petugas?.length || 0} Orang
|
||||
|
|
@ -135,10 +126,10 @@ export default async function ReviewPosyanduPage({ params }: Props) {
|
|||
</div>
|
||||
|
||||
{/* Right Column - Map & Quick Sync */}
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-8 sticky top-8 self-start">
|
||||
|
||||
{/* Map Card */}
|
||||
<div className="bg-white rounded-3xl border-2 border-black shadow-[8px_8px_0px_0px_rgba(59,130,246,0.3)] overflow-hidden sticky top-8">
|
||||
<div className="bg-white rounded-3xl border-2 border-black shadow-[8px_8px_0px_0px_rgba(59,130,246,0.3)] overflow-hidden">
|
||||
<div className="p-6 border-b border-gray-100 flex items-center justify-between bg-blue-50/30">
|
||||
<h3 className="text-base font-black flex items-center gap-3">
|
||||
<MapIcon className="w-5 h-5 text-blue-600" />
|
||||
|
|
@ -156,7 +147,7 @@ export default async function ReviewPosyanduPage({ params }: Props) {
|
|||
)}
|
||||
</div>
|
||||
<div className="aspect-square w-full bg-gray-100 relative">
|
||||
{mapSrc ? (
|
||||
{posyandu.alamat ? (
|
||||
<iframe
|
||||
width="100%"
|
||||
height="100%"
|
||||
|
|
@ -169,22 +160,15 @@ export default async function ReviewPosyanduPage({ params }: Props) {
|
|||
<div className="absolute inset-0 flex flex-col items-center justify-center p-8 text-center gap-4 text-gray-400">
|
||||
<MapIcon className="w-12 h-12 opacity-20" />
|
||||
<p className="text-xs font-bold font-semibold px-4">
|
||||
Koordinat GPS belum dikonfigurasi. Edit data untuk menambahkan Latitude & Longitude.
|
||||
Alamat belum diatur. Edit data untuk menambahkan alamat.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-6 bg-gray-50 border-t border-gray-100">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Latitude</span>
|
||||
<p className="text-xs font-mono font-bold mt-1">{posyandu.latitude || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Longitude</span>
|
||||
<p className="text-xs font-mono font-bold mt-1">{posyandu.longitude || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-500 leading-relaxed italic">
|
||||
Peta di atas ditampilkan secara otomatis berdasarkan alamat Posyandu yang terdaftar.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -152,7 +152,7 @@ export default async function DashboardPage() {
|
|||
title="Kelola Jadwal Posyandu"
|
||||
description="Buat dan atur jadwal kegiatan posyandu bulanan."
|
||||
icon={Calendar}
|
||||
href="#"
|
||||
href="/dashboard/kelola-jadwal"
|
||||
color="red"
|
||||
className="md:col-span-2"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -3,12 +3,24 @@
|
|||
import { logout } from '@/app/actions'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { LogOut } from 'lucide-react'
|
||||
import { showSwal } from '@/lib/swal'
|
||||
|
||||
export function LogoutButton() {
|
||||
const handleLogout = async () => {
|
||||
const result = await showSwal.confirm(
|
||||
'Keluar?',
|
||||
'Apakah Anda yakin ingin mengakhiri sesi ini?'
|
||||
)
|
||||
|
||||
if (result.isConfirmed) {
|
||||
logout()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => logout()}
|
||||
onClick={handleLogout}
|
||||
className="gap-2 border-black bg-white text-black hover:bg-gray-100 hover:text-black transition-colors"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
import Swal from 'sweetalert2'
|
||||
|
||||
export const showSwal = {
|
||||
success: (title: string, text?: string) => {
|
||||
return Swal.fire({
|
||||
title,
|
||||
text,
|
||||
icon: 'success',
|
||||
confirmButtonColor: '#000000',
|
||||
background: '#ffffff',
|
||||
customClass: {
|
||||
popup: 'border-2 border-black shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] rounded-2xl',
|
||||
confirmButton: 'px-8 py-3 bg-black text-white font-bold rounded-xl hover:bg-gray-800 transition-all'
|
||||
}
|
||||
})
|
||||
},
|
||||
error: (title: string, text?: string) => {
|
||||
return Swal.fire({
|
||||
title,
|
||||
text,
|
||||
icon: 'error',
|
||||
confirmButtonColor: '#000000',
|
||||
customClass: {
|
||||
popup: 'border-2 border-black shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] rounded-2xl',
|
||||
confirmButton: 'px-8 py-3 bg-black text-white font-bold rounded-xl'
|
||||
}
|
||||
})
|
||||
},
|
||||
confirm: (title: string, text?: string) => {
|
||||
return Swal.fire({
|
||||
title,
|
||||
text,
|
||||
icon: 'question',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Ya, Lanjutkan',
|
||||
cancelButtonText: 'Batal',
|
||||
confirmButtonColor: '#000000',
|
||||
cancelButtonColor: '#ffffff',
|
||||
customClass: {
|
||||
popup: 'border-2 border-black shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] rounded-2xl',
|
||||
confirmButton: 'px-8 py-3 bg-black text-white font-bold rounded-xl mr-2',
|
||||
cancelButton: 'px-8 py-3 border-2 border-black text-black font-bold rounded-xl hover:bg-gray-50'
|
||||
},
|
||||
buttonsStyling: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@
|
|||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"recharts": "^3.7.0",
|
||||
"sweetalert2": "^11.26.20",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -6996,6 +6997,16 @@
|
|||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sweetalert2": {
|
||||
"version": "11.26.20",
|
||||
"resolved": "https://registry.npmjs.org/sweetalert2/-/sweetalert2-11.26.20.tgz",
|
||||
"integrity": "sha512-mG3guPRzmpqGufTQ05Kr7WQIe1xfBEB9JwPIJbR2plL0ezVzS3qmazuzse0J8+/YYGCGrAL4Us2Sm5VCgHyuRg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/limonte"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwind-merge": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"recharts": "^3.7.0",
|
||||
"sweetalert2": "^11.26.20",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
Loading…
Reference in New Issue