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();
|
||||
@@ -0,0 +1,80 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Donation Distribution WebGIS</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="map"></div>
|
||||
|
||||
<div id="navbar">
|
||||
<div class="logo">🌍 DonationGIS</div>
|
||||
<div class="auth-controls">
|
||||
<span id="user-info" style="display:none; color:white; margin-right:15px; font-weight:500;"></span>
|
||||
<button id="btn-admin-panel" onclick="openAdminPanel()" style="display:none;" class="nav-btn">Admin
|
||||
Dashboard</button>
|
||||
<button id="btn-logistics" onclick="toggleLogisticsDashboard()" style="display:none;" class="nav-btn">📦
|
||||
Sembako Logistics</button>
|
||||
<button id="btn-logout" onclick="logout()" style="display:none;" class="nav-btn logout">Logout</button>
|
||||
<button id="btn-open-login" onclick="openLogin()" class="nav-btn">Login</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login Overlay -->
|
||||
<div id="login-overlay" class="overlay hidden">
|
||||
<div class="glass-panel">
|
||||
<h2>Welcome Back</h2>
|
||||
<p>Sign in to manage map data.</p>
|
||||
<input type="email" id="login-email" placeholder="Email Address">
|
||||
<input type="password" id="login-password" placeholder="Password">
|
||||
<button onclick="login()" class="primary-btn">Sign In</button>
|
||||
<button onclick="closeLogin()" class="text-btn">Cancel</button>
|
||||
<p id="login-error" class="error-msg"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin Dashboard Overlay -->
|
||||
<div id="admin-overlay" class="overlay hidden">
|
||||
<div class="glass-panel">
|
||||
<h2>Admin Dashboard</h2>
|
||||
<p>Register new Surveyor Accounts.</p>
|
||||
<input type="text" id="reg-username" placeholder="Username">
|
||||
<input type="email" id="reg-email" placeholder="Email Address">
|
||||
<input type="password" id="reg-password" placeholder="Password">
|
||||
<button onclick="registerSurveyor()" class="primary-btn">Register Surveyor</button>
|
||||
<button onclick="closeAdminPanel()" class="text-btn">Close</button>
|
||||
<p id="reg-msg" class="msg"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toolbar" class="hidden">
|
||||
<h3>Mapping Tools</h3>
|
||||
<button id="btn-place" onclick="setMode('place')" class="tool-btn">📍 Add Religious Place</button>
|
||||
<button id="btn-house" onclick="setMode('house')" class="tool-btn">🏠 Add House</button>
|
||||
<button id="btn-cancel" onclick="cancelDraw()" class="tool-btn cancel-btn" style="display:none;">Cancel</button>
|
||||
<p id="status-text">Select a tool to begin.</p>
|
||||
</div>
|
||||
|
||||
<!-- Sembako Logistics Dashboard Sidebar -->
|
||||
<div id="logistics-panel" class="hidden">
|
||||
<div class="logistics-header">
|
||||
<h3>📦 Distribusi Sembako</h3>
|
||||
<p>Kebutuhan per Pusat Ibadah</p>
|
||||
<button class="logistics-refresh-btn" onclick="loadSembakoDashboard()" title="Refresh Data">🔄</button>
|
||||
</div>
|
||||
<div id="logistics-content">
|
||||
<p style="text-align:center; color:#888; padding:20px 0;">Click 📦 Sembako Logistics to load data.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,375 @@
|
||||
body { margin: 0; padding: 0; font-family: 'Inter', sans-serif; overflow: hidden; }
|
||||
#map { height: 100vh; width: 100vw; z-index: 1; }
|
||||
|
||||
/* Navbar */
|
||||
#navbar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
padding: 15px 30px;
|
||||
background: linear-gradient(180deg, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0) 100%);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
box-sizing: border-box;
|
||||
pointer-events: none; /* Let map clicks go through where empty */
|
||||
}
|
||||
|
||||
.logo { color: white; font-size: 20px; font-weight: 600; text-shadow: 0 2px 4px rgba(0,0,0,0.5); pointer-events: auto; }
|
||||
.auth-controls { display: flex; align-items: center; gap: 10px; pointer-events: auto; }
|
||||
|
||||
.nav-btn {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
backdrop-filter: blur(5px);
|
||||
border: 1px solid rgba(255,255,255,0.3);
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.nav-btn:hover { background: rgba(255, 255, 255, 0.3); }
|
||||
.nav-btn.logout { background: rgba(220, 53, 69, 0.7); border: none; }
|
||||
.nav-btn.logout:hover { background: rgba(220, 53, 69, 0.9); }
|
||||
|
||||
/* Mapping Toolbar */
|
||||
#toolbar {
|
||||
position: absolute;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
|
||||
z-index: 1000;
|
||||
width: 220px;
|
||||
transition: transform 0.3s, opacity 0.3s;
|
||||
}
|
||||
#toolbar.hidden { transform: translateX(120%); opacity: 0; pointer-events: none; }
|
||||
#toolbar h3 { margin: 0 0 15px 0; font-size: 16px; color: #333; }
|
||||
|
||||
.tool-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.tool-btn:hover { background: #0056b3; transform: translateY(-1px); }
|
||||
.tool-btn.cancel-btn { background: #dc3545; }
|
||||
.tool-btn.cancel-btn:hover { background: #c82333; }
|
||||
#status-text { font-size: 13px; color: #666; margin: 5px 0 0 0; text-align: center; }
|
||||
|
||||
/* Overlays (Glassmorphism) */
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 0; left: 0; width: 100%; height: 100%;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(8px);
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.overlay.hidden { opacity: 0; pointer-events: none; }
|
||||
|
||||
.glass-panel {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(20px);
|
||||
padding: 40px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 16px 32px rgba(0,0,0,0.2);
|
||||
width: 100%;
|
||||
max-width: 350px;
|
||||
text-align: center;
|
||||
border: 1px solid rgba(255,255,255,0.4);
|
||||
transform: translateY(0);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
.overlay.hidden .glass-panel { transform: translateY(20px); }
|
||||
|
||||
.glass-panel h2 { margin: 0 0 10px 0; color: #1a1a1a; }
|
||||
.glass-panel p { color: #666; font-size: 14px; margin-bottom: 20px; }
|
||||
|
||||
.glass-panel input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
margin-bottom: 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background: rgba(255,255,255,0.9);
|
||||
font-family: 'Inter', sans-serif;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.glass-panel input:focus { outline: none; border-color: #007bff; }
|
||||
|
||||
.primary-btn {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: linear-gradient(135deg, #007bff, #0056b3);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.primary-btn:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,123,255,0.3); }
|
||||
.text-btn { background: transparent; border: none; color: #666; font-weight: 500; cursor: pointer; padding: 10px; width: 100%; }
|
||||
.text-btn:hover { color: #333; }
|
||||
|
||||
.error-msg { color: #dc3545 !important; margin-top: 10px; font-weight: 500; height: 20px; font-size: 13px;}
|
||||
.msg { color: #28a745 !important; margin-top: 10px; font-weight: 500; height: 20px; font-size: 13px;}
|
||||
|
||||
/* Map Popup Overrides */
|
||||
.popup-form { display: flex; flex-direction: column; gap: 8px; font-family: 'Inter', sans-serif;}
|
||||
.popup-form input { padding: 8px; border: 1px solid #ccc; border-radius: 4px; }
|
||||
.popup-form select { padding: 8px; border: 1px solid #ccc; border-radius: 4px; background: white; font-family: 'Inter', sans-serif; }
|
||||
.popup-form button { padding: 8px; border-radius: 4px; border: none; font-weight: 500; cursor: pointer; color: white; background: #007bff; transition: background 0.2s;}
|
||||
.popup-form button:hover { background: #0056b3; }
|
||||
|
||||
/* ================================================
|
||||
LOGISTICS DASHBOARD SIDEBAR
|
||||
================================================ */
|
||||
#logistics-panel {
|
||||
position: absolute;
|
||||
top: 70px;
|
||||
left: 20px;
|
||||
width: 300px;
|
||||
max-height: calc(100vh - 100px);
|
||||
background: rgba(15, 23, 42, 0.92);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 48px rgba(0,0,0,0.4);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s;
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
#logistics-panel.hidden {
|
||||
transform: translateX(-120%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.logistics-header {
|
||||
padding: 18px 20px 14px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.logistics-header h3 {
|
||||
margin: 0 0 2px 0;
|
||||
color: #f8fafc;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.logistics-header p {
|
||||
margin: 0;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
.logistics-refresh-btn {
|
||||
position: absolute;
|
||||
top: 16px; right: 16px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
color: white;
|
||||
width: 32px; height: 32px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.logistics-refresh-btn:hover { background: rgba(255,255,255,0.2); }
|
||||
|
||||
#logistics-content {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding: 12px 14px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255,255,255,0.15) transparent;
|
||||
}
|
||||
#logistics-content::-webkit-scrollbar { width: 4px; }
|
||||
#logistics-content::-webkit-scrollbar-track { background: transparent; }
|
||||
#logistics-content::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 4px; }
|
||||
|
||||
/* Summary totals strip */
|
||||
.logistics-summary {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.summary-item {
|
||||
flex: 1;
|
||||
background: rgba(99, 102, 241, 0.25);
|
||||
border: 1px solid rgba(99,102,241,0.35);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.summary-num {
|
||||
display: block;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #a5b4fc;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.summary-label {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Per-place logistics cards */
|
||||
.logistics-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 10px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.logistics-card:hover { background: rgba(255,255,255,0.1); }
|
||||
|
||||
/* House filter control styling */
|
||||
.house-filter-control {
|
||||
background: rgba(255,255,255,0.94);
|
||||
backdrop-filter: blur(8px);
|
||||
padding: 8px 10px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(2,6,23,0.06);
|
||||
box-shadow: 0 8px 20px rgba(2,6,23,0.12);
|
||||
font-size: 13px;
|
||||
color: #0f172a;
|
||||
width: auto;
|
||||
}
|
||||
.house-filter-control .house-filter-title { font-weight: 700; margin-bottom: 6px; }
|
||||
.house-filter-control .house-filter-options { display: flex; gap: 6px; align-items: center; }
|
||||
.house-filter-control .switch { display: inline-block; }
|
||||
.house-filter-control .switch input { display: none; }
|
||||
.house-filter-control .switch .switch-slider {
|
||||
display: inline-block;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
color: #0f172a;
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(2,6,23,0.04);
|
||||
font-weight: 600;
|
||||
transition: all 0.18s;
|
||||
}
|
||||
.house-filter-control .switch input:checked + .switch-slider {
|
||||
background: linear-gradient(135deg,#007bff,#0056b3);
|
||||
color: #fff;
|
||||
box-shadow: 0 6px 18px rgba(0,123,255,0.16);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.logistics-card-icon {
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.logistics-card-body { flex: 1; min-width: 0; }
|
||||
.logistics-card-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.logistics-card-stat {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.logistics-card-residents {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
.pending-verification-panel {
|
||||
margin-top: 18px;
|
||||
border-top: 1px solid rgba(255,255,255,0.12);
|
||||
padding-top: 14px;
|
||||
}
|
||||
.pending-verification-panel h4 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 14px;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.pending-card {
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.pending-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
gap: 10px;
|
||||
}
|
||||
.pending-card-header span {
|
||||
font-size: 12px;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
.verify-btn {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.verify-btn:hover {
|
||||
background: #059669;
|
||||
}
|
||||
.pending-card-body div {
|
||||
font-size: 12px;
|
||||
color: #cbd5e1;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.stat-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
}
|
||||
.stat-badge.need {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #fca5a5;
|
||||
border: 1px solid rgba(239,68,68,0.3);
|
||||
}
|
||||
Reference in New Issue
Block a user