Initial commit of antigravity2 project
This commit is contained in:
@@ -0,0 +1,998 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user