edit files

This commit is contained in:
2026-06-10 19:48:00 +07:00
parent 8d26d3842d
commit 13cc0c9bb7
63 changed files with 8750 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
const Api = (() => {
// Deteksi path API secara otomatis
const BASE = (() => {
const path = window.location.pathname;
const dir = path.substring(0, path.lastIndexOf('/') + 1);
return dir + 'api/index.php';
})();
async function req(method, path, body=null) {
// path contoh: '/penduduk' → '?r=penduduk' | '/penduduk/123' → '?r=penduduk/123'
const cleanPath = path.replace(/^\//, '');
const url = `${BASE}?r=${cleanPath}`;
const opts = {
method,
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
};
if (body) opts.body = JSON.stringify(body);
const res = await fetch(url, opts);
const text = await res.text();
let json;
try {
json = JSON.parse(text);
} catch {
// PHP mengembalikan HTML/teks error bukan JSON
const preview = text.substring(0, 200);
throw new Error(`Server error ${res.status} — bukan JSON: ${preview}`);
}
if (!json.success) throw new Error(json.message || 'Terjadi kesalahan');
return json.data;
}
return {
get: path => req('GET', path),
post: (path, b) => req('POST', path, b),
put: (path, b) => req('PUT', path, b),
delete: path => req('DELETE', path),
};
})();
+278
View File
@@ -0,0 +1,278 @@
/* ════════════════════════════════════════
GLOBAL HELPERS
════════════════════════════════════════ */
const ge = id => document.getElementById(id);
const gv = id => (document.getElementById(id)?.value ?? '').trim();
const sv = (id, val) => { const el = document.getElementById(id); if (el) el.value = val ?? ''; };
const rp = n => n > 0 ? 'Rp ' + parseInt(n).toLocaleString('id-ID') : '—';
const cap = s => s ? s.charAt(0).toUpperCase() + s.slice(1).replace(/_/g, ' ') : '—';
const bdg = (val, _cls) => {
const v = val || '';
const css = v.replace(/_/g,'-').toLowerCase();
return `<span class="bdg bdg-${css}">${v.replace(/_/g,' ').toUpperCase()}</span>`;
};
/* ════════════════════════════════════════
ROLE CONFIG
1 = Admin, 2 = Pengurus RI, 3 = Pimpinan, 4 = Masyarakat
════════════════════════════════════════ */
const ROLE_MENUS = {
1: ['dashboard','peta','penduduk','keluarga','ri','bantuan','prioritas','laporan','chat','log'],
2: ['dashboard','peta','penduduk','keluarga','ri','bantuan','prioritas','laporan','chat'],
3: ['dashboard','peta','penduduk','keluarga','laporan'],
4: ['dashboard','peta'],
99: ['dashboard','peta'],
};
const ROLE_LABELS = {
1: { icon:'⚙️', label:'Administrator', color:'#e74c3c' },
2: { icon:'🕌', label:'Pengurus Rumah Ibadah', color:'#27ae60' },
3: { icon:'🏛️', label:'Pimpinan Daerah', color:'#2980b9' },
4: { icon:'👤', label:'Masyarakat', color:'#8e44ad' },
};
/* ════════════════════════════════════════
APP CONTROLLER
════════════════════════════════════════ */
const App = (() => {
let _user = null, _autoTimer = null;
const currentUser = () => _user;
const userRole = () => _user?.id_role ?? 99;
const canEdit = () => [1,2].includes(userRole());
/* ── Login ── */
async function doLogin() {
const username = gv('login-user'), password = gv('login-pass');
const errEl = ge('login-err');
const btnMasuk = ge('btn-masuk-submit');
errEl.classList.add('hidden');
if (btnMasuk) { btnMasuk.disabled = true; btnMasuk.textContent = 'Memproses...'; }
try {
const res = await fetch('api/index.php?r=auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
credentials: 'same-origin',
});
let json;
const text = await res.text();
try { json = JSON.parse(text); }
catch { throw new Error(`Respons server tidak valid (bukan JSON). Cek error PHP di server.`); }
if (!json.success) throw new Error(json.message || 'Login gagal');
_user = json.data;
setUserUI(_user);
closeModal('modal-login');
ge('login-pass').value = '';
toast(`Selamat datang, ${_user.nama_lengkap}! 👋`, 'success');
// Arahkan ke panel default sesuai role
const defaultPanel = { 1:'dashboard', 2:'dashboard', 3:'dashboard', 4:'dashboard' };
switchPanel(defaultPanel[_user.id_role] || 'dashboard');
startAutoRefresh();
} catch(e) {
errEl.textContent = e.message || 'Username atau password salah';
errEl.classList.remove('hidden');
} finally {
if (btnMasuk) { btnMasuk.disabled = false; btnMasuk.textContent = 'Masuk →'; }
}
}
async function logout() {
try {
await fetch('api/index.php?r=auth/logout', { method:'DELETE', credentials:'same-origin' });
} catch {}
stopAutoRefresh();
_user = null;
setUserUI(null);
toast('Anda telah logout', 'info');
switchPanel('dashboard');
}
/* ── Apply UI per role ── */
function setUserUI(u) {
if (u) {
const info = ROLE_LABELS[u.id_role] || { icon:'👤', label: u.nama_role, color:'#555' };
ge('user-name-el').textContent = u.nama_lengkap;
ge('user-avatar-el').textContent = info.icon;
ge('user-role-el').textContent = info.label.toUpperCase();
ge('user-info').classList.remove('hidden');
ge('btn-login').classList.add('hidden');
applyRoleUI(u.id_role);
// Tampilkan role banner
showRoleBanner(u.id_role, u.nama_lengkap, info);
} else {
ge('user-info').classList.add('hidden');
ge('btn-login').classList.remove('hidden');
applyRoleUI(99);
hideRoleBanner();
}
}
function showRoleBanner(role, nama, info) {
let banner = ge('role-banner');
if (!banner) {
banner = document.createElement('div');
banner.id = 'role-banner';
banner.style.cssText = `
position:fixed; top:0; left:0; right:0; z-index:9999;
padding:6px 16px; font-size:12px; font-weight:600;
display:flex; align-items:center; gap:8px;
transition: all 0.3s;
`;
document.body.prepend(banner);
}
banner.style.background = info.color;
banner.style.color = '#fff';
banner.innerHTML = `
${info.icon} Login sebagai: <strong>${nama}</strong>
&nbsp;|&nbsp; Role: <strong>${info.label}</strong>
&nbsp;|&nbsp; <span style="opacity:0.8;font-size:11px">${roleDescription(role)}</span>
`;
// Geser konten ke bawah sedikit
document.body.style.paddingTop = '30px';
}
function hideRoleBanner() {
const banner = ge('role-banner');
if (banner) banner.remove();
document.body.style.paddingTop = '';
}
function roleDescription(role) {
return {
1: 'Akses penuh — kelola semua data & pengguna',
2: 'Kelola penduduk, bantuan & verifikasi laporan',
3: 'Read-only — monitoring & dashboard',
4: 'Hanya bisa melihat peta & kirim laporan',
}[role] || '';
}
function applyRoleUI(role) {
const allowed = ROLE_MENUS[role] || ROLE_MENUS[99];
// Sembunyikan/tampilkan nav menu
document.querySelectorAll('.nav-btn[data-panel]').forEach(btn => {
const panel = btn.dataset.panel;
btn.style.display = allowed.includes(panel) ? '' : 'none';
});
// Sembunyikan tombol aksi edit/tambah/salur untuk non-editor
const canEditRole = [1,2].includes(role);
document.querySelectorAll('[id^="btn-tambah"],[id^="btn-salur"],[id^="btn-edit"]')
.forEach(el => el.style.display = canEditRole ? '' : 'none');
// Khusus Admin: tampilkan menu log
const logBtn = document.querySelector('[data-panel="log"]');
if (logBtn) logBtn.style.display = role === 1 ? '' : 'none';
// Kirim Laporan hanya untuk masyarakat & umum
const kirimBtn = ge('nav-kirim-laporan');
if (kirimBtn) kirimBtn.style.display = [4,99].includes(role) ? '' : 'none';
}
/* ── Panel routing ── */
let _cur = 'dashboard';
function switchPanel(name) {
const role = userRole();
const allowed = ROLE_MENUS[role] || ROLE_MENUS[99];
// Jika panel tidak boleh diakses role ini, redirect ke dashboard
if (!allowed.includes(name)) {
toast('Anda tidak punya akses ke halaman ini', 'error');
name = 'dashboard';
}
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
ge(`panel-${name}`)?.classList.add('active');
document.querySelector(`[data-panel="${name}"]`)?.classList.add('active');
_cur = name;
if (name === 'peta') setTimeout(() => MapApp.init(), 80);
else loadPanel(name);
}
function loadCurrentPanel() { loadPanel(_cur); }
function loadPanel(n) {
({ dashboard: Dashboard.load, penduduk: Penduduk.load, keluarga: Keluarga.load,
bantuan: Bantuan.load, laporan: LaporanPanel.load, ri: RI.load,
prioritas: PrioritasPanel.load, chat: Chat.init, log: LogPanel.load })[n]?.();
}
/* ── Auto refresh ── */
function startAutoRefresh() {
stopAutoRefresh();
_autoTimer = setInterval(() => {
if (_cur === 'dashboard') Dashboard.load();
if (_cur === 'laporan') LaporanPanel.load();
updateHeaderStats();
}, 30000);
}
function stopAutoRefresh() { clearInterval(_autoTimer); }
/* ── Modals ── */
function showModal(id) { ge(id)?.classList.remove('hidden'); }
function closeModal(id) { ge(id)?.classList.add('hidden'); }
/* ── Toast ── */
function toast(msg, type='info', dur=3800) {
const icons = { success:'✅', error:'❌', info:'️', warning:'⚠️' };
const el = document.createElement('div');
el.className = `toast-item ${type}`;
el.innerHTML = `<span class="toast-icon">${icons[type]||'️'}</span><span>${msg}</span>`;
ge('toast-container').appendChild(el);
setTimeout(() => { el.classList.add('out'); setTimeout(() => el.remove(), 300); }, dur);
}
/* ── Header stats ── */
async function updateHeaderStats() {
try {
const d = await Api.get('/dashboard');
const s = d.statistik;
ge('hp-miskin-n').textContent = s.total_miskin;
ge('hp-rentan-n').textContent = s.total_rentan;
ge('hp-mampu-n').textContent = s.total_mampu;
ge('hp-lap-n').textContent = s.laporan_pending;
const nb = ge('nb-laporan');
s.laporan_pending > 0
? (nb.textContent = s.laporan_pending, nb.classList.remove('hidden'))
: nb.classList.add('hidden');
} catch {}
}
/* ── Events ── */
ge('btn-toggle-sidebar').addEventListener('click', () => {
ge('sidebar').classList.toggle('collapsed');
});
document.addEventListener('click', e => {
if (e.target.classList.contains('modal-overlay')) e.target.classList.add('hidden');
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape')
document.querySelectorAll('.modal-overlay:not(.hidden)').forEach(m => m.classList.add('hidden'));
});
/* ── Init ── */
(async function init() {
try {
const res = await fetch('api/index.php?r=auth/me', { credentials:'same-origin' });
const text = await res.text();
const json = JSON.parse(text);
if (json.success) { _user = json.data; setUserUI(_user); startAutoRefresh(); }
} catch {}
Dashboard.load();
updateHeaderStats();
})();
return { currentUser, userRole, canEdit, doLogin, logout, switchPanel,
showModal, closeModal, toast, updateHeaderStats };
})();
+132
View File
@@ -0,0 +1,132 @@
const Bantuan = (() => {
let jenisList = [];
const DEMO_JENIS = [
{ id:1, nama_bantuan:'Sembako Ramadan', kategori:'sembako', sumber:'Zakat Masjid', satuan:'paket', aktif:1 },
{ id:2, nama_bantuan:'PKH (Prog. Keluarga Harapan)', kategori:'ekonomi', sumber:'APBN', satuan:'Rp', aktif:1 },
{ id:3, nama_bantuan:'BPNT / Kartu Sembako', kategori:'sembako', sumber:'APBN', satuan:'bulan', aktif:1 },
{ id:4, nama_bantuan:'Beasiswa Pendidikan', kategori:'pendidikan', sumber:'CSR Perusahaan', satuan:'paket/tahun',aktif:1 },
{ id:5, nama_bantuan:'BPJS Kesehatan PBI', kategori:'kesehatan', sumber:'APBD', satuan:'bulan', aktif:1 },
{ id:6, nama_bantuan:'Bantuan Sembako Gereja', kategori:'sembako', sumber:'Kolekte Gereja', satuan:'paket', aktif:1 },
{ id:7, nama_bantuan:'Bantuan Vihara (Kwan Im)',kategori:'sembako', sumber:'Dana Sosial Vihara',satuan:'paket', aktif:1 },
{ id:8, nama_bantuan:'Raskin / Beras Miskin', kategori:'sembako', sumber:'Bulog', satuan:'kg', aktif:1 },
];
const DEMO_HISTORI = [
{ id:1, nama_lengkap:'Ahmad Suryana', nama_bantuan:'Sembako Ramadan', tanggal:'2025-03-15', jumlah:1, satuan:'paket', nama_ri:"Masjid Jami'", status_penyaluran:'disalurkan' },
{ id:2, nama_lengkap:'Ahmad Suryana', nama_bantuan:'BPNT', tanggal:'2025-01-10', jumlah:1, satuan:'bulan', nama_ri:"Masjid Jami'", status_penyaluran:'disalurkan' },
{ id:3, nama_lengkap:'Maria Magdalena', nama_bantuan:'Bantuan Sembako Gereja', tanggal:'2025-02-20', jumlah:1, satuan:'paket', nama_ri:'Katedral', status_penyaluran:'disalurkan' },
{ id:4, nama_lengkap:'Lim Ah Kow', nama_bantuan:'Bantuan Vihara', tanggal:'2025-04-01', jumlah:1, satuan:'paket', nama_ri:'Vihara', status_penyaluran:'disalurkan' },
{ id:5, nama_lengkap:'Tan Eng Huat', nama_bantuan:'Raskin', tanggal:'2025-03-05', jumlah:10, satuan:'kg', nama_ri:'Klenteng', status_penyaluran:'disalurkan' },
];
const KAT_COLOR = { sembako:'#06d6a0', ekonomi:'#4361ee', pendidikan:'#4cc9f0', kesehatan:'#ffd166', lainnya:'#6b7a99' };
const KAT_ICON = { sembako:'🛒', ekonomi:'💰', pendidikan:'📚', kesehatan:'🏥', lainnya:'📦' };
async function load() {
try { jenisList = await Api.get('/bantuan'); } catch { jenisList = DEMO_JENIS; }
renderJenis();
loadHistori();
}
function renderJenis() {
const el = ge('bantuan-jenis-list');
el.innerHTML = jenisList.map(b => `
<div style="display:flex;align-items:center;gap:10px;padding:10px 12px;background:var(--bg4);border:1px solid var(--border);border-radius:8px;margin-bottom:6px;border-left:3px solid ${KAT_COLOR[b.kategori]||'#888'}">
<span style="font-size:20px">${KAT_ICON[b.kategori]||'📦'}</span>
<div style="flex:1;min-width:0">
<div style="font-weight:700;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${b.nama_bantuan}</div>
<div style="font-size:11px;color:var(--muted)">${b.kategori} · ${b.sumber||'—'} · per ${b.satuan||'unit'}</div>
</div>
${App.canEdit() ? `<button class="btn btn-danger btn-xs" onclick="Bantuan.hapusJenis(${b.id})">🗑</button>` : ''}
</div>`).join('') || '<p style="color:var(--muted);font-size:12px;padding:12px">Belum ada data bantuan</p>';
}
async function loadHistori() {
let data;
try { data = await Api.get('/histori?status=disalurkan'); } catch { data = DEMO_HISTORI; }
ge('bantuan-histori-list').innerHTML = data.map(h => `
<div style="padding:10px 12px;background:var(--bg4);border:1px solid var(--border);border-radius:8px;margin-bottom:6px">
<div style="display:flex;justify-content:space-between;align-items:flex-start">
<strong style="font-size:12.5px">${h.nama_lengkap}</strong>
<span class="bdg bdg-${h.status_penyaluran||'disalurkan'}" style="font-size:9px">${h.status_penyaluran}</span>
</div>
<div style="font-size:11px;color:var(--muted2);margin-top:3px">${h.nama_bantuan} · ${h.jumlah} ${h.satuan||''}</div>
<div style="font-size:10px;color:var(--muted);margin-top:2px">🕌 ${h.nama_ri||'—'} &nbsp;·&nbsp; 📅 ${h.tanggal?.substring(0,10)||'—'}</div>
</div>`).join('') || '<p style="color:var(--muted);font-size:12px;padding:12px">Belum ada riwayat</p>';
}
function showFormJenis() {
['fjb-nama','fjb-satuan','fjb-sumber','fjb-deskripsi'].forEach(id => sv(id,''));
sv('fjb-kategori','sembako');
App.showModal('modal-jenis-bantuan');
}
async function saveJenis() {
const nama = gv('fjb-nama');
if (!nama) { App.toast('Nama bantuan wajib diisi','error'); return; }
const body = { nama_bantuan: nama, kategori: gv('fjb-kategori'), sumber: gv('fjb-sumber'), satuan: gv('fjb-satuan'), deskripsi: gv('fjb-deskripsi') };
try {
await Api.post('/bantuan', body);
App.toast('Jenis bantuan ditambahkan ✅','success');
App.closeModal('modal-jenis-bantuan');
load();
} catch(e) { App.toast(e.message,'error'); }
}
async function hapusJenis(id) {
if (!confirm('Hapus jenis bantuan ini?')) return;
try { await Api.delete(`/bantuan/${id}`); App.toast('Dihapus','success'); load(); }
catch(e) { App.toast(e.message,'error'); }
}
async function showFormSalur(nikPrefill = '', namaPrefill = '') {
sv('fs-bantuan', '');
sv('fs-nik', nikPrefill);
sv('fs-jumlah', '1');
sv('fs-ket', '');
ge('fs-nik-info').classList.add('hidden');
ge('fs-tgl').value = new Date().toISOString().substring(0,10);
// Isi dropdown jenis bantuan
if (!jenisList.length) { try { jenisList = await Api.get('/bantuan'); } catch { jenisList = DEMO_JENIS; } }
ge('fs-bantuan').innerHTML = jenisList.map(b => `<option value="${b.id}">${KAT_ICON[b.kategori]||'📦'} ${b.nama_bantuan}</option>`).join('');
App.showModal('modal-salur');
if (nikPrefill) { setTimeout(() => cekPenerima(nikPrefill), 200); }
}
function quickSalur(nik, nama) { showFormSalur(nik, nama); }
async function cekPenerima(nik) {
if (nik.length < 16) { ge('fs-nik-info').classList.add('hidden'); return; }
try {
const info = await Api.get(`/histori/${nik}/cek`);
const box = ge('fs-nik-info');
if (info.total > 0) {
box.innerHTML = `✅ Sudah dibantu <strong>${info.total}x</strong> · ${info.jenis_bantuan||'—'} · Terakhir: ${info.terakhir_dibantu?.substring(0,10)||'—'}`;
box.className = 'alert-ok';
} else {
box.innerHTML = `⚠️ Belum pernah menerima bantuan`;
box.className = 'alert-warn';
}
box.classList.remove('hidden');
} catch {
ge('fs-nik-info').classList.add('hidden');
}
}
async function salurkan() {
const nik = gv('fs-nik');
if (!nik || nik.length !== 16) { App.toast('NIK penerima harus 16 digit','error'); return; }
if (!gv('fs-bantuan')) { App.toast('Pilih jenis bantuan','error'); return; }
if (!gv('fs-tgl')) { App.toast('Tanggal wajib diisi','error'); return; }
const body = { nik, id_bantuan: gv('fs-bantuan'), tanggal: gv('fs-tgl'), jumlah: gv('fs-jumlah'), keterangan: gv('fs-ket') };
try {
await Api.post('/histori', body);
App.toast('Bantuan berhasil dicatat ✅','success');
App.closeModal('modal-salur');
load();
} catch(e) { App.toast(e.message,'error'); }
}
return { load, showFormJenis, saveJenis, hapusJenis, showFormSalur, quickSalur, cekPenerima, salurkan };
})();
+99
View File
@@ -0,0 +1,99 @@
const Chat = (() => {
let currentRoom = null, pollTimer = null;
const DEMO_ROOMS = [
{ id:1, nama:'# Koordinasi Umum' },
{ id:2, nama:'# Bantuan Darurat' },
{ id:3, nama:'# Distribusi Sembako' },
];
const DEMO_MSGS = {
null: [
{ id:1, id_user:2, nama_lengkap:'Ahmad Fauzi', pesan:'Salam rekan-rekan semua. Ada keluarga di Gg. Mawar yang sangat butuh bantuan sembako segera.', created_at:'2025-05-13 08:00:00' },
{ id:2, id_user:3, nama_lengkap:'Romo Benediktus',pesan:'Baik. Kami di Katedral juga sedang tangani kasus Ibu Maria yang sakit keras. Mohon dikoordinasikan.', created_at:'2025-05-13 08:05:00' },
{ id:3, id_user:2, nama_lengkap:'Ahmad Fauzi', pesan:'Insya Allah kita distribusi bersama hari Jumat. Tolong daftarkan semua ke sistem ya.', created_at:'2025-05-13 08:10:00' },
{ id:4, id_user:3, nama_lengkap:'Romo Benediktus',pesan:'Setuju. Saya sudah verifikasi laporan Ibu Maria. Status diproses.', created_at:'2025-05-13 08:15:00' },
],
1: [{ id:5, id_user:2, nama_lengkap:'Ahmad Fauzi', pesan:'Koordinasi umum aktif. Silakan gunakan channel ini untuk diskusi lintas RI.', created_at:'2025-05-13 09:00:00' }],
2: [{ id:6, id_user:3, nama_lengkap:'Romo Benediktus', pesan:'Ada kasus darurat di Akcaya — balita gizi buruk. Butuh respons cepat.', created_at:'2025-05-14 07:00:00' }],
3: [{ id:7, id_user:2, nama_lengkap:'Ahmad Fauzi', pesan:'Jadwal distribusi sembako Ramadan: 15 Maret 2025, mulai pukul 08.00 di halaman masjid.', created_at:'2025-05-12 16:00:00' }],
};
async function init() {
// Load rooms
let rooms = [];
try { const d = await Api.get('/chat/rooms'); rooms = d?.length ? d : DEMO_ROOMS; }
catch { rooms = DEMO_ROOMS; }
const container = ge('chat-rooms');
container.innerHTML = `<button class="room-btn active" data-room="" onclick="Chat.loadRoom('',this)"># Umum</button>`;
rooms.forEach(r => {
const btn = document.createElement('button');
btn.className = 'room-btn';
btn.dataset.room = r.id;
btn.textContent = r.nama;
btn.onclick = function() { Chat.loadRoom(r.id, this); };
container.appendChild(btn);
});
await loadRoom(null, container.firstChild);
}
async function loadRoom(roomId, btn) {
currentRoom = roomId || null;
clearInterval(pollTimer);
document.querySelectorAll('.room-btn').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
await fetchMessages();
pollTimer = setInterval(fetchMessages, 5000); // poll tiap 5 detik
}
async function fetchMessages() {
let msgs = [];
try {
const url = currentRoom ? `/chat?room=${currentRoom}` : '/chat';
msgs = await Api.get(url);
} catch {
msgs = DEMO_MSGS[currentRoom] || DEMO_MSGS[null];
}
renderMessages(msgs);
}
function renderMessages(msgs) {
const box = ge('chat-messages');
const user = App.currentUser();
if (!msgs?.length) {
box.innerHTML = '<div class="chat-empty">💬 Belum ada pesan di channel ini</div>';
return;
}
const prevScroll = box.scrollHeight - box.scrollTop;
box.innerHTML = msgs.map(m => {
const mine = user && m.id_user == user.id;
return `<div class="chat-bubble ${mine ? 'mine' : 'other'}">
${!mine ? `<div class="chat-sender">${m.nama_lengkap || 'User'}</div>` : ''}
<div class="chat-text">${escHtml(m.pesan)}</div>
<div class="chat-time">${(m.created_at||'').substring(11,16)}</div>
</div>`;
}).join('');
// Scroll ke bawah jika sudah di bawah
if (prevScroll < 200) box.scrollTop = box.scrollHeight;
}
async function send() {
const inp = ge('chat-input');
const pesan = inp.value.trim();
if (!pesan) return;
if (!App.currentUser()) { App.toast('Harus login untuk mengirim pesan','warning'); App.showModal('modal-login'); return; }
inp.value = '';
try {
await Api.post('/chat', { pesan, id_room: currentRoom });
await fetchMessages();
} catch(e) { App.toast(e.message,'error'); }
}
function escHtml(s) {
return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\n/g,'<br>');
}
return { init, loadRoom, send };
})();
+69
View File
@@ -0,0 +1,69 @@
const Dashboard = (() => {
async function load() {
try {
const d = await Api.get('/dashboard');
const s = d.statistik;
sv2('d-miskin', s.total_miskin); sv2('d-rentan', s.total_rentan);
sv2('d-mampu', s.total_mampu); sv2('d-kk', s.total_kk);
sv2('d-belum', d.belum_dibantu); sv2('d-darurat', s.laporan_pending);
sv2('hp-miskin-n', s.total_miskin); sv2('hp-rentan-n', s.total_rentan);
sv2('hp-mampu-n', s.total_mampu); sv2('hp-lap-n', s.laporan_pending);
ge('dash-updated').textContent = 'Update: ' + new Date().toLocaleTimeString('id-ID');
renderPrio(d.prioritas_darurat);
renderBar('d-kec-chart', d.sebaran_kecamatan, r => ({ label: r.kecamatan||'?', val: +r.miskin+ +r.rentan, color:'#ff4d6d', sub:`${r.miskin} miskin` }));
renderBar('d-ri-chart', d.beban_per_ri, r => ({ label: r.nama, val: +r.kk_miskin, color:'#4361ee', sub:`${r.jumlah_kk} KK` }));
renderBar('d-trend-chart', d.trend_bantuan, r => ({ label: r.bulan, val: +r.jumlah, color:'#06d6a0', sub:`${r.jumlah}x` }));
} catch { loadDemo(); }
}
function loadDemo() {
sv2('d-miskin',6); sv2('d-rentan',4); sv2('d-mampu',2);
sv2('d-kk',8); sv2('d-belum',4); sv2('d-darurat',2);
sv2('hp-miskin-n',6); sv2('hp-rentan-n',4); sv2('hp-mampu-n',2); sv2('hp-lap-n',2);
ge('dash-updated').textContent = 'Data demo (offline)';
renderPrio([
{ nama_lengkap:'Maria Magdalena', umur:54, kondisi_kesehatan:'sakit_berat', kecamatan:'Pontianak Selatan', jumlah_dibantu:1, level_prioritas:'SANGAT TINGGI', nik:'6171010101010006' },
{ nama_lengkap:'Tan Eng Huat', umur:65, kondisi_kesehatan:'disabilitas', kecamatan:'Pontianak Kota', jumlah_dibantu:1, level_prioritas:'SANGAT TINGGI', nik:'6171010101010014' },
{ nama_lengkap:'Lim Ah Kow', umur:59, kondisi_kesehatan:'sakit_berat', kecamatan:'Pontianak Kota', jumlah_dibantu:1, level_prioritas:'TINGGI', nik:'6171010101010010' },
]);
renderBar('d-kec-chart',[{kecamatan:'Pontianak Timur',miskin:2,rentan:1},{kecamatan:'Pontianak Selatan',miskin:2,rentan:2},{kecamatan:'Pontianak Kota',miskin:2,rentan:1}],
r=>({label:r.kecamatan,val:+r.miskin+ +r.rentan,color:'#ff4d6d',sub:`${r.miskin} miskin`}));
renderBar('d-ri-chart',[{nama:"Masjid Jami'",jumlah_kk:3,kk_miskin:2},{nama:'Katedral',jumlah_kk:2,kk_miskin:2},{nama:'Mujahidin',jumlah_kk:1,kk_miskin:1}],
r=>({label:r.nama,val:+r.kk_miskin,color:'#4361ee',sub:`${r.jumlah_kk} KK`}));
renderBar('d-trend-chart',[{bulan:'2025-01',jumlah:2},{bulan:'2025-02',jumlah:1},{bulan:'2025-03',jumlah:3},{bulan:'2025-04',jumlah:1}],
r=>({label:r.bulan,val:+r.jumlah,color:'#06d6a0',sub:`${r.jumlah}x`}));
}
function renderPrio(list) {
const el = ge('d-prio-list');
if (!list?.length) { el.innerHTML = '<div class="empty-state"><div class="es-icon">✅</div><p>Tidak ada kasus darurat</p></div>'; return; }
const cls = { 'SANGAT TINGGI':'pb-sangat-tinggi','TINGGI':'pb-tinggi','SEDANG':'pb-sedang','RENDAH':'pb-rendah' };
el.innerHTML = list.map(p => `
<div class="prio-item" onclick="Bantuan.quickSalur('${p.nik}','${p.nama_lengkap}')">
<span class="prio-badge ${cls[p.level_prioritas]||'pb-rendah'}">${p.level_prioritas}</span>
<div style="flex:1;min-width:0">
<div style="font-weight:700;font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${p.nama_lengkap}</div>
<div style="font-size:11px;color:var(--muted)">${cap(p.kondisi_kesehatan)} · ${p.kecamatan} · dibantu ${p.jumlah_dibantu}x</div>
</div>
<span style="font-size:18px;flex-shrink:0">→</span>
</div>`).join('');
}
function renderBar(elId, data, mapper) {
const el = ge(elId);
if (!data?.length) { el.innerHTML = '<p style="color:var(--muted);font-size:12px;padding:12px">Tidak ada data</p>'; return; }
const mapped = data.map(mapper);
const max = Math.max(...mapped.map(m => m.val), 1);
el.innerHTML = mapped.map(m => {
const pct = Math.round(m.val / max * 100);
return `<div class="bar-row">
<div class="bar-lbl" title="${m.label}">${m.label.slice(0,16)}${m.label.length>16?'…':''}</div>
<div class="bar-track"><div class="bar-fill" style="width:${pct}%;background:${m.color}"></div></div>
<div class="bar-val">${m.val}<span style="font-size:9px;color:var(--muted);margin-left:3px">${m.sub?.replace(/^\d+ /,'')}</span></div>
</div>`;
}).join('');
}
function sv2(id, val) { const el = ge(id); if (el) el.textContent = val ?? '—'; }
return { load };
})();
+127
View File
@@ -0,0 +1,127 @@
const Keluarga = (() => {
let all = [], editId = null;
const DEMO = [
{ id:1, no_kk:'6171010101010001', nama_kepala:'Ahmad Suryana', kecamatan:'Pontianak Timur', kelurahan:'Dalam Bugis', jumlah_anggota:3, total_penghasilan:900000, nama_ri:"Masjid Jami'", status_ekonomi:'miskin', total_bantuan:2 },
{ id:2, no_kk:'6171010101010002', nama_kepala:'Samsul Bahri', kecamatan:'Pontianak Kota', kelurahan:'Sungai Bangkong',jumlah_anggota:2,total_penghasilan:1700000, nama_ri:"Masjid Jami'", status_ekonomi:'rentan', total_bantuan:0 },
{ id:3, no_kk:'6171010101010003', nama_kepala:'Maria Magdalena', kecamatan:'Pontianak Selatan', kelurahan:'Akcaya', jumlah_anggota:3, total_penghasilan:800000, nama_ri:'Katedral', status_ekonomi:'miskin', total_bantuan:1 },
{ id:4, no_kk:'6171010101010004', nama_kepala:'Hendra Wijaya', kecamatan:'Pontianak Selatan', kelurahan:'Parit Tokaya', jumlah_anggota:2, total_penghasilan:1500000, nama_ri:'Mujahidin', status_ekonomi:'rentan', total_bantuan:0 },
{ id:5, no_kk:'6171010101010005', nama_kepala:'Lim Ah Kow', kecamatan:'Pontianak Kota', kelurahan:'Darat Sekip', jumlah_anggota:2, total_penghasilan:700000, nama_ri:'Vihara', status_ekonomi:'miskin', total_bantuan:1 },
{ id:6, no_kk:'6171010101010006', nama_kepala:'Mulyadi', kecamatan:'Pontianak Timur', kelurahan:'Dalam Bugis', jumlah_anggota:2, total_penghasilan:4500000, nama_ri:"Masjid Jami'", status_ekonomi:'mampu', total_bantuan:0 },
{ id:7, no_kk:'6171010101010007', nama_kepala:'Yohanes Kristian',kecamatan:'Pontianak Selatan', kelurahan:'Akcaya', jumlah_anggota:2, total_penghasilan:1100000, nama_ri:'Katedral', status_ekonomi:'rentan', total_bantuan:0 },
{ id:8, no_kk:'6171010101010008', nama_kepala:'Tan Eng Huat', kecamatan:'Pontianak Kota', kelurahan:'Pontianak Kota',jumlah_anggota:1,total_penghasilan:600000, nama_ri:'Klenteng', status_ekonomi:'miskin', total_bantuan:1 },
];
async function load() {
try { all = await Api.get('/keluarga'); } catch { all = DEMO; }
applyFilter();
}
function search(q) { applyFilter(); }
function applyFilter() {
const q = gv('kf-search');
const ek = gv('kf-ekonomi');
const data = all.filter(k =>
(!q || k.nama_kepala?.toLowerCase().includes(q.toLowerCase()) || k.no_kk?.includes(q)) &&
(!ek || k.status_ekonomi === ek)
);
render(data);
}
function render(data) {
const tb = ge('tbody-keluarga');
if (!data.length) { tb.innerHTML = '<tr><td colspan="9"><div class="empty-state"><div class="es-icon">🏠</div><h3>Tidak ada data</h3></div></td></tr>'; return; }
tb.innerHTML = data.map(k => `
<tr class="row-${k.status_ekonomi}">
<td class="mono">${k.no_kk}</td>
<td><strong>${k.nama_kepala}</strong><div style="font-size:10px;color:var(--muted)">${k.kelurahan||''}</div></td>
<td>${k.kecamatan||'—'}</td>
<td style="text-align:center;font-weight:700;font-size:15px">${k.jumlah_anggota||0}</td>
<td class="mono" style="font-size:11px">${rp(k.total_penghasilan)}</td>
<td>${k.nama_ri||'—'}</td>
<td>${bdg(k.status_ekonomi,'')}</td>
<td style="text-align:center">
${k.total_bantuan > 0
? `<span class="bdg bdg-sehat">✅ ${k.total_bantuan}x</span>`
: `<span class="bdg bdg-darurat">❌ Belum</span>`}
</td>
<td>
<div style="display:flex;gap:4px">
<button class="btn btn-secondary btn-xs" onclick="Keluarga.showAnggota(${k.id})" title="Lihat anggota">👥</button>
${App.canEdit() ? `
<button class="btn btn-secondary btn-xs" onclick="Keluarga.showForm(${k.id})" title="Edit">✏️</button>
<button class="btn btn-danger btn-xs" onclick="Keluarga.hapus(${k.id})" title="Hapus">🗑</button>` : ''}
</div>
</td>
</tr>`).join('');
}
async function showForm(id = null) {
editId = id;
ge('mk-title').textContent = id ? '✏️ Edit Data Keluarga' : '🏠 Tambah Kartu Keluarga';
ge('fk-nokk').readOnly = !!id;
// Load RI dropdown
try {
const riList = await Api.get('/rumah-ibadah');
ge('fk-ri').innerHTML = '<option value="">— Pilih Rumah Ibadah —</option>' +
riList.map(r => `<option value="${r.id}">${r.nama}</option>`).join('');
} catch {
ge('fk-ri').innerHTML = '<option value="1">Masjid Jami\' Pontianak</option>';
}
if (id) {
const k = all.find(x => x.id == id) || {};
sv('fk-nokk', k.no_kk); sv('fk-kepala', k.nama_kepala);
sv('fk-alamat', k.alamat); sv('fk-kecamatan', k.kecamatan);
sv('fk-kelurahan', k.kelurahan); sv('fk-ekonomi', k.status_ekonomi || 'rentan');
sv('fk-lat', k.lat); sv('fk-lng', k.lng);
if (k.id_rumah_ibadah) sv('fk-ri', k.id_rumah_ibadah);
} else {
['fk-nokk','fk-kepala','fk-alamat','fk-kecamatan','fk-kelurahan','fk-lat','fk-lng'].forEach(i => sv(i,''));
sv('fk-ekonomi','rentan');
}
App.showModal('modal-keluarga');
}
async function save() {
const nokk = gv('fk-nokk');
if (!nokk || nokk.length !== 16) { App.toast('No. KK harus 16 digit','error'); return; }
if (!gv('fk-kepala')) { App.toast('Nama kepala wajib diisi','error'); return; }
const body = {
no_kk: nokk, nama_kepala: gv('fk-kepala'),
id_rumah_ibadah: gv('fk-ri') || null,
alamat: gv('fk-alamat'), kecamatan: gv('fk-kecamatan'),
kelurahan: gv('fk-kelurahan'), status_ekonomi: gv('fk-ekonomi'),
lat: gv('fk-lat') || null, lng: gv('fk-lng') || null,
};
try {
if (editId) { await Api.put(`/keluarga/${editId}`, body); App.toast('Data KK diperbarui ✅','success'); }
else { await Api.post('/keluarga', body); App.toast('KK ditambahkan ✅','success'); }
App.closeModal('modal-keluarga');
load();
} catch(e) { App.toast(e.message,'error'); }
}
async function hapus(id) {
if (!confirm('Hapus data keluarga ini?\nPastikan semua anggota sudah dipindah atau dihapus.')) return;
try { await Api.delete(`/keluarga/${id}`); App.toast('Data KK dihapus','success'); load(); }
catch(e) { App.toast(e.message,'error'); }
}
async function showAnggota(id) {
const k = all.find(x => x.id == id);
let anggota = [];
try {
const detail = await Api.get(`/keluarga/${id}`);
anggota = detail.anggota || [];
} catch {
anggota = [];
}
if (!anggota.length) { App.toast('Tidak ada data anggota (atau belum terhubung ke API)','info'); return; }
// Tampilkan dalam alert atau panel detail
const info = anggota.map(a => `${a.nama_lengkap} (${a.umur}th) — ${cap(a.status_keluarga)}${a.status_ekonomi}`).join('\n');
alert(`Anggota KK: ${k?.nama_kepala}\n\n${info}`);
}
return { load, search, applyFilter, showForm, save, hapus, showAnggota };
})();
+96
View File
@@ -0,0 +1,96 @@
const LaporanPanel = (() => {
const DEMO = [
{ id:1, nama_pelapor:'Anonim', deskripsi:'Ibu Maria sakit keras sudah seminggu, tidak bisa berobat karena tidak ada biaya. Butuh bantuan medis segera.', urgensi:'darurat', status:'diverifikasi', alamat_laporan:'Jl. Kemuning No.3, Akcaya', created_at:'2025-05-01T08:00:00', nama_ri:'Gereja Katedral' },
{ id:2, nama_pelapor:'Eko Prasetyo', deskripsi:'Ada keluarga baru pindah ke Gg. Melati yang sepertinya sangat membutuhkan bantuan. Anak-anak tidak sekolah.', urgensi:'sedang', status:'pending', alamat_laporan:'Gg. Melati, Dalam Bugis', created_at:'2025-05-10T10:00:00', nama_ri:"Masjid Jami'" },
{ id:3, nama_pelapor:'Anonim', deskripsi:'Pak Tan penyandang disabilitas, hidup sebatang kara dan sangat sulit mendapat penghasilan. Perlu perhatian.', urgensi:'tinggi', status:'diproses', alamat_laporan:'Jl. Gusti Situt No.7', created_at:'2025-05-12T14:00:00', nama_ri:'Klenteng Kwan Ti Miau' },
{ id:4, nama_pelapor:'Hendra', deskripsi:'Balita 2 tahun kondisi gizi buruk. Orang tua tidak mampu memenuhi kebutuhan gizi anak.', urgensi:'darurat', status:'pending', alamat_laporan:'Gg. Teratai, Parit Tokaya', created_at:'2025-05-15T09:00:00', nama_ri:'Masjid Mujahidin' },
];
const URG = { darurat:'🔴', tinggi:'🟠', sedang:'🟡', rendah:'🟢' };
const URG_COLOR = { darurat:'var(--red)', tinggi:'var(--orange)', sedang:'var(--yellow)', rendah:'var(--green)' };
const STATUS_LABEL = { pending:'⏳ Pending', diverifikasi:'✅ Diverifikasi', diproses:'🔄 Diproses', selesai:'🏁 Selesai' };
async function load() {
const st = gv('lf-status');
const ur = gv('lf-urgensi');
let url = '/laporan';
const p = []; if(st) p.push(`status=${st}`); if(ur) p.push(`urgensi=${ur}`);
if(p.length) url += '?' + p.join('&');
let data;
try { data = await Api.get(url); } catch { data = DEMO; }
if(st) data = data.filter(l => l.status === st);
if(ur) data = data.filter(l => l.urgensi === ur);
render(data);
}
function render(data) {
const el = ge('laporan-grid');
if (!data.length) {
el.innerHTML = '<div class="empty-state" style="grid-column:1/-1"><div class="es-icon">✅</div><h3>Tidak ada laporan</h3><p>Semua laporan sudah ditangani</p></div>';
return;
}
el.innerHTML = data.map(l => `
<div class="laporan-card ${l.urgensi}">
<div class="lc-hdr">
<div>
<span class="bdg bdg-${l.urgensi}" style="margin-bottom:6px;display:inline-block">${URG[l.urgensi]||'📋'} ${(l.urgensi||'').toUpperCase()}</span>
<div class="lc-title">#${l.id}${l.nama_pelapor||'Anonim'}</div>
</div>
<span class="bdg bdg-${l.status}">${STATUS_LABEL[l.status]||l.status}</span>
</div>
<div class="lc-desc">${l.deskripsi}</div>
<div class="lc-meta">
<span>📍 ${l.alamat_laporan||'—'}</span>
<span>🕌 ${l.nama_ri||'—'}</span>
<span>📅 ${(l.created_at||'').substring(0,10)}</span>
</div>
${App.canEdit() && l.status !== 'selesai' ? `
<div class="lc-actions">
${l.status === 'pending' ? `<button class="btn btn-secondary btn-sm" onclick="LaporanPanel.updateStatus(${l.id},'diverifikasi')">✅ Verifikasi</button>` : ''}
${l.status === 'diverifikasi' ? `<button class="btn btn-warning btn-sm" onclick="LaporanPanel.updateStatus(${l.id},'diproses')">🔄 Proses</button>` : ''}
${l.status !== 'selesai' ? `<button class="btn btn-success btn-sm" onclick="LaporanPanel.updateStatus(${l.id},'selesai')">🏁 Selesai</button>` : ''}
<button class="btn btn-danger btn-sm" onclick="LaporanPanel.hapus(${l.id})">🗑</button>
</div>` : ''}
</div>`).join('');
}
async function updateStatus(id, status) {
try {
await Api.put(`/laporan/${id}`, { status });
App.toast(`Status laporan diubah: ${STATUS_LABEL[status]||status}`, 'success');
load(); App.updateHeaderStats();
} catch(e) { App.toast(e.message,'error'); }
}
async function hapus(id) {
if (!confirm('Hapus laporan ini?')) return;
try { await Api.delete(`/laporan/${id}`); App.toast('Laporan dihapus','success'); load(); }
catch(e) { App.toast(e.message,'error'); }
}
function showKirimModal() {
['fl-nama','fl-hp','fl-nik','fl-alamat','fl-deskripsi'].forEach(id => sv(id,''));
sv('fl-urgensi','sedang');
App.showModal('modal-laporan');
}
async function kirim() {
if (!gv('fl-deskripsi')) { App.toast('Deskripsi kondisi wajib diisi','error'); return; }
const body = {
nama_pelapor: gv('fl-nama') || null,
no_hp_pelapor: gv('fl-hp') || null,
nik_terdampak: gv('fl-nik') || null,
urgensi: gv('fl-urgensi'),
alamat_laporan:gv('fl-alamat'),
deskripsi: gv('fl-deskripsi'),
};
try {
await Api.post('/laporan', body);
App.closeModal('modal-laporan');
App.toast('Laporan berhasil dikirim! Terima kasih 🙏','success');
load(); App.updateHeaderStats();
} catch(e) { App.toast(e.message,'error'); }
}
return { load, updateStatus, hapus, showKirimModal, kirim };
})();
+40
View File
@@ -0,0 +1,40 @@
const LogPanel = (() => {
const DEMO = [
{ created_at:'2025-05-14 10:32:00', nama_lengkap:'Ahmad Fauzi', aksi:'create_histori_bantuan', tabel:'histori_bantuan', id_record:'5', ip_address:'192.168.1.10' },
{ created_at:'2025-05-14 09:15:00', nama_lengkap:'Romo Benediktus', aksi:'update_laporan', tabel:'laporan', id_record:'1', ip_address:'192.168.1.20' },
{ created_at:'2025-05-13 08:10:00', nama_lengkap:'Ahmad Fauzi', aksi:'update_penduduk', tabel:'penduduk', id_record:'6171010101010006', ip_address:'192.168.1.10' },
{ created_at:'2025-05-13 08:00:00', nama_legendkap:'Ahmad Fauzi', aksi:'login', tabel:'user', id_record:'2', ip_address:'192.168.1.10' },
{ created_at:'2025-05-12 16:45:00', nama_lengkap:'Administrator', aksi:'create_penduduk', tabel:'penduduk', id_record:'6171010101010014', ip_address:'127.0.0.1' },
{ created_at:'2025-05-12 14:00:00', nama_lengkap:'Administrator', aksi:'delete_penduduk', tabel:'penduduk', id_record:'6171999999999999', ip_address:'127.0.0.1' },
];
async function load() {
let data;
try { data = await Api.get('/log'); } catch { data = DEMO; }
render(data);
}
function getAksiClass(aksi) {
if (aksi?.startsWith('create')) return 'create';
if (aksi?.startsWith('update')) return 'update';
if (aksi?.startsWith('delete')) return 'delete';
if (aksi === 'login' || aksi === 'logout') return 'login';
return '';
}
function render(data) {
const tb = ge('tbody-log');
if (!data.length) { tb.innerHTML = '<tr><td colspan="6"><div class="empty-state"><div class="es-icon">📋</div><h3>Tidak ada log</h3></div></td></tr>'; return; }
tb.innerHTML = data.map(l => `
<tr>
<td class="mono" style="font-size:11px;white-space:nowrap">${(l.created_at||'').substring(0,16)}</td>
<td style="font-weight:600">${l.nama_lengkap||'—'}</td>
<td><span class="log-badge ${getAksiClass(l.aksi)}">${l.aksi}</span></td>
<td style="color:var(--muted2);font-size:11px">${l.tabel||'—'}</td>
<td class="mono" style="font-size:10px;color:var(--muted);max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${(l.id_record||'').substring(0,20)}</td>
<td style="font-size:11px;color:var(--muted)">${l.ip_address||'—'}</td>
</tr>`).join('');
}
return { load };
})();
+199
View File
@@ -0,0 +1,199 @@
const MapApp = (() => {
let map, clusterLayer, riLayer, laporanLayer, radiusLayer;
let allPenduduk = [], allRI = [], allLaporan = [];
let layerState = { penduduk:true, ri:true, laporan:false, radius:true };
let ready = false;
const COLOR = { miskin:'#ff4d6d', rentan:'#ffd166', mampu:'#06d6a0' };
const RI_EMOJI = { Masjid:'🕌', Gereja:'⛪', Vihara:'🛕', Pura:'🛕', Klenteng:'🏮', Lainnya:'🏛️' };
function mkIcon(color, emoji='') {
return L.divIcon({
className:'',
html:`<div style="width:30px;height:30px;border-radius:50%;background:${color};display:flex;align-items:center;justify-content:center;font-size:13px;box-shadow:0 2px 8px rgba(0,0,0,.5);border:2px solid rgba(255,255,255,.25)">${emoji||'👤'}</div>`,
iconSize:[30,30],iconAnchor:[15,15],popupAnchor:[0,-16]
});
}
function riIcon(emoji) {
return L.divIcon({
className:'',
html:`<div style="font-size:28px;filter:drop-shadow(0 2px 6px rgba(0,0,0,.6));line-height:1">${emoji}</div>`,
iconSize:[34,34],iconAnchor:[17,17],popupAnchor:[0,-18]
});
}
function init() {
if (ready) { setTimeout(()=>map.invalidateSize(),100); return; }
ready = true;
map = L.map('map',{ center:[-0.028,109.338],zoom:13,zoomControl:true });
const tiles = {
'Dark': L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',{attribution:'© CARTO',maxZoom:19}),
'Street': L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{attribution:'© OSM',maxZoom:19}),
'Satellite': L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',{attribution:'© Esri',maxZoom:19}),
};
tiles['Dark'].addTo(map);
L.control.layers(tiles,{},{collapsed:false,position:'topright'}).addTo(map);
clusterLayer = L.markerClusterGroup({ maxClusterRadius:60, showCoverageOnHover:false });
riLayer = L.layerGroup();
laporanLayer = L.layerGroup();
radiusLayer = L.layerGroup();
map.addLayer(clusterLayer);
map.addLayer(riLayer);
map.addLayer(radiusLayer);
// Koordinat kursor
const coordDiv = L.control({position:'bottomleft'});
coordDiv.onAdd = ()=>{
const d=L.DomUtil.create('div');
d.style.cssText='background:rgba(8,12,20,.85);color:#aaa;padding:4px 12px;border-radius:6px;font-size:11px;font-family:JetBrains Mono,monospace;border:1px solid rgba(255,255,255,.1)';
map.on('mousemove',e=>d.innerHTML=`${e.latlng.lat.toFixed(6)}, ${e.latlng.lng.toFixed(6)}`);
return d;
};
coordDiv.addTo(map);
loadAll();
}
async function loadAll() {
try {
const [gP, gR, gL] = await Promise.all([
Api.get('/peta?layer=penduduk'),
Api.get('/peta?layer=rumah_ibadah'),
Api.get('/peta?layer=laporan'),
]);
allPenduduk = gP.features || [];
allRI = gR.features || [];
allLaporan = gL.features || [];
} catch {
// Demo data
allPenduduk = buildDemoPenduduk();
allRI = buildDemoRI();
allLaporan = buildDemoLaporan();
}
renderAll();
}
function renderAll() {
renderPenduduk(allPenduduk);
renderRI(allRI);
renderLaporan(allLaporan);
}
function renderPenduduk(features) {
clusterLayer.clearLayers();
features.forEach(f => {
const p = f.properties;
const [lng,lat] = f.geometry.coordinates;
const icon = mkIcon(COLOR[p.status_ekonomi]||COLOR.rentan);
L.marker([lat,lng],{icon}).bindPopup(`
<div class="map-popup">
<div class="mp-title">🏠 ${p.nama_kepala||p.nama_lengkap}</div>
<hr class="mp-divider">
<div class="mp-row"><span>No. KK</span><strong class="mono">${p.no_kk||p.nik||'—'}</strong></div>
<div class="mp-row"><span>Alamat</span><strong>${p.alamat||'—'}</strong></div>
<div class="mp-row"><span>Anggota</span><strong>${p.jumlah_anggota||1} jiwa</strong></div>
<div class="mp-row"><span>Status</span><strong style="color:${COLOR[p.status_ekonomi]}">${(p.status_ekonomi||'').toUpperCase()}</strong></div>
<div class="mp-row"><span>Bantuan</span><strong>${p.total_bantuan||0}x diterima</strong></div>
<div class="mp-row"><span>Rumah Ibadah</span><strong>${p.nama_ri||'—'}</strong></div>
</div>`).addTo(clusterLayer);
});
}
function renderRI(features) {
riLayer.clearLayers(); radiusLayer.clearLayers();
features.forEach(f => {
const r = f.properties;
const [lng,lat] = f.geometry.coordinates;
const emoji = RI_EMOJI[r.jenis]||'🏛️';
L.marker([lat,lng],{icon:riIcon(emoji)}).bindPopup(`
<div class="map-popup">
<div class="mp-title">${emoji} ${r.nama}</div>
<div class="mp-row"><span>Jenis</span><strong>${r.jenis}</strong></div>
<div class="mp-row"><span>Alamat</span><strong>${r.alamat||'—'}</strong></div>
<div class="mp-row"><span>Kontak</span><strong>${r.kontak||'—'}</strong></div>
<div class="mp-row"><span>Radius</span><strong>${r.radius_meter}m</strong></div>
</div>`).addTo(riLayer);
L.circle([lat,lng],{
radius:r.radius_meter,color:'#4361ee',fillColor:'#4361ee',
fillOpacity:.06,weight:1.5,dashArray:'6 4'
}).addTo(radiusLayer);
});
}
function renderLaporan(features) {
laporanLayer.clearLayers();
const UC = { darurat:'#ff4d6d', tinggi:'#ff8c42', sedang:'#ffd166', rendah:'#06d6a0' };
const UE = { darurat:'🚨', tinggi:'⚠️', sedang:'📋', rendah:'📝' };
features.forEach(f => {
const l = f.properties;
const [lng,lat] = f.geometry.coordinates;
const icon = mkIcon(UC[l.urgensi]||'#888', UE[l.urgensi]||'📋');
L.marker([lat,lng],{icon}).bindPopup(`
<div class="map-popup">
<div class="mp-title">${UE[l.urgensi]||'📋'} Laporan ${(l.urgensi||'').toUpperCase()}</div>
<hr class="mp-divider">
<div style="font-size:12px;color:#aaa;margin-bottom:8px">${l.deskripsi||'—'}</div>
<div class="mp-row"><span>Alamat</span><strong>${l.alamat_laporan||'—'}</strong></div>
<div class="mp-row"><span>Status</span><strong>${l.status||'—'}</strong></div>
</div>`).addTo(laporanLayer);
});
}
function applyFilter() {
const status = ge('map-status-filter')?.value;
const data = status ? allPenduduk.filter(f => f.properties.status_ekonomi === status) : allPenduduk;
renderPenduduk(data);
}
function toggleLayer(name, btn) {
layerState[name] = !layerState[name];
const on = layerState[name];
const layerMap = { penduduk:clusterLayer, ri:riLayer, laporan:laporanLayer, radius:radiusLayer };
const activeCls = { penduduk:'active-green', ri:'active-blue', laporan:'active-purple', radius:'active-orange' };
on ? map.addLayer(layerMap[name]) : map.removeLayer(layerMap[name]);
if (btn) {
btn.classList.toggle(activeCls[name], on);
}
}
// Demo seed data (offline mode)
function buildDemoPenduduk() {
const seeds = [
{ nama_kepala:'Ahmad Suryana', alamat:'Gg. Mawar No.5', lat:-0.027, lng:109.344, status_ekonomi:'miskin', jumlah_anggota:3, no_kk:'6171010101010001', nama_ri:"Masjid Jami'", total_bantuan:2 },
{ nama_kepala:'Samsul Bahri', alamat:'Jl. Veteran No.12', lat:-0.0315, lng:109.339, status_ekonomi:'rentan', jumlah_anggota:2, no_kk:'6171010101010002', nama_ri:"Masjid Jami'", total_bantuan:0 },
{ nama_kepala:'Maria Magdalena',alamat:'Jl. Kemuning No.3', lat:-0.033, lng:109.331, status_ekonomi:'miskin', jumlah_anggota:3, no_kk:'6171010101010003', nama_ri:'Katedral', total_bantuan:1 },
{ nama_kepala:'Hendra Wijaya', alamat:'Jl. Ahmad Yani', lat:-0.049, lng:109.326, status_ekonomi:'rentan', jumlah_anggota:2, no_kk:'6171010101010004', nama_ri:'Mujahidin', total_bantuan:0 },
{ nama_kepala:'Lim Ah Kow', alamat:'Jl. Diponegoro', lat:-0.022, lng:109.337, status_ekonomi:'miskin', jumlah_anggota:2, no_kk:'6171010101010005', nama_ri:'Vihara', total_bantuan:1 },
{ nama_kepala:'Mulyadi', alamat:'Gg. Teratai No.8', lat:-0.0265, lng:109.346, status_ekonomi:'mampu', jumlah_anggota:2, no_kk:'6171010101010006', nama_ri:"Masjid Jami'", total_bantuan:0 },
{ nama_kepala:'Yohanes Kristian',alamat:'Jl. HOS Cokro', lat:-0.034, lng:109.330, status_ekonomi:'rentan', jumlah_anggota:2, no_kk:'6171010101010007', nama_ri:'Katedral', total_bantuan:0 },
{ nama_kepala:'Tan Eng Huat', alamat:'Jl. Gusti Situt', lat:-0.020, lng:109.4415,status_ekonomi:'miskin', jumlah_anggota:1, no_kk:'6171010101010008', nama_ri:'Klenteng', total_bantuan:1 },
];
return seeds.map(s=>({type:'Feature',geometry:{type:'Point',coordinates:[s.lng,s.lat]},properties:s}));
}
function buildDemoRI() {
const ri = [
{ nama:"Masjid Jami' Pontianak", jenis:'Masjid', lat:-0.02596, lng:109.34307, radius_meter:350, alamat:'Jl. Tanjungpura', kontak:'0561-732001' },
{ nama:'Gereja Katedral Pontianak', jenis:'Gereja', lat:-0.03200, lng:109.33200, radius_meter:300, alamat:'Jl. Patimura No.9', kontak:'0561-766000' },
{ nama:'Vihara Bodhisattva Karaniya Metta', jenis:'Vihara', lat:-0.02100, lng:109.33800, radius_meter:300, alamat:'Jl. Diponegoro', kontak:'0561-733456' },
{ nama:'Klenteng Kwan Ti Miau', jenis:'Klenteng', lat:-0.01900, lng:109.34100, radius_meter:280, alamat:'Jl. Gusti Situt', kontak:'0561-740123' },
{ nama:'Masjid Mujahidin Pontianak', jenis:'Masjid', lat:-0.04800, lng:109.32500, radius_meter:400, alamat:'Jl. Ahmad Yani', kontak:'0561-764567' },
{ nama:'Gereja GPIB Immanuel', jenis:'Gereja', lat:-0.02800, lng:109.34500, radius_meter:300, alamat:'Jl. Rahadi Usman', kontak:'0561-735890' },
];
return ri.map(r=>({type:'Feature',geometry:{type:'Point',coordinates:[r.lng,r.lat]},properties:r}));
}
function buildDemoLaporan() {
return [
{ type:'Feature',geometry:{type:'Point',coordinates:[109.3311,-0.0330]},properties:{ deskripsi:'Ibu Maria sakit keras, tidak bisa berobat', urgensi:'darurat', status:'diverifikasi', alamat_laporan:'Jl. Kemuning No.3' }},
{ type:'Feature',geometry:{type:'Point',coordinates:[109.3415,-0.0200]},properties:{ deskripsi:'Pak Tan penyandang disabilitas butuh bantuan', urgensi:'tinggi', status:'diproses', alamat_laporan:'Jl. Gusti Situt No.7' }},
];
}
return { init, applyFilter, toggleLayer, loadAll };
})();
+217
View File
@@ -0,0 +1,217 @@
const Penduduk = (() => {
let all = [], filtered = [], editNIK = null;
const DEMO = [
{ nik:'6171010101010001', nama_lengkap:'Ahmad Suryana', umur:44, status_keluarga:'kepala', pekerjaan:'Buruh Harian', penghasilan:900000, status_ekonomi:'miskin', kondisi_kesehatan:'sehat', pendidikan_terakhir:'SMP', no_kk:'6171010101010001', kecamatan:'Pontianak Timur' },
{ nik:'6171010101010002', nama_lengkap:'Siti Rahma', umur:39, status_keluarga:'istri', pekerjaan:'Ibu RT', penghasilan:0, status_ekonomi:'miskin', kondisi_kesehatan:'sakit_ringan',pendidikan_terakhir:'SD', no_kk:'6171010101010001', kecamatan:'Pontianak Timur' },
{ nik:'6171010101010003', nama_lengkap:'Budi Suryana', umur:12, status_keluarga:'anak', pekerjaan:'Pelajar', penghasilan:0, status_ekonomi:'miskin', kondisi_kesehatan:'sehat', pendidikan_terakhir:'SMP', no_kk:'6171010101010001', kecamatan:'Pontianak Timur' },
{ nik:'6171010101010004', nama_lengkap:'Samsul Bahri', umur:49, status_keluarga:'kepala', pekerjaan:'Pedagang Kaki Lima',penghasilan:1200000,status_ekonomi:'rentan', kondisi_kesehatan:'sehat', pendidikan_terakhir:'SMA_SMK', no_kk:'6171010101010002', kecamatan:'Pontianak Kota' },
{ nik:'6171010101010005', nama_lengkap:'Dewi Kartika', umur:46, status_keluarga:'istri', pekerjaan:'Buruh Cuci', penghasilan:500000, status_ekonomi:'rentan', kondisi_kesehatan:'sehat', pendidikan_terakhir:'SD', no_kk:'6171010101010002', kecamatan:'Pontianak Kota' },
{ nik:'6171010101010006', nama_lengkap:'Maria Magdalena', umur:54, status_keluarga:'kepala', pekerjaan:'Penjual Gorengan',penghasilan:800000, status_ekonomi:'miskin', kondisi_kesehatan:'sakit_berat', pendidikan_terakhir:'SD', no_kk:'6171010101010003', kecamatan:'Pontianak Selatan' },
{ nik:'6171010101010007', nama_lengkap:'Petrus Yulius', umur:19, status_keluarga:'anak', pekerjaan:'Pelajar', penghasilan:0, status_ekonomi:'miskin', kondisi_kesehatan:'sehat', pendidikan_terakhir:'SMA_SMK', no_kk:'6171010101010003', kecamatan:'Pontianak Selatan' },
{ nik:'6171010101010010', nama_lengkap:'Lim Ah Kow', umur:59, status_keluarga:'kepala', pekerjaan:'Tidak Bekerja', penghasilan:0, status_ekonomi:'miskin', kondisi_kesehatan:'sakit_berat', pendidikan_terakhir:'SD', no_kk:'6171010101010005', kecamatan:'Pontianak Kota' },
{ nik:'6171010101010012', nama_lengkap:'Mulyadi', umur:45, status_keluarga:'kepala', pekerjaan:'PNS Gol. III', penghasilan:4500000, status_ekonomi:'mampu', kondisi_kesehatan:'sehat', pendidikan_terakhir:'S1', no_kk:'6171010101010006', kecamatan:'Pontianak Timur' },
{ nik:'6171010101010014', nama_lengkap:'Tan Eng Huat', umur:65, status_keluarga:'kepala', pekerjaan:'Pemulung', penghasilan:600000, status_ekonomi:'miskin', kondisi_kesehatan:'disabilitas', pendidikan_terakhir:'SD', no_kk:'6171010101010008', kecamatan:'Pontianak Kota' },
];
async function load() {
try { all = await Api.get('/penduduk'); } catch { all = DEMO; }
filtered = [...all];
render(filtered);
ge('pf-count').textContent = `${all.length} total`;
}
function search(q) { applyFilter(q); }
function applyFilter(qOverride) {
const q = qOverride ?? gv('pf-search');
const ek = gv('pf-ekonomi');
const ke = gv('pf-kesehatan');
filtered = all.filter(p =>
(!q || p.nama_lengkap?.toLowerCase().includes(q.toLowerCase()) || p.nik?.includes(q)) &&
(!ek || p.status_ekonomi === ek) &&
(!ke || p.kondisi_kesehatan === ke)
);
render(filtered);
ge('pf-count').textContent = `${filtered.length} / ${all.length}`;
}
const pendidikanLabel = { tidak_sekolah:'Tdk Sekolah', SD:'SD', SMP:'SMP', SMA_SMK:'SMA/SMK', D1_D3:'D3', S1:'S1', S2_S3:'S2/S3' };
function render(data) {
const tb = ge('tbody-penduduk');
if (!data.length) { tb.innerHTML = '<tr><td colspan="10"><div class="empty-state"><div class="es-icon">🔍</div><h3>Tidak ada data</h3><p>Coba ubah filter pencarian</p></div></td></tr>'; return; }
tb.innerHTML = data.map(p => `
<tr class="row-${p.status_ekonomi}">
<td class="mono">${p.nik}</td>
<td><strong>${p.nama_lengkap}</strong><div style="font-size:10px;color:var(--muted)">${p.kecamatan||''}</div></td>
<td>${p.umur||'?'} th</td>
<td>${cap(p.status_keluarga)}</td>
<td>${p.pekerjaan||'—'}</td>
<td style="white-space:nowrap;font-family:'JetBrains Mono',monospace;font-size:11px">${rp(p.penghasilan)}</td>
<td>${bdg(p.status_ekonomi,'')}</td>
<td>${bdg(p.kondisi_kesehatan,'')}</td>
<td>${pendidikanLabel[p.pendidikan_terakhir]||p.pendidikan_terakhir||'—'}</td>
<td>
<div style="display:flex;gap:4px">
<button class="btn btn-secondary btn-xs" onclick="Penduduk.showDetail('${p.nik}')" title="Detail">👁</button>
<button class="btn btn-secondary btn-xs" onclick="Penduduk.showForm('${p.nik}')" title="Edit" ${!App.canEdit()?'style="display:none"':''}>✏️</button>
<button class="btn btn-danger btn-xs" onclick="Penduduk.hapus('${p.nik}')" title="Hapus" ${!App.canEdit()?'style="display:none"':''}>🗑</button>
</div>
</td>
</tr>`).join('');
}
async function showForm(nik = null) {
editNIK = nik;
ge('mp-title').textContent = nik ? `✏️ Edit — ${nik}` : ' Tambah Penduduk Baru';
ge('fp-nik').readOnly = !!nik;
await loadKKDropdown();
if (nik) {
try {
const p = await Api.get(`/penduduk/${nik}`);
fillForm(p);
} catch {
const p = all.find(x => x.nik === nik);
if (p) fillForm(p);
}
} else {
['fp-nik','fp-nama','fp-tempat','fp-pekerjaan','fp-ket-sehat'].forEach(id => sv(id,''));
sv('fp-penghasilan','0'); sv('fp-umur','');
ge('fp-warn').classList.add('hidden');
}
App.showModal('modal-penduduk');
}
function fillForm(p) {
sv('fp-nik', p.nik); sv('fp-nama', p.nama_lengkap);
sv('fp-jkel', p.jenis_kelamin); sv('fp-tgl', p.tanggal_lahir?.substring(0,10));
sv('fp-umur', p.umur ? `${p.umur} tahun` : '');
sv('fp-tempat', p.tempat_lahir); sv('fp-agama', p.agama);
sv('fp-status-kk', p.status_keluarga); sv('fp-kawin', p.status_perkawinan);
sv('fp-hidup', p.status_hidup); sv('fp-ekonomi', p.status_ekonomi);
sv('fp-pekerjaan', p.pekerjaan); sv('fp-penghasilan', p.penghasilan || 0);
sv('fp-pendidikan', p.pendidikan_terakhir); sv('fp-stat-pend', p.status_pendidikan);
sv('fp-kesehatan', p.kondisi_kesehatan); sv('fp-ket-sehat', p.keterangan_kesehatan);
if (p.id_keluarga) sv('fp-kk', p.id_keluarga);
}
async function loadKKDropdown() {
const sel = ge('fp-kk');
try {
const list = await Api.get('/keluarga');
sel.innerHTML = list.map(k => `<option value="${k.id}">${k.no_kk}${k.nama_kepala}</option>`).join('');
} catch {
sel.innerHTML = '<option value="1">Demo — Ahmad Suryana</option>';
}
}
function hitungUmur() {
const tgl = gv('fp-tgl');
if (!tgl) return;
const umur = Math.floor((Date.now() - new Date(tgl)) / (365.25*24*3600*1000));
sv('fp-umur', `${umur} tahun`);
// Validasi
const pend = gv('fp-pendidikan'), stat = gv('fp-stat-pend');
const warns = [];
if (umur >= 7 && umur <= 15 && stat === 'tidak_sekolah')
warns.push(`⚠️ Usia ${umur} tahun — seharusnya masih wajib belajar (SD/SMP)`);
if (umur >= 16 && umur <= 18 && stat === 'tidak_sekolah' && ['tidak_sekolah','SD'].includes(pend))
warns.push(`⚠️ Usia ${umur} tahun — disarankan melanjutkan ke SMA/SMK`);
const wEl = ge('fp-warn');
if (warns.length) { wEl.innerHTML = warns.join('<br>'); wEl.classList.remove('hidden'); }
else wEl.classList.add('hidden');
}
async function save() {
const nik = gv('fp-nik');
if (!nik || nik.length !== 16 || !/^\d+$/.test(nik)) { App.toast('NIK harus 16 digit angka','error'); return; }
if (!gv('fp-nama')) { App.toast('Nama lengkap wajib diisi','error'); return; }
if (!gv('fp-tgl')) { App.toast('Tanggal lahir wajib diisi','error'); return; }
const body = {
nik, id_keluarga: gv('fp-kk'),
nama_lengkap: gv('fp-nama'), jenis_kelamin: gv('fp-jkel'),
tanggal_lahir: gv('fp-tgl'), tempat_lahir: gv('fp-tempat'),
agama: gv('fp-agama'), status_keluarga: gv('fp-status-kk'),
status_perkawinan: gv('fp-kawin'), status_hidup: gv('fp-hidup'),
pekerjaan: gv('fp-pekerjaan'), penghasilan: +gv('fp-penghasilan')||0,
status_ekonomi: gv('fp-ekonomi'), pendidikan_terakhir: gv('fp-pendidikan'),
status_pendidikan: gv('fp-stat-pend'), kondisi_kesehatan: gv('fp-kesehatan'),
keterangan_kesehatan: gv('fp-ket-sehat'),
};
try {
if (editNIK) { await Api.put(`/penduduk/${editNIK}`, body); App.toast('Data diperbarui ✅','success'); }
else { await Api.post('/penduduk', body); App.toast('Penduduk ditambahkan ✅','success'); }
App.closeModal('modal-penduduk');
await load();
App.updateHeaderStats();
} catch(e) { App.toast(e.message,'error'); }
}
async function hapus(nik) {
if (!confirm(`Hapus data penduduk NIK ${nik}?\nTindakan ini tidak dapat dibatalkan.`)) return;
try {
await Api.delete(`/penduduk/${nik}`);
App.toast('Data dihapus','success');
load(); App.updateHeaderStats();
} catch(e) { App.toast(e.message,'error'); }
}
async function showDetail(nik) {
ge('dp-title').textContent = 'Memuat…';
ge('detail-panel').classList.add('open');
let p;
try { p = await Api.get(`/penduduk/${nik}`); }
catch { p = all.find(x => x.nik === nik); }
if (!p) { App.toast('Gagal memuat detail','error'); return; }
ge('dp-title').textContent = p.nama_lengkap;
const histori = p.histori_bantuan || [];
ge('dp-body').innerHTML = `
<div class="dp-section">
<div class="dp-section-title">Identitas</div>
${field('NIK', `<span class="mono">${p.nik}</span>`)}
${field('Jenis Kelamin', p.jenis_kelamin === 'L' ? 'Laki-laki' : 'Perempuan')}
${field('Umur', `${p.umur||'?'} tahun`)}
${field('Tempat Lahir', p.tempat_lahir||'—')}
${field('Agama', p.agama||'—')}
${field('Status KK', cap(p.status_keluarga))}
${field('Perkawinan', cap(p.status_perkawinan))}
</div>
<div class="dp-section">
<div class="dp-section-title">Sosial Ekonomi</div>
${field('Pekerjaan', p.pekerjaan||'—')}
${field('Penghasilan', rp(p.penghasilan))}
${field('Status Ekonomi', bdg(p.status_ekonomi,''))}
${field('Pendidikan', p.pendidikan_terakhir?.replace('_','/')||'—')}
${field('Kesehatan', bdg(p.kondisi_kesehatan,''))}
</div>
<div class="dp-section">
<div class="dp-section-title">Lokasi</div>
${field('Kelurahan', p.kelurahan||'—')}
${field('Kecamatan', p.kecamatan||'—')}
${field('Alamat', p.alamat||'—')}
${field('Rumah Ibadah', p.nama_ri||'—')}
</div>
<div class="dp-section">
<div class="dp-section-title">Riwayat Bantuan (${histori.length}x)</div>
${histori.length ? histori.map(h=>`
<div style="background:var(--bg4);border-radius:8px;padding:8px 10px;margin-bottom:6px;font-size:12px">
<strong>${h.nama_bantuan}</strong><span style="color:var(--muted);font-size:11px;float:right">${h.tanggal?.substring(0,10)}</span>
<div style="color:var(--muted);font-size:11px">${h.nama_ri||''} · ${h.jumlah} ${h.satuan||''}</div>
</div>`).join('') : '<p style="color:var(--muted);font-size:12px">Belum pernah menerima bantuan</p>'}
</div>
${App.canEdit() ? `
<div style="display:flex;gap:8px;margin-top:8px">
<button class="btn btn-primary" style="flex:1" onclick="Bantuan.quickSalur('${p.nik}','${p.nama_lengkap}')">📦 Salurkan Bantuan</button>
<button class="btn btn-secondary" onclick="Penduduk.showForm('${p.nik}')">✏️ Edit</button>
</div>` : ''}
`;
}
function field(lbl, val) {
return `<div class="dp-field"><span class="dp-field-lbl">${lbl}</span><span class="dp-field-val">${val}</span></div>`;
}
function closeDetail() { ge('detail-panel').classList.remove('open'); }
return { load, search, applyFilter, showForm, hitungUmur, save, hapus, showDetail, closeDetail };
})();
+59
View File
@@ -0,0 +1,59 @@
const PrioritasPanel = (() => {
let all = [];
const DEMO = [
{ nik:'6171010101010006', nama_lengkap:'Maria Magdalena', umur:54, status_ekonomi:'miskin', kondisi_kesehatan:'sakit_berat', kecamatan:'Pontianak Selatan', nama_ri:'Katedral', jumlah_dibantu:1, level_prioritas:'SANGAT TINGGI' },
{ nik:'6171010101010014', nama_lengkap:'Tan Eng Huat', umur:65, status_ekonomi:'miskin', kondisi_kesehatan:'disabilitas', kecamatan:'Pontianak Kota', nama_ri:'Klenteng', jumlah_dibantu:1, level_prioritas:'SANGAT TINGGI' },
{ nik:'6171010101010010', nama_lengkap:'Lim Ah Kow', umur:59, status_ekonomi:'miskin', kondisi_kesehatan:'sakit_berat', kecamatan:'Pontianak Kota', nama_ri:'Vihara', jumlah_dibantu:1, level_prioritas:'SANGAT TINGGI' },
{ nik:'6171010101010002', nama_lengkap:'Siti Rahma', umur:39, status_ekonomi:'miskin', kondisi_kesehatan:'sakit_ringan',kecamatan:'Pontianak Timur', nama_ri:"Masjid Jami'", jumlah_dibantu:2, level_prioritas:'TINGGI' },
{ nik:'6171010101010001', nama_lengkap:'Ahmad Suryana', umur:44, status_ekonomi:'miskin', kondisi_kesehatan:'sehat', kecamatan:'Pontianak Timur', nama_ri:"Masjid Jami'", jumlah_dibantu:2, level_prioritas:'SEDANG' },
{ nik:'6171010101010005', nama_lengkap:'Dewi Kartika', umur:46, status_ekonomi:'rentan', kondisi_kesehatan:'sehat', kecamatan:'Pontianak Kota', nama_ri:"Masjid Jami'", jumlah_dibantu:0, level_prioritas:'SEDANG' },
];
const PRIO_CLS = { 'SANGAT TINGGI':'pb-sangat-tinggi', 'TINGGI':'pb-tinggi', 'SEDANG':'pb-sedang', 'RENDAH':'pb-rendah' };
async function load() {
try { all = await Api.get('/prioritas'); } catch { all = DEMO; }
render(all);
}
function render(data) {
const tb = ge('tbody-prioritas');
if (!data.length) { tb.innerHTML = '<tr><td colspan="10"><div class="empty-state"><div class="es-icon">✅</div><h3>Tidak ada warga prioritas</h3></div></td></tr>'; return; }
tb.innerHTML = data.map(p => `
<tr>
<td class="mono">${p.nik}</td>
<td><strong>${p.nama_lengkap}</strong></td>
<td>${p.umur} th</td>
<td>${bdg(p.status_ekonomi,'')}</td>
<td>${bdg(p.kondisi_kesehatan,'')}</td>
<td>${p.kecamatan||'—'}</td>
<td style="font-size:11px">${p.nama_ri||'—'}</td>
<td style="text-align:center">
${p.jumlah_dibantu > 0
? `<span style="font-weight:700;color:var(--green)">${p.jumlah_dibantu}x</span>`
: `<span style="font-weight:700;color:var(--red)">Belum</span>`}
</td>
<td><span class="prio-badge ${PRIO_CLS[p.level_prioritas]||''}">${p.level_prioritas}</span></td>
<td>
${App.canEdit() ? `
<button class="btn btn-primary btn-xs" onclick="Bantuan.quickSalur('${p.nik}','${p.nama_lengkap}')">📦 Bantu</button>
` : '—'}
</td>
</tr>`).join('');
}
function exportCSV() {
if (!all.length) { App.toast('Tidak ada data untuk diekspor','warning'); return; }
const hdr = ['NIK','Nama','Umur','Ekonomi','Kesehatan','Kecamatan','Rumah Ibadah','Dibantu','Prioritas'];
const rows = all.map(p => [p.nik,p.nama_lengkap,p.umur,p.status_ekonomi,p.kondisi_kesehatan,p.kecamatan,p.nama_ri,p.jumlah_dibantu,p.level_prioritas]);
const csv = '\uFEFF' + [hdr,...rows].map(r => r.map(v => `"${v||''}"`).join(',')).join('\n');
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([csv],{type:'text/csv;charset=utf-8'}));
a.download = `prioritas-bantuan-${new Date().toISOString().substring(0,10)}.csv`;
a.click();
App.toast('File CSV berhasil diunduh ✅','success');
}
return { load, exportCSV };
})();
+87
View File
@@ -0,0 +1,87 @@
const RI = (() => {
let all = [], editId = null;
const DEMO = [
{ id:1, nama:"Masjid Jami' Pontianak", jenis:'Masjid', kecamatan:'Pontianak Timur', kontak:'0561-732001', radius_meter:350, jumlah_kk:3, jumlah_penduduk:8, lat:-0.02596, lng:109.34307 },
{ id:2, nama:'Gereja Katedral Pontianak', jenis:'Gereja', kecamatan:'Pontianak Selatan', kontak:'0561-766000', radius_meter:300, jumlah_kk:2, jumlah_penduduk:5, lat:-0.03200, lng:109.33200 },
{ id:3, nama:'Vihara Bodhisattva Karaniya Metta', jenis:'Vihara', kecamatan:'Pontianak Kota', kontak:'0561-733456', radius_meter:300, jumlah_kk:2, jumlah_penduduk:4, lat:-0.02100, lng:109.33800 },
{ id:4, nama:'Klenteng Kwan Ti Miau', jenis:'Klenteng', kecamatan:'Pontianak Kota', kontak:'0561-740123', radius_meter:280, jumlah_kk:1, jumlah_penduduk:2, lat:-0.01900, lng:109.34100 },
{ id:5, nama:'Masjid Mujahidin Pontianak', jenis:'Masjid', kecamatan:'Pontianak Selatan', kontak:'0561-764567', radius_meter:400, jumlah_kk:1, jumlah_penduduk:2, lat:-0.04800, lng:109.32500 },
{ id:6, nama:'Gereja GPIB Immanuel', jenis:'Gereja', kecamatan:'Pontianak Kota', kontak:'0561-735890', radius_meter:300, jumlah_kk:1, jumlah_penduduk:1, lat:-0.02800, lng:109.34500 },
];
const JENIS_EMOJI = { Masjid:'🕌', Gereja:'⛪', Vihara:'🛕', Pura:'🛕', Klenteng:'🏮', Lainnya:'🏛️' };
const JENIS_COLOR = { Masjid:'#06d6a0', Gereja:'#4cc9f0', Vihara:'#ffd166', Pura:'#f97316', Klenteng:'#b48eff', Lainnya:'#6b7a99' };
async function load() {
try { all = await Api.get('/rumah-ibadah'); } catch { all = DEMO; }
render(all);
}
function render(data) {
ge('ri-grid').innerHTML = data.map(r => `
<div class="ri-card" style="border-top:3px solid ${JENIS_COLOR[r.jenis]||'#6b7a99'}">
<div class="ri-icon-big">${JENIS_EMOJI[r.jenis]||'🏛️'}</div>
<div class="ri-name">${r.nama}</div>
<div class="ri-type">${r.jenis} · ${r.kecamatan||'—'}</div>
<div class="ri-stats">
<div class="ri-stat">
<div class="ri-stat-num" style="color:${JENIS_COLOR[r.jenis]}">${r.jumlah_kk||0}</div>
<div class="ri-stat-lbl">KK Binaan</div>
</div>
<div class="ri-stat">
<div class="ri-stat-num" style="color:var(--blue)">${r.jumlah_penduduk||0}</div>
<div class="ri-stat-lbl">Jiwa</div>
</div>
</div>
<div style="font-size:11px;color:var(--muted);margin-bottom:10px">
📞 ${r.kontak||'—'} &nbsp;·&nbsp; ⭕ Radius ${r.radius_meter}m
</div>
${App.canEdit() ? `
<div style="display:flex;gap:6px">
<button class="btn btn-secondary btn-sm" style="flex:1" onclick="RI.showForm(${r.id})">✏️ Edit</button>
<button class="btn btn-danger btn-sm" onclick="RI.hapus(${r.id})">🗑</button>
</div>` : ''}
</div>`).join('');
}
async function showForm(id = null) {
editId = id;
ge('mri-title').textContent = id ? '✏️ Edit Rumah Ibadah' : '🕌 Tambah Rumah Ibadah';
if (id) {
const r = all.find(x => x.id == id) || {};
sv('fri-nama', r.nama); sv('fri-jenis', r.jenis);
sv('fri-radius', r.radius_meter||300); sv('fri-lat', r.lat);
sv('fri-lng', r.lng); sv('fri-kec', r.kecamatan);
sv('fri-kontak', r.kontak); sv('fri-alamat', r.alamat);
} else {
['fri-nama','fri-kec','fri-kontak','fri-alamat'].forEach(i => sv(i,''));
sv('fri-radius','300'); sv('fri-lat',''); sv('fri-lng','');
}
App.showModal('modal-ri');
}
async function save() {
if (!gv('fri-nama')) { App.toast('Nama wajib diisi','error'); return; }
if (!gv('fri-lat') || !gv('fri-lng')) { App.toast('Koordinat (lat/lng) wajib diisi','error'); return; }
const body = {
nama: gv('fri-nama'), jenis: gv('fri-jenis'),
radius_meter: +gv('fri-radius')||300,
lat: +gv('fri-lat'), lng: +gv('fri-lng'),
kecamatan: gv('fri-kec'), kontak: gv('fri-kontak'), alamat: gv('fri-alamat'),
};
try {
if (editId) { await Api.put(`/rumah-ibadah/${editId}`, body); App.toast('Data diperbarui ✅','success'); }
else { await Api.post('/rumah-ibadah', body); App.toast('Rumah ibadah ditambahkan ✅','success'); }
App.closeModal('modal-ri'); load();
} catch(e) { App.toast(e.message,'error'); }
}
async function hapus(id) {
if (!confirm('Nonaktifkan rumah ibadah ini?')) return;
try { await Api.delete(`/rumah-ibadah/${id}`); App.toast('Dinonaktifkan','success'); load(); }
catch(e) { App.toast(e.message,'error'); }
}
return { load, showForm, save, hapus, getAll: () => all };
})();