Files
2026-06-02 12:40:37 +07:00

5612 lines
196 KiB
JavaScript

// ============ AUTHENTICATION ============
let currentUser = null;
async function checkAuth() {
try {
const response = await fetch('api/get_current_user.php');
const data = await response.json();
if (data.success && data.user) {
currentUser = data.user;
hideLoginForm();
showUserInfo();
// Initialize map and the application for existing sessions
initializeMap();
initializeApp();
return true;
} else {
showLoginForm();
return false;
}
} catch (error) {
console.error('Auth check error:', error);
showLoginForm();
return false;
}
}
function showLoginForm() {
const loginOverlay = document.getElementById('login-overlay');
const userBar = document.getElementById('user-info-bar');
const appContainer = document.querySelector('.app-container');
if (loginOverlay) loginOverlay.classList.add('active');
if (userBar) userBar.style.display = 'none';
if (appContainer) appContainer.style.display = 'none';
}
function hideLoginForm() {
const loginOverlay = document.getElementById('login-overlay');
const userBar = document.getElementById('user-info-bar');
const appContainer = document.querySelector('.app-container');
if (loginOverlay) loginOverlay.classList.remove('active');
if (userBar) userBar.style.display = 'flex';
if (appContainer) appContainer.style.display = 'flex';
}
function showUserInfo() {
if (!currentUser) return;
const userDisplay = document.getElementById('current-user-display');
const roleDisplay = document.getElementById('current-role-display');
if (userDisplay) userDisplay.textContent = `Hi, ${currentUser.username}`;
if (roleDisplay) {
roleDisplay.textContent = currentUser.role;
roleDisplay.className = `role-badge role-${currentUser.role.toLowerCase()}`;
}
// Update role-based UI elements
updateAdminUI();
}
async function handleLogin(event) {
event.preventDefault();
const email = document.getElementById('login-email').value;
const password = document.getElementById('login-password').value;
const loginBtn = document.getElementById('login-btn');
const errorDiv = document.getElementById('login-error');
loginBtn.disabled = true;
loginBtn.textContent = 'Logging in...';
errorDiv.classList.remove('show');
try {
const response = await fetch('api/login.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
const data = await response.json();
if (data.success) {
currentUser = data.user;
hideLoginForm();
showUserInfo();
// Reset form
document.getElementById('login-form').reset();
// Initialize app
// Ensure the map is initialized for this session
initializeMap();
initializeApp();
} else {
errorDiv.textContent = data.message || 'Login failed';
errorDiv.classList.add('show');
}
} catch (error) {
console.error('Login error:', error);
errorDiv.textContent = 'An error occurred during login';
errorDiv.classList.add('show');
} finally {
loginBtn.disabled = false;
loginBtn.textContent = 'Login';
}
}
async function handleLogout() {
try {
await fetch('api/logout.php', { method: 'POST' });
currentUser = null;
showLoginForm();
// Clear app data
if (map) {
map.remove();
map = null;
}
} catch (error) {
console.error('Logout error:', error);
}
}
function initializeApp() {
// Show Beranda tab on startup
switchTab('beranda');
// Setup modal for all forms
setupModal();
// Setup configuration controls
setupConfigurationSection();
// Prime the combined report preview so it is ready when Laporan opens
loadCombinedReportPreview();
}
// ============ GLOBAL STATE ============
let map;
let markers = {};
let markerData = {}; // Store marker data for editing
let markerCount = 0;
let currentMapClickCoords = null;
let currentEditingMarkerId = null;
let markerIcons = {}; // Will be initialized after Leaflet loads
let mapClickMode = 'point';
// Masjid management
let masjidMarkers = {};
let masjidCircles = {};
let masjidData = {};
let masjidCount = 0;
let currentEditingMasjidId = null;
// Special needs point management
let needPointMarkers = {};
let needPointData = {};
let needPointCount = 0;
let currentEditingNeedPointId = null;
// Adaptive rendering state
let allNeedPoints = []; // store normalized records for client-side filtering
let needPointLayer = null; // L.LayerGroup for visible points
let renderingMode = 'auto'; // 'high'|'medium'|'low'
let visibleMarkerThreshold = 1000; // will be adjusted based on device
function getStoredRenderingMode() {
try { return localStorage.getItem('renderingMode'); } catch (e) { return null; }
}
function setStoredRenderingMode(mode) {
try {
if (!mode) localStorage.removeItem('renderingMode');
else localStorage.setItem('renderingMode', mode);
} catch (e) {
// ignore
}
}
// Household/Assistance/Verification (Phase A)
let households = [];
let householdSummary = null;
// Lines/Roads Management
let lines = {};
let lineData = {};
let lineCount = 0;
// Polygons/Land Parcels Management
let polygons = {};
let polygonData = {};
let polygonCount = 0;
// Drawing state
let drawMode = {
active: false,
type: null, // 'line' or 'polygon'
coordinates: [],
temporaryLayer: null,
defaultClickHandler: null,
tempMarkers: [] // Store temporary circle markers
};
// Search/Filter state
let filterState = {
roads: { search: '', dateFrom: '', dateTo: '', condition: '' },
parcels: { search: '', dateFrom: '', dateTo: '', certificate: '' }
};
// Pontianak choropleth state
let pontianakState = {
active: false,
adminLevel: 'kecamatan',
currentLayers: { // NEW: Multiple layers can be visible simultaneously
population: false,
road_density: false,
damaged_roads_density: false,
city_boundary: false
},
layers: {}, // RENAMED from areas: layerType -> array of Leaflet GeoJSON layers
colorScales: {}, // NEW: Store color scale for each layer type
legendData: null,
areasData: {}, // Administrative areas data
dataLoaded: false, // NEW: Flag to prevent re-fetching
hasFittedBoundary: false
};
// Search input debounce timers
let searchTimeouts = {
roads: null,
parcels: null
};
// Color styles for roads and polygons
const lineStyles = {
'Nasional': {
color: '#4CAF50', // Green
weight: 4,
opacity: 0.8,
dashArray: null,
lineCap: 'round',
lineJoin: 'round'
},
'Provinsi': {
color: '#2196F3', // Blue
weight: 3.5,
opacity: 0.8,
dashArray: null,
lineCap: 'round',
lineJoin: 'round'
},
'Kabupaten': {
color: '#FF9800', // Orange
weight: 3,
opacity: 0.8,
dashArray: null,
lineCap: 'round',
lineJoin: 'round'
}
};
const polygonStyles = {
'SHM': {
fillColor: '#4CAF50', // Green
weight: 2,
opacity: 0.8,
color: '#2E7D32',
fillOpacity: 0.3,
dashArray: null
},
'HGB': {
fillColor: '#2196F3', // Blue
weight: 2,
opacity: 0.8,
color: '#1565C0',
fillOpacity: 0.3,
dashArray: null
},
'HGU': {
fillColor: '#FF9800', // Orange
weight: 2,
opacity: 0.8,
color: '#E65100',
fillOpacity: 0.3,
dashArray: null
},
'HP': {
fillColor: '#9C27B0', // Purple
weight: 2,
opacity: 0.8,
color: '#6A1B9A',
fillOpacity: 0.3,
dashArray: null
}
};
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
checkAuth();
});
function initializeMap() {
if (map) return; // Map already initialized
initMap();
}
function setupModal() {
const modal = document.getElementById('point-modal');
const addBtn = document.getElementById('add-point-btn');
const addMasjidBtn = document.getElementById('add-masjid-btn');
const addNeedPointBtn = document.getElementById('add-need-point-btn');
const addHouseholdBtn = document.getElementById('add-household-btn');
const addAssistanceBtn = document.getElementById('add-assistance-btn');
const addVerificationBtn = document.getElementById('add-verification-btn');
const computeScoresBtn = document.getElementById('compute-household-scores-btn');
const closeBtn = document.querySelector('.close');
const form = document.getElementById('point-form');
const mapClickModeSelect = document.getElementById('map-click-mode');
const householdFilterStatus = document.getElementById('household-filter-status');
if (addBtn) {
addBtn.onclick = function() {
openPointModal(null);
}
}
if (addMasjidBtn) {
addMasjidBtn.onclick = function() {
openMasjidModal(null);
}
}
if (addNeedPointBtn) {
addNeedPointBtn.onclick = function() {
openNeedPointModal(null);
}
}
if (addHouseholdBtn) {
addHouseholdBtn.onclick = function() {
openHouseholdModal();
}
}
if (addAssistanceBtn) {
addAssistanceBtn.onclick = function() {
openAssistanceModal();
}
}
if (addVerificationBtn) {
addVerificationBtn.onclick = function() {
openVerificationModal();
}
}
if (computeScoresBtn) {
computeScoresBtn.onclick = function() {
computeHouseholdScores();
}
}
// Wire existing select if present (legacy); we also add map shortcut buttons
if (mapClickModeSelect) {
mapClickModeSelect.value = mapClickMode;
mapClickModeSelect.onchange = function(event) {
setMapClickMode(event.target.value, false);
}
}
// Map shortcut buttons (overlay)
function setupMapShortcuts() {
const shortcuts = [
{ id: 'shortcut-point', mode: 'point' },
{ id: 'shortcut-masjid', mode: 'masjid' },
{ id: 'shortcut-need', mode: 'need' },
{ id: 'shortcut-line', mode: 'line' },
{ id: 'shortcut-polygon', mode: 'polygon' }
];
shortcuts.forEach(s => {
const el = document.getElementById(s.id);
if (!el) return;
el.onclick = function() {
setMapClickMode(s.mode);
updateShortcutButtons();
};
});
// Reflect current mode on buttons
function updateShortcutButtons() {
const current = (mapClickMode === 'need' || mapClickMode === 'need_point') ? 'need' : mapClickMode;
shortcuts.forEach(s => {
const el = document.getElementById(s.id);
if (!el) return;
if (current === s.mode) {
el.classList.add('active');
} else {
el.classList.remove('active');
}
});
}
// Expose updater so other parts can refresh visuals
window.updateShortcutButtons = updateShortcutButtons;
// initial reflect
updateShortcutButtons();
}
setupMapShortcuts();
// Wire additional map tool buttons if present
const addLineBtn = document.getElementById('add-line-btn');
const addPolygonBtn = document.getElementById('add-polygon-btn');
if (addLineBtn) addLineBtn.onclick = function() { enableLineDrawMode(); };
if (addPolygonBtn) addPolygonBtn.onclick = function() { enablePolygonDrawMode(); };
if (householdFilterStatus) {
householdFilterStatus.onchange = function() {
loadHouseholdsForPanel();
}
}
if (closeBtn) {
closeBtn.onclick = function() {
closePointModal();
}
}
window.onclick = function(event) {
if (event.target === modal) {
closePointModal();
}
const editModal = document.getElementById('edit-modal');
if (event.target === editModal) {
closeEditModal();
}
const lineModal = document.getElementById('line-modal');
if (event.target === lineModal) {
closeLineModal();
}
const editLineModal = document.getElementById('edit-line-modal');
if (event.target === editLineModal) {
closeEditLineModal();
}
const polygonModal = document.getElementById('polygon-modal');
if (event.target === polygonModal) {
closePolygonModal();
}
const editPolygonModal = document.getElementById('edit-polygon-modal');
if (event.target === editPolygonModal) {
closeEditPolygonModal();
}
const masjidModal = document.getElementById('masjid-modal');
if (event.target === masjidModal) {
closeMasjidModal();
}
const editMasjidModal = document.getElementById('edit-masjid-modal');
if (event.target === editMasjidModal) {
closeEditMasjidModal();
}
const needPointModal = document.getElementById('need-point-modal');
if (event.target === needPointModal) {
closeNeedPointModal();
}
const editNeedPointModal = document.getElementById('edit-need-point-modal');
if (event.target === editNeedPointModal) {
closeEditNeedPointModal();
}
const householdModal = document.getElementById('household-modal');
if (event.target === householdModal) {
closeHouseholdModal();
}
const assistanceModal = document.getElementById('assistance-modal');
if (event.target === assistanceModal) {
closeAssistanceModal();
}
const verificationModal = document.getElementById('verification-modal');
if (event.target === verificationModal) {
closeVerificationModal();
}
}
if (form) {
form.onsubmit = function(e) {
e.preventDefault();
submitPointForm();
}
}
// Setup edit modal form
const editForm = document.getElementById('edit-form');
if (editForm) {
editForm.onsubmit = function(e) {
e.preventDefault();
saveMarkerEdit();
}
}
const masjidForm = document.getElementById('masjid-form');
if (masjidForm) {
masjidForm.onsubmit = function(e) {
e.preventDefault();
submitMasjidForm();
}
}
const editMasjidForm = document.getElementById('edit-masjid-form');
if (editMasjidForm) {
editMasjidForm.onsubmit = function(e) {
e.preventDefault();
saveMasjidEdit();
}
}
const needPointForm = document.getElementById('need-point-form');
if (needPointForm) {
needPointForm.onsubmit = function(e) {
e.preventDefault();
submitNeedPointForm();
}
}
const editNeedPointForm = document.getElementById('edit-need-point-form');
if (editNeedPointForm) {
editNeedPointForm.onsubmit = function(e) {
e.preventDefault();
saveNeedPointEdit();
}
}
const householdForm = document.getElementById('household-form');
if (householdForm) {
householdForm.onsubmit = function(e) {
e.preventDefault();
submitHouseholdForm();
}
}
const assistanceForm = document.getElementById('assistance-form');
if (assistanceForm) {
assistanceForm.onsubmit = function(e) {
e.preventDefault();
submitAssistanceForm();
}
}
const assistanceHouseholdSelect = document.getElementById('assistance-household-id');
if (assistanceHouseholdSelect) {
assistanceHouseholdSelect.onchange = function() {
updateAssistanceModalSummary();
};
}
const verificationForm = document.getElementById('verification-form');
if (verificationForm) {
verificationForm.onsubmit = function(e) {
e.preventDefault();
submitVerificationForm();
}
}
}
// ============ TAB MANAGEMENT ============
function switchTab(tabName, clickedButton = null) {
if (!isTabAllowedForCurrentUser(tabName)) {
showNotification('Akses tab ini tidak tersedia untuk peran Anda', true);
tabName = 'beranda';
clickedButton = null;
}
// Hide all tab contents
const tabContents = document.querySelectorAll('.tab-content');
tabContents.forEach(tab => {
tab.classList.remove('tab-active');
});
// Remove active class from all buttons
const tabButtons = document.querySelectorAll('.tab-button');
tabButtons.forEach(btn => {
btn.classList.remove('tab-active');
});
// Get the selected tab element
const selectedTab = document.getElementById(tabName + '-tab');
if (selectedTab) {
// MOVE TAB TO MAIN CONTENT AREA (NEW LAYOUT)
const mainContent = document.querySelector('.main-content');
if (mainContent) {
// Remove all existing tab-content from main (they will be moved back to sidebar when hidden)
const existingTabs = mainContent.querySelectorAll('.tab-content');
existingTabs.forEach(tab => {
// Move back to sidebar (to maintain DOM structure)
const sidebar = document.querySelector('.sidebar');
if (sidebar) {
sidebar.appendChild(tab);
}
});
// Add selected tab to main content
selectedTab.classList.add('tab-active');
mainContent.appendChild(selectedTab);
} else {
// Fallback: just show the tab (for old layout compatibility)
selectedTab.classList.add('tab-active');
}
}
// Set active button (supports both click and programmatic calls)
let activeButton = clickedButton;
if (!activeButton) {
activeButton = Array.from(tabButtons).find(btn => {
const handler = btn.getAttribute('onclick') || '';
return handler.includes(`'${tabName}'`) || handler.includes(`"${tabName}"`);
});
}
if (activeButton) {
activeButton.classList.add('tab-active');
}
// Special handling for Beranda tab (home/dashboard)
if (tabName === 'beranda') {
loadBerandaDashboard();
return;
}
// Special handling for Peta tab (map)
if (tabName === 'peta') {
// Move map into peta-tab container for proper display
const mapContainer = document.getElementById('peta-map-container');
const mapElement = document.getElementById('map');
if (mapContainer && mapElement && mapElement.parentElement !== mapContainer) {
mapContainer.appendChild(mapElement);
mapContainer.style.display = 'block';
// Reinitialize map in new container. Give the layout
// a bit more time to settle, then force a resize and
// refresh the view so tiles render correctly.
if (map) {
setTimeout(() => {
try {
map.invalidateSize();
map.invalidateSize(true);
// re-center to current center to force redraw
const c = map.getCenter();
map.setView([c.lat, c.lng]);
} catch (e) {
console.warn('Map resize error', e);
}
}, 300);
}
}
loadMarkers();
loadMasjids();
loadNeedPoints();
loadLines();
loadPolygons();
loadHouseholdsForPanel();
loadHouseholdSummary();
loadHouseholdScoreSummary();
loadHouseholdPriorityRanking();
return;
}
// Special handling for Managemen tab
if (tabName === 'managemen') {
loadManagement();
return;
}
// Special handling for Laporan tab (reports)
if (tabName === 'laporan') {
loadCombinedReportPreview();
return;
}
// Special handling for Dashboard tab
if (tabName === 'dashboard') {
loadDashboard();
return;
}
// Special handling for Users tab (admin only)
if (tabName === 'users') {
if (!currentUser || currentUser.role !== 'Admin') {
showNotification('Only admins can access this section', true);
return;
}
loadUsersList();
return;
}
// Special handling for Configuration tab (admin only)
if (tabName === 'konfigurasi') {
if (!currentUser || currentUser.role !== 'Admin') {
showNotification('Only admins can access this section', true);
return;
}
loadConfiguration();
return;
}
// Special handling for Pontianak tab
if (tabName === 'pontianak') {
// Clear other features
Object.keys(markers).forEach(id => {
if (map.hasLayer(markers[id])) map.removeLayer(markers[id]);
});
Object.keys(masjidMarkers).forEach(id => {
if (map.hasLayer(masjidMarkers[id])) map.removeLayer(masjidMarkers[id]);
});
Object.keys(masjidCircles).forEach(id => {
if (map.hasLayer(masjidCircles[id])) map.removeLayer(masjidCircles[id]);
});
Object.keys(needPointMarkers).forEach(id => {
if (map.hasLayer(needPointMarkers[id])) map.removeLayer(needPointMarkers[id]);
});
Object.keys(lines).forEach(id => {
if (map.hasLayer(lines[id])) map.removeLayer(lines[id]);
});
Object.keys(polygons).forEach(id => {
if (map.hasLayer(polygons[id])) map.removeLayer(polygons[id]);
});
pontianakState.active = true;
if (!pontianakState.hasFittedBoundary) {
map.setView([-0.0263, 109.3425], 12);
}
initPontianakMap();
} else {
// Unload Pontianak features and reset state
Object.keys(pontianakState.layers).forEach(layerType => {
if (pontianakState.layers[layerType]) {
pontianakState.layers[layerType].forEach(layer => {
if (map.hasLayer(layer)) map.removeLayer(layer);
});
}
});
pontianakState.layers = {};
pontianakState.areasData = {};
pontianakState.dataLoaded = false;
pontianakState.hasFittedBoundary = false;
pontianakState.active = false;
// Reload regular features if this is one of the main tabs
if (tabName === 'points' || tabName === 'peta' || tabName === 'roads' || tabName === 'parcels') {
loadMarkers();
loadMasjids();
loadNeedPoints();
loadLines();
loadPolygons();
if (tabName === 'points' || tabName === 'peta') {
loadHouseholdsForPanel();
loadHouseholdSummary();
loadHouseholdScoreSummary();
loadHouseholdPriorityRanking();
}
}
}
}
function setMapClickMode(mode, showToast = true) {
// Normalize mode values (support legacy 'need_point')
function normalizeMode(m) {
if (!m) return m;
if (m === 'need_point') return 'need';
return m;
}
// Accept drawing modes as well
const validModes = ['point', 'masjid', 'need', 'line', 'polygon'];
const normalized = normalizeMode(mode);
if (!validModes.includes(normalized)) return;
// If switching from an active draw mode, cancel any drawing first
if (drawMode.active && (mode !== 'line' && mode !== 'polygon')) {
cancelDrawing();
}
mapClickMode = normalized;
const modeSelect = document.getElementById('map-click-mode');
if (modeSelect && modeSelect.value !== normalized) modeSelect.value = normalized;
if (showToast) {
const modeLabels = {
point: 'Point Biasa',
masjid: 'Masjid',
need: 'Point Berkebutuhan',
line: 'Gambar Jalan (Draw Road)',
polygon: 'Gambar Parcel (Draw Polygon)'
};
showNotification('Mode peta: ' + (modeLabels[mode] || mode));
}
// If user selected drawing modes via the select, activate draw mode
if (normalized === 'line') {
enableLineDrawMode();
} else if (normalized === 'polygon') {
enablePolygonDrawMode();
} else {
// restore default click handler
if (drawMode.defaultClickHandler) {
map.off('click', handleMapClickDuringDraw);
map.on('click', drawMode.defaultClickHandler);
}
}
}
// Update shortcut button visuals if present
if (window.updateShortcutButtons && typeof window.updateShortcutButtons === 'function') {
window.updateShortcutButtons();
}
function openPointModal(coords) {
const modal = document.getElementById('point-modal');
const form = document.getElementById('point-form');
const latInput = document.getElementById('point-latitude');
const lngInput = document.getElementById('point-longitude');
const nameInput = document.getElementById('point-name');
const formInfo = document.getElementById('form-info');
const modalTitle = document.getElementById('modal-title');
form.reset();
currentMapClickCoords = coords;
if (coords) {
latInput.value = coords.lat.toFixed(6);
lngInput.value = coords.lng.toFixed(6);
latInput.readOnly = true;
lngInput.readOnly = true;
modalTitle.textContent = 'Create Point';
formInfo.textContent = '✓ Coordinates auto-filled from map click. Just add a name!';
formInfo.classList.add('show');
nameInput.focus();
} else {
latInput.value = '';
lngInput.value = '';
latInput.readOnly = false;
lngInput.readOnly = false;
modalTitle.textContent = 'Add New Point';
formInfo.classList.remove('show');
nameInput.focus();
}
modal.classList.add('show');
}
function closePointModal() {
const modal = document.getElementById('point-modal');
modal.classList.remove('show');
currentMapClickCoords = null;
}
function submitPointForm() {
const name = document.getElementById('point-name').value.trim();
const nomor_spbu = document.getElementById('point-nomor-spbu').value.trim();
const latitude = parseFloat(document.getElementById('point-latitude').value);
const longitude = parseFloat(document.getElementById('point-longitude').value);
const open_24_hours = document.getElementById('point-24hours').checked ? 1 : 0;
// Validate inputs
if (!name) {
showNotification('Please enter a name for the point', true);
return;
}
if (isNaN(latitude) || latitude < -90 || latitude > 90) {
showNotification('Please enter a valid latitude (-90 to 90)', true);
return;
}
if (isNaN(longitude) || longitude < -180 || longitude > 180) {
showNotification('Please enter a valid longitude (-180 to 180)', true);
return;
}
addMarker(name, nomor_spbu, latitude, longitude, open_24_hours);
}
function initMap() {
// Create map centered on Pontianak by default
map = L.map('map').setView([-0.0263, 109.3425], 13);
// Initialize marker icons after Leaflet is loaded
markerIcons.open = L.divIcon({
html: `<div style="background-color: #4CAF50; width: 30px; height: 30px; border-radius: 50%; border: 3px solid white; box-shadow: 0 2px 5px rgba(0,0,0,0.3); display: flex; align-items: center; justify-content: center; color: white; font-weight: bold; font-size: 16px;">✓</div>`,
iconSize: [36, 36],
iconAnchor: [18, 36],
popupAnchor: [0, -36],
className: 'custom-marker open'
});
markerIcons.closed = L.divIcon({
html: `<div style="background-color: #f44336; width: 30px; height: 30px; border-radius: 50%; border: 3px solid white; box-shadow: 0 2px 5px rgba(0,0,0,0.3); display: flex; align-items: center; justify-content: center; color: white; font-weight: bold; font-size: 16px;">✕</div>`,
iconSize: [36, 36],
iconAnchor: [18, 36],
popupAnchor: [0, -36],
className: 'custom-marker closed'
});
markerIcons.masjid = L.divIcon({
html: `<div style="background-color: #1976d2; width: 32px; height: 32px; border-radius: 50%; border: 3px solid white; box-shadow: 0 2px 5px rgba(0,0,0,0.3); display: flex; align-items: center; justify-content: center; color: white; font-weight: bold; font-size: 15px;">M</div>`,
iconSize: [38, 38],
iconAnchor: [19, 38],
popupAnchor: [0, -36],
className: 'custom-marker masjid'
});
markerIcons.need = L.divIcon({
html: `<div style="background-color: #ff9800; width: 30px; height: 30px; border-radius: 50%; border: 3px solid white; box-shadow: 0 2px 5px rgba(0,0,0,0.3); display: flex; align-items: center; justify-content: center; color: white; font-weight: bold; font-size: 14px;">K</div>`,
iconSize: [36, 36],
iconAnchor: [18, 36],
popupAnchor: [0, -36],
className: 'custom-marker need'
});
// Add OpenStreetMap tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
maxZoom: 19
}).addTo(map);
// Store default click handler
drawMode.defaultClickHandler = function(e) {
if (!drawMode.active) {
const coords = {
lat: e.latlng.lat,
lng: e.latlng.lng
};
if (mapClickMode === 'masjid') {
openMasjidModal(coords);
} else if (mapClickMode === 'need') {
openNeedPointModal(coords);
} else {
openPointModal(coords);
}
}
};
// Handle map click to add marker with auto-filled coordinates
map.on('click', drawMode.defaultClickHandler);
}
function addMarker(name, nomor_spbu, lat, lng, open_24_hours) {
// Save to database
fetch('api/save_point.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: name,
nomor_spbu: nomor_spbu,
latitude: lat,
longitude: lng,
open_24_hours: open_24_hours
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
displayMarker(data.id, data.name, data.nomor_spbu, data.latitude, data.longitude, data.open_24_hours);
updateMarkerCount();
showNotification('✓ Point added successfully');
closePointModal();
} else {
showNotification('Error adding point: ' + data.error, true);
}
})
.catch(error => {
console.error('Error:', error);
showNotification('Error adding point', true);
});
}
function displayMarker(id, name, nomor_spbu, lat, lng, open_24_hours) {
// Store marker data for editing
markerData[id] = {
id: id,
name: name,
nomor_spbu: nomor_spbu,
latitude: lat,
longitude: lng,
open_24_hours: open_24_hours
};
// Use colored icon based on status
const icon = open_24_hours ? markerIcons.open : markerIcons.closed;
const marker = L.marker([lat, lng], { draggable: true, icon: icon }).addTo(map);
const updateMarkerPopup = (newLat, newLng) => {
const hoursText = open_24_hours ? '🕐 Open 24 Hours' : '🕐 Limited Hours';
let popupContent = `
<div class="marker-popup">
<div class="marker-popup-name">${escapeHtml(name)}</div>
<div class="marker-popup-hours">${hoursText}</div>`;
if (nomor_spbu) {
popupContent += `<div class="marker-popup-spbu">🏢 SPBU: ${escapeHtml(nomor_spbu)}</div>`;
}
popupContent += `
<div class="marker-popup-info">
<span>Lat: ${newLat.toFixed(4)}</span><br>
<span>Lng: ${newLng.toFixed(4)}</span>
</div>
<div class="marker-popup-actions">
<button type="button" onclick="toggleMarkerStatus(${id})">Toggle Status</button>
<button type="button" onclick="editMarker(${id})">Edit</button>
<button type="button" onclick="deleteMarker(${id})">Delete</button>
</div>
</div>
`;
marker.setPopupContent(popupContent);
};
updateMarkerPopup(lat, lng);
marker.bindPopup();
// Handle marker drag
marker.on('dragend', function() {
const newLat = marker.getLatLng().lat;
const newLng = marker.getLatLng().lng;
// Update in database
fetch('api/update_point.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
id: id,
latitude: newLat,
longitude: newLng
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
updateMarkerPopup(newLat, newLng);
// Update markerData with new coordinates
markerData[id].latitude = newLat;
markerData[id].longitude = newLng;
// Reopen popup to show updated coordinates
marker.closePopup();
marker.openPopup();
showNotification('✓ Point location updated');
} else {
// Revert to old position if update failed
marker.setLatLng([lat, lng]);
showNotification('Error updating point location', true);
}
})
.catch(error => {
console.error('Error:', error);
marker.setLatLng([lat, lng]);
showNotification('Error updating point location', true);
});
});
// Store marker and update reference
markers[id] = marker;
markerCount++;
}
// ============ BERANDA DASHBOARD ============
function loadBerandaDashboard() {
Promise.all([
fetch('api/get_need_points.php').then(response => response.json()),
fetch('api/get_configuration.php').then(response => response.json()),
fetch('api/get_dashboard_summary.php').then(response => response.json())
])
.then(([needPointData, configurationData, dashboardData]) => {
const totalNeedPoints = needPointData?.success && Array.isArray(needPointData.points)
? needPointData.points.length
: 0;
const populationReference = configurationData?.success
? Number(configurationData.configuration?.population_reference || 288315899)
: 288315899;
const povertyPercentage = populationReference > 0
? (totalNeedPoints / populationReference) * 100
: 0;
const targetNasional = configurationData?.success
? Number(configurationData.configuration?.target_nasional || 7.50)
: 7.50;
document.getElementById('beranda-total-poor').textContent = totalNeedPoints.toLocaleString('id-ID');
document.getElementById('beranda-percentage').textContent = povertyPercentage.toFixed(6);
document.getElementById('beranda-target').textContent = targetNasional.toFixed(2);
if (dashboardData?.success) {
updateReportSummary(dashboardData.summary);
}
})
.catch(error => console.error('Error loading beranda dashboard:', error));
// Load regional data
fetch('api/get_area_breakdown.php')
.then(response => response.json())
.then(data => {
if (data.success && Array.isArray(data.areas)) {
renderBerandaRegions(data.areas.slice(0, 5));
}
})
.catch(error => console.error('Error loading area breakdown:', error));
// Load trend chart data
loadTrendChart();
}
function renderBerandaRegions(areas) {
const regionList = document.getElementById('beranda-region-list');
if (!regionList) return;
if (!Array.isArray(areas) || areas.length === 0) {
regionList.innerHTML = `
<div style="background: rgba(255,255,255,0.08); padding: 12px; border-radius: 6px; font-size: 11px; color: rgba(255,255,255,0.75);">
Belum ada data provinsi yang tersedia.
</div>
`;
return;
}
regionList.innerHTML = areas.map((area, index) => `
<div style="background: rgba(255,255,255,0.08); padding: 10px 12px; border-radius: 6px; font-size: 11px; display: flex; justify-content: space-between; align-items: center; gap: 10px;">
<span style="color: white;"><strong>${index + 1}. ${area.area_name || 'N/A'}</strong></span>
<span style="color: rgba(255,255,255,0.75); white-space: nowrap;">${Number(area.household_count || 0).toLocaleString('id-ID')} titik kebutuhan</span>
</div>
`).join('');
}
// Load and render trend charts
function loadTrendChart() {
fetch('api/get_dashboard_trend.php')
.then(response => response.json())
.then(data => {
if (data.success && data.charts) {
renderTrendCharts(data);
}
})
.catch(error => console.error('Error loading trend data:', error));
}
// Render two Chart.js comparison charts: population totals and poverty percentage
function renderTrendCharts(data) {
const populationCanvas = document.getElementById('populationChart');
const populationPlaceholder = document.getElementById('populationChartPlaceholder');
const percentageCanvas = document.getElementById('percentageChart');
const percentagePlaceholder = document.getElementById('percentageChartPlaceholder');
if (!populationCanvas || !percentageCanvas || typeof Chart === 'undefined') return;
// Show canvases and hide placeholders
populationCanvas.style.display = 'block';
if (populationPlaceholder) populationPlaceholder.style.display = 'none';
percentageCanvas.style.display = 'block';
if (percentagePlaceholder) percentagePlaceholder.style.display = 'none';
// Destroy existing charts if they exist
if (window.populationChartInstance) {
window.populationChartInstance.destroy();
}
if (window.percentageChartInstance) {
window.percentageChartInstance.destroy();
}
// CHART 1: Bar chart for absolute population totals
const chart1Data = data.charts.chart1;
const popCtx = populationCanvas.getContext('2d');
window.populationChartInstance = new Chart(popCtx, {
type: 'bar',
data: {
labels: chart1Data.labels,
datasets: [{
data: chart1Data.data,
backgroundColor: [
'#FF6B6B',
'#4ECDC4'
],
borderColor: [
'#ff5252',
'#45b8ad'
],
borderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: {
display: false,
labels: {
color: '#fff',
font: { size: 12 },
padding: 15
}
},
tooltip: {
callbacks: {
label: function(context) {
return context.label + ': ' + formatPopulationValue(context.parsed && typeof context.parsed === 'object' ? context.parsed.y : context.parsed);
}
},
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#fff',
bodyColor: '#fff'
}
},
scales: {
x: {
ticks: {
color: '#fff'
},
grid: {
color: 'rgba(255,255,255,0.12)'
}
},
y: {
beginAtZero: true,
ticks: {
color: '#fff',
callback: function(value) {
return formatPopulationAxisValue(value);
}
},
grid: {
color: 'rgba(255,255,255,0.12)'
}
}
}
}
});
// CHART 2: Bar chart for poverty percentage vs target
const chart2Data = data.charts.chart2;
const pctCtx = percentageCanvas.getContext('2d');
window.percentageChartInstance = new Chart(pctCtx, {
type: 'bar',
data: {
labels: chart2Data.labels,
datasets: [{
data: chart2Data.data,
backgroundColor: [
'#00D4FF',
'#FFB84D'
],
borderColor: [
'#00b8cc',
'#ff9900'
],
borderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: {
display: false,
labels: {
color: '#fff',
font: { size: 12 },
padding: 15
}
},
tooltip: {
callbacks: {
label: function(context) {
const value = context.parsed && typeof context.parsed === 'object' ? context.parsed.y : context.parsed;
return context.label + ': ' + Number(value).toFixed(4) + '%';
}
},
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#fff',
bodyColor: '#fff'
}
},
scales: {
x: {
ticks: {
color: '#fff'
},
grid: {
color: 'rgba(255,255,255,0.12)'
}
},
y: {
beginAtZero: true,
ticks: {
color: '#fff',
callback: function(value) {
return value + '%';
}
},
grid: {
color: 'rgba(255,255,255,0.12)'
}
}
}
}
});
}
function formatPopulationValue(value) {
const numericValue = Number(value) || 0;
if (numericValue >= 1000000000) return (numericValue / 1000000000).toFixed(1) + 'B';
if (numericValue >= 1000000) return (numericValue / 1000000).toFixed(1) + 'M';
if (numericValue >= 1000) return (numericValue / 1000).toFixed(1) + 'K';
return String(Math.round(numericValue));
}
function formatPopulationAxisValue(value) {
const numericValue = Number(value) || 0;
if (numericValue >= 1000000000) return (numericValue / 1000000000).toFixed(1) + 'B';
if (numericValue >= 1000000) return (numericValue / 1000000).toFixed(1) + 'M';
if (numericValue >= 1000) return (numericValue / 1000).toFixed(1) + 'K';
return String(Math.round(numericValue));
}
function updateReportSummary(summary) {
const reportTotal = document.getElementById('report-total');
const reportPct = document.getElementById('report-pct');
const reportHhTotal = document.getElementById('report-hh-total');
if (reportTotal) reportTotal.textContent = summary.total_households || '0';
if (reportPct) reportPct.textContent = (summary.avg_poverty_score || 0).toFixed(2);
if (reportHhTotal) reportHhTotal.textContent = summary.total_households || '0';
}
// ============ LAPORAN DOWNLOAD ============
function loadLaporanSummary() {
loadCombinedReportPreview();
}
async function loadCombinedReportPreview() {
const metaElement = document.getElementById('report-preview-meta');
const frameElement = document.getElementById('report-preview-frame');
if (!metaElement || !frameElement) return;
metaElement.textContent = 'Memuat data laporan lengkap...';
try {
const [summaryResponse, areaResponse, configResponse, needPointsResponse, allRankings] = await Promise.all([
fetch('api/get_dashboard_summary.php'),
fetch('api/get_area_breakdown.php'),
fetch('api/get_configuration.php'),
fetch('api/get_need_points.php'),
fetchAllHouseholdRankingForReport()
]);
const [summaryData, areaData, configurationData, needPointsData] = await Promise.all([
summaryResponse.json(),
areaResponse.json(),
configResponse.json(),
needPointsResponse.json()
]);
const combinedReport = buildCombinedReportData(summaryData, areaData, configurationData, needPointsData, allRankings);
const pdfUrl = await renderCombinedPdfPreview(combinedReport);
if (window.currentCombinedReportPdfUrl) {
URL.revokeObjectURL(window.currentCombinedReportPdfUrl);
}
frameElement.src = pdfUrl;
metaElement.textContent = `Data dimuat: ${combinedReport.summary.totalNeedPoints.toLocaleString('id-ID')} titik kebutuhan & ${combinedReport.rankings.length.toLocaleString('id-ID')} household skor prioritas`;
window.currentCombinedReportPdfUrl = pdfUrl;
updateReportSummary(combinedReport.summary.dashboardSummary);
} catch (error) {
console.error('Error loading combined report preview:', error);
metaElement.textContent = 'Preview laporan gagal dimuat.';
}
}
async function fetchAllHouseholdRankingForReport() {
const allRows = [];
const pageSize = 1000;
let offset = 0;
while (true) {
const response = await fetch(`api/get_household_ranking.php?include_assisted=1&limit=${pageSize}&offset=${offset}`);
const data = await response.json();
if (!data || !data.success || !Array.isArray(data.ranking)) {
throw new Error('Gagal memuat data ranking household');
}
allRows.push(...data.ranking);
if (data.ranking.length < pageSize) {
break;
}
offset += pageSize;
}
return allRows;
}
function getCanvasImageDataUrl(canvasId) {
const canvas = document.getElementById(canvasId);
if (!canvas || typeof canvas.toDataURL !== 'function') return null;
try {
return canvas.toDataURL('image/png');
} catch (error) {
console.warn('Gagal mengambil gambar canvas:', canvasId, error);
return null;
}
}
function createIndonesiaMapPreviewDataUrl(needPoints) {
try {
const canvas = document.createElement('canvas');
canvas.width = 960;
canvas.height = 520;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
const w = canvas.width;
const h = canvas.height;
const mapLeft = 70;
const mapTop = 70;
const mapWidth = w - 120;
const mapHeight = h - 130;
// Background and map frame
const gradient = ctx.createLinearGradient(0, 0, w, h);
gradient.addColorStop(0, '#e7f0ff');
gradient.addColorStop(1, '#f6fbff');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, w, h);
ctx.fillStyle = '#ffffff';
ctx.fillRect(mapLeft, mapTop, mapWidth, mapHeight);
ctx.strokeStyle = '#b8d0ff';
ctx.lineWidth = 2;
ctx.strokeRect(mapLeft, mapTop, mapWidth, mapHeight);
ctx.fillStyle = '#2f4f9b';
ctx.font = 'bold 18px Arial';
ctx.fillText('Pratinjau Peta Indonesia (Data Titik Kebutuhan)', 24, 34);
const lonMin = 95;
const lonMax = 141;
const latMin = -11;
const latMax = 6;
// Grid lines
ctx.strokeStyle = '#e1ebff';
ctx.lineWidth = 1;
for (let i = 1; i < 6; i++) {
const x = mapLeft + (i / 6) * mapWidth;
ctx.beginPath();
ctx.moveTo(x, mapTop);
ctx.lineTo(x, mapTop + mapHeight);
ctx.stroke();
}
for (let i = 1; i < 4; i++) {
const y = mapTop + (i / 4) * mapHeight;
ctx.beginPath();
ctx.moveTo(mapLeft, y);
ctx.lineTo(mapLeft + mapWidth, y);
ctx.stroke();
}
const points = Array.isArray(needPoints) ? needPoints : [];
ctx.fillStyle = 'rgba(220, 53, 69, 0.35)';
for (const point of points) {
const lat = Number(point.latitude);
const lon = Number(point.longitude);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
if (lat < latMin || lat > latMax || lon < lonMin || lon > lonMax) continue;
const x = mapLeft + ((lon - lonMin) / (lonMax - lonMin)) * mapWidth;
const y = mapTop + ((latMax - lat) / (latMax - latMin)) * mapHeight;
ctx.beginPath();
ctx.arc(x, y, 1.7, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = '#3f5fa8';
ctx.font = '12px Arial';
ctx.fillText('Batas: 95°E - 141°E, 11°S - 6°N', mapLeft, h - 24);
ctx.fillText(`Total titik: ${points.length.toLocaleString('id-ID')}`, w - 230, h - 24);
return canvas.toDataURL('image/png');
} catch (error) {
console.warn('Gagal membuat pratinjau peta Indonesia:', error);
return null;
}
}
function buildCombinedReportData(summaryData, areaData, configurationData, needPointsData, rankingData) {
const areas = Array.isArray(areaData?.areas) ? areaData.areas : [];
const rankings = Array.isArray(rankingData) ? rankingData : [];
const dashboardSummary = summaryData?.success ? summaryData.summary : {};
const totalNeedPoints = Array.isArray(needPointsData?.points) ? needPointsData.points.length : 0;
const needPoints = Array.isArray(needPointsData?.points) ? needPointsData.points : [];
const populationReference = Number(configurationData?.configuration?.population_reference || 288315899);
const targetNasional = Number(configurationData?.configuration?.target_nasional || 7.5);
const povertyPercentage = populationReference > 0 ? (totalNeedPoints / populationReference) * 100 : 0;
const verifiedCount = needPoints.filter(item => Number(item.is_verified) === 1).length;
const chartPopulation = getCanvasImageDataUrl('populationChart');
const chartPercentage = getCanvasImageDataUrl('percentageChart');
const mapPreview = createIndonesiaMapPreviewDataUrl(needPoints);
return {
metadata: {
createdAt: new Date(),
populationReference,
targetNasional,
povertyPercentage
},
summary: {
totalNeedPoints,
verifiedCount,
totalAssistance: 0,
dashboardSummary
},
areas,
needPoints,
rankings,
charts: {
population: chartPopulation,
percentage: chartPercentage
},
mapPreview
};
}
async function renderCombinedPdfPreview(report) {
const jsPDFGlobal = window.jspdf && window.jspdf.jsPDF ? window.jspdf.jsPDF : null;
if (!jsPDFGlobal) {
throw new Error('jsPDF library is not available');
}
const doc = new jsPDFGlobal({ unit: 'pt', format: 'a4' });
const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
const margin = 40;
let cursorY = 48;
const now = report.metadata.createdAt ? new Date(report.metadata.createdAt) : new Date();
const formattedDate = now.toLocaleDateString('id-ID', { year: 'numeric', month: 'long', day: 'numeric' });
const addPageNumbers = () => {
const totalPages = doc.internal.pages.length - 1;
for (let i = 1; i <= totalPages; i++) {
doc.setPage(i);
doc.setFontSize(9);
doc.setTextColor(150, 150, 150);
doc.text('Halaman ' + i + ' dari ' + totalPages, pageWidth - margin - 40, pageHeight - 20);
}
};
const addCoverPage = () => {
doc.setFillColor(102, 126, 234);
doc.rect(0, 0, pageWidth, pageHeight / 2, 'F');
doc.setFillColor(118, 75, 162);
doc.rect(0, pageHeight / 2, pageWidth, pageHeight / 2, 'F');
doc.setTextColor(255, 255, 255);
doc.setFont('helvetica', 'bold');
doc.setFontSize(32);
doc.text('LAPORAN GABUNGAN', pageWidth / 2, 180, { align: 'center' });
doc.setFontSize(24);
doc.text('Sistem Informasi Geografis', pageWidth / 2, 220, { align: 'center' });
doc.setDrawColor(255, 255, 255);
doc.setLineWidth(2);
doc.line(margin * 2, 250, pageWidth - margin * 2, 250);
doc.setFont('helvetica', 'normal');
doc.setFontSize(11);
const summaryY = 300;
doc.text('Total Point Berkebutuhan: ' + String(report.summary.totalNeedPoints), pageWidth / 2, summaryY, { align: 'center' });
doc.text('Persentase Kemiskinan: ' + report.metadata.povertyPercentage.toFixed(2) + '%', pageWidth / 2, summaryY + 20, { align: 'center' });
doc.text('Target Nasional: ' + report.metadata.targetNasional.toFixed(2) + '%', pageWidth / 2, summaryY + 40, { align: 'center' });
doc.text('Total Populasi: ' + String(report.metadata.populationReference), pageWidth / 2, summaryY + 60, { align: 'center' });
doc.setFontSize(9);
doc.setTextColor(255, 255, 255);
doc.text('Dibuat: ' + formattedDate, pageWidth / 2, pageHeight - 80, { align: 'center' });
doc.setFontSize(8);
doc.text('Sistem Informasi Geografis Pontianak', pageWidth / 2, pageHeight - 50, { align: 'center' });
doc.text('Dokumen Internal', pageWidth / 2, pageHeight - 30, { align: 'center' });
doc.addPage();
cursorY = 48;
doc.setTextColor(25, 25, 25);
};
const addTitle = (title, subtitle) => {
doc.setFillColor(102, 126, 234);
doc.rect(0, 0, pageWidth, 78, 'F');
doc.setTextColor(255, 255, 255);
doc.setFont('helvetica', 'bold');
doc.setFontSize(20);
doc.text(title, margin, 32);
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.text(subtitle, margin, 52);
cursorY = 100;
doc.setTextColor(25, 25, 25);
};
const addSectionTitle = (title) => {
if (cursorY > pageHeight - 80) {
doc.addPage();
cursorY = 48;
}
doc.setFillColor(243, 246, 255);
doc.rect(margin, cursorY - 6, pageWidth - margin * 2, 32, 'F');
doc.setDrawColor(102, 126, 234);
doc.setLineWidth(2);
doc.line(margin, cursorY + 20, pageWidth - margin, cursorY + 20);
doc.setFont('helvetica', 'bold');
doc.setFontSize(13);
doc.setTextColor(102, 126, 234);
doc.text(title, margin + 10, cursorY + 14);
cursorY += 36;
doc.setTextColor(54, 65, 83);
};
const addMetricsGrid = (metrics) => {
const cellWidth = (pageWidth - margin * 2 - 18) / 2;
metrics.forEach((metric, index) => {
const x = margin + (index % 2) * (cellWidth + 18);
const y = cursorY + Math.floor(index / 2) * 58;
doc.setFillColor(243, 246, 255);
doc.rect(x, y, cellWidth, 50, 'F');
doc.setDrawColor(102, 126, 234);
doc.setLineWidth(0.5);
doc.rect(x, y, cellWidth, 50);
doc.setFont('helvetica', 'bold');
doc.setFontSize(9);
doc.setTextColor(102, 126, 234);
doc.text(metric.label, x + 8, y + 16);
doc.setFontSize(14);
doc.setTextColor(24, 24, 24);
doc.text(String(metric.value), x + 8, y + 33);
});
cursorY += Math.ceil(metrics.length / 2) * 58 + 6;
};
const addParagraph = (text) => {
const lines = doc.splitTextToSize(text, pageWidth - margin * 2);
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.setTextColor(60, 60, 60);
doc.text(lines, margin, cursorY);
cursorY += lines.length * 12 + 8;
};
const addImageBlock = (title, dataUrl, width, height) => {
if (!dataUrl) return;
if (cursorY + height + 36 > pageHeight - 35) {
doc.addPage();
cursorY = 48;
}
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
doc.setTextColor(54, 65, 83);
doc.text(title, margin, cursorY);
cursorY += 12;
doc.addImage(dataUrl, 'PNG', margin, cursorY, width, height);
cursorY += height + 14;
};
const addAutoTable = (head, body, options) => {
var tableOptions = {
startY: cursorY,
head: head,
body: body,
theme: 'striped',
headStyles: { fillColor: [102, 126, 234] },
styles: { fontSize: 8, cellPadding: 4 },
margin: { left: margin, right: margin }
};
if (options) {
Object.assign(tableOptions, options);
}
doc.autoTable(tableOptions);
cursorY = doc.lastAutoTable.finalY + 18;
};
addCoverPage();
addTitle('Laporan Gabungan Sistem Informasi Geografis', 'Memuat seluruh data Beranda, Peta, dan Manajemen');
addSectionTitle('Ringkasan Eksekutif');
addMetricsGrid([
{ label: 'Total Point Berkebutuhan', value: report.summary.totalNeedPoints.toLocaleString('id-ID') },
{ label: 'Persentase Kemiskinan', value: report.metadata.povertyPercentage.toFixed(6) + '%' },
{ label: 'Target Nasional', value: report.metadata.targetNasional.toFixed(2) + '%' },
{ label: 'Total Populasi Indonesia', value: report.metadata.populationReference.toLocaleString('id-ID') }
]);
addParagraph('Laporan ini memuat seluruh informasi utama dari Beranda, pratinjau peta Indonesia berbasis data peta, serta daftar lengkap titik kebutuhan dan household dengan skor prioritas.');
addSectionTitle('Grafik Beranda');
const chartWidth = (pageWidth - margin * 2 - 16) / 2;
const chartHeight = 170;
if (cursorY + chartHeight + 40 > pageHeight - 35) {
doc.addPage();
cursorY = 48;
}
if (report.charts.population) {
doc.addImage(report.charts.population, 'PNG', margin, cursorY, chartWidth, chartHeight);
}
if (report.charts.percentage) {
doc.addImage(report.charts.percentage, 'PNG', margin + chartWidth + 16, cursorY, chartWidth, chartHeight);
}
cursorY += chartHeight + 18;
addSectionTitle('Pratinjau Peta Indonesia');
addImageBlock('Sebaran titik kebutuhan dari modul peta', report.mapPreview, pageWidth - margin * 2, 230);
addSectionTitle('Ringkasan Wilayah (Beranda)');
const areaRows = report.areas.map(area => [
area.area_name || '-',
String(area.household_count || 0),
String(area.area_type || '-')
]);
addAutoTable(
[['Wilayah', 'Jumlah Titik Kebutuhan', 'Tipe']],
areaRows.length > 0 ? areaRows : [['-', '0', '-']]
);
addSectionTitle('Daftar Titik Kebutuhan (Semua Data)');
const needPointRows = report.needPoints.map((point, index) => [
String(index + 1),
point.name || '-',
point.head_name || '-',
point.economic_status || '-',
point.masjid_name || '-',
Number(point.latitude || 0).toFixed(4) + ', ' + Number(point.longitude || 0).toFixed(4)
]);
addAutoTable(
[['No', 'Nama Titik', 'Kepala Keluarga', 'Status Ekonomi', 'Masjid', 'Koordinat']],
needPointRows.length > 0 ? needPointRows : [['-', '-', '-', '-', '-', '-']],
{ styles: { fontSize: 7, cellPadding: 3 } }
);
addSectionTitle('Household dengan Skor Prioritas (Semua Data)');
const rankingRows = report.rankings.map((item, index) => [
String(index + 1),
item.household_code || '-',
item.head_name || '-',
item.priority_level || '-',
Number(item.skr || 0).toFixed(3),
Number(item.spi || 0).toFixed(3),
item.area_name || '-',
Number(item.assisted || 0) === 1 ? 'Ya' : 'Belum'
]);
addAutoTable(
[['No', 'Kode Household', 'Kepala Keluarga', 'Prioritas', 'SKR', 'SPI', 'Wilayah', 'Sudah Dibantu']],
rankingRows.length > 0 ? rankingRows : [['-', '-', '-', '-', '0.000', '0.000', '-', '-']],
{ styles: { fontSize: 7, cellPadding: 3 } }
);
addSectionTitle('Data Geospasial');
addParagraph('Total titik kebutuhan: ' + report.needPoints.length.toLocaleString('id-ID') + ' data. Total household skor prioritas: ' + report.rankings.length.toLocaleString('id-ID') + ' data. Ringkasan wilayah beranda: ' + report.areas.length.toLocaleString('id-ID') + ' data. Export GeoJSON tetap tersedia dari menu peta.');
addPageNumbers();
const pdfBlob = doc.output('blob');
return URL.createObjectURL(pdfBlob);
}
async function downloadCombinedPDF() {
if (!window.currentCombinedReportPdfUrl) {
await loadCombinedReportPreview();
}
if (!window.currentCombinedReportPdfUrl) {
showNotification('Preview laporan belum siap', true);
return;
}
const link = document.createElement('a');
link.href = window.currentCombinedReportPdfUrl;
link.download = `laporan-gabungan-${new Date().toISOString().slice(0, 10)}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
async function downloadGeojson() {
try {
const response = await fetch('api/export_geojson.php?include_households=true&include_points=true&include_roads=true');
const geojson = await response.json();
const element = document.createElement('a');
element.setAttribute('href', 'data:application/geo+json;charset=utf-8,' + encodeURIComponent(JSON.stringify(geojson, null, 2)));
element.setAttribute('download', 'geospatial-data-' + new Date().toISOString().slice(0, 10) + '.geojson');
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
showNotification('GeoJSON data berhasil diunduh');
} catch (error) {
console.error('Error downloading GeoJSON:', error);
showNotification('Error downloading GeoJSON', true);
}
}
// ============ DASHBOARD ============
function loadDashboard() {
fetch('api/get_dashboard_summary.php')
.then(response => response.json())
.then(data => {
if (data.success) {
renderDashboardSummary(data);
}
})
.catch(error => console.error('Error loading dashboard:', error));
fetch('api/get_area_breakdown.php')
.then(response => response.json())
.then(data => {
if (data.success) {
renderAreaBreakdown(data.areas);
}
})
.catch(error => console.error('Error loading area breakdown:', error));
}
function renderDashboardSummary(data) {
const summary = data.summary;
// Update summary cards
document.getElementById('dash-total-households').textContent = summary.total_households;
document.getElementById('dash-avg-score').textContent = summary.avg_poverty_score;
document.getElementById('dash-assisted').textContent = summary.households_assisted;
document.getElementById('dash-assisted-pct').textContent = summary.percentage_assisted + '%';
// Render status breakdown
const statusList = document.getElementById('dash-status-list');
statusList.innerHTML = '';
const statusLabels = {
'unverified': '⏱️ Belum Verifikasi',
'field_verified': '✓ Terverifikasi Lapangan',
'admin_approved': '✅ Admin Approved',
'needs_review': '🔍 Perlu Review',
'rejected': '❌ Ditolak'
};
for (const [status, count] of Object.entries(data.summary.status_breakdown || {})) {
const item = document.createElement('div');
item.className = 'dashboard-status-item';
item.textContent = `${statusLabels[status] || status}: ${count}`;
statusList.appendChild(item);
}
// Render priority breakdown
const priorityList = document.getElementById('dash-priority-list');
priorityList.innerHTML = '';
const priorityLabels = {
'very_high': '🔴 Sangat Tinggi',
'high': '🟠 Tinggi',
'medium': '🟡 Menengah',
'low': '🟢 Rendah'
};
for (const [priority, count] of Object.entries(data.summary.priority_breakdown || {})) {
const item = document.createElement('div');
item.className = 'dashboard-priority-item';
item.textContent = `${priorityLabels[priority] || priority}: ${count}`;
priorityList.appendChild(item);
}
}
function renderAreaBreakdown(areas) {
const areaList = document.getElementById('dash-area-list');
areaList.innerHTML = '';
areas.slice(0, 10).forEach(area => {
const item = document.createElement('div');
item.className = 'dashboard-area-item';
item.innerHTML = `
<div>
<div class="dashboard-area-item-label">${area.area_name}</div>
<div class="dashboard-area-item-value">Keluarga: ${area.household_count || 0}</div>
</div>
<div>
<div class="dashboard-area-item-label">Score Avg: ${(area.avg_poverty_score || 0).toFixed(1)}</div>
<div class="dashboard-area-item-value">Terverif: ${area.verified_count || 0}</div>
</div>
`;
areaList.appendChild(item);
});
}
function refreshDashboard() {
loadDashboard();
showNotification('Dashboard refreshed');
}
// ============ USER MANAGEMENT ============
function loadUsersList() {
if (!currentUser || currentUser.role !== 'Admin') {
showNotification('Only admins can manage users', true);
return;
}
fetch('api/get_users.php')
.then(response => response.json())
.then(data => {
if (data.success) {
renderUsersList(data.users);
} else {
showNotification('Error loading users: ' + (data.error || 'Unknown error'), true);
}
})
.catch(error => {
console.error('Error loading users:', error);
showNotification('Error loading users', true);
});
}
function renderUsersList(users) {
const usersList = document.getElementById('users-list');
usersList.innerHTML = '';
const activeCountEl = document.getElementById('users-active-count');
const inactiveCountEl = document.getElementById('users-inactive-count');
const activeCount = Array.isArray(users) ? users.filter(user => Number(user.is_active) === 1).length : 0;
const inactiveCount = Array.isArray(users) ? Math.max(0, users.length - activeCount) : 0;
if (activeCountEl) activeCountEl.textContent = String(activeCount);
if (inactiveCountEl) inactiveCountEl.textContent = String(inactiveCount);
if (users.length === 0) {
usersList.innerHTML = '<div class="users-empty">Belum ada pengguna yang terdaftar.</div>';
return;
}
users.forEach(user => {
const item = document.createElement('div');
item.className = 'user-item';
const roleLabel = {
'Admin': '👑 Admin',
'Petugas': '📝 Petugas Data',
'Pimpinan': '👔 Pimpinan'
}[user.role] || user.role;
const statusText = user.is_active ? 'Aktif' : 'Nonaktif';
const statusClass = user.is_active ? 'status-active' : 'status-inactive';
item.innerHTML = `
<div class="user-item-avatar ${user.role ? user.role.toLowerCase() : ''}">${(user.username || '?').charAt(0).toUpperCase()}</div>
<div class="user-item-info">
<div class="user-item-username">${user.username}</div>
<div class="user-item-email">${user.email}</div>
<div class="user-item-meta">
<span class="user-item-role">${roleLabel}</span>
<span class="user-item-status ${statusClass}">${statusText}</span>
</div>
</div>
<div class="user-item-actions">
<button class="btn-edit-user" onclick="openEditUserModal(${user.id}, '${user.role}')">Edit</button>
<button class="btn-delete-user" onclick="deleteUserConfirm(${user.id}, '${user.username}')">Hapus</button>
</div>
`;
usersList.appendChild(item);
});
}
async function createNewUser() {
const username = document.getElementById('new-user-username').value.trim();
const email = document.getElementById('new-user-email').value.trim();
const password = document.getElementById('new-user-password').value;
const role = document.getElementById('new-user-role').value;
const resultDiv = document.getElementById('user-creation-result');
// Validation
if (!username || !email || !password || !role) {
resultDiv.textContent = '❌ Semua field harus diisi';
resultDiv.className = 'error';
resultDiv.style.display = 'block';
return;
}
if (password.length < 6) {
resultDiv.textContent = '❌ Password minimal 6 karakter';
resultDiv.className = 'error';
resultDiv.style.display = 'block';
return;
}
try {
const response = await fetch('api/create_user.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, email, password, role })
});
const data = await response.json();
if (data.success) {
resultDiv.textContent = `✅ ${data.message}`;
resultDiv.className = 'users-result success';
resultDiv.style.display = 'block';
// Clear form
document.getElementById('new-user-username').value = '';
document.getElementById('new-user-email').value = '';
document.getElementById('new-user-password').value = '';
document.getElementById('new-user-role').value = 'Petugas';
// Refresh users list
setTimeout(() => loadUsersList(), 1500);
} else {
resultDiv.textContent = `❌ ${data.message || data.error}`;
resultDiv.className = 'users-result error';
resultDiv.style.display = 'block';
}
} catch (error) {
console.error('Error creating user:', error);
resultDiv.textContent = '❌ Error creating user';
resultDiv.className = 'users-result error';
resultDiv.style.display = 'block';
}
}
async function deleteUserConfirm(userId, username) {
if (confirm(`Are you sure you want to deactivate user "${username}"?`)) {
try {
const response = await fetch('api/delete_user.php', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: userId })
});
const data = await response.json();
if (data.success) {
showNotification('User deactivated successfully');
loadUsersList();
} else {
showNotification(`Error: ${data.error}`, true);
}
} catch (error) {
console.error('Error deleting user:', error);
showNotification('Error deactivating user', true);
}
}
}
function openEditUserModal(userId, currentRole) {
const newRole = prompt('Select new role:\n1 = Admin\n2 = Petugas\n3 = Pimpinan\n\nCurrent: ' + currentRole);
if (newRole === null) return; // User cancelled
const roleMap = { '1': 'Admin', '2': 'Petugas', '3': 'Pimpinan' };
const role = roleMap[newRole];
if (!role) {
showNotification('Invalid role selection', true);
return;
}
updateUserRole(userId, role);
}
async function updateUserRole(userId, newRole) {
try {
const response = await fetch('api/update_user.php', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: userId, role: newRole })
});
const data = await response.json();
if (data.success) {
showNotification('User role updated successfully');
loadUsersList();
} else {
showNotification(`Error: ${data.error}`, true);
}
} catch (error) {
console.error('Error updating user:', error);
showNotification('Error updating user role', true);
}
}
function isTabAllowedForCurrentUser(tabName) {
if (!currentUser || !currentUser.role) return true;
const role = String(currentUser.role).toLowerCase();
if (role === 'pimpinan') {
return tabName === 'beranda' || tabName === 'laporan';
}
return true;
}
// Show users tab button if user is admin
function updateAdminUI() {
if (!currentUser) return;
const adminButtons = document.querySelectorAll('.admin-only');
adminButtons.forEach(button => {
button.style.display = currentUser.role === 'Admin' ? 'inline-flex' : 'none';
});
const restrictedForPimpinan = document.querySelectorAll('.tab-button:not(.admin-only)');
restrictedForPimpinan.forEach(button => {
const handler = button.getAttribute('onclick') || '';
const allowsOnlyHomeAndReport = currentUser.role && String(currentUser.role).toLowerCase() === 'pimpinan';
if (allowsOnlyHomeAndReport) {
const isAllowed = handler.includes("'beranda'") || handler.includes('"beranda"') || handler.includes("'laporan'") || handler.includes('"laporan"');
button.style.display = isAllowed ? 'inline-flex' : 'none';
} else {
button.style.display = 'inline-flex';
}
});
if (currentUser.role && String(currentUser.role).toLowerCase() === 'pimpinan') {
if (!isTabAllowedForCurrentUser(getActiveTabName())) {
switchTab('beranda');
}
}
}
function getActiveTabName() {
const activeButton = document.querySelector('.tab-button.tab-active');
if (!activeButton) return 'beranda';
const handler = activeButton.getAttribute('onclick') || '';
const match = handler.match(/switchTab\(['\"]([^'\"]+)['\"]/);
return match ? match[1] : 'beranda';
}
function setupConfigurationSection() {
const saveButton = document.getElementById('save-configuration-btn');
if (saveButton) {
saveButton.onclick = saveConfiguration;
}
// wire rendering mode + refresh/export controls (moved into Konfigurasi tab)
const renderingSelect = document.getElementById('rendering-mode-select');
const refreshBtn = document.getElementById('refresh-needpoints-btn');
const exportBtn = document.getElementById('export-needpoints-btn');
if (renderingSelect) {
const stored = getStoredRenderingMode();
renderingSelect.value = stored ? stored : (renderingMode === 'auto' ? 'auto' : renderingMode);
renderingSelect.onchange = function(e) {
const val = e.target.value;
if (val === 'auto') renderingMode = detectRenderingMode(); else renderingMode = val;
setStoredRenderingMode(val === 'auto' ? null : val);
if (renderingMode === 'high') visibleMarkerThreshold = 3000;
else if (renderingMode === 'medium') visibleMarkerThreshold = 1500;
else if (renderingMode === 'low') visibleMarkerThreshold = 0;
else visibleMarkerThreshold = 600;
renderVisibleNeedPoints();
showNotification('Rendering mode set to ' + renderingMode);
};
}
if (refreshBtn) refreshBtn.onclick = function() { loadNeedPoints(); showNotification('Refreshing need points'); };
if (exportBtn) exportBtn.onclick = function() { downloadGeojson(); showNotification('GeoJSON export started'); };
loadConfiguration();
}
function loadConfiguration() {
fetch('api/get_configuration.php')
.then(response => response.json())
.then(data => {
if (!data.success) return;
const targetInput = document.getElementById('target-nasional-input');
if (targetInput) {
targetInput.value = Number(data.configuration?.target_nasional || 7.5).toFixed(2);
}
const populationInput = document.getElementById('population-reference-input');
if (populationInput) {
populationInput.value = Number(data.configuration?.population_reference || 288315899);
}
const targetDisplay = document.getElementById('beranda-target');
if (targetDisplay) {
targetDisplay.textContent = Number(data.configuration?.target_nasional || 7.5).toFixed(2);
}
})
.catch(error => console.error('Error loading configuration:', error));
}
function saveConfiguration() {
const targetInput = document.getElementById('target-nasional-input');
const populationInput = document.getElementById('population-reference-input');
const resultBox = document.getElementById('configuration-result');
const targetNasional = targetInput ? parseFloat(targetInput.value) : NaN;
const populationReference = populationInput ? parseInt(populationInput.value, 10) : NaN;
if (isNaN(targetNasional) || targetNasional < 0 || targetNasional > 100) {
if (resultBox) {
resultBox.style.display = 'block';
resultBox.style.background = 'rgba(244,67,54,0.2)';
resultBox.style.border = '1px solid rgba(244,67,54,0.4)';
resultBox.textContent = 'Target nasional harus antara 0 sampai 100.';
}
return;
}
if (isNaN(populationReference) || populationReference < 1) {
if (resultBox) {
resultBox.style.display = 'block';
resultBox.style.background = 'rgba(244,67,54,0.2)';
resultBox.style.border = '1px solid rgba(244,67,54,0.4)';
resultBox.textContent = 'Total populasi Indonesia harus lebih besar dari 0.';
}
return;
}
fetch('api/save_configuration.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target_nasional: targetNasional, population_reference: populationReference })
})
.then(response => response.json())
.then(data => {
if (data.success) {
if (resultBox) {
resultBox.style.display = 'block';
resultBox.style.background = 'rgba(76,175,80,0.2)';
resultBox.style.border = '1px solid rgba(76,175,80,0.4)';
resultBox.textContent = 'Konfigurasi berhasil disimpan.';
}
const targetDisplay = document.getElementById('beranda-target');
if (targetDisplay) {
targetDisplay.textContent = Number(data.configuration?.target_nasional || targetNasional).toFixed(2);
}
loadBerandaDashboard();
loadCombinedReportPreview();
} else if (resultBox) {
resultBox.style.display = 'block';
resultBox.style.background = 'rgba(244,67,54,0.2)';
resultBox.style.border = '1px solid rgba(244,67,54,0.4)';
resultBox.textContent = data.error || 'Gagal menyimpan konfigurasi.';
}
})
.catch(error => {
console.error('Error saving configuration:', error);
if (resultBox) {
resultBox.style.display = 'block';
resultBox.style.background = 'rgba(244,67,54,0.2)';
resultBox.style.border = '1px solid rgba(244,67,54,0.4)';
resultBox.textContent = 'Gagal menyimpan konfigurasi.';
}
});
}
// ============ MANAGEMENT / CONFIGURATION FOR NEED POINTS ============
function loadManagement() {
// Load a preview list of need points (10 per page) in the Managemen panel
setupManagementNeedpointControls();
loadManagementNeedPoints();
// Ensure household data is available for score computation path
loadHouseholdsForPanel();
const computeBtn = document.getElementById('manage-compute-score-btn');
if (computeBtn) {
computeBtn.onclick = function() {
computeHouseholdScores();
// refresh management preview shortly after compute call starts
setTimeout(loadManagementScorePreview, 500);
};
}
loadManagementScorePreview();
}
// Management need-point carousel state
let managementNeedPoints = [];
let currentMgmtNeedPage = 0;
const MGMT_NEEDPAGE_SIZE = 10;
function setupManagementNeedpointControls() {
const prev = document.getElementById('needpoint-prev');
const next = document.getElementById('needpoint-next');
if (prev) prev.onclick = function() { renderManagementNeedPointPage(currentMgmtNeedPage - 1); };
if (next) next.onclick = function() { renderManagementNeedPointPage(currentMgmtNeedPage + 1); };
}
function loadManagementNeedPoints() {
fetch('api/get_need_points.php')
.then(r => r.json())
.then(data => {
if (data.success && Array.isArray(data.points)) {
managementNeedPoints = data.points.map(p => normalizeNeedPointRecord(p));
} else {
managementNeedPoints = [];
}
currentMgmtNeedPage = 0;
renderManagementNeedPointPage(0);
})
.catch(err => {
console.error('Error loading management need points', err);
managementNeedPoints = [];
renderManagementNeedPointPage(0);
});
}
function renderManagementNeedPointPage(pageIndex) {
if (!Array.isArray(managementNeedPoints)) managementNeedPoints = [];
const total = managementNeedPoints.length;
const maxPage = Math.max(0, Math.ceil(total / MGMT_NEEDPAGE_SIZE) - 1);
if (pageIndex < 0) pageIndex = 0;
if (pageIndex > maxPage) pageIndex = maxPage;
currentMgmtNeedPage = pageIndex;
const start = pageIndex * MGMT_NEEDPAGE_SIZE;
const slice = managementNeedPoints.slice(start, start + MGMT_NEEDPAGE_SIZE);
const container = document.getElementById('management-needpoint-list');
const pager = document.getElementById('management-needpoint-pager');
if (!container) return;
container.innerHTML = '';
if (slice.length === 0) {
container.innerHTML = '<div style="color: rgba(255,255,255,0.7); padding: 12px;">No need points available</div>';
} else {
let rowsHtml = '';
slice.forEach(p => {
const id = Number(p.id) || 0;
const assisted = Number(p.assisted || 0) === 1;
const name = escapeHtml(p.name || '-');
const nameClass = assisted ? 'assisted-point-name' : '';
const hh = escapeHtml(p.household_name || '-');
const head = escapeHtml(p.head_name || '-');
const econRaw = String(p.economic_status || '-').toLowerCase();
const econClass = econRaw.replace(/[^a-z_]/g, '');
const econLabel = escapeHtml(econRaw === '-' ? '-' : econRaw.replace(/_/g, ' '));
const coords = `${Number(p.latitude).toFixed(4)}, ${Number(p.longitude).toFixed(4)}`;
const verified = Number(p.is_verified || 0) === 1;
const verifyIcon = verified ? '✓' : '✕';
const verifyTitle = verified ? 'Verified' : 'Not Verified';
const bantuanDisabled = !verified;
const bantuanClass = 'mini-icon-btn bantuan' + (bantuanDisabled ? ' disabled' : '');
const bantuanTitle = bantuanDisabled ? 'Lock: household not verified' : 'Kelola Bantuan';
const bantuanOnclick = bantuanDisabled ? '' : `onclick="openNeedPointBantuanModal(${id})"`;
rowsHtml += `<tr class="${verified ? 'row-verified' : ''}"><td class="col-id ${verified ? 'verified-id' : ''}">${id}</td><td class="${nameClass}">${name}</td><td>${hh}</td><td>${head}</td><td><span class="econ-badge econ-${econClass}">${econLabel}</span></td><td>${coords}</td><td><button class="mini-icon-btn verify ${verified ? 'is-on' : 'is-off'}" title="${verifyTitle}" onclick="toggleNeedPointVerification(${id})">${verifyIcon}</button></td><td><button class="${bantuanClass}" title="${bantuanTitle}" ${bantuanOnclick} ${bantuanDisabled ? 'disabled' : ''}>🎁</button></td></tr>`;
});
container.innerHTML = `
<div class="management-needpoint-table-wrap">
<table class="management-needpoint-table">
<thead>
<tr>
<th>ID</th>
<th>Point</th>
<th>Nama Rumah/Keluarga</th>
<th>Kepala Rumah</th>
<th>Keadaan Ekonomi</th>
<th>Koordinat</th>
<th>Verif</th>
<th>Bantuan</th>
</tr>
</thead>
<tbody>${rowsHtml}</tbody>
</table>
</div>
`;
}
if (pager) pager.textContent = `Page ${pageIndex + 1} / ${maxPage + 1}${total} items`;
}
function toggleNeedPointVerification(id) {
fetch('api/toggle_need_point_verification.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: id })
})
.then(r => r.json())
.then(data => {
if (!data.success || !data.point) {
showNotification('Gagal mengubah status verifikasi', true);
return;
}
const updated = normalizeNeedPointRecord(data.point);
const idx = managementNeedPoints.findIndex(p => Number(p.id) === Number(id));
if (idx !== -1) {
managementNeedPoints[idx] = { ...managementNeedPoints[idx], ...updated };
}
renderManagementNeedPointPage(currentMgmtNeedPage);
showNotification(updated.is_verified ? 'Point berkebutuhan diverifikasi' : 'Verifikasi dibatalkan');
})
.catch(err => {
console.error('Verification toggle error:', err);
showNotification('Gagal mengubah verifikasi', true);
});
}
function openNeedPointBantuanModal(id) {
const point = managementNeedPoints.find(p => Number(p.id) === Number(id));
const pointName = point && point.name ? point.name : ('ID ' + id);
// Populate modal content
const modal = document.getElementById('bantuan-manage-modal');
if (!modal) return;
const titleEl = modal.querySelector('.bantuan-point-title');
const instructionsEl = modal.querySelector('.bantuan-instructions');
const openAssistanceBtn = modal.querySelector('.bantuan-open-assistance');
if (titleEl) titleEl.textContent = pointName;
if (instructionsEl) instructionsEl.innerHTML = `
<div class="bantuan-step-list">
<div class="bantuan-step-item"><span class="bantuan-step-number">1</span><span>Verifikasi kondisi household.</span></div>
<div class="bantuan-step-item"><span class="bantuan-step-number">2</span><span>Pilih jenis bantuan yang sesuai.</span></div>
<div class="bantuan-step-item"><span class="bantuan-step-number">3</span><span>Catat nominal, frekuensi, dan petugas penyalur.</span></div>
<div class="bantuan-step-item"><span class="bantuan-step-number">4</span><span>Lanjutkan ke form catat bantuan.</span></div>
</div>
`;
if (openAssistanceBtn) {
openAssistanceBtn.onclick = function() {
// If there are no households yet, create one from the need_point record automatically
if (!Array.isArray(households) || households.length === 0) {
createHouseholdFromNeedPoint(point)
.then(household => {
// Refresh local selects with the newly created household
populateHouseholdSelectOptions();
closeBantuanManageModal();
openAssistanceModal(household.id);
})
.catch(err => {
console.error('Auto-create household failed:', err);
showNotification('Gagal membuat household otomatis: ' + (err.message || err), true);
});
return;
}
// Pre-populate assistance modal if possible by matching head_name or household_name
populateHouseholdSelectOptions();
const select = document.getElementById('assistance-household-id');
if (select && point) {
// Try to find a household id by head_name or household_name
const match = households.find(h => h.head_name === point.head_name || h.household_name === point.household_name);
if (match) {
select.value = String(match.id);
}
}
closeBantuanManageModal();
openAssistanceModal(select ? select.value : null);
};
}
modal.classList.add('show');
}
function closeBantuanManageModal() {
const modal = document.getElementById('bantuan-manage-modal');
if (modal) modal.classList.remove('show');
}
function loadManagementScorePreview() {
fetch('api/get_household_score_summary.php')
.then(response => response.json())
.then(data => {
if (!data.success || !data.summary) {
return;
}
const counts = data.summary.priority_counts || {};
const assistedCount = Number(data.summary.assisted_households || 0);
const assistedEl = document.getElementById('mgmt-assisted-count');
const veryHighEl = document.getElementById('mgmt-priority-very-high-count');
const highEl = document.getElementById('mgmt-priority-high-count');
const mediumEl = document.getElementById('mgmt-priority-medium-count');
const lowEl = document.getElementById('mgmt-priority-low-count');
if (assistedEl) assistedEl.textContent = assistedCount;
if (veryHighEl) veryHighEl.textContent = Number(counts.very_high || 0);
if (highEl) highEl.textContent = Number(counts.high || 0);
if (mediumEl) mediumEl.textContent = Number(counts.medium || 0);
if (lowEl) lowEl.textContent = Number(counts.low || 0);
})
.catch(error => {
console.error('Error loading management score preview:', error);
});
}
function loadMarkers() {
fetch('api/get_points.php')
.then(response => response.json())
.then(data => {
if (data.success && data.markers) {
// Clear existing markers
Object.keys(markers).forEach(id => {
map.removeLayer(markers[id]);
});
markers = {};
markerCount = 0;
// Load new markers
data.markers.forEach(marker => {
displayMarker(
marker.id,
marker.name,
marker.nomor_spbu,
marker.latitude,
marker.longitude,
marker.open_24_hours
);
});
updateMarkerCount();
}
})
.catch(error => console.error('Error loading markers:', error));
}
function updateMarkerCount() {
const countElement = document.getElementById('marker-count');
if (countElement) {
countElement.textContent = markerCount;
}
}
function deleteMarker(id) {
if (!confirm('Are you sure you want to delete this point?')) {
return;
}
fetch('api/delete_point.php', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ id: id })
})
.then(response => response.json())
.then(data => {
if (data.success) {
if (markers[id]) {
map.removeLayer(markers[id]);
delete markers[id];
markerCount--;
updateMarkerCount();
showNotification('✓ Point deleted');
}
} else {
showNotification('Error deleting point: ' + data.error, true);
}
})
.catch(error => {
console.error('Error:', error);
showNotification('Error deleting point', true);
});
}
function editMarker(id) {
if (!markerData[id]) {
showNotification('Marker data not found', true);
return;
}
const data = markerData[id];
currentEditingMarkerId = id;
// Pre-fill form with current marker data
document.getElementById('edit-name').value = data.name;
document.getElementById('edit-nomor-spbu').value = data.nomor_spbu || '';
document.getElementById('edit-latitude').value = data.latitude;
document.getElementById('edit-longitude').value = data.longitude;
document.getElementById('edit-24hours').checked = data.open_24_hours ? true : false;
// Open edit modal
const editModal = document.getElementById('edit-modal');
editModal.classList.add('show');
document.getElementById('edit-name').focus();
}
function closeEditModal() {
const editModal = document.getElementById('edit-modal');
editModal.classList.remove('show');
currentEditingMarkerId = null;
}
function saveMarkerEdit() {
if (!currentEditingMarkerId) {
showNotification('No marker selected for editing', true);
return;
}
const name = document.getElementById('edit-name').value.trim();
const nomor_spbu = document.getElementById('edit-nomor-spbu').value.trim();
const latitude = parseFloat(document.getElementById('edit-latitude').value);
const longitude = parseFloat(document.getElementById('edit-longitude').value);
const open_24_hours = document.getElementById('edit-24hours').checked ? 1 : 0;
// Validate inputs
if (!name) {
showNotification('Please enter a name for the point', true);
return;
}
if (isNaN(latitude) || latitude < -90 || latitude > 90) {
showNotification('Please enter a valid latitude (-90 to 90)', true);
return;
}
if (isNaN(longitude) || longitude < -180 || longitude > 180) {
showNotification('Please enter a valid longitude (-180 to 180)', true);
return;
}
const id = currentEditingMarkerId;
// Update in database
fetch('api/update_point.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
id: id,
name: name,
nomor_spbu: nomor_spbu,
latitude: latitude,
longitude: longitude,
open_24_hours: open_24_hours
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
const oldStatus = markerData[id].open_24_hours;
const newStatus = open_24_hours;
// Update local marker data
markerData[id] = {
id: id,
name: name,
nomor_spbu: nomor_spbu,
latitude: latitude,
longitude: longitude,
open_24_hours: open_24_hours
};
// If status changed, update marker icon and color
if (oldStatus !== newStatus) {
const icon = open_24_hours ? markerIcons.open : markerIcons.closed;
markers[id].setIcon(icon);
}
// Update marker position if changed
const currentPos = markers[id].getLatLng();
if (currentPos.lat !== latitude || currentPos.lng !== longitude) {
markers[id].setLatLng([latitude, longitude]);
}
// Force popup update
if (markers[id].isPopupOpen()) {
markers[id].closePopup();
}
closeEditModal();
showNotification('✓ Point updated successfully');
} else {
showNotification('Error updating point: ' + data.error, true);
}
})
.catch(error => {
console.error('Error:', error);
showNotification('Error updating point', true);
});
}
function toggleMarkerStatus(id) {
if (!markerData[id]) {
showNotification('Marker data not found', true);
return;
}
const currentStatus = markerData[id].open_24_hours;
const newStatus = currentStatus ? 0 : 1;
// Update in database
fetch('api/update_point.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
id: id,
open_24_hours: newStatus
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Update local marker data
markerData[id].open_24_hours = newStatus;
// Update marker icon color
const icon = newStatus ? markerIcons.open : markerIcons.closed;
markers[id].setIcon(icon);
// Update popup content
const marker = markers[id];
const name = markerData[id].name;
const nomor_spbu = markerData[id].nomor_spbu;
const lat = markerData[id].latitude;
const lng = markerData[id].longitude;
const hoursText = newStatus ? '🕐 Open 24 Hours' : '🕐 Limited Hours';
let popupContent = `
<div class="marker-popup">
<div class="marker-popup-name">${escapeHtml(name)}</div>
<div class="marker-popup-hours">${hoursText}</div>`;
if (nomor_spbu) {
popupContent += `<div class="marker-popup-spbu">🏢 SPBU: ${escapeHtml(nomor_spbu)}</div>`;
}
popupContent += `
<div class="marker-popup-info">
<span>Lat: ${lat.toFixed(4)}</span><br>
<span>Lng: ${lng.toFixed(4)}</span>
</div>
<div class="marker-popup-buttons">
<button onclick="toggleMarkerStatus(${id})">Toggle Status</button>
<button onclick="editMarker(${id})">Edit</button>
<button onclick="deleteMarker(${id})">Delete</button>
</div>
</div>
`;
marker.setPopupContent(popupContent);
marker.closePopup();
marker.openPopup();
showNotification('✓ Status updated');
} else {
showNotification('Error updating status: ' + data.error, true);
}
})
.catch(error => {
console.error('Error:', error);
showNotification('Error updating status', true);
});
}
function distanceMeters(lat1, lng1, lat2, lng2) {
return L.latLng(lat1, lng1).distanceTo(L.latLng(lat2, lng2));
}
function findNearestCoveringMasjidLocal(latitude, longitude) {
let nearestMasjid = null;
let nearestDistance = null;
Object.keys(masjidData).forEach(id => {
const masjid = masjidData[id];
const distance = distanceMeters(latitude, longitude, masjid.latitude, masjid.longitude);
if (distance <= masjid.radius_meters) {
if (nearestDistance === null || distance < nearestDistance) {
nearestMasjid = masjid;
nearestDistance = distance;
}
}
});
if (!nearestMasjid) {
return null;
}
return {
...nearestMasjid,
distance_meters: nearestDistance
};
}
function getMasjidPopupContent(id) {
const masjid = masjidData[id];
if (!masjid) {
return '<div class="feature-popup">Masjid data not found</div>';
}
const radiusLabel = Number(masjid.radius_meters).toFixed(0);
return `
<div class="feature-popup">
<div class="feature-popup-name">${escapeHtml(masjid.name)}</div>
<div class="feature-popup-type">🕌 Masjid</div>
<div class="feature-popup-info">Radius: ${radiusLabel} m</div>
<div class="feature-popup-info">Lat: ${masjid.latitude.toFixed(4)} | Lng: ${masjid.longitude.toFixed(4)}</div>
<div class="feature-popup-actions">
<button class="edit-action" onclick="editMasjid(${id})">Edit</button>
<button class="delete-action" onclick="deleteMasjid(${id})">Delete</button>
</div>
</div>
`;
}
function getNeedPointPopupContent(id) {
const point = needPointData[id];
if (!point) {
return '<div class="feature-popup">Point data not found</div>';
}
const distanceLabel = Number(point.distance_meters || 0).toFixed(1);
return `
<div class="feature-popup">
<div class="feature-popup-name">${escapeHtml(point.name)}</div>
<div class="feature-popup-type">♿ Point Berkebutuhan</div>
<div class="feature-popup-info">Masjid: ${escapeHtml(point.masjid_name || '-')}</div>
<div class="feature-popup-info">Jarak ke masjid: ${distanceLabel} m</div>
<div class="feature-popup-info">Lat: ${point.latitude.toFixed(4)} | Lng: ${point.longitude.toFixed(4)}</div>
<div class="feature-popup-info">Nama Rumah: ${escapeHtml(point.household_name || '-')}</div>
<div class="feature-popup-info">Kepala Rumah: ${escapeHtml(point.head_name || '-')}</div>
<div class="feature-popup-info">Keadaan Ekonomi: ${escapeHtml(point.economic_status || '-')}</div>
<div class="feature-popup-actions">
<button class="edit-action" onclick="editNeedPoint(${id})">Edit</button>
<button class="delete-action" onclick="deleteNeedPoint(${id})">Delete</button>
</div>
</div>
`;
}
function updateMasjidCount() {
const countElement = document.getElementById('masjid-count');
if (countElement) {
countElement.textContent = masjidCount;
}
}
function updateNeedPointCount() {
const countElement = document.getElementById('need-point-count');
if (countElement) {
countElement.textContent = needPointCount;
}
}
function removeNeedPointsByIds(ids) {
if (!Array.isArray(ids) || ids.length === 0) {
return;
}
ids.forEach(idValue => {
const id = Number(idValue);
if (needPointMarkers[id] && map.hasLayer(needPointMarkers[id])) {
map.removeLayer(needPointMarkers[id]);
}
if (needPointMarkers[id]) {
delete needPointMarkers[id];
}
if (needPointData[id]) {
delete needPointData[id];
needPointCount = Math.max(0, needPointCount - 1);
}
});
// remove from allNeedPoints cache
allNeedPoints = allNeedPoints.filter(p => !ids.includes(String(p.id)) && !ids.includes(p.id));
// re-render visible layer to keep consistency
if (needPointLayer) renderVisibleNeedPoints();
updateNeedPointCount();
}
function normalizeNeedPointRecord(point) {
return {
id: Number(point.id),
name: point.name,
latitude: Number(point.latitude),
longitude: Number(point.longitude),
masjid_id: Number(point.masjid_id),
household_name: point.household_name || null,
head_name: point.head_name || null,
economic_status: point.economic_status || null,
is_verified: Number(point.is_verified || 0),
masjid_name: point.masjid_name,
masjid_latitude: Number(point.masjid_latitude),
masjid_longitude: Number(point.masjid_longitude),
radius_meters: Number(point.radius_meters),
distance_meters: Number(point.distance_meters || 0)
};
}
function applyNeedPointRecord(record) {
const normalized = normalizeNeedPointRecord(record);
const id = normalized.id;
needPointData[id] = normalized;
if (needPointMarkers[id]) {
needPointMarkers[id].setLatLng([normalized.latitude, normalized.longitude]);
needPointMarkers[id].setPopupContent(getNeedPointPopupContent(id));
}
// keep cache in sync
const idx = allNeedPoints.findIndex(p => Number(p.id) === Number(id));
if (idx !== -1) {
allNeedPoints[idx] = normalized;
} else {
allNeedPoints.push(normalized);
}
}
function loadMasjids() {
fetch('api/get_masjids.php')
.then(response => response.json())
.then(data => {
if (data.success && data.masjids) {
Object.keys(masjidMarkers).forEach(id => {
if (map.hasLayer(masjidMarkers[id])) {
map.removeLayer(masjidMarkers[id]);
}
});
Object.keys(masjidCircles).forEach(id => {
if (map.hasLayer(masjidCircles[id])) {
map.removeLayer(masjidCircles[id]);
}
});
masjidMarkers = {};
masjidCircles = {};
masjidData = {};
masjidCount = 0;
data.masjids.forEach(masjid => {
displayMasjid(masjid);
});
updateMasjidCount();
}
})
.catch(error => console.error('Error loading masjids:', error));
}
function displayMasjid(masjid) {
const id = Number(masjid.id);
const radiusMeters = Number(masjid.radius_meters || 500);
masjidData[id] = {
id: id,
name: masjid.name,
latitude: Number(masjid.latitude),
longitude: Number(masjid.longitude),
radius_meters: radiusMeters,
need_point_count: Number(masjid.need_point_count || 0)
};
const marker = L.marker([masjidData[id].latitude, masjidData[id].longitude], {
draggable: true,
icon: markerIcons.masjid
}).addTo(map);
const circle = L.circle([masjidData[id].latitude, masjidData[id].longitude], {
radius: radiusMeters,
color: '#1976d2',
fillColor: '#64b5f6',
fillOpacity: 0.15,
weight: 2,
interactive: false
}).addTo(map);
marker.bindPopup(getMasjidPopupContent(id));
marker.on('dragend', function() {
const oldLat = masjidData[id].latitude;
const oldLng = masjidData[id].longitude;
const newPosition = marker.getLatLng();
fetch('api/update_masjid.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
id: id,
latitude: newPosition.lat,
longitude: newPosition.lng
})
})
.then(response => response.json())
.then(data => {
if (data.success && data.masjid) {
masjidData[id].name = data.masjid.name;
masjidData[id].latitude = Number(data.masjid.latitude);
masjidData[id].longitude = Number(data.masjid.longitude);
masjidData[id].radius_meters = Number(data.masjid.radius_meters);
marker.setLatLng([masjidData[id].latitude, masjidData[id].longitude]);
marker.setPopupContent(getMasjidPopupContent(id));
circle.setLatLng([masjidData[id].latitude, masjidData[id].longitude]);
circle.setRadius(masjidData[id].radius_meters);
if (Array.isArray(data.deleted_need_point_ids) && data.deleted_need_point_ids.length > 0) {
removeNeedPointsByIds(data.deleted_need_point_ids);
showNotification('✓ Masjid dipindah, ' + data.deleted_need_point_ids.length + ' point berkebutuhan dihapus otomatis');
} else {
showNotification('✓ Lokasi masjid berhasil diperbarui');
}
} else {
marker.setLatLng([oldLat, oldLng]);
showNotification('Error updating masjid: ' + (data.error || 'unknown error'), true);
}
})
.catch(error => {
console.error('Error:', error);
marker.setLatLng([oldLat, oldLng]);
showNotification('Error updating masjid location', true);
});
});
masjidMarkers[id] = marker;
masjidCircles[id] = circle;
masjidCount++;
}
function openMasjidModal(coords) {
const modal = document.getElementById('masjid-modal');
const form = document.getElementById('masjid-form');
const latInput = document.getElementById('masjid-latitude');
const lngInput = document.getElementById('masjid-longitude');
const info = document.getElementById('masjid-form-info');
if (!modal || !form || !latInput || !lngInput) {
return;
}
form.reset();
if (coords) {
latInput.value = coords.lat.toFixed(6);
lngInput.value = coords.lng.toFixed(6);
latInput.readOnly = true;
lngInput.readOnly = true;
if (info) {
info.textContent = 'Koordinat masjid diisi otomatis dari klik peta.';
info.classList.add('show');
}
} else {
latInput.value = '';
lngInput.value = '';
latInput.readOnly = false;
lngInput.readOnly = false;
if (info) {
info.classList.remove('show');
}
}
modal.classList.add('show');
document.getElementById('masjid-name').focus();
}
function closeMasjidModal() {
const modal = document.getElementById('masjid-modal');
if (modal) {
modal.classList.remove('show');
}
}
function submitMasjidForm() {
const name = document.getElementById('masjid-name').value.trim();
const latitude = parseFloat(document.getElementById('masjid-latitude').value);
const longitude = parseFloat(document.getElementById('masjid-longitude').value);
const radiusValue = document.getElementById('masjid-radius').value.trim();
if (!name) {
showNotification('Nama masjid wajib diisi', true);
return;
}
if (isNaN(latitude) || latitude < -90 || latitude > 90) {
showNotification('Latitude masjid tidak valid', true);
return;
}
if (isNaN(longitude) || longitude < -180 || longitude > 180) {
showNotification('Longitude masjid tidak valid', true);
return;
}
if (radiusValue !== '') {
const radius = parseFloat(radiusValue);
if (isNaN(radius) || radius < 50 || radius > 5000) {
showNotification('Radius harus 50-5000 meter', true);
return;
}
}
addMasjid(name, latitude, longitude, radiusValue);
}
function addMasjid(name, latitude, longitude, radiusValue) {
fetch('api/save_masjid.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: name,
latitude: latitude,
longitude: longitude,
radius_meters: radiusValue === '' ? null : parseFloat(radiusValue)
})
})
.then(response => response.json())
.then(data => {
if (data.success && data.masjid) {
displayMasjid(data.masjid);
updateMasjidCount();
closeMasjidModal();
showNotification('✓ Masjid berhasil ditambahkan');
} else {
showNotification('Error adding masjid: ' + data.error, true);
}
})
.catch(error => {
console.error('Error:', error);
showNotification('Error adding masjid', true);
});
}
function editMasjid(id) {
if (!masjidData[id]) {
showNotification('Data masjid tidak ditemukan', true);
return;
}
const masjid = masjidData[id];
currentEditingMasjidId = id;
document.getElementById('edit-masjid-name').value = masjid.name;
document.getElementById('edit-masjid-latitude').value = masjid.latitude;
document.getElementById('edit-masjid-longitude').value = masjid.longitude;
document.getElementById('edit-masjid-radius').value = masjid.radius_meters;
const modal = document.getElementById('edit-masjid-modal');
modal.classList.add('show');
document.getElementById('edit-masjid-name').focus();
}
function closeEditMasjidModal() {
const modal = document.getElementById('edit-masjid-modal');
if (modal) {
modal.classList.remove('show');
}
currentEditingMasjidId = null;
}
function saveMasjidEdit() {
if (!currentEditingMasjidId) {
showNotification('Tidak ada masjid yang dipilih', true);
return;
}
const name = document.getElementById('edit-masjid-name').value.trim();
const latitude = parseFloat(document.getElementById('edit-masjid-latitude').value);
const longitude = parseFloat(document.getElementById('edit-masjid-longitude').value);
const radiusValue = document.getElementById('edit-masjid-radius').value.trim();
if (!name) {
showNotification('Nama masjid wajib diisi', true);
return;
}
if (isNaN(latitude) || latitude < -90 || latitude > 90) {
showNotification('Latitude masjid tidak valid', true);
return;
}
if (isNaN(longitude) || longitude < -180 || longitude > 180) {
showNotification('Longitude masjid tidak valid', true);
return;
}
if (radiusValue !== '') {
const radius = parseFloat(radiusValue);
if (isNaN(radius) || radius < 50 || radius > 5000) {
showNotification('Radius harus 50-5000 meter', true);
return;
}
}
fetch('api/update_masjid.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
id: currentEditingMasjidId,
name: name,
latitude: latitude,
longitude: longitude,
radius_meters: radiusValue === '' ? null : parseFloat(radiusValue)
})
})
.then(response => response.json())
.then(data => {
if (data.success && data.masjid) {
const id = Number(currentEditingMasjidId);
masjidData[id].name = data.masjid.name;
masjidData[id].latitude = Number(data.masjid.latitude);
masjidData[id].longitude = Number(data.masjid.longitude);
masjidData[id].radius_meters = Number(data.masjid.radius_meters);
if (masjidMarkers[id]) {
masjidMarkers[id].setLatLng([masjidData[id].latitude, masjidData[id].longitude]);
masjidMarkers[id].setPopupContent(getMasjidPopupContent(id));
}
if (masjidCircles[id]) {
masjidCircles[id].setLatLng([masjidData[id].latitude, masjidData[id].longitude]);
masjidCircles[id].setRadius(masjidData[id].radius_meters);
}
if (Array.isArray(data.deleted_need_point_ids) && data.deleted_need_point_ids.length > 0) {
removeNeedPointsByIds(data.deleted_need_point_ids);
showNotification('✓ Masjid diperbarui, ' + data.deleted_need_point_ids.length + ' point berkebutuhan dihapus otomatis');
} else {
showNotification('✓ Masjid berhasil diperbarui');
}
closeEditMasjidModal();
} else {
showNotification('Error updating masjid: ' + data.error, true);
}
})
.catch(error => {
console.error('Error:', error);
showNotification('Error updating masjid', true);
});
}
function deleteMasjid(id) {
if (!confirm('Apakah Anda yakin ingin menghapus masjid ini?')) {
return;
}
fetch('api/delete_masjid.php', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ id: id })
})
.then(response => response.json())
.then(data => {
if (data.success) {
if (masjidMarkers[id] && map.hasLayer(masjidMarkers[id])) {
map.removeLayer(masjidMarkers[id]);
}
if (masjidCircles[id] && map.hasLayer(masjidCircles[id])) {
map.removeLayer(masjidCircles[id]);
}
delete masjidMarkers[id];
delete masjidCircles[id];
if (masjidData[id]) {
delete masjidData[id];
masjidCount = Math.max(0, masjidCount - 1);
}
updateMasjidCount();
if (Array.isArray(data.deleted_need_point_ids) && data.deleted_need_point_ids.length > 0) {
removeNeedPointsByIds(data.deleted_need_point_ids);
showNotification('✓ Masjid dihapus, ' + data.deleted_need_point_ids.length + ' point berkebutuhan ikut dihapus');
} else {
showNotification('✓ Masjid berhasil dihapus');
}
} else {
showNotification('Error deleting masjid: ' + data.error, true);
}
})
.catch(error => {
console.error('Error:', error);
showNotification('Error deleting masjid', true);
});
}
function loadNeedPoints() {
fetch('api/get_need_points.php')
.then(response => response.json())
.then(data => {
if (data.success && data.points) {
// store normalized points for viewport-driven rendering
allNeedPoints = data.points.map(p => normalizeNeedPointRecord(p));
// detect device capability and set threshold (allow stored override)
const stored = getStoredRenderingMode();
renderingMode = stored ? stored : detectRenderingMode();
if (renderingMode === 'high') visibleMarkerThreshold = 3000;
else if (renderingMode === 'medium') visibleMarkerThreshold = 1500;
else visibleMarkerThreshold = 600;
// initialize layer container
if (needPointLayer && map && map.hasLayer(needPointLayer)) {
map.removeLayer(needPointLayer);
}
needPointLayer = L.layerGroup().addTo(map);
// render only visible points and update on view changes
renderVisibleNeedPoints();
// debounce handlers
let debounceTimer = null;
map.off('moveend', renderVisibleNeedPoints);
map.off('zoomend', renderVisibleNeedPoints);
map.on('moveend', function() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(renderVisibleNeedPoints, 120);
});
map.on('zoomend', function() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(renderVisibleNeedPoints, 120);
});
}
})
.catch(error => console.error('Error loading need points:', error));
}
function renderVisibleNeedPoints() {
if (!map || !Array.isArray(allNeedPoints)) return;
const bounds = map.getBounds().pad(0.25);
const visible = allNeedPoints.filter(p => bounds.contains([p.latitude, p.longitude]));
// Clear previous layer
needPointLayer.clearLayers();
needPointMarkers = {};
needPointData = {};
needPointCount = 0;
if (visible.length === 0) {
updateNeedPointCount();
return;
}
// If number of visible points is small, use full markers with popups
if (visible.length <= visibleMarkerThreshold) {
visible.forEach(p => {
const id = p.id;
needPointData[id] = p;
const marker = L.marker([p.latitude, p.longitude], { icon: markerIcons.need });
marker.bindPopup(getNeedPointPopupContent(id));
marker.on('click', () => {
marker.openPopup();
});
needPointLayer.addLayer(marker);
needPointMarkers[id] = marker;
needPointCount++;
});
} else {
// Heavy view: use canvas-rendered circle markers for performance and no per-feature popups
const canvasRenderer = L.canvas({ padding: 0.5 });
visible.forEach(p => {
const id = p.id;
needPointData[id] = p;
const circle = L.circleMarker([p.latitude, p.longitude], {
radius: 4,
fillColor: '#ff9800',
color: '#ffffff',
weight: 1,
opacity: 0.9,
fillOpacity: 0.9,
renderer: canvasRenderer
});
needPointLayer.addLayer(circle);
needPointMarkers[id] = circle;
needPointCount++;
});
// Add a light click handler on the map to show nearest point popup when user clicks
map.off('click', heavyModeMapClickHandler);
map.on('click', heavyModeMapClickHandler);
}
updateNeedPointCount();
}
function heavyModeMapClickHandler(e) {
// find nearest point within a small pixel radius
const clickPoint = map.latLngToContainerPoint(e.latlng);
let nearest = null;
let minPx = 40; // pixels
Object.values(needPointData).forEach(p => {
const pt = map.latLngToContainerPoint([p.latitude, p.longitude]);
const dx = pt.x - clickPoint.x;
const dy = pt.y - clickPoint.y;
const d = Math.sqrt(dx*dx + dy*dy);
if (d < minPx) {
minPx = d;
nearest = p;
}
});
if (nearest) {
// show popup at the point
const popup = L.popup({ maxWidth: 300 })
.setLatLng([nearest.latitude, nearest.longitude])
.setContent(getNeedPointPopupContent(nearest.id));
popup.openOn(map);
}
}
function detectRenderingMode() {
try {
const cores = navigator.hardwareConcurrency || 2;
const dpr = window.devicePixelRatio || 1;
if (cores >= 8 && dpr <= 2) return 'high';
if (cores >= 4) return 'medium';
return 'low';
} catch (e) {
return 'medium';
}
}
function openNeedPointModal(coords) {
const modal = document.getElementById('need-point-modal');
const form = document.getElementById('need-point-form');
const latInput = document.getElementById('need-point-latitude');
const lngInput = document.getElementById('need-point-longitude');
const info = document.getElementById('need-point-form-info');
// Management household state and functions
let managementHouseholds = [];
let currentMgmtPage = 0;
const MGMT_PAGE_SIZE = 10;
function loadManagementHouseholds() {
fetch('api/get_households.php?limit=1000')
.then(r => r.json())
.then(data => {
if (data.success && Array.isArray(data.households)) {
managementHouseholds = data.households;
currentMgmtPage = 0;
renderManagementHouseholdPage(0);
} else {
managementHouseholds = [];
renderManagementHouseholdPage(0);
}
})
.catch(err => {
console.error('Error loading management households', err);
managementHouseholds = [];
renderManagementHouseholdPage(0);
});
}
function renderManagementHouseholdPage(pageIndex) {
if (!Array.isArray(managementHouseholds)) managementHouseholds = [];
const total = managementHouseholds.length;
const maxPage = Math.max(0, Math.ceil(total / MGMT_PAGE_SIZE) - 1);
if (pageIndex < 0) pageIndex = 0;
if (pageIndex > maxPage) pageIndex = maxPage;
currentMgmtPage = pageIndex;
const start = pageIndex * MGMT_PAGE_SIZE;
const slice = managementHouseholds.slice(start, start + MGMT_PAGE_SIZE);
const container = document.getElementById('management-household-list');
const pager = document.getElementById('management-household-pager');
if (!container) return;
container.innerHTML = '';
if (slice.length === 0) {
container.innerHTML = '<div style="color: rgba(255,255,255,0.7); padding: 12px;">No households available</div>';
} else {
slice.forEach(h => {
const div = document.createElement('div');
div.className = 'management-household-item';
const code = escapeHtml(h.household_code || '-');
const head = escapeHtml(h.head_name || '-');
const status = escapeHtml(h.verification_status || '-');
const meta = `Lat: ${Number(h.latitude||0).toFixed(4)} Lng: ${Number(h.longitude||0).toFixed(4)}`;
div.innerHTML = `<div class="hh-title">${code}${head}</div><div class="hh-meta">${meta}</div><div class="hh-meta">Status: ${status}</div>`;
div.onclick = () => { openHouseholdModal(); };
container.appendChild(div);
});
}
if (pager) pager.textContent = `Page ${pageIndex + 1} / ${maxPage + 1}${total} households`;
}
if (!modal || !form || !latInput || !lngInput) {
return;
}
form.reset();
if (coords) {
latInput.value = coords.lat.toFixed(6);
lngInput.value = coords.lng.toFixed(6);
latInput.readOnly = true;
lngInput.readOnly = true;
if (info) {
info.textContent = 'Koordinat point berkebutuhan diisi otomatis dari klik peta.';
info.classList.add('show');
}
} else {
latInput.value = '';
lngInput.value = '';
latInput.readOnly = false;
lngInput.readOnly = false;
if (info) {
info.classList.remove('show');
}
}
modal.classList.add('show');
document.getElementById('need-point-name').focus();
}
function closeNeedPointModal() {
const modal = document.getElementById('need-point-modal');
if (modal) {
modal.classList.remove('show');
}
}
function submitNeedPointForm() {
const name = document.getElementById('need-point-name').value.trim();
const latitude = parseFloat(document.getElementById('need-point-latitude').value);
const longitude = parseFloat(document.getElementById('need-point-longitude').value);
const householdName = document.getElementById('need-point-household') ? document.getElementById('need-point-household').value.trim() : '';
const headName = document.getElementById('need-point-head') ? document.getElementById('need-point-head').value.trim() : '';
const economicStatus = document.getElementById('need-point-economic') ? document.getElementById('need-point-economic').value : '';
if (!name) {
showNotification('Nama point berkebutuhan wajib diisi', true);
return;
}
if (isNaN(latitude) || latitude < -90 || latitude > 90) {
showNotification('Latitude point berkebutuhan tidak valid', true);
return;
}
if (isNaN(longitude) || longitude < -180 || longitude > 180) {
showNotification('Longitude point berkebutuhan tidak valid', true);
return;
}
if (!findNearestCoveringMasjidLocal(latitude, longitude)) {
showNotification('Point berkebutuhan harus berada dalam radius masjid', true);
return;
}
addNeedPoint(name, latitude, longitude, householdName, headName, economicStatus);
}
function addNeedPoint(name, latitude, longitude) {
// legacy signature support: addNeedPoint(name, lat, lng, household, head, economic)
const household = arguments.length >= 4 ? arguments[3] : '';
const head = arguments.length >= 5 ? arguments[4] : '';
const economic = arguments.length >= 6 ? arguments[5] : '';
fetch('api/save_need_point.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: name,
latitude: latitude,
longitude: longitude,
household_name: household,
head_name: head,
economic_status: economic
})
})
.then(response => response.json())
.then(data => {
if (data.success && data.point) {
// add to local cache and re-render visible points
const p = normalizeNeedPointRecord(data.point);
allNeedPoints.push(p);
renderVisibleNeedPoints();
closeNeedPointModal();
showNotification('✓ Point berkebutuhan berhasil ditambahkan');
} else {
showNotification('Error adding point berkebutuhan: ' + data.error, true);
}
})
.catch(error => {
console.error('Error:', error);
showNotification('Error adding point berkebutuhan', true);
});
}
function editNeedPoint(id) {
if (!needPointData[id]) {
showNotification('Data point berkebutuhan tidak ditemukan', true);
return;
}
const point = needPointData[id];
currentEditingNeedPointId = id;
document.getElementById('edit-need-point-name').value = point.name;
document.getElementById('edit-need-point-latitude').value = point.latitude;
document.getElementById('edit-need-point-longitude').value = point.longitude;
document.getElementById('edit-need-point-masjid').value = point.masjid_name || '-';
// populate new household fields
const hh = point.household_name || '';
const head = point.head_name || '';
const econ = point.economic_status || 'rentan';
const hhInput = document.getElementById('edit-need-point-household');
const headInput = document.getElementById('edit-need-point-head');
const econInput = document.getElementById('edit-need-point-economic');
if (hhInput) hhInput.value = hh;
if (headInput) headInput.value = head;
if (econInput) econInput.value = econ;
const modal = document.getElementById('edit-need-point-modal');
modal.classList.add('show');
document.getElementById('edit-need-point-name').focus();
}
function closeEditNeedPointModal() {
const modal = document.getElementById('edit-need-point-modal');
if (modal) {
modal.classList.remove('show');
}
currentEditingNeedPointId = null;
}
function saveNeedPointEdit() {
if (!currentEditingNeedPointId) {
showNotification('Tidak ada point berkebutuhan yang dipilih', true);
return;
}
const name = document.getElementById('edit-need-point-name').value.trim();
const latitude = parseFloat(document.getElementById('edit-need-point-latitude').value);
const longitude = parseFloat(document.getElementById('edit-need-point-longitude').value);
if (!name) {
showNotification('Nama point berkebutuhan wajib diisi', true);
return;
}
if (isNaN(latitude) || latitude < -90 || latitude > 90) {
showNotification('Latitude point berkebutuhan tidak valid', true);
return;
}
if (isNaN(longitude) || longitude < -180 || longitude > 180) {
showNotification('Longitude point berkebutuhan tidak valid', true);
return;
}
if (!findNearestCoveringMasjidLocal(latitude, longitude)) {
showNotification('Point berkebutuhan harus berada dalam radius masjid', true);
return;
}
const householdName = document.getElementById('edit-need-point-household') ? document.getElementById('edit-need-point-household').value.trim() : '';
const headName = document.getElementById('edit-need-point-head') ? document.getElementById('edit-need-point-head').value.trim() : '';
const economicStatus = document.getElementById('edit-need-point-economic') ? document.getElementById('edit-need-point-economic').value : '';
fetch('api/update_need_point.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
id: currentEditingNeedPointId,
name: name,
latitude: latitude,
longitude: longitude,
household_name: householdName,
head_name: headName,
economic_status: economicStatus
})
})
.then(response => response.json())
.then(data => {
if (data.success && data.point) {
applyNeedPointRecord(data.point);
closeEditNeedPointModal();
showNotification('✓ Point berkebutuhan berhasil diperbarui');
} else {
showNotification('Error updating point berkebutuhan: ' + data.error, true);
}
})
.catch(error => {
console.error('Error:', error);
showNotification('Error updating point berkebutuhan', true);
});
}
function deleteNeedPoint(id) {
if (!confirm('Apakah Anda yakin ingin menghapus point berkebutuhan ini?')) {
return;
}
fetch('api/delete_need_point.php', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ id: id })
})
.then(response => response.json())
.then(data => {
if (data.success) {
removeNeedPointsByIds([id]);
showNotification('✓ Point berkebutuhan berhasil dihapus');
} else {
showNotification('Error deleting point berkebutuhan: ' + data.error, true);
}
})
.catch(error => {
console.error('Error:', error);
showNotification('Error deleting point berkebutuhan', true);
});
}
// ============ HOUSEHOLD / ASSISTANCE / VERIFICATION (PHASE A) ============
// Toggle household panel visibility in peta tab
function toggleHouseholdPanel() {
const panel = document.getElementById('household-panel-wrapper');
if (panel) {
panel.classList.toggle('visible');
}
}
function refreshHouseholdPanel() {
loadHouseholdsForPanel();
loadHouseholdSummary();
loadHouseholdScoreSummary();
loadHouseholdPriorityRanking();
}
function openHouseholdModal() {
const modal = document.getElementById('household-modal');
const form = document.getElementById('household-form');
const latInput = document.getElementById('household-latitude');
const lngInput = document.getElementById('household-longitude');
if (!modal || !form) {
return;
}
form.reset();
if (latInput && lngInput && map) {
const center = map.getCenter();
latInput.value = center.lat.toFixed(6);
lngInput.value = center.lng.toFixed(6);
}
modal.classList.add('show');
const headNameInput = document.getElementById('household-head-name');
if (headNameInput) {
headNameInput.focus();
}
}
function closeHouseholdModal() {
const modal = document.getElementById('household-modal');
if (modal) {
modal.classList.remove('show');
}
}
function openAssistanceModal(preselectedHouseholdId = null) {
// Always populate household select options; if none exist the form will still open
populateHouseholdSelectOptions();
const modal = document.getElementById('assistance-modal');
const form = document.getElementById('assistance-form');
const dateInput = document.getElementById('assistance-date');
if (!modal || !form) {
return;
}
form.reset();
if (dateInput) {
dateInput.value = new Date().toISOString().slice(0, 10);
}
const householdSelect = document.getElementById('assistance-household-id');
if (householdSelect && preselectedHouseholdId) {
householdSelect.value = String(preselectedHouseholdId);
}
updateAssistanceModalSummary();
modal.classList.add('show');
if (householdSelect) {
householdSelect.focus();
}
}
function updateAssistanceModalSummary() {
const select = document.getElementById('assistance-household-id');
const labelEl = document.getElementById('assistance-selected-household-label');
const metaEl = document.getElementById('assistance-selected-household-meta');
const selectedId = select ? String(select.value || '') : '';
const selectedHousehold = Array.isArray(households)
? households.find(item => String(item.id) === selectedId)
: null;
if (labelEl) {
labelEl.textContent = selectedHousehold
? `${selectedHousehold.household_code || 'HH'} - ${selectedHousehold.head_name || 'Tanpa nama'}`
: 'Pilih household';
}
if (metaEl) {
metaEl.textContent = selectedHousehold
? `${selectedHousehold.verification_status || 'unverified'}${selectedHousehold.latitude || '-'}, ${selectedHousehold.longitude || '-'}`
: 'Data akan terisi otomatis jika tersedia.';
}
}
function createHouseholdFromNeedPoint(point) {
return new Promise((resolve, reject) => {
if (!point || !point.latitude || !point.longitude) {
reject(new Error('Invalid need point data'));
return;
}
const headName = point.head_name || point.household_name || ('Keluarga ' + Math.random().toString(16).slice(2,8));
const payload = {
head_name: headName,
latitude: Number(point.latitude),
longitude: Number(point.longitude),
need_point_id: Number(point.id || 0),
economic_status: point.economic_status || 'unknown',
verification_status: Number(point.is_verified || 0) === 1 ? 'field_verified' : 'unverified'
};
fetch('api/save_household.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
.then(r => r.json())
.then(data => {
if (data && data.success && data.household) {
// Insert into local households cache for immediate use
households = households || [];
households.unshift(data.household);
resolve(data.household);
} else {
reject(new Error(data && data.error ? data.error : 'Failed to create household'));
}
})
.catch(err => reject(err));
});
}
function closeAssistanceModal() {
const modal = document.getElementById('assistance-modal');
if (modal) {
modal.classList.remove('show');
}
}
function openVerificationModal() {
if (!Array.isArray(households) || households.length === 0) {
showNotification('Belum ada household. Tambahkan household terlebih dahulu.', true);
return;
}
populateHouseholdSelectOptions();
const modal = document.getElementById('verification-modal');
const form = document.getElementById('verification-form');
if (!modal || !form) {
return;
}
form.reset();
modal.classList.add('show');
const householdSelect = document.getElementById('verification-household-id');
if (householdSelect) {
householdSelect.focus();
}
}
function closeVerificationModal() {
const modal = document.getElementById('verification-modal');
if (modal) {
modal.classList.remove('show');
}
}
function loadHouseholdsForPanel() {
const statusFilter = document.getElementById('household-filter-status');
const selectedStatus = statusFilter ? statusFilter.value : '';
let url = 'api/get_households.php?limit=200';
if (selectedStatus) {
url += '&verification_status=' + encodeURIComponent(selectedStatus);
}
fetch(url)
.then(response => response.json())
.then(data => {
if (data.success && Array.isArray(data.households)) {
households = data.households;
renderHouseholdList(households);
populateHouseholdSelectOptions();
} else {
households = [];
renderHouseholdList([]);
}
})
.catch(error => {
console.error('Error loading households:', error);
households = [];
renderHouseholdList([]);
showNotification('Error loading household data', true);
});
}
function loadHouseholdSummary() {
fetch('api/get_household_summary.php')
.then(response => response.json())
.then(data => {
if (data.success && data.summary) {
householdSummary = data.summary;
updateHouseholdSummaryUI(householdSummary);
}
})
.catch(error => {
console.error('Error loading household summary:', error);
});
}
function updateHouseholdSummaryUI(summary) {
const totalHouseholds = Number(summary?.totals?.total_households || 0);
const assistedHouseholds = Number(summary?.assistance?.assisted_households || 0);
const verifiedCount = Number(summary?.totals?.field_verified_count || 0) + Number(summary?.totals?.admin_approved_count || 0);
const totalEl = document.getElementById('household-total-count');
const assistedEl = document.getElementById('household-assisted-count');
const verifiedEl = document.getElementById('household-verified-count');
if (totalEl) totalEl.textContent = totalHouseholds;
if (assistedEl) assistedEl.textContent = assistedHouseholds;
if (verifiedEl) verifiedEl.textContent = verifiedCount;
}
function populateHouseholdSelectOptions() {
const selectIds = ['assistance-household-id', 'verification-household-id'];
selectIds.forEach(selectId => {
const select = document.getElementById(selectId);
if (!select) {
return;
}
const previousValue = select.value;
let options = '<option value="">Pilih household...</option>';
households.forEach(household => {
const id = Number(household.id);
const code = household.household_code || 'HH';
const headName = household.head_name || '-';
const label = escapeHtml(String(code + ' - ' + headName));
options += `<option value="${id}">${label}</option>`;
});
select.innerHTML = options;
if (previousValue && households.some(h => String(h.id) === String(previousValue))) {
select.value = previousValue;
}
});
}
function getVerificationStatusLabel(status) {
const labels = {
unverified: 'Unverified',
field_verified: 'Field Verified',
admin_approved: 'Approved',
needs_review: 'Needs Review',
rejected: 'Rejected'
};
return labels[status] || status;
}
function formatRupiah(value) {
const numberValue = Number(value || 0);
return 'Rp ' + new Intl.NumberFormat('id-ID').format(Math.round(numberValue));
}
function renderHouseholdList(householdItems) {
const listContainer = document.getElementById('households-list');
if (!listContainer) {
return;
}
if (!Array.isArray(householdItems) || householdItems.length === 0) {
listContainer.innerHTML = '<div class="household-empty">Belum ada data household.</div>';
return;
}
const html = householdItems.slice(0, 30).map(household => {
const status = String(household.verification_status || 'unverified');
const statusClass = status.replace(/[^a-z_]/g, '');
const statusLabel = escapeHtml(getVerificationStatusLabel(status));
const householdCode = escapeHtml(String(household.household_code || '-'));
const headName = escapeHtml(String(household.head_name || '-'));
const incomeLabel = escapeHtml(formatRupiah(household.monthly_income));
const dependentsLabel = Number(household.dependents_count || 0);
const assistanceCountLabel = Number(household.assistance_count || 0);
const masjidName = escapeHtml(String(household.masjid_name || '-'));
return `
<div class="household-item">
<div class="household-item-header">
<span class="household-code">${householdCode}</span>
<span class="household-status ${statusClass}">${statusLabel}</span>
</div>
<div class="household-name">${headName}</div>
<div class="household-meta">
<span>Pendapatan: ${incomeLabel}</span>
<span>Tanggungan: ${dependentsLabel}</span>
<span>Bantuan tercatat: ${assistanceCountLabel}</span>
<span>Masjid: ${masjidName}</span>
</div>
</div>
`;
}).join('');
listContainer.innerHTML = html;
}
function submitHouseholdForm() {
const code = document.getElementById('household-code').value.trim();
const headName = document.getElementById('household-head-name').value.trim();
const nik = document.getElementById('household-nik').value.trim();
const latitudeRaw = document.getElementById('household-latitude').value;
const longitudeRaw = document.getElementById('household-longitude').value;
const monthlyIncomeRaw = document.getElementById('household-monthly-income').value;
const dependentsRaw = document.getElementById('household-dependents-count').value;
const housingScoreRaw = document.getElementById('household-housing-score').value;
const employmentStatus = document.getElementById('household-employment-status').value;
const verificationStatus = document.getElementById('household-verification-status').value;
const vulnerabilityNotes = document.getElementById('household-vulnerability-notes').value.trim();
const latitude = parseFloat(latitudeRaw);
const longitude = parseFloat(longitudeRaw);
if (!headName) {
showNotification('Nama kepala keluarga wajib diisi', true);
return;
}
if (isNaN(latitude) || latitude < -90 || latitude > 90) {
showNotification('Latitude tidak valid', true);
return;
}
if (isNaN(longitude) || longitude < -180 || longitude > 180) {
showNotification('Longitude tidak valid', true);
return;
}
const payload = {
head_name: headName,
latitude: latitude,
longitude: longitude,
monthly_income: monthlyIncomeRaw === '' ? null : parseFloat(monthlyIncomeRaw),
dependents_count: dependentsRaw === '' ? null : parseInt(dependentsRaw, 10),
housing_condition_score: housingScoreRaw === '' ? null : parseInt(housingScoreRaw, 10),
employment_status: employmentStatus,
verification_status: verificationStatus,
vulnerability_notes: vulnerabilityNotes || null
};
if (code) payload.household_code = code;
if (nik) payload.nik = nik;
fetch('api/save_household.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload)
})
.then(response => response.json())
.then(data => {
if (data.success) {
closeHouseholdModal();
refreshHouseholdPanel();
showNotification('✓ Household berhasil ditambahkan');
} else {
showNotification('Error menambah household: ' + (data.error || 'unknown error'), true);
}
})
.catch(error => {
console.error('Error:', error);
showNotification('Error menambah household', true);
});
}
function submitAssistanceForm() {
const householdIdRaw = document.getElementById('assistance-household-id').value;
const assistanceType = document.getElementById('assistance-type').value;
const assistanceValueRaw = document.getElementById('assistance-value').value;
const assistanceFrequency = document.getElementById('assistance-frequency').value;
const distributionDate = document.getElementById('assistance-date').value;
const deliveryStatus = document.getElementById('assistance-delivery-status').value;
const deliveredBy = document.getElementById('assistance-delivered-by').value.trim();
const notes = document.getElementById('assistance-notes').value.trim();
if (!householdIdRaw || isNaN(parseInt(householdIdRaw, 10))) {
showNotification('Household wajib dipilih', true);
return;
}
if (!distributionDate) {
showNotification('Tanggal distribusi wajib diisi', true);
return;
}
const payload = {
household_id: parseInt(householdIdRaw, 10),
assistance_type: assistanceType,
assistance_value: assistanceValueRaw === '' ? 0 : parseFloat(assistanceValueRaw),
assistance_frequency: assistanceFrequency,
distribution_date: distributionDate,
delivery_status: deliveryStatus,
delivered_by: deliveredBy || null,
notes: notes || null
};
fetch('api/save_assistance_distribution.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload)
})
.then(response => response.json())
.then(data => {
if (data.success) {
closeAssistanceModal();
refreshHouseholdPanel();
showNotification('✓ Data bantuan berhasil disimpan');
} else {
showNotification('Error menyimpan bantuan: ' + (data.error || 'unknown error'), true);
}
})
.catch(error => {
console.error('Error:', error);
showNotification('Error menyimpan bantuan', true);
});
}
function submitVerificationForm() {
const householdIdRaw = document.getElementById('verification-household-id').value;
const verificationStatus = document.getElementById('verification-status').value;
const verifierRole = document.getElementById('verification-verifier-role').value.trim();
const verifierName = document.getElementById('verification-verifier-name').value.trim();
const evidenceCountRaw = document.getElementById('verification-evidence-count').value;
const confidenceScoreRaw = document.getElementById('verification-confidence-score').value;
const fieldNote = document.getElementById('verification-field-note').value.trim();
if (!householdIdRaw || isNaN(parseInt(householdIdRaw, 10))) {
showNotification('Household wajib dipilih', true);
return;
}
if (!verifierName) {
showNotification('Nama verifikator wajib diisi', true);
return;
}
if (confidenceScoreRaw !== '') {
const confidence = parseFloat(confidenceScoreRaw);
if (isNaN(confidence) || confidence < 0 || confidence > 1) {
showNotification('Skor kepercayaan data harus di antara 0 sampai 1', true);
return;
}
}
const payload = {
household_id: parseInt(householdIdRaw, 10),
verification_status: verificationStatus,
verifier_name: verifierName,
verifier_role: verifierRole || null,
evidence_photo_count: evidenceCountRaw === '' ? 0 : parseInt(evidenceCountRaw, 10),
data_confidence_score: confidenceScoreRaw === '' ? null : parseFloat(confidenceScoreRaw),
field_note: fieldNote || null
};
fetch('api/save_verification_log.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload)
})
.then(response => response.json())
.then(data => {
if (data.success) {
closeVerificationModal();
refreshHouseholdPanel();
showNotification('✓ Verifikasi berhasil disimpan');
} else {
showNotification('Error menyimpan verifikasi: ' + (data.error || 'unknown error'), true);
}
})
.catch(error => {
console.error('Error:', error);
showNotification('Error menyimpan verifikasi', true);
});
}
function computeHouseholdScores() {
fetch('api/compute_household_scores.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({})
})
.then(response => response.json())
.then(data => {
if (data.success) {
const computedCount = Number(data.computed_count || 0);
loadHouseholdScoreSummary();
loadManagementScorePreview();
loadHouseholdPriorityRanking();
showNotification('✓ Skor prioritas dihitung untuk ' + computedCount + ' household');
} else {
showNotification('Error menghitung skor: ' + (data.error || 'unknown error'), true);
}
})
.catch(error => {
console.error('Error:', error);
showNotification('Error menghitung skor prioritas', true);
});
}
function loadHouseholdScoreSummary() {
fetch('api/get_household_score_summary.php')
.then(response => response.json())
.then(data => {
if (!data.success || !data.summary) {
return;
}
const counts = data.summary.priority_counts || {};
const assistedCount = Number(data.summary.assisted_households || 0);
const assistedEl = document.getElementById('priority-assisted-count');
const veryHighEl = document.getElementById('priority-very-high-count');
const highEl = document.getElementById('priority-high-count');
const mediumEl = document.getElementById('priority-medium-count');
const lowEl = document.getElementById('priority-low-count');
if (assistedEl) assistedEl.textContent = assistedCount;
if (veryHighEl) veryHighEl.textContent = Number(counts.very_high || 0);
if (highEl) highEl.textContent = Number(counts.high || 0);
if (mediumEl) mediumEl.textContent = Number(counts.medium || 0);
if (lowEl) lowEl.textContent = Number(counts.low || 0);
})
.catch(error => {
console.error('Error loading household score summary:', error);
});
}
function loadHouseholdPriorityRanking() {
fetch('api/get_household_ranking.php?limit=10')
.then(response => response.json())
.then(data => {
if (data.success && Array.isArray(data.ranking)) {
renderHouseholdPriorityList(data.ranking);
}
})
.catch(error => {
console.error('Error loading household ranking:', error);
});
}
function getPriorityLevelLabel(level) {
const labels = {
very_high: 'Very High',
high: 'High',
medium: 'Medium',
low: 'Low'
};
return labels[level] || level;
}
function renderHouseholdPriorityList(rankingItems) {
const container = document.getElementById('household-priority-list');
if (!container) {
return;
}
if (!Array.isArray(rankingItems) || rankingItems.length === 0) {
container.innerHTML = '<div class="household-empty">Belum ada data skor prioritas.</div>';
return;
}
const html = rankingItems.map(item => {
const priorityLevel = String(item.priority_level || 'low');
const priorityClass = priorityLevel.replace(/[^a-z_]/g, '');
const priorityLabel = escapeHtml(getPriorityLevelLabel(priorityLevel));
const householdCode = escapeHtml(String(item.household_code || '-'));
const headName = escapeHtml(String(item.head_name || '-'));
const spiValue = Number(item.spi || 0).toFixed(3);
const assisted = Number(item.assisted || 0) === 1;
const statusLabel = assisted ? 'Assisted' : priorityLabel;
const statusClass = assisted ? 'assisted' : priorityClass;
const nameClass = assisted ? 'priority-item-name assisted-point-name' : 'priority-item-name';
return `
<div class="priority-item">
<div class="priority-item-main">
<span class="priority-item-code">${householdCode}</span>
<span class="${nameClass}">${headName}</span>
</div>
<div class="priority-item-main" style="text-align: right; align-items: flex-end;">
<span class="priority-item-score">SPI ${spiValue}</span>
<span class="priority-item-status ${statusClass}">${statusLabel}</span>
</div>
</div>
`;
}).join('');
container.innerHTML = html;
}
function showNotification(message, isError = false) {
const notification = document.createElement('div');
notification.className = 'notification' + (isError ? ' error' : '');
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.remove();
}, 3000);
}
function escapeHtml(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, m => map[m]);
}
// ============ LINES/ROADS MANAGEMENT ============
function calculatePolylineLength(coordinates) {
let totalDistance = 0;
for (let i = 0; i < coordinates.length - 1; i++) {
const latlng1 = L.latLng(coordinates[i][0], coordinates[i][1]);
const latlng2 = L.latLng(coordinates[i + 1][0], coordinates[i + 1][1]);
totalDistance += latlng1.distanceTo(latlng2);
}
return Math.round(totalDistance * 100) / 100;
}
function loadLines() {
fetch('api/get_lines.php')
.then(response => response.json())
.then(data => {
if (data.success && data.lines) {
// Clear existing lines
Object.keys(lines).forEach(id => {
if (map.hasLayer(lines[id])) {
map.removeLayer(lines[id]);
}
});
lines = {};
lineCount = 0;
// Load new lines
data.lines.forEach(line => {
displayLine(
line.id,
line.name,
line.road_type,
line.coordinates,
line.length_meters
);
});
updateLineCount();
}
})
.catch(error => console.error('Error loading lines:', error));
}
function displayLine(id, name, road_type, coordinates, length_meters, condition_status = 'normal') {
lineData[id] = {
id: id,
name: name,
road_type: road_type,
coordinates: coordinates,
length_meters: length_meters,
condition_status: condition_status
};
const baseStyle = lineStyles[road_type] || lineStyles['Nasional'];
const finalStyle = applyConditionStyle(baseStyle, condition_status);
const polyline = L.polyline(coordinates, finalStyle).addTo(map);
const conditionLabel = condition_status.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase());
const conditionIcon = condition_status === 'normal' ? '✓' : (condition_status === 'minor_damage' ? '⚠️' : '🔴');
const popupContent = `
<div class="feature-popup">
<div class="feature-popup-name">${escapeHtml(name)}</div>
<div class="feature-popup-type">🛣️ ${road_type}</div>
<div class="feature-popup-condition">${conditionIcon} Condition: ${conditionLabel}</div>
<div class="feature-popup-info">Length: ${(length_meters/1000).toFixed(2)} km</div>
<div class="feature-popup-info">Points: ${coordinates.length}</div>
<div class="feature-popup-actions">
<button onclick="editLine(${id})">Edit</button>
<button onclick="deleteLine(${id})">Delete</button>
</div>
</div>
`;
polyline.bindPopup(popupContent);
lines[id] = polyline;
lineCount++;
}
function applyConditionStyle(baseStyle, condition) {
const conditionStyles = {
'normal': { dashArray: null, opacity: 0.8 },
'minor_damage': { dashArray: '5, 5', opacity: 0.7 },
'major_damage': { color: '#d32f2f', dashArray: '2, 3', opacity: 0.85 }
};
return { ...baseStyle, ...conditionStyles[condition] };
}
function updateLineCount() {
const countElement = document.getElementById('line-count');
if (countElement) {
countElement.textContent = lineCount;
}
}
function addLine(name, road_type, coordinates, length_meters) {
fetch('api/save_line.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: name,
road_type: road_type,
coordinates: coordinates,
length_meters: length_meters
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
displayLine(data.id, data.name, data.road_type, data.coordinates, data.length_meters);
updateLineCount();
showNotification('✓ Road added successfully');
closeLineModal();
} else {
showNotification('Error adding road: ' + data.error, true);
}
})
.catch(error => {
console.error('Error:', error);
showNotification('Error adding road', true);
});
}
function editLine(id) {
if (!lineData[id]) {
showNotification('Line data not found', true);
return;
}
const data = lineData[id];
document.getElementById('edit-line-name').value = data.name;
document.getElementById('edit-line-road-type').value = data.road_type;
document.getElementById('edit-line-length').value = (data.length_meters / 1000).toFixed(2);
document.getElementById('edit-line-points').value = data.coordinates.length;
document.getElementById('edit-line-condition').value = data.condition_status || 'normal';
document.getElementById('current-editing-line-id').value = id;
const editModal = document.getElementById('edit-line-modal');
editModal.classList.add('show');
document.getElementById('edit-line-name').focus();
}
function closeLineModal() {
const modal = document.getElementById('line-modal');
modal.classList.remove('show');
// Clean up drawing mode
drawMode.active = false;
drawMode.type = null;
drawMode.coordinates = [];
if (drawMode.temporaryLayer) {
map.removeLayer(drawMode.temporaryLayer);
drawMode.temporaryLayer = null;
}
// Remove temporary markers
drawMode.tempMarkers.forEach(marker => {
map.removeLayer(marker);
});
drawMode.tempMarkers = [];
// Restore default click handler
if (drawMode.defaultClickHandler) {
map.on('click', drawMode.defaultClickHandler);
}
}
function closeEditLineModal() {
const editModal = document.getElementById('edit-line-modal');
editModal.classList.remove('show');
}
function saveLineEdit() {
const id = parseInt(document.getElementById('current-editing-line-id').value);
const name = document.getElementById('edit-line-name').value.trim();
const road_type = document.getElementById('edit-line-road-type').value;
const condition_status = document.getElementById('edit-line-condition').value;
if (!name) {
showNotification('Please enter a name for the road', true);
return;
}
fetch('api/update_line.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
id: id,
name: name,
road_type: road_type,
condition_status: condition_status
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
const line = data.line;
lineData[id].name = line.name;
lineData[id].road_type = line.road_type;
lineData[id].condition_status = line.condition_status;
const baseStyle = lineStyles[line.road_type] || lineStyles['Nasional'];
const finalStyle = applyConditionStyle(baseStyle, line.condition_status);
lines[id].setStyle(finalStyle);
closeEditLineModal();
showNotification('✓ Road updated successfully');
} else {
showNotification('Error updating road: ' + data.error, true);
}
})
.catch(error => {
console.error('Error:', error);
showNotification('Error updating road', true);
});
}
function deleteLine(id) {
if (!confirm('Are you sure you want to delete this road?')) {
return;
}
fetch('api/delete_line.php', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ id: id })
})
.then(response => response.json())
.then(data => {
if (data.success) {
if (lines[id] && map.hasLayer(lines[id])) {
map.removeLayer(lines[id]);
delete lines[id];
delete lineData[id];
lineCount--;
updateLineCount();
showNotification('✓ Road deleted');
}
} else {
showNotification('Error deleting road: ' + data.error, true);
}
})
.catch(error => {
console.error('Error:', error);
showNotification('Error deleting road', true);
});
}
// ============ POLYGONS/LAND PARCELS MANAGEMENT ============
function calculatePolygonArea(coordinates) {
// Close the polygon if not already closed
const closed = (coordinates[coordinates.length - 1][0] === coordinates[0][0] &&
coordinates[coordinates.length - 1][1] === coordinates[0][1])
? coordinates : [...coordinates, coordinates[0]];
// Shoelace formula
let area = 0;
for (let i = 0; i < closed.length - 1; i++) {
const lat1 = closed[i][0], lng1 = closed[i][1];
const lat2 = closed[i + 1][0], lng2 = closed[i + 1][1];
area += (lng2 - lng1) * (lat2 + lat1) / 2;
}
// Convert from degrees to approximate square meters
const metersPerDegree = 111320;
const areaSqMeters = Math.abs(area) * metersPerDegree * metersPerDegree;
return Math.round(areaSqMeters * 100) / 100;
}
function loadPolygons() {
fetch('api/get_polygons.php')
.then(response => response.json())
.then(data => {
if (data.success && data.polygons) {
// Clear existing polygons
Object.keys(polygons).forEach(id => {
if (map.hasLayer(polygons[id])) {
map.removeLayer(polygons[id]);
}
});
polygons = {};
polygonCount = 0;
// Load new polygons
data.polygons.forEach(polygon => {
displayPolygon(
polygon.id,
polygon.name,
polygon.certificate_type,
polygon.coordinates,
polygon.area_sqm
);
});
updatePolygonCount();
}
})
.catch(error => console.error('Error loading polygons:', error));
}
function displayPolygon(id, name, certificate_type, coordinates, area_sqm) {
polygonData[id] = {
id: id,
name: name,
certificate_type: certificate_type,
coordinates: coordinates,
area_sqm: area_sqm
};
const style = polygonStyles[certificate_type] || polygonStyles['SHM'];
const polygon = L.polygon(coordinates, style).addTo(map);
const popupContent = `
<div class="feature-popup">
<div class="feature-popup-name">${escapeHtml(name)}</div>
<div class="feature-popup-type">🏞️ ${certificate_type}</div>
<div class="feature-popup-info">Area: ${(area_sqm/10000).toFixed(2)} hectares</div>
<div class="feature-popup-info">Points: ${coordinates.length}</div>
<div class="feature-popup-actions">
<button onclick="editPolygon(${id})">Edit</button>
<button onclick="deletePolygon(${id})">Delete</button>
</div>
</div>
`;
polygon.bindPopup(popupContent);
polygons[id] = polygon;
polygonCount++;
}
function updatePolygonCount() {
const countElement = document.getElementById('polygon-count');
if (countElement) {
countElement.textContent = polygonCount;
}
}
function addPolygon(name, certificate_type, coordinates, area_sqm) {
fetch('api/save_polygon.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: name,
certificate_type: certificate_type,
coordinates: coordinates,
area_sqm: area_sqm
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
displayPolygon(data.id, data.name, data.certificate_type, data.coordinates, data.area_sqm);
updatePolygonCount();
showNotification('✓ Parcel added successfully');
closePolygonModal();
} else {
showNotification('Error adding parcel: ' + data.error, true);
}
})
.catch(error => {
console.error('Error:', error);
showNotification('Error adding parcel', true);
});
}
function editPolygon(id) {
if (!polygonData[id]) {
showNotification('Polygon data not found', true);
return;
}
const data = polygonData[id];
document.getElementById('edit-polygon-name').value = data.name;
document.getElementById('edit-polygon-cert-type').value = data.certificate_type;
document.getElementById('edit-polygon-area').value = (data.area_sqm / 10000).toFixed(2);
document.getElementById('edit-polygon-points').value = data.coordinates.length;
document.getElementById('current-editing-polygon-id').value = id;
const editModal = document.getElementById('edit-polygon-modal');
editModal.classList.add('show');
document.getElementById('edit-polygon-name').focus();
}
function closePolygonModal() {
const modal = document.getElementById('polygon-modal');
modal.classList.remove('show');
// Clean up drawing mode
drawMode.active = false;
drawMode.type = null;
drawMode.coordinates = [];
if (drawMode.temporaryLayer) {
map.removeLayer(drawMode.temporaryLayer);
drawMode.temporaryLayer = null;
}
// Remove temporary markers
drawMode.tempMarkers.forEach(marker => {
map.removeLayer(marker);
});
drawMode.tempMarkers = [];
// Restore default click handler
if (drawMode.defaultClickHandler) {
map.on('click', drawMode.defaultClickHandler);
}
}
function closeEditPolygonModal() {
const editModal = document.getElementById('edit-polygon-modal');
editModal.classList.remove('show');
}
function savePolygonEdit() {
const id = parseInt(document.getElementById('current-editing-polygon-id').value);
const name = document.getElementById('edit-polygon-name').value.trim();
const certificate_type = document.getElementById('edit-polygon-cert-type').value;
if (!name) {
showNotification('Please enter a name for the parcel', true);
return;
}
fetch('api/update_polygon.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
id: id,
name: name,
certificate_type: certificate_type
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
const polygon = data.polygon;
polygonData[id].name = polygon.name;
polygonData[id].certificate_type = polygon.certificate_type;
const newStyle = polygonStyles[polygon.certificate_type] || polygonStyles['SHM'];
polygons[id].setStyle(newStyle);
closeEditPolygonModal();
showNotification('✓ Parcel updated successfully');
} else {
showNotification('Error updating parcel: ' + data.error, true);
}
})
.catch(error => {
console.error('Error:', error);
showNotification('Error updating parcel', true);
});
}
function deletePolygon(id) {
if (!confirm('Are you sure you want to delete this parcel?')) {
return;
}
fetch('api/delete_polygon.php', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ id: id })
})
.then(response => response.json())
.then(data => {
if (data.success) {
if (polygons[id] && map.hasLayer(polygons[id])) {
map.removeLayer(polygons[id]);
delete polygons[id];
delete polygonData[id];
polygonCount--;
updatePolygonCount();
showNotification('✓ Parcel deleted');
}
} else {
showNotification('Error deleting parcel: ' + data.error, true);
}
})
.catch(error => {
console.error('Error:', error);
showNotification('Error deleting parcel', true);
});
}
// ============ DRAWING MODE ============
function enableLineDrawMode() {
if (drawMode.active) {
showNotification('Drawing mode already active', true);
return;
}
drawMode.active = true;
drawMode.type = 'line';
drawMode.coordinates = [];
drawMode.tempMarkers = [];
// Disable default point click handler
if (drawMode.defaultClickHandler) {
map.off('click', drawMode.defaultClickHandler);
}
showNotification('Click map points to draw road. Double-click to finish.');
document.getElementById('drawing-instructions').textContent = 'Click map points to draw road. Double-click to finish.';
document.getElementById('drawing-toolbar').classList.remove('hidden');
map.on('click', handleMapClickDuringDraw);
map.on('dblclick', completeLineDrawing);
}
function enablePolygonDrawMode() {
if (drawMode.active) {
showNotification('Drawing mode already active', true);
return;
}
drawMode.active = true;
drawMode.type = 'polygon';
drawMode.coordinates = [];
drawMode.tempMarkers = [];
// Disable default point click handler
if (drawMode.defaultClickHandler) {
map.off('click', drawMode.defaultClickHandler);
}
showNotification('Click map points to draw parcel. Right-click to finish.');
document.getElementById('drawing-instructions').textContent = 'Click map points to draw parcel. Right-click to finish.';
document.getElementById('drawing-toolbar').classList.remove('hidden');
map.on('click', handleMapClickDuringDraw);
map.on('contextmenu', completePolygonDrawing);
}
function handleMapClickDuringDraw(e) {
if (!drawMode.active) return;
// Prevent default behavior and opening point modal
L.DomEvent.stopPropagation(e);
L.DomEvent.preventDefault(e.originalEvent);
const latlng = e.latlng;
drawMode.coordinates.push([latlng.lat, latlng.lng]);
// Update point counter
document.getElementById('point-count').textContent = drawMode.coordinates.length;
// Draw temporary polyline or polygon
if (drawMode.type === 'line') {
if (drawMode.temporaryLayer) {
map.removeLayer(drawMode.temporaryLayer);
}
drawMode.temporaryLayer = L.polyline(drawMode.coordinates, {
color: '#667eea',
weight: 3,
opacity: 0.7,
dashArray: '5, 5'
}).addTo(map);
// Draw markers at each point
const marker = L.circleMarker([latlng.lat, latlng.lng], {
radius: 5,
fillColor: '#667eea',
color: '#fff',
weight: 2,
opacity: 1,
fillOpacity: 0.8
}).addTo(map);
drawMode.tempMarkers.push(marker);
} else if (drawMode.type === 'polygon') {
if (drawMode.temporaryLayer) {
map.removeLayer(drawMode.temporaryLayer);
}
drawMode.temporaryLayer = L.polygon(drawMode.coordinates, {
fillColor: '#667eea',
weight: 2,
opacity: 0.7,
color: '#667eea',
fillOpacity: 0.3
}).addTo(map);
// Draw markers at each point
const marker = L.circleMarker([latlng.lat, latlng.lng], {
radius: 5,
fillColor: '#667eea',
color: '#fff',
weight: 2,
opacity: 1,
fillOpacity: 0.8
}).addTo(map);
drawMode.tempMarkers.push(marker);
}
}
function completeLineDrawing() {
if (!drawMode.active || drawMode.type !== 'line') return;
map.off('click', handleMapClickDuringDraw);
map.off('dblclick', completeLineDrawing);
if (drawMode.coordinates.length < 2) {
showNotification('Need at least 2 points to create a road', true);
cancelDrawing();
return;
}
const length_meters = calculatePolylineLength(drawMode.coordinates);
document.getElementById('line-name').value = '';
document.getElementById('line-road-type').value = '';
document.getElementById('line-length').value = (length_meters / 1000).toFixed(2);
document.getElementById('line-points').value = drawMode.coordinates.length;
document.getElementById('line-coordinates').value = JSON.stringify(drawMode.coordinates);
document.getElementById('line-length-meters').value = length_meters;
// Clean up drawing state but keep active until modal closes
if (drawMode.temporaryLayer) {
map.removeLayer(drawMode.temporaryLayer);
drawMode.temporaryLayer = null;
}
// Remove temporary markers
drawMode.tempMarkers.forEach(marker => {
map.removeLayer(marker);
});
drawMode.tempMarkers = [];
document.getElementById('drawing-toolbar').classList.add('hidden');
document.getElementById('line-modal').classList.add('show');
}
function completePolygonDrawing(e) {
if (!drawMode.active || drawMode.type !== 'polygon') return;
if (e) {
L.DomEvent.preventDefault(e);
}
map.off('click', handleMapClickDuringDraw);
map.off('contextmenu', completePolygonDrawing);
if (drawMode.coordinates.length < 3) {
showNotification('Need at least 3 points to create a parcel', true);
cancelDrawing();
return;
}
const area_sqm = calculatePolygonArea(drawMode.coordinates);
document.getElementById('polygon-name').value = '';
document.getElementById('polygon-cert-type').value = '';
document.getElementById('polygon-area').value = (area_sqm / 10000).toFixed(2);
document.getElementById('polygon-points').value = drawMode.coordinates.length;
document.getElementById('polygon-coordinates').value = JSON.stringify(drawMode.coordinates);
document.getElementById('polygon-area-sqm').value = area_sqm;
// Clean up drawing state but keep active until modal closes
if (drawMode.temporaryLayer) {
map.removeLayer(drawMode.temporaryLayer);
drawMode.temporaryLayer = null;
}
// Remove temporary markers
drawMode.tempMarkers.forEach(marker => {
map.removeLayer(marker);
});
drawMode.tempMarkers = [];
document.getElementById('drawing-toolbar').classList.add('hidden');
document.getElementById('polygon-modal').classList.add('show');
}
function cancelDrawing() {
map.off('click', handleMapClickDuringDraw);
map.off('dblclick', completeLineDrawing);
map.off('contextmenu', completePolygonDrawing);
if (drawMode.temporaryLayer) {
map.removeLayer(drawMode.temporaryLayer);
drawMode.temporaryLayer = null;
}
// Remove temporary markers
drawMode.tempMarkers.forEach(marker => {
map.removeLayer(marker);
});
drawMode.tempMarkers = [];
drawMode.active = false;
drawMode.type = null;
drawMode.coordinates = [];
// Restore default click handler for point modal
if (drawMode.defaultClickHandler) {
map.on('click', drawMode.defaultClickHandler);
}
document.getElementById('drawing-toolbar').classList.add('hidden');
showNotification('Drawing cancelled');
}
// Helper to complete current drawing depending on active draw type
function completeDrawing() {
if (!drawMode || !drawMode.active) return;
if (drawMode.type === 'line') {
completeLineDrawing();
} else if (drawMode.type === 'polygon') {
completePolygonDrawing();
}
}
function submitLineForm() {
const name = document.getElementById('line-name').value.trim();
const road_type = document.getElementById('line-road-type').value;
const coordinates = JSON.parse(document.getElementById('line-coordinates').value);
const length_meters = parseFloat(document.getElementById('line-length-meters').value);
if (!name) {
showNotification('Please enter a name for the road', true);
return;
}
if (!road_type) {
showNotification('Please select a road type', true);
return;
}
addLine(name, road_type, coordinates, length_meters);
}
function submitPolygonForm() {
const name = document.getElementById('polygon-name').value.trim();
const certificate_type = document.getElementById('polygon-cert-type').value;
const coordinates = JSON.parse(document.getElementById('polygon-coordinates').value);
const area_sqm = parseFloat(document.getElementById('polygon-area-sqm').value);
if (!name) {
showNotification('Please enter a name for the parcel', true);
return;
}
if (!certificate_type) {
showNotification('Please select a certificate type', true);
return;
}
addPolygon(name, certificate_type, coordinates, area_sqm);
}
// ============ SEARCH & FILTER FOR ROADS ============
function applyRoadsFilter() {
const search = document.getElementById('roads-search').value;
const dateFrom = document.getElementById('roads-date-from').value;
const dateTo = document.getElementById('roads-date-to').value;
const condition = document.getElementById('roads-condition').value;
const params = new URLSearchParams();
if (search) params.append('search', search);
if (dateFrom) params.append('from_date', dateFrom);
if (dateTo) params.append('to_date', dateTo);
if (condition) params.append('condition_status', condition);
fetch(`api/search_roads.php?${params}`)
.then(response => response.json())
.then(data => {
if (data.success) {
Object.keys(lines).forEach(id => {
if (map.hasLayer(lines[id])) map.removeLayer(lines[id]);
});
lines = {};
lineData = {};
lineCount = 0;
data.lines.forEach(line => {
displayLine(line.id, line.name, line.road_type, line.coordinates, line.length_meters, line.condition_status);
});
updateLineCount();
showNotification(`✓ ${data.total} roads found`);
} else {
showNotification('Error applying filter', true);
}
})
.catch(error => {
console.error('Error:', error);
showNotification('Error searching roads', true);
});
}
function clearRoadsFilter() {
document.getElementById('roads-search').value = '';
document.getElementById('roads-date-from').value = '';
document.getElementById('roads-date-to').value = '';
document.getElementById('roads-condition').value = '';
loadLines();
showNotification('✓ Filter cleared');
}
// ============ SEARCH & FILTER FOR PARCELS ============
function applyParcelsFilter() {
const search = document.getElementById('parcels-search').value;
const dateFrom = document.getElementById('parcels-date-from').value;
const dateTo = document.getElementById('parcels-date-to').value;
const certificate = document.getElementById('parcels-certificate').value;
const params = new URLSearchParams();
if (search) params.append('search', search);
if (dateFrom) params.append('from_date', dateFrom);
if (dateTo) params.append('to_date', dateTo);
if (certificate) params.append('certificate_type', certificate);
fetch(`api/search_parcels.php?${params}`)
.then(response => response.json())
.then(data => {
if (data.success) {
Object.keys(polygons).forEach(id => {
if (map.hasLayer(polygons[id])) map.removeLayer(polygons[id]);
});
polygons = {};
polygonData = {};
polygonCount = 0;
data.polygons.forEach(polygon => {
displayPolygon(polygon.id, polygon.name, polygon.certificate_type, polygon.coordinates, polygon.area_sqm);
});
updatePolygonCount();
showNotification(`✓ ${data.total} parcels found`);
} else {
showNotification('Error applying filter', true);
}
})
.catch(error => {
console.error('Error:', error);
showNotification('Error searching parcels', true);
});
}
function clearParcelsFilter() {
document.getElementById('parcels-search').value = '';
document.getElementById('parcels-date-from').value = '';
document.getElementById('parcels-date-to').value = '';
document.getElementById('parcels-certificate').value = '';
loadPolygons();
showNotification('✓ Filter cleared');
}
// ============ DEBOUNCED SEARCH ============
setTimeout(function() {
const roadsSearchInput = document.getElementById('roads-search');
if (roadsSearchInput) {
roadsSearchInput.addEventListener('input', function() {
clearTimeout(searchTimeouts.roads);
searchTimeouts.roads = setTimeout(() => applyRoadsFilter(), 300);
});
}
const parcelsSearchInput = document.getElementById('parcels-search');
if (parcelsSearchInput) {
parcelsSearchInput.addEventListener('input', function() {
clearTimeout(searchTimeouts.parcels);
searchTimeouts.parcels = setTimeout(() => applyParcelsFilter(), 300);
});
}
}, 500);
// ============ PONTIANAK CHOROPLETH MAP ============
function buildChoroplethQuery(layerType) {
const params = new URLSearchParams({ layer_type: layerType });
if (layerType !== 'city_boundary') {
params.set('admin_level', pontianakState.adminLevel || 'kecamatan');
}
return params.toString();
}
// Toggle layer visibility on/off
function toggleChoroplethLayer(layerType) {
// 1. Update state
pontianakState.currentLayers[layerType] = !pontianakState.currentLayers[layerType];
// 2. If toggling OFF, remove from map
if (!pontianakState.currentLayers[layerType]) {
if (pontianakState.layers[layerType]) {
pontianakState.layers[layerType].forEach(layer => {
if (map.hasLayer(layer)) map.removeLayer(layer);
});
delete pontianakState.layers[layerType];
}
} else {
// 3. If toggling ON, fetch and render
fetchAndRenderChoroplethLayer(layerType);
}
// 4. Update legend to reflect current visible layers
updateChoroplethLegend();
}
// Fetch and render a specific layer
function fetchAndRenderChoroplethLayer(layerType) {
fetch(`api/get_choropleth_data.php?${buildChoroplethQuery(layerType)}`)
.then(response => response.json())
.then(data => {
if (data.success) {
renderChoroplethLayer(data, layerType);
}
})
.catch(error => {
console.error('Error fetching layer:', layerType, error);
pontianakState.currentLayers[layerType] = false; // Revert on error
showNotification(`Error loading ${layerType} layer`, true);
});
}
function initPontianakMap() {
if (pontianakState.dataLoaded) return;
// Load administrative areas data
fetch('api/get_pontianak_areas.php')
.then(response => response.json())
.then(data => {
if (data.success) {
data.areas.forEach(area => {
pontianakState.areasData[area.id] = area;
});
}
})
.catch(error => {
console.error('Error loading Pontianak areas:', error);
showNotification('Error loading Pontianak areas', true);
});
// Render only currently visible layers (from checkboxes)
Object.keys(pontianakState.currentLayers).forEach(layerType => {
if (pontianakState.currentLayers[layerType]) {
if (layerType === 'city_boundary') {
renderCityBoundary();
} else {
fetchAndRenderChoroplethLayer(layerType);
}
}
});
pontianakState.dataLoaded = true;
}
function switchChoropleathLayer(layerType) {
pontianakState.currentLayer = layerType;
fetch(`api/get_choropleth_data.php?${buildChoroplethQuery(layerType)}`)
.then(response => response.json())
.then(data => {
if (data.success) {
renderChoroplethLayer(data, layerType);
updateChoroplethLegend();
} else {
showNotification('Error loading choropleth data', true);
}
})
.catch(error => {
console.error('Error:', error);
showNotification('Error loading choropleth data', true);
});
}
function renderChoroplethLayer(data, layerType) {
// Re-render this layer cleanly
if (pontianakState.layers[layerType]) {
pontianakState.layers[layerType].forEach(layer => {
if (map.hasLayer(layer)) map.removeLayer(layer);
});
}
pontianakState.layers[layerType] = [];
// Special handling for city boundary
if (layerType === 'city_boundary') {
renderCityBoundary();
return;
}
// Create color scale for this layer
pontianakState.colorScales[layerType] = createColorScale(data.min_value, data.max_value);
// Render each area
data.data.forEach(feature => {
const color = pontianakState.colorScales[layerType](feature.value);
const geoJsonFeature = {
type: 'Feature',
geometry: typeof feature.geometry === 'string' ? JSON.parse(feature.geometry) : feature.geometry,
properties: {
area_id: feature.area_id,
area_name: feature.area_name,
value: feature.value,
layer_type: layerType,
value_source: feature.value_source || ''
}
};
const layer = L.geoJSON(geoJsonFeature, {
style: {
fillColor: color,
fillOpacity: 0.6, // Slightly reduced for layer overlap visibility
color: '#1f2a44',
weight: 1.1,
className: `choropleth-layer-${layerType}`
},
onEachFeature: function(feature, layer) {
const name = feature.properties.area_name;
const value = feature.properties.value.toFixed(2);
const label = getLayerLabel(layerType);
const valueSource = feature.properties.value_source ? `<br/><small>Source: ${feature.properties.value_source}</small>` : '';
layer.bindPopup(
`<div class="choropleth-popup">
<strong>${escapeHtml(name)}</strong><br/>
<span class="popup-value">${value}</span><br/>
<span class="popup-label">${label}</span>${valueSource}
</div>`
);
}
}).addTo(map);
pontianakState.layers[layerType].push(layer);
});
}
function renderCityBoundary() {
if (!pontianakState.areasData.city_boundary && Object.keys(pontianakState.areasData).length === 0) {
// Fetch city boundary if areas not loaded yet
fetch('api/get_pontianak_areas.php?area_type=city')
.then(response => response.json())
.then(data => {
if (data.success && data.areas.length > 0) {
const cityBoundary = data.areas[0];
pontianakState.areasData.city_boundary = cityBoundary;
renderCityBoundaryLayer(cityBoundary);
}
})
.catch(error => console.error('Error loading city boundary:', error));
} else if (pontianakState.areasData.city_boundary) {
renderCityBoundaryLayer(pontianakState.areasData.city_boundary);
} else {
// Fetch from areas data
fetch('api/get_pontianak_areas.php?area_type=city')
.then(response => response.json())
.then(data => {
if (data.success && data.areas.length > 0) {
const cityBoundary = data.areas[0];
pontianakState.areasData.city_boundary = cityBoundary;
renderCityBoundaryLayer(cityBoundary);
}
});
}
}
function renderCityBoundaryLayer(cityBoundary) {
if (pontianakState.layers.city_boundary) {
pontianakState.layers.city_boundary.forEach(layer => {
if (map.hasLayer(layer)) map.removeLayer(layer);
});
}
const geom = typeof cityBoundary.geometry === 'string' ? JSON.parse(cityBoundary.geometry) : cityBoundary.geometry;
const layer = L.geoJSON({
type: 'Feature',
geometry: geom,
properties: { name: 'Pontianak City Boundary' }
}, {
style: {
fillColor: '#7fb3d5',
fillOpacity: 0.08,
color: '#0d3557',
weight: 4,
dashArray: null,
className: 'choropleth-layer-city_boundary'
},
onEachFeature: function(feature, layer) {
layer.bindPopup('🏙️ Pontianak City Boundary');
}
}).addTo(map);
if (!pontianakState.layers.city_boundary) {
pontianakState.layers.city_boundary = [];
}
pontianakState.layers.city_boundary.push(layer);
if (pontianakState.active && !pontianakState.hasFittedBoundary) {
const bounds = layer.getBounds();
if (bounds && bounds.isValid()) {
map.fitBounds(bounds, { padding: [20, 20] });
pontianakState.hasFittedBoundary = true;
}
}
}
function getLayerLabel(layerType) {
const labels = {
'population': 'People/km²',
'road_density': 'Roads/km²',
'damaged_roads_density': 'Damaged roads/km²',
'city_boundary': 'City Boundary'
};
return labels[layerType] || layerType;
}
function createColorScale(minVal, maxVal) {
const colors = ['#2E86AB', '#A23B72', '#F18F01', '#C73E1D'];
if (minVal === maxVal) {
return () => colors[Math.floor(colors.length / 2)];
}
return function(value) {
const normalized = (value - minVal) / (maxVal - minVal);
const index = Math.max(0, Math.min(normalized * (colors.length - 1), colors.length - 1));
return colors[Math.floor(index)];
};
}
function updateChoroplethLegend() {
const legendDiv = document.getElementById('choropleth-legend');
if (!legendDiv) return;
// Get visible data layers (exclude city_boundary which has no values)
const visibleLayers = Object.keys(pontianakState.currentLayers)
.filter(key => pontianakState.currentLayers[key] && key !== 'city_boundary');
if (visibleLayers.length === 0) {
legendDiv.innerHTML = '<div style="text-align: center; padding: 10px; opacity: 0.7;">No layers selected</div>';
return;
}
legendDiv.innerHTML = '<div style="text-align: center; padding: 10px; opacity: 0.7;">Loading legend...</div>';
const requests = visibleLayers.map(layerType =>
fetch(`api/get_choropleth_data.php?${buildChoroplethQuery(layerType)}`)
.then(response => response.json())
.then(data => {
if (!data.success) {
return null;
}
return {
layerType: layerType,
minValue: data.min_value,
maxValue: data.max_value
};
})
.catch(error => {
console.error('Error updating legend for', layerType, error);
return null;
})
);
Promise.all(requests).then(results => {
const validResults = results.filter(Boolean);
if (validResults.length === 0) {
legendDiv.innerHTML = '<div style="text-align: center; padding: 10px; opacity: 0.7;">Legend unavailable</div>';
return;
}
legendDiv.innerHTML = '';
validResults.forEach(item => {
const div = document.createElement('div');
div.className = 'legend-section';
div.setAttribute('data-layer', item.layerType);
div.innerHTML = buildLegendSection(item.layerType, item.minValue, item.maxValue);
legendDiv.appendChild(div);
});
});
}
function buildLegendSection(layerType, minVal, maxVal) {
const labels = {
'population': 'Population Density (People/km²)',
'road_density': 'Road Density (Roads/km²)',
'damaged_roads_density': 'Damaged Roads Density (Roads/km²)'
};
return `
<div class="legend-layer-name">${labels[layerType] || layerType}</div>
<div class="legend-range">
<div class="legend-item">
<div class="legend-color" style="background-color: #2E86AB;"></div>
<span>${minVal.toFixed(1)}</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #A23B72;"></div>
<span>${((minVal + maxVal) / 2).toFixed(1)}</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #C73E1D;"></div>
<span>${maxVal.toFixed(1)}</span>
</div>
</div>
`;
}
function exportChoroplethGeoJSON() {
window.location.href = 'api/export_geojson.php?type=pontianak_areas';
showNotification('✓ GeoJSON export started');
}