// assets/js/app.js — WebGIS Pontianak v2.0 Global Utilities // ── TOAST SYSTEM ────────────────────────────────────────── const Toast = (() => { let container = null; function getContainer() { if (!container) { container = document.createElement('div'); container.className = 'toast-container'; document.body.appendChild(container); } return container; } function show(message, type = 'info', duration = 3500) { const t = document.createElement('div'); const icons = { success: '✅', error: '❌', info: 'ℹ️', warning: '⚠️' }; t.className = `toast toast-${type}`; t.textContent = `${icons[type] || ''} ${message}`; const c = getContainer(); c.appendChild(t); setTimeout(() => { t.style.opacity = '0'; t.style.transform = 'translateY(12px)'; t.style.transition = 'all 0.3s'; setTimeout(() => t.remove(), 300); }, duration); return t; } return { show, success: m => show(m,'success'), error: m => show(m,'error'), info: m => show(m,'info'), warning: m => show(m,'warning') }; })(); // ── API HELPER ──────────────────────────────────────────── const API = { async get(url) { const res = await fetch(url, { headers: { 'Accept': 'application/json' } }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); }, async post(url, data, isFormData = false) { const opts = { method: 'POST' }; if (isFormData) { opts.body = data; } else { opts.headers = { 'Content-Type': 'application/json' }; opts.body = JSON.stringify(data); } const res = await fetch(url, opts); return res.json(); }, async put(url, data) { const res = await fetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); return res.json(); }, async delete(url) { const res = await fetch(url, { method: 'DELETE' }); return res.json(); } }; // ── DATE & TIME HELPERS ─────────────────────────────────── const DateTime = { format(dateStr, options = {}) { if (!dateStr) return '—'; const defaults = { day: 'numeric', month: 'short', year: 'numeric' }; return new Date(dateStr).toLocaleDateString('id-ID', { ...defaults, ...options }); }, formatFull(dateStr) { if (!dateStr) return '—'; return new Date(dateStr).toLocaleString('id-ID', { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }); }, ago(dateStr) { if (!dateStr) return '—'; const diff = Date.now() - new Date(dateStr).getTime(); const m = Math.floor(diff / 60000); if (m < 1) return 'Baru saja'; if (m < 60) return `${m} menit lalu`; const h = Math.floor(m / 60); if (h < 24) return `${h} jam lalu`; const d = Math.floor(h / 24); if (d < 7) return `${d} hari lalu`; return DateTime.format(dateStr); }, age(dob) { if (!dob) return 0; return Math.floor((Date.now() - new Date(dob).getTime()) / (365.25 * 24 * 3600 * 1000)); }, today() { return new Date().toISOString().split('T')[0]; } }; // ── NUMBER HELPERS ──────────────────────────────────────── const Num = { currency(val, prefix = 'Rp') { if (!val && val !== 0) return '—'; const n = parseFloat(val); if (n >= 1e9) return `${prefix}${(n/1e9).toFixed(1)}M`; if (n >= 1e6) return `${prefix}${(n/1e6).toFixed(1)}jt`; if (n >= 1e3) return `${prefix}${(n/1e3).toFixed(0)}rb`; return `${prefix}${n.toLocaleString('id-ID')}`; }, format(val) { if (!val && val !== 0) return '—'; return parseFloat(val).toLocaleString('id-ID'); }, pct(val, total) { if (!total) return '0%'; return `${((val / total) * 100).toFixed(1)}%`; } }; // ── BADGE HELPERS ───────────────────────────────────────── const Badge = { statusEkonomi(se) { const map = { sangat_miskin: { cls: 'badge-red', label: 'Sangat Miskin' }, miskin: { cls: 'badge-amber', label: 'Miskin' }, rentan: { cls: 'badge-purple', label: 'Rentan' }, mampu: { cls: 'badge-green', label: 'Mampu' }, }; const v = map[se] || { cls: 'badge-gray', label: se }; return `${v.label}`; }, statusVerif(s) { const map = { pending: { cls: 'badge-amber', label: '⏳ Pending' }, diproses: { cls: 'badge-blue', label: '🔄 Diproses' }, disetujui: { cls: 'badge-green', label: '✅ Disetujui' }, ditolak: { cls: 'badge-red', label: '❌ Ditolak' }, }; const v = map[s] || { cls: 'badge-gray', label: s }; return `${v.label}`; }, statusBantuan(s) { const map = { disalurkan: { cls: 'badge-green', label: '✅ Disalurkan' }, ditunda: { cls: 'badge-amber', label: '⏳ Ditunda' }, dibatalkan: { cls: 'badge-red', label: '❌ Dibatalkan' }, }; const v = map[s] || { cls: 'badge-gray', label: s }; return `${v.label}`; }, jenisLaporan(j) { const map = { kelahiran: '🍼 Kelahiran', kematian: '⚰️ Kematian', pindah_masuk: '📦 Pindah Masuk', pindah_keluar: '🚚 Pindah Keluar', pernikahan: '💒 Pernikahan', perceraian: '📋 Perceraian', perubahan_data:'📝 Perubahan Data', lainnya: '📌 Lainnya', }; return map[j] || j; }, jenisIbadah(j) { const map = { Masjid:'🕌', Gereja:'⛪', Vihara:'🏯', Pura:'🛕', Klenteng:'⛩️' }; return `${map[j] || '🏢'} ${j}`; } }; // ── MAP ICON FACTORY ────────────────────────────────────── const MapIcon = { make(color, emoji, size = 30) { return L.divIcon({ className: '', html: `
${emoji}
`, iconSize: [size, size], iconAnchor: [size/2, size/2], popupAnchor: [0, -size/2] }); }, forIbadah(jenis) { const map = { masjid: ['#3b82f6','🕌'], gereja: ['#8b5cf6','⛪'], vihara: ['#f59e0b','🏯'], pura: ['#10b981','🛕'], klenteng: ['#06b6d4','⛩️'], }; const [color, emoji] = map[(jenis||'').toLowerCase()] || ['#64748b','🏢']; return this.make(color, emoji); }, forPenduduk(statusEkonomi) { const map = { sangat_miskin: ['#ef4444','⚠️'], miskin: ['#f97316','🔸'], rentan: ['#eab308','🔶'], mampu: ['#22c55e','🔷'], }; const [color, emoji] = map[statusEkonomi] || ['#64748b','📍']; return this.make(color, emoji); } }; // ── MODAL MANAGER ───────────────────────────────────────── const Modal = { open(id) { document.getElementById(id)?.classList.remove('hidden'); }, close(id) { document.getElementById(id)?.classList.add('hidden'); }, toggle(id){ document.getElementById(id)?.classList.toggle('hidden'); }, }; // ── LOADING BUTTON ──────────────────────────────────────── function setLoading(btn, loading, label = '') { if (loading) { btn._origLabel = btn.innerHTML; btn.disabled = true; btn.innerHTML = `
${label || 'Memproses...'}`; } else { btn.disabled = false; btn.innerHTML = btn._origLabel || label; } } // ── FORM SERIALIZER ─────────────────────────────────────── function serializeForm(formId) { const form = document.getElementById(formId); if (!form) return {}; const data = {}; form.querySelectorAll('input, select, textarea').forEach(el => { if (el.name && !el.disabled) { if (el.type === 'checkbox') data[el.name] = el.checked; else if (el.type === 'radio') { if (el.checked) data[el.name] = el.value; } else data[el.name] = el.value; } }); return data; } // ── DEBOUNCE ────────────────────────────────────────────── function debounce(fn, delay = 300) { let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), delay); }; } // ── CSV EXPORT ──────────────────────────────────────────── function exportToCSV(data, filename = 'export.csv') { if (!data.length) { Toast.warning('Tidak ada data untuk diekspor'); return; } const headers = Object.keys(data[0]); const rows = [headers, ...data.map(r => headers.map(h => `"${String(r[h] ?? '').replace(/"/g, '""')}"` ))]; const csv = rows.map(r => r.join(',')).join('\n'); const a = document.createElement('a'); a.href = 'data:text/csv;charset=utf-8,\uFEFF' + encodeURIComponent(csv); a.download = filename; a.click(); } // ── CONFIRM DIALOG ──────────────────────────────────────── function confirmAction(message, onConfirm) { // Buat modal konfirmasi sederhana const id = 'confirm-' + Date.now(); const el = document.createElement('div'); el.className = 'modal-overlay animate-fade-in'; el.id = id; el.innerHTML = ` `; document.body.appendChild(el); document.getElementById(`${id}-ok`).onclick = () => { el.remove(); onConfirm(); }; } // ── HAVERSINE (client-side) ─────────────────────────────── function haversine(lat1, lon1, lat2, lon2) { const R = 6371000; const phi1 = lat1 * Math.PI / 180, phi2 = lat2 * Math.PI / 180; const dphi = (lat2 - lat1) * Math.PI / 180; const dl = (lon2 - lon1) * Math.PI / 180; const a = Math.sin(dphi/2)**2 + Math.cos(phi1)*Math.cos(phi2)*Math.sin(dl/2)**2; return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); } // ── GEOLOCATION ─────────────────────────────────────────── function getCurrentLocation() { return new Promise((resolve, reject) => { if (!navigator.geolocation) { reject(new Error('Geolocation tidak didukung')); return; } navigator.geolocation.getCurrentPosition( pos => resolve({ lat: pos.coords.latitude, lng: pos.coords.longitude }), err => reject(err), { enableHighAccuracy: true, timeout: 8000 } ); }); } // ── NOTIFIKASI POLLING ──────────────────────────────────── const NotifPoller = { interval: null, callbacks: [], start(intervalMs = 30000) { this.poll(); this.interval = setInterval(() => this.poll(), intervalMs); }, stop() { if (this.interval) clearInterval(this.interval); }, async poll() { try { const d = await API.get('../api/notifikasi.php?unread=1'); this.callbacks.forEach(cb => cb(d)); } catch (e) { /* silent */ } }, onChange(cb) { this.callbacks.push(cb); } }; // ── STORAGE HELPER (sessionStorage) ────────────────────── const Store = { set(key, val) { try { sessionStorage.setItem(key, JSON.stringify(val)); } catch(e){} }, get(key) { try { return JSON.parse(sessionStorage.getItem(key)); } catch(e){ return null; } }, remove(key) { sessionStorage.removeItem(key); }, }; // ── INIT ON DOM READY ───────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { // Close modals on overlay click document.querySelectorAll('.modal-overlay').forEach(overlay => { overlay.addEventListener('click', e => { if (e.target === overlay) overlay.classList.add('hidden'); }); }); // Auto-focus first input in opened modals document.querySelectorAll('.modal-overlay .modal-close').forEach(btn => { btn.addEventListener('click', () => { btn.closest('.modal-overlay')?.classList.add('hidden'); }); }); });