542 lines
28 KiB
PHP
542 lines
28 KiB
PHP
<!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>
|
|
<link rel="stylesheet" href="{{ asset('css/genesis.css') }}">
|
|
</head>
|
|
<body class="min-h-screen app-body">
|
|
|
|
@include('partials.sidebar')
|
|
|
|
<!-- ═══ MAIN CONTENT ════════════════════════════════════════════════════ -->
|
|
<main class="app-main">
|
|
<div 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>
|
|
|
|
<!-- Fitur Dosen Stats -->
|
|
<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-secondary text-3xl"><i class="fa-solid fa-people-roof"></i></div>
|
|
<div class="stat-title text-xs uppercase font-bold opacity-50">Anggota Keluarga</div>
|
|
<div class="stat-value text-secondary" id="statTotalAnggota">-</div>
|
|
<div class="stat-desc" id="statAnggotaDesc">hidup / meninggal</div>
|
|
</div>
|
|
<div class="stat bg-white shadow rounded-2xl border border-base-200">
|
|
<div class="stat-figure text-accent text-3xl"><i class="fa-solid fa-graduation-cap"></i></div>
|
|
<div class="stat-title text-xs uppercase font-bold opacity-50">Pendidikan Terdata</div>
|
|
<div class="stat-value text-accent" id="statPendidikanTerdata">-</div>
|
|
<div class="stat-desc">anggota dengan pendidikan</div>
|
|
</div>
|
|
<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-hand-holding-heart"></i></div>
|
|
<div class="stat-title text-xs uppercase font-bold opacity-50">Bantuan Bulan Ini</div>
|
|
<div class="stat-value text-success" id="statBantuanBulanIni">-</div>
|
|
<div class="stat-desc" id="statBantuanTotal">dari total bantuan</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-right-left"></i></div>
|
|
<div class="stat-title text-xs uppercase font-bold opacity-50">Transfer Pending</div>
|
|
<div class="stat-value text-error" id="statTransferPending">-</div>
|
|
<div class="stat-desc" id="statTransferDesc">permohonan aktif</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>
|
|
|
|
<!-- Fitur Dosen Charts -->
|
|
<div class="grid grid-cols-1 md:grid-cols-3 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">Pendidikan Anggota</h2>
|
|
<canvas id="chartPendidikan" 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">Bantuan per Jenis</h2>
|
|
<canvas id="chartBantuanJenis" 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">Status Transfer</h2>
|
|
<canvas id="chartTransfer" 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>
|
|
</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 = [], dosenSummary = null;
|
|
let chartJenis = null, chartKondisi = null, chartGap = null;
|
|
let chartPendidikan = null, chartBantuanJenis = null, chartTransfer = null;
|
|
|
|
document.addEventListener('DOMContentLoaded', loadDashboard);
|
|
|
|
async function loadDashboard() {
|
|
try {
|
|
const [ri, rm, ds] = await Promise.all([
|
|
fetch('{{ url('/api/ibadah') }}').then(r => r.json()),
|
|
fetch('{{ url('/api/miskin') }}').then(r => r.json()),
|
|
fetch('{{ url('/api/dashboard/dosen-summary') }}').then(r => r.json()),
|
|
]);
|
|
ibadahData = ri;
|
|
miskinData = rm;
|
|
dosenSummary = ds;
|
|
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;
|
|
renderDosenSummary();
|
|
|
|
// ── 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 renderDosenSummary() {
|
|
if (!dosenSummary) return;
|
|
|
|
const anggota = dosenSummary.anggota || {};
|
|
const bantuan = dosenSummary.bantuan || {};
|
|
const transfer = dosenSummary.transfer || {};
|
|
const statusHidup = anggota.status_hidup || {};
|
|
const pendidikan = anggota.pendidikan || {};
|
|
|
|
const pendidikanTerdata = Object.entries(pendidikan)
|
|
.filter(([key]) => key !== 'tidak_diketahui')
|
|
.reduce((total, [, value]) => total + Number(value || 0), 0);
|
|
|
|
document.getElementById('statTotalAnggota').textContent = anggota.total || 0;
|
|
document.getElementById('statAnggotaDesc').textContent = `${statusHidup.hidup || 0} hidup / ${statusHidup.meninggal || 0} meninggal`;
|
|
document.getElementById('statPendidikanTerdata').textContent = pendidikanTerdata;
|
|
document.getElementById('statBantuanBulanIni').textContent = bantuan.bulan_ini || 0;
|
|
document.getElementById('statBantuanTotal').textContent = `${bantuan.total || 0} total bantuan`;
|
|
document.getElementById('statTransferPending').textContent = transfer.pending || 0;
|
|
document.getElementById('statTransferDesc').textContent = `${transfer.disetujui || 0} disetujui / ${transfer.ditolak || 0} ditolak`;
|
|
|
|
const pendidikanLabels = {
|
|
tidak_sekolah: 'Tidak Sekolah',
|
|
sd: 'SD',
|
|
smp: 'SMP',
|
|
sma: 'SMA',
|
|
d3: 'D3',
|
|
s1_plus: 'S1+',
|
|
tidak_diketahui: 'Tidak Diketahui',
|
|
};
|
|
const pendidikanEntries = chartEntries(pendidikan, pendidikanLabels);
|
|
renderOrUpdateChart('chartPendidikan', chartPendidikan, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: pendidikanEntries.labels,
|
|
datasets: [{
|
|
label: 'Anggota',
|
|
data: pendidikanEntries.values,
|
|
backgroundColor: '#14b8a6',
|
|
borderRadius: 6,
|
|
}],
|
|
},
|
|
options: {
|
|
plugins: { legend: { display: false } },
|
|
scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } },
|
|
},
|
|
}, c => chartPendidikan = c);
|
|
|
|
const bantuanEntries = chartEntries(bantuan.per_jenis || {}, {});
|
|
renderOrUpdateChart('chartBantuanJenis', chartBantuanJenis, {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels: bantuanEntries.labels,
|
|
datasets: [{
|
|
data: bantuanEntries.values,
|
|
backgroundColor: ['#22c55e', '#3b82f6', '#f97316', '#8b5cf6', '#ef4444', '#64748b'],
|
|
borderWidth: 2,
|
|
borderColor: '#fff',
|
|
}],
|
|
},
|
|
options: { plugins: { legend: { position: 'bottom' } }, cutout: '55%' },
|
|
}, c => chartBantuanJenis = c);
|
|
|
|
const transferEntries = chartEntries(transfer, {
|
|
pending: 'Pending',
|
|
disetujui: 'Disetujui',
|
|
ditolak: 'Ditolak',
|
|
});
|
|
renderOrUpdateChart('chartTransfer', chartTransfer, {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels: transferEntries.labels,
|
|
datasets: [{
|
|
data: transferEntries.values,
|
|
backgroundColor: ['#f59e0b', '#22c55e', '#ef4444'],
|
|
borderWidth: 2,
|
|
borderColor: '#fff',
|
|
}],
|
|
},
|
|
options: { plugins: { legend: { position: 'bottom' } }, cutout: '55%' },
|
|
}, c => chartTransfer = c);
|
|
}
|
|
|
|
function chartEntries(source, labels) {
|
|
const entries = Object.entries(source || {}).filter(([, value]) => Number(value || 0) > 0);
|
|
if (!entries.length) return { labels: ['Belum ada'], values: [0] };
|
|
|
|
return {
|
|
labels: entries.map(([key]) => labels[key] || key.replace(/_/g, ' ')),
|
|
values: entries.map(([, value]) => Number(value || 0)),
|
|
};
|
|
}
|
|
|
|
// ── NOTIFIKASI PERUBAHAN DATA ────────────────────────────────────────
|
|
function updateTransferBadge(count) {
|
|
document.querySelectorAll('a[href$="/permohonan"]').forEach(link => {
|
|
let badge = link.querySelector('.transfer-pending-badge');
|
|
if (count > 0) {
|
|
if (!badge) {
|
|
badge = document.createElement('span');
|
|
badge.className = 'badge badge-error badge-xs transfer-pending-badge';
|
|
link.appendChild(document.createTextNode(' '));
|
|
link.appendChild(badge);
|
|
}
|
|
badge.textContent = count;
|
|
badge.classList.remove('hidden');
|
|
} else if (badge) {
|
|
badge.classList.add('hidden');
|
|
}
|
|
});
|
|
}
|
|
|
|
(function initNotifPolling() {
|
|
const POLL_MS = 30000;
|
|
const KEY_SEEN = 'notif_latest_seen';
|
|
|
|
async function pollNotifications() {
|
|
try {
|
|
const since = localStorage.getItem(KEY_SEEN) || new Date(0).toISOString();
|
|
const res = await fetch(
|
|
`{{ url('/api/notifications') }}?since=${encodeURIComponent(since)}`,
|
|
{ headers: { 'X-CSRF-TOKEN': csrfToken } }
|
|
);
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
|
|
updateTransferBadge(Number(data.pending_transfer || 0));
|
|
|
|
const hasChanges = data.new_ibadah > 0 || data.new_miskin > 0 || data.new_anggota > 0 || data.new_bantuan > 0 || data.new_transfer > 0;
|
|
if (hasChanges) {
|
|
if (data.new_ibadah > 0) showToast(`${data.new_ibadah} rumah ibadah baru ditambahkan`, 'info');
|
|
if (data.new_miskin > 0) showToast(`${data.new_miskin} keluarga miskin baru ditambahkan`, 'info');
|
|
if (data.new_anggota > 0) showToast(`${data.new_anggota} anggota keluarga baru ditambahkan`, 'info');
|
|
if (data.new_bantuan > 0) showToast(`${data.new_bantuan} bantuan baru dicatat`, 'info');
|
|
if (data.new_transfer > 0) showToast(`${data.new_transfer} permohonan transfer baru`, 'info');
|
|
if (typeof loadDashboard !== 'undefined') loadDashboard();
|
|
}
|
|
const latest = [data.latest_ibadah, data.latest_miskin, data.latest_anggota, data.latest_bantuan, data.latest_transfer]
|
|
.filter(Boolean)
|
|
.sort()
|
|
.pop();
|
|
if (latest) localStorage.setItem(KEY_SEEN, latest);
|
|
} catch (e) { /* silent */ }
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
if (!localStorage.getItem(KEY_SEEN)) localStorage.setItem(KEY_SEEN, new Date().toISOString());
|
|
setInterval(pollNotifications, POLL_MS);
|
|
});
|
|
})();
|
|
|
|
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>
|