Fix: Convert 03PovertyMapping from submodule to regular folder
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
/* ============================================================
|
||||
admin.js — Admin panel: public reports (now accessible to Field Officers)
|
||||
============================================================ */
|
||||
'use strict';
|
||||
|
||||
async function openAdminPanel() {
|
||||
openModal('adminModal');
|
||||
loadPendingReports();
|
||||
updatePendingBadge();
|
||||
}
|
||||
|
||||
async function updatePendingBadge() {
|
||||
try {
|
||||
const r = await fetch('api/public/report.php?action=list&status=pending&limit=1');
|
||||
const d = await r.json();
|
||||
const cnt = d?.data?.total ?? 0;
|
||||
const el = document.getElementById('pendingBadge');
|
||||
if (el) el.textContent = cnt > 0 ? cnt : '';
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Status filter change - tetap tersedia untuk semua role
|
||||
document.getElementById('pendingStatusFilter')
|
||||
?.addEventListener('change', loadPendingReports);
|
||||
|
||||
// Refresh badge every 90 seconds
|
||||
setInterval(updatePendingBadge, 90_000);
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
/* ============================================================
|
||||
api.js — Centralized fetch wrapper
|
||||
============================================================ */
|
||||
'use strict';
|
||||
|
||||
const Http = {
|
||||
async request(url, options = {}) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
};
|
||||
try {
|
||||
const res = await fetch(url, { ...options, headers });
|
||||
const data = await res.json();
|
||||
return { ok: res.ok, status: res.status, data };
|
||||
} catch (err) {
|
||||
console.error('[API] Network error:', err);
|
||||
return { ok: false, status: 0, data: { success: false, message: 'Koneksi gagal.' } };
|
||||
}
|
||||
},
|
||||
async post(endpoint, body = {}) {
|
||||
return this.request(endpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const ApiHouses = {
|
||||
async list(params = {}) {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
return Http.request(API.houses + (qs ? '?' + qs : ''), { method: 'GET' });
|
||||
},
|
||||
async show(id) {
|
||||
return Http.request(API.houses + '?action=show&id=' + id, { method: 'GET' });
|
||||
},
|
||||
async create(body) {
|
||||
return Http.post(API.houses + '?action=create', body);
|
||||
},
|
||||
async update(id, body) {
|
||||
return Http.post(API.houses + '?action=update&id=' + id, body);
|
||||
},
|
||||
async patch(id, body) {
|
||||
return Http.post(API.houses + '?action=patch&id=' + id, body);
|
||||
},
|
||||
async delete(id) {
|
||||
return Http.post(API.houses + '?action=delete&id=' + id);
|
||||
},
|
||||
async verify(id) {
|
||||
return Http.post(API.houses + '?action=verify&id=' + id);
|
||||
},
|
||||
};
|
||||
|
||||
const ApiCenters = {
|
||||
async list(params = {}) {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
return Http.request(API.centers + (qs ? '?' + qs : ''), { method: 'GET' });
|
||||
},
|
||||
async show(id) {
|
||||
return Http.request(API.centers + '?action=show&id=' + id, { method: 'GET' });
|
||||
},
|
||||
async create(body) {
|
||||
return Http.post(API.centers + '?action=create', body);
|
||||
},
|
||||
async update(id, body) {
|
||||
return Http.post(API.centers + '?action=update&id=' + id, body);
|
||||
},
|
||||
async patch(id, body) {
|
||||
return Http.post(API.centers + '?action=patch&id=' + id, body);
|
||||
},
|
||||
async delete(id) {
|
||||
return Http.post(API.centers + '?action=delete&id=' + id);
|
||||
},
|
||||
async coverage(id) {
|
||||
return Http.request(API.centers + '?action=coverage&id=' + id, { method: 'GET' });
|
||||
},
|
||||
async nearby(lat, lng, km = 5) {
|
||||
return Http.request(API.centers + `?action=nearby&lat=${lat}&lng=${lng}&km=${km}`, { method: 'GET' });
|
||||
},
|
||||
};
|
||||
|
||||
const ApiAid = {
|
||||
async list(params = {}) {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
return Http.request(API.aid + (qs ? '?' + qs : ''), { method: 'GET' });
|
||||
},
|
||||
async show(id) {
|
||||
return Http.request(API.aid + '?action=show&id=' + id, { method: 'GET' });
|
||||
},
|
||||
async create(body) {
|
||||
return Http.post(API.aid + '?action=create', body);
|
||||
},
|
||||
async update(id, body) {
|
||||
return Http.post(API.aid + '?action=update&id=' + id, body);
|
||||
},
|
||||
async delete(id) {
|
||||
return Http.post(API.aid + '?action=delete&id=' + id);
|
||||
},
|
||||
async stats() {
|
||||
return Http.request(API.aid + '?action=stats', { method: 'GET' });
|
||||
},
|
||||
};
|
||||
|
||||
const ApiReports = {
|
||||
async list(params = {}) {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
return Http.request(API.reports + (qs ? '?' + qs : ''), { method: 'GET' });
|
||||
},
|
||||
async show(id) {
|
||||
return Http.request(API.reports + '?action=show&id=' + id, { method: 'GET' });
|
||||
},
|
||||
async create(body) {
|
||||
return Http.post(API.reports + '?action=create', body);
|
||||
},
|
||||
async update(id, body) {
|
||||
return Http.post(API.reports + '?action=update&id=' + id, body);
|
||||
},
|
||||
async resolve(id) {
|
||||
return Http.post(API.reports + '?action=resolve&id=' + id);
|
||||
},
|
||||
async delete(id) {
|
||||
return Http.post(API.reports + '?action=delete&id=' + id);
|
||||
},
|
||||
};
|
||||
|
||||
const ApiStats = {
|
||||
async overview() {
|
||||
return Http.request(API.stats + '?action=overview', { method: 'GET' });
|
||||
},
|
||||
async trend() {
|
||||
return Http.request(API.stats + '?action=trend', { method: 'GET' });
|
||||
},
|
||||
async povertyChart() {
|
||||
return Http.request(API.stats + '?action=poverty_chart', { method: 'GET' });
|
||||
},
|
||||
async aidChart() {
|
||||
return Http.request(API.stats + '?action=aid_chart', { method: 'GET' });
|
||||
},
|
||||
async centerStats() {
|
||||
return Http.request(API.stats + '?action=center_stats', { method: 'GET' });
|
||||
},
|
||||
async ageDistribution() {
|
||||
return Http.request(API.stats + '?action=age_distribution', { method: 'GET' });
|
||||
},
|
||||
async education() {
|
||||
return Http.request(API.stats + '?action=education', { method: 'GET' });
|
||||
},
|
||||
};
|
||||
|
||||
const ApiUsers = {
|
||||
async list(params = {}) {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
return Http.request(API.users + (qs ? '?' + qs : ''), { method: 'GET' });
|
||||
},
|
||||
};
|
||||
|
||||
const ApiLogs = {
|
||||
async list(params = {}) {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
return Http.request(API.logs + (qs ? '?' + qs : ''), { method: 'GET' });
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,246 @@
|
||||
/* ============================================================
|
||||
app.js — Main app orchestrator with authentication
|
||||
============================================================ */
|
||||
'use strict';
|
||||
|
||||
// Flag to prevent duplicate initialization
|
||||
let isAppInitialized = false;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Prevent duplicate initialization
|
||||
if (isAppInitialized) {
|
||||
console.warn('App already initialized, skipping duplicate call');
|
||||
return;
|
||||
}
|
||||
|
||||
// First check authentication
|
||||
const isAuthed = await checkAuth();
|
||||
|
||||
if (!isAuthed) {
|
||||
// Not logged in, redirect to login
|
||||
window.location.href = 'login.html';
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize UI based on role
|
||||
initUIByRole();
|
||||
|
||||
// Add logout button to sidebar
|
||||
addLogoutButton();
|
||||
|
||||
// Init map
|
||||
initMap();
|
||||
|
||||
// Init UI
|
||||
initNavTabs();
|
||||
initFilters();
|
||||
initFormTabs();
|
||||
initHelpModal();
|
||||
|
||||
// Load data
|
||||
await loadAllData();
|
||||
|
||||
// Nav tab: activate placement modes
|
||||
hookTabPlacementModes();
|
||||
|
||||
isAppInitialized = true;
|
||||
});
|
||||
|
||||
async function refreshCenters() {
|
||||
const r = await ApiCenters.list();
|
||||
if (r.ok && r.data?.success) {
|
||||
State.centers = r.data.data.centers || [];
|
||||
recountCenterHouseholds(); // Hitung ulang setelah dapat data baru
|
||||
renderCenters();
|
||||
updateLayerCounts();
|
||||
}
|
||||
}
|
||||
|
||||
// Override loadAllData untuk memastikan recount
|
||||
async function loadAllData() {
|
||||
showLoading(true);
|
||||
|
||||
const params = {};
|
||||
if (State.povertyFilter) params.poverty_status = State.povertyFilter;
|
||||
if (State.aidFilter) params.aid_status = State.aidFilter;
|
||||
if (State.searchQuery) params.q = State.searchQuery;
|
||||
if (State.conditionFilter) params.house_condition = State.conditionFilter;
|
||||
params.limit = 500;
|
||||
|
||||
const [centersRes, housesRes] = await Promise.all([
|
||||
ApiCenters.list(),
|
||||
ApiHouses.list(params),
|
||||
loadStats()
|
||||
]);
|
||||
|
||||
if (centersRes.ok && centersRes.data?.success) {
|
||||
State.centers = centersRes.data.data.centers || [];
|
||||
}
|
||||
|
||||
if (housesRes.ok && housesRes.data?.success) {
|
||||
State.houses = housesRes.data.data.households || [];
|
||||
}
|
||||
|
||||
recountCenterHouseholds(); // ⭐ PASTIKAN HITUNG ULANG
|
||||
renderCenters();
|
||||
renderHouses();
|
||||
|
||||
showLoading(false);
|
||||
}
|
||||
|
||||
// Export fungsi refresh
|
||||
window.refreshCenters = refreshCenters;
|
||||
|
||||
async function loadCenters() {
|
||||
const r = await ApiCenters.list();
|
||||
if (r.ok && r.data?.success) {
|
||||
State.centers = r.data.data.centers || [];
|
||||
}
|
||||
renderCenters();
|
||||
}
|
||||
|
||||
async function loadHouses() {
|
||||
const params = {};
|
||||
if (State.povertyFilter) params.poverty_status = State.povertyFilter;
|
||||
if (State.aidFilter) params.aid_status = State.aidFilter;
|
||||
if (State.searchQuery) params.q = State.searchQuery;
|
||||
if (State.conditionFilter) params.house_condition = State.conditionFilter;
|
||||
|
||||
const r = await ApiHouses.list({ ...params, limit: 500 });
|
||||
if (r.ok && r.data?.success) {
|
||||
State.houses = r.data.data.households || [];
|
||||
}
|
||||
renderHouses();
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
const r = await ApiStats.overview();
|
||||
if (!r.ok || !r.data?.success) return;
|
||||
|
||||
const d = r.data.data;
|
||||
State.stats = d;
|
||||
|
||||
animateCount('statCenters', d.centers);
|
||||
animateCount('statHouses', d.households);
|
||||
animateCount('statPopulation', d.population);
|
||||
animateCount('statAided', d.aid_received);
|
||||
animateCount('statPending', d.pending_public ?? 0);
|
||||
}
|
||||
|
||||
// Export for public-reports.js
|
||||
window.loadAllData = loadAllData;
|
||||
window.loadStats = loadStats;
|
||||
|
||||
function animateCount(id, target) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
const start = parseInt(el.textContent.replace(/\D/g,'')) || 0;
|
||||
const diff = target - start;
|
||||
const steps = 20;
|
||||
let step = 0;
|
||||
const interval = setInterval(() => {
|
||||
step++;
|
||||
el.textContent = Math.round(start + (diff * step / steps)).toLocaleString('id-ID');
|
||||
if (step >= steps) clearInterval(interval);
|
||||
}, 30);
|
||||
}
|
||||
|
||||
function initNavTabs() {
|
||||
document.querySelectorAll('.nav-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
const key = tab.dataset.tab;
|
||||
document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
|
||||
if (key === 'dashboard') {
|
||||
openDashboard();
|
||||
setTimeout(() => {
|
||||
document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelector('[data-tab="overview"]').classList.add('active');
|
||||
}, 200);
|
||||
} else if (key === 'overview') {
|
||||
cancelPlacementMode();
|
||||
State.activeFilter = 'all';
|
||||
renderAllLayers();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function hookTabPlacementModes() {
|
||||
document.querySelector('[data-tab="centers"]')?.addEventListener('click', () => {
|
||||
setPlacementMode('center');
|
||||
showToast('Klik peta untuk menambah tempat ibadah.', 'success', 3000);
|
||||
});
|
||||
|
||||
document.querySelector('[data-tab="houses"]')?.addEventListener('click', () => {
|
||||
setPlacementMode('house');
|
||||
showToast('Klik peta untuk menambah rumah tangga.', 'success', 3000);
|
||||
});
|
||||
}
|
||||
|
||||
function initFilters() {
|
||||
document.querySelectorAll('.filter-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
State.activeFilter = btn.dataset.filter;
|
||||
renderAllLayers();
|
||||
});
|
||||
});
|
||||
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', debounce(async (e) => {
|
||||
State.searchQuery = e.target.value.trim();
|
||||
renderAllLayers();
|
||||
}, 300));
|
||||
}
|
||||
}
|
||||
|
||||
let helpStep = 1;
|
||||
const helpTotal = 5;
|
||||
|
||||
function initHelpModal() {
|
||||
document.getElementById('helpBtn')?.addEventListener('click', () => {
|
||||
helpStep = 1;
|
||||
updateHelpStep();
|
||||
openModal('helpModal');
|
||||
});
|
||||
}
|
||||
|
||||
function helpNav(dir) {
|
||||
helpStep = Math.min(helpTotal, Math.max(1, helpStep + dir));
|
||||
updateHelpStep();
|
||||
}
|
||||
|
||||
function updateHelpStep() {
|
||||
document.querySelectorAll('.help-step').forEach(s => s.classList.remove('active'));
|
||||
document.querySelector(`.help-step[data-step="${helpStep}"]`)?.classList.add('active');
|
||||
document.getElementById('helpProgress').textContent = helpStep + ' / ' + helpTotal;
|
||||
document.getElementById('helpPrev').disabled = helpStep === 1;
|
||||
document.getElementById('helpNext').disabled = helpStep === helpTotal;
|
||||
if (helpStep === helpTotal) {
|
||||
document.getElementById('helpNext').textContent = 'Selesai';
|
||||
document.getElementById('helpNext').onclick = () => closeModal('helpModal');
|
||||
} else {
|
||||
document.getElementById('helpNext').textContent = 'Berikutnya →';
|
||||
document.getElementById('helpNext').onclick = () => helpNav(1);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
cancelPlacementMode();
|
||||
document.querySelectorAll('.modal-overlay').forEach(m => {
|
||||
if (m.style.display === 'flex') {
|
||||
m.style.display = 'none';
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
if (e.ctrlKey && e.key === 'r') {
|
||||
e.preventDefault();
|
||||
loadAllData().then(() => showToast('Data diperbarui.', 'success'));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
/* ============================================================
|
||||
auth.js — Authentication and role-based UI management
|
||||
============================================================ */
|
||||
'use strict';
|
||||
|
||||
// Global user state
|
||||
let currentUser = null;
|
||||
let isLoggedIn = false;
|
||||
let userRole = null;
|
||||
|
||||
// ================================================================
|
||||
// Session check on page load
|
||||
// ================================================================
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const response = await fetch('api/auth/check.php');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data && result.data.logged_in === true) {
|
||||
isLoggedIn = true;
|
||||
currentUser = {
|
||||
id: result.data.user_id,
|
||||
name: result.data.name,
|
||||
email: result.data.email,
|
||||
role: result.data.role
|
||||
};
|
||||
userRole = result.data.role;
|
||||
|
||||
window.userRole = userRole;
|
||||
window.currentUser = currentUser;
|
||||
window.currentUserName = currentUser.name;
|
||||
|
||||
return true;
|
||||
} else {
|
||||
isLoggedIn = false;
|
||||
currentUser = null;
|
||||
userRole = null;
|
||||
window.userRole = null;
|
||||
window.currentUser = null;
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth check failed:', error);
|
||||
isLoggedIn = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Initialize UI based on user role
|
||||
// ================================================================
|
||||
function initUIByRole() {
|
||||
if (!isLoggedIn || !userRole) {
|
||||
window.location.href = 'login.html';
|
||||
return;
|
||||
}
|
||||
|
||||
const isAdmin = (userRole === 'admin');
|
||||
|
||||
window.canDelete = isAdmin;
|
||||
|
||||
const chartTab = document.querySelector('.nav-tab[data-tab="dashboard"]');
|
||||
if (chartTab) {
|
||||
chartTab.style.display = isAdmin ? '' : 'none';
|
||||
}
|
||||
|
||||
const adminPanelBtn = document.querySelector('.btn-admin-panel');
|
||||
if (adminPanelBtn) {
|
||||
adminPanelBtn.style.display = ''; // SEMUA USER BISA LIHAT (Field Officer juga)
|
||||
}
|
||||
|
||||
const adminTopBar = document.querySelector('.admin-top-bar');
|
||||
if (adminTopBar) {
|
||||
adminTopBar.style.display = isAdmin ? '' : 'none';
|
||||
}
|
||||
|
||||
const userNameEl = document.getElementById('userName');
|
||||
const userRoleEl = document.getElementById('userRoleDisplay');
|
||||
if (userNameEl && currentUser) {
|
||||
userNameEl.textContent = currentUser.name;
|
||||
}
|
||||
if (userRoleEl && currentUser) {
|
||||
const roleLabel = userRole === 'admin' ? 'Admin' : 'Petugas Lapangan';
|
||||
userRoleEl.textContent = roleLabel;
|
||||
}
|
||||
|
||||
console.log(`UI initialized for role: ${userRole} (Admin: ${isAdmin})`);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Logout function
|
||||
// ================================================================
|
||||
async function logout() {
|
||||
try {
|
||||
await fetch('api/auth/check.php?action=logout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
window.location.href = 'login.html';
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
window.location.href = 'login.html';
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Add logout button to sidebar (only if not already exists)
|
||||
// ================================================================
|
||||
function addLogoutButton() {
|
||||
// CEK: apakah tombol logout sudah ada di HTML (dari index.html)
|
||||
const existingLogoutBtn = document.querySelector('.sidebar-header .logout-btn, #logoutBtn');
|
||||
if (existingLogoutBtn) {
|
||||
console.log('Logout button already exists, skipping dynamic addition');
|
||||
return; // JANGAN tambahkan tombol baru jika sudah ada
|
||||
}
|
||||
|
||||
// Fallback: jika belum ada, baru tambahkan
|
||||
const sidebarHeader = document.querySelector('.sidebar-header');
|
||||
if (!sidebarHeader) return;
|
||||
|
||||
const logoutBtn = document.createElement('button');
|
||||
logoutBtn.id = 'logoutBtn';
|
||||
logoutBtn.className = 'logout-btn';
|
||||
logoutBtn.innerHTML = '<i class="fas fa-sign-out-alt"></i>';
|
||||
logoutBtn.title = 'Logout';
|
||||
logoutBtn.onclick = logout;
|
||||
logoutBtn.style.cssText = `
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--danger);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
padding: 5px;
|
||||
margin-left: 10px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.14s;
|
||||
`;
|
||||
logoutBtn.onmouseenter = () => logoutBtn.style.backgroundColor = 'rgba(214,50,48,0.1)';
|
||||
logoutBtn.onmouseleave = () => logoutBtn.style.backgroundColor = 'transparent';
|
||||
|
||||
const headerRight = sidebarHeader.querySelector('div[style*="margin-left:auto"]');
|
||||
if (headerRight) {
|
||||
headerRight.appendChild(logoutBtn);
|
||||
}
|
||||
}
|
||||
|
||||
function canDelete() {
|
||||
return userRole === 'admin';
|
||||
}
|
||||
|
||||
function getUserRole() {
|
||||
return userRole;
|
||||
}
|
||||
|
||||
function getUserName() {
|
||||
return currentUser ? currentUser.name : '';
|
||||
}
|
||||
|
||||
window.auth = {
|
||||
checkAuth,
|
||||
initUIByRole,
|
||||
logout,
|
||||
addLogoutButton,
|
||||
canDelete,
|
||||
getUserRole,
|
||||
getUserName,
|
||||
currentUser: () => currentUser,
|
||||
isLoggedIn: () => isLoggedIn,
|
||||
userRole: () => userRole
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
/* ============================================================
|
||||
config.js — Global constants, utilities, shared state
|
||||
Single-admin simplified version
|
||||
============================================================ */
|
||||
'use strict';
|
||||
|
||||
const API = {
|
||||
houses: 'api/houses/index.php',
|
||||
centers: 'api/centers/index.php',
|
||||
aid: 'api/aid/index.php',
|
||||
reports: 'api/reports/index.php',
|
||||
stats: 'api/stats/index.php',
|
||||
users: 'api/users/index.php',
|
||||
logs: 'api/logs/index.php',
|
||||
};
|
||||
|
||||
const POVERTY_COLORS = {
|
||||
sangat_miskin: '#d63230',
|
||||
miskin: '#f76707',
|
||||
rentan_miskin: '#f59e0b',
|
||||
terdata: '#0b9e73',
|
||||
};
|
||||
|
||||
const POVERTY_LABELS = {
|
||||
sangat_miskin: 'Sangat Miskin',
|
||||
miskin: 'Miskin',
|
||||
rentan_miskin: 'Rentan Miskin',
|
||||
terdata: 'Terdata',
|
||||
};
|
||||
|
||||
// Short labels for compact display
|
||||
const POVERTY_SHORT = {
|
||||
sangat_miskin: 'Sgt Miskin',
|
||||
miskin: 'Miskin',
|
||||
rentan_miskin: 'Rentan',
|
||||
terdata: 'Terdata',
|
||||
};
|
||||
|
||||
const CENTER_COLORS = {
|
||||
masjid: '#1d6fa4',
|
||||
gereja: '#7c3aed',
|
||||
klenteng: '#b45309',
|
||||
pura: '#0e7f6e',
|
||||
vihara: '#a16207',
|
||||
};
|
||||
|
||||
const CENTER_ICONS = {
|
||||
masjid: 'fa-mosque',
|
||||
gereja: 'fa-church',
|
||||
klenteng: 'fa-torii-gate',
|
||||
pura: 'fa-om',
|
||||
vihara: 'fa-dharmachakra',
|
||||
};
|
||||
|
||||
const CENTER_LABELS = {
|
||||
masjid: 'Masjid',
|
||||
gereja: 'Gereja',
|
||||
klenteng: 'Klenteng',
|
||||
pura: 'Pura',
|
||||
vihara: 'Vihara',
|
||||
};
|
||||
|
||||
const AID_LABELS = {
|
||||
sembako: 'Sembako',
|
||||
pendanaan: 'Pendanaan',
|
||||
pelatihan: 'Pelatihan',
|
||||
sembako_pendanaan: 'Sembako + Pendanaan',
|
||||
sembako_pelatihan: 'Sembako + Pelatihan',
|
||||
pendanaan_pelatihan: 'Pendanaan + Pelatihan',
|
||||
lengkap: 'Lengkap',
|
||||
};
|
||||
|
||||
const SEVERITY_COLORS = {
|
||||
kritis: '#d63230',
|
||||
berat: '#f76707',
|
||||
sedang: '#f59e0b',
|
||||
ringan: '#0b9e73',
|
||||
};
|
||||
|
||||
const State = {
|
||||
activeFilter: 'all',
|
||||
povertyFilter: '',
|
||||
aidFilter: '',
|
||||
conditionFilter: '',
|
||||
searchQuery: '',
|
||||
centers: [],
|
||||
houses: [],
|
||||
stats: null,
|
||||
};
|
||||
|
||||
function debounce(fn, delay = 300) {
|
||||
let t;
|
||||
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), delay); };
|
||||
}
|
||||
|
||||
function formatRp(n) {
|
||||
if (!n) return 'Rp 0';
|
||||
return 'Rp ' + Number(n).toLocaleString('id-ID');
|
||||
}
|
||||
|
||||
function formatDate(str) {
|
||||
if (!str) return '—';
|
||||
return new Date(str).toLocaleDateString('id-ID', { day:'2-digit', month:'short', year:'numeric' });
|
||||
}
|
||||
|
||||
function formatDateTime(str) {
|
||||
if (!str) return '—';
|
||||
return new Date(str).toLocaleString('id-ID', { day:'2-digit', month:'short', year:'numeric', hour:'2-digit', minute:'2-digit' });
|
||||
}
|
||||
|
||||
function truncate(str, n = 28) {
|
||||
if (!str) return '';
|
||||
return str.length > n ? str.slice(0, n) + '…' : str;
|
||||
}
|
||||
|
||||
function showToast(msg, type = 'success', duration = 2800) {
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg;
|
||||
el.className = 'toast ' + type;
|
||||
el.style.display = 'block';
|
||||
setTimeout(() => { el.style.display = 'none'; }, duration);
|
||||
}
|
||||
|
||||
function showLoading(show = true) {
|
||||
document.getElementById('loading').style.display = show ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
function openModal(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) { el.style.display = 'flex'; document.body.style.overflow = 'hidden'; }
|
||||
}
|
||||
function closeModal(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) { el.style.display = 'none'; document.body.style.overflow = ''; }
|
||||
}
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('modal-overlay')) {
|
||||
e.target.style.display = 'none';
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
/* ============================================================
|
||||
dashboard.js — Chart.js analytics dashboard
|
||||
============================================================ */
|
||||
'use strict';
|
||||
|
||||
let charts = {};
|
||||
|
||||
async function openDashboard() {
|
||||
openModal('dashboardModal');
|
||||
// Always fetch fresh data when opening
|
||||
await renderDashboard();
|
||||
}
|
||||
|
||||
function destroyChart(id) {
|
||||
if (charts[id]) {
|
||||
charts[id].destroy();
|
||||
delete charts[id];
|
||||
}
|
||||
}
|
||||
|
||||
async function renderDashboard() {
|
||||
showLoading(true);
|
||||
|
||||
// Destroy all existing charts before re-rendering
|
||||
Object.keys(charts).forEach(key => destroyChart(key));
|
||||
|
||||
try {
|
||||
const [overview, trend, poverty, aidStat, age] = await Promise.all([
|
||||
ApiStats.overview(),
|
||||
ApiStats.trend(),
|
||||
ApiStats.povertyChart(),
|
||||
ApiStats.aidChart(),
|
||||
ApiStats.ageDistribution(),
|
||||
]);
|
||||
|
||||
showLoading(false);
|
||||
|
||||
if (poverty && poverty.ok) renderPovertyChart(poverty.data.data);
|
||||
if (trend && trend.ok) renderTrendChart(trend.data.data);
|
||||
if (age && age.ok) renderAgeChart(age.data.data);
|
||||
if (aidStat && aidStat.ok) renderAidChart(aidStat.data.data);
|
||||
} catch (err) {
|
||||
showLoading(false);
|
||||
console.error('Dashboard render error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function chartDefaults() {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
font: { family: "'DM Sans', sans-serif", size: 10 },
|
||||
color: '#5a6478',
|
||||
padding: 12,
|
||||
usePointStyle: true,
|
||||
pointStyleWidth: 8,
|
||||
boxHeight: 8,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: '#0f1623',
|
||||
titleFont: { family: "'DM Sans', sans-serif", size: 11, weight: '700' },
|
||||
bodyFont: { family: "'DM Sans', sans-serif", size: 11 },
|
||||
padding: 10,
|
||||
cornerRadius: 8,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: 'rgba(0,0,0,0.03)' },
|
||||
ticks: { font: { family: "'DM Sans', sans-serif", size: 9 }, color: '#9ba4b5' },
|
||||
},
|
||||
y: {
|
||||
grid: { color: 'rgba(0,0,0,0.03)' },
|
||||
ticks: { font: { family: "'DM Sans', sans-serif", size: 9 }, color: '#9ba4b5', precision: 0 },
|
||||
beginAtZero: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Poverty distribution (doughnut) -----------------------
|
||||
function renderPovertyChart(data) {
|
||||
destroyChart('poverty');
|
||||
const bd = data?.breakdown || [];
|
||||
if (!bd.length) return;
|
||||
|
||||
const labels = bd.map(r => POVERTY_LABELS[r.poverty_status] || r.poverty_status);
|
||||
const values = bd.map(r => parseInt(r.count));
|
||||
const colors = bd.map(r => POVERTY_COLORS[r.poverty_status] || '#9ba4b5');
|
||||
|
||||
const ctx = document.getElementById('chartPoverty');
|
||||
if (!ctx) return;
|
||||
|
||||
charts.poverty = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{
|
||||
data: values,
|
||||
backgroundColor: colors,
|
||||
borderWidth: 2,
|
||||
borderColor: '#fff',
|
||||
hoverOffset: 8,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
...chartDefaults(),
|
||||
cutout: '62%',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Trend line chart (12 months) --------------------------
|
||||
function renderTrendChart(data) {
|
||||
destroyChart('trend');
|
||||
const rows = data?.trend || [];
|
||||
if (!rows.length) {
|
||||
const ctx = document.getElementById('chartTrend');
|
||||
if (ctx && ctx.parentElement) {
|
||||
ctx.parentElement.innerHTML = '<div style="text-align:center;padding:40px;color:#9ba4b5;"><i class="fas fa-chart-line" style="font-size:24px;margin-bottom:10px;display:block;"></i>Belum ada data pendataan 12 bulan terakhir</div>';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Format bulan (contoh: "2025-01" -> "Jan 2025")
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des'];
|
||||
const labels = rows.map(r => {
|
||||
const [year, month] = r.month.split('-');
|
||||
return `${months[parseInt(month) - 1]} ${year}`;
|
||||
});
|
||||
|
||||
const ctx = document.getElementById('chartTrend');
|
||||
if (!ctx) return;
|
||||
|
||||
charts.trend = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Rumah Baru',
|
||||
data: rows.map(r => r.new_households),
|
||||
backgroundColor: '#46b4f4',
|
||||
borderWidth: 0,
|
||||
borderRadius: 4,
|
||||
},
|
||||
{
|
||||
label: 'Dibantu',
|
||||
data: rows.map(r => r.aided),
|
||||
backgroundColor: '#7d5ce8',
|
||||
borderWidth: 0,
|
||||
borderRadius: 4,
|
||||
}
|
||||
],
|
||||
},
|
||||
options: {
|
||||
...chartDefaults(),
|
||||
plugins: {
|
||||
...chartDefaults().plugins,
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
font: { family: "'DM Sans', sans-serif", size: 10 },
|
||||
color: '#5a6478',
|
||||
usePointStyle: true,
|
||||
pointStyleWidth: 8,
|
||||
boxHeight: 8,
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: {
|
||||
font: { size: 9 },
|
||||
maxRotation: 35,
|
||||
autoSkip: true,
|
||||
maxTicksLimit: 8
|
||||
},
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: 'rgba(0,0,0,0.05)' },
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
precision: 0,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Age distribution of DEPENDENTS (bar) ------------------
|
||||
function renderAgeChart(data) {
|
||||
destroyChart('age');
|
||||
const dist = data?.age_distribution || {};
|
||||
|
||||
// Only dependent age groups
|
||||
const labels = ['Anak (<12)', 'Remaja (12-17)', 'Pemuda (18-30)', 'Dewasa (31-59)', 'Lansia (60+)'];
|
||||
const values = [
|
||||
dist.anak || 0,
|
||||
dist.remaja || 0,
|
||||
dist.pemuda || 0,
|
||||
dist.dewasa || 0,
|
||||
dist.lansia || 0
|
||||
];
|
||||
const colors = ['#7c3aed','#3a56d4','#0b9e73','#d97706','#d63230'];
|
||||
|
||||
// Skip if all zero
|
||||
if (values.every(v => v === 0)) return;
|
||||
|
||||
const ctx = document.getElementById('chartAge');
|
||||
if (!ctx) return;
|
||||
|
||||
charts.age = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{
|
||||
label: 'Jumlah Tanggungan',
|
||||
data: values,
|
||||
backgroundColor: colors.map(c => c + 'cc'),
|
||||
borderColor: colors,
|
||||
borderWidth: 1.5,
|
||||
borderRadius: 5,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
...chartDefaults(),
|
||||
plugins: {
|
||||
...chartDefaults().plugins,
|
||||
legend: { display: false },
|
||||
title: {
|
||||
display: true,
|
||||
font: { family: "'DM Sans', sans-serif", size: 11 },
|
||||
color: '#5a6478',
|
||||
padding: { bottom: 10 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Aid distribution (pie) --------------------------------
|
||||
function renderAidChart(data) {
|
||||
destroyChart('aid');
|
||||
const byType = data?.by_type || [];
|
||||
if (!byType.length) return;
|
||||
|
||||
const colors = ['#3a56d4','#0b9e73','#d97706','#7c3aed','#d63230','#0e7f6e','#b45309'];
|
||||
|
||||
const ctx = document.getElementById('chartAid');
|
||||
if (!ctx) return;
|
||||
|
||||
charts.aid = new Chart(ctx, {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: byType.map(r => AID_LABELS[r.aid_type] || r.aid_type),
|
||||
datasets: [{
|
||||
data: byType.map(r => parseInt(r.cnt)),
|
||||
backgroundColor: byType.map((_, i) => colors[i % colors.length] + 'cc'),
|
||||
borderColor: byType.map((_, i) => colors[i % colors.length]),
|
||||
borderWidth: 1.5,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
...chartDefaults(),
|
||||
plugins: {
|
||||
...chartDefaults().plugins,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Total: ' + (data?.summary?.total_distributions || 0) + ' distribusi',
|
||||
font: { family: "'DM Sans', sans-serif", size: 10 },
|
||||
color: '#9ba4b5',
|
||||
padding: { bottom: 8 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,176 @@
|
||||
/* ============================================================
|
||||
map.js — Leaflet map initialisation + layer management
|
||||
============================================================ */
|
||||
'use strict';
|
||||
|
||||
// ---- Map instance + layer groups ---------------------------
|
||||
let MAP;
|
||||
let layerCenters, layerHouses, layerReports;
|
||||
let layerVisible = { centers: true, houses: true, reports: true };
|
||||
|
||||
// ---- Placement mode: 'center' | 'house' | null -------------
|
||||
let placementMode = null;
|
||||
let tempMarker = null;
|
||||
|
||||
// ---- Init --------------------------------------------------
|
||||
function initMap() {
|
||||
MAP = L.map('map', {
|
||||
center: [-0.0236, 109.3426], // Pontianak, Kalimantan Barat
|
||||
zoom: 13,
|
||||
zoomControl: false,
|
||||
});
|
||||
|
||||
L.control.zoom({ position: 'bottomright' }).addTo(MAP);
|
||||
|
||||
// Tile layers
|
||||
const osmTile = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
maxZoom: 19,
|
||||
});
|
||||
const satelliteTile = L.tileLayer(
|
||||
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
|
||||
attribution: 'Tiles © Esri',
|
||||
maxZoom: 19,
|
||||
});
|
||||
|
||||
osmTile.addTo(MAP);
|
||||
|
||||
// Expose for layer switcher
|
||||
MAP._baseLayers = { osm: osmTile, satellite: satelliteTile };
|
||||
MAP._activeBase = 'osm';
|
||||
|
||||
// Layer groups
|
||||
layerCenters = L.layerGroup().addTo(MAP);
|
||||
layerHouses = L.layerGroup().addTo(MAP);
|
||||
layerReports = L.layerGroup().addTo(MAP);
|
||||
|
||||
// Map click → placement
|
||||
MAP.on('click', onMapClick);
|
||||
|
||||
// Double-click suppression
|
||||
MAP.on('dblclick', (e) => e.originalEvent.preventDefault());
|
||||
}
|
||||
|
||||
// ---- Map click handler -------------------------------------
|
||||
async function onMapClick(e) {
|
||||
if (!placementMode) return;
|
||||
|
||||
const { lat, lng } = e.latlng;
|
||||
|
||||
// Remove temp marker
|
||||
if (tempMarker) { MAP.removeLayer(tempMarker); tempMarker = null; }
|
||||
|
||||
// Place temp crosshair marker
|
||||
tempMarker = L.marker([lat, lng], {
|
||||
icon: L.divIcon({
|
||||
html: `<div style="width:14px;height:14px;border-radius:50%;background:#3a56d4;border:3px solid white;box-shadow:0 2px 8px rgba(0,0,0,0.35);"></div>`,
|
||||
iconSize: [14, 14], iconAnchor: [7, 7], className: '',
|
||||
}),
|
||||
}).addTo(MAP);
|
||||
|
||||
// Reverse geocode
|
||||
showLoading(true);
|
||||
const address = await reverseGeocode(lat, lng);
|
||||
showLoading(false);
|
||||
|
||||
if (placementMode === 'center') {
|
||||
openCenterModal(null, lat, lng, address);
|
||||
} else if (placementMode === 'house') {
|
||||
openHouseModal(null, lat, lng, address);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Reverse Geocode --------------------------------
|
||||
async function reverseGeocode(lat, lng) {
|
||||
try {
|
||||
const url = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&accept-language=id&addressdetails=1&zoom=18`;
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'Accept-Language': 'id',
|
||||
'User-Agent': 'WebGIS-PovertyMapping/2.0'
|
||||
}
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data && data.display_name) {
|
||||
return data.display_name;
|
||||
}
|
||||
|
||||
// Fallback: build address from components
|
||||
if (data && data.address) {
|
||||
const addr = data.address;
|
||||
const parts = [];
|
||||
if (addr.road) parts.push(addr.road);
|
||||
if (addr.house_number) parts.push('No. ' + addr.house_number);
|
||||
if (addr.neighbourhood) parts.push(addr.neighbourhood);
|
||||
if (addr.suburb) parts.push(addr.suburb);
|
||||
if (addr.village) parts.push(addr.village);
|
||||
if (addr.city || addr.town || addr.municipality) parts.push(addr.city || addr.town || addr.municipality);
|
||||
if (addr.state) parts.push(addr.state);
|
||||
if (parts.length > 0) return parts.join(', ');
|
||||
}
|
||||
|
||||
return `${lat.toFixed(6)}, ${lng.toFixed(6)}`;
|
||||
} catch (err) {
|
||||
console.warn('Reverse geocoding failed:', err.message);
|
||||
return `${lat.toFixed(6)}, ${lng.toFixed(6)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Placement mode control --------------------------------
|
||||
function setPlacementMode(mode) {
|
||||
placementMode = mode;
|
||||
const map = document.getElementById('map');
|
||||
map.classList.remove('cursor-add-center', 'cursor-add-house');
|
||||
if (mode === 'center') map.classList.add('cursor-add-center');
|
||||
if (mode === 'house') map.classList.add('cursor-add-house');
|
||||
|
||||
// Highlight nav tabs
|
||||
document.querySelectorAll('.nav-tab').forEach(t => {
|
||||
t.classList.toggle('active', t.dataset.tab === (mode === 'center' ? 'centers' : mode === 'house' ? 'houses' : 'overview'));
|
||||
});
|
||||
}
|
||||
|
||||
function cancelPlacementMode() {
|
||||
placementMode = null;
|
||||
document.getElementById('map').classList.remove('cursor-add-center', 'cursor-add-house');
|
||||
if (tempMarker) { MAP.removeLayer(tempMarker); tempMarker = null; }
|
||||
}
|
||||
|
||||
// ---- Layer toggle ------------------------------------------
|
||||
function toggleLayer(name) {
|
||||
layerVisible[name] = !layerVisible[name];
|
||||
const checkbox = document.getElementById('layerCenters')?.parentElement?.querySelector('#layer' + capitalize(name));
|
||||
const grp = name === 'centers' ? layerCenters : name === 'houses' ? layerHouses : layerReports;
|
||||
|
||||
if (layerVisible[name]) {
|
||||
grp.addTo(MAP);
|
||||
} else {
|
||||
MAP.removeLayer(grp);
|
||||
}
|
||||
|
||||
const cb = document.getElementById('layer' + capitalize(name));
|
||||
if (cb) cb.checked = layerVisible[name];
|
||||
}
|
||||
|
||||
function capitalize(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
|
||||
|
||||
// ---- Fly to point ------------------------------------------
|
||||
function flyTo(lat, lng, zoom = 17) {
|
||||
MAP.flyTo([lat, lng], zoom, { duration: 0.8 });
|
||||
}
|
||||
|
||||
// ---- Clear + rebuild all layers ----------------------------
|
||||
function renderAllLayers() {
|
||||
renderCenters();
|
||||
renderHouses();
|
||||
}
|
||||
|
||||
// ---- Update layer count badges -----------------------------
|
||||
function updateLayerCounts() {
|
||||
const cc = document.getElementById('layerCenterCount');
|
||||
const hc = document.getElementById('layerHouseCount');
|
||||
const rc = document.getElementById('layerReportCount');
|
||||
if (cc) cc.textContent = State.centers.length;
|
||||
if (hc) hc.textContent = State.houses.length;
|
||||
}
|
||||
@@ -0,0 +1,961 @@
|
||||
/* ============================================================
|
||||
markers.js — Draggable markers + inside/outside radius colors
|
||||
============================================================ */
|
||||
'use strict';
|
||||
|
||||
// ====================================================================
|
||||
// UTILITY FUNCTIONS
|
||||
// ====================================================================
|
||||
function escapeHtml(str) {
|
||||
if (str === null || str === undefined) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function truncate(str, n = 28) {
|
||||
if (!str) return '';
|
||||
return str.length > n ? str.slice(0, n) + '…' : str;
|
||||
}
|
||||
|
||||
function formatRp(n) {
|
||||
if (!n) return 'Rp 0';
|
||||
return 'Rp ' + Number(n).toLocaleString('id-ID');
|
||||
}
|
||||
|
||||
function formatDate(str) {
|
||||
if (!str) return '—';
|
||||
return new Date(str).toLocaleDateString('id-ID', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
function educationLabel(edu) {
|
||||
const labels = {
|
||||
'tidak_sekolah': 'Tidak Sekolah',
|
||||
'sd': 'SD',
|
||||
'smp': 'SMP',
|
||||
'sma': 'SMA',
|
||||
'diploma': 'Diploma',
|
||||
'sarjana': 'Sarjana',
|
||||
'pascasarjana': 'Pascasarjana'
|
||||
};
|
||||
return labels[edu] || edu;
|
||||
}
|
||||
|
||||
const radiusCircles = {};
|
||||
let dragInProgress = false;
|
||||
|
||||
// ====================================================================
|
||||
// CENTERS
|
||||
// ====================================================================
|
||||
function renderCenters() {
|
||||
layerCenters.clearLayers();
|
||||
Object.values(radiusCircles).forEach(c => { if (MAP.hasLayer(c)) MAP.removeLayer(c); });
|
||||
|
||||
const filtered = State.centers.filter(c => {
|
||||
if (State.activeFilter === 'houses') return false;
|
||||
if (State.searchQuery) {
|
||||
const q = State.searchQuery.toLowerCase();
|
||||
return c.name.toLowerCase().includes(q) || (c.address || '').toLowerCase().includes(q);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
filtered.forEach(center => {
|
||||
if (!center._marker) {
|
||||
addCenterMarker(center);
|
||||
} else {
|
||||
layerCenters.addLayer(center._marker);
|
||||
if (radiusCircles[center.id]) {
|
||||
radiusCircles[center.id].addTo(MAP);
|
||||
}
|
||||
}
|
||||
});
|
||||
updateLayerCounts();
|
||||
renderCenterList();
|
||||
}
|
||||
|
||||
function addCenterMarker(center) {
|
||||
const color = CENTER_COLORS[center.worship_type] || '#3a56d4';
|
||||
const icon = CENTER_ICONS[center.worship_type] || 'fa-place-of-worship';
|
||||
|
||||
const circle = L.circle([center.latitude, center.longitude], {
|
||||
radius: center.radius,
|
||||
color: color,
|
||||
fillColor: color,
|
||||
fillOpacity: 0.07,
|
||||
weight: 1.5,
|
||||
dashArray: '4 3',
|
||||
});
|
||||
circle.addTo(MAP);
|
||||
radiusCircles[center.id] = circle;
|
||||
|
||||
const marker = L.marker([center.latitude, center.longitude], {
|
||||
icon: L.divIcon({
|
||||
html: `<div class="custom-marker-center" style="background:${color};"><i class="fas ${icon}"></i></div>`,
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 32],
|
||||
popupAnchor: [0, -34],
|
||||
className: '',
|
||||
}),
|
||||
title: center.name,
|
||||
draggable: true,
|
||||
});
|
||||
|
||||
marker.on('dragstart', function() { marker.closePopup(); marker.setZIndexOffset(1000); });
|
||||
marker.on('drag', function(e) { if (radiusCircles[center.id]) radiusCircles[center.id].setLatLng(marker.getLatLng()); });
|
||||
marker.on('dragend', async function(e) {
|
||||
marker.setZIndexOffset(0);
|
||||
const pos = marker.getLatLng();
|
||||
center.latitude = pos.lat;
|
||||
center.longitude = pos.lng;
|
||||
if (radiusCircles[center.id]) radiusCircles[center.id].setLatLng(pos);
|
||||
try {
|
||||
const r = await ApiCenters.patch(center.id, { latitude: pos.lat, longitude: pos.lng });
|
||||
if (r.ok && r.data?.success) {
|
||||
showToast('Posisi tempat ibadah diperbarui.', 'success');
|
||||
updateAllHouseColors();
|
||||
recountCenterHouseholds();
|
||||
loadStats();
|
||||
renderCenterList();
|
||||
renderHouseList();
|
||||
updateLayerCounts();
|
||||
} else showToast('Gagal menyimpan posisi.', 'error');
|
||||
} catch (err) { showToast('Gagal menyimpan posisi.', 'error'); }
|
||||
showCenterPopup(marker, center);
|
||||
});
|
||||
marker.on('click', () => showCenterPopup(marker, center));
|
||||
layerCenters.addLayer(marker);
|
||||
center._marker = marker;
|
||||
}
|
||||
|
||||
function showCenterPopup(marker, center) {
|
||||
const color = CENTER_COLORS[center.worship_type] || '#3a56d4';
|
||||
const icon = CENTER_ICONS[center.worship_type] || 'fa-place-of-worship';
|
||||
const label = CENTER_LABELS[center.worship_type] || center.worship_type;
|
||||
|
||||
// Hitung ulang jumlah rumah saat popup dibuka (memastikan data fresh)
|
||||
let count = 0;
|
||||
let nearbyHouses = [];
|
||||
|
||||
State.houses.forEach(house => {
|
||||
if (house.is_active === false) return;
|
||||
const distance = haversineMeters(
|
||||
house.latitude, house.longitude,
|
||||
center.latitude, center.longitude
|
||||
);
|
||||
if (distance <= center.radius) {
|
||||
count++;
|
||||
nearbyHouses.push({
|
||||
id: house.id,
|
||||
name: house.head_name,
|
||||
distance: Math.round(distance)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Update center household_count jika berbeda
|
||||
if (center.household_count !== count) {
|
||||
center.household_count = count;
|
||||
renderCenterList(); // Refresh sidebar jika ada perubahan
|
||||
}
|
||||
|
||||
// Buat HTML daftar rumah dalam radius
|
||||
let housesListHtml = '';
|
||||
if (nearbyHouses.length > 0) {
|
||||
const displayHouses = nearbyHouses.slice(0, 5);
|
||||
housesListHtml = `
|
||||
<div class="popup-section" style="margin-top: 6px;">
|
||||
<div class="popup-section-label"><i class="fas fa-home"></i> Rumah dalam Radius (${nearbyHouses.length})</div>
|
||||
<div style="max-height: 150px; overflow-y: auto;">
|
||||
${displayHouses.map(h => `
|
||||
<div class="popup-row" style="font-size: 10.5px; cursor: pointer;" onclick="flyToAndOpenHouse(${h.id}, ${center.latitude}, ${center.longitude})">
|
||||
<i class="fas fa-home" style="font-size: 8px;"></i>
|
||||
<span><strong>${escapeHtml(h.name)}</strong> · ${h.distance}m</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
${nearbyHouses.length > 5 ? `<div class="popup-row" style="font-size: 9px; color: var(--text-muted);">+${nearbyHouses.length - 5} rumah lainnya</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const popup = L.popup({ maxWidth: 320, closeButton: true })
|
||||
.setLatLng(marker.getLatLng())
|
||||
.setContent(`
|
||||
<div class="popup-info">
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px;padding-bottom:10px;border-bottom:1px solid #edf0f6;">
|
||||
<div style="width:34px;height:34px;border-radius:9px;background:${color};display:flex;align-items:center;justify-content:center;color:white;font-size:14px;flex-shrink:0;box-shadow:0 2px 8px ${color}44;">
|
||||
<i class="fas ${icon}"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="popup-name" style="margin-bottom:0;">${escapeHtml(center.name)}</div>
|
||||
<div style="font-size:10px;color:var(--text-muted);">${label} · <em style="font-size:9px;">seret untuk pindahkan</em></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="popup-row"><i class="fas fa-map-marker-alt"></i><span>${truncate(center.address || '—', 42)}</span></div>
|
||||
|
||||
<!-- Jumlah Rumah dalam Radius (Live) -->
|
||||
<div class="popup-section" style="background: ${count > 0 ? color + '10' : 'var(--surface-2)'}">
|
||||
<div class="popup-section-label"><i class="fas fa-home"></i> Cakupan Layanan</div>
|
||||
<div class="popup-row" style="margin-bottom: 0;">
|
||||
<i class="fas fa-chart-simple"></i>
|
||||
<span><strong style="font-size: 16px; color: ${color};">${count}</strong> rumah dalam radius ${center.radius}m</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${housesListHtml}
|
||||
|
||||
<div class="popup-section">
|
||||
<div class="popup-section-label"><i class="fas fa-dot-circle"></i> Ubah Radius: <strong id="rcVal_${center.id}" style="color:${color};">${center.radius}m</strong></div>
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<input type="range" min="50" max="5000" step="10" value="${center.radius}"
|
||||
oninput="liveUpdateRadius(${center.id}, this.value)"
|
||||
onchange="saveRadius(${center.id}, this.value)"
|
||||
style="flex:1;height:4px;-webkit-appearance:none;background:#e2e6ef;border-radius:2px;outline:none;border:none;padding:0;accent-color:${color};">
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:9px;color:#9ba4b5;margin-top:3px;">
|
||||
<span>50m</span><span>2.5km</span><span>5km</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="popup-actions">
|
||||
<button class="btn btn-primary btn-sm" onclick="editCenter(${center.id})"><i class="fas fa-pen"></i> Edit</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="showCoverageHouseholds(${center.id})"><i class="fas fa-eye"></i> Lihat Semua</button>
|
||||
${window.canDelete ? `<button class="btn btn-danger btn-sm" onclick="deleteCenter(${center.id})" title="Hapus"><i class="fas fa-trash"></i></button>` : ''}
|
||||
</div>
|
||||
</div>`);
|
||||
|
||||
marker.unbindPopup();
|
||||
marker.bindPopup(popup).openPopup();
|
||||
}
|
||||
|
||||
// Fungsi helper untuk terbang ke rumah dan membuka popupnya
|
||||
function flyToAndOpenHouse(houseId, centerLat, centerLng) {
|
||||
const house = State.houses.find(h => h.id == houseId);
|
||||
if (house) {
|
||||
flyTo(house.latitude, house.longitude, 17);
|
||||
setTimeout(() => {
|
||||
if (house._marker) {
|
||||
house._marker.openPopup();
|
||||
}
|
||||
}, 800);
|
||||
}
|
||||
}
|
||||
window.flyToAndOpenHouse = flyToAndOpenHouse;
|
||||
|
||||
function liveUpdateRadius(centerId, value) {
|
||||
const valSpan = document.getElementById('rcVal_' + centerId);
|
||||
if (valSpan) valSpan.textContent = value + 'm';
|
||||
|
||||
if (radiusCircles[centerId]) {
|
||||
radiusCircles[centerId].setRadius(parseInt(value));
|
||||
}
|
||||
|
||||
// Live update: Hitung ulang jumlah rumah saat slider digeser
|
||||
const center = State.centers.find(c => c.id == centerId);
|
||||
if (center) {
|
||||
let tempCount = 0;
|
||||
State.houses.forEach(house => {
|
||||
if (house.is_active === false) return;
|
||||
const distance = haversineMeters(
|
||||
house.latitude, house.longitude,
|
||||
center.latitude, center.longitude
|
||||
);
|
||||
if (distance <= parseInt(value)) {
|
||||
tempCount++;
|
||||
}
|
||||
});
|
||||
|
||||
// Update tampilan jumlah rumah di popup secara live
|
||||
const popupContent = document.querySelector('.leaflet-popup-content');
|
||||
if (popupContent && center._marker && center._marker.isPopupOpen()) {
|
||||
const countElement = popupContent.querySelector('.popup-section:first-child .popup-row strong');
|
||||
if (countElement) {
|
||||
countElement.textContent = tempCount;
|
||||
countElement.style.color = '#d63230';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRadius(centerId, value) {
|
||||
try {
|
||||
const r = await ApiCenters.patch(centerId, { radius: parseInt(value) });
|
||||
if (r.ok && r.data?.success) {
|
||||
const center = State.centers.find(c => c.id == centerId);
|
||||
if (center) {
|
||||
center.radius = parseInt(value);
|
||||
// Perbarui lingkaran di peta
|
||||
if (radiusCircles[centerId]) {
|
||||
radiusCircles[centerId].setRadius(parseInt(value));
|
||||
}
|
||||
}
|
||||
showToast('Radius diperbarui.', 'success');
|
||||
|
||||
// Update semua data yang terpengaruh
|
||||
updateAllHouseColors();
|
||||
recountCenterHouseholds(); // Hitung ulang jumlah rumah per center
|
||||
renderHouseList(); // Refresh daftar rumah
|
||||
renderCenterList(); // Refresh daftar center dengan count baru
|
||||
updateLayerCounts(); // Update badge layer
|
||||
loadStats(); // Update statistik dashboard
|
||||
|
||||
// Refresh popup center jika terbuka
|
||||
if (center._marker && center._marker.isPopupOpen()) {
|
||||
showCenterPopup(center._marker, center);
|
||||
}
|
||||
} else showToast('Gagal menyimpan radius.', 'error');
|
||||
} catch (err) {
|
||||
showToast('Gagal menyimpan radius.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function showCoverageHouseholds(centerId) {
|
||||
showLoading(true);
|
||||
const r = await ApiCenters.coverage(centerId);
|
||||
showLoading(false);
|
||||
if (!r.ok) { showToast('Gagal memuat data.', 'error'); return; }
|
||||
const { center, households, count } = r.data.data;
|
||||
if (!households.length) { showToast(`Tidak ada rumah dalam radius ${center.radius}m.`, 'warning'); return; }
|
||||
const tempLayer = L.layerGroup();
|
||||
households.forEach(h => {
|
||||
L.circleMarker([h.latitude, h.longitude], {
|
||||
radius: 10,
|
||||
color: '#3a56d4',
|
||||
fillColor: '#3a56d4',
|
||||
fillOpacity: 0.25,
|
||||
weight: 2,
|
||||
}).addTo(tempLayer).bindTooltip(h.head_name, { permanent: false });
|
||||
});
|
||||
tempLayer.addTo(MAP);
|
||||
setTimeout(() => MAP.removeLayer(tempLayer), 10000);
|
||||
flyTo(center.latitude, center.longitude, 15);
|
||||
showToast(`${count} rumah dalam jangkauan ${center.name}.`, 'success');
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// HOUSES
|
||||
// ====================================================================
|
||||
function renderHouses() {
|
||||
layerHouses.clearLayers();
|
||||
|
||||
const filtered = State.houses.filter(h => {
|
||||
if (State.activeFilter === 'centers') return false;
|
||||
if (State.povertyFilter && h.poverty_status !== State.povertyFilter) return false;
|
||||
if (State.aidFilter && h.aid_status !== State.aidFilter) return false;
|
||||
if (State.conditionFilter && h.house_condition !== State.conditionFilter) return false;
|
||||
if (State.searchQuery) {
|
||||
const q = State.searchQuery.toLowerCase();
|
||||
if (!h.head_name.toLowerCase().includes(q) &&
|
||||
!(h.full_address || h.address || '').toLowerCase().includes(q) &&
|
||||
!(h.head_nik || '').includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
filtered.forEach(h => {
|
||||
if (!h._marker) {
|
||||
addHouseMarker(h);
|
||||
} else {
|
||||
// Update color if radius status changed
|
||||
const newColor = getHouseMarkerColor(h.latitude, h.longitude);
|
||||
if (h._marker._houseColor !== newColor) {
|
||||
h._marker._houseColor = newColor;
|
||||
h._marker.setIcon(L.divIcon({
|
||||
html: `<div class="custom-marker-house" style="background:${newColor};"><i class="fas fa-home"></i></div>`,
|
||||
iconSize: [26, 26],
|
||||
iconAnchor: [13, 26],
|
||||
popupAnchor: [0, -28],
|
||||
className: '',
|
||||
}));
|
||||
}
|
||||
layerHouses.addLayer(h._marker);
|
||||
}
|
||||
});
|
||||
updateLayerCounts();
|
||||
renderHouseList(filtered);
|
||||
}
|
||||
|
||||
function isHouseInsideAnyRadius(lat, lng) {
|
||||
for (const center of State.centers) {
|
||||
if (!center.is_active) continue;
|
||||
const distance = haversineMeters(lat, lng, center.latitude, center.longitude);
|
||||
if (distance <= center.radius) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function haversineMeters(lat1, lng1, lat2, lng2) {
|
||||
const R = 6371000;
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
const dLng = (lng2 - lng1) * Math.PI / 180;
|
||||
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||||
Math.sin(dLng / 2) * Math.sin(dLng / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
function recountCenterHouseholds() {
|
||||
console.log('Recounting center households...');
|
||||
|
||||
State.centers.forEach(center => {
|
||||
let count = 0;
|
||||
let householdsInRadius = [];
|
||||
|
||||
State.houses.forEach(house => {
|
||||
if (house.is_active === false) return;
|
||||
|
||||
const distance = haversineMeters(
|
||||
house.latitude, house.longitude,
|
||||
center.latitude, center.longitude
|
||||
);
|
||||
|
||||
if (distance <= center.radius) {
|
||||
count++;
|
||||
householdsInRadius.push({
|
||||
id: house.id,
|
||||
name: house.head_name,
|
||||
distance: Math.round(distance)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
center.household_count = count;
|
||||
center.households_in_radius = householdsInRadius.slice(0, 10); // Simpan 10 terdekat
|
||||
});
|
||||
}
|
||||
|
||||
function getHouseMarkerColor(lat, lng) {
|
||||
return isHouseInsideAnyRadius(lat, lng) ? '#d63230' : '#0b9e73';
|
||||
}
|
||||
|
||||
function updateAllHouseColors() {
|
||||
State.houses.forEach(h => {
|
||||
if (h._marker) {
|
||||
const insideRadius = isHouseInsideAnyRadius(h.latitude, h.longitude);
|
||||
const newColor = insideRadius ? '#d63230' : '#0b9e73';
|
||||
if (h._marker._houseColor !== newColor) {
|
||||
h._marker._houseColor = newColor;
|
||||
h._marker.setIcon(L.divIcon({
|
||||
html: `<div class="custom-marker-house" style="background:${newColor};"><i class="fas fa-home"></i></div>`,
|
||||
iconSize: [26, 26],
|
||||
iconAnchor: [13, 26],
|
||||
popupAnchor: [0, -28],
|
||||
className: '',
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fungsi untuk mendapatkan foto rumah (akan digunakan di popup)
|
||||
function getHousePhotosHtml(houseData) {
|
||||
if (!houseData.house_photos) return '';
|
||||
let photosArr = [];
|
||||
try { photosArr = JSON.parse(houseData.house_photos); } catch(e) { return ''; }
|
||||
if (!photosArr.length) return '';
|
||||
|
||||
const thumbnails = photosArr.slice(0, 4).map(name =>
|
||||
`<img src="uploads/houses/${encodeURIComponent(name)}"
|
||||
style="width:60px;height:60px;object-fit:cover;border-radius:6px;border:1.5px solid #e2e6ef;cursor:zoom-in;"
|
||||
onclick="event.stopPropagation(); PhotoUpload.lightbox('uploads/houses/${encodeURIComponent(name)}')"
|
||||
title="Foto rumah">`
|
||||
).join('');
|
||||
|
||||
return `
|
||||
<div class="hp-section">
|
||||
<div class="hp-section-header">
|
||||
<i class="fas fa-camera"></i>
|
||||
<span>Foto Rumah</span>
|
||||
<span class="hp-count">${photosArr.length}</span>
|
||||
</div>
|
||||
<div class="popup-photo-strip" style="display:flex;flex-wrap:wrap;gap:5px;margin-top:5px;">
|
||||
${thumbnails}
|
||||
${photosArr.length > 4 ? `<span style="font-size:10px;color:#9ba4b5;margin-left:4px;">+${photosArr.length - 4} foto</span>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// showHousePopup (redesigned popup)
|
||||
async function showHousePopup(marker, h, forceRefresh = false) {
|
||||
let houseData = h;
|
||||
|
||||
// Fetch fresh data if aid_history is missing or forceRefresh=true
|
||||
if (forceRefresh || !houseData.aid_history || houseData.aid_history.length === undefined) {
|
||||
try {
|
||||
showLoading(true);
|
||||
const r = await ApiHouses.show(houseData.id);
|
||||
showLoading(false);
|
||||
if (r.ok && r.data?.success) {
|
||||
houseData = r.data.data;
|
||||
const index = State.houses.findIndex(hh => hh.id === houseData.id);
|
||||
if (index !== -1) {
|
||||
State.houses[index] = houseData;
|
||||
if (State.houses[index]._marker) {
|
||||
State.houses[index]._marker._houseData = houseData;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
showLoading(false);
|
||||
console.error('Failed to load fresh household data:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Derived values ───────────────────────────────────────────────
|
||||
const hasAid = (houseData.aid_history && houseData.aid_history.length > 0);
|
||||
const aidStatusText = hasAid ? 'Penerima Bantuan' : 'Belum Ada Bantuan';
|
||||
const aidStatusColor = hasAid ? '#0b9e73' : '#d97706';
|
||||
|
||||
let age = '—';
|
||||
if (houseData.head_date_of_birth) {
|
||||
const bd = new Date(houseData.head_date_of_birth), now = new Date();
|
||||
let a = now.getFullYear() - bd.getFullYear();
|
||||
if (now.getMonth() < bd.getMonth() || (now.getMonth() === bd.getMonth() && now.getDate() < bd.getDate())) a--;
|
||||
age = a;
|
||||
}
|
||||
|
||||
const povColor = POVERTY_COLORS[houseData.poverty_status] || '#9ba4b5';
|
||||
const povLabel = POVERTY_LABELS[houseData.poverty_status] || houseData.poverty_status;
|
||||
|
||||
// Employment one-liner
|
||||
let jobLine = '—';
|
||||
if (houseData.head_employment_status === 'unemployed') {
|
||||
jobLine = 'Tidak Bekerja';
|
||||
} else if (houseData.head_employment_status === 'studying') {
|
||||
jobLine = escapeHtml(houseData.head_institution_name || 'Pelajar/Mahasiswa');
|
||||
} else if (houseData.head_employment_status === 'working') {
|
||||
jobLine = escapeHtml(houseData.head_job_name || 'Bekerja');
|
||||
if (houseData.head_monthly_income) jobLine += ` · ${formatRp(houseData.head_monthly_income)}/bln`;
|
||||
}
|
||||
|
||||
const fullAddress = houseData.full_address || houseData.address || '—';
|
||||
const conditionIcon = houseData.house_condition === 'layak'
|
||||
? `<span style="color:#0b9e73;"><i class="fas fa-check-circle"></i> Layak</span>`
|
||||
: houseData.house_condition === 'tidak_layak'
|
||||
? `<span style="color:#d63230;"><i class="fas fa-times-circle"></i> Tidak Layak</span>`
|
||||
: '—';
|
||||
|
||||
// ── Location pills (RT/RW · Kelurahan · Kecamatan) ───────────────
|
||||
const locationPills = [
|
||||
houseData.rt ? `RT ${escapeHtml(houseData.rt)}/${escapeHtml(houseData.rw || '?')}` : null,
|
||||
houseData.kelurahan ? escapeHtml(houseData.kelurahan) : null,
|
||||
houseData.kecamatan ? escapeHtml(houseData.kecamatan) : null,
|
||||
].filter(Boolean);
|
||||
|
||||
const locationPillsHtml = locationPills.length
|
||||
? `<div class="hp-pills">${locationPills.map(p => `<span class="hp-pill">${p}</span>`).join('')}</div>`
|
||||
: '';
|
||||
|
||||
// ── Family members ────────────────────────────────────────────────
|
||||
let membersHtml = '';
|
||||
if (houseData.household_members && houseData.household_members.length) {
|
||||
const members = houseData.household_members;
|
||||
const shown = members.slice(0, 5);
|
||||
const extra = members.length - shown.length;
|
||||
membersHtml = `
|
||||
<div class="hp-section">
|
||||
<div class="hp-section-header">
|
||||
<i class="fas fa-users"></i>
|
||||
<span>Anggota Keluarga</span>
|
||||
<span class="hp-count">${members.length}</span>
|
||||
</div>
|
||||
<div class="hp-members-list">
|
||||
${shown.map(m => {
|
||||
let statusLine = '';
|
||||
if (m.employment_status === 'working') statusLine = escapeHtml(m.job_name || 'Bekerja');
|
||||
else if (m.employment_status === 'studying') statusLine = escapeHtml(m.institution_name || 'Sekolah');
|
||||
else if (m.employment_status === 'unemployed') statusLine = 'Tidak bekerja';
|
||||
return `<div class="hp-member-row">
|
||||
<div class="hp-member-avatar"><i class="fas fa-user"></i></div>
|
||||
<div class="hp-member-info">
|
||||
<div class="hp-member-name">${escapeHtml(m.name)}</div>
|
||||
<div class="hp-member-meta">${escapeHtml(m.relationship || '—')}${statusLine ? ' · ' + statusLine : ''}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
${extra > 0 ? `<div class="hp-member-more"><i class="fas fa-ellipsis-h"></i> +${extra} anggota lainnya</div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Aid history ───────────────────────────────────────────────────
|
||||
let aidHistoryHtml = '';
|
||||
if (hasAid) {
|
||||
const latestAids = houseData.aid_history.slice(0, 5);
|
||||
const extraAids = houseData.aid_history.length - latestAids.length;
|
||||
aidHistoryHtml = `
|
||||
<div class="hp-section">
|
||||
<div class="hp-section-header">
|
||||
<i class="fas fa-hand-holding-heart"></i>
|
||||
<span>Riwayat Bantuan</span>
|
||||
<span class="hp-count">${houseData.aid_history.length}</span>
|
||||
</div>
|
||||
<div class="hp-aid-list">
|
||||
${latestAids.map(aid => `
|
||||
<div class="hp-aid-row">
|
||||
<div class="hp-aid-left">
|
||||
<span class="hp-aid-type">${escapeHtml(aid.aid_type_label || (typeof AID_LABELS !== 'undefined' && AID_LABELS[aid.aid_type]) || aid.aid_type || 'Bantuan')}</span>
|
||||
${aid.amount ? `<span class="hp-aid-amount">${formatRp(aid.amount)}</span>` : ''}
|
||||
</div>
|
||||
<div class="hp-aid-date">${formatDate(aid.aid_date)}</div>
|
||||
${aid.description || aid.notes ? `<div class="hp-aid-note">${escapeHtml((aid.description || aid.notes || '').substring(0, 55))}${(aid.description || aid.notes || '').length > 55 ? '…' : ''}</div>` : ''}
|
||||
</div>`).join('')}
|
||||
${extraAids > 0 ? `<div class="hp-member-more"><i class="fas fa-ellipsis-h"></i> +${extraAids} bantuan lainnya</div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
} else {
|
||||
aidHistoryHtml = `
|
||||
<div class="hp-section hp-aid-empty">
|
||||
<div class="hp-section-header">
|
||||
<i class="fas fa-hand-holding-heart"></i>
|
||||
<span>Riwayat Bantuan</span>
|
||||
</div>
|
||||
<div class="hp-empty-hint"><i class="fas fa-inbox"></i> Belum ada riwayat bantuan</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Assigned center ───────────────────────────────────────────────
|
||||
const centerHtml = houseData.center_name
|
||||
? `<div class="hp-center-row">
|
||||
<i class="fas fa-place-of-worship"></i>
|
||||
<span>${escapeHtml(houseData.center_name)}</span>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
// ── Coordinates ──────────────────────────────────────────────────
|
||||
const lat = (houseData.latitude || marker.getLatLng().lat).toFixed(6);
|
||||
const lng = (houseData.longitude || marker.getLatLng().lng).toFixed(6);
|
||||
|
||||
// ── Photos Html ──────────────────────────────────────────────────
|
||||
const photosHtml = getHousePhotosHtml(houseData);
|
||||
|
||||
// ── Build popup ──────────────────────────────────────────────────
|
||||
const popup = L.popup({ maxWidth: 360, minWidth: 300, closeButton: true, className: 'hp-leaflet-popup' })
|
||||
.setLatLng(marker.getLatLng())
|
||||
.setContent(`
|
||||
<div class="hp-popup">
|
||||
|
||||
<!-- HEADER -->
|
||||
<div class="hp-header">
|
||||
<div class="hp-drag-hint" title="Seret marker untuk memindahkan">
|
||||
<i class="fas fa-up-down-left-right"></i>
|
||||
</div>
|
||||
<div class="hp-avatar" style="background:${povColor}18;color:${povColor};">
|
||||
<i class="fas fa-home"></i>
|
||||
</div>
|
||||
<div class="hp-header-info">
|
||||
<div class="hp-head-name">${escapeHtml(houseData.head_name)}</div>
|
||||
<div class="hp-nik">NIK: ${escapeHtml(houseData.head_nik || houseData.nik || '—')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STATUS STRIP -->
|
||||
<div class="hp-status-strip">
|
||||
<div class="hp-status-chip" style="background:${povColor}14;color:${povColor};border-color:${povColor}30;">
|
||||
<span class="hp-chip-dot" style="background:${povColor};"></span>${povLabel}
|
||||
</div>
|
||||
<div class="hp-status-chip" style="background:${aidStatusColor}12;color:${aidStatusColor};border-color:${aidStatusColor}28;">
|
||||
<i class="fas ${hasAid ? 'fa-check-circle' : 'fa-clock'}"></i>${aidStatusText}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SCROLLABLE BODY -->
|
||||
<div class="hp-body">
|
||||
|
||||
<!-- Location -->
|
||||
<div class="hp-section">
|
||||
<div class="hp-section-header">
|
||||
<i class="fas fa-map-marker-alt"></i>
|
||||
<span>Lokasi</span>
|
||||
</div>
|
||||
<div class="hp-address">${escapeHtml(fullAddress)}</div>
|
||||
${locationPillsHtml}
|
||||
${centerHtml}
|
||||
<div class="hp-coords"><i class="fas fa-crosshairs"></i>${lat}, ${lng}</div>
|
||||
</div>
|
||||
|
||||
<!-- Photos -->
|
||||
${photosHtml}
|
||||
|
||||
<!-- Head of Household -->
|
||||
<div class="hp-section">
|
||||
<div class="hp-section-header">
|
||||
<i class="fas fa-user"></i>
|
||||
<span>Kepala Keluarga</span>
|
||||
</div>
|
||||
<div class="hp-kv-grid">
|
||||
<div class="hp-kv-row">
|
||||
<span class="hp-kv-label">Usia</span>
|
||||
<span class="hp-kv-val">${age} tahun</span>
|
||||
</div>
|
||||
<div class="hp-kv-row">
|
||||
<span class="hp-kv-label">Pendidikan</span>
|
||||
<span class="hp-kv-val">${educationLabel(houseData.head_education) || '—'}</span>
|
||||
</div>
|
||||
<div class="hp-kv-row">
|
||||
<span class="hp-kv-label">Pekerjaan</span>
|
||||
<span class="hp-kv-val">${jobLine}</span>
|
||||
</div>
|
||||
<div class="hp-kv-row">
|
||||
<span class="hp-kv-label">Kondisi Rumah</span>
|
||||
<span class="hp-kv-val">${conditionIcon}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Family Members -->
|
||||
${membersHtml}
|
||||
|
||||
<!-- Aid History -->
|
||||
${aidHistoryHtml}
|
||||
|
||||
</div><!-- /.hp-body -->
|
||||
|
||||
<!-- ACTIONS -->
|
||||
<div class="hp-actions">
|
||||
<button class="hp-btn hp-btn-primary" onclick="editHouse(${houseData.id})">
|
||||
<i class="fas fa-pen"></i> Edit
|
||||
</button>
|
||||
<button class="hp-btn hp-btn-success" onclick="openAidModalForHouse(${houseData.id})">
|
||||
<i class="fas fa-gift"></i> Tambah Bantuan
|
||||
</button>
|
||||
${window.canDelete ? `<button class="hp-btn hp-btn-danger" onclick="deleteHouse(${houseData.id})" title="Hapus"><i class="fas fa-trash"></i></button>` : ''}
|
||||
</div>
|
||||
|
||||
</div>`);
|
||||
|
||||
marker.unbindPopup();
|
||||
marker.bindPopup(popup).openPopup();
|
||||
}
|
||||
|
||||
// Update click handler untuk memuat data fresh
|
||||
function addHouseMarker(h) {
|
||||
const insideRadius = isHouseInsideAnyRadius(h.latitude, h.longitude);
|
||||
const color = insideRadius ? '#d63230' : '#0b9e73';
|
||||
|
||||
const marker = L.marker([h.latitude, h.longitude], {
|
||||
icon: L.divIcon({
|
||||
html: `<div class="custom-marker-house" style="background:${color};"><i class="fas fa-home"></i></div>`,
|
||||
iconSize: [26, 26],
|
||||
iconAnchor: [13, 26],
|
||||
popupAnchor: [0, -28],
|
||||
className: '',
|
||||
}),
|
||||
title: h.head_name,
|
||||
draggable: true,
|
||||
});
|
||||
|
||||
marker._houseColor = color;
|
||||
marker._houseData = h;
|
||||
marker._originalId = h.id;
|
||||
|
||||
// Dragstart
|
||||
marker.on('dragstart', function() {
|
||||
dragInProgress = true;
|
||||
marker.closePopup();
|
||||
marker.setZIndexOffset(1000);
|
||||
});
|
||||
|
||||
// Drag - update color live
|
||||
marker.on('drag', function(e) {
|
||||
const pos = marker.getLatLng();
|
||||
const newColor = getHouseMarkerColor(pos.lat, pos.lng);
|
||||
if (marker._houseColor !== newColor) {
|
||||
marker._houseColor = newColor;
|
||||
marker.setIcon(L.divIcon({
|
||||
html: `<div class="custom-marker-house" style="background:${newColor};"><i class="fas fa-home"></i></div>`,
|
||||
iconSize: [26, 26],
|
||||
iconAnchor: [13, 26],
|
||||
popupAnchor: [0, -28],
|
||||
className: '',
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
marker.on('dragend', async function(e) {
|
||||
dragInProgress = false;
|
||||
marker.setZIndexOffset(0);
|
||||
|
||||
const pos = marker.getLatLng();
|
||||
const lat = pos.lat, lng = pos.lng;
|
||||
|
||||
// Update local data
|
||||
h.latitude = lat;
|
||||
h.longitude = lng;
|
||||
|
||||
// Update warna marker
|
||||
const newColor = getHouseMarkerColor(lat, lng);
|
||||
marker._houseColor = newColor;
|
||||
marker.setIcon(L.divIcon({
|
||||
html: `<div class="custom-marker-house" style="background:${newColor};"><i class="fas fa-home"></i></div>`,
|
||||
iconSize: [26, 26],
|
||||
iconAnchor: [13, 26],
|
||||
popupAnchor: [0, -28],
|
||||
className: '',
|
||||
}));
|
||||
|
||||
// Reverse geocode untuk dapat alamat baru
|
||||
let newAddress = h.full_address || h.address;
|
||||
try {
|
||||
newAddress = await reverseGeocode(lat, lng);
|
||||
h.full_address = newAddress;
|
||||
} catch (err) {}
|
||||
|
||||
// Save ke database via API
|
||||
showLoading(true);
|
||||
try {
|
||||
const r = await ApiHouses.patch(h.id, {
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
full_address: newAddress
|
||||
});
|
||||
|
||||
if (r.ok && r.data?.success) {
|
||||
if (r.data.data?.managing_center_id) {
|
||||
h.managing_center_id = r.data.data.managing_center_id;
|
||||
const center = State.centers.find(c => c.id == r.data.data.managing_center_id);
|
||||
if (center) h.center_name = center.name;
|
||||
}
|
||||
showToast('Posisi rumah berhasil diperbarui.', 'success');
|
||||
|
||||
// Urutan yang benar - update state dulu
|
||||
|
||||
// 1. Update data di State (Harus pertama)
|
||||
const index = State.houses.findIndex(hi => hi.id === h.id);
|
||||
if (index !== -1) {
|
||||
State.houses[index] = h;
|
||||
}
|
||||
|
||||
// 2. Hitung ulang jumlah rumah per center (membaca dari State.houses yang sudah diupdate)
|
||||
recountCenterHouseholds();
|
||||
|
||||
// 3. Update warna semua marker rumah (yang mungkin berubah status radius)
|
||||
updateAllHouseColors();
|
||||
|
||||
// 4. Update tampilan sidebar
|
||||
renderCenterList();
|
||||
renderHouseList();
|
||||
updateLayerCounts();
|
||||
|
||||
// 5. Update statistik dashboard (ambil data fresh dari server)
|
||||
await loadStats();
|
||||
|
||||
// 6. Refresh popup jika terbuka
|
||||
if (marker.isPopupOpen()) {
|
||||
await showHousePopup(marker, h, true);
|
||||
}
|
||||
} else {
|
||||
showToast(r.data?.message || 'Gagal menyimpan posisi.', 'error');
|
||||
// Kembalikan ke posisi lama jika gagal
|
||||
marker.setLatLng([h.latitude, h.longitude]);
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Gagal menyimpan posisi: ' + err.message, 'error');
|
||||
marker.setLatLng([h.latitude, h.longitude]);
|
||||
} finally {
|
||||
showLoading(false);
|
||||
}
|
||||
|
||||
// Tampilkan popup dengan data terbaru (jika belum terbuka)
|
||||
if (!marker.isPopupOpen()) {
|
||||
await showHousePopup(marker, h, true);
|
||||
}
|
||||
});
|
||||
|
||||
// Click handler dengan loading state
|
||||
marker.on('click', async () => {
|
||||
const loadingPopup = L.popup()
|
||||
.setLatLng(marker.getLatLng())
|
||||
.setContent('<div style="padding: 20px; text-align: center;"><i class="fas fa-circle-notch fa-spin"></i> Memuat data...</div>')
|
||||
.openPopup();
|
||||
|
||||
try {
|
||||
const r = await ApiHouses.show(h.id);
|
||||
if (r.ok && r.data?.success) {
|
||||
const freshData = r.data.data;
|
||||
marker._houseData = freshData;
|
||||
const index = State.houses.findIndex(hh => hh.id === freshData.id);
|
||||
if (index !== -1) {
|
||||
State.houses[index] = freshData;
|
||||
State.houses[index]._marker = marker;
|
||||
}
|
||||
await showHousePopup(marker, freshData);
|
||||
} else {
|
||||
loadingPopup.setContent('<div style="padding: 20px; text-align: center; color: var(--danger);">Gagal memuat data</div>');
|
||||
setTimeout(() => marker.closePopup(), 1500);
|
||||
}
|
||||
} catch (err) {
|
||||
loadingPopup.setContent('<div style="padding: 20px; text-align: center; color: var(--danger);">Error memuat data</div>');
|
||||
setTimeout(() => marker.closePopup(), 1500);
|
||||
}
|
||||
});
|
||||
|
||||
layerHouses.addLayer(marker);
|
||||
h._marker = marker;
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// SIDEBAR LISTS
|
||||
// ====================================================================
|
||||
function renderCenterList() {
|
||||
const el = document.getElementById('centersList');
|
||||
const cnt = document.getElementById('centerCount');
|
||||
const show = State.activeFilter !== 'houses';
|
||||
document.getElementById('centersListSection').style.display = show ? '' : 'none';
|
||||
if (!show) return;
|
||||
const filtered = State.centers.filter(c => {
|
||||
if (!State.searchQuery) return true;
|
||||
const q = State.searchQuery.toLowerCase();
|
||||
return c.name.toLowerCase().includes(q) || (c.address || '').toLowerCase().includes(q);
|
||||
});
|
||||
cnt.textContent = filtered.length;
|
||||
if (!filtered.length) { el.innerHTML = '<div class="empty-state"><i class="fas fa-place-of-worship"></i><p>Tidak ada data</p></div>'; return; }
|
||||
el.innerHTML = filtered.slice(0, 50).map(c => {
|
||||
const color = CENTER_COLORS[c.worship_type] || '#3a56d4';
|
||||
const icon = CENTER_ICONS[c.worship_type] || 'fa-place-of-worship';
|
||||
return `<div class="data-item" onclick="flyTo(${c.latitude},${c.longitude})">
|
||||
<div class="data-item-icon center" style="background:${color}20;color:${color};"><i class="fas ${icon}"></i></div>
|
||||
<div class="data-item-info"><div class="data-item-title">${truncate(c.name, 24)}</div><div class="data-item-subtitle">${CENTER_LABELS[c.worship_type] || ''} · ${c.household_count ?? 0} rumah</div></div>
|
||||
<div class="data-item-actions">
|
||||
<button class="btn-edit" title="Edit" onclick="event.stopPropagation();editCenter(${c.id})"><i class="fas fa-pen"></i></button>
|
||||
${window.canDelete ? `<button class="btn-delete" title="Hapus" onclick="event.stopPropagation();deleteCenter(${c.id})"><i class="fas fa-trash"></i></button>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderHouseList(filtered) {
|
||||
const el = document.getElementById('housesList');
|
||||
const cnt = document.getElementById('houseCount');
|
||||
const show = State.activeFilter !== 'centers';
|
||||
document.getElementById('housesListSection').style.display = show ? '' : 'none';
|
||||
if (!show) return;
|
||||
const list = filtered || State.houses;
|
||||
cnt.textContent = list.length;
|
||||
if (!list.length) { el.innerHTML = '<div class="empty-state"><i class="fas fa-home"></i><p>Tidak ada data</p></div>'; return; }
|
||||
el.innerHTML = list.slice(0, 80).map(h => {
|
||||
const insideRadius = isHouseInsideAnyRadius(h.latitude, h.longitude);
|
||||
const color = insideRadius ? '#d63230' : '#0b9e73';
|
||||
const status = insideRadius ? 'Dalam Radius' : 'Luar Radius';
|
||||
return `<div class="data-item" onclick="flyTo(${h.latitude},${h.longitude})">
|
||||
<div class="data-item-icon house" style="background:${color}18;color:${color};"><i class="fas fa-home"></i></div>
|
||||
<div class="data-item-info"><div class="data-item-title">${truncate(h.head_name, 22)}</div><div class="data-item-subtitle" style="color:${color};">${status}</div></div>
|
||||
<div class="data-item-actions">
|
||||
<button class="btn-edit" title="Edit" onclick="event.stopPropagation();editHouse(${h.id})"><i class="fas fa-pen"></i></button>
|
||||
${window.canDelete ? `<button class="btn-delete" title="Hapus" onclick="event.stopPropagation();deleteHouse(${h.id})"><i class="fas fa-trash"></i></button>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
if (list.length > 80) el.innerHTML += `<div class="empty-state" style="padding:10px;"><p style="color:var(--text-muted);">+${list.length - 80} lainnya — gunakan filter</p></div>`;
|
||||
}
|
||||
|
||||
function updateLayerCounts() {
|
||||
const cc = document.getElementById('layerCenterCount');
|
||||
const hc = document.getElementById('layerHouseCount');
|
||||
if (cc) cc.textContent = State.centers.length;
|
||||
if (hc) hc.textContent = State.houses.length;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/* ============================================================
|
||||
photo-upload.js — Reusable photo upload widget
|
||||
Shared by lapor.html (public reports) and index.html (households)
|
||||
============================================================ */
|
||||
'use strict';
|
||||
|
||||
const PhotoUpload = {
|
||||
MAX_FILES: 5,
|
||||
MAX_MB: 5,
|
||||
|
||||
/**
|
||||
* Validate a FileList against rules.
|
||||
* Returns { valid: true } or { valid: false, message: '...' }
|
||||
*/
|
||||
validate(fileList, existingCount = 0) {
|
||||
const allowed = ['image/jpeg', 'image/png'];
|
||||
const allowedExt = ['jpg', 'jpeg', 'png'];
|
||||
const files = Array.from(fileList);
|
||||
|
||||
if (files.length === 0) return { valid: true };
|
||||
|
||||
if (existingCount + files.length > this.MAX_FILES) {
|
||||
return { valid: false, message: `Maksimal ${this.MAX_FILES} foto. Sudah ada ${existingCount}, pilih paling banyak ${this.MAX_FILES - existingCount} foto lagi.` };
|
||||
}
|
||||
|
||||
for (const f of files) {
|
||||
const ext = f.name.split('.').pop().toLowerCase();
|
||||
if (!allowedExt.includes(ext)) {
|
||||
return { valid: false, message: `File "${f.name}" tidak diizinkan. Gunakan JPG atau PNG.` };
|
||||
}
|
||||
if (!allowed.includes(f.type)) {
|
||||
return { valid: false, message: `File "${f.name}" bukan gambar yang valid.` };
|
||||
}
|
||||
if (f.size > this.MAX_MB * 1024 * 1024) {
|
||||
return { valid: false, message: `File "${f.name}" melebihi batas ${this.MAX_MB} MB.` };
|
||||
}
|
||||
}
|
||||
return { valid: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Build a preview strip of <img> thumbnails for a FileList.
|
||||
* Returns a DocumentFragment.
|
||||
*/
|
||||
buildPreviewStrip(fileList) {
|
||||
const frag = document.createDocumentFragment();
|
||||
Array.from(fileList).forEach(file => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = document.createElement('img');
|
||||
img.src = url;
|
||||
img.className = 'photo-thumb';
|
||||
img.alt = file.name;
|
||||
img.title = file.name;
|
||||
img.onload = () => URL.revokeObjectURL(url);
|
||||
frag.appendChild(img);
|
||||
});
|
||||
return frag;
|
||||
},
|
||||
|
||||
/**
|
||||
* Build a preview strip from saved filenames (already uploaded).
|
||||
* baseUrl e.g. 'uploads/reports/' or 'uploads/houses/'
|
||||
*/
|
||||
buildSavedStrip(filenames, baseUrl, removable = false, onRemove = null) {
|
||||
const frag = document.createDocumentFragment();
|
||||
if (!Array.isArray(filenames)) return frag;
|
||||
filenames.forEach(name => {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.style.cssText = 'position:relative;display:inline-block;';
|
||||
const img = document.createElement('img');
|
||||
img.src = baseUrl + name;
|
||||
img.className = 'photo-thumb';
|
||||
img.alt = name;
|
||||
img.title = 'Klik untuk perbesar';
|
||||
img.style.cursor = 'pointer';
|
||||
img.addEventListener('click', () => PhotoUpload.lightbox(img.src));
|
||||
wrap.appendChild(img);
|
||||
if (removable && onRemove) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.innerHTML = '×';
|
||||
btn.title = 'Hapus foto';
|
||||
btn.style.cssText = 'position:absolute;top:2px;right:2px;width:18px;height:18px;border-radius:50%;border:none;background:rgba(0,0,0,0.6);color:#fff;font-size:12px;line-height:1;cursor:pointer;padding:0;';
|
||||
btn.addEventListener('click', (e) => { e.stopPropagation(); onRemove(name, wrap); });
|
||||
wrap.appendChild(btn);
|
||||
}
|
||||
frag.appendChild(wrap);
|
||||
});
|
||||
return frag;
|
||||
},
|
||||
|
||||
/** Simple lightbox */
|
||||
lightbox(src) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.88);z-index:99999;display:flex;align-items:center;justify-content:center;cursor:zoom-out;';
|
||||
const img = document.createElement('img');
|
||||
img.src = src;
|
||||
img.style.cssText = 'max-width:92vw;max-height:88vh;border-radius:8px;box-shadow:0 8px 40px rgba(0,0,0,.6);';
|
||||
overlay.appendChild(img);
|
||||
overlay.addEventListener('click', () => document.body.removeChild(overlay));
|
||||
document.addEventListener('keydown', function esc(e) {
|
||||
if (e.key === 'Escape') { document.body.removeChild(overlay); document.removeEventListener('keydown', esc); }
|
||||
});
|
||||
document.body.appendChild(overlay);
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload photos to the upload endpoint after a record has been saved.
|
||||
* @param {string} target 'report' | 'house'
|
||||
* @param {number} id The record's database id
|
||||
* @param {FileList} fileList
|
||||
* @returns {Promise<{ok: boolean, data: object}>}
|
||||
*/
|
||||
async upload(target, id, fileList) {
|
||||
if (!fileList || fileList.length === 0) return { ok: true, data: { data: { all_photos: [] } } };
|
||||
const form = new FormData();
|
||||
Array.from(fileList).forEach(f => form.append('photos[]', f));
|
||||
try {
|
||||
const res = await fetch(`api/public/upload.php?target=${target}&id=${id}`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
// ⚠️ Do NOT set Content-Type header — browser sets it with boundary automatically
|
||||
});
|
||||
const data = await res.json();
|
||||
return { ok: res.ok, data };
|
||||
} catch (err) {
|
||||
console.error('[PhotoUpload] Upload error:', err);
|
||||
return { ok: false, data: { success: false, message: 'Upload gagal: ' + err.message } };
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Delete a single saved photo via API.
|
||||
* @param {string} target 'report' | 'house'
|
||||
* @param {number} id Record's database id
|
||||
* @param {string} filename The filename to delete
|
||||
* @returns {Promise<{ok: boolean, data: object}>}
|
||||
*/
|
||||
async deletePhoto(target, id, filename) {
|
||||
const endpointMap = {
|
||||
report: 'api/public/report.php',
|
||||
house: 'api/houses/index.php',
|
||||
};
|
||||
const url = endpointMap[target];
|
||||
if (!url) return { ok: false, data: { success: false, message: 'Target tidak valid.' } };
|
||||
|
||||
try {
|
||||
const res = await fetch(`${url}?action=delete_photo&id=${id}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filename }),
|
||||
});
|
||||
const data = await res.json();
|
||||
return { ok: res.ok, data };
|
||||
} catch (err) {
|
||||
console.error('[PhotoUpload] deletePhoto error:', err);
|
||||
return { ok: false, data: { success: false, message: 'Koneksi gagal: ' + err.message } };
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,350 @@
|
||||
/* ============================================================
|
||||
public-reports.js — Admin panel: public report verification
|
||||
Loaded after app.js — requires loadAllData() and loadStats()
|
||||
on window (exported by app.js)
|
||||
============================================================ */
|
||||
'use strict';
|
||||
|
||||
// ================================================================
|
||||
// API wrapper for public reports
|
||||
// ================================================================
|
||||
const ApiPublicReports = {
|
||||
async list(params = {}) {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
return Http.request('api/public/report.php?action=list' + (qs ? '&' + qs : ''), { method: 'GET' });
|
||||
},
|
||||
async approve(id, body = {}) {
|
||||
// Menambahkan verified_by ke body untuk audit
|
||||
if (window.currentUserName) {
|
||||
body.verified_by = window.currentUserName;
|
||||
}
|
||||
return Http.post(`api/public/report.php?action=approve&id=${id}`, body);
|
||||
},
|
||||
async reject(id, body = {}) {
|
||||
if (window.currentUserName) {
|
||||
body.rejected_by = window.currentUserName;
|
||||
}
|
||||
return Http.post(`api/public/report.php?action=reject&id=${id}`, body);
|
||||
},
|
||||
async delete(id) {
|
||||
return Http.post(`api/public/report.php?action=delete&id=${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
// ================================================================
|
||||
// Load pending reports into admin panel table
|
||||
// ================================================================
|
||||
async function loadPendingReports() {
|
||||
const tbody = document.getElementById('pendingTbody');
|
||||
if (!tbody) return;
|
||||
|
||||
const status = document.getElementById('pendingStatusFilter')?.value ?? 'pending';
|
||||
const params = { limit: 100 };
|
||||
if (status) params.status = status;
|
||||
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="text-center" style="color:#9ba4b5;padding:18px;">Memuat...</td></tr>';
|
||||
|
||||
const r = await ApiPublicReports.list(params);
|
||||
|
||||
if (!r.ok || !r.data?.success) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="text-center" style="color:var(--danger);padding:18px;">Gagal memuat laporan publik.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
const reports = r.data.data?.reports || [];
|
||||
|
||||
// Update badge count (always count actual pending)
|
||||
const badge = document.getElementById('pendingBadge');
|
||||
if (badge) {
|
||||
const pendingCount = reports.filter(rep => rep.status === 'pending').length;
|
||||
badge.textContent = pendingCount > 0 ? pendingCount : '';
|
||||
}
|
||||
|
||||
if (!reports.length) {
|
||||
const emptyMsg = status === 'pending' ? 'Tidak ada laporan yang menunggu verifikasi.' : 'Tidak ada laporan.';
|
||||
tbody.innerHTML = `<tr><td colspan="6" class="text-center" style="color:#9ba4b5;padding:24px;">
|
||||
<i class="fas fa-check-circle" style="font-size:20px;display:block;margin-bottom:8px;opacity:0.3;"></i>
|
||||
${emptyMsg}
|
||||
</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const statusMap = {
|
||||
pending: { label: 'Menunggu', color: '#d97706', bg: '#fef6e4' },
|
||||
approved: { label: 'Disetujui', color: '#0b9e73', bg: '#e0faf3' },
|
||||
rejected: { label: 'Ditolak', color: '#d63230', bg: '#fff0f0' },
|
||||
};
|
||||
|
||||
tbody.innerHTML = reports.map(rep => {
|
||||
const st = statusMap[rep.status] || { label: rep.status, color: '#9ba4b5', bg: '#f5f6f9' };
|
||||
const canAct = rep.status === 'pending';
|
||||
const dateStr = formatDateTime(rep.created_at);
|
||||
|
||||
return `
|
||||
<tr style="vertical-align:top;">
|
||||
<td style="font-size:9.5px;color:#9ba4b5;white-space:nowrap;padding-top:10px;">${dateStr}</td>
|
||||
<td style="padding-top:8px;">
|
||||
<div style="font-size:12px;font-weight:700;color:#0f1623;">${escapeHtml(rep.head_name || '—')}</div>
|
||||
${rep.reporter_name
|
||||
? `<div style="font-size:10px;color:#9ba4b5;margin-top:2px;"><i class="fas fa-user" style="font-size:9px;"></i> ${escapeHtml(rep.reporter_name)}${rep.reporter_phone ? ' · ' + escapeHtml(rep.reporter_phone) : ''}</div>`
|
||||
: ''}
|
||||
</td>
|
||||
<td style="font-size:10.5px;color:#5a6478;max-width:130px;padding-top:10px;">${escapeHtml(truncate(rep.address || '—', 38))}</td>
|
||||
<td style="font-size:10.5px;color:#5a6478;max-width:160px;padding-top:10px;">
|
||||
${escapeHtml(truncate(rep.description || '—', 60))}
|
||||
${buildPhotoMiniStrip(rep.proof_photos, 'uploads/reports/')}
|
||||
</td>
|
||||
<td style="padding-top:10px;">
|
||||
<span style="padding:3px 9px;border-radius:20px;font-size:9.5px;font-weight:700;
|
||||
background:${st.bg};color:${st.color};white-space:nowrap;">${st.label}</span>
|
||||
${rep.admin_notes
|
||||
? `<div style="font-size:9px;color:#9ba4b5;margin-top:3px;">${escapeHtml(truncate(rep.admin_notes, 25))}</div>`
|
||||
: ''}
|
||||
${rep.converted_household_id
|
||||
? `<div style="font-size:9px;color:#0b9e73;margin-top:2px;"><i class="fas fa-home"></i> ID: ${rep.converted_household_id}</div>`
|
||||
: ''}
|
||||
</td>
|
||||
<td style="white-space:nowrap;padding-top:8px;">
|
||||
${canAct ? `
|
||||
<button class="action-btn" onclick="openApproveModal(${rep.id}, ${safeJson(rep)})"
|
||||
style="color:#0b9e73;border-color:#a8e8d4;" title="Setujui & tambah ke peta">
|
||||
<i class="fas fa-check"></i>
|
||||
</button>
|
||||
<button class="action-btn" onclick="openRejectModal(${rep.id})"
|
||||
style="color:var(--danger);border-color:#fcc;" title="Tolak laporan">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
<button class="action-btn" onclick="flyToPublicReport(${rep.latitude}, ${rep.longitude})"
|
||||
title="Lihat di peta" style="color:var(--accent);border-color:#c8d0f5;">
|
||||
<i class="fas fa-map-marker-alt"></i>
|
||||
</button>
|
||||
<button class="action-btn danger" onclick="deletePublicReport(${rep.id})" title="Hapus permanen">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a tiny inline strip of photo thumbnails in the admin table.
|
||||
* photos: JSON string or array of filenames; baseUrl: path prefix.
|
||||
*/
|
||||
function buildPhotoMiniStrip(photos, baseUrl) {
|
||||
let arr = [];
|
||||
try {
|
||||
arr = typeof photos === 'string' ? JSON.parse(photos) : (photos || []);
|
||||
} catch(e) {
|
||||
console.warn('buildPhotoMiniStrip parse error:', e);
|
||||
return '';
|
||||
}
|
||||
if (!arr.length) return '';
|
||||
|
||||
const imgs = arr.slice(0, 3).map(name => {
|
||||
const safeName = encodeURIComponent(name);
|
||||
const src = baseUrl + safeName;
|
||||
return `<img src="${src}"
|
||||
style="width:36px;height:36px;object-fit:cover;border-radius:5px;border:1.5px solid #e2e6ef;cursor:zoom-in;margin-right:3px;"
|
||||
onclick="PhotoUpload.lightbox('${src}')"
|
||||
title="${escapeHtml(name)}">`;
|
||||
}).join('');
|
||||
|
||||
const extra = arr.length > 3 ? `<span style="font-size:10px;color:#9ba4b5;">+${arr.length - 3}</span>` : '';
|
||||
return `<div style="display:flex;align-items:center;flex-wrap:wrap;gap:3px;margin-top:5px;">${imgs}${extra}</div>`;
|
||||
}
|
||||
|
||||
/** Safely JSON-encode report object for inline onclick attribute */
|
||||
function safeJson(obj) {
|
||||
return JSON.stringify(obj)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Approve modal
|
||||
// ================================================================
|
||||
function openApproveModal(reportId, reportData) {
|
||||
document.getElementById('approveReportId').value = reportId;
|
||||
document.getElementById('approveIncome').value = 0;
|
||||
document.getElementById('approveDependents').value = 1;
|
||||
document.getElementById('approveCondition').value = 'tidak_layak';
|
||||
document.getElementById('approveEducation').value = 'sd';
|
||||
document.getElementById('approveNotes').value = '';
|
||||
|
||||
const preview = document.getElementById('approveReportPreview');
|
||||
const photoPreview = document.getElementById('approvePhotoPreview');
|
||||
|
||||
if (preview && reportData) {
|
||||
const data = typeof reportData === 'string' ? JSON.parse(reportData) : reportData;
|
||||
preview.innerHTML = `
|
||||
<div style="display:flex;align-items:flex-start;gap:10px;">
|
||||
<div style="width:32px;height:32px;border-radius:8px;background:#fff0f0;display:flex;align-items:center;justify-content:center;flex-shrink:0;">
|
||||
<i class="fas fa-flag" style="color:var(--danger);font-size:14px;"></i>
|
||||
</div>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="font-size:13px;font-weight:700;color:#0f1623;">${data.head_name || '—'}</div>
|
||||
<div style="font-size:11px;color:#5a6478;margin-top:3px;">${data.address || '—'}</div>
|
||||
<div style="font-size:11px;color:#5a6478;margin-top:5px;font-style:italic;border-left:2px solid #e2e6ef;padding-left:8px;">"${truncate(data.description || '', 100)}"</div>
|
||||
${data.reporter_name ? `<div style="font-size:10.5px;color:#9ba4b5;margin-top:4px;"><i class="fas fa-user" style="font-size:9px;"></i> ${data.reporter_name}</div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// ── TAMPILKAN FOTO BUKTI ──────────────────────────────
|
||||
if (photoPreview) {
|
||||
let photosArr = [];
|
||||
try {
|
||||
photosArr = typeof data.proof_photos === 'string' ? JSON.parse(data.proof_photos) : (data.proof_photos || []);
|
||||
} catch(e) {
|
||||
console.warn('Failed to parse proof_photos:', e);
|
||||
}
|
||||
|
||||
if (photosArr.length) {
|
||||
photoPreview.innerHTML = '';
|
||||
const rId = reportId; // capture for closure
|
||||
const strip = PhotoUpload.buildSavedStrip(
|
||||
photosArr,
|
||||
'uploads/reports/',
|
||||
true, // always removable in admin approve-modal
|
||||
(filename, wrap) => deleteReportPhoto(rId, filename, wrap)
|
||||
);
|
||||
photoPreview.appendChild(strip);
|
||||
} else {
|
||||
photoPreview.innerHTML = '<div style="font-size:11px;color:#9ba4b5;padding:8px 0;"><i class="fas fa-image"></i> Tidak ada foto bukti</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
openModal('approveModal');
|
||||
}
|
||||
|
||||
document.getElementById('approveForm')?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const id = parseInt(document.getElementById('approveReportId').value);
|
||||
if (!id) return;
|
||||
|
||||
const body = {
|
||||
income: parseInt(document.getElementById('approveIncome').value) || 0,
|
||||
dependents: parseInt(document.getElementById('approveDependents').value) || 1,
|
||||
house_condition: document.getElementById('approveCondition').value,
|
||||
education: document.getElementById('approveEducation').value,
|
||||
land_ownership: 'numpang',
|
||||
admin_notes: document.getElementById('approveNotes').value.trim() || null,
|
||||
};
|
||||
|
||||
showLoading(true);
|
||||
const r = await ApiPublicReports.approve(id, body);
|
||||
showLoading(false);
|
||||
|
||||
if (r.ok && r.data?.success) {
|
||||
closeModal('approveModal');
|
||||
showToast('Laporan disetujui. Data rumah ditambahkan ke peta.', 'success', 4000);
|
||||
loadPendingReports();
|
||||
// Refresh map and stats
|
||||
if (typeof loadAllData === 'function') await loadAllData();
|
||||
if (typeof loadStats === 'function') await loadStats();
|
||||
updatePendingBadge();
|
||||
} else {
|
||||
showToast(r.data?.message || 'Gagal menyetujui laporan.', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// Reject modal
|
||||
// ================================================================
|
||||
function openRejectModal(reportId) {
|
||||
document.getElementById('rejectReportId').value = reportId;
|
||||
document.getElementById('rejectNotes').value = '';
|
||||
openModal('rejectModal');
|
||||
}
|
||||
|
||||
document.getElementById('rejectForm')?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const id = parseInt(document.getElementById('rejectReportId').value);
|
||||
if (!id) return;
|
||||
|
||||
const body = {
|
||||
admin_notes: document.getElementById('rejectNotes').value.trim() || null,
|
||||
};
|
||||
|
||||
showLoading(true);
|
||||
const r = await ApiPublicReports.reject(id, body);
|
||||
showLoading(false);
|
||||
|
||||
if (r.ok && r.data?.success) {
|
||||
closeModal('rejectModal');
|
||||
showToast('Laporan ditolak.', 'success');
|
||||
loadPendingReports();
|
||||
if (typeof loadStats === 'function') loadStats();
|
||||
updatePendingBadge();
|
||||
} else {
|
||||
showToast(r.data?.message || 'Gagal menolak laporan.', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// Delete
|
||||
// ================================================================
|
||||
async function deletePublicReport(id) {
|
||||
if (!confirm('Hapus laporan ini secara permanen? Tindakan ini tidak dapat dibatalkan.')) return;
|
||||
showLoading(true);
|
||||
const r = await ApiPublicReports.delete(id);
|
||||
showLoading(false);
|
||||
if (r.ok && r.data?.success) {
|
||||
showToast('Laporan dihapus.', 'success');
|
||||
loadPendingReports();
|
||||
if (typeof loadStats === 'function') loadStats();
|
||||
updatePendingBadge();
|
||||
} else {
|
||||
showToast(r.data?.message || 'Gagal menghapus laporan.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Fly to location on map
|
||||
// ================================================================
|
||||
function flyToPublicReport(lat, lng) {
|
||||
closeModal('adminModal');
|
||||
flyTo(parseFloat(lat), parseFloat(lng), 17);
|
||||
|
||||
// Temporary highlight pulse
|
||||
const highlight = L.circleMarker([lat, lng], {
|
||||
radius: 20, color: '#d63230', fillColor: '#d63230',
|
||||
fillOpacity: 0.2, weight: 2.5,
|
||||
}).addTo(MAP);
|
||||
|
||||
setTimeout(() => { if (MAP.hasLayer(highlight)) MAP.removeLayer(highlight); }, 5000);
|
||||
showToast('Lokasi laporan ditampilkan di peta.', 'success');
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Delete a single proof photo from a public report
|
||||
// ================================================================
|
||||
async function deleteReportPhoto(reportId, filename, thumbWrap) {
|
||||
if (!confirm('Hapus foto bukti ini secara permanen?')) return;
|
||||
|
||||
if (thumbWrap) thumbWrap.style.opacity = '0.4';
|
||||
|
||||
const r = await PhotoUpload.deletePhoto('report', reportId, filename);
|
||||
|
||||
if (r.ok && r.data?.success) {
|
||||
thumbWrap?.remove();
|
||||
showToast('Foto bukti dihapus.', 'success');
|
||||
} else {
|
||||
if (thumbWrap) thumbWrap.style.opacity = '1';
|
||||
showToast(r.data?.message || 'Gagal menghapus foto.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Expose to global scope
|
||||
// ================================================================
|
||||
window.loadPendingReports = loadPendingReports;
|
||||
window.openApproveModal = openApproveModal;
|
||||
window.openRejectModal = openRejectModal;
|
||||
window.deletePublicReport = deletePublicReport;
|
||||
window.flyToPublicReport = flyToPublicReport;
|
||||
window.deleteReportPhoto = deleteReportPhoto;
|
||||
Reference in New Issue
Block a user