351 lines
16 KiB
JavaScript
351 lines
16 KiB
JavaScript
// ===== Dashboard Feature =====
|
|
// Dashboard informatif untuk Admin dan Pengelola Rumah Ibadah
|
|
|
|
(function () {
|
|
'use strict';
|
|
|
|
// ── State ──────────────────────────────────────────────────────────────
|
|
let dashboardData = null;
|
|
let chartInstances = {};
|
|
|
|
// ── Modal Element ──────────────────────────────────────────────────────
|
|
const modal = document.getElementById('dashboardModal');
|
|
if (!modal) return;
|
|
|
|
// ── Open / Close ───────────────────────────────────────────────────────
|
|
window.openDashboard = function () {
|
|
modal.classList.add('show');
|
|
loadDashboard();
|
|
};
|
|
|
|
window.closeDashboard = function () {
|
|
modal.classList.remove('show');
|
|
};
|
|
|
|
document.getElementById('closeDashboardModal')?.addEventListener('click', window.closeDashboard);
|
|
modal.addEventListener('click', function (e) {
|
|
if (e.target === modal) window.closeDashboard();
|
|
});
|
|
|
|
// ── Load Data ──────────────────────────────────────────────────────────
|
|
function loadDashboard() {
|
|
const body = document.getElementById('dashboardBody');
|
|
body.innerHTML = `
|
|
<div class="db-loading">
|
|
<div class="db-spinner"></div>
|
|
<span>Memuat data dashboard…</span>
|
|
</div>`;
|
|
|
|
fetch('api/dashboard/stats.php')
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.status !== 'success') throw new Error(data.message);
|
|
dashboardData = data;
|
|
renderDashboard(data);
|
|
})
|
|
.catch(err => {
|
|
body.innerHTML = `<div class="db-error">❌ Gagal memuat data: ${err.message}</div>`;
|
|
});
|
|
}
|
|
|
|
// ── Render ─────────────────────────────────────────────────────────────
|
|
function renderDashboard(data) {
|
|
const { stats, distribusi, ibadah_list, ibadah_performa, recent_logs, role } = data;
|
|
const isPengelola = role === 'pengelola';
|
|
const ibadah = ibadah_list?.[0] ?? null;
|
|
|
|
const body = document.getElementById('dashboardBody');
|
|
|
|
// ── Header info ──────────────────────────────────────────────────
|
|
let headerHtml = '';
|
|
if (isPengelola && ibadah) {
|
|
const emoji = (window.IBADAH_EMOJI || {})[ibadah.jenis] || '🕌';
|
|
headerHtml = `
|
|
<div class="db-ibadah-header">
|
|
<span class="db-ibadah-emoji">${emoji}</span>
|
|
<div>
|
|
<div class="db-ibadah-name">${esc(ibadah.nama)}</div>
|
|
<div class="db-ibadah-meta">${esc(ibadah.jenis)} · Radius ${ibadah.radius} m · ${esc(ibadah.alamat || '-')}</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
// ── KPI Cards ────────────────────────────────────────────────────
|
|
const pctTerurus = stats.pct_terurus;
|
|
const pctColor = pctTerurus >= 70 ? '#22c55e' : pctTerurus >= 40 ? '#f59e0b' : '#ef4444';
|
|
|
|
let kpiCards = `
|
|
<div class="db-kpi-grid">
|
|
${isPengelola ? '' : `
|
|
<div class="db-kpi-card kpi-ibadah">
|
|
<div class="db-kpi-icon">🕌</div>
|
|
<div class="db-kpi-val">${stats.total_ibadah}</div>
|
|
<div class="db-kpi-label">Rumah Ibadah</div>
|
|
</div>`}
|
|
<div class="db-kpi-card kpi-miskin">
|
|
<div class="db-kpi-icon">🏠</div>
|
|
<div class="db-kpi-val">${stats.total_miskin}</div>
|
|
<div class="db-kpi-label">KK Miskin${isPengelola ? ' (radius)' : ''}</div>
|
|
</div>
|
|
<div class="db-kpi-card kpi-jiwa">
|
|
<div class="db-kpi-icon">👥</div>
|
|
<div class="db-kpi-val">${stats.total_jiwa}</div>
|
|
<div class="db-kpi-label">Total Jiwa</div>
|
|
</div>
|
|
<div class="db-kpi-card kpi-log">
|
|
<div class="db-kpi-icon">📋</div>
|
|
<div class="db-kpi-val">${stats.total_log}</div>
|
|
<div class="db-kpi-label">Log Bantuan</div>
|
|
</div>
|
|
</div>`;
|
|
|
|
// ── Coverage bar ──────────────────────────────────────────────────
|
|
const coverageHtml = stats.total_miskin > 0 ? `
|
|
<div class="db-section">
|
|
<div class="db-section-title">📊 Cakupan Penanganan</div>
|
|
<div class="db-coverage-row">
|
|
<div class="db-coverage-labels">
|
|
<span style="color:#22c55e">✅ Terurus: <strong>${stats.terurus}</strong></span>
|
|
<span style="color:#ef4444">⚠️ Tidak Terurus: <strong>${stats.tidak_terurus}</strong></span>
|
|
<span style="color:${pctColor}; font-weight:700">${pctTerurus}%</span>
|
|
</div>
|
|
<div class="db-progress-bar">
|
|
<div class="db-progress-fill" style="width:${pctTerurus}%; background:${pctColor};"></div>
|
|
</div>
|
|
</div>
|
|
</div>` : '';
|
|
|
|
// ── Charts area ───────────────────────────────────────────────────
|
|
const chartsHtml = `
|
|
<div class="db-charts-grid">
|
|
<div class="db-chart-card">
|
|
<div class="db-chart-title">📦 Kategori Bantuan</div>
|
|
<canvas id="chartKategori" height="180"></canvas>
|
|
</div>
|
|
<div class="db-chart-card">
|
|
<div class="db-chart-title">🎁 Tipe Bantuan (Log)</div>
|
|
<canvas id="chartTipe" height="180"></canvas>
|
|
</div>
|
|
${!isPengelola ? `
|
|
<div class="db-chart-card">
|
|
<div class="db-chart-title">🕌 Jenis Rumah Ibadah</div>
|
|
<canvas id="chartJenis" height="180"></canvas>
|
|
</div>` : ''}
|
|
<div class="db-chart-card db-chart-wide">
|
|
<div class="db-chart-title">📅 Aktivitas Bantuan (6 Bulan)</div>
|
|
<canvas id="chartBulan" height="140"></canvas>
|
|
</div>
|
|
</div>`;
|
|
|
|
// ── Recent logs ───────────────────────────────────────────────────
|
|
let logsHtml = `<div class="db-section"><div class="db-section-title">🕐 Bantuan Terbaru</div>`;
|
|
if (recent_logs.length === 0) {
|
|
logsHtml += `<div class="db-empty">Belum ada log bantuan</div>`;
|
|
} else {
|
|
logsHtml += `<div class="db-log-list">`;
|
|
recent_logs.forEach(log => {
|
|
const tgl = new Date(log.tanggal).toLocaleDateString('id-ID', { day: '2-digit', month: 'short', year: 'numeric' });
|
|
const emoji = (window.IBADAH_EMOJI || {})[log.jenis_ibadah] || '🕌';
|
|
logsHtml += `
|
|
<div class="db-log-item">
|
|
<div class="db-log-dot"></div>
|
|
<div class="db-log-info">
|
|
<div class="db-log-main">
|
|
<span class="db-log-tipe">${esc(log.tipe_bantuan)}</span>
|
|
<span class="db-log-kk">→ ${esc(log.nama_kk)}</span>
|
|
</div>
|
|
<div class="db-log-meta">${emoji} ${esc(log.nama_ibadah)} · ${tgl}${log.keterangan ? ` · <em>${esc(log.keterangan)}</em>` : ''}</div>
|
|
</div>
|
|
</div>`;
|
|
});
|
|
logsHtml += `</div>`;
|
|
}
|
|
logsHtml += `</div>`;
|
|
|
|
// ── Ibadah performa table (admin only) ────────────────────────────
|
|
let perfHtml = '';
|
|
if (!isPengelola && ibadah_performa.length > 0) {
|
|
perfHtml = `
|
|
<div class="db-section">
|
|
<div class="db-section-title">🏆 Performa Rumah Ibadah</div>
|
|
<div class="db-table-wrap">
|
|
<table class="db-table">
|
|
<thead>
|
|
<tr>
|
|
<th>#</th>
|
|
<th>Nama</th>
|
|
<th>Jenis</th>
|
|
<th>Total Log</th>
|
|
<th>Penerima Unik</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${ibadah_performa.map((ib, i) => `
|
|
<tr>
|
|
<td>${i + 1}</td>
|
|
<td><strong>${esc(ib.nama)}</strong></td>
|
|
<td>${esc(ib.jenis)}</td>
|
|
<td><span class="db-badge">${ib.total_log}</span></td>
|
|
<td>${ib.total_penerima}</td>
|
|
</tr>`).join('')}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
// ── Assemble ──────────────────────────────────────────────────────
|
|
body.innerHTML = `
|
|
${headerHtml}
|
|
${kpiCards}
|
|
${coverageHtml}
|
|
${chartsHtml}
|
|
${logsHtml}
|
|
${perfHtml}
|
|
`;
|
|
|
|
// ── Draw Charts ───────────────────────────────────────────────────
|
|
setTimeout(() => {
|
|
drawPieChart('chartKategori', distribusi.kategori, ['#6366f1', '#f59e0b', '#22c55e', '#ef4444', '#06b6d4']);
|
|
drawPieChart('chartTipe', distribusi.tipe_bantuan, ['#4f46e5', '#16a34a', '#dc2626', '#d97706', '#0891b2', '#7c3aed']);
|
|
if (!isPengelola) drawPieChart('chartJenis', distribusi.jenis_ibadah, ['#f97316', '#6366f1', '#22c55e', '#ec4899', '#14b8a6']);
|
|
drawBarChart('chartBulan', distribusi.per_bulan);
|
|
}, 50);
|
|
}
|
|
|
|
// ── Chart helpers ──────────────────────────────────────────────────────
|
|
function drawPieChart(canvasId, dataObj, colors) {
|
|
const canvas = document.getElementById(canvasId);
|
|
if (!canvas) return;
|
|
const entries = Object.entries(dataObj || {});
|
|
if (entries.length === 0) {
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
ctx.fillStyle = '#aaa';
|
|
ctx.font = '13px Inter';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('Belum ada data', canvas.width / 2, canvas.height / 2);
|
|
return;
|
|
}
|
|
const labels = entries.map(([k]) => k);
|
|
const values = entries.map(([, v]) => v);
|
|
const total = values.reduce((a, b) => a + b, 0);
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
const cx = canvas.width / 2;
|
|
const cy = canvas.height * 0.45;
|
|
const r = Math.min(cx, cy) * 0.75;
|
|
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
|
let startAngle = -Math.PI / 2;
|
|
values.forEach((v, i) => {
|
|
const slice = (v / total) * 2 * Math.PI;
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx, cy);
|
|
ctx.arc(cx, cy, r, startAngle, startAngle + slice);
|
|
ctx.closePath();
|
|
ctx.fillStyle = colors[i % colors.length];
|
|
ctx.fill();
|
|
ctx.strokeStyle = '#fff';
|
|
ctx.lineWidth = 2;
|
|
ctx.stroke();
|
|
startAngle += slice;
|
|
});
|
|
|
|
// Center text
|
|
ctx.fillStyle = '#333';
|
|
ctx.font = 'bold 16px Inter';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText(total, cx, cy + 5);
|
|
|
|
// Legend
|
|
const legY = cy + r + 16;
|
|
const legItemW = 90;
|
|
const perRow = Math.floor(canvas.width / legItemW);
|
|
labels.forEach((lbl, i) => {
|
|
const row = Math.floor(i / perRow);
|
|
const col = i % perRow;
|
|
const lx = (canvas.width - Math.min(labels.length, perRow) * legItemW) / 2 + col * legItemW;
|
|
const ly = legY + row * 18;
|
|
ctx.fillStyle = colors[i % colors.length];
|
|
ctx.fillRect(lx, ly, 10, 10);
|
|
ctx.fillStyle = '#555';
|
|
ctx.font = '10px Inter';
|
|
ctx.textAlign = 'left';
|
|
const shortLbl = lbl.length > 10 ? lbl.slice(0, 9) + '…' : lbl;
|
|
ctx.fillText(`${shortLbl} (${values[i]})`, lx + 13, ly + 9);
|
|
});
|
|
}
|
|
|
|
function drawBarChart(canvasId, perBulan) {
|
|
const canvas = document.getElementById(canvasId);
|
|
if (!canvas) return;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
|
if (!perBulan || perBulan.length === 0) {
|
|
ctx.fillStyle = '#aaa';
|
|
ctx.font = '13px Inter';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('Belum ada data bantuan', canvas.width / 2, canvas.height / 2);
|
|
return;
|
|
}
|
|
|
|
const padL = 36, padR = 12, padT = 12, padB = 28;
|
|
const W = canvas.width - padL - padR;
|
|
const H = canvas.height - padT - padB;
|
|
|
|
const values = perBulan.map(d => parseInt(d.cnt));
|
|
const maxVal = Math.max(...values, 1);
|
|
const barW = W / perBulan.length * 0.6;
|
|
const gap = W / perBulan.length;
|
|
|
|
// Grid lines
|
|
ctx.strokeStyle = '#e5e7eb';
|
|
ctx.lineWidth = 1;
|
|
for (let i = 0; i <= 4; i++) {
|
|
const y = padT + H - (i / 4) * H;
|
|
ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(padL + W, y); ctx.stroke();
|
|
ctx.fillStyle = '#9ca3af';
|
|
ctx.font = '9px Inter';
|
|
ctx.textAlign = 'right';
|
|
ctx.fillText(Math.round((i / 4) * maxVal), padL - 4, y + 3);
|
|
}
|
|
|
|
// Bars
|
|
perBulan.forEach((d, i) => {
|
|
const bh = (parseInt(d.cnt) / maxVal) * H;
|
|
const bx = padL + i * gap + (gap - barW) / 2;
|
|
const by = padT + H - bh;
|
|
|
|
const grad = ctx.createLinearGradient(0, by, 0, padT + H);
|
|
grad.addColorStop(0, '#6366f1');
|
|
grad.addColorStop(1, '#a5b4fc');
|
|
ctx.fillStyle = grad;
|
|
ctx.beginPath();
|
|
ctx.roundRect ? ctx.roundRect(bx, by, barW, bh, [4, 4, 0, 0]) : ctx.rect(bx, by, barW, bh);
|
|
ctx.fill();
|
|
|
|
// Value label
|
|
ctx.fillStyle = '#4f46e5';
|
|
ctx.font = 'bold 10px Inter';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText(d.cnt, bx + barW / 2, by - 4);
|
|
|
|
// Month label
|
|
const monthLabel = d.bulan ? d.bulan.slice(5) + '/' + d.bulan.slice(2, 4) : '';
|
|
ctx.fillStyle = '#6b7280';
|
|
ctx.font = '9px Inter';
|
|
ctx.fillText(monthLabel, bx + barW / 2, padT + H + 14);
|
|
});
|
|
}
|
|
|
|
// ── Utility ───────────────────────────────────────────────────────────
|
|
function esc(str) {
|
|
if (!str) return '';
|
|
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
})();
|