diff --git a/app/dashboard/kelola-data/CetakInstanModal.tsx b/app/dashboard/kelola-data/CetakInstanModal.tsx index 4135d6b..49c7a3a 100644 --- a/app/dashboard/kelola-data/CetakInstanModal.tsx +++ b/app/dashboard/kelola-data/CetakInstanModal.tsx @@ -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) } diff --git a/app/dashboard/kelola-data/[id]/CetakPDFButton.tsx b/app/dashboard/kelola-data/[id]/CetakPDFButton.tsx index db7f343..8e25688 100644 --- a/app/dashboard/kelola-data/[id]/CetakPDFButton.tsx +++ b/app/dashboard/kelola-data/[id]/CetakPDFButton.tsx @@ -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) } diff --git a/app/dashboard/kelola-jadwal/CetakBatchJadwalModal.tsx b/app/dashboard/kelola-jadwal/CetakBatchJadwalModal.tsx new file mode 100644 index 0000000..c4818f1 --- /dev/null +++ b/app/dashboard/kelola-jadwal/CetakBatchJadwalModal.tsx @@ -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(null) + const [step, setStep] = useState('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(null) + const [activePetugas, setActivePetugas] = useState([]) + + const templateRef = useRef(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 ( + <> + + + {open && ( +
+
e.stopPropagation()}> + + {/* Header */} +
+
+ +
+

Cetak Batch Jadwal

+

Generate PDF Kolektif Per Periode

+
+
+ {step !== 'generating' && ( + + )} +
+ + {step === 'config' && ( +
+
+
+ + + +
+
+ + + +
+
+ +
+

Lokasi Penyimpanan

+ {supportsFileSys ? ( + + ) : ( +
+ ⚠ Browser Anda tidak mendukung akses folder. File akan diunduh satu per satu secara otomatis. +
+ )} +
+ + +
+ )} + + {step === 'generating' && ( +
+
+
+ +
+
+

Sedang Memproses PDF...

+

Jangan tutup halaman ini

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

Memproses:

+

{progress.name}

+
+ )} + +
+
+

Berjalan

+

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

+
+
+

Estimasi

+

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

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

Gagal Memproses

+

{errorMsg}

+
+ +
+ )} +
+
+ )} + + {/* ─── Hidden PDF Template (Same as CetakPDFJadwal) ─── */} + {activeJadwal && ( +
+
+ +
+
+
+
Pemerintah Kabupaten / Kota
+
DINAS KESEHATAN & POSYANDU
+
+
+
Nomor Dokumen
+
JDWL-{activeJadwal.id.slice(0, 8).toUpperCase()}
+
+
+
+ +
+

SURAT PEMBERITAHUAN JADWAL PELAKSANAAN

+
+
+ +
+
+
DETAIL POSYANDU
+
+
Nama Posyandu
+
{activeJadwal.detail_posyandu.nama_posyandu}
+
+
+
Alamat Lokasi
+
{activeJadwal.detail_posyandu.alamat}
+
+
+
+
WAKTU PELAKSANAAN
+
+
Hari & Tanggal
+
{new Date(activeJadwal.tanggal).toLocaleDateString('id-ID', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
+
+
+
Sesi Operasional
+
{activeJadwal.jam_mulai.slice(0, 5)} - {activeJadwal.jam_selesai.slice(0, 5)} WIB
+
+
+
+ +
+
DAFTAR PETUGAS LOKAL BERTUGAS
+ + + + + + + + + + {activePetugas.length > 0 ? activePetugas.map((p, i) => ( + + + + + + )) : ( + + )} + +
Nama PetugasNomor TeleponJabatan
{p.nama_petugas}{p.nomor_hp}{p.jabatan}
Belum ada petugas lokal yang terdaftar.
+
+ +
+
⚠️ CATATAN SISTEM
+

+ "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." +

+
+ +
+
+
Metode Penjadwalan:
+ Sistem Penjadwalan Otomatis (Algoritma Random Batch)
+ Ditentukan oleh Admin: {activeJadwal.diedit_oleh.replace('[HISTORY] ', '')} +
+
+
Detail Pengunduhan:
+ Dicetak oleh Admin: {adminName}
+ Waktu Cetak: {tglCetak}, {jamCetak} WIB +
+
+ +
DOKUMEN INI DIHASILKAN SECARA OTOMATIS OLEH SISTEM CLOUD STUNTING
+
+
+ )} + + ) +} diff --git a/app/dashboard/kelola-jadwal/CetakPDFJadwal.tsx b/app/dashboard/kelola-jadwal/CetakPDFJadwal.tsx new file mode 100644 index 0000000..b1206ca --- /dev/null +++ b/app/dashboard/kelola-jadwal/CetakPDFJadwal.tsx @@ -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(null) + const [petugas, setPetugas] = useState([]) + + 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 ─── */} +
+
+ {/* Header: Kop Surat Style */} +
+
+
+
+ Pemerintah Kabupaten / Kota +
+
+ DINAS KESEHATAN & POSYANDU +
+
+
+
Nomor Dokumen
+
JDWL-{jadwal.id.slice(0, 8).toUpperCase()}
+
+
+
+ +
+

+ SURAT PEMBERITAHUAN JADWAL PELAKSANAAN +

+
+
+ + {/* Informasi Utama */} +
+
+
+ A. DETAIL POSYANDU +
+
+
Nama Posyandu
+
{jadwal.detail_posyandu.nama_posyandu}
+
+
+
Alamat Lokasi
+
{jadwal.detail_posyandu.alamat}
+
+
+
+
+ B. WAKTU PELAKSANAAN +
+
+
Hari & Tanggal
+
{tglJadwal}
+
+
+
Sesi Operasional
+
{jadwal.jam_mulai.slice(0, 5)} - {jadwal.jam_selesai.slice(0, 5)} WIB
+
+
+
+ + {/* Tabel Petugas */} +
+
+ C. DAFTAR PETUGAS LOKAL BERTUGAS +
+ + + + + + + + + + {petugas.length > 0 ? petugas.map((p, i) => ( + + + + + + )) : ( + + + + )} + +
Nama PetugasNomor TeleponJabatan
{p.nama_petugas}{p.nomor_hp}{p.jabatan}
+ Belum ada petugas lokal yang terdaftar untuk Posyandu ini. +
+
+ + {/* Catatan Penting */} +
+
+ ⚠️ CATATAN SISTEM +
+

+ "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." +

+
+ + {/* Footer / Metadata */} +
+
+
Metode Penjadwalan:
+ Sistem Penjadwalan Otomatis (Algoritma Random Batch)
+ Ditentukan oleh Admin: {jadwal.diedit_oleh.replace('[HISTORY] ', '')} +
+
+
Detail Pengunduhan:
+ Dicetak oleh Admin: {currentAdmin}
+ Waktu Cetak: {tglCetak}, {jamCetak} WIB +
+
+ +
+ DOKUMEN INI DIHASILKAN SECARA OTOMATIS OLEH SISTEM CLOUD STUNTING +
+
+
+ + {/* ─── Visible Button ─── */} + + + ) +} diff --git a/app/dashboard/kelola-jadwal/InstantScheduleModal.tsx b/app/dashboard/kelola-jadwal/InstantScheduleModal.tsx new file mode 100644 index 0000000..23aa1b7 --- /dev/null +++ b/app/dashboard/kelola-jadwal/InstantScheduleModal.tsx @@ -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(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 ( +
+
+ +
+
+
+ +
+

Penjadwalan Instan

+
+ +
+ +
+ + + + +
+ +
+

+ Penjadwalan harus dilakukan minimal 1 hari sebelumnya. +

+

+ Sesi tetap: 08:00 - 11:00 WIB (Selingan 1 Jam). +

+
+
+ + {/* Tanggal */} +
+ + +
+ +
+ +
+
+ 08:00 +
+
+
+ 11:00 +
+ Fixed Slot +
+
+ +
+ +
+
+
+
+ ) +} diff --git a/app/dashboard/kelola-jadwal/JadwalFormModal.tsx b/app/dashboard/kelola-jadwal/JadwalFormModal.tsx new file mode 100644 index 0000000..4252bfd --- /dev/null +++ b/app/dashboard/kelola-jadwal/JadwalFormModal.tsx @@ -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(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 ( +
+
+ + {/* Header */} +
+
+

Atur Ulang Jadwal

+

{data.detail_posyandu.nama_posyandu}

+
+ +
+ +
+ + + + + + {/* Tanggal */} +
+ + { + 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" + /> +
+ + {/* Sesi Section */} +
+ + + {allSlotsFull && ( +
+ +

+ Maaf, seluruh jadwal di hari ini sudah penuh. +

+
+ )} + +
+ {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 ( + + ) + })} +
+
+ +
+ +
+
+
+
+ ) +} diff --git a/app/dashboard/kelola-jadwal/JadwalTable.tsx b/app/dashboard/kelola-jadwal/JadwalTable.tsx new file mode 100644 index 0000000..abe07e9 --- /dev/null +++ b/app/dashboard/kelola-jadwal/JadwalTable.tsx @@ -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(null) + const [isDeleting, setIsDeleting] = useState(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 ( +
+ {/* Header / Action Bar */} +
+
+ {/* Search */} +
+ + 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" + /> +
+ {/* Date Filter */} +
+ + 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" + /> +
+ + +
+ +
+ + + {showHistory ? ( + + ) : ( + <> + {isScheduledThisMonth && ( + + )} + + + )} +
+
+ + {/* Table */} +
+ + + + + + + + + + + + {filteredData.length === 0 ? ( + + + + ) : ( + filteredData.map((j, idx) => ( + + + + + + + + )) + )} + +
NoWaktu & SesiDetail PosyanduOleh AdminAksi
+
+
+ +
+
+

Belum ada jadwal ditemukan

+

Gunakan Penjadwalan Instan untuk membuat jadwal otomatis.

+
+
+
+ {idx + 1} + +
+
+ + {j.jam_mulai.slice(0, 5)} - {j.jam_selesai.slice(0, 5)} +
+ + {new Date(j.tanggal).toLocaleDateString('id-ID', { weekday: 'long', day: 'numeric', month: 'long' })} + +
+
+
+ + {j.detail_posyandu.nama_posyandu} + +
+ + {j.detail_posyandu.alamat} +
+
+
+
+
+ {j.diedit_oleh.replace('[HISTORY] ', '').charAt(0).toUpperCase()} +
+ {j.diedit_oleh.replace('[HISTORY] ', '')} +
+
+
+ + + +
+
+
+ + setIsInstantModalOpen(false)} + adminName={userName} + /> + + setIsEditModalOpen(false)} + data={selectedJadwal} + adminName={userName} + /> +
+ ) +} diff --git a/app/dashboard/kelola-jadwal/action-jadwal.ts b/app/dashboard/kelola-jadwal/action-jadwal.ts new file mode 100644 index 0000000..ff6d9e5 --- /dev/null +++ b/app/dashboard/kelola-jadwal/action-jadwal.ts @@ -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: [] } + } +} diff --git a/app/dashboard/kelola-jadwal/page.tsx b/app/dashboard/kelola-jadwal/page.tsx new file mode 100644 index 0000000..73555c9 --- /dev/null +++ b/app/dashboard/kelola-jadwal/page.tsx @@ -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
Gagal memuat data jadwal.
+ } + + return ( +
+ {/* Header */} +
+
+ +
+ +
+ Kembali ke Dashboard + +
+
+

Kelola Jadwal Posyandu

+

ADMINISTRASI PENJADWALAN

+
+
+ +
+ +
+ + {/* Info Card */} +
+
+ +
+
+

Optimasi Penjadwalan Bulanan

+

+ Kelola jadwal operasional seluruh Posyandu di wilayah Anda secara efisien. Gunakan fitur Penjadwalan Instan untuk mengotomatisasi pembagian sesi dengan mempertimbangkan waktu istirahat (jeda 1 jam). +

+
+
+ +
+
+ + {/* Table Section */} + + +
+
+ ) +} diff --git a/app/dashboard/manajemen-akun/pengguna/[id]/EditPenggunaForm.tsx b/app/dashboard/manajemen-akun/pengguna/[id]/EditPenggunaForm.tsx index d0b0831..0f559e7 100644 --- a/app/dashboard/manajemen-akun/pengguna/[id]/EditPenggunaForm.tsx +++ b/app/dashboard/manajemen-akun/pengguna/[id]/EditPenggunaForm.tsx @@ -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 ( -
- {type === 'success' - ? - : - } - {message} - -
- ) -} +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 && setToast(null)} />} - -
diff --git a/app/dashboard/manajemen-akun/petugas/EditPetugasForm.tsx b/app/dashboard/manajemen-akun/petugas/EditPetugasForm.tsx index af5a556..50e7d56 100644 --- a/app/dashboard/manajemen-akun/petugas/EditPetugasForm.tsx +++ b/app/dashboard/manajemen-akun/petugas/EditPetugasForm.tsx @@ -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 ( -
- {type === 'success' - ? - : - } - {message} - -
- ) -} +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 && ( - setToast(null)} - /> - )} - - - diff --git a/app/dashboard/manajemen-posyandu/ManajemenPosyanduTable.tsx b/app/dashboard/manajemen-posyandu/ManajemenPosyanduTable.tsx index a179ac0..06befcd 100644 --- a/app/dashboard/manajemen-posyandu/ManajemenPosyanduTable.tsx +++ b/app/dashboard/manajemen-posyandu/ManajemenPosyanduTable.tsx @@ -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) { No Informasi Posyandu Petugas & Kontak - Lokasi + Lokasi Aksi @@ -125,19 +133,34 @@ export function ManajemenPosyanduTable({ data }: Props) {
-
-
-
+
+ {p.petugas && p.petugas.length > 0 ? ( + p.petugas.map((petugas, i) => ( +
+
+ + {petugas.nama_petugas} + + {petugas.jabatan && ( + + {petugas.jabatan} + + )} +
+ {petugas.nomor_hp && ( +
+ + {petugas.nomor_hp} +
+ )} +
+ )) + ) : ( +
+ Belum ada petugas
- - {p.petugas?.[0]?.nama_petugas || '-'} - -
-
- - {p.kontak || '-'} -
+ )}
diff --git a/app/dashboard/manajemen-posyandu/PosyanduFormModal.tsx b/app/dashboard/manajemen-posyandu/PosyanduFormModal.tsx index 9ef89a5..cc8ba19 100644 --- a/app/dashboard/manajemen-posyandu/PosyanduFormModal.tsx +++ b/app/dashboard/manajemen-posyandu/PosyanduFormModal.tsx @@ -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([ + { 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 (
-
+
{/* Header */}
@@ -129,67 +179,108 @@ export function PosyanduFormModal({ isOpen, onClose, selectedData }: PosyanduFor {/* Form Body */} - {/* Nama Posyandu */} -
- - setFormData({ ...formData, nama_posyandu: 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" - /> -
- - {/* Petugas & Kontak Section */} -
+ {/* Informasi Dasar */} +
+

Informasi Dasar

setFormData({ ...formData, nama_petugas: e.target.value })} + placeholder="Contoh: Posyandu Melati 01" + value={formData.nama_posyandu} + onChange={e => setFormData({ ...formData, nama_posyandu: 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" />
+
- 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" +