6215b4367a
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>
724 lines
33 KiB
PHP
724 lines
33 KiB
PHP
<?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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|