feat: complete WebGIS implementation with nominal bantuan and cache busting

This commit is contained in:
fananazril
2026-06-11 19:18:25 +07:00
parent 0d59e629bd
commit 0ae2768849
36 changed files with 7687 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
<?php
// ================================================================
// Auth Action — Proses Login
// ================================================================
require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/../config/database.php';
require_once __DIR__ . '/../includes/auth.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: ' . BASE_URL . '/admin/login.php');
exit;
}
$email = trim($_POST['email'] ?? '');
$password = $_POST['password'] ?? '';
if (empty($email) || empty($password)) {
header('Location: ' . BASE_URL . '/admin/login.php?error=empty');
exit;
}
$db = getDB();
$stmt = $db->prepare("SELECT * FROM users WHERE email = ? LIMIT 1");
$stmt->execute([$email]);
$user = $stmt->fetch();
if (!$user || !password_verify($password, $user['password'])) {
// Tambah sedikit delay untuk mencegah brute force
sleep(1);
header('Location: ' . BASE_URL . '/admin/login.php?error=invalid&email=' . urlencode($email));
exit;
}
loginUser($user);
header('Location: ' . BASE_URL . '/admin/');
exit;
+194
View File
@@ -0,0 +1,194 @@
<?php
// ================================================================
// Admin: Export Data (CSV / PDF)
// ================================================================
require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/../config/database.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/functions.php';
requireLogin();
$pageTitle = 'Export Data';
require_once __DIR__ . '/../includes/header.php';
$db = getDB();
$kecList = $db->query("SELECT DISTINCT kecamatan FROM penduduk_miskin WHERE kecamatan IS NOT NULL AND kecamatan != '' ORDER BY kecamatan")->fetchAll(PDO::FETCH_COLUMN);
$ibadahList = $db->query("SELECT id, nama, jenis FROM rumah_ibadah ORDER BY nama")->fetchAll();
?>
<!-- ── Page Header ─────────────────────────────────────────── -->
<div class="page-header">
<div class="page-title-block">
<h1><i class="bi bi-download"></i> Export Data</h1>
<div class="page-subtitle">Export data ke format CSV atau cetak PDF</div>
</div>
<div class="page-actions">
<a href="<?= BASE_URL ?>/admin/" class="btn btn-ghost btn-sm">
<i class="bi bi-arrow-left"></i> Kembali
</a>
</div>
</div>
<!-- ── Export Cards ───────────────────────────────────────── -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:24px;">
<!-- Penduduk Miskin Export -->
<div class="card">
<div class="card-header">
<div class="card-title">👤 Export Penduduk Miskin</div>
</div>
<div class="card-body" style="padding:20px;">
<div class="form-group">
<label class="form-label">Status Bantuan</label>
<select class="form-control" id="exp-status">
<option value="">Semua Status</option>
<option value="belum">❌ Belum Dibantu</option>
<option value="menunggu">⏳ Menunggu Verifikasi</option>
<option value="sudah">✅ Sudah Dibantu</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Kecamatan</label>
<select class="form-control" id="exp-kecamatan">
<option value="">Semua Kecamatan</option>
<?php foreach ($kecList as $k): ?>
<option value="<?= htmlspecialchars($k) ?>"><?= htmlspecialchars($k) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">Rumah Ibadah</label>
<select class="form-control" id="exp-ibadah">
<option value="">Semua Rumah Ibadah</option>
<?php foreach ($ibadahList as $ib): ?>
<option value="<?= $ib['id'] ?>"><?= htmlspecialchars($ib['nama']) ?> (<?= $ib['jenis'] ?>)</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-row" style="margin-top:8px;">
<div class="form-group">
<label class="form-label">Dari Tanggal</label>
<input class="form-control" type="date" id="exp-dari">
</div>
<div class="form-group">
<label class="form-label">Sampai Tanggal</label>
<input class="form-control" type="date" id="exp-sampai">
</div>
</div>
<div style="display:flex;gap:10px;margin-top:16px;flex-wrap:wrap;">
<button class="btn btn-success btn-sm" onclick="doExport('penduduk','csv')">
<i class="bi bi-file-earmark-spreadsheet"></i> Export CSV
</button>
<button class="btn btn-outline-accent btn-sm" onclick="doExport('penduduk','print')">
<i class="bi bi-printer"></i> Cetak / PDF
</button>
</div>
</div>
</div>
<!-- Rumah Ibadah Export -->
<div class="card">
<div class="card-header">
<div class="card-title">🏛️ Export Rumah Ibadah</div>
</div>
<div class="card-body" style="padding:20px;">
<div class="form-group">
<label class="form-label">Jenis Ibadah</label>
<select class="form-control" id="exp-jenis-ib">
<option value="">Semua Jenis</option>
<?php foreach (array_keys(JENIS_IBADAH) as $j): ?>
<option value="<?= $j ?>"><?= JENIS_EMOJI[$j]??'' ?> <?= $j ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">Kecamatan</label>
<select class="form-control" id="exp-kec-ib">
<option value="">Semua Kecamatan</option>
<?php
$kecIb = $db->query("SELECT DISTINCT kecamatan FROM rumah_ibadah WHERE kecamatan IS NOT NULL AND kecamatan != '' ORDER BY kecamatan")->fetchAll(PDO::FETCH_COLUMN);
foreach ($kecIb as $k): ?>
<option value="<?= htmlspecialchars($k) ?>"><?= htmlspecialchars($k) ?></option>
<?php endforeach; ?>
</select>
</div>
<div style="margin-top:14px;padding:14px;background:rgba(91,127,255,0.06);border-radius:8px;border:1px solid rgba(91,127,255,0.15);">
<div style="font-size:12px;color:var(--text2);">
<i class="bi bi-info-circle"></i> Export akan mencakup: Nama, Jenis, Kontak, Alamat, Koordinat, Radius, Jumlah Binaan
</div>
</div>
<div style="display:flex;gap:10px;margin-top:16px;flex-wrap:wrap;">
<button class="btn btn-success btn-sm" onclick="doExport('ibadah','csv')">
<i class="bi bi-file-earmark-spreadsheet"></i> Export CSV
</button>
<button class="btn btn-outline-accent btn-sm" onclick="doExport('ibadah','print')">
<i class="bi bi-printer"></i> Cetak / PDF
</button>
</div>
</div>
</div>
</div>
<!-- ── Recent Exports / Quick Stats ───────────────────────── -->
<div class="card">
<div class="card-header">
<div class="card-title">📊 Ringkasan Data</div>
</div>
<div class="card-body" style="padding:16px;">
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;">
<?php
$counts = [
'Total KK' => $db->query("SELECT COUNT(*) FROM penduduk_miskin")->fetchColumn(),
'Sudah Dibantu' => $db->query("SELECT COUNT(*) FROM penduduk_miskin WHERE status_bantuan='sudah'")->fetchColumn(),
'Belum Dibantu' => $db->query("SELECT COUNT(*) FROM penduduk_miskin WHERE status_bantuan='belum'")->fetchColumn(),
'Menunggu' => $db->query("SELECT COUNT(*) FROM penduduk_miskin WHERE status_bantuan='menunggu'")->fetchColumn(),
'Total Jiwa' => $db->query("SELECT COALESCE(SUM(jumlah_anggota),0) FROM penduduk_miskin")->fetchColumn(),
'Rumah Ibadah' => $db->query("SELECT COUNT(*) FROM rumah_ibadah")->fetchColumn(),
];
$colors = ['#5b7fff','#22c55e','#ef4444','#f59e0b','#a78bfa','#34d399'];
$i = 0;
foreach ($counts as $label => $val):
$c = $colors[$i++];
?>
<div style="background:<?= $c ?>18;border:1px solid <?= $c ?>33;border-radius:10px;padding:14px;text-align:center;">
<div style="font-size:22px;font-weight:800;color:<?= $c ?>"><?= number_format($val) ?></div>
<div style="font-size:11px;color:var(--text2);margin-top:2px;"><?= $label ?></div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<script>
const BASE = '<?= BASE_URL ?>';
function doExport(type, format) {
let params = new URLSearchParams({ type, format });
if (type === 'penduduk') {
const status = document.getElementById('exp-status').value;
const kec = document.getElementById('exp-kecamatan').value;
const ibadah = document.getElementById('exp-ibadah').value;
const dari = document.getElementById('exp-dari').value;
const sampai = document.getElementById('exp-sampai').value;
if (status) params.set('status', status);
if (kec) params.set('kecamatan', kec);
if (ibadah) params.set('ibadah_id', ibadah);
if (dari) params.set('dari', dari);
if (sampai) params.set('sampai', sampai);
} else {
const jenis = document.getElementById('exp-jenis-ib').value;
const kec = document.getElementById('exp-kec-ib').value;
if (jenis) params.set('jenis', jenis);
if (kec) params.set('kecamatan', kec);
}
if (format === 'csv') {
window.open(`${BASE}/api/export.php?${params}`, '_blank');
} else {
window.open(`${BASE}/api/export.php?${params}`, '_blank');
}
}
</script>
<?php require_once __DIR__ . '/../includes/footer.php'; ?>
+364
View File
@@ -0,0 +1,364 @@
<?php
// ================================================================
// Admin: Import Data Massal (CSV)
// ================================================================
require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/../config/database.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/functions.php';
requireLogin();
$pageTitle = 'Import Data';
require_once __DIR__ . '/../includes/header.php';
$db = getDB();
$ibadahList = $db->query("SELECT id, nama, jenis FROM rumah_ibadah ORDER BY nama")->fetchAll();
?>
<!-- ── Page Header ─────────────────────────────────────────── -->
<div class="page-header">
<div class="page-title-block">
<h1><i class="bi bi-upload"></i> Import Data</h1>
<div class="page-subtitle">Import massal data penduduk miskin atau rumah ibadah via CSV</div>
</div>
<div class="page-actions">
<a href="<?= BASE_URL ?>/admin/" class="btn btn-ghost btn-sm">
<i class="bi bi-arrow-left"></i> Kembali
</a>
</div>
</div>
<!-- ── Info Cards ─────────────────────────────────────────── -->
<div class="stats-row" style="margin-bottom:24px;">
<div class="stat-card blue" style="cursor:pointer;" onclick="setImportType('penduduk')">
<div class="stat-icon">👤</div>
<div class="stat-value" style="font-size:16px;">Penduduk Miskin</div>
<div class="stat-label">Import data KK</div>
</div>
<div class="stat-card green" style="cursor:pointer;" onclick="setImportType('ibadah')">
<div class="stat-icon">🏛️</div>
<div class="stat-value" style="font-size:16px;">Rumah Ibadah</div>
<div class="stat-label">Import data ibadah</div>
</div>
</div>
<!-- ── Import Card ─────────────────────────────────────────── -->
<div class="card" style="margin-bottom:20px;">
<div class="card-header">
<div class="card-title" id="import-type-title">📤 Pilih Jenis Import</div>
</div>
<div class="card-body" style="padding:24px;">
<!-- Type Selector -->
<div style="display:flex;gap:12px;margin-bottom:24px;flex-wrap:wrap;">
<button class="btn btn-primary btn-sm import-type-btn active" id="btn-type-penduduk" onclick="setImportType('penduduk')">
<i class="bi bi-people-fill"></i> Penduduk Miskin
</button>
<button class="btn btn-ghost btn-sm import-type-btn" id="btn-type-ibadah" onclick="setImportType('ibadah')">
<i class="bi bi-building"></i> Rumah Ibadah
</button>
</div>
<!-- Format Kolom Guide -->
<div id="format-penduduk">
<div class="card" style="background:rgba(91,127,255,0.06);border:1px solid rgba(91,127,255,0.2);margin-bottom:20px;">
<div class="card-body" style="padding:16px;">
<div style="font-weight:700;color:var(--accent);margin-bottom:10px;font-size:13px;">
<i class="bi bi-info-circle-fill"></i> Format CSV Penduduk Miskin
</div>
<div style="font-size:12px;color:var(--text2);line-height:1.8;">
Kolom (urutan wajib): <code>kk_nama, nik, jumlah_anggota, alamat, kelurahan, kecamatan, lat, lng, status_bantuan, jenis_bantuan, tanggal_bantuan, nominal_bantuan</code>
</div>
<div style="margin-top:10px;">
<strong style="font-size:12px;">Contoh baris:</strong><br>
<code style="font-size:11px;display:block;margin-top:4px;padding:8px;background:rgba(0,0,0,0.2);border-radius:6px;">
Ahmad Sulaiman,6171012345678901,4,Jl. Veteran No. 12,Akcaya,Pontianak Selatan,-0.0430,109.3340,belum,
</code>
</div>
<div style="margin-top:10px;font-size:11px;color:var(--text3);">
⚠️ status_bantuan: belum / sudah / menunggu &nbsp;|&nbsp; Baris pertama bisa berupa header (akan dideteksi otomatis)
</div>
<a href="<?= BASE_URL ?>/admin/template_penduduk.csv" class="btn btn-ghost btn-sm" style="margin-top:12px;" onclick="downloadTemplate('penduduk');return false;">
<i class="bi bi-download"></i> Download Template CSV
</a>
</div>
</div>
</div>
<div id="format-ibadah" style="display:none;">
<div class="card" style="background:rgba(34,197,94,0.06);border:1px solid rgba(34,197,94,0.2);margin-bottom:20px;">
<div class="card-body" style="padding:16px;">
<div style="font-weight:700;color:var(--green);margin-bottom:10px;font-size:13px;">
<i class="bi bi-info-circle-fill"></i> Format CSV Rumah Ibadah
</div>
<div style="font-size:12px;color:var(--text2);line-height:1.8;">
Kolom (urutan wajib): <code>nama, jenis, kontak, alamat, kelurahan, kecamatan, lat, lng, radius</code>
</div>
<div style="margin-top:10px;">
<strong style="font-size:12px;">Contoh baris:</strong><br>
<code style="font-size:11px;display:block;margin-top:4px;padding:8px;background:rgba(0,0,0,0.2);border-radius:6px;">
Masjid Al-Ikhlas,Masjid,0561-123456,Jl. Ahmad Yani,Akcaya,Pontianak Selatan,-0.0412,109.3325,300
</code>
</div>
<div style="margin-top:10px;font-size:11px;color:var(--text3);">
⚠️ jenis: Masjid / Musholla / Gereja / Katolik / Pura / Vihara / Klenteng / Lainnya
</div>
<a href="#" class="btn btn-ghost btn-sm" style="margin-top:12px;" onclick="downloadTemplate('ibadah');return false;">
<i class="bi bi-download"></i> Download Template CSV
</a>
</div>
</div>
</div>
<!-- Upload Form -->
<div id="upload-section">
<div class="form-group" style="margin-bottom:16px;">
<label class="form-label" style="font-weight:600;">Pilih File CSV <span style="color:var(--red)">*</span></label>
<div id="drop-zone" style="border:2px dashed var(--border);border-radius:12px;padding:32px;text-align:center;transition:all .3s;cursor:pointer;">
<div style="font-size:36px;margin-bottom:8px;">📂</div>
<div style="font-weight:600;margin-bottom:4px;">Drag & Drop file CSV di sini</div>
<div style="font-size:12px;color:var(--text3);margin-bottom:12px;">atau klik untuk browse file</div>
<input type="file" id="csv-file" accept=".csv,.txt" style="display:none;">
<button class="btn btn-outline-accent btn-sm" onclick="document.getElementById('csv-file').click()">
<i class="bi bi-folder2-open"></i> Browse File
</button>
</div>
<div id="file-info" style="display:none;margin-top:10px;padding:10px 14px;background:rgba(34,197,94,0.1);border:1px solid rgba(34,197,94,0.3);border-radius:8px;font-size:12px;">
<i class="bi bi-file-earmark-text" style="color:var(--green)"></i>
<span id="file-name"></span> — <span id="file-size"></span>
</div>
</div>
<div style="display:flex;gap:12px;flex-wrap:wrap;">
<button class="btn btn-outline-accent btn-sm" onclick="previewCSV()" id="btn-preview">
<i class="bi bi-eye"></i> Preview Data
</button>
<button class="btn btn-success btn-sm" onclick="doImport()" id="btn-import" disabled>
<i class="bi bi-cloud-upload"></i> Import Data
</button>
</div>
</div>
</div>
</div>
<!-- ── Preview Table ─────────────────────────────────────────── -->
<div id="preview-section" style="display:none;">
<div class="card">
<div class="card-header">
<div class="card-title">👁️ Preview Data (<span id="preview-count">0</span> baris)</div>
<div style="display:flex;gap:8px;">
<span id="preview-valid-badge" style="font-size:12px;color:var(--green);font-weight:700;"></span>
<span id="preview-err-badge" style="font-size:12px;color:var(--red);font-weight:700;"></span>
</div>
</div>
<div class="table-responsive" id="preview-table-wrap"></div>
</div>
</div>
<!-- ── Result Section ─────────────────────────────────────────── -->
<div id="result-section" style="display:none;margin-top:20px;">
<div class="card" id="result-card">
<div class="card-body" style="padding:24px;text-align:center;">
<div style="font-size:48px;margin-bottom:12px;" id="result-icon">✅</div>
<div style="font-size:18px;font-weight:700;margin-bottom:8px;" id="result-title">Import Selesai</div>
<div id="result-detail" style="font-size:14px;color:var(--text2);line-height:1.8;"></div>
<div style="display:flex;gap:12px;justify-content:center;margin-top:20px;flex-wrap:wrap;">
<button class="btn btn-ghost btn-sm" onclick="resetImport()"><i class="bi bi-arrow-repeat"></i> Import Lagi</button>
<a href="<?= BASE_URL ?>/admin/penduduk_miskin.php" class="btn btn-primary btn-sm" id="result-link">
<i class="bi bi-list-ul"></i> Lihat Data
</a>
</div>
</div>
</div>
</div>
<script>
const BASE = '<?= BASE_URL ?>';
let currentType = 'penduduk';
let parsedRows = [];
let hasHeader = false;
// ── Type Selector ──────────────────────────────────────────────
function setImportType(type) {
currentType = type;
document.getElementById('format-penduduk').style.display = type === 'penduduk' ? 'block' : 'none';
document.getElementById('format-ibadah').style.display = type === 'ibadah' ? 'block' : 'none';
document.getElementById('btn-type-penduduk').className = 'btn btn-sm import-type-btn ' + (type === 'penduduk' ? 'btn-primary' : 'btn-ghost');
document.getElementById('btn-type-ibadah').className = 'btn btn-sm import-type-btn ' + (type === 'ibadah' ? 'btn-primary' : 'btn-ghost');
document.getElementById('import-type-title').textContent = type === 'penduduk' ? '👤 Import Penduduk Miskin' : '🏛️ Import Rumah Ibadah';
resetPreview();
}
// ── File Input ─────────────────────────────────────────────────
const dropZone = document.getElementById('drop-zone');
const csvFile = document.getElementById('csv-file');
dropZone.addEventListener('click', () => csvFile.click());
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.style.borderColor = 'var(--accent)'; });
dropZone.addEventListener('dragleave', () => { dropZone.style.borderColor = 'var(--border)'; });
dropZone.addEventListener('drop', e => {
e.preventDefault(); dropZone.style.borderColor = 'var(--border)';
const f = e.dataTransfer.files[0]; if (!f) return;
csvFile.files = e.dataTransfer.files; showFileInfo(f);
});
csvFile.addEventListener('change', () => { if (csvFile.files[0]) showFileInfo(csvFile.files[0]); });
function showFileInfo(f) {
document.getElementById('file-info').style.display = 'block';
document.getElementById('file-name').textContent = f.name;
document.getElementById('file-size').textContent = (f.size / 1024).toFixed(1) + ' KB';
resetPreview();
}
// ── CSV Parser ─────────────────────────────────────────────────
function parseCSV(text) {
const lines = text.split(/\r?\n/).filter(l => l.trim());
return lines.map(line => {
const cols = [];
let cur = '', inQ = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (ch === '"') { inQ = !inQ; }
else if (ch === ',' && !inQ) { cols.push(cur.trim()); cur = ''; }
else { cur += ch; }
}
cols.push(cur.trim());
return cols;
});
}
// ── Preview ────────────────────────────────────────────────────
async function previewCSV() {
const f = csvFile.files[0];
if (!f) { Swal.fire('Error', 'Pilih file CSV terlebih dahulu!', 'error'); return; }
const text = await f.text();
const rows = parseCSV(text);
if (!rows.length) { Swal.fire('Error', 'File kosong atau format tidak valid', 'error'); return; }
const COLS_P = ['kk_nama','nik','jumlah_anggota','alamat','kelurahan','kecamatan','lat','lng','status_bantuan','jenis_bantuan','tanggal_bantuan','nominal_bantuan'];
const COLS_I = ['nama','jenis','kontak','alamat','kelurahan','kecamatan','lat','lng','radius'];
const expectedCols = currentType === 'penduduk' ? COLS_P : COLS_I;
// Deteksi header
const first = rows[0].join(',').toLowerCase();
hasHeader = first.includes('nama') || first.includes('kk_nama') || first.includes('lat');
parsedRows = hasHeader ? rows.slice(1) : rows;
// Build preview table
let valid = 0, errors = 0;
let html = '<table class="admin-table"><thead><tr>';
expectedCols.forEach(c => html += `<th>${c}</th>`);
html += '<th>Status</th></tr></thead><tbody>';
parsedRows.slice(0, 50).forEach((row, idx) => {
const ok = row.length >= (currentType === 'penduduk' ? 8 : 8) && row[currentType === 'penduduk' ? 6 : 6] && row[currentType === 'penduduk' ? 7 : 7];
if (ok) valid++; else errors++;
html += `<tr style="${ok ? '' : 'background:rgba(239,68,68,0.08);'}">`;
for (let i = 0; i < expectedCols.length; i++) {
const val = row[i] || '';
html += `<td style="font-size:11px;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${val.replace(/"/g, '')}">${val || '<span style="color:var(--text3)">—</span>'}</td>`;
}
html += `<td>${ok ? '<span style="color:var(--green);font-size:11px;">✅ OK</span>' : '<span style="color:var(--red);font-size:11px;">❌ Error</span>'}</td></tr>`;
});
html += '</tbody></table>';
document.getElementById('preview-table-wrap').innerHTML = html;
document.getElementById('preview-count').textContent = parsedRows.length;
document.getElementById('preview-valid-badge').textContent = `✅ Valid: ${valid}`;
document.getElementById('preview-err-badge').textContent = errors ? `❌ Error: ${errors}` : '';
document.getElementById('preview-section').style.display = 'block';
document.getElementById('btn-import').disabled = valid === 0;
document.getElementById('result-section').style.display = 'none';
}
// ── Do Import ─────────────────────────────────────────────────
async function doImport() {
const f = csvFile.files[0];
if (!f || !parsedRows.length) return;
const res = await Swal.fire({
title: 'Mulai Import?',
html: `Import <b>${parsedRows.length}</b> baris data <b>${currentType === 'penduduk' ? 'Penduduk Miskin' : 'Rumah Ibadah'}</b>?`,
icon: 'question', showCancelButton: true,
confirmButtonText: 'Ya, Import!', cancelButtonText: 'Batal',
background: '#161b27', color: '#e8ecf4'
});
if (!res.isConfirmed) return;
document.getElementById('btn-import').disabled = true;
document.getElementById('btn-import').innerHTML = '<span class="spinner-border spinner-border-sm"></span> Mengimport...';
const fd = new FormData();
fd.append('file', f);
fd.append('type', currentType);
try {
const resp = await fetch(`${BASE}/api/import.php`, { method: 'POST', body: fd });
const json = await resp.json();
document.getElementById('result-section').style.display = 'block';
document.getElementById('preview-section').style.display = 'none';
if (json.success) {
document.getElementById('result-icon').textContent = '✅';
document.getElementById('result-title').textContent = 'Import Berhasil!';
document.getElementById('result-detail').innerHTML = `
<div style="display:flex;justify-content:center;gap:24px;flex-wrap:wrap;margin-top:10px;">
<div><span style="color:var(--green);font-size:24px;font-weight:800;">${json.imported}</span><br>Berhasil diimpor</div>
<div><span style="color:var(--red);font-size:24px;font-weight:800;">${json.failed}</span><br>Gagal</div>
<div><span style="color:var(--text2);font-size:24px;font-weight:800;">${json.skipped||0}</span><br>Dilewati</div>
</div>
${json.errors?.length ? '<div style="margin-top:14px;font-size:11px;color:var(--red);text-align:left;">'+json.errors.map(e=>`• ${e}`).join('<br>')+'</div>' : ''}`;
document.getElementById('result-link').href = currentType === 'penduduk'
? `${BASE}/admin/penduduk_miskin.php`
: `${BASE}/admin/rumah_ibadah.php`;
} else {
document.getElementById('result-icon').textContent = '❌';
document.getElementById('result-title').textContent = 'Import Gagal';
document.getElementById('result-detail').textContent = json.message || 'Terjadi kesalahan saat import.';
}
} catch (e) {
Swal.fire('Error', 'Gagal mengirim data ke server', 'error');
} finally {
document.getElementById('btn-import').disabled = false;
document.getElementById('btn-import').innerHTML = '<i class="bi bi-cloud-upload"></i> Import Data';
}
}
// ── Reset ─────────────────────────────────────────────────────
function resetPreview() {
parsedRows = [];
document.getElementById('preview-section').style.display = 'none';
document.getElementById('result-section').style.display = 'none';
document.getElementById('btn-import').disabled = true;
}
function resetImport() {
csvFile.value = '';
document.getElementById('file-info').style.display = 'none';
resetPreview();
}
// ── Download Template ──────────────────────────────────────────
function downloadTemplate(type) {
let content, filename;
if (type === 'penduduk') {
content = 'kk_nama,nik,jumlah_anggota,alamat,kelurahan,kecamatan,lat,lng,status_bantuan,jenis_bantuan,tanggal_bantuan,nominal_bantuan\r\n';
content += 'Ahmad Sulaiman,6171012345678901,4,Jl. Veteran No. 12,Akcaya,Pontianak Selatan,-0.0430,109.3340,belum,,,\r\n';
content += 'Budi Santoso,,3,Jl. Imam Bonjol No. 5,Dalam Bugis,Pontianak Kota,-0.0270,109.3440,sudah,Sembako,2026-06-10,500000\r\n';
filename = 'template_penduduk_miskin.csv';
} else {
content = 'nama,jenis,kontak,alamat,kelurahan,kecamatan,lat,lng,radius\r\n';
content += 'Masjid Al-Ikhlas,Masjid,0561-123456,Jl. Ahmad Yani,Akcaya,Pontianak Selatan,-0.0412,109.3325,300\r\n';
content += 'Gereja Bethel,Gereja,0561-654321,Jl. Diponegoro,Dalam Bugis,Pontianak Kota,-0.0280,109.3450,350\r\n';
filename = 'template_rumah_ibadah.csv';
}
const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
}
</script>
<?php require_once __DIR__ . '/../includes/footer.php'; ?>
+288
View File
@@ -0,0 +1,288 @@
<?php
// ================================================================
// Admin Dashboard
// ================================================================
require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/../config/database.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/functions.php';
requireLogin();
$pageTitle = 'Dashboard';
$extraHead = '<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>';
require_once __DIR__ . '/../includes/header.php';
$db = getDB();
// Statistik utama
$stats = [
'ibadah' => (int)$db->query("SELECT COUNT(*) FROM rumah_ibadah")->fetchColumn(),
'miskin' => (int)$db->query("SELECT COUNT(*) FROM penduduk_miskin")->fetchColumn(),
'sudah' => (int)$db->query("SELECT COUNT(*) FROM penduduk_miskin WHERE status_bantuan='sudah'")->fetchColumn(),
'belum' => (int)$db->query("SELECT COUNT(*) FROM penduduk_miskin WHERE status_bantuan='belum'")->fetchColumn(),
'menunggu' => (int)$db->query("SELECT COUNT(*) FROM penduduk_miskin WHERE status_bantuan='menunggu'")->fetchColumn(),
'anggota' => (int)$db->query("SELECT SUM(jumlah_anggota) FROM penduduk_miskin")->fetchColumn(),
'nominal' => (float)$db->query("SELECT COALESCE(SUM(nominal_bantuan),0) FROM penduduk_miskin WHERE status_bantuan='sudah' AND nominal_bantuan IS NOT NULL")->fetchColumn(),
];
// Per jenis ibadah
$jenisStats = $db->query("SELECT jenis, COUNT(*) AS total FROM rumah_ibadah GROUP BY jenis ORDER BY total DESC")->fetchAll();
// Per kecamatan (top 7)
$kecamatanStats = $db->query("
SELECT kecamatan, COUNT(*) AS total,
SUM(CASE WHEN status_bantuan='sudah' THEN 1 ELSE 0 END) AS sudah,
SUM(CASE WHEN status_bantuan='belum' THEN 1 ELSE 0 END) AS belum
FROM penduduk_miskin WHERE kecamatan IS NOT NULL AND kecamatan != ''
GROUP BY kecamatan ORDER BY total DESC LIMIT 7
")->fetchAll();
// Data terbaru 6 baris
$recentMiskin = $db->query("
SELECT pm.id, pm.kk_nama, pm.jumlah_anggota, pm.status_bantuan,
pm.kecamatan, pm.created_at, ri.nama AS ibadah_nama
FROM penduduk_miskin pm
LEFT JOIN rumah_ibadah ri ON pm.rumah_ibadah_id = ri.id
ORDER BY pm.created_at DESC LIMIT 6
")->fetchAll();
// Tren bulanan (6 bulan terakhir)
$trenBulan = $db->query("
SELECT DATE_FORMAT(created_at,'%Y-%m') AS bulan,
COUNT(*) AS total,
SUM(CASE WHEN status_bantuan='sudah' THEN 1 ELSE 0 END) AS sudah
FROM penduduk_miskin
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 6 MONTH)
GROUP BY bulan ORDER BY bulan ASC
")->fetchAll();
?>
<!-- ── Page Header ─────────────────────────────────────────── -->
<div class="page-header">
<div class="page-title-block">
<h1>📊 Dashboard</h1>
<div class="page-subtitle">Ringkasan data WebGIS Sebaran Penduduk Miskin · <?= date('d M Y, H:i') ?> WIB</div>
</div>
<div class="page-actions">
<a href="<?= BASE_URL ?>/admin/import.php" class="btn btn-ghost btn-sm">
<i class="bi bi-upload"></i> Import
</a>
<a href="<?= BASE_URL ?>/admin/export.php" class="btn btn-outline-accent btn-sm">
<i class="bi bi-download"></i> Export
</a>
<a href="<?= BASE_URL ?>/" target="_blank" class="btn btn-primary btn-sm">
<i class="bi bi-map-fill"></i> Buka Peta
</a>
</div>
</div>
<!-- ── Stat Cards ──────────────────────────────────────────── -->
<div class="stats-row">
<div class="stat-card blue">
<div class="stat-icon">🏛️</div>
<div class="stat-value"><?= $stats['ibadah'] ?></div>
<div class="stat-label">Rumah Ibadah</div>
</div>
<div class="stat-card yellow">
<div class="stat-icon">👥</div>
<div class="stat-value"><?= $stats['miskin'] ?></div>
<div class="stat-label">KK Terdaftar</div>
</div>
<div class="stat-card green">
<div class="stat-icon">✅</div>
<div class="stat-value"><?= $stats['sudah'] ?></div>
<div class="stat-label">Sudah Dibantu</div>
</div>
<div class="stat-card red">
<div class="stat-icon">❌</div>
<div class="stat-value"><?= $stats['belum'] ?></div>
<div class="stat-label">Belum Dibantu</div>
</div>
<div class="stat-card yellow" style="--yellow:#f59e0b">
<div class="stat-icon">⏳</div>
<div class="stat-value"><?= $stats['menunggu'] ?></div>
<div class="stat-label">Menunggu Verif.</div>
</div>
<div class="stat-card blue">
<div class="stat-icon">👤</div>
<div class="stat-value"><?= number_format($stats['anggota'], 0, ',', '.') ?></div>
<div class="stat-label">Total Jiwa</div>
</div>
<div class="stat-card green" style="grid-column: span 2; display:flex; flex-direction:row; justify-content:flex-start; align-items:center; gap:16px;">
<div class="stat-icon" style="margin-bottom:0; font-size:32px;">💰</div>
<div>
<div class="stat-value" style="font-size:20px; font-family:'JetBrains Mono',monospace;">Rp <?= number_format($stats['nominal'], 0, ',', '.') ?></div>
<div class="stat-label" style="text-align:left;">Total Bantuan Disalurkan</div>
</div>
</div>
</div>
<!-- ── Charts Row ──────────────────────────────────────────── -->
<div style="display:grid;grid-template-columns:1fr 1fr 1.2fr;gap:16px;margin-bottom:20px;">
<!-- Chart: Status Bantuan (Doughnut) -->
<div class="card">
<div class="card-header">
<div class="card-title">📊 Status Bantuan</div>
</div>
<div class="card-body" style="padding:14px;">
<div class="chart-container" style="height:200px;">
<canvas id="chart-status"></canvas>
</div>
<div style="display:flex;gap:8px;justify-content:center;flex-wrap:wrap;margin-top:10px;font-size:11px;">
<span style="color:#22c55e">● Sudah: <?= $stats['sudah'] ?></span>
<span style="color:#ef4444">● Belum: <?= $stats['belum'] ?></span>
<span style="color:#f59e0b">● Menunggu: <?= $stats['menunggu'] ?></span>
</div>
</div>
</div>
<!-- Chart: Per Jenis Ibadah (Pie) -->
<div class="card">
<div class="card-header">
<div class="card-title">🕌 Jenis Ibadah</div>
</div>
<div class="card-body" style="padding:14px;">
<div class="chart-container" style="height:200px;">
<canvas id="chart-jenis"></canvas>
</div>
</div>
</div>
<!-- Chart: Tren Bulanan (Line) -->
<div class="card">
<div class="card-header">
<div class="card-title">📈 Tren 6 Bulan</div>
</div>
<div class="card-body" style="padding:14px;">
<div class="chart-container" style="height:200px;">
<canvas id="chart-tren"></canvas>
</div>
</div>
</div>
</div>
<!-- ── Kecamatan + Recent ──────────────────────────────────── -->
<div style="display:grid;grid-template-columns:1fr 1.5fr;gap:16px;margin-bottom:20px;">
<!-- Per Kecamatan Bar -->
<div class="card">
<div class="card-header">
<div class="card-title">📍 Top Kecamatan</div>
</div>
<div class="card-body" style="padding:14px;">
<div class="chart-container" style="height:240px;">
<canvas id="chart-kecamatan"></canvas>
</div>
</div>
</div>
<!-- Data Terbaru -->
<div class="card">
<div class="card-header">
<div class="card-title">🕐 Data Terbaru</div>
<a href="<?= BASE_URL ?>/admin/penduduk_miskin.php" class="btn btn-ghost btn-sm">Lihat Semua</a>
</div>
<div class="table-responsive">
<table class="admin-table">
<thead>
<tr>
<th>Nama KK</th>
<th>Kecamatan</th>
<th>Status</th>
<th>Ibadah</th>
<th>Tanggal</th>
</tr>
</thead>
<tbody>
<?php foreach ($recentMiskin as $r): ?>
<tr>
<td><a href="<?= BASE_URL ?>/admin/penduduk_miskin.php?edit=<?= $r['id'] ?>" style="color:var(--text);font-weight:600;text-decoration:none;"><?= htmlspecialchars($r['kk_nama']) ?></a></td>
<td style="color:var(--text2);font-size:12px;"><?= htmlspecialchars($r['kecamatan'] ?: '—') ?></td>
<td><span class="badge-status badge-<?= $r['status_bantuan'] ?>"><?= ucfirst($r['status_bantuan']) ?></span></td>
<td style="font-size:11px;color:var(--text2);"><?= htmlspecialchars($r['ibadah_nama'] ?: '—') ?></td>
<td style="font-size:11px;color:var(--text3);"><?= formatTanggal($r['created_at']) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($recentMiskin)): ?>
<tr><td colspan="5" style="text-align:center;color:var(--text3);padding:24px;">Belum ada data</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
<!-- ── Chart.js Init ───────────────────────────────────────── -->
<script>
const cOpts = { responsive: true, maintainAspectRatio: false,
plugins: { legend: { labels: { color:'#8890b0', font:{ size:11 } } } } };
// Status Bantuan — Doughnut
new Chart(document.getElementById('chart-status').getContext('2d'), {
type: 'doughnut',
data: {
labels: ['Sudah Dibantu','Belum Dibantu','Menunggu'],
datasets: [{ data: [<?= $stats['sudah'] ?>,<?= $stats['belum'] ?>,<?= $stats['menunggu'] ?>],
backgroundColor: ['rgba(34,197,94,0.8)','rgba(239,68,68,0.8)','rgba(245,158,11,0.8)'],
borderColor: ['#161b27'], borderWidth: 2 }]
},
options: { ...cOpts, cutout:'65%' }
});
// Jenis Ibadah — Pie
const jenisLabels = <?= json_encode(array_column($jenisStats, 'jenis')) ?>;
const jenisData = <?= json_encode(array_column($jenisStats, 'total')) ?>;
const jenisColors = ['rgba(91,127,255,.8)','rgba(124,91,255,.8)','rgba(34,197,94,.8)','rgba(245,158,11,.8)','rgba(239,68,68,.8)','rgba(96,165,250,.8)','rgba(251,191,36,.8)','rgba(139,92,246,.8)'];
new Chart(document.getElementById('chart-jenis').getContext('2d'), {
type: 'pie',
data: { labels: jenisLabels, datasets: [{ data: jenisData,
backgroundColor: jenisColors.slice(0, jenisLabels.length),
borderColor: '#161b27', borderWidth: 2 }] },
options: { ...cOpts }
});
// Tren Bulanan — Line
const trenLabels = <?= json_encode(array_column($trenBulan,'bulan')) ?>;
const trenTotal = <?= json_encode(array_column($trenBulan,'total')) ?>;
const trenSudah = <?= json_encode(array_column($trenBulan,'sudah')) ?>;
new Chart(document.getElementById('chart-tren').getContext('2d'), {
type: 'line',
data: {
labels: trenLabels,
datasets: [
{ label:'Total KK', data: trenTotal, borderColor:'#5b7fff', backgroundColor:'rgba(91,127,255,0.1)', tension:0.4, fill:true, pointRadius:4 },
{ label:'Dibantu', data: trenSudah, borderColor:'#22c55e', backgroundColor:'rgba(34,197,94,0.07)', tension:0.4, fill:true, pointRadius:4 }
]
},
options: { ...cOpts, scales: {
x: { ticks:{ color:'#565e80', font:{size:10} }, grid:{color:'rgba(255,255,255,0.04)'} },
y: { ticks:{ color:'#565e80', font:{size:10} }, grid:{color:'rgba(255,255,255,0.04)'} }
}}
});
// Kecamatan — Bar
const kecLabels = <?= json_encode(array_column($kecamatanStats,'kecamatan')) ?>;
const kecTotal = <?= json_encode(array_column($kecamatanStats,'total')) ?>;
const kecSudah = <?= json_encode(array_column($kecamatanStats,'sudah')) ?>;
const kecBelum = <?= json_encode(array_column($kecamatanStats,'belum')) ?>;
new Chart(document.getElementById('chart-kecamatan').getContext('2d'), {
type: 'bar',
data: {
labels: kecLabels,
datasets: [
{ label:'Sudah', data: kecSudah, backgroundColor:'rgba(34,197,94,0.75)', borderRadius:4 },
{ label:'Belum', data: kecBelum, backgroundColor:'rgba(239,68,68,0.7)', borderRadius:4 }
]
},
options: { ...cOpts, plugins:{...cOpts.plugins, legend:{...cOpts.plugins.legend}},
scales:{
x:{ ticks:{color:'#565e80',font:{size:10}}, grid:{color:'rgba(255,255,255,0.04)'}, stacked:true },
y:{ ticks:{color:'#565e80',font:{size:10}}, grid:{color:'rgba(255,255,255,0.04)'}, stacked:true }
}
}
});
</script>
<?php require_once __DIR__ . '/../includes/footer.php'; ?>
+91
View File
@@ -0,0 +1,91 @@
<?php
// ================================================================
// Admin Login — WebGIS Sebaran Penduduk Miskin
// ================================================================
require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/../includes/auth.php';
// Jika sudah login, redirect ke dashboard
if (isLoggedIn()) {
header('Location: ' . BASE_URL . '/admin/');
exit;
}
$error = $_GET['error'] ?? '';
$errorMsg = match($error) {
'empty' => 'Email dan password tidak boleh kosong.',
'invalid' => 'Email atau password salah.',
default => ''
};
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login — <?= APP_NAME ?></title>
<meta name="description" content="Login Admin WebGIS Sebaran Penduduk Miskin">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/admin.css">
</head>
<body>
<div class="login-page">
<div class="login-card">
<!-- Logo -->
<div class="login-logo">
<div class="login-logo-icon">🗺️</div>
<h1><?= APP_NAME ?></h1>
<p>Portal Admin · v<?= APP_VERSION ?></p>
</div>
<!-- Error Alert -->
<?php if ($errorMsg): ?>
<div class="alert alert-danger" style="margin-bottom:18px;" id="login-error">
<i class="bi bi-exclamation-circle-fill"></i> <?= htmlspecialchars($errorMsg) ?>
</div>
<?php endif; ?>
<!-- Form -->
<form action="<?= BASE_URL ?>/admin/auth_action.php" method="POST" id="login-form">
<div class="form-group">
<label class="form-label">Alamat Email</label>
<div class="login-input">
<i class="bi bi-envelope-fill"></i>
<input type="email" name="email" id="login-email"
placeholder="admin@povertymap.id"
value="<?= htmlspecialchars($_GET['email'] ?? '') ?>"
required autocomplete="email">
</div>
</div>
<div class="form-group" style="margin-bottom:20px;">
<label class="form-label">Password</label>
<div class="login-input">
<i class="bi bi-lock-fill"></i>
<input type="password" name="password" id="login-password"
placeholder="••••••••" required autocomplete="current-password">
</div>
</div>
<button type="submit" class="login-btn" id="btn-login">
<i class="bi bi-box-arrow-in-right"></i> Masuk ke Dashboard
</button>
</form>
<div class="login-footer">
<i class="bi bi-shield-lock"></i> Sistem diproteksi — Akses terbatas untuk staf berwenang
</div>
</div>
</div>
<script>
document.getElementById('login-form').addEventListener('submit', function() {
const btn = document.getElementById('btn-login');
btn.disabled = true;
btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Memverifikasi...';
});
</script>
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
<?php
// ================================================================
// Logout
// ================================================================
require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/../includes/auth.php';
logoutUser();
header('Location: ' . BASE_URL . '/admin/login.php');
exit;
+427
View File
@@ -0,0 +1,427 @@
<?php
// ================================================================
// Admin: CRUD Penduduk Miskin
// ================================================================
require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/../config/database.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/functions.php';
requireLogin();
$pageTitle = 'Penduduk Miskin';
require_once __DIR__ . '/../includes/header.php';
$db = getDB();
$perPage = 15;
$page = max(1, (int)($_GET['page'] ?? 1));
$offset = ($page - 1) * $perPage;
$search = trim($_GET['search'] ?? '');
$filterSt = $_GET['status'] ?? '';
$filterKec = $_GET['kecamatan'] ?? '';
$filterIb = (int)($_GET['ibadah_id'] ?? 0);
$where = ['1=1'];
$params = [];
if ($search) { $where[] = '(pm.kk_nama LIKE ? OR pm.nik LIKE ? OR pm.alamat LIKE ?)'; $q="%$search%"; $params=array_merge($params,[$q,$q,$q]); }
if ($filterSt) { $where[] = 'pm.status_bantuan = ?'; $params[] = $filterSt; }
if ($filterKec){ $where[] = 'pm.kecamatan = ?'; $params[] = $filterKec; }
if ($filterIb) { $where[] = 'pm.rumah_ibadah_id = ?'; $params[] = $filterIb; }
$ws = implode(' AND ', $where);
$stmtCnt = $db->prepare("SELECT COUNT(*) FROM penduduk_miskin pm WHERE $ws");
$stmtCnt->execute($params);
$total = (int)$stmtCnt->fetchColumn();
$totalPages = max(1, ceil($total / $perPage));
$stmt = $db->prepare("
SELECT pm.*, ri.nama AS ibadah_nama, ri.jenis AS ibadah_jenis
FROM penduduk_miskin pm
LEFT JOIN rumah_ibadah ri ON pm.rumah_ibadah_id = ri.id
WHERE $ws ORDER BY pm.created_at DESC LIMIT $perPage OFFSET $offset
");
$stmt->execute($params);
$rows = $stmt->fetchAll();
$kecList = $db->query("SELECT DISTINCT kecamatan FROM penduduk_miskin WHERE kecamatan IS NOT NULL AND kecamatan != '' ORDER BY kecamatan")->fetchAll(PDO::FETCH_COLUMN);
$ibadahList = $db->query("SELECT id, nama, jenis FROM rumah_ibadah ORDER BY nama")->fetchAll();
?>
<!-- ── Page Header ─────────────────────────────────────────── -->
<div class="page-header">
<div class="page-title-block">
<h1>👤 Penduduk Miskin</h1>
<div class="page-subtitle">Total <?= $total ?> KK terdaftar</div>
</div>
<div class="page-actions">
<a href="<?= BASE_URL ?>/?mode=miskin" target="_blank" class="btn btn-ghost btn-sm">
<i class="bi bi-map"></i> Tambah di Peta
</a>
<button class="btn btn-success btn-sm" onclick="openMiskinModal()">
<i class="bi bi-plus-lg"></i> Tambah Manual
</button>
</div>
</div>
<!-- ── Filter ─────────────────────────────────────────────── -->
<div class="card" style="margin-bottom:16px;">
<div class="card-body" style="padding:14px;">
<form method="GET" style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;">
<div class="search-bar" style="flex:1;min-width:200px;">
<i class="bi bi-search search-ico"></i>
<input type="text" class="form-control" name="search" placeholder="Cari nama, NIK, alamat..." value="<?= htmlspecialchars($search) ?>">
</div>
<div>
<select class="form-control" name="status">
<option value="">Semua Status</option>
<option value="belum" <?= $filterSt==='belum'?'selected':'' ?>>❌ Belum Dibantu</option>
<option value="menunggu" <?= $filterSt==='menunggu'?'selected':'' ?>>⏳ Menunggu</option>
<option value="sudah" <?= $filterSt==='sudah'?'selected':'' ?>>✅ Sudah Dibantu</option>
</select>
</div>
<div>
<select class="form-control" name="kecamatan">
<option value="">Semua Kecamatan</option>
<?php foreach ($kecList as $k): ?>
<option value="<?= htmlspecialchars($k) ?>" <?= $filterKec===$k?'selected':'' ?>><?= htmlspecialchars($k) ?></option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-search"></i> Filter</button>
<a href="<?= BASE_URL ?>/admin/penduduk_miskin.php" class="btn btn-ghost btn-sm"><i class="bi bi-x"></i> Reset</a>
</form>
</div>
</div>
<!-- ── Table ───────────────────────────────────────────────── -->
<div class="card">
<div class="table-responsive">
<table class="admin-table">
<thead>
<tr>
<th>#</th>
<th>Nama KK</th>
<th>NIK</th>
<th>Anggota</th>
<th>Kecamatan</th>
<th>Status</th>
<th>Rumah Ibadah</th>
<th>Jarak</th>
<th>Bantuan</th>
<th>Nominal</th>
<th>Terdaftar</th>
<th class="td-actions">Aksi</th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $i => $row): ?>
<tr>
<td style="color:var(--text3);font-size:11px;"><?= $offset+$i+1 ?></td>
<td style="font-weight:700;"><?= htmlspecialchars($row['kk_nama']) ?></td>
<td style="font-size:11px;color:var(--text3);font-family:monospace;"><?= htmlspecialchars($row['nik']?: '—') ?></td>
<td style="text-align:center;font-weight:700;"><?= $row['jumlah_anggota'] ?></td>
<td style="font-size:12px;color:var(--text2);"><?= htmlspecialchars($row['kecamatan']?: '—') ?></td>
<td><span class="badge-status badge-<?= $row['status_bantuan'] ?>"><?= ucfirst($row['status_bantuan']) ?></span></td>
<td style="font-size:11px;color:var(--text2);"><?= htmlspecialchars($row['ibadah_nama'] ?: '—') ?></td>
<td style="font-size:11px;color:var(--accent);"><?= $row['jarak_ke_ibadah'] ? round($row['jarak_ke_ibadah']).' m' : '—' ?></td>
<td style="font-size:11px;color:var(--text2);"><?= htmlspecialchars($row['jenis_bantuan'] ?: '—') ?></td>
<td style="font-size:11px;color:var(--green);font-family:monospace;"><?= $row['nominal_bantuan'] ? 'Rp '.number_format($row['nominal_bantuan'],0,',','.') : '—' ?></td>
<td style="font-size:11px;color:var(--text3);"><?= formatTanggal($row['created_at']) ?></td>
<td class="td-actions">
<button class="btn btn-ghost btn-sm" onclick='editMiskinRow(<?= htmlspecialchars(json_encode($row)) ?>)' title="Edit">
<i class="bi bi-pencil-fill"></i>
</button>
<?php if ($row['bukti_file']): ?>
<a href="<?= BASE_URL ?>/assets/uploads/<?= htmlspecialchars($row['bukti_file']) ?>" target="_blank" class="btn btn-ghost btn-sm" title="Lihat Bukti">
<i class="bi bi-paperclip"></i>
</a>
<?php endif; ?>
<button class="btn btn-danger btn-sm" onclick="deleteMiskin(<?= $row['id'] ?>, '<?= addslashes($row['kk_nama']) ?>')" title="Hapus">
<i class="bi bi-trash-fill"></i>
</button>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($rows)): ?>
<tr><td colspan="11" style="text-align:center;padding:32px;color:var(--text3);">
<div style="font-size:28px;margin-bottom:6px;">👤</div>Tidak ada data ditemukan
</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<!-- Pagination -->
<?php if ($totalPages > 1): ?>
<div style="padding:14px 18px;border-top:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px;">
<div style="font-size:12px;color:var(--text2);">Menampilkan <?= $offset+1 ?><?= min($offset+$perPage,$total) ?> dari <?= $total ?> data</div>
<div class="pagination">
<?php
$base = BASE_URL . '/admin/penduduk_miskin.php?' . http_build_query(array_filter(['search'=>$search,'status'=>$filterSt,'kecamatan'=>$filterKec]));
for ($p = 1; $p <= $totalPages; $p++):
?>
<a href="<?= $base ?>&page=<?= $p ?>" class="page-btn <?= $p===$page?'active':'' ?>"><?= $p ?></a>
<?php endfor; ?>
</div>
</div>
<?php endif; ?>
</div>
<!-- ── Modal Tambah/Edit Penduduk ─────────────────────────── -->
<div class="modal fade" id="modal-miskin" tabindex="-1">
<div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="miskin-modal-title">👤 Tambah Penduduk Miskin</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="m-edit-id">
<div class="section-label">📍 Lokasi</div>
<div class="form-row" style="margin-bottom:12px;">
<div class="form-group">
<label class="form-label">Latitude <span style="color:var(--red)">*</span></label>
<input class="form-control" id="m-lat" type="number" step="any" placeholder="-0.0263">
</div>
<div class="form-group">
<label class="form-label">Longitude <span style="color:var(--red)">*</span></label>
<input class="form-control" id="m-lng" type="number" step="any" placeholder="109.3425">
</div>
</div>
<div class="form-row" style="margin-bottom:12px;">
<div class="form-group"><label class="form-label">Kelurahan</label><input class="form-control" id="m-kelurahan" placeholder="Kelurahan"></div>
<div class="form-group"><label class="form-label">Kecamatan</label><input class="form-control" id="m-kecamatan" placeholder="Kecamatan"></div>
</div>
<div class="form-group" style="margin-bottom:14px;">
<label class="form-label">Alamat Lengkap</label>
<input class="form-control" id="m-alamat" placeholder="Jl. xxx No. xxx">
</div>
<div class="section-label">👨‍👩‍👧 Data Keluarga</div>
<div class="form-row" style="margin-bottom:12px;">
<div class="form-group"><label class="form-label">Nama KK <span style="color:var(--red)">*</span></label><input class="form-control" id="m-kk-nama" placeholder="Nama kepala keluarga"></div>
<div class="form-group"><label class="form-label">NIK KK</label><input class="form-control" id="m-nik" placeholder="16 digit" maxlength="16"></div>
</div>
<div class="form-group" style="margin-bottom:14px;">
<label class="form-label">Jumlah Anggota Keluarga</label>
<input class="form-control" id="m-jumlah" type="number" min="1" value="1" style="max-width:160px;">
</div>
<div class="section-label">👥 Detail Anggota</div>
<div id="m-anggota-container"></div>
<button type="button" class="btn btn-outline-accent btn-sm" onclick="addMAnggota()" style="width:100%;margin-bottom:14px;">
<i class="bi bi-plus-circle"></i> Tambah Anggota
</button>
<div class="section-label">🤝 Status Bantuan</div>
<div class="form-group">
<label class="form-label">Status <span style="color:var(--red)">*</span></label>
<select class="form-control" id="m-status" onchange="toggleMBantuan()">
<option value="belum">❌ Belum Dibantu</option>
<option value="menunggu">⏳ Menunggu Verifikasi</option>
<option value="sudah">✅ Sudah Dibantu</option>
</select>
</div>
<div id="m-bantuan-fields" style="display:none;">
<div class="form-row" style="margin-bottom:12px;">
<div class="form-group"><label class="form-label">Jenis Bantuan</label><input class="form-control" id="m-jenis-bantuan" placeholder="Mis. Sembako"></div>
<div class="form-group"><label class="form-label">Tanggal Bantuan</label><input class="form-control" id="m-tanggal" type="date"></div>
</div>
<div class="form-group">
<label class="form-label">💰 Nominal Bantuan (Rupiah)</label>
<div style="position:relative;">
<span style="position:absolute;left:12px;top:50%;transform:translateY(-50%);font-size:12px;color:var(--text2);font-weight:700;pointer-events:none;">Rp</span>
<input class="form-control" id="m-nominal"
type="text" inputmode="numeric"
placeholder="0"
style="padding-left:36px;font-family:'JetBrains Mono',monospace;"
oninput="formatNominalInput(this)">
</div>
<div style="font-size:10px;color:var(--text3);margin-top:4px;">Kosongkan jika belum ditentukan nominal</div>
</div>
</div>
<div class="section-label">📎 Bukti (Opsional)</div>
<div class="form-group">
<input type="file" class="form-control" id="m-bukti" accept=".jpg,.jpeg,.png,.gif,.pdf,.doc,.docx">
<div class="form-hint">JPG, PNG, PDF, DOC maks 10 MB. Biarkan kosong jika tidak diubah.</div>
<div id="m-bukti-info" style="margin-top:6px;font-size:11px;color:var(--text2);"></div>
</div>
<div class="section-label">🏛️ Rumah Ibadah (opsional override)</div>
<div class="form-group">
<label class="form-label">Rumah Ibadah Terdekat (akan dipilih otomatis)</label>
<select class="form-control" id="m-ibadah-id">
<option value="">— Otomatis dari koordinat —</option>
<?php foreach ($ibadahList as $ib): ?>
<option value="<?= $ib['id'] ?>"><?= htmlspecialchars($ib['nama']) ?> (<?= $ib['jenis'] ?>)</option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-ghost" data-bs-dismiss="modal"><i class="bi bi-x-lg"></i> Batal</button>
<button type="button" class="btn btn-success" id="btn-save-m"><i class="bi bi-floppy2-fill"></i> Simpan</button>
</div>
</div>
</div>
</div>
<script>
const BASE = '<?= BASE_URL ?>';
let mAIdx = 0;
function formatNominalInput(el) {
let raw = el.value.replace(/[^0-9]/g, '');
el.value = raw.replace(/\B(?=(\d{3})+(?!\d))/g, '.');
}
function openMiskinModal() { fillMModal(null); }
function editMiskinRow(data) { fillMModal(data); }
function fillMModal(data) {
const isEdit = data !== null;
document.getElementById('miskin-modal-title').textContent = isEdit ? '✏️ Edit Penduduk Miskin' : '👤 Tambah Penduduk Miskin';
document.getElementById('m-edit-id').value = isEdit ? data.id : '';
document.getElementById('m-kk-nama').value = isEdit ? data.kk_nama : '';
document.getElementById('m-nik').value = isEdit ? (data.nik||'') : '';
document.getElementById('m-jumlah').value = isEdit ? (data.jumlah_anggota||1) : 1;
document.getElementById('m-lat').value = isEdit ? data.lat : '';
document.getElementById('m-lng').value = isEdit ? data.lng : '';
document.getElementById('m-kelurahan').value = isEdit ? (data.kelurahan||'') : '';
document.getElementById('m-kecamatan').value = isEdit ? (data.kecamatan||'') : '';
document.getElementById('m-alamat').value = isEdit ? (data.alamat||'') : '';
document.getElementById('m-status').value = isEdit ? data.status_bantuan : 'belum';
document.getElementById('m-jenis-bantuan').value = isEdit ? (data.jenis_bantuan||'') : '';
document.getElementById('m-tanggal').value = isEdit ? (data.tanggal_bantuan||'') : '';
// Set nominal bantuan
const nomEl = document.getElementById('m-nominal');
if (nomEl) {
const rawNom = isEdit && data.nominal_bantuan ? parseFloat(data.nominal_bantuan) : '';
nomEl.value = rawNom !== '' ? rawNom.toLocaleString('id-ID', {maximumFractionDigits:0}) : '';
}
document.getElementById('m-ibadah-id').value = isEdit ? (data.rumah_ibadah_id||'') : '';
document.getElementById('m-bukti').value = '';
document.getElementById('m-bukti-info').innerHTML = isEdit && data.bukti_file
? `📎 File saat ini: <a href="${BASE}/assets/uploads/${data.bukti_file}" target="_blank" style="color:var(--accent)">${data.bukti_file}</a>`
: '';
document.getElementById('m-anggota-container').innerHTML = '';
mAIdx = 0;
if (isEdit && data.id) loadMAnggota(data.id);
toggleMBantuan();
new bootstrap.Modal(document.getElementById('modal-miskin')).show();
}
function toggleMBantuan() {
const statusVal = document.getElementById('m-status').value;
document.getElementById('m-bantuan-fields').style.display =
(statusVal === 'sudah' || statusVal === 'menunggu') ? 'block' : 'none';
}
async function loadMAnggota(pid) {
try {
const r = await fetch(`${BASE}/api/anggota_keluarga.php?penduduk_id=${pid}`);
const js = await r.json();
if (js.success) js.data.forEach(a => addMAnggota(a));
} catch{}
}
function addMAnggota(data = null) {
mAIdx++;
const c = document.getElementById('m-anggota-container');
const d = document.createElement('div');
d.className = 'anggota-row'; d.id = `ma-row-${mAIdx}`;
d.innerHTML = `
<div class="anggota-row-header">
<span class="anggota-num">Anggota ${mAIdx}</span>
<button class="btn-remove-anggota" type="button" onclick="document.getElementById('ma-row-${mAIdx}').remove()">✕</button>
</div>
<div class="form-row">
<div class="form-group" style="margin-bottom:0"><label class="form-label">Nama *</label><input class="form-control ma-nama" id="ma-nama-${mAIdx}" value="${data?.nama||''}"></div>
<div class="form-group" style="margin-bottom:0"><label class="form-label">Hubungan</label><input class="form-control" id="ma-hub-${mAIdx}" value="${data?.hubungan||''}"></div>
</div>
<div class="form-row" style="margin-top:8px">
<div class="form-group" style="margin-bottom:0"><label class="form-label">Umur</label><input class="form-control" id="ma-umur-${mAIdx}" type="number" min="0" value="${data?.umur||''}"></div>
<div class="form-group" style="margin-bottom:0"><label class="form-label">Pekerjaan</label><input class="form-control" id="ma-pkj-${mAIdx}" value="${data?.pekerjaan||''}"></div>
</div>
<div class="form-group" style="margin-top:8px;margin-bottom:0">
<label class="form-label">Keterangan</label>
<input class="form-control" id="ma-ket-${mAIdx}" placeholder="Catatan tambahan (opsional)" value="${data?.keterangan||''}">
</div>`;
c.appendChild(d);
}
function collectMAnggota() {
const rows = document.querySelectorAll('.ma-nama');
return Array.from(rows).map(el => {
const idx = el.id.replace('ma-nama-','');
return {
nama: el.value.trim(),
hubungan: document.getElementById(`ma-hub-${idx}`)?.value || '',
umur: document.getElementById(`ma-umur-${idx}`)?.value || '',
pekerjaan: document.getElementById(`ma-pkj-${idx}`)?.value || '',
keterangan: document.getElementById(`ma-ket-${idx}`)?.value || ''
};
}).filter(a => a.nama);
}
document.getElementById('btn-save-m').addEventListener('click', async function() {
const nama = document.getElementById('m-kk-nama').value.trim();
const lat = document.getElementById('m-lat').value;
const lng = document.getElementById('m-lng').value;
if (!nama || !lat || !lng) { Swal.fire('Error','Nama KK, Lat, dan Lng wajib diisi!','error'); return; }
const editId = document.getElementById('m-edit-id').value;
const fd = new FormData();
fd.append('kk_nama', nama);
fd.append('nik', document.getElementById('m-nik').value);
fd.append('jumlah_anggota', document.getElementById('m-jumlah').value||1);
fd.append('lat', lat); fd.append('lng', lng);
fd.append('kelurahan', document.getElementById('m-kelurahan').value);
fd.append('kecamatan', document.getElementById('m-kecamatan').value);
fd.append('alamat', document.getElementById('m-alamat').value);
fd.append('status_bantuan', document.getElementById('m-status').value);
fd.append('jenis_bantuan', document.getElementById('m-jenis-bantuan').value);
fd.append('tanggal_bantuan', document.getElementById('m-tanggal').value);
// Nominal bantuan: hapus titik pemisah ribuan sebelum kirim
const nomRaw = (document.getElementById('m-nominal')?.value || '').replace(/\./g,'');
fd.append('nominal_bantuan', nomRaw);
fd.append('anggota', JSON.stringify(collectMAnggota()));
const buktiInput = document.getElementById('m-bukti');
if (buktiInput.files[0]) fd.append('bukti_file', buktiInput.files[0]);
this.disabled = true; this.textContent = '⏳ Menyimpan...';
try {
const url = editId ? `${BASE}/api/penduduk_miskin.php?id=${editId}` : `${BASE}/api/penduduk_miskin.php`;
const resp = await fetch(url, { method: editId?'PUT':'POST', body: fd });
const json = await resp.json();
if (json.success) { location.reload(); } else { Swal.fire('Error', json.message, 'error'); }
} catch { Swal.fire('Error','Gagal menyimpan','error'); }
finally { this.disabled = false; this.innerHTML = '<i class="bi bi-floppy2-fill"></i> Simpan'; }
});
async function deleteMiskin(id, nama) {
const res = await Swal.fire({
title: 'Hapus Penduduk?', html: `Data <b>${nama}</b> akan dihapus permanen.`,
icon: 'warning', showCancelButton: true, confirmButtonText: 'Ya, Hapus!',
cancelButtonText: 'Batal', confirmButtonColor: '#ef4444',
background: '#161b27', color: '#e8ecf4'
});
if (!res.isConfirmed) return;
try {
const r = await fetch(`${BASE}/api/penduduk_miskin.php?id=${id}`, { method:'DELETE' });
const js = await r.json();
if (js.success) { Swal.fire({title:'Terhapus!',icon:'success',timer:1500,background:'#161b27',color:'#e8ecf4'}).then(()=>location.reload()); }
else Swal.fire('Error', js.message, 'error');
} catch { Swal.fire('Error','Gagal menghapus','error'); }
}
</script>
<?php require_once __DIR__ . '/../includes/footer.php'; ?>
+328
View File
@@ -0,0 +1,328 @@
<?php
// ================================================================
// Admin: CRUD Rumah Ibadah
// ================================================================
require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/../config/database.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/functions.php';
requireLogin();
$pageTitle = 'Rumah Ibadah';
require_once __DIR__ . '/../includes/header.php';
$db = getDB();
// Pagination
$perPage = 15;
$page = max(1, (int)($_GET['page'] ?? 1));
$offset = ($page - 1) * $perPage;
// Filter & search
$search = trim($_GET['search'] ?? '');
$filterJenis = $_GET['jenis'] ?? '';
$filterKec = $_GET['kecamatan'] ?? '';
$where = ['1=1'];
$params = [];
if ($search) { $where[] = '(nama LIKE ? OR kontak LIKE ? OR alamat LIKE ?)'; $q = "%$search%"; $params = array_merge($params, [$q,$q,$q]); }
if ($filterJenis) { $where[] = 'jenis = ?'; $params[] = $filterJenis; }
if ($filterKec) { $where[] = 'kecamatan = ?'; $params[] = $filterKec; }
$whereStr = implode(' AND ', $where);
$total = (int)$db->prepare("SELECT COUNT(*) FROM rumah_ibadah WHERE $whereStr")->execute($params) ? $db->prepare("SELECT COUNT(*) FROM rumah_ibadah WHERE $whereStr")->execute($params) : 0;
// Re-execute for count
$stmtCount = $db->prepare("SELECT COUNT(*) FROM rumah_ibadah WHERE $whereStr");
$stmtCount->execute($params);
$total = (int)$stmtCount->fetchColumn();
$totalPages = max(1, ceil($total / $perPage));
$stmt = $db->prepare("
SELECT ri.*,
(SELECT COUNT(*) FROM penduduk_miskin pm WHERE pm.rumah_ibadah_id = ri.id) AS jumlah_binaan
FROM rumah_ibadah ri
WHERE $whereStr ORDER BY ri.created_at DESC LIMIT $perPage OFFSET $offset
");
$stmt->execute($params);
$rows = $stmt->fetchAll();
// Kecamatan list for filter
$kecamatanList = $db->query("SELECT DISTINCT kecamatan FROM rumah_ibadah WHERE kecamatan IS NOT NULL AND kecamatan != '' ORDER BY kecamatan")->fetchAll(PDO::FETCH_COLUMN);
?>
<!-- ── Page Header ─────────────────────────────────────────── -->
<div class="page-header">
<div class="page-title-block">
<h1>🏛️ Rumah Ibadah</h1>
<div class="page-subtitle">Total <?= $total ?> data terdaftar</div>
</div>
<div class="page-actions">
<a href="<?= BASE_URL ?>/?mode=ibadah" target="_blank" class="btn btn-ghost btn-sm">
<i class="bi bi-map"></i> Tambah di Peta
</a>
<button class="btn btn-primary btn-sm" onclick="openModal()">
<i class="bi bi-plus-lg"></i> Tambah Manual
</button>
</div>
</div>
<!-- ── Filter Bar ──────────────────────────────────────────── -->
<div class="card" style="margin-bottom:16px;">
<div class="card-body" style="padding:14px;">
<form method="GET" style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;">
<div class="search-bar" style="flex:1;min-width:200px;">
<i class="bi bi-search search-ico"></i>
<input type="text" class="form-control" name="search" placeholder="Cari nama, kontak, alamat..." value="<?= htmlspecialchars($search) ?>">
</div>
<div>
<select class="form-control" name="jenis">
<option value="">Semua Jenis</option>
<?php foreach (array_keys(JENIS_IBADAH) as $j): ?>
<option value="<?= $j ?>" <?= $filterJenis===$j?'selected':'' ?>><?= JENIS_EMOJI[$j]??'' ?> <?= $j ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<select class="form-control" name="kecamatan">
<option value="">Semua Kecamatan</option>
<?php foreach ($kecamatanList as $k): ?>
<option value="<?= htmlspecialchars($k) ?>" <?= $filterKec===$k?'selected':'' ?>><?= htmlspecialchars($k) ?></option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-search"></i> Filter</button>
<a href="<?= BASE_URL ?>/admin/rumah_ibadah.php" class="btn btn-ghost btn-sm"><i class="bi bi-x"></i> Reset</a>
</form>
</div>
</div>
<!-- ── Table ───────────────────────────────────────────────── -->
<div class="card">
<div class="table-responsive">
<table class="admin-table">
<thead>
<tr>
<th>#</th>
<th>Nama</th>
<th>Jenis</th>
<th>Kontak</th>
<th>Kecamatan</th>
<th>Radius</th>
<th>Binaan</th>
<th>Koordinat</th>
<th>Ditambahkan</th>
<th class="td-actions">Aksi</th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $i => $row): ?>
<tr>
<td style="color:var(--text3);font-size:11px;"><?= $offset + $i + 1 ?></td>
<td style="font-weight:700;"><?= htmlspecialchars($row['nama']) ?></td>
<td>
<span class="badge-status" style="background:rgba(91,127,255,0.12);color:var(--accent)">
<?= JENIS_EMOJI[$row['jenis']]??'' ?> <?= $row['jenis'] ?>
</span>
</td>
<td style="color:var(--text2);font-size:12px;"><?= htmlspecialchars($row['kontak'] ?: '—') ?></td>
<td style="color:var(--text2);font-size:12px;"><?= htmlspecialchars($row['kecamatan'] ?: '—') ?></td>
<td style="color:var(--accent);font-weight:700;"><?= formatRadius($row['radius']) ?></td>
<td>
<?php if ($row['jumlah_binaan'] > 0): ?>
<a href="<?= BASE_URL ?>/admin/penduduk_miskin.php?ibadah_id=<?= $row['id'] ?>" style="color:var(--green);font-weight:700;text-decoration:none;"><?= $row['jumlah_binaan'] ?> warga</a>
<?php else: ?>
<span style="color:var(--text3)">0</span>
<?php endif; ?>
</td>
<td style="font-size:10px;color:var(--text3);font-family:monospace;"><?= number_format($row['lat'],5) ?>, <?= number_format($row['lng'],5) ?></td>
<td style="font-size:11px;color:var(--text3);"><?= formatTanggal($row['created_at']) ?></td>
<td class="td-actions">
<button class="btn btn-ghost btn-sm" onclick="editRow(<?= htmlspecialchars(json_encode($row)) ?>)" title="Edit">
<i class="bi bi-pencil-fill"></i>
</button>
<a href="<?= BASE_URL ?>/?focus=ibadah&id=<?= $row['id'] ?>" target="_blank" class="btn btn-ghost btn-sm" title="Lihat di Peta">
<i class="bi bi-map-fill"></i>
</a>
<button class="btn btn-danger btn-sm" onclick="deleteRow(<?= $row['id'] ?>, '<?= addslashes($row['nama']) ?>')" title="Hapus">
<i class="bi bi-trash-fill"></i>
</button>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($rows)): ?>
<tr><td colspan="10" style="text-align:center;padding:32px;color:var(--text3);">
<div style="font-size:28px;margin-bottom:6px;">🏛️</div>
Tidak ada data ditemukan
</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<!-- Pagination -->
<?php if ($totalPages > 1): ?>
<div style="padding:14px 18px;border-top:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px;">
<div style="font-size:12px;color:var(--text2);">
Menampilkan <?= $offset+1 ?><?= min($offset+$perPage,$total) ?> dari <?= $total ?> data
</div>
<div class="pagination">
<?php
$base = BASE_URL . '/admin/rumah_ibadah.php?' . http_build_query(array_filter(['search'=>$search,'jenis'=>$filterJenis,'kecamatan'=>$filterKec]));
for ($p = 1; $p <= $totalPages; $p++):
?>
<a href="<?= $base ?>&page=<?= $p ?>" class="page-btn <?= $p===$page?'active':'' ?>"><?= $p ?></a>
<?php endfor; ?>
</div>
</div>
<?php endif; ?>
</div>
<!-- ── Modal Tambah/Edit ───────────────────────────────────── -->
<div class="modal fade" id="modal-ibadah" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="modal-title">🏛️ Tambah Rumah Ibadah</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="edit-id">
<div class="section-label">📍 Lokasi</div>
<div class="form-row" style="margin-bottom:14px;">
<div class="form-group">
<label class="form-label">Latitude <span style="color:var(--red)">*</span></label>
<input class="form-control" id="f-lat" type="number" step="any" placeholder="-0.0263">
</div>
<div class="form-group">
<label class="form-label">Longitude <span style="color:var(--red)">*</span></label>
<input class="form-control" id="f-lng" type="number" step="any" placeholder="109.3425">
</div>
</div>
<div class="form-row" style="margin-bottom:14px;">
<div class="form-group">
<label class="form-label">Kelurahan</label>
<input class="form-control" id="f-kelurahan" placeholder="Kelurahan">
</div>
<div class="form-group">
<label class="form-label">Kecamatan</label>
<input class="form-control" id="f-kecamatan" placeholder="Kecamatan">
</div>
</div>
<div class="section-label">🕌 Data Ibadah</div>
<div class="form-group">
<label class="form-label">Nama Rumah Ibadah <span style="color:var(--red)">*</span></label>
<input class="form-control" id="f-nama" placeholder="Nama lengkap rumah ibadah">
</div>
<div class="form-row" style="margin-bottom:14px;">
<div class="form-group">
<label class="form-label">Jenis</label>
<select class="form-control" id="f-jenis">
<option value="Masjid">🕌 Masjid</option>
<option value="Musholla">🕌 Musholla</option>
<option value="Gereja">⛪ Gereja</option>
<option value="Katolik">⛪ Gereja Katolik</option>
<option value="Pura">🛕 Pura</option>
<option value="Vihara">🛕 Vihara</option>
<option value="Klenteng">⛩️ Klenteng</option>
<option value="Lainnya">🙏 Lainnya</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Kontak</label>
<input class="form-control" id="f-kontak" placeholder="Telepon / WhatsApp">
</div>
</div>
<div class="form-group">
<label class="form-label">Alamat Lengkap</label>
<input class="form-control" id="f-alamat" placeholder="Jalan, nomor, dll.">
</div>
<div class="section-label">📏 Radius Layanan</div>
<div class="form-group">
<div class="range-wrapper">
<input type="range" id="f-radius" min="100" max="5000" step="50" value="300" style="flex:1;accent-color:var(--accent);">
<span class="range-val" id="f-radius-val">300 m</span>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-ghost" data-bs-dismiss="modal"><i class="bi bi-x-lg"></i> Batal</button>
<button type="button" class="btn btn-primary" id="btn-save"><i class="bi bi-floppy2-fill"></i> Simpan</button>
</div>
</div>
</div>
</div>
<script>
const BASE = '<?= BASE_URL ?>';
// Radius slider
document.getElementById('f-radius').addEventListener('input', function() {
const v = parseInt(this.value);
document.getElementById('f-radius-val').textContent = v >= 1000 ? (v/1000).toFixed(1)+' km' : v+' m';
});
function openModal(data = null) {
const isEdit = data !== null;
document.getElementById('modal-title').textContent = isEdit ? '✏️ Edit Rumah Ibadah' : '🏛️ Tambah Rumah Ibadah';
document.getElementById('edit-id').value = isEdit ? data.id : '';
document.getElementById('f-nama').value = isEdit ? data.nama : '';
document.getElementById('f-jenis').value = isEdit ? data.jenis : 'Masjid';
document.getElementById('f-kontak').value = isEdit ? (data.kontak || '') : '';
document.getElementById('f-alamat').value = isEdit ? (data.alamat || '') : '';
document.getElementById('f-kelurahan').value = isEdit ? (data.kelurahan||'') : '';
document.getElementById('f-kecamatan').value = isEdit ? (data.kecamatan||'') : '';
document.getElementById('f-lat').value = isEdit ? data.lat : '';
document.getElementById('f-lng').value = isEdit ? data.lng : '';
const rad = isEdit ? parseInt(data.radius) : 300;
document.getElementById('f-radius').value = rad;
document.getElementById('f-radius-val').textContent = rad >= 1000 ? (rad/1000).toFixed(1)+' km' : rad+' m';
new bootstrap.Modal(document.getElementById('modal-ibadah')).show();
}
function editRow(data) { openModal(data); }
document.getElementById('btn-save').addEventListener('click', async function() {
const nama = document.getElementById('f-nama').value.trim();
const lat = document.getElementById('f-lat').value;
const lng = document.getElementById('f-lng').value;
if (!nama || !lat || !lng) { Swal.fire('Error','Nama, Lat, dan Lng wajib diisi!','error'); return; }
const editId = document.getElementById('edit-id').value;
const payload = {
nama, jenis: document.getElementById('f-jenis').value,
kontak: document.getElementById('f-kontak').value,
alamat: document.getElementById('f-alamat').value,
kelurahan: document.getElementById('f-kelurahan').value,
kecamatan: document.getElementById('f-kecamatan').value,
lat: parseFloat(lat), lng: parseFloat(lng),
radius: parseInt(document.getElementById('f-radius').value)
};
this.disabled = true; this.textContent = '⏳ Menyimpan...';
try {
const url = editId ? `${BASE}/api/rumah_ibadah.php?id=${editId}` : `${BASE}/api/rumah_ibadah.php`;
const resp = await fetch(url, { method: editId?'PUT':'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
const json = await resp.json();
if (json.success) { location.reload(); } else { Swal.fire('Error', json.message, 'error'); }
} catch(e) { Swal.fire('Error','Gagal menyimpan data','error'); }
finally { this.disabled = false; this.innerHTML = '<i class="bi bi-floppy2-fill"></i> Simpan'; }
});
async function deleteRow(id, nama) {
const res = await Swal.fire({
title: 'Hapus Rumah Ibadah?',
html: `<b>${nama}</b> akan dihapus permanen. Warga binaan akan di-reset.`,
icon: 'warning', showCancelButton: true,
confirmButtonText: 'Ya, Hapus!', cancelButtonText: 'Batal',
confirmButtonColor: '#ef4444', background: '#161b27', color: '#e8ecf4'
});
if (!res.isConfirmed) return;
try {
const r = await fetch(`${BASE}/api/rumah_ibadah.php?id=${id}`, { method:'DELETE' });
const js = await r.json();
if (js.success) { Swal.fire({title:'Terhapus!',icon:'success',timer:1500,background:'#161b27',color:'#e8ecf4'}).then(() => location.reload()); }
else Swal.fire('Error', js.message, 'error');
} catch { Swal.fire('Error','Gagal menghapus','error'); }
}
</script>
<?php require_once __DIR__ . '/../includes/footer.php'; ?>
+311
View File
@@ -0,0 +1,311 @@
<?php
// ================================================================
// Admin: Manajemen User
// ================================================================
require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/../config/database.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/functions.php';
requireLogin();
requireAdmin(); // Hanya admin
$pageTitle = 'Manajemen Pengguna';
require_once __DIR__ . '/../includes/header.php';
$db = getDB();
$current = getCurrentUser();
$users = $db->query("SELECT id, nama, email, role, created_at FROM users ORDER BY role DESC, nama ASC")->fetchAll();
?>
<!-- ── Page Header ─────────────────────────────────────────── -->
<div class="page-header">
<div class="page-title-block">
<h1><i class="bi bi-person-gear"></i> Manajemen Pengguna</h1>
<div class="page-subtitle">Total <?= count($users) ?> pengguna terdaftar</div>
</div>
<div class="page-actions">
<button class="btn btn-primary btn-sm" onclick="openUserModal()">
<i class="bi bi-person-plus-fill"></i> Tambah Pengguna
</button>
</div>
</div>
<!-- ── Users Table ─────────────────────────────────────────── -->
<div class="card">
<div class="table-responsive">
<table class="admin-table">
<thead>
<tr>
<th>#</th>
<th>Nama</th>
<th>Email</th>
<th>Role</th>
<th>Terdaftar</th>
<th class="td-actions">Aksi</th>
</tr>
</thead>
<tbody>
<?php foreach ($users as $i => $u): ?>
<tr>
<td style="color:var(--text3);font-size:11px;"><?= $i + 1 ?></td>
<td>
<div style="display:flex;align-items:center;gap:10px;">
<div style="width:34px;height:34px;border-radius:50%;background:<?= $u['role'] === 'admin' ? '#5b7fff' : '#22c55e' ?>;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:14px;flex-shrink:0;">
<?= strtoupper(substr($u['nama'], 0, 1)) ?>
</div>
<div>
<div style="font-weight:700;"><?= htmlspecialchars($u['nama']) ?></div>
<?php if ($u['id'] == $current['id']): ?>
<div style="font-size:10px;color:var(--accent);">Anda</div>
<?php endif; ?>
</div>
</div>
</td>
<td style="font-size:12px;color:var(--text2);"><?= htmlspecialchars($u['email']) ?></td>
<td>
<span class="badge-status" style="background:<?= $u['role'] === 'admin' ? 'rgba(91,127,255,0.15)' : 'rgba(34,197,94,0.15)' ?>;color:<?= $u['role'] === 'admin' ? 'var(--accent)' : 'var(--green)' ?>;">
<i class="bi bi-<?= $u['role'] === 'admin' ? 'shield-fill' : 'person-fill' ?>"></i>
<?= ucfirst($u['role']) ?>
</span>
</td>
<td style="font-size:11px;color:var(--text3);"><?= formatTanggal($u['created_at']) ?></td>
<td class="td-actions">
<button class="btn btn-ghost btn-sm" onclick='openEditUser(<?= json_encode($u) ?>)' title="Edit">
<i class="bi bi-pencil-fill"></i>
</button>
<button class="btn btn-ghost btn-sm" onclick="openResetPwd(<?= $u['id'] ?>, '<?= addslashes($u['nama']) ?>')" title="Reset Password">
<i class="bi bi-key-fill"></i>
</button>
<?php if ($u['id'] != $current['id']): ?>
<button class="btn btn-danger btn-sm" onclick="deleteUser(<?= $u['id'] ?>, '<?= addslashes($u['nama']) ?>')" title="Hapus">
<i class="bi bi-trash-fill"></i>
</button>
<?php else: ?>
<button class="btn btn-ghost btn-sm" disabled title="Tidak bisa hapus akun sendiri">
<i class="bi bi-trash" style="opacity:.3"></i>
</button>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<!-- ── Info Card ───────────────────────────────────────────── -->
<div class="card" style="margin-top:20px;">
<div class="card-body" style="padding:16px;">
<div style="display:flex;gap:16px;flex-wrap:wrap;font-size:13px;">
<div style="display:flex;align-items:center;gap:8px;">
<span style="width:12px;height:12px;background:#5b7fff;border-radius:50%;display:inline-block;"></span>
<strong>Admin</strong>: Akses penuh ke semua fitur termasuk manajemen user, import/export
</div>
<div style="display:flex;align-items:center;gap:8px;">
<span style="width:12px;height:12px;background:#22c55e;border-radius:50%;display:inline-block;"></span>
<strong>Operator</strong>: Akses CRUD rumah ibadah & penduduk miskin
</div>
</div>
</div>
</div>
<!-- ── Modal Tambah/Edit User ─────────────────────────────── -->
<div class="modal fade" id="modal-user" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="user-modal-title">👤 Tambah Pengguna</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="u-id">
<div class="form-group">
<label class="form-label">Nama Lengkap <span style="color:var(--red)">*</span></label>
<input class="form-control" id="u-nama" placeholder="Nama lengkap pengguna">
</div>
<div class="form-group">
<label class="form-label">Email <span style="color:var(--red)">*</span></label>
<input class="form-control" id="u-email" type="email" placeholder="email@domain.com">
</div>
<div class="form-group" id="u-pwd-group">
<label class="form-label">Password <span style="color:var(--red)" id="u-pwd-req">*</span></label>
<div style="position:relative;">
<input class="form-control" id="u-password" type="password" placeholder="Min. 6 karakter" style="padding-right:40px;">
<button type="button" onclick="togglePwd()" style="position:absolute;right:10px;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--text2);cursor:pointer;">
<i class="bi bi-eye" id="pwd-eye"></i>
</button>
</div>
<div class="form-hint" id="u-pwd-hint">Biarkan kosong jika tidak ingin mengubah password</div>
</div>
<div class="form-group">
<label class="form-label">Role <span style="color:var(--red)">*</span></label>
<select class="form-control" id="u-role">
<option value="operator">Operator</option>
<option value="admin">Admin</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-ghost" data-bs-dismiss="modal"><i class="bi bi-x-lg"></i> Batal</button>
<button type="button" class="btn btn-primary" id="btn-save-user"><i class="bi bi-floppy2-fill"></i> Simpan</button>
</div>
</div>
</div>
</div>
<!-- ── Modal Reset Password ───────────────────────────────── -->
<div class="modal fade" id="modal-reset-pwd" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">🔑 Reset Password</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="rp-id">
<div style="margin-bottom:16px;padding:12px;background:rgba(245,158,11,0.1);border:1px solid rgba(245,158,11,0.3);border-radius:8px;font-size:13px;">
<i class="bi bi-exclamation-triangle-fill" style="color:#f59e0b"></i>
Reset password untuk: <strong id="rp-nama"></strong>
</div>
<div class="form-group">
<label class="form-label">Password Baru <span style="color:var(--red)">*</span></label>
<input class="form-control" id="rp-pwd" type="password" placeholder="Min. 6 karakter">
</div>
<div class="form-group">
<label class="form-label">Konfirmasi Password <span style="color:var(--red)">*</span></label>
<input class="form-control" id="rp-confirm" type="password" placeholder="Ulangi password baru">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-ghost" data-bs-dismiss="modal"><i class="bi bi-x-lg"></i> Batal</button>
<button type="button" class="btn btn-warning" id="btn-reset-pwd"><i class="bi bi-key-fill"></i> Reset Password</button>
</div>
</div>
</div>
</div>
<script>
const BASE = '<?= BASE_URL ?>';
let userModalBs, resetPwdModalBs;
document.addEventListener('DOMContentLoaded', () => {
userModalBs = new bootstrap.Modal(document.getElementById('modal-user'));
resetPwdModalBs = new bootstrap.Modal(document.getElementById('modal-reset-pwd'));
});
function openUserModal() {
document.getElementById('user-modal-title').textContent = '👤 Tambah Pengguna';
document.getElementById('u-id').value = '';
document.getElementById('u-nama').value = '';
document.getElementById('u-email').value = '';
document.getElementById('u-password').value = '';
document.getElementById('u-role').value = 'operator';
document.getElementById('u-pwd-req').style.display = 'inline';
document.getElementById('u-pwd-hint').style.display = 'none';
userModalBs.show();
}
function openEditUser(data) {
document.getElementById('user-modal-title').textContent = '✏️ Edit Pengguna';
document.getElementById('u-id').value = data.id;
document.getElementById('u-nama').value = data.nama;
document.getElementById('u-email').value = data.email;
document.getElementById('u-password').value = '';
document.getElementById('u-role').value = data.role;
document.getElementById('u-pwd-req').style.display = 'none';
document.getElementById('u-pwd-hint').style.display = 'block';
userModalBs.show();
}
function openResetPwd(id, nama) {
document.getElementById('rp-id').value = id;
document.getElementById('rp-nama').textContent = nama;
document.getElementById('rp-pwd').value = '';
document.getElementById('rp-confirm').value = '';
resetPwdModalBs.show();
}
function togglePwd() {
const inp = document.getElementById('u-password');
const eye = document.getElementById('pwd-eye');
if (inp.type === 'password') {
inp.type = 'text'; eye.className = 'bi bi-eye-slash';
} else {
inp.type = 'password'; eye.className = 'bi bi-eye';
}
}
// ── Save User ─────────────────────────────────────────────────
document.getElementById('btn-save-user').addEventListener('click', async function() {
const nama = document.getElementById('u-nama').value.trim();
const email = document.getElementById('u-email').value.trim();
const pwd = document.getElementById('u-password').value;
const role = document.getElementById('u-role').value;
const editId = document.getElementById('u-id').value;
if (!nama || !email) { Swal.fire('Error', 'Nama dan email wajib diisi!', 'error'); return; }
if (!editId && !pwd) { Swal.fire('Error', 'Password wajib diisi untuk pengguna baru!', 'error'); return; }
if (pwd && pwd.length < 6) { Swal.fire('Error', 'Password minimal 6 karakter!', 'error'); return; }
const payload = { nama, email, role };
if (pwd) payload.password = pwd;
this.disabled = true; this.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
try {
const url = editId ? `${BASE}/api/users.php?id=${editId}` : `${BASE}/api/users.php`;
const resp = await fetch(url, { method: editId ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
const json = await resp.json();
if (json.success) {
Swal.fire({ title: 'Berhasil!', icon: 'success', timer: 1500, background: '#161b27', color: '#e8ecf4' })
.then(() => location.reload());
} else { Swal.fire('Error', json.message, 'error'); }
} catch { Swal.fire('Error', 'Gagal menyimpan data', 'error'); }
finally { this.disabled = false; this.innerHTML = '<i class="bi bi-floppy2-fill"></i> Simpan'; }
});
// ── Reset Password ─────────────────────────────────────────────
document.getElementById('btn-reset-pwd').addEventListener('click', async function() {
const id = document.getElementById('rp-id').value;
const pwd = document.getElementById('rp-pwd').value;
const confirm = document.getElementById('rp-confirm').value;
if (!pwd || pwd.length < 6) { Swal.fire('Error', 'Password minimal 6 karakter!', 'error'); return; }
if (pwd !== confirm) { Swal.fire('Error', 'Konfirmasi password tidak cocok!', 'error'); return; }
this.disabled = true;
try {
const resp = await fetch(`${BASE}/api/users.php?id=${id}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pwd })
});
const json = await resp.json();
if (json.success) {
Swal.fire({ title: 'Password Direset!', icon: 'success', timer: 1500, background: '#161b27', color: '#e8ecf4' })
.then(() => location.reload());
} else { Swal.fire('Error', json.message, 'error'); }
} catch { Swal.fire('Error', 'Gagal reset password', 'error'); }
finally { this.disabled = false; }
});
// ── Delete User ───────────────────────────────────────────────
async function deleteUser(id, nama) {
const res = await Swal.fire({
title: 'Hapus Pengguna?',
html: `Pengguna <b>${nama}</b> akan dihapus permanen.`,
icon: 'warning', showCancelButton: true,
confirmButtonText: 'Ya, Hapus!', cancelButtonText: 'Batal',
confirmButtonColor: '#ef4444', background: '#161b27', color: '#e8ecf4'
});
if (!res.isConfirmed) return;
try {
const r = await fetch(`${BASE}/api/users.php?id=${id}`, { method: 'DELETE' });
const js = await r.json();
if (js.success) {
Swal.fire({ title: 'Terhapus!', icon: 'success', timer: 1200, background: '#161b27', color: '#e8ecf4' })
.then(() => location.reload());
} else { Swal.fire('Error', js.message, 'error'); }
} catch { Swal.fire('Error', 'Gagal menghapus', 'error'); }
}
</script>
<?php require_once __DIR__ . '/../includes/footer.php'; ?>
+47
View File
@@ -0,0 +1,47 @@
<?php
// ================================================================
// API: Anggota Keluarga
// ================================================================
require_once __DIR__ . '/../config/database.php';
require_once __DIR__ . '/../includes/functions.php';
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
$db = getDB();
$method = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
$pid = isset($_GET['penduduk_id']) ? (int)$_GET['penduduk_id'] : null;
if ($method === 'GET') {
if (!$pid) jsonResponse(['success' => false, 'message' => 'penduduk_id wajib'], 400);
$stmt = $db->prepare("SELECT * FROM anggota_keluarga WHERE penduduk_id = ? ORDER BY id");
$stmt->execute([$pid]);
jsonResponse(['success' => true, 'data' => $stmt->fetchAll()]);
}
if ($method === 'POST') {
$body = json_decode(file_get_contents('php://input'), true) ?? [];
if (empty($body['penduduk_id']) || empty($body['nama'])) {
jsonResponse(['success' => false, 'message' => 'penduduk_id dan nama wajib'], 422);
}
$stmt = $db->prepare("INSERT INTO anggota_keluarga (penduduk_id,nama,hubungan,umur,pekerjaan,keterangan) VALUES (?,?,?,?,?,?)");
$stmt->execute([
(int)$body['penduduk_id'],
sanitize($body['nama'] ?? ''),
sanitize($body['hubungan'] ?? ''),
!empty($body['umur']) ? (int)$body['umur'] : null,
sanitize($body['pekerjaan'] ?? ''),
sanitize($body['keterangan'] ?? ''),
]);
jsonResponse(['success' => true, 'id' => $db->lastInsertId()], 201);
}
if ($method === 'DELETE') {
if (!$id) jsonResponse(['success' => false, 'message' => 'id wajib'], 400);
$stmt = $db->prepare("DELETE FROM anggota_keluarga WHERE id = ?");
$stmt->execute([$id]);
jsonResponse(['success' => true]);
}
jsonResponse(['success' => false, 'message' => 'Method tidak didukung'], 405);
+211
View File
@@ -0,0 +1,211 @@
<?php
// ================================================================
// API: Export Data ke CSV atau HTML Print
// ================================================================
require_once __DIR__ . '/../config/database.php';
require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/../includes/functions.php';
// Wajib login untuk export
session_name(SESSION_NAME);
session_start();
if (empty($_SESSION['user_id'])) {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'Unauthorized']);
exit;
}
$db = getDB();
$type = $_GET['type'] ?? 'penduduk'; // penduduk | ibadah
$format = $_GET['format'] ?? 'csv'; // csv | print
// ── Query Penduduk Miskin ──────────────────────────────────────
if ($type === 'penduduk') {
$where = ['1=1'];
$params = [];
if (!empty($_GET['status'])) { $where[] = 'pm.status_bantuan = ?'; $params[] = $_GET['status']; }
if (!empty($_GET['kecamatan'])) { $where[] = 'pm.kecamatan = ?'; $params[] = $_GET['kecamatan']; }
if (!empty($_GET['ibadah_id'])) { $where[] = 'pm.rumah_ibadah_id = ?'; $params[] = (int)$_GET['ibadah_id']; }
if (!empty($_GET['dari'])) { $where[] = 'DATE(pm.created_at) >= ?'; $params[] = $_GET['dari']; }
if (!empty($_GET['sampai'])) { $where[] = 'DATE(pm.created_at) <= ?'; $params[] = $_GET['sampai']; }
$ws = implode(' AND ', $where);
$stmt = $db->prepare("
SELECT pm.id, pm.kk_nama, pm.nik, pm.jumlah_anggota, pm.alamat,
pm.kelurahan, pm.kecamatan, pm.lat, pm.lng,
pm.status_bantuan, pm.jenis_bantuan, pm.tanggal_bantuan, pm.nominal_bantuan, pm.jarak_ke_ibadah,
pm.created_at, ri.nama AS ibadah_nama, ri.jenis AS ibadah_jenis
FROM penduduk_miskin pm
LEFT JOIN rumah_ibadah ri ON pm.rumah_ibadah_id = ri.id
WHERE $ws ORDER BY pm.kecamatan, pm.kk_nama
");
$stmt->execute($params);
$rows = $stmt->fetchAll();
if ($format === 'csv') {
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="penduduk_miskin_' . date('Ymd_His') . '.csv"');
$out = fopen('php://output', 'w');
// BOM for Excel UTF-8
fputs($out, "\xEF\xBB\xBF");
fputcsv($out, ['No','Nama KK','NIK','Jumlah Anggota','Alamat','Kelurahan','Kecamatan',
'Latitude','Longitude','Status Bantuan','Jenis Bantuan','Tanggal Bantuan','Nominal Bantuan',
'Jarak ke Ibadah (m)','Rumah Ibadah','Jenis Ibadah','Terdaftar']);
foreach ($rows as $i => $row) {
fputcsv($out, [
$i + 1,
$row['kk_nama'],
$row['nik'] ?: '',
$row['jumlah_anggota'],
$row['alamat'] ?: '',
$row['kelurahan'] ?: '',
$row['kecamatan'] ?: '',
$row['lat'],
$row['lng'],
$row['status_bantuan'],
$row['jenis_bantuan'] ?: '',
$row['tanggal_bantuan'] ?: '',
$row['nominal_bantuan'] ? $row['nominal_bantuan'] : '',
$row['jarak_ke_ibadah'] ? round($row['jarak_ke_ibadah']) : '',
$row['ibadah_nama'] ?: '',
$row['ibadah_jenis'] ?: '',
$row['created_at'],
]);
}
fclose($out);
exit;
}
// Print / PDF view
$title = 'Data Penduduk Miskin';
$subtitle = 'WebGIS Sebaran Penduduk Miskin — ' . APP_NAME;
$cols = ['No', 'Nama KK', 'NIK', 'Anggota', 'Kecamatan', 'Status', 'Nominal Bantuan', 'Rumah Ibadah', 'Jarak'];
$tableRows = array_map(function($r, $i) {
$sc = ['sudah' => '#22c55e', 'menunggu' => '#f59e0b', 'belum' => '#ef4444'][$r['status_bantuan']] ?? '#ef4444';
return [$i + 1, $r['kk_nama'], $r['nik'] ?: '—', $r['jumlah_anggota'],
$r['kecamatan'] ?: '—',
"<span style='color:{$sc};font-weight:700'>".ucfirst($r['status_bantuan']).'</span>',
$r['nominal_bantuan'] ? 'Rp '.number_format($r['nominal_bantuan'],0,',','.') : '—',
$r['ibadah_nama'] ?: '—',
$r['jarak_ke_ibadah'] ? round($r['jarak_ke_ibadah']).' m' : '—'];
}, $rows, array_keys($rows));
}
// ── Query Rumah Ibadah ────────────────────────────────────────
if ($type === 'ibadah') {
$where = ['1=1'];
$params = [];
if (!empty($_GET['jenis'])) { $where[] = 'jenis = ?'; $params[] = $_GET['jenis']; }
if (!empty($_GET['kecamatan'])) { $where[] = 'kecamatan = ?'; $params[] = $_GET['kecamatan']; }
$ws = implode(' AND ', $where);
$stmt = $db->prepare("
SELECT ri.*,
(SELECT COUNT(*) FROM penduduk_miskin pm WHERE pm.rumah_ibadah_id = ri.id) AS jumlah_binaan
FROM rumah_ibadah ri WHERE $ws ORDER BY jenis, nama
");
$stmt->execute($params);
$rows = $stmt->fetchAll();
if ($format === 'csv') {
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="rumah_ibadah_' . date('Ymd_His') . '.csv"');
$out = fopen('php://output', 'w');
fputs($out, "\xEF\xBB\xBF");
fputcsv($out, ['No','Nama','Jenis','Kontak','Alamat','Kelurahan','Kecamatan',
'Latitude','Longitude','Radius (m)','Jumlah Binaan','Terdaftar']);
foreach ($rows as $i => $row) {
fputcsv($out, [
$i + 1, $row['nama'], $row['jenis'], $row['kontak'] ?: '',
$row['alamat'] ?: '', $row['kelurahan'] ?: '', $row['kecamatan'] ?: '',
$row['lat'], $row['lng'], $row['radius'], $row['jumlah_binaan'], $row['created_at']
]);
}
fclose($out);
exit;
}
$title = 'Data Rumah Ibadah';
$subtitle = 'WebGIS Sebaran Penduduk Miskin — ' . APP_NAME;
$cols = ['No', 'Nama', 'Jenis', 'Kontak', 'Kecamatan', 'Radius', 'Binaan'];
$tableRows = array_map(function($r, $i) {
return [$i + 1, $r['nama'], $r['jenis'], $r['kontak'] ?: '—',
$r['kecamatan'] ?: '—', formatRadius($r['radius']), $r['jumlah_binaan']];
}, $rows, array_keys($rows));
}
// ── HTML Print View ───────────────────────────────────────────
$total = count($rows);
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $title ?></title>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Plus Jakarta Sans', sans-serif; background: #fff; color: #1a1a2e; padding: 20px; font-size: 12px; }
.print-header { text-align: center; margin-bottom: 24px; border-bottom: 2px solid #5b7fff; padding-bottom: 16px; }
.print-header h1 { font-size: 20px; font-weight: 800; color: #1a1a2e; }
.print-header p { font-size: 12px; color: #666; margin-top: 4px; }
.print-meta { display: flex; gap: 16px; justify-content: center; margin-top: 8px; flex-wrap: wrap; font-size: 11px; color: #555; }
table { width: 100%; border-collapse: collapse; margin-top: 16px; }
th { background: #5b7fff; color: #fff; padding: 8px 10px; text-align: left; font-size: 11px; font-weight: 600; }
td { padding: 6px 10px; border-bottom: 1px solid #eee; font-size: 11px; vertical-align: top; }
tr:nth-child(even) td { background: #f8f9ff; }
.print-footer { margin-top: 20px; text-align: right; font-size: 10px; color: #999; }
.no-print { position: fixed; top: 20px; right: 20px; display: flex; gap: 8px; z-index: 999; }
.btn-print { background: #5b7fff; color: #fff; border: none; padding: 10px 20px; border-radius: 8px; cursor: pointer; font-family: inherit; font-weight: 600; font-size: 13px; }
.btn-close-p { background: #ef4444; color: #fff; border: none; padding: 10px 20px; border-radius: 8px; cursor: pointer; font-family: inherit; font-weight: 600; font-size: 13px; }
@media print { .no-print { display: none; } body { padding: 0; } }
</style>
</head>
<body>
<div class="no-print">
<button class="btn-print" onclick="window.print()">🖨️ Cetak / Save PDF</button>
<button class="btn-close-p" onclick="window.close()">✕ Tutup</button>
</div>
<div class="print-header">
<h1><?= htmlspecialchars($title) ?></h1>
<p><?= htmlspecialchars($subtitle) ?></p>
<div class="print-meta">
<span>📅 Dicetak: <?= date('d F Y, H:i') ?> WIB</span>
<span>📊 Total: <?= $total ?> data</span>
<?php if (!empty($_GET['status'])): ?>
<span>🔍 Status: <?= htmlspecialchars(ucfirst($_GET['status'])) ?></span>
<?php endif; ?>
<?php if (!empty($_GET['kecamatan'])): ?>
<span>📍 Kecamatan: <?= htmlspecialchars($_GET['kecamatan']) ?></span>
<?php endif; ?>
</div>
</div>
<table>
<thead>
<tr><?php foreach ($cols as $c) echo "<th>$c</th>"; ?></tr>
</thead>
<tbody>
<?php foreach ($tableRows as $tr): ?>
<tr><?php foreach ($tr as $td) echo "<td>$td</td>"; ?></tr>
<?php endforeach; ?>
<?php if (empty($tableRows)): ?>
<tr><td colspan="<?= count($cols) ?>" style="text-align:center;padding:20px;color:#999;">Tidak ada data</td></tr>
<?php endif; ?>
</tbody>
</table>
<div class="print-footer">
<?= APP_NAME ?> v<?= APP_VERSION ?> — <?= BASE_URL ?>
</div>
<script>
// Auto-trigger print dialog for print format
<?php if ($format === 'print'): ?>
window.addEventListener('load', () => setTimeout(() => {}, 500));
<?php endif; ?>
</script>
</body>
</html>
+44
View File
@@ -0,0 +1,44 @@
<?php
// ================================================================
// API: Reverse Geocoding Proxy (Nominatim)
// ================================================================
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
$lat = isset($_GET['lat']) ? (float)$_GET['lat'] : null;
$lng = isset($_GET['lng']) ? (float)$_GET['lng'] : null;
if (!$lat || !$lng) {
http_response_code(400);
echo json_encode(['error' => 'lat dan lng wajib']);
exit;
}
$url = "https://nominatim.openstreetmap.org/reverse?format=json&lat={$lat}&lon={$lng}&addressdetails=1&accept-language=id";
$opts = ['http' => ['header' => "User-Agent: WebGIS-PovertyMap/2.0 (https://github.com)\r\n", 'timeout' => 10]];
$context = stream_context_create($opts);
$result = @file_get_contents($url, false, $context);
if ($result === false) {
echo json_encode(['display_name' => "{$lat}, {$lng}", 'fallback' => true]);
exit;
}
$data = json_decode($result, true);
$addr = $data['display_name'] ?? "{$lat}, {$lng}";
// Ekstrak komponen alamat
$components = $data['address'] ?? [];
$kelurahan = $components['suburb'] ?? $components['village'] ?? $components['neighbourhood'] ?? '';
$kecamatan = $components['city_district'] ?? $components['district'] ?? '';
$kota = $components['city'] ?? $components['town'] ?? $components['county'] ?? '';
echo json_encode([
'display_name' => $addr,
'kelurahan' => $kelurahan,
'kecamatan' => $kecamatan,
'kota' => $kota,
'lat' => $lat,
'lng' => $lng,
]);
+184
View File
@@ -0,0 +1,184 @@
<?php
// ================================================================
// API: Import Data Massal dari CSV
// ================================================================
require_once __DIR__ . '/../config/database.php';
require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/functions.php';
header('Content-Type: application/json; charset=utf-8');
// Hanya POST
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'Method tidak didukung']);
exit;
}
$db = getDB();
$type = $_POST['type'] ?? ''; // 'penduduk' | 'ibadah'
if (!in_array($type, ['penduduk', 'ibadah'])) {
echo json_encode(['success' => false, 'message' => 'Tipe import tidak valid']); exit;
}
if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
echo json_encode(['success' => false, 'message' => 'File tidak valid atau tidak ada']); exit;
}
// Baca isi file
$content = file_get_contents($_FILES['file']['tmp_name']);
if ($content === false) {
echo json_encode(['success' => false, 'message' => 'Gagal membaca file']); exit;
}
// Normalize line endings & parse CSV
$content = str_replace("\r\n", "\n", $content);
$content = str_replace("\r", "\n", $content);
$lines = array_filter(explode("\n", $content), 'strlen');
$lines = array_values($lines);
if (empty($lines)) {
echo json_encode(['success' => false, 'message' => 'File kosong']); exit;
}
// Parse CSV baris per baris
function parseCSVLine(string $line): array {
$cols = [];
$cur = '';
$inQ = false;
for ($i = 0; $i < strlen($line); $i++) {
$ch = $line[$i];
if ($ch === '"') { $inQ = !$inQ; }
elseif ($ch === ',' && !$inQ) { $cols[] = trim($cur, " \t\""); $cur = ''; }
else { $cur .= $ch; }
}
$cols[] = trim($cur, " \t\"");
return $cols;
}
// Deteksi header row
$firstLine = strtolower($lines[0]);
$hasHeader = str_contains($firstLine, 'nama') || str_contains($firstLine, 'lat') || str_contains($firstLine, 'kk_nama');
$dataLines = $hasHeader ? array_slice($lines, 1) : $lines;
$imported = 0;
$failed = 0;
$skipped = 0;
$errors = [];
// ── Import Penduduk Miskin ─────────────────────────────────────
if ($type === 'penduduk') {
$stmt = $db->prepare("
INSERT INTO penduduk_miskin
(kk_nama, nik, jumlah_anggota, alamat, kelurahan, kecamatan, lat, lng,
rumah_ibadah_id, jarak_ke_ibadah, status_bantuan, jenis_bantuan, tanggal_bantuan, nominal_bantuan)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
");
foreach ($dataLines as $lineNum => $line) {
if (!trim($line)) continue;
$row = parseCSVLine($line);
// kk_nama (0), nik (1), jumlah_anggota (2), alamat (3), kelurahan (4),
// kecamatan (5), lat (6), lng (7), status_bantuan (8), jenis_bantuan (9),
// tanggal_bantuan (10), nominal_bantuan (11)
if (count($row) < 8 || empty($row[0]) || empty($row[6]) || empty($row[7])) {
$failed++;
$errors[] = "Baris " . ($lineNum + ($hasHeader ? 2 : 1)) . ": kolom tidak lengkap atau koordinat kosong";
if (count($errors) > 10) { $errors[] = '... dan lebih banyak error'; break; }
continue;
}
$lat = (float)$row[6];
$lng = (float)$row[7];
if ($lat === 0.0 && $lng === 0.0) { $skipped++; continue; }
$status = in_array($row[8] ?? '', ['sudah','belum','menunggu']) ? $row[8] : 'belum';
$tanggal_bantuan = !empty($row[10]) ? sanitize($row[10]) : null;
$nominal_bantuan = isset($row[11]) && $row[11] !== '' ? (float)str_replace(['.', ','], ['', '.'], $row[11]) : null;
$nearest = findNearestIbadah($lat, $lng);
$ibadahId = $nearest ? (int)$nearest['id'] : null;
$jarakIbadah = $nearest ? (float)$nearest['jarak'] : null;
try {
$stmt->execute([
sanitize($row[0]),
sanitize($row[1] ?? ''),
max(1, (int)($row[2] ?? 1)),
sanitize($row[3] ?? ''),
sanitize($row[4] ?? ''),
sanitize($row[5] ?? ''),
$lat, $lng,
$ibadahId,
$jarakIbadah,
$status,
sanitize($row[9] ?? ''),
$tanggal_bantuan,
$nominal_bantuan,
]);
$imported++;
} catch (PDOException $e) {
$failed++;
$errors[] = "Baris " . ($lineNum + ($hasHeader ? 2 : 1)) . ": " . $e->getMessage();
}
}
}
// ── Import Rumah Ibadah ───────────────────────────────────────
if ($type === 'ibadah') {
$validJenis = ['Masjid','Musholla','Gereja','Katolik','Pura','Vihara','Klenteng','Lainnya'];
$stmt = $db->prepare("
INSERT INTO rumah_ibadah (nama, jenis, kontak, alamat, kelurahan, kecamatan, lat, lng, radius)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
");
foreach ($dataLines as $lineNum => $line) {
if (!trim($line)) continue;
$row = parseCSVLine($line);
// nama (0), jenis (1), kontak (2), alamat (3), kelurahan (4),
// kecamatan (5), lat (6), lng (7), radius (8)
if (count($row) < 8 || empty($row[0]) || empty($row[6]) || empty($row[7])) {
$failed++;
$errors[] = "Baris " . ($lineNum + ($hasHeader ? 2 : 1)) . ": kolom tidak lengkap";
if (count($errors) > 10) { $errors[] = '... dan lebih banyak error'; break; }
continue;
}
$lat = (float)$row[6];
$lng = (float)$row[7];
if ($lat === 0.0 && $lng === 0.0) { $skipped++; continue; }
$jenis = in_array($row[1] ?? '', $validJenis) ? $row[1] : 'Lainnya';
$radius = max(100, min(5000, (int)($row[8] ?? RADIUS_DEFAULT)));
try {
$stmt->execute([
sanitize($row[0]),
$jenis,
sanitize($row[2] ?? ''),
sanitize($row[3] ?? ''),
sanitize($row[4] ?? ''),
sanitize($row[5] ?? ''),
$lat, $lng, $radius,
]);
$imported++;
} catch (PDOException $e) {
$failed++;
$errors[] = "Baris " . ($lineNum + ($hasHeader ? 2 : 1)) . ": " . $e->getMessage();
}
}
}
echo json_encode([
'success' => true,
'imported' => $imported,
'failed' => $failed,
'skipped' => $skipped,
'errors' => array_slice($errors, 0, 15),
'message' => "Import selesai: $imported berhasil, $failed gagal, $skipped dilewati",
], JSON_UNESCAPED_UNICODE);
+36
View File
@@ -0,0 +1,36 @@
<?php
// ================================================================
// API: Notify — Cek apakah ada data baru sejak timestamp tertentu
// ================================================================
require_once __DIR__ . '/../config/database.php';
require_once __DIR__ . '/../includes/functions.php';
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
$db = getDB();
$since = $_GET['since'] ?? date('Y-m-d H:i:s', strtotime('-1 minute'));
// Sanitasi sederhana
$since = preg_replace('/[^0-9\-: ]/', '', $since);
$newMiskin = (int)$db->prepare("SELECT COUNT(*) FROM penduduk_miskin WHERE created_at > ?")->execute([$since]) ? 0 : 0;
$stmtM = $db->prepare("SELECT COUNT(*) FROM penduduk_miskin WHERE created_at > ?");
$stmtM->execute([$since]);
$newMiskin = (int)$stmtM->fetchColumn();
$stmtI = $db->prepare("SELECT COUNT(*) FROM rumah_ibadah WHERE created_at > ?");
$stmtI->execute([$since]);
$newIbadah = (int)$stmtI->fetchColumn();
$stmtTotal = $db->query("SELECT COUNT(*) FROM penduduk_miskin WHERE status_bantuan='belum'");
$belumDibantu = (int)$stmtTotal->fetchColumn();
jsonResponse([
'success' => true,
'new_miskin' => $newMiskin,
'new_ibadah' => $newIbadah,
'belum_dibantu' => $belumDibantu,
'has_new' => ($newMiskin + $newIbadah) > 0,
'server_time' => date('Y-m-d H:i:s'),
]);
+275
View File
@@ -0,0 +1,275 @@
<?php
// ================================================================
// API: Penduduk Miskin
// Methods: GET, POST, PUT, DELETE
// ================================================================
require_once __DIR__ . '/../config/database.php';
require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/../includes/functions.php';
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204); exit;
}
$db = getDB();
$method = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
// ── GET ────────────────────────────────────────────────────────
if ($method === 'GET') {
if ($id) {
// Detail lengkap termasuk anggota keluarga dan info ibadah
$stmt = $db->prepare("
SELECT pm.*,
ri.nama AS ibadah_nama, ri.jenis AS ibadah_jenis,
ri.kontak AS ibadah_kontak, ri.lat AS ibadah_lat, ri.lng AS ibadah_lng
FROM penduduk_miskin pm
LEFT JOIN rumah_ibadah ri ON pm.rumah_ibadah_id = ri.id
WHERE pm.id = ?
");
$stmt->execute([$id]);
$row = $stmt->fetch();
if (!$row) jsonResponse(['success' => false, 'message' => 'Data tidak ditemukan'], 404);
// Anggota keluarga
$stmtAk = $db->prepare("SELECT * FROM anggota_keluarga WHERE penduduk_id = ? ORDER BY id");
$stmtAk->execute([$id]);
$row['anggota'] = $stmtAk->fetchAll();
jsonResponse(['success' => true, 'data' => $row]);
}
// List dengan filter + search
$where = ['1=1'];
$params = [];
if (!empty($_GET['status'])) {
$where[] = 'pm.status_bantuan = ?';
$params[] = $_GET['status'];
}
if (!empty($_GET['kecamatan'])) {
$where[] = 'pm.kecamatan = ?';
$params[] = $_GET['kecamatan'];
}
if (!empty($_GET['kelurahan'])) {
$where[] = 'pm.kelurahan = ?';
$params[] = $_GET['kelurahan'];
}
if (!empty($_GET['ibadah_id'])) {
$where[] = 'pm.rumah_ibadah_id = ?';
$params[] = (int)$_GET['ibadah_id'];
}
if (!empty($_GET['search'])) {
$where[] = '(pm.kk_nama LIKE ? OR pm.alamat LIKE ? OR pm.nik LIKE ?)';
$q = '%' . $_GET['search'] . '%';
$params = array_merge($params, [$q, $q, $q]);
}
$sql = "SELECT pm.*,
ri.nama AS ibadah_nama,
ri.jenis AS ibadah_jenis
FROM penduduk_miskin pm
LEFT JOIN rumah_ibadah ri ON pm.rumah_ibadah_id = ri.id
WHERE " . implode(' AND ', $where) . "
ORDER BY pm.created_at DESC";
$stmt = $db->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll();
jsonResponse(['success' => true, 'data' => $rows, 'total' => count($rows)]);
}
// ── POST ───────────────────────────────────────────────────────
if ($method === 'POST') {
$isMultipart = str_contains($_SERVER['CONTENT_TYPE'] ?? '', 'multipart/form-data');
$body = $isMultipart ? $_POST : (json_decode(file_get_contents('php://input'), true) ?? []);
$required = ['kk_nama', 'lat', 'lng'];
foreach ($required as $f) {
if (empty($body[$f])) jsonResponse(['success' => false, 'message' => "Field '$f' wajib diisi"], 422);
}
$lat = (float)$body['lat'];
$lng = (float)$body['lng'];
// Auto-cari rumah ibadah terdekat
$nearest = findNearestIbadah($lat, $lng);
$ibadahId = $nearest ? (int)$nearest['id'] : null;
$jarakIbadah = $nearest ? (float)$nearest['jarak'] : null;
// Handle upload bukti
$buktiFile = null;
if (!empty($_FILES['bukti_file']) && $_FILES['bukti_file']['error'] === UPLOAD_ERR_OK) {
$buktiFile = handleUpload($_FILES['bukti_file'], 'bukti');
}
$stmt = $db->prepare("
INSERT INTO penduduk_miskin
(kk_nama, nik, jumlah_anggota, alamat, kelurahan, kecamatan, lat, lng,
rumah_ibadah_id, jarak_ke_ibadah, status_bantuan, jenis_bantuan,
tanggal_bantuan, nominal_bantuan, bukti_file)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
");
$stmt->execute([
sanitize($body['kk_nama']),
sanitize($body['nik'] ?? ''),
(int)($body['jumlah_anggota'] ?? 1),
sanitize($body['alamat'] ?? ''),
sanitize($body['kelurahan'] ?? ''),
sanitize($body['kecamatan'] ?? ''),
$lat, $lng,
$ibadahId,
$jarakIbadah,
$body['status_bantuan'] ?? 'belum',
sanitize($body['jenis_bantuan'] ?? ''),
!empty($body['tanggal_bantuan']) ? $body['tanggal_bantuan'] : null,
!empty($body['nominal_bantuan']) ? (float)str_replace(['.', ','], ['', '.'], $body['nominal_bantuan']) : null,
$buktiFile,
]);
$newId = $db->lastInsertId();
// Simpan anggota keluarga jika ada (anggota bisa berupa JSON string dari FormData)
$anggotaData = $body['anggota'] ?? null;
if (is_string($anggotaData)) $anggotaData = json_decode($anggotaData, true);
if (!empty($anggotaData) && is_array($anggotaData)) {
saveAnggota($db, $newId, $anggotaData);
}
// Fetch full data
$new = $db->query("
SELECT pm.*, ri.nama AS ibadah_nama, ri.jenis AS ibadah_jenis
FROM penduduk_miskin pm
LEFT JOIN rumah_ibadah ri ON pm.rumah_ibadah_id = ri.id
WHERE pm.id = $newId
")->fetch();
jsonResponse(['success' => true, 'message' => 'Data penduduk berhasil ditambahkan', 'data' => $new], 201);
}
// ── PUT ────────────────────────────────────────────────────────
if ($method === 'PUT') {
if (!$id) jsonResponse(['success' => false, 'message' => 'ID wajib disertakan'], 400);
// Cek apakah multipart (ada file) atau JSON
$isMultipart = str_contains($_SERVER['CONTENT_TYPE'] ?? '', 'multipart/form-data');
$body = $isMultipart ? $_POST : (json_decode(file_get_contents('php://input'), true) ?? []);
$current = $db->prepare("SELECT * FROM penduduk_miskin WHERE id = ?");
$current->execute([$id]);
$existing = $current->fetch();
if (!$existing) jsonResponse(['success' => false, 'message' => 'Data tidak ditemukan'], 404);
$lat = (float)($body['lat'] ?? $existing['lat']);
$lng = (float)($body['lng'] ?? $existing['lng']);
// Re-assign ibadah terdekat jika koordinat berubah
$coordChanged = ($lat != (float)$existing['lat'] || $lng != (float)$existing['lng']);
if ($coordChanged || isset($body['force_reassign'])) {
$nearest = findNearestIbadah($lat, $lng);
$ibadahId = $nearest ? (int)$nearest['id'] : null;
$jarakIbadah = $nearest ? (float)$nearest['jarak'] : null;
} else {
$ibadahId = (int)($body['rumah_ibadah_id'] ?? $existing['rumah_ibadah_id']);
$jarakIbadah = (float)($existing['jarak_ke_ibadah'] ?? 0);
}
// Handle upload bukti baru
$buktiFile = $existing['bukti_file'];
if (!empty($_FILES['bukti_file']) && $_FILES['bukti_file']['error'] === UPLOAD_ERR_OK) {
$newFile = handleUpload($_FILES['bukti_file'], 'bukti');
if ($newFile) $buktiFile = $newFile;
}
$status_bantuan = $body['status_bantuan'] ?? $existing['status_bantuan'];
if ($status_bantuan === 'belum') {
$jenis_bantuan = null;
$tanggal_bantuan = null;
$nominal_bantuan = null;
} else {
$jenis_bantuan = isset($body['jenis_bantuan']) ? sanitize($body['jenis_bantuan']) : $existing['jenis_bantuan'];
$tanggal_bantuan = isset($body['tanggal_bantuan']) ? (!empty($body['tanggal_bantuan']) ? $body['tanggal_bantuan'] : null) : $existing['tanggal_bantuan'];
if (isset($body['nominal_bantuan'])) {
$nominal_bantuan = $body['nominal_bantuan'] !== '' ? (float)str_replace(['.', ','], ['', '.'], $body['nominal_bantuan']) : null;
} else {
$nominal_bantuan = $existing['nominal_bantuan'];
}
}
$stmt = $db->prepare("
UPDATE penduduk_miskin
SET kk_nama=?, nik=?, jumlah_anggota=?, alamat=?, kelurahan=?, kecamatan=?,
lat=?, lng=?, rumah_ibadah_id=?, jarak_ke_ibadah=?,
status_bantuan=?, jenis_bantuan=?, tanggal_bantuan=?, nominal_bantuan=?, bukti_file=?
WHERE id=?
");
$stmt->execute([
sanitize($body['kk_nama'] ?? $existing['kk_nama']),
sanitize($body['nik'] ?? $existing['nik'] ?? ''),
(int)($body['jumlah_anggota'] ?? $existing['jumlah_anggota']),
sanitize($body['alamat'] ?? $existing['alamat'] ?? ''),
sanitize($body['kelurahan'] ?? $existing['kelurahan'] ?? ''),
sanitize($body['kecamatan'] ?? $existing['kecamatan'] ?? ''),
$lat, $lng,
$ibadahId, $jarakIbadah,
$status_bantuan,
$jenis_bantuan,
$tanggal_bantuan,
$nominal_bantuan,
$buktiFile,
$id,
]);
// Update anggota keluarga (anggota bisa berupa JSON string dari FormData)
if (isset($body['anggota'])) {
$anggotaDataPut = $body['anggota'];
if (is_string($anggotaDataPut)) $anggotaDataPut = json_decode($anggotaDataPut, true);
if (is_array($anggotaDataPut)) {
$db->prepare("DELETE FROM anggota_keluarga WHERE penduduk_id = ?")->execute([$id]);
saveAnggota($db, $id, $anggotaDataPut);
}
}
$updated = $db->query("
SELECT pm.*, ri.nama AS ibadah_nama, ri.jenis AS ibadah_jenis
FROM penduduk_miskin pm
LEFT JOIN rumah_ibadah ri ON pm.rumah_ibadah_id = ri.id
WHERE pm.id = $id
")->fetch();
jsonResponse(['success' => true, 'message' => 'Data berhasil diperbarui', 'data' => $updated]);
}
// ── DELETE ─────────────────────────────────────────────────────
if ($method === 'DELETE') {
if (!$id) jsonResponse(['success' => false, 'message' => 'ID wajib disertakan'], 400);
$stmt = $db->prepare("DELETE FROM penduduk_miskin WHERE id = ?");
$stmt->execute([$id]);
if ($stmt->rowCount() === 0) jsonResponse(['success' => false, 'message' => 'Data tidak ditemukan'], 404);
jsonResponse(['success' => true, 'message' => 'Data berhasil dihapus']);
}
// ── Helper ─────────────────────────────────────────────────────
function saveAnggota(PDO $db, int $pendudukId, array $list): void {
$stmt = $db->prepare("
INSERT INTO anggota_keluarga (penduduk_id, nama, hubungan, umur, pekerjaan, keterangan)
VALUES (?,?,?,?,?,?)
");
foreach ($list as $a) {
if (empty($a['nama'])) continue;
$stmt->execute([
$pendudukId,
sanitize($a['nama'] ?? ''),
sanitize($a['hubungan'] ?? ''),
!empty($a['umur']) ? (int)$a['umur'] : null,
sanitize($a['pekerjaan'] ?? ''),
sanitize($a['keterangan'] ?? ''),
]);
}
}
jsonResponse(['success' => false, 'message' => 'Method tidak didukung'], 405);
+148
View File
@@ -0,0 +1,148 @@
<?php
// ================================================================
// API: Rumah Ibadah
// Methods: GET, POST, PUT, DELETE
// ================================================================
require_once __DIR__ . '/../config/database.php';
require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/../includes/functions.php';
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
$db = getDB();
$method = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
// ── GET ────────────────────────────────────────────────────────
if ($method === 'GET') {
if ($id) {
// Single record
$stmt = $db->prepare("
SELECT ri.*,
(SELECT COUNT(*) FROM penduduk_miskin pm WHERE pm.rumah_ibadah_id = ri.id) AS jumlah_binaan
FROM rumah_ibadah ri WHERE ri.id = ?
");
$stmt->execute([$id]);
$row = $stmt->fetch();
if (!$row) jsonResponse(['success' => false, 'message' => 'Data tidak ditemukan'], 404);
jsonResponse(['success' => true, 'data' => $row]);
}
// List dengan filter
$where = ['1=1'];
$params = [];
if (!empty($_GET['jenis'])) {
$where[] = 'jenis = ?';
$params[] = $_GET['jenis'];
}
if (!empty($_GET['kecamatan'])) {
$where[] = 'kecamatan = ?';
$params[] = $_GET['kecamatan'];
}
if (!empty($_GET['search'])) {
$where[] = 'nama LIKE ?';
$params[] = '%' . $_GET['search'] . '%';
}
$sql = "SELECT ri.*,
(SELECT COUNT(*) FROM penduduk_miskin pm WHERE pm.rumah_ibadah_id = ri.id) AS jumlah_binaan
FROM rumah_ibadah ri
WHERE " . implode(' AND ', $where) . "
ORDER BY ri.nama ASC";
$stmt = $db->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll();
jsonResponse(['success' => true, 'data' => $rows, 'total' => count($rows)]);
}
// ── POST ───────────────────────────────────────────────────────
if ($method === 'POST') {
$body = json_decode(file_get_contents('php://input'), true) ?? $_POST;
$required = ['nama', 'jenis', 'lat', 'lng'];
foreach ($required as $f) {
if (empty($body[$f])) jsonResponse(['success' => false, 'message' => "Field '$f' wajib diisi"], 422);
}
$stmt = $db->prepare("
INSERT INTO rumah_ibadah (nama, jenis, kontak, alamat, kelurahan, kecamatan, lat, lng, radius)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
");
$stmt->execute([
sanitize($body['nama']),
$body['jenis'],
sanitize($body['kontak'] ?? ''),
sanitize($body['alamat'] ?? ''),
sanitize($body['kelurahan'] ?? ''),
sanitize($body['kecamatan'] ?? ''),
(float)$body['lat'],
(float)$body['lng'],
(int)($body['radius'] ?? RADIUS_DEFAULT),
]);
$newId = $db->lastInsertId();
$new = $db->query("SELECT * FROM rumah_ibadah WHERE id = $newId")->fetch();
jsonResponse(['success' => true, 'message' => 'Rumah ibadah berhasil ditambahkan', 'data' => $new], 201);
}
// ── PUT ────────────────────────────────────────────────────────
if ($method === 'PUT') {
if (!$id) jsonResponse(['success' => false, 'message' => 'ID wajib disertakan'], 400);
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$stmt = $db->prepare("
UPDATE rumah_ibadah
SET nama=?, jenis=?, kontak=?, alamat=?, kelurahan=?, kecamatan=?, lat=?, lng=?, radius=?
WHERE id=?
");
$stmt->execute([
sanitize($body['nama'] ?? ''),
$body['jenis'] ?? 'Masjid',
sanitize($body['kontak'] ?? ''),
sanitize($body['alamat'] ?? ''),
sanitize($body['kelurahan'] ?? ''),
sanitize($body['kecamatan'] ?? ''),
(float)($body['lat'] ?? 0),
(float)($body['lng'] ?? 0),
(int)($body['radius'] ?? RADIUS_DEFAULT),
$id,
]);
// Re-assign penduduk yang terhubung jika radius berubah
reassignPendudukAfterIbadahUpdate();
$updated = $db->query("SELECT * FROM rumah_ibadah WHERE id = $id")->fetch();
jsonResponse(['success' => true, 'message' => 'Data berhasil diperbarui', 'data' => $updated]);
}
// ── DELETE ─────────────────────────────────────────────────────
if ($method === 'DELETE') {
if (!$id) jsonResponse(['success' => false, 'message' => 'ID wajib disertakan'], 400);
$stmt = $db->prepare("DELETE FROM rumah_ibadah WHERE id = ?");
$stmt->execute([$id]);
if ($stmt->rowCount() === 0) jsonResponse(['success' => false, 'message' => 'Data tidak ditemukan'], 404);
jsonResponse(['success' => true, 'message' => 'Data berhasil dihapus']);
}
function reassignPendudukAfterIbadahUpdate(): void {
$db = getDB();
$penduduk = $db->query("SELECT id, lat, lng FROM penduduk_miskin")->fetchAll();
foreach ($penduduk as $p) {
$nearest = findNearestIbadah((float)$p['lat'], (float)$p['lng']);
if ($nearest) {
$stmt = $db->prepare("UPDATE penduduk_miskin SET rumah_ibadah_id=?, jarak_ke_ibadah=? WHERE id=?");
$stmt->execute([$nearest['id'], $nearest['jarak'], $p['id']]);
}
}
}
jsonResponse(['success' => false, 'message' => 'Method tidak didukung'], 405);
+63
View File
@@ -0,0 +1,63 @@
<?php
// ================================================================
// API: Statistics
// ================================================================
require_once __DIR__ . '/../config/database.php';
require_once __DIR__ . '/../includes/functions.php';
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
$db = getDB();
// Total counts
$totalIbadah = $db->query("SELECT COUNT(*) FROM rumah_ibadah")->fetchColumn();
$totalMiskin = $db->query("SELECT COUNT(*) FROM penduduk_miskin")->fetchColumn();
$sudahDibantu = $db->query("SELECT COUNT(*) FROM penduduk_miskin WHERE status_bantuan='sudah'")->fetchColumn();
$belumDibantu = $db->query("SELECT COUNT(*) FROM penduduk_miskin WHERE status_bantuan='belum'")->fetchColumn();
$menunggu = $db->query("SELECT COUNT(*) FROM penduduk_miskin WHERE status_bantuan='menunggu'")->fetchColumn();
$totalNominal = $db->query("SELECT COALESCE(SUM(nominal_bantuan),0) FROM penduduk_miskin WHERE status_bantuan='sudah' AND nominal_bantuan IS NOT NULL")->fetchColumn();
$totalNominalAll = $db->query("SELECT COALESCE(SUM(nominal_bantuan),0) FROM penduduk_miskin WHERE nominal_bantuan IS NOT NULL")->fetchColumn();
// Per jenis ibadah
$jenisStats = $db->query("
SELECT jenis, COUNT(*) as total,
(SELECT COUNT(*) FROM penduduk_miskin pm WHERE pm.rumah_ibadah_id = ri.id) AS binaan
FROM rumah_ibadah ri
GROUP BY jenis ORDER BY total DESC
")->fetchAll();
// Per kecamatan (top 5)
$kecamatanStats = $db->query("
SELECT kecamatan, COUNT(*) as total,
SUM(CASE WHEN status_bantuan='sudah' THEN 1 ELSE 0 END) AS sudah,
SUM(CASE WHEN status_bantuan='belum' THEN 1 ELSE 0 END) AS belum
FROM penduduk_miskin
WHERE kecamatan != '' AND kecamatan IS NOT NULL
GROUP BY kecamatan ORDER BY total DESC LIMIT 5
")->fetchAll();
// Data terbaru (5 data)
$recentMiskin = $db->query("
SELECT pm.id, pm.kk_nama, pm.status_bantuan, pm.created_at,
ri.nama AS ibadah_nama
FROM penduduk_miskin pm
LEFT JOIN rumah_ibadah ri ON pm.rumah_ibadah_id = ri.id
ORDER BY pm.created_at DESC LIMIT 5
")->fetchAll();
jsonResponse([
'success' => true,
'data' => [
'total_ibadah' => (int)$totalIbadah,
'total_miskin' => (int)$totalMiskin,
'sudah_dibantu' => (int)$sudahDibantu,
'belum_dibantu' => (int)$belumDibantu,
'menunggu' => (int)$menunggu,
'total_nominal' => (float)$totalNominal,
'total_nominal_all' => (float)$totalNominalAll,
'jenis_stats' => $jenisStats,
'kecamatan_stats' => $kecamatanStats,
'recent_miskin' => $recentMiskin,
]
]);
+31
View File
@@ -0,0 +1,31 @@
<?php
// ================================================================
// API: File Upload
// ================================================================
require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/../includes/functions.php';
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['success' => false, 'message' => 'Method harus POST'], 405);
}
if (empty($_FILES['file'])) {
jsonResponse(['success' => false, 'message' => 'Tidak ada file yang diupload'], 400);
}
$file = $_FILES['file'];
$prefix = sanitize($_POST['prefix'] ?? 'upload');
$filename = handleUpload($file, $prefix);
if (!$filename) {
jsonResponse(['success' => false, 'message' => 'Upload gagal. Pastikan tipe file dan ukuran sesuai (maks 10MB, tipe: jpg/jpeg/png/gif/pdf/doc/docx)'], 422);
}
jsonResponse([
'success' => true,
'filename' => $filename,
'url' => UPLOAD_URL . $filename,
'message' => 'File berhasil diupload',
]);
+132
View File
@@ -0,0 +1,132 @@
<?php
// ================================================================
// API: User Management (CRUD)
// Hanya bisa diakses oleh admin
// ================================================================
require_once __DIR__ . '/../config/database.php';
require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/functions.php';
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
// Auth check — hanya admin
session_name(SESSION_NAME);
session_start();
$sessionRole = $_SESSION['user']['role'] ?? $_SESSION['user_role'] ?? null;
$sessionUserId = $_SESSION['user']['id'] ?? $_SESSION['user_id'] ?? null;
if (!$sessionUserId || $sessionRole !== 'admin') {
jsonResponse(['success' => false, 'message' => 'Akses ditolak: hanya admin'], 403);
}
$db = getDB();
$method = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
// ── GET ────────────────────────────────────────────────────────
if ($method === 'GET') {
if ($id) {
$stmt = $db->prepare("SELECT id, nama, email, role, created_at FROM users WHERE id = ?");
$stmt->execute([$id]);
$row = $stmt->fetch();
if (!$row) jsonResponse(['success' => false, 'message' => 'User tidak ditemukan'], 404);
jsonResponse(['success' => true, 'data' => $row]);
}
$rows = $db->query("SELECT id, nama, email, role, created_at FROM users ORDER BY role DESC, nama")->fetchAll();
jsonResponse(['success' => true, 'data' => $rows, 'total' => count($rows)]);
}
// ── POST (Tambah user baru) ────────────────────────────────────
if ($method === 'POST') {
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$nama = trim($body['nama'] ?? '');
$email = trim($body['email'] ?? '');
$pwd = $body['password'] ?? '';
$role = in_array($body['role'] ?? '', ['admin','operator']) ? $body['role'] : 'operator';
if (!$nama || !$email || !$pwd) {
jsonResponse(['success' => false, 'message' => 'Nama, email, dan password wajib diisi'], 422);
}
if (strlen($pwd) < 6) {
jsonResponse(['success' => false, 'message' => 'Password minimal 6 karakter'], 422);
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
jsonResponse(['success' => false, 'message' => 'Format email tidak valid'], 422);
}
// Cek duplikat email
$check = $db->prepare("SELECT id FROM users WHERE email = ?");
$check->execute([$email]);
if ($check->fetch()) {
jsonResponse(['success' => false, 'message' => 'Email sudah terdaftar'], 409);
}
$hash = password_hash($pwd, PASSWORD_BCRYPT);
$stmt = $db->prepare("INSERT INTO users (nama, email, password, role) VALUES (?, ?, ?, ?)");
$stmt->execute([$nama, $email, $hash, $role]);
$newId = $db->lastInsertId();
$new = $db->query("SELECT id, nama, email, role, created_at FROM users WHERE id = $newId")->fetch();
jsonResponse(['success' => true, 'message' => 'Pengguna berhasil ditambahkan', 'data' => $new], 201);
}
// ── PUT (Edit user) ────────────────────────────────────────────
if ($method === 'PUT') {
if (!$id) jsonResponse(['success' => false, 'message' => 'ID wajib disertakan'], 400);
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$existing = $db->prepare("SELECT * FROM users WHERE id = ?");
$existing->execute([$id]);
$user = $existing->fetch();
if (!$user) jsonResponse(['success' => false, 'message' => 'User tidak ditemukan'], 404);
$nama = trim($body['nama'] ?? $user['nama']);
$email = trim($body['email'] ?? $user['email']);
$role = in_array($body['role'] ?? '', ['admin','operator']) ? $body['role'] : $user['role'];
$pwd = $body['password'] ?? '';
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
jsonResponse(['success' => false, 'message' => 'Format email tidak valid'], 422);
}
// Cek duplikat email (kecuali user ini sendiri)
$checkEmail = $db->prepare("SELECT id FROM users WHERE email = ? AND id != ?");
$checkEmail->execute([$email, $id]);
if ($checkEmail->fetch()) {
jsonResponse(['success' => false, 'message' => 'Email sudah digunakan oleh user lain'], 409);
}
if ($pwd) {
if (strlen($pwd) < 6) jsonResponse(['success' => false, 'message' => 'Password minimal 6 karakter'], 422);
$hash = password_hash($pwd, PASSWORD_BCRYPT);
$stmt = $db->prepare("UPDATE users SET nama=?, email=?, role=?, password=? WHERE id=?");
$stmt->execute([$nama, $email, $role, $hash, $id]);
} else {
$stmt = $db->prepare("UPDATE users SET nama=?, email=?, role=? WHERE id=?");
$stmt->execute([$nama, $email, $role, $id]);
}
$updated = $db->query("SELECT id, nama, email, role, created_at FROM users WHERE id = $id")->fetch();
jsonResponse(['success' => true, 'message' => 'Data pengguna berhasil diperbarui', 'data' => $updated]);
}
// ── DELETE ─────────────────────────────────────────────────────
if ($method === 'DELETE') {
if (!$id) jsonResponse(['success' => false, 'message' => 'ID wajib disertakan'], 400);
// Tidak boleh hapus diri sendiri
if ($id == $sessionUserId) {
jsonResponse(['success' => false, 'message' => 'Tidak bisa menghapus akun sendiri'], 403);
}
$stmt = $db->prepare("DELETE FROM users WHERE id = ?");
$stmt->execute([$id]);
if ($stmt->rowCount() === 0) jsonResponse(['success' => false, 'message' => 'User tidak ditemukan'], 404);
jsonResponse(['success' => true, 'message' => 'Pengguna berhasil dihapus']);
}
jsonResponse(['success' => false, 'message' => 'Method tidak didukung'], 405);
+647
View File
@@ -0,0 +1,647 @@
// ═══════════════════════════════════════════════════════════════
// MAP SETUP
// ═══════════════════════════════════════════════════════════════
const map = L.map('map', { preferCanvas: false }).setView([-0.0263, 109.3425], 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© <a href="https://www.openstreetmap.org/copyright" style="color:#5b7fff">OpenStreetMap</a> contributors',
maxZoom: 19
}).addTo(map);
setTimeout(() => map.invalidateSize(), 150);
// ═══════════════════════════════════════════════════════════════
// STATE
// ═══════════════════════════════════════════════════════════════
let mode = null; // 'ibadah' | 'miskin' | null
let editMode = null; // { type:'ibadah'|'miskin', id } — saat edit
let ibadahList = [];
let miskinList = [];
let myMarker = null;
let pendingLatLng = null;
let pendingAddr = null;
let tempMarker = null;
// ═══════════════════════════════════════════════════════════════
// TOAST
// ═══════════════════════════════════════════════════════════════
function showToast(msg, color = '#5b7fff') {
const t = document.getElementById('toast');
t.innerHTML = msg;
t.style.display = 'block';
t.style.borderLeft = `3px solid ${color}`;
clearTimeout(t._timer);
t._timer = setTimeout(() => t.style.display = 'none', 3500);
}
// ═══════════════════════════════════════════════════════════════
// TOGGLE MODE + HIGHLIGHT
// ═══════════════════════════════════════════════════════════════
function setMode(m) {
if (mode === m) {
mode = null; clearHighlights(); updateToolbar(); resetMapCursor(); return;
}
mode = m; updateToolbar(); resetMapCursor();
if (m === 'ibadah') {
document.getElementById('map').classList.add('adding-ibadah');
highlightAll('ibadah');
showToast('🏛️ Klik peta untuk menentukan lokasi Rumah Ibadah', '#5b7fff');
} else {
document.getElementById('map').classList.add('adding-miskin');
highlightAll('miskin');
showToast('⛽ Klik peta untuk menentukan lokasi SPBU / Pangkalan BBM', '#f59e0b');
}
}
function resetMapCursor() {
document.getElementById('map').classList.remove('adding-ibadah', 'adding-miskin');
}
function updateToolbar() {
document.getElementById('toggle-ibadah').className = 'toggle-btn' + (mode === 'ibadah' ? ' active-ibadah' : '');
document.getElementById('toggle-miskin').className = 'toggle-btn' + (mode === 'miskin' ? ' active-miskin' : '');
}
function highlightAll(type) {
clearHighlights();
if (type === 'ibadah') {
ibadahList.forEach(ib => {
if (ib.circle) ib.circle.setStyle({ fillOpacity: 0.18, weight: 3, opacity: 1 });
pulseMarker(ib.marker);
const c = document.getElementById('card-ibadah-' + ib.id);
if (c) c.classList.add('highlighted');
});
} else {
miskinList.forEach(m => {
pulseMarker(m.marker);
const c = document.getElementById('card-miskin-' + m.id);
if (c) c.classList.add('highlighted');
});
}
}
function clearHighlights() {
ibadahList.forEach(ib => {
if (ib.circle) ib.circle.setStyle({ fillOpacity: 0.08, weight: 2, opacity: 0.8 });
const c = document.getElementById('card-ibadah-' + ib.id);
if (c) c.classList.remove('highlighted');
});
miskinList.forEach(m => {
const c = document.getElementById('card-miskin-' + m.id);
if (c) c.classList.remove('highlighted');
});
}
function pulseMarker(marker) {
if (!marker) return;
const el = marker.getElement(); if (!el) return;
el.classList.remove('leaflet-marker-pulsing');
void el.offsetWidth;
el.classList.add('leaflet-marker-pulsing');
setTimeout(() => el.classList.remove('leaflet-marker-pulsing'), 3000);
}
// ═══════════════════════════════════════════════════════════════
// REVERSE GEOCODING
// ═══════════════════════════════════════════════════════════════
async function reverseGeocode(lat, lng) {
try {
const r = await fetch(
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&addressdetails=1`,
{ headers: { 'Accept-Language': 'id' } }
);
const d = await r.json();
return d.display_name || `${lat.toFixed(5)}, ${lng.toFixed(5)}`;
} catch { return `${lat.toFixed(5)}, ${lng.toFixed(5)}`; }
}
// ═══════════════════════════════════════════════════════════════
// RADIUS: cari rumah ibadah TERDEKAT yang masih dalam radius
// ═══════════════════════════════════════════════════════════════
function getNearestIbadah(lat, lng) {
let nearest = null, minDist = Infinity;
for (const ib of ibadahList) {
const dist = map.distance([lat, lng], [ib.lat, ib.lng]);
if (dist <= ib.radius && dist < minDist) {
minDist = dist; nearest = ib;
}
}
return nearest; // null = di luar semua radius
}
function updateAllMiskinColors() {
miskinList.forEach(m => {
const nearest = getNearestIbadah(m.lat, m.lng);
m.inRadius = nearest !== null;
m.nearestIbadah = nearest ? nearest.id : null;
if (m.marker) m.marker.setIcon(createMiskinIcon(m.inRadius));
if (m.marker) m.marker.setPopupContent(buildMiskinPopup(m));
});
updateStats();
renderMiskinList();
}
// ═══════════════════════════════════════════════════════════════
// ICONS
// ═══════════════════════════════════════════════════════════════
function createIbadahIcon(emoji) {
return L.divIcon({
html: `<div style="background:linear-gradient(135deg,#5b7fff,#7c5bff);width:36px;height:36px;
border-radius:50% 50% 50% 0;transform:rotate(-45deg);border:3px solid #fff;
box-shadow:0 3px 10px rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;">
<span style="transform:rotate(45deg);font-size:16px;">${emoji}</span></div>`,
iconSize: [36,36], iconAnchor: [18,36], popupAnchor: [0,-40], className: ''
});
}
function createMiskinIcon(inRadius) {
// Icon SPBU — fuel pump style
const c = inRadius ? '#f59e0b' : '#ef4444';
const s = inRadius ? 'rgba(245,158,11,0.5)' : 'rgba(239,68,68,0.5)';
return L.divIcon({
html: `<div style="background:${c};width:22px;height:22px;border-radius:4px;
border:3px solid #fff;box-shadow:0 2px 8px ${s};
display:flex;align-items:center;justify-content:center;font-size:10px;">⛽</div>`,
iconSize: [22,22], iconAnchor: [11,11], popupAnchor: [0,-14], className: ''
});
}
function createTempIcon(type) {
if (type === 'ibadah') return L.divIcon({
html: `<div style="background:linear-gradient(135deg,#5b7fff,#7c5bff);opacity:0.55;
width:36px;height:36px;border-radius:50% 50% 50% 0;transform:rotate(-45deg);
border:3px dashed #fff;display:flex;align-items:center;justify-content:center;">
<span style="transform:rotate(45deg);font-size:16px;">❓</span></div>`,
iconSize: [36,36], iconAnchor: [18,36], className: ''
});
return L.divIcon({
html: `<div style="background:#8890b0;width:20px;height:20px;border-radius:50%;
border:3px dashed #fff;opacity:0.55;"></div>`,
iconSize: [20,20], iconAnchor: [10,10], className: ''
});
}
function createMyIcon() {
return L.divIcon({
html: `<div style="background:#f59e0b;width:24px;height:24px;border-radius:50%;
border:3px solid #fff;box-shadow:0 2px 12px rgba(245,158,11,0.6);animation:mypulse 1.5s infinite;"></div>
<style>@keyframes mypulse{0%,100%{box-shadow:0 0 0 0 rgba(245,158,11,0.6)}50%{box-shadow:0 0 0 8px rgba(245,158,11,0)}}</style>`,
iconSize: [24,24], iconAnchor: [12,12], popupAnchor: [0,-14], className: ''
});
}
// ═══════════════════════════════════════════════════════════════
// MAP CLICK → FORM POPUP
// ═══════════════════════════════════════════════════════════════
map.on('click', async (e) => {
if (!mode) return;
const { lat, lng } = e.latlng;
pendingLatLng = { lat, lng };
pendingAddr = null;
editMode = null; // ini bukan edit
if (tempMarker) map.removeLayer(tempMarker);
tempMarker = L.marker([lat, lng], { icon: createTempIcon(mode) }).addTo(map);
openFormOverlay(mode, lat, lng, null); // null = mode tambah baru
reverseGeocode(lat, lng).then(addr => {
pendingAddr = addr;
const el = document.getElementById(mode === 'ibadah' ? 'f-ibadah-addr' : 'f-miskin-addr');
if (el) el.innerHTML = `📍 ${addr}`;
});
});
// ═══════════════════════════════════════════════════════════════
// FORM OVERLAY — bisa untuk tambah BARU atau EDIT
// ═══════════════════════════════════════════════════════════════
function openFormOverlay(type, lat, lng, existingData) {
const isEdit = existingData !== null;
editMode = isEdit ? { type, id: existingData.id } : null;
const header = document.getElementById('form-header');
const title = document.getElementById('form-title');
const fI = document.getElementById('form-ibadah-fields');
const fM = document.getElementById('form-miskin-fields');
const saveBtn = document.getElementById('btn-form-save');
const coordText = `Lat: ${lat.toFixed(6)} Lng: ${lng.toFixed(6)}`;
if (type === 'ibadah') {
title.textContent = isEdit ? '✏️ Edit Rumah Ibadah' : '🏛️ Tambah Rumah Ibadah';
header.className = 'form-header ibadah-header';
fI.style.display = 'block';
fM.style.display = 'none';
document.getElementById('f-ibadah-coord').textContent = coordText;
document.getElementById('f-ibadah-name').value = isEdit ? existingData.name : '';
document.getElementById('f-ibadah-type').value = isEdit ? existingData.type : '🕌';
const rad = isEdit ? existingData.radius : 500;
document.getElementById('f-radius-slider').value = rad;
document.getElementById('f-radius-val').textContent = fmtRadius(rad);
if (isEdit) {
document.getElementById('f-ibadah-addr').innerHTML = `📍 ${existingData.addr}`;
pendingAddr = existingData.addr;
} else {
document.getElementById('f-ibadah-addr').innerHTML = '<span class="addr-loading">🔄 Memuat alamat...</span>';
}
saveBtn.onclick = isEdit ? applyEditIbadah : saveIbadah;
} else {
title.textContent = isEdit ? '✏️ Edit Penduduk Miskin' : '👤 Tambah Penduduk Miskin';
header.className = 'form-header miskin-header';
fI.style.display = 'none';
fM.style.display = 'block';
document.getElementById('f-miskin-coord').textContent = coordText;
document.getElementById('f-miskin-name').value = isEdit ? existingData.name : '';
if (isEdit) {
document.getElementById('f-miskin-addr').innerHTML = `📍 ${existingData.addr}`;
pendingAddr = existingData.addr;
} else {
document.getElementById('f-miskin-addr').innerHTML = '<span class="addr-loading">🔄 Memuat alamat...</span>';
}
saveBtn.onclick = isEdit ? applyEditMiskin : saveMiskin;
}
document.getElementById('form-overlay').classList.remove('hidden');
setTimeout(() => {
const inp = document.getElementById(type === 'ibadah' ? 'f-ibadah-name' : 'f-miskin-name');
if (inp) inp.focus();
}, 100);
}
window.closeFormOverlay = function () {
document.getElementById('form-overlay').classList.add('hidden');
if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; }
pendingLatLng = null; pendingAddr = null; editMode = null;
};
// radius slider in form
document.getElementById('f-radius-slider').addEventListener('input', function () {
document.getElementById('f-radius-val').textContent = fmtRadius(parseInt(this.value));
});
// SPBU radius slider
document.getElementById('f-radius-slider-spbu').addEventListener('input', function () {
document.getElementById('f-radius-val-spbu').textContent = fmtRadius(parseInt(this.value));
});
function fmtRadius(v) { return v >= 1000 ? (v/1000).toFixed(1)+' km' : v+' m'; }
// ═══════════════════════════════════════════════════════════════
// SAVE — TAMBAH IBADAH BARU
// ═══════════════════════════════════════════════════════════════
function saveIbadah() {
if (!pendingLatLng) return;
const { lat, lng } = pendingLatLng;
const name = document.getElementById('f-ibadah-name').value.trim() || 'Rumah Ibadah';
const type = document.getElementById('f-ibadah-type').value;
const radius = parseInt(document.getElementById('f-radius-slider').value);
const addr = pendingAddr || `${lat.toFixed(5)}, ${lng.toFixed(5)}`;
if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; }
// also get spbu radius slider for form that was open
const id = Date.now();
const marker = L.marker([lat, lng], { icon: createIbadahIcon(type) }).addTo(map);
const circle = L.circle([lat, lng], {
radius, color: '#5b7fff', fillColor: '#5b7fff',
fillOpacity: 0.08, weight: 2, dashArray: '6,4', opacity: 0.8
}).addTo(map);
const obj = { id, name, type, lat, lng, radius, addr, marker, circle };
marker.bindPopup(() => buildIbadahPopup(obj));
ibadahList.push(obj);
closeFormOverlay();
updateAllMiskinColors();
renderIbadahList();
updateStats();
setTimeout(() => {
pulseMarker(marker);
const c = document.getElementById('card-ibadah-' + id);
if (c) { c.classList.add('highlighted'); c.scrollIntoView({ behavior:'smooth', block:'nearest' }); }
}, 100);
showToast(`${type} ${name} ditambahkan`, '#5b7fff');
}
// ═══════════════════════════════════════════════════════════════
// EDIT — TERAPKAN EDIT IBADAH
// ═══════════════════════════════════════════════════════════════
function applyEditIbadah() {
if (!editMode) return;
const ib = ibadahList.find(x => x.id === editMode.id);
if (!ib) return;
ib.name = document.getElementById('f-ibadah-name').value.trim() || ib.name;
ib.type = document.getElementById('f-ibadah-type').value;
ib.radius = parseInt(document.getElementById('f-radius-slider').value);
// koordinat & addr tidak berubah saat edit (hanya metadata)
// Update marker icon & circle
ib.marker.setIcon(createIbadahIcon(ib.type));
ib.circle.setRadius(ib.radius);
ib.marker.setPopupContent(buildIbadahPopup(ib));
closeFormOverlay();
updateAllMiskinColors();
renderIbadahList();
updateStats();
showToast(`✏️ ${ib.type} ${ib.name} diperbarui`, '#5b7fff');
}
// ═══════════════════════════════════════════════════════════════
// SAVE — TAMBAH MISKIN BARU
// ═══════════════════════════════════════════════════════════════
function saveMiskin() {
if (!pendingLatLng) return;
const { lat, lng } = pendingLatLng;
const name = document.getElementById('f-miskin-name').value.trim() || 'SPBU';
const spbuType = document.getElementById('f-spbu-type') ? document.getElementById('f-spbu-type').value : '⛽';
const radius = parseInt(document.getElementById('f-radius-slider-spbu').value) || 1000;
const addr = pendingAddr || `${lat.toFixed(5)}, ${lng.toFixed(5)}`;
const nearest = getNearestIbadah(lat, lng);
if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; }
const id = Date.now();
const inRadius = nearest !== null;
const marker = L.marker([lat, lng], { icon: createMiskinIcon(true) }).addTo(map);
const circle = L.circle([lat, lng], {
radius, color: '#f59e0b', fillColor: '#f59e0b',
fillOpacity: 0.06, weight: 1.5, dashArray: '4,4', opacity: 0.7
}).addTo(map);
const obj = { id, name, type: spbuType, lat, lng, inRadius, nearestIbadah: nearest ? nearest.id : null, addr, marker, circle, radius };
marker.bindPopup(() => buildMiskinPopup(obj));
miskinList.push(obj);
closeFormOverlay();
updateStats();
renderMiskinList();
setTimeout(() => {
pulseMarker(marker);
const c = document.getElementById('card-miskin-' + id);
if (c) { c.classList.add('highlighted'); c.scrollIntoView({ behavior:'smooth', block:'nearest' }); }
}, 100);
showToast(`${spbuType} ${name} ditambahkan`, '#f59e0b');
}
// ═══════════════════════════════════════════════════════════════
// EDIT — TERAPKAN EDIT MISKIN
// ═══════════════════════════════════════════════════════════════
function applyEditMiskin() {
if (!editMode) return;
const m = miskinList.find(x => x.id === editMode.id);
if (!m) return;
m.name = document.getElementById('f-miskin-name').value.trim() || m.name;
m.marker.setPopupContent(buildMiskinPopup(m));
closeFormOverlay();
updateStats();
renderMiskinList();
showToast(`✏️ Data ${m.name} diperbarui`, '#22c55e');
}
// ═══════════════════════════════════════════════════════════════
// POPUP BUILDERS
// ═══════════════════════════════════════════════════════════════
function buildIbadahPopup(ib) {
return `<b>${ib.type} ${ib.name}</b>
<div class="popup-addr">📍 ${ib.addr}</div>
<div style="margin-top:6px;font-size:11px;color:#8890b0;">
Radius: <b style="color:#5b7fff">${fmtRadius(ib.radius)}</b>
&nbsp;·&nbsp; ${ib.lat.toFixed(5)}, ${ib.lng.toFixed(5)}
</div>
<div style="margin-top:8px;">
<span onclick="openEditIbadah(${ib.id})" style="cursor:pointer;font-size:11px;color:#5b7fff;
background:rgba(91,127,255,0.15);padding:3px 9px;border-radius:5px;font-weight:600;">
✏️ Edit
</span>
</div>`;
}
function buildMiskinPopup(m) {
const nearest = ibadahList.find(x => x.id === m.nearestIbadah);
const nearestLabel = nearest
? `<div style="margin-top:4px;font-size:11px;color:#5b7fff;">
📌 Terdekat: <b>${nearest.type} ${nearest.name}</b>
(${Math.round(map.distance([m.lat,m.lng],[nearest.lat,nearest.lng]))}m)
</div>`
: '';
return `<b>⛽ ${m.type || ''} ${m.name}</b>
<div style="margin-top:4px;">
<span style="background:rgba(245,158,11,0.2);color:#f59e0b;
padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;">
⛽ SPBU / Pangkalan BBM
</span>
</div>
${nearestLabel}
<div class="popup-addr">📍 ${m.addr}</div>
<div style="margin-top:5px;font-size:11px;color:#8890b0;">${m.lat.toFixed(5)}, ${m.lng.toFixed(5)}</div>
<div style="margin-top:8px;">
<span onclick="openEditMiskin(${m.id})" style="cursor:pointer;font-size:11px;color:#f59e0b;
background:rgba(245,158,11,0.15);padding:3px 9px;border-radius:5px;font-weight:600;">
✏️ Edit
</span>
</div>`;
}
// ═══════════════════════════════════════════════════════════════
// OPEN EDIT (dipanggil dari popup peta ATAU tombol edit di sidebar)
// ═══════════════════════════════════════════════════════════════
window.openEditIbadah = (id) => {
const ib = ibadahList.find(x => x.id === id); if (!ib) return;
map.closePopup();
pendingLatLng = { lat: ib.lat, lng: ib.lng };
openFormOverlay('ibadah', ib.lat, ib.lng, ib);
};
window.openEditMiskin = (id) => {
const m = miskinList.find(x => x.id === id); if (!m) return;
map.closePopup();
pendingLatLng = { lat: m.lat, lng: m.lng };
openFormOverlay('miskin', m.lat, m.lng, m);
};
// ═══════════════════════════════════════════════════════════════
// RENDER SIDEBAR
// ═══════════════════════════════════════════════════════════════
function renderIbadahList() {
const el = document.getElementById('ibadah-list');
document.getElementById('count-ibadah').textContent = ibadahList.length;
if (!ibadahList.length) { el.innerHTML = '<div class="empty-state">Belum ada rumah ibadah</div>'; return; }
el.innerHTML = ibadahList.map(ib => `
<div class="item-card" id="card-ibadah-${ib.id}" onclick="focusIbadah(${ib.id})">
<div class="item-icon">${ib.type}</div>
<div class="item-info">
<div class="item-name">${ib.name}</div>
<div class="item-addr">${truncate(ib.addr, 52)}</div>
<div class="item-coord">${ib.lat.toFixed(4)}, ${ib.lng.toFixed(4)}</div>
<!-- Live radius slider -->
<div class="item-radius-row" onclick="event.stopPropagation()">
<input type="range" min="100" max="5000" step="100" value="${ib.radius}"
oninput="liveRadius(${ib.id}, this.value)"
title="Geser untuk ubah radius">
<span class="radius-display" id="rdisplay-${ib.id}">${fmtRadius(ib.radius)}</span>
</div>
</div>
<div class="item-actions">
<button class="btn-edit" onclick="event.stopPropagation();openEditIbadah(${ib.id})" title="Edit">✏️</button>
<button class="btn-remove" onclick="event.stopPropagation();removeIbadah(${ib.id})" title="Hapus">✕</button>
</div>
</div>`
).join('');
}
function renderMiskinList() {
const el = document.getElementById('miskin-list');
document.getElementById('count-miskin').textContent = miskinList.length;
if (!miskinList.length) { el.innerHTML = '<div class="empty-state">Belum ada data SPBU</div>'; return; }
el.innerHTML = miskinList.map(m => {
const nearest = ibadahList.find(x => x.id === m.nearestIbadah);
const nearestHtml = nearest
? `<div class="nearest-tag">📌 ${nearest.type} ${truncate(nearest.name, 20)}</div>`
: '';
return `
<div class="item-card" id="card-miskin-${m.id}" onclick="focusMiskin(${m.id})">
<div class="item-icon">${m.type || '⛽'}</div>
<div class="item-info">
<div class="item-name">${m.name}</div>
<div class="item-addr">${truncate(m.addr, 52)}</div>
<div class="item-coord">${m.lat.toFixed(4)}, ${m.lng.toFixed(4)}</div>
${nearestHtml}
</div>
<div class="item-actions">
<span class="badge badge-yellow">⛽</span>
<button class="btn-edit" onclick="event.stopPropagation();openEditMiskin(${m.id})" title="Edit">✏️</button>
<button class="btn-remove" onclick="event.stopPropagation();removeMiskin(${m.id})" title="Hapus">✕</button>
</div>
</div>`;
}).join('');
}
// ─── Live radius update dari slider sidebar ───────────────────
window.liveRadius = (id, val) => {
const ib = ibadahList.find(x => x.id === id); if (!ib) return;
ib.radius = parseInt(val);
if (ib.circle) ib.circle.setRadius(ib.radius);
const disp = document.getElementById('rdisplay-' + id);
if (disp) disp.textContent = fmtRadius(ib.radius);
ib.marker.setPopupContent(buildIbadahPopup(ib));
updateAllMiskinColors(); // recalculate nearest
};
// ═══════════════════════════════════════════════════════════════
// FOCUS (klik card → pan + popup)
// ═══════════════════════════════════════════════════════════════
window.focusIbadah = (id) => {
const ib = ibadahList.find(x => x.id === id); if (!ib) return;
map.setView([ib.lat, ib.lng], Math.max(map.getZoom(), 15));
ib.marker.openPopup(); pulseMarker(ib.marker);
};
window.focusMiskin = (id) => {
const m = miskinList.find(x => x.id === id); if (!m) return;
map.setView([m.lat, m.lng], Math.max(map.getZoom(), 16));
m.marker.openPopup(); pulseMarker(m.marker);
};
// ═══════════════════════════════════════════════════════════════
// STATS
// ═══════════════════════════════════════════════════════════════
function updateStats() {
document.getElementById('stat-ibadah').textContent = ibadahList.length;
document.getElementById('stat-in').textContent = miskinList.length;
document.getElementById('stat-out').textContent = 0; // not used for SPBU
}
function truncate(s, n) { return s.length > n ? s.slice(0,n)+'…' : s; }
// ═══════════════════════════════════════════════════════════════
// REMOVE
// ═══════════════════════════════════════════════════════════════
window.removeIbadah = (id) => {
const i = ibadahList.findIndex(x => x.id === id); if (i < 0) return;
map.removeLayer(ibadahList[i].marker);
map.removeLayer(ibadahList[i].circle);
ibadahList.splice(i, 1);
updateAllMiskinColors(); renderIbadahList(); updateStats();
};
window.removeMiskin = (id) => {
const i = miskinList.findIndex(x => x.id === id); if (i < 0) return;
map.removeLayer(miskinList[i].marker);
if (miskinList[i].circle) map.removeLayer(miskinList[i].circle);
miskinList.splice(i, 1);
updateStats(); renderMiskinList();
};
// ═══════════════════════════════════════════════════════════════
// RESET
// ═══════════════════════════════════════════════════════════════
document.getElementById('btn-reset').addEventListener('click', () => {
ibadahList.forEach(ib => { map.removeLayer(ib.marker); map.removeLayer(ib.circle); });
miskinList.forEach(m => { map.removeLayer(m.marker); if (m.circle) map.removeLayer(m.circle); });
if (myMarker) { map.removeLayer(myMarker); myMarker = null; }
if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; }
ibadahList = []; miskinList = [];
mode = null; editMode = null; updateToolbar(); resetMapCursor();
document.getElementById('form-overlay').classList.add('hidden');
document.getElementById('my-location-info').style.display = 'none';
document.getElementById('loc-bar').style.display = 'none';
renderIbadahList(); renderMiskinList(); updateStats();
showToast('🗑️ Semua data dihapus', '#ef4444');
});
// ═══════════════════════════════════════════════════════════════
// GEOLOCATION
// ═══════════════════════════════════════════════════════════════
function doLocate() {
if (!navigator.geolocation) { showToast('❌ Browser tidak mendukung Geolocation', '#ef4444'); return; }
showToast('📡 Mendeteksi lokasi...', '#f59e0b');
navigator.geolocation.getCurrentPosition(async (pos) => {
const lat = pos.coords.latitude, lng = pos.coords.longitude;
if (myMarker) map.removeLayer(myMarker);
myMarker = L.marker([lat, lng], { icon: createMyIcon() }).addTo(map);
const addr = await reverseGeocode(lat, lng);
myMarker.bindPopup(`<b>💻 Lokasi Saya</b>
<div class="popup-addr">📍 ${addr}</div>
<div style="margin-top:5px;font-size:11px;color:#8890b0;">
±${Math.round(pos.coords.accuracy)}m · ${lat.toFixed(6)}, ${lng.toFixed(6)}
</div>`).openPopup();
map.setView([lat, lng], 15);
document.getElementById('my-location-info').style.display = 'block';
document.getElementById('my-addr').textContent = truncate(addr, 80);
document.getElementById('my-coord').textContent = `${lat.toFixed(6)}, ${lng.toFixed(6)} · ±${Math.round(pos.coords.accuracy)}m`;
const lb = document.getElementById('loc-bar');
lb.style.display = 'flex';
document.getElementById('loc-bar-addr').textContent = truncate(addr, 60);
document.getElementById('loc-bar-coord').textContent = `${lat.toFixed(5)}, ${lng.toFixed(5)}`;
showToast('✅ Lokasi berhasil ditemukan!', '#f59e0b');
}, (err) => {
const msgs = { 1:'Izin lokasi ditolak', 2:'Posisi tidak tersedia', 3:'Timeout' };
showToast('❌ ' + (msgs[err.code] || 'Gagal mendapatkan lokasi'), '#ef4444');
}, { enableHighAccuracy: false, timeout: 10000 });
}
document.getElementById('btn-locate').addEventListener('click', doLocate);
document.getElementById('btn-locate-map').addEventListener('click', doLocate);
// ═══════════════════════════════════════════════════════════════
// KEYBOARD
// ═══════════════════════════════════════════════════════════════
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
if (!document.getElementById('form-overlay').classList.contains('hidden')) {
closeFormOverlay();
} else {
mode = null; updateToolbar(); resetMapCursor(); clearHighlights();
}
}
});
+491
View File
@@ -0,0 +1,491 @@
/* ================================================================
Admin Panel CSS — WebGIS Sebaran Penduduk Miskin
Dark theme, Bootstrap 5 override, glassmorphism
================================================================ */
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap');
:root {
--bg: #0f1117;
--bg2: #161b27;
--bg3: #1e2538;
--bg4: #242c42;
--border: rgba(255,255,255,0.07);
--text: #e8ecf4;
--text2: #8890b0;
--text3: #565e80;
--accent: #5b7fff;
--accent2: #7c5bff;
--green: #22c55e;
--red: #ef4444;
--yellow: #f59e0b;
--glass: rgba(255,255,255,0.04);
--glass-hover: rgba(255,255,255,0.07);
--radius: 12px;
--shadow: 0 8px 32px rgba(0,0,0,0.45);
--navbar-h: 60px;
--sidebar-w: 240px;
--transition: 0.2s cubic-bezier(0.4,0,0.2,1);
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
font-family: 'Plus Jakarta Sans', sans-serif;
background: var(--bg);
color: var(--text);
font-size: 14px;
}
/* ── NAVBAR ────────────────────────────────────────────────────── */
.admin-navbar {
position: fixed; top: 0; left: 0; right: 0;
height: var(--navbar-h); z-index: 1000;
background: rgba(22,27,39,0.95); backdrop-filter: blur(14px);
border-bottom: 1px solid var(--border);
display: flex; align-items: center;
padding: 0 24px; gap: 16px;
box-shadow: 0 2px 20px rgba(0,0,0,0.3);
}
.navbar-brand {
display: flex; align-items: center; gap: 10px;
text-decoration: none; font-weight: 800; font-size: 14px;
color: var(--text); flex-shrink: 0;
}
.navbar-brand .brand-icon {
width: 34px; height: 34px;
background: linear-gradient(135deg, var(--accent), var(--accent2));
border-radius: 9px; display: flex; align-items: center; justify-content: center;
font-size: 16px;
}
.navbar-right { margin-left: auto; display: flex; align-items: center; gap: 10px; }
.btn-nav-link {
display: flex; align-items: center; gap: 6px;
padding: 6px 14px; border-radius: 8px;
background: var(--glass); border: 1px solid var(--border);
color: var(--text2); text-decoration: none;
font-size: 12px; font-weight: 600;
transition: all var(--transition); white-space: nowrap;
}
.btn-nav-link:hover { background: var(--glass-hover); color: var(--text); border-color: rgba(255,255,255,0.12); }
.btn-nav-link.danger { color: var(--red); border-color: rgba(239,68,68,0.2); background: rgba(239,68,68,0.05); }
.btn-nav-link.danger:hover { background: rgba(239,68,68,0.12); }
.user-badge {
display: flex; align-items: center; gap: 7px;
padding: 5px 12px; background: var(--glass);
border: 1px solid var(--border); border-radius: 20px;
font-size: 12px; font-weight: 600; color: var(--text2);
}
.user-role-dot { width: 8px; height: 8px; border-radius: 50%; }
.user-role-dot.admin { background: var(--accent); box-shadow: 0 0 6px var(--accent); }
.user-role-dot.operator { background: var(--green); box-shadow: 0 0 6px var(--green); }
.role-pill {
font-size: 9px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.5px;
padding: 2px 7px; border-radius: 4px;
background: rgba(91,127,255,0.15); color: var(--accent);
}
/* ── LAYOUT ─────────────────────────────────────────────────────── */
.admin-layout {
display: flex; padding-top: var(--navbar-h);
min-height: 100vh;
}
/* ── SIDEBAR ────────────────────────────────────────────────────── */
.admin-sidebar {
width: var(--sidebar-w); min-width: var(--sidebar-w);
position: fixed; top: var(--navbar-h); bottom: 0; left: 0;
background: var(--bg2); border-right: 1px solid var(--border);
overflow-y: auto; z-index: 100;
scrollbar-width: thin; scrollbar-color: var(--bg3) transparent;
padding: 16px 0 24px;
}
.sidebar-nav { display: flex; flex-direction: column; gap: 2px; padding: 0 10px; }
.nav-section-label {
font-size: 9px; font-weight: 800; text-transform: uppercase;
letter-spacing: 0.8px; color: var(--text3);
padding: 10px 8px 4px; margin-top: 6px;
}
.nav-item {
display: flex; align-items: center; gap: 9px;
padding: 9px 12px; border-radius: 9px;
text-decoration: none; color: var(--text2);
font-size: 13px; font-weight: 600;
transition: all var(--transition); position: relative;
}
.nav-item:hover { background: var(--glass-hover); color: var(--text); }
.nav-item.active { background: rgba(91,127,255,0.15); color: var(--accent); }
.nav-item.active::before {
content: ''; position: absolute; left: 0; top: 8px; bottom: 8px;
width: 3px; border-radius: 0 3px 3px 0; background: var(--accent);
}
.nav-item.danger { color: var(--red); }
.nav-item.danger:hover { background: rgba(239,68,68,0.1); }
.nav-item i { width: 16px; text-align: center; flex-shrink: 0; }
/* ── MAIN CONTENT ───────────────────────────────────────────────── */
.admin-content {
flex: 1; margin-left: var(--sidebar-w);
padding: 28px 28px 40px;
min-height: calc(100vh - var(--navbar-h));
}
/* ── PAGE HEADER ────────────────────────────────────────────────── */
.page-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 24px; flex-wrap: wrap; gap: 12px;
}
.page-title-block h1 {
font-size: 20px; font-weight: 800; margin-bottom: 2px;
}
.page-subtitle { font-size: 12px; color: var(--text2); }
.page-actions { display: flex; gap: 8px; flex-wrap: wrap; }
/* ── STAT CARDS ─────────────────────────────────────────────────── */
.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px,1fr)); gap: 14px; margin-bottom: 24px; }
.stat-card {
background: var(--bg2); border: 1px solid var(--border);
border-radius: var(--radius); padding: 16px 18px;
transition: all var(--transition); cursor: default;
position: relative; overflow: hidden;
}
.stat-card::before {
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px;
border-radius: var(--radius) var(--radius) 0 0;
}
.stat-card.blue::before { background: linear-gradient(90deg, var(--accent), var(--accent2)); }
.stat-card.green::before { background: linear-gradient(90deg, var(--green), #16a34a); }
.stat-card.red::before { background: linear-gradient(90deg, var(--red), #b91c1c); }
.stat-card.yellow::before { background: linear-gradient(90deg, var(--yellow), #d97706); }
.stat-card:hover { transform: translateY(-2px); box-shadow: var(--shadow); }
.stat-icon { font-size: 26px; margin-bottom: 8px; }
.stat-value { font-size: 28px; font-weight: 800; line-height: 1; }
.stat-label { font-size: 11px; color: var(--text2); margin-top: 4px; font-weight: 500; }
.stat-card.blue .stat-value { color: var(--accent); }
.stat-card.green .stat-value { color: var(--green); }
.stat-card.red .stat-value { color: var(--red); }
.stat-card.yellow .stat-value { color: var(--yellow); }
/* ── CARDS ──────────────────────────────────────────────────────── */
.card {
background: var(--bg2); border: 1px solid var(--border);
border-radius: var(--radius); overflow: hidden;
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
}
.card-header {
padding: 14px 18px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; justify-content: space-between;
background: rgba(255,255,255,0.02);
}
.card-title { font-size: 14px; font-weight: 700; }
.card-body { padding: 18px; }
/* Chart container */
.chart-container { position: relative; width: 100%; }
/* ── TABLE ──────────────────────────────────────────────────────── */
.table-responsive { overflow-x: auto; }
.admin-table {
width: 100%; border-collapse: collapse;
font-size: 13px;
}
.admin-table th {
padding: 10px 14px;
background: var(--bg3); color: var(--text2);
font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.6px;
border-bottom: 1px solid var(--border);
white-space: nowrap; text-align: left;
}
.admin-table td {
padding: 11px 14px;
border-bottom: 1px solid var(--border);
color: var(--text); vertical-align: middle;
}
.admin-table tr:last-child td { border-bottom: none; }
.admin-table tbody tr:hover td { background: var(--glass); }
.admin-table .td-actions { white-space: nowrap; }
/* ── BADGES ─────────────────────────────────────────────────────── */
.badge-status {
display: inline-flex; align-items: center; gap: 4px;
padding: 3px 9px; border-radius: 6px;
font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.4px;
}
.badge-sudah { background: rgba(34,197,94,0.15); color: var(--green); }
.badge-belum { background: rgba(239,68,68,0.14); color: var(--red); }
.badge-menunggu { background: rgba(245,158,11,0.15); color: var(--yellow); }
.badge-admin { background: rgba(91,127,255,0.15); color: var(--accent); }
.badge-operator { background: rgba(34,197,94,0.12); color: var(--green); }
/* ── BUTTONS ─────────────────────────────────────────────────────── */
.btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 16px; border-radius: 8px;
font-size: 13px; font-weight: 600; font-family: inherit;
border: none; cursor: pointer;
transition: all var(--transition); white-space: nowrap;
text-decoration: none;
}
.btn-sm { padding: 5px 10px; font-size: 12px; border-radius: 6px; }
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { background: #4a6ef5; transform: translateY(-1px); box-shadow: 0 4px 14px rgba(91,127,255,0.4); }
.btn-success { background: var(--green); color: #fff; }
.btn-success:hover { filter: brightness(1.1); transform: translateY(-1px); }
.btn-danger { background: rgba(239,68,68,0.12); color: var(--red); border: 1px solid rgba(239,68,68,0.2); }
.btn-danger:hover { background: rgba(239,68,68,0.2); }
.btn-warning { background: rgba(245,158,11,0.12); color: var(--yellow); border: 1px solid rgba(245,158,11,0.2); }
.btn-warning:hover { background: rgba(245,158,11,0.22); }
.btn-ghost { background: var(--glass); color: var(--text2); border: 1px solid var(--border); }
.btn-ghost:hover { background: var(--glass-hover); color: var(--text); }
.btn-outline-accent { background: transparent; color: var(--accent); border: 1px solid rgba(91,127,255,0.35); }
.btn-outline-accent:hover { background: rgba(91,127,255,0.1); }
/* ── FORM ELEMENTS ──────────────────────────────────────────────── */
.form-group { margin-bottom: 14px; }
.form-label {
display: block; font-size: 11px; font-weight: 700;
color: var(--text2); margin-bottom: 5px;
text-transform: uppercase; letter-spacing: 0.5px;
}
.form-control {
width: 100%; padding: 9px 12px;
background: var(--bg3); border: 1px solid var(--border);
border-radius: 8px; color: var(--text);
font-size: 13px; font-family: inherit;
transition: border-color var(--transition); outline: none;
}
.form-control:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(91,127,255,0.12); }
.form-control::placeholder { color: var(--text3); }
select.form-control { cursor: pointer; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.form-hint { font-size: 10px; color: var(--text3); margin-top: 4px; }
/* Input group */
.input-group { display: flex; align-items: stretch; }
.input-group .form-control { border-radius: 8px 0 0 8px; }
.input-group .input-group-btn {
padding: 9px 12px; background: var(--bg3); border: 1px solid var(--border);
border-left: none; border-radius: 0 8px 8px 0; cursor: pointer; color: var(--text2);
font-size: 13px; transition: all var(--transition);
}
.input-group .input-group-btn:hover { background: var(--bg4); color: var(--text); }
/* ── SEARCH BAR ─────────────────────────────────────────────────── */
.search-bar { position: relative; }
.search-bar input { padding-left: 36px; }
.search-bar .search-ico {
position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
color: var(--text3); font-size: 13px; pointer-events: none;
}
/* ── MODAL (Bootstrap override) ─────────────────────────────────── */
.modal-content {
background: var(--bg2) !important;
border: 1px solid var(--border) !important;
border-radius: 16px !important;
color: var(--text) !important;
}
.modal-header {
border-bottom: 1px solid var(--border) !important;
padding: 16px 20px !important;
}
.modal-title { font-weight: 700 !important; font-size: 15px !important; }
.btn-close { filter: invert(1) !important; opacity: 0.5 !important; }
.btn-close:hover { opacity: 1 !important; }
.modal-body { padding: 18px 20px !important; }
.modal-footer {
border-top: 1px solid var(--border) !important;
padding: 12px 20px !important;
}
/* ── ALERT ──────────────────────────────────────────────────────── */
.alert { border-radius: 10px; padding: 12px 16px; font-size: 13px; border: none; }
.alert-danger { background: rgba(239,68,68,0.1); color: var(--red); border-left: 3px solid var(--red); }
.alert-success { background: rgba(34,197,94,0.1); color: var(--green); border-left: 3px solid var(--green); }
.alert-warning { background: rgba(245,158,11,0.1); color: var(--yellow); border-left: 3px solid var(--yellow); }
.alert-info { background: rgba(91,127,255,0.1); color: var(--accent); border-left: 3px solid var(--accent); }
/* ── SECTION LABEL ──────────────────────────────────────────────── */
.section-label {
font-size: 10px; font-weight: 800; color: var(--text2);
text-transform: uppercase; letter-spacing: 0.7px;
margin: 16px 0 8px; padding-bottom: 6px;
border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 6px;
}
.section-label::before {
content: ''; display: block; width: 3px; height: 12px;
background: linear-gradient(var(--accent), var(--accent2)); border-radius: 2px;
}
/* ── ANGGOTA ROWS ───────────────────────────────────────────────── */
.anggota-row {
background: var(--bg3); border: 1px solid var(--border);
border-radius: 9px; padding: 12px; margin-bottom: 10px;
}
.anggota-row-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.anggota-num { font-size: 11px; font-weight: 700; color: var(--text2); }
.btn-remove-anggota {
width: 22px; height: 22px; border: none; border-radius: 5px;
background: rgba(239,68,68,0.12); color: var(--red);
cursor: pointer; font-size: 12px; display: flex; align-items: center; justify-content: center;
}
/* ── UPLOAD AREA ─────────────────────────────────────────────────── */
.upload-area {
border: 2px dashed var(--border); border-radius: 10px;
padding: 20px; text-align: center; cursor: pointer;
transition: all var(--transition); position: relative;
display: block;
}
.upload-area:hover, .upload-area.dragover {
border-color: var(--accent); background: rgba(91,127,255,0.05);
}
.upload-text { font-size: 12px; color: var(--text2); }
.upload-preview { margin-top: 8px; }
.upload-preview img { max-width: 100%; max-height: 120px; border-radius: 8px; }
/* ── IMPORT/EXPORT PAGE ─────────────────────────────────────────── */
.import-zone {
border: 2px dashed rgba(91,127,255,0.3); border-radius: 14px;
padding: 36px; text-align: center; cursor: pointer;
transition: all var(--transition); position: relative; background: rgba(91,127,255,0.03);
}
.import-zone:hover, .import-zone.dragover {
border-color: var(--accent); background: rgba(91,127,255,0.07);
}
.import-zone-icon { font-size: 40px; margin-bottom: 10px; }
.import-zone-text { font-size: 14px; font-weight: 600; color: var(--text2); margin-bottom: 4px; }
.import-zone-sub { font-size: 11px; color: var(--text3); }
.export-card {
background: var(--bg2); border: 1px solid var(--border);
border-radius: 12px; padding: 20px; text-align: center;
transition: all var(--transition);
}
.export-card:hover { border-color: rgba(91,127,255,0.3); transform: translateY(-2px); box-shadow: var(--shadow); }
.export-icon { font-size: 36px; margin-bottom: 10px; }
.export-title { font-size: 14px; font-weight: 700; margin-bottom: 4px; }
.export-sub { font-size: 11px; color: var(--text2); margin-bottom: 14px; }
/* ── LOGIN PAGE ──────────────────────────────────────────────────── */
.login-page {
min-height: 100vh; display: flex; align-items: center; justify-content: center;
background: var(--bg);
background-image: radial-gradient(ellipse at 20% 50%, rgba(91,127,255,0.08) 0%, transparent 50%),
radial-gradient(ellipse at 80% 20%, rgba(124,91,255,0.06) 0%, transparent 40%);
padding: 20px;
}
.login-card {
width: 100%; max-width: 420px;
background: var(--bg2); border: 1px solid var(--border);
border-radius: 20px; padding: 36px;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
}
.login-logo {
text-align: center; margin-bottom: 28px;
}
.login-logo-icon {
width: 56px; height: 56px; margin: 0 auto 10px;
background: linear-gradient(135deg, var(--accent), var(--accent2));
border-radius: 16px; display: flex; align-items: center;
justify-content: center; font-size: 24px;
box-shadow: 0 8px 24px rgba(91,127,255,0.4);
}
.login-logo h1 { font-size: 16px; font-weight: 800; margin-bottom: 3px; }
.login-logo p { font-size: 11px; color: var(--text2); }
.login-input {
position: relative; margin-bottom: 14px;
}
.login-input i {
position: absolute; left: 13px; top: 50%; transform: translateY(-50%);
color: var(--text3); font-size: 15px; pointer-events: none;
}
.login-input input {
width: 100%; padding: 11px 12px 11px 40px;
background: var(--bg3); border: 1px solid var(--border);
border-radius: 10px; color: var(--text);
font-size: 14px; font-family: inherit;
transition: all var(--transition); outline: none;
}
.login-input input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(91,127,255,0.15); }
.login-input input::placeholder { color: var(--text3); }
.login-btn {
width: 100%; padding: 12px;
background: linear-gradient(135deg, var(--accent), var(--accent2));
border: none; border-radius: 10px; color: #fff;
font-size: 14px; font-weight: 700; font-family: inherit;
cursor: pointer; transition: all var(--transition);
box-shadow: 0 4px 14px rgba(91,127,255,0.4);
}
.login-btn:hover { transform: translateY(-1px); box-shadow: 0 6px 20px rgba(91,127,255,0.5); }
.login-footer {
text-align: center; margin-top: 16px;
font-size: 11px; color: var(--text3);
}
/* ── PAGINATION ──────────────────────────────────────────────────── */
.pagination { display: flex; gap: 4px; align-items: center; flex-wrap: wrap; }
.page-btn {
padding: 5px 10px; border-radius: 6px;
background: var(--glass); border: 1px solid var(--border);
color: var(--text2); font-size: 12px; cursor: pointer;
transition: all var(--transition);
}
.page-btn:hover, .page-btn.active { background: rgba(91,127,255,0.15); color: var(--accent); border-color: rgba(91,127,255,0.3); }
.page-btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* ── RANGE ───────────────────────────────────────────────────────── */
.range-wrapper { display: flex; align-items: center; gap: 10px; }
.range-wrapper input[type=range] { flex: 1; accent-color: var(--accent); }
.range-val { font-weight: 700; color: var(--accent); font-size: 13px; min-width: 50px; text-align: right; }
/* ── PREVIEW IMAGE ────────────────────────────────────────────────── */
.img-preview {
max-width: 100%; max-height: 140px; object-fit: contain;
border-radius: 8px; border: 1px solid var(--border);
}
/* ── RESPONSIVE ─────────────────────────────────────────────────── */
@media (max-width: 900px) {
.admin-sidebar { transform: translateX(-100%); transition: transform 0.3s; }
.admin-sidebar.open { transform: translateX(0); }
.admin-content { margin-left: 0; padding: 20px 16px; }
.form-row { grid-template-columns: 1fr; }
.stats-row { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 480px) {
.stats-row { grid-template-columns: 1fr; }
.page-header { flex-direction: column; align-items: flex-start; }
}
/* ── SCROLLBAR ───────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--bg3); border-radius: 3px; }
+463
View File
@@ -0,0 +1,463 @@
/* ================================================================
WebGIS Sebaran Penduduk Miskin — Dashboard CSS
================================================================ */
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap');
/* ── Reset & Variables ──────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f1117;
--bg2: #161b27;
--bg3: #1e2538;
--sidebar-w: 340px;
--border: rgba(255,255,255,0.07);
--text: #e8ecf4;
--text2: #8890b0;
--text3: #565e80;
--accent: #5b7fff;
--accent2: #7c5bff;
--green: #22c55e;
--red: #ef4444;
--yellow: #f59e0b;
--glass: rgba(255,255,255,0.04);
--glass-hover: rgba(255,255,255,0.07);
--radius: 12px;
--shadow: 0 8px 32px rgba(0,0,0,0.4);
--transition: 0.2s cubic-bezier(0.4,0,0.2,1);
}
html, body { height: 100%; overflow: hidden; font-family: 'Plus Jakarta Sans', sans-serif; background: var(--bg); color: var(--text); }
/* ── Layout ─────────────────────────────────────────────────── */
.app-layout { display: flex; height: 100vh; overflow: hidden; }
/* ── SIDEBAR ─────────────────────────────────────────────────── */
.sidebar {
width: var(--sidebar-w);
min-width: var(--sidebar-w);
background: var(--bg2);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 10;
}
.sidebar-header {
padding: 18px 20px 14px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.sidebar-brand {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
}
.brand-icon {
width: 38px; height: 38px;
background: linear-gradient(135deg, var(--accent), var(--accent2));
border-radius: 10px;
display: flex; align-items: center; justify-content: center;
font-size: 18px;
flex-shrink: 0;
}
.brand-title { font-size: 13px; font-weight: 700; line-height: 1.3; color: var(--text); }
.brand-sub { font-size: 10px; color: var(--text2); font-weight: 500; }
/* Search box */
.search-box {
position: relative;
margin-bottom: 8px;
}
.search-box input {
width: 100%; padding: 8px 12px 8px 34px;
background: var(--bg3); border: 1px solid var(--border);
border-radius: 8px; color: var(--text);
font-size: 12px; font-family: inherit;
transition: border-color var(--transition);
outline: none;
}
.search-box input:focus { border-color: var(--accent); }
.search-box input::placeholder { color: var(--text3); }
.search-box .search-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); font-size: 13px; pointer-events: none; }
/* Filter row */
.filter-row { display: flex; gap: 6px; flex-wrap: wrap; }
.filter-select {
flex: 1; min-width: 80px;
padding: 6px 8px; background: var(--bg3);
border: 1px solid var(--border); border-radius: 7px;
color: var(--text); font-size: 11px; font-family: inherit;
outline: none; cursor: pointer;
}
.filter-select:focus { border-color: var(--accent); }
/* Stats */
.stats-grid {
display: grid; grid-template-columns: 1fr 1fr;
gap: 8px; padding: 14px 20px;
border-bottom: 1px solid var(--border); flex-shrink: 0;
}
.stat-card {
background: var(--bg3); border-radius: 10px;
padding: 10px 12px; text-align: center;
border: 1px solid var(--border);
transition: transform var(--transition);
}
.stat-card:hover { transform: translateY(-2px); }
.stat-card.full { grid-column: 1/-1; }
.stat-num { font-size: 22px; font-weight: 800; line-height: 1; }
.stat-label{ font-size: 10px; color: var(--text2); margin-top: 3px; font-weight: 500; }
.stat-card.blue .stat-num { color: var(--accent); }
.stat-card.green .stat-num { color: var(--green); }
.stat-card.red .stat-num { color: var(--red); }
.stat-card.yellow .stat-num { color: var(--yellow); }
/* Tabs */
.sidebar-tabs {
display: flex; border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.tab-btn {
flex: 1; padding: 10px;
background: none; border: none;
color: var(--text2); font-size: 12px; font-weight: 600;
cursor: pointer; font-family: inherit;
transition: all var(--transition);
border-bottom: 2px solid transparent;
display: flex; align-items: center; justify-content: center; gap: 5px;
}
.tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); background: var(--glass); }
.tab-btn:hover:not(.active) { color: var(--text); background: var(--glass); }
/* List container */
.sidebar-body { flex: 1; overflow-y: auto; padding: 10px 12px; scrollbar-width: thin; scrollbar-color: var(--bg3) transparent; }
.sidebar-body::-webkit-scrollbar { width: 4px; }
.sidebar-body::-webkit-scrollbar-track { background: transparent; }
.sidebar-body::-webkit-scrollbar-thumb { background: var(--bg3); border-radius: 2px; }
/* Item cards */
.item-card {
background: var(--glass); border: 1px solid var(--border);
border-radius: var(--radius); padding: 10px 12px;
margin-bottom: 7px; cursor: pointer;
transition: all var(--transition);
display: flex; align-items: flex-start; gap: 10px;
}
.item-card:hover { background: var(--glass-hover); border-color: rgba(91,127,255,0.3); transform: translateX(2px); }
.item-card.highlighted { border-color: var(--accent); background: rgba(91,127,255,0.1); }
.item-icon { font-size: 22px; flex-shrink: 0; width: 34px; text-align: center; }
.item-info { flex: 1; min-width: 0; }
.item-name { font-size: 12px; font-weight: 700; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.item-addr { font-size: 10px; color: var(--text2); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.item-coord{ font-size: 9px; color: var(--text3); margin-top: 2px; font-family: 'JetBrains Mono', monospace; }
.item-meta { display: flex; gap: 5px; align-items: center; margin-top: 4px; flex-wrap: wrap; }
.item-actions { display: flex; gap: 4px; flex-shrink: 0; flex-direction: column; align-items: center; }
.btn-icon {
width: 26px; height: 26px; border: none; border-radius: 6px;
cursor: pointer; font-size: 12px; display: flex; align-items: center; justify-content: center;
transition: all var(--transition);
}
.btn-icon.edit { background: rgba(91,127,255,0.15); color: var(--accent); }
.btn-icon.delete { background: rgba(239,68,68,0.12); color: var(--red); }
.btn-icon:hover { filter: brightness(1.3); transform: scale(1.1); }
/* Radius slider in card */
.item-radius-row { display: flex; align-items: center; gap: 6px; margin-top: 5px; }
.item-radius-row input[type=range] { flex: 1; height: 3px; accent-color: var(--accent); cursor: pointer; }
.radius-val { font-size: 10px; color: var(--accent); font-weight: 700; min-width: 38px; }
/* Badge */
.badge-status {
display: inline-block; padding: 2px 7px; border-radius: 4px;
font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px;
}
.badge-belum { background: rgba(239,68,68,0.15); color: var(--red); }
.badge-sudah { background: rgba(34,197,94,0.15); color: var(--green); }
.badge-menunggu { background: rgba(245,158,11,0.15); color: var(--yellow); }
/* Nearest tag */
.nearest-tag { font-size: 9px; color: var(--accent); background: rgba(91,127,255,0.12); padding: 2px 6px; border-radius: 4px; }
/* Empty state */
.empty-state { text-align: center; padding: 24px 12px; color: var(--text3); font-size: 12px; }
.empty-state .empty-icon { font-size: 28px; margin-bottom: 6px; display: block; opacity: 0.5; }
/* Sidebar footer */
.sidebar-footer { padding: 12px; border-top: 1px solid var(--border); flex-shrink: 0; }
.sidebar-btn-row { display: flex; gap: 7px; }
.btn-sm-action {
flex: 1; padding: 8px; border: 1px solid var(--border); border-radius: 8px;
background: var(--glass); color: var(--text2);
font-size: 11px; font-weight: 600; font-family: inherit;
cursor: pointer; transition: all var(--transition);
display: flex; align-items: center; justify-content: center; gap: 4px;
}
.btn-sm-action:hover { background: var(--glass-hover); color: var(--text); border-color: rgba(255,255,255,0.15); }
.btn-sm-action.accent { border-color: rgba(91,127,255,0.3); color: var(--accent); background: rgba(91,127,255,0.08); }
.btn-sm-action.accent:hover { background: rgba(91,127,255,0.15); }
/* ── MAP CONTAINER ────────────────────────────────────────────── */
.map-wrap { flex: 1; position: relative; overflow: hidden; }
#map { width: 100%; height: 100%; }
#map.adding-ibadah { cursor: crosshair !important; }
#map.adding-miskin { cursor: cell !important; }
/* ── TOOLBAR (floating) ───────────────────────────────────────── */
.map-toolbar {
position: absolute; top: 16px; left: 50%; transform: translateX(-50%);
z-index: 900; display: flex; align-items: center; gap: 6px;
background: rgba(15,17,23,0.92); backdrop-filter: blur(12px);
border: 1px solid var(--border); border-radius: 50px;
padding: 6px 12px; box-shadow: var(--shadow);
}
.toolbar-label { font-size: 11px; color: var(--text2); font-weight: 600; margin-right: 4px; }
.toggle-btn {
padding: 6px 14px; border: 1px solid var(--border); border-radius: 50px;
background: var(--glass); color: var(--text2);
font-size: 11px; font-weight: 600; font-family: inherit;
cursor: pointer; transition: all var(--transition);
white-space: nowrap;
}
.toggle-btn:hover { background: var(--glass-hover); color: var(--text); }
.toggle-btn.active-ibadah { background: rgba(91,127,255,0.2); color: var(--accent); border-color: rgba(91,127,255,0.4); }
.toggle-btn.active-miskin { background: rgba(34,197,94,0.15); color: var(--green); border-color: rgba(34,197,94,0.35); }
.toolbar-divider { width: 1px; height: 20px; background: var(--border); margin: 0 4px; }
.toolbar-icon-btn {
width: 32px; height: 32px; border: 1px solid var(--border); border-radius: 50%;
background: var(--glass); color: var(--text2);
font-size: 14px; cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: all var(--transition);
}
.toolbar-icon-btn:hover { background: var(--glass-hover); color: var(--text); }
/* ── MAP CONTROLS (bottom-right) ──────────────────────────────── */
.map-controls {
position: absolute; bottom: 30px; right: 14px;
z-index: 900; display: flex; flex-direction: column; gap: 8px;
}
.map-ctrl-btn {
width: 42px; height: 42px;
background: rgba(15,17,23,0.92); backdrop-filter: blur(12px);
border: 1px solid var(--border); border-radius: 10px;
color: var(--text); font-size: 16px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: all var(--transition); box-shadow: var(--shadow);
}
.map-ctrl-btn:hover { background: var(--bg3); border-color: var(--accent); color: var(--accent); }
.map-ctrl-btn.active { background: rgba(91,127,255,0.2); border-color: var(--accent); color: var(--accent); }
/* ── LOC BAR ─────────────────────────────────────────────────── */
.loc-bar {
position: absolute; bottom: 10px; left: 50%; transform: translateX(-50%);
z-index: 900; background: rgba(15,17,23,0.9); backdrop-filter: blur(10px);
border: 1px solid var(--border); border-radius: 50px;
padding: 6px 16px; display: none; align-items: center; gap: 8px;
font-size: 11px; color: var(--text2); box-shadow: var(--shadow);
max-width: 600px; white-space: nowrap; overflow: hidden;
animation: fadeIn 0.3s ease;
}
.loc-bar strong { color: var(--text); font-size: 11px; }
@keyframes fadeIn { from { opacity:0; transform: translateX(-50%) translateY(6px); } to { opacity:1; transform: translateX(-50%) translateY(0); } }
/* ── TOAST ─────────────────────────────────────────────────────── */
#toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
background: rgba(22,27,39,0.95); backdrop-filter: blur(10px);
border-radius: 10px; padding: 12px 20px;
font-size: 13px; font-weight: 600;
z-index: 9999; box-shadow: var(--shadow);
border-left: 3px solid var(--accent);
display: none; max-width: 420px; text-align: center;
animation: toastIn 0.3s ease;
}
@keyframes toastIn { from { opacity:0; transform: translateX(-50%) translateY(10px); } to { opacity:1; transform: translateX(-50%) translateY(0); } }
/* ── MODAL ─────────────────────────────────────────────────────── */
.modal-overlay {
position: fixed; inset: 0; z-index: 2000;
background: rgba(0,0,0,0.7); backdrop-filter: blur(6px);
display: flex; align-items: center; justify-content: center;
padding: 20px;
opacity: 0; pointer-events: none;
transition: opacity 0.25s;
}
.modal-overlay.open { opacity: 1; pointer-events: all; }
.modal-card {
background: var(--bg2); border: 1px solid var(--border);
border-radius: 18px; width: 100%; max-width: 540px;
max-height: 90vh; display: flex; flex-direction: column;
box-shadow: 0 20px 60px rgba(0,0,0,0.6);
transform: scale(0.95) translateY(10px);
transition: transform 0.25s;
overflow: hidden;
}
.modal-overlay.open .modal-card { transform: scale(1) translateY(0); }
.modal-header {
padding: 16px 20px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; justify-content: space-between;
flex-shrink: 0;
}
.modal-title { font-size: 15px; font-weight: 700; }
.modal-close {
width: 28px; height: 28px; border: none; border-radius: 50%;
background: var(--glass); color: var(--text2); cursor: pointer;
font-size: 14px; display: flex; align-items: center; justify-content: center;
transition: all var(--transition);
}
.modal-close:hover { background: rgba(239,68,68,0.15); color: var(--red); }
.modal-body { flex: 1; overflow-y: auto; padding: 18px 20px; scrollbar-width: thin; scrollbar-color: var(--bg3) transparent; }
.modal-footer { padding: 14px 20px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 8px; flex-shrink: 0; }
/* Form elements */
.form-group { margin-bottom: 14px; }
.form-label { display: block; font-size: 11px; font-weight: 700; color: var(--text2); margin-bottom: 5px; text-transform: uppercase; letter-spacing: 0.5px; }
.form-control {
width: 100%; padding: 9px 12px;
background: var(--bg3); border: 1px solid var(--border);
border-radius: 8px; color: var(--text);
font-size: 13px; font-family: inherit;
transition: border-color var(--transition);
outline: none;
}
.form-control:focus { border-color: var(--accent); }
.form-control::placeholder { color: var(--text3); }
select.form-control { cursor: pointer; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.addr-preview {
padding: 8px 10px; background: rgba(91,127,255,0.06);
border: 1px solid rgba(91,127,255,0.15); border-radius: 7px;
font-size: 11px; color: var(--text2); margin-top: 5px; min-height: 32px;
display: flex; align-items: center; gap: 6px;
}
.addr-loading { color: var(--text3); }
/* Range slider */
.range-wrapper { display: flex; align-items: center; gap: 10px; }
.range-wrapper input[type=range] { flex: 1; accent-color: var(--accent); }
.range-val { font-weight: 700; color: var(--accent); font-size: 13px; min-width: 50px; text-align: right; }
/* Anggota keluarga dynamic rows */
.anggota-row {
background: var(--bg3); border: 1px solid var(--border);
border-radius: 8px; padding: 10px; margin-bottom: 8px;
position: relative;
}
.anggota-row-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.anggota-num { font-size: 11px; font-weight: 700; color: var(--text2); }
.btn-remove-anggota {
width: 22px; height: 22px; border: none; border-radius: 5px;
background: rgba(239,68,68,0.12); color: var(--red);
cursor: pointer; font-size: 12px; display: flex; align-items: center; justify-content: center;
}
.section-label {
font-size: 11px; font-weight: 700; color: var(--text2);
text-transform: uppercase; letter-spacing: 0.6px;
margin: 14px 0 8px; padding-bottom: 5px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 6px;
}
.section-label::before { content: ''; display: block; width: 3px; height: 13px; background: var(--accent); border-radius: 2px; }
/* ── BUTTONS ───────────────────────────────────────────────────── */
.btn {
padding: 9px 18px; border-radius: 8px;
font-size: 13px; font-weight: 600; font-family: inherit;
border: none; cursor: pointer;
transition: all var(--transition);
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
}
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { background: #4a6ef5; transform: translateY(-1px); }
.btn-success { background: var(--green); color: #fff; }
.btn-success:hover { filter: brightness(1.1); transform: translateY(-1px); }
.btn-danger { background: rgba(239,68,68,0.12); color: var(--red); border: 1px solid rgba(239,68,68,0.2); }
.btn-danger:hover { background: rgba(239,68,68,0.2); }
.btn-ghost { background: var(--glass); color: var(--text2); border: 1px solid var(--border); }
.btn-ghost:hover { background: var(--glass-hover); color: var(--text); }
.btn-outline-accent { background: transparent; color: var(--accent); border: 1px solid rgba(91,127,255,0.35); }
.btn-outline-accent:hover { background: rgba(91,127,255,0.1); }
/* ── POPUP (Leaflet) ────────────────────────────────────────────── */
.leaflet-popup-content-wrapper {
background: var(--bg2) !important;
border: 1px solid var(--border) !important;
border-radius: 12px !important;
box-shadow: 0 8px 32px rgba(0,0,0,0.5) !important;
color: var(--text) !important;
}
.leaflet-popup-tip { background: var(--bg2) !important; }
.leaflet-popup-content { margin: 14px 16px !important; font-family: 'Plus Jakarta Sans', sans-serif !important; font-size: 13px !important; line-height: 1.5; min-width: 200px; }
.popup-title { font-size: 14px; font-weight: 700; margin-bottom: 8px; }
.popup-addr { font-size: 11px; color: var(--text2); margin-top: 4px; }
.popup-coord { font-size: 10px; color: var(--text3); font-family: monospace; }
.popup-divider { height: 1px; background: var(--border); margin: 8px 0; }
.popup-row { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--text2); margin: 3px 0; }
.popup-row strong { color: var(--text); }
.popup-actions { display: flex; gap: 6px; margin-top: 10px; }
.popup-btn {
flex: 1; padding: 5px 10px; border-radius: 6px; border: none;
font-size: 11px; font-weight: 600; font-family: inherit; cursor: pointer;
transition: all var(--transition);
}
.popup-btn.edit { background: rgba(91,127,255,0.15); color: var(--accent); }
.popup-btn.danger { background: rgba(239,68,68,0.12); color: var(--red); }
.popup-btn:hover { filter: brightness(1.3); }
/* ── MARKER ANIMATIONS ───────────────────────────────────────────── */
@keyframes markerPulse {
0%,100% { transform: scale(1); }
50% { transform: scale(1.25); }
}
.marker-pulsing { animation: markerPulse 0.6s ease 3; }
/* ── UPLOAD PREVIEW ───────────────────────────────────────────────── */
.upload-area {
border: 2px dashed var(--border); border-radius: 10px;
padding: 16px; text-align: center; cursor: pointer;
transition: all var(--transition); position: relative;
}
.upload-area:hover, .upload-area.dragover { border-color: var(--accent); background: rgba(91,127,255,0.05); }
.upload-area input[type=file] { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
.upload-text { font-size: 12px; color: var(--text2); }
.upload-preview { margin-top: 8px; }
.upload-preview img { max-width: 100%; max-height: 120px; border-radius: 6px; }
/* ── Legend ───────────────────────────────────────────────────────── */
.map-legend {
position: absolute; bottom: 80px; right: 14px; z-index: 800;
background: rgba(15,17,23,0.9); backdrop-filter: blur(10px);
border: 1px solid var(--border); border-radius: 10px;
padding: 10px 14px; font-size: 11px; color: var(--text2);
box-shadow: var(--shadow);
}
.legend-title { font-weight: 700; color: var(--text); margin-bottom: 7px; font-size: 11px; }
.legend-item { display: flex; align-items: center; gap: 7px; margin-bottom: 4px; }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.legend-rect { width: 20px; height: 10px; border-radius: 3px; flex-shrink: 0; }
/* ── Leaflet custom zoom ─────────────────────────────────────────── */
.leaflet-control-zoom { display: none !important; }
.leaflet-control-attribution { font-size: 9px !important; background: rgba(0,0,0,0.5) !important; color: #666 !important; }
.leaflet-control-attribution a { color: #8890b0 !important; }
/* ── Responsive ──────────────────────────────────────────────────── */
@media (max-width: 768px) {
.sidebar { position: absolute; left: -100%; z-index: 500; transition: left 0.3s; width: 85vw; height: 100%; }
.sidebar.mobile-open { left: 0; }
.map-toolbar { top: 10px; padding: 5px 8px; gap: 4px; }
.toggle-btn { padding: 5px 10px; }
}
+943
View File
@@ -0,0 +1,943 @@
// ================================================================
// MAP.JS — WebGIS Peta Interaktif (terhubung ke PHP/MySQL API)
// Variabel global BASE_URL, MAP_LAT, MAP_LNG, MAP_ZOOM
// diinjeksikan dari index.php via PHP
// ================================================================
// ── INIT MAP ────────────────────────────────────────────────────
const map = L.map('map', { preferCanvas: false, zoomControl: false })
.setView([MAP_LAT, MAP_LNG], MAP_ZOOM);
// Basemaps
const BASEMAPS = {
osm: L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© <a href="https://www.openstreetmap.org/copyright" style="color:#5b7fff">OpenStreetMap</a>',
maxZoom: 19
}),
satellite: L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
attribution: '© Esri World Imagery', maxZoom: 19
}),
dark: L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '© CartoDB', maxZoom: 19
})
};
const BASEMAP_KEYS = ['osm', 'satellite', 'dark'];
const BASEMAP_ICONS = { osm: '🗺️', satellite: '🛰️', dark: '🌑' };
let basemapIdx = 0;
BASEMAPS.osm.addTo(map);
// Layer groups
const ibadahLayer = L.layerGroup().addTo(map);
const miskinLayer = L.layerGroup().addTo(map);
let heatLayer = null;
let routeControl = null;
let myMarker = null;
// State
let mode = null; // 'ibadah' | 'miskin' | null
let tempMarker = null;
let pendingLatLng = null;
let pendingGeo = null; // reverse geocode result
let allIbadah = []; // Array of ibadah objects with marker & circle
let allMiskin = []; // Array of miskin objects with marker
let anggotaCount = 0;
// ── JENIS METADATA ──────────────────────────────────────────────
const JENIS_COLOR = {
Masjid: '#10b981', Musholla: '#34d399',
Gereja: '#3b82f6', Katolik: '#60a5fa',
Pura: '#f59e0b', Vihara: '#fbbf24',
Klenteng: '#ef4444', Lainnya: '#8b5cf6'
};
const JENIS_EMOJI = {
Masjid: '🕌', Musholla: '🕌',
Gereja: '⛪', Katolik: '⛪',
Pura: '🛕', Vihara: '🛕',
Klenteng: '⛩️', Lainnya: '🙏'
};
const STATUS_COLOR = { sudah: '#22c55e', menunggu: '#f59e0b', belum: '#ef4444' };
const STATUS_LABEL = { sudah: 'Sudah Dibantu', menunggu: 'Menunggu Verif.', belum: 'Belum Dibantu' };
// ── HELPERS ──────────────────────────────────────────────────────
function fmtRadius(v) { v = parseInt(v); return v >= 1000 ? (v/1000).toFixed(1)+' km' : v+' m'; }
function truncate(s, n) { s = (s || ''); return s.length > n ? s.slice(0,n)+'\u2026' : s; }
function fmtRupiah(n) {
if (!n && n !== 0) return '\u2014';
return 'Rp\u00a0' + parseFloat(n).toLocaleString('id-ID', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
}
window.formatNominalInput = function(el) {
let raw = el.value.replace(/[^0-9]/g, '');
el.value = raw.replace(/\B(?=(\d{3})+(?!\d))/g, '.');
};
// ── TOAST ────────────────────────────────────────────────────────
function showToast(msg, color = '#5b7fff') {
const t = document.getElementById('toast');
t.innerHTML = msg;
t.style.display = 'block';
t.style.borderLeftColor = color;
clearTimeout(t._tid);
t._tid = setTimeout(() => t.style.display = 'none', 3500);
}
// ── ICONS ────────────────────────────────────────────────────────
function createIbadahIcon(jenis) {
const c = JENIS_COLOR[jenis] || '#5b7fff';
const em = JENIS_EMOJI[jenis] || '🏛️';
return L.divIcon({
html: `<div style="background:${c};width:36px;height:36px;
border-radius:50% 50% 50% 0;transform:rotate(-45deg);border:3px solid #fff;
box-shadow:0 4px 12px rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;">
<span style="transform:rotate(45deg);font-size:16px;">${em}</span></div>`,
iconSize:[36,36], iconAnchor:[18,36], popupAnchor:[0,-42], className:''
});
}
function createMiskinIcon(status) {
const c = STATUS_COLOR[status] || '#ef4444';
const glow = c.replace('#','') === '22c55e'
? 'rgba(34,197,94,0.55)' : c === '#f59e0b'
? 'rgba(245,158,11,0.55)' : 'rgba(239,68,68,0.55)';
return L.divIcon({
html: `<div style="background:${c};width:20px;height:20px;border-radius:50%;
border:3px solid #fff;box-shadow:0 2px 8px ${glow};"></div>`,
iconSize:[20,20], iconAnchor:[10,10], popupAnchor:[0,-13], className:''
});
}
function createTempIcon(type) {
if (type === 'ibadah') return L.divIcon({
html: `<div style="background:linear-gradient(135deg,#5b7fff,#7c5bff);opacity:0.6;
width:36px;height:36px;border-radius:50% 50% 50% 0;transform:rotate(-45deg);
border:3px dashed #fff;display:flex;align-items:center;justify-content:center;">
<span style="transform:rotate(45deg);font-size:16px;">📍</span></div>`,
iconSize:[36,36], iconAnchor:[18,36], className:''
});
return L.divIcon({
html: `<div style="background:#8890b0;width:20px;height:20px;border-radius:50%;
border:3px dashed #fff;opacity:0.6;"></div>`,
iconSize:[20,20], iconAnchor:[10,10], className:''
});
}
function createMyIcon() {
return L.divIcon({
html: `<div style="background:#f59e0b;width:24px;height:24px;border-radius:50%;
border:3px solid #fff;box-shadow:0 2px 12px rgba(245,158,11,0.7);
animation:mypulse 1.8s infinite ease-in-out;"></div>
<style>@keyframes mypulse{0%,100%{box-shadow:0 0 0 0 rgba(245,158,11,.6)}50%{box-shadow:0 0 0 8px rgba(245,158,11,0)}}</style>`,
iconSize:[24,24], iconAnchor:[12,12], popupAnchor:[0,-16], className:''
});
}
// ── LOAD DATA ────────────────────────────────────────────────────
async function loadIbadah() {
try {
const q = buildIbadahQuery();
const r = await fetch(`${BASE_URL}/api/rumah_ibadah.php${q}`);
const js = await r.json();
if (!js.success) return;
ibadahLayer.clearLayers();
allIbadah = [];
js.data.forEach(d => addIbadahToMap(d));
updateStats();
renderIbadahList();
} catch(e) { console.error('loadIbadah:', e); }
}
async function loadMiskin() {
try {
const q = buildMiskinQuery();
const r = await fetch(`${BASE_URL}/api/penduduk_miskin.php${q}`);
const js = await r.json();
if (!js.success) return;
miskinLayer.clearLayers();
allMiskin = [];
js.data.forEach(d => addMiskinToMap(d));
updateStats();
renderMiskinList();
rebuildHeatmap();
} catch(e) { console.error('loadMiskin:', e); }
}
function buildIbadahQuery() {
const jenis = document.getElementById('filter-jenis').value;
const kec = document.getElementById('filter-kecamatan')?.value || '';
const kel = document.getElementById('filter-kelurahan')?.value || '';
const search = document.getElementById('global-search').value.trim();
const p = [];
if (jenis) p.push('jenis=' + encodeURIComponent(jenis));
if (kec) p.push('kecamatan=' + encodeURIComponent(kec));
if (kel) p.push('kelurahan=' + encodeURIComponent(kel));
if (search) p.push('search=' + encodeURIComponent(search));
return p.length ? '?' + p.join('&') : '';
}
function buildMiskinQuery() {
const status = document.getElementById('filter-status').value;
const kec = document.getElementById('filter-kecamatan')?.value || '';
const kel = document.getElementById('filter-kelurahan')?.value || '';
const search = document.getElementById('global-search').value.trim();
const p = [];
if (status) p.push('status=' + encodeURIComponent(status));
if (kec) p.push('kecamatan=' + encodeURIComponent(kec));
if (kel) p.push('kelurahan=' + encodeURIComponent(kel));
if (search) p.push('search=' + encodeURIComponent(search));
return p.length ? '?' + p.join('&') : '';
}
// ── LOAD KECAMATAN & KELURAHAN OPTIONS ─────────────────────────────────
let _allWilayah = []; // cache [{kecamatan, kelurahan}]
async function loadKecamatanOptions() {
try {
const r = await fetch(`${BASE_URL}/api/penduduk_miskin.php`);
const js = await r.json();
if (!js.success) return;
// Cache semua data wilayah
_allWilayah = js.data.map(d => ({ kecamatan: d.kecamatan || '', kelurahan: d.kelurahan || '' }));
const selKec = document.getElementById('filter-kecamatan');
if (!selKec) return;
// Populate kecamatan
const kecs = [...new Set(_allWilayah.map(d => d.kecamatan).filter(Boolean))].sort();
const curKec = selKec.value;
selKec.innerHTML = '<option value="">Semua Kecamatan</option>';
kecs.forEach(k => {
const opt = document.createElement('option');
opt.value = k; opt.textContent = k;
if (k === curKec) opt.selected = true;
selKec.appendChild(opt);
});
// Populate kelurahan (cascade dari kecamatan terpilih)
_updateKelurahanOptions();
} catch(e) { /* silent */ }
}
function _updateKelurahanOptions() {
const selKel = document.getElementById('filter-kelurahan');
if (!selKel) return;
const kec = document.getElementById('filter-kecamatan')?.value || '';
const kels = [...new Set(
_allWilayah
.filter(d => !kec || d.kecamatan === kec)
.map(d => d.kelurahan)
.filter(Boolean)
)].sort();
const cur = selKel.value;
selKel.innerHTML = '<option value="">Semua Kelurahan</option>';
kels.forEach(k => {
const opt = document.createElement('option');
opt.value = k; opt.textContent = k;
if (k === cur) opt.selected = true;
selKel.appendChild(opt);
});
}
function addIbadahToMap(d) {
const lat = parseFloat(d.lat);
const lng = parseFloat(d.lng);
const r = parseInt(d.radius);
const c = JENIS_COLOR[d.jenis] || '#5b7fff';
const marker = L.marker([lat, lng], { icon: createIbadahIcon(d.jenis) });
const circle = L.circle([lat, lng], {
radius: r, color: c, fillColor: c,
fillOpacity: 0.07, weight: 2, dashArray: '6,4', opacity: 0.8
});
const obj = { ...d, lat, lng, radius: r, marker, circle };
marker.bindPopup(() => buildIbadahPopup(obj));
marker.addTo(ibadahLayer);
circle.addTo(ibadahLayer);
allIbadah.push(obj);
}
function addMiskinToMap(d) {
const lat = parseFloat(d.lat);
const lng = parseFloat(d.lng);
const marker = L.marker([lat, lng], { icon: createMiskinIcon(d.status_bantuan) });
const obj = { ...d, lat, lng, marker };
marker.bindPopup(() => buildMiskinPopup(obj));
marker.addTo(miskinLayer);
allMiskin.push(obj);
}
// ── POPUPS ───────────────────────────────────────────────────────
function buildIbadahPopup(d) {
const em = JENIS_EMOJI[d.jenis] || '🏛️';
return `
<div class="popup-title">${em} ${d.nama}</div>
<div class="popup-divider"></div>
<div class="popup-row"><span>Jenis</span><strong>${d.jenis}</strong></div>
${d.kontak ? `<div class="popup-row"><span>Kontak</span><strong>${d.kontak}</strong></div>` : ''}
<div class="popup-row"><span>Radius</span><strong style="color:var(--accent)">${fmtRadius(d.radius)}</strong></div>
<div class="popup-row"><span>Binaan</span><strong>${d.jumlah_binaan || 0} warga</strong></div>
${d.alamat ? `<div class="popup-addr">📍 ${d.alamat}</div>` : ''}
<div class="popup-coord">${d.lat.toFixed(6)}, ${d.lng.toFixed(6)}</div>
<div class="popup-actions">
<button class="popup-btn edit" onclick="openEditIbadah(${d.id})">✏️ Edit</button>
<button class="popup-btn danger" onclick="confirmDeleteIbadah(${d.id})">🗑️ Hapus</button>
</div>`;
}
function buildMiskinPopup(d) {
const sc = STATUS_COLOR[d.status_bantuan] || '#ef4444';
const sl = STATUS_LABEL[d.status_bantuan] || d.status_bantuan;
const ib = allIbadah.find(x => x.id == d.rumah_ibadah_id);
return `
<div class="popup-title">👤 ${d.kk_nama}</div>
<div class="popup-divider"></div>
<div class="popup-row">
<span style="background:${sc}22;color:${sc};padding:2px 8px;border-radius:4px;font-weight:700;font-size:11px;">${sl}</span>
</div>
${d.jumlah_anggota ? `<div class="popup-row"><span>Anggota</span><strong>${d.jumlah_anggota} orang</strong></div>` : ''}
${ib ? `<div class="popup-row"><span>Ibadah</span><strong style="color:var(--accent)">${JENIS_EMOJI[ib.jenis]||''} ${ib.nama}</strong></div>` : ''}
${d.jarak_ke_ibadah ? `<div class="popup-row"><span>Jarak</span><strong>${Math.round(d.jarak_ke_ibadah)} m</strong></div>` : ''}
${d.jenis_bantuan ? `<div class="popup-row"><span>Bantuan</span><strong>${d.jenis_bantuan}</strong></div>` : ''}
${d.nominal_bantuan ? `<div class="popup-row"><span>Nominal</span><strong style="color:var(--green);font-family:'JetBrains Mono',monospace;">${fmtRupiah(d.nominal_bantuan)}</strong></div>` : ''}
${d.bukti_file ? `<div class="popup-row"><a href="${BASE_URL}/assets/uploads/${d.bukti_file}" target="_blank" style="color:var(--accent);font-size:11px;">📎 Lihat Bukti</a></div>` : ''}
${d.alamat ? `<div class="popup-addr">📍 ${truncate(d.alamat, 80)}</div>` : ''}
<div class="popup-coord">${d.lat.toFixed(6)}, ${d.lng.toFixed(6)}</div>
<div class="popup-actions">
<button class="popup-btn edit" onclick="openEditMiskin(${d.id})">✏️ Edit</button>
<button class="popup-btn danger" onclick="confirmDeleteMiskin(${d.id})">🗑️ Hapus</button>
${ib ? `<button class="popup-btn" style="background:rgba(91,127,255,.15);color:var(--accent)" onclick="routeTo(${d.lat},${d.lng},${ib.lat},${ib.lng})">🗺️ Rute</button>` : ''}
</div>`;
}
// ── STATS ────────────────────────────────────────────────────────
function updateStats() {
document.getElementById('stat-ibadah').textContent = allIbadah.length;
document.getElementById('stat-miskin').textContent = allMiskin.length;
document.getElementById('stat-sudah').textContent = allMiskin.filter(m => m.status_bantuan === 'sudah').length;
document.getElementById('stat-belum').textContent = allMiskin.filter(m => m.status_bantuan === 'belum').length;
document.getElementById('count-ibadah').textContent = allIbadah.length;
document.getElementById('count-miskin').textContent = allMiskin.length;
const totalNominal = allMiskin.reduce((acc, m) => {
return acc + (m.status_bantuan === 'sudah' && m.nominal_bantuan ? parseFloat(m.nominal_bantuan) : 0);
}, 0);
const elNominal = document.getElementById('stat-nominal');
if (elNominal) elNominal.textContent = fmtRupiah(totalNominal);
}
// ── HEATMAP ──────────────────────────────────────────────────────
function rebuildHeatmap() {
if (!heatLayer || !map.hasLayer(heatLayer)) return;
heatLayer.setLatLngs(allMiskin.map(m => [m.lat, m.lng, 0.9]));
}
function toggleHeatmap() {
const btn = document.getElementById('btn-heatmap');
if (heatLayer && map.hasLayer(heatLayer)) {
map.removeLayer(heatLayer);
btn.classList.remove('active');
showToast('🔥 Heatmap dinonaktifkan', '#f59e0b');
} else {
const pts = allMiskin.map(m => [m.lat, m.lng, 0.9]);
if (!heatLayer) heatLayer = L.heatLayer(pts, { radius:32, blur:22, maxZoom:17 });
else heatLayer.setLatLngs(pts);
heatLayer.addTo(map);
btn.classList.add('active');
showToast('🔥 Heatmap aktif', '#f59e0b');
}
}
// ── BASEMAP ──────────────────────────────────────────────────────
function toggleBasemap() {
map.removeLayer(BASEMAPS[BASEMAP_KEYS[basemapIdx]]);
basemapIdx = (basemapIdx + 1) % BASEMAP_KEYS.length;
BASEMAPS[BASEMAP_KEYS[basemapIdx]].addTo(map);
document.getElementById('btn-layers').textContent = BASEMAP_ICONS[BASEMAP_KEYS[basemapIdx]];
showToast(`🗺️ Basemap: ${BASEMAP_KEYS[basemapIdx].toUpperCase()}`, '#5b7fff');
}
// ── SIDEBAR & TABS ───────────────────────────────────────────────
function switchTab(tab) {
document.getElementById('list-ibadah').style.display = tab === 'ibadah' ? 'block' : 'none';
document.getElementById('list-miskin').style.display = tab === 'miskin' ? 'block' : 'none';
document.getElementById('tab-ibadah').className = 'tab-btn' + (tab === 'ibadah' ? ' active' : '');
document.getElementById('tab-miskin').className = 'tab-btn' + (tab === 'miskin' ? ' active' : '');
}
function toggleSidebar() {
document.getElementById('sidebar').classList.toggle('mobile-open');
}
// ── SEARCH & FILTER ──────────────────────────────────────────────
let searchTimer = null;
document.getElementById('global-search').addEventListener('input', function () {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => { loadIbadah(); loadMiskin(); }, 350);
});
document.getElementById('filter-status').addEventListener('change', loadMiskin);
document.getElementById('filter-jenis').addEventListener('change', loadIbadah);
document.getElementById('filter-kecamatan')?.addEventListener('change', () => {
_updateKelurahanOptions(); // cascade: update kelurahan sesuai kecamatan
loadIbadah(); loadMiskin();
});
document.getElementById('filter-kelurahan')?.addEventListener('change', () => { loadIbadah(); loadMiskin(); });
// ── RENDER LISTS ─────────────────────────────────────────────────
function renderIbadahList() {
const el = document.getElementById('list-ibadah');
if (!allIbadah.length) {
el.innerHTML = `<div class="empty-state"><span class="empty-icon">🏛️</span><div>Belum ada data rumah ibadah</div></div>`;
return;
}
el.innerHTML = allIbadah.map(ib => `
<div class="item-card" id="card-ibadah-${ib.id}" onclick="focusMarker('ibadah',${ib.id})">
<div class="item-icon">${JENIS_EMOJI[ib.jenis] || '🏛️'}</div>
<div class="item-info">
<div class="item-name">${ib.nama}</div>
<div class="item-addr">${ib.jenis}${ib.kecamatan ? ' · '+ib.kecamatan : ''}</div>
<div class="item-meta">
<span class="nearest-tag">📏 ${fmtRadius(ib.radius)}</span>
<span class="nearest-tag">👥 ${ib.jumlah_binaan || 0} binaan</span>
</div>
<div class="item-radius-row" onclick="event.stopPropagation()">
<input type="range" min="100" max="5000" step="50" value="${ib.radius}"
oninput="liveRadius(${ib.id}, this.value)" title="Geser untuk ubah radius">
<span class="radius-val" id="rdisplay-${ib.id}">${fmtRadius(ib.radius)}</span>
</div>
</div>
<div class="item-actions">
<button class="btn-icon edit" onclick="event.stopPropagation();openEditIbadah(${ib.id})" title="Edit">✏️</button>
<button class="btn-icon delete" onclick="event.stopPropagation();confirmDeleteIbadah(${ib.id})" title="Hapus">✕</button>
</div>
</div>`).join('');
}
function renderMiskinList() {
const el = document.getElementById('list-miskin');
if (!allMiskin.length) {
el.innerHTML = `<div class="empty-state"><span class="empty-icon">👤</span><div>Belum ada data penduduk</div></div>`;
return;
}
el.innerHTML = allMiskin.map(m => {
const ib = allIbadah.find(x => x.id == m.rumah_ibadah_id);
const sc = STATUS_COLOR[m.status_bantuan] || '#ef4444';
return `
<div class="item-card" id="card-miskin-${m.id}" onclick="focusMarker('miskin',${m.id})">
<div class="item-icon">👤</div>
<div class="item-info">
<div class="item-name">${m.kk_nama}</div>
<div class="item-addr">${m.kecamatan || m.kelurahan || truncate(m.alamat||'',40) || '—'}</div>
<div class="item-meta">
<span class="badge-status" style="background:${sc}22;color:${sc}">${STATUS_LABEL[m.status_bantuan]||m.status_bantuan}</span>
${ib ? `<span class="nearest-tag">📌 ${truncate(ib.nama,18)}</span>` : ''}
</div>
</div>
<div class="item-actions">
<button class="btn-icon edit" onclick="event.stopPropagation();openEditMiskin(${m.id})" title="Edit">✏️</button>
<button class="btn-icon delete" onclick="event.stopPropagation();confirmDeleteMiskin(${m.id})" title="Hapus">✕</button>
</div>
</div>`;
}).join('');
}
// ── FOCUS MARKER ─────────────────────────────────────────────────
function focusMarker(type, id) {
if (type === 'ibadah') {
const ib = allIbadah.find(x => x.id == id); if (!ib) return;
map.setView([ib.lat, ib.lng], Math.max(map.getZoom(), 16));
ib.marker.openPopup();
} else {
const m = allMiskin.find(x => x.id == id); if (!m) return;
map.setView([m.lat, m.lng], Math.max(map.getZoom(), 17));
m.marker.openPopup();
}
}
// ── LIVE RADIUS ──────────────────────────────────────────────────
window.liveRadius = function (id, val) {
const ib = allIbadah.find(x => x.id == id); if (!ib) return;
ib.radius = parseInt(val);
if (ib.circle) ib.circle.setRadius(ib.radius);
const disp = document.getElementById('rdisplay-' + id);
if (disp) disp.textContent = fmtRadius(ib.radius);
};
// ── MODE / MAP CLICK ─────────────────────────────────────────────
function setMode(m) {
if (mode === m) { mode = null; _resetMode(); return; }
mode = m; _resetMode();
if (m === 'ibadah') {
document.getElementById('map').classList.add('adding-ibadah');
showToast('🏛️ Klik peta untuk menentukan lokasi Rumah Ibadah', '#5b7fff');
} else {
document.getElementById('map').classList.add('adding-miskin');
showToast('👤 Klik peta untuk menentukan lokasi Penduduk Miskin', '#22c55e');
}
_updateToolbar();
}
function _resetMode() {
document.getElementById('map').classList.remove('adding-ibadah', 'adding-miskin');
}
function _updateToolbar() {
document.getElementById('toggle-ibadah').className = 'toggle-btn' + (mode === 'ibadah' ? ' active-ibadah' : '');
document.getElementById('toggle-miskin').className = 'toggle-btn' + (mode === 'miskin' ? ' active-miskin' : '');
}
map.on('click', async (e) => {
if (!mode) return;
const { lat, lng } = e.latlng;
pendingLatLng = { lat, lng };
pendingGeo = null;
if (tempMarker) map.removeLayer(tempMarker);
tempMarker = L.marker([lat, lng], { icon: createTempIcon(mode) }).addTo(map);
if (mode === 'ibadah') _openModalIbadah(lat, lng, null);
else _openModalMiskin(lat, lng, null);
// Reverse geocode async
try {
const gr = await fetch(`${BASE_URL}/api/geocode.php?lat=${lat}&lng=${lng}`);
pendingGeo = await gr.json();
_fillGeoFields(mode);
} catch { /* no-op */ }
});
function _fillGeoFields(type) {
if (!pendingGeo) return;
const addr = pendingGeo.display_name || '';
if (type === 'ibadah') {
const el = document.getElementById('ibadah-addr-display');
if (el) el.textContent = addr ? '📍 ' + addr : '—';
const kel = document.getElementById('ibadah-kelurahan');
const kec = document.getElementById('ibadah-kecamatan');
if (kel && !kel.value && pendingGeo.kelurahan) kel.value = pendingGeo.kelurahan;
if (kec && !kec.value && pendingGeo.kecamatan) kec.value = pendingGeo.kecamatan;
} else {
const el = document.getElementById('miskin-addr-display');
if (el) el.textContent = addr ? '📍 ' + addr : '—';
const kel = document.getElementById('miskin-kelurahan');
const kec = document.getElementById('miskin-kecamatan');
if (kel && !kel.value && pendingGeo.kelurahan) kel.value = pendingGeo.kelurahan;
if (kec && !kec.value && pendingGeo.kecamatan) kec.value = pendingGeo.kecamatan;
}
}
// ── MODAL IBADAH ─────────────────────────────────────────────────
function _openModalIbadah(lat, lng, data) {
const isEdit = data !== null;
document.getElementById('modal-ibadah-title').textContent = isEdit ? '✏️ Edit Rumah Ibadah' : '🏛️ Tambah Rumah Ibadah';
document.getElementById('ibadah-edit-id').value = isEdit ? data.id : '';
document.getElementById('ibadah-nama').value = isEdit ? data.nama : '';
document.getElementById('ibadah-jenis').value = isEdit ? data.jenis : 'Masjid';
document.getElementById('ibadah-kontak').value = isEdit ? (data.kontak||'') : '';
document.getElementById('ibadah-kelurahan').value = isEdit ? (data.kelurahan||'') : '';
document.getElementById('ibadah-kecamatan').value = isEdit ? (data.kecamatan||'') : '';
const rad = isEdit ? data.radius : 300;
document.getElementById('ibadah-radius').value = rad;
document.getElementById('ibadah-radius-val').textContent = fmtRadius(rad);
document.getElementById('ibadah-coord-display').textContent = `Lat: ${lat.toFixed(6)} Lng: ${lng.toFixed(6)}`;
const addrEl = document.getElementById('ibadah-addr-display');
if (isEdit) addrEl.textContent = '📍 ' + (data.alamat || `${lat.toFixed(5)}, ${lng.toFixed(5)}`);
else addrEl.innerHTML = '<span class="addr-loading">🔄 Memuat alamat...</span>';
document.getElementById('modal-ibadah').classList.add('open');
setTimeout(() => document.getElementById('ibadah-nama').focus(), 120);
}
window.openEditIbadah = function (id) {
const ib = allIbadah.find(x => x.id == id); if (!ib) return;
map.closePopup();
pendingLatLng = { lat: ib.lat, lng: ib.lng };
_openModalIbadah(ib.lat, ib.lng, ib);
};
window.closeModalIbadah = function () {
document.getElementById('modal-ibadah').classList.remove('open');
if (tempMarker && !document.getElementById('modal-miskin').classList.contains('open')) {
map.removeLayer(tempMarker); tempMarker = null;
}
if (!document.getElementById('modal-miskin').classList.contains('open')) {
pendingLatLng = null; pendingGeo = null;
mode = null; _resetMode(); _updateToolbar();
}
};
document.getElementById('ibadah-radius').addEventListener('input', function () {
document.getElementById('ibadah-radius-val').textContent = fmtRadius(parseInt(this.value));
// Live preview on existing circle
const eid = document.getElementById('ibadah-edit-id').value;
if (eid) { const ib = allIbadah.find(x => x.id == eid); if (ib?.circle) ib.circle.setRadius(parseInt(this.value)); }
});
document.getElementById('btn-save-ibadah').addEventListener('click', _saveIbadah);
async function _saveIbadah() {
if (!pendingLatLng) { showToast('❌ Pilih lokasi di peta terlebih dahulu', '#ef4444'); return; }
const { lat, lng } = pendingLatLng;
const editId = document.getElementById('ibadah-edit-id').value;
const nama = document.getElementById('ibadah-nama').value.trim();
if (!nama) { showToast('❌ Nama rumah ibadah wajib diisi!', '#ef4444'); return; }
const payload = {
nama, jenis: document.getElementById('ibadah-jenis').value,
kontak: document.getElementById('ibadah-kontak').value,
kelurahan: document.getElementById('ibadah-kelurahan').value,
kecamatan: document.getElementById('ibadah-kecamatan').value,
alamat: pendingGeo?.display_name || '',
lat, lng, radius: parseInt(document.getElementById('ibadah-radius').value)
};
const btn = document.getElementById('btn-save-ibadah');
btn.disabled = true; btn.textContent = '⏳ Menyimpan...';
try {
const url = editId ? `${BASE_URL}/api/rumah_ibadah.php?id=${editId}` : `${BASE_URL}/api/rumah_ibadah.php`;
const resp = await fetch(url, { method: editId?'PUT':'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
const json = await resp.json();
if (json.success) {
window.closeModalIbadah();
await Promise.all([loadIbadah(), loadMiskin()]);
showToast(`${json.message}`, '#22c55e');
} else { showToast('❌ ' + json.message, '#ef4444'); }
} catch { showToast('❌ Gagal menyimpan data', '#ef4444'); }
finally { btn.disabled = false; btn.innerHTML = '<i class="bi bi-floppy"></i> Simpan'; }
}
// ── MODAL MISKIN ─────────────────────────────────────────────────
function _openModalMiskin(lat, lng, data) {
const isEdit = data !== null;
document.getElementById('modal-miskin-title').textContent = isEdit ? '✏️ Edit Penduduk Miskin' : '👤 Tambah Penduduk Miskin';
document.getElementById('miskin-edit-id').value = isEdit ? data.id : '';
document.getElementById('miskin-kk-nama').value = isEdit ? data.kk_nama : '';
document.getElementById('miskin-nik').value = isEdit ? (data.nik||'') : '';
document.getElementById('miskin-jumlah').value = isEdit ? (data.jumlah_anggota||1) : 1;
document.getElementById('miskin-kelurahan').value = isEdit ? (data.kelurahan||'') : '';
document.getElementById('miskin-kecamatan').value = isEdit ? (data.kecamatan||'') : '';
document.getElementById('miskin-status').value = isEdit ? data.status_bantuan : 'belum';
document.getElementById('miskin-jenis-bantuan').value = isEdit ? (data.jenis_bantuan||'') : '';
document.getElementById('miskin-tanggal').value = isEdit ? (data.tanggal_bantuan||'') : '';
// Nominal bantuan
const nomEl = document.getElementById('miskin-nominal');
if (nomEl) {
const rawNom = isEdit && data.nominal_bantuan ? parseFloat(data.nominal_bantuan) : '';
nomEl.value = rawNom !== '' ? rawNom.toLocaleString('id-ID', {maximumFractionDigits:0}) : '';
}
// Keterangan menunggu
const ketEl = document.getElementById('miskin-keterangan');
if (ketEl) ketEl.value = isEdit ? (data.keterangan||'') : '';
document.getElementById('miskin-coord-display').textContent = `Lat: ${lat.toFixed(6)} Lng: ${lng.toFixed(6)}`;
const addrEl = document.getElementById('miskin-addr-display');
if (isEdit) addrEl.textContent = '📍 ' + (data.alamat || `${lat.toFixed(5)}, ${lng.toFixed(5)}`);
else addrEl.innerHTML = '<span class="addr-loading">🔄 Memuat alamat...</span>';
_toggleBantuanFields();
// Reset anggota
anggotaCount = 0;
document.getElementById('anggota-container').innerHTML = '';
if (isEdit && data.id) _loadAnggota(data.id);
// Bukti preview
const prev = document.getElementById('miskin-bukti-preview');
prev.innerHTML = isEdit && data.bukti_file
? `<div style="font-size:11px;color:var(--text2)">📎 File: <a href="${BASE_URL}/assets/uploads/${data.bukti_file}" target="_blank" style="color:var(--accent)">${data.bukti_file}</a></div>`
: '';
document.getElementById('miskin-bukti').value = '';
document.getElementById('modal-miskin').classList.add('open');
setTimeout(() => document.getElementById('miskin-kk-nama').focus(), 120);
}
async function _loadAnggota(pid) {
try {
const r = await fetch(`${BASE_URL}/api/anggota_keluarga.php?penduduk_id=${pid}`);
const js = await r.json();
if (js.success) js.data.forEach(a => _addAnggotaRow(a));
} catch { /* silent */ }
}
window.closeModalMiskin = function () {
document.getElementById('modal-miskin').classList.remove('open');
if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; }
pendingLatLng = null; pendingGeo = null;
mode = null; _resetMode(); _updateToolbar();
};
window.openEditMiskin = function (id) {
const m = allMiskin.find(x => x.id == id); if (!m) return;
map.closePopup();
pendingLatLng = { lat: m.lat, lng: m.lng };
_openModalMiskin(m.lat, m.lng, m);
};
function _toggleBantuanFields() {
const st = document.getElementById('miskin-status').value;
const showBantuan = (st === 'sudah' || st === 'menunggu');
document.getElementById('bantuan-fields').style.display = showBantuan ? 'block' : 'none';
const ketWrap = document.getElementById('keterangan-menunggu-wrap');
if (ketWrap) ketWrap.style.display = (st === 'menunggu') ? 'block' : 'none';
}
document.getElementById('miskin-status').addEventListener('change', _toggleBantuanFields);
// Anggota rows
function _addAnggotaRow(data) {
anggotaCount++;
const idx = anggotaCount;
const container = document.getElementById('anggota-container');
const div = document.createElement('div');
div.className = 'anggota-row'; div.id = `anggota-row-${idx}`;
div.innerHTML = `
<div class="anggota-row-header">
<span class="anggota-num">👤 Anggota ${idx}</span>
<button class="btn-remove-anggota" onclick="removeAnggotaRow(${idx})" type="button">✕</button>
</div>
<div class="form-row">
<div class="form-group" style="margin-bottom:0">
<label class="form-label">Nama *</label>
<input class="form-control" id="ak-nama-${idx}" placeholder="Nama lengkap" value="${data?.nama||''}">
</div>
<div class="form-group" style="margin-bottom:0">
<label class="form-label">Hubungan</label>
<input class="form-control" id="ak-hub-${idx}" placeholder="Istri/Anak/Dll" value="${data?.hubungan||''}">
</div>
</div>
<div class="form-row" style="margin-top:8px">
<div class="form-group" style="margin-bottom:0">
<label class="form-label">Umur</label>
<input class="form-control" id="ak-umur-${idx}" type="number" min="0" placeholder="Tahun" value="${data?.umur||''}">
</div>
<div class="form-group" style="margin-bottom:0">
<label class="form-label">Pekerjaan</label>
<input class="form-control" id="ak-pkj-${idx}" placeholder="Pekerjaan" value="${data?.pekerjaan||''}">
</div>
</div>
<div class="form-group" style="margin-top:8px;margin-bottom:0">
<label class="form-label">Keterangan</label>
<input class="form-control" id="ak-ket-${idx}" placeholder="Catatan tambahan (opsional)" value="${data?.keterangan||''}">
</div>`;
container.appendChild(div);
}
window.addAnggotaRow = () => _addAnggotaRow(null);
window.removeAnggotaRow = (idx) => { const r = document.getElementById(`anggota-row-${idx}`); if (r) r.remove(); };
document.getElementById('btn-add-anggota').addEventListener('click', () => _addAnggotaRow(null));
function _collectAnggota() {
const rows = document.querySelectorAll('.anggota-row');
const res = [];
rows.forEach(row => {
const idx = row.id.replace('anggota-row-', '');
const nama = document.getElementById(`ak-nama-${idx}`)?.value?.trim();
if (!nama) return;
res.push({
nama,
hubungan: document.getElementById(`ak-hub-${idx}`)?.value || '',
umur: document.getElementById(`ak-umur-${idx}`)?.value || '',
pekerjaan: document.getElementById(`ak-pkj-${idx}`)?.value || '',
keterangan: document.getElementById(`ak-ket-${idx}`)?.value || ''
});
});
return res;
}
document.getElementById('btn-save-miskin').addEventListener('click', _saveMiskin);
async function _saveMiskin() {
if (!pendingLatLng) { showToast('❌ Pilih lokasi di peta terlebih dahulu', '#ef4444'); return; }
const { lat, lng } = pendingLatLng;
const editId = document.getElementById('miskin-edit-id').value;
const nama = document.getElementById('miskin-kk-nama').value.trim();
if (!nama) { showToast('❌ Nama Kepala Keluarga wajib diisi!', '#ef4444'); return; }
const btn = document.getElementById('btn-save-miskin');
btn.disabled = true; btn.textContent = '⏳ Menyimpan...';
const fd = new FormData();
fd.append('kk_nama', nama);
fd.append('nik', document.getElementById('miskin-nik').value);
fd.append('jumlah_anggota', document.getElementById('miskin-jumlah').value || 1);
fd.append('kelurahan', document.getElementById('miskin-kelurahan').value);
fd.append('kecamatan', document.getElementById('miskin-kecamatan').value);
fd.append('alamat', pendingGeo?.display_name || '');
fd.append('lat', lat); fd.append('lng', lng);
fd.append('status_bantuan', document.getElementById('miskin-status').value);
fd.append('jenis_bantuan', document.getElementById('miskin-jenis-bantuan').value);
fd.append('tanggal_bantuan', document.getElementById('miskin-tanggal').value);
// Nominal bantuan: hapus titik pemisah ribuan sebelum kirim
const nomRaw = (document.getElementById('miskin-nominal')?.value || '').replace(/\./g,'');
fd.append('nominal_bantuan', nomRaw);
fd.append('anggota', JSON.stringify(_collectAnggota()));
const buktiInput = document.getElementById('miskin-bukti');
if (buktiInput.files[0]) fd.append('bukti_file', buktiInput.files[0]);
try {
const url = editId ? `${BASE_URL}/api/penduduk_miskin.php?id=${editId}` : `${BASE_URL}/api/penduduk_miskin.php`;
const resp = await fetch(url, { method: editId?'PUT':'POST', body: fd });
const json = await resp.json();
if (json.success) {
window.closeModalMiskin();
await loadMiskin();
showToast(`${json.message}`, '#22c55e');
} else { showToast('❌ ' + json.message, '#ef4444'); }
} catch { showToast('❌ Gagal menyimpan data', '#ef4444'); }
finally { btn.disabled = false; btn.innerHTML = '<i class="bi bi-floppy"></i> Simpan'; }
}
// ── DELETE ───────────────────────────────────────────────────────
window.confirmDeleteIbadah = async function (id) {
if (!confirm('Hapus rumah ibadah ini? Warga yang terkait akan di-reset.')) return;
try {
const r = await fetch(`${BASE_URL}/api/rumah_ibadah.php?id=${id}`, { method:'DELETE' });
const js = await r.json();
if (js.success) { map.closePopup(); await Promise.all([loadIbadah(), loadMiskin()]); showToast('🗑️ Data dihapus', '#f59e0b'); }
else showToast('❌ ' + js.message, '#ef4444');
} catch { showToast('❌ Gagal menghapus', '#ef4444'); }
};
window.confirmDeleteMiskin = async function (id) {
if (!confirm('Hapus data penduduk ini?')) return;
try {
const r = await fetch(`${BASE_URL}/api/penduduk_miskin.php?id=${id}`, { method:'DELETE' });
const js = await r.json();
if (js.success) { map.closePopup(); await loadMiskin(); showToast('🗑️ Data dihapus', '#f59e0b'); }
else showToast('❌ ' + js.message, '#ef4444');
} catch { showToast('❌ Gagal menghapus', '#ef4444'); }
};
// ── GEOLOCATION ──────────────────────────────────────────────────
function doLocate() {
if (!navigator.geolocation) { showToast('❌ Browser tidak mendukung Geolocation', '#ef4444'); return; }
showToast('📡 Mendeteksi lokasi...', '#f59e0b');
navigator.geolocation.getCurrentPosition(async (pos) => {
const lat = pos.coords.latitude, lng = pos.coords.longitude;
if (myMarker) map.removeLayer(myMarker);
myMarker = L.marker([lat, lng], { icon: createMyIcon() }).addTo(map);
let addr = `${lat.toFixed(5)}, ${lng.toFixed(5)}`;
try {
const gr = await fetch(`${BASE_URL}/api/geocode.php?lat=${lat}&lng=${lng}`);
const gj = await gr.json(); addr = gj.display_name || addr;
} catch { /* silent */ }
myMarker.bindPopup(`<b>📍 Lokasi Saya</b><div class="popup-addr">${addr}</div>
<div class="popup-coord">±${Math.round(pos.coords.accuracy)}m · ${lat.toFixed(6)}, ${lng.toFixed(6)}</div>`).openPopup();
map.setView([lat, lng], 16);
const lb = document.getElementById('loc-bar');
lb.style.display = 'flex';
document.getElementById('loc-bar-addr').textContent = truncate(addr, 60);
document.getElementById('loc-bar-coord').textContent = `${lat.toFixed(5)}, ${lng.toFixed(5)} · ±${Math.round(pos.coords.accuracy)}m`;
showToast('✅ Lokasi ditemukan!', '#22c55e');
}, (err) => {
const msgs = {1:'Izin ditolak',2:'Posisi tidak tersedia',3:'Timeout'};
showToast('❌ ' + (msgs[err.code]||'Gagal mendapatkan lokasi'), '#ef4444');
}, { enableHighAccuracy: true, timeout: 10000 });
}
// ── ROUTING ──────────────────────────────────────────────────────
window.routeTo = function (fromLat, fromLng, destLat, destLng) {
if (routeControl) { try { map.removeControl(routeControl); } catch{} routeControl = null; }
map.closePopup();
routeControl = L.Routing.control({
waypoints: [L.latLng(fromLat, fromLng), L.latLng(destLat, destLng)],
lineOptions: { styles: [{ color: '#5b7fff', weight: 4, opacity: 0.85 }] },
createMarker: () => null, show: false,
addWaypoints: false, draggableWaypoints: false
}).addTo(map);
showToast('🗺️ Rute ditampilkan', '#5b7fff');
};
// ── REFRESH ──────────────────────────────────────────────────────
async function refreshAll() {
showToast('🔄 Memperbarui data...', '#f59e0b');
await Promise.all([loadIbadah(), loadMiskin()]);
showToast('✅ Data diperbarui', '#22c55e');
}
// ── KEYBOARD ─────────────────────────────────────────────────────
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
if (document.getElementById('modal-ibadah').classList.contains('open')) window.closeModalIbadah();
else if (document.getElementById('modal-miskin').classList.contains('open')) window.closeModalMiskin();
else { mode = null; _resetMode(); _updateToolbar(); }
}
});
// ── UPLOAD PREVIEW ───────────────────────────────────────────────
document.getElementById('miskin-bukti')?.addEventListener('change', function () {
const prev = document.getElementById('miskin-bukti-preview');
if (!this.files[0]) { prev.innerHTML = ''; return; }
const f = this.files[0];
if (f.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (ev) => {
prev.innerHTML = `<div class="upload-preview"><img src="${ev.target.result}" style="max-height:100px;border-radius:6px;margin-top:6px;"></div>`;
};
reader.readAsDataURL(f);
} else {
prev.innerHTML = `<div style="font-size:11px;color:var(--accent);margin-top:6px;">📎 ${f.name} (${(f.size/1024).toFixed(1)} KB)</div>`;
}
});
// ── NOTIFICATION SYSTEM ─────────────────────────────────────────
let notifLastCheck = new Date().toISOString().replace('T',' ').substring(0,19);
let notifCount = 0;
let notifPanelOpen = false;
function toggleNotifPanel() {
notifPanelOpen = !notifPanelOpen;
const panel = document.getElementById('notif-panel');
if (panel) panel.style.display = notifPanelOpen ? 'block' : 'none';
if (notifPanelOpen) {
// Clear badge when panel opened
notifCount = 0;
const badge = document.getElementById('notif-badge');
if (badge) badge.style.display = 'none';
}
}
async function checkNotifications() {
try {
const r = await fetch(`${BASE_URL}/api/notify.php?since=${encodeURIComponent(notifLastCheck)}`);
const js = await r.json();
if (!js.success) return;
notifLastCheck = js.server_time;
const list = document.getElementById('notif-list');
const badge = document.getElementById('notif-badge');
let html = '';
if (js.new_miskin > 0 || js.new_ibadah > 0) {
notifCount += (js.new_miskin + js.new_ibadah);
if (js.new_miskin > 0) {
html += `<div style="padding:8px 0;border-bottom:1px solid rgba(255,255,255,.05);"><span style="color:#22c55e;">👤 +${js.new_miskin}</span> data penduduk baru</div>`;
showToast(`🔔 ${js.new_miskin} data penduduk baru ditambahkan`, '#22c55e');
await loadMiskin();
}
if (js.new_ibadah > 0) {
html += `<div style="padding:8px 0;border-bottom:1px solid rgba(255,255,255,.05);"><span style="color:#5b7fff;">🏛️ +${js.new_ibadah}</span> rumah ibadah baru</div>`;
showToast(`🔔 ${js.new_ibadah} rumah ibadah baru ditambahkan`, '#5b7fff');
await loadIbadah();
}
if (list) list.innerHTML = html + (list.innerHTML || '');
if (badge) { badge.style.display = 'flex'; badge.textContent = notifCount > 9 ? '9+' : notifCount; }
}
if (js.belum_dibantu > 0 && list && !list.innerHTML.includes('belum-info')) {
list.innerHTML += `<div id="belum-info" style="padding:8px 0;font-size:11px;color:var(--text3);">📊 ${js.belum_dibantu} warga belum dibantu</div>`;
}
if (!list?.innerHTML.trim()) {
if (list) list.innerHTML = '<div style="color:var(--text3);text-align:center;padding:16px;">Tidak ada notifikasi baru</div>';
}
} catch(e) { /* silent */ }
}
// Poll setiap 60 detik
setInterval(checkNotifications, 60000);
// ── INIT ─────────────────────────────────────────────────────────
setTimeout(() => map.invalidateSize(), 150);
refreshAll().then(() => { loadKecamatanOptions(); });
+62
View File
@@ -0,0 +1,62 @@
<?php
// ================================================================
// Konfigurasi Global Aplikasi
// ================================================================
// Base URL aplikasi
define('BASE_URL', 'http://localhost/webjir/poverty_map');
define('BASE_PATH', dirname(__DIR__));
// Koordinat default peta (Pontianak)
define('MAP_DEFAULT_LAT', -0.0263);
define('MAP_DEFAULT_LNG', 109.3425);
define('MAP_DEFAULT_ZOOM', 13);
// Radius default rumah ibadah (meter)
define('RADIUS_DEFAULT', 300);
define('RADIUS_MIN', 100);
define('RADIUS_MAX', 5000);
// Upload settings
define('UPLOAD_DIR', BASE_PATH . '/assets/uploads/');
define('UPLOAD_URL', BASE_URL . '/assets/uploads/');
define('UPLOAD_MAX_SIZE', 10 * 1024 * 1024); // 10 MB
define('UPLOAD_ALLOWED', ['jpg','jpeg','png','gif','pdf','doc','docx']);
// App info
define('APP_NAME', 'WebGIS Sebaran Penduduk Miskin');
define('APP_VERSION', '2.0.0');
// Session
define('SESSION_NAME', 'poverty_map_sess');
// Emoji shorthand (digunakan di view layer)
define('JENIS_EMOJI', [
'Masjid' => '🕌',
'Musholla' => '🕌',
'Gereja' => '⛪',
'Katolik' => '⛪',
'Pura' => '🛕',
'Vihara' => '🛕',
'Klenteng' => '⛩️',
'Lainnya' => '🙏',
]);
// Jenis rumah ibadah dengan emoji
define('JENIS_IBADAH', [
'Masjid' => ['emoji' => '🕌', 'color' => '#10b981'],
'Musholla' => ['emoji' => '🕌', 'color' => '#34d399'],
'Gereja' => ['emoji' => '⛪', 'color' => '#3b82f6'],
'Katolik' => ['emoji' => '⛪', 'color' => '#60a5fa'],
'Pura' => ['emoji' => '🛕', 'color' => '#f59e0b'],
'Vihara' => ['emoji' => '🛕', 'color' => '#fbbf24'],
'Klenteng' => ['emoji' => '⛩️', 'color' => '#ef4444'],
'Lainnya' => ['emoji' => '🙏', 'color' => '#8b5cf6'],
]);
// Status bantuan
define('STATUS_BANTUAN', [
'belum' => ['label' => 'Belum Dibantu', 'color' => '#ef4444', 'badge' => 'danger'],
'sudah' => ['label' => 'Sudah Dibantu', 'color' => '#22c55e', 'badge' => 'success'],
'menunggu' => ['label' => 'Menunggu Verif', 'color' => '#f59e0b', 'badge' => 'warning'],
]);
+27
View File
@@ -0,0 +1,27 @@
<?php
// ================================================================
// Konfigurasi Database
// ================================================================
define('DB_HOST', 'localhost');
define('DB_USER', 'root');
define('DB_PASS', '');
define('DB_NAME', 'poverty_map');
define('DB_CHARSET','utf8mb4');
function getDB(): PDO {
static $pdo = null;
if ($pdo === null) {
$dsn = "mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=" . DB_CHARSET;
try {
$pdo = new PDO($dsn, DB_USER, DB_PASS, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
} catch (PDOException $e) {
http_response_code(500);
die(json_encode(['success' => false, 'message' => 'Database connection failed: ' . $e->getMessage()]));
}
}
return $pdo;
}
+134
View File
@@ -0,0 +1,134 @@
-- ================================================================
-- WebGIS Sebaran Penduduk Miskin
-- Database: poverty_map
-- ================================================================
CREATE DATABASE IF NOT EXISTS poverty_map CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE poverty_map;
-- ----------------------------------------------------------------
-- Tabel: users
-- ----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS users (
id INT NOT NULL AUTO_INCREMENT,
nama VARCHAR(100) NOT NULL,
email VARCHAR(100) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
role ENUM('admin','operator') NOT NULL DEFAULT 'operator',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ----------------------------------------------------------------
-- Tabel: rumah_ibadah
-- ----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS rumah_ibadah (
id INT NOT NULL AUTO_INCREMENT,
nama VARCHAR(150) NOT NULL,
jenis ENUM('Masjid','Musholla','Gereja','Katolik','Pura','Vihara','Klenteng','Lainnya') NOT NULL DEFAULT 'Masjid',
kontak VARCHAR(100) DEFAULT NULL,
alamat TEXT DEFAULT NULL,
kelurahan VARCHAR(100) DEFAULT NULL,
kecamatan VARCHAR(100) DEFAULT NULL,
lat DECIMAL(10,7) NOT NULL,
lng DECIMAL(10,7) NOT NULL,
radius INT NOT NULL DEFAULT 300,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ----------------------------------------------------------------
-- Tabel: penduduk_miskin
-- ----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS penduduk_miskin (
id INT NOT NULL AUTO_INCREMENT,
kk_nama VARCHAR(150) NOT NULL,
nik VARCHAR(20) DEFAULT NULL,
jumlah_anggota INT NOT NULL DEFAULT 1,
alamat TEXT DEFAULT NULL,
kelurahan VARCHAR(100) DEFAULT NULL,
kecamatan VARCHAR(100) DEFAULT NULL,
lat DECIMAL(10,7) NOT NULL,
lng DECIMAL(10,7) NOT NULL,
rumah_ibadah_id INT DEFAULT NULL,
jarak_ke_ibadah DECIMAL(10,2) DEFAULT NULL,
status_bantuan ENUM('belum','sudah','menunggu') NOT NULL DEFAULT 'belum',
jenis_bantuan VARCHAR(200) DEFAULT NULL,
tanggal_bantuan DATE DEFAULT NULL,
nominal_bantuan DECIMAL(15,2) DEFAULT NULL COMMENT 'Nominal bantuan dalam Rupiah',
bukti_file VARCHAR(255) DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
FOREIGN KEY (rumah_ibadah_id) REFERENCES rumah_ibadah(id) ON DELETE SET NULL ON UPDATE CASCADE,
INDEX idx_status (status_bantuan),
INDEX idx_ibadah (rumah_ibadah_id),
INDEX idx_kecamatan (kecamatan)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ----------------------------------------------------------------
-- Tabel: anggota_keluarga
-- ----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS anggota_keluarga (
id INT NOT NULL AUTO_INCREMENT,
penduduk_id INT NOT NULL,
nama VARCHAR(150) NOT NULL,
hubungan VARCHAR(50) DEFAULT NULL,
umur INT DEFAULT NULL,
pekerjaan VARCHAR(100) DEFAULT NULL,
keterangan TEXT DEFAULT NULL,
PRIMARY KEY (id),
FOREIGN KEY (penduduk_id) REFERENCES penduduk_miskin(id) ON DELETE CASCADE ON UPDATE CASCADE,
INDEX idx_penduduk (penduduk_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ----------------------------------------------------------------
-- Tabel: bantuan (riwayat bantuan)
-- ----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS bantuan (
id INT NOT NULL AUTO_INCREMENT,
penduduk_id INT NOT NULL,
jenis_bantuan VARCHAR(200) NOT NULL,
tanggal_bantuan DATE NOT NULL,
keterangan TEXT DEFAULT NULL,
bukti_file VARCHAR(255) DEFAULT NULL,
created_by INT DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
FOREIGN KEY (penduduk_id) REFERENCES penduduk_miskin(id) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ----------------------------------------------------------------
-- Default admin user: admin@povertymap.id / admin123
-- ----------------------------------------------------------------
INSERT IGNORE INTO users (nama, email, password, role) VALUES
('Administrator', 'admin@povertymap.id', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'admin'),
('Operator', 'operator@povertymap.id', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'operator');
-- ----------------------------------------------------------------
-- Sample Data Rumah Ibadah (Pontianak)
-- ----------------------------------------------------------------
INSERT IGNORE INTO rumah_ibadah (nama, jenis, kontak, alamat, kelurahan, kecamatan, lat, lng, radius) VALUES
('Masjid Raya Mujahidin', 'Masjid', '0561-123456', 'Jl. Jend. Ahmad Yani, Pontianak', 'Akcaya', 'Pontianak Selatan', -0.0412, 109.3325, 500),
('Masjid Sultan Syarif Abdurrahman', 'Masjid', '0561-123457', 'Jl. Tanjungpura, Pontianak', 'Dalam Bugis', 'Pontianak Kota', -0.0263, 109.3425, 400),
('Gereja Katedral Santo Yosef', 'Gereja', '0561-123458', 'Jl. Pattimura, Pontianak', 'Mariana', 'Pontianak Kota', -0.0290, 109.3380, 350),
('Vihara Bodhisattva Karaniya', 'Vihara', '0561-123459', 'Jl. Diponegoro, Pontianak', 'Sungai Bangkong', 'Pontianak Kota', -0.0320, 109.3350, 300);
-- ----------------------------------------------------------------
-- Sample Data Penduduk Miskin
-- ----------------------------------------------------------------
INSERT IGNORE INTO penduduk_miskin (kk_nama, nik, jumlah_anggota, alamat, kelurahan, kecamatan, lat, lng, rumah_ibadah_id, jarak_ke_ibadah, status_bantuan, jenis_bantuan, tanggal_bantuan) VALUES
('Ahmad Sulaiman', '6171012345678901', 4, 'Jl. Veteran No. 12, Pontianak', 'Akcaya', 'Pontianak Selatan', -0.0430, 109.3340, 1, 210.5, 'sudah', 'Sembako + Uang Tunai', '2024-01-15'),
('Budi Santoso', '6171023456789012', 3, 'Jl. Imam Bonjol No. 5, Pontianak', 'Dalam Bugis', 'Pontianak Kota', -0.0270, 109.3440, 2, 180.2, 'belum', NULL, NULL),
('Siti Rahayu', '6171034567890123', 5, 'Jl. Gajahmada No. 8, Pontianak', 'Mariana', 'Pontianak Kota', -0.0300, 109.3400, 3, 320.8, 'menunggu', NULL, NULL),
('Wati Susanti', '6171045678901234', 2, 'Jl. Diponegoro No. 20, Pontianak', 'Sungai Bangkong', 'Pontianak Kota', -0.0350, 109.3370, 4, 180.0, 'belum', NULL, NULL);
-- Sample anggota keluarga
INSERT IGNORE INTO anggota_keluarga (penduduk_id, nama, hubungan, umur, pekerjaan, keterangan) VALUES
(1, 'Fatimah', 'Istri', 35, 'Ibu Rumah Tangga', NULL),
(1, 'Ali', 'Anak', 12, 'Pelajar', 'Kelas 6 SD'),
(1, 'Sari', 'Anak', 8, 'Pelajar', 'Kelas 2 SD'),
(2, 'Dewi Santoso', 'Istri', 30, 'Pedagang Kecil', NULL),
(2, 'Rizki', 'Anak', 10, 'Pelajar', NULL);
+62
View File
@@ -0,0 +1,62 @@
<?php
// ================================================================
// Auth Helper
// ================================================================
require_once __DIR__ . '/../config/config.php';
if (session_status() === PHP_SESSION_NONE) {
session_name(SESSION_NAME);
session_start();
}
function isLoggedIn(): bool {
return isset($_SESSION['user_id']) && !empty($_SESSION['user_id']);
}
function requireLogin(): void {
if (!isLoggedIn()) {
header('Location: ' . BASE_URL . '/admin/login.php');
exit;
}
}
function requireAdmin(): void {
requireLogin();
if (getCurrentUser()['role'] !== 'admin') {
http_response_code(403);
die('<h2>Akses Ditolak</h2><p>Anda tidak memiliki izin untuk mengakses halaman ini.</p>');
}
}
function getCurrentUser(): ?array {
if (!isLoggedIn()) return null;
return [
'id' => $_SESSION['user_id'],
'nama' => $_SESSION['user_nama'] ?? '',
'email' => $_SESSION['user_email'] ?? '',
'role' => $_SESSION['user_role'] ?? 'operator',
];
}
function loginUser(array $user): void {
$_SESSION['user_id'] = $user['id'];
$_SESSION['user_nama'] = $user['nama'];
$_SESSION['user_email'] = $user['email'];
$_SESSION['user_role'] = $user['role'];
// Also store as array for API compatibility
$_SESSION['user'] = [
'id' => $user['id'],
'nama' => $user['nama'],
'email' => $user['email'],
'role' => $user['role'],
];
}
function logoutUser(): void {
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $p['path'], $p['domain'], $p['secure'], $p['httponly']);
}
session_destroy();
}
+5
View File
@@ -0,0 +1,5 @@
</main>
</div><!-- .admin-layout -->
<?= $extraFooter ?? '' ?>
</body>
</html>
+128
View File
@@ -0,0 +1,128 @@
<?php
// ================================================================
// Helper Functions
// ================================================================
require_once __DIR__ . '/../config/database.php';
/**
* JSON response helper
*/
function jsonResponse(array $data, int $code = 200): void {
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
/**
* Haversine formula: jarak antara dua koordinat (meter)
*/
function haversineDistance(float $lat1, float $lng1, float $lat2, float $lng2): float {
$R = 6371000; // radius bumi meter
$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);
$c = 2 * atan2(sqrt($a), sqrt(1 - $a));
return $R * $c;
}
/**
* Cari rumah ibadah terdekat dari koordinat
*/
function findNearestIbadah(float $lat, float $lng): ?array {
$db = getDB();
$rows = $db->query("SELECT id, nama, jenis, lat, lng, radius FROM rumah_ibadah")->fetchAll();
$best = null;
$minDist = PHP_FLOAT_MAX;
foreach ($rows as $row) {
$dist = haversineDistance($lat, $lng, (float)$row['lat'], (float)$row['lng']);
if ($dist <= (float)$row['radius'] && $dist < $minDist) {
$minDist = $dist;
$best = array_merge($row, ['jarak' => round($dist, 2)]);
}
}
// Jika tidak ada dalam radius, cari yang paling dekat saja
if ($best === null && !empty($rows)) {
foreach ($rows as $row) {
$dist = haversineDistance($lat, $lng, (float)$row['lat'], (float)$row['lng']);
if ($dist < $minDist) {
$minDist = $dist;
$best = array_merge($row, ['jarak' => round($dist, 2), 'di_luar_radius' => true]);
}
}
}
return $best;
}
/**
* Sanitize string input
*/
function sanitize(string $val): string {
return trim(htmlspecialchars($val, ENT_QUOTES, 'UTF-8'));
}
/**
* Format radius display
*/
function formatRadius(int $meter): string {
return $meter >= 1000 ? number_format($meter / 1000, 1) . ' km' : $meter . ' m';
}
/**
* Format tanggal Indonesia
*/
function formatTanggal(?string $date): string {
if (!$date) return '-';
$ts = strtotime($date);
$bulan = ['','Jan','Feb','Mar','Apr','Mei','Jun','Jul','Agt','Sep','Okt','Nov','Des'];
return date('d', $ts) . ' ' . $bulan[(int)date('n', $ts)] . ' ' . date('Y', $ts);
}
/**
* Handle file upload
*/
function handleUpload(array $file, string $prefix = 'bukti'): ?string {
if ($file['error'] !== UPLOAD_ERR_OK) return null;
if ($file['size'] > UPLOAD_MAX_SIZE) return null;
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($ext, UPLOAD_ALLOWED)) return null;
if (!is_dir(UPLOAD_DIR)) mkdir(UPLOAD_DIR, 0755, true);
$filename = $prefix . '_' . date('Ymd_His') . '_' . uniqid() . '.' . $ext;
$dest = UPLOAD_DIR . $filename;
if (move_uploaded_file($file['tmp_name'], $dest)) return $filename;
return null;
}
/**
* Emoji dan label untuk jenis ibadah
*/
function ibadahEmoji(string $jenis): string {
$map = [
'Masjid' => '🕌',
'Musholla' => '🕌',
'Gereja' => '⛪',
'Katolik' => '⛪',
'Pura' => '🛕',
'Vihara' => '🛕',
'Klenteng' => '⛩️',
'Lainnya' => '🙏',
];
return $map[$jenis] ?? '🏛️';
}
/**
* Color untuk marker berdasarkan status
*/
function statusColor(string $status): string {
return match($status) {
'sudah' => '#22c55e',
'menunggu' => '#f59e0b',
default => '#ef4444',
};
}
+94
View File
@@ -0,0 +1,94 @@
<?php
require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/auth.php';
$user = getCurrentUser();
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= isset($pageTitle) ? htmlspecialchars($pageTitle) . ' — ' : '' ?><?= APP_NAME ?></title>
<meta name="description" content="WebGIS Sebaran Penduduk Miskin — Sistem pemetaan interaktif rumah ibadah dan penduduk miskin berbasis web.">
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<!-- Google Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<!-- SweetAlert2 CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/sweetalert2@11/dist/sweetalert2.min.css">
<!-- Admin CSS -->
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/admin.css?v=<?= time() ?>">
<?= $extraHead ?? '' ?>
</head>
<body>
<!-- Bootstrap 5 JS (di head agar tersedia untuk modal) -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<!-- SweetAlert2 JS -->
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<!-- ══ NAVBAR ══════════════════════════════════════════════ -->
<nav class="admin-navbar">
<a href="<?= BASE_URL ?>/admin/" class="navbar-brand">
<span class="brand-icon">🗺️</span>
<span><?= APP_NAME ?></span>
</a>
<div class="navbar-right">
<a href="<?= BASE_URL ?>/" target="_blank" class="btn-nav-link" title="Lihat Peta">
<i class="bi bi-map"></i> Buka Peta
</a>
<?php if ($user): ?>
<div class="user-badge">
<span class="user-role-dot <?= $user['role'] ?>"></span>
<span><?= htmlspecialchars($user['nama']) ?></span>
<span class="role-pill"><?= ucfirst($user['role']) ?></span>
</div>
<a href="<?= BASE_URL ?>/admin/logout.php" class="btn-nav-link danger" title="Logout">
<i class="bi bi-box-arrow-right"></i>
</a>
<?php endif; ?>
</div>
</nav>
<div class="admin-layout">
<!-- ══ SIDEBAR ══════════════════════════════════════════════ -->
<aside class="admin-sidebar" id="admin-sidebar">
<nav class="sidebar-nav">
<div class="nav-section-label">Menu</div>
<a href="<?= BASE_URL ?>/admin/" class="nav-item <?= (basename($_SERVER['PHP_SELF']) === 'index.php' && strpos($_SERVER['REQUEST_URI'], '/admin/') !== false) ? 'active' : '' ?>">
<i class="bi bi-speedometer2"></i> Dashboard
</a>
<a href="<?= BASE_URL ?>/admin/rumah_ibadah.php" class="nav-item <?= (basename($_SERVER['PHP_SELF']) === 'rumah_ibadah.php') ? 'active' : '' ?>">
<i class="bi bi-building"></i> Rumah Ibadah
</a>
<a href="<?= BASE_URL ?>/admin/penduduk_miskin.php" class="nav-item <?= (basename($_SERVER['PHP_SELF']) === 'penduduk_miskin.php') ? 'active' : '' ?>">
<i class="bi bi-people"></i> Penduduk Miskin
</a>
<?php if ($user && $user['role'] === 'admin'): ?>
<div class="nav-section-label" style="margin-top:20px;">Admin</div>
<a href="<?= BASE_URL ?>/admin/import.php" class="nav-item <?= (basename($_SERVER['PHP_SELF']) === 'import.php') ? 'active' : '' ?>">
<i class="bi bi-upload"></i> Import Data
</a>
<a href="<?= BASE_URL ?>/admin/export.php" class="nav-item <?= (basename($_SERVER['PHP_SELF']) === 'export.php') ? 'active' : '' ?>">
<i class="bi bi-download"></i> Export Data
</a>
<a href="<?= BASE_URL ?>/admin/users.php" class="nav-item <?= (basename($_SERVER['PHP_SELF']) === 'users.php') ? 'active' : '' ?>">
<i class="bi bi-person-gear"></i> Pengguna
</a>
<?php endif; ?>
<div class="nav-section-label" style="margin-top:20px;">Lainnya</div>
<a href="<?= BASE_URL ?>/" target="_blank" class="nav-item">
<i class="bi bi-map-fill"></i> Lihat Peta
</a>
<a href="<?= BASE_URL ?>/admin/logout.php" class="nav-item danger">
<i class="bi bi-box-arrow-right"></i> Logout
</a>
</nav>
</aside>
<!-- ══ MAIN CONTENT ══════════════════════════════════════════ -->
<main class="admin-content">
+86
View File
@@ -0,0 +1,86 @@
<?php // Modal: Tambah / Edit Rumah Ibadah ?>
<div class="modal-overlay" id="modal-ibadah">
<div class="modal-card">
<!-- Header -->
<div class="modal-header" style="background:linear-gradient(135deg,rgba(91,127,255,0.12),rgba(124,91,255,0.08));border-bottom:1px solid rgba(91,127,255,0.2);">
<div class="modal-title" id="modal-ibadah-title">🏛️ Tambah Rumah Ibadah</div>
<button class="modal-close" onclick="closeModalIbadah()" type="button" title="Tutup"></button>
</div>
<!-- Body -->
<div class="modal-body">
<input type="hidden" id="ibadah-edit-id">
<!-- Lokasi -->
<div class="section-label">📍 Lokasi</div>
<div class="form-group">
<div class="addr-preview" id="ibadah-coord-display" style="font-family:monospace;font-size:11px;">Lat: Lng: </div>
<div class="addr-preview" id="ibadah-addr-display" style="margin-top:6px;">
<span class="addr-loading">Pilih titik di peta terlebih dahulu...</span>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Kelurahan</label>
<input class="form-control" id="ibadah-kelurahan" placeholder="Kelurahan / Desa">
</div>
<div class="form-group">
<label class="form-label">Kecamatan</label>
<input class="form-control" id="ibadah-kecamatan" placeholder="Kecamatan">
</div>
</div>
<!-- Data Rumah Ibadah -->
<div class="section-label">🕌 Data Rumah Ibadah</div>
<div class="form-group">
<label class="form-label">Nama Rumah Ibadah <span style="color:var(--red)">*</span></label>
<input class="form-control" id="ibadah-nama" placeholder="Mis. Masjid Al-Ikhlas">
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Jenis <span style="color:var(--red)">*</span></label>
<select class="form-control" id="ibadah-jenis">
<option value="Masjid">🕌 Masjid</option>
<option value="Musholla">🕌 Musholla</option>
<option value="Gereja"> Gereja</option>
<option value="Katolik"> Gereja Katolik</option>
<option value="Pura">🛕 Pura</option>
<option value="Vihara">🛕 Vihara</option>
<option value="Klenteng">⛩️ Klenteng</option>
<option value="Lainnya">🙏 Lainnya</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Kontak / Telepon</label>
<input class="form-control" id="ibadah-kontak" placeholder="0561-xxx / 08xx-xxxx">
</div>
</div>
<!-- Radius -->
<div class="section-label">📏 Radius Layanan</div>
<div class="form-group">
<div class="range-wrapper">
<input type="range" id="ibadah-radius" min="100" max="5000" step="50" value="300"
style="flex:1;accent-color:var(--accent);">
<span class="range-val" id="ibadah-radius-val">300 m</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:9px;color:var(--text3);margin-top:3px;">
<span>Min: 100 m</span><span>Default: 300 m</span><span>Max: 5 km</span>
</div>
</div>
</div><!-- /.modal-body -->
<!-- Footer -->
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeModalIbadah()" type="button">
<i class="bi bi-x-lg"></i> Batal
</button>
<button class="btn btn-primary" id="btn-save-ibadah" type="button">
<i class="bi bi-floppy2-fill"></i> Simpan
</button>
</div>
</div><!-- /.modal-card -->
</div><!-- /.modal-overlay #modal-ibadah -->
+132
View File
@@ -0,0 +1,132 @@
<?php // Modal: Tambah / Edit Penduduk Miskin ?>
<div class="modal-overlay" id="modal-miskin">
<div class="modal-card" style="max-width:600px;">
<!-- Header -->
<div class="modal-header" style="background:linear-gradient(135deg,rgba(34,197,94,0.1),rgba(34,197,94,0.04));border-bottom:1px solid rgba(34,197,94,0.2);">
<div class="modal-title" id="modal-miskin-title">👤 Tambah Penduduk Miskin</div>
<button class="modal-close" onclick="closeModalMiskin()" type="button" title="Tutup">&#x2715;</button>
</div>
<!-- Body -->
<div class="modal-body">
<input type="hidden" id="miskin-edit-id">
<!-- Lokasi -->
<div class="section-label">📍 Lokasi</div>
<div class="form-group">
<div class="addr-preview" id="miskin-coord-display" style="font-family:monospace;font-size:11px;">Lat: Lng: </div>
<div class="addr-preview" id="miskin-addr-display" style="margin-top:6px;">
<span class="addr-loading">Pilih titik di peta terlebih dahulu...</span>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Kelurahan</label>
<input class="form-control" id="miskin-kelurahan" placeholder="Kelurahan / Desa">
</div>
<div class="form-group">
<label class="form-label">Kecamatan</label>
<input class="form-control" id="miskin-kecamatan" placeholder="Kecamatan">
</div>
</div>
<!-- Data Keluarga -->
<div class="section-label">👨‍👩‍👧 Data Kepala Keluarga</div>
<div class="form-group">
<label class="form-label">Nama Kepala Keluarga <span style="color:var(--red)">*</span></label>
<input class="form-control" id="miskin-kk-nama" placeholder="Nama lengkap kepala keluarga">
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">NIK KK</label>
<input class="form-control" id="miskin-nik" placeholder="16 digit NIK (opsional)" maxlength="16">
</div>
<div class="form-group">
<label class="form-label">Jumlah Anggota</label>
<input class="form-control" id="miskin-jumlah" type="number" min="1" max="30" value="1" placeholder="1">
</div>
</div>
<!-- Anggota Keluarga -->
<div class="section-label">👥 Detail Anggota Keluarga</div>
<div id="anggota-container"></div>
<button class="btn btn-outline-accent" id="btn-add-anggota" type="button"
style="width:100%;margin-bottom:4px;">
<i class="bi bi-plus-circle"></i> Tambah Anggota Keluarga
</button>
<!-- Status Bantuan -->
<div class="section-label">🤝 Status Bantuan</div>
<div class="form-group">
<label class="form-label">Status Bantuan <span style="color:var(--red)">*</span></label>
<select class="form-control" id="miskin-status">
<option value="belum"> Belum Dibantu</option>
<option value="menunggu"> Menunggu Verifikasi</option>
<option value="sudah"> Sudah Dibantu</option>
</select>
</div>
<!-- Bantuan fields (tampil untuk: sudah DAN menunggu) -->
<div id="bantuan-fields" style="display:none;">
<div class="form-row">
<div class="form-group">
<label class="form-label">Jenis Bantuan</label>
<input class="form-control" id="miskin-jenis-bantuan" placeholder="Mis. Sembako + Uang Tunai">
</div>
<div class="form-group" id="wrap-tanggal">
<label class="form-label">Tanggal Bantuan</label>
<input class="form-control" id="miskin-tanggal" type="date">
</div>
</div>
<!-- BARU: Nominal Bantuan (Rupiah) -->
<div class="form-group">
<label class="form-label">💰 Nominal Bantuan (Rupiah)</label>
<div style="position:relative;">
<span style="position:absolute;left:12px;top:50%;transform:translateY(-50%);font-size:12px;color:var(--text2);font-weight:700;pointer-events:none;">Rp</span>
<input class="form-control" id="miskin-nominal"
type="text" inputmode="numeric"
placeholder="0"
style="padding-left:36px;font-family:'JetBrains Mono',monospace;"
oninput="formatNominalInput(this)">
</div>
<div style="font-size:10px;color:var(--text3);margin-top:4px;">Kosongkan jika belum ditentukan nominal</div>
</div>
<!-- Keterangan (tampil khusus status menunggu) -->
<div class="form-group" id="keterangan-menunggu-wrap" style="display:none;">
<label class="form-label">📋 Keterangan Verifikasi</label>
<input class="form-control" id="miskin-keterangan" placeholder="Alasan atau catatan menunggu verifikasi...">
</div>
</div>
<!-- Upload Bukti -->
<div class="section-label">📎 Bukti Pendukung (Opsional)</div>
<div class="form-group">
<label class="upload-area" for="miskin-bukti" id="upload-area-miskin">
<input type="file" id="miskin-bukti" accept=".jpg,.jpeg,.png,.gif,.pdf,.doc,.docx" style="position:absolute;inset:0;opacity:0;cursor:pointer;">
<div class="upload-text">
<div style="font-size:22px;margin-bottom:4px;">📁</div>
<div>Klik atau seret file ke sini</div>
<div style="font-size:10px;color:var(--text3);margin-top:3px;">JPG · PNG · PDF · DOC (maks 10 MB)</div>
</div>
</label>
<div id="miskin-bukti-preview"></div>
</div>
</div><!-- /.modal-body -->
<!-- Footer -->
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeModalMiskin()" type="button">
<i class="bi bi-x-lg"></i> Batal
</button>
<button class="btn btn-success" id="btn-save-miskin" type="button">
<i class="bi bi-floppy2-fill"></i> Simpan
</button>
</div>
</div><!-- /.modal-card -->
</div><!-- /.modal-overlay #modal-miskin -->
+204
View File
@@ -0,0 +1,204 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>⛽ Input SPBU & Rumah Ibadah — WebGIS Kalbar</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css"/>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- HEADER -->
<header>
<div class="logo"></div>
<div style="flex:1;">
<h1>Input Manual SPBU & Rumah Ibadah</h1>
<p>Mode 2 — OpenStreetMap · Geolocation · Reverse Geocoding</p>
</div>
<a href="landing.php" title="Kembali ke pilih mode" style="
display:inline-flex;align-items:center;gap:7px;
background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.12);
color:#8890b0;text-decoration:none;font-size:12px;font-weight:600;
padding:6px 14px;border-radius:20px;transition:all 0.2s;
white-space:nowrap;
" onmouseover="this.style.color='#e8ecf4';this.style.background='rgba(255,255,255,0.1)'" onmouseout="this.style.color='#8890b0';this.style.background='rgba(255,255,255,0.06)'">
← Pilih Mode
</a>
</header>
<div class="main">
<!-- SIDEBAR -->
<div class="sidebar">
<div class="section">
<div class="section-title">
<span class="dot" style="background:var(--yellow)"></span>Lokasi Anda
</div>
<button class="btn btn-loc" id="btn-locate">📍 Deteksi Lokasi Laptop</button>
<div id="my-location-info" style="display:none; margin-top:10px;">
<div class="item-card" style="cursor:default;">
<div class="item-icon">💻</div>
<div class="item-info">
<div class="item-name">Lokasi Saya</div>
<div class="item-addr" id="my-addr">Memuat alamat...</div>
<div class="item-coord" id="my-coord"></div>
</div>
</div>
</div>
</div>
<div class="section">
<div class="section-title">
<span class="dot" style="background:var(--accent)"></span>Statistik
</div>
<div class="stats-row">
<div class="stat-box blue">
<div class="stat-num" id="stat-ibadah">0</div>
<div class="stat-label">Ibadah</div>
</div>
<div class="stat-box yellow">
<div class="stat-num" id="stat-in">0</div>
<div class="stat-label">SPBU</div>
</div>
<div class="stat-box red" style="display:none;">
<div class="stat-num" id="stat-out">0</div>
<div class="stat-label">Di Luar</div>
</div>
</div>
</div>
<div class="section">
<div class="section-title">
<span class="dot"></span>Rumah Ibadah
<span class="count-badge" id="count-ibadah">0</span>
</div>
<div class="item-list" id="ibadah-list">
<div class="empty-state">Belum ada rumah ibadah</div>
</div>
</div>
<div class="section">
<div class="section-title">
<span class="dot" style="background:var(--green)"></span>SPBU & Pangkalan BBM
<span class="count-badge" id="count-miskin">0</span>
</div>
<div class="item-list" id="miskin-list">
<div class="empty-state">Belum ada data SPBU</div>
</div>
</div>
<div class="section">
<button class="btn btn-danger" id="btn-reset">🗑️ Hapus Semua Data</button>
</div>
</div>
<!-- MAP CONTAINER -->
<div class="map-container">
<div id="map"></div>
<!-- TOGGLE TOOLBAR -->
<div class="toolbar" id="toolbar">
<div class="toolbar-label">Tambah:</div>
<div class="toggle-group">
<button class="toggle-btn" id="toggle-ibadah" onclick="setMode('ibadah')">🏛️ Ibadah</button>
<button class="toggle-btn" id="toggle-miskin" onclick="setMode('miskin')">⛽ SPBU</button>
</div>
<button class="toolbar-loc-btn" id="btn-locate-map" title="Deteksi Lokasi">📍</button>
</div>
<div id="loc-bar">
<span class="loc-icon">📍</span>
<div class="loc-text">
<strong id="loc-bar-addr">Mendapatkan alamat...</strong>
<span id="loc-bar-coord"></span>
</div>
</div>
</div>
</div>
<div id="toast"></div>
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- FORM OVERLAY: TAMBAH / EDIT IBADAH & MISKIN -->
<!-- ═══════════════════════════════════════════════════════════ -->
<div id="form-overlay" class="form-overlay hidden">
<div class="form-card">
<div class="form-header" id="form-header">
<span id="form-title">Tambah Titik</span>
<button class="form-close" onclick="closeFormOverlay()"></button>
</div>
<div class="form-body">
<!-- ── IBADAH FIELDS ── -->
<div id="form-ibadah-fields">
<label>Nama Rumah Ibadah</label>
<input type="text" id="f-ibadah-name" placeholder="cth: Masjid Al-Ikhlas">
<label>Jenis</label>
<select id="f-ibadah-type">
<option value="🕌">🕌 Masjid</option>
<option value="⛪">⛪ Gereja</option>
<option value="🛕">🛕 Pura / Vihara</option>
<option value="⛩️">⛩️ Klenteng</option>
<option value="🙏">🙏 Lainnya</option>
</select>
<label>Radius Layanan</label>
<div class="range-label">
<span style="font-size:11px;color:var(--text2)">100m</span>
<span class="range-val" id="f-radius-val">500 m</span>
<span style="font-size:11px;color:var(--text2)">5km</span>
</div>
<input type="range" id="f-radius-slider" min="100" max="5000" step="100" value="500">
<div class="form-coord" id="f-ibadah-coord"></div>
<div class="form-addr-preview" id="f-ibadah-addr">
<span class="addr-loading">🔄 Memuat alamat...</span>
</div>
</div>
<!-- ── SPBU FIELDS ── -->
<div id="form-miskin-fields" style="display:none">
<label>Nama SPBU / Pangkalan BBM</label>
<input type="text" id="f-miskin-name" placeholder="cth: SPBU 64.751.01 Jl. Veteran">
<label>Jenis</label>
<select id="f-spbu-type">
<option value="⛽">⛽ SPBU Pertamina</option>
<option value="🛢️">🛢️ Pangkalan BBM</option>
<option value="🏪">🏪 Agen LPG</option>
<option value="🔋">🔋 SPKLU (Listrik)</option>
<option value="⚡">⚡ Lainnya</option>
</select>
<label>Radius Layanan</label>
<div class="range-label">
<span style="font-size:11px;color:var(--text2)">100m</span>
<span class="range-val" id="f-radius-val-spbu">1 km</span>
<span style="font-size:11px;color:var(--text2)">10km</span>
</div>
<input type="range" id="f-radius-slider-spbu" min="100" max="10000" step="100" value="1000">
<div class="form-coord" id="f-miskin-coord"></div>
<div class="form-addr-preview" id="f-miskin-addr">
<span class="addr-loading">🔄 Memuat alamat...</span>
</div>
</div>
</div>
<div class="form-footer">
<button class="btn btn-ghost" onclick="closeFormOverlay()">Batal</button>
<button class="btn btn-primary" id="btn-form-save">💾 Simpan</button>
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
<script src="app.js"></script>
</body>
</html>
+248
View File
@@ -0,0 +1,248 @@
<?php
// ================================================================
// Dashboard Utama — WebGIS Peta Interaktif
// ================================================================
require_once __DIR__ . '/config/config.php';
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= APP_NAME ?></title>
<meta name="description" content="WebGIS interaktif untuk pemetaan sebaran penduduk miskin dan rumah ibadah dengan analisis spasial radius layanan.">
<!-- Leaflet -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css"/>
<!-- Google Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<!-- Bootstrap Icons -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<!-- App CSS -->
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/app.css?v=<?= time() ?>">
<!-- SweetAlert2 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/sweetalert2@11/dist/sweetalert2.min.css">
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<style>
/* Extra inline for Leaflet.heat & routing indicator */
.leaflet-routing-container { display: none !important; }
</style>
</head>
<body>
<div class="app-layout">
<!-- ══════════════════════════════════════════════════════════
SIDEBAR
══════════════════════════════════════════════════════════ -->
<aside class="sidebar" id="sidebar">
<!-- Header -->
<div class="sidebar-header">
<div class="sidebar-brand">
<div class="brand-icon">🗺️</div>
<div>
<div class="brand-title"><?= APP_NAME ?></div>
<div class="brand-sub">OpenStreetMap · Leaflet.js</div>
</div>
</div>
<!-- Search -->
<div class="search-box">
<span class="search-icon">🔍</span>
<input type="text" id="global-search" placeholder="Cari warga atau rumah ibadah...">
</div>
<!-- Filter -->
<div class="filter-row">
<select class="filter-select" id="filter-status">
<option value="">Semua Status</option>
<option value="belum">Belum Dibantu</option>
<option value="sudah">Sudah Dibantu</option>
<option value="menunggu">Menunggu</option>
</select>
<select class="filter-select" id="filter-jenis">
<option value="">Semua Ibadah</option>
<option value="Masjid">Masjid</option>
<option value="Musholla">Musholla</option>
<option value="Gereja">Gereja</option>
<option value="Katolik">Katolik</option>
<option value="Pura">Pura</option>
<option value="Vihara">Vihara</option>
<option value="Klenteng">Klenteng</option>
<option value="Lainnya">Lainnya</option>
</select>
</div>
<!-- Filter Wilayah -->
<div class="filter-row" style="margin-top:6px;">
<select class="filter-select" id="filter-kecamatan" style="width:100%;">
<option value="">Semua Kecamatan</option>
</select>
</div>
<div class="filter-row" style="margin-top:4px;">
<select class="filter-select" id="filter-kelurahan" style="width:100%;">
<option value="">Semua Kelurahan</option>
</select>
</div>
</div>
<!-- Stats -->
<div class="stats-grid">
<div class="stat-card blue">
<div class="stat-num" id="stat-ibadah">0</div>
<div class="stat-label">Rumah Ibadah</div>
</div>
<div class="stat-card yellow">
<div class="stat-num" id="stat-miskin">0</div>
<div class="stat-label">Total Warga</div>
</div>
<div class="stat-card green">
<div class="stat-num" id="stat-sudah">0</div>
<div class="stat-label">Sudah Dibantu</div>
</div>
<div class="stat-card red">
<div class="stat-num" id="stat-belum">0</div>
<div class="stat-label">Belum Dibantu</div>
</div>
</div>
<div style="background:rgba(34,197,94,0.1); border:1px solid rgba(34,197,94,0.2); border-radius:12px; padding:12px; margin-bottom:16px; display:flex; align-items:center; gap:12px;">
<div style="font-size:24px; padding:8px; background:rgba(34,197,94,0.15); border-radius:8px;">💰</div>
<div>
<div id="stat-nominal" style="font-size:18px; font-weight:800; color:var(--green); font-family:'JetBrains Mono',monospace;">Rp 0</div>
<div style="font-size:11px; color:var(--text2); margin-top:2px;">Total Bantuan Disalurkan</div>
</div>
</div>
<!-- Tabs -->
<div class="sidebar-tabs">
<button class="tab-btn active" id="tab-ibadah" onclick="switchTab('ibadah')">
🏛️ Rumah Ibadah <span class="count-badge" id="count-ibadah">0</span>
</button>
<button class="tab-btn" id="tab-miskin" onclick="switchTab('miskin')">
👤 Penduduk <span class="count-badge" id="count-miskin">0</span>
</button>
</div>
<!-- List Body -->
<div class="sidebar-body" id="sidebar-body">
<div id="list-ibadah">
<div class="empty-state">
<span class="empty-icon">🏛️</span>
<div>Belum ada data rumah ibadah</div>
</div>
</div>
<div id="list-miskin" style="display:none">
<div class="empty-state">
<span class="empty-icon">👤</span>
<div>Belum ada data penduduk</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="sidebar-footer">
<div class="sidebar-btn-row">
<a href="<?= BASE_URL ?>/landing.php" class="btn-sm-action" title="Kembali ke pilih mode" style="text-decoration:none;text-align:center;">
<i class="bi bi-house-fill"></i> Mode
</a>
<button class="btn-sm-action accent" onclick="window.open('<?= BASE_URL ?>/admin/', '_blank')">
<i class="bi bi-grid"></i> Admin
</button>
<button class="btn-sm-action" onclick="refreshAll()" title="Refresh data">
<i class="bi bi-arrow-clockwise"></i> Refresh
</button>
</div>
</div>
</aside>
<!-- ══════════════════════════════════════════════════════════
MAP CONTAINER
══════════════════════════════════════════════════════════ -->
<div class="map-wrap">
<div id="map"></div>
<!-- Floating Toolbar -->
<div class="map-toolbar" id="map-toolbar">
<span class="toolbar-label">Tambah:</span>
<button class="toggle-btn" id="toggle-ibadah" onclick="setMode('ibadah')">🏛️ Ibadah</button>
<button class="toggle-btn" id="toggle-miskin" onclick="setMode('miskin')">👤 Penduduk</button>
<div class="toolbar-divider"></div>
<button class="toolbar-icon-btn" onclick="doLocate()" title="Lokasi Saya">📍</button>
<button class="toolbar-icon-btn" id="btn-notif" onclick="toggleNotifPanel()" title="Notifikasi" style="position:relative;">
🔔<span id="notif-badge" style="display:none;position:absolute;top:-4px;right:-4px;background:#ef4444;color:#fff;font-size:9px;font-weight:700;border-radius:50%;width:16px;height:16px;display:none;align-items:center;justify-content:center;">0</span>
</button>
<button class="toolbar-icon-btn" id="toggle-sidebar-btn" onclick="toggleSidebar()" title="Toggle Sidebar">☰</button>
</div>
<!-- Notification Panel -->
<div id="notif-panel" style="display:none;position:absolute;top:60px;right:16px;width:280px;background:var(--panel-bg,#1e2538);border:1px solid var(--border,rgba(255,255,255,.08));border-radius:12px;z-index:1000;box-shadow:0 8px 32px rgba(0,0,0,.4);overflow:hidden;">
<div style="padding:12px 16px;border-bottom:1px solid rgba(255,255,255,.06);font-weight:700;font-size:13px;display:flex;justify-content:space-between;align-items:center;">
🔔 Notifikasi
<button onclick="toggleNotifPanel()" style="background:none;border:none;color:var(--text3,#666);cursor:pointer;font-size:16px;">✕</button>
</div>
<div id="notif-list" style="padding:12px 16px;font-size:12px;min-height:60px;"></div>
</div>
<!-- Map Controls -->
<div class="map-controls">
<button class="map-ctrl-btn" onclick="map.zoomIn()" title="Zoom In"></button>
<button class="map-ctrl-btn" onclick="map.zoomOut()" title="Zoom Out"></button>
<button class="map-ctrl-btn" id="btn-heatmap" onclick="toggleHeatmap()" title="Heatmap">🔥</button>
<button class="map-ctrl-btn" id="btn-layers" onclick="toggleBasemap()" title="Ganti Basemap">🗺️</button>
</div>
<!-- Legend -->
<div class="map-legend" id="map-legend">
<div class="legend-title">Legenda</div>
<div class="legend-item"><div class="legend-dot" style="background:#22c55e"></div> Sudah Dibantu</div>
<div class="legend-item"><div class="legend-dot" style="background:#ef4444"></div> Belum Dibantu</div>
<div class="legend-item"><div class="legend-dot" style="background:#f59e0b"></div> Menunggu Verif.</div>
<div class="legend-item" style="margin-top:6px;">
<div class="legend-rect" style="background:rgba(91,127,255,0.15);border:1px dashed #5b7fff"></div> Radius Layanan
</div>
</div>
<!-- Location Bar -->
<div class="loc-bar" id="loc-bar">
📍 <strong id="loc-bar-addr">—</strong>
<span id="loc-bar-coord" style="font-size:10px;opacity:0.6;"></span>
</div>
</div><!-- .map-wrap -->
</div><!-- .app-layout -->
<!-- Toast -->
<div id="toast"></div>
<!-- ══════════════════════════════════════════════════════════
MODAL: TAMBAH/EDIT RUMAH IBADAH
══════════════════════════════════════════════════════════ -->
<?php include __DIR__ . '/includes/modal_ibadah.php'; ?>
<!-- ══════════════════════════════════════════════════════════
MODAL: TAMBAH/EDIT PENDUDUK MISKIN
══════════════════════════════════════════════════════════ -->
<?php include __DIR__ . '/includes/modal_miskin.php'; ?>
<!-- ══════════════════════════════════════════════════════════
SCRIPTS
══════════════════════════════════════════════════════════ -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
<!-- Leaflet Heat -->
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
<!-- Leaflet Routing Machine -->
<script src="https://unpkg.com/leaflet-routing-machine@3.2.12/dist/leaflet-routing-machine.min.js"></script>
<script>
const BASE_URL = '<?= BASE_URL ?>';
const MAP_LAT = <?= MAP_DEFAULT_LAT ?>;
const MAP_LNG = <?= MAP_DEFAULT_LNG ?>;
const MAP_ZOOM = <?= MAP_DEFAULT_ZOOM ?>;
</script>
<script src="<?= BASE_URL ?>/assets/js/map.js?v=<?= time() ?>"></script>
</body>
</html>
+435
View File
@@ -0,0 +1,435 @@
<?php
// ================================================================
// Landing Page — Pemilih Mode Aplikasi WebGIS
// ================================================================
require_once __DIR__ . '/config/config.php';
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebGIS Kalimantan Barat — Pilih Mode</title>
<meta name="description" content="Pilih mode aplikasi WebGIS: Peta Kemiskinan & Rumah Ibadah atau Input Manual SPBU.">
<!-- Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
<!-- Bootstrap Icons -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<style>
/* ── RESET & BASE ───────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0a0e1a;
--bg2: #0f1526;
--card: rgba(255,255,255,0.04);
--card-hov: rgba(255,255,255,0.07);
--border: rgba(255,255,255,0.08);
--text1: #e8ecf4;
--text2: #8890b0;
--text3: #4a5070;
/* Mode 1 — Poverty Map */
--c1-from: #5b7fff;
--c1-to: #a855f7;
--c1-glow: rgba(91,127,255,0.35);
--c1-glow2: rgba(168,85,247,0.25);
/* Mode 2 — SPBU */
--c2-from: #f59e0b;
--c2-to: #ef4444;
--c2-glow: rgba(245,158,11,0.35);
--c2-glow2: rgba(239,68,68,0.25);
}
html, body {
min-height: 100vh;
background: var(--bg);
font-family: 'Plus Jakarta Sans', sans-serif;
color: var(--text1);
overflow-x: hidden;
}
/* ── ANIMATED BG ──────────────────────────────────────────── */
.bg-orbs {
position: fixed; inset: 0; z-index: 0; pointer-events: none; overflow: hidden;
}
.orb {
position: absolute; border-radius: 50%;
filter: blur(80px); opacity: 0.12;
animation: floatOrb 12s ease-in-out infinite;
}
.orb-1 {
width: 500px; height: 500px;
background: radial-gradient(circle, #5b7fff, transparent);
top: -150px; left: -150px;
animation-delay: 0s; animation-duration: 14s;
}
.orb-2 {
width: 400px; height: 400px;
background: radial-gradient(circle, #a855f7, transparent);
top: 30%; right: -100px;
animation-delay: -4s; animation-duration: 11s;
}
.orb-3 {
width: 350px; height: 350px;
background: radial-gradient(circle, #f59e0b, transparent);
bottom: -100px; left: 30%;
animation-delay: -8s; animation-duration: 16s;
}
.orb-4 {
width: 300px; height: 300px;
background: radial-gradient(circle, #ef4444, transparent);
bottom: 10%; right: 20%;
animation-delay: -3s; animation-duration: 13s;
}
@keyframes floatOrb {
0%, 100% { transform: translate(0, 0) scale(1); }
33% { transform: translate(30px, -40px) scale(1.05); }
66% { transform: translate(-20px, 20px) scale(0.95); }
}
/* Grid overlay */
.bg-grid {
position: fixed; inset: 0; z-index: 0; pointer-events: none;
background-image:
linear-gradient(rgba(91,127,255,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(91,127,255,0.03) 1px, transparent 1px);
background-size: 48px 48px;
}
/* ── LAYOUT ──────────────────────────────────────────────── */
.page-wrap {
position: relative; z-index: 1;
min-height: 100vh;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
padding: 40px 20px;
gap: 48px;
}
/* ── HEADER ──────────────────────────────────────────────── */
.hero {
text-align: center;
animation: fadeInDown 0.7s ease both;
}
.hero-badge {
display: inline-flex; align-items: center; gap: 7px;
background: rgba(91,127,255,0.12);
border: 1px solid rgba(91,127,255,0.3);
color: #7c9fff;
font-size: 11px; font-weight: 700; letter-spacing: 1.2px; text-transform: uppercase;
padding: 5px 14px; border-radius: 100px;
margin-bottom: 20px;
}
.hero-title {
font-size: clamp(2rem, 5vw, 3.2rem);
font-weight: 900;
line-height: 1.15;
background: linear-gradient(135deg, #e8ecf4 30%, #8890b0 100%);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 12px;
}
.hero-sub {
font-size: clamp(0.9rem, 2vw, 1.05rem);
color: var(--text2);
max-width: 480px; line-height: 1.6;
}
/* ── MODE CARDS ──────────────────────────────────────────── */
.cards-row {
display: flex; gap: 24px; flex-wrap: wrap;
justify-content: center;
animation: fadeInUp 0.7s ease 0.15s both;
}
.mode-card {
position: relative;
width: 320px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 24px;
padding: 36px 32px 32px;
cursor: pointer;
text-decoration: none; color: inherit;
overflow: hidden;
transition: transform 0.3s ease, box-shadow 0.3s ease, border-color 0.3s ease, background 0.3s ease;
display: flex; flex-direction: column; gap: 18px;
}
.mode-card:hover {
transform: translateY(-6px);
background: var(--card-hov);
}
/* Animated top glow bar */
.mode-card::before {
content: '';
position: absolute; top: 0; left: 0; right: 0;
height: 3px;
border-radius: 24px 24px 0 0;
transition: opacity 0.3s ease;
opacity: 0.6;
}
.mode-card:hover::before { opacity: 1; }
/* Shimmer on hover */
.mode-card::after {
content: '';
position: absolute; inset: 0;
background: radial-gradient(circle at 50% 0%, rgba(255,255,255,0.05), transparent 70%);
opacity: 0; transition: opacity 0.4s ease;
border-radius: 24px;
pointer-events: none;
}
.mode-card:hover::after { opacity: 1; }
/* Mode 1 */
.card-mode1::before { background: linear-gradient(90deg, var(--c1-from), var(--c1-to)); }
.card-mode1:hover {
border-color: rgba(91,127,255,0.4);
box-shadow:
0 20px 60px rgba(0,0,0,0.5),
0 0 0 1px rgba(91,127,255,0.2),
inset 0 0 80px var(--c1-glow2);
}
/* Mode 2 */
.card-mode2::before { background: linear-gradient(90deg, var(--c2-from), var(--c2-to)); }
.card-mode2:hover {
border-color: rgba(245,158,11,0.4);
box-shadow:
0 20px 60px rgba(0,0,0,0.5),
0 0 0 1px rgba(245,158,11,0.2),
inset 0 0 80px var(--c2-glow2);
}
/* Icon */
.card-icon-wrap {
width: 64px; height: 64px;
border-radius: 18px;
display: flex; align-items: center; justify-content: center;
font-size: 28px;
position: relative;
transition: transform 0.3s ease;
}
.mode-card:hover .card-icon-wrap { transform: scale(1.08) rotate(-3deg); }
.card-mode1 .card-icon-wrap {
background: linear-gradient(135deg, rgba(91,127,255,0.2), rgba(168,85,247,0.15));
border: 1px solid rgba(91,127,255,0.3);
box-shadow: 0 4px 20px var(--c1-glow);
}
.card-mode2 .card-icon-wrap {
background: linear-gradient(135deg, rgba(245,158,11,0.2), rgba(239,68,68,0.15));
border: 1px solid rgba(245,158,11,0.3);
box-shadow: 0 4px 20px var(--c2-glow);
}
.card-num {
position: absolute; top: -8px; right: -8px;
width: 22px; height: 22px; border-radius: 50%;
font-size: 10px; font-weight: 800; color: #fff;
display: flex; align-items: center; justify-content: center;
}
.card-mode1 .card-num { background: linear-gradient(135deg, var(--c1-from), var(--c1-to)); }
.card-mode2 .card-num { background: linear-gradient(135deg, var(--c2-from), var(--c2-to)); }
/* Text */
.card-label {
font-size: 11px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase;
margin-bottom: 4px;
}
.card-mode1 .card-label { color: #7c9fff; }
.card-mode2 .card-label { color: #fbbf24; }
.card-title {
font-size: 1.35rem; font-weight: 800; color: var(--text1);
line-height: 1.25; margin-bottom: 8px;
}
.card-desc {
font-size: 0.875rem; color: var(--text2); line-height: 1.6;
}
/* Features list */
.card-features {
list-style: none; display: flex; flex-direction: column; gap: 7px;
}
.card-features li {
font-size: 12px; color: var(--text2);
display: flex; align-items: center; gap: 8px;
}
.card-features li .feat-dot {
width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0;
}
.card-mode1 .feat-dot { background: #5b7fff; }
.card-mode2 .feat-dot { background: #f59e0b; }
/* CTA Button */
.card-cta {
margin-top: 4px;
padding: 12px 20px;
border-radius: 12px;
font-size: 14px; font-weight: 700;
border: none; cursor: pointer;
display: flex; align-items: center; justify-content: center; gap: 8px;
transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease;
text-decoration: none;
position: relative; overflow: hidden;
}
.card-cta::after {
content: '';
position: absolute; inset: 0;
background: rgba(255,255,255,0.1);
opacity: 0; transition: opacity 0.2s ease;
}
.card-cta:hover::after { opacity: 1; }
.card-cta:active { transform: scale(0.97); }
.card-mode1 .card-cta {
background: linear-gradient(135deg, var(--c1-from), var(--c1-to));
color: #fff;
box-shadow: 0 4px 20px var(--c1-glow);
}
.card-mode1 .card-cta:hover { box-shadow: 0 6px 30px var(--c1-glow); transform: translateY(-1px); }
.card-mode2 .card-cta {
background: linear-gradient(135deg, var(--c2-from), var(--c2-to));
color: #fff;
box-shadow: 0 4px 20px var(--c2-glow);
}
.card-mode2 .card-cta:hover { box-shadow: 0 6px 30px var(--c2-glow); transform: translateY(-1px); }
/* Arrow icon animate */
.arrow-icon {
transition: transform 0.25s ease;
display: inline-block;
}
.card-cta:hover .arrow-icon { transform: translateX(4px); }
/* ── FOOTER ──────────────────────────────────────────────── */
.landing-footer {
animation: fadeInUp 0.7s ease 0.3s both;
text-align: center;
font-size: 12px; color: var(--text3);
display: flex; align-items: center; gap: 16px; flex-wrap: wrap; justify-content: center;
}
.landing-footer a {
color: var(--text2); text-decoration: none;
transition: color 0.2s;
}
.landing-footer a:hover { color: var(--text1); }
.landing-footer .sep { opacity: 0.4; }
/* ── ANIMATIONS ──────────────────────────────────────────── */
@keyframes fadeInDown {
from { opacity: 0; transform: translateY(-20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── RESPONSIVE ──────────────────────────────────────────── */
@media (max-width: 700px) {
.cards-row { flex-direction: column; align-items: center; }
.mode-card { width: 100%; max-width: 360px; }
}
</style>
</head>
<body>
<!-- Background -->
<div class="bg-orbs">
<div class="orb orb-1"></div>
<div class="orb orb-2"></div>
<div class="orb orb-3"></div>
<div class="orb orb-4"></div>
</div>
<div class="bg-grid"></div>
<!-- Page -->
<div class="page-wrap">
<!-- Hero -->
<div class="hero">
<div class="hero-badge">
<i class="bi bi-globe-asia-australia"></i>
WebGIS Kalimantan Barat
</div>
<h1 class="hero-title">Pilih Mode Aplikasi</h1>
<p class="hero-sub">Pilih salah satu modul pemetaan yang ingin Anda gunakan. Kedua mode menggunakan OpenStreetMap interaktif.</p>
</div>
<!-- Mode Cards -->
<div class="cards-row">
<!-- ── MODE 1: POVERTY MAP ─────────────────────────────── -->
<a href="<?= BASE_URL ?>/index.php" class="mode-card card-mode1" id="card-poverty">
<div class="card-icon-wrap">
🗺️
<div class="card-num">1</div>
</div>
<div>
<div class="card-label">Mode 1</div>
<div class="card-title">Peta Kemiskinan<br>& Rumah Ibadah</div>
<div class="card-desc">Pemetaan sebaran penduduk miskin berbasis database dengan analisis radius layanan rumah ibadah secara real-time.</div>
</div>
<ul class="card-features">
<li><span class="feat-dot"></span> Database penduduk miskin & KK</li>
<li><span class="feat-dot"></span> Radius layanan rumah ibadah</li>
<li><span class="feat-dot"></span> Heatmap sebaran & statistik</li>
<li><span class="feat-dot"></span> Import/Export Excel & filter wilayah</li>
</ul>
<div class="card-cta">
<i class="bi bi-map-fill"></i>
Buka Poverty Map
<i class="bi bi-arrow-right arrow-icon"></i>
</div>
</a>
<!-- ── MODE 2: INPUT SPBU ──────────────────────────────── -->
<a href="<?= BASE_URL ?>/index.html" class="mode-card card-mode2" id="card-spbu">
<div class="card-icon-wrap">
<div class="card-num">2</div>
</div>
<div>
<div class="card-label">Mode 2</div>
<div class="card-title">Input Manual<br>SPBU & Ibadah</div>
<div class="card-desc">Input cepat lokasi SPBU dan rumah ibadah langsung di peta tanpa akun. Data tersimpan di sesi browser secara lokal.</div>
</div>
<ul class="card-features">
<li><span class="feat-dot"></span> Klik peta untuk tambah titik</li>
<li><span class="feat-dot"></span> Deteksi lokasi otomatis (GPS)</li>
<li><span class="feat-dot"></span> Reverse geocoding alamat</li>
<li><span class="feat-dot"></span> Tanpa login, langsung pakai</li>
</ul>
<div class="card-cta">
<i class="bi bi-fuel-pump-fill"></i>
Buka Input SPBU
<i class="bi bi-arrow-right arrow-icon"></i>
</div>
</a>
</div>
<!-- Footer -->
<div class="landing-footer">
<span>© <?= date('Y') ?> WebGIS Kalbar</span>
<span class="sep">|</span>
<a href="<?= BASE_URL ?>/admin/" title="Panel Admin">
<i class="bi bi-grid-3x3-gap-fill"></i> Panel Admin
</a>
<span class="sep">|</span>
<span>OpenStreetMap · Leaflet.js</span>
</div>
</div>
</body>
</html>
+307
View File
@@ -0,0 +1,307 @@
/* ─── VARIABLES ──────────────────────────────────────────────── */
:root {
--bg: #0f1117;
--surface: #1a1d27;
--surface2: #22263a;
--surface3: #2a2f45;
--border: #2e3350;
--accent: #5b7fff;
--accent2: #7c5bff;
--green: #22c55e;
--red: #ef4444;
--yellow: #f59e0b;
--orange: #f97316;
--text: #e8eaf6;
--text2: #8890b0;
--header-h: 61px;
--sidebar-w: 300px;
}
/* ─── RESET ──────────────────────────────────────────────────── */
* { box-sizing: border-box; margin: 0; padding: 0; }
html { height: 100%; }
body {
font-family: 'Plus Jakarta Sans', sans-serif;
background: var(--bg); color: var(--text);
height: 100%; overflow: hidden;
}
/* ─── HEADER ─────────────────────────────────────────────────── */
header {
position: fixed; top: 0; left: 0; right: 0;
height: var(--header-h);
background: var(--surface); border-bottom: 1px solid var(--border);
padding: 0 20px; display: flex; align-items: center; gap: 12px; z-index: 800;
}
header .logo {
width: 36px; height: 36px; flex-shrink: 0;
background: linear-gradient(135deg, var(--accent), var(--accent2));
border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 18px;
}
header h1 {
font-size: 15px; font-weight: 700; letter-spacing: -0.3px;
background: linear-gradient(90deg, var(--text), var(--accent));
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
header p { font-size: 11px; color: var(--text2); margin-top: 1px; }
/* ─── LAYOUT ─────────────────────────────────────────────────── */
.main { position: fixed; top: var(--header-h); left: 0; right: 0; bottom: 0; }
/* ─── SIDEBAR ────────────────────────────────────────────────── */
.sidebar {
position: absolute; top: 0; left: 0; width: var(--sidebar-w); bottom: 0;
background: var(--surface); border-right: 1px solid var(--border);
overflow-y: auto; display: flex; flex-direction: column; z-index: 500;
}
.sidebar::-webkit-scrollbar { width: 4px; }
.sidebar::-webkit-scrollbar-track { background: transparent; }
.sidebar::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
/* ─── SECTION ────────────────────────────────────────────────── */
.section { padding: 14px 16px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
.section-title {
font-size: 10px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase;
color: var(--text2); margin-bottom: 10px; display: flex; align-items: center; gap: 6px;
}
.section-title .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
.count-badge {
margin-left: auto; background: var(--surface3); border: 1px solid var(--border);
border-radius: 10px; padding: 1px 7px; font-size: 10px; color: var(--text2);
font-family: 'JetBrains Mono', monospace;
}
/* ─── BUTTONS ────────────────────────────────────────────────── */
.btn {
width: 100%; padding: 9px 14px; border-radius: 8px; border: none;
font-family: inherit; font-size: 13px; font-weight: 600; cursor: pointer;
transition: all 0.15s; display: flex; align-items: center; justify-content: center; gap: 6px;
}
.btn-primary { background: linear-gradient(135deg, var(--accent), var(--accent2)); color: white; flex: 2; }
.btn-primary:hover { opacity: 0.88; transform: translateY(-1px); }
.btn-danger { background: rgba(239,68,68,0.12); color: var(--red); border: 1px solid rgba(239,68,68,0.25); }
.btn-danger:hover { background: rgba(239,68,68,0.22); }
.btn-warning { background: rgba(249,115,22,0.12); color: var(--orange); border: 1px solid rgba(249,115,22,0.25); }
.btn-warning:hover { background: rgba(249,115,22,0.22); }
.btn-loc { background: rgba(245,158,11,0.12); color: var(--yellow); border: 1px solid rgba(245,158,11,0.25); }
.btn-loc:hover { background: rgba(245,158,11,0.22); }
.btn-ghost { background: var(--surface3); color: var(--text2); border: 1px solid var(--border); flex: 1; }
.btn-ghost:hover { background: var(--border); color: var(--text); }
/* ─── STATS ──────────────────────────────────────────────────── */
.stats-row { display: flex; gap: 6px; }
.stat-box { flex: 1; background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; padding: 8px; text-align: center; }
.stat-box .stat-num { font-family: 'JetBrains Mono', monospace; font-size: 20px; font-weight: 700; }
.stat-box .stat-label { font-size: 10px; color: var(--text2); margin-top: 2px; }
.stat-box.green .stat-num { color: var(--green); }
.stat-box.red .stat-num { color: var(--red); }
.stat-box.blue .stat-num { color: var(--accent); }
.stat-box.yellow .stat-num { color: var(--yellow); }
/* ─── ITEM LIST ──────────────────────────────────────────────── */
.item-list { display: flex; flex-direction: column; gap: 5px; }
.item-card {
background: var(--surface2); border: 1px solid var(--border);
border-radius: 8px; padding: 8px 10px; display: flex; align-items: flex-start;
gap: 8px; font-size: 12px; cursor: pointer; transition: border-color 0.15s, background 0.15s;
}
.item-card:hover { border-color: var(--accent); background: var(--surface3); }
.item-card.highlighted { border-color: var(--accent) !important; background: rgba(91,127,255,0.1) !important; box-shadow: 0 0 0 1px var(--accent); }
.item-card .item-icon { font-size: 15px; flex-shrink: 0; margin-top: 1px; }
.item-card .item-info { flex: 1; min-width: 0; }
.item-card .item-name { font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.item-card .item-addr { color: var(--text2); font-size: 10px; margin-top: 2px; line-height: 1.3; }
.item-card .item-coord { font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--text2); margin-top: 2px; }
/* Radius live-edit di sidebar */
.item-radius-row {
display: flex; align-items: center; gap: 6px; margin-top: 5px;
}
.item-radius-row input[type="range"] {
flex: 1; height: 3px; margin-bottom: 0; background: var(--border);
}
.item-radius-row input[type="range"]::-webkit-slider-thumb {
width: 13px; height: 13px; background: var(--accent);
}
.radius-display {
font-family: 'JetBrains Mono', monospace; font-size: 9px;
color: var(--accent); background: var(--surface3);
padding: 1px 5px; border-radius: 4px; border: 1px solid var(--border);
white-space: nowrap; flex-shrink: 0;
}
/* Nearest ibadah tag */
.nearest-tag {
font-size: 9px; padding: 1px 5px; border-radius: 4px;
background: rgba(91,127,255,0.15); color: var(--accent);
border: 1px solid rgba(91,127,255,0.3); margin-top: 3px;
display: inline-block; font-weight: 600;
}
.badge { font-size: 10px; padding: 2px 5px; border-radius: 4px; font-weight: 700; flex-shrink: 0; }
.badge-green { background: rgba(34,197,94,0.15); color: var(--green); }
.badge-red { background: rgba(239,68,68,0.12); color: var(--red); }
.badge-yellow { background: rgba(245,158,11,0.15); color: var(--yellow); }
.item-actions { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
.btn-remove {
background: transparent; border: none; cursor: pointer; color: var(--text2);
font-size: 13px; padding: 2px 4px; border-radius: 4px; transition: color 0.15s, background 0.15s; line-height: 1;
}
.btn-remove:hover { color: var(--red); background: rgba(239,68,68,0.1); }
.btn-edit {
background: transparent; border: none; cursor: pointer; color: var(--text2);
font-size: 12px; padding: 2px 4px; border-radius: 4px; transition: color 0.15s, background 0.15s; line-height: 1;
}
.btn-edit:hover { color: var(--accent); background: rgba(91,127,255,0.1); }
.empty-state { text-align: center; color: var(--text2); font-size: 11px; padding: 14px 0; opacity: 0.5; }
label {
display: block; font-size: 11px; color: var(--text2);
margin-bottom: 5px; font-weight: 500;
}
/* ─── MAP CONTAINER ──────────────────────────────────────────── */
.map-container { position: absolute; top: 0; left: var(--sidebar-w); right: 0; bottom: 0; z-index: 1; }
#map { width: 100%; height: 100%; }
/* ─── TOOLBAR ────────────────────────────────────────────────── */
.toolbar {
position: absolute; top: 14px; left: 50%; transform: translateX(-50%);
z-index: 400; background: var(--surface); border: 1px solid var(--border);
border-radius: 50px; padding: 6px 8px; display: flex; align-items: center; gap: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.4); white-space: nowrap;
}
.toolbar-label { font-size: 11px; color: var(--text2); font-weight: 600; padding: 0 4px; }
.toggle-group {
display: flex; gap: 3px; background: var(--surface2);
border: 1px solid var(--border); border-radius: 40px; padding: 3px;
}
.toggle-btn {
padding: 6px 14px; border-radius: 40px; border: none;
font-family: inherit; font-size: 12px; font-weight: 600;
cursor: pointer; transition: all 0.18s; color: var(--text2); background: transparent;
}
.toggle-btn:hover { color: var(--text); background: var(--surface3); }
.toggle-btn.active-ibadah { background: linear-gradient(135deg, var(--accent), var(--accent2)); color: white; box-shadow: 0 2px 10px rgba(91,127,255,0.4); }
.toggle-btn.active-miskin { background: linear-gradient(135deg, #16a34a, var(--green)); color: white; box-shadow: 0 2px 10px rgba(34,197,94,0.4); }
.toolbar-loc-btn {
width: 32px; height: 32px; border-radius: 50%; border: 1px solid var(--border);
background: var(--surface2); cursor: pointer; font-size: 14px; transition: all 0.15s;
display: flex; align-items: center; justify-content: center;
}
.toolbar-loc-btn:hover { background: rgba(245,158,11,0.15); border-color: var(--yellow); }
#map.adding-ibadah { cursor: crosshair !important; }
#map.adding-miskin { cursor: cell !important; }
/* ─── LOC BAR ────────────────────────────────────────────────── */
#loc-bar {
position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%);
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
padding: 10px 16px; z-index: 400; font-size: 12px; color: var(--text2);
display: none; gap: 8px; align-items: center; max-width: 90%;
box-shadow: 0 4px 24px rgba(0,0,0,0.4);
}
#loc-bar .loc-icon { font-size: 16px; }
#loc-bar .loc-text strong { color: var(--yellow); display: block; font-size: 13px; }
/* ─── PULSE ──────────────────────────────────────────────────── */
@keyframes markerPulse {
0% { box-shadow: 0 0 0 0 rgba(91,127,255,0.8); }
70% { box-shadow: 0 0 0 12px rgba(91,127,255,0); }
100% { box-shadow: 0 0 0 0 rgba(91,127,255,0); }
}
.leaflet-marker-pulsing div { animation: markerPulse 1s ease-out 3 !important; }
/* ─── FORM OVERLAY ───────────────────────────────────────────── */
.form-overlay {
position: fixed; inset: 0; z-index: 2000;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.5); backdrop-filter: blur(3px); transition: opacity 0.2s;
}
.form-overlay.hidden { display: none; }
.form-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: 16px; width: 380px; max-width: 95vw;
box-shadow: 0 20px 60px rgba(0,0,0,0.5); overflow: hidden;
animation: formIn 0.2s cubic-bezier(0.34,1.56,0.64,1);
}
@keyframes formIn {
from { opacity: 0; transform: scale(0.92) translateY(10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.form-header {
padding: 14px 18px; display: flex; align-items: center; justify-content: space-between;
border-bottom: 1px solid var(--border); font-weight: 700; font-size: 14px;
}
.form-header.ibadah-header { background: linear-gradient(135deg, rgba(91,127,255,0.15), rgba(124,91,255,0.1)); }
.form-header.miskin-header { background: linear-gradient(135deg, rgba(34,197,94,0.12), rgba(22,163,74,0.08)); }
.form-close {
background: var(--surface2); border: 1px solid var(--border); border-radius: 6px;
cursor: pointer; color: var(--text2); width: 26px; height: 26px; font-size: 12px;
display: flex; align-items: center; justify-content: center; transition: all 0.15s;
}
.form-close:hover { color: var(--red); border-color: var(--red); }
.form-body { padding: 16px 18px; display: flex; flex-direction: column; }
.form-body label {
font-size: 11px; color: var(--text2); margin-bottom: 5px;
font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;
}
.form-body input[type="text"],
.form-body select {
width: 100%; background: var(--surface2); border: 1px solid var(--border);
border-radius: 8px; color: var(--text); padding: 8px 12px;
font-size: 13px; font-family: inherit; outline: none;
transition: border-color 0.2s; margin-bottom: 12px;
}
.form-body input:focus, .form-body select:focus { border-color: var(--accent); }
.range-label { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
.range-val {
font-family: 'JetBrains Mono', monospace; font-size: 12px;
background: var(--surface2); padding: 2px 8px; border-radius: 4px;
color: var(--accent); border: 1px solid var(--border);
}
input[type="range"] {
-webkit-appearance: none; width: 100%; height: 4px;
background: var(--border); border-radius: 2px; outline: none; margin-bottom: 12px;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; width: 16px; height: 16px;
border-radius: 50%; background: var(--accent); cursor: pointer; border: 2px solid var(--bg);
}
.form-coord {
font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2);
margin-bottom: 8px; padding: 5px 8px; background: var(--surface2);
border-radius: 6px; border: 1px solid var(--border);
}
.form-addr-preview {
font-size: 11px; color: var(--text2); line-height: 1.4; padding: 8px 10px;
background: var(--surface2); border-radius: 8px; border: 1px solid var(--border); margin-bottom: 4px;
}
.addr-loading { color: var(--text2); font-style: italic; }
.form-footer { padding: 12px 18px; border-top: 1px solid var(--border); display: flex; gap: 8px; }
/* ─── TOAST ──────────────────────────────────────────────────── */
#toast {
position: fixed; top: 76px; right: 16px;
background: var(--surface2); border: 1px solid var(--border);
border-radius: 10px; padding: 11px 15px; font-size: 13px;
z-index: 9999; display: none; animation: slideIn 0.2s ease;
max-width: 280px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);
}
@keyframes slideIn { from { opacity:0; transform: translateX(16px); } to { opacity:1; transform: translateX(0); } }
/* ─── LEAFLET POPUP ──────────────────────────────────────────── */
.leaflet-popup-content-wrapper {
background: var(--surface2) !important; color: var(--text) !important;
border: 1px solid var(--border) !important; border-radius: 10px !important;
box-shadow: 0 4px 20px rgba(0,0,0,0.4) !important;
font-family: 'Plus Jakarta Sans', sans-serif !important;
}
.leaflet-popup-tip { background: var(--surface2) !important; }
.leaflet-popup-content { margin: 12px 14px !important; font-size: 13px !important; line-height: 1.6 !important; }
.leaflet-popup-content b { color: var(--accent); }
.leaflet-popup-content .popup-addr { color: var(--text2); font-size: 11px; margin-top: 4px; }
.leaflet-popup-close-button { color: var(--text2) !important; }