3893 lines
200 KiB
HTML
3893 lines
200 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="id">
|
|
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<meta name="description"
|
|
content="WebGIS Poverty Mapping Sistem Informasi Geografis Pemetaan Kemiskinan Berbasis Rumah Ibadah">
|
|
<title>WebGIS Poverty Mapping Dashboard</title>
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css" />
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
|
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap"
|
|
rel="stylesheet">
|
|
<link rel="stylesheet" href="style.css?v=1.7">
|
|
</head>
|
|
|
|
<body>
|
|
|
|
<!-- LOGIN SCREEN -->
|
|
<div id="login-screen">
|
|
<div class="login-wrapper">
|
|
<div class="login-brand">
|
|
<div class="brand-icon"><i class="fa-solid fa-map-location-dot" style="color:#fff"></i></div>
|
|
<h1>WebGIS Poverty Mapping</h1>
|
|
<p>Sistem Pemetaan Kemiskinan Berbasis Rumah Ibadah</p>
|
|
</div>
|
|
<div class="login-card">
|
|
<!-- -- STEP INDICATOR -- -->
|
|
<div class="step-indicator">
|
|
<div class="step-dot active" id="sdot-1"></div>
|
|
<div class="step-line"></div>
|
|
<div class="step-dot" id="sdot-2"></div>
|
|
</div>
|
|
|
|
<!-- STEP 1 : PILIH ROLE -->
|
|
<div class="login-step" id="step-role">
|
|
<h2>Selamat Datang</h2>
|
|
<p class="sub">Pilih peran Anda untuk melanjutkan</p>
|
|
<div class="role-grid-full">
|
|
<div class="role-card" id="role-admin" onclick="selectRoleAndNext('admin')">
|
|
<div class="role-card-icon admin-icon">
|
|
<i class="fa-solid fa-user-shield"></i>
|
|
</div>
|
|
<div class="role-card-body">
|
|
<span class="role-card-name">Admin</span>
|
|
<span class="role-card-desc">Kelola data, verifikasi laporan & manajemen pengguna</span>
|
|
</div>
|
|
<div class="role-card-arrow"><i class="fa-solid fa-chevron-right"></i></div>
|
|
</div>
|
|
<div class="role-card" id="role-surveyor" onclick="selectRoleAndNext('surveyor')">
|
|
<div class="role-card-icon surveyor-icon">
|
|
<i class="fa-solid fa-clipboard-list"></i>
|
|
</div>
|
|
<div class="role-card-body">
|
|
<span class="role-card-name">Surveyor</span>
|
|
<span class="role-card-desc">Input data lapangan & pelaporan kondisi warga</span>
|
|
</div>
|
|
<div class="role-card-arrow"><i class="fa-solid fa-chevron-right"></i></div>
|
|
</div>
|
|
<div class="role-card" id="role-pemangku" onclick="selectRoleAndNext('pemangku')">
|
|
<div class="role-card-icon pemangku-icon">
|
|
<i class="fa-solid fa-chart-simple"></i>
|
|
</div>
|
|
<div class="role-card-body">
|
|
<span class="role-card-name">Pemangku</span>
|
|
<span class="role-card-desc">Analisis data & pengambilan keputusan strategis</span>
|
|
</div>
|
|
<div class="role-card-arrow"><i class="fa-solid fa-chevron-right"></i></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- STEP 2 : FORM KREDENSIAL -->
|
|
<div class="login-step hidden-step" id="step-credentials">
|
|
<!-- Role badge aktif -->
|
|
<button class="back-btn" onclick="goBackToRole()" title="Ganti peran">
|
|
<i class="fa-solid fa-arrow-left"></i>
|
|
</button>
|
|
<div class="active-role-badge" id="active-role-badge">
|
|
<div class="arb-icon" id="arb-icon"></div>
|
|
<div class="arb-info">
|
|
<span class="arb-label">Masuk sebagai</span>
|
|
<span class="arb-name" id="arb-name"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="login-field">
|
|
<label>Username</label>
|
|
<div class="input-wrap">
|
|
<i class="fa-solid fa-user"></i>
|
|
<input type="text" id="login-user" placeholder="Masukkan username">
|
|
</div>
|
|
</div>
|
|
<div class="login-field">
|
|
<label>Password</label>
|
|
<div class="input-wrap">
|
|
<i class="fa-solid fa-lock"></i>
|
|
<input type="password" id="login-pass" placeholder="Masukkan password">
|
|
<button type="button" class="toggle-pass" onclick="togglePassword('login-pass', this)" tabindex="-1"
|
|
title="Tampilkan/sembunyikan password">
|
|
<i class="fa-solid fa-eye"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<button class="login-btn" id="login-btn" onclick="doLogin()">
|
|
<i class="fa-solid fa-right-to-bracket"></i> Masuk ke WebGIS Poverty Mapping
|
|
</button>
|
|
<div class="login-error" id="login-error"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- APP -->
|
|
<div id="app" class="hidden">
|
|
|
|
<!-- SIDEBAR -->
|
|
<div id="sidebar">
|
|
<div class="sidebar-header">
|
|
<div class="sidebar-logo">
|
|
<div class="logo-icon"><i class="fa-solid fa-map-location-dot"></i></div>
|
|
<div class="logo-text">
|
|
<h1>WebGIS Poverty Mapping</h1>
|
|
<p>Pemetaan Kemiskinan</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<nav class="sidebar-nav" id="sidebar-nav"></nav>
|
|
<div class="sidebar-footer">
|
|
<div class="user-pill">
|
|
<div class="user-avatar" id="sb-avatar">A</div>
|
|
<div class="user-info">
|
|
<div class="u-name" id="sb-name">Admin</div>
|
|
<div class="u-role" id="sb-role">Administrator</div>
|
|
</div>
|
|
<button class="logout-btn-small" onclick="doLogout()" title="Keluar"><i
|
|
class="fa-solid fa-arrow-right-from-bracket"></i></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- MAIN -->
|
|
<div id="main">
|
|
<!-- TOPBAR -->
|
|
<div id="topbar">
|
|
<div class="topbar-left" style="display:flex;align-items:center;gap:12px">
|
|
<button class="topbar-btn" id="sidebar-toggle" onclick="toggleSidebar()" title="Kecilkan/Besarkan Sidebar">
|
|
<i class="fa-solid fa-bars"></i>
|
|
</button>
|
|
<div>
|
|
<h2 id="page-title" style="margin:0">Dashboard</h2>
|
|
<div class="breadcrumb" id="page-bread" style="margin-top:2px">WebGIS Poverty Mapping / Dashboard</div>
|
|
</div>
|
|
</div>
|
|
<div class="topbar-right">
|
|
<button class="topbar-btn" id="notif-btn" onclick="switchPage('verify')" title="Verifikasi Pending">
|
|
<i class="fa-solid fa-bell"></i>
|
|
<span class="notif-dot" id="notif-dot" style="display:none"></span>
|
|
</button>
|
|
<button class="topbar-btn" onclick="loadAll()" title="Refresh Data">
|
|
<i class="fa-solid fa-rotate-right"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- CONTENT AREA -->
|
|
<div id="content-area">
|
|
<div id="page-content"></div>
|
|
</div>
|
|
<!-- Persistent Map dipindahkan ke halaman yang butuh peta, tidak pernah di-destroy -->
|
|
<div id="persistent-map" style="width:100%;height:100%;display:none;"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- EDIT PANEL -->
|
|
<div id="edit-panel" onclick="if(event.target===this)closeEditPanel()">
|
|
<div class="preview-box">
|
|
<div class="pb-header">
|
|
<h3 id="ep-title">Edit Data</h3>
|
|
<button class="pb-close" onclick="closeEditPanel()"><i class="fa-solid fa-xmark"></i></button>
|
|
</div>
|
|
<div class="modal-two-col">
|
|
<div class="modal-col-left" id="ep-body"></div>
|
|
<div class="modal-col-right">
|
|
<div id="map-edit-modal"
|
|
style="width: 100%; height: 100%; min-height: 380px; border-radius: var(--radius-sm); border: 1px solid var(--border);">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="ep-footer" id="ep-footer">
|
|
<button class="ep-save" id="ep-save" onclick="saveEdit()"><i class="fa-solid fa-floppy-disk"></i>
|
|
Simpan</button>
|
|
<button class="ep-del" onclick="deleteFromEdit()"><i class="fa-solid fa-trash"></i> Hapus</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<!-- DETAIL MODAL -->
|
|
<div id="detail-modal" onclick="if(event.target===this)closeDetailModal()">
|
|
<div class="dm-box">
|
|
<div class="dm-header">
|
|
<h3 id="dm-title"><i class="fa-solid fa-circle-info"></i> Detail Data</h3>
|
|
<button class="dm-close" onclick="closeDetailModal()"><i class="fa-solid fa-xmark"></i></button>
|
|
</div>
|
|
<div class="dm-body" id="dm-body"></div>
|
|
<div class="dm-footer" id="dm-footer"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- REJECT MODAL -->
|
|
<div id="reject-modal" class="modal-overlay">
|
|
<div class="modal-box">
|
|
<h3><i class="fa-solid fa-triangle-exclamation" style="color:var(--red);margin-right:8px"></i>Tolak Data</h3>
|
|
<p>Berikan alasan penolakan agar surveyor bisa memperbaiki data.</p>
|
|
<textarea id="reject-reason" placeholder="Contoh: Foto tidak jelas, koordinat tidak akurat..."></textarea>
|
|
<div class="modal-btns">
|
|
<button style="background:var(--bg);color:var(--text2)" onclick="closeRejectModal()">Batal</button>
|
|
<button style="background:var(--red-light);color:var(--red)" onclick="confirmReject()">Tolak Data</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- EDIT USER MODAL -->
|
|
<div id="edit-user-modal" class="modal-overlay">
|
|
<div class="modal-box">
|
|
<h3><i class="fa-solid fa-user-gear" style="color:var(--blue);margin-right:8px"></i>Edit User</h3>
|
|
<p style="margin-bottom:12px;font-size:12px;color:var(--text3)">Ubah data atau reset password pengguna.</p>
|
|
|
|
<input type="hidden" id="eu-id">
|
|
|
|
<div class="form-group" style="margin-bottom:12px;text-align:left">
|
|
<label style="display:block;margin-bottom:4px;font-size:12px;font-weight:600;color:var(--text2)">Username (Tidak
|
|
dapat diubah)</label>
|
|
<input type="text" id="eu-username" disabled
|
|
style="width:100%;padding:8px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--border2);color:var(--text2)">
|
|
</div>
|
|
|
|
<div class="form-group" style="margin-bottom:12px;text-align:left">
|
|
<label style="display:block;margin-bottom:4px;font-size:12px;font-weight:600;color:var(--text2)">Nama
|
|
Lengkap</label>
|
|
<input type="text" id="eu-nama"
|
|
style="width:100%;padding:8px;border:1px solid var(--border);border-radius:var(--radius-sm)">
|
|
</div>
|
|
|
|
<div class="form-group" style="margin-bottom:12px;text-align:left">
|
|
<label style="display:block;margin-bottom:4px;font-size:12px;font-weight:600;color:var(--text2)">Role</label>
|
|
<select id="eu-role"
|
|
style="width:100%;padding:8px;border:1px solid var(--border);border-radius:var(--radius-sm)">
|
|
<option value="surveyor">Surveyor</option>
|
|
<option value="pemangku">Pemangku Keputusan</option>
|
|
<option value="admin">Admin</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-group" style="margin-bottom:16px;text-align:left">
|
|
<label style="display:block;margin-bottom:4px;font-size:12px;font-weight:600;color:var(--text2)">Password Baru
|
|
(Kosongkan jika tidak diubah)</label>
|
|
<div style="position:relative">
|
|
<input type="password" id="eu-pass" placeholder="min 6 karakter"
|
|
style="width:100%;padding:8px 36px 8px 8px;border:1px solid var(--border);border-radius:var(--radius-sm)">
|
|
<button type="button" onclick="togglePassword('eu-pass', this)" tabindex="-1"
|
|
title="Tampilkan/sembunyikan password"
|
|
style="position:absolute;right:10px;top:50%;transform:translateY(-50%);background:none;border:none;cursor:pointer;color:var(--text3);font-size:13px;padding:2px 4px;display:flex;align-items:center"><i
|
|
class="fa-solid fa-eye"></i></button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-btns">
|
|
<button style="background:var(--bg);color:var(--text2)" onclick="closeEditUserModal()">Batal</button>
|
|
<button style="background:var(--blue-light);color:var(--blue);font-weight:600"
|
|
onclick="confirmEditUser()">Simpan</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- PREVIEW MODAL -->
|
|
<div id="preview-modal" onclick="if(event.target===this)closePreviewModal()">
|
|
<div class="preview-box">
|
|
<div class="pb-header">
|
|
<h3 id="pb-title"><i class="fa-solid fa-eye"></i> Detail Data</h3>
|
|
<button class="pb-close" onclick="closePreviewModal()"><i class="fa-solid fa-xmark"></i></button>
|
|
</div>
|
|
<div class="modal-two-col">
|
|
<div class="modal-col-left" id="pb-body"></div>
|
|
<div class="modal-col-right">
|
|
<div id="map-preview-modal"
|
|
style="width: 100%; height: 100%; min-height: 380px; border-radius: var(--radius-sm); border: 1px solid var(--border);">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="pb-footer">
|
|
<button onclick="closePreviewModal()"><i class="fa-solid fa-xmark" style="margin-right:6px"></i>Tutup</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- TOAST & LOADING -->
|
|
<div id="toast"></div>
|
|
<div id="loading">
|
|
<div class="spin"></div>
|
|
<p>Memuat data...</p>
|
|
</div>
|
|
|
|
<!-- LIGHTBOX -->
|
|
<div id="lightbox" onclick="closeLightbox()">
|
|
<button id="lightbox-close" onclick="closeLightbox()" title="Tutup"><i class="fa-solid fa-xmark"></i></button>
|
|
<img id="lightbox-img" src="" alt="Foto" onclick="event.stopPropagation()">
|
|
</div>
|
|
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.heat/0.2.0/leaflet-heat.js"></script>
|
|
<script>
|
|
// Global fetch interceptor to append JWT token for tab-session isolation
|
|
(function () {
|
|
const originalFetch = window.fetch;
|
|
window.fetch = function (url, options) {
|
|
options = options || {};
|
|
const token = sessionStorage.getItem('user_token');
|
|
if (token) {
|
|
if (options.headers instanceof Headers) {
|
|
if (!options.headers.has('Authorization')) {
|
|
options.headers.set('Authorization', 'Bearer ' + token);
|
|
}
|
|
if (!options.headers.has('X-Authorization')) {
|
|
options.headers.set('X-Authorization', 'Bearer ' + token);
|
|
}
|
|
} else {
|
|
options.headers = options.headers || {};
|
|
if (!options.headers['Authorization'] && !options.headers['authorization']) {
|
|
options.headers['Authorization'] = 'Bearer ' + token;
|
|
}
|
|
if (!options.headers['X-Authorization'] && !options.headers['x-authorization']) {
|
|
options.headers['X-Authorization'] = 'Bearer ' + token;
|
|
}
|
|
}
|
|
}
|
|
return originalFetch(url, options);
|
|
};
|
|
})();
|
|
|
|
// --
|
|
// WebGIS Poverty Mapping Main App
|
|
// --
|
|
const API = {
|
|
ibadah: 'api/ibadah.php', warga: 'api/warga.php', stats: 'api/stats.php',
|
|
auth: 'api/auth.php', upload: 'api/upload.php', verify: 'api/verify.php', audit: 'api/audit.php'
|
|
};
|
|
const COLORS = { masjid: '#f59e0b', gereja: '#3b82f6', pura: '#8b5cf6', klenteng: '#f97316', vihara: '#10b981', lain: '#6b7280' };
|
|
const ICONS = { masjid: 'fa-solid fa-mosque', gereja: 'fa-solid fa-church', pura: 'fa-solid fa-gopuram', klenteng: 'fa-solid fa-torii-gate', vihara: 'fa-solid fa-dharmachakra', lain: 'fa-solid fa-place-of-worship' };
|
|
// Warna berdasarkan STATUS (Pendekatan B: warna = status, ikon = jenis)
|
|
const STATUS_COLORS = {
|
|
ibadah: {
|
|
verified: '#10b981', // hijau terverifikasi
|
|
pending: '#f59e0b', // amber menunggu verifikasi
|
|
rejected: '#ef4444', // merah ditolak
|
|
},
|
|
warga: {
|
|
verified_covered: '#10b981', // hijau verified & dalam radius ibadah
|
|
verified_uncovered: '#9ca3af', // abu verified tapi belum terjangkau
|
|
pending: '#f59e0b', // amber menunggu verifikasi
|
|
rejected: '#ef4444', // merah ditolak
|
|
}
|
|
};
|
|
const ROLE_LABELS = { admin: 'Administrator', surveyor: 'Surveyor Lapangan', pemangku: 'Pemangku Keputusan' };
|
|
|
|
// -- STATE ----------------------------------
|
|
let map = null, mapPage = null, heatMap = null, heatLayer = null;
|
|
let ibadahLayer = {}, wargaLayer = {};
|
|
let addMode = null, pickedCoord = { ibadah: null, miskin: null };
|
|
let editState = { type: null, id: null };
|
|
let tempMarker = null, dragActive = false;
|
|
let currentUser = null, pendingReject = null;
|
|
let uploadedFotoPath = { ibadah: null, miskin: null };
|
|
let cachedIbadah = [], cachedWarga = [], cachedStats = {};
|
|
let currentPage = 'dashboard';
|
|
let selectedRole = 'admin';
|
|
let dashTab = 'overview';
|
|
|
|
// -- DAFTAR STATE --------------------------
|
|
let daftarState = { tab: 'ibadah', search: '', status: '', kecamatan: '', jenis: '', layanan: '', myDataOnly: false, surveyorId: '', page: 1, perPage: 10, activeId: null };
|
|
|
|
// -- LOGIN MULTISTEP
|
|
const ROLE_META = {
|
|
admin: { label: 'Administrator', icon: 'fa-user-shield', colorClass: 'admin-icon' },
|
|
surveyor: { label: 'Surveyor Lapangan', icon: 'fa-clipboard-list', colorClass: 'surveyor-icon' },
|
|
pemangku: { label: 'Pemangku Keputusan', icon: 'fa-chart-simple', colorClass: 'pemangku-icon' },
|
|
};
|
|
|
|
function selectRoleAndNext(role) {
|
|
selectedRole = role;
|
|
// Animate card click feedback
|
|
const card = document.getElementById('role-' + role);
|
|
card.classList.add('selected');
|
|
setTimeout(() => goToCredentials(), 220);
|
|
}
|
|
|
|
function goToCredentials() {
|
|
const meta = ROLE_META[selectedRole];
|
|
// Update badge
|
|
const badge = document.getElementById('active-role-badge');
|
|
const icon = document.getElementById('arb-icon');
|
|
const name = document.getElementById('arb-name');
|
|
icon.className = 'arb-icon ' + meta.colorClass;
|
|
icon.innerHTML = `<i class="fa-solid ${meta.icon}"></i>`;
|
|
name.textContent = meta.label;
|
|
|
|
// Step dot indicator
|
|
document.getElementById('sdot-1').classList.remove('active');
|
|
document.getElementById('sdot-2').classList.add('active');
|
|
|
|
// Slide transition
|
|
const s1 = document.getElementById('step-role');
|
|
const s2 = document.getElementById('step-credentials');
|
|
s1.classList.add('slide-out-left');
|
|
s2.classList.remove('hidden-step');
|
|
s2.classList.add('slide-in-right');
|
|
|
|
s1.addEventListener('animationend', () => {
|
|
s1.classList.add('hidden-step');
|
|
s1.classList.remove('slide-out-left');
|
|
}, { once: true });
|
|
s2.addEventListener('animationend', () => {
|
|
s2.classList.remove('slide-in-right');
|
|
document.getElementById('login-user').focus();
|
|
}, { once: true });
|
|
}
|
|
|
|
function goBackToRole() {
|
|
const s1 = document.getElementById('step-role');
|
|
const s2 = document.getElementById('step-credentials');
|
|
|
|
// Reset step dots
|
|
document.getElementById('sdot-1').classList.add('active');
|
|
document.getElementById('sdot-2').classList.remove('active');
|
|
|
|
// Remove selected state on all cards
|
|
['admin', 'surveyor', 'pemangku'].forEach(r => {
|
|
document.getElementById('role-' + r).classList.remove('selected');
|
|
});
|
|
|
|
// Clear errors & inputs
|
|
document.getElementById('login-error').textContent = '';
|
|
|
|
// Slide back
|
|
s2.classList.add('slide-out-right');
|
|
s1.classList.remove('hidden-step');
|
|
s1.classList.add('slide-in-left');
|
|
|
|
s2.addEventListener('animationend', () => {
|
|
s2.classList.add('hidden-step');
|
|
s2.classList.remove('slide-out-right');
|
|
}, { once: true });
|
|
s1.addEventListener('animationend', () => {
|
|
s1.classList.remove('slide-in-left');
|
|
}, { once: true });
|
|
}
|
|
|
|
// Keep original selectRole for backward compat (used in doLogout reset)
|
|
function selectRole(role) { selectedRole = role; }
|
|
|
|
document.getElementById('login-pass').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
|
|
document.getElementById('login-user').addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('login-pass').focus(); });
|
|
|
|
function togglePassword(inputId, btn) {
|
|
const input = document.getElementById(inputId);
|
|
const icon = btn.querySelector('i');
|
|
if (input.type === 'password') {
|
|
input.type = 'text';
|
|
icon.classList.replace('fa-eye', 'fa-eye-slash');
|
|
} else {
|
|
input.type = 'password';
|
|
icon.classList.replace('fa-eye-slash', 'fa-eye');
|
|
}
|
|
}
|
|
|
|
async function doLogin() {
|
|
const username = document.getElementById('login-user').value.trim();
|
|
const password = document.getElementById('login-pass').value;
|
|
const errEl = document.getElementById('login-error');
|
|
const btn = document.getElementById('login-btn');
|
|
errEl.textContent = '';
|
|
if (!username || !password) { errEl.textContent = 'Username dan password wajib diisi'; return; }
|
|
btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Masuk...';
|
|
try {
|
|
const res = await fetch(API.auth + '?action=login', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
|
body: JSON.stringify({ username, password })
|
|
}).then(r => r.json());
|
|
if (!res.success) {
|
|
errEl.textContent = res.error || 'Login gagal';
|
|
return;
|
|
}
|
|
// Check role matches selection
|
|
if (res.user.role !== selectedRole) {
|
|
errEl.textContent = `Akun ini adalah ${ROLE_LABELS[res.user.role]}, bukan ${ROLE_LABELS[selectedRole] || selectedRole}. Pilih peran yang sesuai.`;
|
|
await fetch(API.auth + '?action=logout', { method: 'POST', credentials: 'include' });
|
|
return;
|
|
}
|
|
if (res.token) {
|
|
sessionStorage.setItem('user_token', res.token);
|
|
}
|
|
currentUser = res.user;
|
|
await showApp();
|
|
} catch (e) {
|
|
errEl.textContent = 'Koneksi gagal. Pastikan server berjalan.';
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.innerHTML = '<i class="fa-solid fa-right-to-bracket"></i> Masuk ke WebGIS Poverty Mapping';
|
|
}
|
|
}
|
|
|
|
async function doLogout() {
|
|
// Detach persistent map first to move it back to #main before any container is destroyed
|
|
detachPersistentMap();
|
|
|
|
await fetch(API.auth + '?action=logout', { method: 'POST', credentials: 'include' });
|
|
currentUser = null;
|
|
sessionStorage.removeItem('user_token');
|
|
if (location.hash) {
|
|
history.replaceState(null, null, ' ');
|
|
}
|
|
document.getElementById('login-screen').classList.remove('hidden');
|
|
document.getElementById('app').classList.add('hidden');
|
|
document.getElementById('login-user').value = '';
|
|
document.getElementById('login-pass').value = '';
|
|
// Reset to step 1
|
|
const s1 = document.getElementById('step-role');
|
|
const s2 = document.getElementById('step-credentials');
|
|
s1.classList.remove('hidden-step', 'slide-out-left', 'slide-in-left');
|
|
s2.classList.add('hidden-step');
|
|
s2.classList.remove('slide-in-right', 'slide-out-right');
|
|
document.getElementById('sdot-1').classList.add('active');
|
|
document.getElementById('sdot-2').classList.remove('active');
|
|
['admin', 'surveyor', 'pemangku'].forEach(r => document.getElementById('role-' + r).classList.remove('selected'));
|
|
if (persistentMap) { persistentMap.remove(); persistentMap = null; _persistentMapAttachedTo = null; }
|
|
map = null;
|
|
if (heatMap) { heatMap.remove(); heatMap = null; }
|
|
persistentHeatLayer = null;
|
|
ibadahLayer = {}; wargaLayer = {};
|
|
_dataVersion = 0; _lastMarkerVersion = -1;
|
|
}
|
|
|
|
async function checkSession() {
|
|
try {
|
|
const res = await fetch(API.auth + '?action=me', { credentials: 'include' }).then(r => r.json());
|
|
if (res.logged_in && res.user) { currentUser = res.user; await showApp(); }
|
|
} catch (e) { }
|
|
}
|
|
|
|
async function showApp() {
|
|
document.getElementById('login-screen').classList.add('hidden');
|
|
document.getElementById('app').classList.remove('hidden');
|
|
const u = currentUser;
|
|
const av = document.getElementById('sb-avatar');
|
|
av.textContent = u.nama_lengkap.charAt(0).toUpperCase();
|
|
av.className = 'user-avatar ' + u.role;
|
|
document.getElementById('sb-name').textContent = u.nama_lengkap;
|
|
document.getElementById('sb-role').textContent = ROLE_LABELS[u.role] || u.role;
|
|
buildNav();
|
|
window.scrollTo(0, 0);
|
|
|
|
// Load data dulu
|
|
await loadAll();
|
|
const hashPage = location.hash.replace('#', '');
|
|
const validPages = getNavItems(currentUser.role).map(item => item.id);
|
|
const targetPage = validPages.includes(hashPage) ? hashPage : 'dashboard';
|
|
switchPage(targetPage);
|
|
}
|
|
|
|
// -- NAV
|
|
function getNavItems(role) {
|
|
const all = [
|
|
{ id: 'dashboard', icon: 'fa-chart-pie', label: 'Dashboard', roles: ['admin', 'surveyor', 'pemangku'] },
|
|
{ id: 'peta', icon: 'fa-map', label: 'Peta Interaktif', roles: ['admin', 'surveyor', 'pemangku'] },
|
|
{ id: 'ibadah', icon: 'fa-place-of-worship', label: 'Input Ibadah', roles: ['surveyor'] },
|
|
{ id: 'miskin', icon: 'fa-people-group', label: 'Input Warga', roles: ['surveyor'] },
|
|
{ id: 'daftar', icon: 'fa-list', label: 'Daftar Data', roles: ['admin', 'surveyor', 'pemangku'], badge: 'daftar' },
|
|
{ id: 'verify', icon: 'fa-circle-check', label: 'Verifikasi', roles: ['admin'], badge: 'verify' },
|
|
{ id: 'users', icon: 'fa-users', label: 'Kelola User', roles: ['admin'] },
|
|
];
|
|
return all.filter(i => i.roles.includes(role));
|
|
}
|
|
|
|
function buildNav() {
|
|
const items = getNavItems(currentUser.role);
|
|
const nav = document.getElementById('sidebar-nav');
|
|
const sections = {
|
|
main: ['dashboard', 'peta'],
|
|
input: ['ibadah', 'miskin'],
|
|
data: ['daftar', 'verify'],
|
|
analisis: ['analisis'],
|
|
admin: ['users'],
|
|
};
|
|
const sectionLabels = { main: 'Menu Utama', input: 'Input Data', data: 'Manajemen Data', analisis: 'Analisis', admin: 'Administrasi' };
|
|
let html = '';
|
|
for (const [sec, ids] of Object.entries(sections)) {
|
|
const filtered = ids.filter(id => items.find(i => i.id === id));
|
|
if (!filtered.length) continue;
|
|
html += `<div class="nav-section"><div class="nav-section-label">${sectionLabels[sec]}</div>`;
|
|
filtered.forEach(id => {
|
|
const item = items.find(i => i.id === id);
|
|
if (!item) return;
|
|
const badgeHtml = item.badge ? `<span class="nav-badge" id="nb-${item.badge}" style="display:none">0</span>` : '';
|
|
html += `<div class="nav-item" id="nav-${item.id}" onclick="switchPage('${item.id}')" title="${item.label}">
|
|
<i class="fa-solid ${item.icon} nav-icon"></i><span class="nav-label">${item.label}</span>${badgeHtml}
|
|
</div>`;
|
|
});
|
|
html += '</div>';
|
|
}
|
|
nav.innerHTML = html;
|
|
}
|
|
|
|
// -- NAVIGATION SWITCH
|
|
function switchPage(page) {
|
|
// Detach persistent map sebelum innerHTML halaman diganti
|
|
detachPersistentMap();
|
|
currentPage = page;
|
|
document.querySelectorAll('.nav-item').forEach(el => {
|
|
el.classList.toggle('active', el.id === 'nav-' + page);
|
|
});
|
|
const titles = {
|
|
dashboard: 'Dashboard', peta: 'Peta Interaktif', ibadah: 'Input Rumah Ibadah',
|
|
miskin: 'Input Warga Miskin', daftar: 'Daftar Data', verify: 'Verifikasi Data',
|
|
analisis: 'Panel Analisis',
|
|
users: 'Kelola User'
|
|
};
|
|
const title = titles[page] || page;
|
|
document.getElementById('page-title').textContent = title;
|
|
document.getElementById('page-bread').textContent = `WebGIS Poverty Mapping / ${title}`;
|
|
if (page === 'ibadah') setAddMode('ibadah');
|
|
else if (page === 'miskin') setAddMode('miskin');
|
|
else cancelAddMode();
|
|
renderPage(page);
|
|
window.scrollTo(0, 0);
|
|
|
|
// Update URL hash
|
|
if (location.hash !== '#' + page) {
|
|
location.hash = page;
|
|
}
|
|
}
|
|
|
|
// Global listener for SPA back/forward browser buttons
|
|
window.addEventListener('hashchange', () => {
|
|
if (!currentUser) return;
|
|
const hashPage = location.hash.replace('#', '');
|
|
const validPages = getNavItems(currentUser.role).map(item => item.id);
|
|
if (validPages.includes(hashPage) && currentPage !== hashPage) {
|
|
switchPage(hashPage);
|
|
}
|
|
});
|
|
|
|
// -- RENDER PAGES ---------------------------
|
|
function renderPage(page) {
|
|
const area = document.getElementById('page-content');
|
|
switch (page) {
|
|
case 'dashboard': renderDashboard(area); break;
|
|
case 'peta': renderMapPage(area); break;
|
|
case 'ibadah': renderFormIbadah(area); break;
|
|
case 'miskin': renderFormMiskin(area); break;
|
|
case 'daftar': renderDaftar(area); break;
|
|
case 'verify': renderVerify(area); break;
|
|
case 'users': renderUsers(area); break;
|
|
case 'analisis': switchPage('dashboard'); break; // redirect fallback
|
|
}
|
|
}
|
|
|
|
function switchDashTab(tab) {
|
|
detachPersistentMap();
|
|
dashTab = tab;
|
|
renderDashboard(document.getElementById('page-content'));
|
|
}
|
|
|
|
// -- DASHBOARD -----------------------------
|
|
function renderDashboard(area) {
|
|
// Jika data belum ada, tampilkan skeleton dulu
|
|
if (!cachedStats || Object.keys(cachedStats).length === 0) {
|
|
const skeletonCard = `<div class="stat-card" style="animation:pulse 1.4s ease-in-out infinite">
|
|
<div style="height:11px;border-radius:6px;background:var(--border);width:60%;margin-bottom:10px"></div>
|
|
<div style="height:32px;border-radius:8px;background:var(--border);width:40%;margin-bottom:8px"></div>
|
|
<div style="height:10px;border-radius:6px;background:var(--border2);width:80%"></div>
|
|
</div>`;
|
|
area.innerHTML = `
|
|
<style>@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}</style>
|
|
<div class="stat-grid" style="grid-template-columns:repeat(auto-fit,minmax(160px,1fr));margin-bottom:20px">
|
|
${skeletonCard.repeat(4)}
|
|
</div>
|
|
<div class="content-grid">
|
|
<div class="card"><div class="card-header"><h3>Peta Persebaran</h3></div>
|
|
<div class="card-map" style="display:flex;align-items:center;justify-content:center;background:var(--border2)">
|
|
<div style="text-align:center;color:var(--text3)"><div class="spin"></div><p style="margin-top:10px;font-size:13px">Memuat data peta...</p></div>
|
|
</div>
|
|
</div>
|
|
<div class="card"><div class="card-header"><h3>Informasi & Legenda</h3></div>
|
|
<div class="card-body" style="animation:pulse 1.4s ease-in-out infinite">
|
|
<div style="height:12px;border-radius:6px;background:var(--border);width:70%;margin-bottom:12px"></div>
|
|
<div style="height:12px;border-radius:6px;background:var(--border2);width:50%;margin-bottom:12px"></div>
|
|
<div style="height:12px;border-radius:6px;background:var(--border);width:80%"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="card" style="margin-bottom:16px"><div class="card-header"><h3>Data Terbaru</h3></div>
|
|
<div class="card-body" style="animation:pulse 1.4s ease-in-out infinite">
|
|
${`<div style="display:flex;gap:12px;padding:10px 0;border-bottom:1px solid var(--border2)"><div style="width:36px;height:36px;border-radius:50%;background:var(--border);flex-shrink:0"></div><div style="flex:1"><div style="height:12px;border-radius:6px;background:var(--border);width:55%;margin-bottom:8px"></div><div style="height:10px;border-radius:6px;background:var(--border2);width:80%"></div></div></div>`.repeat(4)}
|
|
</div>
|
|
</div>`;
|
|
return;
|
|
}
|
|
|
|
const s = cachedStats;
|
|
const role = currentUser.role;
|
|
const pct = s.kk_total > 0 ? Math.round((s.kk_terlayani / s.kk_total) * 100) : 0;
|
|
|
|
// Tab switcher jika user admin atau pemangku
|
|
let tabsHtml = '';
|
|
if (role === 'admin' || role === 'pemangku') {
|
|
tabsHtml = `
|
|
<div class="daftar-tabs" style="margin-bottom:16px">
|
|
<button class="daftar-tab ${dashTab === 'overview' ? 'active' : ''}" onclick="switchDashTab('overview')"><i class="fa-solid fa-chart-pie"></i> Ikhtisar & Peta</button>
|
|
<button class="daftar-tab ${dashTab === 'analisis' ? 'active' : ''}" onclick="switchDashTab('analisis')"><i class="fa-solid fa-chart-column"></i> Analisis Wilayah & Bantuan</button>
|
|
</div>`;
|
|
}
|
|
|
|
let recentCardHtml = '';
|
|
if (role === 'admin' || role === 'surveyor') {
|
|
recentCardHtml = `
|
|
<div class="card" style="margin-bottom:16px">
|
|
<div class="card-header">
|
|
<h3>Data Terbaru</h3>
|
|
<button onclick="switchPage('daftar')" style="font-size:11px;font-weight:600;color:var(--blue);border:none;background:none;cursor:pointer;">Lihat Semua</button>
|
|
</div>
|
|
<div class="card-body" id="dash-recent"></div>
|
|
</div>`;
|
|
}
|
|
|
|
if ((role === 'admin' || role === 'pemangku') && dashTab === 'analisis') {
|
|
renderDashboardAnalisis(area, tabsHtml, s, pct);
|
|
return;
|
|
}
|
|
|
|
let roleCards = '';
|
|
if (role === 'admin') {
|
|
roleCards = `
|
|
<div class="stat-card">
|
|
<div class="s-header"><div class="s-label">Ibadah Pending</div><div class="s-icon icon-amber"><i class="fa-solid fa-clock"></i></div></div>
|
|
<div class="s-value">${s.pending_ibadah || 0}</div>
|
|
<div class="s-sub"><span style="color:var(--amber)"><i class="fa-solid fa-clock" style="margin-right:4px"></i>Menunggu verifikasi</span></div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="s-header"><div class="s-label">Warga Pending</div><div class="s-icon icon-red"><i class="fa-solid fa-user-clock"></i></div></div>
|
|
<div class="s-value">${s.pending_warga || 0}</div>
|
|
<div class="s-sub"><span style="color:var(--red)"><i class="fa-solid fa-clock" style="margin-right:4px"></i>Menunggu verifikasi</span></div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="s-header"><div class="s-label">Total User</div><div class="s-icon icon-purple"><i class="fa-solid fa-users"></i></div></div>
|
|
<div class="s-value">${s.total_users || 0}</div>
|
|
<div class="s-sub">Akun aktif terdaftar</div>
|
|
</div>`;
|
|
} else if (role === 'surveyor') {
|
|
roleCards = `
|
|
<div class="stat-card">
|
|
<div class="s-header"><div class="s-label">Ibadah Saya</div><div class="s-icon icon-amber"><i class="fa-solid fa-place-of-worship"></i></div></div>
|
|
<div class="s-value">${s.my_pending_ibadah || 0}</div>
|
|
<div class="s-sub">Pending verifikasi</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="s-header"><div class="s-label">Warga Saya</div><div class="s-icon icon-red"><i class="fa-solid fa-people-group"></i></div></div>
|
|
<div class="s-value">${s.my_pending_warga || 0}</div>
|
|
<div class="s-sub">Pending verifikasi</div>
|
|
</div>`;
|
|
}
|
|
|
|
area.innerHTML = `
|
|
${tabsHtml}
|
|
<div class="stat-grid" style="grid-template-columns:repeat(auto-fit,minmax(160px,1fr))">
|
|
<div class="stat-card">
|
|
<div class="s-header"><div class="s-label">Rumah Ibadah</div><div class="s-icon icon-blue"><i class="fa-solid fa-place-of-worship"></i></div></div>
|
|
<div class="s-value">${s.ibadah || 0}</div>
|
|
<div class="s-sub">Data terverifikasi</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="s-header"><div class="s-label">Kepala Keluarga</div><div class="s-icon icon-red"><i class="fa-solid fa-house-chimney-user"></i></div></div>
|
|
<div class="s-value">${s.kk_total || 0}</div>
|
|
<div class="s-sub"><span class="badge-down">Warga Miskin</span></div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="s-header"><div class="s-label">KK Terlayani</div><div class="s-icon icon-green"><i class="fa-solid fa-circle-check"></i></div></div>
|
|
<div class="s-value">${s.kk_terlayani || 0}</div>
|
|
<div class="s-sub"><span class="badge-up">${pct}%</span> coverage</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="s-header"><div class="s-label">Total Jiwa</div><div class="s-icon icon-purple"><i class="fa-solid fa-users"></i></div></div>
|
|
<div class="s-value">${s.jiwa_total || 0}</div>
|
|
<div class="s-sub">Terdata dalam sistem</div>
|
|
</div>
|
|
${roleCards}
|
|
</div>
|
|
|
|
<div class="content-grid">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h3>Peta Persebaran</h3>
|
|
<button onclick="switchPage('peta')" style="font-size:11px;font-weight:600;color:var(--blue);border:none;background:none;cursor:pointer;">Lihat Penuh </button>
|
|
</div>
|
|
<div class="card-map" id="dash-map-container"></div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-header"><h3>Informasi & Legenda</h3></div>
|
|
<div class="card-body">
|
|
<div class="info-section">
|
|
<div class="info-section-title">Jenis Rumah Ibadah</div>
|
|
${Object.entries(ICONS).map(([jenis]) => `
|
|
<div class="legend-row">
|
|
<div class="legend-dot" style="background:${COLORS[jenis]}"></div>
|
|
<i class="${ICONS[jenis]}" style="margin-right:4px;color:${COLORS[jenis]}"></i>
|
|
${jenis.charAt(0).toUpperCase() + jenis.slice(1)}
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
${currentUser.role !== 'pemangku' ? `
|
|
<div class="info-section" style="margin-top:14px">
|
|
<div class="info-section-title">Status Pin (Badge)</div>
|
|
<div class="legend-row" style="align-items:center;gap:8px">
|
|
<div style="position:relative;width:22px;height:22px;flex-shrink:0">
|
|
<div style="width:22px;height:22px;background:#6b7280;border-radius:50% 50% 50% 4px;transform:rotate(-45deg);border:2px solid rgba(255,255,255,.9)"></div>
|
|
</div>
|
|
<span style="font-size:12px">Terverifikasi <em style="color:var(--text3)">(tanpa badge)</em></span>
|
|
</div>
|
|
<div class="legend-row" style="align-items:center;gap:8px">
|
|
<div style="position:relative;width:22px;height:22px;flex-shrink:0">
|
|
<div style="width:22px;height:22px;background:#6b7280;border-radius:50% 50% 50% 4px;transform:rotate(-45deg);border:2px solid rgba(255,255,255,.9);opacity:0.5"></div>
|
|
<div style="position:absolute;top:-3px;right:-3px;width:11px;height:11px;background:#f59e0b;border-radius:50%;border:1.5px solid #fff;display:flex;align-items:center;justify-content:center;font-size:6px;color:#fff;font-weight:700">!</div>
|
|
</div>
|
|
<span style="font-size:12px">Menunggu verifikasi <em style="color:var(--text3)">(redup)</em></span>
|
|
</div>
|
|
<div class="legend-row" style="align-items:center;gap:8px">
|
|
<div style="position:relative;width:22px;height:22px;flex-shrink:0">
|
|
<div style="width:22px;height:22px;background:#6b7280;border-radius:50% 50% 50% 4px;transform:rotate(-45deg);border:2px solid rgba(255,255,255,.9);opacity:0.45"></div>
|
|
<div style="position:absolute;top:-3px;right:-3px;width:11px;height:11px;background:#ef4444;border-radius:50%;border:1.5px solid #fff;display:flex;align-items:center;justify-content:center;font-size:6px;color:#fff;font-weight:700"> </div>
|
|
</div>
|
|
<span style="font-size:12px">Ditolak</span>
|
|
</div>
|
|
</div>` : ''}
|
|
<div class="info-section" style="margin-top:14px">
|
|
<div class="info-section-title">Status Warga Miskin</div>
|
|
<div class="legend-row"><div class="legend-dot" style="background:#10b981"></div>Terlayani (dalam radius)</div>
|
|
<div class="legend-row"><div class="legend-dot" style="background:#9ca3af"></div>Belum terlayani</div>
|
|
</div>
|
|
<div class="info-section" style="margin-top:14px">
|
|
<div class="info-section-title">Ringkasan Data</div>
|
|
<div class="mini-stat"><span class="m-label">KK Belum Terlayani</span><span class="m-val" style="color:var(--red)">${s.kk_belum || 0}</span></div>
|
|
<div class="mini-stat"><span class="m-label">Coverage Rate</span><span class="m-val" style="color:var(--green)">${pct}%</span></div>
|
|
<div class="mini-stat"><span class="m-label">Jiwa Terlayani</span><span class="m-val">${s.jiwa_terlayani || 0}</span></div>
|
|
</div>
|
|
${currentUser.role === 'admin' ? `
|
|
<div class="info-section" style="margin-top:14px">
|
|
<div class="info-section-title">Ubah Radius Global</div>
|
|
<div class="form-group">
|
|
<div class="radius-row">
|
|
<input type="range" id="global-r" min="100" max="5000" step="100" value="500" oninput="document.getElementById('gr-val').textContent=this.value+'m'">
|
|
<div class="radius-val" id="gr-val">500m</div>
|
|
</div>
|
|
</div>
|
|
<button class="submit-btn primary full" onclick="applyGlobalRadius()"><i class="fa-solid fa-arrows-left-right-to-line"></i> Terapkan ke Semua</button>
|
|
</div>`: ''}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
${recentCardHtml}
|
|
`;
|
|
// Persistent map tile & marker di-cache, tidak di-destroy saat navigasi
|
|
setTimeout(() => {
|
|
attachPersistentMapTo('dash-map-container');
|
|
getOrCreatePersistentMap();
|
|
rebuildPersistentMarkers();
|
|
fitPersistentMapToData();
|
|
renderDashRecent();
|
|
}, 50);
|
|
}
|
|
|
|
function renderDashRecent() {
|
|
const el = document.getElementById('dash-recent');
|
|
if (!el) return;
|
|
|
|
const combined = [
|
|
...cachedIbadah.map(item => ({ ...item, type: 'ibadah' })),
|
|
...cachedWarga.map(item => ({ ...item, type: 'warga' }))
|
|
];
|
|
|
|
combined.sort((a, b) => {
|
|
const timeA = a.created_at ? new Date(a.created_at.replace(/-/g, '/')).getTime() : 0;
|
|
const timeB = b.created_at ? new Date(b.created_at.replace(/-/g, '/')).getTime() : 0;
|
|
return timeB - timeA;
|
|
});
|
|
|
|
const items = combined.slice(0, 5);
|
|
if (!items.length) { el.innerHTML = '<div class="empty-state"><div class="e-icon"><i class="fa-solid fa-database"></i></div><p>Belum ada data terbaru</p></div>'; return; }
|
|
|
|
el.innerHTML = items.map(item => {
|
|
let iconHtml = '';
|
|
let titleHtml = '';
|
|
let subHtml = '';
|
|
|
|
if (item.type === 'ibadah') {
|
|
iconHtml = `<div class="list-icon" style="background:${COLORS[item.jenis]}22;color:${COLORS[item.jenis]}"><i class="${ICONS[item.jenis]}"></i></div>`;
|
|
titleHtml = `<div class="list-name">${h(item.nama)} <span style="font-size:10px;font-weight:600;padding:2px 6px;border-radius:4px;background:var(--border2);color:var(--text2);margin-left:6px">Rumah Ibadah</span></div>`;
|
|
subHtml = `<div class="list-sub">PIC: ${h(item.pic)} • Radius: ${item.radius}m • ${item.jumlah_kk || 0} KK</div>`;
|
|
} else {
|
|
const bantuanLabels = {
|
|
sembako: 'Sembako Bulanan',
|
|
beasiswa: 'Beasiswa Anak Asuh',
|
|
modal: 'Modal Pemberdayaan',
|
|
tunai: 'Santunan Tunai/Kesehatan',
|
|
tidak_ada: 'Belum Menerima Bantuan'
|
|
};
|
|
const bantuanTxt = bantuanLabels[item.bantuan_diterima] || 'Belum Menerima Bantuan';
|
|
iconHtml = `<div class="list-icon" style="background:#3b82f622;color:#3b82f6"><i class="fa-solid fa-people-group"></i></div>`;
|
|
titleHtml = `<div class="list-name">${h(item.nama_kk)} <span style="font-size:10px;font-weight:600;padding:2px 6px;border-radius:4px;background:var(--border2);color:var(--text2);margin-left:6px">Warga Miskin</span></div>`;
|
|
subHtml = `<div class="list-sub">NIK: ${h(item.nik || '-')} • Anggota: ${item.jumlah_anggota} jiwa • Bantuan: ${bantuanTxt}</div>`;
|
|
}
|
|
|
|
return `
|
|
<div class="list-item" onclick="flyToOnMap(${item.lat},${item.lng})">
|
|
${iconHtml}
|
|
<div class="list-body">
|
|
${titleHtml}
|
|
${subHtml}
|
|
</div>
|
|
<div class="list-end"><span class="badge badge-${item.status}">${statusLabel(item.status)}</span></div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
// -- PETA PAGE -----------------------------
|
|
function renderMapPage(area) {
|
|
const inputBtns = currentUser.role === 'surveyor' ? `
|
|
<button class="submit-btn secondary" onclick="switchPage('ibadah')" style="padding:7px 14px;font-size:12px"><i class="fa-solid fa-plus"></i> Input Ibadah</button>
|
|
<button class="submit-btn secondary" onclick="switchPage('miskin')" style="padding:7px 14px;font-size:12px"><i class="fa-solid fa-plus"></i> Input Warga</button>` : '';
|
|
area.innerHTML = `
|
|
<div class="card" style="margin-bottom:0">
|
|
<div class="card-header">
|
|
<h3>Peta Interaktif Kemiskinan</h3>
|
|
${inputBtns ? `<div style="display:flex;gap:8px">${inputBtns}</div>` : ''}
|
|
</div>
|
|
<div style="height:calc(100vh - 200px);min-height:400px"><div id="map-full-container" style="height:100%"></div></div>
|
|
</div>`;
|
|
setTimeout(() => {
|
|
attachPersistentMapTo('map-full-container');
|
|
getOrCreatePersistentMap();
|
|
rebuildPersistentMarkers();
|
|
fitPersistentMapToData();
|
|
}, 50);
|
|
}
|
|
|
|
// -- FORM IBADAH ---------------------------
|
|
function renderFormIbadah(area) {
|
|
area.innerHTML = `
|
|
<div class="content-grid-form">
|
|
<div class="card">
|
|
<div class="card-header"><h3>Tambah Rumah Ibadah</h3><span style="font-size:11px;color:var(--text2)">Klik peta untuk menentukan lokasi</span></div>
|
|
<div class="card-body">
|
|
<div class="form-section-title">Unggah Foto (Opsional)</div>
|
|
<div class="form-group">
|
|
<div class="upload-area" id="upload-ibadah">
|
|
<div class="u-icon"><i class="fa-solid fa-camera"></i></div>
|
|
<p>Klik atau seret foto ke sini<br><strong>JPEG/PNG max 5MB GPS otomatis terdeteksi</strong></p>
|
|
<input type="file" accept="image/jpeg,image/png" onchange="handleFotoUpload(this,'ibadah')">
|
|
</div>
|
|
<div class="upload-preview" id="preview-ibadah">
|
|
<img id="preview-ibadah-img">
|
|
<button class="rem-btn" onclick="removeFoto('ibadah')"> </button>
|
|
</div>
|
|
<div id="exif-badge-ibadah"></div>
|
|
</div>
|
|
|
|
<div class="form-section-title" style="margin-top:16px">Informasi Rumah Ibadah</div>
|
|
<div class="form-grid">
|
|
<div class="form-group">
|
|
<label>Jenis</label>
|
|
<select id="f-jenis">
|
|
<option value="masjid">Masjid / Musholla</option>
|
|
<option value="gereja">Gereja</option>
|
|
<option value="pura">Pura</option>
|
|
<option value="klenteng">Klenteng</option>
|
|
<option value="vihara">Vihara</option>
|
|
<option value="lain">Lainnya</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group"><label>Nama Rumah Ibadah</label><input type="text" id="f-nama" placeholder="Cth: Masjid Al-Ikhlas"></div>
|
|
<div class="form-group"><label>PIC (Penanggung Jawab)</label><input type="text" id="f-pic" placeholder="Nama PIC"></div>
|
|
<div class="form-group"><label>No. WhatsApp PIC</label><input type="text" id="f-wa" placeholder="08xx-xxxx"></div>
|
|
</div>
|
|
<div class="form-group"><label>Alamat (Otomatis)</label><input type="text" id="f-alamat" placeholder="Klik peta untuk mengisi..." readonly style="color:var(--text2); background:var(--border2)"></div>
|
|
<input type="hidden" id="f-kecamatan">
|
|
<div class="form-group">
|
|
<label>Radius Tanggung Jawab</label>
|
|
<div class="radius-row">
|
|
<input type="range" id="f-radius" min="100" max="5000" step="100" value="500" oninput="document.getElementById('f-rval').textContent=this.value+'m'">
|
|
<div class="radius-val" id="f-rval">500m</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-section-title">Lokasi</div>
|
|
<div class="coord-box" id="coord-ib-box">
|
|
<i class="fa-solid fa-location-dot" style="color:var(--text3)"></i>
|
|
<span class="coord-val" id="ci-coords">Belum dipilih klik di peta atau unggah foto GPS</span>
|
|
</div>
|
|
<div style="margin-top:14px;display:flex;gap:8px">
|
|
<button class="submit-btn primary" id="btn-ibadah" onclick="submitIbadah()" disabled style="flex:1"><i class="fa-solid fa-plus"></i> Tambah Rumah Ibadah</button>
|
|
<button class="submit-btn secondary" onclick="switchPage('dashboard')" style="padding:10px 16px">Batal</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-header"><h3>Peta Pilih Lokasi</h3></div>
|
|
<div style="height:calc(100vh - 220px);min-height:400px"><div id="map-input-ib" style="height:100%"></div></div>
|
|
</div>
|
|
</div>`;
|
|
setupUploadDragDrop('upload-ibadah');
|
|
setTimeout(() => { initMapInput('map-input-ib', 'ibadah'); }, 50);
|
|
}
|
|
|
|
// -- FORM MISKIN ---------------------------
|
|
function renderFormMiskin(area) {
|
|
area.innerHTML = `
|
|
<div class="content-grid-form">
|
|
<div class="card">
|
|
<div class="card-header"><h3>Tambah Data Warga Miskin</h3><span style="font-size:11px;color:var(--text2)">Klik peta untuk menentukan lokasi</span></div>
|
|
<div class="card-body">
|
|
<div class="form-section-title">Unggah Foto KK / Rumah (Opsional)</div>
|
|
<div class="form-group">
|
|
<div class="upload-area" id="upload-miskin">
|
|
<div class="u-icon"><i class="fa-solid fa-camera"></i></div>
|
|
<p>Klik atau seret foto ke sini<br><strong>JPEG/PNG max 5MB GPS otomatis terdeteksi</strong></p>
|
|
<input type="file" accept="image/jpeg,image/png" onchange="handleFotoUpload(this,'miskin')">
|
|
</div>
|
|
<div class="upload-preview" id="preview-miskin">
|
|
<img id="preview-miskin-img">
|
|
<button class="rem-btn" onclick="removeFoto('miskin')"> </button>
|
|
</div>
|
|
<div id="exif-badge-miskin"></div>
|
|
</div>
|
|
|
|
<div class="form-section-title" style="margin-top:16px">Data Kepala Keluarga</div>
|
|
<div class="form-grid">
|
|
<div class="form-group"><label>Nama Kepala Keluarga</label><input type="text" id="m-nama" placeholder="Nama KK"></div>
|
|
<div class="form-group"><label>Jumlah Anggota</label><input type="number" id="m-anggota" value="1" min="1" max="30"></div>
|
|
</div>
|
|
<div class="form-group"><label>NIK Kepala Keluarga</label><input type="text" id="m-nik" placeholder="16 Digit NIK Kepala Keluarga" maxlength="16" oninput="this.value = this.value.replace(/[^0-9]/g, '')"></div>
|
|
<div class="form-group"><label>Bantuan Rumah Ibadah</label>
|
|
<select id="m-bantuan">
|
|
<option value="tidak_ada">Belum Menerima Bantuan</option>
|
|
<option value="sembako">Sembako Bulanan</option>
|
|
<option value="beasiswa">Beasiswa Anak Asuh</option>
|
|
<option value="modal">Modal Pemberdayaan</option>
|
|
<option value="tunai">Santunan Tunai/Kesehatan</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group"><label>Keterangan (Opsional)</label><textarea id="m-ket" rows="2" style="resize:vertical" placeholder="Info tambahan..."></textarea></div>
|
|
<div class="form-group"><label>Alamat (Otomatis)</label><input type="text" id="m-alamat" placeholder="Klik peta untuk mengisi..." readonly style="color:var(--text2); background:var(--border2)"></div>
|
|
<input type="hidden" id="m-kecamatan">
|
|
|
|
<div class="form-section-title">Lokasi Rumah</div>
|
|
<div class="coord-box" id="coord-wg-box">
|
|
<i class="fa-solid fa-location-dot" style="color:var(--text3)"></i>
|
|
<span class="coord-val" id="cm-coords">Belum dipilih klik di peta atau unggah foto GPS</span>
|
|
</div>
|
|
<div style="margin-top:14px;display:flex;gap:8px">
|
|
<button class="submit-btn success" id="btn-miskin" onclick="submitMiskin()" disabled style="flex:1"><i class="fa-solid fa-plus"></i> Tambah Data Warga</button>
|
|
<button class="submit-btn secondary" onclick="switchPage('dashboard')" style="padding:10px 16px">Batal</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-header"><h3>Peta Pilih Lokasi</h3></div>
|
|
<div style="height:calc(100vh - 220px);min-height:400px"><div id="map-input-wg" style="height:100%"></div></div>
|
|
</div>
|
|
</div>`;
|
|
setupUploadDragDrop('upload-miskin');
|
|
setTimeout(() => { initMapInput('map-input-wg', 'miskin'); }, 50);
|
|
}
|
|
|
|
// -- DAFTAR --------------------------------
|
|
function daftarActionBtn(type, data) {
|
|
const id = data.id;
|
|
let html = `<div class="dc-actions" style="display:flex;gap:6px;margin-top:6px;justify-content:flex-end">`;
|
|
|
|
// Global Detail button: accessible to everyone
|
|
html += `<button class="btn-icon-action view" onclick="event.stopPropagation();openPreviewModal('${type}',${id})" title="Lihat Detail"><i class="fa-solid fa-eye"></i></button>`;
|
|
|
|
// Edit button: Admin, or Surveyor if it's their pending data
|
|
if (canEdit(type, data)) {
|
|
html += `<button class="btn-icon-action edit" onclick="event.stopPropagation();openEditPanel('${type}',${id})" title="Edit Data"><i class="fa-solid fa-pen-to-square"></i></button>`;
|
|
}
|
|
|
|
// Delete button: Admin, or Surveyor if it's their data
|
|
if (currentUser.role === 'admin' || (currentUser.role === 'surveyor' && data.surveyor_id == currentUser.id)) {
|
|
html += `<button class="btn-icon-action delete" onclick="event.stopPropagation();${type === 'ibadah' ? 'delIbadah' : 'delWarga'}(${id})" title="Hapus Data"><i class="fa-solid fa-trash"></i></button>`;
|
|
}
|
|
|
|
html += `</div>`;
|
|
return html;
|
|
}
|
|
function renderDaftar(area) {
|
|
daftarState.page = 1;
|
|
daftarState.activeId = null;
|
|
// Pemangku hanya melihat data verified
|
|
if (currentUser?.role === 'pemangku') daftarState.status = 'verified';
|
|
|
|
area.innerHTML = `
|
|
<div class="content-grid daftar-grid-container">
|
|
<div>
|
|
<!-- TABS -->
|
|
<div class="daftar-tabs">
|
|
<button class="daftar-tab ${daftarState.tab === 'ibadah' ? 'active' : ''}" onclick="switchDaftarTab('ibadah')">
|
|
<i class="fa-solid fa-place-of-worship"></i> Rumah Ibadah <span class="tab-count">${cachedIbadah.length}</span>
|
|
</button>
|
|
<button class="daftar-tab ${daftarState.tab === 'warga' ? 'active' : ''}" onclick="switchDaftarTab('warga')">
|
|
<i class="fa-solid fa-people-group"></i> Warga Miskin <span class="tab-count">${cachedWarga.length}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- TOOLBAR -->
|
|
<div class="daftar-toolbar" id="daftar-toolbar"></div>
|
|
|
|
<!-- SUMMARY -->
|
|
<div class="daftar-summary" id="daftar-summary"></div>
|
|
|
|
<!-- LIST -->
|
|
<div class="daftar-list" id="daftar-list"></div>
|
|
|
|
<!-- PAGINATION -->
|
|
<div class="daftar-pagination" id="daftar-pagination"></div>
|
|
</div>
|
|
|
|
<!-- MAP SIDEBAR -->
|
|
<div class="card daftar-map-card">
|
|
<div class="card-header"><h3><i class="fa-solid fa-map-location-dot" style="color:var(--blue);margin-right:8px"></i>Peta</h3></div>
|
|
<div class="map-container"><div id="map-daftar"></div></div>
|
|
</div>
|
|
</div>`;
|
|
|
|
renderDaftarToolbar();
|
|
renderDaftarContent();
|
|
setTimeout(() => { initDaftarMap(); }, 50);
|
|
}
|
|
|
|
function switchDaftarTab(tab) {
|
|
daftarState.tab = tab;
|
|
daftarState.page = 1;
|
|
daftarState.search = '';
|
|
daftarState.status = '';
|
|
daftarState.kecamatan = '';
|
|
daftarState.jenis = '';
|
|
daftarState.layanan = '';
|
|
daftarState.activeId = null;
|
|
// Update tab UI
|
|
document.querySelectorAll('.daftar-tab').forEach(el => {
|
|
el.classList.toggle('active', el.textContent.includes(tab === 'ibadah' ? 'Rumah' : 'Warga'));
|
|
});
|
|
// Pastikan layer tab yang baru aktif tercentang tapi jangan reset layer lainnya
|
|
const m = mapInstances['map-daftar'];
|
|
if (m && m._layerVisible) {
|
|
m._layerVisible[tab === 'ibadah' ? 'ibadah' : 'warga'] = true;
|
|
}
|
|
renderDaftarToolbar();
|
|
renderDaftarContent();
|
|
syncDaftarMap();
|
|
}
|
|
|
|
function renderDaftarToolbar() {
|
|
const tb = document.getElementById('daftar-toolbar');
|
|
if (!tb) return;
|
|
const isIb = daftarState.tab === 'ibadah';
|
|
const isPemangku = currentUser?.role === 'pemangku';
|
|
|
|
// Untuk pemangku: paksa hanya verified
|
|
if (isPemangku && daftarState.status !== 'verified') {
|
|
daftarState.status = 'verified';
|
|
}
|
|
|
|
// Collect unique kecamatan values
|
|
const src = isIb ? cachedIbadah : cachedWarga;
|
|
const kecSet = [...new Set(src.map(d => d.kecamatan).filter(Boolean))].sort();
|
|
|
|
let filterHtml = '';
|
|
// Status filter pemangku tidak perlu filter status (hanya verified)
|
|
if (!isPemangku) {
|
|
filterHtml += `<div class="daftar-filter"><select id="df-status" onchange="daftarFilterChange()">
|
|
<option value="">Semua Status</option>
|
|
<option value="verified" ${daftarState.status === 'verified' ? 'selected' : ''}>Verified</option>
|
|
<option value="pending" ${daftarState.status === 'pending' ? 'selected' : ''}>Pending</option>
|
|
<option value="rejected" ${daftarState.status === 'rejected' ? 'selected' : ''}>Ditolak</option>
|
|
</select></div>`;
|
|
}
|
|
|
|
// Kecamatan filter
|
|
if (kecSet.length) {
|
|
filterHtml += `<div class="daftar-filter"><select id="df-kec" onchange="daftarFilterChange()">
|
|
<option value="">Semua Kecamatan</option>
|
|
${kecSet.map(k => `<option value="${h(k)}" ${daftarState.kecamatan === k ? 'selected' : ''}>${h(k)}</option>`).join('')}
|
|
</select></div>`;
|
|
}
|
|
|
|
// Jenis filter (ibadah only)
|
|
if (isIb) {
|
|
filterHtml += `<div class="daftar-filter"><select id="df-jenis" onchange="daftarFilterChange()">
|
|
<option value="">Semua Jenis</option>
|
|
${Object.keys(COLORS).map(j => `<option value="${j}" ${daftarState.jenis === j ? 'selected' : ''}>${j.charAt(0).toUpperCase() + j.slice(1)}</option>`).join('')}
|
|
</select></div>`;
|
|
}
|
|
|
|
// Layanan filter (warga only)
|
|
if (!isIb) {
|
|
filterHtml += `<div class="daftar-filter"><select id="df-layanan" onchange="daftarFilterChange()">
|
|
<option value="">Semua Layanan</option>
|
|
<option value="terlayani" ${daftarState.layanan === 'terlayani' ? 'selected' : ''}>Terlayani</option>
|
|
<option value="belum_terlayani" ${daftarState.layanan === 'belum_terlayani' ? 'selected' : ''}>Belum terlayani</option>
|
|
</select></div>`;
|
|
}
|
|
|
|
// Admin Surveyor filter dropdown
|
|
if (currentUser?.role === 'admin') {
|
|
const surveySet = [];
|
|
const seenIds = new Set();
|
|
[...cachedIbadah, ...cachedWarga].forEach(d => {
|
|
if (d.surveyor_id && !seenIds.has(d.surveyor_id)) {
|
|
seenIds.add(d.surveyor_id);
|
|
surveySet.push({ id: d.surveyor_id, name: d.surveyor_nama });
|
|
}
|
|
});
|
|
surveySet.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
|
|
|
filterHtml += `<div class="daftar-filter"><select id="df-surveyor" onchange="daftarFilterChange()">
|
|
<option value="">Semua Surveyor</option>
|
|
${surveySet.map(s => `<option value="${s.id}" ${daftarState.surveyorId == s.id ? 'selected' : ''}>${h(s.name || 'Unknown')}</option>`).join('')}
|
|
</select></div>`;
|
|
}
|
|
|
|
// Surveyor Custom Checkbox "Data Saya Saja"
|
|
if (currentUser?.role === 'surveyor') {
|
|
filterHtml += `
|
|
<div class="daftar-filter-checkbox" style="display:flex;align-items:center;margin-left:auto;gap:6px;font-size:12px;color:var(--text2);cursor:pointer;user-select:none">
|
|
<input type="checkbox" id="df-my-data" ${daftarState.myDataOnly ? 'checked' : ''} onchange="daftarFilterChange()" style="cursor:pointer;width:15px;height:15px;">
|
|
<label for="df-my-data" style="cursor:pointer;font-weight:600;display:flex;align-items:center;gap:4px">
|
|
<i class="fa-solid fa-user-pen" style="color:var(--blue)"></i> Data Saya Saja
|
|
</label>
|
|
</div>`;
|
|
}
|
|
|
|
const placeholder = isIb ? 'Cari nama tempat ibadah atau PIC...' : 'Cari nama kepala keluarga...';
|
|
tb.innerHTML = `
|
|
<div class="daftar-search">
|
|
<i class="fa-solid fa-magnifying-glass"></i>
|
|
<input type="text" id="df-search" placeholder="${placeholder}" value="${h(daftarState.search)}" oninput="daftarSearchChange(this.value)">
|
|
</div>
|
|
${filterHtml}`;
|
|
}
|
|
|
|
function daftarSearchChange(val) {
|
|
daftarState.search = val.toLowerCase();
|
|
daftarState.page = 1;
|
|
daftarState.activeId = null;
|
|
renderDaftarContent();
|
|
syncDaftarMap();
|
|
}
|
|
|
|
function daftarFilterChange() {
|
|
daftarState.status = currentUser?.role === 'pemangku'
|
|
? 'verified'
|
|
: (document.getElementById('df-status')?.value || '');
|
|
daftarState.kecamatan = document.getElementById('df-kec')?.value || '';
|
|
daftarState.jenis = document.getElementById('df-jenis')?.value || '';
|
|
daftarState.layanan = document.getElementById('df-layanan')?.value || '';
|
|
if (currentUser?.role === 'surveyor') {
|
|
daftarState.myDataOnly = document.getElementById('df-my-data')?.checked || false;
|
|
}
|
|
if (currentUser?.role === 'admin') {
|
|
daftarState.surveyorId = document.getElementById('df-surveyor')?.value || '';
|
|
}
|
|
daftarState.page = 1;
|
|
daftarState.activeId = null;
|
|
renderDaftarContent();
|
|
syncDaftarMap();
|
|
}
|
|
|
|
function getDaftarFiltered() {
|
|
const s = daftarState;
|
|
let data = s.tab === 'ibadah' ? [...cachedIbadah] : [...cachedWarga];
|
|
|
|
// Search filter
|
|
if (s.search) {
|
|
if (s.tab === 'ibadah') {
|
|
data = data.filter(d => (d.nama || '').toLowerCase().includes(s.search) || (d.pic || '').toLowerCase().includes(s.search));
|
|
} else {
|
|
data = data.filter(d => (d.nama_kk || '').toLowerCase().includes(s.search));
|
|
}
|
|
}
|
|
|
|
// Status filter
|
|
if (s.status) data = data.filter(d => d.status === s.status);
|
|
|
|
// Kecamatan filter
|
|
if (s.kecamatan) data = data.filter(d => d.kecamatan === s.kecamatan);
|
|
|
|
// Surveyor filter
|
|
if (currentUser?.role === 'surveyor' && s.myDataOnly) {
|
|
data = data.filter(d => d.surveyor_id == currentUser.id);
|
|
}
|
|
if (currentUser?.role === 'admin' && s.surveyorId) {
|
|
data = data.filter(d => d.surveyor_id == s.surveyorId);
|
|
}
|
|
|
|
// Jenis filter (ibadah)
|
|
if (s.tab === 'ibadah' && s.jenis) data = data.filter(d => d.jenis === s.jenis);
|
|
|
|
// Layanan filter (warga)
|
|
if (s.tab === 'warga' && s.layanan) {
|
|
if (s.layanan === 'terlayani') data = data.filter(d => !!d.ibadah_id);
|
|
else if (s.layanan === 'belum_terlayani' || s.layanan === 'diluar') data = data.filter(d => !d.ibadah_id);
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
function renderDaftarContent() {
|
|
const listEl = document.getElementById('daftar-list');
|
|
const sumEl = document.getElementById('daftar-summary');
|
|
const pgEl = document.getElementById('daftar-pagination');
|
|
if (!listEl) return;
|
|
|
|
const filtered = getDaftarFiltered();
|
|
const total = filtered.length;
|
|
const totalPages = Math.max(1, Math.ceil(total / daftarState.perPage));
|
|
if (daftarState.page > totalPages) daftarState.page = totalPages;
|
|
|
|
const start = (daftarState.page - 1) * daftarState.perPage;
|
|
const pageData = filtered.slice(start, start + daftarState.perPage);
|
|
|
|
// Summary
|
|
if (sumEl) {
|
|
const from = total ? start + 1 : 0;
|
|
const to = Math.min(start + daftarState.perPage, total);
|
|
const label = daftarState.tab === 'ibadah' ? 'rumah ibadah' : 'warga';
|
|
sumEl.innerHTML = `<span class="ds-text">Menampilkan <strong>${from}-${to}</strong> dari <strong>${total}</strong> ${label}</span>`;
|
|
}
|
|
|
|
// Cards
|
|
if (!pageData.length) {
|
|
const icon = daftarState.tab === 'ibadah' ? 'fa-solid fa-place-of-worship' : 'fa-solid fa-users';
|
|
listEl.innerHTML = `<div class="empty-state"><div class="e-icon"><i class="${icon}"></i></div><p>Tidak ada data ditemukan</p></div>`;
|
|
} else {
|
|
listEl.innerHTML = pageData.map(d => renderDaftarCard(d)).join('');
|
|
}
|
|
|
|
// Pagination
|
|
if (pgEl) {
|
|
if (totalPages <= 1) { pgEl.innerHTML = ''; return; }
|
|
let pgHtml = `<button ${daftarState.page <= 1 ? 'disabled' : ''} onclick="daftarGoPage(${daftarState.page - 1})"><i class="fa-solid fa-chevron-left" style="font-size:10px"></i></button>`;
|
|
|
|
// Smart page range
|
|
let pages = [];
|
|
if (totalPages <= 7) {
|
|
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
|
} else {
|
|
pages.push(1);
|
|
if (daftarState.page > 3) pages.push('...');
|
|
for (let i = Math.max(2, daftarState.page - 1); i <= Math.min(totalPages - 1, daftarState.page + 1); i++) pages.push(i);
|
|
if (daftarState.page < totalPages - 2) pages.push('...');
|
|
pages.push(totalPages);
|
|
}
|
|
|
|
pages.forEach(p => {
|
|
if (p === '...') { pgHtml += `<span class="pg-info">...</span>`; }
|
|
else { pgHtml += `<button class="${p === daftarState.page ? 'active' : ''}" onclick="daftarGoPage(${p})">${p}</button>`; }
|
|
});
|
|
|
|
pgHtml += `<button ${daftarState.page >= totalPages ? 'disabled' : ''} onclick="daftarGoPage(${daftarState.page + 1})"><i class="fa-solid fa-chevron-right" style="font-size:10px"></i></button>`;
|
|
pgEl.innerHTML = pgHtml;
|
|
}
|
|
}
|
|
|
|
function renderDaftarCard(d) {
|
|
const isIb = daftarState.tab === 'ibadah';
|
|
const id = d.id;
|
|
const isActive = daftarState.activeId === `${daftarState.tab}-${id}`;
|
|
|
|
if (isIb) {
|
|
const clr = COLORS[d.jenis] || '#6b7280';
|
|
return `<div class="daftar-card ${isActive ? 'active' : ''}" onclick="daftarCardClick('ibadah',${id},${d.lat},${d.lng})" id="dc-ibadah-${id}">
|
|
<div class="dc-icon" style="background:${clr}18;color:${clr}"><i class="${ICONS[d.jenis] || 'fa-solid fa-place-of-worship'}"></i></div>
|
|
<div class="dc-body">
|
|
<div class="dc-name">${h(d.nama)}</div>
|
|
<div class="dc-sub">
|
|
<span><i class="fa-solid fa-user-tie" style="margin-right:2px"></i>${h(d.pic || '-')}</span>
|
|
<span><i class="fa-solid fa-ruler" style="margin-right:2px"></i>${d.radius}m</span>
|
|
${d.kecamatan ? `<span><i class="fa-solid fa-location-dot" style="margin-right:2px"></i>${h(d.kecamatan)}</span>` : ''}
|
|
</div>
|
|
${d.status === 'rejected' && d.alasan_penolakan ? `<div class="dc-rejection"><i class="fa-solid fa-circle-xmark"></i><span><strong>Catatan admin:</strong> ${h(d.alasan_penolakan)}</span></div>` : ''}
|
|
</div>
|
|
<div class="dc-end" style="display:flex; flex-direction:column; align-items:flex-end; justify-content:space-between; height:100%; min-height:48px">
|
|
<div style="display:flex; gap:4px; align-items:center; justify-content:flex-end">
|
|
<span class="badge badge-${d.status}" style="margin:0">${statusLabel(d.status)}</span>
|
|
</div>
|
|
${daftarActionBtn('ibadah', d)}
|
|
</div>
|
|
</div>`;
|
|
} else {
|
|
const covered = !!d.ibadah_id;
|
|
return `<div class="daftar-card ${isActive ? 'active' : ''}" onclick="daftarCardClick('warga',${id},${d.lat},${d.lng})" id="dc-warga-${id}">
|
|
<div class="dc-icon" style="background:${covered ? 'var(--green-light)' : 'var(--border2)'};color:${covered ? 'var(--green)' : 'var(--text3)'}"><i class="fa-solid fa-user"></i></div>
|
|
<div class="dc-body">
|
|
<div class="dc-name">${h(d.nama_kk)}</div>
|
|
<div class="dc-sub">
|
|
<span><i class="fa-solid fa-users" style="margin-right:2px"></i>${d.jumlah_anggota} jiwa</span>
|
|
${d.kecamatan ? `<span><i class="fa-solid fa-location-dot" style="margin-right:2px"></i>${h(d.kecamatan)}</span>` : ''}
|
|
</div>
|
|
${d.status === 'rejected' && d.alasan_penolakan ? `<div class="dc-rejection"><i class="fa-solid fa-circle-xmark"></i><span><strong>Catatan admin:</strong> ${h(d.alasan_penolakan)}</span></div>` : ''}
|
|
</div>
|
|
<div class="dc-end" style="display:flex; flex-direction:column; align-items:flex-end; justify-content:space-between; height:100%; min-height:48px">
|
|
<div style="display:flex; gap:4px; align-items:center; flex-wrap:wrap; justify-content:flex-end">
|
|
<span class="badge badge-${d.status}" style="margin:0">${statusLabel(d.status)}</span>
|
|
${d.status === 'verified' ? `<span class="badge ${covered ? 'badge-in' : 'badge-out'}" style="font-size:9px; margin:0"><i class="${covered ? 'fa-solid fa-circle-check' : 'fa-solid fa-triangle-exclamation'}" style="margin-right:2px"></i>${covered ? 'Terlayani' : 'Di luar'}</span>` : ''}
|
|
</div>
|
|
${daftarActionBtn('warga', d)}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
}
|
|
|
|
function daftarCardClick(type, id, lat, lng) {
|
|
// Highlight active card
|
|
const prevActive = document.querySelector('.daftar-card.active');
|
|
if (prevActive) prevActive.classList.remove('active');
|
|
|
|
daftarState.activeId = `${type}-${id}`;
|
|
const card = document.getElementById(`dc-${type}-${id}`);
|
|
if (card) card.classList.add('active');
|
|
|
|
// Fly map to location
|
|
const dMap = mapInstances['map-daftar'];
|
|
if (dMap && lat && lng) {
|
|
dMap.flyTo([lat, lng], 17, { duration: 0.8 });
|
|
|
|
// Show highlight pulse on the map point
|
|
if (window._daftarHighlight) { try { dMap.removeLayer(window._daftarHighlight); } catch (e) { } }
|
|
window._daftarHighlight = L.circleMarker([lat, lng], {
|
|
radius: 18, color: '#3b82f6', fillColor: '#3b82f6', fillOpacity: 0.15, weight: 2.5, dashArray: '4 4'
|
|
}).addTo(dMap);
|
|
// Remove pulse after 3 seconds
|
|
setTimeout(() => { if (window._daftarHighlight) { try { dMap.removeLayer(window._daftarHighlight); } catch (e) { } window._daftarHighlight = null; } }, 3000);
|
|
}
|
|
}
|
|
|
|
function daftarGoPage(p) {
|
|
daftarState.page = p;
|
|
daftarState.activeId = null;
|
|
renderDaftarContent();
|
|
// Scroll list to top
|
|
const listEl = document.getElementById('daftar-list');
|
|
if (listEl) listEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
}
|
|
|
|
function initDaftarMap() {
|
|
const containerId = 'map-daftar';
|
|
if (mapInstances[containerId]) { mapInstances[containerId].remove(); }
|
|
const center = [cachedIbadah.length ? cachedIbadah[0].lat : -6.2, cachedIbadah.length ? cachedIbadah[0].lng : 106.8];
|
|
const m = L.map(containerId, { zoomControl: true, attributionControl: false, preferCanvas: true }).setView(center, 12);
|
|
|
|
const osm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 });
|
|
const light = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { maxZoom: 19 });
|
|
const dark = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { maxZoom: 19 });
|
|
light.addTo(m);
|
|
|
|
const baseMaps = {
|
|
"Default Street Map": osm,
|
|
"Clean Light Map": light,
|
|
"Premium Dark Map": dark
|
|
};
|
|
|
|
const layerIbadah = L.layerGroup();
|
|
const layerWarga = L.layerGroup();
|
|
|
|
m.layerGroups = { ibadah: layerIbadah, warga: layerWarga };
|
|
|
|
// State checklist: default sesuai tab aktif saat init
|
|
m._layerVisible = {
|
|
ibadah: daftarState.tab === 'ibadah',
|
|
warga: daftarState.tab === 'warga'
|
|
};
|
|
|
|
// Terapkan state awal ke peta
|
|
if (m._layerVisible.ibadah) layerIbadah.addTo(m);
|
|
if (m._layerVisible.warga) layerWarga.addTo(m);
|
|
|
|
const overlayMaps = { "Rumah Ibadah": layerIbadah, "Warga Miskin": layerWarga };
|
|
L.control.layers(baseMaps, overlayMaps, { collapsed: true }).addTo(m);
|
|
|
|
// Catat perubahan checklist dari user jangan pernah override ini di syncDaftarMap
|
|
m.on('overlayadd', e => {
|
|
if (e.layer === layerIbadah) m._layerVisible.ibadah = true;
|
|
if (e.layer === layerWarga) m._layerVisible.warga = true;
|
|
});
|
|
m.on('overlayremove', e => {
|
|
if (e.layer === layerIbadah) m._layerVisible.ibadah = false;
|
|
if (e.layer === layerWarga) m._layerVisible.warga = false;
|
|
});
|
|
|
|
mapInstances[containerId] = m;
|
|
syncDaftarMap();
|
|
}
|
|
|
|
function syncDaftarMap() {
|
|
const m = mapInstances['map-daftar'];
|
|
if (!m || !m.layerGroups) return;
|
|
|
|
const layerIbadah = m.layerGroups.ibadah;
|
|
const layerWarga = m.layerGroups.warga;
|
|
|
|
layerIbadah.clearLayers();
|
|
layerWarga.clearLayers();
|
|
if (window._daftarHighlight) { try { m.removeLayer(window._daftarHighlight); } catch (e) { } window._daftarHighlight = null; }
|
|
|
|
const filtered = getDaftarFiltered();
|
|
const bounds = [];
|
|
|
|
if (daftarState.tab === 'ibadah') {
|
|
filtered.forEach(d => {
|
|
if (!d.lat || !d.lng) return;
|
|
bounds.push([d.lat, d.lng]);
|
|
placeMarkerOn(m, d, 'ibadah');
|
|
});
|
|
cachedWarga.forEach(d => { placeMarkerOn(m, d, 'warga'); });
|
|
} else {
|
|
filtered.forEach(d => {
|
|
if (!d.lat || !d.lng) return;
|
|
bounds.push([d.lat, d.lng]);
|
|
placeMarkerOn(m, d, 'warga');
|
|
});
|
|
cachedIbadah.forEach(d => { placeMarkerOn(m, d, 'ibadah'); });
|
|
}
|
|
|
|
// Hormati state checklist user jangan hardcode show/hide
|
|
const vis = m._layerVisible || { ibadah: true, warga: false };
|
|
if (vis.ibadah) { if (!m.hasLayer(layerIbadah)) layerIbadah.addTo(m); }
|
|
else { if (m.hasLayer(layerIbadah)) m.removeLayer(layerIbadah); }
|
|
if (vis.warga) { if (!m.hasLayer(layerWarga)) layerWarga.addTo(m); }
|
|
else { if (m.hasLayer(layerWarga)) m.removeLayer(layerWarga); }
|
|
|
|
if (bounds.length) {
|
|
m.fitBounds(bounds, { padding: [30, 30], maxZoom: 15 });
|
|
}
|
|
}
|
|
|
|
// -- VERIFY --------------------------------
|
|
// -- VERIFY (Table layout) ------------------
|
|
let _verifyData = { ibadah: [], warga: [] };
|
|
let _verifyTab = 'ibadah'; // active tab: 'ibadah' | 'warga'
|
|
let _verifyPage = 1;
|
|
let _verifyPerPage = 10;
|
|
|
|
async function renderVerify(area) {
|
|
area.innerHTML = '<div class="empty-state"><div class="spin"></div><p style="margin-top:12px">Memuat data pending...</p></div>';
|
|
try {
|
|
const [ibRes, wRes] = await Promise.all([
|
|
fetch(API.verify + '?type=ibadah&status=pending', { credentials: 'include' }).then(r => r.json()),
|
|
fetch(API.verify + '?type=warga&status=pending', { credentials: 'include' }).then(r => r.json()),
|
|
]);
|
|
_verifyData.ibadah = ibRes.data || [];
|
|
_verifyData.warga = wRes.data || [];
|
|
// Set default tab ke yang ada datanya
|
|
if (!_verifyData.ibadah.length && _verifyData.warga.length) _verifyTab = 'warga';
|
|
else _verifyTab = 'ibadah';
|
|
|
|
_verifyPage = 1;
|
|
|
|
if (!_verifyData.ibadah.length && !_verifyData.warga.length) {
|
|
area.innerHTML = '<div class="empty-state"><div class="e-icon"><i class="fa-solid fa-circle-check" style="color:var(--green)"></i></div><p>Semua data sudah diverifikasi!<br>Tidak ada data pending.</p></div>';
|
|
return;
|
|
}
|
|
area.innerHTML = `
|
|
<div class="verify-tabs" id="vt-tabs"></div>
|
|
<div class="verify-toolbar">
|
|
<div class="vt-search">
|
|
<span class="vt-search-icon"><i class="fa-solid fa-magnifying-glass"></i></span>
|
|
<input type="text" id="vt-search-input" placeholder="Cari nama, surveyor, kecamatan..." oninput="renderVerifyTables()">
|
|
</div>
|
|
<select id="vt-sort" onchange="renderVerifyTables()">
|
|
<option value="newest">Terbaru</option>
|
|
<option value="oldest">Terlama</option>
|
|
</select>
|
|
<span class="verify-count-badge" id="vt-count"></span>
|
|
</div>
|
|
<div id="vt-table-section"></div>
|
|
<div class="daftar-pagination" id="vt-pagination"></div>`;
|
|
renderVerifyTabs();
|
|
renderVerifyTables();
|
|
} catch (e) { area.innerHTML = '<div class="empty-state"><p>Gagal memuat data. Coba refresh.</p></div>'; }
|
|
}
|
|
|
|
function switchVerifyTab(tab) {
|
|
_verifyTab = tab;
|
|
_verifyPage = 1;
|
|
renderVerifyTabs();
|
|
renderVerifyTables();
|
|
}
|
|
|
|
function renderVerifyTabs() {
|
|
const tabsEl = document.getElementById('vt-tabs');
|
|
if (!tabsEl) return;
|
|
const nIb = _verifyData.ibadah.length;
|
|
const nW = _verifyData.warga.length;
|
|
tabsEl.innerHTML = `
|
|
<button class="verify-tab tab-ibadah ${_verifyTab === 'ibadah' ? 'active' : ''}" onclick="switchVerifyTab('ibadah')">
|
|
<i class="fa-solid fa-place-of-worship"></i> Rumah Ibadah
|
|
<span class="vtab-count">${nIb}</span>
|
|
</button>
|
|
<button class="verify-tab ${_verifyTab === 'warga' ? 'active' : ''}" onclick="switchVerifyTab('warga')">
|
|
<i class="fa-solid fa-user-tag"></i> Warga Miskin
|
|
<span class="vtab-count">${nW}</span>
|
|
</button>`;
|
|
}
|
|
|
|
function renderVerifyTables() {
|
|
const q = (document.getElementById('vt-search-input')?.value || '').toLowerCase();
|
|
const sort = document.getElementById('vt-sort')?.value || 'newest';
|
|
const sorter = (a, b) => sort === 'newest'
|
|
? new Date(b.created_at) - new Date(a.created_at)
|
|
: new Date(a.created_at) - new Date(b.created_at);
|
|
|
|
const sec = document.getElementById('vt-table-section');
|
|
const pgEl = document.getElementById('vt-pagination');
|
|
if (!sec) return;
|
|
|
|
let filtered = [];
|
|
const isIb = (_verifyTab === 'ibadah');
|
|
|
|
if (isIb) {
|
|
const matchIb = ib => !q || [ib.nama, ib.surveyor_nama, ib.kecamatan, ib.pic, ib.alamat, ib.jenis]
|
|
.some(v => (v || '').toLowerCase().includes(q));
|
|
filtered = [..._verifyData.ibadah].filter(matchIb).sort(sorter);
|
|
} else {
|
|
const matchW = w => !q || [w.nama_kk, w.surveyor_nama, w.kecamatan, w.nik, w.keterangan, w.alamat]
|
|
.some(v => (v || '').toLowerCase().includes(q));
|
|
filtered = [..._verifyData.warga].filter(matchW).sort(sorter);
|
|
}
|
|
|
|
const countEl = document.getElementById('vt-count');
|
|
if (countEl) countEl.textContent = filtered.length + ' pending';
|
|
|
|
if (!filtered.length) {
|
|
sec.innerHTML = '<div class="empty-state" style="padding:40px 0"><p>Tidak ada data yang cocok dengan pencarian.</p></div>';
|
|
if (pgEl) pgEl.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
// Pagination calculation
|
|
const total = filtered.length;
|
|
const totalPages = Math.max(1, Math.ceil(total / _verifyPerPage));
|
|
if (_verifyPage > totalPages) _verifyPage = totalPages;
|
|
if (_verifyPage < 1) _verifyPage = 1;
|
|
|
|
const start = (_verifyPage - 1) * _verifyPerPage;
|
|
const pageData = filtered.slice(start, start + _verifyPerPage);
|
|
|
|
if (isIb) {
|
|
sec.innerHTML = `
|
|
<div class="verify-table-wrap verify-ibadah-table">
|
|
<table>
|
|
<thead><tr>
|
|
<th style="width:52px">Foto</th>
|
|
<th>Nama Tempat Ibadah</th>
|
|
<th>Jenis</th>
|
|
<th>Kecamatan</th>
|
|
<th>PIC & WA</th>
|
|
<th>Radius</th>
|
|
<th>Surveyor</th>
|
|
<th>Tanggal</th>
|
|
<th style="text-align:right">Aksi</th>
|
|
</tr></thead>
|
|
<tbody>
|
|
${pageData.map(ib => `
|
|
<tr>
|
|
<td>
|
|
${ib.foto_path
|
|
? `<img class="vt-thumb" src="${ib.foto_path}" onclick="window.open('${ib.foto_path}','_blank')" title="Lihat foto">`
|
|
: `<div class="vt-no-thumb"><i class="${ICONS[ib.jenis]}"></i></div>`}
|
|
</td>
|
|
<td>
|
|
<div class="vt-name"><i class="${ICONS[ib.jenis]}" style="margin-right:5px;color:var(--amber)"></i>${h(ib.nama)}</div>
|
|
${ib.alamat ? `<div class="vt-sub">${h(ib.alamat.substring(0, 45))}${ib.alamat.length > 45 ? '...' : ''}</div>` : ''}
|
|
</td>
|
|
<td><span class="vt-tag amber"><i class="${ICONS[ib.jenis]}"></i> ${h(ib.jenis)}</span></td>
|
|
<td><div class="vt-sub">${ib.kecamatan ? 'Kec. ' + h(ib.kecamatan) : ' '}</div></td>
|
|
<td>
|
|
<div class="vt-sub" style="font-weight:700;color:var(--text)">${h(ib.pic || ' ')}</div>
|
|
${ib.no_wa ? `<div class="vt-sub" style="font-size:11px;color:var(--text2)"><i class="fa-brands fa-whatsapp" style="color:var(--green)"></i> ${h(ib.no_wa)}</div>` : ''}
|
|
</td>
|
|
<td><div class="vt-sub" style="white-space:nowrap">${ib.radius} m</div></td>
|
|
<td><div class="vt-sub" style="font-size:12px;color:var(--text)">${h(ib.surveyor_nama || ' ')}</div></td>
|
|
<td><div class="vt-sub" style="white-space:nowrap">${formatDate(ib.created_at)}</div></td>
|
|
<td>
|
|
<div class="vt-actions" style="justify-content:flex-end">
|
|
<button class="btn-detail" onclick="openDetailModal('ibadah',${ib.id})"><i class="fa-solid fa-eye"></i> Detail</button>
|
|
<button class="btn-approve" onclick="approveData('ibadah',${ib.id})"><i class="fa-solid fa-check"></i></button>
|
|
<button class="btn-reject" onclick="openRejectModal('ibadah',${ib.id})"><i class="fa-solid fa-xmark"></i></button>
|
|
</div>
|
|
</td>
|
|
</tr>`).join('')}
|
|
</tbody>
|
|
</table>
|
|
</div>`;
|
|
} else {
|
|
sec.innerHTML = `
|
|
<div class="verify-table-wrap verify-warga-table">
|
|
<table>
|
|
<thead><tr>
|
|
<th style="width:52px">Foto</th>
|
|
<th>Nama Kepala Keluarga</th>
|
|
<th>NIK KK</th>
|
|
<th>Kecamatan</th>
|
|
<th>Jml Anggota</th>
|
|
<th>Ibadah Terdekat</th>
|
|
<th>Surveyor</th>
|
|
<th>Tanggal</th>
|
|
<th style="text-align:right">Aksi</th>
|
|
</tr></thead>
|
|
<tbody>
|
|
${pageData.map(w => `
|
|
<tr>
|
|
<td>
|
|
${w.foto_path
|
|
? `<img class="vt-thumb" src="${w.foto_path}" onclick="window.open('${w.foto_path}','_blank')" title="Lihat foto">`
|
|
: `<div class="vt-no-thumb"><i class="fa-solid fa-user"></i></div>`}
|
|
</td>
|
|
<td>
|
|
<div class="vt-name"><i class="fa-solid fa-user" style="margin-right:5px;color:var(--blue)"></i>${h(w.nama_kk)}</div>
|
|
${w.alamat ? `<div class="vt-sub">${h(w.alamat.substring(0, 45))}${w.alamat.length > 45 ? '...' : ''}</div>` : ''}
|
|
${w.keterangan ? `<div class="vt-sub" style="color:var(--text3);font-style:italic">${h(w.keterangan.substring(0, 40))}${w.keterangan.length > 40 ? '...' : ''}</div>` : ''}
|
|
</td>
|
|
<td>
|
|
<div class="vt-col-nik">${h(w.nik || ' ')}</div>
|
|
</td>
|
|
<td><div class="vt-sub">${w.kecamatan ? 'Kec. ' + h(w.kecamatan) : ' '}</div></td>
|
|
<td style="text-align:center">
|
|
<div class="vt-col-jiwa">${w.jumlah_anggota}</div>
|
|
<div class="vt-col-jiwa-sub">jiwa</div>
|
|
</td>
|
|
<td>
|
|
${w.ibadah_id
|
|
? `<span class="vt-tag"><i class="fa-solid fa-circle-check"></i> ${h(w.ibadah_nama || 'Terlayani')}</span>`
|
|
: `<span class="vt-tag red"><i class="fa-solid fa-triangle-exclamation"></i> Belum terlayani</span>`}
|
|
</td>
|
|
<td><div class="vt-sub" style="font-size:12px;color:var(--text)">${h(w.surveyor_nama || ' ')}</div></td>
|
|
<td><div class="vt-sub" style="white-space:nowrap">${formatDate(w.created_at)}</div></td>
|
|
<td>
|
|
<div class="vt-actions" style="justify-content:flex-end">
|
|
<button class="btn-detail" onclick="openDetailModal('warga',${w.id})"><i class="fa-solid fa-eye"></i> Detail</button>
|
|
<button class="btn-approve" onclick="approveData('warga',${w.id})"><i class="fa-solid fa-check"></i></button>
|
|
<button class="btn-reject" onclick="openRejectModal('warga',${w.id})"><i class="fa-solid fa-xmark"></i></button>
|
|
</div>
|
|
</td>
|
|
</tr>`).join('')}
|
|
</tbody>
|
|
</table>
|
|
</div>`;
|
|
}
|
|
|
|
// Render Pagination Buttons
|
|
if (pgEl) {
|
|
if (totalPages <= 1) { pgEl.innerHTML = ''; return; }
|
|
let pgHtml = `<button ${_verifyPage <= 1 ? 'disabled' : ''} onclick="verifyGoPage(${_verifyPage - 1})"><i class="fa-solid fa-chevron-left" style="font-size:10px"></i></button>`;
|
|
|
|
let pages = [];
|
|
if (totalPages <= 7) {
|
|
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
|
} else {
|
|
pages.push(1);
|
|
if (_verifyPage > 3) pages.push('...');
|
|
for (let i = Math.max(2, _verifyPage - 1); i <= Math.min(totalPages - 1, _verifyPage + 1); i++) pages.push(i);
|
|
if (_verifyPage < totalPages - 2) pages.push('...');
|
|
pages.push(totalPages);
|
|
}
|
|
|
|
pages.forEach(p => {
|
|
if (p === '...') { pgHtml += `<span class="pg-info">...</span>`; }
|
|
else { pgHtml += `<button class="${p === _verifyPage ? 'active' : ''}" onclick="verifyGoPage(${p})">${p}</button>`; }
|
|
});
|
|
|
|
pgHtml += `<button ${_verifyPage >= totalPages ? 'disabled' : ''} onclick="verifyGoPage(${_verifyPage + 1})"><i class="fa-solid fa-chevron-right" style="font-size:10px"></i></button>`;
|
|
pgEl.innerHTML = pgHtml;
|
|
}
|
|
}
|
|
|
|
function verifyGoPage(p) {
|
|
_verifyPage = p;
|
|
renderVerifyTables();
|
|
const sec = document.getElementById('vt-tabs');
|
|
if (sec) sec.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
}
|
|
|
|
function openDetailModal(type, id) {
|
|
const data = type === 'ibadah'
|
|
? _verifyData.ibadah.find(x => x.id == id)
|
|
: _verifyData.warga.find(x => x.id == id);
|
|
if (!data) return;
|
|
const titleEl = document.getElementById('dm-title');
|
|
const bodyEl = document.getElementById('dm-body');
|
|
const footEl = document.getElementById('dm-footer');
|
|
if (type === 'ibadah') {
|
|
titleEl.innerHTML = `<i class="${ICONS[data.jenis]}" style="color:var(--amber)"></i> Detail Rumah Ibadah`;
|
|
bodyEl.innerHTML = `
|
|
${data.foto_path ? `<img class="dm-photo" src="${data.foto_path}" onclick="window.open('${data.foto_path}','_blank')" title="Klik untuk buka penuh">` : ''}
|
|
<div class="dm-section">
|
|
<div class="dm-section-title">Informasi Umum</div>
|
|
<div class="dm-fields">
|
|
<div class="dm-field full"><label>Nama Tempat Ibadah</label><span>${h(data.nama)}</span></div>
|
|
<div class="dm-field"><label>Jenis</label><span><i class="${ICONS[data.jenis]}" style="margin-right:5px"></i>${h(data.jenis)}</span></div>
|
|
<div class="dm-field"><label>Status</label><span><span class="badge badge-pending">Pending</span></span></div>
|
|
<div class="dm-field full"><label>Alamat</label><span>${h(data.alamat || ' ')}</span></div>
|
|
<div class="dm-field">${data.kecamatan ? `<label>Kecamatan</label><span>Kec. ${h(data.kecamatan)}</span>` : '<label>Kecamatan</label><span> </span>'}</div>
|
|
<div class="dm-field"><label>Radius Pelayanan</label><span>${data.radius} meter</span></div>
|
|
</div>
|
|
</div>
|
|
<div class="dm-section">
|
|
<div class="dm-section-title">Penanggung Jawab</div>
|
|
<div class="dm-fields">
|
|
<div class="dm-field"><label>PIC</label><span>${h(data.pic)}</span></div>
|
|
<div class="dm-field"><label>No. WhatsApp</label><span>${data.no_wa ? `<i class="fa-brands fa-whatsapp" style="color:var(--green);margin-right:4px"></i>${h(data.no_wa)}` : ' '}</span></div>
|
|
</div>
|
|
</div>
|
|
<div class="dm-section">
|
|
<div class="dm-section-title">Koordinat & Surveyor</div>
|
|
<div class="dm-fields">
|
|
<div class="dm-field"><label>Latitude</label><span>${(+data.lat).toFixed(6)}</span></div>
|
|
<div class="dm-field"><label>Longitude</label><span>${(+data.lng).toFixed(6)}</span></div>
|
|
<div class="dm-field"><label>Surveyor</label><span>${h(data.surveyor_nama || ' ')}</span></div>
|
|
<div class="dm-field"><label>Tanggal Input</label><span>${formatDate(data.created_at)}</span></div>
|
|
</div>
|
|
</div>`;
|
|
} else {
|
|
titleEl.innerHTML = `<i class="fa-solid fa-user" style="color:var(--blue)"></i> Detail Warga Miskin`;
|
|
bodyEl.innerHTML = `
|
|
${data.foto_path ? `<img class="dm-photo" src="${data.foto_path}" onclick="window.open('${data.foto_path}','_blank')" title="Klik untuk buka penuh">` : ''}
|
|
<div class="dm-section">
|
|
<div class="dm-section-title">Data KK</div>
|
|
<div class="dm-fields">
|
|
<div class="dm-field full"><label>Nama Kepala Keluarga</label><span>${h(data.nama_kk)}</span></div>
|
|
<div class="dm-field"><label>NIK KK</label><span>${h(data.nik || ' ')}</span></div>
|
|
<div class="dm-field"><label>Jumlah Anggota</label><span>${data.jumlah_anggota} jiwa</span></div>
|
|
<div class="dm-field full"><label>Alamat</label><span>${h(data.alamat || ' ')}</span></div>
|
|
<div class="dm-field"><label>Kecamatan</label><span>${data.kecamatan ? 'Kec. ' + h(data.kecamatan) : ' '}</span></div>
|
|
<div class="dm-field"><label>Status</label><span><span class="badge badge-pending">Pending</span></span></div>
|
|
${data.keterangan ? `<div class="dm-field full"><label>Keterangan</label><span>${h(data.keterangan)}</span></div>` : ''}
|
|
</div>
|
|
</div>
|
|
<div class="dm-section">
|
|
<div class="dm-section-title">Status Pelayanan</div>
|
|
<div class="dm-fields">
|
|
<div class="dm-field full">
|
|
<label>Ibadah Terdekat</label>
|
|
<span style="color:${data.ibadah_id ? 'var(--green)' : 'var(--red)'}">
|
|
<i class="${data.ibadah_id ? 'fa-solid fa-circle-check' : 'fa-solid fa-triangle-exclamation'}" style="margin-right:5px"></i>
|
|
${h(data.ibadah_nama || 'Belum terlayani')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="dm-section">
|
|
<div class="dm-section-title">Koordinat & Surveyor</div>
|
|
<div class="dm-fields">
|
|
<div class="dm-field"><label>Latitude</label><span>${(+data.lat).toFixed(6)}</span></div>
|
|
<div class="dm-field"><label>Longitude</label><span>${(+data.lng).toFixed(6)}</span></div>
|
|
<div class="dm-field"><label>Surveyor</label><span>${h(data.surveyor_nama || ' ')}</span></div>
|
|
<div class="dm-field"><label>Tanggal Input</label><span>${formatDate(data.created_at)}</span></div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
footEl.innerHTML = `
|
|
<button onclick="closeDetailModal();openEditPanel('${type}',${data.id})" style="background:var(--bg);color:var(--text2);border:1.5px solid var(--border)">
|
|
<i class="fa-solid fa-pen"></i> Edit
|
|
</button>
|
|
<button class="btn-reject" onclick="closeDetailModal();openRejectModal('${type}',${data.id})" style="padding:9px 18px">
|
|
<i class="fa-solid fa-xmark"></i> Tolak
|
|
</button>
|
|
<button class="btn-approve" onclick="closeDetailModal();approveData('${type}',${data.id})" style="padding:9px 18px">
|
|
<i class="fa-solid fa-check"></i> Setujui
|
|
</button>`;
|
|
document.getElementById('detail-modal').classList.add('show');
|
|
}
|
|
|
|
function closeDetailModal() {
|
|
document.getElementById('detail-modal').classList.remove('show');
|
|
}
|
|
|
|
|
|
async function approveData(type, id) {
|
|
try {
|
|
const res = await fetch(API.verify + `?type=${type}&id=${id}`, {
|
|
method: 'PUT', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
|
body: JSON.stringify({ status: 'verified' })
|
|
}).then(r => r.json());
|
|
if (!res.success) throw new Error(res.error);
|
|
showToast(' Data berhasil diverifikasi!', 'success');
|
|
const savedTab = _verifyTab;
|
|
await loadAll(); await renderVerify(document.getElementById('page-content'));
|
|
_verifyTab = savedTab; renderVerifyTabs(); renderVerifyTables();
|
|
} catch (e) { showToast('Error: ' + e.message, 'error'); }
|
|
}
|
|
function openRejectModal(type, id) {
|
|
pendingReject = { type, id };
|
|
document.getElementById('reject-reason').value = '';
|
|
document.getElementById('reject-modal').classList.add('show');
|
|
}
|
|
function closeRejectModal() {
|
|
pendingReject = null;
|
|
document.getElementById('reject-modal').classList.remove('show');
|
|
}
|
|
async function confirmReject() {
|
|
if (!pendingReject) return;
|
|
const reason = document.getElementById('reject-reason').value.trim();
|
|
if (!reason) { showToast('Berikan alasan penolakan', 'error'); return; }
|
|
try {
|
|
const res = await fetch(API.verify + `?type=${pendingReject.type}&id=${pendingReject.id}`, {
|
|
method: 'PUT', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
|
body: JSON.stringify({ status: 'rejected', alasan_penolakan: reason })
|
|
}).then(r => r.json());
|
|
if (!res.success) throw new Error(res.error);
|
|
showToast('Data ditolak', 'info');
|
|
const savedTab2 = _verifyTab;
|
|
closeRejectModal(); await loadAll(); await renderVerify(document.getElementById('page-content'));
|
|
_verifyTab = savedTab2; renderVerifyTabs(); renderVerifyTables();
|
|
} catch (e) { showToast('Error: ' + e.message, 'error'); }
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// -- ANALISIS (Pemangku) -------------------
|
|
// -- ANALISIS DASHBOARD (Pemangku & Admin) --
|
|
function renderDashboardAnalisis(area, tabsHtml, s, pct) {
|
|
const jiwaLayanPct = s.jiwa_total > 0 ? Math.round((s.jiwa_terlayani / s.jiwa_total) * 100) : 0;
|
|
const ratioIbadahPerKK = s.ibadah > 0 && s.kk_total > 0 ? (s.kk_total / s.ibadah).toFixed(1) : '-';
|
|
|
|
// -- Data per kecamatan --------------------
|
|
const KECAMATAN_LIST = ['Pontianak Utara', 'Pontianak Timur', 'Pontianak Selatan', 'Pontianak Barat', 'Pontianak Kota', 'Pontianak Tenggara'];
|
|
const ibadahV = cachedIbadah.filter(i => i.status === 'verified');
|
|
const wargaV = cachedWarga.filter(w => w.status === 'verified');
|
|
|
|
const byKec = {};
|
|
KECAMATAN_LIST.forEach(k => { byKec[k] = { ibadah: 0, kk: 0, kkTerlayani: 0, jiwa: 0, jiwaTerlayani: 0 }; });
|
|
|
|
ibadahV.forEach(ib => {
|
|
const k = ib.kecamatan;
|
|
if (k && byKec[k]) byKec[k].ibadah++;
|
|
});
|
|
wargaV.forEach(w => {
|
|
const k = w.kecamatan;
|
|
if (k && byKec[k]) {
|
|
byKec[k].kk++;
|
|
byKec[k].jiwa += w.jumlah_anggota || 0;
|
|
if (w.ibadah_id) { byKec[k].kkTerlayani++; byKec[k].jiwaTerlayani += w.jumlah_anggota || 0; }
|
|
}
|
|
});
|
|
|
|
// Kecamatan tanpa data kecamatan tersebut dikumpulkan
|
|
const lainKK = wargaV.filter(w => !w.kecamatan || !byKec[w.kecamatan]).length;
|
|
const lainIb = ibadahV.filter(ib => !ib.kecamatan || !byKec[ib.kecamatan]).length;
|
|
|
|
// Sorted by coverage asc (prioritas intervensi paling atas)
|
|
const kecRows = KECAMATAN_LIST
|
|
.map(k => ({ nama: k, ...byKec[k], pct: byKec[k].kk > 0 ? Math.round((byKec[k].kkTerlayani / byKec[k].kk) * 100) : null }))
|
|
.filter(r => r.kk > 0 || r.ibadah > 0)
|
|
.sort((a, b) => {
|
|
if (a.pct === null && b.pct === null) return 0;
|
|
if (a.pct === null) return 1;
|
|
if (b.pct === null) return -1;
|
|
return a.pct - b.pct;
|
|
});
|
|
|
|
const statusLabel = (pct) => {
|
|
if (pct === null) return { label: 'Tidak ada data warga', color: '#9ca3af', bg: '#f3f4f6' };
|
|
if (pct < 40) return { label: 'Kritis', color: '#dc2626', bg: '#fef2f2' };
|
|
if (pct < 70) return { label: 'Perlu perhatian', color: '#d97706', bg: '#fffbeb' };
|
|
if (pct < 90) return { label: 'Cukup baik', color: '#0284c7', bg: '#f0f9ff' };
|
|
return { label: 'Sangat baik', color: '#16a34a', bg: '#f0fdf4' };
|
|
};
|
|
|
|
// Distribusi jenis ibadah
|
|
const byJenis = {};
|
|
ibadahV.forEach(ib => { byJenis[ib.jenis] = (byJenis[ib.jenis] || 0) + 1; });
|
|
|
|
// Kecamatan prioritas (kritis atau belum terlayani)
|
|
const prioritas = kecRows.filter(r => r.pct !== null && r.pct < 70).slice(0, 3);
|
|
|
|
// Bar chart max
|
|
const maxKK = Math.max(...kecRows.map(r => r.kk), 1);
|
|
|
|
// Bantuan statistics
|
|
const sembakoCount = s.bantuan_sembako || 0;
|
|
const beasiswaCount = s.bantuan_beasiswa || 0;
|
|
const modalCount = s.bantuan_modal || 0;
|
|
const tunaiCount = s.bantuan_tunai || 0;
|
|
const tidakAdaCount = s.bantuan_tidak_ada || 0;
|
|
const totalB = sembakoCount + beasiswaCount + modalCount + tunaiCount + tidakAdaCount || 1;
|
|
|
|
const pctSembako = Math.round((sembakoCount / totalB) * 100);
|
|
const pctBeasiswa = Math.round((beasiswaCount / totalB) * 100);
|
|
const pctModal = Math.round((modalCount / totalB) * 100);
|
|
const pctTunai = Math.round((tunaiCount / totalB) * 100);
|
|
const pctTidakAda = Math.round((tidakAdaCount / totalB) * 100);
|
|
|
|
area.innerHTML = `
|
|
${tabsHtml}
|
|
|
|
<!-- KPI Cards -->
|
|
<div class="stat-grid" style="grid-template-columns:repeat(auto-fit,minmax(150px,1fr));margin-bottom:20px">
|
|
<div class="stat-card">
|
|
<div class="s-header"><div class="s-label">Coverage Rate</div><div class="s-icon icon-green"><i class="fa-solid fa-percent"></i></div></div>
|
|
<div class="s-value" style="color:${pct >= 70 ? 'var(--green)' : pct >= 40 ? 'var(--amber)' : 'var(--red)'}">${pct}%</div>
|
|
<div class="s-sub">KK terlayani dari total</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="s-header"><div class="s-label">KK Belum Terlayani</div><div class="s-icon icon-red"><i class="fa-solid fa-triangle-exclamation"></i></div></div>
|
|
<div class="s-value" style="color:var(--red)">${s.kk_belum || 0}</div>
|
|
<div class="s-sub">Perlu intervensi segera</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="s-header"><div class="s-label">Jiwa Terlayani</div><div class="s-icon icon-blue"><i class="fa-solid fa-users"></i></div></div>
|
|
<div class="s-value">${s.jiwa_terlayani || 0}</div>
|
|
<div class="s-sub">${jiwaLayanPct}% dari ${s.jiwa_total || 0} jiwa</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="s-header"><div class="s-label">Rasio KK/Ibadah</div><div class="s-icon icon-amber"><i class="fa-solid fa-scale-balanced"></i></div></div>
|
|
<div class="s-value">${ratioIbadahPerKK}</div>
|
|
<div class="s-sub">KK miskin per rumah ibadah</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tabel per kecamatan -->
|
|
<div class="card" style="margin-bottom:20px">
|
|
<div class="card-header"><h3>Sebaran per Kecamatan</h3><span style="font-size:11px;color:var(--text2)">Diurutkan dari yang paling perlu perhatian</span></div>
|
|
<div class="card-body" style="padding:0">
|
|
<div style="overflow-x:auto">
|
|
<table style="width:100%;border-collapse:collapse;font-size:12px">
|
|
<thead>
|
|
<tr style="border-bottom:1px solid var(--border2)">
|
|
<th style="text-align:left;padding:10px 14px;font-weight:600;color:var(--text2);font-size:11px;text-transform:uppercase;letter-spacing:.04em">Kecamatan</th>
|
|
<th style="text-align:right;padding:10px 10px;font-weight:600;color:var(--text2);font-size:11px;text-transform:uppercase;letter-spacing:.04em">KK Miskin</th>
|
|
<th style="text-align:right;padding:10px 10px;font-weight:600;color:var(--text2);font-size:11px;text-transform:uppercase;letter-spacing:.04em">Terlayani</th>
|
|
<th style="text-align:right;padding:10px 10px;font-weight:600;color:var(--text2);font-size:11px;text-transform:uppercase;letter-spacing:.04em">Jiwa</th>
|
|
<th style="text-align:right;padding:10px 10px;font-weight:600;color:var(--text2);font-size:11px;text-transform:uppercase;letter-spacing:.04em">Ibadah</th>
|
|
<th style="text-align:left;padding:10px 14px;font-weight:600;color:var(--text2);font-size:11px;text-transform:uppercase;letter-spacing:.04em">Coverage</th>
|
|
<th style="text-align:left;padding:10px 14px;font-weight:600;color:var(--text2);font-size:11px;text-transform:uppercase;letter-spacing:.04em">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${kecRows.length === 0 ? `<tr><td colspan="7" style="text-align:center;padding:24px;color:var(--text2);font-size:12px">Belum ada data kecamatan. Pastikan surveyor mengisi kolom kecamatan saat input data.</td></tr>` :
|
|
kecRows.map((r, i) => {
|
|
const st = statusLabel(r.pct);
|
|
return `<tr style="border-bottom:1px solid var(--border2);${i % 2 === 1 ? 'background:var(--bg2)' : ''}">
|
|
<td style="padding:10px 14px;font-weight:500;color:var(--text1)">${r.nama}</td>
|
|
<td style="padding:10px;text-align:right;color:var(--text1)">${r.kk}</td>
|
|
<td style="padding:10px;text-align:right;color:var(--text1)">${r.kkTerlayani}</td>
|
|
<td style="padding:10px;text-align:right;color:var(--text2);font-size:11px">${r.jiwa}</td>
|
|
<td style="padding:10px;text-align:right;color:var(--text2)">${r.ibadah}</td>
|
|
<td style="padding:10px 14px">
|
|
<div style="display:flex;align-items:center;gap:8px">
|
|
<div style="flex:1;height:6px;background:var(--border2);border-radius:3px;overflow:hidden;min-width:60px">
|
|
<div style="height:100%;width:${r.pct ?? 0}%;background:${r.pct >= 70 ? '#10b981' : r.pct >= 40 ? '#f59e0b' : '#ef4444'};border-radius:3px;transition:width .5s"></div>
|
|
</div>
|
|
<span style="font-size:12px;font-weight:500;min-width:32px;color:var(--text1)">${r.pct !== null ? r.pct + '%' : '—'}</span>
|
|
</div>
|
|
</td>
|
|
<td style="padding:10px 14px">
|
|
<span style="font-size:11px;font-weight:500;padding:2px 8px;border-radius:99px;background:${st.bg};color:${st.color};white-space:nowrap">${st.label}</span>
|
|
</td>
|
|
</tr>`;
|
|
}).join('')
|
|
}
|
|
${lainKK > 0 || lainIb > 0 ? `<tr style="border-top:1px solid var(--border2);background:var(--bg2)">
|
|
<td style="padding:8px 14px;font-size:11px;color:var(--text2);font-style:italic">Tanpa kecamatan / lainnya</td>
|
|
<td style="padding:8px 10px;text-align:right;font-size:11px;color:var(--text2)">${lainKK}</td>
|
|
<td colspan="2" style="padding:8px 10px;font-size:11px;color:var(--text2)">—</td>
|
|
<td style="padding:8px 10px;text-align:right;font-size:11px;color:var(--text2)">${lainIb}</td>
|
|
<td colspan="2" style="padding:8px 14px;font-size:11px;color:var(--text2)">Data kecamatan belum diisi</td>
|
|
</tr>` : ''}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tiga Grafik Analisis Wilayah & Program -->
|
|
<div class="content-grid" style="grid-template-columns:repeat(auto-fit,minmax(300px,1fr));margin-bottom:20px">
|
|
|
|
<!-- Bar chart coverage per kecamatan -->
|
|
<div class="card">
|
|
<div class="card-header"><h3>Perbandingan KK per Kecamatan</h3></div>
|
|
<div class="card-body">
|
|
${kecRows.length === 0
|
|
? `<p style="color:var(--text2);font-size:12px;text-align:center;padding:24px 0">Belum ada data kecamatan</p>`
|
|
: kecRows.map(r => {
|
|
const pctBar = Math.round((r.kk / maxKK) * 100);
|
|
const pctLayan = r.kk > 0 ? Math.round((r.kkTerlayani / r.kk) * 100) : 0;
|
|
const belum = r.kk - r.kkTerlayani;
|
|
return `<div style="margin-bottom:14px">
|
|
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:4px">
|
|
<span style="font-size:12px;font-weight:500;color:var(--text1)">${r.nama.replace('Pontianak ', 'Ptk. ')}</span>
|
|
<span style="font-size:11px;color:var(--text2)">${r.kkTerlayani} terlayani · <span style="color:${belum > 0 ? 'var(--red)' : 'var(--green)'}">${belum} belum</span></span>
|
|
</div>
|
|
<div style="height:10px;background:var(--border2);border-radius:5px;overflow:hidden">
|
|
<div style="display:flex;height:100%;width:${pctBar}%">
|
|
<div style="height:100%;width:${pctLayan}%;background:#10b981;border-radius:5px 0 0 5px"></div>
|
|
<div style="height:100%;flex:1;background:#f87171"></div>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}).join('')
|
|
}
|
|
<div style="display:flex;gap:16px;margin-top:12px;padding-top:10px;border-top:1px solid var(--border2)">
|
|
<div style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--text2)"><div style="width:10px;height:10px;background:#10b981;border-radius:2px"></div>Terlayani</div>
|
|
<div style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--text2)"><div style="width:10px;height:10px;background:#f87171;border-radius:2px"></div>Belum terlayani</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Distribusi jenis ibadah -->
|
|
<div class="card">
|
|
<div class="card-header"><h3>Distribusi Rumah Ibadah</h3><span style="font-size:11px;color:var(--text2)">${s.ibadah || 0} total terverifikasi</span></div>
|
|
<div class="card-body">
|
|
${Object.entries(byJenis).length === 0
|
|
? `<p style="color:var(--text2);font-size:12px;text-align:center;padding:24px 0">Belum ada data</p>`
|
|
: Object.entries(byJenis).sort((a, b) => b[1] - a[1]).map(([jenis, count]) => {
|
|
const pctJ = s.ibadah > 0 ? Math.round((count / s.ibadah) * 100) : 0;
|
|
return `<div style="margin-bottom:13px">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">
|
|
<span style="font-size:12px;font-weight:500;color:var(--text1)"><i class="${ICONS[jenis]}" style="margin-right:5px;color:${COLORS[jenis]}"></i>${jenis.charAt(0).toUpperCase() + jenis.slice(1)}</span>
|
|
<span style="font-size:11px;color:var(--text2)">${count} (${pctJ}%)</span>
|
|
</div>
|
|
<div style="height:7px;background:var(--border2);border-radius:4px;overflow:hidden">
|
|
<div style="height:100%;width:${pctJ}%;background:${COLORS[jenis]};border-radius:4px;transition:width .5s"></div>
|
|
</div>
|
|
</div>`;
|
|
}).join('')
|
|
}
|
|
<div style="margin-top:14px;padding-top:12px;border-top:1px solid var(--border2)">
|
|
<div style="font-size:11px;color:var(--text2);margin-bottom:6px;font-weight:500">Coverage keseluruhan</div>
|
|
<div style="display:flex;align-items:center;gap:10px">
|
|
<div style="flex:1;height:10px;background:var(--border2);border-radius:5px;overflow:hidden">
|
|
<div style="height:100%;width:${pct}%;background:${pct >= 70 ? '#10b981' : pct >= 40 ? '#f59e0b' : '#ef4444'};border-radius:5px;transition:width .5s"></div>
|
|
</div>
|
|
<span style="font-size:13px;font-weight:500;color:var(--text1)">${pct}%</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Distribusi Bantuan Rumah Ibadah -->
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h3>Distribusi Program Bantuan</h3>
|
|
<span style="font-size:11px;color:var(--text2)">Program aktif berjalan di lapangan</span>
|
|
</div>
|
|
<div class="card-body">
|
|
<div style="margin-bottom:13px">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">
|
|
<span style="font-size:12px;font-weight:600;color:var(--text1)"><i class="fa-solid fa-basket-shopping" style="margin-right:6px;color:#3b82f6"></i>Sembako Bulanan</span>
|
|
<span style="font-size:11px;color:var(--text2)">${sembakoCount} KK (${pctSembako}%)</span>
|
|
</div>
|
|
<div style="height:7px;background:var(--border2);border-radius:4px;overflow:hidden">
|
|
<div style="height:100%;width:${pctSembako}%;background:#3b82f6;border-radius:4px;transition:width .5s"></div>
|
|
</div>
|
|
</div>
|
|
<div style="margin-bottom:13px">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">
|
|
<span style="font-size:12px;font-weight:600;color:var(--text1)"><i class="fa-solid fa-graduation-cap" style="margin-right:6px;color:#10b981"></i>Beasiswa Anak Asuh</span>
|
|
<span style="font-size:11px;color:var(--text2)">${beasiswaCount} KK (${pctBeasiswa}%)</span>
|
|
</div>
|
|
<div style="height:7px;background:var(--border2);border-radius:4px;overflow:hidden">
|
|
<div style="height:100%;width:${pctBeasiswa}%;background:#10b981;border-radius:4px;transition:width .5s"></div>
|
|
</div>
|
|
</div>
|
|
<div style="margin-bottom:13px">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">
|
|
<span style="font-size:12px;font-weight:600;color:var(--text1)"><i class="fa-solid fa-briefcase" style="margin-right:6px;color:#8b5cf6"></i>Modal Pemberdayaan</span>
|
|
<span style="font-size:11px;color:var(--text2)">${modalCount} KK (${pctModal}%)</span>
|
|
</div>
|
|
<div style="height:7px;background:var(--border2);border-radius:4px;overflow:hidden">
|
|
<div style="height:100%;width:${pctModal}%;background:#8b5cf6;border-radius:4px;transition:width .5s"></div>
|
|
</div>
|
|
</div>
|
|
<div style="margin-bottom:13px">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">
|
|
<span style="font-size:12px;font-weight:600;color:var(--text1)"><i class="fa-solid fa-hand-holding-heart" style="margin-right:6px;color:#ec4899"></i>Santunan Tunai/Kesehatan</span>
|
|
<span style="font-size:11px;color:var(--text2)">${tunaiCount} KK (${pctTunai}%)</span>
|
|
</div>
|
|
<div style="height:7px;background:var(--border2);border-radius:4px;overflow:hidden">
|
|
<div style="height:100%;width:${pctTunai}%;background:#ec4899;border-radius:4px;transition:width .5s"></div>
|
|
</div>
|
|
</div>
|
|
<div style="margin-top:14px;padding-top:12px;border-top:1px solid var(--border2)">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">
|
|
<span style="font-size:12px;font-weight:600;color:var(--text2)"><i class="fa-solid fa-triangle-exclamation" style="margin-right:6px;color:#f59e0b"></i>Belum Terbantu</span>
|
|
<span style="font-size:11px;color:var(--text2)">${tidakAdaCount} KK (${pctTidakAda}%)</span>
|
|
</div>
|
|
<div style="height:7px;background:var(--border2);border-radius:4px;overflow:hidden">
|
|
<div style="height:100%;width:${pctTidakAda}%;background:#f59e0b;border-radius:4px;transition:width .5s"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Rekomendasi berbasis data -->
|
|
<div class="card">
|
|
<div class="card-header"><h3>Rekomendasi Tindakan</h3><span style="font-size:11px;color:var(--text2)">Berdasarkan analisis data terverifikasi</span></div>
|
|
<div class="card-body" style="display:flex;flex-direction:column;gap:10px">
|
|
|
|
${prioritas.length > 0 ? `
|
|
<div style="background:#fef2f2;border-left:3px solid #ef4444;padding:12px 14px;border-radius:0 6px 6px 0">
|
|
<div style="font-size:12px;font-weight:600;color:#dc2626;margin-bottom:6px"><i class="fa-solid fa-triangle-exclamation" style="margin-right:5px"></i>Kecamatan Prioritas Intervensi</div>
|
|
<div style="display:flex;flex-wrap:wrap;gap:8px">
|
|
${prioritas.map(r => `
|
|
<div style="background:#fff;border:1px solid #fca5a5;border-radius:6px;padding:6px 10px;font-size:11px">
|
|
<div style="font-weight:600;color:#991b1b">${r.nama}</div>
|
|
<div style="color:#6b7280;margin-top:2px">${r.kk - r.kkTerlayani} KK belum terlayani · coverage ${r.pct}%</div>
|
|
</div>`).join('')}
|
|
</div>
|
|
<div style="font-size:11px;color:#6b7280;margin-top:8px">Pertimbangkan penambahan atau perluasan radius rumah ibadah di kecamatan ini.</div>
|
|
</div>` : ''}
|
|
|
|
${pct >= 70 ? `
|
|
<div style="background:#f0fdf4;border-left:3px solid #10b981;padding:12px 14px;border-radius:0 6px 6px 0">
|
|
<div style="font-size:12px;font-weight:600;color:#166534;margin-bottom:4px"><i class="fa-solid fa-circle-check" style="margin-right:5px"></i>Coverage Kota: ${pct}%</div>
|
|
<div style="font-size:11px;color:#166534">Program pemetaan berjalan baik. Fokus pada validasi data dan penyempurnaan kecamatan dengan coverage rendah.</div>
|
|
</div>` : `
|
|
<div style="background:#fffbeb;border-left:3px solid #f59e0b;padding:12px 14px;border-radius:0 6px 6px 0">
|
|
<div style="font-size:12px;font-weight:600;color:#92400e;margin-bottom:4px"><i class="fa-solid fa-clock" style="margin-right:5px"></i>Coverage Kota Masih ${pct}%</div>
|
|
<div style="font-size:11px;color:#92400e">Masih ada ${s.kk_belum} KK (${s.jiwa_total - s.jiwa_terlayani} jiwa) yang belum terjangkau. Percepatan survei dan verifikasi diperlukan.</div>
|
|
</div>`}
|
|
|
|
<div style="background:#eff6ff;border-left:3px solid #3b82f6;padding:12px 14px;border-radius:0 6px 6px 0">
|
|
<div style="font-size:12px;font-weight:600;color:#1e40af;margin-bottom:4px"><i class="fa-solid fa-clipboard-list" style="margin-right:5px"></i>Ringkasan Eksekutif</div>
|
|
<div style="font-size:11px;color:#1e40af;line-height:1.7">
|
|
${s.ibadah || 0} rumah ibadah terverifikasi melayani ${s.kk_terlayani || 0} dari ${s.kk_total || 0} KK miskin
|
|
(${s.jiwa_terlayani || 0} dari ${s.jiwa_total || 0} jiwa) di Kota Pontianak.
|
|
${kecRows.filter(r => r.pct !== null).length > 0
|
|
? `Kecamatan dengan coverage tertinggi: <strong>${[...kecRows].filter(r => r.pct !== null).sort((a, b) => b.pct - a.pct)[0]?.nama}</strong>.`
|
|
: ''}
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
// -- USERS ---------------------------------
|
|
async function renderUsers(area) {
|
|
area.innerHTML = '<div class="empty-state"><div class="spin"></div><p style="margin-top:12px">Memuat data user...</p></div>';
|
|
try {
|
|
const res = await fetch(API.auth + '?action=users', { credentials: 'include' }).then(r => r.json());
|
|
const users = res.data || [];
|
|
window._cachedUsersList = users;
|
|
const roleColors = { admin: '#f59e0b', surveyor: '#10b981', pemangku: '#3b82f6' };
|
|
area.innerHTML = `
|
|
<div class="content-grid">
|
|
<div>
|
|
<div class="card" style="margin-bottom:16px">
|
|
<div class="card-header"><h3>Tambah Akun Baru</h3></div>
|
|
<div class="card-body">
|
|
<div class="form-grid">
|
|
<div class="form-group"><label>Username</label><input type="text" id="nu-username" placeholder="username"></div>
|
|
<div class="form-group"><label>Password</label><div style="position:relative"><input type="password" id="nu-pass" placeholder="min 6 karakter" style="width:100%;padding-right:36px"><button type="button" onclick="togglePassword('nu-pass', this)" tabindex="-1" title="Tampilkan/sembunyikan password" style="position:absolute;right:10px;top:50%;transform:translateY(-50%);background:none;border:none;cursor:pointer;color:var(--text3);font-size:13px;padding:2px 4px;display:flex;align-items:center;transition:color .2s" onmouseover="this.style.color='var(--blue)'" onmouseout="this.style.color='var(--text3)'"><i class="fa-solid fa-eye"></i></button></div></div>
|
|
<div class="form-group"><label>Nama Lengkap</label><input type="text" id="nu-nama" placeholder="Nama"></div>
|
|
<div class="form-group"><label>Role</label>
|
|
<select id="nu-role">
|
|
<option value="surveyor">Surveyor</option>
|
|
<option value="pemangku">Pemangku Keputusan</option>
|
|
<option value="admin">Admin</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<button class="submit-btn primary" onclick="createUser()"><i class="fa-solid fa-plus"></i> Buat Akun</button>
|
|
</div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-header"><h3>Daftar User (${users.length})</h3></div>
|
|
<div class="card-body">
|
|
<div class="user-card-list">
|
|
${users.map(u => `
|
|
<div class="user-card-item" style="${!u.is_active ? 'opacity:.4' : ''}">
|
|
<div class="uc-avatar" style="background:${roleColors[u.role] || '#6b7280'}">${h(u.nama_lengkap).charAt(0).toUpperCase()}</div>
|
|
<div class="uc-info">
|
|
<div class="uc-name">${h(u.nama_lengkap)} <span style="color:var(--text3);font-weight:400">@${h(u.username)}</span></div>
|
|
<div class="uc-role"><span class="badge badge-${u.role === 'admin' ? 'pending' : (u.role === 'surveyor' ? 'verified' : 'in')}">${ROLE_LABELS[u.role] || u.role}</span> ${!u.is_active ? '<span style="color:var(--red);font-size:10px">(Nonaktif)</span>' : ''}</div>
|
|
</div>
|
|
<div class="uc-actions" style="display:flex;align-items:center;gap:4px">
|
|
<button class="uc-btn" style="background:var(--blue-light);color:var(--blue)" onclick="openEditUserModal(${u.id})" title="Edit User"><i class="fa-solid fa-pen-to-square"></i></button>
|
|
${u.id != currentUser.id ? (u.is_active
|
|
? `<button class="uc-btn" style="background:var(--red-light);color:var(--red)" onclick="toggleUserActive(${u.id},false)" title="Nonaktifkan User"><i class="fa-solid fa-ban"></i></button>`
|
|
: `<button class="uc-btn" style="background:var(--green-light);color:var(--green)" onclick="toggleUserActive(${u.id},true)" title="Aktifkan User"><i class="fa-solid fa-check"></i></button>`)
|
|
: ''}
|
|
</div>
|
|
</div>`).join('')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="card" style="height:fit-content">
|
|
<div class="card-header"><h3>Statistik User</h3></div>
|
|
<div class="card-body">
|
|
<div class="mini-stat"><span class="m-label">Total Aktif</span><span class="m-val">${users.filter(u => u.is_active).length}</span></div>
|
|
<div class="mini-stat"><span class="m-label">Admin</span><span class="m-val">${users.filter(u => u.role === 'admin' && u.is_active).length}</span></div>
|
|
<div class="mini-stat"><span class="m-label">Surveyor</span><span class="m-val">${users.filter(u => u.role === 'surveyor' && u.is_active).length}</span></div>
|
|
<div class="mini-stat"><span class="m-label">Pemangku</span><span class="m-val">${users.filter(u => u.role === 'pemangku' && u.is_active).length}</span></div>
|
|
<div class="mini-stat"><span class="m-label">Nonaktif</span><span class="m-val" style="color:var(--red)">${users.filter(u => !u.is_active).length}</span></div>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
} catch (e) { area.innerHTML = '<div class="empty-state"><p>Gagal memuat data user</p></div>'; }
|
|
}
|
|
|
|
async function createUser() {
|
|
const username = document.getElementById('nu-username').value.trim();
|
|
const password = document.getElementById('nu-pass').value;
|
|
const nama = document.getElementById('nu-nama').value.trim();
|
|
const role = document.getElementById('nu-role').value;
|
|
if (!username || !password || !nama) { showToast('Semua field wajib diisi', 'error'); return; }
|
|
if (password.length < 6) { showToast('Password min 6 karakter', 'error'); return; }
|
|
try {
|
|
const res = await fetch(API.auth + '?action=register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ username, password, nama_lengkap: nama, role }) }).then(r => r.json());
|
|
if (!res.success) throw new Error(res.error);
|
|
showToast('Akun berhasil dibuat!', 'success');
|
|
renderUsers(document.getElementById('page-content'));
|
|
} catch (e) { showToast('Error: ' + e.message, 'error'); }
|
|
}
|
|
|
|
async function toggleUserActive(id, active) {
|
|
if (!active && !confirm('Nonaktifkan user ini?')) return;
|
|
await fetch(API.auth + `?action=update&id=${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ is_active: active }) }).then(r => r.json());
|
|
showToast(active ? 'User diaktifkan' : 'User dinonaktifkan', 'info');
|
|
renderUsers(document.getElementById('page-content'));
|
|
}
|
|
|
|
function openEditUserModal(id) {
|
|
const u = window._cachedUsersList?.find(x => x.id == id);
|
|
if (!u) return;
|
|
document.getElementById('eu-id').value = u.id;
|
|
document.getElementById('eu-username').value = u.username;
|
|
document.getElementById('eu-nama').value = u.nama_lengkap;
|
|
document.getElementById('eu-role').value = u.role;
|
|
document.getElementById('eu-pass').value = '';
|
|
document.getElementById('edit-user-modal').classList.add('show');
|
|
}
|
|
|
|
function closeEditUserModal() {
|
|
document.getElementById('edit-user-modal').classList.remove('show');
|
|
}
|
|
|
|
async function confirmEditUser() {
|
|
const id = document.getElementById('eu-id').value;
|
|
const nama_lengkap = document.getElementById('eu-nama').value.trim();
|
|
const role = document.getElementById('eu-role').value;
|
|
const password = document.getElementById('eu-pass').value;
|
|
|
|
if (!nama_lengkap) { showToast('Nama lengkap wajib diisi', 'error'); return; }
|
|
if (password && password.length < 6) { showToast('Password baru min 6 karakter', 'error'); return; }
|
|
|
|
const payload = { nama_lengkap, role };
|
|
if (password) {
|
|
payload.password = password;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(API.auth + `?action=update&id=${id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify(payload)
|
|
}).then(r => r.json());
|
|
|
|
if (!res.success) throw new Error(res.error);
|
|
showToast('Data user berhasil diperbarui!', 'success');
|
|
closeEditUserModal();
|
|
renderUsers(document.getElementById('page-content'));
|
|
} catch (e) { showToast('Error: ' + e.message, 'error'); }
|
|
}
|
|
|
|
|
|
|
|
// -- MAP FUNCTIONS
|
|
let mapInstances = {};
|
|
|
|
// -- PERSISTENT MAP (Dashboard + Peta Interaktif)
|
|
// Map ini TIDAK pernah di-destroy selama sesi. Tile dan marker di-cache.
|
|
// Saat navigasi halaman, map div dipindahkan ke container halaman aktif.
|
|
let persistentMap = null;
|
|
let _persistentMapAttachedTo = null;
|
|
let _dataVersion = 0;
|
|
let _lastMarkerVersion = -1;
|
|
|
|
function getOrCreatePersistentMap() {
|
|
if (persistentMap) return persistentMap;
|
|
|
|
const mapEl = document.getElementById('persistent-map');
|
|
const center = [cachedIbadah.length ? +cachedIbadah[0].lat : -0.02,
|
|
cachedIbadah.length ? +cachedIbadah[0].lng : 109.33];
|
|
|
|
const m = L.map(mapEl, {
|
|
zoomControl: true,
|
|
attributionControl: false,
|
|
preferCanvas: true
|
|
}).setView(center, 13);
|
|
|
|
// Tile layers dibuat SEKALI, di-cache oleh browser
|
|
const osm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 });
|
|
const light = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { maxZoom: 19 });
|
|
const dark = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { maxZoom: 19 });
|
|
light.addTo(m);
|
|
|
|
const layerIbadah = L.layerGroup().addTo(m);
|
|
const layerWarga = L.layerGroup().addTo(m);
|
|
const layerHeat = L.layerGroup(); // Placeholder layer untuk overlay control
|
|
m.layerGroups = { ibadah: layerIbadah, warga: layerWarga, heat: layerHeat };
|
|
m._statusFilter = defaultStatusFilter();
|
|
|
|
L.control.layers(
|
|
{ "Street Map": osm, "Light Map": light, "Dark Map": dark },
|
|
{ "Rumah Ibadah": layerIbadah, "Warga Miskin": layerWarga, "Heatmap Kemiskinan": layerHeat },
|
|
{ collapsed: true }
|
|
).addTo(m);
|
|
addStatusFilterControl(m);
|
|
addHeatmapSettingsControl(m);
|
|
|
|
// Integrasi event listener heatmap
|
|
m.on('overlayadd', e => {
|
|
if (e.name === "Heatmap Kemiskinan") {
|
|
showHeatmapControls(m, true);
|
|
}
|
|
});
|
|
m.on('overlayremove', e => {
|
|
if (e.name === "Heatmap Kemiskinan") {
|
|
showHeatmapControls(m, false);
|
|
}
|
|
});
|
|
|
|
persistentMap = m;
|
|
map = m; // agar flyToOnMap() dan referensi lama tetap bekerja
|
|
mapInstances['persistent'] = m;
|
|
|
|
return m;
|
|
}
|
|
|
|
function attachPersistentMapTo(containerId) {
|
|
const container = document.getElementById(containerId);
|
|
if (!container) return;
|
|
|
|
const mapEl = document.getElementById('persistent-map');
|
|
mapEl.style.display = 'block';
|
|
container.appendChild(mapEl);
|
|
_persistentMapAttachedTo = containerId;
|
|
|
|
// invalidateSize setelah DOM reparenting (jalankan di beberapa interval untuk mematangkan transisi layout)
|
|
setTimeout(() => { if (persistentMap) persistentMap.invalidateSize(); }, 50);
|
|
setTimeout(() => { if (persistentMap) persistentMap.invalidateSize(); }, 200);
|
|
}
|
|
|
|
function detachPersistentMap() {
|
|
if (!_persistentMapAttachedTo) return;
|
|
const mapEl = document.getElementById('persistent-map');
|
|
if (mapEl) {
|
|
mapEl.style.display = 'none';
|
|
// Pindahkan ke #main agar tidak ikut dihapus saat innerHTML diganti
|
|
document.getElementById('main').appendChild(mapEl);
|
|
}
|
|
_persistentMapAttachedTo = null;
|
|
}
|
|
|
|
function rebuildPersistentMarkers(force = false) {
|
|
const m = persistentMap;
|
|
if (!m) return;
|
|
|
|
// Skip jika data belum berubah sejak terakhir rebuild
|
|
if (!force && _dataVersion === _lastMarkerVersion) return;
|
|
_lastMarkerVersion = _dataVersion;
|
|
|
|
// Clear dan rebuild markers
|
|
m.layerGroups.ibadah.clearLayers();
|
|
m.layerGroups.warga.clearLayers();
|
|
m.ibadahLayer = {};
|
|
m.wargaLayer = {};
|
|
ibadahLayer = m.ibadahLayer;
|
|
wargaLayer = m.wargaLayer;
|
|
|
|
cachedIbadah.forEach(ib => placeMarkerOn(m, ib, 'ibadah'));
|
|
cachedWarga.forEach(w => placeMarkerOn(m, w, 'warga'));
|
|
applyStatusFilter(m);
|
|
}
|
|
|
|
function fitPersistentMapToData() {
|
|
const m = persistentMap;
|
|
if (!m) return;
|
|
const pts = cachedIbadah.filter(i => i.status === 'verified').map(i => [+i.lat, +i.lng]);
|
|
if (pts.length) m.fitBounds(pts, { padding: [20, 20] });
|
|
}
|
|
|
|
// Backward compat tidak digunakan lagi, persistent map di-detach bukan di-hide
|
|
function hideMapOverlay() { }
|
|
|
|
// initMapFull Deprecated: sekarang menggunakan persistent map.
|
|
// Fungsi ini dipertahankan sebagai fallback.
|
|
function initMapFull(containerId) {
|
|
getOrCreatePersistentMap();
|
|
rebuildPersistentMarkers();
|
|
attachPersistentMapTo(containerId);
|
|
fitPersistentMapToData();
|
|
}
|
|
|
|
function initMapInput(containerId, type) {
|
|
if (mapInstances[containerId]) { mapInstances[containerId].remove(); }
|
|
const center = cachedIbadah.length && cachedIbadah[0].lat ? [cachedIbadah[0].lat, cachedIbadah[0].lng] : [-6.2, 106.8];
|
|
const m = L.map(containerId, { zoomControl: true, attributionControl: false, preferCanvas: true }).setView(center, 14);
|
|
|
|
const osm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 });
|
|
const light = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { maxZoom: 19 });
|
|
const dark = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { maxZoom: 19 });
|
|
light.addTo(m);
|
|
|
|
const layerIbadah = L.layerGroup();
|
|
const layerWarga = L.layerGroup();
|
|
|
|
m.layerGroups = {
|
|
ibadah: layerIbadah,
|
|
warga: layerWarga
|
|
};
|
|
|
|
// Ibadah markers: selalu tampil di kedua form (ibadah & miskin) sebagai referensi lokasi
|
|
cachedIbadah.filter(i => i.status === 'verified' || i.status === 'pending').forEach(ib => placeMarkerOn(m, ib, 'ibadah'));
|
|
layerIbadah.addTo(m);
|
|
|
|
// Warga markers: selalu diisi di layerWarga agar bisa diaktifkan via layer control
|
|
cachedWarga.filter(w => w.status === 'verified' || w.status === 'pending').forEach(w => placeMarkerOn(m, w, 'warga'));
|
|
if (type === 'miskin') {
|
|
layerWarga.addTo(m);
|
|
}
|
|
|
|
m.on('click', e => onMapClickInput(e, type));
|
|
m.getContainer().style.cursor = type === 'ibadah' ? 'crosshair' : 'cell';
|
|
|
|
const baseMaps = {
|
|
"Default Street Map": osm,
|
|
"Clean Light Map": light,
|
|
"Premium Dark Map": dark
|
|
};
|
|
|
|
const overlayMaps = {
|
|
"Rumah Ibadah": layerIbadah,
|
|
"Warga Miskin": layerWarga
|
|
};
|
|
L.control.layers(baseMaps, overlayMaps, { collapsed: true }).addTo(m);
|
|
|
|
mapInstances[containerId] = m;
|
|
}
|
|
|
|
function initMapSide(containerId) {
|
|
if (mapInstances[containerId]) { mapInstances[containerId].remove(); }
|
|
const center = [cachedIbadah.length ? cachedIbadah[0].lat : -6.2, cachedIbadah.length ? cachedIbadah[0].lng : 106.8];
|
|
const m = L.map(containerId, { zoomControl: false, attributionControl: false, preferCanvas: true }).setView(center, 12);
|
|
|
|
const osm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 });
|
|
const light = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { maxZoom: 19 });
|
|
const dark = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { maxZoom: 19 });
|
|
light.addTo(m);
|
|
|
|
cachedIbadah.filter(i => i.status === 'verified' || i.status === 'pending').forEach(ib => placeMarkerOn(m, ib, 'ibadah'));
|
|
cachedWarga.filter(w => w.status === 'verified' || w.status === 'pending').forEach(w => placeMarkerOn(m, w, 'warga'));
|
|
|
|
if (cachedIbadah.length) {
|
|
const pts = cachedIbadah.filter(i => i.status === 'verified').map(i => [i.lat, i.lng]);
|
|
if (pts.length) m.fitBounds(pts, { padding: [10, 10] });
|
|
}
|
|
|
|
const baseMaps = {
|
|
"Default Street Map": osm,
|
|
"Clean Light Map": light,
|
|
"Premium Dark Map": dark
|
|
};
|
|
L.control.layers(baseMaps, null, { collapsed: true }).addTo(m);
|
|
|
|
mapInstances[containerId] = m;
|
|
}
|
|
|
|
|
|
|
|
// -- STATUS FILTER HELPERS
|
|
|
|
// Kembalikan objek filter default sesuai role
|
|
function defaultStatusFilter() {
|
|
const isPemangku = currentUser?.role === 'pemangku';
|
|
return {
|
|
ibadah_verified: true,
|
|
ibadah_pending: !isPemangku,
|
|
ibadah_rejected: false,
|
|
warga_verified_covered: true,
|
|
warga_verified_uncovered: true,
|
|
warga_pending: !isPemangku,
|
|
warga_rejected: false,
|
|
};
|
|
}
|
|
|
|
// Sembunyikan/tampilkan marker berdasarkan filter aktif di m._statusFilter
|
|
function applyStatusFilter(m) {
|
|
if (!m._statusFilter) return;
|
|
const f = m._statusFilter;
|
|
const ibMarkers = m.ibadahLayer || {};
|
|
const wgMarkers = m.wargaLayer || {};
|
|
// Ibadah
|
|
Object.values(ibMarkers).forEach(({ marker, circle, data }) => {
|
|
const s = data.status;
|
|
const show = s === 'verified' ? f.ibadah_verified
|
|
: s === 'pending' ? f.ibadah_pending
|
|
: s === 'rejected' ? f.ibadah_rejected
|
|
: false;
|
|
const target = m.layerGroups ? m.layerGroups.ibadah : m;
|
|
if (show) { if (!target.hasLayer(marker)) target.addLayer(marker); if (!target.hasLayer(circle)) target.addLayer(circle); }
|
|
else { if (target.hasLayer(marker)) target.removeLayer(marker); if (target.hasLayer(circle)) target.removeLayer(circle); }
|
|
});
|
|
// Warga
|
|
Object.values(wgMarkers).forEach(({ marker, data }) => {
|
|
const s = data.status;
|
|
const covered = !!data.ibadah_id;
|
|
let show;
|
|
if (s === 'pending') show = f.warga_pending;
|
|
else if (s === 'rejected') show = f.warga_rejected;
|
|
else show = covered ? f.warga_verified_covered : f.warga_verified_uncovered;
|
|
const target = m.layerGroups ? m.layerGroups.warga : m;
|
|
if (show) { if (!target.hasLayer(marker)) target.addLayer(marker); }
|
|
else { if (target.hasLayer(marker)) target.removeLayer(marker); }
|
|
});
|
|
}
|
|
|
|
// Custom Leaflet control: panel filter status
|
|
function addStatusFilterControl(m) {
|
|
const StatusControl = L.Control.extend({
|
|
options: { position: 'bottomright' },
|
|
onAdd: function () {
|
|
const div = L.DomUtil.create('div', '');
|
|
div.style.cssText = 'background:#fff;border-radius:10px;padding:10px 12px;box-shadow:0 2px 12px rgba(0,0,0,.15);font-family:Plus Jakarta Sans,sans-serif;font-size:11px;min-width:190px;';
|
|
L.DomEvent.disableClickPropagation(div);
|
|
L.DomEvent.disableScrollPropagation(div);
|
|
|
|
const f = m._statusFilter;
|
|
const countIb = (s) => cachedIbadah.filter(i => i.status === s).length;
|
|
const countW = (s, cov) => {
|
|
if (s === 'verified') return cachedWarga.filter(w => w.status === 'verified' && (cov ? !!w.ibadah_id : !w.ibadah_id)).length;
|
|
return cachedWarga.filter(w => w.status === s).length;
|
|
};
|
|
|
|
const row = (key, color, icon, label, count, dash) => {
|
|
const checked = f[key] ? 'checked' : '';
|
|
const borderStyle = dash ? `2px dashed ${color}` : `2px solid ${color}`;
|
|
return `<label style="display:flex;align-items:center;gap:7px;cursor:pointer;padding:3px 0;user-select:none;">
|
|
<input type="checkbox" data-key="${key}" ${checked} style="display:none">
|
|
<span style="width:14px;height:14px;border-radius:50%;background:${color};border:${borderStyle};flex-shrink:0;opacity:${dash ? 0.7 : 1}"></span>
|
|
<span style="flex:1;color:#374151">${icon} ${label}</span>
|
|
<span style="background:#f3f4f6;color:#6b7280;border-radius:99px;padding:1px 6px;font-size:10px">${count}</span>
|
|
</label>`;
|
|
};
|
|
|
|
const isPemangku = currentUser?.role === 'pemangku';
|
|
|
|
div.innerHTML = isPemangku ? `
|
|
<div style="font-weight:600;color:#111827;margin-bottom:8px;font-size:12px">
|
|
<i class="fa-solid fa-users" style="margin-right:5px;color:#6b7280"></i>Filter Warga
|
|
</div>
|
|
${row('warga_verified_covered', '#10b981', ' ', 'Terlayani', countW('verified', true), false)}
|
|
${row('warga_verified_uncovered', '#9ca3af', ' ', 'Belum terlayani', countW('verified', false), false)}
|
|
` : `
|
|
<div style="font-weight:600;color:#111827;margin-bottom:8px;font-size:12px">
|
|
<i class="fa-solid fa-layer-group" style="margin-right:5px;color:#6b7280"></i>Filter Status
|
|
</div>
|
|
<div style="font-size:10px;color:#9ca3af;margin-bottom:5px;text-transform:uppercase;letter-spacing:.04em">Rumah Ibadah</div>
|
|
${row('ibadah_verified', '#10b981', ' ', 'Terverifikasi', countIb('verified'), false)}
|
|
${row('ibadah_pending', '#f59e0b', ' ', 'Menunggu verif.', countIb('pending'), true)}
|
|
${row('ibadah_rejected', '#ef4444', ' ', 'Ditolak', countIb('rejected'), true)}
|
|
<div style="font-size:10px;color:#9ca3af;margin:7px 0 5px;text-transform:uppercase;letter-spacing:.04em">Warga Miskin</div>
|
|
${row('warga_verified_covered', '#10b981', ' ', 'Terlayani', countW('verified', true), false)}
|
|
${row('warga_verified_uncovered', '#9ca3af', ' ', 'Belum terlayani', countW('verified', false), false)}
|
|
${row('warga_pending', '#f59e0b', ' ', 'Menunggu verif.', countW('pending'), true)}
|
|
${row('warga_rejected', '#ef4444', ' ', 'Ditolak', countW('rejected'), true)}
|
|
`;
|
|
|
|
// Pasang event listener checkbox
|
|
div.querySelectorAll('input[type=checkbox]').forEach(cb => {
|
|
cb.addEventListener('change', () => {
|
|
f[cb.dataset.key] = cb.checked;
|
|
// Update visual dot opacity
|
|
const dot = cb.nextElementSibling;
|
|
if (dot) dot.style.opacity = cb.checked ? '1' : '0.3';
|
|
applyStatusFilter(m);
|
|
});
|
|
});
|
|
|
|
return div;
|
|
}
|
|
});
|
|
new StatusControl().addTo(m);
|
|
}
|
|
|
|
// Custom Leaflet control: panel pengaturan heatmap
|
|
function addHeatmapSettingsControl(m) {
|
|
const HeatmapControl = L.Control.extend({
|
|
options: { position: 'bottomleft' },
|
|
onAdd: function () {
|
|
const div = L.DomUtil.create('div', 'heatmap-settings-control');
|
|
div.style.cssText = 'background:#fff;border-radius:10px;padding:10px 12px;box-shadow:0 2px 12px rgba(0,0,0,.15);font-family:Plus Jakarta Sans,sans-serif;font-size:11px;min-width:180px;display:none;';
|
|
L.DomEvent.disableClickPropagation(div);
|
|
L.DomEvent.disableScrollPropagation(div);
|
|
|
|
div.innerHTML = `
|
|
<div style="font-weight:700;margin-bottom:6px;display:flex;align-items:center;gap:4px">
|
|
<i class="fa-solid fa-fire" style="color:#ef4444"></i> Pengaturan Heatmap
|
|
</div>
|
|
<div style="margin-bottom:6px">
|
|
<label style="display:block;margin-bottom:2px;color:#4b5563">Filter Kecamatan</label>
|
|
<select id="heat-m-kec" style="width:100%;padding:4px 6px;border:1px solid #d1d5db;border-radius:4px;font-size:10px;background:#fff;" onchange="updatePersistentHeatmap()">
|
|
<option value="">Semua Kecamatan</option>
|
|
<option value="Pontianak Utara">Pontianak Utara</option>
|
|
<option value="Pontianak Timur">Pontianak Timur</option>
|
|
<option value="Pontianak Selatan">Pontianak Selatan</option>
|
|
<option value="Pontianak Barat">Pontianak Barat</option>
|
|
<option value="Pontianak Kota">Pontianak Kota</option>
|
|
<option value="Pontianak Tenggara">Pontianak Tenggara</option>
|
|
</select>
|
|
</div>
|
|
<div style="margin-bottom:6px">
|
|
<label style="display:block;margin-bottom:2px;color:#4b5563">Radius: <span id="heat-m-rval">25</span></label>
|
|
<input type="range" id="heat-m-radius" min="10" max="60" value="25" style="width:100%" oninput="document.getElementById('heat-m-rval').textContent=this.value;updatePersistentHeatRadius(this.value)">
|
|
</div>
|
|
<div style="height:8px;border-radius:4px;background:linear-gradient(90deg,#0000ff,#00ffff,#00ff00,#ffff00,#ff0000);margin:6px 0 2px"></div>
|
|
<div style="display:flex;justify-content:space-between;font-size:8px;color:#9ca3af"><span>Rendah</span><span>Tinggi</span></div>
|
|
`;
|
|
return div;
|
|
}
|
|
});
|
|
new HeatmapControl().addTo(m);
|
|
}
|
|
|
|
let persistentHeatLayer = null;
|
|
|
|
window.updatePersistentHeatmap = function () {
|
|
const m = persistentMap;
|
|
if (!m) return;
|
|
if (persistentHeatLayer) {
|
|
m.removeLayer(persistentHeatLayer);
|
|
persistentHeatLayer = null;
|
|
}
|
|
|
|
const layerHeat = m.layerGroups?.heat;
|
|
if (!layerHeat || !m.hasLayer(layerHeat)) return;
|
|
|
|
const kec = document.getElementById('heat-m-kec')?.value.trim();
|
|
const pts = cachedWarga
|
|
.filter(w => w.status === 'verified' && (!kec || (w.kecamatan && w.kecamatan.toLowerCase().includes(kec.toLowerCase()))))
|
|
.map(w => [+w.lat, +w.lng, +w.jumlah_anggota || 1]);
|
|
|
|
if (!pts.length) return;
|
|
|
|
const rad = parseInt(document.getElementById('heat-m-radius')?.value || 25);
|
|
persistentHeatLayer = L.heatLayer(pts, {
|
|
radius: rad,
|
|
blur: 15,
|
|
maxZoom: 17,
|
|
gradient: { 0.2: 'blue', 0.4: 'cyan', 0.6: 'lime', 0.8: 'yellow', 1: 'red' }
|
|
}).addTo(m);
|
|
};
|
|
|
|
window.updatePersistentHeatRadius = function (val) {
|
|
if (persistentHeatLayer) {
|
|
persistentHeatLayer.setOptions({ radius: parseInt(val) });
|
|
}
|
|
};
|
|
|
|
window.showHeatmapControls = function (m, show) {
|
|
const ctrlEl = document.querySelector('.heatmap-settings-control');
|
|
if (ctrlEl) {
|
|
ctrlEl.style.display = show ? 'block' : 'none';
|
|
}
|
|
if (show) {
|
|
updatePersistentHeatmap();
|
|
} else {
|
|
if (persistentHeatLayer) {
|
|
m.removeLayer(persistentHeatLayer);
|
|
persistentHeatLayer = null;
|
|
}
|
|
}
|
|
};
|
|
|
|
function placeMarkerOn(m, data, type, all = false) {
|
|
const status = data.status; // 'verified' | 'pending' | 'rejected'
|
|
const isVerified = status === 'verified';
|
|
const isPending = status === 'pending';
|
|
const isRejected = status === 'rejected';
|
|
// Outline dashed untuk data yang belum/tidak final
|
|
const dash = isVerified ? null : '5 4';
|
|
const isPemangku = currentUser?.role === 'pemangku';
|
|
let mk;
|
|
|
|
if (type === 'ibadah') {
|
|
// Warna pin selalu berdasarkan jenis ibadah (konsisten semua role)
|
|
const clr = COLORS[data.jenis] || '#6b7280';
|
|
const opacity = isRejected ? 0.45 : isPending ? 0.5 : 1;
|
|
const target = m.layerGroups ? m.layerGroups.ibadah : m;
|
|
const circle = L.circle([data.lat, data.lng], {
|
|
radius: data.radius, color: clr, fillColor: clr,
|
|
fillOpacity: isVerified ? .07 : .02,
|
|
weight: isVerified ? 2 : 1,
|
|
dashArray: dash, opacity
|
|
}).addTo(target);
|
|
|
|
// Badge kecil status tampil untuk semua role (kecuali verified tidak ada badge)
|
|
const badgeIcon = isVerified ? '' : isPending
|
|
? `<div style="position:absolute;top:-4px;right:-4px;width:13px;height:13px;background:#f59e0b;border-radius:50%;border:1.5px solid #fff;display:flex;align-items:center;justify-content:center;font-size:7px;color:#fff;font-weight:700">!</div>`
|
|
: `<div style="position:absolute;top:-4px;right:-4px;width:13px;height:13px;background:#ef4444;border-radius:50%;border:1.5px solid #fff;display:flex;align-items:center;justify-content:center;font-size:7px;color:#fff;font-weight:700"> </div>`;
|
|
|
|
const ic = L.divIcon({
|
|
className: '',
|
|
html: `<div style="position:relative;width:32px;height:32px;opacity:${opacity}">
|
|
<div style="width:32px;height:32px;background:${clr};border-radius:50% 50% 50% 4px;transform:rotate(-45deg);border:3px solid rgba(255,255,255,.9);box-shadow:0 3px 12px rgba(0,0,0,.4);display:flex;align-items:center;justify-content:center;">
|
|
<i class="${ICONS[data.jenis] || 'fa-solid fa-place-of-worship'}" style="transform:rotate(45deg);font-size:13px;color:#fff;"></i>
|
|
</div>
|
|
${badgeIcon}
|
|
</div>`,
|
|
iconSize: [32, 32], iconAnchor: [16, 28], popupAnchor: [0, -30]
|
|
});
|
|
mk = L.marker([data.lat, data.lng], { icon: ic }).addTo(target);
|
|
if (!m.ibadahLayer) m.ibadahLayer = {};
|
|
if (!m.ibadahLayer[data.id]) {
|
|
m.ibadahLayer[data.id] = { marker: mk, circle: circle, data: { ...data } };
|
|
} else {
|
|
m.ibadahLayer[data.id].marker = mk;
|
|
m.ibadahLayer[data.id].circle = circle;
|
|
}
|
|
if (m === persistentMap) ibadahLayer = m.ibadahLayer;
|
|
} else {
|
|
const sc = STATUS_COLORS.warga;
|
|
const covered = !!data.ibadah_id;
|
|
let clrDot;
|
|
if (isVerified) clrDot = covered ? sc.verified_covered : sc.verified_uncovered;
|
|
else if (isPending) clrDot = sc.pending;
|
|
else clrDot = sc.rejected;
|
|
const opacity = isRejected ? 0.45 : 1;
|
|
const target = m.layerGroups ? m.layerGroups.warga : m;
|
|
|
|
// Fase 6: Gunakan L.circleMarker (Canvas) alih-alih L.marker (DOM)
|
|
mk = L.circleMarker([data.lat, data.lng], {
|
|
radius: 8,
|
|
fillColor: clrDot,
|
|
fillOpacity: opacity,
|
|
color: isVerified ? 'rgba(255,255,255,0.85)' : 'rgba(255,255,255,0.7)',
|
|
weight: isVerified ? 2.5 : 2,
|
|
dashArray: isVerified ? null : '3 3',
|
|
opacity: opacity
|
|
}).addTo(target);
|
|
|
|
if (!m.wargaLayer) m.wargaLayer = {};
|
|
if (!m.wargaLayer[data.id]) {
|
|
m.wargaLayer[data.id] = { marker: mk, data: { ...data } };
|
|
} else {
|
|
m.wargaLayer[data.id].marker = mk;
|
|
}
|
|
if (m === persistentMap) wargaLayer = m.wargaLayer;
|
|
}
|
|
|
|
// Siapkan konten interactive tooltip
|
|
const tooltipContent = type === 'ibadah' ? `
|
|
<div class="map-tooltip ibadah-tooltip">
|
|
<div class="tooltip-title">
|
|
<i class="${ICONS[data.jenis] || 'fa-solid fa-place-of-worship'}" style="color:${COLORS[data.jenis] || '#f59e0b'}"></i>
|
|
<span>${h(data.nama)}</span>
|
|
</div>
|
|
<div class="tooltip-row">
|
|
<span>PIC</span>
|
|
<strong>${h(data.pic || '-')}</strong>
|
|
</div>
|
|
<div class="tooltip-row">
|
|
<span>Radius</span>
|
|
<strong>${data.radius}m</strong>
|
|
</div>
|
|
<div class="tooltip-row">
|
|
<span>Kecamatan</span>
|
|
<strong>${h(data.kecamatan || '-')}</strong>
|
|
</div>
|
|
${isPemangku ? '' : `<div class="tooltip-row">
|
|
<span>Status</span>
|
|
<span class="badge badge-${data.status}">${statusLabel(data.status)}</span>
|
|
</div>`}
|
|
<div class="tooltip-footer">
|
|
<i class="fa-solid fa-eye"></i> Detail
|
|
</button>
|
|
</div>
|
|
</div>
|
|
` : `
|
|
<div class="map-tooltip warga-tooltip">
|
|
<div class="tooltip-title">
|
|
<i class="fa-solid fa-user" style="color:#ef4444"></i>
|
|
<span>${h(data.nama_kk)}</span>
|
|
</div>
|
|
<div class="tooltip-row">
|
|
<span>Anggota</span>
|
|
<strong>${data.jumlah_anggota} jiwa</strong>
|
|
</div>
|
|
<div class="tooltip-row">
|
|
<span>Kecamatan</span>
|
|
<strong>${h(data.kecamatan || '-')}</strong>
|
|
</div>
|
|
<div class="tooltip-row">
|
|
<span>Pelayanan</span>
|
|
<strong style="color:${data.ibadah_id ? 'var(--green)' : 'var(--red)'}">${data.ibadah_id ? h(data.ibadah_nama) : 'Belum terlayani'}</strong>
|
|
</div>
|
|
<div class="tooltip-row">
|
|
<span>Bantuan Rumah Ibadah</span>
|
|
<strong style="color:${data.bantuan_diterima && data.bantuan_diterima !== 'tidak_ada' ? 'var(--green)' : 'var(--text3)'}">
|
|
${({ sembako: 'Sembako Bulanan', beasiswa: 'Beasiswa Anak Asuh', modal: 'Modal Pemberdayaan', tunai: 'Santunan Tunai/Kesehatan', tidak_ada: 'Belum Menerima' }[data.bantuan_diterima] || 'Belum Menerima')}
|
|
</strong>
|
|
</div>
|
|
${isPemangku ? '' : `<div class="tooltip-row">
|
|
<span>Status</span>
|
|
<span class="badge badge-${data.status}">${statusLabel(data.status)}</span>
|
|
</div>`}
|
|
<div class="tooltip-footer">
|
|
<button class="tooltip-btn" onclick="event.stopPropagation(); openPreviewModal('warga', ${data.id})">
|
|
<i class="fa-solid fa-eye"></i> Detail
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Fase 7: Lazy Tooltip Allocation
|
|
mk.on('mouseover', function (e) {
|
|
if (!mk._tooltipInstance) {
|
|
const tooltip = L.tooltip({
|
|
interactive: true,
|
|
direction: 'top',
|
|
offset: type === 'ibadah' ? [0, -28] : [0, -10],
|
|
className: 'interactive-map-tooltip'
|
|
}).setContent(tooltipContent);
|
|
|
|
tooltip.on('add', function () {
|
|
const el = tooltip.getElement();
|
|
if (el) {
|
|
el.style.pointerEvents = 'auto';
|
|
el.addEventListener('mouseenter', () => {
|
|
if (mk._closeTimeout) {
|
|
clearTimeout(mk._closeTimeout);
|
|
mk._closeTimeout = null;
|
|
}
|
|
});
|
|
el.addEventListener('mouseleave', () => {
|
|
mk._closeTimeout = setTimeout(() => {
|
|
tooltip.remove();
|
|
if (window.activeMapTooltip === tooltip) {
|
|
window.activeMapTooltip = null;
|
|
}
|
|
}, 200);
|
|
});
|
|
}
|
|
});
|
|
mk._tooltipInstance = tooltip;
|
|
}
|
|
|
|
const tooltip = mk._tooltipInstance;
|
|
if (mk._closeTimeout) {
|
|
clearTimeout(mk._closeTimeout);
|
|
mk._closeTimeout = null;
|
|
}
|
|
|
|
if (window.activeMapTooltip && window.activeMapTooltip !== tooltip) {
|
|
try { window.activeMapTooltip.remove(); } catch (err) { }
|
|
}
|
|
window.activeMapTooltip = tooltip;
|
|
|
|
tooltip.setLatLng(mk.getLatLng());
|
|
if (!tooltip._map) {
|
|
tooltip.addTo(m);
|
|
}
|
|
});
|
|
|
|
mk.on('mouseout', function (e) {
|
|
const tooltip = mk._tooltipInstance;
|
|
if (!tooltip) return;
|
|
mk._closeTimeout = setTimeout(() => {
|
|
tooltip.remove();
|
|
if (window.activeMapTooltip === tooltip) {
|
|
window.activeMapTooltip = null;
|
|
}
|
|
}, 300);
|
|
});
|
|
|
|
mk.on('click', () => openEditPanel(type, data.id));
|
|
}
|
|
|
|
let inputTempMarker = {};
|
|
function onMapClickInput(e, type) {
|
|
const { lat, lng } = e.latlng;
|
|
const mapId = type === 'ibadah' ? 'map-input-ib' : 'map-input-wg';
|
|
const m = mapInstances[mapId];
|
|
if (!m) return;
|
|
if (inputTempMarker[type]) m.removeLayer(inputTempMarker[type]);
|
|
const clr = type === 'ibadah' ? '#f59e0b' : '#ef4444';
|
|
inputTempMarker[type] = L.circleMarker([lat, lng], { radius: 10, color: clr, fillColor: clr, fillOpacity: .7, weight: 2 }).addTo(m);
|
|
if (type === 'ibadah') {
|
|
pickedCoord.ibadah = { lat, lng };
|
|
const ci = document.getElementById('ci-coords');
|
|
if (ci) { ci.textContent = lat.toFixed(6) + ', ' + lng.toFixed(6); document.getElementById('coord-ib-box').classList.add('has-coord'); }
|
|
const btn = document.getElementById('btn-ibadah');
|
|
if (btn) btn.disabled = false;
|
|
reverseGeocode(lat, lng, data => {
|
|
if (data) {
|
|
const el = document.getElementById('f-alamat');
|
|
if (el) el.value = data.display_name || '';
|
|
|
|
const addr = data.address || {};
|
|
const kec = addr.city_district || addr.subdistrict || addr.town || addr.municipality || '';
|
|
const elKec = document.getElementById('f-kecamatan');
|
|
if (elKec && kec) elKec.value = kec.trim();
|
|
}
|
|
});
|
|
} else {
|
|
pickedCoord.miskin = { lat, lng };
|
|
const cm = document.getElementById('cm-coords');
|
|
if (cm) { cm.textContent = lat.toFixed(6) + ', ' + lng.toFixed(6); document.getElementById('coord-wg-box').classList.add('has-coord'); }
|
|
const btn = document.getElementById('btn-miskin');
|
|
if (btn) btn.disabled = false;
|
|
reverseGeocode(lat, lng, data => {
|
|
if (data) {
|
|
const el = document.getElementById('m-alamat');
|
|
if (el) el.value = data.display_name || '';
|
|
|
|
const addr = data.address || {};
|
|
const kec = addr.city_district || addr.subdistrict || addr.town || addr.municipality || '';
|
|
const elKec = document.getElementById('m-kecamatan');
|
|
if (elKec && kec) elKec.value = kec.trim();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function flyToOnMap(lat, lng) {
|
|
if (map) { map.flyTo([lat, lng], 16, { duration: 1 }); }
|
|
}
|
|
function flyToMain(lat, lng, id, type) {
|
|
flyToOnMap(lat, lng);
|
|
openEditPanel(type, id);
|
|
}
|
|
|
|
|
|
|
|
// -- ADD MODE -------------------------------
|
|
function setAddMode(type) {
|
|
addMode = type;
|
|
}
|
|
function cancelAddMode() {
|
|
addMode = null;
|
|
Object.values(inputTempMarker).forEach(m => { if (m._map) m._map.removeLayer(m); });
|
|
inputTempMarker = {};
|
|
pickedCoord = { ibadah: null, miskin: null };
|
|
}
|
|
|
|
// -- UPLOAD + EXIF
|
|
function setupUploadDragDrop(areaId) {
|
|
const area = document.getElementById(areaId);
|
|
if (!area) return;
|
|
area.addEventListener('dragover', e => { e.preventDefault(); area.classList.add('drag'); });
|
|
area.addEventListener('dragleave', () => area.classList.remove('drag'));
|
|
area.addEventListener('drop', e => {
|
|
e.preventDefault(); area.classList.remove('drag');
|
|
if (e.dataTransfer.files.length > 0) handleFotoFile(e.dataTransfer.files[0], areaId.replace('upload-', ''));
|
|
});
|
|
}
|
|
function handleFotoUpload(input, type) { if (input.files.length > 0) handleFotoFile(input.files[0], type); }
|
|
async function handleFotoFile(file, type) {
|
|
extractExifClient(file, type);
|
|
const reader = new FileReader();
|
|
reader.onload = e => {
|
|
const pr = document.getElementById('preview-' + type);
|
|
const img = document.getElementById('preview-' + type + '-img');
|
|
if (pr && img) { img.src = e.target.result; pr.style.display = 'block'; }
|
|
const ua = document.getElementById('upload-' + type);
|
|
if (ua) ua.style.display = 'none';
|
|
};
|
|
reader.readAsDataURL(file);
|
|
const fd = new FormData(); fd.append('foto', file);
|
|
try {
|
|
const res = await fetch(API.upload, { method: 'POST', body: fd, credentials: 'include' }).then(r => r.json());
|
|
if (!res.success) throw new Error(res.error);
|
|
uploadedFotoPath[type] = res.foto_path;
|
|
if (res.has_gps && res.lat && res.lng) {
|
|
setCoordFromExif(type, res.lat, res.lng);
|
|
const b = document.getElementById('exif-badge-' + type);
|
|
if (b) b.innerHTML = '<span class="exif-chip exif-found"><i class="fa-solid fa-location-dot"></i> GPS terdeteksi dari foto!</span>';
|
|
} else {
|
|
const b = document.getElementById('exif-badge-' + type);
|
|
if (b) b.innerHTML = '<span class="exif-chip exif-none"><i class="fa-solid fa-location-crosshairs"></i> Tidak ada GPS klik peta</span>';
|
|
}
|
|
} catch (e) { showToast('Upload gagal: ' + e.message, 'error'); }
|
|
}
|
|
function removeFoto(type) {
|
|
const pr = document.getElementById('preview-' + type); if (pr) pr.style.display = 'none';
|
|
const ua = document.getElementById('upload-' + type); if (ua) ua.style.display = 'block';
|
|
uploadedFotoPath[type] = null;
|
|
const b = document.getElementById('exif-badge-' + type); if (b) b.innerHTML = '';
|
|
}
|
|
function setCoordFromExif(type, lat, lng) {
|
|
if (type === 'ibadah') {
|
|
pickedCoord.ibadah = { lat, lng };
|
|
const ci = document.getElementById('ci-coords'); if (ci) ci.textContent = lat.toFixed(6) + ', ' + lng.toFixed(6);
|
|
const cb = document.getElementById('coord-ib-box'); if (cb) cb.classList.add('has-coord');
|
|
const btn = document.getElementById('btn-ibadah'); if (btn) btn.disabled = false;
|
|
reverseGeocode(lat, lng, data => {
|
|
if (data) {
|
|
const el = document.getElementById('f-alamat');
|
|
if (el) el.value = data.display_name || '';
|
|
|
|
const addr = data.address || {};
|
|
const kec = addr.city_district || addr.subdistrict || addr.town || addr.municipality || '';
|
|
const elKec = document.getElementById('f-kecamatan');
|
|
if (elKec && kec) elKec.value = kec.trim();
|
|
}
|
|
});
|
|
} else {
|
|
pickedCoord.miskin = { lat, lng };
|
|
const cm = document.getElementById('cm-coords'); if (cm) cm.textContent = lat.toFixed(6) + ', ' + lng.toFixed(6);
|
|
const cb = document.getElementById('coord-wg-box'); if (cb) cb.classList.add('has-coord');
|
|
const btn = document.getElementById('btn-miskin'); if (btn) btn.disabled = false;
|
|
reverseGeocode(lat, lng, data => {
|
|
if (data) {
|
|
const el = document.getElementById('m-alamat');
|
|
if (el) el.value = data.display_name || '';
|
|
|
|
const addr = data.address || {};
|
|
const kec = addr.city_district || addr.subdistrict || addr.town || addr.municipality || '';
|
|
const elKec = document.getElementById('m-kecamatan');
|
|
if (elKec && kec) elKec.value = kec.trim();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
function extractExifClient(file, type) {
|
|
if (file.type !== 'image/jpeg') return;
|
|
const reader = new FileReader();
|
|
reader.onload = function (e) {
|
|
const view = new DataView(e.target.result);
|
|
const gps = parseExifGps(view);
|
|
if (gps) { setCoordFromExif(type, gps.lat, gps.lng); const b = document.getElementById('exif-badge-' + type); if (b) b.innerHTML = '<span class="exif-chip exif-found"><i class="fa-solid fa-location-dot"></i> GPS terdeteksi dari foto!</span>'; }
|
|
};
|
|
reader.readAsArrayBuffer(file);
|
|
}
|
|
function parseExifGps(view) {
|
|
if (view.byteLength < 14) return null;
|
|
if (view.getUint16(0) !== 0xFFD8) return null;
|
|
let offset = 2;
|
|
while (offset < view.byteLength - 2) {
|
|
const marker = view.getUint16(offset);
|
|
if (marker === 0xFFE1) {
|
|
const length = view.getUint16(offset + 2);
|
|
const exifData = offset + 4;
|
|
if (view.getUint32(exifData) === 0x45786966 && view.getUint16(exifData + 4) === 0x0000) {
|
|
return parseExifTiff(view, exifData + 6, length - 6);
|
|
}
|
|
offset += 2 + length;
|
|
} else if ((marker & 0xFF00) === 0xFF00) {
|
|
offset += 2 + view.getUint16(offset + 2);
|
|
} else break;
|
|
}
|
|
return null;
|
|
}
|
|
function parseExifTiff(view, start, len) {
|
|
try {
|
|
const le = view.getUint16(start) === 0x4949;
|
|
const read16 = (o) => view.getUint16(o, le);
|
|
const read32 = (o) => view.getUint32(o, le);
|
|
if (read16(start + 2) !== 42) return null;
|
|
const ifd0 = start + read32(start + 4);
|
|
const n = read16(ifd0);
|
|
let gpsOffset = null;
|
|
for (let i = 0; i < n; i++) {
|
|
const e = ifd0 + 2 + i * 12;
|
|
if (read16(e) === 0x8825) { gpsOffset = start + read32(e + 8); break; }
|
|
}
|
|
if (!gpsOffset) return null;
|
|
const ng = read16(gpsOffset);
|
|
let latRef = 'N', lat = null, lngRef = 'E', lng = null;
|
|
for (let i = 0; i < ng; i++) {
|
|
const e = gpsOffset + 2 + i * 12;
|
|
const tag = read16(e);
|
|
const type = read16(e + 2);
|
|
const count = read32(e + 4);
|
|
const valOff = e + 8;
|
|
if (tag === 1) { latRef = String.fromCharCode(view.getUint8(valOff)); }
|
|
else if (tag === 3) { lngRef = String.fromCharCode(view.getUint8(valOff)); }
|
|
else if (tag === 2 || tag === 4) {
|
|
const ptr = start + read32(valOff);
|
|
const d = readRational(view, ptr, le);
|
|
const m2 = readRational(view, ptr + 8, le);
|
|
const s = readRational(view, ptr + 16, le);
|
|
const dec = d + m2 / 60 + s / 3600;
|
|
if (tag === 2) lat = dec; else lng = dec;
|
|
}
|
|
}
|
|
if (lat === null || lng === null) return null;
|
|
return { lat: latRef === 'S' ? -lat : lat, lng: lngRef === 'W' ? -lng : lng };
|
|
} catch { return null; }
|
|
}
|
|
function readRational(view, offset, le) {
|
|
const num = view.getUint32(offset, le);
|
|
const den = view.getUint32(offset + 4, le);
|
|
return den === 0 ? 0 : num / den;
|
|
}
|
|
|
|
// -- SUBMIT IBADAH
|
|
async function submitIbadah() {
|
|
const nama = document.getElementById('f-nama')?.value.trim();
|
|
const jenis = document.getElementById('f-jenis')?.value;
|
|
const alamat = document.getElementById('f-alamat')?.value.trim();
|
|
const pic = document.getElementById('f-pic')?.value.trim();
|
|
const wa = document.getElementById('f-wa')?.value.trim();
|
|
const radius = parseInt(document.getElementById('f-radius')?.value || 500);
|
|
const kec = document.getElementById('f-kecamatan')?.value.trim() || null;
|
|
const coord = pickedCoord.ibadah;
|
|
if (!nama) return showToast('Masukkan nama rumah ibadah', 'error');
|
|
if (!coord) return showToast('Pilih lokasi di peta terlebih dahulu', 'error');
|
|
if (!pic) return showToast('Masukkan nama PIC', 'error');
|
|
const btn = document.getElementById('btn-ibadah');
|
|
btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Menyimpan...';
|
|
try {
|
|
const res = await fetch(API.ibadah, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
|
body: JSON.stringify({ nama, jenis, alamat, pic, no_wa: wa, radius, lat: coord.lat, lng: coord.lng, foto_path: uploadedFotoPath.ibadah, kecamatan: kec })
|
|
}).then(r => r.json());
|
|
if (!res.success) throw new Error(res.error || 'Gagal');
|
|
const msg = res.status === 'pending' ? 'Data dikirim, menunggu verifikasi admin' : 'Rumah ibadah berhasil ditambahkan!';
|
|
showToast(msg, res.status === 'pending' ? 'info' : 'success');
|
|
uploadedFotoPath.ibadah = null; await loadAll(); switchPage('daftar');
|
|
} catch (e) { showToast('Error: ' + e.message, 'error'); }
|
|
btn.disabled = false; btn.innerHTML = '<i class="fa-solid fa-plus"></i> Tambah Rumah Ibadah';
|
|
}
|
|
|
|
// -- SUBMIT WARGA
|
|
async function submitMiskin() {
|
|
const nama = document.getElementById('m-nama')?.value.trim();
|
|
const anggota = parseInt(document.getElementById('m-anggota')?.value || 1);
|
|
const ket = document.getElementById('m-ket')?.value.trim();
|
|
const nik = document.getElementById('m-nik')?.value.trim() || null;
|
|
const bantuan = document.getElementById('m-bantuan')?.value || 'tidak_ada';
|
|
const alamat = document.getElementById('m-alamat')?.value.trim() || null;
|
|
const kec = document.getElementById('m-kecamatan')?.value.trim() || null;
|
|
const coord = pickedCoord.miskin;
|
|
if (!nama) return showToast('Masukkan nama Kepala Keluarga', 'error');
|
|
if (!coord) return showToast('Pilih lokasi di peta', 'error');
|
|
if (nik && !/^\d{16}$/.test(nik)) return showToast('NIK harus tepat 16 digit angka', 'error');
|
|
const btn = document.getElementById('btn-miskin');
|
|
btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Menyimpan...';
|
|
try {
|
|
const res = await fetch(API.warga, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
|
body: JSON.stringify({ nama_kk: nama, jumlah_anggota: anggota, keterangan: ket, lat: coord.lat, lng: coord.lng, foto_path: uploadedFotoPath.miskin, nik: nik, alamat: alamat, kecamatan: kec, bantuan_diterima: bantuan })
|
|
}).then(r => r.json());
|
|
if (!res.success) throw new Error(res.error || 'Gagal');
|
|
let msg = res.status === 'pending' ? 'Data dikirim, menunggu verifikasi' : (res.ibadah_nama ? `Masuk tanggung jawab: ${res.ibadah_nama}` : 'Ditambahkan belum ada ibadah terdekat');
|
|
showToast(msg, res.status === 'pending' ? 'info' : (res.ibadah_nama ? 'success' : 'info'));
|
|
uploadedFotoPath.miskin = null; await loadAll(); switchPage('daftar');
|
|
} catch (e) { showToast('Error: ' + e.message, 'error'); }
|
|
btn.disabled = false; btn.innerHTML = '<i class="fa-solid fa-plus"></i> Tambah Data Warga';
|
|
}
|
|
|
|
// -- EDIT PANEL
|
|
function openEditPanel(type, id) {
|
|
editState = { type, id }; dragActive = false;
|
|
const body = document.getElementById('ep-body');
|
|
const footer = document.getElementById('ep-footer');
|
|
const data = type === 'ibadah'
|
|
? (ibadahLayer[id]?.data || cachedIbadah.find(x => x.id == id))
|
|
: (wargaLayer[id]?.data || cachedWarga.find(x => x.id == id));
|
|
if (!data) return;
|
|
const editable = canEdit(type, data);
|
|
footer.style.display = editable ? 'flex' : 'none';
|
|
|
|
const saveBtn = document.getElementById('ep-save');
|
|
if (saveBtn) {
|
|
if (data.status === 'rejected') {
|
|
saveBtn.innerHTML = '<i class="fa-solid fa-paper-plane"></i> Ajukan Ulang';
|
|
} else {
|
|
saveBtn.innerHTML = '<i class="fa-solid fa-floppy-disk"></i> Simpan';
|
|
}
|
|
}
|
|
|
|
document.getElementById('ep-title').innerHTML = type === 'ibadah' ? `<i class="${ICONS[data.jenis]}"></i> Edit Ibadah` : '<i class="fa-solid fa-user"></i> Edit Warga';
|
|
if (type === 'ibadah') {
|
|
const ib = data;
|
|
body.innerHTML = `
|
|
${ib.foto_path ? `<img src="${ib.foto_path}" style="width:100%;border-radius:var(--radius-sm);margin-bottom:14px;max-height:150px;object-fit:cover;cursor:pointer" onclick="window.open('${ib.foto_path}','_blank')">` : ''}
|
|
<div style="margin-bottom:12px"><span class="badge badge-${ib.status}">${statusLabel(ib.status)}</span>${ib.status === 'rejected' && ib.alasan_penolakan ? `<div style="color:var(--red);font-size:11px;margin-top:6px;padding:8px;background:var(--red-light);border-radius:var(--radius-sm)"><i class="fa-solid fa-circle-xmark" style="margin-right:3px"></i>${h(ib.alasan_penolakan)}</div>` : ''}</div>
|
|
<div class="form-group"><label>Jenis</label><select id="e-jenis" ${!editable ? 'disabled' : ''}>
|
|
<option value="masjid" ${ib.jenis === 'masjid' ? 'selected' : ''}>Masjid</option>
|
|
<option value="gereja" ${ib.jenis === 'gereja' ? 'selected' : ''}>Gereja</option>
|
|
<option value="pura" ${ib.jenis === 'pura' ? 'selected' : ''}>Pura</option>
|
|
<option value="klenteng" ${ib.jenis === 'klenteng' ? 'selected' : ''}>Klenteng</option>
|
|
<option value="vihara" ${ib.jenis === 'vihara' ? 'selected' : ''}>Vihara</option>
|
|
<option value="lain" ${ib.jenis === 'lain' ? 'selected' : ''}>Lainnya</option>
|
|
</select></div>
|
|
<div class="form-group"><label>Nama</label><input type="text" id="e-nama" value="${h(ib.nama)}" ${!editable ? 'disabled' : ''}></div>
|
|
<div class="form-group"><label>Alamat</label><input type="text" id="e-alamat" value="${h(ib.alamat || '')}" ${!editable ? 'disabled' : ''}></div>
|
|
<input type="hidden" id="e-kecamatan" value="${h(ib.kecamatan || '')}">
|
|
<div class="form-group"><label>PIC</label><input type="text" id="e-pic" value="${h(ib.pic)}" ${!editable ? 'disabled' : ''}></div>
|
|
<div class="form-group"><label>No. WhatsApp</label><input type="text" id="e-wa" value="${h(ib.no_wa || '')}" ${!editable ? 'disabled' : ''}></div>
|
|
<div class="form-group"><label>Radius</label><div class="radius-row"><input type="range" id="e-radius" min="100" max="5000" step="100" value="${ib.radius}" ${!editable ? 'disabled' : ''} oninput="document.getElementById('e-rval').textContent=this.value+'m'"><div class="radius-val" id="e-rval">${ib.radius}m</div></div></div>
|
|
<div class="form-group"><label>Koordinat</label><div class="coord-box has-coord"><i class="fa-solid fa-location-dot" style="color:var(--green)"></i><span class="coord-val" id="e-ib-coord">${(+ib.lat).toFixed(6)}, ${(+ib.lng).toFixed(6)}</span></div></div>`;
|
|
} else {
|
|
const w = data;
|
|
body.innerHTML = `
|
|
${w.foto_path ? `<img src="${w.foto_path}" style="width:100%;border-radius:var(--radius-sm);margin-bottom:14px;max-height:150px;object-fit:cover;cursor:pointer" onclick="window.open('${w.foto_path}','_blank')">` : ''}
|
|
<div style="margin-bottom:12px"><span class="badge badge-${w.status}">${statusLabel(w.status)}</span>${w.status === 'rejected' && w.alasan_penolakan ? `<div style="color:var(--red);font-size:11px;margin-top:6px;padding:8px;background:var(--red-light);border-radius:var(--radius-sm)"><i class="fa-solid fa-circle-xmark" style="margin-right:3px"></i>${h(w.alasan_penolakan)}</div>` : ''}</div>
|
|
<div class="form-group"><label>Nama KK</label><input type="text" id="e-nama-kk" value="${h(w.nama_kk)}" ${!editable ? 'disabled' : ''}></div>
|
|
<div class="form-group"><label>NIK Kepala Keluarga</label><input type="text" id="e-nik" value="${h(w.nik || '')}" placeholder="16 Digit NIK Kepala Keluarga" maxlength="16" oninput="this.value = this.value.replace(/[^0-9]/g, '')" ${!editable ? 'disabled' : ''}></div>
|
|
<div class="form-group"><label>Jumlah Anggota</label><input type="number" id="e-anggota" value="${w.jumlah_anggota}" min="1" max="30" ${!editable ? 'disabled' : ''}></div>
|
|
<div class="form-group"><label>Alamat Lengkap</label><input type="text" id="e-alamat" value="${h(w.alamat || '')}" ${!editable ? 'disabled' : ''}></div>
|
|
<input type="hidden" id="e-kecamatan" value="${h(w.kecamatan || '')}">
|
|
<div class="form-group"><label>Keterangan</label><textarea id="e-ket" rows="2" style="resize:vertical" ${!editable ? 'disabled' : ''}>${h(w.keterangan || '')}</textarea></div>
|
|
<div class="form-group"><label>Bantuan Rumah Ibadah</label>
|
|
<select id="e-bantuan" ${!editable ? 'disabled' : ''}>
|
|
<option value="tidak_ada" ${w.bantuan_diterima === 'tidak_ada' ? 'selected' : ''}>Belum Menerima Bantuan</option>
|
|
<option value="sembako" ${w.bantuan_diterima === 'sembako' ? 'selected' : ''}>Sembako Bulanan</option>
|
|
<option value="beasiswa" ${w.bantuan_diterima === 'beasiswa' ? 'selected' : ''}>Beasiswa Anak Asuh</option>
|
|
<option value="modal" ${w.bantuan_diterima === 'modal' ? 'selected' : ''}>Modal Pemberdayaan</option>
|
|
<option value="tunai" ${w.bantuan_diterima === 'tunai' ? 'selected' : ''}>Santunan Tunai/Kesehatan</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group"><label>Status Pelayanan</label>
|
|
<div class="coord-box ${w.ibadah_id ? 'has-coord' : ''}">
|
|
<span><i class="${w.ibadah_id ? 'fa-solid fa-circle-check' : 'fa-solid fa-triangle-exclamation'}" style="color:${w.ibadah_id ? 'var(--green)' : 'var(--amber)'}"></i></span>
|
|
<span class="coord-val" style="color:${w.ibadah_id ? 'var(--green)' : 'var(--red)'}">${h(w.ibadah_nama || 'Belum terlayani')}</span>
|
|
</div>
|
|
</div>
|
|
<div class="form-group"><label>Koordinat</label><div class="coord-box has-coord"><i class="fa-solid fa-location-dot" style="color:var(--green)"></i><span class="coord-val" id="e-w-coord">${(+w.lat).toFixed(6)}, ${(+w.lng).toFixed(6)}</span></div></div>`;
|
|
}
|
|
document.getElementById('edit-panel').classList.add('open');
|
|
setTimeout(() => initModalEditMap(type, data, editable), 100);
|
|
}
|
|
function closeEditPanel() {
|
|
document.getElementById('edit-panel').classList.remove('open');
|
|
if (mapInstances['map-edit-modal']) {
|
|
mapInstances['map-edit-modal'].remove();
|
|
delete mapInstances['map-edit-modal'];
|
|
}
|
|
window._editModalCoord = null;
|
|
window._editModalCircle = null;
|
|
editState = { type: null, id: null }; dragActive = false;
|
|
}
|
|
async function saveEdit() {
|
|
const { type, id } = editState;
|
|
if (!type || !id) return;
|
|
const data = type === 'ibadah'
|
|
? (ibadahLayer[id]?.data || cachedIbadah.find(x => x.id == id))
|
|
: (wargaLayer[id]?.data || cachedWarga.find(x => x.id == id));
|
|
const isRejected = data?.status === 'rejected';
|
|
const btn = document.getElementById('ep-save');
|
|
btn.disabled = true;
|
|
btn.innerHTML = isRejected
|
|
? '<i class="fa-solid fa-spinner fa-spin"></i> Mengajukan...'
|
|
: '<i class="fa-solid fa-spinner fa-spin"></i> Menyimpan...';
|
|
try {
|
|
let res;
|
|
if (type === 'ibadah') {
|
|
const ib = data;
|
|
res = await fetch(API.ibadah + '?id=' + id, {
|
|
method: 'PUT', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
|
body: JSON.stringify({
|
|
nama: document.getElementById('e-nama').value.trim(),
|
|
jenis: document.getElementById('e-jenis').value,
|
|
alamat: document.getElementById('e-alamat').value.trim(),
|
|
pic: document.getElementById('e-pic').value.trim(),
|
|
no_wa: document.getElementById('e-wa').value.trim(),
|
|
radius: parseInt(document.getElementById('e-radius').value),
|
|
kecamatan: document.getElementById('e-kecamatan')?.value || ib.kecamatan || '',
|
|
lat: window._editModalCoord?.lat || ib.lat,
|
|
lng: window._editModalCoord?.lng || ib.lng
|
|
})
|
|
}).then(r => r.json());
|
|
} else {
|
|
const w = data;
|
|
res = await fetch(API.warga + '?id=' + id, {
|
|
method: 'PUT', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
|
body: JSON.stringify({
|
|
nama_kk: document.getElementById('e-nama-kk').value.trim(),
|
|
jumlah_anggota: parseInt(document.getElementById('e-anggota').value) || 1,
|
|
keterangan: document.getElementById('e-ket').value.trim(),
|
|
nik: document.getElementById('e-nik')?.value.trim() || null,
|
|
alamat: document.getElementById('e-alamat')?.value.trim() || null,
|
|
kecamatan: document.getElementById('e-kecamatan')?.value || null,
|
|
bantuan_diterima: document.getElementById('e-bantuan')?.value || 'tidak_ada',
|
|
lat: window._editModalCoord?.lat || w.lat,
|
|
lng: window._editModalCoord?.lng || w.lng
|
|
})
|
|
}).then(r => r.json());
|
|
}
|
|
if (res && !res.success) {
|
|
showToast('Gagal menyimpan: ' + (res.error || 'Terjadi kesalahan'), 'error');
|
|
btn.disabled = false;
|
|
btn.innerHTML = isRejected
|
|
? '<i class="fa-solid fa-paper-plane"></i> Ajukan Ulang'
|
|
: '<i class="fa-solid fa-floppy-disk"></i> Simpan';
|
|
return;
|
|
}
|
|
showToast(isRejected ? 'Data berhasil diajukan ulang!' : 'Perubahan disimpan!', 'success');
|
|
closeEditPanel(); await loadAll();
|
|
if (currentPage === 'daftar' || currentPage === 'dashboard') renderPage(currentPage);
|
|
} catch (e) { showToast('Error: ' + e.message, 'error'); }
|
|
btn.disabled = false;
|
|
btn.innerHTML = isRejected
|
|
? '<i class="fa-solid fa-paper-plane"></i> Ajukan Ulang'
|
|
: '<i class="fa-solid fa-floppy-disk"></i> Simpan';
|
|
}
|
|
async function deleteFromEdit() {
|
|
const { type, id } = editState;
|
|
if (!type || !id) return;
|
|
if (!confirm('Hapus data ini?')) return;
|
|
const ep = type === 'ibadah' ? API.ibadah : API.warga;
|
|
const res = await fetch(ep + '?id=' + id, { method: 'DELETE', credentials: 'include' }).then(r => r.json());
|
|
if (!res.success) { showToast('Gagal hapus: ' + (res.error || ''), 'error'); return; }
|
|
showToast('Data dihapus', 'info'); closeEditPanel(); await loadAll();
|
|
if (currentPage === 'daftar') renderPage(currentPage);
|
|
}
|
|
async function delIbadah(id) { if (!confirm('Hapus rumah ibadah ini?')) return; await fetch(API.ibadah + '?id=' + id, { method: 'DELETE', credentials: 'include' }); showToast('Dihapus', 'info'); await loadAll(); renderPage(currentPage); }
|
|
async function delWarga(id) { if (!confirm('Hapus data warga ini?')) return; await fetch(API.warga + '?id=' + id, { method: 'DELETE', credentials: 'include' }); showToast('Dihapus', 'info'); await loadAll(); renderPage(currentPage); }
|
|
|
|
// -- LOAD ALL
|
|
async function loadAll() {
|
|
showLoading(true);
|
|
try {
|
|
const [ibRes, wRes, sRes] = await Promise.all([
|
|
fetch(API.ibadah, { credentials: 'include' }).then(r => r.json()),
|
|
fetch(API.warga, { credentials: 'include' }).then(r => r.json()),
|
|
fetch(API.stats, { credentials: 'include' }).then(r => r.json()),
|
|
]);
|
|
cachedIbadah = ibRes.data || []; cachedWarga = wRes.data || []; cachedStats = sRes;
|
|
_dataVersion++;
|
|
updateNavBadges();
|
|
} catch (e) { showToast('Gagal memuat: ' + e.message, 'error'); }
|
|
showLoading(false);
|
|
}
|
|
|
|
function updateNavBadges() {
|
|
const s = cachedStats;
|
|
if (currentUser?.role === 'admin') {
|
|
const total = (s.pending_ibadah || 0) + (s.pending_warga || 0);
|
|
const nb = document.getElementById('nb-verify');
|
|
if (nb) { nb.textContent = total; nb.style.display = total > 0 ? 'inline' : 'none'; }
|
|
const nd = document.getElementById('notif-dot');
|
|
if (nd) nd.style.display = total > 0 ? 'block' : 'none';
|
|
}
|
|
if (currentUser?.role === 'surveyor') {
|
|
const total = (s.my_pending_ibadah || 0) + (s.my_pending_warga || 0);
|
|
const nb = document.getElementById('nb-daftar');
|
|
if (nb) { nb.textContent = total; nb.style.display = total > 0 ? 'inline' : 'none'; }
|
|
}
|
|
}
|
|
|
|
async function applyGlobalRadius() {
|
|
const val = parseInt(document.getElementById('global-r')?.value || 500);
|
|
if (!confirm(`Ubah radius semua rumah ibadah menjadi ${val}m?`)) return;
|
|
for (const id of cachedIbadah.filter(i => i.status === 'verified').map(i => i.id)) {
|
|
await fetch(API.ibadah + '?id=' + id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ radius: val }) });
|
|
}
|
|
showToast(`Radius semua ${val}m`, 'success'); await loadAll(); renderPage(currentPage);
|
|
}
|
|
|
|
// -- MODAL MAP INITIALIZATION
|
|
function initModalPreviewMap(type, data) {
|
|
const containerId = 'map-preview-modal';
|
|
if (mapInstances[containerId]) { mapInstances[containerId].remove(); }
|
|
const center = [data.lat, data.lng];
|
|
const m = L.map(containerId, { zoomControl: true, attributionControl: false, preferCanvas: true }).setView(center, 16);
|
|
|
|
const osm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 });
|
|
const light = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { maxZoom: 19 });
|
|
const dark = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { maxZoom: 19 });
|
|
light.addTo(m);
|
|
|
|
// Overlay layer groups
|
|
const layerIbadah = L.layerGroup();
|
|
const layerWarga = L.layerGroup();
|
|
m.layerGroups = { ibadah: layerIbadah, warga: layerWarga };
|
|
|
|
// Populate overlays from cached data
|
|
cachedIbadah.filter(i => i.status === 'verified' || i.status === 'pending').forEach(ib => placeMarkerOn(m, ib, 'ibadah'));
|
|
cachedWarga.filter(w => w.status === 'verified' || w.status === 'pending').forEach(w => placeMarkerOn(m, w, 'warga'));
|
|
|
|
// Show both overlays by default in preview
|
|
layerIbadah.addTo(m);
|
|
layerWarga.addTo(m);
|
|
|
|
const baseMaps = {
|
|
"Default Street Map": osm,
|
|
"Clean Light Map": light,
|
|
"Premium Dark Map": dark
|
|
};
|
|
const overlayMaps = {
|
|
"Rumah Ibadah": layerIbadah,
|
|
"Warga Miskin": layerWarga
|
|
};
|
|
L.control.layers(baseMaps, overlayMaps, { collapsed: true }).addTo(m);
|
|
|
|
const pending = data.status === 'pending';
|
|
const opacity = pending ? 0.5 : 1;
|
|
|
|
if (type === 'ibadah') {
|
|
const clr = COLORS[data.jenis] || '#6b7280';
|
|
const isVerified = data.status === 'verified';
|
|
const isPending = data.status === 'pending';
|
|
const opacity = data.status === 'rejected' ? 0.45 : isPending ? 0.5 : 1;
|
|
L.circle([data.lat, data.lng], { radius: data.radius, color: clr, fillColor: clr, fillOpacity: isVerified ? .07 : .02, weight: isVerified ? 2 : 1, dashArray: isVerified ? null : '5 4', opacity }).addTo(m);
|
|
const badgeIcon = isVerified ? '' : isPending
|
|
? `<div style="position:absolute;top:-4px;right:-4px;width:13px;height:13px;background:#f59e0b;border-radius:50%;border:1.5px solid #fff;display:flex;align-items:center;justify-content:center;font-size:7px;color:#fff;font-weight:700">!</div>`
|
|
: `<div style="position:absolute;top:-4px;right:-4px;width:13px;height:13px;background:#ef4444;border-radius:50%;border:1.5px solid #fff;display:flex;align-items:center;justify-content:center;font-size:7px;color:#fff;font-weight:700"> </div>`;
|
|
const ic = L.divIcon({
|
|
className: '',
|
|
html: `<div style="position:relative;width:36px;height:36px;opacity:${opacity}">
|
|
<div style="width:36px;height:36px;background:${clr};border-radius:50% 50% 50% 4px;transform:rotate(-45deg);border:3px solid rgba(255,255,255,.9);box-shadow:0 3px 14px rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;">
|
|
<i class="${ICONS[data.jenis] || 'fa-solid fa-place-of-worship'}" style="transform:rotate(45deg);font-size:14px;color:#fff;"></i>
|
|
</div>
|
|
${badgeIcon}
|
|
</div>`,
|
|
iconSize: [36, 36], iconAnchor: [18, 32]
|
|
});
|
|
L.marker([data.lat, data.lng], { icon: ic }).addTo(m).bindPopup(`<strong>${data.nama}</strong><br>${data.radius}m radius`).openPopup();
|
|
} else {
|
|
const sc = STATUS_COLORS.warga;
|
|
const covered = !!data.ibadah_id;
|
|
const isVerified = data.status === 'verified';
|
|
const clrDot = isVerified ? (covered ? sc.verified_covered : sc.verified_uncovered)
|
|
: data.status === 'pending' ? sc.pending : sc.rejected;
|
|
const opacity = data.status === 'rejected' ? 0.45 : 1;
|
|
const borderStyle = isVerified ? '3px solid rgba(255,255,255,.85)' : '2px dashed rgba(255,255,255,.85)';
|
|
const ic = L.divIcon({
|
|
className: '',
|
|
html: `<div style="width:18px;height:18px;background:${clrDot};border-radius:50%;border:${borderStyle};box-shadow:0 2px 8px rgba(0,0,0,.4);opacity:${opacity}"></div>`,
|
|
iconSize: [18, 18], iconAnchor: [9, 9]
|
|
});
|
|
L.marker([data.lat, data.lng], { icon: ic }).addTo(m).bindPopup(`<strong>${data.nama_kk}</strong><br>${data.jumlah_anggota} jiwa`).openPopup();
|
|
|
|
if (data.ibadah_id) {
|
|
const ib = cachedIbadah.find(x => x.id == data.ibadah_id);
|
|
if (ib) {
|
|
const clrIb = COLORS[ib.jenis] || '#6b7280';
|
|
const ibVerified = ib.status === 'verified';
|
|
const ibPending = ib.status === 'pending';
|
|
const ibOpacity = ib.status === 'rejected' ? 0.45 : ibPending ? 0.5 : 1;
|
|
L.circle([ib.lat, ib.lng], { radius: ib.radius, color: clrIb, fillColor: clrIb, fillOpacity: ibVerified ? .08 : .02, weight: ibVerified ? 2 : 1, dashArray: ibVerified ? null : '5 4', opacity: ibOpacity }).addTo(m);
|
|
const ibBadge = ibVerified ? '' : ibPending
|
|
? `<div style="position:absolute;top:-4px;right:-4px;width:13px;height:13px;background:#f59e0b;border-radius:50%;border:1.5px solid #fff;display:flex;align-items:center;justify-content:center;font-size:7px;color:#fff;font-weight:700">!</div>`
|
|
: `<div style="position:absolute;top:-4px;right:-4px;width:13px;height:13px;background:#ef4444;border-radius:50%;border:1.5px solid #fff;display:flex;align-items:center;justify-content:center;font-size:7px;color:#fff;font-weight:700"> </div>`;
|
|
const icIb = L.divIcon({
|
|
className: '',
|
|
html: `<div style="position:relative;width:32px;height:32px;opacity:${ibOpacity}">
|
|
<div style="width:32px;height:32px;background:${clrIb};border-radius:50% 50% 50% 4px;transform:rotate(-45deg);border:3px solid rgba(255,255,255,.9);box-shadow:0 3px 12px rgba(0,0,0,.4);display:flex;align-items:center;justify-content:center;">
|
|
<i class="${ICONS[ib.jenis] || 'fa-solid fa-place-of-worship'}" style="transform:rotate(45deg);font-size:13px;color:#fff;"></i>
|
|
</div>
|
|
${ibBadge}
|
|
</div>`,
|
|
iconSize: [32, 32], iconAnchor: [16, 28]
|
|
});
|
|
L.marker([ib.lat, ib.lng], { icon: icIb }).addTo(m);
|
|
L.polyline([[data.lat, data.lng], [ib.lat, ib.lng]], { color: '#10b981', weight: 2.5, dashArray: '6 5', opacity: 0.85 }).addTo(m);
|
|
const bounds = L.latLngBounds([[data.lat, data.lng], [ib.lat, ib.lng]]);
|
|
m.fitBounds(bounds, { padding: [50, 50], maxZoom: 16 });
|
|
}
|
|
}
|
|
}
|
|
|
|
mapInstances[containerId] = m;
|
|
setTimeout(() => { m.invalidateSize(); }, 100);
|
|
}
|
|
|
|
function initModalEditMap(type, data, editable) {
|
|
const containerId = 'map-edit-modal';
|
|
if (mapInstances[containerId]) { mapInstances[containerId].remove(); }
|
|
const center = [data.lat, data.lng];
|
|
const m = L.map(containerId, { zoomControl: true, attributionControl: false, preferCanvas: true }).setView(center, 16);
|
|
|
|
const osm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 });
|
|
const light = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { maxZoom: 19 });
|
|
const dark = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { maxZoom: 19 });
|
|
light.addTo(m);
|
|
|
|
// Overlay layer groups for context
|
|
const layerIbadah = L.layerGroup();
|
|
const layerWarga = L.layerGroup();
|
|
m.layerGroups = { ibadah: layerIbadah, warga: layerWarga };
|
|
|
|
// Populate from cache (excluding the currently edited item)
|
|
cachedIbadah.filter(i => i.status !== 'rejected' && !(type === 'ibadah' && i.id == data.id)).forEach(ib => placeMarkerOn(m, ib, 'ibadah'));
|
|
cachedWarga.filter(w => w.status !== 'rejected' && !(type === 'warga' && w.id == data.id)).forEach(w => placeMarkerOn(m, w, 'warga'));
|
|
|
|
// Show relevant overlay by default
|
|
if (type === 'ibadah') { layerIbadah.addTo(m); }
|
|
else { layerWarga.addTo(m); layerIbadah.addTo(m); }
|
|
|
|
const baseMaps = {
|
|
"Default Street Map": osm,
|
|
"Clean Light Map": light,
|
|
"Premium Dark Map": dark
|
|
};
|
|
const overlayMaps = {
|
|
"Rumah Ibadah": layerIbadah,
|
|
"Warga Miskin": layerWarga
|
|
};
|
|
L.control.layers(baseMaps, overlayMaps, { collapsed: true }).addTo(m);
|
|
|
|
const pending = data.status === 'pending';
|
|
const opacity = pending ? 0.5 : 1;
|
|
|
|
window._editModalCoord = { lat: data.lat, lng: data.lng };
|
|
window._editModalCircle = null;
|
|
|
|
let mk;
|
|
if (type === 'ibadah') {
|
|
const clr = COLORS[data.jenis] || '#6b7280';
|
|
const isVerified = data.status === 'verified';
|
|
const isPending = data.status === 'pending';
|
|
const opacityEdit = data.status === 'rejected' ? 0.45 : isPending ? 0.5 : 1;
|
|
window._editModalCircle = L.circle([data.lat, data.lng], { radius: data.radius, color: clr, fillColor: clr, fillOpacity: isVerified ? .07 : .02, weight: isVerified ? 2 : 1, dashArray: isVerified ? null : '5 4', opacity: opacityEdit }).addTo(m);
|
|
|
|
const badgeEdit = isVerified ? '' : isPending
|
|
? `<div style="position:absolute;top:-4px;right:-4px;width:13px;height:13px;background:#f59e0b;border-radius:50%;border:1.5px solid #fff;display:flex;align-items:center;justify-content:center;font-size:7px;color:#fff;font-weight:700">!</div>`
|
|
: `<div style="position:absolute;top:-4px;right:-4px;width:13px;height:13px;background:#ef4444;border-radius:50%;border:1.5px solid #fff;display:flex;align-items:center;justify-content:center;font-size:7px;color:#fff;font-weight:700"> </div>`;
|
|
const ic = L.divIcon({
|
|
className: '',
|
|
html: `<div style="position:relative;width:32px;height:32px;opacity:${opacityEdit}">
|
|
<div style="width:32px;height:32px;background:${clr};border-radius:50% 50% 50% 4px;transform:rotate(-45deg);border:3px solid rgba(255,255,255,.9);box-shadow:0 3px 12px rgba(0,0,0,.4);display:flex;align-items:center;justify-content:center;">
|
|
<i class="${ICONS[data.jenis] || 'fa-solid fa-place-of-worship'}" style="transform:rotate(45deg);font-size:13px;color:#fff;"></i>
|
|
</div>
|
|
${badgeEdit}
|
|
</div>`,
|
|
iconSize: [32, 32], iconAnchor: [16, 28]
|
|
});
|
|
mk = L.marker([data.lat, data.lng], { icon: ic, draggable: editable }).addTo(m);
|
|
|
|
const slider = document.getElementById('e-radius');
|
|
if (slider) {
|
|
slider.addEventListener('input', function (e) {
|
|
if (window._editModalCircle) {
|
|
window._editModalCircle.setRadius(parseInt(e.target.value));
|
|
}
|
|
});
|
|
}
|
|
} else {
|
|
const sc = STATUS_COLORS.warga;
|
|
const covered = !!data.ibadah_id;
|
|
const isVerified = data.status === 'verified';
|
|
const clrDot = isVerified ? (covered ? sc.verified_covered : sc.verified_uncovered)
|
|
: data.status === 'pending' ? sc.pending : sc.rejected;
|
|
const opacity = data.status === 'rejected' ? 0.45 : 1;
|
|
const borderStyle = isVerified ? '2.5px solid rgba(255,255,255,.7)' : '2px dashed rgba(255,255,255,.85)';
|
|
const ic = L.divIcon({
|
|
className: '',
|
|
html: `<div style="width:20px;height:20px;background:${clrDot};border-radius:50%;border:${borderStyle};box-shadow:0 2px 6px rgba(0,0,0,.35);opacity:${opacity}"></div>`,
|
|
iconSize: [20, 20], iconAnchor: [10, 10]
|
|
});
|
|
mk = L.marker([data.lat, data.lng], { icon: ic, draggable: editable }).addTo(m);
|
|
}
|
|
|
|
if (editable) {
|
|
m.getContainer().style.cursor = 'crosshair';
|
|
|
|
const updateLoc = (lat, lng) => {
|
|
window._editModalCoord = { lat, lng };
|
|
const coordSpanId = type === 'ibadah' ? 'e-ib-coord' : 'e-w-coord';
|
|
const span = document.getElementById(coordSpanId);
|
|
if (span) span.textContent = lat.toFixed(6) + ', ' + lng.toFixed(6);
|
|
|
|
mk.setLatLng([lat, lng]);
|
|
if (window._editModalCircle) window._editModalCircle.setLatLng([lat, lng]);
|
|
|
|
reverseGeocode(lat, lng, res => {
|
|
if (res) {
|
|
const elAlamat = document.getElementById('e-alamat');
|
|
if (elAlamat) elAlamat.value = res.display_name || '';
|
|
|
|
const addr = res.address || {};
|
|
const kec = addr.city_district || addr.subdistrict || addr.town || addr.municipality || '';
|
|
const elKec = document.getElementById('e-kecamatan');
|
|
if (elKec && kec) elKec.value = kec.trim();
|
|
}
|
|
});
|
|
};
|
|
|
|
mk.on('dragend', function (e) {
|
|
const latlng = mk.getLatLng();
|
|
updateLoc(latlng.lat, latlng.lng);
|
|
});
|
|
|
|
m.on('click', function (e) {
|
|
updateLoc(e.latlng.lat, e.latlng.lng);
|
|
});
|
|
}
|
|
|
|
mapInstances[containerId] = m;
|
|
setTimeout(() => { m.invalidateSize(); }, 100);
|
|
}
|
|
|
|
// -- PREVIEW MODAL (Pemangku)
|
|
function openPreviewModal(type, id) {
|
|
const data = type === 'ibadah'
|
|
? cachedIbadah.find(x => x.id == id)
|
|
: cachedWarga.find(x => x.id == id);
|
|
if (!data) return;
|
|
const modal = document.getElementById('preview-modal');
|
|
const body = document.getElementById('pb-body');
|
|
const title = document.getElementById('pb-title');
|
|
|
|
if (type === 'ibadah') {
|
|
const ib = data;
|
|
title.innerHTML = `<i class="${ICONS[ib.jenis]}" style="color:${COLORS[ib.jenis]}"></i> Detail Rumah Ibadah`;
|
|
body.innerHTML = `
|
|
${ib.foto_path
|
|
? `<div class="pb-foto-thumb" onclick="openLightbox('${ib.foto_path}')">
|
|
<img src="${ib.foto_path}" alt="Foto">
|
|
<div class="pb-foto-thumb-info">
|
|
<div class="pb-foto-thumb-label">Foto Dokumentasi</div>
|
|
<div class="pb-foto-thumb-hint"><i class="fa-solid fa-magnifying-glass-plus"></i> Klik untuk lihat penuh</div>
|
|
</div>
|
|
<i class="fa-solid fa-expand" style="color:var(--text3);font-size:13px"></i>
|
|
</div>`
|
|
: '<div style="padding:16px 12px;text-align:center;background:var(--bg);border-radius:var(--radius-sm);margin-bottom:14px;color:var(--text3);font-size:12px;border:1.5px dashed var(--border)"><i class="fa-solid fa-image" style="font-size:22px;display:block;margin-bottom:6px"></i>Tidak ada foto</div>'}
|
|
<div style="margin-bottom:14px"><span class="badge badge-${ib.status}">${statusLabel(ib.status)}</span>${ib.status === 'rejected' && ib.alasan_penolakan ? `
|
|
<div class="rejection-notice" style="margin-top:10px">
|
|
<i class="fa-solid fa-circle-xmark rn-icon"></i>
|
|
<div><div class="rn-label">Catatan Admin Alasan Penolakan</div>${h(ib.alasan_penolakan)}</div>
|
|
</div>` : ''}</div>
|
|
<div class="pb-field"><div class="pb-label">Nama</div><div class="pb-value">${h(ib.nama)}</div></div>
|
|
<div class="pb-field"><div class="pb-label">Jenis</div><div class="pb-value" style="text-transform:capitalize"><i class="${ICONS[ib.jenis]}" style="margin-right:6px;color:${COLORS[ib.jenis]}"></i>${h(ib.jenis)}</div></div>
|
|
<div class="pb-field"><div class="pb-label">Alamat Lengkap</div><div class="pb-value">${h(ib.alamat || '-')}</div></div>
|
|
<div class="pb-field-grid">
|
|
<div class="pb-field"><div class="pb-label">Kecamatan</div><div class="pb-value">${h(ib.kecamatan || '-')}</div></div>
|
|
<div class="pb-field"><div class="pb-label">PIC</div><div class="pb-value">${h(ib.pic || '-')}</div></div>
|
|
</div>
|
|
<div class="pb-field-grid">
|
|
<div class="pb-field"><div class="pb-label">No. WhatsApp</div><div class="pb-value">${h(ib.no_wa || '-')}</div></div>
|
|
<div class="pb-field"><div class="pb-label">Radius Pelayanan</div><div class="pb-value">${ib.radius}m</div></div>
|
|
</div>
|
|
<div class="pb-field-grid">
|
|
<div class="pb-field"><div class="pb-label">KK Terlayani</div><div class="pb-value">${ib.jumlah_kk || 0} KK ${ib.jumlah_jiwa || 0} jiwa</div></div>
|
|
<div class="pb-field"><div class="pb-label">Surveyor</div><div class="pb-value">${h(ib.surveyor_nama || '-')}</div></div>
|
|
</div>
|
|
<div class="pb-field-grid">
|
|
<div class="pb-field"><div class="pb-label">Koordinat</div><div class="pb-value"><i class="fa-solid fa-location-dot" style="color:var(--green);margin-right:6px"></i>${(+ib.lat).toFixed(6)}, ${(+ib.lng).toFixed(6)}</div></div>
|
|
<div class="pb-field"><div class="pb-label">Tanggal Input</div><div class="pb-value">${formatDate(ib.created_at)}</div></div>
|
|
</div>
|
|
`;
|
|
} else {
|
|
const w = data;
|
|
title.innerHTML = `<i class="fa-solid fa-user" style="color:var(--red)"></i> Detail Data Warga`;
|
|
body.innerHTML = `
|
|
${w.foto_path
|
|
? `<div class="pb-foto-thumb" onclick="openLightbox('${w.foto_path}')">
|
|
<img src="${w.foto_path}" alt="Foto">
|
|
<div class="pb-foto-thumb-info">
|
|
<div class="pb-foto-thumb-label">Foto Dokumentasi</div>
|
|
<div class="pb-foto-thumb-hint"><i class="fa-solid fa-magnifying-glass-plus"></i> Klik untuk lihat penuh</div>
|
|
</div>
|
|
<i class="fa-solid fa-expand" style="color:var(--text3);font-size:13px"></i>
|
|
</div>`
|
|
: '<div style="padding:16px 12px;text-align:center;background:var(--bg);border-radius:var(--radius-sm);margin-bottom:14px;color:var(--text3);font-size:12px;border:1.5px dashed var(--border)"><i class="fa-solid fa-image" style="font-size:22px;display:block;margin-bottom:6px"></i>Tidak ada foto</div>'}
|
|
<div style="margin-bottom:14px"><span class="badge badge-${w.status}">${statusLabel(w.status)}</span>${w.status === 'rejected' && w.alasan_penolakan ? `
|
|
<div class="rejection-notice" style="margin-top:10px">
|
|
<i class="fa-solid fa-circle-xmark rn-icon"></i>
|
|
<div><div class="rn-label">Catatan Admin Alasan Penolakan</div>${h(w.alasan_penolakan)}</div>
|
|
</div>` : ''}</div>
|
|
<div class="pb-field"><div class="pb-label">Nama Kepala Keluarga</div><div class="pb-value">${h(w.nama_kk)}</div></div>
|
|
<div class="pb-field-grid">
|
|
<div class="pb-field"><div class="pb-label">NIK Kepala Keluarga</div><div class="pb-value">${h(w.nik || '-')}</div></div>
|
|
<div class="pb-field"><div class="pb-label">Jumlah Anggota</div><div class="pb-value">${w.jumlah_anggota} jiwa</div></div>
|
|
</div>
|
|
<div class="pb-field"><div class="pb-label">Alamat Lengkap</div><div class="pb-value">${h(w.alamat || '-')}</div></div>
|
|
<div class="pb-field-grid">
|
|
<div class="pb-field"><div class="pb-label">Kecamatan</div><div class="pb-value">${h(w.kecamatan || '-')}</div></div>
|
|
<div class="pb-field"><div class="pb-label">Status Pelayanan</div><div class="pb-value" style="color:${w.ibadah_id ? 'var(--green)' : 'var(--red)'}"><i class="${w.ibadah_id ? 'fa-solid fa-circle-check' : 'fa-solid fa-triangle-exclamation'}" style="margin-right:6px"></i>${w.ibadah_id ? (h(w.ibadah_nama) || 'Terlayani') : 'Belum terlayani'}</div></div>
|
|
</div>
|
|
<div class="pb-field"><div class="pb-label">Bantuan Rumah Ibadah</div><div class="pb-value" style="font-weight:600;color:${w.bantuan_diterima && w.bantuan_diterima !== 'tidak_ada' ? 'var(--green)' : 'var(--text3)'}">${({ sembako: 'Sembako Bulanan', beasiswa: 'Beasiswa Anak Asuh', modal: 'Modal Pemberdayaan', tunai: 'Santunan Tunai/Kesehatan', tidak_ada: 'Belum Menerima Bantuan' }[w.bantuan_diterima] || 'Belum Menerima Bantuan')}</div></div>
|
|
<div class="pb-field"><div class="pb-label">Keterangan</div><div class="pb-value">${h(w.keterangan || '-')}</div></div>
|
|
<div class="pb-field-grid">
|
|
<div class="pb-field"><div class="pb-label">Surveyor</div><div class="pb-value">${h(w.surveyor_nama || '-')}</div></div>
|
|
<div class="pb-field"><div class="pb-label">Tanggal Input</div><div class="pb-value">${formatDate(w.created_at)}</div></div>
|
|
</div>
|
|
<div class="pb-field"><div class="pb-label">Koordinat</div><div class="pb-value"><i class="fa-solid fa-location-dot" style="color:var(--green);margin-right:6px"></i>${(+w.lat).toFixed(6)}, ${(+w.lng).toFixed(6)}</div></div>
|
|
`;
|
|
}
|
|
modal.classList.add('show');
|
|
setTimeout(() => initModalPreviewMap(type, data), 100);
|
|
}
|
|
function closePreviewModal() {
|
|
document.getElementById('preview-modal').classList.remove('show');
|
|
if (mapInstances['map-preview-modal']) {
|
|
mapInstances['map-preview-modal'].remove();
|
|
delete mapInstances['map-preview-modal'];
|
|
}
|
|
}
|
|
|
|
// -- HELPERS
|
|
function canEdit(type, data) {
|
|
if (currentUser.role === 'admin') return true;
|
|
if (currentUser.role === 'surveyor' && data.surveyor_id == currentUser.id && (data.status === 'pending' || data.status === 'rejected')) return true;
|
|
return false;
|
|
}
|
|
function statusLabel(s) {
|
|
if (s === 'pending') return 'Pending';
|
|
if (s === 'verified') return 'Verified';
|
|
if (s === 'rejected') return 'Ditolak';
|
|
return s;
|
|
}
|
|
function h(s) { return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
|
function formatDate(d) { if (!d) return ''; const dt = new Date(d); return dt.toLocaleDateString('id-ID', { day: '2-digit', month: 'short', year: 'numeric' }) + ' ' + dt.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' }); }
|
|
function showLoading(v) { const el = document.getElementById('loading'); el.classList.toggle('show', v); }
|
|
// -- Geocode Cache
|
|
const _geocodeCache = {};
|
|
let _geocodeTimer = null;
|
|
|
|
async function reverseGeocode(lat, lng, cb) {
|
|
const key = lat.toFixed(4) + ',' + lng.toFixed(4);
|
|
if (_geocodeCache[key]) { cb(_geocodeCache[key]); return; }
|
|
|
|
clearTimeout(_geocodeTimer);
|
|
_geocodeTimer = setTimeout(async () => {
|
|
try {
|
|
const r = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&accept-language=id`);
|
|
const d = await r.json();
|
|
_geocodeCache[key] = d;
|
|
cb(d);
|
|
}
|
|
catch { cb(null); }
|
|
}, 300);
|
|
}
|
|
let toastTimer;
|
|
function showToast(msg, type = 'info') {
|
|
clearTimeout(toastTimer);
|
|
const el = document.getElementById('toast');
|
|
el.textContent = msg; el.className = 'show ' + type;
|
|
toastTimer = setTimeout(() => el.className = '', 3500);
|
|
}
|
|
function openPopupForNearest(e, m) { }
|
|
|
|
// -- LIGHTBOX
|
|
function openLightbox(src) {
|
|
const lb = document.getElementById('lightbox');
|
|
const img = document.getElementById('lightbox-img');
|
|
img.src = src;
|
|
lb.classList.add('show');
|
|
document.addEventListener('keydown', _lbEsc);
|
|
}
|
|
function closeLightbox() {
|
|
document.getElementById('lightbox').classList.remove('show');
|
|
document.getElementById('lightbox-img').src = '';
|
|
document.removeEventListener('keydown', _lbEsc);
|
|
}
|
|
function _lbEsc(e) { if (e.key === 'Escape') closeLightbox(); }
|
|
|
|
// -- SIDEBAR COLLAPSE
|
|
function toggleSidebar() {
|
|
const sb = document.getElementById('sidebar');
|
|
sb.classList.toggle('collapsed');
|
|
|
|
// Invalidate sizes of all active Leaflet map instances
|
|
setTimeout(() => {
|
|
if (persistentMap) persistentMap.invalidateSize();
|
|
Object.values(mapInstances).forEach(m => {
|
|
if (m && typeof m.invalidateSize === 'function') {
|
|
m.invalidateSize();
|
|
}
|
|
});
|
|
}, 250);
|
|
}
|
|
|
|
// -- BOOT
|
|
checkSession();
|
|
</script>
|
|
</body>
|
|
|
|
</html> |