Fix: Convert 03PovertyMapping from submodule to regular folder

This commit is contained in:
cindy
2026-06-10 19:36:55 +07:00
parent 1a9534757e
commit aed321d490
42 changed files with 11130 additions and 1 deletions
File diff suppressed because it is too large Load Diff
+29
View File
@@ -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);
});
+162
View File
@@ -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' });
},
};
+246
View File
@@ -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'));
}
});
+170
View File
@@ -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
};
+142
View File
@@ -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 = '';
}
});
+286
View File
@@ -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
+176
View File
@@ -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;
}
+961
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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;
}
+159
View File
@@ -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 = '&times;';
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, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
// ================================================================
// 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;