5612 lines
196 KiB
JavaScript
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 = {
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
'"': '"',
|
|
"'": '''
|
|
};
|
|
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');
|
|
}
|