feat: Plan 4 — Dashboard Statistik + Gap Analysis
- Add /dashboard route (all authenticated roles)
- Add dashboard view with Chart.js 4.4:
- 4 stat cards (ibadah, miskin, jiwa, % terjangkau)
- Donut chart: ibadah per jenis
- Horizontal bar chart: miskin per kondisi rumah
- Gap Analysis: Turf.js coverage computation, doughnut chart,
per-ibadah coverage table sorted by KK count, uncovered family list
- esc() helper applied to all user-data innerHTML
- Add Dashboard nav link to map, data, and admin views
- 5 new tests: unauthenticated redirect + all 4 roles can access
- Mark K-02 and K-03 complete in missing_features.md
- Update README with new page and feature section
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -26,7 +26,15 @@ Sistem mendukung pengelolaan data secara penuh:
|
||||
- **Ownership Rumah Ibadah**: Pengurus Rumah Ibadah hanya dapat mengubah atau menghapus data rumah ibadah yang mereka tambahkan sendiri. Administrator dapat mengubah semua data.
|
||||
- **Data Tabular**: Halaman khusus yang menampilkan seluruh data dalam bentuk tabel dengan statistik ringkasan.
|
||||
|
||||
### 3. Analisis Spasial Radius
|
||||
### 3. Dashboard Statistik & Gap Analysis
|
||||
Halaman analitik komprehensif di `/dashboard`, dapat diakses semua role:
|
||||
- **Statistik Ringkasan**: Total rumah ibadah, keluarga miskin, jiwa, dan persentase cakupan.
|
||||
- **Chart Breakdown**: Distribusi rumah ibadah per jenis (donut chart) dan keluarga miskin per kondisi rumah (bar chart horizontal) menggunakan Chart.js.
|
||||
- **Gap Analysis**: Menghitung keluarga miskin yang tidak berada dalam radius rumah ibadah manapun, menggunakan Turf.js untuk kalkulasi jarak geodesik yang akurat.
|
||||
- **Tabel Coverage per Ibadah**: Jumlah KK dan jiwa dalam radius masing-masing rumah ibadah, diurutkan dari terbanyak.
|
||||
- **Daftar Tidak Terjangkau**: Tabel seluruh keluarga miskin yang membutuhkan perhatian lebih.
|
||||
|
||||
### 4. Analisis Spasial Radius
|
||||
Fitur unggulan untuk menganalisis sebaran keluarga miskin di sekitar rumah ibadah:
|
||||
- **Analisis Otomatis**: Mengklik marker rumah ibadah akan langsung memunculkan lingkaran radius dan panel statistik.
|
||||
- **Penghitungan Real-time**: Menggunakan **Turf.js**, sistem menghitung secara instan jumlah rumah, jumlah Kepala Keluarga (KK), dan jumlah Jiwa yang berada di dalam radius terpilih.
|
||||
@@ -133,7 +141,8 @@ docker compose exec app php artisan cache:clear
|
||||
1. **Beranda (`/`)**: Halaman landing dengan latar belakang peta animasi dan navigasi cepat.
|
||||
2. **Peta Interaktif (`/map`)**: Antarmuka utama untuk melihat sebaran dan melakukan analisis radius.
|
||||
3. **Data Tabular (`/data`)**: Manajemen data dalam format tabel dengan ringkasan statistik total.
|
||||
4. **Admin Pengguna (`/admin/users`)**: Manajemen pengguna sistem — tambah, edit, hapus akun, assign role, dan reset password. Hanya dapat diakses oleh Administrator.
|
||||
4. **Dashboard Statistik (`/dashboard`)**: Analisis lengkap sebaran data — breakdown per jenis rumah ibadah, kondisi rumah miskin, Gap Analysis (% keluarga miskin terjangkau/tidak), dan tabel coverage per rumah ibadah. Dapat diakses oleh semua role.
|
||||
5. **Admin Pengguna (`/admin/users`)**: Manajemen pengguna sistem — tambah, edit, hapus akun, assign role, dan reset password. Hanya dapat diakses oleh Administrator.
|
||||
|
||||
---
|
||||
*Dokumentasi ini dibuat untuk menjelaskan kapabilitas sistem WebGIS Pemetaan Kemiskinan versi Laravel.*
|
||||
|
||||
+6
-6
@@ -2,7 +2,7 @@
|
||||
|
||||
> Berdasarkan perbandingan dokumen SKPL (`D1041231009_RadityaIndraPutranto_SKPL_SIG.docx`) dengan implementasi aktual project.
|
||||
>
|
||||
> **Terakhir diperbarui:** 2026-06-03 — setelah implementasi Manajemen Pengguna (Plan 3).
|
||||
> **Terakhir diperbarui:** 2026-06-03 — setelah implementasi Dashboard Statistik + Gap Analysis (Plan 4).
|
||||
|
||||
---
|
||||
|
||||
@@ -62,8 +62,8 @@ Data bisa ditambah dan dihapus. Untuk lokasi: drag marker (ibadah & miskin) dan
|
||||
|
||||
| Kode | Fitur | Status |
|
||||
|---|---|---|
|
||||
| K-02 | **Dashboard Statistik Lengkap** — Perbandingan antar wilayah, tren waktu | ❌ Belum ada (hanya ada total dasar di halaman Data) |
|
||||
| K-03 | **Analisis Kesenjangan (Gap Analysis)** — Identifikasi wilayah miskin yang kurang terjangkau rumah ibadah | ❌ Belum ada |
|
||||
| K-02 | **Dashboard Statistik Lengkap** — Perbandingan antar wilayah, tren waktu | ✅ Selesai |
|
||||
| K-03 | **Analisis Kesenjangan (Gap Analysis)** — Identifikasi wilayah miskin yang kurang terjangkau rumah ibadah | ✅ Selesai |
|
||||
| K-04 | **Ekspor Laporan Terfilter** — Ekspor khusus per radius/wilayah untuk laporan resmi | ❌ Belum ada (hanya CSV semua data) |
|
||||
|
||||
---
|
||||
@@ -87,9 +87,9 @@ Data bisa ditambah dan dihapus. Untuk lokasi: drag marker (ibadah & miskin) dan
|
||||
| Ownership pengurus_ibadah | ✅ 1/1 | — |
|
||||
| Fitur Admin | ✅ 1/8 | ❌ 7 fitur |
|
||||
| Fitur Edit Data | ✅ 2/2 | — |
|
||||
| Fitur Analisis/Dashboard | — | ❌ 3 fitur |
|
||||
| Fitur Analisis/Dashboard | ✅ 2/3 | ❌ 1 fitur (K-04 ekspor) |
|
||||
| Fitur Cetak/Ekspor | — | ❌ 3 fitur |
|
||||
| **Total sisa** | | **~13 item** |
|
||||
| **Total sisa** | | **~11 item** |
|
||||
|
||||
---
|
||||
|
||||
@@ -100,6 +100,6 @@ Data bisa ditambah dan dihapus. Untuk lokasi: drag marker (ibadah & miskin) dan
|
||||
| ~~Plan 1~~ | ~~Auth & RBAC~~ | ✅ Selesai |
|
||||
| ~~Plan 2~~ | ~~Form Edit Rumah Ibadah & Keluarga Miskin + Ownership~~ | ✅ Selesai |
|
||||
| ~~Plan 3~~ | ~~Manajemen Pengguna (Admin: CRUD user, assign role)~~ | ✅ Selesai |
|
||||
| Plan 4 | Dashboard Statistik + Gap Analysis (Pemangku Kebijakan) | Sedang |
|
||||
| ~~Plan 4~~ | ~~Dashboard Statistik + Gap Analysis (Pemangku Kebijakan)~~ | ✅ Selesai |
|
||||
| Plan 5 | Ekspor PDF + Ekspor per Radius + Cetak Laporan | Sedang |
|
||||
| Plan 6 | Fitur Admin lanjutan (Bulk delete, CSV import, Log, Backup) | Rendah |
|
||||
|
||||
@@ -39,6 +39,11 @@
|
||||
<i class="fa-solid fa-table-list"></i> Data Tabular
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url('/dashboard') }}" class="font-medium">
|
||||
<i class="fa-solid fa-chart-bar"></i> Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url('/admin/users') }}" class="font-semibold bg-primary/10 text-primary rounded-lg">
|
||||
<i class="fa-solid fa-users-gear"></i> Admin
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>Dashboard Statistik - WebGIS</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
|
||||
<script src="https://unpkg.com/@turf/turf@6/turf.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { font-family: 'Plus Jakarta Sans', sans-serif; }
|
||||
body { font-weight: 500; background-color: #f8fafc; }
|
||||
h1, h2, h3, h4, h5, h6, .btn, .card-title, .stat-value { font-weight: 700 !important; letter-spacing: -0.01em; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen">
|
||||
|
||||
<!-- ═══ NAVBAR ═══════════════════════════════════════════════════════════ -->
|
||||
<div class="navbar bg-base-100 border-b border-base-200 shadow-sm sticky top-0 z-[1000]">
|
||||
<div class="flex-1">
|
||||
<a href="{{ url('/') }}" class="btn btn-ghost text-lg font-bold gap-2 text-primary">
|
||||
<i class="fa-solid fa-house-chimney-crack"></i>
|
||||
WebGIS Pemetaan Kemiskinan
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<ul class="menu menu-horizontal px-1 gap-1">
|
||||
<li>
|
||||
<a href="{{ url('/map') }}" class="font-medium">
|
||||
<i class="fa-solid fa-map"></i> Peta Interaktif
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url('/data') }}" class="font-medium">
|
||||
<i class="fa-solid fa-table-list"></i> Data Tabular
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url('/dashboard') }}" class="font-semibold bg-primary/10 text-primary rounded-lg">
|
||||
<i class="fa-solid fa-chart-bar"></i> Dashboard
|
||||
</a>
|
||||
</li>
|
||||
@if(auth()->user()->hasRole('administrator'))
|
||||
<li>
|
||||
<a href="{{ url('/admin/users') }}" class="font-medium">
|
||||
<i class="fa-solid fa-users-gear"></i> Admin
|
||||
</a>
|
||||
</li>
|
||||
@endif
|
||||
</ul>
|
||||
<!-- User dropdown -->
|
||||
<div class="dropdown dropdown-end ml-2">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost gap-2">
|
||||
<div class="avatar placeholder">
|
||||
<div class="bg-primary text-primary-content rounded-full w-8">
|
||||
<span class="text-xs font-bold">{{ substr(auth()->user()->name, 0, 1) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="hidden sm:inline text-sm font-semibold">{{ auth()->user()->name }}</span>
|
||||
<i class="fa-solid fa-chevron-down text-xs opacity-50"></i>
|
||||
</div>
|
||||
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2 shadow-lg border border-base-200 mt-2">
|
||||
<li class="menu-title">
|
||||
<span class="text-xs text-base-content/50 capitalize">{{ str_replace('_', ' ', auth()->user()->getRoleNames()->first() ?? 'user') }}</span>
|
||||
</li>
|
||||
<li>
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<button type="submit" class="text-error w-full text-left flex items-center gap-2">
|
||||
<i class="fa-solid fa-right-from-bracket"></i>
|
||||
Keluar
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ MAIN CONTENT ════════════════════════════════════════════════════ -->
|
||||
<main class="max-w-screen-xl mx-auto px-4 py-8">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold flex items-center gap-3">
|
||||
<i class="fa-solid fa-chart-bar text-primary"></i>
|
||||
Dashboard Statistik
|
||||
</h1>
|
||||
<p class="text-base-content/50 mt-1">Analisis sebaran dan kesenjangan layanan rumah ibadah.</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats Row -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<div class="stat bg-white shadow rounded-2xl border border-base-200">
|
||||
<div class="stat-figure text-success text-3xl"><i class="fa-solid fa-mosque"></i></div>
|
||||
<div class="stat-title text-xs uppercase font-bold opacity-50">Rumah Ibadah</div>
|
||||
<div class="stat-value text-success" id="statTotalIbadah">-</div>
|
||||
</div>
|
||||
<div class="stat bg-white shadow rounded-2xl border border-base-200">
|
||||
<div class="stat-figure text-error text-3xl"><i class="fa-solid fa-house-user"></i></div>
|
||||
<div class="stat-title text-xs uppercase font-bold opacity-50">Keluarga Miskin</div>
|
||||
<div class="stat-value text-error" id="statTotalMiskin">-</div>
|
||||
</div>
|
||||
<div class="stat bg-white shadow rounded-2xl border border-base-200">
|
||||
<div class="stat-figure text-warning text-3xl"><i class="fa-solid fa-users"></i></div>
|
||||
<div class="stat-title text-xs uppercase font-bold opacity-50">Total Jiwa</div>
|
||||
<div class="stat-value text-warning" id="statTotalJiwa">-</div>
|
||||
</div>
|
||||
<div class="stat bg-white shadow rounded-2xl border border-base-200">
|
||||
<div class="stat-figure text-info text-3xl"><i class="fa-solid fa-circle-check"></i></div>
|
||||
<div class="stat-title text-xs uppercase font-bold opacity-50">Miskin Terjangkau</div>
|
||||
<div class="stat-value text-info" id="statPctCovered">-</div>
|
||||
<div class="stat-desc" id="statCoveredDesc">dari total keluarga miskin</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-base-200 p-6">
|
||||
<h2 class="text-sm font-bold uppercase opacity-50 mb-4">Rumah Ibadah per Jenis</h2>
|
||||
<canvas id="chartJenis" height="220"></canvas>
|
||||
</div>
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-base-200 p-6">
|
||||
<h2 class="text-sm font-bold uppercase opacity-50 mb-4">Keluarga Miskin per Kondisi Rumah</h2>
|
||||
<canvas id="chartKondisi" height="220"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gap Analysis Section -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-base-200 p-6 mb-8">
|
||||
<h2 class="text-lg font-bold flex items-center gap-2 mb-6">
|
||||
<i class="fa-solid fa-magnifying-glass-chart text-secondary"></i>
|
||||
Analisis Kesenjangan (Gap Analysis)
|
||||
</h2>
|
||||
<p class="text-sm text-base-content/60 mb-6">
|
||||
Keluarga miskin yang "tidak terjangkau" adalah yang tidak berada dalam radius analisis
|
||||
rumah ibadah manapun berdasarkan koordinat lokasi masing-masing.
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Doughnut Chart -->
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
<canvas id="chartGap" style="max-height:260px;max-width:260px"></canvas>
|
||||
<div class="flex gap-6 mt-4 text-sm font-semibold">
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="w-3 h-3 rounded-full bg-success inline-block"></span>
|
||||
Terjangkau
|
||||
</span>
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="w-3 h-3 rounded-full bg-error inline-block"></span>
|
||||
Tidak Terjangkau
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Per-Ibadah Coverage Table -->
|
||||
<div class="overflow-x-auto">
|
||||
<h3 class="text-sm font-bold uppercase opacity-50 mb-3">Coverage per Rumah Ibadah</h3>
|
||||
<table class="table table-sm w-full">
|
||||
<thead>
|
||||
<tr class="text-xs font-bold uppercase opacity-50">
|
||||
<th>Nama</th>
|
||||
<th class="text-center">Radius</th>
|
||||
<th class="text-center">KK dalam Radius</th>
|
||||
<th class="text-center">Jiwa</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="coverageTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Uncovered List -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-base-200 p-6">
|
||||
<h2 class="text-lg font-bold flex items-center gap-2 mb-4">
|
||||
<i class="fa-solid fa-triangle-exclamation text-error"></i>
|
||||
Keluarga Miskin Tidak Terjangkau
|
||||
<span class="badge badge-error badge-sm ml-1" id="uncoveredBadge">0</span>
|
||||
</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra table-sm w-full">
|
||||
<thead>
|
||||
<tr class="text-xs font-bold uppercase opacity-50">
|
||||
<th>ID</th>
|
||||
<th>Nama KK</th>
|
||||
<th>Alamat</th>
|
||||
<th>Kondisi</th>
|
||||
<th class="text-center">Jiwa</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="uncoveredTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Toast Container -->
|
||||
<div class="toast toast-end toast-bottom z-[9999]" id="toastBox"></div>
|
||||
|
||||
<!-- ═══ JAVASCRIPT ════════════════════════════════════════════════════════ -->
|
||||
<script>
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
function isInside(point, center, radius) {
|
||||
const cLat = center.lat !== undefined ? center.lat : center[0];
|
||||
const cLng = center.lng !== undefined ? center.lng : center[1];
|
||||
const p1 = turf.point([Number(point.longitude), Number(point.latitude)]);
|
||||
const p2 = turf.point([Number(cLng), Number(cLat)]);
|
||||
return turf.distance(p1, p2, { units: 'meters' }) <= radius;
|
||||
}
|
||||
|
||||
let ibadahData = [], miskinData = [];
|
||||
let chartJenis = null, chartKondisi = null, chartGap = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', loadDashboard);
|
||||
|
||||
async function loadDashboard() {
|
||||
try {
|
||||
const [ri, rm] = await Promise.all([
|
||||
fetch('{{ url('/api/ibadah') }}').then(r => r.json()),
|
||||
fetch('{{ url('/api/miskin') }}').then(r => r.json()),
|
||||
]);
|
||||
ibadahData = ri;
|
||||
miskinData = rm;
|
||||
renderDashboard();
|
||||
} catch (e) {
|
||||
showToast('Gagal memuat data', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderDashboard() {
|
||||
// ── 1. SUMMARY STATS ─────────────────────────────────────
|
||||
const totalJiwa = miskinData.reduce((s, m) => s + parseInt(m.jumlah_orang || 0), 0);
|
||||
document.getElementById('statTotalIbadah').textContent = ibadahData.length;
|
||||
document.getElementById('statTotalMiskin').textContent = miskinData.length;
|
||||
document.getElementById('statTotalJiwa').textContent = totalJiwa;
|
||||
|
||||
// ── 2. GAP ANALYSIS ──────────────────────────────────────
|
||||
const coveredSet = new Set();
|
||||
ibadahData.forEach(ib => {
|
||||
miskinData.forEach(m => {
|
||||
if (isInside(m, { lat: ib.latitude, lng: ib.longitude }, ib.radius || 500)) {
|
||||
coveredSet.add(m.id_rumah);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const coveredCount = coveredSet.size;
|
||||
const uncoveredCount = miskinData.length - coveredCount;
|
||||
const pct = miskinData.length > 0
|
||||
? Math.round((coveredCount / miskinData.length) * 100)
|
||||
: 0;
|
||||
|
||||
document.getElementById('statPctCovered').textContent = pct + '%';
|
||||
document.getElementById('statCoveredDesc').textContent =
|
||||
coveredCount + ' dari ' + miskinData.length + ' keluarga';
|
||||
|
||||
// ── 3. CHART: IBADAH PER JENIS ───────────────────────────
|
||||
const jenisCounts = {};
|
||||
ibadahData.forEach(i => {
|
||||
const j = i.jenis || 'lainnya';
|
||||
jenisCounts[j] = (jenisCounts[j] || 0) + 1;
|
||||
});
|
||||
const jenisColors = {
|
||||
masjid: '#22c55e',
|
||||
gereja: '#3b82f6',
|
||||
pura: '#f59e0b',
|
||||
klenteng: '#ef4444',
|
||||
vihara: '#8b5cf6',
|
||||
lainnya: '#6b7280',
|
||||
};
|
||||
renderOrUpdateChart('chartJenis', chartJenis, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: Object.keys(jenisCounts).map(k => k.charAt(0).toUpperCase() + k.slice(1)),
|
||||
datasets: [{
|
||||
data: Object.values(jenisCounts),
|
||||
backgroundColor: Object.keys(jenisCounts).map(k => jenisColors[k] || '#6b7280'),
|
||||
borderWidth: 2,
|
||||
borderColor: '#fff',
|
||||
}],
|
||||
},
|
||||
options: { plugins: { legend: { position: 'bottom' } }, cutout: '55%' },
|
||||
}, c => chartJenis = c);
|
||||
|
||||
// ── 4. CHART: MISKIN PER KONDISI RUMAH ───────────────────
|
||||
const kondisiOrder = ['sangat_buruk', 'buruk', 'sedang'];
|
||||
const kondisiLabels = { sangat_buruk: 'Sangat Buruk', buruk: 'Buruk', sedang: 'Sedang' };
|
||||
const kondisiColors = { sangat_buruk: '#ef4444', buruk: '#f97316', sedang: '#eab308' };
|
||||
const kondisiCounts = { sangat_buruk: 0, buruk: 0, sedang: 0 };
|
||||
miskinData.forEach(m => {
|
||||
const k = m.kondisi_rumah || '';
|
||||
if (kondisiCounts[k] !== undefined) kondisiCounts[k]++;
|
||||
});
|
||||
renderOrUpdateChart('chartKondisi', chartKondisi, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: kondisiOrder.map(k => kondisiLabels[k]),
|
||||
datasets: [{
|
||||
label: 'Jumlah KK',
|
||||
data: kondisiOrder.map(k => kondisiCounts[k]),
|
||||
backgroundColor: kondisiOrder.map(k => kondisiColors[k]),
|
||||
borderRadius: 6,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y',
|
||||
plugins: { legend: { display: false } },
|
||||
scales: { x: { beginAtZero: true, ticks: { stepSize: 1 } } },
|
||||
},
|
||||
}, c => chartKondisi = c);
|
||||
|
||||
// ── 5. CHART: GAP DOUGHNUT ───────────────────────────────
|
||||
renderOrUpdateChart('chartGap', chartGap, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Terjangkau', 'Tidak Terjangkau'],
|
||||
datasets: [{
|
||||
data: [coveredCount, uncoveredCount],
|
||||
backgroundColor: ['#22c55e', '#ef4444'],
|
||||
borderWidth: 2,
|
||||
borderColor: '#fff',
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: ctx => ` ${ctx.label}: ${ctx.raw} KK`,
|
||||
},
|
||||
},
|
||||
},
|
||||
cutout: '60%',
|
||||
},
|
||||
}, c => chartGap = c);
|
||||
|
||||
// ── 6. COVERAGE TABLE PER IBADAH ─────────────────────────
|
||||
const coverageRows = ibadahData.map(ib => {
|
||||
const inRadius = miskinData.filter(m =>
|
||||
isInside(m, { lat: ib.latitude, lng: ib.longitude }, ib.radius || 500)
|
||||
);
|
||||
const jiwaInRadius = inRadius.reduce((s, m) => s + parseInt(m.jumlah_orang || 0), 0);
|
||||
return { ib, count: inRadius.length, jiwa: jiwaInRadius };
|
||||
}).sort((a, b) => b.count - a.count);
|
||||
|
||||
document.getElementById('coverageTableBody').innerHTML = coverageRows.length
|
||||
? coverageRows.map(r => `
|
||||
<tr>
|
||||
<td class="font-semibold text-sm">${esc(r.ib.nama)}</td>
|
||||
<td class="text-center"><span class="badge badge-outline badge-xs">${r.ib.radius || 500}m</span></td>
|
||||
<td class="text-center font-bold text-primary">${r.count}</td>
|
||||
<td class="text-center text-sm opacity-70">${r.jiwa}</td>
|
||||
</tr>`).join('')
|
||||
: '<tr><td colspan="4" class="text-center py-4 opacity-30 italic">Belum ada data ibadah</td></tr>';
|
||||
|
||||
// ── 7. UNCOVERED LIST ────────────────────────────────────
|
||||
const uncovered = miskinData.filter(m => !coveredSet.has(m.id_rumah));
|
||||
document.getElementById('uncoveredBadge').textContent = uncoveredCount;
|
||||
document.getElementById('uncoveredTableBody').innerHTML = uncovered.length
|
||||
? uncovered.map(m => `
|
||||
<tr>
|
||||
<td class="font-bold text-error text-xs">${esc(m.id_rumah)}</td>
|
||||
<td>${esc(m.nama_kk || '-')}</td>
|
||||
<td class="text-xs opacity-70 max-w-xs truncate">${esc(m.alamat || '-')}</td>
|
||||
<td class="capitalize text-xs">${esc(m.kondisi_rumah || '-')}</td>
|
||||
<td class="text-center">${m.jumlah_orang || 0}</td>
|
||||
</tr>`).join('')
|
||||
: '<tr><td colspan="5" class="text-center py-6 text-success font-semibold">Semua keluarga miskin terjangkau! 🎉</td></tr>';
|
||||
}
|
||||
|
||||
function renderOrUpdateChart(canvasId, existingChart, config, setter) {
|
||||
if (existingChart) existingChart.destroy();
|
||||
const ctx = document.getElementById(canvasId).getContext('2d');
|
||||
setter(new Chart(ctx, config));
|
||||
}
|
||||
|
||||
function showToast(msg, type = 'info') {
|
||||
const t = document.createElement('div');
|
||||
const cls = type === 'success' ? 'alert-success' : type === 'error' ? 'alert-error' : 'alert-info';
|
||||
t.className = `alert ${cls} shadow-lg py-3 text-xs font-bold`;
|
||||
t.innerHTML = `<span><i class="fa-solid fa-circle-info mr-2"></i>${esc(msg)}</span>`;
|
||||
document.getElementById('toastBox').appendChild(t);
|
||||
setTimeout(() => t.remove(), 3000);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -36,6 +36,11 @@
|
||||
<ul class="menu menu-horizontal px-1 gap-1">
|
||||
<li><a href="{{ url('/map') }}" class="font-medium"><i class="fa-solid fa-map"></i> Peta</a></li>
|
||||
<li><a href="{{ url('/data') }}" class="font-semibold bg-primary/10 text-primary rounded-lg"><i class="fa-solid fa-table-list"></i> Data Tabular</a></li>
|
||||
<li>
|
||||
<a href="{{ url('/dashboard') }}" class="font-medium">
|
||||
<i class="fa-solid fa-chart-bar"></i> Dashboard
|
||||
</a>
|
||||
</li>
|
||||
@if(auth()->user()->hasRole('administrator'))
|
||||
<li>
|
||||
<a href="{{ url('/admin/users') }}" class="font-medium">
|
||||
|
||||
@@ -88,6 +88,11 @@
|
||||
<i class="fa-solid fa-table-list"></i> Data Tabular
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url('/dashboard') }}" class="font-medium">
|
||||
<i class="fa-solid fa-chart-bar"></i> Dashboard
|
||||
</a>
|
||||
</li>
|
||||
@if(auth()->user()->hasRole('administrator'))
|
||||
<li>
|
||||
<a href="{{ url('/admin/users') }}" class="font-medium">
|
||||
|
||||
@@ -18,6 +18,7 @@ Route::middleware('auth')->group(function () {
|
||||
Route::get('/', fn() => view('welcome'));
|
||||
Route::get('/map', fn() => view('map'));
|
||||
Route::get('/data', fn() => view('data'));
|
||||
Route::get('/dashboard', fn() => view('dashboard'))->name('dashboard');
|
||||
|
||||
// API routes moved here for session + CSRF middleware
|
||||
Route::prefix('api')->group(function () {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DashboardTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
foreach (['administrator', 'pengurus_ibadah', 'petugas_pendataan', 'pemangku_kebijakan'] as $role) {
|
||||
\Spatie\Permission\Models\Role::create(['name' => $role, 'guard_name' => 'web']);
|
||||
}
|
||||
}
|
||||
|
||||
private function createUser(string $role): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole($role);
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function test_unauthenticated_cannot_access_dashboard(): void
|
||||
{
|
||||
$this->get('/dashboard')->assertRedirect('/login');
|
||||
}
|
||||
|
||||
public function test_administrator_can_access_dashboard(): void
|
||||
{
|
||||
$this->actingAs($this->createUser('administrator'));
|
||||
$this->get('/dashboard')->assertOk();
|
||||
}
|
||||
|
||||
public function test_pengurus_ibadah_can_access_dashboard(): void
|
||||
{
|
||||
$this->actingAs($this->createUser('pengurus_ibadah'));
|
||||
$this->get('/dashboard')->assertOk();
|
||||
}
|
||||
|
||||
public function test_petugas_pendataan_can_access_dashboard(): void
|
||||
{
|
||||
$this->actingAs($this->createUser('petugas_pendataan'));
|
||||
$this->get('/dashboard')->assertOk();
|
||||
}
|
||||
|
||||
public function test_pemangku_kebijakan_can_access_dashboard(): void
|
||||
{
|
||||
$this->actingAs($this->createUser('pemangku_kebijakan'));
|
||||
$this->get('/dashboard')->assertOk();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user