Initial commit — WebGIS UAS GeoMiskin

Sistem WebGIS analisis sebaran penduduk miskin dan jangkauan rumah ibadah.
Fitur: peta interaktif Leaflet.js, CRUD rumah ibadah & penduduk miskin,
auto-kategorisasi haversine, heatmap, export PDF/CSV, dashboard analitik,
manajemen pengguna (superadmin/admin), login sistem.

Teknologi: PHP, MySQL, Leaflet.js, OpenStreetMap, Nominatim Geocoding, mPDF.

Catatan: jalankan `composer install` di folder uas/ untuk install dependencies.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Athallah Ghathfan Aqila
2026-06-11 13:31:33 +07:00
commit 6215b4367a
59 changed files with 6154 additions and 0 deletions
+723
View File
@@ -0,0 +1,723 @@
<?php
include 'auth.php';
requireLogin();
$user = currentUser();
$isAdmin = isSuperAdmin();
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard — GeoMiskin</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
:root{--bg:#0f172a;--bg2:#1e293b;--bg3:#334155;--bg4:#475569;--txt:#e2e8f0;--muted:#64748b;--blue:#3b82f6;--green:#22c55e;--red:#ef4444;--yellow:#f59e0b;--indigo:#6366f1;--cyan:#06b6d4;--pink:#ec4899;--radius:14px;--trans:.2s ease}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;font-family:'Inter',sans-serif;background:var(--bg);color:var(--txt);overflow-x:hidden}
body{display:flex;flex-direction:column}
/* TOPBAR */
.topbar{background:var(--bg2);border-bottom:1px solid rgba(255,255,255,.06);padding:0 28px;height:60px;display:flex;align-items:center;gap:16px;flex-shrink:0;position:sticky;top:0;z-index:100}
.topbar-brand{display:flex;align-items:center;gap:10px;margin-right:auto}
.topbar-logo{width:34px;height:34px;background:linear-gradient(135deg,var(--blue),var(--indigo));border-radius:9px;display:flex;align-items:center;justify-content:center;flex-shrink:0}
.topbar-logo i{color:#fff;font-size:15px}
.topbar-title{font-size:15px;font-weight:700}
.topbar-sub{font-size:11px;color:var(--muted);font-weight:400}
.topbar-user{display:flex;align-items:center;gap:8px;font-size:13px}
.topbar-avatar{width:30px;height:30px;border-radius:50%;background:linear-gradient(135deg,var(--blue),var(--indigo));display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:12px;flex-shrink:0}
.topbar-name{font-weight:600;color:var(--txt)}
.topbar-role{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px}
.topbar-btn{display:inline-flex;align-items:center;gap:7px;padding:8px 14px;border-radius:9px;font-size:12px;font-weight:600;cursor:pointer;text-decoration:none;transition:var(--trans);border:none;font-family:inherit}
.btn-ghost{background:rgba(255,255,255,.06);color:var(--txt);border:1px solid rgba(255,255,255,.08)}
.btn-ghost:hover{background:rgba(255,255,255,.1)}
.btn-blue{background:var(--blue);color:#fff}
.btn-blue:hover{background:#2563eb}
.btn-red{background:rgba(239,68,68,.15);color:var(--red);border:1px solid rgba(239,68,68,.2)}
.btn-red:hover{background:rgba(239,68,68,.25)}
.divider-v{width:1px;height:24px;background:rgba(255,255,255,.08)}
/* CONTENT */
.content{flex:1;padding:28px;overflow-y:auto}
.page-header{margin-bottom:24px}
.page-header h1{font-size:22px;font-weight:700;display:flex;align-items:center;gap:10px}
.page-header h1 i{color:var(--blue)}
.page-header p{font-size:13px;color:var(--muted);margin-top:4px}
/* SUMMARY CARDS */
.summary-grid{display:grid;grid-template-columns:repeat(6,1fr);gap:14px;margin-bottom:24px}
@media(max-width:1200px){.summary-grid{grid-template-columns:repeat(3,1fr)}}
@media(max-width:768px){.summary-grid{grid-template-columns:repeat(2,1fr)}}
.s-card{background:var(--bg2);border:1px solid rgba(255,255,255,.06);border-radius:var(--radius);padding:18px;display:flex;flex-direction:column;gap:10px;position:relative;overflow:hidden;transition:var(--trans)}
.s-card:hover{border-color:rgba(255,255,255,.12);transform:translateY(-2px)}
.s-card::before{content:'';position:absolute;top:-20px;right:-20px;width:80px;height:80px;border-radius:50%;opacity:.06}
.s-card.blue::before{background:var(--blue)}
.s-card.green::before{background:var(--green)}
.s-card.red::before{background:var(--red)}
.s-card.yellow::before{background:var(--yellow)}
.s-card.indigo::before{background:var(--indigo)}
.s-card.cyan::before{background:var(--cyan)}
.s-icon{width:36px;height:36px;border-radius:10px;display:flex;align-items:center;justify-content:center}
.s-icon.blue{background:rgba(59,130,246,.2)} .s-icon.blue i{color:var(--blue)}
.s-icon.green{background:rgba(34,197,94,.2)} .s-icon.green i{color:var(--green)}
.s-icon.red{background:rgba(239,68,68,.2)} .s-icon.red i{color:var(--red)}
.s-icon.yellow{background:rgba(245,158,11,.2)} .s-icon.yellow i{color:var(--yellow)}
.s-icon.indigo{background:rgba(99,102,241,.2)} .s-icon.indigo i{color:var(--indigo)}
.s-icon.cyan{background:rgba(6,182,212,.2)} .s-icon.cyan i{color:var(--cyan)}
.s-icon i{font-size:15px}
.s-num{font-size:28px;font-weight:700;line-height:1;letter-spacing:-1px}
.s-lbl{font-size:11px;color:var(--muted);font-weight:500}
.s-badge{display:inline-flex;align-items:center;gap:4px;font-size:10px;font-weight:600;padding:2px 8px;border-radius:20px;margin-top:4px}
.s-badge.up{background:rgba(34,197,94,.15);color:var(--green)}
.s-badge.down{background:rgba(239,68,68,.15);color:var(--red)}
.s-badge.warn{background:rgba(245,158,11,.15);color:var(--yellow)}
/* PROGRESS */
.progress-section{background:var(--bg2);border:1px solid rgba(255,255,255,.06);border-radius:var(--radius);padding:20px;margin-bottom:24px}
.progress-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px}
.progress-header h3{font-size:14px;font-weight:700;display:flex;align-items:center;gap:8px}
.progress-header h3 i{color:var(--green)}
.progress-pct{font-size:24px;font-weight:700;color:var(--green)}
.progress-bar-wrap{background:var(--bg3);border-radius:8px;height:10px;overflow:hidden;margin-bottom:12px}
.progress-bar-fill{height:100%;background:linear-gradient(90deg,var(--blue),var(--green));border-radius:8px;transition:width .8s cubic-bezier(.4,0,.2,1)}
.progress-breakdown{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
.pb-item{text-align:center;padding:10px;background:var(--bg3);border-radius:10px}
.pb-num{font-size:20px;font-weight:700}
.pb-lbl{font-size:10px;color:var(--muted);margin-top:2px}
.pb-item.belum .pb-num{color:var(--red)}
.pb-item.sudah .pb-num{color:var(--green)}
.pb-item.tunggu .pb-num{color:var(--yellow)}
/* CHARTS GRID */
.charts-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;margin-bottom:24px}
@media(max-width:1100px){.charts-grid{grid-template-columns:1fr 1fr}}
@media(max-width:700px){.charts-grid{grid-template-columns:1fr}}
.chart-card{background:var(--bg2);border:1px solid rgba(255,255,255,.06);border-radius:var(--radius);padding:20px}
.chart-card.wide{grid-column:span 2}
@media(max-width:1100px){.chart-card.wide{grid-column:span 1}}
.chart-title{font-size:13px;font-weight:700;display:flex;align-items:center;gap:7px;margin-bottom:16px}
.chart-title i{font-size:14px;color:var(--blue)}
.chart-wrap{position:relative}
.chart-wrap canvas{max-width:100%}
/* TABLE */
.table-section{background:var(--bg2);border:1px solid rgba(255,255,255,.06);border-radius:var(--radius);overflow:hidden;margin-bottom:24px}
.table-header{padding:18px 20px;border-bottom:1px solid rgba(255,255,255,.06);display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}
.table-header h3{font-size:14px;font-weight:700;display:flex;align-items:center;gap:8px}
.table-header h3 i{color:var(--blue)}
.table-search{position:relative}
.table-search i{position:absolute;left:10px;top:50%;transform:translateY(-50%);color:var(--muted);font-size:12px}
.table-search input{background:var(--bg);border:1px solid rgba(255,255,255,.08);border-radius:8px;padding:7px 10px 7px 30px;font-size:12px;font-family:inherit;color:var(--txt);outline:none;width:180px;transition:var(--trans)}
.table-search input:focus{border-color:var(--blue)}
.table-search input::placeholder{color:var(--muted)}
table{width:100%;border-collapse:collapse}
thead tr{background:rgba(255,255,255,.03)}
th{padding:10px 14px;text-align:left;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);border-bottom:1px solid rgba(255,255,255,.06);white-space:nowrap}
td{padding:12px 14px;font-size:13px;border-bottom:1px solid rgba(255,255,255,.04);vertical-align:middle}
tbody tr:last-child td{border-bottom:none}
tbody tr:hover td{background:rgba(255,255,255,.02)}
.status-pill{display:inline-flex;align-items:center;gap:5px;padding:3px 10px;border-radius:20px;font-size:10px;font-weight:700}
.sp-belum{background:rgba(239,68,68,.15);color:var(--red)}
.sp-sudah{background:rgba(34,197,94,.15);color:var(--green)}
.sp-tunggu{background:rgba(245,158,11,.15);color:var(--yellow)}
.table-empty{padding:60px 20px;text-align:center;color:var(--muted)}
.table-empty i{font-size:32px;display:block;margin-bottom:8px;opacity:.4}
.pagination{padding:12px 20px;border-top:1px solid rgba(255,255,255,.06);display:flex;align-items:center;justify-content:space-between;font-size:12px;color:var(--muted)}
.pg-info{font-size:12px}
.pg-btns{display:flex;gap:6px}
.pg-btn{background:var(--bg3);border:1px solid rgba(255,255,255,.06);color:var(--txt);border-radius:6px;padding:5px 10px;font-size:11px;cursor:pointer;font-family:inherit;transition:var(--trans)}
.pg-btn:hover{background:var(--bg4)}
.pg-btn.active{background:var(--blue);border-color:var(--blue);color:#fff}
.pg-btn:disabled{opacity:.35;cursor:not-allowed}
/* IBADAH TABLE */
.ibadah-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:24px}
@media(max-width:900px){.ibadah-grid{grid-template-columns:1fr}}
/* LOADING */
.loading-overlay{position:fixed;inset:0;background:var(--bg);display:flex;align-items:center;justify-content:center;z-index:9999;transition:opacity .4s}
.loading-overlay.hide{opacity:0;pointer-events:none}
.spinner{width:40px;height:40px;border:3px solid var(--bg3);border-top-color:var(--blue);border-radius:50%;animation:spin .8s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
/* TOAST */
#toast-wrap{position:fixed;bottom:24px;right:24px;z-index:9999;display:flex;flex-direction:column;gap:8px}
.toast{background:var(--bg2);border:1px solid rgba(255,255,255,.08);color:var(--txt);padding:12px 16px;border-radius:12px;font-size:13px;font-weight:500;display:flex;align-items:center;gap:8px;animation:slideUp .25s ease;border-left:4px solid var(--blue);min-width:200px}
.toast.success{border-left-color:var(--green)}.toast.error{border-left-color:var(--red)}
@keyframes slideUp{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
/* MISC */
.dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:180px}
</style>
</head>
<body>
<div class="loading-overlay" id="loader"><div class="spinner"></div></div>
<!-- ===== TOPBAR ===== -->
<div class="topbar">
<div class="topbar-brand">
<div class="topbar-logo"><i class="fas fa-chart-bar"></i></div>
<div>
<div class="topbar-title">GeoMiskin Dashboard</div>
<div class="topbar-sub">Ringkasan data sebaran penduduk miskin</div>
</div>
</div>
<a href="index.php" class="topbar-btn btn-ghost"><i class="fas fa-map-marked-alt"></i> Buka Peta</a>
<?php if ($isAdmin): ?>
<a href="users.php" class="topbar-btn btn-ghost"><i class="fas fa-users-cog"></i> Pengguna</a>
<?php endif; ?>
<div class="divider-v"></div>
<div class="topbar-user">
<div class="topbar-avatar"><?= strtoupper(substr($user['nama'],0,1)) ?></div>
<div><div class="topbar-name"><?= htmlspecialchars($user['nama']) ?></div><div class="topbar-role"><?= $user['role'] ?></div></div>
</div>
<div class="divider-v"></div>
<a href="logout.php" class="topbar-btn btn-red"><i class="fas fa-sign-out-alt"></i> Keluar</a>
</div>
<!-- ===== CONTENT ===== -->
<div class="content">
<div class="page-header">
<h1><i class="fas fa-chart-pie"></i> Dashboard Analitik</h1>
<p id="last-updated">Memuat data...</p>
</div>
<!-- SUMMARY CARDS -->
<div class="summary-grid">
<div class="s-card blue">
<div class="s-icon blue"><i class="fas fa-users"></i></div>
<div class="s-num" id="s-total">—</div>
<div class="s-lbl">Total KK Miskin</div>
</div>
<div class="s-card green">
<div class="s-icon green"><i class="fas fa-check-circle"></i></div>
<div class="s-num" id="s-sudah">—</div>
<div class="s-lbl">Sudah Dibantu</div>
</div>
<div class="s-card red">
<div class="s-icon red"><i class="fas fa-times-circle"></i></div>
<div class="s-num" id="s-belum">—</div>
<div class="s-lbl">Belum Dibantu</div>
</div>
<div class="s-card yellow">
<div class="s-icon yellow"><i class="fas fa-hourglass-half"></i></div>
<div class="s-num" id="s-tunggu">—</div>
<div class="s-lbl">Menunggu Verifikasi</div>
</div>
<div class="s-card indigo">
<div class="s-icon indigo"><i class="fas fa-place-of-worship"></i></div>
<div class="s-num" id="s-ibadah">—</div>
<div class="s-lbl">Rumah Ibadah</div>
</div>
<div class="s-card cyan">
<div class="s-icon cyan"><i class="fas fa-person"></i></div>
<div class="s-num" id="s-jiwa">—</div>
<div class="s-lbl">Total Jiwa</div>
</div>
</div>
<!-- PROGRESS -->
<div class="progress-section">
<div class="progress-header">
<h3><i class="fas fa-tasks"></i> Progress Bantuan</h3>
<div class="progress-pct" id="prog-pct">0%</div>
</div>
<div class="progress-bar-wrap"><div class="progress-bar-fill" id="prog-bar" style="width:0"></div></div>
<div class="progress-breakdown">
<div class="pb-item belum"><div class="pb-num" id="pb-belum">0</div><div class="pb-lbl">Belum Dibantu</div></div>
<div class="pb-item sudah"><div class="pb-num" id="pb-sudah">0</div><div class="pb-lbl">Sudah Dibantu</div></div>
<div class="pb-item tunggu"><div class="pb-num" id="pb-tunggu">0</div><div class="pb-lbl">Menunggu Verifikasi</div></div>
</div>
</div>
<!-- CHARTS -->
<div class="charts-grid">
<div class="chart-card">
<div class="chart-title"><i class="fas fa-pie-chart"></i> Distribusi Status Bantuan</div>
<div class="chart-wrap" style="height:220px"><canvas id="chart-status"></canvas></div>
</div>
<div class="chart-card wide">
<div class="chart-title"><i class="fas fa-bar-chart"></i> KK Binaan per Rumah Ibadah</div>
<div class="chart-wrap" style="height:220px"><canvas id="chart-ibadah"></canvas></div>
</div>
<div class="chart-card">
<div class="chart-title"><i class="fas fa-building"></i> Distribusi Jenis Rumah Ibadah</div>
<div class="chart-wrap" style="height:220px"><canvas id="chart-jenis"></canvas></div>
</div>
<div class="chart-card wide">
<div class="chart-title"><i class="fas fa-people-group"></i> Jumlah Anggota per KK</div>
<div class="chart-wrap" style="height:220px"><canvas id="chart-anggota"></canvas></div>
</div>
</div>
<!-- KECAMATAN CHART -->
<div class="chart-card" id="kecamatan-chart-section" style="margin-bottom:24px;display:none">
<div class="chart-title"><i class="fas fa-map" style="color:var(--indigo)"></i> Sebaran KK Miskin per Kecamatan</div>
<div class="chart-wrap" style="height:240px"><canvas id="chart-kecamatan"></canvas></div>
</div>
<!-- TABLES ROW -->
<div class="ibadah-grid">
<!-- Rumah Ibadah Ranking -->
<div class="table-section">
<div class="table-header">
<h3><i class="fas fa-trophy"></i> Rumah Ibadah & KK Binaan</h3>
</div>
<table>
<thead><tr><th>#</th><th>Nama</th><th>Jenis</th><th style="text-align:right">KK Binaan</th></tr></thead>
<tbody id="tbl-ibadah-body"><tr><td colspan="4" class="table-empty"><i class="fas fa-spinner fa-spin"></i></td></tr></tbody>
</table>
</div>
<!-- Bantuan Stats per Jenis -->
<div class="table-section">
<div class="table-header">
<h3><i class="fas fa-hand-holding-heart"></i> Rekap Bantuan per Jenis Ibadah</h3>
</div>
<table>
<thead><tr><th>Jenis</th><th style="text-align:right">Total KK</th><th style="text-align:right">Dibantu</th><th style="text-align:right">%</th></tr></thead>
<tbody id="tbl-rekap-body"><tr><td colspan="4" class="table-empty"><i class="fas fa-spinner fa-spin"></i></td></tr></tbody>
</table>
</div>
</div>
<!-- PENDUDUK TABLE -->
<div class="table-section">
<div class="table-header">
<h3><i class="fas fa-list"></i> Daftar Penduduk Miskin</h3>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<select id="tbl-filter-kec" onchange="filterTable()" style="background:var(--bg);border:1px solid rgba(255,255,255,.08);border-radius:8px;padding:7px 10px;font-size:12px;font-family:inherit;color:var(--txt);outline:none;cursor:pointer">
<option value="">Semua Kecamatan</option>
</select>
<div class="table-search"><i class="fas fa-search"></i><input type="text" id="tbl-search" placeholder="Cari nama / alamat..." oninput="filterTable()"></div>
</div>
</div>
<table>
<thead>
<tr>
<th>#</th>
<th>Nama KK</th>
<th>NIK</th>
<th>Kecamatan</th>
<th>Alamat</th>
<th style="text-align:center">Anggota</th>
<th>Rumah Ibadah</th>
<th style="text-align:right">Jarak</th>
<th>Status</th>
</tr>
</thead>
<tbody id="tbl-penduduk-body">
<tr><td colspan="9" class="table-empty"><i class="fas fa-spinner fa-spin"></i><br>Memuat data...</td></tr>
</tbody>
</table>
<div class="pagination">
<div class="pg-info" id="pg-info">—</div>
<div class="pg-btns" id="pg-btns"></div>
</div>
</div>
</div><!-- /content -->
<div id="toast-wrap"></div>
<script>
// ── state
let allIbadah = [], allPenduduk = [], filteredPenduduk = [];
let page = 1;
const PER_PAGE = 15;
let chartStatus, chartIbadah, chartJenis, chartAnggota, chartKecamatan;
const JENIS_COLOR = {
'Masjid':'#10b981','Musholla':'#34d399','Gereja Protestan':'#3b82f6',
'Gereja Katolik':'#6366f1','Pura':'#f59e0b','Vihara':'#ef4444',
'Klenteng':'#ec4899','Lainnya':'#94a3b8'
};
Chart.defaults.color = '#64748b';
Chart.defaults.font.family = 'Inter, sans-serif';
Chart.defaults.font.size = 11;
// ── load
async function loadData() {
try {
const r = await fetch('api/get_data.php');
const text = await r.text();
let d;
try { d = JSON.parse(text); }
catch(e) {
toast('Server error: ' + text.substring(0, 120), 'error');
document.getElementById('loader').classList.add('hide');
document.getElementById('last-updated').textContent = 'Gagal memuat data';
return;
}
if (d.status === 'error') {
toast('DB Error: ' + d.message, 'error');
document.getElementById('loader').classList.add('hide');
document.getElementById('last-updated').textContent = 'Koneksi database gagal';
return;
}
allIbadah = d.ibadah || [];
allPenduduk = d.penduduk || [];
filteredPenduduk = [...allPenduduk];
updateAll();
document.getElementById('last-updated').textContent =
'Data diperbarui: ' + new Date().toLocaleString('id-ID');
document.getElementById('loader').classList.add('hide');
} catch(e) {
toast('Gagal memuat data: ' + e.message, 'error');
document.getElementById('loader').classList.add('hide');
document.getElementById('last-updated').textContent = 'Gagal memuat data';
}
}
function updateAll() {
updateCards();
updateProgress();
updateCharts();
updateIbadahTable();
updateRekapTable();
// Populate kecamatan dropdown
const kecList = [...new Set(allPenduduk.map(p=>p.kecamatan).filter(Boolean))].sort();
const selK = document.getElementById('tbl-filter-kec');
selK.innerHTML = '<option value="">Semua Kecamatan</option>' +
kecList.map(k=>`<option value="${k}">${k}</option>`).join('');
filteredPenduduk = [...allPenduduk];
renderTable();
}
// ── summary cards
function updateCards() {
const total = allPenduduk.length;
const sudah = allPenduduk.filter(p => p.status_bantuan === 'sudah_dibantu').length;
const belum = allPenduduk.filter(p => p.status_bantuan === 'belum_dibantu').length;
const tunggu = allPenduduk.filter(p => p.status_bantuan === 'menunggu_verifikasi').length;
const jiwa = allPenduduk.reduce((s, p) => s + (parseInt(p.jumlah_anggota) || 1), 0);
animNum('s-total', total);
animNum('s-sudah', sudah);
animNum('s-belum', belum);
animNum('s-tunggu', tunggu);
animNum('s-ibadah', allIbadah.length);
animNum('s-jiwa', jiwa);
}
function animNum(id, target) {
const el = document.getElementById(id);
const start = parseInt(el.textContent) || 0;
const dur = 600, step = 16;
let cur = start, t = 0;
const timer = setInterval(() => {
t += step;
cur = Math.round(start + (target - start) * Math.min(t / dur, 1));
el.textContent = cur.toLocaleString('id-ID');
if (t >= dur) { el.textContent = target.toLocaleString('id-ID'); clearInterval(timer); }
}, step);
}
// ── progress
function updateProgress() {
const total = allPenduduk.length;
const sudah = allPenduduk.filter(p => p.status_bantuan === 'sudah_dibantu').length;
const belum = allPenduduk.filter(p => p.status_bantuan === 'belum_dibantu').length;
const tunggu = allPenduduk.filter(p => p.status_bantuan === 'menunggu_verifikasi').length;
const pct = total > 0 ? Math.round(sudah / total * 100) : 0;
document.getElementById('prog-pct').textContent = pct + '%';
document.getElementById('prog-bar').style.width = pct + '%';
document.getElementById('pb-sudah').textContent = sudah;
document.getElementById('pb-belum').textContent = belum;
document.getElementById('pb-tunggu').textContent = tunggu;
}
// ── charts
function updateCharts() {
const total = allPenduduk.length;
const sudah = allPenduduk.filter(p => p.status_bantuan === 'sudah_dibantu').length;
const belum = allPenduduk.filter(p => p.status_bantuan === 'belum_dibantu').length;
const tunggu = allPenduduk.filter(p => p.status_bantuan === 'menunggu_verifikasi').length;
// Donut: status
if (chartStatus) chartStatus.destroy();
chartStatus = new Chart(document.getElementById('chart-status'), {
type: 'doughnut',
data: {
labels: ['Belum Dibantu','Sudah Dibantu','Menunggu Verifikasi'],
datasets: [{
data: [belum, sudah, tunggu],
backgroundColor: ['#ef4444','#22c55e','#f59e0b'],
borderWidth: 0, hoverOffset: 6
}]
},
options: {
cutout: '62%', responsive: true, maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom', labels: { padding: 14, usePointStyle: true, pointStyleWidth: 8 } },
tooltip: { callbacks: { label: ctx => ` ${ctx.label}: ${ctx.raw} KK (${total?Math.round(ctx.raw/total*100):0}%)` } }
}
}
});
// Bar: KK per ibadah
const ibadahKK = allIbadah.map(i => ({
nama: i.nama.length > 22 ? i.nama.substring(0,22)+'…' : i.nama,
jenis: i.jenis,
count: allPenduduk.filter(p => p.rumah_ibadah_id == i.id).length
})).sort((a,b) => b.count - a.count);
if (chartIbadah) chartIbadah.destroy();
chartIbadah = new Chart(document.getElementById('chart-ibadah'), {
type: 'bar',
data: {
labels: ibadahKK.map(x => x.nama),
datasets: [{
label: 'KK Binaan',
data: ibadahKK.map(x => x.count),
backgroundColor: ibadahKK.map(x => (JENIS_COLOR[x.jenis]||'#94a3b8') + 'cc'),
borderColor: ibadahKK.map(x => JENIS_COLOR[x.jenis]||'#94a3b8'),
borderWidth: 1.5, borderRadius: 6
}]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { grid: { color: 'rgba(255,255,255,.04)' }, ticks: { maxRotation: 35, minRotation: 0 } },
y: { grid: { color: 'rgba(255,255,255,.04)' }, beginAtZero: true, ticks: { precision: 0 } }
}
}
});
// Bar: Jenis Ibadah
const jenisCounts = {};
allIbadah.forEach(i => { jenisCounts[i.jenis] = (jenisCounts[i.jenis]||0) + 1; });
const jenisKeys = Object.keys(jenisCounts);
if (chartJenis) chartJenis.destroy();
chartJenis = new Chart(document.getElementById('chart-jenis'), {
type: 'bar',
data: {
labels: jenisKeys,
datasets: [{
label: 'Jumlah',
data: jenisKeys.map(k => jenisCounts[k]),
backgroundColor: jenisKeys.map(k => (JENIS_COLOR[k]||'#94a3b8') + 'cc'),
borderColor: jenisKeys.map(k => JENIS_COLOR[k]||'#94a3b8'),
borderWidth: 1.5, borderRadius: 6
}]
},
options: {
indexAxis: 'y', responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { grid: { color: 'rgba(255,255,255,.04)' }, ticks: { precision: 0 } },
y: { grid: { color: 'rgba(255,255,255,.04)' } }
}
}
});
// Bar: Distribusi jumlah anggota per KK
const anggotaDist = {};
allPenduduk.forEach(p => {
const j = parseInt(p.jumlah_anggota) || 1;
const key = j <= 6 ? String(j) : '7+';
anggotaDist[key] = (anggotaDist[key]||0) + 1;
});
const anggotaKeys = ['1','2','3','4','5','6','7+'];
if (chartAnggota) chartAnggota.destroy();
chartAnggota = new Chart(document.getElementById('chart-anggota'), {
type: 'bar',
data: {
labels: anggotaKeys.map(k => k + ' jiwa'),
datasets: [{
label: 'Jumlah KK',
data: anggotaKeys.map(k => anggotaDist[k]||0),
backgroundColor: '#3b82f6cc', borderColor: '#3b82f6',
borderWidth: 1.5, borderRadius: 6
}]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { grid: { color: 'rgba(255,255,255,.04)' } },
y: { grid: { color: 'rgba(255,255,255,.04)' }, beginAtZero: true, ticks: { precision: 0 } }
}
}
});
// Bar: KK per Kecamatan
const kecCounts = {};
allPenduduk.forEach(p => {
if (!p.kecamatan) return;
kecCounts[p.kecamatan] = (kecCounts[p.kecamatan]||0) + 1;
});
const kecKeys = Object.keys(kecCounts).sort((a,b) => kecCounts[b] - kecCounts[a]);
const kecSection = document.getElementById('kecamatan-chart-section');
kecSection.style.display = kecKeys.length ? '' : 'none';
if (kecKeys.length) {
if (chartKecamatan) chartKecamatan.destroy();
chartKecamatan = new Chart(document.getElementById('chart-kecamatan'), {
type: 'bar',
data: {
labels: kecKeys,
datasets: [{
label: 'KK Miskin',
data: kecKeys.map(k => kecCounts[k]),
backgroundColor: '#6366f1cc', borderColor: '#6366f1',
borderWidth: 1.5, borderRadius: 6
}]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { grid: { color: 'rgba(255,255,255,.04)' } },
y: { grid: { color: 'rgba(255,255,255,.04)' }, beginAtZero: true, ticks: { precision: 0 } }
}
}
});
}
}
// ── ibadah ranking table
function updateIbadahTable() {
const ranked = allIbadah.map(i => ({
...i,
kk: allPenduduk.filter(p => p.rumah_ibadah_id == i.id).length
})).sort((a,b) => b.kk - a.kk);
const tbody = document.getElementById('tbl-ibadah-body');
if (!ranked.length) {
tbody.innerHTML = '<tr><td colspan="4" class="table-empty"><i class="fas fa-mosque"></i><br>Belum ada rumah ibadah</td></tr>';
return;
}
tbody.innerHTML = ranked.map((r, i) => `
<tr>
<td style="color:var(--muted);font-size:12px">${i+1}</td>
<td>
<div style="display:flex;align-items:center;gap:8px">
<div class="dot" style="background:${JENIS_COLOR[r.jenis]||'#94a3b8'}"></div>
<span class="truncate" style="font-weight:500">${esc(r.nama)}</span>
</div>
</td>
<td style="font-size:11px;color:var(--muted)">${r.jenis}</td>
<td style="text-align:right">
<span style="font-weight:700;font-size:15px;color:${r.kk>0?'var(--blue)':'var(--muted)'}">${r.kk}</span>
<span style="color:var(--muted);font-size:11px"> KK</span>
</td>
</tr>`).join('');
}
// ── rekap table
function updateRekapTable() {
const jenisList = [...new Set(allIbadah.map(i => i.jenis))].sort();
const tbody = document.getElementById('tbl-rekap-body');
if (!jenisList.length) {
tbody.innerHTML = '<tr><td colspan="4" class="table-empty">Tidak ada data</td></tr>';
return;
}
tbody.innerHTML = jenisList.map(jenis => {
const ibadahIds = allIbadah.filter(i => i.jenis === jenis).map(i => i.id);
const kks = allPenduduk.filter(p => ibadahIds.includes(parseInt(p.rumah_ibadah_id)));
const total = kks.length;
const sudah = kks.filter(p => p.status_bantuan === 'sudah_dibantu').length;
const pct = total > 0 ? Math.round(sudah / total * 100) : 0;
return `<tr>
<td><div style="display:flex;align-items:center;gap:7px"><div class="dot" style="background:${JENIS_COLOR[jenis]||'#94a3b8'}"></div><span style="font-size:12px">${jenis}</span></div></td>
<td style="text-align:right;font-weight:600">${total}</td>
<td style="text-align:right;color:var(--green);font-weight:600">${sudah}</td>
<td style="text-align:right">
<span style="font-weight:700;color:${pct>=50?'var(--green)':pct>=25?'var(--yellow)':'var(--red)'}">${pct}%</span>
</td>
</tr>`;
}).join('');
}
// ── penduduk table
function filterTable() {
const q = document.getElementById('tbl-search').value.toLowerCase();
const fK = document.getElementById('tbl-filter-kec').value;
filteredPenduduk = allPenduduk.filter(p =>
(!fK || p.kecamatan === fK) &&
((p.kk_nama||'').toLowerCase().includes(q) ||
(p.nik||'').includes(q) ||
(p.alamat||'').toLowerCase().includes(q) ||
(p.kecamatan||'').toLowerCase().includes(q) ||
(p.kelurahan||'').toLowerCase().includes(q))
);
page = 1;
renderTable();
}
function renderTable() {
const total = filteredPenduduk.length;
const pages = Math.ceil(total / PER_PAGE) || 1;
page = Math.max(1, Math.min(page, pages));
const slice = filteredPenduduk.slice((page-1)*PER_PAGE, page*PER_PAGE);
const SL = {belum_dibantu:'Belum',sudah_dibantu:'Sudah',menunggu_verifikasi:'Menunggu'};
const SK = {belum_dibantu:'sp-belum',sudah_dibantu:'sp-sudah',menunggu_verifikasi:'sp-tunggu'};
const tbody = document.getElementById('tbl-penduduk-body');
if (!slice.length) {
tbody.innerHTML = '<tr><td colspan="9" class="table-empty"><i class="fas fa-search"></i><br>Tidak ada data</td></tr>';
} else {
tbody.innerHTML = slice.map((p, i) => {
const ib = allIbadah.find(x => x.id == p.rumah_ibadah_id);
const no = (page-1)*PER_PAGE + i + 1;
return `<tr>
<td style="color:var(--muted);font-size:11px">${no}</td>
<td><span class="truncate" style="font-weight:600;display:block">${esc(p.kk_nama||'-')}</span></td>
<td style="font-size:11px;color:var(--muted);font-family:monospace">${p.nik||'-'}</td>
<td style="font-size:12px;color:var(--indigo)">${p.kecamatan?esc(p.kecamatan):'<span style="color:var(--muted)">—</span>'}</td>
<td><span class="truncate" style="color:var(--muted);font-size:12px;display:block">${esc(p.alamat||'-')}</span></td>
<td style="text-align:center;font-weight:600">${p.jumlah_anggota||1}</td>
<td style="font-size:12px;color:var(--muted)">${ib?esc(ib.nama).substring(0,20)+(ib.nama.length>20?'…':''):'—'}</td>
<td style="text-align:right;font-size:12px;font-family:monospace;color:var(--muted)">${p.jarak_meter?parseFloat(p.jarak_meter).toFixed(0)+' m':'—'}</td>
<td><span class="status-pill ${SK[p.status_bantuan]||'sp-belum'}">${SL[p.status_bantuan]||p.status_bantuan}</span></td>
</tr>`;
}).join('');
}
// pagination info
document.getElementById('pg-info').textContent =
total > 0 ? `Menampilkan ${(page-1)*PER_PAGE+1}${Math.min(page*PER_PAGE,total)} dari ${total} data` : 'Tidak ada data';
// pagination buttons
const pgDiv = document.getElementById('pg-btns');
pgDiv.innerHTML = '';
const addBtn = (label, pg, disabled=false, active=false) => {
const b = document.createElement('button');
b.className = 'pg-btn' + (active?' active':'');
b.textContent = label;
b.disabled = disabled;
b.onclick = () => { page = pg; renderTable(); };
pgDiv.appendChild(b);
};
addBtn('', page-1, page===1);
const start = Math.max(1, page-2), end = Math.min(pages, page+2);
for (let p = start; p <= end; p++) addBtn(p, p, false, p===page);
addBtn('', page+1, page===pages);
}
// ── utils
function esc(s) {
return String(s||'').replace(/[&<>"']/g, m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));
}
function toast(msg, type='info') {
const t = document.createElement('div');
t.className = `toast ${type}`;
t.innerHTML = `<i class="fas fa-${type==='success'?'check-circle':'exclamation-circle'}"></i><span>${msg}</span>`;
document.getElementById('toast-wrap').appendChild(t);
setTimeout(() => { t.style.cssText='opacity:0;transition:.3s'; setTimeout(()=>t.remove(),300); }, 3500);
}
loadData();
</script>
</body>
</html>