Files
2026-06-12 00:05:22 +07:00

998 lines
42 KiB
JavaScript

const map = L.map('map', { zoomControl: false }).setView([-0.0258, 109.3323], 13);
L.control.zoom({ position: 'bottomright' }).addTo(map);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map);
let currentMode = null;
let tempLayer = null;
let places = [];
let houses = [];
let houseFilter = 'all'; // 'all' | 'linked' | 'unlinked'
// Add a simple layer control (top-right) to filter households by linked status
const HouseFilterControl = L.Control.extend({
options: { position: 'bottomright' },
onAdd: function () {
const container = L.DomUtil.create('div', 'leaflet-bar leaflet-control house-filter-control');
container.innerHTML = `
<div class="house-filter-title">Filter Households</div>
<div class="house-filter-options">
<label class="switch"><input type="radio" name="houseFilter" value="all" checked><span class="switch-slider">All</span></label>
<label class="switch"><input type="radio" name="houseFilter" value="linked"><span class="switch-slider">Linked</span></label>
<label class="switch"><input type="radio" name="houseFilter" value="unlinked"><span class="switch-slider">Unlinked</span></label>
</div>
`;
L.DomEvent.disableClickPropagation(container);
setTimeout(() => {
container.querySelectorAll('input[name="houseFilter"]').forEach(inp => {
inp.addEventListener('change', (e) => {
houseFilter = e.target.value;
applyHouseFilter(houseFilter);
});
});
}, 50);
return container;
}
});
map.addControl(new HouseFilterControl());
// Auth State
let authToken = localStorage.getItem('authToken');
let userRole = localStorage.getItem('userRole');
let userName = localStorage.getItem('userName');
async function apiFetch(url, options = {}) {
if (!options.headers) options.headers = {};
if (authToken) options.headers['Authorization'] = `Bearer ${authToken}`;
const response = await fetch(url, options);
if (response.status === 401 || response.status === 403) {
// Only alert on modification routes
if (options.method && options.method !== 'GET') {
const data = await response.json();
alert(data.message || 'Authentication error or forbidden access.');
}
throw new Error('Unauthorized');
}
return response;
}
const getHouseIcon = (color) => L.divIcon({
className: 'custom-div-icon',
html: `<div style="background-color:${color}; width:16px; height:16px; border-radius:50%; border:2px solid white; box-shadow: 0 0 3px rgba(0,0,0,0.5);"></div>`,
iconSize: [20, 20], iconAnchor: [10, 10]
});
const centerIcon = L.divIcon({
className: 'custom-div-icon',
html: `<div style="background-color:#007bff; width:12px; height:12px; border-radius:50%; border:2px solid white;"></div>`,
iconSize: [16, 16], iconAnchor: [8, 8]
});
function getPlaceIcon(type) {
// Use simple emoji markers inside divIcon for clarity
let bg = '#007bff';
let emoji = '🕌';
switch (type) {
case 'mosque': bg = '#0ea5e9'; emoji = '🕌'; break;
case 'church_catholic': bg = '#6366f1'; emoji = '⛪️'; break;
case 'church_protestant': bg = '#6ee7b7'; emoji = '⛪️'; break;
case 'pura': bg = '#f97316'; emoji = '🛕'; break;
case 'vihara': bg = '#f43f5e'; emoji = '🕍'; break;
case 'kelenteng': bg = '#f59e0b'; emoji = '🀄️'; break;
default: bg = '#007bff'; emoji = '🕌';
}
return L.divIcon({
className: 'custom-div-icon',
html: `<div style="background-color:${bg}; width:20px; height:20px; border-radius:50%; border:2px solid white; display:flex; align-items:center; justify-content:center; font-size:12px;">${emoji}</div>`,
iconSize: [24, 24], iconAnchor: [12, 12]
});
}
function getPlaceEmoji(type) {
switch (type) {
case 'mosque': return '🕌';
case 'church_catholic': return '⛪️';
case 'church_protestant': return '⛪️';
case 'pura': return '🛕';
case 'vihara': return '🕍';
case 'kelenteng': return '🀄️';
default: return '🕌';
}
}
// ------------------------------------------------------------------
// AUTHENTICATION & UI TOGGLES
// ------------------------------------------------------------------
function openLogin() { document.getElementById('login-overlay').classList.remove('hidden'); }
function closeLogin() { document.getElementById('login-overlay').classList.add('hidden'); document.getElementById('login-error').innerText = ''; }
function openAdminPanel() { document.getElementById('admin-overlay').classList.remove('hidden'); }
function closeAdminPanel() { document.getElementById('admin-overlay').classList.add('hidden'); document.getElementById('reg-msg').innerText = ''; }
async function login() {
const email = document.getElementById('login-email').value;
const password = document.getElementById('login-password').value;
try {
const res = await fetch('/api/auth/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || 'Login failed');
authToken = data.token; userRole = data.role; userName = data.username;
localStorage.setItem('authToken', authToken);
localStorage.setItem('userRole', userRole);
localStorage.setItem('userName', userName);
closeLogin();
updateUIPermissions();
await refreshData();
} catch (err) {
document.getElementById('login-error').innerText = err.message;
}
}
function logout() {
authToken = null; userRole = null; userName = null;
localStorage.removeItem('authToken');
localStorage.removeItem('userRole');
localStorage.removeItem('userName');
clearMapData();
updateUIPermissions();
}
async function registerSurveyor() {
const username = document.getElementById('reg-username').value;
const email = document.getElementById('reg-email').value;
const password = document.getElementById('reg-password').value;
const msgEl = document.getElementById('reg-msg');
try {
const res = await apiFetch('/api/auth/register', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, email, password, role: 'surveyor' })
});
const data = await res.json();
if (!res.ok) { msgEl.style.color = '#dc3545'; msgEl.innerText = data.message || 'Error'; }
else { msgEl.style.color = '#28a745'; msgEl.innerText = 'Surveyor registered successfully!'; }
} catch (err) {
msgEl.style.color = '#dc3545';
msgEl.innerText = 'Registration failed. Check permissions.';
}
}
function updateUIPermissions() {
const toolbar = document.getElementById('toolbar');
const loginBtn = document.getElementById('btn-open-login');
const logoutBtn = document.getElementById('btn-logout');
const adminPanelBtn = document.getElementById('btn-admin-panel');
const logisticsBtn = document.getElementById('btn-logistics');
const userInfo = document.getElementById('user-info');
if (userRole === 'admin' || userRole === 'surveyor') {
toolbar.classList.remove('hidden');
loginBtn.style.display = 'none';
logoutBtn.style.display = 'block';
logisticsBtn.style.display = 'block';
userInfo.style.display = 'inline';
userInfo.innerText = `Welcome, ${userName} (${userRole})`;
if (userRole === 'admin') adminPanelBtn.style.display = 'block';
else adminPanelBtn.style.display = 'none';
} else {
toolbar.classList.add('hidden');
loginBtn.style.display = 'block';
logoutBtn.style.display = 'none';
logisticsBtn.style.display = 'none';
userInfo.style.display = 'none';
adminPanelBtn.style.display = 'none';
// Hide logistics panel if open
document.getElementById('logistics-panel').classList.add('hidden');
cancelDraw();
}
}
function clearMapData() {
places.forEach(place => {
if (place.marker && map.hasLayer(place.marker)) map.removeLayer(place.marker);
if (place.circle && map.hasLayer(place.circle)) map.removeLayer(place.circle);
});
houses.forEach(house => {
if (house.layer && map.hasLayer(house.layer)) map.removeLayer(house.layer);
});
places = [];
houses = [];
}
async function refreshData() {
clearMapData();
await loadData();
}
function getPopupButtons(type, id) {
if (!userRole) return ''; // Public sees nothing
let buttons = `<button onclick="update${type}(${id})">Update</button>`;
if (userRole === 'admin') {
buttons += `<button onclick="delete${type}(${id})" style="background: #dc3545;">Delete</button>`;
}
return buttons;
}
async function loadData() {
clearMapData();
const placesRes = await apiFetch('/api/places').catch(() => null);
if (placesRes) { places = await placesRes.json(); places.forEach(p => renderPlace(p)); }
const housesRes = await apiFetch('/api/houses').catch(() => null);
if (housesRes) { houses = await housesRes.json(); houses.forEach(h => renderHouse(h)); }
// Apply current house filter after rendering
applyHouseFilter(houseFilter);
updateUIPermissions();
}
function setMode(mode) {
currentMode = mode;
document.getElementById('status-text').innerText = mode === 'place' ? 'Click on map to place a Circle.' : 'Click on map to place a Point.';
document.getElementById('btn-cancel').style.display = 'block';
cleanupTemp();
}
function cancelDraw() {
currentMode = null;
document.getElementById('status-text').innerText = 'Select a tool to begin.';
document.getElementById('btn-cancel').style.display = 'none';
cleanupTemp();
}
function cleanupTemp() {
if (tempLayer) {
if (tempLayer.marker) {
map.removeLayer(tempLayer.marker);
map.removeLayer(tempLayer.circle);
} else {
map.removeLayer(tempLayer);
}
tempLayer = null;
}
}
// Map Click Logic
map.on('click', function (e) {
if (!userRole) return; // Prevent public from drawing
if (currentMode === 'place') {
currentMode = 'filling_form';
tempLayer = {
marker: L.marker(e.latlng, { icon: centerIcon, draggable: true }).addTo(map),
circle: L.circle(e.latlng, { radius: 500, color: 'blue' }).addTo(map)
};
tempLayer.marker.on('drag', (ev) => tempLayer.circle.setLatLng(ev.target.getLatLng()));
const popupContent = `
<div class="popup-form">
<strong>Adjust Radius (m)</strong>
<input type="number" id="radiusInput" value="500" oninput="updateTempRadius(this.value)">
<button onclick="nextPlaceStep()">Confirm Radius</button>
</div>
`;
tempLayer.marker.bindPopup(popupContent, { closeOnClick: false, autoClose: false }).openPopup();
}
else if (currentMode === 'house') {
currentMode = 'filling_form';
tempLayer = L.marker(e.latlng, { icon: getHouseIcon('gray'), draggable: true }).addTo(map);
const popupContent = `
<div class="popup-form">
<strong>Enter Household Details</strong>
<input type="text" id="hName" placeholder="Head of Family Name">
<input type="text" id="hNIK" placeholder="Head NIK (16 digits)">
<input type="text" id="hKK" placeholder="KK Number (16 digits)">
<input type="number" id="hRes" placeholder="Number of Residents">
<label style="font-size:11px; font-weight:bold; margin-top:2px;">BPS Poverty Indicators:</label>
<select id="hElectricity">
<option value="450">450 VA</option>
<option value="900">900 VA</option>
<option value="1300">1300 VA</option>
<option value=">1300">>1300 VA</option>
</select>
<select id="hFloor">
<option value="dirt">Dirt Floor</option>
<option value="wood">Wood Floor</option>
<option value="cement">Cement Floor</option>
<option value="ceramic">Ceramic/Tile Floor</option>
</select>
<select id="hWall">
<option value="bamboo">Bamboo Wall</option>
<option value="wood">Wood Wall</option>
<option value="brick">Brick Wall</option>
</select>
<select id="hWater">
<option value="river">River Water</option>
<option value="rainwater">Rainwater</option>
<option value="well">Well Water</option>
<option value="pdam">PDAM (Tap Water)</option>
<option value="bottled">Bottled Water</option>
</select>
<button onclick="saveHouse()">Save House</button>
</div>
`;
tempLayer.bindPopup(popupContent, { closeOnClick: false, autoClose: false }).openPopup();
}
});
window.updateTempRadius = function (val) {
if (tempLayer && tempLayer.circle) tempLayer.circle.setRadius(parseInt(val));
}
window.nextPlaceStep = function () {
const popupContent = `
<div class="popup-form">
<strong>Enter Place Name</strong>
<input type="text" id="pName" placeholder="Mosque / Church Name">
<label style="font-size:11px; margin-top:4px;">Type:</label>
<select id="pType">
<option value="mosque">Mosque</option>
<option value="church_catholic">Church (Catholic)</option>
<option value="church_protestant">Church (Protestant)</option>
<option value="pura">Pura</option>
<option value="vihara">Vihara</option>
<option value="kelenteng">Kelenteng</option>
</select>
<button onclick="savePlace()">Save Place</button>
</div>
`;
tempLayer.marker.bindPopup(popupContent).openPopup();
}
window.savePlace = async function () {
const name = document.getElementById('pName').value;
const place_type = document.getElementById('pType') ? document.getElementById('pType').value : 'mosque';
if (!name) return alert("Name is required!");
const latlng = tempLayer.marker.getLatLng();
const radius = tempLayer.circle.getRadius();
try {
const response = await apiFetch('/api/places', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, lat: latlng.lat, lng: latlng.lng, radius, place_type })
});
const newPlace = await response.json();
places.push(newPlace);
renderPlace(newPlace);
cleanupTemp();
cancelDraw();
await refreshData();
} catch (e) { }
}
window.saveHouse = async function () {
const name = document.getElementById('hName').value;
const nik = document.getElementById('hNIK').value;
const kk = document.getElementById('hKK').value;
const resCount = document.getElementById('hRes').value;
const electricity = document.getElementById('hElectricity').value;
const floor = document.getElementById('hFloor').value;
const wall = document.getElementById('hWall').value;
const water = document.getElementById('hWater').value;
if (!name || !nik || !kk || !resCount) return alert("All fields are required!");
const latlng = tempLayer.getLatLng();
let nearestPlace = findNearestPlace(latlng);
const linked_place_id = nearestPlace ? nearestPlace.id : null;
try {
const response = await apiFetch('/api/houses', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
head_name: name,
head_nik: nik,
kk_number: kk,
resident_count: parseInt(resCount, 10),
lat: latlng.lat,
lng: latlng.lng,
linked_place_id,
electricity_va: electricity,
floor_material: floor,
wall_material: wall,
drinking_water_source: water
})
});
if (!response.ok) {
const errData = await response.json();
alert("Error saving household: " + (errData.message || response.statusText));
return;
}
const newHouse = await response.json();
houses.push(newHouse);
renderHouse(newHouse);
cleanupTemp();
cancelDraw();
await refreshData();
} catch (e) {
console.error("Save household error:", e);
alert("Failed to save household. Please check console logs.");
}
}
// ------------------------------------------------------------------
// EDIT AND DELETE LOGIC
// ------------------------------------------------------------------
window.updatePlace = async function (id) {
const place = places.find(p => p.id === id);
const newName = document.getElementById(`edit-pName-${id}`).value;
const newRadius = parseInt(document.getElementById(`edit-pRadius-${id}`).value);
const newType = document.getElementById(`edit-pType-${id}`) ? document.getElementById(`edit-pType-${id}`).value : 'mosque';
try {
await apiFetch(`/api/places/attr/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName, radius: newRadius, place_type: newType })
});
place.name = newName;
place.radius = newRadius;
place.place_type = newType;
renderPlace(place);
map.closePopup();
await refreshData();
} catch (e) { }
}
window.deletePlace = async function (id) {
if (!confirm("Are you sure you want to delete this place?")) return;
try {
await apiFetch(`/api/places/${id}`, { method: 'DELETE' });
const placeIndex = places.findIndex(p => p.id === id);
if (placeIndex > -1) {
const place = places[placeIndex];
if (place.marker && map.hasLayer(place.marker)) map.removeLayer(place.marker);
if (place.circle && map.hasLayer(place.circle)) map.removeLayer(place.circle);
places.splice(placeIndex, 1);
}
map.closePopup();
await refreshData();
} catch (e) { }
}
window.updateHouse = async function (id) {
const house = houses.find(h => h.id === id);
const newName = document.getElementById(`edit-hName-${id}`).value;
const newNIK = document.getElementById(`edit-hNIK-${id}`).value;
const newKK = document.getElementById(`edit-hKK-${id}`).value;
const newRes = document.getElementById(`edit-hRes-${id}`).value;
const newElectricity = document.getElementById(`edit-hElectricity-${id}`).value;
const newFloor = document.getElementById(`edit-hFloor-${id}`).value;
const newWall = document.getElementById(`edit-hWall-${id}`).value;
const newWater = document.getElementById(`edit-hWater-${id}`).value;
try {
await apiFetch(`/api/houses/attr/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
head_name: newName,
head_nik: newNIK,
kk_number: newKK,
resident_count: newRes,
electricity_va: newElectricity,
floor_material: newFloor,
wall_material: newWall,
drinking_water_source: newWater
})
});
house.name = newName;
house.head_name = newName;
house.head_nik = newNIK;
house.kk_number = newKK;
house.resident_count = newRes;
house.electricity_va = newElectricity;
house.floor_material = newFloor;
house.wall_material = newWall;
house.drinking_water_source = newWater;
renderHouse(house);
map.closePopup();
await refreshData();
} catch (e) {
console.error("Update household error:", e);
alert("Failed to update household.");
}
}
window.deleteHouse = async function (id) {
if (!confirm("Are you sure you want to delete this house?")) return;
try {
await apiFetch(`/api/houses/${id}`, { method: 'DELETE' });
const houseIndex = houses.findIndex(h => h.id === id);
if (houseIndex > -1) {
const house = houses[houseIndex];
map.removeLayer(house.layer);
houses.splice(houseIndex, 1);
}
map.closePopup();
await refreshData();
} catch (e) { }
}
// ------------------------------------------------------------------
// RENDERING
// ------------------------------------------------------------------
function renderPlace(place) {
if (place.marker) map.removeLayer(place.marker);
if (place.circle) map.removeLayer(place.circle);
// Draggable only if admin or owner. But for simplicity, enable draggable only if logged in.
const draggable = userRole ? true : false;
const marker = L.marker([place.lat, place.lng], { icon: getPlaceIcon(place.place_type || 'mosque'), draggable }).addTo(map);
const circle = L.circle([place.lat, place.lng], { radius: place.radius, color: '#3388ff', fillOpacity: 0.2 }).addTo(map);
place.marker = marker;
place.circle = circle;
if (userRole) {
const popupContent = `
<div class="popup-form">
<strong>Edit Religious Place</strong>
<label style="font-size:12px;">Name:</label>
<input type="text" id="edit-pName-${place.id}" value="${place.name}">
<label style="font-size:12px;">Radius (m):</label>
<input type="number" id="edit-pRadius-${place.id}" value="${place.radius}">
<label style="font-size:12px;">Type:</label>
<select id="edit-pType-${place.id}">
<option value="mosque" ${place.place_type === 'mosque' ? 'selected' : ''}>Mosque</option>
<option value="church_catholic" ${place.place_type === 'church_catholic' ? 'selected' : ''}>Church (Catholic)</option>
<option value="church_protestant" ${place.place_type === 'church_protestant' ? 'selected' : ''}>Church (Protestant)</option>
<option value="pura" ${place.place_type === 'pura' ? 'selected' : ''}>Pura</option>
<option value="vihara" ${place.place_type === 'vihara' ? 'selected' : ''}>Vihara</option>
<option value="kelenteng" ${place.place_type === 'kelenteng' ? 'selected' : ''}>Kelenteng</option>
</select>
${getPopupButtons('Place', place.id)}
</div>
`;
marker.bindPopup(popupContent);
} else {
marker.bindPopup(`<strong>${place.name}</strong><br>Radius: ${place.radius}m`);
}
if (draggable) {
marker.on('drag', (e) => circle.setLatLng(e.target.getLatLng()));
marker.on('dragend', async function (e) {
const newLatLng = e.target.getLatLng();
const oldLat = place.lat; const oldLng = place.lng;
place.lat = newLatLng.lat; place.lng = newLatLng.lng;
try {
await apiFetch(`/api/places/${place.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lat: place.lat, lng: place.lng })
});
recalculateAllHouses();
} catch (err) {
// Revert on permission failure
place.lat = oldLat; place.lng = oldLng;
marker.setLatLng([oldLat, oldLng]);
circle.setLatLng([oldLat, oldLng]);
}
});
}
}
function renderHouse(house) {
if (house.layer) map.removeLayer(house.layer);
const draggable = userRole ? true : false;
const markerColor = house.linked_place_id ? '#28a745' : '#dc3545';
const marker = L.marker([house.lat, house.lng], { icon: getHouseIcon(markerColor), draggable }).addTo(map);
house.layer = marker;
const linkedPlace = places.find(p => p.id === house.linked_place_id);
const linkedName = linkedPlace ? linkedPlace.name : 'Unlinked';
const linkedStatus = house.linked_place_id ? `<span style="color:green;font-size:12px;">Linked to</span> <strong style="font-size:12px; color:#e2e8f0;">${linkedName}</strong>` : `<span style="color:red;font-size:12px;">Unlinked</span>`;
if (userRole) {
const canDeliver = (userRole === 'admin' || userRole === 'surveyor') && house.verification_status === 'verified';
const deliveryFormHtml = canDeliver ? `
<hr style="margin: 8px 0; border-color: #e0e0e0;">
<label style="font-size:11px; font-weight:bold; color: #1a7f3c;">📦 Sembako Handover</label>
<input type="text" id="delivery-notes-${house.id}" placeholder="Handover notes (optional)" style="margin-top:4px;">
<button
onclick="submitSembakoDelivery(${house.id}, ${house.linked_place_id || 'null'})"
style="background: linear-gradient(135deg, #1a7f3c, #28a745); color:white; font-weight:600; margin-top:4px;"
>✅ Confirm Handover Completed</button>
` : '';
const verifyHouseholdHtml = userRole === 'admin' && house.verification_status === 'pending' ? `
<button onclick="verifyHousehold(${house.id})" style="background: #f59e0b; color: white; margin-top: 8px;">🔎 Verify Household</button>
` : '';
const pendingMessage = !canDeliver && house.verification_status !== 'verified' ? `
<hr style="margin: 8px 0; border-color: #e0e0e0;">
<p style="font-size:11px; color:#856404; background:#fff3cd; padding:4px 6px; border-radius:4px; margin:0;">⚠️ Handover locked — household must be <strong>verified</strong> first.</p>
` : '';
const popupContent = `
<div class="popup-form" style="min-width: 200px;">
<strong>Edit Household</strong> ${linkedStatus}
<label style="font-size:11px; margin-bottom:-4px;">Head of Family:</label>
<input type="text" id="edit-hName-${house.id}" value="${house.name || ''}">
<label style="font-size:11px; margin-bottom:-4px;">Head NIK:</label>
<input type="text" id="edit-hNIK-${house.id}" value="${house.head_nik || ''}">
<label style="font-size:11px; margin-bottom:-4px;">KK Number:</label>
<input type="text" id="edit-hKK-${house.id}" value="${house.kk_number || ''}">
<label style="font-size:11px; margin-bottom:-4px;">Residents:</label>
<input type="number" id="edit-hRes-${house.id}" value="${house.resident_count || 0}">
<label style="font-size:11px; font-weight:bold; margin-top:2px;">BPS Poverty Indicators:</label>
<select id="edit-hElectricity-${house.id}">
<option value="450" ${house.electricity_va == '450' ? 'selected' : ''}>450 VA</option>
<option value="900" ${house.electricity_va == '900' ? 'selected' : ''}>900 VA</option>
<option value="1300" ${house.electricity_va == '1300' ? 'selected' : ''}>1300 VA</option>
<option value=">1300" ${house.electricity_va == '>1300' ? 'selected' : ''}>>1300 VA</option>
</select>
<select id="edit-hFloor-${house.id}">
<option value="dirt" ${house.floor_material == 'dirt' ? 'selected' : ''}>Dirt Floor</option>
<option value="wood" ${house.floor_material == 'wood' ? 'selected' : ''}>Wood Floor</option>
<option value="cement" ${house.floor_material == 'cement' ? 'selected' : ''}>Cement Floor</option>
<option value="ceramic" ${house.floor_material == 'ceramic' ? 'selected' : ''}>Ceramic/Tile Floor</option>
</select>
<select id="edit-hWall-${house.id}">
<option value="bamboo" ${house.wall_material == 'bamboo' ? 'selected' : ''}>Bamboo Wall</option>
<option value="wood" ${house.wall_material == 'wood' ? 'selected' : ''}>Wood Wall</option>
<option value="brick" ${house.wall_material == 'brick' ? 'selected' : ''}>Brick Wall</option>
</select>
<select id="edit-hWater-${house.id}">
<option value="river" ${house.drinking_water_source == 'river' ? 'selected' : ''}>River Water</option>
<option value="rainwater" ${house.drinking_water_source == 'rainwater' ? 'selected' : ''}>Rainwater</option>
<option value="well" ${house.drinking_water_source == 'well' ? 'selected' : ''}>Well Water</option>
<option value="pdam" ${house.drinking_water_source == 'pdam' ? 'selected' : ''}>PDAM (Tap Water)</option>
<option value="bottled" ${house.drinking_water_source == 'bottled' ? 'selected' : ''}>Bottled Water</option>
</select>
${deliveryFormHtml}
${verifyHouseholdHtml}
${pendingMessage}
${getPopupButtons('House', house.id)}
</div>
`;
marker.bindPopup(popupContent, { maxWidth: 280 });
} else {
marker.bindPopup(`
<strong>Household: ${house.name}</strong><br>
Residents: ${house.resident_count}<br>
NIK: ${house.head_nik || '-'}<br>
KK: ${house.kk_number || '-'}<br>
<hr style="margin: 5px 0;">
<strong>Linked Place:</strong> ${linkedName}<br>
<strong>BPS Poverty Indicators:</strong><br>
⚡ Electricity: ${house.electricity_va} VA<br>
🪵 Floor: ${house.floor_material}<br>
🧱 Wall: ${house.wall_material}<br>
🚰 Water: ${house.drinking_water_source}<br>
Status: <strong>${house.verification_status}</strong><br>
${linkedStatus}
`);
}
if (draggable) {
marker.on('dragend', async function (e) {
const newLatLng = e.target.getLatLng();
const oldLat = house.lat; const oldLng = house.lng; const oldLink = house.linked_place_id;
house.lat = newLatLng.lat; house.lng = newLatLng.lng;
let nearestPlace = findNearestPlace(newLatLng);
house.linked_place_id = nearestPlace ? nearestPlace.id : null;
try {
await apiFetch(`/api/houses/${house.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lat: house.lat, lng: house.lng, linked_place_id: house.linked_place_id })
});
renderHouse(house);
} catch (err) {
// Revert
house.lat = oldLat; house.lng = oldLng; house.linked_place_id = oldLink;
renderHouse(house);
}
});
}
}
function findNearestPlace(latlng) {
let nearest = null;
let minDistance = Infinity;
places.forEach(place => {
const placeLatLng = L.latLng(place.lat, place.lng);
const dist = map.distance(latlng, placeLatLng);
if (dist <= place.radius && dist < minDistance) {
minDistance = dist;
nearest = place;
}
});
return nearest;
}
function applyHouseFilter(mode) {
for (let house of houses) {
if (!house.layer) continue;
const linked = !!house.linked_place_id;
if (mode === 'all') {
if (!map.hasLayer(house.layer)) map.addLayer(house.layer);
} else if (mode === 'linked') {
if (linked) { if (!map.hasLayer(house.layer)) map.addLayer(house.layer); } else { if (map.hasLayer(house.layer)) map.removeLayer(house.layer); }
} else if (mode === 'unlinked') {
if (!linked) { if (!map.hasLayer(house.layer)) map.addLayer(house.layer); } else { if (map.hasLayer(house.layer)) map.removeLayer(house.layer); }
}
}
}
async function recalculateAllHouses() {
for (let house of houses) {
const houseLatLng = L.latLng(house.lat, house.lng);
let nearestPlace = findNearestPlace(houseLatLng);
const newLinkId = nearestPlace ? nearestPlace.id : null;
if (house.linked_place_id !== newLinkId) {
house.linked_place_id = newLinkId;
try {
// Not all houses might be owned by current surveyor, so this might silently fail on server.
// We wrap it securely in apiFetch without throwing hard.
await apiFetch(`/api/houses/${house.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lat: house.lat, lng: house.lng, linked_place_id: house.linked_place_id })
});
} catch (e) { }
renderHouse(house);
}
}
}
// ------------------------------------------------------------------
// SEMBAKO DISTRIBUTION & LOGISTICS DASHBOARD
// ------------------------------------------------------------------
/**
* submitSembakoDelivery
* Called from the popup button. Sends a POST to /api/distribution/log.
* @param {number} householdId - The household's database ID
* @param {number|null} placeId - The linked religious place ID
*/
window.submitSembakoDelivery = async function (householdId, placeId) {
const notesEl = document.getElementById(`delivery-notes-${householdId}`);
const notes = notesEl ? notesEl.value.trim() : '';
try {
const res = await apiFetch('/api/distribution/log', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
household_id: householdId,
place_id: placeId,
amount_value: 1, // 1 sembako package per handover
notes: notes || null
})
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || 'Server error');
alert(`✅ Handover logged successfully! Distribution ID: ${data.distribution_id}`);
map.closePopup();
// Refresh the logistics dashboard if it's open
if (!document.getElementById('logistics-panel').classList.contains('hidden')) {
loadSembakoDashboard();
}
} catch (err) {
console.error('Sembako delivery error:', err);
alert(`❌ Failed to log handover: ${err.message}`);
}
};
window.verifyHousehold = async function (householdId) {
try {
const res = await apiFetch(`/api/houses/verify/${householdId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' }
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || 'Verification failed');
alert('✅ Household verified successfully. Handover is now enabled.');
await refreshData();
} catch (err) {
console.error('Household verification error:', err);
alert(`❌ ${err.message}`);
}
};
async function loadPendingHandoverVerifications() {
const res = await apiFetch('/api/distribution/pending');
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.message || 'Failed to fetch pending verifications');
}
return res.json();
}
async function verifyDistribution(distributionId) {
if (!confirm('Verify this sembako handover?')) return;
try {
const res = await apiFetch(`/api/distribution/verify/${distributionId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' }
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || 'Verification failed');
alert('✅ Handover verified successfully.');
await loadSembakoDashboard();
} catch (err) {
console.error('Verification error:', err);
alert(`❌ ${err.message}`);
}
}
/**
* loadSembakoDashboard
* Fetches /api/analytics/sembako-demand and renders results
* inside the #logistics-panel sidebar.
*/
async function loadSembakoDashboard() {
const contentEl = document.getElementById('logistics-content');
contentEl.innerHTML = '<p style="text-align:center; color:#aaa;">Loading...</p>';
try {
const res = await apiFetch('/api/analytics/sembako-demand');
if (!res.ok) throw new Error('Failed to fetch analytics');
const data = await res.json();
const totalHouseholds = data.reduce((sum, r) => sum + Number(r.households_needing_aid || 0), 0);
const totalResidents = data.reduce((sum, r) => sum + Number(r.total_residents_needing_aid || 0), 0);
const rows = data.map(row => `
<div class="logistics-card">
<div class="logistics-card-icon">${getPlaceEmoji(row.place_type)}</div>
<div class="logistics-card-body">
<div class="logistics-card-name">${row.place_name}</div>
<div class="logistics-card-stat">
<span class="stat-badge need">${Number(row.households_needing_aid || 0)} KK</span>
perlu sembako
</div>
<div class="logistics-card-residents">👥 ${Number(row.total_residents_needing_aid || 0)} jiwa terdampak</div>
</div>
</div>
`).join('');
let pageContent = `
<div class="logistics-summary">
<div class="summary-item"><span class="summary-num">${totalHouseholds}</span><span class="summary-label">Total KK</span></div>
<div class="summary-item"><span class="summary-num">${totalResidents}</span><span class="summary-label">Total Jiwa</span></div>
</div>
${rows.length ? rows : `
<div style="text-align:center; padding: 20px 0; color:#28a745;">
<span style="font-size:32px;">🎉</span>
<p style="margin-top:8px; font-weight:600;">All verified households have received sembako!</p>
</div>
`}
`;
if (userRole === 'admin') {
const pending = await loadPendingHandoverVerifications();
pageContent += renderPendingHandoverSection(pending);
const pendingHouses = await loadPendingHouseholdVerifications();
pageContent += renderPendingHouseholdSection(pendingHouses);
}
contentEl.innerHTML = pageContent;
} catch (err) {
contentEl.innerHTML = `<p style="color:#dc3545; padding:10px;">❌ ${err.message}. Please log in first.</p>`;
}
}
function renderPendingHandoverSection(records) {
if (!records || records.length === 0) {
return `
<div class="pending-verification-panel">
<h4>Pending Handover Verification</h4>
<p style="font-size:12px; color:#cbd5e1;">No pending handovers need verification right now.</p>
</div>
`;
}
const rows = records.map(record => `
<div class="pending-card">
<div class="pending-card-header">
<span>#${record.id}</span>
<button class="verify-btn" onclick="verifyDistribution(${record.id})">Verify</button>
</div>
<div class="pending-card-body">
<div><strong>Household:</strong> ${record.head_name || '-'} (${record.kk_number || '-'})</div>
<div><strong>Place:</strong> ${record.place_name || 'Unlinked'}</div>
<div><strong>Surveyor:</strong> ${record.distributed_by_name || 'Unknown'}</div>
<div><strong>Notes:</strong> ${record.notes || '-'}</div>
</div>
</div>
`).join('');
return `
<div class="pending-verification-panel">
<h4>Pending Handover Verification</h4>
${rows}
</div>
`;
}
async function loadPendingHouseholdVerifications() {
const res = await apiFetch('/api/houses/pending');
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.message || 'Failed to fetch pending households');
}
return res.json();
}
function renderPendingHouseholdSection(records) {
if (!records || records.length === 0) {
return `
<div class="pending-verification-panel">
<h4>Pending Household Verifications</h4>
<p style="font-size:12px; color:#cbd5e1;">No pending household assessments.</p>
</div>
`;
}
const rows = records.map(r => `
<div class="pending-card">
<div class="pending-card-header">
<span>#${r.assessment_id} - ${r.head_name || '-'} </span>
<button class="verify-btn" onclick="verifyHousehold(${r.household_id})">Verify</button>
</div>
<div class="pending-card-body">
<div><strong>KK:</strong> ${r.kk_number || '-'}</div>
<div><strong>Surveyor:</strong> ${r.surveyor_name || 'Unknown'}</div>
<div><strong>Place:</strong> ${r.place_name || 'Unlinked'}</div>
<div><strong>Indicators:</strong> ⚡ ${r.electricity_va || '-'} • Floor: ${r.floor_material || '-'} • Wall: ${r.wall_material || '-'}</div>
</div>
</div>
`).join('');
return `
<div class="pending-verification-panel">
<h4>Pending Household Verifications</h4>
${rows}
</div>
`;
}
window.toggleLogisticsDashboard = function () {
const panel = document.getElementById('logistics-panel');
const isHidden = panel.classList.toggle('hidden');
if (!isHidden && (userRole === 'admin' || userRole === 'surveyor')) {
loadSembakoDashboard();
}
};
// Boot up
loadData();