Files
Athallah Ghathfan Aqila 6215b4367a 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>
2026-06-11 13:31:33 +07:00

724 lines
33 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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>