pertama dan terakhir
This commit is contained in:
@@ -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'; ?>
|
||||
@@ -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'; ?>
|
||||
@@ -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'; ?>
|
||||
@@ -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'; ?>
|
||||
@@ -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'; ?>
|
||||
@@ -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'; ?>
|
||||
@@ -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'; ?>
|
||||
@@ -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'; ?>
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
@@ -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;">×</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;">×</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'; ?>
|
||||
@@ -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'; ?>
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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()]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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%; }
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
});
|
||||
});
|
||||
@@ -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; }
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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']?? '',
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/config/session.php';
|
||||
startAppSession();
|
||||
session_destroy();
|
||||
header('Location: ' . app_url('login.php'));
|
||||
exit;
|
||||
@@ -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'; ?>
|
||||
@@ -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'; ?>
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
@@ -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;">×</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'; ?>
|
||||
Reference in New Issue
Block a user