feat: add dashboard feature with modular API endpoints and UI integration
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
require_once '../auth_helper.php';
|
||||
requireLogin();
|
||||
include_once '../db_connect.php';
|
||||
|
||||
$user = getCurrentUser();
|
||||
$isPengelola = $user['role'] === 'pengelola';
|
||||
$ibadahId = $isPengelola ? intval($user['ibadah_id']) : null;
|
||||
|
||||
// ── Helper: Haversine distance in meters ──────────────────────────────────
|
||||
function haversine($lat1, $lng1, $lat2, $lng2) {
|
||||
$R = 6371000;
|
||||
$dLat = deg2rad($lat2 - $lat1);
|
||||
$dLng = deg2rad($lng2 - $lng1);
|
||||
$a = sin($dLat/2)*sin($dLat/2)
|
||||
+ cos(deg2rad($lat1))*cos(deg2rad($lat2))*sin($dLng/2)*sin($dLng/2);
|
||||
return $R * 2 * atan2(sqrt($a), sqrt(1-$a));
|
||||
}
|
||||
|
||||
// ── 1. Rumah Ibadah ───────────────────────────────────────────────────────
|
||||
if ($isPengelola && $ibadahId) {
|
||||
$riRes = $conn->query("SELECT * FROM rumah_ibadah WHERE id=$ibadahId");
|
||||
} else {
|
||||
$riRes = $conn->query("SELECT * FROM rumah_ibadah");
|
||||
}
|
||||
$ibadahList = [];
|
||||
while ($row = $riRes->fetch_assoc()) $ibadahList[] = $row;
|
||||
$totalIbadah = count($ibadahList);
|
||||
|
||||
// Distribusi per jenis
|
||||
$jenisDist = [];
|
||||
foreach ($ibadahList as $ib) {
|
||||
$j = $ib['jenis'] ?? 'Lainnya';
|
||||
$jenisDist[$j] = ($jenisDist[$j] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// ── 2. Penduduk Miskin ────────────────────────────────────────────────────
|
||||
$pmRes = $conn->query("SELECT * FROM penduduk_miskin");
|
||||
$allMiskin = [];
|
||||
while ($row = $pmRes->fetch_assoc()) $allMiskin[] = $row;
|
||||
|
||||
// Filter: pengelola hanya lihat yang dalam radius ibadahnya
|
||||
if ($isPengelola && $ibadahId && !empty($ibadahList)) {
|
||||
$ib = $ibadahList[0];
|
||||
$filteredMiskin = array_filter($allMiskin, function($m) use ($ib) {
|
||||
return haversine((float)$ib['lat'], (float)$ib['lng'], (float)$m['lat'], (float)$m['lng']) <= (float)$ib['radius'];
|
||||
});
|
||||
$filteredMiskin = array_values($filteredMiskin);
|
||||
} else {
|
||||
$filteredMiskin = $allMiskin;
|
||||
}
|
||||
|
||||
$totalMiskin = count($filteredMiskin);
|
||||
$totalJiwa = array_sum(array_column($filteredMiskin, 'jumlah_jiwa'));
|
||||
|
||||
// Kategori bantuan
|
||||
$kategoriDist = [];
|
||||
foreach ($filteredMiskin as $m) {
|
||||
$k = $m['kategori_bantuan'] ?? 'Lainnya';
|
||||
$kategoriDist[$k] = ($kategoriDist[$k] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// Terurus vs Tidak (dalam radius salah satu ibadah)
|
||||
$terurusCount = 0;
|
||||
$tidakTerurusCount = 0;
|
||||
foreach ($filteredMiskin as $m) {
|
||||
$inAnyRadius = false;
|
||||
foreach ($ibadahList as $ib) {
|
||||
if (haversine((float)$ib['lat'], (float)$ib['lng'], (float)$m['lat'], (float)$m['lng']) <= (float)$ib['radius']) {
|
||||
$inAnyRadius = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($inAnyRadius) $terurusCount++;
|
||||
else $tidakTerurusCount++;
|
||||
}
|
||||
|
||||
// Jika admin: hitung seluruh miskin dalam radius seluruh ibadah
|
||||
if (!$isPengelola) {
|
||||
$allIbadahRes = $conn->query("SELECT * FROM rumah_ibadah");
|
||||
$allIbadahList = [];
|
||||
while ($row = $allIbadahRes->fetch_assoc()) $allIbadahList[] = $row;
|
||||
|
||||
$terurusCount = 0;
|
||||
$tidakTerurusCount = 0;
|
||||
foreach ($allMiskin as $m) {
|
||||
$inAnyRadius = false;
|
||||
foreach ($allIbadahList as $ib) {
|
||||
if (haversine((float)$ib['lat'], (float)$ib['lng'], (float)$m['lat'], (float)$m['lng']) <= (float)$ib['radius']) {
|
||||
$inAnyRadius = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($inAnyRadius) $terurusCount++;
|
||||
else $tidakTerurusCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Log Bantuan ────────────────────────────────────────────────────────
|
||||
if ($isPengelola && $ibadahId) {
|
||||
$logRes = $conn->query("
|
||||
SELECT lb.*, ri.nama as nama_ibadah, ri.jenis as jenis_ibadah, pm.nama as nama_kk
|
||||
FROM log_bantuan lb
|
||||
JOIN rumah_ibadah ri ON ri.id = lb.ibadah_id
|
||||
JOIN penduduk_miskin pm ON pm.id = lb.miskin_id
|
||||
WHERE lb.ibadah_id = $ibadahId
|
||||
ORDER BY lb.tanggal DESC, lb.id DESC
|
||||
LIMIT 10
|
||||
");
|
||||
$totalLogRes = $conn->query("SELECT COUNT(*) as cnt FROM log_bantuan WHERE ibadah_id=$ibadahId");
|
||||
} else {
|
||||
$logRes = $conn->query("
|
||||
SELECT lb.*, ri.nama as nama_ibadah, ri.jenis as jenis_ibadah, pm.nama as nama_kk
|
||||
FROM log_bantuan lb
|
||||
JOIN rumah_ibadah ri ON ri.id = lb.ibadah_id
|
||||
JOIN penduduk_miskin pm ON pm.id = lb.miskin_id
|
||||
ORDER BY lb.tanggal DESC, lb.id DESC
|
||||
LIMIT 10
|
||||
");
|
||||
$totalLogRes = $conn->query("SELECT COUNT(*) as cnt FROM log_bantuan");
|
||||
}
|
||||
|
||||
$recentLogs = [];
|
||||
while ($row = $logRes->fetch_assoc()) $recentLogs[] = $row;
|
||||
|
||||
$totalLog = 0;
|
||||
if ($totalLogRes && $trow = $totalLogRes->fetch_assoc()) $totalLog = (int)$trow['cnt'];
|
||||
|
||||
// Distribusi tipe bantuan dari log
|
||||
$tipeDist = [];
|
||||
if ($isPengelola && $ibadahId) {
|
||||
$tipeRes = $conn->query("SELECT tipe_bantuan, COUNT(*) as cnt FROM log_bantuan WHERE ibadah_id=$ibadahId GROUP BY tipe_bantuan");
|
||||
} else {
|
||||
$tipeRes = $conn->query("SELECT tipe_bantuan, COUNT(*) as cnt FROM log_bantuan GROUP BY tipe_bantuan");
|
||||
}
|
||||
while ($row = $tipeRes->fetch_assoc()) $tipeDist[$row['tipe_bantuan']] = (int)$row['cnt'];
|
||||
|
||||
// Bantuan per bulan (6 bulan terakhir)
|
||||
if ($isPengelola && $ibadahId) {
|
||||
$bulanRes = $conn->query("
|
||||
SELECT DATE_FORMAT(tanggal, '%Y-%m') as bulan, COUNT(*) as cnt
|
||||
FROM log_bantuan
|
||||
WHERE ibadah_id=$ibadahId AND tanggal >= DATE_SUB(CURDATE(), INTERVAL 6 MONTH)
|
||||
GROUP BY bulan ORDER BY bulan ASC
|
||||
");
|
||||
} else {
|
||||
$bulanRes = $conn->query("
|
||||
SELECT DATE_FORMAT(tanggal, '%Y-%m') as bulan, COUNT(*) as cnt
|
||||
FROM log_bantuan
|
||||
WHERE tanggal >= DATE_SUB(CURDATE(), INTERVAL 6 MONTH)
|
||||
GROUP BY bulan ORDER BY bulan ASC
|
||||
");
|
||||
}
|
||||
$bantuanPerBulan = [];
|
||||
while ($row = $bulanRes->fetch_assoc()) $bantuanPerBulan[] = $row;
|
||||
|
||||
// ── 4. Ibadah Tipe (admin only: performa per ibadah) ─────────────────────
|
||||
$ibadahPerforma = [];
|
||||
if (!$isPengelola) {
|
||||
$perfRes = $conn->query("
|
||||
SELECT ri.id, ri.nama, ri.jenis, ri.radius,
|
||||
COUNT(DISTINCT lb.id) as total_log,
|
||||
COUNT(DISTINCT lb.miskin_id) as total_penerima
|
||||
FROM rumah_ibadah ri
|
||||
LEFT JOIN log_bantuan lb ON lb.ibadah_id = ri.id
|
||||
GROUP BY ri.id
|
||||
ORDER BY total_log DESC
|
||||
");
|
||||
while ($row = $perfRes->fetch_assoc()) $ibadahPerforma[] = $row;
|
||||
}
|
||||
|
||||
// ── Output ────────────────────────────────────────────────────────────────
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'role' => $user['role'],
|
||||
'ibadah_id' => $ibadahId,
|
||||
'stats' => [
|
||||
'total_ibadah' => $totalIbadah,
|
||||
'total_miskin' => $totalMiskin,
|
||||
'total_jiwa' => $totalJiwa,
|
||||
'total_log' => $totalLog,
|
||||
'terurus' => $terurusCount,
|
||||
'tidak_terurus' => $tidakTerurusCount,
|
||||
'pct_terurus' => $totalMiskin > 0 ? round($terurusCount / $totalMiskin * 100, 1) : 0,
|
||||
],
|
||||
'distribusi' => [
|
||||
'jenis_ibadah' => $jenisDist,
|
||||
'kategori' => $kategoriDist,
|
||||
'tipe_bantuan' => $tipeDist,
|
||||
'per_bulan' => $bantuanPerBulan,
|
||||
],
|
||||
'ibadah_list' => $ibadahList,
|
||||
'ibadah_performa' => $ibadahPerforma,
|
||||
'recent_logs' => $recentLogs,
|
||||
]);
|
||||
@@ -4,11 +4,29 @@ require_once '../auth_helper.php';
|
||||
requireAdminOrPengelola();
|
||||
include_once '../db_connect.php';
|
||||
|
||||
// Ambil info ibadah pengelola jika role pengelola
|
||||
$currentUser = getCurrentUser();
|
||||
$ibadahFilter = null; // null = admin (tidak dibatasi)
|
||||
if ($currentUser && $currentUser['role'] === 'pengelola') {
|
||||
$ibadah_id = $currentUser['ibadah_id'];
|
||||
if (!$ibadah_id) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Akun pengelola tidak terhubung ke rumah ibadah manapun.']);
|
||||
exit;
|
||||
}
|
||||
$res = $conn->query("SELECT lat, lng, radius FROM rumah_ibadah WHERE id=$ibadah_id");
|
||||
if (!$res || $res->num_rows === 0) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Rumah ibadah yang dikelola tidak ditemukan.']);
|
||||
exit;
|
||||
}
|
||||
$ibadahFilter = $res->fetch_assoc();
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents("php://input"));
|
||||
|
||||
if (is_array($data)) {
|
||||
$conn->begin_transaction();
|
||||
$inserted = 0;
|
||||
$skipped = 0;
|
||||
$errors = [];
|
||||
foreach ($data as $item) {
|
||||
if (isset($item->lat) && isset($item->lng) && $item->lat !== '' && $item->lng !== '') {
|
||||
@@ -18,6 +36,21 @@ if (is_array($data)) {
|
||||
$lat = (float)$item->lat;
|
||||
$lng = (float)$item->lng;
|
||||
|
||||
// Jika pengelola, cek apakah item berada dalam radius ibadahnya
|
||||
if ($ibadahFilter !== null) {
|
||||
$R = 6371000;
|
||||
$lat1 = deg2rad((float)$ibadahFilter['lat']);
|
||||
$lat2 = deg2rad($lat);
|
||||
$dLat = $lat2 - $lat1;
|
||||
$dLng = deg2rad($lng - (float)$ibadahFilter['lng']);
|
||||
$a = sin($dLat/2)*sin($dLat/2) + cos($lat1)*cos($lat2)*sin($dLng/2)*sin($dLng/2);
|
||||
$dist = $R * 2 * atan2(sqrt($a), sqrt(1-$a));
|
||||
if ($dist > (float)$ibadahFilter['radius']) {
|
||||
$skipped++;
|
||||
continue; // Lewati item di luar radius
|
||||
}
|
||||
}
|
||||
|
||||
$query = "INSERT INTO penduduk_miskin (nama, kategori_bantuan, jumlah_jiwa, lat, lng)
|
||||
VALUES ('$nama', '$kategori', $jumlah_jiwa, $lat, $lng)";
|
||||
|
||||
@@ -31,10 +64,15 @@ if (is_array($data)) {
|
||||
|
||||
if (empty($errors) && $inserted > 0) {
|
||||
$conn->commit();
|
||||
echo json_encode(["status" => "success", "message" => "Berhasil mengimpor $inserted data penduduk miskin."]);
|
||||
$msg = "Berhasil mengimpor $inserted data penduduk miskin.";
|
||||
if ($skipped > 0) $msg .= " ($skipped data di luar radius diabaikan.)";
|
||||
echo json_encode(["status" => "success", "message" => $msg]);
|
||||
} else if ($inserted === 0) {
|
||||
$conn->rollback();
|
||||
echo json_encode(["status" => "error", "message" => "Tidak ada data valid yang diimpor."]);
|
||||
$msg = $skipped > 0
|
||||
? "Semua $skipped data berada di luar radius rumah ibadah Anda, tidak ada yang diimpor."
|
||||
: "Tidak ada data valid yang diimpor.";
|
||||
echo json_encode(["status" => "error", "message" => $msg]);
|
||||
} else {
|
||||
$conn->rollback();
|
||||
echo json_encode(["status" => "error", "message" => "Gagal mengimpor beberapa data. Transaksi dibatalkan.", "errors" => $errors]);
|
||||
|
||||
@@ -27,6 +27,38 @@ if (!empty($_POST)) {
|
||||
}
|
||||
}
|
||||
|
||||
// Validasi pengelola: hanya boleh tambah jika lokasi berada dalam radius ibadah yang dikelolanya
|
||||
$currentUser = getCurrentUser();
|
||||
if ($currentUser && $currentUser['role'] === 'pengelola') {
|
||||
$ibadah_id = $currentUser['ibadah_id'];
|
||||
if (!$ibadah_id) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Akun pengelola tidak terhubung ke rumah ibadah manapun.']);
|
||||
exit;
|
||||
}
|
||||
// Ambil data ibadah (lat, lng, radius)
|
||||
$res = $conn->query("SELECT lat, lng, radius FROM rumah_ibadah WHERE id=$ibadah_id");
|
||||
if (!$res || $res->num_rows === 0) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Rumah ibadah yang dikelola tidak ditemukan.']);
|
||||
exit;
|
||||
}
|
||||
$ib = $res->fetch_assoc();
|
||||
// Hitung jarak menggunakan Haversine formula
|
||||
$R = 6371000; // meter
|
||||
$lat1 = deg2rad((float)$ib['lat']);
|
||||
$lat2 = deg2rad($lat);
|
||||
$dLat = $lat2 - $lat1;
|
||||
$dLng = deg2rad($lng - (float)$ib['lng']);
|
||||
$a = sin($dLat/2)*sin($dLat/2) + cos($lat1)*cos($lat2)*sin($dLng/2)*sin($dLng/2);
|
||||
$dist = $R * 2 * atan2(sqrt($a), sqrt(1-$a));
|
||||
if ($dist > (float)$ib['radius']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Lokasi penduduk di luar radius rumah ibadah Anda. Hanya boleh menambah penduduk dalam radius yang dikelola.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
if ($lat != 0 && $lng != 0) {
|
||||
$upload_dir = "../../uploads/";
|
||||
if (!file_exists($upload_dir)) {
|
||||
|
||||
@@ -9,6 +9,49 @@ $data = json_decode(file_get_contents("php://input"));
|
||||
if(!empty($data->id)) {
|
||||
$id = (int)$data->id;
|
||||
|
||||
// Validasi pengelola: hanya boleh hapus penduduk dalam radius ibadah yang dikelolanya
|
||||
$currentUser = getCurrentUser();
|
||||
if ($currentUser && $currentUser['role'] === 'pengelola') {
|
||||
$ibadah_id = $currentUser['ibadah_id'];
|
||||
if (!$ibadah_id) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Akun pengelola tidak terhubung ke rumah ibadah manapun.']);
|
||||
exit;
|
||||
}
|
||||
// Ambil koordinat penduduk
|
||||
$mRes = $conn->query("SELECT lat, lng FROM penduduk_miskin WHERE id=$id");
|
||||
if (!$mRes || $mRes->num_rows === 0) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data penduduk tidak ditemukan.']);
|
||||
exit;
|
||||
}
|
||||
$mRow = $mRes->fetch_assoc();
|
||||
$pLat = (float)$mRow['lat'];
|
||||
$pLng = (float)$mRow['lng'];
|
||||
|
||||
// Ambil data ibadah
|
||||
$iRes = $conn->query("SELECT lat, lng, radius FROM rumah_ibadah WHERE id=$ibadah_id");
|
||||
if (!$iRes || $iRes->num_rows === 0) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Rumah ibadah yang dikelola tidak ditemukan.']);
|
||||
exit;
|
||||
}
|
||||
$ib = $iRes->fetch_assoc();
|
||||
|
||||
// Hitung jarak Haversine
|
||||
$R = 6371000;
|
||||
$lat1 = deg2rad((float)$ib['lat']);
|
||||
$lat2 = deg2rad($pLat);
|
||||
$dLat = $lat2 - $lat1;
|
||||
$dLng = deg2rad($pLng - (float)$ib['lng']);
|
||||
$a = sin($dLat/2)*sin($dLat/2) + cos($lat1)*cos($lat2)*sin($dLng/2)*sin($dLng/2);
|
||||
$dist = $R * 2 * atan2(sqrt($a), sqrt(1-$a));
|
||||
if ($dist > (float)$ib['radius']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Penduduk ini berada di luar radius rumah ibadah Anda. Tidak diizinkan menghapus.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$query = "DELETE FROM penduduk_miskin WHERE id=$id";
|
||||
|
||||
if($conn->query($query)) {
|
||||
|
||||
@@ -30,6 +30,47 @@ if (!empty($_POST)) {
|
||||
}
|
||||
}
|
||||
|
||||
// Validasi pengelola: hanya boleh edit penduduk yang lokasinya dalam radius ibadah yang dikelolanya
|
||||
$currentUser = getCurrentUser();
|
||||
if ($currentUser && $currentUser['role'] === 'pengelola') {
|
||||
$ibadah_id = $currentUser['ibadah_id'];
|
||||
if (!$ibadah_id) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Akun pengelola tidak terhubung ke rumah ibadah manapun.']);
|
||||
exit;
|
||||
}
|
||||
$res = $conn->query("SELECT lat, lng, radius FROM rumah_ibadah WHERE id=$ibadah_id");
|
||||
if (!$res || $res->num_rows === 0) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Rumah ibadah yang dikelola tidak ditemukan.']);
|
||||
exit;
|
||||
}
|
||||
$ib = $res->fetch_assoc();
|
||||
// Gunakan koordinat penduduk yang sedang diedit (dari DB jika lat/lng 0, atau dari input)
|
||||
$checkLat = $lat;
|
||||
$checkLng = $lng;
|
||||
if ($checkLat == 0 && $checkLng == 0 && $id > 0) {
|
||||
$mRes = $conn->query("SELECT lat, lng FROM penduduk_miskin WHERE id=$id");
|
||||
if ($mRes && $mRow = $mRes->fetch_assoc()) {
|
||||
$checkLat = (float)$mRow['lat'];
|
||||
$checkLng = (float)$mRow['lng'];
|
||||
}
|
||||
}
|
||||
$R = 6371000;
|
||||
$lat1 = deg2rad((float)$ib['lat']);
|
||||
$lat2 = deg2rad($checkLat);
|
||||
$dLat = $lat2 - $lat1;
|
||||
$dLng = deg2rad($checkLng - (float)$ib['lng']);
|
||||
$a = sin($dLat/2)*sin($dLat/2) + cos($lat1)*cos($lat2)*sin($dLng/2)*sin($dLng/2);
|
||||
$dist = $R * 2 * atan2(sqrt($a), sqrt(1-$a));
|
||||
if ($dist > (float)$ib['radius']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Penduduk ini berada di luar radius rumah ibadah Anda. Tidak diizinkan mengedit.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if ($id > 0) {
|
||||
// Get current files to delete them if updated
|
||||
$old_foto_rumah = null;
|
||||
|
||||
+267
-2
@@ -22,8 +22,8 @@ body, html {
|
||||
.ui-container {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
left: 30%;
|
||||
transform: translateX(-70%);
|
||||
z-index: 1000;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
@@ -1020,3 +1020,268 @@ body.right-panel-open .auth-widget {
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
DASHBOARD STYLES
|
||||
============================================================ */
|
||||
|
||||
#dashboardModal .modal-content {
|
||||
width: min(920px, 96vw);
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#dashboardModal .modal-header {
|
||||
background: linear-gradient(135deg, #1e1b4b 0%, #312e81 50%, #4338ca 100%);
|
||||
border-radius: 12px 12px 0 0;
|
||||
padding: 18px 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#dashboardModal .modal-header h3 {
|
||||
color: white;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
#dashboardModal .modal-close {
|
||||
color: rgba(255,255,255,0.7);
|
||||
font-size: 22px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
#dashboardModal .modal-close:hover { color: white; }
|
||||
|
||||
#dashboardBody {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.db-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
padding: 60px 0;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.db-spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 3px solid #e2e8f0;
|
||||
border-top-color: #6366f1;
|
||||
border-radius: 50%;
|
||||
animation: dbSpin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes dbSpin { to { transform: rotate(360deg); } }
|
||||
|
||||
.db-error {
|
||||
text-align: center;
|
||||
color: #ef4444;
|
||||
padding: 40px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.db-ibadah-header {
|
||||
background: linear-gradient(135deg, #312e81, #4338ca);
|
||||
border-radius: 14px;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
color: white;
|
||||
box-shadow: 0 4px 20px rgba(67,56,202,0.3);
|
||||
}
|
||||
|
||||
.db-ibadah-emoji { font-size: 36px; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2)); }
|
||||
.db-ibadah-name { font-size: 18px; font-weight: 700; }
|
||||
.db-ibadah-meta { font-size: 12px; color: rgba(255,255,255,0.75); margin-top: 3px; }
|
||||
|
||||
.db-kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.db-kpi-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 18px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.db-kpi-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 4px;
|
||||
border-radius: 16px 16px 0 0;
|
||||
}
|
||||
|
||||
.kpi-ibadah::before { background: linear-gradient(90deg, #f97316, #ea580c); }
|
||||
.kpi-miskin::before { background: linear-gradient(90deg, #ef4444, #dc2626); }
|
||||
.kpi-jiwa::before { background: linear-gradient(90deg, #6366f1, #4f46e5); }
|
||||
.kpi-log::before { background: linear-gradient(90deg, #22c55e, #16a34a); }
|
||||
|
||||
.db-kpi-card:hover { transform: translateY(-3px); box-shadow: 0 8px 24px rgba(0,0,0,0.1); }
|
||||
|
||||
.db-kpi-icon { font-size: 28px; line-height: 1; margin-bottom: 2px; }
|
||||
.db-kpi-val { font-size: 32px; font-weight: 800; color: #1e1e2e; line-height: 1; }
|
||||
.db-kpi-label { font-size: 11px; font-weight: 600; color: #6b7280; text-align: center; text-transform: uppercase; letter-spacing: 0.4px; }
|
||||
|
||||
.db-section {
|
||||
background: white;
|
||||
border-radius: 14px;
|
||||
padding: 16px 18px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.db-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #374151;
|
||||
margin-bottom: 12px;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.db-coverage-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.db-progress-bar {
|
||||
height: 12px;
|
||||
background: #fee2e2;
|
||||
border-radius: 99px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.db-progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 99px;
|
||||
transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.db-charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.db-chart-card {
|
||||
background: white;
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.db-chart-wide { grid-column: 1 / -1; }
|
||||
|
||||
.db-chart-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #374151;
|
||||
margin-bottom: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
.db-chart-card canvas { width: 100% !important; display: block; }
|
||||
|
||||
.db-log-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.db-log-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: #f8fafc;
|
||||
border-radius: 10px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.db-log-item:hover { background: #f1f5f9; }
|
||||
|
||||
.db-log-dot {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #6366f1;
|
||||
flex-shrink: 0;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.db-log-info { flex: 1; min-width: 0; }
|
||||
|
||||
.db-log-main {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #1e1e2e;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.db-log-tipe {
|
||||
background: #ede9fe;
|
||||
color: #4f46e5;
|
||||
padding: 1px 7px;
|
||||
border-radius: 99px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.db-log-kk { color: #374151; }
|
||||
|
||||
.db-log-meta { font-size: 10px; color: #9ca3af; margin-top: 3px; }
|
||||
|
||||
.db-table-wrap {
|
||||
overflow-x: auto;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.db-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.db-table thead tr { background: #f8fafc; }
|
||||
.db-table th { padding: 10px 14px; text-align: left; font-size: 11px; font-weight: 700; color: #6b7280; text-transform: uppercase; letter-spacing: 0.4px; border-bottom: 1px solid #e5e7eb; }
|
||||
.db-table td { padding: 10px 14px; color: #374151; border-bottom: 1px solid #f3f4f6; }
|
||||
.db-table tbody tr:last-child td { border-bottom: none; }
|
||||
.db-table tbody tr:hover td { background: #f8fafc; }
|
||||
|
||||
.db-badge {
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: white;
|
||||
padding: 2px 9px;
|
||||
border-radius: 99px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.db-empty { text-align: center; color: #aaa; font-size: 13px; padding: 20px; }
|
||||
|
||||
@@ -52,6 +52,8 @@
|
||||
function updateAuthUI() {
|
||||
if (!authWidget) return;
|
||||
|
||||
const dashboardBtn = document.getElementById('menuDashboard');
|
||||
|
||||
if (window.currentUser) {
|
||||
// Logged In state
|
||||
const roleLabel = window.currentUser.role === 'admin' ? 'Admin' : 'Pengelola';
|
||||
@@ -71,6 +73,9 @@
|
||||
|
||||
document.getElementById('authLogoutBtn').addEventListener('click', logout);
|
||||
|
||||
// Show Dashboard button for all logged-in users
|
||||
if (dashboardBtn) dashboardBtn.style.display = 'flex';
|
||||
|
||||
// Show user management button in sidebar for Admin
|
||||
const adminDivider = document.getElementById('sidebarAdminDivider');
|
||||
if (window.currentUser.role === 'admin') {
|
||||
@@ -87,6 +92,7 @@
|
||||
`;
|
||||
document.getElementById('authLoginBtn').addEventListener('click', showLoginModal);
|
||||
if (menuUsersBtn) menuUsersBtn.style.display = 'none';
|
||||
if (dashboardBtn) dashboardBtn.style.display = 'none';
|
||||
const adminDivider = document.getElementById('sidebarAdminDivider');
|
||||
if (adminDivider) adminDivider.style.display = 'none';
|
||||
}
|
||||
@@ -177,6 +183,14 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Dashboard Button
|
||||
const menuDashboardBtn = document.getElementById('menuDashboard');
|
||||
if (menuDashboardBtn) {
|
||||
menuDashboardBtn.addEventListener('click', function() {
|
||||
if (typeof window.openDashboard === 'function') window.openDashboard();
|
||||
});
|
||||
}
|
||||
|
||||
if (closeUserManagementModal) {
|
||||
closeUserManagementModal.addEventListener('click', function() {
|
||||
userManagementModal.classList.remove('show');
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
// ===== 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,'"');
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -36,6 +36,18 @@ function makeMiskinIcon(inRadius) {
|
||||
let ibadahDataList = [];
|
||||
let miskinMarkerList = []; // Simpan referensi marker untuk update warna
|
||||
|
||||
// Helper: cek apakah koordinat (lat, lng) berada dalam radius ibadah pengelola yang login
|
||||
function isInMyIbadahRadius(lat, lng) {
|
||||
if (!window.currentUser || window.currentUser.role !== 'pengelola') return true; // admin selalu bisa
|
||||
const myIbadahId = window.currentUser.ibadah_id;
|
||||
if (!myIbadahId) return false;
|
||||
const ib = ibadahDataList.find(i => i.id == myIbadahId);
|
||||
if (!ib) return false;
|
||||
const dist = L.latLng(lat, lng).distanceTo(L.latLng(ib.lat, ib.lng));
|
||||
return dist <= ib.radius;
|
||||
}
|
||||
window.isInMyIbadahRadius = isInMyIbadahRadius;
|
||||
|
||||
let isResizing = false;
|
||||
let resizingCircle = null;
|
||||
let resizingIbadah = null;
|
||||
@@ -177,8 +189,22 @@ function addIbadahMarker(item) {
|
||||
// Konteks Menu untuk Tambah Penduduk Miskin (klik kanan peta)
|
||||
map.on('contextmenu', function(e) {
|
||||
|
||||
const canManage = !!(window.currentUser && (window.currentUser.role === 'admin' || window.currentUser.role === 'pengelola'));
|
||||
if (!canManage) return;
|
||||
const isAdminOrPengelola = !!(window.currentUser && (window.currentUser.role === 'admin' || window.currentUser.role === 'pengelola'));
|
||||
if (!isAdminOrPengelola) return;
|
||||
|
||||
const canAddHere = isInMyIbadahRadius(e.latlng.lat, e.latlng.lng);
|
||||
|
||||
if (!canAddHere) {
|
||||
// Pengelola: lokasi di luar radius ibadahnya
|
||||
const warnContent = `
|
||||
<div class="popup-form">
|
||||
<h4>⚠️ Di Luar Radius</h4>
|
||||
<p style="font-size:12px; color:#e11d48; margin:4px 0 0;">Anda hanya dapat menambah penduduk miskin di dalam radius rumah ibadah yang Anda kelola.</p>
|
||||
</div>
|
||||
`;
|
||||
L.popup().setLatLng(e.latlng).setContent(warnContent).openOn(map);
|
||||
return;
|
||||
}
|
||||
|
||||
const popupContent = `
|
||||
<div class="popup-form">
|
||||
@@ -397,7 +423,9 @@ function addMiskinMarker(item) {
|
||||
const lng = parseFloat(item.lng);
|
||||
if (isNaN(lat) || isNaN(lng)) return;
|
||||
|
||||
const canManage = !!(window.currentUser && (window.currentUser.role === 'admin' || window.currentUser.role === 'pengelola'));
|
||||
// Pengelola hanya bisa manage marker yang ada di dalam radius ibadahnya
|
||||
const isAdminOrPengelola = !!(window.currentUser && (window.currentUser.role === 'admin' || window.currentUser.role === 'pengelola'));
|
||||
const canManage = isAdminOrPengelola && isInMyIbadahRadius(lat, lng);
|
||||
|
||||
const marker = L.marker([lat, lng], { icon: makeMiskinIcon(false), draggable: canManage });
|
||||
marker.miskinData = item;
|
||||
@@ -475,8 +503,8 @@ window.formAddMiskin = function(lat, lng) {
|
||||
<label>Lat: ${lat.toFixed(6)}, Lng: ${lng.toFixed(6)}</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Nama Penduduk</label>
|
||||
<input type="text" id="miskinNama" placeholder="Nama Lengkap">
|
||||
<label>Nama Kepala Keluarga</label>
|
||||
<input type="text" id="miskinNama" placeholder="Nama Kepala Keluarga">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Kategori Bantuan</label>
|
||||
@@ -510,7 +538,7 @@ window.saveNewMiskin = function(lat, lng) {
|
||||
const fotoRumahInput = document.getElementById('miskinFotoRumah');
|
||||
const fotoKKInput = document.getElementById('miskinFotoKK');
|
||||
|
||||
if (!nama) { alert('Nama harus diisi!'); return; }
|
||||
if (!nama) { alert('Nama kepala keluarga harus diisi!'); return; }
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('nama', nama);
|
||||
@@ -543,7 +571,7 @@ window.openEditMiskinModal = function(id) {
|
||||
|
||||
const bodyHTML = `
|
||||
<div class="form-group">
|
||||
<label>Nama Penduduk</label>
|
||||
<label>Nama Kepala Keluarga</label>
|
||||
<input type="text" id="editMiskinNama" value="${d.nama}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -679,7 +707,7 @@ window.openImportMiskinModal = function() {
|
||||
<table style="width:100%; border-collapse:collapse; font-size:11px; text-align:left;">
|
||||
<thead>
|
||||
<tr style="background:#f1f5f9; border-bottom:1px solid #cbd5e1; color:#475569; position: sticky; top: 0;">
|
||||
<th style="padding:6px 8px;">Nama</th>
|
||||
<th style="padding:6px 8px;">Kepala Keluarga</th>
|
||||
<th style="padding:6px 8px;">Kategori</th>
|
||||
<th style="padding:6px 8px;">Jiwa</th>
|
||||
<th style="padding:6px 8px;">Koordinat</th>
|
||||
@@ -845,7 +873,7 @@ function handleImportFileChange(e) {
|
||||
parsedImportData = validRows;
|
||||
|
||||
if (validRows.length === 0) {
|
||||
errorMsg.textContent = "Tidak ditemukan data valid (pastikan kolom nama, lat, dan lng terisi).";
|
||||
errorMsg.textContent = "Tidak ditemukan data valid (pastikan kolom nama_kk/nama, lat, dan lng terisi).";
|
||||
errorMsg.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
+15
-3
@@ -165,9 +165,13 @@
|
||||
rightPanelTitle.textContent = '🏠 Penduduk Miskin';
|
||||
const canManage = !!(window.currentUser && (window.currentUser.role === 'admin' || window.currentUser.role === 'pengelola'));
|
||||
if (canManage) {
|
||||
const isPengelola = window.currentUser.role === 'pengelola';
|
||||
const addLabel = isPengelola
|
||||
? '<i class="fas fa-plus"></i> Tambah <span style="font-size:10px;opacity:0.8;">(dalam radius)</span>'
|
||||
: '<i class="fas fa-plus"></i> Tambah';
|
||||
rightPanelActions.innerHTML = `
|
||||
<button class="btn-panel-add" id="btnPanelTambahMiskin">
|
||||
<i class="fas fa-plus"></i> Tambah
|
||||
${addLabel}
|
||||
</button>
|
||||
<button class="btn-panel-import" id="btnPanelImportMiskin">
|
||||
<i class="fas fa-file-import"></i> Impor
|
||||
@@ -177,7 +181,10 @@
|
||||
window.currentAddMode = 'miskin_click';
|
||||
document.getElementById('map').style.cursor = 'crosshair';
|
||||
if (window.cursorTooltip) {
|
||||
window.cursorTooltip.textContent = 'Klik di peta untuk tentukan lokasi penduduk miskin';
|
||||
const tipText = isPengelola
|
||||
? 'Klik di dalam radius ibadah Anda untuk tambah penduduk'
|
||||
: 'Klik di peta untuk tentukan lokasi penduduk miskin';
|
||||
window.cursorTooltip.textContent = tipText;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -232,7 +239,11 @@
|
||||
}
|
||||
|
||||
function buildMiskinCard(d, terurus) {
|
||||
const canManage = !!(window.currentUser && (window.currentUser.role === 'admin' || window.currentUser.role === 'pengelola'));
|
||||
// Pengelola hanya boleh edit/hapus penduduk yang dalam radius ibadahnya
|
||||
const isPengelolaOrAdmin = !!(window.currentUser && (window.currentUser.role === 'admin' || window.currentUser.role === 'pengelola'));
|
||||
const canManage = isPengelolaOrAdmin && (typeof window.isInMyIbadahRadius === 'function'
|
||||
? window.isInMyIbadahRadius(d.lat, d.lng)
|
||||
: true);
|
||||
const iconCls = terurus ? 'card-icon-miskin-in' : 'card-icon-miskin-out';
|
||||
const badge = d.kategori_bantuan === 'Makan'
|
||||
? '<span class="data-card-badge badge-orange">Bantuan Makan</span>'
|
||||
@@ -287,6 +298,7 @@
|
||||
${actionButtons}
|
||||
</div>
|
||||
<div class="data-card-expand">
|
||||
<div class="expand-row"><span>Kepala Keluarga</span><span>${escHtml(d.nama)}</span></div>
|
||||
<div class="expand-row"><span>Kategori Bantuan</span><span>${escHtml(d.kategori_bantuan)}</span></div>
|
||||
<div class="expand-row"><span>Jumlah Jiwa</span><span>${d.jumlah_jiwa || '-'}</span></div>
|
||||
<div class="expand-row"><span>Status</span><span>${terurus ? 'Terurus ✅' : 'Tidak Terurus ⚠️'}</span></div>
|
||||
|
||||
@@ -61,6 +61,10 @@
|
||||
<span class="sidebar-emoji">🏠</span>
|
||||
<span class="sidebar-label">Miskin</span>
|
||||
</button>
|
||||
<button class="sidebar-btn" id="menuDashboard" style="display:none;" title="Dashboard">
|
||||
<span class="sidebar-emoji">📊</span>
|
||||
<span class="sidebar-label">Dashboard</span>
|
||||
</button>
|
||||
|
||||
<div class="sidebar-divider" id="sidebarAdminDivider" style="display:none;"></div>
|
||||
<button class="sidebar-btn" id="menuUsers" style="display:none;" title="Manajemen Pengguna">
|
||||
@@ -254,6 +258,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Modal -->
|
||||
<div id="dashboardModal" class="unified-modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>📊 Dashboard Informasi</h3>
|
||||
<span class="modal-close" id="closeDashboardModal">×</span>
|
||||
</div>
|
||||
<div id="dashboardBody"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Management Modal -->
|
||||
<div id="userManagementModal" class="unified-modal">
|
||||
<div class="modal-content" style="width: 700px; max-width: 95%;">
|
||||
@@ -346,7 +361,7 @@
|
||||
<div style="flex:1;">
|
||||
<div style="font-size: 13px; font-weight: 700; color: #fbbf24; margin-bottom: 4px; letter-spacing: 0.3px;">DISCLAIMER — DATA DUMMY</div>
|
||||
<div style="font-size: 12px; line-height: 1.6; color: #cbd5e1;">
|
||||
Seluruh data pada aplikasi ini termasuk <strong style="color:#fde68a;">nama penduduk, foto rumah, dan foto Kartu Keluarga (KK)</strong> merupakan <strong style="color:#fde68a;">data fiktif / dummy</strong> yang hanya digunakan untuk keperluan demonstrasi dan pengujian sistem. Bukan data nyata.
|
||||
Seluruh data pada aplikasi ini termasuk <strong style="color:#fde68a;">nama kepala keluarga, foto rumah, dan foto Kartu Keluarga (KK)</strong> merupakan <strong style="color:#fde68a;">data fiktif / dummy</strong> yang hanya digunakan untuk keperluan demonstrasi dan pengujian sistem. Bukan data nyata.
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="document.getElementById('disclaimerBanner').style.display='none'" style="
|
||||
@@ -386,5 +401,8 @@
|
||||
<!-- Core Auth Feature -->
|
||||
<script src="assets/js/features/auth.js?v=<?= time() ?>"></script>
|
||||
|
||||
<!-- Dashboard Feature -->
|
||||
<script src="assets/js/features/dashboard.js?v=<?= time() ?>"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.3 MiB |
Reference in New Issue
Block a user