feat: complete WebGIS implementation with nominal bantuan and cache busting
This commit is contained in:
@@ -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;
|
||||
@@ -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'; ?>
|
||||
@@ -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 | 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
@@ -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'; ?>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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'; ?>
|
||||
@@ -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
@@ -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'; ?>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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
@@ -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);
|
||||
@@ -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'),
|
||||
]);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
]
|
||||
]);
|
||||
@@ -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
@@ -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);
|
||||
@@ -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>
|
||||
· ${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();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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; }
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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(); });
|
||||
@@ -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'],
|
||||
]);
|
||||
@@ -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
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
</main>
|
||||
</div><!-- .admin-layout -->
|
||||
<?= $extraFooter ?? '' ?>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -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">
|
||||
@@ -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 -->
|
||||
@@ -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">✕</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
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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; }
|
||||
Reference in New Issue
Block a user