Files

2543 lines
159 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>WebGIS</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css"/>
<style>
html,body{height:100%;margin:0;padding:0;overflow:hidden}
body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,'Helvetica Neue',Arial;line-height:1.35;color:#1f2937;background:#f8fafc}
#app-shell{display:flex;flex-direction:column;height:100%;width:100%}
#map-wrap{flex:1;min-width:0;position:relative}
#map{height:100%;width:100%}
#data-panel{background:linear-gradient(180deg,#ffffff 0%,#f8fafc 100%);display:flex;flex-direction:column;overflow:hidden;flex:1}
#data-panel-header-mini{padding:18px 18px 14px;border-bottom:1px solid #e5e7eb;background:linear-gradient(180deg,#ffffff 0%,#f9fafb 100%)}
.data-panel-header{display:none}
.data-panel-kicker{font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:#64748b;font-weight:700}
.data-panel-title{margin:4px 0 0;font-size:20px;line-height:1.1;color:#0f172a}
.data-panel-subtitle{margin:8px 0 0;color:#64748b;font-size:13px}
.data-view-toggle{display:inline-flex;gap:6px;margin-top:14px;padding:4px;background:#eef2ff;border-radius:14px}
.data-view-toggle button{border:0;background:transparent;color:#334155;padding:9px 12px;border-radius:10px;font-weight:600;cursor:pointer;transition:background .15s,color .15s,box-shadow .15s}
.data-view-toggle button.active{background:#1e40af;color:#fff;box-shadow:0 2px 8px rgba(30,64,175,0.3)}
.data-panel-summary{padding:12px 18px 0;color:#475569;font-size:13px}
.data-table-wrap{flex:1;overflow:auto;padding:14px 18px 0}
.data-table{width:100%;border-collapse:separate;border-spacing:0 10px;table-layout:fixed}
.data-table thead th{font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:#64748b;text-align:center;padding:0 8px;vertical-align:middle}
.data-table tbody tr{background:#fff;box-shadow:0 8px 24px rgba(15,23,42,0.06);border-radius:14px}
.data-table tbody td{padding:12px 8px;vertical-align:middle;text-align:center;border-top:1px solid rgba(255,255,255,0);word-wrap:break-word;overflow-wrap:anywhere}
.data-table tbody td:first-child{border-top-left-radius:14px;border-bottom-left-radius:14px;width:64px}
.data-table tbody td:last-child{border-top-right-radius:14px;border-bottom-right-radius:14px;width:118px}
.data-table thead th:first-child{width:64px}
.data-table thead th:last-child{width:118px}
.data-table thead th:nth-child(4), .data-table tbody td:nth-child(4){width:90px}
.data-table thead th:nth-child(5), .data-table tbody td:nth-child(5){width:130px}
.data-table tbody td .data-muted, .data-table tbody td .data-name, .data-table tbody td .data-pill, .data-table tbody td .data-badge{display:inline-flex;justify-content:center;align-items:center;max-width:100%}
.data-table tbody td .data-name, .data-table tbody td .data-muted{line-height:1.25}
.data-index{display:inline-flex;align-items:center;justify-content:center;min-width:28px;height:28px;border-radius:999px;background:#e2e8f0;color:#0f172a;font-weight:700;font-size:13px}
.data-name{font-weight:700;color:#0f172a}
.data-address{color:#475569;font-size:13px;margin-top:2px}
.data-muted{color:#64748b;font-size:13px}
.data-badge{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;font-size:12px;font-weight:700;white-space:nowrap}
.data-badge.is-green{background:#dcfce7;color:#166534}
.data-badge.is-yellow{background:#fef3c7;color:#92400e}
.data-badge.is-blue{background:#dbeafe;color:#1d4ed8}
.data-pill{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;background:#f1f5f9;color:#0f172a;font-size:12px;font-weight:600;margin-right:6px;margin-bottom:6px}
.data-actions{display:flex;gap:8px;justify-content:flex-end;flex-wrap:nowrap;white-space:nowrap}
.data-action-btn{display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:10px;border:1px solid #e2e8f0;background:#fff;color:#0f172a;cursor:pointer;transition:transform .12s,box-shadow .12s,background .12s}
.data-action-btn:hover{transform:translateY(-1px);box-shadow:0 10px 18px rgba(15,23,42,0.08);background:#f8fafc}
.data-action-btn svg{width:16px;height:16px;display:block}
.data-empty{padding:26px 18px;color:#64748b;font-size:14px;text-align:center}
.data-pagination{padding:14px 18px 18px;border-top:1px solid #e5e7eb;background:linear-gradient(180deg,rgba(255,255,255,0.65),#fff)}
.data-pagination-inner{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}
.data-pagination-info{color:#64748b;font-size:13px}
.data-pagination-controls{display:flex;gap:6px;flex-wrap:wrap}
.data-page-btn{border:1px solid #dbe3ef;background:#fff;color:#0f172a;padding:7px 11px;border-radius:10px;cursor:pointer;font-weight:600}
.data-page-btn.active{background:#0f172a;color:#fff;border-color:#0f172a}
.data-page-btn:disabled{opacity:.45;cursor:not-allowed}
.data-page-ellipsis{display:inline-flex;align-items:center;padding:0 6px;color:#64748b}
.data-link{color:#1d4ed8;text-decoration:none;font-weight:600}
.data-link:hover{text-decoration:underline}
.table-chip-list{display:flex;flex-wrap:wrap}
@media (max-width: 980px){
body{overflow:auto}
}
/* Form element styling */
.modal-content{background:#fff;padding:18px;border-radius:10px;min-width:340px;max-width:640px;box-shadow:0 10px 30px rgba(15,23,42,0.12)}
.modal-content label{display:block;margin-top:10px;color:#111827}
.auth-field{margin-bottom:16px}
.auth-field label{display:block;color:#111827;font-weight:500;font-size:14px;margin-bottom:8px}
.auth-field input[type="text"],
.auth-field input[type="email"],
.auth-field input[type="password"]{width:100%;padding:10px 12px;border-radius:8px;border:1px solid #e5e7eb;background:#fff;box-sizing:border-box;margin:0 !important}
.modal-content input[type="text"], .modal-content input[type="number"], .modal-content input[type="date"], .modal-content input[type="search"], .modal-content select{width:100%;padding:8px 10px;border-radius:8px;border:1px solid #e5e7eb;background:#fff;margin-top:6px;box-sizing:border-box}
.modal-content textarea{width:100%;padding:8px 10px;border-radius:8px;border:1px solid #e5e7eb;background:#fff;margin-top:6px;box-sizing:border-box;min-height:84px}
.modal-content input:disabled, .modal-content select:disabled{background:#f3f4f6;color:#9ca3af;cursor:not-allowed;opacity:1}
.modal-content input[type="file"]{margin-top:6px}
.assistance-combobox{position:relative}
.assistance-combobox .assist-source-input{width:100%;padding-right:34px;appearance:none;-webkit-appearance:none;-moz-appearance:none;cursor:text;color:#111827;font-weight:400}
.assistance-combobox .assist-source-toggle{position:absolute;right:10px;top:calc(50% + 10px);transform:translateY(-50%);width:18px;height:18px;border:0;background:transparent;color:#111827;cursor:pointer;padding:0;display:flex;align-items:center;justify-content:center;pointer-events:auto}
.assistance-fields.is-disabled .assist-source-toggle{color:#9ca3af}
.assistance-fields.is-enabled .assist-source-toggle{color:#111827}
.assistance-combobox .assist-source-toggle::before{content:'';display:block;width:8px;height:8px;border-right:3px solid currentColor;border-bottom:3px solid currentColor;transform:rotate(45deg);margin-top:-2px}
.assistance-combobox .assist-source-panel{position:absolute;left:0;right:0;top:100%;margin-top:6px;background:#fff;border:1px solid #d1d5db;border-radius:10px;box-shadow:0 14px 30px rgba(15,23,42,0.14);z-index:22000;max-height:260px;overflow:auto;display:none}
.assistance-combobox .assist-source-panel.show{display:block}
.assistance-combobox .assist-source-item{padding:9px 10px;cursor:pointer;font-size:13px;line-height:1.35;border-bottom:1px solid #f3f4f6}
.assistance-combobox .assist-source-item:last-child{border-bottom:0}
.assistance-combobox .assist-source-item:hover,.assistance-combobox .assist-source-item.active{background:#f8fafc}
.assistance-combobox .assist-source-empty{padding:10px;color:#6b7280;font-size:13px}
.assistance-fields.is-disabled .assist-source-input::placeholder{color:#9ca3af;opacity:1}
.assistance-fields.is-disabled .assist-source-input::-webkit-input-placeholder{color:#9ca3af;opacity:1}
.assistance-fields.is-disabled .assist-source-input::-moz-placeholder{color:#9ca3af;opacity:1}
.assistance-fields.is-disabled .assist-source-input:-ms-input-placeholder{color:#9ca3af;opacity:1}
.assistance-fields.is-enabled .assist-source-input{color:#111827;font-weight:400}
.assistance-fields.is-enabled .assist-source-input::placeholder{color:#111827 !important;opacity:1;font-weight:400}
.assistance-fields.is-enabled .assist-source-input::-webkit-input-placeholder{color:#111827 !important;opacity:1;font-weight:400}
.assistance-fields.is-enabled .assist-source-input::-moz-placeholder{color:#111827 !important;opacity:1;font-weight:400}
.assistance-fields.is-enabled .assist-source-input:-ms-input-placeholder{color:#111827 !important;opacity:1;font-weight:400}
.modal-content .label-title{display:inline-flex; align-items:center; gap:4px}
.modal-content .required-star{color:#dc2626; font-weight:700; line-height:1}
.layer-legend{display:inline-flex;align-items:center;gap:6px;line-height:1.2}
.layer-legend .legend-marker{display:inline-flex;align-items:flex-end;justify-content:center;width:16px;height:22px;flex:0 0 16px}
.layer-legend .legend-marker svg{display:block;width:16px;height:22px}
.layer-legend .legend-text{white-space:nowrap}
.btn-primary{background:#2563eb;color:#fff;padding:8px 12px;border:0;border-radius:8px;cursor:pointer}
.btn-secondary{background:#f3f4f6;color:#111;padding:8px 12px;border:0;border-radius:8px;cursor:pointer}
.modal-actions{margin-top:14px;text-align:right}
/* file name display next to file input */
.file-name{display:inline-block;margin-left:8px;color:#374151;font-size:13px}
/* Popup buttons style */
.leaflet-popup-content button{background:#111827;color:#fff;border:0;padding:6px 8px;border-radius:6px;margin-right:6px;cursor:pointer}
.leaflet-popup-content button:hover{opacity:0.95}
/* Debug overlay subtle */
#debug-overlay{position:absolute;left:10px;bottom:10px;z-index:12000;background:rgba(255,255,255,0.95);padding:8px;border-radius:8px;font-size:13px;color:#111;border:1px solid rgba(0,0,0,0.06);display:none}
/* Prevent double-click text selection on layer control while keeping it clickable */
.leaflet-control-layers label{ -webkit-user-select:none; -moz-user-select:none; -ms-user-select:none; user-select:none; cursor:pointer }
/* Toast messages */
#toast-container{position:fixed;right:16px;bottom:16px;z-index:14000;display:flex;flex-direction:column;gap:8px;align-items:flex-end}
.toast{background:#333;color:#fff;padding:10px 14px;border-radius:8px;box-shadow:0 6px 20px rgba(0,0,0,0.3);max-width:320px;font-size:13px;opacity:0;transform:translateY(-6px);transition:opacity .18s,transform .18s}
.toast.show{opacity:1;transform:translateY(0)}
.toast.error{background:#d32f2f}
.toast.success{background:#2e7d32}
.modal {position:fixed;left:0;top:0;right:0;bottom:0;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;z-index:9999}
.modal.hidden{display:none}
.modal-content{background:#fff;padding:18px;border-radius:10px;min-width:320px;max-width:520px;width:420px;max-height:80vh;overflow:auto}
.modal-content label{display:block;margin-top:8px}
.modal-actions{margin-top:12px;text-align:right}
.btn-primary{background:#1976d2;color:#fff;padding:8px 12px;border:0;border-radius:6px;cursor:pointer}
.btn-secondary{background:#eee;padding:8px 12px;border:0;border-radius:6px;cursor:pointer}
.btn-destructive{background:#d32f2f;color:#fff;padding:8px 12px;border:0;border-radius:6px;cursor:pointer}
.btn-destructive:hover{opacity:0.95}
.leaflet-popup-content{margin:14px 16px}
.leaflet-popup-content-wrapper{border-radius:16px}
.leaflet-popup-content .location-popup{min-width:380px;max-width:520px}
.leaflet-popup-content .location-popup-title{font-size:16px;font-weight:700;color:#0f172a;margin-bottom:6px;line-height:1.25}
.leaflet-popup-content .location-popup-meta{font-size:13px;color:#475569;margin-top:6px}
.leaflet-popup-content .location-popup-photo{margin-top:8px}
.leaflet-popup-content .location-popup-actions{display:flex;align-items:center;gap:8px;flex-wrap:nowrap;white-space:nowrap;margin-top:12px;justify-content:flex-start}
.leaflet-popup-content .location-popup-actions button{margin-right:0}
#masjid-radius-control{position:absolute;left:10px;bottom:20px;background:#fff;padding:8px;border-radius:6px;box-shadow:0 2px 6px rgba(0,0,0,0.2);z-index:10000}
/* Filter panel */
/* filter panel removed (moved into masjid control) */
/* nicer masjid control */
#masjid-radius-control{width:300px;padding:12px;border-radius:10px;border:1px solid rgba(0,0,0,0.06);box-shadow:0 12px 40px rgba(2,6,23,0.12);background:linear-gradient(180deg,#ffffff,#fbfbfe)}
#masjid-radius-control .mc-title{font-weight:700;margin-bottom:6px;color:#0f172a}
#masjid-radius-control .mc-address{font-size:13px;color:#475569;margin-bottom:8px}
#masjid-radius-control .mc-controls{display:flex;align-items:center;gap:8px}
#masjid-radius-control .mc-radius{flex:1}
#masjid-radius-control .mc-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:10px}
#masjid-radius-control .mc-actions button{background:#eef2ff;color:#0f172a;border:0;padding:6px 8px;border-radius:8px;cursor:pointer}
#masjid-radius-control .mc-actions button.destructive{background:#fee2e2}
</style>
</head>
<body>
<div id="modal" class="modal hidden" role="dialog" aria-hidden="true">
<div class="modal-content">
<h3 id="modal-title">Form</h3>
<form id="modal-form">
<div id="modal-fields"></div>
<div class="modal-actions">
<button type="button" id="modal-cancel" class="btn-secondary">Batal</button>
<button type="submit" id="modal-submit" class="btn-primary">Simpan</button>
</div>
</form>
</div>
</div>
<div id="app-shell">
<div id="top-right-controls" style="position:fixed;right:12px;top:12px;z-index:11500;display:flex;flex-direction:column;align-items:flex-end;gap:8px;max-width:calc(100vw - 24px)">
<div id="auth-bar" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;justify-content:flex-end;max-width:calc(100vw - 24px)">
<span id="auth-user-label" style="font-size:13px;color:#0f172a;background:#fff;padding:8px 10px;border-radius:999px;box-shadow:0 6px 18px rgba(15,23,42,0.08)">Tamu</span>
<button type="button" id="auth-login-btn" style="background:#0f172a;color:#fff;border:0;padding:8px 12px;border-radius:8px;cursor:pointer;font-weight:600">Masuk</button>
<button type="button" id="auth-logout-btn" style="display:none;background:#fee2e2;color:#991b1b;border:0;padding:8px 12px;border-radius:8px;cursor:pointer;font-weight:600">Keluar</button>
<button type="button" id="admin-dashboard-btn" style="display:none;background:#1d4ed8;color:#fff;border:0;padding:8px 12px;border-radius:8px;cursor:pointer;font-weight:600">Admin</button>
</div>
</div>
<div style="position:fixed;left:50%;top:10px;transform:translateX(-50%);z-index:11000;display:flex;gap:8px">
<button id="view-map-btn" onclick="switchView('map')" style="background:#1e40af;color:#fff;border:0;padding:10px 16px;border-radius:8px;cursor:pointer;font-weight:600;font-size:15px;box-shadow:0 2px 8px rgba(30,64,175,0.3)">📍 Peta</button>
<button id="view-data-btn" onclick="switchView('data')" style="background:#e5e7eb;color:#374151;border:0;padding:10px 16px;border-radius:8px;cursor:pointer;font-weight:600;font-size:15px">📋 Daftar Data</button>
</div>
<main id="map-wrap" style="display:flex;flex:1;min-width:0;position:relative">
<div id="map" style="flex:1"></div>
</main>
<div id="data-panel" style="display:none;background:linear-gradient(180deg,#ffffff 0%,#f8fafc 100%);flex:1;min-width:0;flex-direction:column;overflow:hidden">
<div id="data-panel-header-mini" style="padding:18px 18px 14px;border-bottom:1px solid #e5e7eb;background:linear-gradient(180deg,#ffffff 0%,#f9fafb 100%)">
<div class="data-panel-kicker">📋 Data</div>
<div class="data-panel-title">Daftar Lokasi</div>
<div class="data-panel-subtitle">Pilih kategori untuk melihat data</div>
<div class="data-view-toggle">
<button type="button" id="data-view-worship" class="active" onclick="setDataPanelView('worship')">📍 Tempat Ibadah</button>
<button type="button" id="data-view-house" onclick="setDataPanelView('house')">🏠 Rumah</button>
</div>
</div>
<div id="data-panel-summary" style="padding:12px 18px 0;color:#475569;font-size:13px"></div>
<div class="data-table-wrap">
<table class="data-table">
<thead id="data-table-head"></thead>
<tbody id="data-table-body"></tbody>
</table>
</div>
<div class="data-pagination" style="padding:14px 18px 18px;border-top:1px solid #e5e7eb;background:linear-gradient(180deg,rgba(255,255,255,0.65),#fff)">
<div class="data-pagination-inner" style="display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap">
<div id="data-pagination-info" class="data-pagination-info" style="color:#64748b;font-size:13px"></div>
<div id="data-pagination-controls" class="data-pagination-controls" style="display:flex;gap:6px;flex-wrap:wrap"></div>
</div>
</div>
</div>
</div>
<div id="auth-modal" class="modal hidden" role="dialog" aria-hidden="true">
<div class="modal-content">
<h3 id="auth-modal-title">Masuk</h3>
<form id="auth-modal-form">
<div id="auth-modal-fields"></div>
<div id="auth-modal-switch" style="margin-top:12px;font-size:13px;color:#475569;display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap"></div>
<div class="modal-actions">
<button type="button" id="auth-modal-cancel" class="btn-secondary">Batal</button>
<button type="submit" id="auth-modal-submit" class="btn-primary">Kirim</button>
</div>
</form>
</div>
</div>
<div id="admin-modal" class="modal hidden" role="dialog" aria-hidden="true">
<div class="modal-content" style="max-width:760px;width:min(760px,92vw)">
<h3>Admin Dashboard</h3>
<div style="font-size:13px;color:#475569;margin-bottom:10px">Persetujuan akun pengelola yang menunggu aktivasi.</div>
<div id="admin-pending-list" style="max-height:60vh;overflow:auto"></div>
<div class="modal-actions">
<button type="button" id="admin-modal-close" class="btn-secondary">Tutup</button>
</div>
</div>
</div>
<div id="admin-detail-modal" class="modal hidden" role="dialog" aria-hidden="true">
<div class="modal-content" style="max-width:860px;width:min(860px,94vw)">
<h3 id="admin-detail-title">Detail Pendaftaran</h3>
<div id="admin-detail-body" style="max-height:70vh;overflow:auto;font-size:14px;color:#334155"></div>
<div class="modal-actions">
<button type="button" id="admin-detail-close" class="btn-secondary">Tutup</button>
</div>
</div>
</div>
<div id="admin-reject-modal" class="modal hidden" role="dialog" aria-hidden="true" style="z-index:11000">
<div class="modal-content" style="max-width:480px;width:min(480px,90vw)">
<h3>Alasan Penolakan</h3>
<div style="font-size:13px;color:#475569;margin-bottom:12px">Silakan masukkan alasan penolakan untuk pengelola ini. Alasan akan dikirimkan ke email pendaftar.</div>
<div style="margin-bottom:16px">
<textarea id="admin-reject-reason-input" style="width:100%;height:100px;padding:8px;border:1px solid #cbd5e1;border-radius:6px;box-sizing:border-box" placeholder="Tulis alasan di sini..."></textarea>
</div>
<div class="modal-actions" style="display:flex;justify-content:flex-end;gap:8px">
<button type="button" id="admin-reject-cancel" class="btn-secondary">Batal</button>
<button type="button" id="admin-reject-submit" class="btn-destructive">Tolak Pendaftaran</button>
</div>
</div>
</div>
<!-- Email Verification Modal -->
<!-- Image viewer modal (separate from form modal) -->
<div id="image-viewer" style="display:none;position:fixed;left:0;top:0;right:0;bottom:0;z-index:13000;background:rgba(0,0,0,0.85);align-items:center;justify-content:center;padding:16px;box-sizing:border-box">
<div style="position:relative;width:min(92vw,960px);max-width:960px;max-height:88vh;margin:auto;display:flex;align-items:center;justify-content:center;overflow:hidden;border-radius:12px;background:rgba(15,23,42,0.1)">
<button id="image-viewer-close" style="position:absolute;right:12px;top:12px;background:#fff;border-radius:20px;border:0;padding:6px 8px;cursor:pointer;z-index:13001;box-shadow:0 2px 8px rgba(0,0,0,0.2)"></button>
<img id="image-viewer-img" src="" style="display:block;max-width:100%;max-height:88vh;width:auto;height:auto;object-fit:contain;border-radius:10px;box-shadow:0 6px 30px rgba(0,0,0,0.6)">
</div>
</div>
<!-- Toast container for non-blocking messages -->
<div id="toast-container"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js"></script>
<script>
window.authToken = localStorage.getItem('webgis_jwt') || '';
try{
window.currentUser = JSON.parse(localStorage.getItem('webgis_user') || 'null');
}catch(e){
window.currentUser = null;
}
function syncAuthToken(token){
try{
window.authToken = token || '';
if(window.authToken){ localStorage.setItem('webgis_jwt', window.authToken); }
else { localStorage.removeItem('webgis_jwt'); }
}catch(e){}
}
function syncCurrentUser(user){
try{
window.currentUser = user || null;
if(window.currentUser){ localStorage.setItem('webgis_user', JSON.stringify(window.currentUser)); }
else { localStorage.removeItem('webgis_user'); }
}catch(e){
window.currentUser = user || null;
}
}
function buildHeaders(contentType = 'application/json'){
const headers = {};
if(contentType) headers['Content-Type'] = contentType;
if(window.authToken){ headers['Authorization'] = 'Bearer ' + window.authToken; }
return headers;
}
// increase allowed zoom: tiles provided by OSM go up to ~19, allow higher zoom levels for closer inspection
// make mouse-wheel zoom less aggressive so one scroll notch feels closer to one +/- button step
const map = L.map('map', {
maxZoom: 22,
minZoom: 2,
zoomControl: true,
zoomDelta: 1,
wheelPxPerZoomLevel: 240,
wheelDebounceTime: 80
}).setView([-0.0552, 109.35], 13);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxNativeZoom: 19, maxZoom: 22, attribution: '&copy; OpenStreetMap contributors' }).addTo(map);
const rumahBelumDibantuGroup = L.layerGroup().addTo(map);
const rumahSudahDibantuGroup = L.layerGroup().addTo(map);
const masjidGroup = L.layerGroup().addTo(map);
// lokasiGroup must be a FeatureGroup for Leaflet.Draw edit controls
const lokasiGroup = L.featureGroup().addTo(map);
const rumahMarkers = [];
let masjidCircle = null;
let masjidControlEl = null;
let currentEditingLayer = null; // used when creating/editing a marker to allow suggestion updates
// filter thresholds (null = disabled)
// masjid-specific filter intervals (defaults: min = 0, max = null/unbounded)
let masjidIncomeMin = 0;
let masjidIncomeMax = null;
let masjidMembersMin = 0;
let masjidMembersMax = null;
// whether worship filter UI is expanded (controls whether filters are applied)
let masjidFiltersExpanded = false;
const dataPanelState = {
view: 'worship',
page: 1,
pageSize: 10
};
let lokasiRecords = [];
let lokasiMarkerById = new Map();
function getRecordIdKey(value){
return String(value === undefined || value === null ? '' : value);
}
function formatMoney(value){
try{
if(value === undefined || value === null || value === '') return '-';
const numberValue = Number(value);
if(isNaN(numberValue)) return '-';
return 'Rp ' + new Intl.NumberFormat('id-ID').format(numberValue);
}catch(e){
return '-';
}
}
function formatCount(value, suffix){
try{
const numberValue = Number(value);
if(isNaN(numberValue)) return '0' + (suffix ? ' ' + suffix : '');
return String(numberValue) + (suffix ? ' ' + suffix : '');
}catch(e){
return '0' + (suffix ? ' ' + suffix : '');
}
}
function buildIconSvg(kind){
if(kind === 'eye'){
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1.5 12s3.5-6.5 10.5-6.5S22.5 12 22.5 12 19 18.5 12 18.5 1.5 12 1.5 12z"></path><circle cx="12" cy="12" r="3.5"></circle></svg>';
}
if(kind === 'edit'){
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"></path><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"></path></svg>';
}
if(kind === 'delete'){
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"></path><path d="M8 6V4h8v2"></path><path d="M19 6l-1 14H6L5 6"></path><path d="M10 11v6"></path><path d="M14 11v6"></path></svg>';
}
return '';
}
function buildActionButton(kind, label, handler, id){
return `<button type="button" class="data-action-btn" aria-label="${escapeHtml(label)}" title="${escapeHtml(label)}" onclick="${handler}('${escapeHtml(id)}')">${buildIconSvg(kind)}</button>`;
}
function getPanelRecords(){
const records = Array.isArray(lokasiRecords) ? lokasiRecords.slice() : [];
const filtered = records.filter(item => {
const jenis = String(item && item.jenis ? item.jenis : '').toLowerCase();
if(dataPanelState.view === 'house') return jenis === 'rumah';
return isWorshipType(jenis);
});
filtered.sort((left, right) => {
const leftPrimary = dataPanelState.view === 'house' ? String(left.alamat || '') : String(left.nama || '');
const rightPrimary = dataPanelState.view === 'house' ? String(right.alamat || '') : String(right.nama || '');
const primaryCompare = leftPrimary.localeCompare(rightPrimary, 'id', { sensitivity: 'base' });
if(primaryCompare !== 0) return primaryCompare;
const leftSecondary = dataPanelState.view === 'house' ? String(left.nama_kk || left.nama || '') : String(left.alamat || '');
const rightSecondary = dataPanelState.view === 'house' ? String(right.nama_kk || right.nama || '') : String(right.alamat || '');
return leftSecondary.localeCompare(rightSecondary, 'id', { sensitivity: 'base' });
});
return filtered;
}
function goToLocation(id){
switchView('map');
setTimeout(() => {
const marker = lokasiMarkerById.get(getRecordIdKey(id));
if(!marker){
showToast('Titik lokasi tidak ditemukan di peta', 'error', 3500);
return;
}
try{
const latlng = marker.getLatLng();
map.invalidateSize();
map.flyTo(latlng, Math.max(map.getZoom(), 19), { animate: true, duration: 0.55 });
if(typeof marker.openPopup === 'function'){
map.once('moveend', function(){
try{ marker.openPopup(); }catch(e){}
});
}
if(marker._jenis && isWorshipType(marker._jenis)){
try{ showMasjidRadius(marker); }catch(e){}
}
}catch(e){
console.error(e);
}
}, 50);
}
function closePopupById(id){
try{
const marker = lokasiMarkerById.get(getRecordIdKey(id));
if(marker && typeof marker.closePopup === 'function') marker.closePopup();
}catch(e){ console.error(e); }
}
window.currentUser = null;
function isCurrentUserAdmin(){
return !!(window.currentUser && window.currentUser.role === 'admin' && window.currentUser.status === 'active');
}
function isCurrentUserManager(){
return !!(window.currentUser && window.currentUser.role === 'manager' && window.currentUser.status === 'active');
}
function canEditRecord(item){
if(isCurrentUserAdmin()) return true;
if(!isCurrentUserManager()) return false;
return String(item && item.created_by ? item.created_by : '') === String(window.currentUser.id);
}
function updateAuthBar(){
const label = document.getElementById('auth-user-label');
const loginBtn = document.getElementById('auth-login-btn');
const logoutBtn = document.getElementById('auth-logout-btn');
const adminBtn = document.getElementById('admin-dashboard-btn');
const user = window.currentUser;
if(!label) return;
if(!user){
label.textContent = 'Tamu';
label.style.cursor = 'default';
label.onclick = null;
if(loginBtn) loginBtn.style.display = 'inline-block';
if(logoutBtn) logoutBtn.style.display = 'none';
if(adminBtn) adminBtn.style.display = 'none';
} else {
const nameOnly = user.name || user.email || 'Pengguna';
if(user.role === 'admin'){
label.innerHTML = '<span style="font-weight:700">' + escapeHtml(nameOnly) + '</span><span style="margin-left:8px;font-size:12px;color:#1d4ed8;font-weight:600">Verifikasi pengelola</span>';
label.style.cursor = 'pointer';
label.onclick = function(){ openAdminDashboard(); };
} else {
label.textContent = nameOnly;
label.style.cursor = 'default';
label.onclick = null;
}
if(loginBtn) loginBtn.style.display = 'none';
if(logoutBtn) logoutBtn.style.display = 'inline-block';
if(adminBtn) adminBtn.style.display = 'none';
}
}
function attachTopRightControls(){
try{
const cluster = document.getElementById('top-right-controls');
const mapWrap = document.getElementById('map-wrap');
if(!cluster || !mapWrap) return;
const controlContainer = mapWrap.querySelector('.leaflet-top.leaflet-right');
if(!controlContainer) return;
if(controlContainer.parentNode !== cluster){
cluster.appendChild(controlContainer);
}
try{
controlContainer.style.position = 'static';
controlContainer.style.top = 'auto';
controlContainer.style.right = 'auto';
controlContainer.style.margin = '0';
}catch(e){}
}catch(e){}
}
// State for registration verification code
let authRegisterState = {
code_sent: false,
code: null,
email: null
};
function openAuthModal(mode){
const title = document.getElementById('auth-modal-title');
const fields = document.getElementById('auth-modal-fields');
const switchArea = document.getElementById('auth-modal-switch');
const submitBtn = document.getElementById('auth-modal-submit');
const modal = document.getElementById('auth-modal');
if(submitBtn) {
submitBtn.disabled = false;
}
// Reset registration verification state
if(mode === 'login'){
authRegisterState = { code_sent: false, code: null, email: null };
}
if(!title || !fields || !modal) return;
if(mode === 'register'){
title.textContent = 'Daftar Pengelola';
fields.innerHTML = '<div class="auth-field"><label><span class="label-title">Nama <span class="required-star">*</span></span><input name="name" type="text" required></label></div>'+
'<div class="auth-field"><label><span class="label-title">Email <span class="required-star">*</span></span><input name="email" type="email" required></label></div>'+
'<div class="auth-field"><label><span class="label-title">Password <span class="required-star">*</span></span><input name="password" type="password" required></label></div>'+
'<div class="auth-field"><label><span class="label-title">Konfirmasi Password <span class="required-star">*</span></span><input name="password_confirm" type="password" required></label></div>'+
'<div class="auth-field"><label><span class="label-title">Organisasi / Tempat Ibadah <span class="required-star">*</span></span><input name="organization" type="text" required></label></div>'+
'<div class="auth-field"><label><span class="label-title">Alamat Organisasi <span class="required-star">*</span></span><textarea name="org_address" required></textarea></label></div>'+
'<div class="auth-field"><label><span class="label-title">Nomor Telp Organisasi <span class="required-star">*</span></span><input name="org_phone" type="text" required></label></div>'+
'<div class="auth-field">' +
'<label><span class="label-title">Bukti Organisasi <span class="required-star">*</span></span></label>' +
'<div style="margin-top:6px"><input name="org_proof" id="org_proof_input" type="file" accept=".png,.jpg,.jpeg,.svg,.pdf" required></div>' +
'<div style="font-size:12px;color:#64748b;margin-top:6px">Hanya file: .png, .jpg, .jpeg, .svg, atau .pdf. Maks 5MB.</div>' +
'</div>'+
'<div style="display:flex;gap:8px;margin-top:12px">' +
'<button type="button" id="auth-send-code-btn" class="btn" style="flex:1;padding:10px;background:#3b82f6;color:white;border:none;border-radius:4px;cursor:pointer;font-size:14px">Kirim Kode Verifikasi</button>' +
'</div>' +
'<div class="auth-field"><label><span class="label-title">Kode Verifikasi <span class="required-star">*</span></span><input name="verify_code" type="text" placeholder="Masukkan 6 digit" maxlength="6" pattern="[0-9]{6}" required></label></div>'+
'<div style="font-size:12px;color:#64748b;margin-top:8px">Akun akan berstatus menunggu persetujuan admin setelah pendaftaran berhasil.</div>';
if(switchArea){ switchArea.innerHTML = '<span>Sudah punya akun? <button type="button" id="auth-switch-to-login" class="data-link" style="background:transparent;border:0;padding:0;cursor:pointer;margin-left:4px">Masuk</button></span>'; }
if(submitBtn) submitBtn.textContent = 'Daftar';
} else if(mode === 'login'){
title.textContent = 'Masuk';
fields.innerHTML = '<div class="auth-field"><label><span class="label-title">Email</span><input name="email" type="email" required></label></div>' +
'<div class="auth-field"><label><span class="label-title">Password</span><input name="password" type="password" required></label></div>' +
'<div style="text-align:right;margin-top:-6px;margin-bottom:12px">' +
'<button type="button" id="auth-forgot-password-link" style="background:transparent;border:0;padding:0;color:#3b82f6;cursor:pointer;font-size:13px">Lupa Password?</button>' +
'</div>';
if(switchArea){ switchArea.innerHTML = '<span>Ingin daftar pengelola? <button type="button" id="auth-switch-to-register" class="data-link" style="background:transparent;border:0;padding:0;cursor:pointer;margin-left:4px">Daftar di sini</button></span>'; }
if(submitBtn) submitBtn.textContent = 'Masuk';
} else if(mode === 'forgot_password'){
title.textContent = 'Lupa Password';
fields.innerHTML =
'<div class="auth-field"><label><span class="label-title">Email <span class="required-star">*</span></span><input name="email" type="email" required></label></div>'+
'<div style="display:flex;gap:8px;margin-top:12px;margin-bottom:12px">' +
'<button type="button" id="auth-send-forgot-code-btn" class="btn" style="flex:1;padding:10px;background:#3b82f6;color:white;border:none;border-radius:4px;cursor:pointer;font-size:14px">Kirim Kode Verifikasi</button>' +
'</div>' +
'<div class="auth-field"><label><span class="label-title">Kode Verifikasi <span class="required-star">*</span></span><input name="verify_code" type="text" placeholder="Masukkan 6 digit" maxlength="6" pattern="[0-9]{6}" required></label></div>'+
'<div class="auth-field"><label><span class="label-title">Password Baru <span class="required-star">*</span></span><input name="password" type="password" required></label></div>'+
'<div class="auth-field"><label><span class="label-title">Konfirmasi Password Baru <span class="required-star">*</span></span><input name="password_confirm" type="password" required></label></div>';
if(switchArea){ switchArea.innerHTML = '<span>Kembali ke <button type="button" id="auth-switch-to-login" class="data-link" style="background:transparent;border:0;padding:0;cursor:pointer;margin-left:4px">Masuk</button></span>'; }
if(submitBtn) submitBtn.textContent = 'Reset Password';
}
modal.classList.remove('hidden');
modal.setAttribute('aria-hidden','false');
try{ syncRequiredFieldMarkers(); }catch(e){}
try{
const switchToRegister = document.getElementById('auth-switch-to-register');
const switchToLogin = document.getElementById('auth-switch-to-login');
const forgotPasswordLink = document.getElementById('auth-forgot-password-link');
if(switchToRegister) switchToRegister.onclick = function(){ openAuthModal('register'); };
if(switchToLogin) switchToLogin.onclick = function(){ openAuthModal('login'); };
if(forgotPasswordLink) forgotPasswordLink.onclick = function(){ openAuthModal('forgot_password'); };
}catch(e){}
if(mode === 'register'){
const orgProof = document.getElementById('org_proof_input');
if(orgProof){
orgProof.addEventListener('change', function(){
const f = (orgProof.files && orgProof.files[0]) ? orgProof.files[0] : null;
if(!f) return;
const maxSize = 5 * 1024 * 1024;
const allowed = ['jpg','jpeg','png','svg','pdf'];
const ext = (f.name || '').split('.').pop().toLowerCase();
if(f.size > maxSize){
showToast('Ukuran file bukti organisasi maksimal 5MB', 'error', 5000);
orgProof.value = '';
return;
}
if(!allowed.includes(ext)){
showToast('Jenis file tidak diperbolehkan. Hanya .png, .jpg, .jpeg, .svg, atau .pdf.', 'error', 5000);
orgProof.value = '';
return;
}
});
}
}
const form = document.getElementById('auth-modal-form');
if(form) form.onsubmit = async function(ev){
ev.preventDefault();
const submitBtn = form.querySelector('button[type="submit"]') || document.getElementById('auth-modal-submit');
const originalText = submitBtn ? submitBtn.textContent : 'Kirim';
if(submitBtn) {
submitBtn.disabled = true;
submitBtn.textContent = 'Memproses...';
}
try {
let endpoint = 'src/api/auth/login.php';
if(mode === 'register') endpoint = 'src/api/auth/register.php';
if(mode === 'forgot_password') endpoint = 'src/api/auth/reset_password.php';
let resp, jr;
if(mode === 'register' || mode === 'forgot_password'){
// Validate password length and match
const passwordInput = form.querySelector('input[name="password"]');
const password = passwordInput ? passwordInput.value : '';
const passwordConfirmInput = form.querySelector('input[name="password_confirm"]');
const passwordConfirm = passwordConfirmInput ? passwordConfirmInput.value : '';
if(password.length < 8){
showToast('Password minimal harus 8 karakter', 'error', 5000);
if(submitBtn) { submitBtn.disabled = false; submitBtn.textContent = originalText; }
return;
}
if(password !== passwordConfirm){
showToast('Konfirmasi password tidak cocok', 'error', 5000);
if(submitBtn) { submitBtn.disabled = false; submitBtn.textContent = originalText; }
return;
}
if(mode === 'register'){
// client-side validation for org_proof: size <=5MB and allowed extensions
const fileInput = form.querySelector('input[name="org_proof"]') || document.getElementById('org_proof_input');
if(!fileInput){
showToast('Input bukti organisasi tidak ditemukan', 'error', 4000);
if(submitBtn) { submitBtn.disabled = false; submitBtn.textContent = originalText; }
return;
}
if(!(fileInput.files && fileInput.files.length > 0)){
showToast('Mohon unggah bukti organisasi (foto/pdf).', 'error', 5000);
if(submitBtn) { submitBtn.disabled = false; submitBtn.textContent = originalText; }
return;
}
const f = fileInput.files[0];
const maxSize = 5 * 1024 * 1024;
const allowed = ['jpg','jpeg','png','svg','pdf'];
const ext = (f.name || '').split('.').pop().toLowerCase();
if(f.size > maxSize){
showToast('Ukuran file bukti organisasi maksimal 5MB', 'error', 5000);
if(submitBtn) { submitBtn.disabled = false; submitBtn.textContent = originalText; }
return;
}
if(!allowed.includes(ext)){
showToast('Jenis file tidak diperbolehkan. Hanya .png, .jpg, .jpeg, .svg, atau .pdf.', 'error', 5000);
if(submitBtn) { submitBtn.disabled = false; submitBtn.textContent = originalText; }
return;
}
}
// Check verify code
const codeInput = form.querySelector('input[name="verify_code"]');
const code = codeInput ? codeInput.value.trim() : '';
if(!code || code.length !== 6 || !/^\d{6}$/.test(code)){
showToast('Masukkan kode verifikasi 6 digit yang valid', 'error', 4000);
if(submitBtn) { submitBtn.disabled = false; submitBtn.textContent = originalText; }
return;
}
if(mode === 'register'){
const fd = new FormData(form);
resp = await fetch(endpoint, { method: 'POST', headers: buildHeaders(null), body: fd, credentials: 'same-origin' });
jr = await resp.json().catch(()=>null);
} else {
const fd = new FormData(form);
const payload = {};
for(const [k,v] of fd.entries()) payload[k] = v;
resp = await fetch(endpoint, { method: 'POST', headers: buildHeaders(), body: JSON.stringify(payload), credentials: 'same-origin' });
jr = await resp.json().catch(()=>null);
}
} else {
const fd = new FormData(form);
const payload = {};
for(const [k,v] of fd.entries()) payload[k] = v;
resp = await fetch(endpoint, { method: 'POST', headers: buildHeaders(), body: JSON.stringify(payload), credentials: 'same-origin' });
jr = await resp.json().catch(()=>null);
}
if(!resp.ok || !jr || jr.success === false){
showToast((jr && (jr.error || jr.status || jr.message)) ? (jr.error || jr.status || jr.message) : 'Gagal memproses respons dari server', 'error', 5000);
if(submitBtn) { submitBtn.disabled = false; submitBtn.textContent = originalText; }
return;
}
if(mode === 'register'){
authRegisterState = { code_sent: false, code: null, email: null };
closeAuthModal();
showToast('Pendaftaran berhasil! Akun menunggu persetujuan admin.', 'success', 5000);
} else if(mode === 'forgot_password'){
authRegisterState = { code_sent: false, code: null, email: null };
closeAuthModal();
showToast('Reset password berhasil! Silakan masuk kembali.', 'success', 5000);
} else {
if(jr && jr.token){ syncAuthToken(jr.token); }
if(jr && jr.user){ syncCurrentUser(jr.user); }
await refreshAuthState();
closeAuthModal();
showToast('Berhasil masuk', 'success', 3000);
loadData();
}
}catch(e){
console.error(e);
showToast('Gagal memproses autentikasi', 'error', 5000);
if(submitBtn) { submitBtn.disabled = false; submitBtn.textContent = originalText; }
}
};
// Setup event listeners for registration and forgot password "Send Verification Code" buttons
const wireVerificationBtn = function(btnId) {
const sendCodeBtn = document.getElementById(btnId);
if(sendCodeBtn){
sendCodeBtn.onclick = async function(ev){
ev.preventDefault();
const emailInput = form.querySelector('input[name="email"]');
const email = emailInput ? emailInput.value.trim().toLowerCase() : '';
if(!email || !email.includes('@')){
showToast('Masukkan email yang valid', 'error', 3000);
return;
}
// Client-side cooldown check
const limitKey = 'webgis_limit_' + btoa(email);
let limitInfo = { lastTime: 0 };
try{
const cached = localStorage.getItem(limitKey);
if(cached) limitInfo = JSON.parse(cached);
}catch(e){}
const now = Date.now();
if(limitInfo.lastTime && now - limitInfo.lastTime < 30 * 1000){
const remaining = Math.ceil((30 * 1000 - (now - limitInfo.lastTime)) / 1000);
showToast(`Silakan tunggu ${remaining} detik sebelum mengirim kembali.`, 'error', 3000);
return;
}
sendCodeBtn.disabled = true;
sendCodeBtn.textContent = 'Mengirim...';
try{
const resp = await fetch('src/api/auth/send_verification_code.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({email: email})
});
const jr = await resp.json().catch(()=>null);
if(!resp.ok || (jr && jr.success === false)){
const errMessage = jr && jr.message ? jr.message : (jr && jr.error ? jr.error : 'Gagal mengirim kode');
showToast(errMessage, 'error', 5000);
sendCodeBtn.disabled = false;
sendCodeBtn.textContent = 'Kirim Kode Verifikasi';
return;
}
// Update client side cooldown info on success
limitInfo.lastTime = now;
try{ localStorage.setItem(limitKey, JSON.stringify(limitInfo)); }catch(e){}
authRegisterState.code_sent = true;
authRegisterState.email = email;
if(jr.debug_code){
authRegisterState.code = jr.debug_code;
const codeInput = form.querySelector('input[name="verify_code"]');
if(codeInput){
codeInput.value = jr.debug_code;
showToast('Gagal mengirim email. Menggunakan kode pengujian: ' + jr.debug_code, 'warning', 8000);
}
} else {
showToast('Kode verifikasi terkirim ke email Anda! Silakan cek email Anda.', 'success', 6000);
}
// Cooldown countdown timer
let cooldown = 30;
sendCodeBtn.disabled = true;
sendCodeBtn.textContent = `Kirim Ulang (${cooldown}s)`;
const timer = setInterval(() => {
cooldown--;
if(cooldown <= 0){
clearInterval(timer);
sendCodeBtn.disabled = false;
sendCodeBtn.textContent = 'Kirim Kode Verifikasi';
} else {
sendCodeBtn.textContent = `Kirim Ulang (${cooldown}s)`;
}
}, 1000);
}catch(e){
console.error(e);
showToast('Gagal mengirim kode verifikasi', 'error', 5000);
sendCodeBtn.disabled = false;
sendCodeBtn.textContent = 'Kirim Kode Verifikasi';
}
};
}
};
if(mode === 'register'){
wireVerificationBtn('auth-send-code-btn');
} else if(mode === 'forgot_password'){
wireVerificationBtn('auth-send-forgot-code-btn');
}
}
function closeAuthModal(){
const modal = document.getElementById('auth-modal');
if(!modal) return;
modal.classList.add('hidden');
modal.setAttribute('aria-hidden','true');
// Reset form states
const form = document.getElementById('auth-modal-form');
if(form) {
form.reset();
const submitBtn = form.querySelector('button[type="submit"]') || document.getElementById('auth-modal-submit');
if(submitBtn) {
submitBtn.disabled = false;
submitBtn.textContent = 'Kirim';
}
}
authRegisterState = { code_sent: false, code: null, email: null };
}
// Email Verification Modal Functions
async function refreshAuthState(){
try{
const resp = await fetch('src/api/auth/me.php', { credentials: 'same-origin', headers: buildHeaders(null) });
const jr = await resp.json().catch(()=>null);
if(resp.ok && jr && jr.authenticated){
syncCurrentUser(jr.user);
} else if(resp.status === 401 || resp.status === 403){
syncAuthToken('');
syncCurrentUser(null);
} else {
try{
const cachedUser = JSON.parse(localStorage.getItem('webgis_user') || 'null');
if(cachedUser && window.authToken){
syncCurrentUser(cachedUser);
} else if(!window.currentUser && cachedUser){
syncCurrentUser(cachedUser);
}
}catch(err){}
}
}catch(e){
try{
const cachedUser = JSON.parse(localStorage.getItem('webgis_user') || 'null');
if(cachedUser && !window.currentUser){
syncCurrentUser(cachedUser);
}
}catch(err){
if(!window.currentUser){
window.currentUser = null;
}
}
}
updateAuthBar();
}
async function logoutUser(){
try{
await fetch('src/api/auth/logout.php', { method: 'POST', credentials: 'same-origin', headers: buildHeaders(null) });
}catch(e){}
syncAuthToken('');
syncCurrentUser(null);
updateAuthBar();
loadData();
}
async function openAdminDashboard(){
if(!isCurrentUserAdmin()) return;
const modal = document.getElementById('admin-modal');
const list = document.getElementById('admin-pending-list');
if(!modal || !list) return;
list.innerHTML = '<div style="padding:12px;color:#64748b">Memuat data...</div>';
modal.classList.remove('hidden');
modal.setAttribute('aria-hidden','false');
try{
const resp = await fetch('src/api/admin/list_pending_managers.php', { credentials: 'same-origin', headers: buildHeaders(null) });
const jr = await resp.json().catch(()=>null);
if(!resp.ok || !jr || !jr.success){
list.innerHTML = '<div class="data-empty">Gagal memuat data.</div>';
return;
}
const items = Array.isArray(jr.pending) ? jr.pending : [];
window.pendingManagerItems = items;
if(items.length === 0){
list.innerHTML = '<div class="data-empty">Tidak ada pendaftaran yang menunggu.</div>';
return;
}
list.innerHTML = items.map((item, index) => `
<div style="border:1px solid #e5e7eb;border-radius:12px;padding:12px;margin-bottom:10px;background:#fff">
<div style="font-weight:700;color:#0f172a">${escapeHtml(item.name || '-')}</div>
<div style="font-size:13px;color:#475569">${escapeHtml(item.email || '-')}</div>
<div style="font-size:12px;color:#64748b;margin-top:6px">${escapeHtml(item.created_at || '')}</div>
<div style="display:flex;gap:8px;margin-top:10px">
<button type="button" class="btn-secondary" onclick="openPendingManagerDetailByIndex(${index})">Detail</button>
</div>
</div>
`).join('');
}catch(e){
console.error(e);
list.innerHTML = '<div class="data-empty">Gagal memuat data.</div>';
}
}
async function approvePendingUser(id){
try{
const resp = await fetch('src/api/admin/approve_user.php', { method: 'POST', headers: buildHeaders(), body: JSON.stringify({ id: id }), credentials: 'same-origin' });
const jr = await resp.json().catch(()=>null);
if(!resp.ok || !jr || !jr.success){ showToast('Gagal menyetujui user', 'error', 4000); return; }
showToast('User disetujui', 'success', 3000);
closePendingManagerDetail();
openAdminDashboard();
}catch(e){ console.error(e); showToast('Gagal menyetujui user', 'error', 4000); }
}
function closeRejectModal(){
const modal = document.getElementById('admin-reject-modal');
if(modal) {
modal.classList.add('hidden');
modal.setAttribute('aria-hidden','true');
}
}
function rejectPendingUser(id){
const modal = document.getElementById('admin-reject-modal');
const input = document.getElementById('admin-reject-reason-input');
const submitBtn = document.getElementById('admin-reject-submit');
const cancelBtn = document.getElementById('admin-reject-cancel');
if(!modal || !input || !submitBtn || !cancelBtn) return;
input.value = '';
submitBtn.disabled = false;
submitBtn.textContent = 'Tolak Pendaftaran';
cancelBtn.onclick = function(){
closeRejectModal();
};
submitBtn.onclick = async function(){
const reason = input.value.trim();
if(!reason){
showToast('Alasan penolakan wajib diisi!', 'error', 4000);
return;
}
submitBtn.disabled = true;
submitBtn.textContent = 'Memproses...';
try{
const resp = await fetch('src/api/admin/reject_user.php', { method: 'POST', headers: buildHeaders(), body: JSON.stringify({ id: id, reason: reason }), credentials: 'same-origin' });
const jr = await resp.json().catch(()=>null);
if(!resp.ok || !jr || !jr.success){
showToast('Gagal menolak user', 'error', 4000);
submitBtn.disabled = false;
submitBtn.textContent = 'Tolak Pendaftaran';
return;
}
showToast('User ditolak', 'success', 3000);
closeRejectModal();
closePendingManagerDetail();
openAdminDashboard();
}catch(e){
console.error(e);
showToast('Gagal menolak user', 'error', 4000);
submitBtn.disabled = false;
submitBtn.textContent = 'Tolak Pendaftaran';
}
};
modal.classList.remove('hidden');
modal.setAttribute('aria-hidden','false');
}
function closeAdminDashboard(){
const modal = document.getElementById('admin-modal');
if(!modal) return;
modal.classList.add('hidden');
modal.setAttribute('aria-hidden','true');
}
function renderDataPanel(){
const headEl = document.getElementById('data-table-head');
const bodyEl = document.getElementById('data-table-body');
const summaryEl = document.getElementById('data-panel-summary');
const paginationInfoEl = document.getElementById('data-pagination-info');
const paginationControlsEl = document.getElementById('data-pagination-controls');
if(!headEl || !bodyEl || !summaryEl || !paginationInfoEl || !paginationControlsEl) return;
const rows = getPanelRecords();
const totalRows = rows.length;
const totalPages = Math.max(1, Math.ceil(totalRows / dataPanelState.pageSize));
if(dataPanelState.page > totalPages) dataPanelState.page = totalPages;
if(dataPanelState.page < 1) dataPanelState.page = 1;
const startIndex = (dataPanelState.page - 1) * dataPanelState.pageSize;
const pageRows = rows.slice(startIndex, startIndex + dataPanelState.pageSize);
const isHouseView = dataPanelState.view === 'house';
const isGuest = !window.currentUser;
if(isHouseView){
headEl.innerHTML = isGuest
? '<tr><th style="width:64px">No</th><th>Alamat</th><th>Status Bulan Ini</th><th>Pemberi Bantuan</th><th style="width:118px;text-align:right">Aksi</th></tr>'
: '<tr><th style="width:64px">No</th><th>Alamat</th><th>Nama Kepala Keluarga</th><th style="width:90px;text-align:center">Anggota</th><th>Penghasilan Bulanan</th><th>Status Bulan Ini</th><th>Pemberi Bantuan</th><th style="width:118px;text-align:right">Aksi</th></tr>';
} else {
headEl.innerHTML = '<tr><th style="width:64px">No</th><th style="width:120px">Rumah Ibadah</th><th style="width:150px">Nama Tempat Ibadah</th><th>Alamat</th><th>Bantuan Bulan Ini</th><th style="width:118px;text-align:right">Aksi</th></tr>';
}
summaryEl.textContent = isHouseView
? 'Menampilkan rumah yang aktif dengan urutan alamat.'
: 'Menampilkan tempat ibadah yang aktif dengan urutan nama.';
if(totalRows === 0){
bodyEl.innerHTML = `<tr><td colspan="${isHouseView ? (isGuest ? 5 : 8) : 6}"><div class="data-empty">Tidak ada data yang cocok untuk filter ini.</div></td></tr>`;
} else {
bodyEl.innerHTML = pageRows.map((item, index) => {
const id = getRecordIdKey(item.id);
const rowNumber = startIndex + index + 1;
if(isHouseView){
const assistedThisMonth = Number(item.bantuan_bulan_ini || 0) > 0 || (item.assisted && parseInt(item.assisted) === 1);
const statusBadge = assistedThisMonth
? '<span class="data-badge is-green">Sudah dibantu bulan ini</span>'
: '<span class="data-badge is-yellow">Belum dibantu bulan ini</span>';
const canEdit = canEditRecord(item);
const canAssistEdit = isCurrentUserManager() || isCurrentUserAdmin();
const providerName = item && item.sumber_bantuan_name ? item.sumber_bantuan_name : '';
const providerType = item && item.sumber_bantuan_jenis ? item.sumber_bantuan_jenis : '';
const providerPrefixed = providerName ? ((providerType ? (providerType.charAt(0).toUpperCase() + providerType.slice(1)) + ' - ' : '') + providerName) : '';
const providerCellContent = assistedThisMonth ? escapeHtml(providerPrefixed || '-') : '<span style="color:#9ca3af">-</span>';
if(isGuest){
return `<tr>
<td><span class="data-index">${rowNumber}</span></td>
<td><div class="data-muted">${escapeHtml(item.alamat || '-')}</div></td>
<td>${statusBadge}</td>
<td><div style="font-size:13px;color:#475569;white-space:nowrap">${providerCellContent}</div></td>
<td><div class="data-actions">${buildActionButton('eye', 'Lihat di peta', 'goToLocation', id)}</div></td>
</tr>`;
}
return `<tr>
<td><span class="data-index">${rowNumber}</span></td>
<td><div class="data-muted">${escapeHtml(item.alamat || '-')}</div></td>
<td><div class="data-name">${escapeHtml(item.nama_kk || item.nama || '-')}</div></td>
<td><div class="data-muted">${escapeHtml(formatCount(item.members, 'orang'))}</div></td>
<td><div class="data-muted">${escapeHtml(formatMoney(item.monthly_income))}</div></td>
<td>${statusBadge}</td>
<td><div style="font-size:13px;color:#475569;white-space:nowrap">${providerCellContent}</div></td>
<td><div class="data-actions">${buildActionButton('eye', 'Lihat di peta', 'goToLocation', id)}${(canEdit || canAssistEdit) ? buildActionButton('edit', 'Edit data', 'openEdit', id) : ''}${canEdit ? buildActionButton('delete', 'Hapus data', 'hapus', id) : ''}</div></td>
</tr>`;
}
const jenisLabel = (item.jenis || 'Tempat Ibadah').charAt(0).toUpperCase() + (item.jenis || 'Tempat Ibadah').slice(1);
const canEdit = canEditRecord(item);
const actionButtons = canEdit
? `${buildActionButton('eye', 'Lihat di peta', 'goToLocation', id)}${buildActionButton('edit', 'Edit data', 'openEdit', id)}${buildActionButton('delete', 'Hapus data', 'hapus', id)}`
: buildActionButton('eye', 'Lihat di peta', 'goToLocation', id);
return `<tr>
<td><span class="data-index">${rowNumber}</span></td>
<td><span class="data-pill" style="margin:0">${escapeHtml(jenisLabel)}</span></td>
<td><div class="data-name">${escapeHtml(item.nama || '-')}</div></td>
<td><div class="data-muted">${escapeHtml(item.alamat || '-')}</div></td>
<td><span class="data-badge is-green">${escapeHtml(formatCount(item.bantuan_bulan_ini, 'bantuan'))}</span></td>
<td><div class="data-actions">${actionButtons}</div></td>
</tr>`;
}).join('');
}
paginationInfoEl.textContent = totalRows === 0
? '0 data'
: `Halaman ${dataPanelState.page} dari ${totalPages} · ${totalRows} data`;
const pages = [];
if(totalPages > 1){
const pushPage = (page) => pages.push(`<button type="button" class="data-page-btn ${page === dataPanelState.page ? 'active' : ''}" onclick="setDataPanelPage(${page})">${page}</button>`);
const pushEllipsis = () => pages.push('<span class="data-page-ellipsis">…</span>');
pages.push(`<button type="button" class="data-page-btn" ${dataPanelState.page === 1 ? 'disabled' : ''} onclick="setDataPanelPage(${Math.max(1, dataPanelState.page - 1)})">Prev</button>`);
if(totalPages <= 7){
for(let page = 1; page <= totalPages; page++) pushPage(page);
} else {
pushPage(1);
const start = Math.max(2, dataPanelState.page - 1);
const end = Math.min(totalPages - 1, dataPanelState.page + 1);
if(start > 2) pushEllipsis();
for(let page = start; page <= end; page++) pushPage(page);
if(end < totalPages - 1) pushEllipsis();
pushPage(totalPages);
}
pages.push(`<button type="button" class="data-page-btn" ${dataPanelState.page === totalPages ? 'disabled' : ''} onclick="setDataPanelPage(${Math.min(totalPages, dataPanelState.page + 1)})">Next</button>`);
}
paginationControlsEl.innerHTML = pages.join('');
}
function switchView(viewType){
const mapWrap = document.getElementById('map-wrap');
const dataPanel = document.getElementById('data-panel');
const viewMapBtn = document.getElementById('view-map-btn');
const viewDataBtn = document.getElementById('view-data-btn');
const topRightControls = document.getElementById('top-right-controls');
if(viewType === 'map'){
if(mapWrap) mapWrap.style.display = 'flex';
if(dataPanel) dataPanel.style.display = 'none';
if(topRightControls) topRightControls.style.display = 'flex';
if(viewMapBtn) { viewMapBtn.style.background = '#1e40af'; viewMapBtn.style.color = '#fff'; viewMapBtn.style.boxShadow = '0 2px 8px rgba(30,64,175,0.3)'; }
if(viewDataBtn) { viewDataBtn.style.background = '#e5e7eb'; viewDataBtn.style.color = '#374151'; viewDataBtn.style.boxShadow = 'none'; }
} else if(viewType === 'data'){
if(mapWrap) mapWrap.style.display = 'none';
if(dataPanel) dataPanel.style.display = 'flex';
if(topRightControls) topRightControls.style.display = 'none';
if(viewMapBtn) { viewMapBtn.style.background = '#e5e7eb'; viewMapBtn.style.color = '#374151'; viewMapBtn.style.boxShadow = 'none'; }
if(viewDataBtn) { viewDataBtn.style.background = '#1e40af'; viewDataBtn.style.color = '#fff'; viewDataBtn.style.boxShadow = '0 2px 8px rgba(30,64,175,0.3)'; }
try{ renderDataPanel(); }catch(e){}
try{ hideMasjidRadius(); }catch(e){}
try{ const modalEl = document.getElementById('modal'); if(modalEl) modalEl.classList.add('hidden'); }catch(e){}
try{ currentEditingLayer = null; }catch(e){}
}
}
function setDataPanelView(view){
if(view !== 'house' && view !== 'worship') return;
dataPanelState.view = view;
dataPanelState.page = 1;
const worshipBtn = document.getElementById('data-view-worship');
const houseBtn = document.getElementById('data-view-house');
if(worshipBtn) worshipBtn.classList.toggle('active', view === 'worship');
if(houseBtn) houseBtn.classList.toggle('active', view === 'house');
renderDataPanel();
}
function setDataPanelPage(page){
const nextPage = Math.max(1, parseInt(page, 10) || 1);
dataPanelState.page = nextPage;
renderDataPanel();
}
// helper to check worship types in record
const worshipTypes = ['masjid','gereja','pura','vihara','klenteng'];
function isWorshipType(jenis){ try{ if(!jenis) return false; return worshipTypes.indexOf(String(jenis).toLowerCase()) !== -1; }catch(e){return false;} }
const assistanceSourceOptions = [
'Pemerintah Pusat',
'Pemerintah Daerah',
'Kementerian Sosial',
'Kementerian Kesehatan',
'Kementerian Pendidikan',
'Baznas',
'Lazis',
'CSR Perusahaan',
'Donatur Individu',
'Lainnya'
];
const assistanceShapeOptions = [
'Tunai',
'Pangan/Sembako',
'Pendidikan',
'Kesehatan',
'Program Keluarga Harapan',
'Lainnya'
];
function escapeHtml(value){
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function getDisplayName(rec){
if(!rec) return '';
const a = String(rec.nama || '').trim();
const b = String(rec.nama_kk || '').trim();
const isDash = s => !s || /^[-–—\s]+$/.test(s);
if(!isDash(a)) return a;
if(!isDash(b)) return b;
return '';
}
function toDateInputValue(value){
try{
if(!value) return '';
return String(value).split(' ')[0].split('T')[0];
}catch(e){
return '';
}
}
function todayDateInputValue(){
try{
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}catch(e){
return '';
}
}
function buildAssistanceFieldsHtml(record){
const assisted = !!(record && record.assisted && parseInt(record.assisted)===1);
const dateValue = assisted ? toDateInputValue(record.last_assisted_at || todayDateInputValue()) : '';
const notesValue = escapeHtml(record && record.assistance_notes ? record.assistance_notes : '');
const shapeValue = String(record && record.bentuk_bantuan ? record.bentuk_bantuan : '');
const sourceValue = String(record && record.sumber_bantuan ? record.sumber_bantuan : '');
const disabledAttr = assisted ? '' : 'disabled';
const shapeOptionsHtml = ['Tunai','Pangan/Sembako','Pendidikan','Kesehatan','Lainnya'].map(v =>
`<option value="${escapeHtml(v)}" ${String(v) === shapeValue ? 'selected' : ''}>${escapeHtml(v)}</option>`
).join('');
return `
<div id="assistance-fields" class="assistance-fields ${assisted ? 'is-enabled' : 'is-disabled'}" style="margin-top:12px">
<label><input name="assisted" type="checkbox" ${assisted ? 'checked' : ''}> Telah menerima bantuan</label>
<label>Tanggal Bantuan <input name="last_assisted_at" type="date" value="${escapeHtml(dateValue)}" ${disabledAttr} ${assisted ? 'required' : ''}></label>
<div class="assistance-combobox">
<label id="sumber-bantuan-label">Sumber Bantuan</label>
<input type="search" id="sumber-bantuan-search" class="assist-source-input" placeholder="Ketik nama tempat ibadah" value="${escapeHtml(sourceValue)}" ${disabledAttr} autocomplete="off">
<button type="button" class="assist-source-toggle" id="sumber-bantuan-toggle" ${disabledAttr} aria-label="Buka daftar sumber bantuan"></button>
<input type="hidden" name="sumber_bantuan" id="sumber-bantuan-value" value="${escapeHtml(sourceValue)}">
<div class="assist-source-panel" id="sumber-bantuan-panel" aria-label="Daftar sumber bantuan"></div>
</div>
<label>Bentuk Bantuan
<select name="bentuk_bantuan" ${disabledAttr} ${assisted ? 'required' : ''}>
<option value="">Pilih bentuk bantuan</option>
${shapeOptionsHtml}
</select>
</label>
<label>Catatan Bantuan <input name="assistance_notes" type="text" value="${notesValue}" ${disabledAttr}></label>
</div>`;
}
function createLocationPinIcon(color){
const fill = color || '#d32f2f';
const html = '<div class="location-pin" style="--pin-color:' + fill + '">' +
'<svg width="30" height="42" viewBox="0 0 30 42" aria-hidden="true" focusable="false">' +
'<path d="M15 1C8.4 1 3 6.4 3 13c0 8.4 12 28 12 28s12-19.6 12-28C27 6.4 21.6 1 15 1z" fill="' + fill + '" stroke="#ffffff" stroke-width="2"></path>' +
'<circle cx="15" cy="13" r="5.25" fill="#ffffff"></circle>' +
'</svg>' +
'</div>';
return L.divIcon({
html: html,
className: 'location-pin-icon',
iconSize: [30, 42],
iconAnchor: [15, 42],
popupAnchor: [0, -36]
});
}
function buildMarkerLegendSvg(color){
const fill = color || '#d32f2f';
return '<span class="legend-marker" aria-hidden="true">' +
'<svg viewBox="0 0 30 42" focusable="false" aria-hidden="true">' +
'<path d="M15 1C8.4 1 3 6.4 3 13c0 8.4 12 28 12 28s12-19.6 12-28C27 6.4 21.6 1 15 1z" fill="' + fill + '" stroke="#ffffff" stroke-width="2"></path>' +
'<circle cx="15" cy="13" r="5.25" fill="#ffffff"></circle>' +
'</svg>' +
'</span>';
}
function buildLayerLegendLabel(title, colors, description){
const colorList = Array.isArray(colors) ? colors : [colors];
const markers = colorList.filter(Boolean).map(color => buildMarkerLegendSvg(color)).join('');
return '<span class="layer-legend" aria-label="' + title + '">' + markers + '<span class="legend-text">' + title + '</span></span>';
}
function syncRequiredFieldMarkers(){
try{
const labels = document.querySelectorAll('#modal-fields label');
labels.forEach(label => {
if(!label || !label.querySelector) return;
const control = label.querySelector('input, select, textarea');
const shouldShow = !!control && !!control.required && !control.disabled;
let title = label.querySelector(':scope > .label-title');
if(shouldShow && !title){
title = document.createElement('span');
title.className = 'label-title';
const beforeNodes = [];
for(const node of Array.from(label.childNodes)){
if(node === control) break;
beforeNodes.push(node);
}
beforeNodes.forEach(node => title.appendChild(node));
label.insertBefore(title, control || label.firstChild);
}
let star = title ? title.querySelector('.required-star') : null;
if(shouldShow){
if(!star){
star = document.createElement('span');
star.className = 'required-star';
star.textContent = '*';
if(title){ title.appendChild(star); }
}
} else if(star){
star.remove();
if(title && !title.textContent.trim() && title.children.length === 0){
title.remove();
}
}
});
const sourceLabel = document.getElementById('sumber-bantuan-label');
const sourceInput = document.getElementById('sumber-bantuan-search');
if(sourceLabel){
const requiredSource = !!(sourceInput && sourceInput.required && !sourceInput.disabled);
let title = sourceLabel.querySelector(':scope > .label-title');
if(requiredSource && !title){
title = document.createElement('span');
title.className = 'label-title';
while(sourceLabel.firstChild){ title.appendChild(sourceLabel.firstChild); }
sourceLabel.appendChild(title);
}
if(title){
let star = title.querySelector('.required-star');
if(requiredSource && !star){
star = document.createElement('span');
star.className = 'required-star';
star.textContent = '*';
title.appendChild(star);
} else if(!requiredSource && star){
star.remove();
}
}
}
}catch(e){}
}
function setRequiredStarForControl(control, required){
try{
if(!control || !control.closest) return;
const label = control.closest('label');
if(!label) return;
let title = label.querySelector(':scope > .label-title');
if(required && !title){
title = document.createElement('span');
title.className = 'label-title';
while(label.firstChild && label.firstChild !== control){
title.appendChild(label.firstChild);
}
label.insertBefore(title, control);
}
if(!title) return;
let star = title.querySelector('.required-star');
if(required){
if(!star){
star = document.createElement('span');
star.className = 'required-star';
star.textContent = '*';
title.appendChild(star);
}
} else if(star){
star.remove();
if(!title.textContent.trim() && title.children.length === 0){ title.remove(); }
}
}catch(e){}
}
function populateAssistanceSourceOptions(){
const panel = document.getElementById('sumber-bantuan-panel');
const valueInput = document.getElementById('sumber-bantuan-value');
const searchInput = document.getElementById('sumber-bantuan-search');
if(!panel || !valueInput || !searchInput) return;
fetch('src/api/get_lokasi.php', { headers: buildHeaders(null), credentials: 'same-origin' })
.then(response => {
if(!response.ok){
throw new Error('get_lokasi_failed_' + response.status);
}
return response.json();
})
.then(allLoc => {
const filter = (window && window.assistanceSourceFilter) ? window.assistanceSourceFilter : null;
const items = (allLoc || [])
.filter(loc => {
if(!loc) return false;
const jenis = String(loc.jenis || '').toLowerCase();
if(filter && filter.worshipOnly){ if(!isWorshipType(jenis)) return false; }
else { if(jenis === 'rumah') return false; }
if(filter && (filter.createdBy !== undefined && filter.createdBy !== null)){
if(String(loc.created_by || '') !== String(filter.createdBy)) return false;
}
return true;
})
.map(loc => {
const rawName = String(loc && loc.nama ? loc.nama : '').trim();
const jenis = String(loc && loc.jenis ? loc.jenis : '').trim();
const label = rawName + (jenis ? ' — ' + jenis.charAt(0).toUpperCase() + jenis.slice(1) : '');
return { id: String(loc.id || ''), name: rawName, label: label };
})
.filter(item => item.name !== '')
.sort((a, b) => a.name.localeCompare(b.name, 'id', { sensitivity: 'base' }));
window.assistanceSourceItems = items;
window.assistanceSourceLoaded = true;
const currentValue = String(valueInput.value || '').trim();
if(currentValue){
const matched = items.find(item => item.id === currentValue || item.name.toLowerCase() === currentValue.toLowerCase() || item.label.toLowerCase() === currentValue.toLowerCase());
if(matched){ searchInput.value = matched.label; valueInput.value = matched.id; }
}
renderAssistanceSourceOptions(searchInput.value || '', false, false);
syncAssistanceSourceSearch();
})
.catch(() => {});
}
function normalizeAssistanceQuery(query){
const q = String(query || '').trim();
if(!q) return { type: 'empty', term: '' };
const wildcard = q.indexOf('*') !== -1 || q.indexOf('?') !== -1;
if(wildcard){
const pattern = '^' + q.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$';
try{ return { type: 'regex', regex: new RegExp(pattern, 'i') }; }catch(e){ return { type: 'contains', term: q.toLowerCase() }; }
}
try{ return { type: 'regex', regex: new RegExp(q, 'i') }; }catch(e){ return { type: 'contains', term: q.toLowerCase() }; }
}
function getFilteredAssistanceSourceItems(query){
try{
const items = Array.isArray(window.assistanceSourceItems) ? window.assistanceSourceItems : [];
const q = normalizeAssistanceQuery(query);
if(q.type === 'empty') return items;
return items.filter(item => {
const text = item.label || '';
if(q.type === 'regex'){ return q.regex.test(text); }
return text.toLowerCase().indexOf(q.term) !== -1;
});
}catch(e){}
return [];
}
function renderAssistanceSourceOptions(query, append=false, showPanel=true){
try{
const panel = document.getElementById('sumber-bantuan-panel');
if(!panel) return;
const items = getFilteredAssistanceSourceItems(query);
const pageSize = 15;
const currentShown = append ? Array.from(panel.querySelectorAll('.assist-source-item')).length : 0;
const nextShown = Math.min(items.length, currentShown > 0 ? currentShown + pageSize : pageSize);
const visibleItems = items.slice(0, nextShown);
panel.innerHTML = '';
if(visibleItems.length === 0){
const empty = document.createElement('div');
empty.className = 'assist-source-empty';
empty.textContent = 'Tidak ditemukan';
panel.appendChild(empty);
if(showPanel) panel.classList.add('show');
return;
}
visibleItems.forEach(item => {
const row = document.createElement('div');
row.className = 'assist-source-item';
row.textContent = item.label;
row.dataset.value = item.id;
row.dataset.label = item.label;
row.addEventListener('mousedown', function(ev){ ev.preventDefault(); selectAssistanceSource(item); });
panel.appendChild(row);
});
if(showPanel) panel.classList.add('show');
}catch(e){}
}
function selectAssistanceSource(item){
try{
const search = document.getElementById('sumber-bantuan-search');
const valueInput = document.getElementById('sumber-bantuan-value');
const panel = document.getElementById('sumber-bantuan-panel');
if(search) search.value = item.label;
if(valueInput) valueInput.value = item.id;
if(panel) panel.classList.remove('show');
}catch(e){}
}
function syncAssistanceSourceSearch(){
try{
const search = document.getElementById('sumber-bantuan-search');
const valueInput = document.getElementById('sumber-bantuan-value');
if(!search || !valueInput) return;
const items = Array.isArray(window.assistanceSourceItems) ? window.assistanceSourceItems : [];
const selected = items.find(item => item.id === String(valueInput.value || ''));
search.value = selected ? selected.label : search.value || '';
}catch(e){}
}
// Geocoding helpers (Nominatim)
function debounce(fn, wait){ let t=null; return function(...args){ clearTimeout(t); t=setTimeout(()=>fn.apply(this,args), wait); }; }
async function geocodeSearch(q){
if(!q || q.length<2) return [];
const url = 'https://nominatim.openstreetmap.org/search?format=jsonv2&addressdetails=1&limit=6&q=' + encodeURIComponent(q);
try{
const r = await fetch(url, { headers: { 'Accept': 'application/json' } });
if(r.ok){ const j = await r.json().catch(()=>null); return j || []; }
}catch(e){
console.error('geocodeSearch failed', e);
}
return [];
}
async function reverseGeocode(lat, lon){
const url = `https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${encodeURIComponent(lat)}&lon=${encodeURIComponent(lon)}`;
try{
const r = await fetch(url, { headers: { 'Accept': 'application/json' } });
if(r.ok){ const j = await r.json().catch(()=>null); return j || null; }
}catch(e){
console.error('reverseGeocode failed', e);
}
return null;
}
function detectWorshipType(r){ try{
const dn = (r.display_name||'').toLowerCase();
const typ = (r.type||'').toLowerCase();
if(dn.indexOf('masjid')!==-1 || dn.indexOf('mosque')!==-1 || typ.indexOf('mosque')!==-1) return 'masjid';
if(dn.indexOf('church')!==-1 || dn.indexOf('gereja')!==-1) return 'gereja';
if(dn.indexOf('pura')!==-1) return 'pura';
if(dn.indexOf('vihara')!==-1 || dn.indexOf('buddhist')!==-1) return 'vihara';
if(dn.indexOf('klenteng')!==-1 || dn.indexOf('kong')!==-1 || dn.indexOf('temple')!==-1) return 'klenteng';
if(typ.indexOf('place_of_worship')!==-1) return 'masjid';
return null;
}catch(e){return null;} }
function extractNameFromResult(r){ try{ if(!r) return ''; if(r.display_name){ const parts = r.display_name.split(','); return parts[0].trim(); } return ''; }catch(e){ return ''; }
}
function formatDateToDMY(s){
try{
if(!s) return '';
// try Date parsing first (ISO or RFC)
const d = new Date(s);
if(!isNaN(d.getTime())){
const dd = ('0'+d.getDate()).slice(-2);
const mm = ('0'+(d.getMonth()+1)).slice(-2);
const yyyy = d.getFullYear();
return dd + '-' + mm + '-' + yyyy;
}
// fallback: accept 'YYYY-MM-DD [HH:MM:SS]' or 'YYYY-MM-DD'
const datePart = (''+s).split(' ')[0];
const parts = datePart.split('-');
if(parts.length===3){ return parts[2] + '-' + parts[1] + '-' + parts[0]; }
return s;
}catch(e){ return s; }
}
function openModal(title, html, onSubmit){
document.getElementById('modal-title').textContent = title;
document.getElementById('modal-fields').innerHTML = html;
syncRequiredFieldMarkers();
// enhance file inputs: add a filename span, a visible size-note and client-side size check (max 5 MB)
try{
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
const ALLOWED_TYPES = ['image/jpeg','image/png','image/webp'];
const fileInputs = document.querySelectorAll('#modal-fields input[type="file"]');
fileInputs.forEach(fi=>{
let s = fi.parentNode.querySelector('.file-name');
if(!s){ s = document.createElement('span'); s.className='file-name'; s.style.display='block'; s.style.fontSize='12px'; s.style.color='#333'; s.style.marginTop='6px'; fi.parentNode.appendChild(s); }
let note = fi.parentNode.querySelector('.file-note');
if(!note){ note = document.createElement('div'); note.className='file-note'; note.style.fontSize='12px'; note.style.color='#666'; note.style.marginTop='6px'; note.textContent = 'Maksimal 5 MB — Format: JPG, PNG, WEBP'; fi.parentNode.appendChild(note); }
fi.addEventListener('change', function(){
const f = (fi.files && fi.files[0]) ? fi.files[0] : null;
if(!f){ s.textContent = ''; return; }
if(f.size > MAX_FILE_SIZE){
try{ showToast('Ukuran file melebihi 5 MB', 'error', 5000); }catch(e){}
fi.value = '';
s.textContent = '';
return;
}
if(f.type && ALLOWED_TYPES.indexOf(f.type) === -1){
try{ showToast('Format file tidak didukung. Gunakan JPG, PNG, atau WEBP.', 'error', 6000); }catch(e){}
fi.value = '';
s.textContent = '';
return;
}
s.textContent = f.name + ' (' + Math.round(f.size/1024) + ' KB)';
});
});
}catch(e){}
// helpers to move phone label into household slot and back
function movePhoneToSlot(){ try{ const labels = Array.from(document.querySelectorAll('#modal-fields label')); const phoneLabel = labels.find(l=> l.querySelector && l.querySelector('input[name="no_telp"]')); const slot = document.querySelector('#modal-fields .hf-phone-slot'); if(phoneLabel && slot && phoneLabel.parentNode !== slot){ slot.appendChild(phoneLabel); } }catch(e){} }
function movePhoneBackBeforeAddress(){ try{ const slotLabel = document.querySelector('#modal-fields .hf-phone-slot > label'); const addrLabel = Array.from(document.querySelectorAll('#modal-fields label')).find(l=> l.querySelector && l.querySelector('input[name="alamat"]')); if(slotLabel && addrLabel && addrLabel.parentNode){ addrLabel.parentNode.insertBefore(slotLabel, addrLabel); } }catch(e){} }
window.movePhoneToSlot = movePhoneToSlot;
window.movePhoneBackBeforeAddress = movePhoneBackBeforeAddress;
// enhance address inputs: autocomplete + selection
try{
const addrInputs = document.querySelectorAll('#modal-fields input[name="alamat"]');
addrInputs.forEach(inp=>{
// create suggestion container
let box = inp.parentNode.querySelector('.suggestions');
if(!box){ box = document.createElement('div'); box.className='suggestions'; box.style.position='absolute'; box.style.background='#fff'; box.style.border='1px solid #e5e7eb'; box.style.borderRadius='6px'; box.style.boxShadow='0 6px 20px rgba(15,23,42,0.06)'; box.style.zIndex=20000; box.style.maxHeight='220px'; box.style.overflow='auto'; box.style.width = inp.offsetWidth + 'px'; box.style.display='none'; box.style.padding='6px'; box.style.marginTop='6px'; inp.parentNode.style.position='relative'; inp.parentNode.appendChild(box); }
const doSearch = debounce(async function(){ const q = inp.value; if(!q || q.length<2){ box.style.display='none'; box.innerHTML=''; return; } const res = await geocodeSearch(q); box.innerHTML=''; if(!res || res.length===0){ box.style.display='none'; return; } res.forEach(r=>{ const el = document.createElement('div'); el.style.padding='6px'; el.style.cursor='pointer'; el.style.borderRadius='6px'; el.style.fontSize='13px'; el.textContent = (r.display_name||r.display_name); el.addEventListener('click', function(){ inp.value = r.display_name || ''; box.style.display='none';
// if suggestion has lat/lon, update editing layer or store coordinates
if(r.lat && r.lon){ try{ if(currentEditingLayer && typeof currentEditingLayer.setLatLng === 'function'){ currentEditingLayer.setLatLng([parseFloat(r.lat), parseFloat(r.lon)]); map.panTo([parseFloat(r.lat), parseFloat(r.lon)]); } // store chosen coords on input dataset
inp.dataset.lat = r.lat; inp.dataset.lon = r.lon; }catch(e){}
}
// if suggestion indicates a place of worship, set jenis accordingly and mark required
try{ const sel = document.querySelector('#modal-fields select[name="jenis"]'); const wt = detectWorshipType(r); if(wt && sel){ sel.value=wt; sel.dispatchEvent(new Event('change')); } }catch(e){}
// auto-fill name if suggestion appears to be a place of worship
try{ const wt2 = detectWorshipType(r); if(wt2){ const nameInput = document.querySelector('#modal-fields input[name="nama"]'); if(nameInput && !nameInput.value) nameInput.value = extractNameFromResult(r); } }catch(e){}
}); el.addEventListener('mouseenter', ()=> el.style.background='#f8fafc'); el.addEventListener('mouseleave', ()=> el.style.background='transparent'); box.appendChild(el); }); box.style.display='block'; }, 300);
inp.addEventListener('input', doSearch);
inp.addEventListener('blur', function(){ setTimeout(()=>{ box.style.display='none'; },200); });
});
}catch(e){}
const modal = document.getElementById('modal'); modal.classList.remove('hidden'); modal.setAttribute('aria-hidden','false');
// after modal opens, ensure phone field is moved into household slot once DOM stabilizes
try{ requestAnimationFrame(()=>{ try{ movePhoneToSlot(); }catch(e){}; setTimeout(()=>{ try{ movePhoneToSlot(); }catch(e){} },10); }); }catch(e){}
// normalize monthly_income display: remove trailing decimals and show integer Rupiah
try{
const mi = document.querySelector('#modal-fields input[name="monthly_income"]');
if(mi){
try{ if(mi.value !== undefined && mi.value !== null && mi.value !== ''){ mi.value = String(Math.round(Number(mi.value))); } }catch(e){}
mi.addEventListener('blur', function(){ try{ if(this.value !== undefined && this.value !== null && this.value !== '') this.value = String(Math.round(Number(this.value))); }catch(e){} });
}
}catch(e){}
// ensure jenis change toggles requiredness for name and alamat and hides photo for masjid
try{
const jenisSel = document.querySelector('#modal-fields select[name="jenis"]');
const nameInp = document.querySelector('#modal-fields input[name="nama"]');
const addrInp = document.querySelector('#modal-fields input[name="alamat"]');
const photoField = document.querySelector('#modal-fields #photo-field, #modal-fields #photo-field-create');
if(jenisSel){
const toggleReq = function(){
if(isWorshipType(jenisSel.value)){
if(nameInp) nameInp.required = true;
if(addrInp) addrInp.required = true;
if(photoField) photoField.style.display = 'none';
} else if (jenisSel.value==='rumah'){
if(nameInp) nameInp.required = false;
if(addrInp) addrInp.required = true;
if(photoField) photoField.style.display = 'block';
// ensure nama_kk (when present) is required
try{ const namaKk = document.querySelector('#modal-fields input[name="nama_kk"]'); if(namaKk) namaKk.required = true; }catch(e){}
} else {
if(nameInp) nameInp.required = false;
if(addrInp) addrInp.required = false;
if(photoField) photoField.style.display = 'block';
}
setRequiredStarForControl(nameInp, !!(nameInp && nameInp.required));
setRequiredStarForControl(addrInp, !!(addrInp && addrInp.required));
try{ const namaKk = document.querySelector('#modal-fields input[name="nama_kk"]'); setRequiredStarForControl(namaKk, !!(namaKk && namaKk.required)); }catch(e){}
syncRequiredFieldMarkers();
};
jenisSel.addEventListener('change', toggleReq);
// initialize
toggleReq();
}
// ensure assisted checkbox toggles assistance fields and resets fields when unchecked
try{
const assistedChk = document.querySelector('#modal-fields input[name="assisted"]');
const assistedDate = document.querySelector('#modal-fields input[name="last_assisted_at"]');
const assistedShape = document.querySelector('#modal-fields select[name="bentuk_bantuan"]');
const assistedNotes = document.querySelector('#modal-fields input[name="assistance_notes"]');
const assistanceBlock = document.getElementById('assistance-fields');
const assistedSourceValue = document.getElementById('sumber-bantuan-value');
window.assistanceSourceLoaded = false;
window.assistanceSourceItems = [];
populateAssistanceSourceOptions();
const assistedSourceSearch = document.getElementById('sumber-bantuan-search');
const assistedSourcePanel = document.getElementById('sumber-bantuan-panel');
const assistedSourceToggle = document.getElementById('sumber-bantuan-toggle');
if(assistedSourceSearch){
assistedSourceSearch.addEventListener('input', function(){
if(assistedSourceValue) assistedSourceValue.value = '';
renderAssistanceSourceOptions(this.value, false);
});
assistedSourceSearch.addEventListener('focus', function(){ renderAssistanceSourceOptions(this.value, false); });
assistedSourceSearch.addEventListener('click', function(){ renderAssistanceSourceOptions(this.value, false); });
assistedSourceSearch.addEventListener('blur', function(){ setTimeout(()=>{ if(assistedSourcePanel) assistedSourcePanel.classList.remove('show'); }, 140); });
assistedSourceSearch.addEventListener('keydown', function(ev){ if(ev.key === 'Escape' && assistedSourcePanel){ assistedSourcePanel.classList.remove('show'); } });
}
if(assistedSourceToggle){
assistedSourceToggle.addEventListener('click', function(){
if(!assistedSourcePanel) return;
if(assistedSourcePanel.classList.contains('show')){ assistedSourcePanel.classList.remove('show'); }
else { renderAssistanceSourceOptions(assistedSourceSearch ? assistedSourceSearch.value : '', false); }
});
}
if(assistedSourcePanel){
assistedSourcePanel.addEventListener('scroll', function(){
const nearBottom = this.scrollTop + this.clientHeight >= this.scrollHeight - 24;
if(nearBottom) renderAssistanceSourceOptions(assistedSourceSearch ? assistedSourceSearch.value : '', true);
});
assistedSourcePanel.addEventListener('mouseleave', function(){ /* keep open until focus changes */ });
document.addEventListener('click', function closeSourcePanel(ev){
const root = document.getElementById('assistance-fields');
if(!root || !ev.target || !root.contains(ev.target)) return;
if(ev.target === assistedSourceSearch || ev.target === assistedSourceToggle || assistedSourcePanel.contains(ev.target)) return;
}, { once: true });
}
const setDisabled = function(el, disabled){ if(!el) return; el.disabled = disabled; };
const toggleAssistFields = function(forceDate){ try{
const enabled = !!(assistedChk && assistedChk.checked);
if(assistanceBlock){ assistanceBlock.classList.toggle('is-enabled', enabled); assistanceBlock.classList.toggle('is-disabled', !enabled); }
[assistedDate, assistedSourceSearch, assistedSourceToggle, assistedShape, assistedNotes].forEach(el => setDisabled(el, !enabled));
if(enabled){
if(assistedDate && (!assistedDate.value || forceDate)){ assistedDate.value = todayDateInputValue(); }
if(assistedDate) assistedDate.required = true;
if(assistedSourceSearch) assistedSourceSearch.required = true;
if(assistedShape) assistedShape.required = true;
} else {
// reset semua field ke kosong
if(assistedDate){ assistedDate.required = false; assistedDate.value = ''; }
if(assistedSourceValue){ assistedSourceValue.value = ''; }
if(assistedSourceSearch){ assistedSourceSearch.required = false; assistedSourceSearch.value = ''; }
if(assistedSourceSearch){ assistedSourceSearch.value = ''; }
if(assistedSourcePanel){ assistedSourcePanel.classList.remove('show'); }
if(assistedShape){ assistedShape.required = false; assistedShape.value = ''; }
if(assistedNotes){ assistedNotes.value = ''; }
}
syncRequiredFieldMarkers();
}catch(e){} };
if(assistedChk){ assistedChk.addEventListener('change', function(){ toggleAssistFields(true); }); toggleAssistFields(false); }
setTimeout(()=>{ try{ syncAssistanceSourceSearch(); }catch(e){} }, 30);
setTimeout(()=>{ try{ syncRequiredFieldMarkers(); }catch(e){} }, 30);
}catch(e){}
}catch(e){}
const form = document.getElementById('modal-form');
form.onsubmit = function(ev){
ev.preventDefault();
try{
const assistedInput = form.querySelector('input[name="assisted"]');
const assistedActive = !!(assistedInput && assistedInput.checked);
const sourceValue = form.querySelector('#sumber-bantuan-value');
if(sourceValue && assistedActive){
const candidate = String(sourceValue.value || '').trim();
if(!candidate){
const sourceSearch = form.querySelector('#sumber-bantuan-search');
if(sourceSearch){ sourceSearch.setCustomValidity('Pilih sumber bantuan dari daftar'); sourceSearch.reportValidity(); }
return;
}
}
const sourceSearch = form.querySelector('#sumber-bantuan-search');
if(sourceSearch) sourceSearch.setCustomValidity('');
}catch(e){}
if(!form.checkValidity()){ form.reportValidity(); return; }
const fd = new FormData(form);
const obj = {};
for(const [k,v] of fd.entries()) obj[k]=v;
const assistedField = form.querySelector('input[name="assisted"]');
const assistedActive = !!(assistedField && assistedField.checked);
obj.assisted = assistedActive ? 1 : 0;
if(assistedActive){
const sourceValue = form.querySelector('#sumber-bantuan-value');
const shapeInput = form.querySelector('select[name="bentuk_bantuan"]');
const notesInput = form.querySelector('input[name="assistance_notes"]');
const dateInput = form.querySelector('input[name="last_assisted_at"]');
if(sourceValue) obj.sumber_bantuan = sourceValue.value || '';
if(shapeInput) obj.bentuk_bantuan = shapeInput.value || '';
if(notesInput) obj.assistance_notes = notesInput.value || '';
if(dateInput) obj.last_assisted_at = dateInput.value || '';
} else {
obj.last_assisted_at = null;
obj.sumber_bantuan = null;
obj.bentuk_bantuan = null;
obj.assistance_notes = null;
}
// capture file input (if any) so callers can upload after modal closes
const fileInput = form.querySelector('input[name="photo"]');
const file = (fileInput && fileInput.files && fileInput.files[0]) ? fileInput.files[0] : null;
onSubmit(obj, file);
closeModal();
// clear current editing layer reference
currentEditingLayer = null;
};
document.getElementById('modal-cancel').onclick = closeModal;
}
function closeModal(){ const modal=document.getElementById('modal'); modal.classList.add('hidden'); modal.setAttribute('aria-hidden','true'); document.getElementById('modal-fields').innerHTML = ''; currentEditingLayer = null; try{ window.assistanceSourceFilter = null; }catch(e){} try{ const submit = document.getElementById('modal-submit'); if(submit){ submit.textContent = 'Simpan'; submit.className = 'btn-primary'; } }catch(e){} }
function showMasjidRadius(marker){
hideMasjidRadius();
const center = marker.getLatLng();
const defaultRadius = 200;
masjidCircle = L.circle(center, {radius: defaultRadius, color:'#1976d2', fillOpacity:0.08, interactive:false}).addTo(map);
masjidControlEl = document.createElement('div'); masjidControlEl.id='masjid-radius-control';
try{ masjidControlEl.style.position='absolute'; masjidControlEl.style.right='10px'; masjidControlEl.style.bottom='20px'; masjidControlEl.style.zIndex='14000'; masjidControlEl.style.width='320px'; }catch(e){}
const rawName = marker._data && marker._data.nama ? String(marker._data.nama).trim() : '';
const typeLabel = marker._data && marker._data.jenis ? String(marker._data.jenis).charAt(0).toUpperCase() + String(marker._data.jenis).slice(1) : 'Tempat Ibadah';
const name = rawName !== '' ? rawName : '';
const alamat = marker._data && marker._data.alamat ? marker._data.alamat : '';
const recId = marker._data && marker._data.id ? marker._data.id : '';
// title
const titleEl = document.createElement('div'); titleEl.className = 'mc-title'; titleEl.textContent = (name || typeLabel); masjidControlEl.appendChild(titleEl);
// address
const addrEl = document.createElement('div'); addrEl.className = 'mc-address'; addrEl.textContent = alamat; masjidControlEl.appendChild(addrEl);
// controls (radius display + slider)
const controls = document.createElement('div'); controls.className = 'mc-controls';
const radiusWrap = document.createElement('div'); radiusWrap.className = 'mc-radius';
radiusWrap.innerHTML = `Radius (m): <input id="masjid-radius-input" type="number" min="50" max="3000" value="${defaultRadius}" style="width:110px;margin-left:8px;padding:6px;border-radius:6px;border:1px solid #e5e7eb">`;
const slider = document.createElement('input'); slider.type='range'; slider.id='masjid-radius-slider'; slider.min='50'; slider.max='3000'; slider.value = String(defaultRadius); slider.style.width='100%';
controls.appendChild(radiusWrap); controls.appendChild(slider); masjidControlEl.appendChild(controls);
// actions
const actions = document.createElement('div'); actions.className = 'mc-actions';
const isGuest = !window.currentUser;
const editBtn = document.createElement('button'); editBtn.id='masjid-edit'; editBtn.textContent='Edit';
const delBtn = document.createElement('button'); delBtn.id='masjid-delete'; delBtn.textContent='Hapus'; delBtn.className='destructive';
const closeBtn = document.createElement('button'); closeBtn.id='masjid-radius-close'; closeBtn.textContent='Tutup';
if(!isGuest){ actions.appendChild(editBtn); actions.appendChild(delBtn); } actions.appendChild(closeBtn); masjidControlEl.appendChild(actions);
// add masjid filter controls (interval inputs) - only for non-guest users
if(!isGuest){
const filterWrap = document.createElement('div'); filterWrap.style.marginTop = '10px';
filterWrap.innerHTML = `<div style="font-weight:600;margin-bottom:6px">Filter untuk pewarnaan (interval)</div>`+
`<div style="display:flex;gap:8px;align-items:center;margin-bottom:6px"><label style="width:110px">Penghasilan (Rp)</label><input id="masjid-income-min" type="number" min="0" value="0" style="width:90px;padding:6px;border-radius:6px;border:1px solid #e5e7eb"> - <input id="masjid-income-max" type="number" min="0" placeholder="(max)" style="width:90px;padding:6px;border-radius:6px;border:1px solid #e5e7eb"></div>`+
`<div style="display:flex;gap:8px;align-items:center;margin-bottom:6px"><label style="width:110px">Jumlah Anggota</label><input id="masjid-members-min" type="number" min="0" value="0" style="width:90px;padding:6px;border-radius:6px;border:1px solid #e5e7eb"> - <input id="masjid-members-max" type="number" min="0" placeholder="(max)" style="width:90px;padding:6px;border-radius:6px;border:1px solid #e5e7eb"></div>`+
`<div style="text-align:right;margin-top:6px"><button id="masjid-filter-apply">Terapkan</button> <button id="masjid-filter-reset">Reset</button></div>`;
// create a toggle button to show/hide filter block
const filterToggle = document.createElement('button'); filterToggle.id='masjid-filter-toggle'; filterToggle.textContent = 'Filter ▾';
filterToggle.style.marginTop = '10px'; filterToggle.style.background = 'transparent'; filterToggle.style.border = '0'; filterToggle.style.color = '#0f172a'; filterToggle.style.cursor = 'pointer'; filterToggle.style.fontWeight = '600';
masjidControlEl.appendChild(filterToggle);
const filterContainer = document.createElement('div'); filterContainer.id = 'masjid-filter-container'; filterContainer.style.display = 'none';
try{ filterContainer.appendChild(filterWrap); masjidControlEl.appendChild(filterContainer); }catch(e){ console.error('failed to append filterWrap into container', e); }
// toggle behavior
try{
filterToggle.addEventListener('click', function(){
try{
if(filterContainer.style.display === 'none' || !filterContainer.style.display){
filterContainer.style.display = 'block';
filterToggle.textContent = 'Filter ▴';
masjidFiltersExpanded = true;
} else {
filterContainer.style.display = 'none';
filterToggle.textContent = 'Filter ▾';
masjidFiltersExpanded = false;
}
updateHomeColors(center, masjidCircle ? masjidCircle.getRadius() : defaultRadius);
}catch(e){ console.error('filter toggle failed', e); }
});
}catch(e){ console.error('filter toggle wiring failed', e); }
// wire filter controls (apply/reset)
try{
const miMin = document.getElementById('masjid-income-min');
const miMax = document.getElementById('masjid-income-max');
const mmMin = document.getElementById('masjid-members-min');
const mmMax = document.getElementById('masjid-members-max');
const applyBtn = document.getElementById('masjid-filter-apply');
const resetBtn = document.getElementById('masjid-filter-reset');
const applyFn = function(){ try{
const imin = miMin && miMin.value !== '' ? Number(miMin.value) : 0;
const imax = miMax && miMax.value !== '' ? Number(miMax.value) : null;
const mmin = mmMin && mmMin.value !== '' ? Number(mmMin.value) : 0;
const mmax = mmMax && mmMax.value !== '' ? Number(mmMax.value) : null;
masjidIncomeMin = isNaN(imin) ? 0 : imin;
masjidIncomeMax = (imax === null || isNaN(imax)) ? null : imax;
masjidMembersMin = isNaN(mmin) ? 0 : mmin;
masjidMembersMax = (mmax === null || isNaN(mmax)) ? null : mmax;
updateHomeColors(center, masjidCircle ? masjidCircle.getRadius() : defaultRadius);
}catch(e){console.error(e);} };
applyBtn && applyBtn.addEventListener('click', applyFn);
resetBtn && resetBtn.addEventListener('click', function(){ try{ if(miMin) miMin.value='0'; if(miMax) miMax.value=''; if(mmMin) mmMin.value='0'; if(mmMax) mmMax.value=''; masjidIncomeMin=0; masjidIncomeMax=null; masjidMembersMin=0; masjidMembersMax=null; updateHomeColors(center, masjidCircle ? masjidCircle.getRadius() : defaultRadius); }catch(e){} });
}catch(e){console.error('masjid filter wiring failed', e);}
}
try{ document.body.appendChild(masjidControlEl); }catch(e){ console.error('failed to append masjidControlEl', e); }
slider.addEventListener('input', function(e){ const r = parseInt(e.target.value,10); try{ masjidCircle.setRadius(r); }catch(e){}; const num = document.getElementById('masjid-radius-input'); if(num) num.value = r; updateHomeColors(center, r); });
// allow user to type radius number directly; only apply during input when within range, enforce bounds on blur/Enter
try{
const numInp = document.getElementById('masjid-radius-input');
if(numInp){
numInp.addEventListener('input', function(ev){
const raw = this.value;
const v = parseInt(raw,10);
if(isNaN(v)) return; // allow typing
if(v >= 50 && v <= 3000){
try{ slider.value = String(v); masjidCircle.setRadius(v); }catch(e){}
updateHomeColors(center, v);
}
});
// enforce/clamp on blur
numInp.addEventListener('blur', function(){
let v = parseInt(this.value,10);
if(isNaN(v)){
// reset to current slider value
this.value = slider.value;
return;
}
if(v < 50){ v = 50; this.value = v; try{ showToast('Radius minimal 50 m','error',2200); }catch(e){} }
if(v > 3000){ v = 3000; this.value = v; try{ showToast('Radius maksimal 3000 m','error',2200); }catch(e){} }
try{ slider.value = String(v); masjidCircle.setRadius(v); }catch(e){}
updateHomeColors(center, v);
});
// also apply on Enter
numInp.addEventListener('keydown', function(ev){ if(ev.key === 'Enter'){ this.blur(); } });
}
}catch(e){}
closeBtn.addEventListener('click', hideMasjidRadius);
if(!isGuest){
if(editBtn){ editBtn.addEventListener('click', function(){ hideMasjidRadius(); openEdit(recId); }); }
if(delBtn){ delBtn.addEventListener('click', function(){ hideMasjidRadius(); hapus(recId); }); }
}
updateHomeColors(center, defaultRadius);
}
function hideMasjidRadius(){
if(masjidCircle){ map.removeLayer(masjidCircle); masjidCircle=null; }
if(masjidControlEl){ masjidControlEl.remove(); masjidControlEl=null; }
// restore marker colors: assisted -> green, others -> red (clear masjid-filtering)
rumahMarkers.forEach(m=>{ try{ const assisted = m._data && (m._data.assisted && parseInt(m._data.assisted)===1); const color = assisted ? '#2e7d32' : '#d32f2f'; if(m && typeof m.setIcon === 'function'){ m.setIcon(createLocationPinIcon(color)); } }catch(e){} });
}
function updateHomeColors(centerLatLng, radiusMeters){
// If masjidCircle exists, prefer its center/radius for coloring
try{
if(masjidCircle){ centerLatLng = masjidCircle.getLatLng(); radiusMeters = masjidCircle.getRadius(); }
}catch(e){}
rumahMarkers.forEach(m=>{
try{
const latlng = m.getLatLng();
const dist = (centerLatLng && typeof centerLatLng.distanceTo === 'function') ? latlng.distanceTo(centerLatLng) : Infinity;
const assisted = m._data && (m._data.assisted && parseInt(m._data.assisted)===1);
// only apply masjid filters when masjidCircle is active AND the filter UI is expanded
const filtersActive = !!masjidCircle && !!masjidFiltersExpanded;
const incomeVal = (m._data && m._data.monthly_income !== undefined && m._data.monthly_income !== null && m._data.monthly_income !== '') ? Number(m._data.monthly_income) : null;
const membersVal = (m._data && m._data.members !== undefined && m._data.members !== null && m._data.members !== '') ? Number(m._data.members) : null;
const meetsIncome = filtersActive && (incomeVal !== null) && (Number(incomeVal) >= Number(masjidIncomeMin)) && (masjidIncomeMax === null || Number(incomeVal) <= Number(masjidIncomeMax));
const meetsMembers = filtersActive && (membersVal !== null) && (Number(membersVal) >= Number(masjidMembersMin)) && (masjidMembersMax === null || Number(membersVal) <= Number(masjidMembersMax));
// Use AND between filters: house must satisfy BOTH income AND members when filters are active
if(assisted || ((radiusMeters !== undefined && dist <= radiusMeters) && ( (!filtersActive) || (meetsIncome && meetsMembers) ))){
if(m && typeof m.setIcon === 'function'){ m.setIcon(createLocationPinIcon('#2e7d32')); }
} else {
if(m && typeof m.setIcon === 'function'){ m.setIcon(createLocationPinIcon('#d32f2f')); }
}
}catch(e){}
});
}
function loadData(){
try{ hideMasjidRadius(); }catch(e){}
fetch('src/api/get_lokasi.php', { credentials: 'same-origin', headers: buildHeaders(null) }).then(r=>r.json()).then(data=>{
lokasiGroup.clearLayers(); rumahBelumDibantuGroup.clearLayers(); rumahSudahDibantuGroup.clearLayers(); masjidGroup.clearLayers(); rumahMarkers.length=0; lokasiMarkerById = new Map();
lokasiRecords = Array.isArray(data) ? data : [];
let rumahCount=0, masjidCount=0;
lokasiRecords.forEach(item=>{
const jenis = (item.jenis||'').toLowerCase();
const alamatEsc = (item.alamat||'');
const color = isWorshipType(jenis) ? '#1976d2' : '#d32f2f';
if(jenis==='rumah' || isWorshipType(jenis)){
const marker = L.marker([item.latitude, item.longitude],{icon:createLocationPinIcon(color)});
marker._data = item; marker._jenis = jenis;
lokasiMarkerById.set(getRecordIdKey(item.id), marker);
let photoHtml = '';
if(item.photo_path){
const sep = item.photo_path.indexOf('?') !== -1 ? '&' : '?';
const photoUrl = item.photo_path + sep + 't=' + Date.now();
photoHtml = `<div style="margin-top:6px"><a class="popup-image-link" href="${photoUrl}"><img src="${photoUrl}" style="max-width:160px;max-height:120px;cursor:pointer"></a></div>`;
}
if(isWorshipType(jenis)){
masjidCount++;
masjidGroup.addLayer(marker);
marker.on('click', ()=>{ showMasjidRadius(marker); });
} else {
rumahCount++;
const assisted = item.assisted && parseInt(item.assisted)===1;
const statusHtml = assisted ? "<span style='color:green'>Telah menerima bantuan bulan ini</span>" : "<span style='color:orange'>Belum menerima bantuan bulan ini</span>";
const isGuest = !window.currentUser;
const canAssistEdit = isCurrentUserManager() || isCurrentUserAdmin();
const showEditBtn = canEditRecord(item) || (!isGuest && canAssistEdit);
const actionHtml = showEditBtn
? `${canEditRecord(item) ? `<button onclick="hapus('${item.id}')">Hapus</button> ` : ''}<button onclick="openEdit('${item.id}')">Edit</button> <button class="popup-close" onclick="closePopupById('${item.id}')">Tutup</button>`
: `<button class="popup-close" onclick="closePopupById('${item.id}')">Tutup</button>`;
const popupTitle = isGuest ? 'Lokasi Bantuan' : (getDisplayName(item) || 'Lokasi Bantuan');
const providerName = item && item.sumber_bantuan_name ? item.sumber_bantuan_name : '';
const providerType = item && item.sumber_bantuan_jenis ? item.sumber_bantuan_jenis : '';
const providerPrefixed = providerName ? ((providerType ? (providerType.charAt(0).toUpperCase() + providerType.slice(1)) + ' - ' : '') + providerName) : '';
const providerHtml = assisted ? `<div style="font-size:13px;color:#475569;margin-top:6px">Pemberi: ${escapeHtml(providerPrefixed || '-')}</div>` : `<div style="font-size:13px;color:#9ca3af;margin-top:6px">Pemberi: -</div>`;
const popupHtml = `<div class="location-popup location-popup-house"><div class="location-popup-title">${escapeHtml(popupTitle)}</div><div>${statusHtml}</div><div class="location-popup-meta">${escapeHtml(alamatEsc)}</div>${providerHtml}${!isGuest && photoHtml ? `<div class="location-popup-photo">${photoHtml}</div>` : ''}<div class="location-popup-actions">${actionHtml}</div></div>`;
marker.bindPopup(popupHtml, { closeButton: false, maxWidth: 640, minWidth: 420, autoPanPadding: [24, 24] });
rumahMarkers.push(marker);
if(assisted) {
marker.setIcon(createLocationPinIcon('#2e7d32'));
rumahSudahDibantuGroup.addLayer(marker);
} else {
marker.setIcon(createLocationPinIcon('#d32f2f'));
rumahBelumDibantuGroup.addLayer(marker);
}
}
lokasiGroup.addLayer(marker);
} else {
const marker = L.marker([item.latitude, item.longitude]);
marker._data = item; marker._jenis = jenis;
lokasiMarkerById.set(getRecordIdKey(item.id), marker);
const canAssistEdit = isCurrentUserManager() || isCurrentUserAdmin();
const showEditBtn2 = canEditRecord(item) || canAssistEdit;
const actionHtml = showEditBtn2 ? `<button onclick=\"hapus('${item.id}')\">Hapus</button> <button onclick=\"openEdit('${item.id}')\">Edit</button>` : '';
marker.bindPopup(`<div class="location-popup"><div class="location-popup-title">${escapeHtml(item.nama || '')}</div><div class="location-popup-meta">${escapeHtml(alamatEsc)}</div><div class="location-popup-actions">${actionHtml}<button class="popup-close" onclick=\"closePopupById('${item.id}')\">Tutup</button></div></div>`, { closeButton: false, maxWidth: 520, minWidth: 360, autoPanPadding: [24, 24] });
lokasiGroup.addLayer(marker);
}
});
try{ const dbg = document.getElementById('debug-overlay'); if(dbg){ dbg.textContent = `Loaded ${lokasiRecords.length} lokasi — Rumah: ${rumahCount}, Masjid: ${masjidCount}`; } }catch(e){}
try{ updateHomeColors(map.getCenter(), 0); }catch(e){}
renderDataPanel();
}).catch(err=>console.error(err));
}
function hapus(id){
// show confirmation modal with record name (if available)
fetch('src/api/get_lokasi.php', { credentials: 'same-origin', headers: buildHeaders(null) }).then(r=>r.json()).then(arr=>{
const rec = (arr||[]).find(x=>String(x.id)===String(id));
const displayName = rec ? (rec.nama || rec.nama_kk || 'lokasi ini') : 'lokasi ini';
const html = `<input type="hidden" name="id" value="${id}"><p>Anda yakin ingin menghapus <b>${displayName}</b>? Tindakan ini <b>tidak dapat dibatalkan</b>.</p>`;
openModal('Konfirmasi Hapus', html, function(values){
(async function(){
try{
const resp = await fetch('src/api/hapus_lokasi.php',{method:'POST',headers:buildHeaders(),body:JSON.stringify({id:id})});
const jr = await resp.json().catch(()=>null);
if(jr && jr.success===false){ showToast('Gagal menghapus: ' + (jr.error||'unknown'), 'error', 6000); }
}catch(e){ console.error('hapus failed', e); showToast('Gagal menghapus lokasi', 'error', 6000); }
loadData();
})();
});
// style the submit button as destructive for this confirmation
try{ const submit = document.getElementById('modal-submit'); if(submit){ submit.textContent = 'Hapus'; submit.className = 'btn-destructive'; } }catch(e){}
}).catch(err=>{ console.error(err); showToast('Gagal memuat data lokasi', 'error', 4000); });
}
function openEdit(id){
// fetch record then open modal
fetch('src/api/get_lokasi.php', { credentials: 'same-origin', headers: buildHeaders(null) }).then(r=>r.json()).then(arr=>{
const rec = (arr||[]).find(x=>String(x.id)===String(id));
if(!rec){ showToast('Record tidak ditemukan', 'error', 4200); return; }
const isAdmin = isCurrentUserAdmin();
const isManager = isCurrentUserManager();
const isOwner = String(rec && rec.created_by ? rec.created_by : '') === String(window.currentUser && window.currentUser.id ? window.currentUser.id : '');
const canFullEdit = isAdmin || (isManager && isOwner);
if(!canFullEdit && !isManager){
showToast('Anda hanya bisa mengedit data milik Anda sendiri', 'error', 5000);
return;
}
// If manager but not owner, allow assistance-only edit (restricted modal)
if(isManager && !isOwner && !isAdmin){
const hasNamaKk = rec && rec.nama_kk && !/^[-–—\s]+$/.test(String(rec.nama_kk || '').trim());
const kepalaKeluargaHtml = hasNamaKk ? `<div style="margin-top:8px"><strong>Nama Kepala Keluarga</strong><div style="margin-top:6px">${escapeHtml(rec.nama_kk)}</div></div>` : '';
const namaFallbackHtml = !hasNamaKk ? `<div><strong>Nama</strong><div style="margin-top:6px">${escapeHtml(getDisplayName(rec) || '')}</div></div>` : '';
const html = `<input type="hidden" name="id" value="${rec.id}">`+
`${namaFallbackHtml}`+
`${kepalaKeluargaHtml}`+
`<div style="margin-top:8px"><strong>Alamat</strong><div style="margin-top:6px">${escapeHtml(rec.alamat || '')}</div></div>`+
`${buildAssistanceFieldsHtml(rec)}`;
// restrict assistance source to worship places created by current manager
try{ window.assistanceSourceFilter = { createdBy: window.currentUser && window.currentUser.id ? window.currentUser.id : null, worshipOnly: true }; }catch(e){}
openModal('Edit Bantuan', html, function(values){
const payload = { id: rec.id };
// only submit assistance-related fields
payload.assisted = values.assisted ? 1 : 0;
payload.last_assisted_at = values.last_assisted_at || null;
payload.sumber_bantuan = values.sumber_bantuan || '';
payload.bentuk_bantuan = values.bentuk_bantuan || '';
payload.assistance_notes = values.assistance_notes || '';
(async function(){
try{
const resp = await fetch('src/api/update_lokasi.php',{method:'POST',headers:buildHeaders(),body:JSON.stringify(payload),credentials:'same-origin'});
const jr = await resp.json().catch(()=>null);
if(!resp.ok || (jr && jr.success===false)){
showToast('Gagal memperbarui data bantuan', 'error', 5000);
} else {
try{ showToast('Perubahan bantuan tersimpan', 'success', 3000); }catch(e){}
}
}catch(e){ console.error(e); showToast('Gagal memperbarui data bantuan', 'error', 5000); }
loadData();
})();
});
return;
}
const householdHtml = (rec.jenis === 'rumah') ?
`<div id="household-fields">`+
`<label>Nama Kepala Keluarga <input name="nama_kk" type="text" value="${rec.nama_kk||''}" required></label><div class="hf-phone-slot"></div><label>Status Rumah <input name="rumah_status" type="text" value="${rec.rumah_status||''}"></label><label>Jumlah Anggota <input name="members" type="number" value="${rec.members||''}" required></label><label>Penghasilan Bulanan (Rp) <input name="monthly_income" type="number" step="1" min="0" value="${(rec.monthly_income !== undefined && rec.monthly_income !== null && rec.monthly_income !== '') ? Math.round(Number(rec.monthly_income)) : ''}" required></label><div style="font-size:12px;color:#666;margin-top:4px">Masukkan nilai dalam Rupiah, tanpa simbol (contoh: 2000000)</div>`+
`</div>` : `<div id="household-fields"></div>`;
const html = `<input type=\"hidden\" name=\"id\" value=\"${rec.id}\">`+
`<label>Jenis <select name=\"jenis\" required><option value=\"\">Pilih jenis</option><option value=\"rumah\">Rumah</option><option value=\"masjid\">Masjid</option><option value=\"gereja\">Gereja</option><option value=\"pura\">Pura</option><option value=\"vihara\">Vihara</option><option value=\"klenteng\">Klenteng</option></select></label>`+
`<div id=\"nama-field\" style=\"display:block\"><label>Nama <input name=\"nama\" type=\"text\" value=\"${rec.nama||''}\"></label></div>`+
`${householdHtml}`+
`<label>No Telp <input name=\"no_telp\" type=\"text\" value=\"${rec.no_telp||''}\"></label>`+
`<label>Alamat <input name=\"alamat\" type=\"text\" value=\"${rec.alamat||''}\"></label>`+
`${buildAssistanceFieldsHtml(rec)}`+
`<div id=\"photo-field\" style=\"display:none\"><label>Foto Rumah <input name=\"photo\" type=\"file\" accept=\"image/*\"></label><div id=\"current-photo-name\" style=\"font-size:12px;color:#444;margin-top:6px\"></div></div>`;
// clear any assistance source filter for full edit
try{ window.assistanceSourceFilter = null; }catch(e){}
openModal('Edit Lokasi', html, function(values, file){
const payload = {id: id, nama: values.nama||'', jenis: values.jenis||'rumah', no_telp: values.no_telp||'', alamat: values.alamat||'', nama_kk: values.nama_kk||null, rumah_status: values.rumah_status||null, members: values.members||null, monthly_income: values.monthly_income||null };
const assistedOn = values.assisted ? 1 : 0;
payload.assisted = assistedOn;
if(assistedOn){
payload.last_assisted_at = values.last_assisted_at || '';
payload.sumber_bantuan = values.sumber_bantuan || '';
payload.bentuk_bantuan = values.bentuk_bantuan || '';
payload.assistance_notes = values.assistance_notes || '';
} else {
payload.last_assisted_at = null;
payload.sumber_bantuan = null;
payload.bentuk_bantuan = null;
payload.assistance_notes = null;
}
(async function(){
let updateSuccess = false;
try{
const respUpdate = await fetch('src/api/update_lokasi.php',{method:'POST',headers:buildHeaders(),body:JSON.stringify(payload),credentials:'same-origin'});
const jrUpdate = await respUpdate.json().catch(()=>null);
if(!respUpdate.ok || (jrUpdate && jrUpdate.success===false)){
const reason = jrUpdate && jrUpdate.error ? jrUpdate.error : 'unknown';
showToast('Gagal menyimpan perubahan: ' + reason, 'error', 6000);
} else {
updateSuccess = true;
// optimistically update marker on the map so UI reflects change immediately
try{
const idStr = String(id);
const m = rumahMarkers.find(x=> x && x._data && String(x._data.id)===idStr);
if(m){
try{ m._data = Object.assign({}, m._data || {}, payload); }catch(e){}
// Recompute colors using centralized logic (includes assisted, masjid radius and filters)
try{ updateHomeColors(map.getCenter(), 0); }catch(e){}
// update popup content if present
try{
const pd = m._data || {};
const statusHtml = (pd.assisted && parseInt(pd.assisted)===1) ? "<span style='color:green'>Telah menerima bantuan</span>" : "<span style='color:orange'>Belum menerima bantuan</span>";
const alamatEsc = pd.alamat || '';
let photoHtml = '';
if(pd.photo_path){ const sep = pd.photo_path.indexOf('?') !== -1 ? '&' : '?'; const photoUrl = pd.photo_path + sep + 't=' + Date.now(); photoHtml = `<div style="margin-top:6px"><a class="popup-image-link" href="${photoUrl}"><img src="${photoUrl}" style="max-width:160px;max-height:120px;cursor:pointer"></a></div>`; }
const nameLabel = pd.nama || pd.nama_kk || '';
const actionHtml = canEditRecord(pd) ? `<br><button onclick="hapus('${pd.id}')">Hapus</button> <button onclick="openEdit('${pd.id}')">Edit</button>` : '';
const newPopupHtml = `<b>${nameLabel}</b><br>${statusHtml}<div style="font-size:12px;color:#444;margin-top:6px">${alamatEsc}</div>${photoHtml}${actionHtml}`;
try{ const p = m.getPopup && m.getPopup(); if(p){ p.setContent(newPopupHtml); } }catch(e){}
}catch(e){}
}
}catch(e){}
}
}catch(e){ console.error('update failed', e); showToast('Gagal menyimpan perubahan: ' + (e && e.message ? e.message : e), 'error', 6000); }
if(updateSuccess){
let uploadSuccess = true;
if(file){
try{
const fdata = new FormData(); fdata.append('file', file); fdata.append('id', id);
const resp = await fetch('src/api/upload_photo.php',{method:'POST',headers:buildHeaders(null),body:fdata,credentials:'same-origin'});
const jr = await resp.json().catch(()=>null);
if(!jr || !jr.success){
uploadSuccess = false;
const reason = jr && (jr.error || (jr.last_error && jr.last_error.message)) ? (jr.error || jr.last_error.message) : 'unknown';
showToast('Gagal mengunggah foto: ' + reason, 'error', 7000);
} else {
showToast('Foto berhasil diunggah', 'success', 3000);
}
}catch(e){
uploadSuccess = false;
console.error('upload failed',e);
showToast('Gagal mengunggah foto: ' + (e && e.message ? e.message : e), 'error', 7000);
}
}
if(uploadSuccess){
showToast('Perubahan tersimpan', 'success', 2600);
}
}
loadData();
})();
});
// populate household fields
setTimeout(()=>{
const sel = document.querySelector('#modal-fields select[name="jenis"]'); if(sel) sel.value = rec.jenis||'rumah';
// if this record has a fixed jenis (rumah or masjid), prevent changing jenis in edit mode
try{
const recJenis = (rec.jenis||'').toLowerCase();
if(sel && (recJenis==='rumah' || isWorshipType(recJenis))){
sel.disabled = true;
// ensure a hidden input exists so the jenis value is submitted
let existing = document.querySelector('#modal-fields input[type="hidden"][name="jenis"]');
if(!existing){
const hin = document.createElement('input'); hin.type='hidden'; hin.name='jenis'; hin.value = rec.jenis||''; document.getElementById('modal-fields').appendChild(hin);
} else {
try{ existing.value = rec.jenis||''; }catch(e){}
}
} else if(sel){
sel.disabled = false;
const existing = document.querySelector('#modal-fields input[type="hidden"][name="jenis"]'); if(existing) try{ existing.remove(); }catch(e){}
}
}catch(e){}
const hf = document.getElementById('household-fields'); const namaField=document.getElementById('nama-field'); const assistanceFields=document.getElementById('assistance-fields'); const photoField=document.getElementById('photo-field');
function render(){
if(!hf) return;
if(sel && sel.value==='rumah'){
hf.innerHTML = `<label>Nama Kepala Keluarga <input name=\"nama_kk\" type=\"text\" value=\"${rec.nama_kk||''}\" required></label><div class=\"hf-phone-slot\"></div><label>Status Rumah <input name=\"rumah_status\" type=\"text\" value=\"${rec.rumah_status||''}\"></label><label>Jumlah Anggota <input name=\"members\" type=\"number\" value=\"${rec.members||''}\" required></label><label>Penghasilan Bulanan (Rp) <input name=\"monthly_income\" type=\"number\" step=\"1\" min=\"0\" value=\"${(rec.monthly_income !== undefined && rec.monthly_income !== null && rec.monthly_income !== '') ? Math.round(Number(rec.monthly_income)) : ''}\" required></label><div style=\"font-size:12px;color:#666;margin-top:4px\">Masukkan nilai dalam Rupiah, tanpa simbol (contoh: 2000000)</div>`;
if(namaField) namaField.style.display='none';
if(assistanceFields) assistanceFields.style.display='block';
if(photoField) photoField.style.display='block';
try{ const assistChk = document.querySelector('#modal-fields input[name="assisted"]'); if(assistChk && assistChk.checked === false){ assistChk.dispatchEvent(new Event('change')); } }catch(e){}
try{
const slot = hf.querySelector('.hf-phone-slot');
let phoneInput = document.querySelector('#modal-fields input[name="no_telp"]');
let phoneLabel = phoneInput ? phoneInput.closest('label') : null;
if(!phoneLabel){
// create a labeled phone input inside the slot
phoneLabel = document.createElement('label');
phoneLabel.innerHTML = 'No Telp <input name="no_telp" type="text">';
phoneInput = phoneLabel.querySelector('input[name="no_telp"]');
if(phoneInput && rec && rec.no_telp) phoneInput.value = rec.no_telp || '';
}
// append into slot (dedupe if another exists elsewhere)
if(slot && phoneLabel && slot.firstChild !== phoneLabel){
slot.appendChild(phoneLabel);
}
// remove any duplicate top-level phone labels (except the one we appended)
Array.from(document.querySelectorAll('#modal-fields label')).forEach(l=>{
if(l !== phoneLabel && l.querySelector && l.querySelector('input[name="no_telp"]')){
try{ l.remove(); }catch(e){}
}
});
}catch(e){}
} else {
hf.innerHTML='';
if(namaField) namaField.style.display='block';
if(assistanceFields) assistanceFields.style.display='none';
if(photoField) photoField.style.display='none';
try{ const assistChk = document.querySelector('#modal-fields input[name="assisted"]'); if(assistChk && assistChk.checked){ assistChk.checked = false; assistChk.dispatchEvent(new Event('change')); } }catch(e){}
try{
const moved = hf.querySelector('.hf-phone-slot > label');
if(moved){ const addr = document.querySelector('#modal-fields input[name=\"alamat\"]'); if(addr && addr.parentNode){ addr.parentNode.parentNode.insertBefore(moved, addr.parentNode); } }
}catch(e){}
}
}
if(sel) sel.addEventListener('change', render); render();
try{
const f = document.querySelector('#modal-fields input[name="nama_kk"]'); if(f) f.value = rec.nama_kk||'';
const rs = document.querySelector('#modal-fields input[name="rumah_status"]'); if(rs) rs.value = rec.rumah_status||'';
const mem = document.querySelector('#modal-fields input[name="members"]'); if(mem) mem.value = (rec.members !== undefined && rec.members !== null) ? rec.members : '';
const mi = document.querySelector('#modal-fields input[name="monthly_income"]');
if(mi){
if(rec.monthly_income !== undefined && rec.monthly_income !== null && rec.monthly_income !== ''){
try{ mi.value = String(Math.round(Number(rec.monthly_income))); }catch(e){ try{ const raw = String(rec.monthly_income); mi.value = raw.replace(/\.00$/,'').replace(/\.0$/,''); }catch(ee){ mi.value = rec.monthly_income; } }
} else { mi.value = ''; }
}
const p = document.querySelector('#modal-fields input[name="no_telp"]'); if(p) p.value = rec.no_telp||'';
const fa = document.querySelector('#modal-fields input[name="assisted"]'); if(fa) fa.checked = rec.assisted && parseInt(rec.assisted)===1;
const fd = document.querySelector('#modal-fields input[name="last_assisted_at"]'); if(fd) fd.value = rec.last_assisted_at ? rec.last_assisted_at.split(' ')[0] : '';
if(fa){ try{ fa.dispatchEvent(new Event('change')); }catch(e){} }
const fs = document.querySelector('#sumber-bantuan-value');
if(fs){
const currentValue = String(rec.sumber_bantuan || '');
fs.value = currentValue;
}
const fb = document.querySelector('#modal-fields select[name="bentuk_bantuan"]'); if(fb) fb.value = rec.bentuk_bantuan||'';
// bantuan_terakhir removed from schema; skip showing historical assistance
const fn = document.querySelector('#modal-fields input[name="assistance_notes"]'); if(fn) fn.value = rec.assistance_notes||'';
try{ setTimeout(()=>{ syncAssistanceSourceSearch(); }, 40); }catch(e){}
// ensure top-level name and alamat are set as well
const nameTop = document.querySelector('#modal-fields input[name="nama"]'); if(nameTop) nameTop.value = rec.nama||'';
const addrTop = document.querySelector('#modal-fields input[name="alamat"]'); if(addrTop) addrTop.value = rec.alamat||'';
// ensure phone moved into slot after values populated
requestAnimationFrame(()=>{ try{ movePhoneToSlot(); }catch(e){}; setTimeout(()=>{ try{ movePhoneToSlot(); }catch(e){} },10); });
// show current photo filename (instead of browser 'No file chosen')
const photoNameEl = document.getElementById('current-photo-name');
if(photoNameEl){
if(rec.photo_path){
let bn = '';
let urlRaw = rec.photo_path || '';
try{
// If the stored path is the serve_upload fallback, extract the `f` query value
const m = urlRaw.match(/[?&]f=([^&]+)/);
if(m && m[1]){
bn = decodeURIComponent(m[1]);
}
}catch(e){}
if(!bn){ bn = (urlRaw||'').split('/').pop().split('?')[0]; }
const url = urlRaw + (urlRaw.indexOf('?') !== -1 ? '&' : '?') + 't=' + Date.now();
photoNameEl.innerHTML = `Foto saat ini: <a href=\"${url}\" target=\"_blank\">${bn}</a>`;
} else {
photoNameEl.innerHTML = '';
}
}
// ensure jenis select reflects current value (or placeholder)
try{ const sel = document.querySelector('#modal-fields select[name="jenis"]'); if(sel){ sel.value = rec.jenis||''; sel.dispatchEvent(new Event('change')); } }catch(e){}
// after populating values, ensure phone moved into household for rumah
try{ requestAnimationFrame(()=>{ try{ movePhoneToSlot(); }catch(e){}; setTimeout(()=>{ try{ movePhoneToSlot(); }catch(e){} },10); }); }catch(e){}
}catch(e){}
// if this is a place of worship and name empty, attempt reverse geocode to suggest a name
try{
if(isWorshipType((rec.jenis||'').toLowerCase()) && (!rec.nama || rec.nama.trim()==='') && rec.latitude && rec.longitude){
(async function(){ const rev = await reverseGeocode(rec.latitude, rec.longitude); const wt = rev ? detectWorshipType(rev) : null; if(rev && wt){ const nameInp = document.querySelector('#modal-fields input[name="nama"]'); if(nameInp && !nameInp.value) nameInp.value = extractNameFromResult(rev); } })();
}
}catch(e){}
},50);
});
}
// Leaflet.draw: add draw control for adding markers
const drawControl = new L.Control.Draw({
draw: { marker: true, polyline: false, polygon: false, rectangle: false, circle: false, circlemarker: false },
edit: { featureGroup: lokasiGroup, edit: false, remove: false }
});
// image viewer: delegate clicks from popup thumbnails and filename links
function openImageViewer(url){
try{
const viewer = document.getElementById('image-viewer');
const img = document.getElementById('image-viewer-img');
if(!viewer || !img) return;
img.src = url;
viewer.style.display = 'flex';
}catch(e){ console.error(e); }
}
function isPreviewableImage(path){
if(!path) return false;
return /\.(png|jpe?g|gif|webp|svg)(\?|#|$)/i.test(String(path));
}
function openProofPreview(path){
if(!path) return;
if(isPreviewableImage(path)){
openImageViewer(path + (path.indexOf('?') !== -1 ? '&' : '?') + 't=' + Date.now());
return;
}
window.open(path, '_blank', 'noopener,noreferrer');
}
document.addEventListener('click', function(ev){
try{
const a = ev.target.closest && ev.target.closest('a');
if(!a) return;
const href = a.getAttribute('href') || '';
if(a.classList.contains('popup-image-link') || href.indexOf('/uploads/')!==-1){
ev.preventDefault();
openImageViewer(href + (href.indexOf('?')!==-1 ? '&' : '?') + 't=' + Date.now());
}
}catch(e){}
}, false);
const ivClose = document.getElementById('image-viewer-close'); if(ivClose){ ivClose.addEventListener('click', function(){ const v=document.getElementById('image-viewer'); const i=document.getElementById('image-viewer-img'); if(v) v.style.display='none'; if(i) i.src=''; }); }
// Non-blocking toast helper
function showToast(message, type='error', timeout=5000){
try{
const container = document.getElementById('toast-container'); if(!container) return;
const t = document.createElement('div'); t.className = 'toast ' + (type||'error'); t.textContent = message;
container.appendChild(t);
// show
requestAnimationFrame(()=> t.classList.add('show'));
setTimeout(()=>{ t.classList.remove('show'); setTimeout(()=>{ try{ t.remove(); }catch(e){} },200); }, timeout);
}catch(e){ console.error('toast error', e); }
}
map.addControl(drawControl);
map.on(L.Draw.Event.CREATED, function (e) {
const layer = e.layer;
if(e.layerType === 'marker'){
if(!window.currentUser){
showToast('Harus login terlebih dahulu untuk menambah data', 'error', 4000);
map.removeLayer(layer);
return;
}
const latlng = layer.getLatLng();
currentEditingLayer = layer;
const html = `<label>Jenis <select name="jenis" required><option value="">Pilih jenis</option><option value="rumah">Rumah</option><option value="masjid">Masjid</option><option value="gereja">Gereja</option><option value="pura">Pura</option><option value="vihara">Vihara</option><option value="klenteng">Klenteng</option></select></label>`+
`<div id="form-details-wrapper" style="display:none"><div id="nama-field"><label>Nama <input name="nama" type="text"></label></div>`+
`<div id="household-fields"></div>`+
`<label>No Telp <input name="no_telp" type="text"></label>`+
`<label>Alamat <input name="alamat" type="text"></label>`+
`${buildAssistanceFieldsHtml({assisted:false})}`+
`<div id="photo-field-create"><label>Foto <input name="photo" type="file" accept="image/*"></label></div></div>`;
openModal('Tambah Lokasi', html, function(values, file){
(async function(){
const usedLatLng = (currentEditingLayer && typeof currentEditingLayer.getLatLng === 'function') ? currentEditingLayer.getLatLng() : latlng;
const payload = { nama: values.nama||'', jenis: values.jenis||'rumah', no_telp: values.no_telp||'', alamat: values.alamat||'', nama_kk: values.nama_kk||null, rumah_status: values.rumah_status||null, members: values.members||null, monthly_income: values.monthly_income||null, latitude: usedLatLng.lat, longitude: usedLatLng.lng };
const assistedOn = values.assisted ? 1 : 0;
payload.assisted = assistedOn;
if(assistedOn){
payload.last_assisted_at = values.last_assisted_at || '';
payload.sumber_bantuan = values.sumber_bantuan || '';
payload.bentuk_bantuan = values.bentuk_bantuan || '';
payload.assistance_notes = values.assistance_notes || '';
}
try{
const res = await fetch('src/api/tambah_lokasi.php',{method:'POST',headers:buildHeaders(),body:JSON.stringify(payload),credentials:'same-origin'});
const j = await res.json().catch(()=>null);
const newId = j && j.id ? j.id : null;
if(newId && file){
try{
const fdata = new FormData(); fdata.append('file', file); fdata.append('id', newId);
const resp = await fetch('src/api/upload_photo.php',{method:'POST',headers:buildHeaders(null),body:fdata,credentials:'same-origin'});
const jr = await resp.json().catch(()=>null);
if(!jr || !jr.success){
const reason = jr && (jr.error || (jr.last_error && jr.last_error.message)) ? (jr.error || jr.last_error.message) : 'unknown';
showToast('Gagal mengunggah foto: ' + reason, 'error', 7000);
} else {
showToast('Foto berhasil diunggah', 'success', 3000);
}
}catch(e){ console.error('upload failed',e); showToast('Gagal mengunggah foto: ' + (e && e.message ? e.message : e), 'error', 7000); }
}
}catch(e){ console.error(e); }
loadData();
})();
});
// populate household fields and wire jenis change like edit modal (run for this modal instance)
setTimeout(()=>{
const sel = document.querySelector('#modal-fields select[name="jenis"]'); if(sel) sel.value = '';
const hf = document.getElementById('household-fields'); const namaField=document.getElementById('nama-field'); const assistanceFields=document.getElementById('assistance-fields'); const photoField=document.getElementById('photo-field-create');
const detailsWrapper = document.getElementById('form-details-wrapper');
const assistedSourceSearch = document.getElementById('sumber-bantuan-search');
function render(){ if(!hf) return; const v = (sel && sel.value)||'';
if(detailsWrapper) detailsWrapper.style.display = v ? 'block' : 'none';
if(sel && v==='rumah'){ hf.innerHTML = `<label>Nama Kepala Keluarga <input name="nama_kk" type="text" required></label><div class="hf-phone-slot"></div><label>Status Rumah <input name="rumah_status" type="text"></label><label>Jumlah Anggota <input name="members" type="number" required></label><label>Penghasilan Bulanan (Rp) <input name="monthly_income" type="number" step="1" min="0" required></label><div style="font-size:12px;color:#666;margin-top:4px">Masukkan nilai dalam Rupiah, tanpa simbol (contoh: 2000000)</div>`; if(namaField) namaField.style.display='none'; if(assistanceFields) assistanceFields.style.display='block'; if(photoField) photoField.style.display='block'; try{ const assistChk = document.querySelector('#modal-fields input[name="assisted"]'); if(assistChk && assistChk.checked === false){ assistChk.dispatchEvent(new Event('change')); } }catch(e){}
movePhoneToSlot();
} else if(isWorshipType(v)) { hf.innerHTML=''; if(namaField) namaField.style.display='block'; if(assistanceFields) assistanceFields.style.display='none'; if(photoField) photoField.style.display='none'; try{ const assistChk = document.querySelector('#modal-fields input[name="assisted"]'); if(assistChk && assistChk.checked){ assistChk.checked = false; assistChk.dispatchEvent(new Event('change')); } }catch(e){}
// require name for worship types
try{ const nameInput = document.querySelector('#modal-fields input[name="nama"]'); if(nameInput) nameInput.required = true; }catch(e){}
movePhoneBackBeforeAddress();
} else if(v) { hf.innerHTML=''; if(namaField) namaField.style.display='block'; if(assistanceFields) assistanceFields.style.display='none'; if(photoField) photoField.style.display='none'; try{ const assistChk = document.querySelector('#modal-fields input[name="assisted"]'); if(assistChk && assistChk.checked){ assistChk.checked = false; assistChk.dispatchEvent(new Event('change')); } }catch(e){}
// non-worship non-rumah
try{ const nameInput = document.querySelector('#modal-fields input[name="nama"]'); if(nameInput) nameInput.required = false; }catch(e){}
movePhoneBackBeforeAddress();
} }
syncRequiredFieldMarkers();
if(sel) sel.addEventListener('change', render); render();
// ensure phone positioned correctly after populating
try{ movePhoneToSlot(); }catch(e){}
},50);
// do a reverse geocode to prefill address/name where possible
(async function(){ try{ const rev = await reverseGeocode(latlng.lat, latlng.lng); if(rev && rev.display_name){ const addrInp = document.querySelector('#modal-fields input[name="alamat"]'); if(addrInp){ addrInp.value = rev.display_name; addrInp.dataset.lat = latlng.lat; addrInp.dataset.lon = latlng.lng; } const wt = detectWorshipType(rev); if(wt){ const nameInp = document.querySelector('#modal-fields input[name="nama"]'); if(nameInp && !nameInp.value){ nameInp.value = extractNameFromResult(rev); } } } }catch(e){} })();
}
});
// Layer control (ensure visible)
const overlays = {
[buildLayerLegendLabel('Rumah belum dibantu', '#d32f2f')]: rumahBelumDibantuGroup,
[buildLayerLegendLabel('Rumah sudah dibantu', '#2e7d32')]: rumahSudahDibantuGroup,
[buildLayerLegendLabel('Tempat Ibadah', '#1976d2', 'marker biru')]: masjidGroup,
'Lokasi': lokasiGroup
};
L.control.layers(null, overlays, { position: 'topright', collapsed: false }).addTo(map);
setTimeout(attachTopRightControls, 0);
setTimeout(attachTopRightControls, 50);
setTimeout(attachTopRightControls, 200);
// Tab switching removed - full screen map only
try{
const worshipBtn = document.getElementById('data-view-worship');
const houseBtn = document.getElementById('data-view-house');
if(worshipBtn) worshipBtn.addEventListener('click', function(){ setDataPanelView('worship'); });
if(houseBtn) houseBtn.addEventListener('click', function(){ setDataPanelView('house'); });
renderDataPanel();
}catch(e){}
try{
const loginBtn = document.getElementById('auth-login-btn');
const logoutBtn = document.getElementById('auth-logout-btn');
const adminBtn = document.getElementById('admin-dashboard-btn');
const authCancelBtn = document.getElementById('auth-modal-cancel');
const adminCloseBtn = document.getElementById('admin-modal-close');
const adminDetailCloseBtn = document.getElementById('admin-detail-close');
if(loginBtn) loginBtn.addEventListener('click', function(){ openAuthModal('login'); });
if(logoutBtn) logoutBtn.addEventListener('click', function(){ logoutUser(); });
if(adminBtn) adminBtn.addEventListener('click', function(){ openAdminDashboard(); });
if(authCancelBtn) authCancelBtn.addEventListener('click', function(){ closeAuthModal(); });
if(adminCloseBtn) adminCloseBtn.addEventListener('click', function(){ closeAdminDashboard(); });
if(adminDetailCloseBtn) adminDetailCloseBtn.addEventListener('click', function(){ closePendingManagerDetail(); });
document.getElementById('auth-modal').addEventListener('click', function(ev){ if(ev.target === this) closeAuthModal(); });
document.getElementById('admin-modal').addEventListener('click', function(ev){ if(ev.target === this) closeAdminDashboard(); });
document.getElementById('admin-detail-modal').addEventListener('click', function(ev){ if(ev.target === this) closePendingManagerDetail(); });
document.getElementById('admin-reject-modal').addEventListener('click', function(ev){ if(ev.target === this) closeRejectModal(); });
// Admin dashboard wiring is handled by openAdminDashboard() above.
}catch(e){}
if(window.authToken){
try{ refreshAuthState(); }catch(e){}
}
function openPendingManagerDetail(u){
const modal = document.getElementById('admin-detail-modal');
const title = document.getElementById('admin-detail-title');
const body = document.getElementById('admin-detail-body');
if(!modal || !title || !body) return;
title.textContent = 'Detail Pendaftaran';
const proofPath = u.org_proof_path || '';
const proofPreview = isPreviewableImage(proofPath)
? `<button type="button" class="btn-primary" onclick='openProofPreview(${JSON.stringify(proofPath)})'>Lihat Foto</button>`
: (proofPath ? `<a href="${escapeHtml(proofPath)}" target="_blank" class="data-link">Lihat file bukti</a>` : '-');
body.innerHTML = `
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px">
<div style="padding:12px;border:1px solid #e2e8f0;border-radius:12px;background:#fff"><div style="font-size:12px;color:#64748b">Nama</div><div style="font-weight:700;color:#0f172a">${escapeHtml(u.name || '-')}</div></div>
<div style="padding:12px;border:1px solid #e2e8f0;border-radius:12px;background:#fff"><div style="font-size:12px;color:#64748b">Email</div><div style="font-weight:700;color:#0f172a">${escapeHtml(u.email || '-')}</div></div>
<div style="padding:12px;border:1px solid #e2e8f0;border-radius:12px;background:#fff"><div style="font-size:12px;color:#64748b">Organisasi / Tempat Ibadah</div><div style="font-weight:700;color:#0f172a">${escapeHtml(u.organization || '-')}</div></div>
<div style="padding:12px;border:1px solid #e2e8f0;border-radius:12px;background:#fff"><div style="font-size:12px;color:#64748b">Alamat Organisasi</div><div style="font-weight:700;color:#0f172a">${escapeHtml(u.org_address || '-')}</div></div>
<div style="padding:12px;border:1px solid #e2e8f0;border-radius:12px;background:#fff"><div style="font-size:12px;color:#64748b">Nomor Telp Organisasi</div><div style="font-weight:700;color:#0f172a">${escapeHtml(u.org_phone || '-')}</div></div>
<div style="padding:12px;border:1px solid #e2e8f0;border-radius:12px;background:#fff"><div style="font-size:12px;color:#64748b">Tanggal Daftar</div><div style="font-weight:700;color:#0f172a">${u.created_at ? escapeHtml(formatDateToDMY(u.created_at)) : '-'}</div></div>
</div>
<div style="margin-top:14px;padding:12px;border:1px solid #e2e8f0;border-radius:12px;background:#fff">
<div style="font-size:12px;color:#64748b">Bukti Organisasi</div>
<div style="font-weight:700;color:#0f172a;margin-top:8px">${proofPreview}</div>
</div>
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:18px;padding-top:12px;border-top:1px solid #e2e8f0">
<button type="button" class="btn-destructive" style="padding:8px 16px;font-size:14px" onclick="rejectPendingUser('${escapeHtml(u.id)}')">Tolak</button>
<button type="button" class="btn-primary" style="padding:8px 16px;font-size:14px" onclick="approvePendingUser('${escapeHtml(u.id)}')">Setujui</button>
</div>
`;
modal.classList.remove('hidden');
modal.setAttribute('aria-hidden','false');
}
function openPendingManagerDetailByIndex(index){
const items = Array.isArray(window.pendingManagerItems) ? window.pendingManagerItems : [];
const item = items[index];
if(!item) return;
openPendingManagerDetail(item);
}
function closePendingManagerDetail(){
const modal = document.getElementById('admin-detail-modal');
if(!modal) return;
modal.classList.add('hidden');
modal.setAttribute('aria-hidden','true');
}
try{ setTimeout(()=>{ map.invalidateSize(); }, 0); }catch(e){}
// initial load
loadData();
// masjid-specific filters moved into masjid control; floating filter panel removed
</script>
<style>
@media (max-width: 1200px) {
#auth-bar {
right: 12px !important;
top: 64px !important;
max-width: calc(100vw - 24px) !important;
}
}
</style>
</body>
</html>