pertama dan terakhir

This commit is contained in:
Tazyeuu
2026-06-10 22:07:47 +07:00
commit cd376094df
143 changed files with 12090 additions and 0 deletions
+79
View File
@@ -0,0 +1,79 @@
<?php
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/auth_check.php';
requireRole('admin');
$pdo = Database::getConnection();
$rows = $pdo->query("SELECT id,nama,jenis_jalan,created_at FROM jalan ORDER BY id DESC")->fetchAll();
$pageTitle='Data Jalan'; $activeNav='jalan';
require_once __DIR__ . '/partials/header.php';
?>
<div class="page-header">
<div class="breadcrumb"><span>Admin</span><span class="sep">/</span><span>Data Jalan</span></div>
<h1>🛣️ Manajemen Data Jalan</h1>
<p>Kelola data ruas jalan yang terpetakan dalam sistem.</p>
</div>
<div class="page-toolbar">
<div class="search-box"><i class="fas fa-search search-icon"></i><input type="text" id="searchInput" placeholder="Cari jalan..." oninput="filterTable('searchInput','jalanTable')"></div>
<button class="btn btn-primary" onclick="openModal('modalTambah')"><i class="fas fa-plus"></i> Tambah Jalan</button>
</div>
<div class="table-wrap">
<table class="data-table" id="jalanTable">
<thead><tr><th>#</th><th>Nama Jalan</th><th>Jenis</th><th>Ditambahkan</th><th>Aksi</th></tr></thead>
<tbody>
<?php foreach($rows as $i=>$r): ?>
<tr>
<td style="color:var(--text-muted)"><?=$i+1?></td>
<td><strong><?=htmlspecialchars($r['nama'])?></strong></td>
<td><span class="badge badge-info"><?=htmlspecialchars($r['jenis_jalan']??'-')?></span></td>
<td style="color:var(--text-muted);font-size:0.8rem;"><?=date('d M Y',strtotime($r['created_at']))?></td>
<td><div class="actions">
<button class="btn btn-ghost btn-sm btn-icon" onclick="editRow(<?=htmlspecialchars(json_encode($r))?>)"><i class="fas fa-edit"></i></button>
<button class="btn btn-danger btn-sm btn-icon" onclick="hapus(<?=$r['id']?>,'<?=addslashes($r['nama'])?>')"><i class="fas fa-trash"></i></button>
</div></td>
</tr>
<?php endforeach; ?>
<?php if(!$rows):?><tr><td colspan="5" style="text-align:center;padding:40px;color:var(--text-muted);">Belum ada data jalan.</td></tr><?php endif;?>
</tbody>
</table></div>
<div class="modal-overlay" id="modalTambah"><div class="modal">
<div class="modal-header"><span class="modal-title">🛣️ Tambah Jalan</span><button class="modal-close" onclick="closeModal('modalTambah')">×</button></div>
<form id="formTambah">
<div class="form-group"><label class="form-label">Nama Jalan *</label><input type="text" name="nama" class="form-control" placeholder="Nama ruas jalan" required></div>
<div class="form-group"><label class="form-label">Jenis Jalan *</label>
<select name="jenis_jalan" class="form-control">
<option value="Jalan Arteri">Jalan Arteri</option>
<option value="Jalan Kolektor">Jalan Kolektor</option>
<option value="Jalan Lokal" selected>Jalan Lokal</option>
<option value="Jalan Lingkungan">Jalan Lingkungan</option>
<option value="Jalan Tol">Jalan Tol</option>
</select>
</div>
<p style="font-size:0.82rem;color:var(--text-muted);background:rgba(59,130,246,0.08);border:1px solid rgba(59,130,246,0.2);border-radius:8px;padding:10px;"><i class="fas fa-info-circle"></i> Geometri jalan (LineString) hanya bisa ditambahkan melalui <a href="<?= app_url('admin/peta.php') ?>" style="color:var(--primary-light);">Peta Interaktif</a>.</p>
</form>
<div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal('modalTambah')">Batal</button><button class="btn btn-primary" onclick="simpan()"><i class="fas fa-save"></i> Simpan (Tanpa Geometri)</button></div>
</div></div>
<div class="modal-overlay" id="modalEdit"><div class="modal">
<div class="modal-header"><span class="modal-title">✏️ Edit Jalan</span><button class="modal-close" onclick="closeModal('modalEdit')">×</button></div>
<input type="hidden" id="editId">
<div class="form-group"><label class="form-label">Nama Jalan *</label><input type="text" id="editNama" class="form-control" required></div>
<div class="form-group"><label class="form-label">Jenis Jalan</label>
<select id="editJenis" class="form-control">
<option value="Jalan Arteri">Jalan Arteri</option><option value="Jalan Kolektor">Jalan Kolektor</option>
<option value="Jalan Lokal">Jalan Lokal</option><option value="Jalan Lingkungan">Jalan Lingkungan</option><option value="Jalan Tol">Jalan Tol</option>
</select>
</div>
<div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal('modalEdit')">Batal</button><button class="btn btn-primary" onclick="update()"><i class="fas fa-save"></i> Perbarui</button></div>
</div></div>
<?php $extraScript = <<<'JS'
<script>
const API=APP_BASE + '/api/jalan.php';
function editRow(r){ document.getElementById('editId').value=r.id; document.getElementById('editNama').value=r.nama; document.getElementById('editJenis').value=r.jenis_jalan||'Jalan Lokal'; openModal('modalEdit'); }
async function simpan(){ const f=document.getElementById('formTambah'); const res=await fetch(API,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({nama:f.nama.value,jenis_jalan:f.jenis_jalan.value,geometry:{type:'LineString',coordinates:[]}})}); const d=await res.json(); if(d.status==='success'){showToast('Jalan disimpan!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); }
async function update(){ const id=document.getElementById('editId').value; const res=await fetch(API+'?id='+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({nama:document.getElementById('editNama').value,jenis_jalan:document.getElementById('editJenis').value})}); const d=await res.json(); if(d.status==='success'){showToast('Jalan diperbarui!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); }
async function hapus(id,nama){ if(!confirm(`Hapus jalan "${nama}"?`))return; const res=await fetch(API+'?id='+id,{method:'DELETE'}); const d=await res.json(); if(d.status==='success'){showToast('Jalan dihapus!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); }
</script>
JS; ?>
<?php require_once __DIR__ . '/partials/footer.php'; ?>
+82
View File
@@ -0,0 +1,82 @@
<?php
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/auth_check.php';
requireRole('admin');
$pdo = Database::getConnection();
$rows = $pdo->query("SELECT id,nama_pemilik,status_kepemilikan,luas,created_at FROM kavling ORDER BY id DESC")->fetchAll();
$pageTitle='Data Kavling'; $activeNav='kavling';
require_once __DIR__ . '/partials/header.php';
$statusColors = ['SHM'=>'badge-success','HGB'=>'badge-info','HGU'=>'badge-warning','HP'=>'badge-primary'];
$statusFull = ['SHM'=>'Sertifikat Hak Milik','HGB'=>'Hak Guna Bangunan','HGU'=>'Hak Guna Usaha','HP'=>'Hak Pakai'];
?>
<div class="page-header">
<div class="breadcrumb"><span>Admin</span><span class="sep">/</span><span>Kavling</span></div>
<h1>🏘️ Manajemen Kavling / Parsil Tanah</h1>
<p>Kelola data bidang tanah berdasarkan status kepemilikan sertifikat.</p>
</div>
<div class="page-toolbar">
<div class="search-box"><i class="fas fa-search search-icon"></i><input type="text" id="searchInput" placeholder="Cari kavling..." oninput="filterTable('searchInput','kavTable')"></div>
<button class="btn btn-primary" onclick="openModal('modalTambah')"><i class="fas fa-plus"></i> Tambah Kavling</button>
</div>
<div class="table-wrap">
<table class="data-table" id="kavTable">
<thead><tr><th>#</th><th>Nama Pemilik</th><th>Status Kepemilikan</th><th>Luas (m²)</th><th>Ditambahkan</th><th>Aksi</th></tr></thead>
<tbody>
<?php foreach($rows as $i=>$r): $sk=$r['status_kepemilikan']; ?>
<tr>
<td style="color:var(--text-muted)"><?=$i+1?></td>
<td><strong><?=htmlspecialchars($r['nama_pemilik'])?></strong></td>
<td><span class="badge <?=$statusColors[$sk]??'badge-primary'?>" title="<?=$statusFull[$sk]??$sk?>"><?=$sk?></span></td>
<td><?=$r['luas'] ? number_format($r['luas'],0,',','.') : '-'?></td>
<td style="color:var(--text-muted);font-size:0.8rem;"><?=date('d M Y',strtotime($r['created_at']))?></td>
<td><div class="actions">
<button class="btn btn-ghost btn-sm btn-icon" onclick="editRow(<?=htmlspecialchars(json_encode($r))?>)"><i class="fas fa-edit"></i></button>
<button class="btn btn-danger btn-sm btn-icon" onclick="hapus(<?=$r['id']?>,'<?=addslashes($r['nama_pemilik'])?>')"><i class="fas fa-trash"></i></button>
</div></td>
</tr>
<?php endforeach; ?>
<?php if(!$rows):?><tr><td colspan="6" style="text-align:center;padding:40px;color:var(--text-muted);">Belum ada data kavling.</td></tr><?php endif;?>
</tbody>
</table></div>
<div class="modal-overlay" id="modalTambah"><div class="modal">
<div class="modal-header"><span class="modal-title">🏘️ Tambah Kavling</span><button class="modal-close" onclick="closeModal('modalTambah')">×</button></div>
<form id="formTambah">
<div class="form-group"><label class="form-label">Nama Pemilik *</label><input type="text" name="nama_pemilik" class="form-control" placeholder="Nama lengkap pemilik" required></div>
<div class="form-group"><label class="form-label">Status Kepemilikan *</label>
<select name="status_kepemilikan" class="form-control">
<option value="SHM">Sertifikat Hak Milik (SHM)</option>
<option value="HGB">Sertifikat Hak Guna Bangunan (HGB)</option>
<option value="HGU">Sertifikat Hak Guna Usaha (HGU)</option>
<option value="HP">Sertifikat Hak Pakai (HP)</option>
</select>
</div>
<div class="form-group"><label class="form-label">Luas (m²)</label><input type="number" name="luas" class="form-control" placeholder="Luas bidang tanah" min="0" step="0.01"></div>
<p style="font-size:0.82rem;color:var(--text-muted);background:rgba(59,130,246,0.08);border:1px solid rgba(59,130,246,0.2);border-radius:8px;padding:10px;"><i class="fas fa-info-circle"></i> Polygon kavling bisa digambar di <a href="<?= app_url('admin/peta.php') ?>" style="color:var(--primary-light);">Peta Interaktif</a>.</p>
</form>
<div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal('modalTambah')">Batal</button><button class="btn btn-primary" onclick="simpan()"><i class="fas fa-save"></i> Simpan</button></div>
</div></div>
<div class="modal-overlay" id="modalEdit"><div class="modal">
<div class="modal-header"><span class="modal-title">✏️ Edit Kavling</span><button class="modal-close" onclick="closeModal('modalEdit')">×</button></div>
<input type="hidden" id="editId">
<div class="form-group"><label class="form-label">Nama Pemilik *</label><input type="text" id="editNama" class="form-control" required></div>
<div class="form-group"><label class="form-label">Status Kepemilikan</label>
<select id="editStatus" class="form-control">
<option value="SHM">SHM</option><option value="HGB">HGB</option><option value="HGU">HGU</option><option value="HP">HP</option>
</select>
</div>
<div class="form-group"><label class="form-label">Luas (m²)</label><input type="number" id="editLuas" class="form-control" min="0" step="0.01"></div>
<div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal('modalEdit')">Batal</button><button class="btn btn-primary" onclick="update()"><i class="fas fa-save"></i> Perbarui</button></div>
</div></div>
<?php $extraScript = <<<'JS'
<script>
const API=APP_BASE + '/api/kavling.php';
function editRow(r){ document.getElementById('editId').value=r.id; document.getElementById('editNama').value=r.nama_pemilik; document.getElementById('editStatus').value=r.status_kepemilikan; document.getElementById('editLuas').value=r.luas||0; openModal('modalEdit'); }
async function simpan(){ const f=document.getElementById('formTambah'); if(!f.nama_pemilik.value){showToast('Nama pemilik wajib diisi!','error');return;} const res=await fetch(API,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({nama_pemilik:f.nama_pemilik.value,status_kepemilikan:f.status_kepemilikan.value,luas:parseFloat(f.luas.value)||0,geometry:{type:'Polygon',coordinates:[[]]}})}); const d=await res.json(); if(d.status==='success'){showToast('Kavling disimpan!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); }
async function update(){ const id=document.getElementById('editId').value; const res=await fetch(API+'?id='+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({nama_pemilik:document.getElementById('editNama').value,status_kepemilikan:document.getElementById('editStatus').value,luas:parseFloat(document.getElementById('editLuas').value)||0})}); const d=await res.json(); if(d.status==='success'){showToast('Kavling diperbarui!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); }
async function hapus(id,nama){ if(!confirm(`Hapus kavling milik "${nama}"?`))return; const res=await fetch(API+'?id='+id,{method:'DELETE'}); const d=await res.json(); if(d.status==='success'){showToast('Kavling dihapus!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); }
</script>
JS; ?>
<?php require_once __DIR__ . '/partials/footer.php'; ?>
+57
View File
@@ -0,0 +1,57 @@
<?php
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/auth_check.php';
requireRole('admin');
$pdo = Database::getConnection();
$rows = $pdo->query("SELECT k.id, k.nama_kawasan, k.created_at, COUNT(w.id) as jumlah_warga
FROM kawasan_kumuh k LEFT JOIN warga_miskin w ON ST_Contains(k.geom, w.geom)
GROUP BY k.id ORDER BY k.id DESC")->fetchAll();
$pageTitle='Kawasan Kumuh'; $activeNav='kawasan';
require_once __DIR__ . '/partials/header.php';
?>
<div class="page-header">
<div class="breadcrumb"><span>Admin</span><span class="sep">/</span><span>Kawasan Kumuh</span></div>
<h1>⚠️ Manajemen Kawasan Kumuh</h1>
<p>Kelola data kawasan analisis spasial kepadatan warga miskin (choropleth).</p>
</div>
<div class="page-toolbar">
<div class="search-box"><i class="fas fa-search search-icon"></i><input type="text" id="searchInput" placeholder="Cari kawasan..." oninput="filterTable('searchInput','kawasanTable')"></div>
<a href="<?= app_url('admin/peta.php') ?>" class="btn btn-primary"><i class="fas fa-map"></i> Gambar di Peta</a>
</div>
<div class="table-wrap">
<table class="data-table" id="kawasanTable">
<thead><tr><th>#</th><th>Nama Kawasan</th><th>Warga Terdampak</th><th>Status</th><th>Ditambahkan</th><th>Aksi</th></tr></thead>
<tbody>
<?php foreach($rows as $i=>$r): $cnt=(int)$r['jumlah_warga']; $status=$cnt>3?'Rawan Kumuh':($cnt>0?'Perlu Perhatian':'Aman'); $badge=$cnt>3?'badge-danger':($cnt>0?'badge-warning':'badge-success'); ?>
<tr>
<td style="color:var(--text-muted)"><?=$i+1?></td>
<td><strong><?=htmlspecialchars($r['nama_kawasan'])?></strong></td>
<td style="text-align:center;font-size:1.1rem;font-weight:700;"><?=$cnt?></td>
<td><span class="badge <?=$badge?>"><?=$status?></span></td>
<td style="color:var(--text-muted);font-size:0.8rem;"><?=date('d M Y',strtotime($r['created_at']))?></td>
<td><div class="actions">
<button class="btn btn-ghost btn-sm btn-icon" onclick="editRow(<?=htmlspecialchars(json_encode($r))?>)"><i class="fas fa-edit"></i></button>
<button class="btn btn-danger btn-sm btn-icon" onclick="hapus(<?=$r['id']?>,'<?=addslashes($r['nama_kawasan'])?>')"><i class="fas fa-trash"></i></button>
</div></td>
</tr>
<?php endforeach; ?>
<?php if(!$rows):?><tr><td colspan="6" style="text-align:center;padding:40px;color:var(--text-muted);">Belum ada data kawasan. Gambar polygon di peta untuk menambahkan.</td></tr><?php endif;?>
</tbody>
</table></div>
<div class="modal-overlay" id="modalEdit"><div class="modal">
<div class="modal-header"><span class="modal-title">✏️ Edit Kawasan</span><button class="modal-close" onclick="closeModal('modalEdit')">×</button></div>
<input type="hidden" id="editId">
<div class="form-group"><label class="form-label">Nama Kawasan *</label><input type="text" id="editNama" class="form-control" required></div>
<div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal('modalEdit')">Batal</button><button class="btn btn-primary" onclick="update()"><i class="fas fa-save"></i> Perbarui</button></div>
</div></div>
<?php $extraScript = <<<'JS'
<script>
const API=APP_BASE + '/api/kawasan_kumuh.php';
function editRow(r){ document.getElementById('editId').value=r.id; document.getElementById('editNama').value=r.nama_kawasan; openModal('modalEdit'); }
async function update(){ const id=document.getElementById('editId').value; const res=await fetch(API+'?id='+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({nama_kawasan:document.getElementById('editNama').value})}); const d=await res.json(); if(d.status==='success'){showToast('Kawasan diperbarui!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); }
async function hapus(id,nama){ if(!confirm(`Hapus kawasan "${nama}"?`))return; const res=await fetch(API+'?id='+id,{method:'DELETE'}); const d=await res.json(); if(d.status==='success'){showToast('Kawasan dihapus!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); }
</script>
JS; ?>
<?php require_once __DIR__ . '/partials/footer.php'; ?>
+191
View File
@@ -0,0 +1,191 @@
<?php
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/auth_check.php';
requireRole('admin');
$pdo = Database::getConnection();
$sql = "SELECT l.id, l.kategori, l.deskripsi, l.status, l.created_at,
u.nama_lengkap, u.username, ST_X(l.geometry) as lng, ST_Y(l.geometry) as lat
FROM laporan_warga l
JOIN users u ON l.user_id = u.id
ORDER BY l.id DESC";
$rows = $pdo->query($sql)->fetchAll();
$pageTitle = 'Laporan Warga';
$activeNav = 'laporan';
$extraHead = '<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">';
require_once __DIR__ . '/partials/header.php';
function badgeStatus($status) {
if($status === 'menunggu') return 'badge-warning';
if($status === 'diproses') return 'badge-info';
if($status === 'selesai') return 'badge-success';
if($status === 'ditolak') return 'badge-danger';
return 'badge-primary';
}
?>
<div class="page-header">
<div class="breadcrumb"><span>Admin</span><span class="sep">/</span><span>Laporan Warga</span></div>
<h1>📢 Validasi Laporan Warga</h1>
<p>Kelola dan perbarui status laporan kerusakan infrastruktur dari warga.</p>
</div>
<div class="page-toolbar">
<div class="search-box">
<i class="fas fa-search search-icon"></i>
<input type="text" id="searchInput" placeholder="Cari laporan..." oninput="filterTable('searchInput','lapTable')">
</div>
</div>
<div class="table-wrap">
<table class="data-table" id="lapTable">
<thead>
<tr>
<th>#</th>
<th>Pelapor</th>
<th>Kategori</th>
<th>Deskripsi</th>
<th>Status</th>
<th>Tanggal</th>
<th>Aksi</th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $i => $r): ?>
<tr>
<td style="color:var(--text-muted)"><?= $i+1 ?></td>
<td><strong><?= htmlspecialchars($r['nama_lengkap'] ?: $r['username']) ?></strong></td>
<td><?= htmlspecialchars($r['kategori']) ?></td>
<td style="color:var(--text-secondary); max-width: 250px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
<?= htmlspecialchars($r['deskripsi']) ?>
</td>
<td><span class="badge <?= badgeStatus($r['status']) ?>"><?= ucfirst($r['status']) ?></span></td>
<td style="color:var(--text-muted); font-size:0.8rem;"><?= date('d M Y', strtotime($r['created_at'])) ?></td>
<td>
<div class="actions">
<button class="btn btn-ghost btn-sm btn-icon" onclick='lihatLokasi(<?= json_encode($r) ?>)' title="Lihat Lokasi"><i class="fas fa-map-marker-alt" style="color: #3B82F6;"></i></button>
<button class="btn btn-ghost btn-sm btn-icon" onclick='editStatus(<?= json_encode($r) ?>)' title="Ubah Status"><i class="fas fa-edit"></i></button>
<button class="btn btn-danger btn-sm btn-icon" onclick="hapus(<?= $r['id'] ?>)" title="Hapus Laporan"><i class="fas fa-trash"></i></button>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php if (!$rows): ?>
<tr><td colspan="7" style="text-align:center;padding:40px;color:var(--text-muted);">Belum ada laporan dari warga.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<!-- Modal Ubah Status -->
<div class="modal-overlay" id="modalStatus">
<div class="modal">
<div class="modal-header">
<span class="modal-title">✏️ Perbarui Status Laporan</span>
<button class="modal-close" onclick="closeModal('modalStatus')">×</button>
</div>
<input type="hidden" id="editId">
<div class="form-group">
<label class="form-label">Ubah Status</label>
<select id="editStatusSelect" class="form-control">
<option value="menunggu">Menunggu</option>
<option value="diproses">Diproses</option>
<option value="selesai">Selesai</option>
<option value="ditolak">Ditolak</option>
</select>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeModal('modalStatus')">Batal</button>
<button class="btn btn-primary" onclick="updateStatus()"><i class="fas fa-save"></i> Simpan</button>
</div>
</div>
</div>
<!-- Modal Lihat Peta -->
<div class="modal-overlay" id="modalMap">
<div class="modal">
<div class="modal-header">
<span class="modal-title">📍 Lokasi Laporan</span>
<button class="modal-close" onclick="closeModal('modalMap')">×</button>
</div>
<div style="padding-bottom: 12px; font-size: 0.9rem;">
<strong id="mapKategori"></strong><br>
<span style="color: var(--text-secondary);" id="mapDeskripsi"></span>
</div>
<div id="miniMapLihat" style="height:300px;border-radius:8px;border:1px solid #E5E7EB;"></div>
</div>
</div>
<?php $extraScript = <<<'JS'
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const API = APP_BASE + '/api/laporan.php';
function editStatus(r) {
document.getElementById('editId').value = r.id;
document.getElementById('editStatusSelect').value = r.status;
openModal('modalStatus');
}
async function updateStatus() {
const id = document.getElementById('editId').value;
const status = document.getElementById('editStatusSelect').value;
const res = await fetch(API + '?id=' + id, {
method:'PUT',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({ status: status })
});
const d = await res.json();
if(d.status === 'success') {
showToast('Status diperbarui!');
setTimeout(() => location.reload(), 900);
} else {
showToast(d.message,'error');
}
}
async function hapus(id) {
if(!confirm('Hapus laporan secara permanen?')) return;
const res = await fetch(API + '?id=' + id, { method:'DELETE' });
const d = await res.json();
if(d.status === 'success') {
showToast('Laporan dihapus!');
setTimeout(() => location.reload(), 900);
} else {
showToast(d.message,'error');
}
}
let mapInstance = null, mapMarker = null;
const originalOpenModal = window.openModal;
window.openModal = function(id) {
originalOpenModal(id);
if(id === 'modalMap') {
setTimeout(() => {
if(mapInstance) mapInstance.invalidateSize();
}, 100);
}
};
function lihatLokasi(r) {
document.getElementById('mapKategori').innerText = r.kategori;
document.getElementById('mapDeskripsi').innerText = r.deskripsi;
openModal('modalMap');
setTimeout(() => {
if(!mapInstance) {
mapInstance = L.map('miniMapLihat');
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png').addTo(mapInstance);
}
if(mapMarker) mapInstance.removeLayer(mapMarker);
const latLng = [parseFloat(r.lat), parseFloat(r.lng)];
mapMarker = L.marker(latLng).addTo(mapInstance);
mapInstance.setView(latLng, 16);
}, 150);
}
</script>
JS; ?>
<?php require_once __DIR__ . '/partials/footer.php'; ?>
+140
View File
@@ -0,0 +1,140 @@
<?php
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/auth_check.php';
requireRole('admin');
$pdo = Database::getConnection();
$rows = $pdo->query("SELECT id,nama,agama,radius_bantuan_meter,created_at FROM rumah_ibadah ORDER BY id DESC")->fetchAll();
$pageTitle='Rumah Ibadah'; $activeNav='rumah';
$extraHead = '<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">';
require_once __DIR__ . '/partials/header.php';
$agamaIcon = ['Islam'=>'🕌','Kristen'=>'⛪','Katolik'=>'⛪','Hindu'=>'🛕','Buddha'=>'☸️','Konghucu'=>'🏯'];
$agamaBadge = ['Islam'=>'badge-success','Kristen'=>'badge-info','Katolik'=>'badge-primary','Hindu'=>'badge-warning','Buddha'=>'badge-danger','Konghucu'=>'badge-warning'];
$grouped = [];
foreach($agamaIcon as $ag => $ic) { $grouped[$ag] = []; }
foreach($rows as $r) {
if(!isset($grouped[$r['agama']])) $grouped[$r['agama']] = [];
$grouped[$r['agama']][] = $r;
}
?>
<div class="page-header">
<div class="breadcrumb"><span>Admin</span><span class="sep">/</span><span>Rumah Ibadah</span></div>
<h1>🕌 Manajemen Rumah Ibadah</h1>
<p>Kelola data rumah ibadah untuk 5 agama yang diakui di Indonesia.</p>
</div>
<div class="page-toolbar">
<div class="search-box"><i class="fas fa-search search-icon"></i><input type="text" id="searchInput" placeholder="Cari rumah ibadah..." oninput="filterTableMulti()"></div>
<button class="btn btn-primary" onclick="openModal('modalTambah')"><i class="fas fa-plus"></i> Tambah</button>
</div>
<?php foreach($grouped as $agama => $list): ?>
<h3 style="margin-top: 24px; margin-bottom: 12px; font-size: 1.1rem; color: var(--text-primary); border-bottom: 2px solid var(--border-light); padding-bottom: 8px;">
<?= $agamaIcon[$agama] ?? '🏛' ?> <?= $agama ?> <span class="badge badge-info" style="margin-left: 8px;"><?= count($list) ?></span>
</h3>
<div class="table-wrap" style="margin-bottom: 32px;">
<table class="data-table ri-table">
<thead><tr><th>#</th><th>Nama</th><th>Radius Bantuan</th><th>Ditambahkan</th><th>Aksi</th></tr></thead>
<tbody>
<?php foreach($list as $i=>$r): ?>
<tr>
<td style="color:var(--text-muted)"><?=$i+1?></td>
<td><strong><?=htmlspecialchars($r['nama'])?></strong></td>
<td><span class="badge badge-info"><?= number_format((int)$r['radius_bantuan_meter'], 0, ',', '.') ?> m</span></td>
<td style="color:var(--text-muted);font-size:0.8rem;"><?=date('d M Y',strtotime($r['created_at']))?></td>
<td><div class="actions">
<button class="btn btn-ghost btn-sm btn-icon" onclick="editRow(<?=htmlspecialchars(json_encode($r))?>)"><i class="fas fa-edit"></i></button>
<button class="btn btn-danger btn-sm btn-icon" onclick="hapus(<?=$r['id']?>,'<?=addslashes($r['nama'])?>')"><i class="fas fa-trash"></i></button>
</div></td>
</tr>
<?php endforeach; ?>
<?php if(!$list):?><tr><td colspan="5" style="text-align:center;padding:30px;color:var(--text-muted);">Belum ada data <?=$agama?>.</td></tr><?php endif;?>
</tbody>
</table></div>
<?php endforeach; ?>
<div class="modal-overlay" id="modalTambah"><div class="modal">
<div class="modal-header"><span class="modal-title">🕌 Tambah Rumah Ibadah</span><button class="modal-close" onclick="closeModal('modalTambah')">×</button></div>
<form id="formTambah">
<div class="form-group"><label class="form-label">Nama *</label><input type="text" name="nama" class="form-control" placeholder="Nama rumah ibadah" required></div>
<div class="form-group"><label class="form-label">Agama *</label>
<select name="agama" class="form-control">
<option value="Islam">🕌 Islam</option><option value="Kristen">⛪ Kristen</option>
<option value="Katolik">⛪ Katolik</option><option value="Hindu">🛕 Hindu</option>
<option value="Buddha">☸️ Buddha</option><option value="Konghucu">🏯 Konghucu</option>
</select>
</div>
<div class="form-group"><label class="form-label">Radius Bantuan (meter) *</label><input type="number" name="radius_bantuan_meter" class="form-control" min="100" max="10000" step="50" value="1000" required></div>
<div class="form-group"><label class="form-label">Pilih Lokasi di Peta</label>
<div id="miniMapTambah" style="height:200px;border-radius:8px;border:1px solid #E5E7EB;margin-bottom:10px;z-index:1;"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<input type="number" name="lat" id="latTambah" class="form-control" placeholder="Latitude" step="any" readonly style="background:#F9FAFB;">
<input type="number" name="lng" id="lngTambah" class="form-control" placeholder="Longitude" step="any" readonly style="background:#F9FAFB;">
</div>
</div>
</form>
<div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal('modalTambah')">Batal</button><button class="btn btn-primary" onclick="simpan()"><i class="fas fa-save"></i> Simpan</button></div>
</div></div>
<div class="modal-overlay" id="modalEdit"><div class="modal">
<div class="modal-header"><span class="modal-title">✏️ Edit Rumah Ibadah</span><button class="modal-close" onclick="closeModal('modalEdit')">×</button></div>
<input type="hidden" id="editId">
<div class="form-group"><label class="form-label">Nama *</label><input type="text" id="editNama" class="form-control" required></div>
<div class="form-group"><label class="form-label">Agama</label>
<select id="editAgama" class="form-control">
<option value="Islam">🕌 Islam</option><option value="Kristen">⛪ Kristen</option>
<option value="Katolik">⛪ Katolik</option><option value="Hindu">🛕 Hindu</option>
<option value="Buddha">☸️ Buddha</option><option value="Konghucu">🏯 Konghucu</option>
</select>
</div>
<div class="form-group"><label class="form-label">Radius Bantuan (meter) *</label><input type="number" id="editRadius" class="form-control" min="100" max="10000" step="50" required></div>
<div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal('modalEdit')">Batal</button><button class="btn btn-primary" onclick="update()"><i class="fas fa-save"></i> Perbarui</button></div>
</div></div>
<?php $extraScript = <<<'JS'
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const API=APP_BASE + '/api/rumah_ibadah.php';
let miniMap = null, miniMarker = null;
// Override openModal to initialize map
const originalOpenModal = window.openModal;
window.openModal = function(id) {
originalOpenModal(id);
if(id === 'modalTambah') {
setTimeout(() => {
if(!miniMap) {
miniMap = L.map('miniMapTambah').setView([-0.0583, 109.3448], 13);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png').addTo(miniMap);
miniMap.on('click', function(e) {
const lat = e.latlng.lat;
const lng = e.latlng.lng;
if(!miniMarker) miniMarker = L.marker([lat, lng]).addTo(miniMap);
else miniMarker.setLatLng([lat, lng]);
document.getElementById('latTambah').value = lat.toFixed(6);
document.getElementById('lngTambah').value = lng.toFixed(6);
});
} else {
miniMap.invalidateSize();
}
}, 100);
}
};
function filterTableMulti() {
const v = document.getElementById('searchInput').value.toLowerCase();
document.querySelectorAll('.ri-table').forEach(tbl => {
tbl.querySelectorAll('tbody tr').forEach(tr => {
if(tr.cells.length > 1) tr.style.display = tr.innerText.toLowerCase().includes(v) ? '' : 'none';
});
});
}
function normalizeRadius(v){ const n=parseInt(v,10); if(Number.isNaN(n)) return 1000; return Math.max(100, Math.min(10000, n)); }
function editRow(r){ document.getElementById('editId').value=r.id; document.getElementById('editNama').value=r.nama; document.getElementById('editAgama').value=r.agama; document.getElementById('editRadius').value=normalizeRadius(r.radius_bantuan_meter); openModal('modalEdit'); }
async function simpan(){ const f=document.getElementById('formTambah'); if(!f.nama.value){showToast('Nama wajib!','error');return;}
const lat=parseFloat(f.lat.value)||(-0.03), lng=parseFloat(f.lng.value)||(109.34);
const res=await fetch(API,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({nama:f.nama.value,agama:f.agama.value,radius_bantuan_meter:normalizeRadius(f.radius_bantuan_meter.value),geometry:{type:'Point',coordinates:[lng,lat]}})}); const d=await res.json(); if(d.status==='success'){showToast('Rumah ibadah disimpan!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); }
async function update(){ const id=document.getElementById('editId').value; const res=await fetch(API+'?id='+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({nama:document.getElementById('editNama').value,agama:document.getElementById('editAgama').value,radius_bantuan_meter:normalizeRadius(document.getElementById('editRadius').value)})}); const d=await res.json(); if(d.status==='success'){showToast('Diperbarui!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); }
async function hapus(id,nama){ if(!confirm(`Hapus "${nama}"?`))return; const res=await fetch(API+'?id='+id,{method:'DELETE'}); const d=await res.json(); if(d.status==='success'){showToast('Dihapus!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); }
</script>
JS; ?>
<?php require_once __DIR__ . '/partials/footer.php'; ?>
+164
View File
@@ -0,0 +1,164 @@
<?php
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/auth_check.php';
requireRole('admin');
$pdo = Database::getConnection();
$rows = $pdo->query("SELECT id, nama, deskripsi, buka_24_jam, created_at FROM spbu ORDER BY id DESC")->fetchAll();
$pageTitle = 'Data SPBU'; $activeNav = 'spbu';
$extraHead = '<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">';
require_once __DIR__ . '/partials/header.php';
?>
<div class="page-header">
<div class="breadcrumb"><span>Admin</span><span class="sep">/</span><span>Data SPBU</span></div>
<h1>⛽ Manajemen Data SPBU</h1>
<p>Kelola data Stasiun Pengisian Bahan Bakar Umum (SPBU) di wilayah pemetaan.</p>
</div>
<div class="page-toolbar">
<div class="search-box">
<i class="fas fa-search search-icon"></i>
<input type="text" id="searchInput" placeholder="Cari SPBU..." oninput="filterTable('searchInput','spbuTable')">
</div>
<button class="btn btn-primary" onclick="openModal('modalTambah')">
<i class="fas fa-plus"></i> Tambah SPBU
</button>
</div>
<div class="table-wrap">
<table class="data-table" id="spbuTable">
<thead><tr>
<th>#</th><th>Nama SPBU</th><th>Deskripsi</th><th>Status</th><th>Ditambahkan</th><th>Aksi</th>
</tr></thead>
<tbody>
<?php foreach ($rows as $i => $r): ?>
<tr>
<td style="color:var(--text-muted)"><?= $i+1 ?></td>
<td><strong><?= htmlspecialchars($r['nama']) ?></strong></td>
<td style="color:var(--text-secondary)"><?= htmlspecialchars($r['deskripsi'] ?: '-') ?></td>
<td><span class="badge <?= $r['buka_24_jam'] ? 'badge-success' : 'badge-warning' ?>"><?= $r['buka_24_jam'] ? '✅ 24 Jam' : '🕐 Terbatas' ?></span></td>
<td style="color:var(--text-muted); font-size:0.8rem;"><?= date('d M Y, H:i', strtotime($r['created_at'])) ?></td>
<td><div class="actions">
<button class="btn btn-ghost btn-sm btn-icon" onclick="editRow(<?= htmlspecialchars(json_encode($r)) ?>)" title="Edit"><i class="fas fa-edit"></i></button>
<button class="btn btn-danger btn-sm btn-icon" onclick="hapus(<?= $r['id'] ?>,'<?= addslashes($r['nama']) ?>')" title="Hapus"><i class="fas fa-trash"></i></button>
</div></td>
</tr>
<?php endforeach; ?>
<?php if (!$rows): ?><tr><td colspan="6" style="text-align:center;padding:40px;color:var(--text-muted);">Belum ada data SPBU. <a href="#" onclick="openModal('modalTambah')">Tambah sekarang</a></td></tr><?php endif; ?>
</tbody>
</table>
</div>
<!-- Modal Tambah -->
<div class="modal-overlay" id="modalTambah">
<div class="modal">
<div class="modal-header">
<span class="modal-title">⛽ Tambah SPBU Baru</span>
<button class="modal-close" onclick="closeModal('modalTambah')">×</button>
</div>
<form id="formTambah">
<div class="form-group"><label class="form-label">Nama SPBU *</label><input type="text" name="nama" class="form-control" placeholder="Nama SPBU" required></div>
<div class="form-group"><label class="form-label">Deskripsi</label><textarea name="deskripsi" class="form-control" placeholder="Keterangan tambahan..."></textarea></div>
<div class="form-group"><label class="form-label" style="display:flex;align-items:center;gap:10px;cursor:pointer;"><input type="checkbox" name="buka_24_jam" id="cb24jam" style="width:16px;height:16px;accent-color:var(--accent);"> Buka 24 Jam</label></div>
<div class="form-group">
<label class="form-label">Pilih Lokasi di Peta *</label>
<div id="miniMapTambah" style="height:200px;border-radius:8px;border:1px solid #E5E7EB;margin-bottom:10px;z-index:1;"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<input type="number" name="lat" id="latTambah" class="form-control" placeholder="Latitude" step="any" required readonly style="background:#F9FAFB;">
<input type="number" name="lng" id="lngTambah" class="form-control" placeholder="Longitude" step="any" required readonly style="background:#F9FAFB;">
</div>
</div>
</form>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeModal('modalTambah')">Batal</button>
<button class="btn btn-primary" onclick="simpan()"><i class="fas fa-save"></i> Simpan</button>
</div>
</div></div>
<!-- Modal Edit -->
<div class="modal-overlay" id="modalEdit">
<div class="modal">
<div class="modal-header">
<span class="modal-title">✏️ Edit Data SPBU</span>
<button class="modal-close" onclick="closeModal('modalEdit')">×</button>
</div>
<form id="formEdit">
<input type="hidden" name="id" id="editId">
<div class="form-group"><label class="form-label">Nama SPBU *</label><input type="text" name="nama" id="editNama" class="form-control" required></div>
<div class="form-group"><label class="form-label">Deskripsi</label><textarea name="deskripsi" id="editDesk" class="form-control"></textarea></div>
<div class="form-group"><label class="form-label" style="display:flex;align-items:center;gap:10px;cursor:pointer;"><input type="checkbox" id="editCb24" style="width:16px;height:16px;accent-color:var(--accent);"> Buka 24 Jam</label></div>
</form>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeModal('modalEdit')">Batal</button>
<button class="btn btn-primary" onclick="update()"><i class="fas fa-save"></i> Perbarui</button>
</div>
</div></div>
<?php $extraScript = <<<'JS'
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const API = APP_BASE + '/api/spbu.php';
let miniMap = null, miniMarker = null;
const originalOpenModal = window.openModal;
window.openModal = function(id) {
originalOpenModal(id);
if(id === 'modalTambah') {
setTimeout(() => {
if(!miniMap) {
miniMap = L.map('miniMapTambah').setView([-0.0583, 109.3448], 13);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png').addTo(miniMap);
miniMap.on('click', function(e) {
const lat = e.latlng.lat;
const lng = e.latlng.lng;
if(!miniMarker) miniMarker = L.marker([lat, lng]).addTo(miniMap);
else miniMarker.setLatLng([lat, lng]);
document.getElementById('latTambah').value = lat.toFixed(6);
document.getElementById('lngTambah').value = lng.toFixed(6);
});
} else {
miniMap.invalidateSize();
}
}, 100);
}
};
function editRow(r) {
document.getElementById('editId').value = r.id;
document.getElementById('editNama').value = r.nama;
document.getElementById('editDesk').value = r.deskripsi || '';
document.getElementById('editCb24').checked = !!r.buka_24_jam;
openModal('modalEdit');
}
async function simpan() {
const f = document.getElementById('formTambah');
const lat = parseFloat(f.lat.value), lng = parseFloat(f.lng.value);
if (!f.nama.value || isNaN(lat) || isNaN(lng)) { showToast('Lengkapi nama dan pilih lokasi di peta!','error'); return; }
const res = await fetch(API, { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ nama:f.nama.value, deskripsi:f.deskripsi.value,
buka_24_jam: f.querySelector('[name=buka_24_jam]').checked ? 1:0,
geometry:{ type:'Point', coordinates:[lng,lat] }}) });
const d = await res.json();
if(d.status==='success') { showToast('SPBU berhasil disimpan!'); setTimeout(()=>location.reload(),900); }
else showToast(d.message,'error');
}
async function update() {
const id = document.getElementById('editId').value;
const res = await fetch(API+'?id='+id, { method:'PUT', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ nama:document.getElementById('editNama').value,
deskripsi:document.getElementById('editDesk').value,
buka_24_jam: document.getElementById('editCb24').checked ? 1:0 }) });
const d = await res.json();
if(d.status==='success') { showToast('SPBU berhasil diperbarui!'); setTimeout(()=>location.reload(),900); }
else showToast(d.message,'error');
}
async function hapus(id, nama) {
if(!confirm(`Hapus SPBU "${nama}"?`)) return;
const res = await fetch(API+'?id='+id, { method:'DELETE' });
const d = await res.json();
if(d.status==='success') { showToast('SPBU dihapus!'); setTimeout(()=>location.reload(),900); }
else showToast(d.message,'error');
}
</script>
JS; ?>
<?php require_once __DIR__ . '/partials/footer.php'; ?>
+101
View File
@@ -0,0 +1,101 @@
<?php
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/auth_check.php';
requireRole('admin');
$pdo = Database::getConnection();
$rows = $pdo->query("SELECT id,nama_kk,penghasilan,jumlah_tanggungan,created_at FROM warga_miskin ORDER BY id DESC")->fetchAll();
$pageTitle='Warga Miskin'; $activeNav='warga';
$extraHead = '<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">';
require_once __DIR__ . '/partials/header.php';
?>
<div class="page-header">
<div class="breadcrumb"><span>Admin</span><span class="sep">/</span><span>Warga Miskin</span></div>
<h1>👥 Manajemen Data Warga Miskin</h1>
<p>Kelola data kepala keluarga yang terdaftar sebagai penerima bantuan sosial.</p>
</div>
<div class="page-toolbar">
<div class="search-box"><i class="fas fa-search search-icon"></i><input type="text" id="searchInput" placeholder="Cari warga..." oninput="filterTable('searchInput','wargaTable')"></div>
<button class="btn btn-primary" onclick="openModal('modalTambah')"><i class="fas fa-plus"></i> Tambah Data</button>
</div>
<div class="table-wrap">
<table class="data-table" id="wargaTable">
<thead><tr><th>#</th><th>Nama KK</th><th>Penghasilan/Bln</th><th>Tanggungan</th><th>Ditambahkan</th><th>Aksi</th></tr></thead>
<tbody>
<?php foreach($rows as $i=>$r): ?>
<tr>
<td style="color:var(--text-muted)"><?=$i+1?></td>
<td><strong><?=htmlspecialchars($r['nama_kk'])?></strong></td>
<td>Rp <?=number_format($r['penghasilan'],0,',','.')?></td>
<td><span class="badge <?=$r['jumlah_tanggungan']>=4?'badge-danger':($r['jumlah_tanggungan']>=2?'badge-warning':'badge-success')?>"><?=$r['jumlah_tanggungan']?> orang</span></td>
<td style="color:var(--text-muted);font-size:0.8rem;"><?=date('d M Y',strtotime($r['created_at']))?></td>
<td><div class="actions">
<button class="btn btn-ghost btn-sm btn-icon" onclick="editRow(<?=htmlspecialchars(json_encode($r))?>)"><i class="fas fa-edit"></i></button>
<button class="btn btn-danger btn-sm btn-icon" onclick="hapus(<?=$r['id']?>,'<?=addslashes($r['nama_kk'])?>')"><i class="fas fa-trash"></i></button>
</div></td>
</tr>
<?php endforeach; ?>
<?php if(!$rows):?><tr><td colspan="6" style="text-align:center;padding:40px;color:var(--text-muted);">Belum ada data.</td></tr><?php endif;?>
</tbody>
</table></div>
<div class="modal-overlay" id="modalTambah"><div class="modal">
<div class="modal-header"><span class="modal-title">👥 Tambah Warga Miskin</span><button class="modal-close" onclick="closeModal('modalTambah')">×</button></div>
<form id="formTambah">
<div class="form-group"><label class="form-label">Nama Kepala Keluarga *</label><input type="text" name="nama_kk" class="form-control" placeholder="Nama lengkap KK" required></div>
<div class="form-group"><label class="form-label">Penghasilan per Bulan (Rp)</label><input type="number" name="penghasilan" class="form-control" placeholder="0" min="0"></div>
<div class="form-group"><label class="form-label">Jumlah Tanggungan (jiwa)</label><input type="number" name="jumlah_tanggungan" class="form-control" placeholder="0" min="0" max="20"></div>
<div class="form-group"><label class="form-label">Pilih Lokasi di Peta *</label>
<div id="miniMapTambah" style="height:200px;border-radius:8px;border:1px solid #E5E7EB;margin-bottom:10px;z-index:1;"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<input type="number" name="lat" id="latTambah" class="form-control" placeholder="Latitude" step="any" required readonly style="background:#F9FAFB;">
<input type="number" name="lng" id="lngTambah" class="form-control" placeholder="Longitude" step="any" required readonly style="background:#F9FAFB;">
</div>
</div>
</form>
<div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal('modalTambah')">Batal</button><button class="btn btn-primary" onclick="simpan()"><i class="fas fa-save"></i> Simpan</button></div>
</div></div>
<div class="modal-overlay" id="modalEdit"><div class="modal">
<div class="modal-header"><span class="modal-title">✏️ Edit Data Warga</span><button class="modal-close" onclick="closeModal('modalEdit')">×</button></div>
<input type="hidden" id="editId">
<div class="form-group"><label class="form-label">Nama KK *</label><input type="text" id="editNama" class="form-control" required></div>
<div class="form-group"><label class="form-label">Penghasilan/Bln (Rp)</label><input type="number" id="editPenghasilan" class="form-control" min="0"></div>
<div class="form-group"><label class="form-label">Jumlah Tanggungan</label><input type="number" id="editTanggungan" class="form-control" min="0" max="20"></div>
<div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal('modalEdit')">Batal</button><button class="btn btn-primary" onclick="update()"><i class="fas fa-save"></i> Perbarui</button></div>
</div></div>
<?php $extraScript = <<<'JS'
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const API=APP_BASE + '/api/warga_miskin.php';
let miniMap = null, miniMarker = null;
const originalOpenModal = window.openModal;
window.openModal = function(id) {
originalOpenModal(id);
if(id === 'modalTambah') {
setTimeout(() => {
if(!miniMap) {
miniMap = L.map('miniMapTambah').setView([-0.0583, 109.3448], 13);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png').addTo(miniMap);
miniMap.on('click', function(e) {
const lat = e.latlng.lat;
const lng = e.latlng.lng;
if(!miniMarker) miniMarker = L.marker([lat, lng]).addTo(miniMap);
else miniMarker.setLatLng([lat, lng]);
document.getElementById('latTambah').value = lat.toFixed(6);
document.getElementById('lngTambah').value = lng.toFixed(6);
});
} else {
miniMap.invalidateSize();
}
}, 100);
}
};
function editRow(r){ document.getElementById('editId').value=r.id; document.getElementById('editNama').value=r.nama_kk; document.getElementById('editPenghasilan').value=r.penghasilan; document.getElementById('editTanggungan').value=r.jumlah_tanggungan; openModal('modalEdit'); }
async function simpan(){ const f=document.getElementById('formTambah'); if(!f.nama_kk.value){showToast('Nama KK wajib!','error');return;} const lat=parseFloat(f.lat.value), lng=parseFloat(f.lng.value); if(isNaN(lat)||isNaN(lng)){showToast('Silakan pilih lokasi di peta!','error');return;} const res=await fetch(API,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({nama_kk:f.nama_kk.value,penghasilan:parseFloat(f.penghasilan.value)||0,jumlah_tanggungan:parseInt(f.jumlah_tanggungan.value)||0,geometry:{type:'Point',coordinates:[lng,lat]}})}); const d=await res.json(); if(d.status==='success'){showToast('Data disimpan!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); }
async function update(){ const id=document.getElementById('editId').value; const res=await fetch(API+'?id='+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({nama_kk:document.getElementById('editNama').value,penghasilan:parseFloat(document.getElementById('editPenghasilan').value)||0,jumlah_tanggungan:parseInt(document.getElementById('editTanggungan').value)||0})}); const d=await res.json(); if(d.status==='success'){showToast('Diperbarui!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); }
async function hapus(id,nama){ if(!confirm(`Hapus data "${nama}"?`))return; const res=await fetch(API+'?id='+id,{method:'DELETE'}); const d=await res.json(); if(d.status==='success'){showToast('Dihapus!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); }
</script>
JS; ?>
<?php require_once __DIR__ . '/partials/footer.php'; ?>
+172
View File
@@ -0,0 +1,172 @@
<?php
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/auth_check.php';
requireRole('admin');
$pdo = Database::getConnection();
// Fetch summary stats
$stats = $pdo->query("SELECT
(SELECT COUNT(*) FROM spbu) as spbu,
(SELECT COUNT(*) FROM jalan) as jalan,
(SELECT COUNT(*) FROM kavling) as kavling,
(SELECT COUNT(*) FROM rumah_ibadah) as rumah_ibadah,
(SELECT COUNT(*) FROM warga_miskin) as warga_miskin,
(SELECT COUNT(*) FROM kawasan_kumuh) as kawasan_kumuh,
(SELECT COUNT(*) FROM spbu WHERE buka_24_jam=1) as spbu_24jam,
(SELECT COUNT(*) FROM users) as total_users
")->fetch();
// Kawasan merah
$merahResult = $pdo->query("SELECT COUNT(*) as cnt FROM (
SELECT k.id FROM kawasan_kumuh k
LEFT JOIN warga_miskin w ON ST_Contains(k.geom, w.geom)
GROUP BY k.id HAVING COUNT(w.id) > 3
) sub")->fetch();
$kawasanMerah = (int)($merahResult['cnt'] ?? 0);
// Warga miskin di luar semua radius bantuan rumah ibadah
$blankCount = $pdo->query("
SELECT COUNT(*) as cnt
FROM warga_miskin w
WHERE NOT EXISTS (
SELECT 1
FROM rumah_ibadah ri
WHERE ST_Distance_Sphere(w.geom, ri.geom) <= ri.radius_bantuan_meter
)
")->fetch();
$blankSpot = (int)($blankCount['cnt'] ?? 0);
// Recent data
$recentSpbu = $pdo->query("SELECT nama, buka_24_jam, created_at FROM spbu ORDER BY created_at DESC LIMIT 5")->fetchAll();
$recentWarga = $pdo->query("SELECT nama_kk, penghasilan, jumlah_tanggungan, created_at FROM warga_miskin ORDER BY created_at DESC LIMIT 5")->fetchAll();
$pageTitle = 'Dashboard';
$activeNav = 'dashboard';
require_once __DIR__ . '/partials/header.php';
?>
<!-- ── Quick Access Map ── -->
<div class="card" style="background: #EFF6FF; border: 1px solid #BFDBFE; margin-bottom: 24px;">
<div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:16px;">
<div>
<h3 style="font-size:1.1rem; font-weight:700; margin-bottom:6px; color:#1E3A8A;">🗺️ Buka Peta Interaktif</h3>
<p style="color:#3B82F6; font-size:0.88rem; max-width:500px;">
Tambahkan, edit, dan analisis data spasial langsung di peta. Mendukung drawing tool untuk semua layer data.
</p>
</div>
<div style="display:flex; gap:10px;">
<a href="<?= app_url('admin/peta.php') ?>" class="btn btn-primary" style="box-shadow:0 4px 6px -1px rgba(37,99,235,0.2);">
<i class="fas fa-map-marked-alt"></i> Buka Peta Admin
</a>
</div>
</div>
</div>
<!-- ── Stats Grid ── -->
<div class="stats-grid">
<div class="stat-card primary">
<div class="stat-icon"><i class="fas fa-gas-pump"></i></div>
<div class="stat-body">
<div class="stat-label">Total SPBU</div>
<div class="stat-value" id="cnt-spbu"><?= $stats['spbu'] ?></div>
<div class="stat-change"><?= $stats['spbu_24jam'] ?> buka 24 jam</div>
</div>
</div>
<div class="stat-card info">
<div class="stat-icon"><i class="fas fa-road"></i></div>
<div class="stat-body">
<div class="stat-label">Ruas Jalan</div>
<div class="stat-value"><?= $stats['jalan'] ?></div>
<div class="stat-change">Segmen terpetakan</div>
</div>
</div>
<div class="stat-card success">
<div class="stat-icon"><i class="fas fa-mosque"></i></div>
<div class="stat-body">
<div class="stat-label">Rumah Ibadah</div>
<div class="stat-value"><?= $stats['rumah_ibadah'] ?></div>
<div class="stat-change">5 agama tercakup</div>
</div>
</div>
<div class="stat-card warning">
<div class="stat-icon"><i class="fas fa-users"></i></div>
<div class="stat-body">
<div class="stat-label">Warga Miskin</div>
<div class="stat-value"><?= $stats['warga_miskin'] ?></div>
<div class="stat-change"><?= $blankSpot ?> blank spot</div>
</div>
</div>
<div class="stat-card danger">
<div class="stat-icon"><i class="fas fa-exclamation-triangle"></i></div>
<div class="stat-body">
<div class="stat-label">Kawasan Rawan</div>
<div class="stat-value"><?= $kawasanMerah ?></div>
<div class="stat-change">dari <?= $stats['kawasan_kumuh'] ?> kawasan</div>
</div>
</div>
<div class="stat-card violet">
<div class="stat-icon"><i class="fas fa-vector-square"></i></div>
<div class="stat-body">
<div class="stat-label">Kavling / Parsil</div>
<div class="stat-value"><?= $stats['kavling'] ?></div>
<div class="stat-change">Bidang tanah tercatat</div>
</div>
</div>
</div>
<!-- ── Content Row ── -->
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 24px;">
<!-- Recent SPBU -->
<div class="card">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:16px;">
<h3 style="font-size:0.95rem; font-weight:700;">⛽ SPBU Terbaru</h3>
<a href="<?= app_url('admin/data_spbu.php') ?>" class="btn btn-ghost btn-sm">Lihat Semua</a>
</div>
<?php if ($recentSpbu): ?>
<div style="display:flex; flex-direction:column; gap:8px;">
<?php foreach ($recentSpbu as $s): ?>
<div style="display:flex; align-items:center; justify-content:space-between; padding:10px 12px; background:#F9FAFB; border:1px solid #E5E7EB; border-radius:8px;">
<div>
<div style="font-weight:600; font-size:0.88rem;"><?= htmlspecialchars($s['nama']) ?></div>
<div style="font-size:0.75rem; color:var(--text-muted);"><?= date('d M Y', strtotime($s['created_at'])) ?></div>
</div>
<span class="badge <?= $s['buka_24_jam'] ? 'badge-success' : 'badge-danger' ?>">
<?= $s['buka_24_jam'] ? '24 Jam' : 'Terbatas' ?>
</span>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<p style="color:var(--text-muted); text-align:center; padding:20px 0; font-size:0.85rem;">Belum ada data SPBU</p>
<?php endif; ?>
</div>
<!-- Recent Warga Miskin -->
<div class="card">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:16px;">
<h3 style="font-size:0.95rem; font-weight:700;">👥 Warga Terdaftar</h3>
<a href="<?= app_url('admin/data_warga.php') ?>" class="btn btn-ghost btn-sm">Lihat Semua</a>
</div>
<?php if ($recentWarga): ?>
<div style="display:flex; flex-direction:column; gap:8px;">
<?php foreach ($recentWarga as $w): ?>
<div style="display:flex; align-items:center; justify-content:space-between; padding:10px 12px; background:#F9FAFB; border:1px solid #E5E7EB; border-radius:8px;">
<div>
<div style="font-weight:600; font-size:0.88rem;"><?= htmlspecialchars($w['nama_kk']) ?></div>
<div style="font-size:0.75rem; color:var(--text-muted);">Rp <?= number_format($w['penghasilan'],0,',','.') ?>/bln · <?= $w['jumlah_tanggungan'] ?> tanggungan</div>
</div>
<span class="badge badge-warning"><?= $w['jumlah_tanggungan'] ?> org</span>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<p style="color:var(--text-muted); text-align:center; padding:20px 0; font-size:0.85rem;">Belum ada data warga</p>
<?php endif; ?>
</div>
</div>
<?php require_once __DIR__ . '/partials/footer.php'; ?>
+119
View File
@@ -0,0 +1,119 @@
</div><!-- /admin-content -->
</main><!-- /admin-main -->
<!-- Toast Container -->
<div class="toast-container" id="toastContainer"></div>
<script>
// ── Clock ──
function updateClock() {
const now = new Date();
document.getElementById('clockText').textContent =
now.toLocaleTimeString('id-ID', { hour:'2-digit', minute:'2-digit', second:'2-digit' });
}
updateClock();
setInterval(updateClock, 1000);
// ── Toast utility ──
window.showToast = function(msg, type = 'success') {
const t = document.createElement('div');
t.className = `toast ${type}`;
const icons = { success: 'check-circle', error: 'exclamation-circle', info: 'info-circle' };
t.innerHTML = `<i class="fas fa-${icons[type]||'info-circle'}" style="margin-right:8px;"></i>${msg}`;
document.getElementById('toastContainer').appendChild(t);
setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 400); }, 3000);
};
// ── Modal helpers ──
window.openModal = function(id) {
document.getElementById(id)?.classList.add('active');
document.body.style.overflow = 'hidden';
};
window.closeModal = function(id) {
document.getElementById(id)?.classList.remove('active');
document.body.style.overflow = '';
};
// Close modal on overlay click
document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', e => {
if (e.target === overlay) closeModal(overlay.id);
});
});
// ── Search filter ──
window.filterTable = function(inputId, tableId) {
const q = document.getElementById(inputId)?.value.toLowerCase() || '';
document.querySelectorAll(`#${tableId} tbody tr`).forEach(row => {
row.style.display = row.textContent.toLowerCase().includes(q) ? '' : 'none';
});
};
// Realtime SPBU snackbar
(function initSpbuRealtimeSnackbar() {
const API_URL = APP_BASE + '/api/spbu_status.php';
const REFRESH_MS = 60000;
const SHOW_MS = 7000;
let lastOpen = null;
let hideTimer = null;
function ensureSnackbar() {
let el = document.getElementById('spbuRealtimeSnackbar');
if (el) return el;
el = document.createElement('div');
el.id = 'spbuRealtimeSnackbar';
el.className = 'spbu-snackbar';
el.innerHTML = `
<div class="spbu-snackbar-icon"><i class="fas fa-gas-pump"></i></div>
<div>
<div class="spbu-snackbar-title" id="spbuSnackbarTitle">SPBU sedang buka</div>
<div class="spbu-snackbar-meta" id="spbuSnackbarMeta">Memuat status terbaru...</div>
</div>
<div class="spbu-snackbar-count" id="spbuSnackbarCount">-</div>
`;
document.body.appendChild(el);
return el;
}
function showSnackbar(data) {
const el = ensureSnackbar();
const open = Number(data.spbu_buka || 0);
const total = Number(data.total_spbu || 0);
const closed = Number(data.spbu_tutup || Math.max(0, total - open));
const time = data.server_time ? data.server_time.slice(11, 16) : new Date().toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' });
document.getElementById('spbuSnackbarTitle').textContent = `${open} SPBU sedang buka`;
document.getElementById('spbuSnackbarMeta').textContent = `${closed} tutup/terbatas dari ${total} total - update ${time} WIB`;
document.getElementById('spbuSnackbarCount').textContent = open;
el.classList.add('show');
clearTimeout(hideTimer);
hideTimer = setTimeout(() => el.classList.remove('show'), SHOW_MS);
}
async function refreshStatus(forceShow = false) {
try {
const res = await fetch(API_URL, { cache: 'no-store' });
const json = await res.json();
if (json.status !== 'success' || !json.data) return;
const open = Number(json.data.spbu_buka || 0);
if (forceShow || lastOpen === null || open !== lastOpen) {
showSnackbar(json.data);
}
lastOpen = open;
} catch (e) {
// Snackbar is informational; keep the page quiet if polling fails.
}
}
document.addEventListener('DOMContentLoaded', () => {
refreshStatus(true);
setInterval(() => refreshStatus(false), REFRESH_MS);
});
})();
</script>
<?= $extraScript ?? '' ?>
</body>
</html>
+110
View File
@@ -0,0 +1,110 @@
<?php
/**
* Admin Layout — sidebar + topbar
* Include di awal setiap halaman admin setelah auth check.
* Variables expected from parent: $pageTitle, $activeNav
*/
$user = currentUser();
$initial = strtoupper(substr($user['nama_lengkap'] ?: $user['username'], 0, 1));
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($pageTitle ?? 'Admin Panel') ?> — WebGIS Smart City</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<link rel="stylesheet" href="<?= app_url('assets/css/main.css') ?>?v=20260610-spbu-snackbar">
<link rel="stylesheet" href="<?= app_url('assets/css/admin.css') ?>">
<script>const APP_BASE = '<?= app_url() ?>';</script>
<?= $extraHead ?? '' ?>
</head>
<body class="admin-page <?= $bodyClass ?? '' ?>">
<!-- ══ SIDEBAR ══ -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-brand">
<div class="brand-icon">🗺️</div>
<div class="brand-text">
<span class="brand-name">WebGIS</span>
<span class="brand-sub">Smart City Admin</span>
</div>
</div>
<nav class="sidebar-nav">
<div class="nav-section-title">Utama</div>
<a href="<?= app_url('admin/index.php') ?>"
class="nav-item <?= ($activeNav==='dashboard') ? 'active' : '' ?>">
<span class="nav-icon"><i class="fas fa-chart-pie"></i></span> Dashboard
</a>
<a href="<?= app_url('admin/peta.php') ?>"
class="nav-item <?= ($activeNav==='peta') ? 'active' : '' ?>">
<span class="nav-icon"><i class="fas fa-map-marked-alt"></i></span> Peta Interaktif
</a>
<div class="nav-section-title">Data Spasial</div>
<a href="<?= app_url('admin/data_spbu.php') ?>"
class="nav-item <?= ($activeNav==='spbu') ? 'active' : '' ?>">
<span class="nav-icon"><i class="fas fa-gas-pump"></i></span> SPBU
</a>
<a href="<?= app_url('admin/data_jalan.php') ?>"
class="nav-item <?= ($activeNav==='jalan') ? 'active' : '' ?>">
<span class="nav-icon"><i class="fas fa-road"></i></span> Jalan
</a>
<a href="<?= app_url('admin/data_kavling.php') ?>"
class="nav-item <?= ($activeNav==='kavling') ? 'active' : '' ?>">
<span class="nav-icon"><i class="fas fa-vector-square"></i></span> Kavling / Parsil
</a>
<a href="<?= app_url('admin/data_rumah.php') ?>"
class="nav-item <?= ($activeNav==='rumah') ? 'active' : '' ?>">
<span class="nav-icon"><i class="fas fa-mosque"></i></span> Rumah Ibadah
</a>
<a href="<?= app_url('admin/data_warga.php') ?>"
class="nav-item <?= ($activeNav==='warga') ? 'active' : '' ?>">
<span class="nav-icon"><i class="fas fa-users"></i></span> Warga Miskin
</a>
<a href="<?= app_url('admin/data_kawasan.php') ?>"
class="nav-item <?= ($activeNav==='kawasan') ? 'active' : '' ?>">
<span class="nav-icon"><i class="fas fa-draw-polygon"></i></span> Kawasan Kumuh
</a>
<a href="<?= app_url('admin/data_laporan.php') ?>"
class="nav-item <?= ($activeNav==='laporan') ? 'active' : '' ?>">
<span class="nav-icon"><i class="fas fa-bullhorn"></i></span> Laporan Warga
</a>
<div class="nav-section-title">Manajemen</div>
<a href="<?= app_url('admin/users.php') ?>"
class="nav-item <?= ($activeNav==='users') ? 'active' : '' ?>">
<span class="nav-icon"><i class="fas fa-user-shield"></i></span> Kelola Users
</a>
</nav>
<div class="sidebar-footer">
<div class="user-info">
<div class="user-avatar"><?= $initial ?></div>
<div class="user-details">
<div class="user-name"><?= htmlspecialchars($user['nama_lengkap'] ?: $user['username']) ?></div>
<div class="user-role">Administrator</div>
</div>
<a href="<?= app_url('logout.php') ?>" class="user-logout" title="Keluar">
<i class="fas fa-sign-out-alt"></i>
</a>
</div>
</div>
</aside>
<!-- ══ MAIN CONTENT ══ -->
<main class="admin-main">
<div class="topbar">
<span class="topbar-title"><?= htmlspecialchars($pageTitle ?? 'Dashboard') ?></span>
<div class="topbar-actions">
<div class="topbar-time" id="clock">
<i class="fas fa-clock"></i> <span id="clockText"></span>
</div>
<a href="<?= app_url('admin/peta.php') ?>" class="btn btn-primary btn-sm">
<i class="fas fa-map"></i> Buka Peta
</a>
</div>
</div>
<div class="admin-content">
+173
View File
@@ -0,0 +1,173 @@
<?php
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/auth_check.php';
requireRole('admin');
$pageTitle = 'Peta Interaktif';
$activeNav = 'peta';
$bodyClass = 'admin-map-page map-page';
$extraHead = '
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.css">
<link rel="stylesheet" href="' . app_url('assets/css/map.css') . '?v=20260610-2">
';
require_once __DIR__ . '/partials/header.php';
?>
<div class="map-wrap">
<div id="admin-map"></div>
<aside class="map-sidebar">
<div class="map-panel-header">
<div class="map-panel-icon"><i class="fas fa-layer-group"></i></div>
<div>
<h3>Layer Peta</h3>
<p>Kontrol data yang tampil di peta</p>
</div>
</div>
<div class="layer-section">
<div class="layer-section-title">Infrastruktur</div>
<label class="layer-item">
<input type="checkbox" id="ly-spbu" checked>
<span class="layer-color layer-point" style="--layer-color:#F59E0B"><i class="fas fa-gas-pump"></i></span>
<span class="layer-text">
<span class="layer-name">SPBU</span>
<span class="layer-meta">Stasiun pengisian bahan bakar</span>
</span>
</label>
<label class="layer-item">
<input type="checkbox" id="ly-jalan" checked>
<span class="layer-color layer-line" style="--layer-color:#3B82F6"></span>
<span class="layer-text">
<span class="layer-name">Jalan</span>
<span class="layer-meta">Ruas jalan utama dan lokal</span>
</span>
</label>
<label class="layer-item">
<input type="checkbox" id="ly-kavling" checked>
<span class="layer-color layer-area" style="--layer-color:#7C3AED"></span>
<span class="layer-text">
<span class="layer-name">Kavling / Parsil</span>
<span class="layer-meta">Batas bidang tanah</span>
</span>
</label>
</div>
<div class="layer-section">
<div class="layer-section-title">Sosial</div>
<label class="layer-item">
<input type="checkbox" id="ly-rumah" checked>
<span class="layer-color layer-point" style="--layer-color:#8B5CF6"><i class="fas fa-mosque"></i></span>
<span class="layer-text">
<span class="layer-name">Rumah Ibadah</span>
<span class="layer-meta">Fasilitas keagamaan</span>
</span>
</label>
<label class="layer-item">
<input type="checkbox" id="ly-warga" checked>
<span class="layer-color layer-point" style="--layer-color:#EF4444"><i class="fas fa-users"></i></span>
<span class="layer-text">
<span class="layer-name">Warga Miskin</span>
<span class="layer-meta">Sebaran keluarga penerima bantuan</span>
</span>
</label>
<label class="layer-item">
<input type="checkbox" id="ly-kawasan">
<span class="layer-color layer-area layer-hatch" style="--layer-color:#DC2626"></span>
<span class="layer-text">
<span class="layer-name">Kawasan Kumuh</span>
<span class="layer-meta">Area prioritas penanganan</span>
</span>
</label>
</div>
<div class="layer-section">
<div class="layer-section-title">Analisis</div>
<label class="layer-item">
<input type="checkbox" id="ly-choropleth">
<span class="layer-color layer-gradient"></span>
<span class="layer-text">
<span class="layer-name">Choropleth Kepadatan</span>
<span class="layer-meta">Kepadatan warga miskin per kawasan</span>
</span>
</label>
<label class="layer-item">
<input type="checkbox" id="ly-blankspot">
<span class="layer-color layer-point" style="--layer-color:#F97316"><i class="fas fa-location-dot"></i></span>
<span class="layer-text">
<span class="layer-name">Blank Spot Bansos</span>
<span class="layer-meta">Titik jauh dari fasilitas bantuan</span>
</span>
</label>
</div>
<div class="map-quickstats">
<div class="qs-item"><div class="qs-value" id="qs-spbu" style="color:#F59E0B">-</div><div class="qs-label">SPBU</div></div>
<div class="qs-item"><div class="qs-value" id="qs-jalan" style="color:#3B82F6">-</div><div class="qs-label">Jalan</div></div>
<div class="qs-item"><div class="qs-value" id="qs-kavling" style="color:#7C3AED">-</div><div class="qs-label">Kavling</div></div>
<div class="qs-item"><div class="qs-value" id="qs-warga" style="color:#EF4444">-</div><div class="qs-label">Warga</div></div>
</div>
</aside>
<div class="map-analysis">
<button data-draw="spbu" title="Klik lalu klik di peta untuk menaruh SPBU"><i class="fas fa-gas-pump"></i> SPBU</button>
<button data-draw="jalan" title="Klik beberapa titik untuk menggambar jalan"><i class="fas fa-road"></i> Jalan</button>
<button data-draw="kavling" title="Klik titik sudut polygon untuk menggambar kavling"><i class="fas fa-vector-square"></i> Kavling</button>
<button data-draw="kawasan" title="Gambar polygon kawasan kumuh"><i class="fas fa-draw-polygon"></i> Kawasan</button>
<button data-draw="rumah" title="Taruh marker rumah ibadah"><i class="fas fa-mosque"></i> Rumah Ibadah</button>
<button data-draw="warga" title="Taruh marker warga miskin"><i class="fas fa-user"></i> Warga</button>
<button id="btn-find-spbu" class="map-tool-accent" title="Cari SPBU terdekat dari pusat peta"><i class="fas fa-search-location"></i> SPBU Terdekat</button>
</div>
<div class="spbu-panel" id="spbuPanel" style="display:none;">
<div class="spbu-panel-title">
<i class="fas fa-search-location"></i> SPBU Terdekat
<button class="modal-close" id="btn-close-spbu" style="margin-left:auto;background:none;border:none;color:var(--text-muted);cursor:pointer;font-size:1.2rem;">&times;</button>
</div>
<div id="spbuPanelResult"></div>
</div>
<div class="map-legend">
<div class="map-legend-title"><i class="fas fa-list-ul"></i> Legenda</div>
<div class="legend-grid">
<div class="legend-item"><span class="legend-symbol legend-dot" style="--legend-color:#10B981"></span><span>SPBU 24 Jam</span></div>
<div class="legend-item"><span class="legend-symbol legend-dot" style="--legend-color:#F59E0B"></span><span>SPBU Terbatas</span></div>
<div class="legend-item"><span class="legend-symbol legend-line" style="--legend-color:#3B82F6"></span><span>Jalan</span></div>
<div class="legend-item"><span class="legend-symbol legend-area" style="--legend-color:#7C3AED"></span><span>Kavling</span></div>
<div class="legend-item"><span class="legend-symbol legend-dot" style="--legend-color:#8B5CF6"></span><span>Rumah Ibadah</span></div>
<div class="legend-item"><span class="legend-symbol legend-dot" style="--legend-color:#EF4444"></span><span>Warga Miskin</span></div>
<div class="legend-item"><span class="legend-symbol legend-hatch" style="--legend-color:#DC2626"></span><span>Kawasan Kumuh</span></div>
<div class="legend-item"><span class="legend-symbol legend-dot" style="--legend-color:#F97316"></span><span>Blank Spot</span></div>
</div>
</div>
<div class="map-loading" id="loadingOverlay" style="display:none;">
<div class="spinner"></div>
<span>Memuat data...</span>
</div>
<div class="map-modal" id="formModal">
<div class="map-modal-content">
<div class="modal-header" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<span class="modal-title" id="formModalTitle">Tambah Data</span>
<button class="modal-close" onclick="closeFormModal()" style="background:none;border:none;color:var(--text-muted);cursor:pointer;font-size:1.3rem;">&times;</button>
</div>
<div id="formModalBody"></div>
</div>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.js"></script>
<script src="<?= app_url('assets/js/admin-map.js') ?>?v=20260610-radius"></script>
<?php $extraScript = ''; ?>
<?php require_once __DIR__ . '/partials/footer.php'; ?>
+179
View File
@@ -0,0 +1,179 @@
<?php
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/auth_check.php';
requireRole('admin');
$pdo = Database::getConnection();
$rows = $pdo->query("SELECT id, username, role, nama_lengkap, created_at FROM users ORDER BY id ASC")->fetchAll();
$pageTitle = 'Kelola Users';
$activeNav = 'users';
require_once __DIR__ . '/partials/header.php';
?>
<div class="page-header">
<div class="breadcrumb"><span>Admin</span><span class="sep">/</span><span>Users</span></div>
<h1>👤 Manajemen User Sistem</h1>
<p>Kelola akun administrator dan pengguna yang dapat masuk ke WebGIS Smart City.</p>
</div>
<div class="page-toolbar">
<div class="search-box">
<i class="fas fa-search search-icon"></i>
<input type="text" id="searchInput" placeholder="Cari user..." oninput="filterTable('searchInput','usersTable')">
</div>
<button class="btn btn-primary" onclick="openModal('modalTambah')">
<i class="fas fa-plus"></i> Tambah User
</button>
</div>
<div class="table-wrap">
<table class="data-table" id="usersTable">
<thead><tr>
<th>#</th><th>Username</th><th>Nama Lengkap</th><th>Role</th><th>Dibuat</th><th>Aksi</th>
</tr></thead>
<tbody>
<?php foreach ($rows as $i => $r): ?>
<tr>
<td style="color:var(--text-muted)"><?= $i+1 ?></td>
<td><strong><?= htmlspecialchars($r['username']) ?></strong></td>
<td><?= htmlspecialchars($r['nama_lengkap'] ?: '-') ?></td>
<td>
<?php if ($r['role'] === 'admin'): ?>
<span class="badge badge-danger">👨‍💼 Admin</span>
<?php else: ?>
<span class="badge badge-info">👤 Pengguna</span>
<?php endif; ?>
</td>
<td style="color:var(--text-muted); font-size:0.8rem;"><?= date('d M Y', strtotime($r['created_at'])) ?></td>
<td><div class="actions">
<button class="btn btn-ghost btn-sm btn-icon" onclick='editRow(<?= json_encode($r, JSON_HEX_APOS|JSON_HEX_QUOT) ?>)' title="Edit">
<i class="fas fa-edit"></i>
</button>
<button class="btn btn-danger btn-sm btn-icon" onclick="hapus(<?= $r['id'] ?>,'<?= addslashes($r['username']) ?>')" title="Hapus">
<i class="fas fa-trash"></i>
</button>
</div></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<!-- Modal Tambah -->
<div class="modal-overlay" id="modalTambah">
<div class="modal">
<div class="modal-header">
<span class="modal-title">👤 Tambah User Baru</span>
<button class="modal-close" onclick="closeModal('modalTambah')">×</button>
</div>
<form id="formTambah">
<div class="form-group"><label class="form-label">Username *</label><input type="text" name="username" id="tUsername" class="form-control" placeholder="username unik" required></div>
<div class="form-group"><label class="form-label">Nama Lengkap</label><input type="text" name="nama_lengkap" id="tNama" class="form-control" placeholder="Nama lengkap"></div>
<div class="form-group"><label class="form-label">Role *</label>
<select name="role" id="tRole" class="form-control">
<option value="user">👤 Pengguna (read-only)</option>
<option value="admin">👨‍💼 Administrator</option>
</select>
</div>
<div class="form-group"><label class="form-label">Password *</label><input type="password" name="password" id="tPassword" class="form-control" placeholder="Minimal 6 karakter" required minlength="6"></div>
</form>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeModal('modalTambah')">Batal</button>
<button class="btn btn-primary" onclick="simpan()"><i class="fas fa-save"></i> Simpan</button>
</div>
</div>
</div>
<!-- Modal Edit -->
<div class="modal-overlay" id="modalEdit">
<div class="modal">
<div class="modal-header">
<span class="modal-title">✏️ Edit User</span>
<button class="modal-close" onclick="closeModal('modalEdit')">×</button>
</div>
<input type="hidden" id="editId">
<div class="form-group"><label class="form-label">Username *</label><input type="text" id="editUsername" class="form-control" required></div>
<div class="form-group"><label class="form-label">Nama Lengkap</label><input type="text" id="editNama" class="form-control"></div>
<div class="form-group"><label class="form-label">Role *</label>
<select id="editRole" class="form-control">
<option value="user">👤 Pengguna</option>
<option value="admin">👨‍💼 Administrator</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Password Baru</label>
<input type="password" id="editPassword" class="form-control" placeholder="Kosongkan jika tidak ingin mengubah" minlength="6">
<div style="font-size:0.72rem;color:var(--text-muted);margin-top:4px;">Kosongkan jika tidak ingin mengubah password.</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeModal('modalEdit')">Batal</button>
<button class="btn btn-primary" onclick="update()"><i class="fas fa-save"></i> Perbarui</button>
</div>
</div>
</div>
<?php $extraScript = <<<'JS'
<script>
const API = APP_BASE + '/api/users.php';
function editRow(r) {
document.getElementById('editId').value = r.id;
document.getElementById('editUsername').value = r.username;
document.getElementById('editNama').value = r.nama_lengkap || '';
document.getElementById('editRole').value = r.role;
document.getElementById('editPassword').value = '';
openModal('modalEdit');
}
async function simpan() {
const u = document.getElementById('tUsername').value.trim();
const p = document.getElementById('tPassword').value;
if (!u) return showToast('Username wajib!', 'error');
if (p.length < 6) return showToast('Password minimal 6 karakter!', 'error');
const res = await fetch(API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: u,
nama_lengkap: document.getElementById('tNama').value,
role: document.getElementById('tRole').value,
password: p
})
});
const d = await res.json();
if (d.status === 'success') { showToast('User berhasil ditambahkan!'); setTimeout(() => location.reload(), 900); }
else showToast(d.message || 'Gagal', 'error');
}
async function update() {
const id = document.getElementById('editId').value;
const u = document.getElementById('editUsername').value.trim();
const p = document.getElementById('editPassword').value;
if (!u) return showToast('Username wajib!', 'error');
if (p && p.length < 6) return showToast('Password minimal 6 karakter!', 'error');
const body = {
username: u,
nama_lengkap: document.getElementById('editNama').value,
role: document.getElementById('editRole').value
};
if (p) body.password = p;
const res = await fetch(API + '?id=' + id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const d = await res.json();
if (d.status === 'success') { showToast('User diperbarui!'); setTimeout(() => location.reload(), 900); }
else showToast(d.message || 'Gagal', 'error');
}
async function hapus(id, username) {
if (id == 1) return showToast('Admin utama (ID=1) tidak dapat dihapus!', 'error');
if (!confirm(`Hapus user "${username}"?`)) return;
const res = await fetch(API + '?id=' + id, { method: 'DELETE' });
const d = await res.json();
if (d.status === 'success') { showToast('User dihapus!'); setTimeout(() => location.reload(), 900); }
else showToast(d.message || 'Gagal', 'error');
}
</script>
JS; ?>
<?php require_once __DIR__ . '/partials/footer.php'; ?>
+66
View File
@@ -0,0 +1,66 @@
<?php
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/helpers.php';
$pdo = Database::getConnection();
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
sendError('Method not allowed', 405);
}
// Warga miskin yang berada di luar semua radius bantuan rumah ibadah.
$sql = "
SELECT
w.id,
w.nama_kk,
w.penghasilan,
ST_AsGeoJSON(w.geom) AS geojson,
ri.nama AS rumah_ibadah_terdekat,
ST_Distance_Sphere(w.geom, ri.geom) AS jarak_meter,
ri.radius_bantuan_meter,
(ST_Distance_Sphere(w.geom, ri.geom) - ri.radius_bantuan_meter) AS selisih_meter
FROM warga_miskin w
JOIN rumah_ibadah ri
JOIN (
SELECT
nearest_distance.warga_id,
MIN(nearest_distance.jarak_meter) AS min_jarak_meter
FROM (
SELECT
w2.id AS warga_id,
ST_Distance_Sphere(w2.geom, ri2.geom) AS jarak_meter
FROM warga_miskin w2
CROSS JOIN rumah_ibadah ri2
) nearest_distance
GROUP BY nearest_distance.warga_id
) nearest
ON nearest.warga_id = w.id
AND ABS(ST_Distance_Sphere(w.geom, ri.geom) - nearest.min_jarak_meter) < 0.001
WHERE NOT EXISTS (
SELECT 1
FROM rumah_ibadah ri_in_radius
WHERE ST_Distance_Sphere(w.geom, ri_in_radius.geom) <= ri_in_radius.radius_bantuan_meter
)
GROUP BY w.id
ORDER BY selisih_meter DESC
";
$stmt = $pdo->query($sql);
$features = [];
while ($r = $stmt->fetch()) {
$features[] = [
'type' => 'Feature',
'geometry' => json_decode($r['geojson']),
'properties' => [
'id' => (int)$r['id'],
'nama_kk' => $r['nama_kk'],
'jarak_km' => round(((float)$r['jarak_meter']) / 1000, 2),
'radius_km' => round(((int)$r['radius_bantuan_meter']) / 1000, 2),
'selisih_km' => round(((float)$r['selisih_meter']) / 1000, 2),
'rumah_ibadah_terdekat' => $r['rumah_ibadah_terdekat']
]
];
}
sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Blank Spot');
+52
View File
@@ -0,0 +1,52 @@
<?php
require_once __DIR__ . '/../config/session.php';
/**
* Shared response helper for all API endpoints
*/
header('Content-Type: application/json');
$allowedOrigin = getenv('CORS_ALLOWED_ORIGIN');
if ($allowedOrigin !== false && $allowedOrigin !== '') {
header('Access-Control-Allow-Origin: ' . $allowedOrigin);
header('Vary: 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; }
function sendSuccess($data, string $message = 'OK', int $code = 200): void {
http_response_code($code);
echo json_encode(['status' => 'success', 'message' => $message, 'data' => $data]);
exit;
}
function sendError(string $message = 'Bad Request', int $code = 400): void {
http_response_code($code);
echo json_encode(['status' => 'error', 'message' => $message, 'data' => null]);
exit;
}
function getInput(): array {
return json_decode(file_get_contents('php://input'), true) ?? [];
}
function requireApiRole(string $role): void {
if (session_status() === PHP_SESSION_NONE) {
startAppSession();
}
if (!isset($_SESSION['user_id'], $_SESSION['role'])) {
sendError('Unauthorized', 401);
}
if ($_SESSION['role'] !== $role) {
sendError('Forbidden', 403);
}
}
function requireAdminForMutation(): void {
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
requireApiRole('admin');
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/helpers.php';
$pdo = Database::getConnection(); $method = $_SERVER['REQUEST_METHOD'];
requireAdminForMutation();
switch ($method) {
case 'GET':
$stmt = $pdo->query("SELECT id,nama,jenis_jalan,created_at,ST_AsGeoJSON(geom) as geojson FROM jalan ORDER BY id DESC");
$features=[];
while($r=$stmt->fetch()) $features[]=['type'=>'Feature','geometry'=>json_decode($r['geojson']),'properties'=>['id'=>$r['id'],'nama'=>$r['nama'],'jenis_jalan'=>$r['jenis_jalan'],'created_at'=>$r['created_at']]];
sendSuccess(['type'=>'FeatureCollection','features'=>$features],'Data Jalan'); break;
case 'POST':
$d=getInput(); if(empty($d['nama'])||empty($d['geometry'])) sendError('Nama & geometri wajib');
$pdo->prepare("INSERT INTO jalan(nama,jenis_jalan,geom) VALUES(?,?,ST_GeomFromGeoJSON(?))")->execute([$d['nama'],$d['jenis_jalan']??'Lokal',json_encode($d['geometry'])]);
sendSuccess(['id'=>$pdo->lastInsertId()],'Jalan disimpan',201); break;
case 'PUT':
$d=getInput(); $id=$_GET['id']??null; if(!$id) sendError('ID wajib');
$pdo->prepare("UPDATE jalan SET nama=?,jenis_jalan=? WHERE id=?")->execute([$d['nama'],$d['jenis_jalan'],$id]);
sendSuccess(null,'Jalan diperbarui'); break;
case 'DELETE':
$id=$_GET['id']??null; if(!$id) sendError('ID wajib');
$pdo->prepare("DELETE FROM jalan WHERE id=?")->execute([$id]); sendSuccess(null,'Jalan dihapus'); break;
default: sendError('Method not allowed',405);
}
+26
View File
@@ -0,0 +1,26 @@
<?php require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/helpers.php';
$pdo = Database::getConnection(); $method = $_SERVER['REQUEST_METHOD'];
requireAdminForMutation();
switch ($method) {
case 'GET':
$stmt = $pdo->query("SELECT id,nama_pemilik,status_kepemilikan,luas,created_at,ST_AsGeoJSON(geom) as geojson FROM kavling ORDER BY id DESC");
$features=[];
while($r=$stmt->fetch()) $features[]=['type'=>'Feature','geometry'=>json_decode($r['geojson']),'properties'=>['id'=>$r['id'],'nama_pemilik'=>$r['nama_pemilik'],'status_kepemilikan'=>$r['status_kepemilikan'],'luas'=>$r['luas'],'created_at'=>$r['created_at']]];
sendSuccess(['type'=>'FeatureCollection','features'=>$features],'Data Kavling'); break;
case 'POST':
$d=getInput(); if(empty($d['nama_pemilik'])||empty($d['status_kepemilikan'])||empty($d['geometry'])) sendError('Data tidak lengkap');
$pdo->prepare("INSERT INTO kavling(nama_pemilik,status_kepemilikan,luas,geom) VALUES(?,?,?,ST_GeomFromGeoJSON(?))")->execute([$d['nama_pemilik'],$d['status_kepemilikan'],$d['luas']??0,json_encode($d['geometry'])]);
sendSuccess(['id'=>$pdo->lastInsertId()],'Kavling disimpan',201); break;
case 'PUT':
$d=getInput(); $id=$_GET['id']??null; if(!$id) sendError('ID wajib');
if(!empty($d['geometry'])){
$pdo->prepare("UPDATE kavling SET geom=ST_GeomFromGeoJSON(?) WHERE id=?")->execute([json_encode($d['geometry']),$id]);
} else {
$pdo->prepare("UPDATE kavling SET nama_pemilik=?,status_kepemilikan=?,luas=? WHERE id=?")->execute([$d['nama_pemilik'],$d['status_kepemilikan'],$d['luas']??0,$id]);
}
sendSuccess(null,'Kavling diperbarui'); break;
case 'DELETE':
$id=$_GET['id']??null; if(!$id) sendError('ID wajib');
$pdo->prepare("DELETE FROM kavling WHERE id=?")->execute([$id]); sendSuccess(null,'Kavling dihapus'); break;
default: sendError('Method not allowed',405);
}
+22
View File
@@ -0,0 +1,22 @@
<?php require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/helpers.php';
$pdo = Database::getConnection(); $method = $_SERVER['REQUEST_METHOD'];
requireAdminForMutation();
switch ($method) {
case 'GET':
$stmt = $pdo->query("SELECT id,nama_kawasan,created_at,ST_AsGeoJSON(geom) as geojson FROM kawasan_kumuh ORDER BY id DESC");
$features=[];
while($r=$stmt->fetch()) $features[]=['type'=>'Feature','geometry'=>json_decode($r['geojson']),'properties'=>['id'=>$r['id'],'nama_kawasan'=>$r['nama_kawasan'],'created_at'=>$r['created_at']]];
sendSuccess(['type'=>'FeatureCollection','features'=>$features],'Data Kawasan Kumuh'); break;
case 'POST':
$d=getInput(); if(empty($d['nama_kawasan'])||empty($d['geometry'])) sendError('Data tidak lengkap');
$pdo->prepare("INSERT INTO kawasan_kumuh(nama_kawasan,geom) VALUES(?,ST_GeomFromGeoJSON(?))")->execute([$d['nama_kawasan'],json_encode($d['geometry'])]);
sendSuccess(['id'=>$pdo->lastInsertId()],'Kawasan disimpan',201); break;
case 'PUT':
$d=getInput(); $id=$_GET['id']??null; if(!$id) sendError('ID wajib');
$pdo->prepare("UPDATE kawasan_kumuh SET nama_kawasan=? WHERE id=?")->execute([$d['nama_kawasan'],$id]);
sendSuccess(null,'Kawasan diperbarui'); break;
case 'DELETE':
$id=$_GET['id']??null; if(!$id) sendError('ID wajib');
$pdo->prepare("DELETE FROM kawasan_kumuh WHERE id=?")->execute([$id]); sendSuccess(null,'Kawasan dihapus'); break;
default: sendError('Method not allowed',405);
}
+104
View File
@@ -0,0 +1,104 @@
<?php
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/session.php';
startAppSession();
header('Content-Type: application/json');
if (!isset($_SESSION['user_id'])) {
http_response_code(401);
echo json_encode(['status' => 'error', 'message' => 'Unauthorized']);
exit;
}
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
try {
if ($method === 'GET') {
// Jika parameter my_reports=1, hanya laporan milik user yang login
// Jika tidak dan role=admin, ambil semua.
$my_reports = $_GET['my_reports'] ?? 0;
$sql = "SELECT l.*, ST_X(l.geometry) as lng, ST_Y(l.geometry) as lat, u.nama_lengkap, u.username
FROM laporan_warga l
JOIN users u ON l.user_id = u.id ";
if ($my_reports || $_SESSION['role'] !== 'admin') {
// User biasa hanya boleh melihat laporannya sendiri di tabel dashboard,
// tapi mungkin butuh lihat semua laporan di peta publik?
// Untuk peta public (semua laporan disetujui atau diproses), tambahkan parameter public=1
if (isset($_GET['public']) && $_GET['public'] == 1) {
$sql .= " WHERE l.status IN ('diproses', 'selesai') ";
} else {
$sql .= " WHERE l.user_id = " . (int)$_SESSION['user_id'];
}
}
$sql .= " ORDER BY l.id DESC";
$rows = $pdo->query($sql)->fetchAll();
$features = [];
foreach ($rows as $row) {
$features[] = [
'type' => 'Feature',
'properties' => [
'id' => $row['id'],
'user_id' => $row['user_id'],
'pelapor' => $row['nama_lengkap'] ?: $row['username'],
'kategori' => $row['kategori'],
'deskripsi' => $row['deskripsi'],
'status' => $row['status'],
'created_at' => $row['created_at']
],
'geometry' => [
'type' => 'Point',
'coordinates' => [(float)$row['lng'], (float)$row['lat']]
]
];
}
echo json_encode([
'type' => 'FeatureCollection',
'features' => $features
]);
}
elseif ($method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['kategori'], $input['deskripsi'], $input['geometry'])) {
throw new Exception("Data tidak lengkap.");
}
$coords = $input['geometry']['coordinates'];
$stmt = $pdo->prepare("INSERT INTO laporan_warga (user_id, kategori, deskripsi, geometry) VALUES (?, ?, ?, ST_GeomFromText(?))");
$stmt->execute([
$_SESSION['user_id'],
$input['kategori'],
$input['deskripsi'],
"POINT({$coords[0]} {$coords[1]})"
]);
echo json_encode(['status' => 'success', 'message' => 'Laporan berhasil dibuat.']);
}
elseif ($method === 'PUT') {
if ($_SESSION['role'] !== 'admin') throw new Exception("Unauthorized");
$id = $_GET['id'] ?? 0;
$input = json_decode(file_get_contents('php://input'), true);
$status = $input['status'] ?? 'menunggu';
$stmt = $pdo->prepare("UPDATE laporan_warga SET status = ? WHERE id = ?");
$stmt->execute([$status, $id]);
echo json_encode(['status' => 'success', 'message' => 'Status laporan diperbarui.']);
}
elseif ($method === 'DELETE') {
$id = $_GET['id'] ?? 0;
// User bisa hapus kalau status menunggu. Admin bisa hapus semua.
if ($_SESSION['role'] === 'admin') {
$stmt = $pdo->prepare("DELETE FROM laporan_warga WHERE id = ?");
$stmt->execute([$id]);
} else {
$stmt = $pdo->prepare("DELETE FROM laporan_warga WHERE id = ? AND user_id = ? AND status = 'menunggu'");
$stmt->execute([$id, $_SESSION['user_id']]);
}
echo json_encode(['status' => 'success', 'message' => 'Laporan dihapus.']);
}
} catch (Exception $e) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => $e->getMessage()]);
}
+102
View File
@@ -0,0 +1,102 @@
<?php
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/helpers.php';
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
requireAdminForMutation();
function normalizeRadius($value): int {
$radius = (int)($value ?? 1000);
if ($radius < 100) return 100;
if ($radius > 10000) return 10000;
return $radius;
}
switch ($method) {
case 'GET':
$stmt = $pdo->query("
SELECT
r.id,
r.nama,
r.agama,
r.radius_bantuan_meter,
r.created_at,
ST_AsGeoJSON(r.geom) AS geojson,
IFNULL(AVG(u.rating), 0) AS avg_rating,
COUNT(u.id) AS total_ulasan
FROM rumah_ibadah r
LEFT JOIN ulasan_fasilitas u
ON u.fasilitas_id = r.id
AND u.fasilitas_tipe = 'rumah_ibadah'
GROUP BY r.id
ORDER BY r.id DESC
");
$features = [];
while ($r = $stmt->fetch()) {
$features[] = [
'type' => 'Feature',
'geometry' => json_decode($r['geojson']),
'properties' => [
'id' => (int)$r['id'],
'nama' => $r['nama'],
'agama' => $r['agama'],
'radius_bantuan_meter' => (int)$r['radius_bantuan_meter'],
'radius_bantuan_km' => round(((int)$r['radius_bantuan_meter']) / 1000, 2),
'created_at' => $r['created_at'],
'avg_rating' => round((float)$r['avg_rating'], 1),
'total_ulasan' => (int)$r['total_ulasan']
]
];
}
sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Rumah Ibadah');
break;
case 'POST':
$d = getInput();
if (empty($d['nama']) || empty($d['agama']) || empty($d['geometry'])) {
sendError('Data tidak lengkap');
}
$radius = normalizeRadius($d['radius_bantuan_meter'] ?? null);
$pdo->prepare("
INSERT INTO rumah_ibadah (nama, agama, radius_bantuan_meter, geom)
VALUES (?, ?, ?, ST_GeomFromGeoJSON(?))
")->execute([$d['nama'], $d['agama'], $radius, json_encode($d['geometry'])]);
sendSuccess(['id' => $pdo->lastInsertId()], 'Rumah Ibadah disimpan', 201);
break;
case 'PUT':
$d = getInput();
$id = $_GET['id'] ?? null;
if (!$id) sendError('ID wajib');
if (!empty($d['geometry'])) {
$pdo->prepare("UPDATE rumah_ibadah SET geom = ST_GeomFromGeoJSON(?) WHERE id = ?")
->execute([json_encode($d['geometry']), $id]);
} else {
$radius = normalizeRadius($d['radius_bantuan_meter'] ?? null);
$pdo->prepare("
UPDATE rumah_ibadah
SET nama = ?, agama = ?, radius_bantuan_meter = ?
WHERE id = ?
")->execute([$d['nama'], $d['agama'], $radius, $id]);
}
sendSuccess(null, 'Rumah Ibadah diperbarui');
break;
case 'DELETE':
$id = $_GET['id'] ?? null;
if (!$id) sendError('ID wajib');
$pdo->prepare("DELETE FROM rumah_ibadah WHERE id = ?")->execute([$id]);
sendSuccess(null, 'Rumah Ibadah dihapus');
break;
default:
sendError('Method not allowed', 405);
}
+57
View File
@@ -0,0 +1,57 @@
<?php require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/helpers.php';
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
requireAdminForMutation();
switch ($method) {
case 'GET':
$stmt = $pdo->query("SELECT s.id, s.nama, s.deskripsi, s.buka_24_jam, s.created_at, ST_AsGeoJSON(s.geom) as geojson,
IFNULL(AVG(u.rating), 0) as avg_rating, COUNT(u.id) as total_ulasan
FROM spbu s
LEFT JOIN ulasan_fasilitas u ON u.fasilitas_id = s.id AND u.fasilitas_tipe = 'spbu'
GROUP BY s.id
ORDER BY s.id DESC");
$features = [];
while ($r = $stmt->fetch()) {
$features[] = [
'type'=>'Feature',
'geometry'=>json_decode($r['geojson']),
'properties'=>[
'id'=>$r['id'],
'nama'=>$r['nama'],
'deskripsi'=>$r['deskripsi'],
'buka_24_jam'=>(int)$r['buka_24_jam'],
'created_at'=>$r['created_at'],
'avg_rating'=>round($r['avg_rating'], 1),
'total_ulasan'=>$r['total_ulasan']
]
];
}
sendSuccess(['type'=>'FeatureCollection','features'=>$features], 'Data SPBU');
break;
case 'POST':
$d = getInput();
if (empty($d['nama']) || empty($d['geometry'])) sendError('Nama dan geometri wajib diisi');
$stmt = $pdo->prepare("INSERT INTO spbu (nama, deskripsi, buka_24_jam, geom) VALUES (?,?,?,ST_GeomFromGeoJSON(?))");
$stmt->execute([$d['nama'], $d['deskripsi']??'', (int)($d['buka_24_jam']??0), json_encode($d['geometry'])]);
sendSuccess(['id'=>$pdo->lastInsertId()], 'SPBU disimpan', 201);
break;
case 'PUT':
$d = getInput(); $id = $_GET['id'] ?? null;
if (!$id) sendError('ID wajib disertakan');
if (!empty($d['geometry'])) {
$stmt = $pdo->prepare("UPDATE spbu SET geom=ST_GeomFromGeoJSON(?) WHERE id=?");
$stmt->execute([json_encode($d['geometry']), $id]);
} else {
$stmt = $pdo->prepare("UPDATE spbu SET nama=?, deskripsi=?, buka_24_jam=? WHERE id=?");
$stmt->execute([$d['nama'], $d['deskripsi']??'', (int)($d['buka_24_jam']??0), $id]);
}
sendSuccess(null, 'SPBU diperbarui');
break;
case 'DELETE':
$id = $_GET['id'] ?? null;
if (!$id) sendError('ID wajib disertakan');
$pdo->prepare("DELETE FROM spbu WHERE id=?")->execute([$id]);
sendSuccess(null, 'SPBU dihapus');
break;
default: sendError('Method not allowed', 405);
}
+29
View File
@@ -0,0 +1,29 @@
<?php
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
sendError('Method not allowed', 405);
}
$pdo = Database::getConnection();
date_default_timezone_set('Asia/Jakarta');
$row = $pdo->query("
SELECT
COUNT(*) AS total_spbu,
SUM(CASE WHEN buka_24_jam = 1 THEN 1 ELSE 0 END) AS spbu_buka
FROM spbu
")->fetch();
$total = (int)($row['total_spbu'] ?? 0);
$open = (int)($row['spbu_buka'] ?? 0);
sendSuccess([
'total_spbu' => $total,
'spbu_buka' => $open,
'spbu_tutup' => max(0, $total - $open),
'basis_perhitungan' => 'buka_24_jam',
'server_time' => date('Y-m-d H:i:s'),
'timezone' => date_default_timezone_get()
], 'Status SPBU');
+13
View File
@@ -0,0 +1,13 @@
<?php require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/helpers.php';
$pdo = Database::getConnection();
if ($_SERVER['REQUEST_METHOD'] !== 'GET') sendError('Method not allowed', 405);
$lat = (float)($_GET['lat'] ?? 0); $lng = (float)($_GET['lng'] ?? 0);
if (!$lat || !$lng) sendError('lat dan lng wajib diisi');
$wkt = sprintf('POINT(%F %F)', $lng, $lat);
$stmt = $pdo->prepare("SELECT id, nama, buka_24_jam, ST_AsGeoJSON(geom) as geojson,
ST_Distance_Sphere(geom, ST_GeomFromText(?)) / 1000 as jarak_km
FROM spbu ORDER BY jarak_km ASC LIMIT 1");
$stmt->execute([$wkt]);
$r = $stmt->fetch();
if (!$r) sendError('Tidak ada SPBU di database', 404);
sendSuccess(['type'=>'Feature','geometry'=>json_decode($r['geojson']),'properties'=>['id'=>$r['id'],'nama'=>$r['nama'],'buka_24_jam'=>(int)$r['buka_24_jam'],'jarak_km'=>round($r['jarak_km'],2)]], 'SPBU terdekat');
+53
View File
@@ -0,0 +1,53 @@
<?php require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/helpers.php';
$pdo = Database::getConnection();
if ($_SERVER['REQUEST_METHOD'] !== 'GET') sendError('Method not allowed', 405);
// Summary counts
$counts = $pdo->query("SELECT
(SELECT COUNT(*) FROM spbu) as total_spbu,
(SELECT COUNT(*) FROM jalan) as total_jalan,
(SELECT COUNT(*) FROM kavling) as total_kavling,
(SELECT COUNT(*) FROM rumah_ibadah) as total_rumah_ibadah,
(SELECT COUNT(*) FROM warga_miskin) as total_warga_miskin,
(SELECT COUNT(*) FROM kawasan_kumuh) as total_kawasan_kumuh,
(SELECT COUNT(*) FROM spbu WHERE buka_24_jam=1) as spbu_24jam
")->fetch();
// Choropleth: kawasan kumuh + jumlah warga di dalamnya
$sql = "SELECT k.id, k.nama_kawasan, ST_AsGeoJSON(k.geom) as geojson,
COUNT(w.id) as jumlah_warga,
COALESCE(SUM(w.jumlah_tanggungan),0) as total_tanggungan
FROM kawasan_kumuh k
LEFT JOIN warga_miskin w ON ST_Contains(k.geom, w.geom)
GROUP BY k.id";
$stmt = $pdo->query($sql);
$features = [];
$merahCount = 0;
while ($r = $stmt->fetch()) {
$jumlah = (int)$r['jumlah_warga'];
if ($jumlah > 3) $merahCount++;
$features[] = [
'type' => 'Feature',
'geometry' => json_decode($r['geojson']),
'properties' => [
'id' => $r['id'],
'nama_kawasan' => $r['nama_kawasan'],
'jumlah_warga' => $jumlah,
'total_tanggungan' => (int)$r['total_tanggungan'],
]
];
}
// Rumah ibadah by agama
$byAgama = $pdo->query("SELECT agama, COUNT(*) as total FROM rumah_ibadah GROUP BY agama")->fetchAll();
// Kavling by status
$byStatus = $pdo->query("SELECT status_kepemilikan, COUNT(*) as total FROM kavling GROUP BY status_kepemilikan")->fetchAll();
sendSuccess([
'counts' => $counts,
'kawasan_merah' => $merahCount,
'choropleth' => ['type' => 'FeatureCollection', 'features' => $features],
'rumah_by_agama' => $byAgama,
'kavling_by_status' => $byStatus,
], 'Statistik OK');
+50
View File
@@ -0,0 +1,50 @@
<?php
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/session.php';
startAppSession();
header('Content-Type: application/json');
if (!isset($_SESSION['user_id']) || $_SESSION['role'] !== 'user') {
http_response_code(401);
echo json_encode(['status' => 'error', 'message' => 'Hanya pengguna terdaftar yang dapat memberikan ulasan']);
exit;
}
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
try {
if ($method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['fasilitas_tipe'], $input['fasilitas_id'], $input['rating'])) {
throw new Exception("Data ulasan tidak lengkap.");
}
$tipe = $input['fasilitas_tipe'];
if (!in_array($tipe, ['spbu', 'rumah_ibadah'])) throw new Exception("Tipe fasilitas tidak valid");
$fasilitas_id = (int)$input['fasilitas_id'];
$rating = (int)$input['rating'];
$komentar = trim($input['komentar'] ?? '');
if ($rating < 1 || $rating > 5) throw new Exception("Rating harus antara 1 sampai 5");
// Cek apakah sudah pernah mengulas
$check = $pdo->prepare("SELECT id FROM ulasan_fasilitas WHERE user_id = ? AND fasilitas_tipe = ? AND fasilitas_id = ?");
$check->execute([$_SESSION['user_id'], $tipe, $fasilitas_id]);
if ($check->fetch()) {
throw new Exception("Anda sudah memberikan ulasan untuk fasilitas ini.");
}
$stmt = $pdo->prepare("INSERT INTO ulasan_fasilitas (user_id, fasilitas_tipe, fasilitas_id, rating, komentar) VALUES (?, ?, ?, ?, ?)");
$stmt->execute([$_SESSION['user_id'], $tipe, $fasilitas_id, $rating, $komentar]);
echo json_encode(['status' => 'success', 'message' => 'Ulasan berhasil disimpan! Terima kasih atas partisipasi Anda.']);
} else {
throw new Exception("Method not allowed");
}
} catch (Exception $e) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => $e->getMessage()]);
}
+31
View File
@@ -0,0 +1,31 @@
<?php require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/helpers.php';
$pdo = Database::getConnection(); $method = $_SERVER['REQUEST_METHOD'];
requireApiRole('admin');
switch ($method) {
case 'GET':
$stmt = $pdo->query("SELECT id,username,role,nama_lengkap,created_at FROM users ORDER BY id ASC");
sendSuccess($stmt->fetchAll(), 'Data Users'); break;
case 'POST':
$d=getInput();
if(empty($d['username'])||empty($d['password'])||empty($d['role'])) sendError('Data tidak lengkap');
$hash = password_hash($d['password'], PASSWORD_BCRYPT);
try {
$pdo->prepare("INSERT INTO users(username,password,role,nama_lengkap) VALUES(?,?,?,?)")->execute([$d['username'],$hash,$d['role'],$d['nama_lengkap']??'']);
sendSuccess(['id'=>$pdo->lastInsertId()],'User disimpan',201);
} catch(Exception $e) { sendError('Username sudah digunakan'); }
break;
case 'PUT':
$d=getInput(); $id=$_GET['id']??null; if(!$id) sendError('ID wajib');
if(!empty($d['password'])) {
$hash=password_hash($d['password'],PASSWORD_BCRYPT);
$pdo->prepare("UPDATE users SET username=?,password=?,role=?,nama_lengkap=? WHERE id=?")->execute([$d['username'],$hash,$d['role'],$d['nama_lengkap']??'',$id]);
} else {
$pdo->prepare("UPDATE users SET username=?,role=?,nama_lengkap=? WHERE id=?")->execute([$d['username'],$d['role'],$d['nama_lengkap']??'',$id]);
}
sendSuccess(null,'User diperbarui'); break;
case 'DELETE':
$id=$_GET['id']??null; if(!$id) sendError('ID wajib');
if($id==1) sendError('Admin utama tidak dapat dihapus');
$pdo->prepare("DELETE FROM users WHERE id=?")->execute([$id]); sendSuccess(null,'User dihapus'); break;
default: sendError('Method not allowed',405);
}
+26
View File
@@ -0,0 +1,26 @@
<?php require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/helpers.php';
$pdo = Database::getConnection(); $method = $_SERVER['REQUEST_METHOD'];
requireAdminForMutation();
switch ($method) {
case 'GET':
$stmt = $pdo->query("SELECT id,nama_kk,penghasilan,jumlah_tanggungan,created_at,ST_AsGeoJSON(geom) as geojson FROM warga_miskin ORDER BY id DESC");
$features=[];
while($r=$stmt->fetch()) $features[]=['type'=>'Feature','geometry'=>json_decode($r['geojson']),'properties'=>['id'=>$r['id'],'nama_kk'=>$r['nama_kk'],'penghasilan'=>$r['penghasilan'],'jumlah_tanggungan'=>$r['jumlah_tanggungan'],'created_at'=>$r['created_at']]];
sendSuccess(['type'=>'FeatureCollection','features'=>$features],'Data Warga Miskin'); break;
case 'POST':
$d=getInput(); if(empty($d['nama_kk'])||empty($d['geometry'])) sendError('Data tidak lengkap');
$pdo->prepare("INSERT INTO warga_miskin(nama_kk,penghasilan,jumlah_tanggungan,geom) VALUES(?,?,?,ST_GeomFromGeoJSON(?))")->execute([$d['nama_kk'],$d['penghasilan']??0,$d['jumlah_tanggungan']??0,json_encode($d['geometry'])]);
sendSuccess(['id'=>$pdo->lastInsertId()],'Warga disimpan',201); break;
case 'PUT':
$d=getInput(); $id=$_GET['id']??null; if(!$id) sendError('ID wajib');
if(!empty($d['geometry'])){
$pdo->prepare("UPDATE warga_miskin SET geom=ST_GeomFromGeoJSON(?) WHERE id=?")->execute([json_encode($d['geometry']),$id]);
} else {
$pdo->prepare("UPDATE warga_miskin SET nama_kk=?,penghasilan=?,jumlah_tanggungan=? WHERE id=?")->execute([$d['nama_kk'],$d['penghasilan']??0,$d['jumlah_tanggungan']??0,$id]);
}
sendSuccess(null,'Warga diperbarui'); break;
case 'DELETE':
$id=$_GET['id']??null; if(!$id) sendError('ID wajib');
$pdo->prepare("DELETE FROM warga_miskin WHERE id=?")->execute([$id]); sendSuccess(null,'Warga dihapus'); break;
default: sendError('Method not allowed',405);
}
+364
View File
@@ -0,0 +1,364 @@
/* =========================================================
ADMIN LAYOUT STYLES — sidebar, topbar, main content
========================================================= */
body.admin-page {
display: flex;
min-height: 100vh;
background: var(--bg-base);
}
/* ══════════════════════════════
SIDEBAR
══════════════════════════════ */
.sidebar {
width: var(--sidebar-w);
flex-shrink: 0;
background: var(--sidebar-bg);
border-right: 1px solid var(--sidebar-border);
display: flex;
flex-direction: column;
height: 100vh;
position: fixed;
top: 0; left: 0;
z-index: 100;
overflow-y: auto;
transition: var(--transition);
}
.sidebar-brand {
padding: 24px 20px;
border-bottom: 1px solid var(--sidebar-border);
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.brand-icon {
width: 40px; height: 40px;
background: var(--primary);
border-radius: 10px;
display: flex; align-items: center; justify-content: center;
font-size: 1.1rem;
color: white;
flex-shrink: 0;
box-shadow: var(--shadow-sm);
}
.brand-text {
display: flex;
flex-direction: column;
line-height: 1.3;
}
.brand-name {
font-size: 0.95rem;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.brand-sub {
font-size: 0.75rem;
color: var(--text-muted);
font-weight: 500;
}
.sidebar-nav {
padding: 16px 0;
flex: 1;
}
.nav-section-title {
padding: 16px 20px 8px;
font-size: 0.7rem;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 20px;
color: var(--text-secondary);
font-size: 0.9rem;
font-weight: 500;
text-decoration: none;
border-radius: 0;
transition: var(--transition);
position: relative;
cursor: pointer;
border: none;
background: none;
width: 100%;
text-align: left;
}
.nav-item:hover {
background: #F3F4F6;
color: var(--text-primary);
}
.nav-item.active {
background: #EFF6FF;
color: var(--primary-dark);
}
.nav-item.active::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 4px;
background: var(--primary);
border-radius: 0 2px 2px 0;
}
.nav-item .nav-icon {
width: 20px;
text-align: center;
font-size: 0.95rem;
flex-shrink: 0;
color: inherit;
}
.nav-item .nav-badge {
margin-left: auto;
background: var(--danger);
color: #fff;
font-size: 0.7rem;
font-weight: 700;
padding: 2px 8px;
border-radius: 20px;
}
.sidebar-footer {
border-top: 1px solid var(--sidebar-border);
padding: 16px;
flex-shrink: 0;
background: #FAFAFA;
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
padding: 10px;
border-radius: var(--radius-md);
background: #FFFFFF;
border: 1px solid var(--sidebar-border);
box-shadow: var(--shadow-sm);
}
.user-avatar {
width: 36px; height: 36px;
border-radius: 50%;
background: #E0E7FF;
display: flex; align-items: center; justify-content: center;
font-size: 0.9rem;
font-weight: 700;
color: var(--primary-dark);
flex-shrink: 0;
}
.user-details { flex: 1; overflow: hidden; }
.user-name { font-size: 0.85rem; font-weight: 600; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.user-role { font-size: 0.75rem; color: var(--text-muted); text-transform: capitalize; }
.user-logout {
background: none; border: none; color: var(--text-muted);
cursor: pointer; font-size: 1.1rem; padding: 6px;
transition: all 0.2s;
border-radius: var(--radius-sm);
}
.user-logout:hover { color: var(--danger); background: #FEF2F2; }
/* ══════════════════════════════
MAIN CONTENT AREA
══════════════════════════════ */
.admin-main {
margin-left: var(--sidebar-w);
flex: 1;
display: flex;
flex-direction: column;
min-height: 100vh;
min-width: 0;
}
/* ── Topbar ── */
.topbar {
height: 64px;
background: var(--bg-surface);
border-bottom: 1px solid var(--border-light);
display: flex;
align-items: center;
padding: 0 32px;
gap: 16px;
position: sticky;
top: 0;
z-index: 50;
}
.topbar-title {
font-size: 1.05rem;
font-weight: 600;
color: var(--text-primary);
flex: 1;
}
.topbar-actions {
display: flex;
align-items: center;
gap: 12px;
}
.topbar-time {
font-size: 0.82rem;
font-weight: 500;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
background: #F9FAFB;
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
}
/* ── Content ── */
.admin-content {
flex: 1;
padding: 32px;
overflow-y: auto;
}
/* ══════════════════════════════
DASHBOARD STATS CARDS
══════════════════════════════ */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 20px;
margin-bottom: 32px;
}
.stat-card {
background: var(--bg-surface);
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
padding: 24px;
display: flex;
align-items: flex-start;
gap: 16px;
transition: var(--transition);
box-shadow: var(--shadow-sm);
}
.stat-card:hover { transform: translateY(-3px); box-shadow: var(--shadow-md); border-color: var(--border-hover); }
.stat-icon {
width: 48px; height: 48px;
border-radius: var(--radius-lg);
display: flex; align-items: center; justify-content: center;
font-size: 1.3rem;
flex-shrink: 0;
}
.stat-body { flex: 1; min-width: 0; }
.stat-label { font-size: 0.8rem; color: var(--text-muted); font-weight: 600; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.05em; }
.stat-value { font-size: 1.9rem; font-weight: 800; color: var(--text-primary); line-height: 1; letter-spacing: -0.02em; }
.stat-change { font-size: 0.75rem; color: var(--text-muted); margin-top: 8px; font-weight: 500; }
/* Card color variants (Flat UI style) */
.stat-card.primary .stat-icon { background: #EFF6FF; color: #2563EB; }
.stat-card.success .stat-icon { background: #ECFDF5; color: #059669; }
.stat-card.danger .stat-icon { background: #FEF2F2; color: #DC2626; }
.stat-card.warning .stat-icon { background: #FFFBEB; color: #D97706; }
.stat-card.info .stat-icon { background: #F0F9FF; color: #0284C7; }
.stat-card.violet .stat-icon { background: #F5F3FF; color: #7C3AED; }
/* ══════════════════════════════
DATA TABLE PAGE LAYOUT
══════════════════════════════ */
.page-toolbar {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
flex-wrap: wrap;
}
.page-toolbar .search-box {
flex: 1;
min-width: 250px;
max-width: 360px;
position: relative;
}
.search-box .search-icon {
position: absolute;
left: 14px; top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
font-size: 0.9rem;
}
.search-box input {
width: 100%;
padding: 10px 14px 10px 38px;
background: #FFFFFF;
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: 0.9rem;
outline: none;
transition: var(--transition);
font-family: 'Inter', sans-serif;
box-shadow: var(--shadow-sm);
}
.search-box input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); }
.search-box input::placeholder { color: #9CA3AF; }
/* ══════════════════════════════
MAP PAGE IN ADMIN
══════════════════════════════ */
.admin-map-page .admin-content {
padding: 0;
display: flex;
flex-direction: column;
}
.admin-map-page .topbar {
z-index: 500;
}
#admin-map {
flex: 1;
min-height: calc(100vh - 64px);
}
.map-sidebar-floating {
position: absolute;
top: 16px; left: 16px;
z-index: 400;
width: 280px;
background: #FFFFFF;
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
padding: 16px;
box-shadow: var(--shadow-md);
}
/* ══════════════════════════════
RESPONSIVE
══════════════════════════════ */
@media (max-width: 768px) {
.sidebar { transform: translateX(-100%); }
.sidebar.open { transform: translateX(0); box-shadow: var(--shadow-lg); }
.admin-main { margin-left: 0; }
.stats-grid { grid-template-columns: 1fr; }
}
+215
View File
@@ -0,0 +1,215 @@
/* =========================================================
AUTH PAGE STYLES — login.php
========================================================= */
body.auth-page {
background: #F9FAFB;
min-height: 100vh;
padding: 20px;
overflow-y: auto;
}
/* ── Center Panel (Form) ── */
.auth-center {
width: 100%;
max-width: 440px;
margin: 40px auto;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
background: #FFFFFF;
border-radius: 16px;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.05), 0 8px 10px -6px rgba(0, 0, 0, 0.01);
border: 1px solid #E5E7EB;
}
.auth-form-wrap {
width: 100%;
}
.auth-form-title {
font-size: 1.8rem;
font-weight: 800;
color: #111827;
margin-bottom: 8px;
letter-spacing: -0.03em;
}
.auth-form-subtitle {
color: #6B7280;
font-size: 0.95rem;
margin-bottom: 32px;
}
/* Role selector */
.role-selector {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 32px;
}
.role-card {
position: relative;
cursor: pointer;
}
.role-card input[type="radio"] {
position: absolute;
opacity: 0;
width: 0; height: 0;
}
.role-card-label {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px 12px;
border-radius: 12px;
border: 2px solid #E5E7EB;
background: #FFFFFF;
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
}
.role-card input:checked + .role-card-label {
border-color: #2563EB;
background: #EFF6FF;
}
.role-card-label:hover {
border-color: #D1D5DB;
background: #F9FAFB;
}
.role-card input:checked + .role-card-label:hover {
border-color: #2563EB;
}
.role-card-icon {
font-size: 1.5rem;
line-height: 1;
}
.role-card-name {
font-size: 0.9rem;
font-weight: 600;
color: #111827;
}
.role-card input:checked + .role-card-label .role-card-name {
color: #1D4ED8;
}
.role-card-desc {
font-size: 0.75rem;
color: #6B7280;
}
/* Login form */
.auth-form .form-group { margin-bottom: 20px; }
.auth-form .input-with-icon {
position: relative;
}
.auth-form .input-with-icon .input-icon {
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
color: #9CA3AF;
font-size: 1rem;
pointer-events: none;
}
.auth-form .input-with-icon .form-control {
padding-left: 44px;
height: 46px;
border-radius: 8px;
}
.auth-form .btn-login {
width: 100%;
padding: 14px;
font-size: 1rem;
justify-content: center;
background: #2563EB;
color: #FFFFFF;
border-radius: 8px;
margin-top: 12px;
font-weight: 600;
box-shadow: 0 4px 6px -1px rgba(37, 99, 235, 0.2);
}
.auth-form .btn-login:hover {
background: #1D4ED8;
box-shadow: 0 6px 8px -1px rgba(37, 99, 235, 0.3);
}
.auth-error {
background: #FEF2F2;
border: 1px solid #FECACA;
border-radius: 8px;
padding: 12px 16px;
color: #B91C1C;
font-size: 0.9rem;
margin-bottom: 24px;
display: flex;
align-items: center;
gap: 10px;
}
.auth-hint {
margin-top: 36px;
padding-top: 24px;
border-top: 1px solid #E5E7EB;
}
.auth-hint-title {
font-size: 0.8rem;
font-weight: 700;
color: #6B7280;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 12px;
}
.auth-hint-creds {
display: flex;
flex-direction: column;
gap: 10px;
}
.auth-hint-item {
display: flex;
justify-content: space-between;
align-items: center;
background: #F9FAFB;
border: 1px solid #E5E7EB;
border-radius: 8px;
padding: 10px 14px;
font-size: 0.85rem;
}
.auth-hint-role {
font-weight: 600;
color: #374151;
}
.auth-hint-pass {
font-family: monospace;
color: #4B5563;
background: #E5E7EB;
padding: 4px 10px;
border-radius: 6px;
font-weight: 500;
}
/* Responsive */
@media (max-width: 900px) {
.auth-center { width: 100%; border-left: none; }
}
+248
View File
@@ -0,0 +1,248 @@
/* =========================================================
WEBGIS PROJECT FINAL — Design System (main.css)
========================================================= */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
:root {
/* Colors - Clean Light Theme */
--primary: #2563EB; /* Professional Blue */
--primary-light: #3B82F6;
--primary-dark: #1D4ED8;
--accent: #10B981;
--danger: #EF4444;
--warning: #F59E0B;
--info: #0EA5E9;
/* Backgrounds */
--bg-base: #F9FAFB; /* Very light gray */
--bg-surface: #FFFFFF; /* Pure white */
--bg-elevated: #FFFFFF;
--bg-card: #FFFFFF;
/* Borders */
--border-light: #E5E7EB;
--border-hover: #D1D5DB;
/* Text */
--text-primary: #111827; /* Near black */
--text-secondary: #4B5563; /* Dark gray */
--text-muted: #6B7280; /* Medium gray */
/* Sidebar */
--sidebar-w: 260px;
--sidebar-bg: #FFFFFF;
--sidebar-border: #E5E7EB;
/* Misc */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
--transition: all 0.2s ease-in-out;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body {
font-family: 'Inter', sans-serif;
background: var(--bg-base);
color: var(--text-primary);
font-size: 14px;
line-height: 1.6;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
a { color: var(--primary); text-decoration: none; transition: var(--transition); }
a:hover { color: var(--primary-dark); }
/* ── Scrollbar ── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #CBD5E1; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #94A3B8; }
/* ── Buttons ── */
.btn {
display: inline-flex; align-items: center; gap: 8px;
padding: 8px 16px; border-radius: var(--radius-md); border: 1px solid transparent;
font-size: 0.85rem; font-weight: 500; font-family: 'Inter', sans-serif;
cursor: pointer; transition: var(--transition); white-space: nowrap;
outline: none;
}
.btn-primary { background: var(--primary); color: #fff; box-shadow: var(--shadow-sm); }
.btn-primary:hover { background: var(--primary-dark); }
.btn-success { background: var(--accent); color: #fff; box-shadow: var(--shadow-sm); }
.btn-success:hover { filter: brightness(0.95); }
.btn-danger { background: var(--danger); color: #fff; }
.btn-danger:hover { filter: brightness(0.95); }
.btn-ghost { background: transparent; border: 1px solid var(--border-light); color: var(--text-secondary); }
.btn-ghost:hover { background: #F3F4F6; color: var(--text-primary); border-color: var(--border-hover); }
.btn-sm { padding: 6px 12px; font-size: 0.78rem; }
.btn-icon { padding: 8px; border-radius: var(--radius-sm); }
/* ── Cards ── */
.card {
background: var(--bg-surface);
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
padding: 24px;
box-shadow: var(--shadow-sm);
}
/* ── Form Elements ── */
.form-group { margin-bottom: 16px; }
.form-label { display: block; font-size: 0.82rem; font-weight: 600; color: var(--text-primary); margin-bottom: 6px; }
.form-control {
width: 100%; padding: 10px 14px; border-radius: var(--radius-md);
background: #FFFFFF; border: 1px solid var(--border-light);
color: var(--text-primary); font-size: 0.9rem; font-family: 'Inter', sans-serif;
outline: none; transition: var(--transition); box-shadow: var(--shadow-sm);
}
.form-control:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); }
.form-control::placeholder { color: #9CA3AF; }
select.form-control {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 36px;
}
textarea.form-control { resize: vertical; min-height: 80px; }
/* ── Badges ── */
.badge { display: inline-flex; align-items: center; padding: 4px 10px; border-radius: var(--radius-xl); font-size: 0.75rem; font-weight: 600; }
.badge-primary { background: #EFF6FF; color: #1D4ED8; border: 1px solid #BFDBFE; }
.badge-success { background: #ECFDF5; color: #047857; border: 1px solid #A7F3D0; }
.badge-danger { background: #FEF2F2; color: #B91C1C; border: 1px solid #FECACA; }
.badge-warning { background: #FFFBEB; color: #B45309; border: 1px solid #FDE68A; }
.badge-info { background: #F0F9FF; color: #0369A1; border: 1px solid #BAE6FD; }
/* ── Tables ── */
.table-wrap { overflow-x: auto; border-radius: var(--radius-lg); border: 1px solid var(--border-light); background: var(--bg-surface); box-shadow: var(--shadow-sm); }
.data-table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
.data-table thead th { background: #F9FAFB; padding: 14px 16px; text-align: left; font-size: 0.75rem; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; white-space: nowrap; border-bottom: 1px solid var(--border-light); }
.data-table tbody tr { border-bottom: 1px solid var(--border-light); transition: background 0.15s; }
.data-table tbody tr:last-child { border-bottom: none; }
.data-table tbody tr:hover { background: #F3F4F6; }
.data-table td { padding: 14px 16px; color: var(--text-primary); vertical-align: middle; }
.data-table .actions { display: flex; gap: 6px; }
/* ── Modal ── */
.modal-overlay {
display: none; position: fixed; inset: 0; z-index: 9999;
background: rgba(17, 24, 39, 0.4); backdrop-filter: blur(2px);
align-items: center; justify-content: center;
}
.modal-overlay.active { display: flex; }
.modal {
background: var(--bg-surface); border: 1px solid var(--border-light);
border-radius: var(--radius-lg); padding: 32px;
width: 90%; max-width: 500px; max-height: 90vh; overflow-y: auto;
box-shadow: var(--shadow-lg);
animation: modalIn 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes modalIn { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } }
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
.modal-title { font-size: 1.15rem; font-weight: 700; color: var(--text-primary); }
.modal-close { background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 1.4rem; padding: 4px; border-radius: var(--radius-sm); transition: all 0.15s; }
.modal-close:hover { background: #F3F4F6; color: var(--text-primary); }
.modal-footer { display: flex; gap: 12px; justify-content: flex-end; margin-top: 28px; padding-top: 20px; border-top: 1px solid var(--border-light); }
/* ── Toast ── */
.toast-container { position: fixed; bottom: 24px; right: 24px; z-index: 99999; display: flex; flex-direction: column; gap: 10px; }
.toast { padding: 14px 20px; border-radius: var(--radius-md); font-size: 0.9rem; font-weight: 500; color: #fff; box-shadow: var(--shadow-md); animation: toastIn 0.3s ease; transition: opacity 0.3s; }
.toast.success { background: #10B981; }
.toast.error { background: #EF4444; }
.toast.info { background: #3B82F6; }
@keyframes toastIn { from { opacity: 0; transform: translateX(30px); } to { opacity: 1; transform: translateX(0); } }
/* SPBU realtime snackbar */
.spbu-snackbar {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 99998;
display: grid;
grid-template-columns: 38px minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
width: min(360px, calc(100vw - 32px));
padding: 13px 14px;
color: var(--text-primary);
background: #FFFFFF;
border: 1px solid #A7F3D0;
border-left: 4px solid var(--accent);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
opacity: 0;
pointer-events: none;
transform: translateY(18px);
transition: opacity 0.25s ease, transform 0.25s ease;
}
.spbu-snackbar.show {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.spbu-snackbar-icon {
width: 38px;
height: 38px;
display: grid;
place-items: center;
color: #047857;
background: #ECFDF5;
border: 1px solid #A7F3D0;
border-radius: var(--radius-md);
}
.spbu-snackbar-title {
font-size: 0.84rem;
font-weight: 800;
line-height: 1.2;
}
.spbu-snackbar-meta {
margin-top: 3px;
color: var(--text-muted);
font-size: 0.72rem;
line-height: 1.3;
}
.spbu-snackbar-count {
min-width: 44px;
padding: 5px 8px;
color: #047857;
background: #ECFDF5;
border: 1px solid #A7F3D0;
border-radius: var(--radius-md);
text-align: center;
font-size: 1.05rem;
font-weight: 800;
}
/* ── Page header ── */
.page-header { margin-bottom: 28px; }
.page-header h1 { font-size: 1.6rem; font-weight: 700; color: var(--text-primary); letter-spacing: -0.02em; }
.page-header p { color: var(--text-secondary); margin-top: 6px; font-size: 0.9rem; }
.page-header .breadcrumb { display: flex; gap: 8px; align-items: center; font-size: 0.82rem; color: var(--text-muted); margin-bottom: 12px; }
.page-header .breadcrumb .sep { color: #D1D5DB; }
/* ── Utilities ── */
.text-muted { color: var(--text-muted); }
.text-secondary { color: var(--text-secondary); }
.text-success { color: var(--accent); }
.text-danger { color: var(--danger); }
.text-warning { color: var(--warning); }
.flex { display: flex; }
.flex-center { display: flex; align-items: center; justify-content: center; }
.gap-2 { gap: 8px; }
.gap-3 { gap: 12px; }
.mt-1 { margin-top: 8px; }
.mt-2 { margin-top: 16px; }
.mb-1 { margin-bottom: 8px; }
.mb-2 { margin-bottom: 16px; }
.w-full { width: 100%; }
+765
View File
@@ -0,0 +1,765 @@
/* =========================================================
MAP STYLES - interactive map panels and Leaflet overrides
========================================================= */
.map-page {
height: 100vh;
overflow: hidden;
background: var(--bg-base);
}
.map-wrap {
position: relative;
width: 100%;
height: calc(100vh - 64px);
background: #E5E7EB;
}
#admin-map,
#user-map {
width: 100%;
height: 100%;
background: #E5E7EB;
}
.leaflet-container {
font-family: 'Inter', sans-serif;
background: #E5E7EB;
}
.leaflet-control-attribution {
background: rgba(255, 255, 255, 0.92) !important;
color: var(--text-muted) !important;
border: 1px solid var(--border-light);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-sm);
padding: 3px 8px;
font-size: 0.68rem;
}
.leaflet-control-attribution a {
color: var(--primary-dark) !important;
}
.leaflet-bar {
border: 1px solid var(--border-light) !important;
border-radius: var(--radius-md) !important;
box-shadow: var(--shadow-md) !important;
overflow: hidden;
}
.leaflet-control-zoom a,
.leaflet-draw-toolbar a {
width: 34px !important;
height: 34px !important;
line-height: 34px !important;
background: #FFFFFF !important;
color: var(--text-primary) !important;
border: 0 !important;
border-bottom: 1px solid var(--border-light) !important;
}
.leaflet-control-zoom a:last-child,
.leaflet-draw-toolbar a:last-child {
border-bottom: 0 !important;
}
.leaflet-control-zoom a:hover,
.leaflet-draw-toolbar a:hover {
background: #F3F4F6 !important;
color: var(--primary-dark) !important;
}
/* Layer panel */
.map-sidebar {
position: absolute;
top: 16px;
left: 16px;
z-index: 500;
width: 316px;
max-height: calc(100vh - 112px);
overflow-y: auto;
background: rgba(255, 255, 255, 0.96);
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
padding: 16px;
box-shadow: var(--shadow-lg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.map-panel-header {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 14px;
margin-bottom: 14px;
border-bottom: 1px solid var(--border-light);
}
.map-panel-icon {
width: 40px;
height: 40px;
display: grid;
place-items: center;
flex-shrink: 0;
border-radius: var(--radius-md);
background: #EFF6FF;
color: var(--primary-dark);
border: 1px solid #BFDBFE;
}
.map-panel-header h3 {
margin: 0;
color: var(--text-primary);
font-size: 0.98rem;
line-height: 1.2;
font-weight: 700;
}
.map-panel-header p {
margin: 3px 0 0;
color: var(--text-muted);
font-size: 0.76rem;
line-height: 1.35;
}
.layer-section {
margin-bottom: 16px;
}
.layer-section:last-of-type {
margin-bottom: 12px;
}
.layer-section-title {
margin: 0 0 8px;
color: var(--text-muted);
font-size: 0.68rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.layer-item {
display: grid;
grid-template-columns: 18px 30px minmax(0, 1fr);
align-items: center;
gap: 10px;
min-height: 48px;
padding: 8px 10px;
border: 1px solid transparent;
border-radius: var(--radius-md);
color: var(--text-secondary);
cursor: pointer;
user-select: none;
transition: var(--transition);
}
.layer-item + .layer-item {
margin-top: 4px;
}
.layer-item:hover {
background: #F9FAFB;
border-color: var(--border-light);
color: var(--text-primary);
}
.layer-item input[type="checkbox"] {
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--primary);
cursor: pointer;
}
.layer-color {
--layer-color: var(--primary);
width: 30px;
height: 30px;
display: grid;
place-items: center;
flex-shrink: 0;
color: #FFFFFF;
background: var(--layer-color);
border: 1px solid color-mix(in srgb, var(--layer-color) 72%, #000 28%);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.28);
}
.layer-color i {
font-size: 0.76rem;
}
.layer-point {
border-radius: 50%;
}
.layer-line {
height: 4px;
border-radius: 999px;
align-self: center;
box-shadow: none;
}
.layer-area {
border-radius: 6px;
background: color-mix(in srgb, var(--layer-color) 22%, #FFFFFF 78%);
border-color: var(--layer-color);
}
.layer-hatch {
background:
repeating-linear-gradient(45deg, color-mix(in srgb, var(--layer-color) 78%, #FFFFFF 22%) 0 4px, transparent 4px 8px),
color-mix(in srgb, var(--layer-color) 12%, #FFFFFF 88%);
}
.layer-gradient {
border-radius: 6px;
background: linear-gradient(90deg, #10B981 0%, #F59E0B 50%, #EF4444 100%);
border-color: #D1D5DB;
}
.layer-text {
display: flex;
flex-direction: column;
min-width: 0;
line-height: 1.25;
}
.layer-name {
color: var(--text-primary);
font-size: 0.86rem;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.layer-meta {
margin-top: 2px;
color: var(--text-muted);
font-size: 0.7rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.map-quickstats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
padding-top: 14px;
border-top: 1px solid var(--border-light);
}
.qs-item {
min-width: 0;
padding: 9px 6px;
text-align: center;
background: #F9FAFB;
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
}
.qs-value {
font-size: 1.08rem;
line-height: 1;
font-weight: 800;
}
.qs-label {
margin-top: 3px;
color: var(--text-muted);
font-size: 0.62rem;
font-weight: 700;
}
/* Toolbar */
.map-analysis {
position: absolute;
top: 16px;
left: 50%;
z-index: 500;
display: flex;
align-items: center;
gap: 6px;
max-width: calc(100% - 380px);
padding: 8px;
background: rgba(255, 255, 255, 0.96);
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
transform: translateX(-50%);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.map-analysis button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
min-height: 34px;
padding: 7px 12px;
color: var(--text-secondary);
background: #FFFFFF;
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
font-family: 'Inter', sans-serif;
font-size: 0.78rem;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
box-shadow: var(--shadow-sm);
transition: var(--transition);
}
.map-analysis button:hover {
background: #F3F4F6;
color: var(--text-primary);
border-color: var(--border-hover);
}
.map-analysis button.active {
color: #FFFFFF;
background: var(--primary);
border-color: var(--primary);
}
.map-analysis .map-tool-accent {
color: #047857;
background: #ECFDF5;
border-color: #A7F3D0;
}
.map-analysis .map-tool-accent:hover {
color: #065F46;
background: #D1FAE5;
border-color: #6EE7B7;
}
/* Nearest SPBU panel */
.spbu-panel {
position: absolute;
top: 16px;
right: 16px;
z-index: 500;
width: 310px;
padding: 16px;
background: rgba(255, 255, 255, 0.96);
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.spbu-panel-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
color: var(--text-primary);
font-size: 0.94rem;
font-weight: 800;
}
.spbu-result {
margin-top: 10px;
padding: 12px;
background: #F9FAFB;
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
}
.spbu-result-name {
color: var(--text-primary);
font-size: 0.9rem;
font-weight: 800;
}
.spbu-result-meta {
margin-top: 4px;
color: var(--text-muted);
font-size: 0.75rem;
}
.spbu-result-distance {
margin-top: 8px;
color: var(--primary-dark);
font-size: 1.35rem;
line-height: 1;
font-weight: 800;
}
/* Markers */
.marker-pin {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
color: #FFFFFF;
background: var(--primary);
border: 2px solid #FFFFFF;
border-radius: 50% 50% 50% 0;
box-shadow: 0 6px 12px rgba(17, 24, 39, 0.28);
font-size: 0.9rem;
transform: rotate(-45deg);
}
.marker-pin > * {
transform: rotate(45deg);
}
.div-icon-wrap {
background: transparent;
border: none;
}
/* Popups */
.leaflet-popup-content-wrapper {
color: var(--text-primary) !important;
background: #FFFFFF !important;
border: 1px solid var(--border-light);
border-radius: var(--radius-lg) !important;
box-shadow: var(--shadow-lg);
}
.leaflet-popup-tip {
background: #FFFFFF !important;
}
.leaflet-popup-content {
margin: 13px 16px !important;
font-family: 'Inter', sans-serif;
font-size: 0.85rem;
line-height: 1.5;
}
.popup-title {
margin-bottom: 6px;
color: var(--text-primary);
font-size: 0.98rem;
font-weight: 800;
}
.popup-row {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 5px;
color: var(--text-secondary);
}
.popup-row strong {
color: var(--text-primary);
}
.popup-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.popup-actions .btn {
padding: 5px 10px;
font-size: 0.75rem;
}
/* Legend */
.map-legend {
position: absolute;
right: 16px;
bottom: 16px;
z-index: 500;
min-width: 230px;
padding: 14px;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.96);
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.map-legend-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
color: var(--text-primary);
font-size: 0.82rem;
font-weight: 800;
}
.legend-grid {
display: grid;
gap: 7px;
}
.legend-item {
display: grid;
grid-template-columns: 24px minmax(0, 1fr);
align-items: center;
gap: 8px;
min-height: 22px;
color: var(--text-secondary);
font-size: 0.76rem;
font-weight: 600;
}
.legend-symbol {
--legend-color: var(--primary);
justify-self: center;
background: var(--legend-color);
}
.legend-dot {
width: 12px;
height: 12px;
border: 2px solid #FFFFFF;
border-radius: 50%;
box-shadow: 0 0 0 1px color-mix(in srgb, var(--legend-color) 70%, #000 30%);
}
.legend-line {
width: 22px;
height: 4px;
border-radius: 999px;
}
.legend-area {
width: 16px;
height: 16px;
border: 1px solid var(--legend-color);
border-radius: 4px;
background: color-mix(in srgb, var(--legend-color) 18%, #FFFFFF 82%);
}
.legend-hatch {
width: 16px;
height: 16px;
border: 1px solid var(--legend-color);
border-radius: 4px;
background:
repeating-linear-gradient(45deg, color-mix(in srgb, var(--legend-color) 76%, #FFFFFF 24%) 0 3px, transparent 3px 6px),
color-mix(in srgb, var(--legend-color) 12%, #FFFFFF 88%);
}
/* Modal */
.map-modal {
position: fixed;
inset: 0;
z-index: 9999;
display: none;
align-items: center;
justify-content: center;
background: rgba(17, 24, 39, 0.45);
backdrop-filter: blur(2px);
}
.map-modal.active {
display: flex;
}
.map-modal-content {
width: 90%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
padding: 28px 32px;
background: var(--bg-surface);
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
animation: modalIn 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes modalIn {
from {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* Loading and toast */
.map-loading {
position: absolute;
inset: 0;
z-index: 600;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--text-primary);
background: rgba(249, 250, 251, 0.72);
backdrop-filter: blur(3px);
font-size: 0.95rem;
font-weight: 700;
}
.spinner {
width: 28px;
height: 28px;
border: 3px solid rgba(37, 99, 235, 0.16);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.toast-container {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 99999;
display: flex;
flex-direction: column;
gap: 10px;
}
.toast {
max-width: 320px;
padding: 12px 20px;
color: #FFFFFF;
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
font-size: 0.88rem;
font-weight: 600;
animation: toastIn 0.3s ease;
transition: opacity 0.3s;
}
.toast.success { background: #10B981; }
.toast.error { background: #EF4444; }
.toast.info { background: #3B82F6; }
@keyframes toastIn {
from {
opacity: 0;
transform: translateX(30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.read-only-badge {
position: absolute;
top: 16px;
right: 16px;
z-index: 500;
display: flex;
align-items: center;
gap: 7px;
padding: 8px 13px;
color: #047857;
background: #ECFDF5;
border: 1px solid #A7F3D0;
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
font-size: 0.78rem;
font-weight: 800;
}
/* Responsive */
@media (max-width: 1180px) {
.map-analysis {
left: auto;
right: 16px;
max-width: calc(100% - 364px);
overflow-x: auto;
transform: none;
}
}
@media (max-width: 900px) {
.map-sidebar {
width: 300px;
}
.map-analysis {
top: auto;
right: 16px;
bottom: 16px;
left: 16px;
max-width: none;
justify-content: flex-start;
}
.map-legend {
right: 16px;
bottom: 78px;
}
}
@media (max-width: 768px) {
.map-wrap {
height: calc(100vh - 64px);
}
.map-sidebar {
top: 8px;
left: 8px;
width: calc(100% - 16px);
max-height: 46vh;
padding: 12px;
}
.map-panel-header {
margin-bottom: 10px;
padding-bottom: 10px;
}
.layer-item {
min-height: 44px;
}
.map-quickstats {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.map-analysis {
right: 8px;
bottom: 8px;
left: 8px;
padding: 6px;
overflow-x: auto;
}
.map-analysis button {
min-height: 32px;
padding: 6px 10px;
font-size: 0.74rem;
}
.spbu-panel {
display: none;
}
.map-legend {
right: 8px;
bottom: 66px;
min-width: 210px;
max-width: calc(100% - 16px);
}
.read-only-badge {
top: auto;
right: 8px;
bottom: 66px;
}
}
+491
View File
@@ -0,0 +1,491 @@
/* =========================================================
admin-map.js — Full WebGIS Map (admin mode: edit & draw)
========================================================= */
const API = APP_BASE + '/api';
const STAT_API = APP_BASE + '/api/statistik.php';
const state = {
map: null,
layers: {
spbu: null, jalan: null, kavling: null,
kawasan: null, rumah: null, warga: null,
choropleth: null, blankSpot: null, spbuRoute: null
},
activeDraw: null, // 'spbu' | 'jalan' | 'kavling' | 'kawasan' | 'rumah' | 'warga'
drawHandlers: {},
pendingGeometry: null
};
const COLORS = {
spbu: '#F59E0B',
spbu24: '#10B981',
jalan: '#3B82F6',
kavling: '#8B5CF6',
kawasan: '#EF4444',
rumah: '#A855F7',
warga: '#EF4444',
blankSpot: '#F97316'
};
/* ── Icon factory ── */
function makeIcon(emoji, bgColor, size = 34) {
return L.divIcon({
className: 'div-icon-wrap',
html: `<div class="marker-pin" style="background:${bgColor};width:${size}px;height:${size}px;"><span>${emoji}</span></div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size],
popupAnchor: [0, -size]
});
}
const ICONS = {
spbu24: makeIcon('⛽', COLORS.spbu24),
spbu: makeIcon('⛽', COLORS.spbu),
rumah: makeIcon('🕌', COLORS.rumah),
warga: makeIcon('👤', COLORS.warga),
blankSpot: makeIcon('⚠️', COLORS.blankSpot, 36)
};
/* ── Toast ── */
function toast(msg, type = 'success') {
const c = document.getElementById('toastContainer');
const t = document.createElement('div');
t.className = `toast ${type}`;
t.innerHTML = `<i class="fas fa-${type === 'success' ? 'check-circle' : (type === 'error' ? 'exclamation-circle' : 'info-circle')}" style="margin-right:8px;"></i>${msg}`;
c.appendChild(t);
setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 400); }, 3000);
}
/* ── Initialize map ── */
function initMap() {
state.map = L.map('admin-map', { zoomControl: false })
.setView([-0.0583, 109.3448], 13);
L.control.zoom({ position: 'bottomright' }).addTo(state.map);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap, © CARTO',
subdomains: 'abcd',
maxZoom: 20
}).addTo(state.map);
}
/* ── Fetch GeoJSON ── */
async function fetchLayer(path) {
const r = await fetch(`${API}/${path}`);
const j = await r.json();
return j.status === 'success' ? j.data : { type: 'FeatureCollection', features: [] };
}
function formatKm(value) {
return Number(value || 0).toLocaleString('id-ID', {
minimumFractionDigits: 0,
maximumFractionDigits: 2
});
}
/* ── Render: SPBU (Point) ── */
function renderSpbu(data) {
if (state.layers.spbu) state.map.removeLayer(state.layers.spbu);
state.layers.spbu = L.geoJSON(data, {
pointToLayer: (f, ll) => L.marker(ll, { icon: f.properties.buka_24_jam ? ICONS.spbu24 : ICONS.spbu }),
onEachFeature: (f, l) => bindPopup(l, 'spbu', f.properties)
});
if (document.getElementById('ly-spbu')?.checked) state.layers.spbu.addTo(state.map);
}
/* ── Render: Jalan (LineString) ── */
function renderJalan(data) {
if (state.layers.jalan) state.map.removeLayer(state.layers.jalan);
state.layers.jalan = L.geoJSON(data, {
style: { color: COLORS.jalan, weight: 5, opacity: 0.85 },
onEachFeature: (f, l) => bindPopup(l, 'jalan', f.properties)
});
if (document.getElementById('ly-jalan')?.checked) state.layers.jalan.addTo(state.map);
}
/* ── Render: Kavling (Polygon) ── */
function renderKavling(data) {
if (state.layers.kavling) state.map.removeLayer(state.layers.kavling);
state.layers.kavling = L.geoJSON(data, {
style: { color: COLORS.kavling, fillColor: COLORS.kavling, fillOpacity: 0.3, weight: 2 },
onEachFeature: (f, l) => bindPopup(l, 'kavling', f.properties)
});
if (document.getElementById('ly-kavling')?.checked) state.layers.kavling.addTo(state.map);
}
/* ── Render: Kawasan Kumuh (Polygon) ── */
function renderKawasan(data) {
if (state.layers.kawasan) state.map.removeLayer(state.layers.kawasan);
state.layers.kawasan = L.geoJSON(data, {
style: { color: COLORS.kawasan, fillColor: COLORS.kawasan, fillOpacity: 0.25, weight: 2, dashArray: '6 4' },
onEachFeature: (f, l) => bindPopup(l, 'kawasan', f.properties)
});
if (document.getElementById('ly-kawasan')?.checked) state.layers.kawasan.addTo(state.map);
}
/* ── Render: Rumah Ibadah (Point) ── */
function renderRumah(data) {
if (state.layers.rumah) state.map.removeLayer(state.layers.rumah);
state.layers.rumah = L.featureGroup();
(data.features || []).forEach((f) => {
if (!f.geometry || f.geometry.type !== 'Point') return;
const props = f.properties || {};
const ll = L.latLng(f.geometry.coordinates[1], f.geometry.coordinates[0]);
const circle = L.circle(ll, {
radius: Number(props.radius_bantuan_meter || 1000),
color: COLORS.rumah,
fillColor: COLORS.rumah,
fillOpacity: 0.08,
opacity: 0.55,
weight: 2
});
const marker = L.marker(ll, { icon: ICONS.rumah });
bindPopup(circle, 'rumah', props);
bindPopup(marker, 'rumah', props);
state.layers.rumah.addLayer(circle);
state.layers.rumah.addLayer(marker);
});
if (document.getElementById('ly-rumah')?.checked) state.layers.rumah.addTo(state.map);
}
/* ── Render: Warga Miskin (Point) ── */
function renderWarga(data) {
if (state.layers.warga) state.map.removeLayer(state.layers.warga);
state.layers.warga = L.geoJSON(data, {
pointToLayer: (f, ll) => L.marker(ll, { icon: ICONS.warga }),
onEachFeature: (f, l) => bindPopup(l, 'warga', f.properties)
});
if (document.getElementById('ly-warga')?.checked) state.layers.warga.addTo(state.map);
}
/* ── Render: Choropleth (kawasan + count warga) ── */
function renderChoropleth(data) {
if (state.layers.choropleth) state.map.removeLayer(state.layers.choropleth);
state.layers.choropleth = L.geoJSON(data, {
style: (f) => {
const c = f.properties.jumlah_warga;
let color = '#10B981';
if (c > 0) color = '#F59E0B';
if (c > 3) color = '#EF4444';
return { color, fillColor: color, fillOpacity: 0.35, weight: 3 };
},
onEachFeature: (f, l) => {
const c = f.properties.jumlah_warga;
l.bindPopup(`<div class="popup-title">${f.properties.nama_kawasan}</div>
<div class="popup-row">Jumlah Warga: <strong>${c}</strong></div>
<div class="popup-row">Total Tanggungan: <strong>${f.properties.total_tanggungan}</strong></div>`);
}
});
if (document.getElementById('ly-choropleth')?.checked) state.layers.choropleth.addTo(state.map);
}
/* ── Render: Blank Spot (warga yang jauh dari rumah ibadah) ── */
function renderBlankSpot(data) {
if (state.layers.blankSpot) state.map.removeLayer(state.layers.blankSpot);
state.layers.blankSpot = L.geoJSON(data, {
pointToLayer: (f, ll) => L.marker(ll, { icon: ICONS.blankSpot }),
onEachFeature: (f, l) => {
l.bindPopup(`<div class="popup-title">⚠️ ${f.properties.nama_kk}</div>
<div class="popup-row">Jarak ke RI terdekat: <strong>${formatKm(f.properties.jarak_km)} km</strong></div>
<div class="popup-row">Radius bantuan: <strong>${formatKm(f.properties.radius_km)} km</strong></div>
<div class="popup-row">Di luar radius: <strong>${formatKm(f.properties.selisih_km)} km</strong></div>
<div class="popup-row">Rumah ibadah terdekat: <strong>${f.properties.rumah_ibadah_terdekat || '-'}</strong></div>
<div class="popup-row" style="color:#F97316">Blank spot di luar radius bantuan</div>`);
}
});
if (document.getElementById('ly-blankspot')?.checked) state.layers.blankSpot.addTo(state.map);
}
/* ── Popup binding helper ── */
function bindPopup(layer, type, props) {
let html = `<div class="popup-title">${props.nama || props.nama_pemilik || props.nama_kk || props.nama_kawasan || 'Tanpa Nama'}</div>`;
if (type === 'spbu') {
html += `<div class="popup-row">24 Jam: <strong style="color:${props.buka_24_jam ? '#10B981' : '#EF4444'}">${props.buka_24_jam ? 'Ya' : 'Tidak'}</strong></div>`;
if (props.deskripsi) html += `<div class="popup-row">${props.deskripsi}</div>`;
} else if (type === 'jalan') {
html += `<div class="popup-row">Jenis: <strong>${props.jenis_jalan || '-'}</strong></div>`;
} else if (type === 'kavling') {
html += `<div class="popup-row">Status: <strong>${props.status_kepemilikan || '-'}</strong></div>`;
html += `<div class="popup-row">Luas: <strong>${props.luas ? props.luas + ' m²' : '-'}</strong></div>`;
} else if (type === 'rumah') {
html += `<div class="popup-row">Agama: <strong>${props.agama || '-'}</strong></div>`;
html += `<div class="popup-row">Radius Bantuan: <strong>${formatKm(props.radius_bantuan_km)} km</strong></div>`;
} else if (type === 'warga') {
html += `<div class="popup-row">Penghasilan: <strong>Rp ${Number(props.penghasilan || 0).toLocaleString('id-ID')}</strong></div>`;
html += `<div class="popup-row">Tanggungan: <strong>${props.jumlah_tanggungan || 0} org</strong></div>`;
}
html += `<div class="popup-actions">
<button class="btn btn-danger btn-sm" onclick="deleteFeature('${type}', ${props.id})"><i class="fas fa-trash"></i> Hapus</button>
<button class="btn btn-ghost btn-sm" onclick="window.location.href=APP_BASE + '/admin/data_${type === 'kavling' ? 'kavling' : (type === 'kawasan' ? 'kawasan' : (type === 'rumah' ? 'rumah' : (type === 'warga' ? 'warga' : type)))}.php'"><i class="fas fa-edit"></i> Kelola</button>
</div>`;
layer.bindPopup(html);
}
/* ── Delete from map ── */
window.deleteFeature = async function (type, id) {
if (!confirm('Yakin hapus data ini?')) return;
const ep = { spbu: 'spbu.php', jalan: 'jalan.php', kavling: 'kavling.php', kawasan: 'kawasan_kumuh.php', rumah: 'rumah_ibadah.php', warga: 'warga_miskin.php' }[type];
if (!ep) return;
const r = await fetch(`${API}/${ep}?id=${id}`, { method: 'DELETE' });
const j = await r.json();
if (j.status === 'success') { toast('Data dihapus!'); loadAll(); }
else toast(j.message, 'error');
};
/* ── Layer toggling ── */
function bindLayerToggles() {
const map = {
'ly-spbu': 'spbu', 'ly-jalan': 'jalan', 'ly-kavling': 'kavling',
'ly-kawasan': 'kawasan', 'ly-rumah': 'rumah', 'ly-warga': 'warga',
'ly-choropleth': 'choropleth', 'ly-blankspot': 'blankSpot'
};
Object.entries(map).forEach(([id, key]) => {
document.getElementById(id)?.addEventListener('change', (e) => {
const layer = state.layers[key];
if (!layer) return;
if (e.target.checked) state.map.addLayer(layer);
else state.map.removeLayer(layer);
});
});
}
/* ── Drawing tool activation ── */
function activateDraw(type) {
if (state.activeDraw) deactivateDraw();
state.activeDraw = type;
// Update UI button state
document.querySelectorAll('[data-draw]').forEach(b => b.classList.remove('active'));
document.querySelector(`[data-draw="${type}"]`)?.classList.add('active');
// Create appropriate L.Draw handler
let handler = null;
if (type === 'spbu' || type === 'rumah' || type === 'warga') {
handler = new L.Draw.Marker(state.map);
} else if (type === 'jalan') {
handler = new L.Draw.Polyline(state.map, { shapeOptions: { color: COLORS.jalan, weight: 5 } });
} else if (type === 'kavling' || type === 'kawasan') {
handler = new L.Draw.Polygon(state.map, { shapeOptions: { color: type === 'kavling' ? COLORS.kavling : COLORS.kawasan } });
}
state.drawHandlers[type] = handler;
handler.enable();
}
/* ── Deactivate drawing ── */
function deactivateDraw() {
if (state.activeDraw && state.drawHandlers[state.activeDraw]) {
state.drawHandlers[state.activeDraw].disable();
}
state.activeDraw = null;
document.querySelectorAll('[data-draw]').forEach(b => b.classList.remove('active'));
}
/* ── Draw event listener ── */
function bindDrawEvent() {
state.map.on(L.Draw.Event.CREATED, (e) => {
const geom = e.layer.toGeoJSON().geometry;
state.pendingGeometry = geom;
openFormModal(state.activeDraw, geom);
});
state.map.on(L.Draw.Event.DRAWSTOP, () => {
// User cancelled drawing
document.querySelectorAll('[data-draw]').forEach(b => b.classList.remove('active'));
state.activeDraw = null;
});
}
/* ── Form modal untuk input data baru ── */
function openFormModal(type, geometry) {
const modal = document.getElementById('formModal');
const title = document.getElementById('formModalTitle');
const body = document.getElementById('formModalBody');
const titles = { spbu: '⛽ Tambah SPBU', jalan: '🛣️ Tambah Jalan', kavling: '🏘️ Tambah Kavling',
kawasan: '⚠️ Tambah Kawasan Kumuh', rumah: '🕌 Tambah Rumah Ibadah', warga: '👤 Tambah Warga Miskin' };
title.textContent = titles[type] || 'Tambah Data';
let formHtml = '';
if (type === 'spbu') {
formHtml = `
<div class="form-group"><label class="form-label">Nama SPBU *</label><input type="text" id="f-nama" class="form-control" required></div>
<div class="form-group"><label class="form-label">Deskripsi</label><textarea id="f-deskripsi" class="form-control"></textarea></div>
<div class="form-group"><label class="form-label" style="display:flex;align-items:center;gap:8px;cursor:pointer;"><input type="checkbox" id="f-24" style="accent-color:var(--primary);"> Buka 24 Jam</label></div>`;
} else if (type === 'jalan') {
formHtml = `
<div class="form-group"><label class="form-label">Nama Jalan *</label><input type="text" id="f-nama" class="form-control" required></div>
<div class="form-group"><label class="form-label">Jenis Jalan</label>
<select id="f-jenis" class="form-control">
<option>Jalan Arteri</option><option>Jalan Kolektor</option>
<option selected>Jalan Lokal</option><option>Jalan Lingkungan</option>
</select></div>`;
} else if (type === 'kavling') {
formHtml = `
<div class="form-group"><label class="form-label">Nama Pemilik *</label><input type="text" id="f-nama" class="form-control" required></div>
<div class="form-group"><label class="form-label">Status Kepemilikan *</label>
<select id="f-status" class="form-control">
<option value="SHM">SHM</option><option value="HGB">HGB</option>
<option value="HGU">HGU</option><option value="HP">HP</option>
</select></div>
<div class="form-group"><label class="form-label">Luas (m²)</label><input type="number" id="f-luas" class="form-control" min="0" step="0.01"></div>`;
} else if (type === 'kawasan') {
formHtml = `
<div class="form-group"><label class="form-label">Nama Kawasan *</label><input type="text" id="f-nama" class="form-control" required></div>`;
} else if (type === 'rumah') {
formHtml = `
<div class="form-group"><label class="form-label">Nama *</label><input type="text" id="f-nama" class="form-control" required></div>
<div class="form-group"><label class="form-label">Agama *</label>
<select id="f-agama" class="form-control">
<option>Islam</option><option>Kristen</option><option>Katolik</option>
<option>Hindu</option><option>Buddha</option><option>Konghucu</option>
</select></div>
<div class="form-group"><label class="form-label">Radius Bantuan (meter) *</label><input type="number" id="f-radius" class="form-control" min="100" max="10000" step="50" value="1000" required></div>`;
} else if (type === 'warga') {
formHtml = `
<div class="form-group"><label class="form-label">Nama Kepala Keluarga *</label><input type="text" id="f-nama" class="form-control" required></div>
<div class="form-group"><label class="form-label">Penghasilan/Bln (Rp)</label><input type="number" id="f-penghasilan" class="form-control" min="0"></div>
<div class="form-group"><label class="form-label">Jumlah Tanggungan</label><input type="number" id="f-tanggungan" class="form-control" min="0" max="20"></div>`;
}
body.innerHTML = formHtml + `
<div class="modal-footer" style="margin-top:20px;display:flex;gap:10px;justify-content:flex-end;">
<button class="btn btn-ghost" onclick="closeFormModal()">Batal</button>
<button class="btn btn-primary" id="btn-simpan" onclick="saveFeature('${type}')"><i class="fas fa-save"></i> Simpan</button>
</div>`;
modal.classList.add('active');
}
window.closeFormModal = function () {
document.getElementById('formModal').classList.remove('active');
state.pendingGeometry = null;
deactivateDraw();
};
/* ── Save feature to backend ── */
window.saveFeature = async function (type) {
const ep = { spbu: 'spbu.php', jalan: 'jalan.php', kavling: 'kavling.php', kawasan: 'kawasan_kumuh.php', rumah: 'rumah_ibadah.php', warga: 'warga_miskin.php' }[type];
if (!ep) return;
let payload = { geometry: state.pendingGeometry };
const v = (id) => document.getElementById(id)?.value;
if (type === 'spbu') {
if (!v('f-nama')) return toast('Nama wajib!', 'error');
payload = { ...payload, nama: v('f-nama'), deskripsi: v('f-deskripsi') || '', buka_24_jam: document.getElementById('f-24').checked ? 1 : 0 };
} else if (type === 'jalan') {
if (!v('f-nama')) return toast('Nama wajib!', 'error');
payload = { ...payload, nama: v('f-nama'), jenis_jalan: v('f-jenis') };
} else if (type === 'kavling') {
if (!v('f-nama')) return toast('Nama wajib!', 'error');
payload = { ...payload, nama_pemilik: v('f-nama'), status_kepemilikan: v('f-status'), luas: parseFloat(v('f-luas')) || 0 };
} else if (type === 'kawasan') {
if (!v('f-nama')) return toast('Nama wajib!', 'error');
payload = { ...payload, nama_kawasan: v('f-nama') };
} else if (type === 'rumah') {
if (!v('f-nama')) return toast('Nama wajib!', 'error');
payload = { ...payload, nama: v('f-nama'), agama: v('f-agama'), radius_bantuan_meter: Math.max(100, Math.min(10000, parseInt(v('f-radius'), 10) || 1000)) };
} else if (type === 'warga') {
if (!v('f-nama')) return toast('Nama wajib!', 'error');
payload = { ...payload, nama_kk: v('f-nama'), penghasilan: parseFloat(v('f-penghasilan')) || 0, jumlah_tanggungan: parseInt(v('f-tanggungan')) || 0 };
}
try {
const r = await fetch(`${API}/${ep}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const j = await r.json();
if (j.status === 'success') { toast('Data berhasil disimpan!'); closeFormModal(); loadAll(); }
else toast(j.message || 'Gagal menyimpan', 'error');
} catch (e) { toast('Error: ' + e.message, 'error'); }
};
/* ── Analysis: SPBU Terdekat ── */
async function findNearestSpbu() {
const center = state.map.getCenter();
document.getElementById('spbuPanelResult').innerHTML = '<div class="spinner"></div> Mencari...';
document.getElementById('spbuPanel').style.display = 'block';
try {
const r = await fetch(`${API}/spbu_terdekat.php?lat=${center.lat}&lng=${center.lng}`);
const j = await r.json();
if (j.status === 'success' && j.data) {
const p = j.data.properties;
const coords = j.data.geometry.coordinates;
const html = `<div class="spbu-result">
<div class="spbu-result-name">⛽ ${p.nama}</div>
<div class="spbu-result-meta">${p.buka_24_jam ? '🟢 24 Jam' : '🟡 Terbatas'}</div>
<div class="spbu-result-distance">${p.jarak_km} km</div>
</div>`;
document.getElementById('spbuPanelResult').innerHTML = html;
// Show marker
if (state.layers.spbuRoute) state.map.removeLayer(state.layers.spbuRoute);
state.layers.spbuRoute = L.marker([coords[1], coords[0]], { icon: ICONS.spbu }).addTo(state.map)
.bindPopup(`<b>${p.nama}</b><br>${p.jarak_km} km dari pusat peta`).openPopup();
} else {
document.getElementById('spbuPanelResult').innerHTML = `<div class="spbu-result">${j.message || 'Tidak ada SPBU'}</div>`;
}
} catch (e) {
document.getElementById('spbuPanelResult').innerHTML = `<div class="spbu-result">Error: ${e.message}</div>`;
}
}
/* ── Load all layers ── */
async function loadAll() {
document.getElementById('loadingOverlay').style.display = 'flex';
try {
const [spbu, jalan, kavling, kawasan, rumah, warga, stat, blank] = await Promise.all([
fetchLayer('spbu.php'),
fetchLayer('jalan.php'),
fetchLayer('kavling.php'),
fetchLayer('kawasan_kumuh.php'),
fetchLayer('rumah_ibadah.php'),
fetchLayer('warga_miskin.php'),
fetch(STAT_API).then(r => r.json()).then(j => j.data).catch(() => null),
fetchLayer('blank_spot.php')
]);
renderSpbu(spbu); renderJalan(jalan); renderKavling(kavling);
renderKawasan(kawasan); renderRumah(rumah); renderWarga(warga);
if (stat && stat.choropleth) renderChoropleth(stat.choropleth);
if (blank) renderBlankSpot(blank);
// Update quick stats
if (stat && stat.counts) {
document.getElementById('qs-spbu').textContent = stat.counts.total_spbu;
document.getElementById('qs-jalan').textContent = stat.counts.total_jalan;
document.getElementById('qs-kavling').textContent = stat.counts.total_kavling;
document.getElementById('qs-warga').textContent = stat.counts.total_warga_miskin;
}
} catch (e) {
toast('Gagal load data: ' + e.message, 'error');
} finally {
document.getElementById('loadingOverlay').style.display = 'none';
}
}
/* ── Init ── */
document.addEventListener('DOMContentLoaded', () => {
initMap();
loadAll();
bindLayerToggles();
bindDrawEvent();
document.querySelectorAll('[data-draw]').forEach(btn => {
btn.addEventListener('click', () => {
const type = btn.dataset.draw;
if (state.activeDraw === type) deactivateDraw();
else activateDraw(type);
});
});
document.getElementById('btn-find-spbu')?.addEventListener('click', findNearestSpbu);
document.getElementById('btn-close-spbu')?.addEventListener('click', () => {
document.getElementById('spbuPanel').style.display = 'none';
if (state.layers.spbuRoute) { state.map.removeLayer(state.layers.spbuRoute); state.layers.spbuRoute = null; }
});
});
+358
View File
@@ -0,0 +1,358 @@
/* =========================================================
user-map.js — Read-only WebGIS Map (user mode)
========================================================= */
const API = APP_BASE + '/api';
const STAT_API = APP_BASE + '/api/statistik.php';
const state = {
map: null,
layers: {
spbu: null, jalan: null, kavling: null,
kawasan: null, rumah: null, warga: null,
choropleth: null, blankSpot: null, spbuRoute: null
}
};
const COLORS = {
spbu: '#F59E0B',
spbu24: '#10B981',
jalan: '#3B82F6',
kavling: '#8B5CF6',
kawasan: '#EF4444',
rumah: '#A855F7',
warga: '#EF4444',
blankSpot: '#F97316'
};
function makeIcon(emoji, bgColor, size = 34) {
return L.divIcon({
className: 'div-icon-wrap',
html: `<div class="marker-pin" style="background:${bgColor};width:${size}px;height:${size}px;"><span>${emoji}</span></div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size],
popupAnchor: [0, -size]
});
}
const ICONS = {
spbu24: makeIcon('⛽', COLORS.spbu24),
spbu: makeIcon('⛽', COLORS.spbu),
rumah: makeIcon('🕌', COLORS.rumah),
warga: makeIcon('👤', COLORS.warga),
blankSpot: makeIcon('⚠️', COLORS.blankSpot, 36)
};
function toast(msg, type = 'success') {
const c = document.getElementById('toastContainer');
const t = document.createElement('div');
t.className = `toast ${type}`;
t.innerHTML = `<i class="fas fa-${type === 'success' ? 'check-circle' : 'info-circle'}" style="margin-right:8px;"></i>${msg}`;
c.appendChild(t);
setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 400); }, 3000);
}
function initMap() {
state.map = L.map('user-map', { zoomControl: false })
.setView([-0.0583, 109.3448], 13);
L.control.zoom({ position: 'bottomright' }).addTo(state.map);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap, © CARTO',
subdomains: 'abcd',
maxZoom: 20
}).addTo(state.map);
}
async function fetchLayer(path) {
const r = await fetch(`${API}/${path}`);
const j = await r.json();
return j.status === 'success' ? j.data : { type: 'FeatureCollection', features: [] };
}
function formatKm(value) {
return Number(value || 0).toLocaleString('id-ID', {
minimumFractionDigits: 0,
maximumFractionDigits: 2
});
}
function bindReadOnlyPopup(layer, type, props) {
let html = `<div class="popup-title">${props.nama || props.nama_pemilik || props.nama_kk || props.nama_kawasan || 'Tanpa Nama'}</div>`;
if (type === 'spbu') {
html += `<div class="popup-row">24 Jam: <strong style="color:${props.buka_24_jam ? '#10B981' : '#EF4444'}">${props.buka_24_jam ? 'Ya' : 'Tidak'}</strong></div>`;
if (props.deskripsi) html += `<div class="popup-row">${props.deskripsi}</div>`;
html += `<div class="popup-row">Rating: <strong>${props.avg_rating > 0 ? '⭐ ' + props.avg_rating + ' (' + props.total_ulasan + ' ulasan)' : 'Belum ada ulasan'}</strong></div>`;
html += `<button class="btn btn-primary btn-sm" style="margin-top:10px;width:100%;" onclick="bukaReview('spbu', ${props.id}, '${props.nama}')">Beri Ulasan</button>`;
} else if (type === 'jalan') {
html += `<div class="popup-row">Jenis: <strong>${props.jenis_jalan || '-'}</strong></div>`;
} else if (type === 'kavling') {
html += `<div class="popup-row">Status: <strong>${props.status_kepemilikan || '-'}</strong></div>`;
html += `<div class="popup-row">Luas: <strong>${props.luas ? props.luas + ' m²' : '-'}</strong></div>`;
} else if (type === 'rumah') {
html += `<div class="popup-row">Agama: <strong>${props.agama || '-'}</strong></div>`;
html += `<div class="popup-row">Radius Bantuan: <strong>${formatKm(props.radius_bantuan_km)} km</strong></div>`;
html += `<div class="popup-row">Rating: <strong>${props.avg_rating > 0 ? '⭐ ' + props.avg_rating + ' (' + props.total_ulasan + ' ulasan)' : 'Belum ada ulasan'}</strong></div>`;
html += `<button class="btn btn-primary btn-sm" style="margin-top:10px;width:100%;" onclick="bukaReview('rumah_ibadah', ${props.id}, '${props.nama}')">Beri Ulasan</button>`;
} else if (type === 'warga') {
html += `<div class="popup-row">Penghasilan: <strong>Rp ${Number(props.penghasilan || 0).toLocaleString('id-ID')}</strong></div>`;
html += `<div class="popup-row">Tanggungan: <strong>${props.jumlah_tanggungan || 0} org</strong></div>`;
}
layer.bindPopup(html);
}
// Ulasan Modal Logic
window.bukaReview = function(tipe, id, nama) {
let m = document.getElementById('modalReview');
if(!m) {
m = document.createElement('div');
m.id = 'modalReview';
m.className = 'modal-overlay';
m.innerHTML = `
<div class="modal">
<div class="modal-header">
<span class="modal-title">⭐ Beri Ulasan</span>
<button class="modal-close" onclick="document.getElementById('modalReview').style.display='none'">×</button>
</div>
<div style="padding:15px;">
<h4 id="revNama" style="margin-top:0; color:var(--text-primary);"></h4>
<input type="hidden" id="revTipe"><input type="hidden" id="revId">
<div class="form-group">
<label class="form-label">Rating (1-5)</label>
<select id="revRating" class="form-control">
<option value="5">⭐⭐⭐⭐⭐ Sangat Baik</option>
<option value="4">⭐⭐⭐⭐ Baik</option>
<option value="3">⭐⭐⭐ Cukup</option>
<option value="2">⭐⭐ Buruk</option>
<option value="1">⭐ Sangat Buruk</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Komentar</label>
<textarea id="revKomen" class="form-control" rows="3" placeholder="Tuliskan pengalaman Anda..."></textarea>
</div>
<button class="btn btn-primary" style="width:100%" onclick="submitReview()">Kirim Ulasan</button>
</div>
</div>`;
document.body.appendChild(m);
}
document.getElementById('revTipe').value = tipe;
document.getElementById('revId').value = id;
document.getElementById('revNama').innerText = "Mengulas: " + nama;
document.getElementById('revRating').value = "5";
document.getElementById('revKomen').value = "";
m.style.display = 'flex';
};
window.submitReview = async function() {
const tipe = document.getElementById('revTipe').value;
const id = document.getElementById('revId').value;
const rating = document.getElementById('revRating').value;
const komen = document.getElementById('revKomen').value;
const res = await fetch(APP_BASE + '/api/ulasan.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fasilitas_tipe: tipe, fasilitas_id: id, rating: rating, komentar: komen })
});
const j = await res.json();
if(j.status === 'success') {
toast('Ulasan berhasil dikirim!');
document.getElementById('modalReview').style.display = 'none';
loadAll(); // reload data
} else {
toast(j.message, 'error');
}
};
function renderSpbu(data) {
if (state.layers.spbu) state.map.removeLayer(state.layers.spbu);
state.layers.spbu = L.geoJSON(data, {
pointToLayer: (f, ll) => L.marker(ll, { icon: f.properties.buka_24_jam ? ICONS.spbu24 : ICONS.spbu }),
onEachFeature: (f, l) => bindReadOnlyPopup(l, 'spbu', f.properties)
});
if (document.getElementById('ly-spbu')?.checked) state.layers.spbu.addTo(state.map);
}
function renderJalan(data) {
if (state.layers.jalan) state.map.removeLayer(state.layers.jalan);
state.layers.jalan = L.geoJSON(data, {
style: { color: COLORS.jalan, weight: 5, opacity: 0.85 },
onEachFeature: (f, l) => bindReadOnlyPopup(l, 'jalan', f.properties)
});
if (document.getElementById('ly-jalan')?.checked) state.layers.jalan.addTo(state.map);
}
function renderKavling(data) {
if (state.layers.kavling) state.map.removeLayer(state.layers.kavling);
state.layers.kavling = L.geoJSON(data, {
style: { color: COLORS.kavling, fillColor: COLORS.kavling, fillOpacity: 0.3, weight: 2 },
onEachFeature: (f, l) => bindReadOnlyPopup(l, 'kavling', f.properties)
});
if (document.getElementById('ly-kavling')?.checked) state.layers.kavling.addTo(state.map);
}
function renderKawasan(data) {
if (state.layers.kawasan) state.map.removeLayer(state.layers.kawasan);
state.layers.kawasan = L.geoJSON(data, {
style: { color: COLORS.kawasan, fillColor: COLORS.kawasan, fillOpacity: 0.25, weight: 2, dashArray: '6 4' },
onEachFeature: (f, l) => bindReadOnlyPopup(l, 'kawasan', f.properties)
});
if (document.getElementById('ly-kawasan')?.checked) state.layers.kawasan.addTo(state.map);
}
function renderRumah(data) {
if (state.layers.rumah) state.map.removeLayer(state.layers.rumah);
state.layers.rumah = L.featureGroup();
(data.features || []).forEach((f) => {
if (!f.geometry || f.geometry.type !== 'Point') return;
const props = f.properties || {};
const ll = L.latLng(f.geometry.coordinates[1], f.geometry.coordinates[0]);
const circle = L.circle(ll, {
radius: Number(props.radius_bantuan_meter || 1000),
color: COLORS.rumah,
fillColor: COLORS.rumah,
fillOpacity: 0.08,
opacity: 0.55,
weight: 2
});
const marker = L.marker(ll, { icon: ICONS.rumah });
bindReadOnlyPopup(circle, 'rumah', props);
bindReadOnlyPopup(marker, 'rumah', props);
state.layers.rumah.addLayer(circle);
state.layers.rumah.addLayer(marker);
});
if (document.getElementById('ly-rumah')?.checked) state.layers.rumah.addTo(state.map);
}
function renderWarga(data) {
if (state.layers.warga) state.map.removeLayer(state.layers.warga);
state.layers.warga = L.geoJSON(data, {
pointToLayer: (f, ll) => L.marker(ll, { icon: ICONS.warga }),
onEachFeature: (f, l) => bindReadOnlyPopup(l, 'warga', f.properties)
});
if (document.getElementById('ly-warga')?.checked) state.layers.warga.addTo(state.map);
}
function renderChoropleth(data) {
if (state.layers.choropleth) state.map.removeLayer(state.layers.choropleth);
state.layers.choropleth = L.geoJSON(data, {
style: (f) => {
const c = f.properties.jumlah_warga;
let color = '#10B981';
if (c > 0) color = '#F59E0B';
if (c > 3) color = '#EF4444';
return { color, fillColor: color, fillOpacity: 0.35, weight: 3 };
},
onEachFeature: (f, l) => {
l.bindPopup(`<div class="popup-title">${f.properties.nama_kawasan}</div>
<div class="popup-row">Jumlah Warga: <strong>${f.properties.jumlah_warga}</strong></div>
<div class="popup-row">Total Tanggungan: <strong>${f.properties.total_tanggungan}</strong></div>`);
}
});
if (document.getElementById('ly-choropleth')?.checked) state.layers.choropleth.addTo(state.map);
}
function renderBlankSpot(data) {
if (state.layers.blankSpot) state.map.removeLayer(state.layers.blankSpot);
state.layers.blankSpot = L.geoJSON(data, {
pointToLayer: (f, ll) => L.marker(ll, { icon: ICONS.blankSpot }),
onEachFeature: (f, l) => {
l.bindPopup(`<div class="popup-title">⚠️ ${f.properties.nama_kk}</div>
<div class="popup-row">Jarak ke RI terdekat: <strong>${formatKm(f.properties.jarak_km)} km</strong></div>
<div class="popup-row">Radius bantuan: <strong>${formatKm(f.properties.radius_km)} km</strong></div>
<div class="popup-row">Di luar radius: <strong>${formatKm(f.properties.selisih_km)} km</strong></div>
<div class="popup-row">Rumah ibadah terdekat: <strong>${f.properties.rumah_ibadah_terdekat || '-'}</strong></div>
<div class="popup-row" style="color:#F97316">Blank spot di luar radius bantuan</div>`);
}
});
if (document.getElementById('ly-blankspot')?.checked) state.layers.blankSpot.addTo(state.map);
}
function bindLayerToggles() {
const map = {
'ly-spbu': 'spbu', 'ly-jalan': 'jalan', 'ly-kavling': 'kavling',
'ly-kawasan': 'kawasan', 'ly-rumah': 'rumah', 'ly-warga': 'warga',
'ly-choropleth': 'choropleth', 'ly-blankspot': 'blankSpot'
};
Object.entries(map).forEach(([id, key]) => {
document.getElementById(id)?.addEventListener('change', (e) => {
const layer = state.layers[key];
if (!layer) return;
if (e.target.checked) state.map.addLayer(layer);
else state.map.removeLayer(layer);
});
});
}
async function findNearestSpbu() {
const center = state.map.getCenter();
document.getElementById('spbuPanelResult').innerHTML = '<div class="spinner"></div> Mencari...';
document.getElementById('spbuPanel').style.display = 'block';
try {
const r = await fetch(`${API}/spbu_terdekat.php?lat=${center.lat}&lng=${center.lng}`);
const j = await r.json();
if (j.status === 'success' && j.data) {
const p = j.data.properties;
const coords = j.data.geometry.coordinates;
document.getElementById('spbuPanelResult').innerHTML = `<div class="spbu-result">
<div class="spbu-result-name">⛽ ${p.nama}</div>
<div class="spbu-result-meta">${p.buka_24_jam ? '🟢 24 Jam' : '🟡 Terbatas'}</div>
<div class="spbu-result-distance">${p.jarak_km} km</div>
</div>`;
if (state.layers.spbuRoute) state.map.removeLayer(state.layers.spbuRoute);
state.layers.spbuRoute = L.marker([coords[1], coords[0]], { icon: ICONS.spbu }).addTo(state.map)
.bindPopup(`<b>${p.nama}</b><br>${p.jarak_km} km dari pusat peta`).openPopup();
} else {
document.getElementById('spbuPanelResult').innerHTML = `<div class="spbu-result">${j.message || 'Tidak ada SPBU'}</div>`;
}
} catch (e) {
document.getElementById('spbuPanelResult').innerHTML = `<div class="spbu-result">Error: ${e.message}</div>`;
}
}
async function loadAll() {
document.getElementById('loadingOverlay').style.display = 'flex';
try {
const [spbu, jalan, kavling, kawasan, rumah, warga, stat, blank] = await Promise.all([
fetchLayer('spbu.php'),
fetchLayer('jalan.php'),
fetchLayer('kavling.php'),
fetchLayer('kawasan_kumuh.php'),
fetchLayer('rumah_ibadah.php'),
fetchLayer('warga_miskin.php'),
fetch(STAT_API).then(r => r.json()).then(j => j.data).catch(() => null),
fetchLayer('blank_spot.php')
]);
renderSpbu(spbu); renderJalan(jalan); renderKavling(kavling);
renderKawasan(kawasan); renderRumah(rumah); renderWarga(warga);
if (stat && stat.choropleth) renderChoropleth(stat.choropleth);
if (blank) renderBlankSpot(blank);
if (stat && stat.counts) {
document.getElementById('qs-spbu').textContent = stat.counts.total_spbu;
document.getElementById('qs-jalan').textContent = stat.counts.total_jalan;
document.getElementById('qs-kavling').textContent = stat.counts.total_kavling;
document.getElementById('qs-warga').textContent = stat.counts.total_warga_miskin;
}
} catch (e) {
toast('Gagal load data: ' + e.message, 'error');
} finally {
document.getElementById('loadingOverlay').style.display = 'none';
}
}
document.addEventListener('DOMContentLoaded', () => {
initMap();
loadAll();
bindLayerToggles();
document.getElementById('btn-find-spbu')?.addEventListener('click', findNearestSpbu);
document.getElementById('btn-close-spbu')?.addEventListener('click', () => {
document.getElementById('spbuPanel').style.display = 'none';
if (state.layers.spbuRoute) { state.map.removeLayer(state.layers.spbuRoute); state.layers.spbuRoute = null; }
});
});
+25
View File
@@ -0,0 +1,25 @@
<?php
/**
* Application path helpers.
*
* Set APP_BASE_PATH to the subdirectory that serves project_final.
* In this repository's Coolify image the final app runs at /project_final.
*/
function app_base_path(): string {
$basePath = getenv('APP_BASE_PATH');
if ($basePath === false) {
$basePath = '/project/project_final';
}
$basePath = trim($basePath);
if ($basePath === '' || $basePath === '/') {
return '';
}
return '/' . trim($basePath, '/');
}
function app_url(string $path = ''): string {
$path = '/' . ltrim($path, '/');
return app_base_path() . $path;
}
+45
View File
@@ -0,0 +1,45 @@
<?php
/**
* auth_check.php — Middleware: pastikan user sudah login dan role sesuai.
* Usage: require_once __DIR__ . '/../config/auth_check.php';
* requireRole('admin'); // atau 'user'
*/
require_once __DIR__ . '/session.php';
if (session_status() === PHP_SESSION_NONE) {
startAppSession();
}
function isLoggedIn(): bool {
return isset($_SESSION['user_id']) && isset($_SESSION['role']);
}
function requireLogin(?string $redirect = null): void {
if (!isLoggedIn()) {
$redirect = $redirect ?? app_url('login.php');
header("Location: $redirect");
exit;
}
}
function requireRole(string $role, ?string $redirect = null): void {
requireLogin($redirect);
if ($_SESSION['role'] !== $role) {
// Redirect ke dashboard yang sesuai role-nya
if ($_SESSION['role'] === 'admin') {
header('Location: ' . app_url('admin/index.php'));
} else {
header('Location: ' . app_url('user/index.php'));
}
exit;
}
}
function currentUser(): array {
return [
'id' => $_SESSION['user_id'] ?? null,
'username' => $_SESSION['username'] ?? '',
'role' => $_SESSION['role'] ?? '',
'nama_lengkap'=> $_SESSION['nama_lengkap']?? '',
];
}
+34
View File
@@ -0,0 +1,34 @@
<?php
/**
* Singleton PDO connection.
*
* Coolify: set DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, and DB_PASSWORD
* from the database service credentials.
*/
class Database {
private static $conn = null;
private static function env(string $key, string $default = ''): string {
$value = getenv($key);
return ($value === false || $value === '') ? $default : $value;
}
public static function getConnection() {
if (self::$conn === null) {
$host = self::env('DB_HOST', '127.0.0.1');
$port = self::env('DB_PORT', '3306');
$dbName = self::env('DB_DATABASE', 'webgis_db');
$username = self::env('DB_USERNAME', 'root');
$password = self::env('DB_PASSWORD', '');
self::$conn = new PDO(
"mysql:host={$host};port={$port};dbname={$dbName};charset=utf8mb4",
$username,
$password
);
self::$conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
self::$conn->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
}
return self::$conn;
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
require_once __DIR__ . '/app.php';
function startAppSession(): void {
if (session_status() === PHP_SESSION_ACTIVE) {
return;
}
$secureEnv = strtolower((string)getenv('SESSION_SECURE'));
$isHttps = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https')
|| $secureEnv === 'true'
|| $secureEnv === '1';
session_set_cookie_params([
'lifetime' => 0,
'path' => app_base_path() ?: '/',
'secure' => $isHttps,
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
}
+13
View File
@@ -0,0 +1,13 @@
<?php
require_once __DIR__ . '/config/session.php';
startAppSession();
if (isset($_SESSION['user_id'])) {
if ($_SESSION['role'] === 'admin') {
header('Location: admin/index.php');
} else {
header('Location: user/index.php');
}
exit;
}
header('Location: login.php');
exit;
+135
View File
@@ -0,0 +1,135 @@
<?php
require_once __DIR__ . '/config/session.php';
startAppSession();
if (isset($_SESSION['user_id'])) {
header($_SESSION['role'] === 'admin' ? 'Location: admin/index.php' : 'Location: user/index.php');
exit;
}
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
require_once __DIR__ . '/config/db.php';
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
if ($username && $password) {
$pdo = Database::getConnection();
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = ? LIMIT 1");
$stmt->execute([$username]);
$user = $stmt->fetch();
if ($user && password_verify($password, $user['password'])) {
session_regenerate_id(true);
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
$_SESSION['role'] = $user['role'];
$_SESSION['nama_lengkap'] = $user['nama_lengkap'];
header($user['role'] === 'admin' ? 'Location: admin/index.php' : 'Location: user/index.php');
exit;
}
$error = 'Username atau password salah. Silakan coba lagi.';
} else {
$error = 'Username dan password wajib diisi.';
}
}
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - WebGIS Smart City</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<link rel="stylesheet" href="assets/css/main.css">
<link rel="stylesheet" href="assets/css/auth.css">
</head>
<body class="auth-page">
<div class="auth-center">
<div class="auth-form-wrap">
<h2 class="auth-form-title">Selamat Datang</h2>
<p class="auth-form-subtitle">Masuk ke sistem WebGIS Smart City Pontianak.</p>
<div class="role-selector">
<label class="role-card">
<input type="radio" name="role_display" value="admin" checked>
<div class="role-card-label">
<div class="role-card-icon"><i class="fas fa-user-shield"></i></div>
<div class="role-card-name">Admin</div>
<div class="role-card-desc">Kelola semua data</div>
</div>
</label>
<label class="role-card">
<input type="radio" name="role_display" value="user">
<div class="role-card-label">
<div class="role-card-icon"><i class="fas fa-user"></i></div>
<div class="role-card-name">Pengguna</div>
<div class="role-card-desc">Lihat & analisis peta</div>
</div>
</label>
</div>
<?php if ($error): ?>
<div class="auth-error">
<i class="fas fa-exclamation-circle"></i>
<?= htmlspecialchars($error) ?>
</div>
<?php endif; ?>
<div style="background:#EFF6FF; border:1px solid #BFDBFE; border-radius:8px; padding:12px; margin-bottom:16px; font-size:0.85rem; color:#1E3A8A;">
<strong><i class="fas fa-info-circle"></i> Akun Testing:</strong><br>
Admin: <code>admin</code> / <code>admin123</code><br>
User: <code>pengguna</code> / <code>user123</code>
</div>
<form method="POST" class="auth-form">
<div class="form-group">
<label class="form-label">Username</label>
<div class="input-with-icon">
<i class="fas fa-user input-icon"></i>
<input type="text" name="username" class="form-control"
placeholder="Masukkan username"
value="<?= htmlspecialchars($_POST['username'] ?? '') ?>"
required autocomplete="username">
</div>
</div>
<div class="form-group">
<label class="form-label">Password</label>
<div class="input-with-icon">
<i class="fas fa-lock input-icon"></i>
<input type="password" name="password" class="form-control"
placeholder="Masukkan password"
required autocomplete="current-password"
id="passwordInput">
<button type="button" class="input-icon" style="right:12px;left:auto;cursor:pointer;background:none;border:none;color:var(--text-muted);" onclick="togglePass()">
<i class="fas fa-eye" id="eyeIcon"></i>
</button>
</div>
</div>
<button type="submit" class="btn btn-primary btn-login">
<i class="fas fa-sign-in-alt"></i>
Masuk ke Sistem
</button>
</form>
</div>
</div>
<script>
function togglePass() {
const inp = document.getElementById('passwordInput');
const ico = document.getElementById('eyeIcon');
if (inp.type === 'password') {
inp.type = 'text';
ico.className = 'fas fa-eye-slash';
} else {
inp.type = 'password';
ico.className = 'fas fa-eye';
}
}
</script>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
<?php
require_once __DIR__ . '/config/session.php';
startAppSession();
session_destroy();
header('Location: ' . app_url('login.php'));
exit;
+116
View File
@@ -0,0 +1,116 @@
<?php
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/auth_check.php';
requireRole('user');
$pdo = Database::getConnection();
$stats = $pdo->query("SELECT
(SELECT COUNT(*) FROM spbu) as spbu,
(SELECT COUNT(*) FROM jalan) as jalan,
(SELECT COUNT(*) FROM kavling) as kavling,
(SELECT COUNT(*) FROM rumah_ibadah) as rumah_ibadah,
(SELECT COUNT(*) FROM warga_miskin) as warga_miskin,
(SELECT COUNT(*) FROM kawasan_kumuh) as kawasan_kumuh,
(SELECT COUNT(*) FROM spbu WHERE buka_24_jam=1) as spbu_24jam
")->fetch();
// Hitung kawasan merah (rawan)
$merahResult = $pdo->query("SELECT COUNT(*) as cnt FROM (
SELECT k.id FROM kawasan_kumuh k
LEFT JOIN warga_miskin w ON ST_Contains(k.geom, w.geom)
GROUP BY k.id HAVING COUNT(w.id) > 3
) sub")->fetch();
$kawasanMerah = (int)($merahResult['cnt'] ?? 0);
// Hitung blank spot
$blankCount = $pdo->query("
SELECT COUNT(*) as cnt
FROM warga_miskin w
WHERE NOT EXISTS (
SELECT 1
FROM rumah_ibadah ri
WHERE ST_Distance_Sphere(w.geom, ri.geom) <= ri.radius_bantuan_meter
)
")->fetch();
$blankSpot = (int)($blankCount['cnt'] ?? 0);
$pageTitle = 'Beranda';
$activeNav = 'dashboard';
require_once __DIR__ . '/partials/header.php';
?>
<div class="card" style="background: #FFFFFF; border: 1px solid #E5E7EB; border-radius: var(--radius-lg); padding: 24px; margin-bottom: 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.05);">
<h2 style="font-size:1.4rem;font-weight:700;margin-bottom:6px;color:var(--text-primary);">👋 Selamat Datang, <?= htmlspecialchars(currentUser()['nama_lengkap'] ?: currentUser()['username']) ?>!</h2>
<p style="color:var(--text-secondary);font-size:0.9rem;max-width:600px;">
Anda masuk sebagai <strong>Pengguna</strong>. Anda dapat melihat seluruh data spasial, analisis, dan berpartisipasi dengan mengirim Laporan Warga atau memberikan ulasan fasilitas.
</p>
</div>
<div class="stats-grid">
<div class="stat-card primary">
<div class="stat-icon"><i class="fas fa-gas-pump"></i></div>
<div class="stat-body">
<div class="stat-label">Total SPBU</div>
<div class="stat-value"><?= $stats['spbu'] ?></div>
<div class="stat-change"><?= $stats['spbu_24jam'] ?> buka 24 jam</div>
</div>
</div>
<div class="stat-card info">
<div class="stat-icon"><i class="fas fa-road"></i></div>
<div class="stat-body">
<div class="stat-label">Ruas Jalan</div>
<div class="stat-value"><?= $stats['jalan'] ?></div>
<div class="stat-change">Segmen terpetakan</div>
</div>
</div>
<div class="stat-card success">
<div class="stat-icon"><i class="fas fa-mosque"></i></div>
<div class="stat-body">
<div class="stat-label">Rumah Ibadah</div>
<div class="stat-value"><?= $stats['rumah_ibadah'] ?></div>
<div class="stat-change">Lokasi tercatat</div>
</div>
</div>
<div class="stat-card warning">
<div class="stat-icon"><i class="fas fa-users"></i></div>
<div class="stat-body">
<div class="stat-label">Warga Miskin</div>
<div class="stat-value"><?= $stats['warga_miskin'] ?></div>
<div class="stat-change"><?= $blankSpot ?> blank spot</div>
</div>
</div>
<div class="stat-card danger">
<div class="stat-icon"><i class="fas fa-exclamation-triangle"></i></div>
<div class="stat-body">
<div class="stat-label">Kawasan Rawan</div>
<div class="stat-value"><?= $kawasanMerah ?></div>
<div class="stat-change">dari <?= $stats['kawasan_kumuh'] ?> kawasan</div>
</div>
</div>
<div class="stat-card violet">
<div class="stat-icon"><i class="fas fa-vector-square"></i></div>
<div class="stat-body">
<div class="stat-label">Kavling / Parsil</div>
<div class="stat-value"><?= $stats['kavling'] ?></div>
<div class="stat-change">Bidang tanah</div>
</div>
</div>
</div>
<div class="card" style="background: #FFFFFF; border: 1px solid #E5E7EB; border-radius: var(--radius-lg); padding: 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.05);">
<div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:16px;">
<div>
<h3 style="font-size:1.1rem; font-weight:700; margin-bottom:6px;">🗺️ Buka Peta Interaktif</h3>
<p style="color:var(--text-secondary); font-size:0.88rem; max-width:500px;">
Lihat semua layer data spasial: SPBU, jalan, kavling, rumah ibadah, warga miskin, kawasan kumuh, dan analisis spasial.
</p>
</div>
<div style="display:flex; gap:10px;">
<a href="<?= app_url('user/peta.php') ?>" class="btn btn-success">
<i class="fas fa-map-marked-alt"></i> Buka Peta
</a>
</div>
</div>
</div>
<?php require_once __DIR__ . '/partials/footer.php'; ?>
+191
View File
@@ -0,0 +1,191 @@
<?php
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/auth_check.php';
requireRole('user');
$pdo = Database::getConnection();
// Fetch user's own reports
$stmt = $pdo->prepare("SELECT id, kategori, deskripsi, status, created_at FROM laporan_warga WHERE user_id = ? ORDER BY id DESC");
$stmt->execute([$_SESSION['user_id']]);
$rows = $stmt->fetchAll();
$pageTitle = 'Laporan Warga';
$activeNav = 'laporan';
$extraHead = '<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">';
require_once __DIR__ . '/partials/header.php';
function badgeStatus($status) {
if($status === 'menunggu') return 'badge-warning';
if($status === 'diproses') return 'badge-info';
if($status === 'selesai') return 'badge-success';
if($status === 'ditolak') return 'badge-danger';
return 'badge-primary';
}
?>
<div class="page-header">
<div class="breadcrumb"><span>User</span><span class="sep">/</span><span>Laporan Warga</span></div>
<h1>📢 Laporan Fasilitas & Infrastruktur</h1>
<p>Laporkan masalah infrastruktur seperti jalan rusak, banjir, atau fasilitas umum yang terbengkalai.</p>
</div>
<div class="page-toolbar">
<div class="search-box">
<i class="fas fa-search search-icon"></i>
<input type="text" id="searchInput" placeholder="Cari laporan saya..." oninput="filterTable('searchInput','lapTable')">
</div>
<button class="btn btn-primary" onclick="openModal('modalTambah')">
<i class="fas fa-plus"></i> Buat Laporan Baru
</button>
</div>
<div class="table-wrap">
<table class="data-table" id="lapTable">
<thead>
<tr>
<th>#</th>
<th>Kategori</th>
<th>Deskripsi Singkat</th>
<th>Status</th>
<th>Tanggal</th>
<th>Aksi</th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $i => $r): ?>
<tr>
<td style="color:var(--text-muted)"><?= $i+1 ?></td>
<td><strong><?= htmlspecialchars($r['kategori']) ?></strong></td>
<td style="color:var(--text-secondary); max-width: 250px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
<?= htmlspecialchars($r['deskripsi']) ?>
</td>
<td><span class="badge <?= badgeStatus($r['status']) ?>"><?= ucfirst($r['status']) ?></span></td>
<td style="color:var(--text-muted); font-size:0.8rem;"><?= date('d M Y, H:i', strtotime($r['created_at'])) ?></td>
<td>
<div class="actions">
<?php if($r['status'] === 'menunggu'): ?>
<button class="btn btn-danger btn-sm btn-icon" onclick="hapus(<?= $r['id'] ?>)" title="Batalkan Laporan"><i class="fas fa-trash"></i></button>
<?php else: ?>
<span style="font-size:0.75rem; color:var(--text-muted);">Dikunci</span>
<?php endif; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php if (!$rows): ?>
<tr>
<td colspan="6" style="text-align:center;padding:40px;color:var(--text-muted);">
Anda belum pernah membuat laporan.
</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
<!-- Modal Tambah Laporan -->
<div class="modal-overlay" id="modalTambah">
<div class="modal">
<div class="modal-header">
<span class="modal-title">📢 Buat Laporan Baru</span>
<button class="modal-close" onclick="closeModal('modalTambah')">×</button>
</div>
<form id="formTambah">
<div class="form-group">
<label class="form-label">Kategori Masalah *</label>
<select name="kategori" class="form-control" required>
<option value="Jalan Rusak">Jalan Rusak</option>
<option value="Banjir">Banjir</option>
<option value="Fasilitas Terbengkalai">Fasilitas Terbengkalai</option>
<option value="Lampu Jalan Mati">Lampu Jalan Mati</option>
<option value="Lainnya">Lainnya</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Deskripsi Lengkap *</label>
<textarea name="deskripsi" class="form-control" placeholder="Jelaskan detail masalahnya..." required rows="3"></textarea>
</div>
<div class="form-group">
<label class="form-label">Pilih Lokasi di Peta *</label>
<div id="miniMapTambah" style="height:200px;border-radius:8px;border:1px solid #E5E7EB;margin-bottom:10px;z-index:1;"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<input type="number" name="lat" id="latTambah" class="form-control" placeholder="Latitude" step="any" required readonly style="background:#F9FAFB;">
<input type="number" name="lng" id="lngTambah" class="form-control" placeholder="Longitude" step="any" required readonly style="background:#F9FAFB;">
</div>
</div>
</form>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeModal('modalTambah')">Batal</button>
<button class="btn btn-primary" onclick="simpan()"><i class="fas fa-paper-plane"></i> Kirim Laporan</button>
</div>
</div>
</div>
<?php $extraScript = <<<'JS'
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const API = APP_BASE + '/api/laporan.php';
let miniMap = null, miniMarker = null;
const originalOpenModal = window.openModal;
window.openModal = function(id) {
originalOpenModal(id);
if(id === 'modalTambah') {
setTimeout(() => {
if(!miniMap) {
miniMap = L.map('miniMapTambah').setView([-0.0583, 109.3448], 13);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png').addTo(miniMap);
miniMap.on('click', function(e) {
const lat = e.latlng.lat;
const lng = e.latlng.lng;
if(!miniMarker) miniMarker = L.marker([lat, lng]).addTo(miniMap);
else miniMarker.setLatLng([lat, lng]);
document.getElementById('latTambah').value = lat.toFixed(6);
document.getElementById('lngTambah').value = lng.toFixed(6);
});
} else {
miniMap.invalidateSize();
}
}, 100);
}
};
async function simpan() {
const f = document.getElementById('formTambah');
const lat = parseFloat(f.lat.value), lng = parseFloat(f.lng.value);
if (!f.kategori.value || !f.deskripsi.value || isNaN(lat) || isNaN(lng)) {
showToast('Lengkapi kategori, deskripsi, dan pilih lokasi!','error');
return;
}
const res = await fetch(API, {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({
kategori: f.kategori.value,
deskripsi: f.deskripsi.value,
geometry: { type:'Point', coordinates:[lng, lat] }
})
});
const d = await res.json();
if(d.status === 'success') {
showToast('Laporan berhasil dikirim!');
setTimeout(() => location.reload(), 900);
} else {
showToast(d.message,'error');
}
}
async function hapus(id) {
if(!confirm('Apakah Anda yakin ingin membatalkan laporan ini?')) return;
const res = await fetch(API + '?id=' + id, { method:'DELETE' });
const d = await res.json();
if(d.status === 'success') {
showToast('Laporan dibatalkan!');
setTimeout(() => location.reload(), 900);
} else {
showToast(d.message,'error');
}
}
</script>
JS; ?>
<?php require_once __DIR__ . '/partials/footer.php'; ?>
+119
View File
@@ -0,0 +1,119 @@
</div><!-- /admin-content -->
</main><!-- /admin-main -->
<!-- Toast Container -->
<div class="toast-container" id="toastContainer"></div>
<script>
// ── Clock ──
function updateClock() {
const now = new Date();
document.getElementById('clockText').textContent =
now.toLocaleTimeString('id-ID', { hour:'2-digit', minute:'2-digit', second:'2-digit' });
}
updateClock();
setInterval(updateClock, 1000);
// ── Toast utility ──
window.showToast = function(msg, type = 'success') {
const t = document.createElement('div');
t.className = `toast ${type}`;
const icons = { success: 'check-circle', error: 'exclamation-circle', info: 'info-circle' };
t.innerHTML = `<i class="fas fa-${icons[type]||'info-circle'}" style="margin-right:8px;"></i>${msg}`;
document.getElementById('toastContainer').appendChild(t);
setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 400); }, 3000);
};
// ── Modal helpers ──
window.openModal = function(id) {
document.getElementById(id)?.classList.add('active');
document.body.style.overflow = 'hidden';
};
window.closeModal = function(id) {
document.getElementById(id)?.classList.remove('active');
document.body.style.overflow = '';
};
// Close modal on overlay click
document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', e => {
if (e.target === overlay) closeModal(overlay.id);
});
});
// ── Search filter ──
window.filterTable = function(inputId, tableId) {
const q = document.getElementById(inputId)?.value.toLowerCase() || '';
document.querySelectorAll(`#${tableId} tbody tr`).forEach(row => {
row.style.display = row.textContent.toLowerCase().includes(q) ? '' : 'none';
});
};
// Realtime SPBU snackbar
(function initSpbuRealtimeSnackbar() {
const API_URL = APP_BASE + '/api/spbu_status.php';
const REFRESH_MS = 60000;
const SHOW_MS = 7000;
let lastOpen = null;
let hideTimer = null;
function ensureSnackbar() {
let el = document.getElementById('spbuRealtimeSnackbar');
if (el) return el;
el = document.createElement('div');
el.id = 'spbuRealtimeSnackbar';
el.className = 'spbu-snackbar';
el.innerHTML = `
<div class="spbu-snackbar-icon"><i class="fas fa-gas-pump"></i></div>
<div>
<div class="spbu-snackbar-title" id="spbuSnackbarTitle">SPBU sedang buka</div>
<div class="spbu-snackbar-meta" id="spbuSnackbarMeta">Memuat status terbaru...</div>
</div>
<div class="spbu-snackbar-count" id="spbuSnackbarCount">-</div>
`;
document.body.appendChild(el);
return el;
}
function showSnackbar(data) {
const el = ensureSnackbar();
const open = Number(data.spbu_buka || 0);
const total = Number(data.total_spbu || 0);
const closed = Number(data.spbu_tutup || Math.max(0, total - open));
const time = data.server_time ? data.server_time.slice(11, 16) : new Date().toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' });
document.getElementById('spbuSnackbarTitle').textContent = `${open} SPBU sedang buka`;
document.getElementById('spbuSnackbarMeta').textContent = `${closed} tutup/terbatas dari ${total} total - update ${time} WIB`;
document.getElementById('spbuSnackbarCount').textContent = open;
el.classList.add('show');
clearTimeout(hideTimer);
hideTimer = setTimeout(() => el.classList.remove('show'), SHOW_MS);
}
async function refreshStatus(forceShow = false) {
try {
const res = await fetch(API_URL, { cache: 'no-store' });
const json = await res.json();
if (json.status !== 'success' || !json.data) return;
const open = Number(json.data.spbu_buka || 0);
if (forceShow || lastOpen === null || open !== lastOpen) {
showSnackbar(json.data);
}
lastOpen = open;
} catch (e) {
// Snackbar is informational; keep the page quiet if polling fails.
}
}
document.addEventListener('DOMContentLoaded', () => {
refreshStatus(true);
setInterval(() => refreshStatus(false), REFRESH_MS);
});
})();
</script>
<?= $extraScript ?? '' ?>
</body>
</html>
+85
View File
@@ -0,0 +1,85 @@
<?php
/**
* User Layout — sidebar + topbar (read-only mode, beda style dari admin)
* Variables expected from parent: $pageTitle, $activeNav
*/
$user = currentUser();
$initial = strtoupper(substr($user['nama_lengkap'] ?: $user['username'], 0, 1));
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($pageTitle ?? 'User Portal') ?> — WebGIS Smart City</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<link rel="stylesheet" href="<?= app_url('assets/css/main.css') ?>?v=20260610-spbu-snackbar">
<link rel="stylesheet" href="<?= app_url('assets/css/admin.css') ?>">
<script>const APP_BASE = '<?= app_url() ?>';</script>
<?= $extraHead ?? '' ?>
</head>
<body class="admin-page user-portal <?= $bodyClass ?? '' ?>">
<!-- ══ SIDEBAR ══ -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-brand">
<div class="brand-icon" style="background:linear-gradient(135deg,#10B981,#059669);">🗺️</div>
<div class="brand-text">
<span class="brand-name">WebGIS</span>
<span class="brand-sub">Pengguna</span>
</div>
</div>
<nav class="sidebar-nav">
<div class="nav-section-title">Menu</div>
<a href="<?= app_url('user/index.php') ?>"
class="nav-item <?= ($activeNav==='dashboard') ? 'active' : '' ?>">
<span class="nav-icon"><i class="fas fa-chart-pie"></i></span> Beranda
</a>
<a href="<?= app_url('user/peta.php') ?>"
class="nav-item <?= ($activeNav==='peta') ? 'active' : '' ?>">
<span class="nav-icon"><i class="fas fa-map-marked-alt"></i></span> Peta Interaktif
</a>
<a href="<?= app_url('user/laporan.php') ?>"
class="nav-item <?= ($activeNav==='laporan') ? 'active' : '' ?>">
<span class="nav-icon"><i class="fas fa-bullhorn"></i></span> Laporan Warga
</a>
<div class="nav-section-title">Informasi</div>
<div class="user-info" style="background:rgba(16,185,129,0.08);border-color:rgba(16,185,129,0.2);">
<i class="fas fa-info-circle" style="color:#059669;font-size:1.1rem;"></i>
<div style="font-size:0.78rem;color:var(--text-secondary);line-height:1.4;">
Anda masuk sebagai <strong style="color:#059669">Pengguna</strong>. Data hanya dapat dilihat, tidak dapat diubah.
</div>
</div>
</nav>
<div class="sidebar-footer">
<div class="user-info">
<div class="user-avatar" style="background:linear-gradient(135deg,#10B981,#059669);"><?= $initial ?></div>
<div class="user-details">
<div class="user-name"><?= htmlspecialchars($user['nama_lengkap'] ?: $user['username']) ?></div>
<div class="user-role">Pengguna</div>
</div>
<a href="<?= app_url('logout.php') ?>" class="user-logout" title="Keluar">
<i class="fas fa-sign-out-alt"></i>
</a>
</div>
</div>
</aside>
<!-- ══ MAIN CONTENT ══ -->
<main class="admin-main">
<div class="topbar">
<span class="topbar-title"><?= htmlspecialchars($pageTitle ?? 'Beranda') ?></span>
<div class="topbar-actions">
<div class="topbar-time" id="clock">
<i class="fas fa-clock"></i> <span id="clockText"></span>
</div>
<span class="badge badge-success" style="font-size:0.7rem;padding:5px 10px;">
<i class="fas fa-eye"></i> Mode Baca
</span>
</div>
</div>
<div class="admin-content">
+161
View File
@@ -0,0 +1,161 @@
<?php
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/auth_check.php';
requireRole('user');
$pageTitle = 'Peta Interaktif';
$activeNav = 'peta';
$bodyClass = 'admin-map-page map-page';
$extraHead = '
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<link rel="stylesheet" href="' . app_url('assets/css/map.css') . '?v=20260610-2">
';
require_once __DIR__ . '/partials/header.php';
?>
<div class="map-wrap">
<div id="user-map"></div>
<div class="read-only-badge">
<i class="fas fa-eye"></i> Mode Baca
</div>
<aside class="map-sidebar">
<div class="map-panel-header">
<div class="map-panel-icon"><i class="fas fa-layer-group"></i></div>
<div>
<h3>Layer Peta</h3>
<p>Kontrol data yang tampil di peta</p>
</div>
</div>
<div class="layer-section">
<div class="layer-section-title">Infrastruktur</div>
<label class="layer-item">
<input type="checkbox" id="ly-spbu" checked>
<span class="layer-color layer-point" style="--layer-color:#F59E0B"><i class="fas fa-gas-pump"></i></span>
<span class="layer-text">
<span class="layer-name">SPBU</span>
<span class="layer-meta">Stasiun pengisian bahan bakar</span>
</span>
</label>
<label class="layer-item">
<input type="checkbox" id="ly-jalan" checked>
<span class="layer-color layer-line" style="--layer-color:#3B82F6"></span>
<span class="layer-text">
<span class="layer-name">Jalan</span>
<span class="layer-meta">Ruas jalan utama dan lokal</span>
</span>
</label>
<label class="layer-item">
<input type="checkbox" id="ly-kavling" checked>
<span class="layer-color layer-area" style="--layer-color:#7C3AED"></span>
<span class="layer-text">
<span class="layer-name">Kavling / Parsil</span>
<span class="layer-meta">Batas bidang tanah</span>
</span>
</label>
</div>
<div class="layer-section">
<div class="layer-section-title">Sosial</div>
<label class="layer-item">
<input type="checkbox" id="ly-rumah" checked>
<span class="layer-color layer-point" style="--layer-color:#8B5CF6"><i class="fas fa-mosque"></i></span>
<span class="layer-text">
<span class="layer-name">Rumah Ibadah</span>
<span class="layer-meta">Fasilitas keagamaan</span>
</span>
</label>
<label class="layer-item">
<input type="checkbox" id="ly-warga" checked>
<span class="layer-color layer-point" style="--layer-color:#EF4444"><i class="fas fa-users"></i></span>
<span class="layer-text">
<span class="layer-name">Warga Miskin</span>
<span class="layer-meta">Sebaran keluarga penerima bantuan</span>
</span>
</label>
<label class="layer-item">
<input type="checkbox" id="ly-kawasan">
<span class="layer-color layer-area layer-hatch" style="--layer-color:#DC2626"></span>
<span class="layer-text">
<span class="layer-name">Kawasan Kumuh</span>
<span class="layer-meta">Area prioritas penanganan</span>
</span>
</label>
</div>
<div class="layer-section">
<div class="layer-section-title">Analisis</div>
<label class="layer-item">
<input type="checkbox" id="ly-choropleth">
<span class="layer-color layer-gradient"></span>
<span class="layer-text">
<span class="layer-name">Choropleth Kepadatan</span>
<span class="layer-meta">Kepadatan warga miskin per kawasan</span>
</span>
</label>
<label class="layer-item">
<input type="checkbox" id="ly-blankspot">
<span class="layer-color layer-point" style="--layer-color:#F97316"><i class="fas fa-location-dot"></i></span>
<span class="layer-text">
<span class="layer-name">Blank Spot Bansos</span>
<span class="layer-meta">Titik jauh dari fasilitas bantuan</span>
</span>
</label>
</div>
<div class="map-quickstats">
<div class="qs-item"><div class="qs-value" id="qs-spbu" style="color:#F59E0B">-</div><div class="qs-label">SPBU</div></div>
<div class="qs-item"><div class="qs-value" id="qs-jalan" style="color:#3B82F6">-</div><div class="qs-label">Jalan</div></div>
<div class="qs-item"><div class="qs-value" id="qs-kavling" style="color:#7C3AED">-</div><div class="qs-label">Kavling</div></div>
<div class="qs-item"><div class="qs-value" id="qs-warga" style="color:#EF4444">-</div><div class="qs-label">Warga</div></div>
</div>
</aside>
<div class="map-analysis">
<button id="btn-find-spbu" class="map-tool-accent" title="Cari SPBU terdekat dari pusat peta">
<i class="fas fa-search-location"></i> SPBU Terdekat
</button>
</div>
<div class="spbu-panel" id="spbuPanel" style="display:none;">
<div class="spbu-panel-title">
<i class="fas fa-search-location"></i> SPBU Terdekat
<button class="modal-close" id="btn-close-spbu" style="margin-left:auto;background:none;border:none;color:var(--text-muted);cursor:pointer;font-size:1.2rem;">&times;</button>
</div>
<div id="spbuPanelResult"></div>
</div>
<div class="map-legend">
<div class="map-legend-title"><i class="fas fa-list-ul"></i> Legenda</div>
<div class="legend-grid">
<div class="legend-item"><span class="legend-symbol legend-dot" style="--legend-color:#10B981"></span><span>SPBU 24 Jam</span></div>
<div class="legend-item"><span class="legend-symbol legend-dot" style="--legend-color:#F59E0B"></span><span>SPBU Terbatas</span></div>
<div class="legend-item"><span class="legend-symbol legend-line" style="--legend-color:#3B82F6"></span><span>Jalan</span></div>
<div class="legend-item"><span class="legend-symbol legend-area" style="--legend-color:#7C3AED"></span><span>Kavling</span></div>
<div class="legend-item"><span class="legend-symbol legend-dot" style="--legend-color:#8B5CF6"></span><span>Rumah Ibadah</span></div>
<div class="legend-item"><span class="legend-symbol legend-dot" style="--legend-color:#EF4444"></span><span>Warga Miskin</span></div>
<div class="legend-item"><span class="legend-symbol legend-hatch" style="--legend-color:#DC2626"></span><span>Kawasan Kumuh</span></div>
<div class="legend-item"><span class="legend-symbol legend-dot" style="--legend-color:#F97316"></span><span>Blank Spot</span></div>
</div>
</div>
<div class="map-loading" id="loadingOverlay" style="display:none;">
<div class="spinner"></div>
<span>Memuat data...</span>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="<?= app_url('assets/js/user-map.js') ?>?v=20260610-radius"></script>
<?php $extraScript = ''; ?>
<?php require_once __DIR__ . '/partials/footer.php'; ?>