First commit / commit pertama

This commit is contained in:
Mr.Haruna
2026-06-13 11:24:58 +07:00
commit 522c4f7200
166 changed files with 13326 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
<?php
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/auth_check.php';
requireAnyRole(['operator']);
$pdo = Database::getConnection();
$pageTitle = 'Analisis Spasial';
$activeNav = 'analisis';
$extraHead = '<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">';
require_once __DIR__ . '/partials/header.php';
require_once __DIR__ . '/../partials/analisis_body.php';
require_once __DIR__ . '/partials/footer.php';
+191
View File
@@ -0,0 +1,191 @@
<?php
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/auth_check.php';
requireAnyRole(['operator']);
$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'; ?>
+324
View File
@@ -0,0 +1,324 @@
<?php
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/auth_check.php';
requireAnyRole(['operator']);
$pdo = Database::getConnection();
$rows = $pdo->query("
SELECT w.*, wl.nama AS wilayah_nama,
(SELECT GROUP_CONCAT(jb.kode SEPARATOR ',')
FROM bantuan_sosial bs JOIN jenis_bantuan jb ON jb.id=bs.jenis_bantuan_id
WHERE bs.warga_id=w.id AND bs.is_aktif=1) AS bantuan_aktif,
(SELECT COUNT(*) FROM foto_rumah f WHERE f.warga_id=w.id) AS jumlah_foto
FROM warga_miskin w
LEFT JOIN wilayah wl ON wl.id=w.wilayah_id
ORDER BY w.id DESC
")->fetchAll();
$wilayahList = $pdo->query("SELECT id, nama FROM wilayah ORDER BY nama")->fetchAll();
$jenisList = $pdo->query("SELECT id, kode, nama FROM jenis_bantuan ORDER BY id")->fetchAll();
$pageTitle = 'Data Rumah Tangga Miskin'; $activeNav = 'warga';
$extraHead = '<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">';
require_once __DIR__ . '/partials/header.php';
function verifBadge($s) {
return match ($s) {
'terverifikasi' => '<span class="badge badge-success">✔ Terverifikasi</span>',
'perlu_tinjauan' => '<span class="badge badge-warning">⚠ Perlu Tinjauan</span>',
default => '<span class="badge" style="background:#F3F4F6;color:#6B7280;">○ Belum</span>',
};
}
?>
<div class="page-header">
<div class="breadcrumb"><span>Operator</span><span class="sep">/</span><span>Rumah Tangga Miskin</span></div>
<h1>👥 Data Rumah Tangga Miskin</h1>
<p>Kelola data lengkap rumah tangga, status bantuan, foto kondisi rumah, dan verifikasi (KF-12 s/d 18).</p>
</div>
<div class="page-toolbar" style="flex-wrap:wrap;gap:10px;">
<div class="search-box"><i class="fas fa-search search-icon"></i><input type="text" id="searchInput" placeholder="Cari nama / NIK..." oninput="filterTable('searchInput','wargaTable')"></div>
<select id="filterVerif" class="form-control" style="max-width:180px;" onchange="applyVerifFilter()">
<option value="">Semua status</option>
<option value="belum">Belum</option>
<option value="terverifikasi">Terverifikasi</option>
<option value="perlu_tinjauan">Perlu Tinjauan</option>
</select>
<button class="btn btn-ghost" onclick="bulkVerify()"><i class="fas fa-check-double"></i> Verifikasi Terpilih</button>
<a class="btn btn-ghost" href="<?= app_url('api/warga_export.php?format=csv') ?>"><i class="fas fa-file-csv"></i> Export CSV</a>
<a class="btn btn-ghost" href="<?= app_url('api/warga_export.php?format=print') ?>" target="_blank"><i class="fas fa-print"></i> Cetak/PDF</a>
<button class="btn btn-ghost" onclick="openModal('modalImport')"><i class="fas fa-file-import"></i> Import CSV</button>
<button class="btn btn-primary" onclick="openTambah()"><i class="fas fa-plus"></i> Tambah Data</button>
</div>
<div class="table-wrap">
<table class="data-table" id="wargaTable">
<thead><tr>
<th><input type="checkbox" id="checkAll" onclick="toggleAll(this)"></th>
<th>Nama KK</th><th>NIK</th><th>Kelurahan</th><th>Penghasilan</th>
<th>Jiwa</th><th>Bantuan</th><th>Foto</th><th>Verifikasi</th><th>Aksi</th>
</tr></thead>
<tbody>
<?php foreach ($rows as $r): ?>
<tr data-verif="<?= $r['status_verifikasi'] ?>">
<td><input type="checkbox" class="rowCheck" value="<?= $r['id'] ?>"></td>
<td><strong><?= htmlspecialchars($r['nama_kk']) ?></strong></td>
<td style="font-size:0.82rem;color:var(--text-muted)"><?= htmlspecialchars($r['nik_kk'] ?: '-') ?></td>
<td style="font-size:0.85rem"><?= htmlspecialchars($r['wilayah_nama'] ?: '-') ?></td>
<td>Rp <?= number_format($r['penghasilan'],0,',','.') ?></td>
<td><?= $r['jumlah_jiwa'] ?: ($r['jumlah_tanggungan'] ?: '-') ?></td>
<td style="font-size:0.78rem"><?= $r['bantuan_aktif'] ? '<span class="badge badge-info">'.str_replace(',','</span> <span class="badge badge-info">',htmlspecialchars($r['bantuan_aktif'])).'</span>' : '<span style="color:#9CA3AF">—</span>' ?></td>
<td><?= $r['jumlah_foto'] > 0 ? '📷 '.$r['jumlah_foto'] : '<span style="color:#9CA3AF">—</span>' ?></td>
<td><?= verifBadge($r['status_verifikasi']) ?></td>
<td><div class="actions">
<button class="btn btn-ghost btn-sm btn-icon" title="Kelola (bantuan & foto)" onclick="openDetail(<?= $r['id'] ?>,'<?= addslashes($r['nama_kk']) ?>')"><i class="fas fa-folder-open"></i></button>
<button class="btn btn-ghost btn-sm btn-icon" title="Edit" onclick='editRow(<?= json_encode($r, JSON_HEX_APOS|JSON_HEX_QUOT) ?>)'><i class="fas fa-edit"></i></button>
<button class="btn btn-danger btn-sm btn-icon" title="Hapus" 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="10" style="text-align:center;padding:40px;color:var(--text-muted);">Belum ada data.</td></tr><?php endif; ?>
</tbody>
</table>
</div>
<!-- ===== Modal Tambah/Edit ===== -->
<div class="modal-overlay" id="modalForm"><div class="modal" style="max-width:680px;">
<div class="modal-header"><span class="modal-title" id="formTitle">Tambah Data</span><button class="modal-close" onclick="closeModal('modalForm')">×</button></div>
<div style="max-height:70vh;overflow-y:auto;padding-right:6px;">
<input type="hidden" id="fId">
<h4 style="margin:4px 0 8px;font-size:0.85rem;color:#2563EB;">Identitas</h4>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<div class="form-group"><label class="form-label">Nama KK *</label><input id="fNama" class="form-control" required></div>
<div class="form-group"><label class="form-label">NIK KK</label><input id="fNik" class="form-control" maxlength="16" placeholder="16 digit"></div>
</div>
<div class="form-group"><label class="form-label">Alamat</label><input id="fAlamat" class="form-control"></div>
<div style="display:grid;grid-template-columns:2fr 1fr 1fr;gap:10px;">
<div class="form-group"><label class="form-label">Kelurahan</label>
<select id="fWilayah" class="form-control"><option value="">- pilih -</option>
<?php foreach ($wilayahList as $w): ?><option value="<?= $w['id'] ?>"><?= htmlspecialchars($w['nama']) ?></option><?php endforeach; ?>
</select></div>
<div class="form-group"><label class="form-label">RT</label><input id="fRt" class="form-control"></div>
<div class="form-group"><label class="form-label">RW</label><input id="fRw" class="form-control"></div>
</div>
<h4 style="margin:10px 0 8px;font-size:0.85rem;color:#2563EB;">Demografi & Ekonomi</h4>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<div class="form-group"><label class="form-label">Jumlah Jiwa</label><input id="fJiwa" type="number" min="0" class="form-control"></div>
<div class="form-group"><label class="form-label">Jumlah Tanggungan</label><input id="fTanggungan" type="number" min="0" class="form-control"></div>
<div class="form-group"><label class="form-label">Pekerjaan KK</label><input id="fPekerjaan" class="form-control"></div>
<div class="form-group"><label class="form-label">Penghasilan/bln (Rp)</label><input id="fPenghasilan" type="number" min="0" class="form-control"></div>
</div>
<h4 style="margin:10px 0 8px;font-size:0.85rem;color:#2563EB;">Kondisi Rumah</h4>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<div class="form-group"><label class="form-label">Sumber Air</label>
<select id="fAir" class="form-control"><option value="">-</option><option>SUMUR_BOR</option><option>PDAM</option><option>SUNGAI</option><option>HUJAN</option><option>TIDAK_ADA</option></select></div>
<div class="form-group"><label class="form-label">Listrik</label>
<select id="fListrik" class="form-control"><option value="">-</option><option>PLN</option><option>NON_PLN</option><option>TIDAK_ADA</option></select></div>
<div class="form-group"><label class="form-label">Material Dinding</label>
<select id="fDinding" class="form-control"><option value="">-</option><option>PERMANEN</option><option>SEMI_PERMANEN</option><option>TIDAK_PERMANEN</option></select></div>
<div class="form-group"><label class="form-label">Status Kepemilikan</label>
<select id="fKepemilikan" class="form-control"><option value="">-</option><option>MILIK</option><option>SEWA</option><option>NUMPANG</option><option>LAINNYA</option></select></div>
</div>
<div class="form-group"><label class="form-label">Status Verifikasi</label>
<select id="fVerif" class="form-control"><option value="belum">Belum</option><option value="terverifikasi">Terverifikasi</option><option value="perlu_tinjauan">Perlu Tinjauan</option></select></div>
<h4 style="margin:10px 0 8px;font-size:0.85rem;color:#2563EB;">Lokasi *</h4>
<div id="miniMap" style="height:220px;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 id="fLat" class="form-control" placeholder="Latitude" readonly style="background:#F9FAFB;">
<input id="fLng" class="form-control" placeholder="Longitude" readonly style="background:#F9FAFB;">
</div>
</div>
<div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal('modalForm')">Batal</button><button class="btn btn-primary" onclick="simpan()"><i class="fas fa-save"></i> Simpan</button></div>
</div></div>
<!-- ===== Modal Detail: Bantuan & Foto ===== -->
<div class="modal-overlay" id="modalDetail"><div class="modal" style="max-width:640px;">
<div class="modal-header"><span class="modal-title" id="detailTitle">Kelola</span><button class="modal-close" onclick="closeModal('modalDetail')">×</button></div>
<div style="max-height:70vh;overflow-y:auto;">
<h4 style="font-size:0.9rem;margin-bottom:8px;">💳 Status Bantuan</h4>
<div id="bantuanList" style="margin-bottom:10px;"></div>
<div style="display:grid;grid-template-columns:2fr 1fr 1fr auto;gap:8px;align-items:end;background:#F9FAFB;padding:10px;border-radius:8px;">
<div><label class="form-label" style="font-size:0.75rem">Jenis</label>
<select id="bJenis" class="form-control">
<?php foreach ($jenisList as $j): ?><option value="<?= $j['id'] ?>"><?= htmlspecialchars($j['kode']) ?></option><?php endforeach; ?>
</select></div>
<div><label class="form-label" style="font-size:0.75rem">Mulai</label><input id="bMulai" type="date" class="form-control"></div>
<div><label class="form-label" style="font-size:0.75rem">Berakhir</label><input id="bBerakhir" type="date" class="form-control"></div>
<button class="btn btn-primary btn-sm" onclick="addBantuan()"><i class="fas fa-plus"></i></button>
</div>
<h4 style="font-size:0.9rem;margin:16px 0 8px;">📷 Foto Kondisi Rumah (maks 5)</h4>
<div id="fotoGallery" style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:10px;"></div>
<input type="file" id="fotoInput" accept="image/*" multiple style="font-size:0.85rem;">
<button class="btn btn-primary btn-sm" onclick="uploadFoto()"><i class="fas fa-upload"></i> Unggah</button>
</div>
<div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal('modalDetail')">Tutup</button></div>
</div></div>
<!-- ===== Modal Import ===== -->
<div class="modal-overlay" id="modalImport"><div class="modal">
<div class="modal-header"><span class="modal-title">📥 Import CSV</span><button class="modal-close" onclick="closeModal('modalImport')">×</button></div>
<p style="font-size:0.85rem;color:var(--text-secondary);margin-bottom:10px;">Format kolom (Lampiran A): <code>nik_kk, nama_kk, kode_kelurahan, latitude, longitude, jumlah_jiwa, sumber_air, status_listrik, material_dinding, pekerjaan_kk</code></p>
<a href="<?= app_url('api/warga_export.php?format=template') ?>" class="btn btn-ghost btn-sm" style="margin-bottom:12px;"><i class="fas fa-download"></i> Unduh Template CSV</a>
<div class="form-group"><input type="file" id="importFile" accept=".csv" class="form-control"></div>
<div id="importResult" style="font-size:0.85rem;"></div>
<div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal('modalImport')">Batal</button><button class="btn btn-primary" onclick="doImport()"><i class="fas fa-file-import"></i> Proses</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';
const API_BANTUAN = APP_BASE + '/api/bantuan.php';
const API_FOTO = APP_BASE + '/api/foto.php';
let miniMap=null, marker=null, currentDetailId=null;
function ensureMap(lat=-0.0500, lng=109.3450){
setTimeout(()=>{
if(!miniMap){
miniMap=L.map('miniMap').setView([lat,lng],13);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',{subdomains:'abcd'}).addTo(miniMap);
miniMap.on('click',e=>setMarker(e.latlng.lat,e.latlng.lng));
} else { miniMap.invalidateSize(); miniMap.setView([lat,lng],13); }
},120);
}
function setMarker(lat,lng){
if(!marker) marker=L.marker([lat,lng]).addTo(miniMap); else marker.setLatLng([lat,lng]);
document.getElementById('fLat').value=lat.toFixed(6);
document.getElementById('fLng').value=lng.toFixed(6);
}
function clearForm(){
['fId','fNama','fNik','fAlamat','fRt','fRw','fJiwa','fTanggungan','fPekerjaan','fPenghasilan','fLat','fLng'].forEach(i=>document.getElementById(i).value='');
['fWilayah','fAir','fListrik','fDinding','fKepemilikan'].forEach(i=>document.getElementById(i).value='');
document.getElementById('fVerif').value='belum';
if(marker&&miniMap){miniMap.removeLayer(marker);marker=null;}
}
function openTambah(){ clearForm(); document.getElementById('formTitle').textContent='Tambah Data'; openModal('modalForm'); ensureMap(); }
function editRow(r){
clearForm();
document.getElementById('formTitle').textContent='Edit: '+r.nama_kk;
document.getElementById('fId').value=r.id;
document.getElementById('fNama').value=r.nama_kk||'';
document.getElementById('fNik').value=r.nik_kk||'';
document.getElementById('fAlamat').value=r.alamat||'';
document.getElementById('fWilayah').value=r.wilayah_id||'';
document.getElementById('fRt').value=r.rt||''; document.getElementById('fRw').value=r.rw||'';
document.getElementById('fJiwa').value=r.jumlah_jiwa||''; document.getElementById('fTanggungan').value=r.jumlah_tanggungan||'';
document.getElementById('fPekerjaan').value=r.pekerjaan_kk||''; document.getElementById('fPenghasilan').value=r.penghasilan||'';
document.getElementById('fAir').value=r.sumber_air||''; document.getElementById('fListrik').value=r.status_listrik||'';
document.getElementById('fDinding').value=r.material_dinding||''; document.getElementById('fKepemilikan').value=r.status_kepemilikan||'';
document.getElementById('fVerif').value=r.status_verifikasi||'belum';
openModal('modalForm');
ensureMap();
}
function payload(){
return {
nama_kk:document.getElementById('fNama').value.trim(),
nik_kk:document.getElementById('fNik').value.trim(),
alamat:document.getElementById('fAlamat').value.trim(),
wilayah_id:document.getElementById('fWilayah').value||null,
rt:document.getElementById('fRt').value.trim(), rw:document.getElementById('fRw').value.trim(),
jumlah_jiwa:document.getElementById('fJiwa').value||null,
jumlah_tanggungan:parseInt(document.getElementById('fTanggungan').value)||0,
pekerjaan_kk:document.getElementById('fPekerjaan').value.trim(),
penghasilan:parseFloat(document.getElementById('fPenghasilan').value)||0,
sumber_air:document.getElementById('fAir').value,
status_listrik:document.getElementById('fListrik').value,
material_dinding:document.getElementById('fDinding').value,
status_kepemilikan:document.getElementById('fKepemilikan').value,
status_verifikasi:document.getElementById('fVerif').value
};
}
async function simpan(){
const id=document.getElementById('fId').value;
const p=payload();
if(!p.nama_kk) return showToast('Nama KK wajib!','error');
const lat=parseFloat(document.getElementById('fLat').value), lng=parseFloat(document.getElementById('fLng').value);
if(id){
const res=await fetch(API+'?id='+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)});
const d=await res.json();
if(d.status==='success'){ showToast('Diperbarui!'); setTimeout(()=>location.reload(),800);} else showToast(d.message,'error');
} else {
if(isNaN(lat)||isNaN(lng)) return showToast('Pilih lokasi di peta!','error');
p.geometry={type:'Point',coordinates:[lng,lat]};
const res=await fetch(API,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)});
const d=await res.json();
if(d.status==='success'){ showToast('Data disimpan!'); setTimeout(()=>location.reload(),800);} 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(),700);}else showToast(d.message,'error'); }
// ---- Verifikasi ----
function applyVerifFilter(){
const v=document.getElementById('filterVerif').value;
document.querySelectorAll('#wargaTable tbody tr').forEach(tr=>{
tr.style.display=(!v||tr.dataset.verif===v)?'':'none';
});
}
function toggleAll(c){ document.querySelectorAll('.rowCheck').forEach(x=>x.checked=c.checked); }
async function bulkVerify(){
const ids=[...document.querySelectorAll('.rowCheck:checked')].map(x=>x.value);
if(!ids.length) return showToast('Pilih minimal satu baris','error');
if(!confirm(`Tandai ${ids.length} data sebagai Terverifikasi?`))return;
for(const id of ids){ await fetch(API+'?id='+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({status_verifikasi:'terverifikasi'})}); }
showToast('Verifikasi massal selesai!'); setTimeout(()=>location.reload(),800);
}
// ---- Detail: bantuan & foto ----
async function openDetail(id,nama){
currentDetailId=id;
document.getElementById('detailTitle').textContent='Kelola: '+nama;
openModal('modalDetail');
loadBantuan(); loadFoto();
}
async function loadBantuan(){
const res=await fetch(API_BANTUAN+'?warga_id='+currentDetailId); const d=await res.json();
const box=document.getElementById('bantuanList');
if(!d.data||!d.data.length){ box.innerHTML='<p style="color:#9CA3AF;font-size:0.85rem">Belum ada bantuan.</p>'; return; }
box.innerHTML=d.data.map(b=>`<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 10px;border:1px solid #E5E7EB;border-radius:8px;margin-bottom:6px;">
<div><strong>${b.kode}</strong> <span style="font-size:0.78rem;color:#6B7280">${b.nama}</span>
<div style="font-size:0.72rem;color:#9CA3AF">${b.tgl_mulai||'-'} s/d ${b.tgl_berakhir||'-'}</div></div>
<button class="btn btn-danger btn-sm btn-icon" onclick="delBantuan(${b.id})"><i class="fas fa-trash"></i></button></div>`).join('');
}
async function addBantuan(){
const body={warga_id:currentDetailId,jenis_bantuan_id:document.getElementById('bJenis').value,tgl_mulai:document.getElementById('bMulai').value,tgl_berakhir:document.getElementById('bBerakhir').value};
const res=await fetch(API_BANTUAN,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const d=await res.json(); if(d.status==='success'){showToast('Bantuan ditambah');loadBantuan();}else showToast(d.message,'error');
}
async function delBantuan(id){ const res=await fetch(API_BANTUAN+'?id='+id,{method:'DELETE'}); const d=await res.json(); if(d.status==='success'){showToast('Dihapus');loadBantuan();} }
async function loadFoto(){
const res=await fetch(API_FOTO+'?warga_id='+currentDetailId); const d=await res.json();
const g=document.getElementById('fotoGallery');
if(!d.data||!d.data.length){ g.innerHTML='<p style="color:#9CA3AF;font-size:0.85rem">Belum ada foto.</p>'; return; }
g.innerHTML=d.data.map(f=>`<div style="position:relative;"><img src="${f.url}" style="width:90px;height:90px;object-fit:cover;border-radius:8px;border:1px solid #E5E7EB;">
<button onclick="delFoto(${f.id})" style="position:absolute;top:-6px;right:-6px;background:#EF4444;color:#fff;border:none;border-radius:50%;width:22px;height:22px;cursor:pointer;">×</button></div>`).join('');
}
async function uploadFoto(){
const inp=document.getElementById('fotoInput'); if(!inp.files.length) return showToast('Pilih foto dulu','error');
const fd=new FormData(); fd.append('warga_id',currentDetailId);
for(const f of inp.files) fd.append('foto[]',f);
const res=await fetch(API_FOTO,{method:'POST',body:fd}); const d=await res.json();
if(d.status==='success'){showToast(d.message);inp.value='';loadFoto();}else showToast(d.message,'error');
}
async function delFoto(id){ const res=await fetch(API_FOTO+'?id='+id,{method:'DELETE'}); const d=await res.json(); if(d.status==='success'){showToast('Foto dihapus');loadFoto();} }
// ---- Import ----
async function doImport(){
const inp=document.getElementById('importFile'); if(!inp.files.length) return showToast('Pilih file CSV','error');
const fd=new FormData(); fd.append('file',inp.files[0]);
document.getElementById('importResult').innerHTML='Memproses...';
const res=await fetch(APP_BASE+'/api/warga_import.php',{method:'POST',body:fd}); const d=await res.json();
if(d.status==='success'){
document.getElementById('importResult').innerHTML=`<div style="color:#059669">✔ ${d.data.berhasil} baris diimpor.</div>`+
(d.data.gagal?`<div style="color:#DC2626">${d.data.gagal} gagal:<br>${d.data.errors.join('<br>')}</div>`:'');
setTimeout(()=>location.reload(),1800);
} else { document.getElementById('importResult').innerHTML=`<div style="color:#DC2626">${d.message}</div>`; }
}
</script>
JS; ?>
<?php require_once __DIR__ . '/partials/footer.php'; ?>
+171
View File
@@ -0,0 +1,171 @@
<?php
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/auth_check.php';
requireAnyRole(['operator']);
$pdo = Database::getConnection();
// KPI kemiskinan
$stats = $pdo->query("SELECT
(SELECT COUNT(*) FROM warga_miskin) AS total_warga,
(SELECT COUNT(*) FROM warga_miskin WHERE status_verifikasi='terverifikasi') AS terverifikasi,
(SELECT COUNT(*) FROM warga_miskin WHERE status_verifikasi='belum') AS belum,
(SELECT COUNT(*) FROM warga_miskin WHERE status_verifikasi='perlu_tinjauan') AS perlu_tinjauan,
(SELECT COUNT(*) FROM wilayah) AS total_wilayah,
(SELECT COUNT(DISTINCT warga_id) FROM bantuan_sosial WHERE is_aktif=1) AS warga_dibantu,
(SELECT COUNT(*) FROM users) AS total_users
")->fetch();
$total = (int)$stats['total_warga'];
$dibantu = (int)$stats['warga_dibantu'];
$cakupan = $total > 0 ? round($dibantu / $total * 100, 1) : 0;
$belumDibantu = $total - $dibantu;
// Zona prioritas: kelurahan dengan % miskin > 5%
$zona = $pdo->query("
SELECT COUNT(*) AS cnt FROM (
SELECT w.id, (COUNT(wm.id) / NULLIF(w.jumlah_penduduk,0) * 100) AS persen
FROM wilayah w LEFT JOIN warga_miskin wm ON ST_Contains(w.geom, wm.geom)
GROUP BY w.id HAVING persen > 5
) sub
")->fetch();
$zonaPrioritas = (int)($zona['cnt'] ?? 0);
// Data terbaru
$recentWarga = $pdo->query("
SELECT w.nama_kk, w.penghasilan, w.status_verifikasi, wl.nama AS kelurahan, w.created_at
FROM warga_miskin w LEFT JOIN wilayah wl ON wl.id=w.wilayah_id
ORDER BY w.id DESC LIMIT 6
")->fetchAll();
// Rekap per kelurahan
$rekapWilayah = $pdo->query("
SELECT w.nama, w.jumlah_penduduk, COUNT(wm.id) AS miskin
FROM wilayah w LEFT JOIN warga_miskin wm ON ST_Contains(w.geom, wm.geom)
GROUP BY w.id ORDER BY miskin DESC
")->fetchAll();
$pageTitle = 'Dashboard';
$activeNav = 'dashboard';
require_once __DIR__ . '/partials/header.php';
function vBadge($s) {
return match ($s) {
'terverifikasi' => '<span class="badge badge-success">Terverifikasi</span>',
'perlu_tinjauan' => '<span class="badge badge-warning">Perlu Tinjauan</span>',
default => '<span class="badge" style="background:#F3F4F6;color:#6B7280;">Belum</span>',
};
}
?>
<!-- Quick access -->
<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;">🗺️ Peta Pemetaan Kemiskinan</h3>
<p style="color:#3B82F6; font-size:0.88rem; max-width:520px;">
Visualisasikan sebaran kemiskinan (choropleth & heatmap), kelola data rumah tangga, dan analisis zona prioritas.
</p>
</div>
<a href="<?= app_url('admin/peta.php') ?>" class="btn btn-primary"><i class="fas fa-map-marked-alt"></i> Buka Peta</a>
</div>
</div>
<!-- KPI -->
<div class="stats-grid">
<div class="stat-card primary">
<div class="stat-icon"><i class="fas fa-house-user"></i></div>
<div class="stat-body">
<div class="stat-label">Total Rumah Tangga Miskin</div>
<div class="stat-value"><?= $total ?></div>
<div class="stat-change"><?= $stats['total_wilayah'] ?> kelurahan</div>
</div>
</div>
<div class="stat-card success">
<div class="stat-icon"><i class="fas fa-check-circle"></i></div>
<div class="stat-body">
<div class="stat-label">Terverifikasi</div>
<div class="stat-value"><?= $stats['terverifikasi'] ?></div>
<div class="stat-change"><?= $stats['belum'] ?> belum diverifikasi</div>
</div>
</div>
<div class="stat-card info">
<div class="stat-icon"><i class="fas fa-hand-holding-heart"></i></div>
<div class="stat-body">
<div class="stat-label">Cakupan Bantuan</div>
<div class="stat-value"><?= $cakupan ?>%</div>
<div class="stat-change"><?= $dibantu ?> dari <?= $total ?> RT</div>
</div>
</div>
<div class="stat-card warning">
<div class="stat-icon"><i class="fas fa-user-clock"></i></div>
<div class="stat-body">
<div class="stat-label">Belum Terima Bantuan</div>
<div class="stat-value"><?= $belumDibantu ?></div>
<div class="stat-change">kandidat prioritas</div>
</div>
</div>
<div class="stat-card danger">
<div class="stat-icon"><i class="fas fa-triangle-exclamation"></i></div>
<div class="stat-body">
<div class="stat-label">Zona Prioritas</div>
<div class="stat-value"><?= $zonaPrioritas ?></div>
<div class="stat-change">kelurahan &gt; 5% miskin</div>
</div>
</div>
<div class="stat-card violet">
<div class="stat-icon"><i class="fas fa-clipboard-check"></i></div>
<div class="stat-body">
<div class="stat-label">Perlu Tinjauan</div>
<div class="stat-value"><?= $stats['perlu_tinjauan'] ?></div>
<div class="stat-change">data menunggu review</div>
</div>
</div>
</div>
<!-- Content row -->
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:20px; margin-bottom:24px;">
<!-- Rekap per kelurahan -->
<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;">📊 Rekap per Kelurahan</h3>
</div>
<?php foreach ($rekapWilayah as $w): $p = $w['jumlah_penduduk'] > 0 ? round($w['miskin']/$w['jumlah_penduduk']*100,1) : 0; ?>
<div style="margin-bottom:10px;">
<div style="display:flex;justify-content:space-between;font-size:0.85rem;margin-bottom:3px;">
<span><?= htmlspecialchars($w['nama']) ?></span>
<strong><?= $w['miskin'] ?> RT (<?= $p ?>%)</strong>
</div>
<div style="height:7px;background:#F3F4F6;border-radius:4px;overflow:hidden;">
<div style="height:100%;width:<?= min($p*8,100) ?>%;background:<?= $p>10?'#EF4444':($p>5?'#FB923C':($p>2?'#FBBF24':'#10B981')) ?>;"></div>
</div>
</div>
<?php endforeach; ?>
<?php if (!$rekapWilayah): ?><p style="color:var(--text-muted);font-size:0.85rem;">Belum ada data wilayah.</p><?php endif; ?>
</div>
<!-- Data terbaru -->
<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;">👥 Rumah Tangga Terbaru</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);"><?= htmlspecialchars($w['kelurahan'] ?: 'Tanpa kelurahan') ?> · Rp <?= number_format($w['penghasilan'],0,',','.') ?></div>
</div>
<?= vBadge($w['status_verifikasi']) ?>
</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'; ?>
+59
View File
@@ -0,0 +1,59 @@
<?php
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/auth_check.php';
requireAnyRole(['operator']);
$pdo = Database::getConnection();
$rows = $pdo->query(
"SELECT id, username, aksi, tabel_target, record_id, keterangan, ip_address, created_at
FROM activity_log ORDER BY id DESC LIMIT 300"
)->fetchAll();
$pageTitle = 'Log Aktivitas';
$activeNav = 'log';
require_once __DIR__ . '/partials/header.php';
function aksiBadge(string $a): string {
return match ($a) {
'login' => '<span class="badge badge-info">login</span>',
'logout' => '<span class="badge" style="background:#F3F4F6;color:#6B7280;">logout</span>',
'create' => '<span class="badge badge-success">create</span>',
'update' => '<span class="badge badge-warning">update</span>',
'delete' => '<span class="badge badge-danger">delete</span>',
default => '<span class="badge">' . htmlspecialchars($a) . '</span>',
};
}
?>
<div class="page-header">
<div class="breadcrumb"><span>Operator</span><span class="sep">/</span><span>Log Aktivitas</span></div>
<h1>📋 Log Aktivitas Sistem</h1>
<p>Audit trail aktivitas pengguna terautentikasi (KF-04). Menampilkan 300 entri terbaru.</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 log..." oninput="filterTable('searchInput','logTable')">
</div>
</div>
<div class="table-wrap">
<table class="data-table" id="logTable">
<thead><tr>
<th>Waktu</th><th>User</th><th>Aksi</th><th>Tabel</th><th>Record</th><th>Keterangan</th><th>IP</th>
</tr></thead>
<tbody>
<?php foreach ($rows as $r): ?>
<tr>
<td style="font-size:0.8rem;color:var(--text-muted);white-space:nowrap;"><?= date('d M Y H:i:s', strtotime($r['created_at'])) ?></td>
<td><strong><?= htmlspecialchars($r['username'] ?: '-') ?></strong></td>
<td><?= aksiBadge($r['aksi']) ?></td>
<td style="font-size:0.82rem;"><?= htmlspecialchars($r['tabel_target'] ?: '-') ?></td>
<td style="font-size:0.82rem;color:var(--text-muted);"><?= $r['record_id'] !== null ? (int)$r['record_id'] : '-' ?></td>
<td style="font-size:0.85rem;"><?= htmlspecialchars($r['keterangan'] ?: '-') ?></td>
<td style="font-size:0.78rem;color:var(--text-muted);"><?= htmlspecialchars($r['ip_address'] ?: '-') ?></td>
</tr>
<?php endforeach; ?>
<?php if (!$rows): ?><tr><td colspan="7" style="text-align:center;padding:40px;color:var(--text-muted);">Belum ada aktivitas tercatat.</td></tr><?php endif; ?>
</tbody>
</table>
</div>
<?php require_once __DIR__ . '/partials/footer.php'; ?>
+54
View File
@@ -0,0 +1,54 @@
</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';
});
};
</script>
<?= $extraScript ?? '' ?>
</body>
</html>
+106
View File
@@ -0,0 +1,106 @@
<?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 ?? 'Panel Operator') ?> — WebGIS Kemiskinan</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">Panel Operator</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 Kemiskinan</div>
<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> Data Rumah Tangga
</a>
<a href="<?= app_url('admin/wilayah.php') ?>"
class="nav-item <?= ($activeNav==='wilayah') ? 'active' : '' ?>">
<span class="nav-icon"><i class="fas fa-draw-polygon"></i></span> Kelola Wilayah
</a>
<a href="<?= app_url('admin/analisis.php') ?>"
class="nav-item <?= ($activeNav==='analisis') ? 'active' : '' ?>">
<span class="nav-icon"><i class="fas fa-chart-line"></i></span> Analisis Spasial
</a>
<a href="<?= app_url('admin/pelaporan.php') ?>"
class="nav-item <?= ($activeNav==='pelaporan') ? 'active' : '' ?>">
<span class="nav-icon"><i class="fas fa-file-pdf"></i></span> Laporan &amp; Cetak
</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>
<a href="<?= app_url('admin/log.php') ?>"
class="nav-item <?= ($activeNav==='log') ? 'active' : '' ?>">
<span class="nav-icon"><i class="fas fa-clipboard-list"></i></span> Log Aktivitas
</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">Operator</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">
+10
View File
@@ -0,0 +1,10 @@
<?php
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/auth_check.php';
requireAnyRole(['operator']);
$pdo = Database::getConnection();
$pageTitle = 'Laporan & Cetak';
$activeNav = 'pelaporan';
require_once __DIR__ . '/partials/header.php';
require_once __DIR__ . '/../partials/laporan_body.php';
require_once __DIR__ . '/partials/footer.php';
+31
View File
@@ -0,0 +1,31 @@
<?php
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/auth_check.php';
requireAnyRole(['operator']);
$pageTitle = 'Peta Interaktif';
$activeNav = 'peta';
$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" style="display:flex;align-items:flex-start;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:12px;">
<div>
<h1>🗺️ Peta Pemetaan Kemiskinan</h1>
<p>Sebaran rumah tangga miskin, choropleth tingkat kemiskinan, heatmap kepadatan, dan fasilitas publik.</p>
</div>
<a href="<?= app_url('admin/data_warga.php') ?>" class="btn btn-primary" style="white-space:nowrap;">
<i class="fas fa-plus"></i> Kelola Data Warga</a>
</div>
<div>
<div id="skpl-map" style="height:74vh;border-radius:12px;border:1px solid #E5E7EB;z-index:1;"></div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
<script src="<?= app_url('assets/js/skpl-layers.js') ?>?v=2"></script>
<script>const SKPL_ROLE = 'operator';</script>
<script src="<?= app_url('assets/js/skpl-map.js') ?>?v=1"></script>
<?php $extraScript = ''; ?>
<?php require_once __DIR__ . '/partials/footer.php'; ?>
+235
View File
@@ -0,0 +1,235 @@
<?php
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/auth_check.php';
requireAnyRole(['operator']);
$pdo = Database::getConnection();
$rows = $pdo->query(
"SELECT id, username, email, role, is_active, wilayah_tugas, nama_lengkap, last_login, created_at
FROM users ORDER BY id ASC"
)->fetchAll();
$wilayahList = $pdo->query("SELECT nama FROM wilayah ORDER BY nama")->fetchAll();
$pageTitle = 'Kelola Users';
$activeNav = 'users';
require_once __DIR__ . '/partials/header.php';
// Helper badge per role
function roleBadge(string $role): string {
return match ($role) {
'operator' => '<span class="badge badge-danger">🛠️ Operator</span>',
'pimpinan' => '<span class="badge" style="background:#EDE9FE;color:#6D28D9;">📊 Pimpinan</span>',
'enumerator' => '<span class="badge badge-info">📋 Enumerator</span>',
'publik' => '<span class="badge" style="background:#F3F4F6;color:#6B7280;">🌐 Publik</span>',
default => htmlspecialchars($role),
};
}
?>
<div class="page-header">
<div class="breadcrumb"><span>Operator</span><span class="sep">/</span><span>Users</span></div>
<h1>👤 Manajemen User Sistem</h1>
<p>Kelola akun 4 peran: Publik, Enumerator, Operator, dan Pimpinan (KF-02 / KF-03).</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>Email</th><th>Role</th>
<th>Wilayah</th><th>Status</th><th>Login Terakhir</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 style="font-size:0.82rem;color:var(--text-muted)"><?= htmlspecialchars($r['email'] ?: '-') ?></td>
<td><?= roleBadge($r['role']) ?></td>
<td style="font-size:0.82rem"><?= htmlspecialchars($r['wilayah_tugas'] ?: '-') ?></td>
<td>
<?php if ((int)$r['is_active'] === 1): ?>
<span class="badge badge-success">Aktif</span>
<?php else: ?>
<span class="badge badge-danger">Nonaktif</span>
<?php endif; ?>
</td>
<td style="color:var(--text-muted); font-size:0.78rem;">
<?= $r['last_login'] ? date('d M Y H:i', strtotime($r['last_login'])) : 'Belum pernah' ?>
</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" id="tUsername" class="form-control" placeholder="username unik" required></div>
<div class="form-group"><label class="form-label">Nama Lengkap</label><input type="text" id="tNama" class="form-control" placeholder="Nama lengkap"></div>
<div class="form-group"><label class="form-label">Email</label><input type="email" id="tEmail" class="form-control" placeholder="email@contoh.id"></div>
<div class="form-group"><label class="form-label">Role *</label>
<select id="tRole" class="form-control" onchange="toggleWilayah('t')">
<option value="enumerator">📋 Enumerator</option>
<option value="operator">🛠️ Operator</option>
<option value="pimpinan">📊 Pimpinan</option>
<option value="publik">🌐 Publik</option>
</select>
</div>
<div class="form-group" id="tWilayahGroup"><label class="form-label">Wilayah Tugas</label>
<select id="tWilayah" class="form-control"><option value="">- pilih kelurahan -</option>
<?php foreach ($wilayahList as $w): ?><option value="<?= htmlspecialchars($w['nama']) ?>"><?= htmlspecialchars($w['nama']) ?></option><?php endforeach; ?>
</select></div>
<div class="form-group"><label class="form-label">Password *</label><input type="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">Email</label><input type="email" id="editEmail" class="form-control"></div>
<div class="form-group"><label class="form-label">Role *</label>
<select id="editRole" class="form-control" onchange="toggleWilayah('edit')">
<option value="enumerator">📋 Enumerator</option>
<option value="operator">🛠️ Operator</option>
<option value="pimpinan">📊 Pimpinan</option>
<option value="publik">🌐 Publik</option>
</select>
</div>
<div class="form-group" id="editWilayahGroup"><label class="form-label">Wilayah Tugas</label>
<select id="editWilayah" class="form-control"><option value="">- pilih kelurahan -</option>
<?php foreach ($wilayahList as $w): ?><option value="<?= htmlspecialchars($w['nama']) ?>"><?= htmlspecialchars($w['nama']) ?></option><?php endforeach; ?>
</select></div>
<div class="form-group"><label class="form-label">Status</label>
<select id="editActive" class="form-control">
<option value="1">Aktif</option>
<option value="0">Nonaktif</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 diubah" minlength="6">
</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';
// Wilayah tugas hanya relevan untuk enumerator
function toggleWilayah(prefix) {
const role = document.getElementById(prefix + 'Role').value;
const group = document.getElementById(prefix + 'WilayahGroup');
group.style.display = (role === 'enumerator') ? 'block' : 'none';
}
function editRow(r) {
document.getElementById('editId').value = r.id;
document.getElementById('editUsername').value = r.username;
document.getElementById('editNama').value = r.nama_lengkap || '';
document.getElementById('editEmail').value = r.email || '';
document.getElementById('editRole').value = r.role;
document.getElementById('editWilayah').value = r.wilayah_tugas || '';
document.getElementById('editActive').value = String(r.is_active);
document.getElementById('editPassword').value = '';
toggleWilayah('edit');
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,
email: document.getElementById('tEmail').value,
role: document.getElementById('tRole').value,
wilayah_tugas: document.getElementById('tWilayah').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,
email: document.getElementById('editEmail').value,
role: document.getElementById('editRole').value,
wilayah_tugas: document.getElementById('editWilayah').value,
is_active: document.getElementById('editActive').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('Akun 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');
}
toggleWilayah('t');
</script>
JS; ?>
<?php require_once __DIR__ . '/partials/footer.php'; ?>
+160
View File
@@ -0,0 +1,160 @@
<?php
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/auth_check.php';
requireAnyRole(['operator']);
$pageTitle = 'Kelola Wilayah';
$activeNav = 'wilayah';
$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">
';
require_once __DIR__ . '/partials/header.php';
?>
<div class="page-header" style="margin-bottom:12px;">
<div class="breadcrumb"><span>Operator</span><span class="sep">/</span><span>Kelola Wilayah</span></div>
<h1>🗺️ Kelola Batas Wilayah (Kelurahan)</h1>
<p>Gambar polygon batas kelurahan di peta, ubah bentuk/ukuran (geser titik), atur jumlah penduduk, atau hapus.</p>
</div>
<div style="display:grid;grid-template-columns:1.7fr 1fr;gap:16px;">
<div style="position:relative;">
<div id="wil-map" style="height:72vh;border-radius:12px;border:1px solid #E5E7EB;z-index:1;"></div>
<div style="position:absolute;bottom:12px;left:12px;z-index:500;background:#fff;padding:8px 12px;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,.15);font-size:0.78rem;max-width:260px;">
<b>Cara pakai:</b> klik ikon ⬠ (kiri atas peta) untuk gambar polygon baru. Ikon ✏️ untuk geser titik/ubah ukuran, 🗑️ untuk hapus. Klik polygon untuk edit atribut.
</div>
</div>
<div class="card">
<h3 id="formTitle" style="font-size:0.95rem;margin-bottom:10px;">Atribut Wilayah</h3>
<input type="hidden" id="wId">
<div class="form-group"><label class="form-label">Nama Kelurahan *</label><input id="wNama" class="form-control" placeholder="mis. Kel. Sungai Jawi"></div>
<div class="form-group"><label class="form-label">Kode Wilayah (BPS)</label><input id="wKode" class="form-control" placeholder="mis. 6171010005"></div>
<div class="form-group"><label class="form-label">Jenis</label>
<select id="wJenis" class="form-control"><option value="kelurahan">Kelurahan</option><option value="kecamatan">Kecamatan</option></select></div>
<div class="form-group"><label class="form-label">Jumlah Penduduk (KK)</label><input id="wPenduduk" type="number" min="0" class="form-control" placeholder="0"></div>
<div id="geomNote" style="font-size:0.78rem;color:#9CA3AF;margin-bottom:10px;">Belum ada polygon. Gambar di peta dulu untuk wilayah baru.</div>
<button class="btn btn-primary" style="width:100%;" onclick="simpanWilayah()"><i class="fas fa-save"></i> Simpan</button>
<button class="btn btn-ghost" style="width:100%;margin-top:6px;" onclick="resetForm()">Bersihkan Form</button>
<h3 style="font-size:0.95rem;margin:16px 0 8px;">Daftar Wilayah</h3>
<div id="wList" style="font-size:0.85rem;"></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>
const WAPI = APP_BASE + '/api/wilayah.php';
let map, drawn, pendingLayer = null;
const layerById = {};
function colorMiskin(p){ return p>=10?'#EF4444':p>=5?'#FB923C':p>=2?'#FBBF24':p>0?'#A3E635':'#2ECC71'; }
function initMap(){
map = L.map('wil-map').setView([-0.0500,109.3450],13);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',{subdomains:'abcd',maxZoom:19}).addTo(map);
drawn = new L.FeatureGroup().addTo(map);
const drawControl = new L.Control.Draw({
edit: { featureGroup: drawn, remove: true },
draw: { polygon:{allowIntersection:false,showArea:true}, marker:false, polyline:false, circle:false, circlemarker:false, rectangle:false }
});
map.addControl(drawControl);
map.on(L.Draw.Event.CREATED, e => {
pendingLayer = e.layer;
drawn.addLayer(pendingLayer);
resetForm(); // mode wilayah baru
document.getElementById('formTitle').textContent = 'Wilayah Baru (polygon tergambar)';
document.getElementById('geomNote').innerHTML = '<span style="color:#059669">✔ Polygon siap. Isi atribut lalu Simpan.</span>';
});
map.on(L.Draw.Event.EDITED, async e => {
for (const layer of Object.values(e.layers._layers)) {
const id = layer.wilayahId; if(!id) continue;
const p = layer.props || {};
await fetch(`${WAPI}?id=${id}`, {method:'PUT',headers:{'Content-Type':'application/json'},
body:JSON.stringify({nama:p.nama,kode_wilayah:p.kode_wilayah,jenis:p.jenis,jumlah_penduduk:p.jumlah_penduduk,geometry:layer.toGeoJSON().geometry})});
}
showToast('Bentuk wilayah diperbarui'); loadWilayah();
});
map.on(L.Draw.Event.DELETED, async e => {
for (const layer of Object.values(e.layers._layers)) {
if (layer.wilayahId) await fetch(`${WAPI}?id=${layer.wilayahId}`, {method:'DELETE'});
}
showToast('Wilayah dihapus'); loadWilayah();
});
}
async function loadWilayah(){
Object.keys(layerById).forEach(k=>delete layerById[k]);
drawn.clearLayers(); pendingLayer=null;
const d = (await (await fetch(WAPI)).json()).data;
(d.features||[]).forEach(f=>{
const layer = L.geoJSON(f,{style:{color:'#374151',weight:1.5,fillColor:colorMiskin(f.properties.persentase_miskin),fillOpacity:.45}});
layer.eachLayer(l=>{
l.wilayahId = f.properties.id; l.props = f.properties;
l.bindTooltip(f.properties.nama,{sticky:true});
l.on('click',()=>editAttr(f.properties));
drawn.addLayer(l); layerById[f.properties.id]=l;
});
});
// daftar
document.getElementById('wList').innerHTML = (d.features||[]).map(f=>{const p=f.properties;
return `<div style="display:flex;justify-content:space-between;align-items:center;padding:7px 0;border-bottom:1px solid #F3F4F6;">
<div><b>${p.nama}</b><div style="font-size:0.72rem;color:#9CA3AF;">${p.jumlah_penduduk} KK · ${p.jumlah_miskin} miskin · ${p.persentase_miskin}%</div></div>
<div style="display:flex;gap:4px;">
<button class="btn btn-ghost btn-sm btn-icon" title="Edit atribut" onclick='editAttr(${JSON.stringify(p)})'><i class="fas fa-pen"></i></button>
<button class="btn btn-ghost btn-sm btn-icon" title="Zoom" onclick="zoomTo(${p.id})"><i class="fas fa-magnifying-glass"></i></button>
<button class="btn btn-danger btn-sm btn-icon" title="Hapus" onclick="hapusWilayah(${p.id},'${p.nama.replace(/'/g,'')}')"><i class="fas fa-trash"></i></button>
</div></div>`;}).join('') || '<p style="color:#9CA3AF">Belum ada wilayah.</p>';
}
function zoomTo(id){ const l=layerById[id]; if(l) map.fitBounds(l.getBounds(),{padding:[40,40]}); }
function editAttr(p){
document.getElementById('formTitle').textContent = 'Edit Atribut: '+p.nama;
document.getElementById('wId').value = p.id;
document.getElementById('wNama').value = p.nama||'';
document.getElementById('wKode').value = p.kode_wilayah||'';
document.getElementById('wJenis').value = p.jenis||'kelurahan';
document.getElementById('wPenduduk').value = p.jumlah_penduduk||0;
document.getElementById('geomNote').innerHTML = 'Mengubah atribut wilayah ini (bentuk diubah lewat tool ✏️ di peta).';
pendingLayer = null;
zoomTo(p.id);
}
function resetForm(){
document.getElementById('formTitle').textContent='Atribut Wilayah';
['wId','wNama','wKode','wPenduduk'].forEach(i=>document.getElementById(i).value='');
document.getElementById('wJenis').value='kelurahan';
document.getElementById('geomNote').innerHTML='Belum ada polygon. Gambar di peta dulu untuk wilayah baru.';
}
async function simpanWilayah(){
const nama=document.getElementById('wNama').value.trim();
if(!nama) return showToast('Nama kelurahan wajib','error');
const body={ nama, kode_wilayah:document.getElementById('wKode').value.trim(), jenis:document.getElementById('wJenis').value, jumlah_penduduk:parseInt(document.getElementById('wPenduduk').value)||0 };
const id=document.getElementById('wId').value;
if(pendingLayer && !id){
body.geometry = pendingLayer.toGeoJSON().geometry;
const r=await (await fetch(WAPI,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})).json();
if(r.status==='success'){ showToast('Wilayah baru disimpan'); loadWilayah(); resetForm(); } else showToast(r.message,'error');
} else if(id){
const r=await (await fetch(`${WAPI}?id=${id}`,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})).json();
if(r.status==='success'){ showToast('Atribut diperbarui'); loadWilayah(); } else showToast(r.message,'error');
} else {
showToast('Gambar polygon dulu di peta untuk wilayah baru','error');
}
}
async function hapusWilayah(id,nama){
if(!confirm(`Hapus wilayah "${nama}"?`))return;
const r=await (await fetch(`${WAPI}?id=${id}`,{method:'DELETE'})).json();
if(r.status==='success'){ showToast('Dihapus'); loadWilayah(); resetForm(); } else showToast(r.message,'error');
}
document.addEventListener('DOMContentLoaded',()=>{ initMap(); loadWilayah(); });
</script>
<?php $extraScript=''; ?>
<?php require_once __DIR__ . '/partials/footer.php'; ?>
+63
View File
@@ -0,0 +1,63 @@
<?php
/**
* api/bantuan.php — Status bantuan sosial per rumah tangga (KF-17).
*
* GET ?master=1 → daftar master jenis_bantuan
* GET ?warga_id=ID → daftar bantuan untuk satu warga
* POST {warga_id, jenis_bantuan_id, tgl_mulai, tgl_berakhir, is_aktif, keterangan}
* DELETE ?id=ID
*/
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/helpers.php';
require_once __DIR__ . '/../config/activity.php';
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
requireOperatorForMutation();
switch ($method) {
case 'GET':
if (isset($_GET['master'])) {
sendSuccess($pdo->query("SELECT id, kode, nama, deskripsi FROM jenis_bantuan ORDER BY id")->fetchAll(), 'Jenis Bantuan');
}
$wargaId = $_GET['warga_id'] ?? null;
if (!$wargaId) sendError('warga_id wajib');
$stmt = $pdo->prepare("
SELECT bs.id, bs.jenis_bantuan_id, jb.kode, jb.nama,
bs.tgl_mulai, bs.tgl_berakhir, bs.is_aktif, bs.keterangan
FROM bantuan_sosial bs
JOIN jenis_bantuan jb ON jb.id = bs.jenis_bantuan_id
WHERE bs.warga_id = ?
ORDER BY bs.id DESC
");
$stmt->execute([$wargaId]);
sendSuccess($stmt->fetchAll(), 'Bantuan warga');
break;
case 'POST':
$d = getInput();
if (empty($d['warga_id']) || empty($d['jenis_bantuan_id'])) sendError('warga_id & jenis_bantuan_id wajib');
$pdo->prepare("
INSERT INTO bantuan_sosial (warga_id, jenis_bantuan_id, tgl_mulai, tgl_berakhir, is_aktif, keterangan)
VALUES (?,?,?,?,?,?)
")->execute([
$d['warga_id'], $d['jenis_bantuan_id'],
($d['tgl_mulai'] ?? '') ?: null, ($d['tgl_berakhir'] ?? '') ?: null,
isset($d['is_aktif']) ? (int)$d['is_aktif'] : 1, ($d['keterangan'] ?? '') ?: null,
]);
$id = (int)$pdo->lastInsertId();
logActivity($pdo, 'create', 'bantuan_sosial', $id, "Tambah bantuan warga #{$d['warga_id']}");
sendSuccess(['id' => $id], 'Bantuan ditambahkan', 201);
break;
case 'DELETE':
$id = $_GET['id'] ?? null;
if (!$id) sendError('ID wajib');
$pdo->prepare("DELETE FROM bantuan_sosial WHERE id=?")->execute([$id]);
logActivity($pdo, 'delete', 'bantuan_sosial', (int)$id, 'Hapus bantuan');
sendSuccess(null, 'Bantuan dihapus');
break;
default:
sendError('Method not allowed', 405);
}
+53
View File
@@ -0,0 +1,53 @@
<?php
/**
* api/cakupan_bantuan.php — Analisis cakupan bantuan (KF-22).
* GET → ringkasan cakupan keseluruhan + per jenis bantuan + per kelurahan.
*/
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/helpers.php';
$pdo = Database::getConnection();
requireApiAnyRole(['operator', 'pimpinan']);
if ($_SERVER['REQUEST_METHOD'] !== 'GET') sendError('Method not allowed', 405);
$total = (int)$pdo->query("SELECT COUNT(*) FROM warga_miskin")->fetchColumn();
$dibantu = (int)$pdo->query("SELECT COUNT(DISTINCT warga_id) FROM bantuan_sosial WHERE is_aktif=1")->fetchColumn();
// Per jenis bantuan
$perJenis = $pdo->query("
SELECT jb.kode, jb.nama,
COUNT(DISTINCT CASE WHEN bs.is_aktif=1 THEN bs.warga_id END) AS penerima
FROM jenis_bantuan jb
LEFT JOIN bantuan_sosial bs ON bs.jenis_bantuan_id = jb.id
GROUP BY jb.id ORDER BY jb.id
")->fetchAll();
foreach ($perJenis as &$j) {
$j['penerima'] = (int)$j['penerima'];
$j['persen'] = $total > 0 ? round($j['penerima'] / $total * 100, 1) : 0;
}
unset($j);
// Per kelurahan
$perWilayah = $pdo->query("
SELECT w.nama,
COUNT(DISTINCT wm.id) AS miskin,
COUNT(DISTINCT CASE WHEN bs.id IS NOT NULL THEN wm.id END) AS dibantu
FROM wilayah w
LEFT JOIN warga_miskin wm ON ST_Contains(w.geom, wm.geom)
LEFT JOIN bantuan_sosial bs ON bs.warga_id = wm.id AND bs.is_aktif=1
GROUP BY w.id ORDER BY w.nama
")->fetchAll();
foreach ($perWilayah as &$w) {
$w['miskin'] = (int)$w['miskin']; $w['dibantu'] = (int)$w['dibantu'];
$w['cakupan'] = $w['miskin'] > 0 ? round($w['dibantu'] / $w['miskin'] * 100, 1) : 0;
}
unset($w);
sendSuccess([
'total_warga' => $total,
'dibantu' => $dibantu,
'belum_dibantu' => $total - $dibantu,
'cakupan_persen' => $total > 0 ? round($dibantu / $total * 100, 1) : 0,
'per_jenis' => $perJenis,
'per_wilayah' => $perWilayah,
], 'Cakupan Bantuan');
+21
View File
@@ -0,0 +1,21 @@
<?php
/**
* api/fasilitas.php — GeoJSON fasilitas publik (untuk peta & proximity).
* GET → FeatureCollection titik fasilitas (sekolah/puskesmas/pasar/dll).
*/
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/helpers.php';
$pdo = Database::getConnection();
if ($_SERVER['REQUEST_METHOD'] !== 'GET') sendError('Method not allowed', 405);
$stmt = $pdo->query("SELECT id, nama, jenis, ST_AsGeoJSON(geom) AS geojson FROM fasilitas_publik ORDER BY jenis, nama");
$features = [];
while ($r = $stmt->fetch()) {
$features[] = [
'type' => 'Feature',
'geometry' => json_decode($r['geojson']),
'properties' => ['id' => (int)$r['id'], 'nama' => $r['nama'], 'jenis' => $r['jenis']],
];
}
sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Fasilitas Publik');
+65
View File
@@ -0,0 +1,65 @@
<?php
/**
* api/filter.php — Filter multi-kriteria warga miskin (KF-19).
* GET params (semua opsional):
* wilayah_id, sumber_air, material_dinding, status_kepemilikan, status_listrik,
* verif (status_verifikasi), pekerjaan (LIKE),
* bantuan_jenis (id) + bantuan_mode (menerima|tidak)
* Mengembalikan GeoJSON warga yang cocok + jumlah.
*/
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/helpers.php';
$pdo = Database::getConnection();
requireApiAnyRole(['operator', 'pimpinan']);
if ($_SERVER['REQUEST_METHOD'] !== 'GET') sendError('Method not allowed', 405);
$where = [];
$params = [];
$eq = function ($col, $key) use (&$where, &$params) {
if (isset($_GET[$key]) && $_GET[$key] !== '') { $where[] = "w.$col = ?"; $params[] = $_GET[$key]; }
};
$eq('wilayah_id', 'wilayah_id');
$eq('sumber_air', 'sumber_air');
$eq('material_dinding', 'material_dinding');
$eq('status_kepemilikan', 'status_kepemilikan');
$eq('status_listrik', 'status_listrik');
$eq('status_verifikasi', 'verif');
if (!empty($_GET['pekerjaan'])) { $where[] = "w.pekerjaan_kk LIKE ?"; $params[] = '%' . $_GET['pekerjaan'] . '%'; }
if (!empty($_GET['bantuan_jenis'])) {
$mode = ($_GET['bantuan_mode'] ?? 'menerima') === 'tidak' ? 'NOT EXISTS' : 'EXISTS';
$where[] = "$mode (SELECT 1 FROM bantuan_sosial bs WHERE bs.warga_id=w.id AND bs.jenis_bantuan_id=? AND bs.is_aktif=1)";
$params[] = $_GET['bantuan_jenis'];
}
$sql = "SELECT w.id, w.nama_kk, w.nik_kk, w.penghasilan, w.jumlah_jiwa, w.sumber_air,
w.material_dinding, w.status_verifikasi, w.pekerjaan_kk, wl.nama AS kelurahan,
ST_AsGeoJSON(w.geom) AS geojson,
(SELECT GROUP_CONCAT(jb.kode SEPARATOR ',') FROM bantuan_sosial bs
JOIN jenis_bantuan jb ON jb.id=bs.jenis_bantuan_id
WHERE bs.warga_id=w.id AND bs.is_aktif=1) AS bantuan_aktif
FROM warga_miskin w LEFT JOIN wilayah wl ON wl.id=w.wilayah_id";
if ($where) $sql .= " WHERE " . implode(' AND ', $where);
$sql .= " ORDER BY w.id DESC";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$features = [];
while ($r = $stmt->fetch()) {
$features[] = [
'type' => 'Feature',
'geometry' => json_decode($r['geojson']),
'properties' => [
'id' => (int)$r['id'], 'nama_kk' => $r['nama_kk'], 'nik_kk' => $r['nik_kk'],
'penghasilan' => $r['penghasilan'], 'jumlah_jiwa' => $r['jumlah_jiwa'],
'sumber_air' => $r['sumber_air'], 'material_dinding' => $r['material_dinding'],
'status_verifikasi' => $r['status_verifikasi'], 'pekerjaan_kk' => $r['pekerjaan_kk'],
'kelurahan' => $r['kelurahan'], 'bantuan_aktif' => $r['bantuan_aktif'],
],
];
}
sendSuccess(['type' => 'FeatureCollection', 'features' => $features, 'jumlah' => count($features)], 'Hasil Filter');
+110
View File
@@ -0,0 +1,110 @@
<?php
/**
* api/foto.php — Upload & kelola foto kondisi rumah (KF-13).
*
* GET ?warga_id=ID → daftar foto {id, url}
* POST multipart: warga_id + foto[] (15) → kompres & simpan
* DELETE ?id=ID → hapus foto
*
* Foto dikompres (maks lebar 1024px, JPEG q75) memakai GD.
*/
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/helpers.php';
require_once __DIR__ . '/../config/app.php';
require_once __DIR__ . '/../config/activity.php';
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
// GET: cukup login. Mutasi: operator atau enumerator.
if ($method !== 'GET') requireApiAnyRole(['operator', 'enumerator']);
const MAX_FOTO = 5;
const UPLOAD_DIR = __DIR__ . '/../uploads/rumah/';
function compressImage(string $src, string $dst, int $maxW = 1024, int $quality = 75): bool {
$info = @getimagesize($src);
if (!$info) return false;
[$w, $h] = $info;
switch ($info['mime']) {
case 'image/jpeg': $img = imagecreatefromjpeg($src); break;
case 'image/png': $img = imagecreatefrompng($src); break;
case 'image/webp': $img = imagecreatefromwebp($src); break;
default: return false;
}
if (!$img) return false;
$nw = $w > $maxW ? $maxW : $w;
$nh = (int)round($h * ($nw / $w));
$canvas = imagecreatetruecolor($nw, $nh);
// latar putih untuk PNG transparan
imagefill($canvas, 0, 0, imagecolorallocate($canvas, 255, 255, 255));
imagecopyresampled($canvas, $img, 0, 0, 0, 0, $nw, $nh, $w, $h);
$ok = imagejpeg($canvas, $dst, $quality);
imagedestroy($img);
imagedestroy($canvas);
return $ok;
}
switch ($method) {
case 'GET':
$wargaId = $_GET['warga_id'] ?? null;
if (!$wargaId) sendError('warga_id wajib');
$stmt = $pdo->prepare("SELECT id, file_path FROM foto_rumah WHERE warga_id=? ORDER BY id");
$stmt->execute([$wargaId]);
$out = [];
while ($r = $stmt->fetch()) {
$out[] = ['id' => (int)$r['id'], 'url' => app_url($r['file_path'])];
}
sendSuccess($out, 'Foto rumah');
break;
case 'POST':
$wargaId = $_POST['warga_id'] ?? null;
if (!$wargaId) sendError('warga_id wajib');
if (empty($_FILES['foto'])) sendError('Tidak ada file diunggah');
$existing = (int)$pdo->query("SELECT COUNT(*) FROM foto_rumah WHERE warga_id=" . (int)$wargaId)->fetchColumn();
// Normalisasi $_FILES['foto'] ke array
$files = $_FILES['foto'];
$names = is_array($files['name']) ? $files['name'] : [$files['name']];
$tmps = is_array($files['tmp_name']) ? $files['tmp_name'] : [$files['tmp_name']];
if (!is_dir(UPLOAD_DIR)) mkdir(UPLOAD_DIR, 0775, true);
$saved = 0;
foreach ($tmps as $i => $tmp) {
if ($existing + $saved >= MAX_FOTO) break;
if (!is_uploaded_file($tmp)) continue;
$fname = 'w' . (int)$wargaId . '_' . uniqid() . '.jpg';
$dst = UPLOAD_DIR . $fname;
if (compressImage($tmp, $dst)) {
$rel = 'uploads/rumah/' . $fname;
$pdo->prepare("INSERT INTO foto_rumah (warga_id, file_path) VALUES (?,?)")
->execute([$wargaId, $rel]);
$saved++;
}
}
if ($saved === 0) sendError('Gagal memproses foto (format harus JPG/PNG/WEBP)');
logActivity($pdo, 'create', 'foto_rumah', (int)$wargaId, "Upload {$saved} foto");
sendSuccess(['uploaded' => $saved], "{$saved} foto diunggah", 201);
break;
case 'DELETE':
$id = $_GET['id'] ?? null;
if (!$id) sendError('ID wajib');
$row = $pdo->prepare("SELECT file_path FROM foto_rumah WHERE id=?");
$row->execute([$id]);
$fp = $row->fetchColumn();
if ($fp) {
$abs = __DIR__ . '/../' . $fp;
if (is_file($abs)) @unlink($abs);
$pdo->prepare("DELETE FROM foto_rumah WHERE id=?")->execute([$id]);
logActivity($pdo, 'delete', 'foto_rumah', (int)$id, 'Hapus foto');
}
sendSuccess(null, 'Foto dihapus');
break;
default:
sendError('Method not allowed', 405);
}
+31
View File
@@ -0,0 +1,31 @@
<?php
/**
* api/heatmap.php — Titik kepadatan kemiskinan agregat (KF-08).
*
* GET → daftar [lat, lng, weight] tanpa data pribadi (aman untuk publik).
* weight = jumlah_tanggungan + 1 (intensitas kepadatan).
*/
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/helpers.php';
$pdo = Database::getConnection();
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
sendError('Method not allowed', 405);
}
$stmt = $pdo->query(
"SELECT ST_Y(geom) AS lat, ST_X(geom) AS lng, jumlah_tanggungan
FROM warga_miskin"
);
$points = [];
while ($r = $stmt->fetch()) {
$points[] = [
(float)$r['lat'],
(float)$r['lng'],
(int)$r['jumlah_tanggungan'] + 1,
];
}
sendSuccess($points, 'Heatmap Kepadatan');
+67
View File
@@ -0,0 +1,67 @@
<?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 {
requireApiAnyRole([$role]);
}
function requireApiAnyRole(array $roles): void {
if (session_status() === PHP_SESSION_NONE) {
startAppSession();
}
if (!isset($_SESSION['user_id'], $_SESSION['role'])) {
sendError('Unauthorized', 401);
}
if (!in_array($_SESSION['role'], $roles, true)) {
sendError('Forbidden', 403);
}
}
/**
* Mutasi data (POST/PUT/DELETE) hanya untuk Operator.
* GET tetap terbuka sesuai kebutuhan endpoint.
*/
function requireOperatorForMutation(): void {
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
requireApiAnyRole(['operator']);
}
}
/**
* Alias kompatibilitas lama.
*/
function requireAdminForMutation(): void {
requireOperatorForMutation();
}
+40
View File
@@ -0,0 +1,40 @@
<?php
/**
* api/komparatif.php — Indikator komparatif antar kelurahan (KF-31).
* GET → labels + dataset: persentase_miskin, cakupan_bantuan, jumlah_miskin, persen_verifikasi.
*/
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/helpers.php';
$pdo = Database::getConnection();
requireApiAnyRole(['operator', 'pimpinan']);
if ($_SERVER['REQUEST_METHOD'] !== 'GET') sendError('Method not allowed', 405);
$rows = $pdo->query("
SELECT w.nama, w.jumlah_penduduk,
COUNT(DISTINCT wm.id) AS miskin,
COUNT(DISTINCT CASE WHEN bs.id IS NOT NULL THEN wm.id END) AS dibantu,
COUNT(DISTINCT CASE WHEN wm.status_verifikasi='terverifikasi' THEN wm.id END) AS terverif
FROM wilayah w
LEFT JOIN warga_miskin wm ON ST_Contains(w.geom, wm.geom)
LEFT JOIN bantuan_sosial bs ON bs.warga_id = wm.id AND bs.is_aktif=1
GROUP BY w.id ORDER BY w.nama
")->fetchAll();
$labels = []; $persenMiskin = []; $cakupan = []; $jumlahMiskin = []; $persenVerif = [];
foreach ($rows as $r) {
$miskin = (int)$r['miskin']; $penduduk = (int)$r['jumlah_penduduk'];
$labels[] = $r['nama'];
$persenMiskin[] = $penduduk > 0 ? round($miskin / $penduduk * 100, 2) : 0;
$cakupan[] = $miskin > 0 ? round($r['dibantu'] / $miskin * 100, 1) : 0;
$jumlahMiskin[] = $miskin;
$persenVerif[] = $miskin > 0 ? round($r['terverif'] / $miskin * 100, 1) : 0;
}
sendSuccess([
'labels' => $labels,
'persentase_miskin' => $persenMiskin,
'cakupan_bantuan' => $cakupan,
'jumlah_miskin' => $jumlahMiskin,
'persen_verifikasi' => $persenVerif,
], 'Komparatif Wilayah');
+104
View File
@@ -0,0 +1,104 @@
<?php
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/session.php';
startAppSession();
header('Content-Type: application/json');
if (!isset($_SESSION['user_id'])) {
http_response_code(401);
echo json_encode(['status' => 'error', 'message' => 'Unauthorized']);
exit;
}
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
try {
if ($method === 'GET') {
// Jika parameter my_reports=1, hanya laporan milik user yang login
// Jika tidak dan role=admin, ambil semua.
$my_reports = $_GET['my_reports'] ?? 0;
$sql = "SELECT l.*, ST_X(l.geometry) as lng, ST_Y(l.geometry) as lat, u.nama_lengkap, u.username
FROM laporan_warga l
JOIN users u ON l.user_id = u.id ";
if ($my_reports || $_SESSION['role'] !== 'admin') {
// User biasa hanya boleh melihat laporannya sendiri di tabel dashboard,
// tapi mungkin butuh lihat semua laporan di peta publik?
// Untuk peta public (semua laporan disetujui atau diproses), tambahkan parameter public=1
if (isset($_GET['public']) && $_GET['public'] == 1) {
$sql .= " WHERE l.status IN ('diproses', 'selesai') ";
} else {
$sql .= " WHERE l.user_id = " . (int)$_SESSION['user_id'];
}
}
$sql .= " ORDER BY l.id DESC";
$rows = $pdo->query($sql)->fetchAll();
$features = [];
foreach ($rows as $row) {
$features[] = [
'type' => 'Feature',
'properties' => [
'id' => $row['id'],
'user_id' => $row['user_id'],
'pelapor' => $row['nama_lengkap'] ?: $row['username'],
'kategori' => $row['kategori'],
'deskripsi' => $row['deskripsi'],
'status' => $row['status'],
'created_at' => $row['created_at']
],
'geometry' => [
'type' => 'Point',
'coordinates' => [(float)$row['lng'], (float)$row['lat']]
]
];
}
echo json_encode([
'type' => 'FeatureCollection',
'features' => $features
]);
}
elseif ($method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['kategori'], $input['deskripsi'], $input['geometry'])) {
throw new Exception("Data tidak lengkap.");
}
$coords = $input['geometry']['coordinates'];
$stmt = $pdo->prepare("INSERT INTO laporan_warga (user_id, kategori, deskripsi, geometry) VALUES (?, ?, ?, ST_GeomFromText(?))");
$stmt->execute([
$_SESSION['user_id'],
$input['kategori'],
$input['deskripsi'],
"POINT({$coords[0]} {$coords[1]})"
]);
echo json_encode(['status' => 'success', 'message' => 'Laporan berhasil dibuat.']);
}
elseif ($method === 'PUT') {
if ($_SESSION['role'] !== 'admin') throw new Exception("Unauthorized");
$id = $_GET['id'] ?? 0;
$input = json_decode(file_get_contents('php://input'), true);
$status = $input['status'] ?? 'menunggu';
$stmt = $pdo->prepare("UPDATE laporan_warga SET status = ? WHERE id = ?");
$stmt->execute([$status, $id]);
echo json_encode(['status' => 'success', 'message' => 'Status laporan diperbarui.']);
}
elseif ($method === 'DELETE') {
$id = $_GET['id'] ?? 0;
// User bisa hapus kalau status menunggu. Admin bisa hapus semua.
if ($_SESSION['role'] === 'admin') {
$stmt = $pdo->prepare("DELETE FROM laporan_warga WHERE id = ?");
$stmt->execute([$id]);
} else {
$stmt = $pdo->prepare("DELETE FROM laporan_warga WHERE id = ? AND user_id = ? AND status = 'menunggu'");
$stmt->execute([$id, $_SESSION['user_id']]);
}
echo json_encode(['status' => 'success', 'message' => 'Laporan dihapus.']);
}
} catch (Exception $e) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => $e->getMessage()]);
}
+67
View File
@@ -0,0 +1,67 @@
<?php
/**
* api/laporan_bantuan.php — Rekapitulasi penerima bantuan per jenis (KF-30).
* ?format=print (default) | json
*/
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/auth_check.php';
requireAnyRole(['operator', 'pimpinan']);
$pdo = Database::getConnection();
$format = $_GET['format'] ?? 'print';
$rows = $pdo->query("
SELECT jb.kode, jb.nama AS jenis_nama, w.nama_kk, w.nik_kk,
bs.tgl_mulai, bs.tgl_berakhir, w.status_verifikasi,
(SELECT wl.nama FROM wilayah wl WHERE ST_Contains(wl.geom, w.geom) LIMIT 1) AS kelurahan
FROM bantuan_sosial bs
JOIN warga_miskin w ON w.id = bs.warga_id
JOIN jenis_bantuan jb ON jb.id = bs.jenis_bantuan_id
WHERE bs.is_aktif = 1
ORDER BY jb.id, w.nama_kk
")->fetchAll();
// kelompokkan per jenis
$grup = [];
foreach ($rows as $r) $grup[$r['kode'] . ' — ' . $r['jenis_nama']][] = $r;
if ($format === 'json') {
header('Content-Type: application/json');
echo json_encode(['status' => 'success', 'data' => ['total' => count($rows), 'jenis' => array_map('count', $grup)]]);
exit;
}
?>
<!DOCTYPE html><html lang="id"><head><meta charset="UTF-8">
<title>Rekap Penerima Bantuan</title>
<style>
body{font-family:Arial,sans-serif;margin:28px;color:#111;}
h2{margin:0 0 2px;} .sub{color:#666;font-size:0.85rem;margin-bottom:16px;}
h3{margin:18px 0 6px;background:#2563EB;color:#fff;padding:6px 10px;border-radius:6px;font-size:0.95rem;}
table{width:100%;border-collapse:collapse;font-size:0.78rem;margin-bottom:8px;}
th,td{border:1px solid #ccc;padding:5px 7px;text-align:left;} th{background:#f1f5f9;}
button{padding:8px 14px;background:#2563EB;color:#fff;border:none;border-radius:6px;cursor:pointer;margin-bottom:14px;}
@media print{button{display:none;}}
</style></head><body>
<button onclick="window.print()">🖨️ Cetak / Simpan PDF</button>
<h2>Rekapitulasi Penerima Bantuan Sosial</h2>
<div class="sub">WebGIS Pemetaan Kemiskinan · dicetak <?= date('d M Y H:i') ?> · total <?= count($rows) ?> penerima aktif</div>
<?php if (!$grup): ?><p>Belum ada penerima bantuan aktif.</p><?php endif; ?>
<?php foreach ($grup as $judul => $list): ?>
<h3><?= htmlspecialchars($judul) ?> (<?= count($list) ?>)</h3>
<table>
<thead><tr><th>#</th><th>Nama KK</th><th>NIK</th><th>Kelurahan</th><th>Mulai</th><th>Berakhir</th><th>Verifikasi</th></tr></thead>
<tbody>
<?php foreach ($list as $i => $r): ?>
<tr>
<td><?= $i+1 ?></td>
<td><?= htmlspecialchars($r['nama_kk']) ?></td>
<td><?= htmlspecialchars($r['nik_kk'] ?: '-') ?></td>
<td><?= htmlspecialchars($r['kelurahan'] ?: '-') ?></td>
<td><?= htmlspecialchars($r['tgl_mulai'] ?: '-') ?></td>
<td><?= htmlspecialchars($r['tgl_berakhir'] ?: '-') ?></td>
<td><?= htmlspecialchars($r['status_verifikasi']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endforeach; ?>
</body></html>
+92
View File
@@ -0,0 +1,92 @@
<?php
/**
* api/laporan_wilayah.php — Laporan per kelurahan (KF-29).
* ?wilayah_id=ID [&format=print|json] (default print)
* Daftar warga dihitung secara spasial (ST_Contains).
*/
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/auth_check.php';
requireAnyRole(['operator', 'pimpinan']);
$pdo = Database::getConnection();
$wid = $_GET['wilayah_id'] ?? null;
$format = $_GET['format'] ?? 'print';
if (!$wid) { http_response_code(400); echo 'wilayah_id wajib'; exit; }
$wil = $pdo->prepare("SELECT id, nama, jumlah_penduduk FROM wilayah WHERE id=?");
$wil->execute([$wid]);
$w = $wil->fetch();
if (!$w) { http_response_code(404); echo 'Wilayah tidak ditemukan'; exit; }
$stmt = $pdo->prepare("
SELECT wm.id, wm.nik_kk, wm.nama_kk, wm.penghasilan, wm.jumlah_jiwa,
wm.sumber_air, wm.material_dinding, wm.status_verifikasi,
(SELECT GROUP_CONCAT(jb.kode SEPARATOR ', ') FROM bantuan_sosial bs
JOIN jenis_bantuan jb ON jb.id=bs.jenis_bantuan_id
WHERE bs.warga_id=wm.id AND bs.is_aktif=1) AS bantuan
FROM warga_miskin wm
JOIN wilayah w ON w.id = ?
WHERE ST_Contains(w.geom, wm.geom)
ORDER BY wm.nama_kk
");
$stmt->execute([$wid]);
$rows = $stmt->fetchAll();
$miskin = count($rows);
$dibantu = count(array_filter($rows, fn($r) => !empty($r['bantuan'])));
$terverif = count(array_filter($rows, fn($r) => $r['status_verifikasi'] === 'terverifikasi'));
$persen = $w['jumlah_penduduk'] > 0 ? round($miskin / $w['jumlah_penduduk'] * 100, 2) : 0;
$cakupan = $miskin > 0 ? round($dibantu / $miskin * 100, 1) : 0;
if ($format === 'json') {
header('Content-Type: application/json');
echo json_encode(['status' => 'success', 'data' => [
'nama' => $w['nama'], 'penduduk' => (int)$w['jumlah_penduduk'],
'miskin' => $miskin, 'dibantu' => $dibantu, 'terverif' => $terverif,
'persen' => $persen, 'cakupan' => $cakupan,
]]);
exit;
}
?>
<!DOCTYPE html><html lang="id"><head><meta charset="UTF-8">
<title>Laporan <?= htmlspecialchars($w['nama']) ?></title>
<style>
body{font-family:Arial,sans-serif;margin:28px;color:#111;}
h2{margin:0 0 2px;} .sub{color:#666;font-size:0.85rem;margin-bottom:16px;}
.kpi{display:flex;gap:12px;margin-bottom:18px;flex-wrap:wrap;}
.kpi .b{border:1px solid #ddd;border-radius:8px;padding:10px 16px;min-width:120px;}
.kpi .n{font-size:1.4rem;font-weight:bold;} .kpi .l{font-size:0.75rem;color:#666;}
table{width:100%;border-collapse:collapse;font-size:0.76rem;}
th,td{border:1px solid #ccc;padding:5px 7px;text-align:left;} th{background:#f1f5f9;}
button{padding:8px 14px;background:#2563EB;color:#fff;border:none;border-radius:6px;cursor:pointer;margin-bottom:14px;}
@media print{button{display:none;}}
</style></head><body>
<button onclick="window.print()">🖨️ Cetak / Simpan PDF</button>
<h2>Laporan Kemiskinan — <?= htmlspecialchars($w['nama']) ?></h2>
<div class="sub">WebGIS Pemetaan Kemiskinan · dicetak <?= date('d M Y H:i') ?></div>
<div class="kpi">
<div class="b"><div class="n"><?= $miskin ?></div><div class="l">Rumah Tangga Miskin</div></div>
<div class="b"><div class="n"><?= $persen ?>%</div><div class="l">dari <?= (int)$w['jumlah_penduduk'] ?> KK</div></div>
<div class="b"><div class="n"><?= $cakupan ?>%</div><div class="l">Cakupan Bantuan</div></div>
<div class="b"><div class="n"><?= $terverif ?></div><div class="l">Terverifikasi</div></div>
</div>
<table>
<thead><tr><th>#</th><th>NIK</th><th>Nama KK</th><th>Jiwa</th><th>Penghasilan</th><th>Sumber Air</th><th>Dinding</th><th>Bantuan</th><th>Verifikasi</th></tr></thead>
<tbody>
<?php foreach ($rows as $i => $r): ?>
<tr>
<td><?= $i+1 ?></td>
<td><?= htmlspecialchars($r['nik_kk'] ?: '-') ?></td>
<td><?= htmlspecialchars($r['nama_kk']) ?></td>
<td><?= $r['jumlah_jiwa'] ?: '-' ?></td>
<td>Rp <?= number_format($r['penghasilan'],0,',','.') ?></td>
<td><?= htmlspecialchars($r['sumber_air'] ?: '-') ?></td>
<td><?= htmlspecialchars($r['material_dinding'] ?: '-') ?></td>
<td><?= htmlspecialchars($r['bantuan'] ?: '-') ?></td>
<td><?= htmlspecialchars($r['status_verifikasi']) ?></td>
</tr>
<?php endforeach; ?>
<?php if (!$rows): ?><tr><td colspan="9" style="text-align:center;padding:20px;">Tidak ada data di wilayah ini.</td></tr><?php endif; ?>
</tbody>
</table>
</body></html>
+51
View File
@@ -0,0 +1,51 @@
<?php
/**
* api/proximity.php — Jarak warga ke fasilitas publik terdekat (KF-21).
* GET ?radius=METER (default 1000) [&jenis=sekolah|puskesmas|pasar|...]
* Mengembalikan GeoJSON warga + fasilitas terdekat + jarak + flag terisolasi.
*/
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/helpers.php';
$pdo = Database::getConnection();
requireApiAnyRole(['operator', 'pimpinan']);
if ($_SERVER['REQUEST_METHOD'] !== 'GET') sendError('Method not allowed', 405);
$radius = (float)($_GET['radius'] ?? 1000);
$jenis = $_GET['jenis'] ?? '';
$jenisFilter = $jenis !== '' ? "WHERE fp.jenis = " . $pdo->quote($jenis) : "";
$sql = "
SELECT w.id, w.nama_kk, ST_AsGeoJSON(w.geom) AS geojson,
(SELECT ST_Distance_Sphere(w.geom, fp.geom) FROM fasilitas_publik fp $jenisFilter
ORDER BY ST_Distance_Sphere(w.geom, fp.geom) LIMIT 1) AS jarak_m,
(SELECT fp.nama FROM fasilitas_publik fp $jenisFilter
ORDER BY ST_Distance_Sphere(w.geom, fp.geom) LIMIT 1) AS fasilitas,
(SELECT fp.jenis FROM fasilitas_publik fp $jenisFilter
ORDER BY ST_Distance_Sphere(w.geom, fp.geom) LIMIT 1) AS fasilitas_jenis
FROM warga_miskin w
ORDER BY jarak_m DESC
";
$stmt = $pdo->query($sql);
$features = [];
$terisolasi = 0;
while ($r = $stmt->fetch()) {
$jarak = $r['jarak_m'] !== null ? round((float)$r['jarak_m']) : null;
$iso = ($jarak !== null && $jarak > $radius);
if ($iso) $terisolasi++;
$features[] = [
'type' => 'Feature',
'geometry' => json_decode($r['geojson']),
'properties' => [
'id' => (int)$r['id'], 'nama_kk' => $r['nama_kk'],
'jarak_m' => $jarak, 'jarak_km' => $jarak !== null ? round($jarak / 1000, 2) : null,
'fasilitas' => $r['fasilitas'], 'fasilitas_jenis' => $r['fasilitas_jenis'],
'terisolasi' => $iso,
],
];
}
sendSuccess([
'type' => 'FeatureCollection', 'features' => $features,
'radius_m' => $radius, 'jumlah_terisolasi' => $terisolasi, 'total' => count($features),
], 'Proximity Analysis');
+45
View File
@@ -0,0 +1,45 @@
<?php
/**
* api/tren.php — Tren waktu jumlah warga & cakupan bantuan (KF-23).
* GET → seri bulanan (12 bulan terakhir): warga baru, kumulatif warga, bantuan baru.
*/
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/helpers.php';
$pdo = Database::getConnection();
requireApiAnyRole(['operator', 'pimpinan']);
if ($_SERVER['REQUEST_METHOD'] !== 'GET') sendError('Method not allowed', 405);
// Bangun 12 bulan terakhir
$months = [];
for ($i = 11; $i >= 0; $i--) {
$months[date('Y-m', strtotime("-$i month"))] = ['warga' => 0, 'bantuan' => 0];
}
foreach ($pdo->query("SELECT DATE_FORMAT(created_at,'%Y-%m') ym, COUNT(*) c FROM warga_miskin GROUP BY ym") as $r) {
if (isset($months[$r['ym']])) $months[$r['ym']]['warga'] = (int)$r['c'];
}
foreach ($pdo->query("SELECT DATE_FORMAT(created_at,'%Y-%m') ym, COUNT(*) c FROM bantuan_sosial GROUP BY ym") as $r) {
if (isset($months[$r['ym']])) $months[$r['ym']]['bantuan'] = (int)$r['c'];
}
// warga sebelum jendela 12 bulan (untuk basis kumulatif)
$firstMonth = array_key_first($months);
$base = (int)$pdo->query("SELECT COUNT(*) FROM warga_miskin WHERE DATE_FORMAT(created_at,'%Y-%m') < '$firstMonth'")->fetchColumn();
$labels = []; $wargaBaru = []; $kumulatif = []; $bantuanBaru = [];
$run = $base;
foreach ($months as $ym => $v) {
$labels[] = $ym;
$wargaBaru[] = $v['warga'];
$run += $v['warga'];
$kumulatif[] = $run;
$bantuanBaru[] = $v['bantuan'];
}
sendSuccess([
'labels' => $labels,
'warga_baru' => $wargaBaru,
'kumulatif' => $kumulatif,
'bantuan_baru' => $bantuanBaru,
], 'Tren Waktu');
+94
View File
@@ -0,0 +1,94 @@
<?php
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/helpers.php';
require_once __DIR__ . '/../config/activity.php';
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
// Manajemen user hanya untuk Operator (KF-02/03)
requireApiAnyRole(['operator']);
const VALID_ROLES = ['publik', 'enumerator', 'operator', 'pimpinan'];
switch ($method) {
case 'GET':
$stmt = $pdo->query(
"SELECT id, username, email, role, is_active, wilayah_tugas, nama_lengkap, last_login, 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');
}
if (!in_array($d['role'], VALID_ROLES, true)) sendError('Role tidak valid');
$hash = password_hash($d['password'], PASSWORD_BCRYPT, ['cost' => 12]);
try {
$pdo->prepare(
"INSERT INTO users (username, email, password, role, is_active, wilayah_tugas, nama_lengkap)
VALUES (?,?,?,?,?,?,?)"
)->execute([
$d['username'],
$d['email'] ?? null,
$hash,
$d['role'],
isset($d['is_active']) ? (int)$d['is_active'] : 1,
$d['wilayah_tugas'] ?? null,
$d['nama_lengkap'] ?? '',
]);
$id = (int)$pdo->lastInsertId();
logActivity($pdo, 'create', 'users', $id, "Tambah user {$d['username']} ({$d['role']})");
sendSuccess(['id' => $id], '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['role']) && !in_array($d['role'], VALID_ROLES, true)) sendError('Role tidak valid');
if (!empty($d['password'])) {
$hash = password_hash($d['password'], PASSWORD_BCRYPT, ['cost' => 12]);
$pdo->prepare(
"UPDATE users SET username=?, email=?, password=?, role=?, is_active=?, wilayah_tugas=?, nama_lengkap=?
WHERE id=?"
)->execute([
$d['username'], $d['email'] ?? null, $hash, $d['role'],
isset($d['is_active']) ? (int)$d['is_active'] : 1,
$d['wilayah_tugas'] ?? null, $d['nama_lengkap'] ?? '', $id,
]);
} else {
$pdo->prepare(
"UPDATE users SET username=?, email=?, role=?, is_active=?, wilayah_tugas=?, nama_lengkap=?
WHERE id=?"
)->execute([
$d['username'], $d['email'] ?? null, $d['role'],
isset($d['is_active']) ? (int)$d['is_active'] : 1,
$d['wilayah_tugas'] ?? null, $d['nama_lengkap'] ?? '', $id,
]);
}
logActivity($pdo, 'update', 'users', (int)$id, "Edit user {$d['username']}");
sendSuccess(null, 'User diperbarui');
break;
case 'DELETE':
$id = $_GET['id'] ?? null;
if (!$id) sendError('ID wajib');
if ($id == 1) sendError('Akun utama tidak dapat dihapus');
if ($id == ($_SESSION['user_id'] ?? null)) sendError('Tidak dapat menghapus akun sendiri');
$pdo->prepare("DELETE FROM users WHERE id=?")->execute([$id]);
logActivity($pdo, 'delete', 'users', (int)$id, 'Hapus user');
sendSuccess(null, 'User dihapus');
break;
default:
sendError('Method not allowed', 405);
}
+93
View File
@@ -0,0 +1,93 @@
<?php
/**
* api/warga_export.php — Export data warga (KF-16).
* ?format=csv → unduh CSV semua data
* ?format=template → unduh template CSV kosong (Lampiran A)
* ?format=print → halaman cetak (PDF via browser print)
*/
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/auth_check.php';
requireAnyRole(['operator', 'pimpinan']);
$pdo = Database::getConnection();
$format = $_GET['format'] ?? 'csv';
if ($format === 'template') {
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="template_warga.csv"');
$out = fopen('php://output', 'w');
fputcsv($out, ['nik_kk','nama_kk','kode_kelurahan','latitude','longitude','jumlah_jiwa','sumber_air','status_listrik','material_dinding','pekerjaan_kk']);
fputcsv($out, ['3171010000000001','Contoh Nama','6171010001','-0.0510','109.3610','4','SUNGAI','NON_PLN','TIDAK_PERMANEN','Buruh Harian']);
fclose($out);
exit;
}
$rows = $pdo->query("
SELECT w.id, w.nik_kk, w.nama_kk, wl.nama AS kelurahan, w.alamat, w.rt, w.rw,
w.jumlah_jiwa, w.jumlah_tanggungan, w.pekerjaan_kk, w.penghasilan,
w.sumber_air, w.status_listrik, w.material_dinding, w.status_kepemilikan, w.status_verifikasi,
ST_Y(w.geom) AS lat, ST_X(w.geom) AS lng,
(SELECT GROUP_CONCAT(jb.kode SEPARATOR '; ')
FROM bantuan_sosial bs JOIN jenis_bantuan jb ON jb.id=bs.jenis_bantuan_id
WHERE bs.warga_id=w.id AND bs.is_aktif=1) AS bantuan
FROM warga_miskin w LEFT JOIN wilayah wl ON wl.id=w.wilayah_id
ORDER BY w.id
")->fetchAll();
if ($format === 'csv') {
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="data_warga_' . date('Ymd') . '.csv"');
$out = fopen('php://output', 'w');
fputcsv($out, ['ID','NIK KK','Nama KK','Kelurahan','Alamat','RT','RW','Jiwa','Tanggungan','Pekerjaan','Penghasilan','Sumber Air','Listrik','Dinding','Kepemilikan','Verifikasi','Bantuan Aktif','Lat','Lng']);
foreach ($rows as $r) {
fputcsv($out, [
$r['id'], $r['nik_kk'], $r['nama_kk'], $r['kelurahan'], $r['alamat'], $r['rt'], $r['rw'],
$r['jumlah_jiwa'], $r['jumlah_tanggungan'], $r['pekerjaan_kk'], $r['penghasilan'],
$r['sumber_air'], $r['status_listrik'], $r['material_dinding'], $r['status_kepemilikan'],
$r['status_verifikasi'], $r['bantuan'], $r['lat'], $r['lng'],
]);
}
fclose($out);
exit;
}
// format=print → HTML untuk dicetak/disimpan PDF
?>
<!DOCTYPE html>
<html lang="id"><head>
<meta charset="UTF-8">
<title>Laporan Data Warga Miskin</title>
<style>
body{font-family:Arial,sans-serif;margin:24px;color:#111;}
h2{margin:0 0 2px;} .sub{color:#666;font-size:0.85rem;margin-bottom:14px;}
table{width:100%;border-collapse:collapse;font-size:0.72rem;}
th,td{border:1px solid #ccc;padding:4px 6px;text-align:left;}
th{background:#f1f5f9;}
.toolbar{margin-bottom:12px;}
button{padding:8px 14px;background:#2563EB;color:#fff;border:none;border-radius:6px;cursor:pointer;}
@media print{.toolbar{display:none;}}
</style></head>
<body>
<div class="toolbar"><button onclick="window.print()">🖨️ Cetak / Simpan PDF</button></div>
<h2>Laporan Data Rumah Tangga Miskin</h2>
<div class="sub">WebGIS Pemetaan Kemiskinan — dicetak <?= date('d M Y H:i') ?> — total <?= count($rows) ?> rumah tangga</div>
<table>
<thead><tr><th>#</th><th>NIK</th><th>Nama KK</th><th>Kelurahan</th><th>Jiwa</th><th>Penghasilan</th><th>Sumber Air</th><th>Dinding</th><th>Bantuan</th><th>Verifikasi</th></tr></thead>
<tbody>
<?php foreach ($rows as $i => $r): ?>
<tr>
<td><?= $i+1 ?></td>
<td><?= htmlspecialchars($r['nik_kk'] ?: '-') ?></td>
<td><?= htmlspecialchars($r['nama_kk']) ?></td>
<td><?= htmlspecialchars($r['kelurahan'] ?: '-') ?></td>
<td><?= $r['jumlah_jiwa'] ?: '-' ?></td>
<td>Rp <?= number_format($r['penghasilan'],0,',','.') ?></td>
<td><?= htmlspecialchars($r['sumber_air'] ?: '-') ?></td>
<td><?= htmlspecialchars($r['material_dinding'] ?: '-') ?></td>
<td><?= htmlspecialchars($r['bantuan'] ?: '-') ?></td>
<td><?= htmlspecialchars($r['status_verifikasi']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</body></html>
+83
View File
@@ -0,0 +1,83 @@
<?php
/**
* api/warga_import.php — Import data warga dari CSV (KF-15).
* Template kolom (Lampiran A):
* nik_kk, nama_kk, kode_kelurahan, latitude, longitude,
* jumlah_jiwa, sumber_air, status_listrik, material_dinding, pekerjaan_kk
*/
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/helpers.php';
require_once __DIR__ . '/../config/activity.php';
$pdo = Database::getConnection();
requireApiAnyRole(['operator']);
if ($_SERVER['REQUEST_METHOD'] !== 'POST') sendError('Method not allowed', 405);
if (empty($_FILES['file']['tmp_name'])) sendError('File CSV wajib diunggah');
$fh = fopen($_FILES['file']['tmp_name'], 'r');
if (!$fh) sendError('Gagal membaca file');
// Peta kode kelurahan -> id
$wmap = [];
foreach ($pdo->query("SELECT id, kode_wilayah FROM wilayah") as $w) {
if ($w['kode_wilayah']) $wmap[trim($w['kode_wilayah'])] = $w['id'];
}
$header = fgetcsv($fh);
if (!$header) sendError('CSV kosong');
$header = array_map(fn($h) => strtolower(trim($h)), $header);
$idx = array_flip($header);
$need = ['nama_kk', 'latitude', 'longitude'];
foreach ($need as $col) {
if (!isset($idx[$col])) sendError("Kolom wajib '$col' tidak ditemukan di CSV");
}
$enumAir = ['SUMUR_BOR','PDAM','SUNGAI','HUJAN','TIDAK_ADA'];
$enumListrik = ['PLN','NON_PLN','TIDAK_ADA'];
$enumDinding = ['PERMANEN','SEMI_PERMANEN','TIDAK_PERMANEN'];
$val = fn($row, $key) => isset($idx[$key]) && isset($row[$idx[$key]]) ? trim($row[$idx[$key]]) : null;
$enumOrNull = fn($v, $allowed) => ($v && in_array(strtoupper($v), $allowed, true)) ? strtoupper($v) : null;
$ins = $pdo->prepare("
INSERT INTO warga_miskin
(nama_kk, nik_kk, wilayah_id, jumlah_jiwa, pekerjaan_kk, penghasilan, jumlah_tanggungan,
sumber_air, status_listrik, material_dinding, status_verifikasi, created_by, geom)
VALUES (?,?,?,?,?,?,?,?,?,?, 'belum', ?, ST_GeomFromText(?))
");
$ok = 0; $fail = 0; $errors = []; $line = 1;
while (($row = fgetcsv($fh)) !== false) {
$line++;
if (count(array_filter($row, fn($x) => $x !== '' && $x !== null)) === 0) continue; // baris kosong
$nama = $val($row, 'nama_kk');
$lat = $val($row, 'latitude');
$lng = $val($row, 'longitude');
if (!$nama) { $fail++; $errors[] = "Baris $line: nama_kk kosong"; continue; }
if (!is_numeric($lat) || !is_numeric($lng)) { $fail++; $errors[] = "Baris $line: koordinat tidak valid"; continue; }
$kode = $val($row, 'kode_kelurahan');
$wid = ($kode && isset($wmap[$kode])) ? $wmap[$kode] : null;
try {
$ins->execute([
$nama, $val($row, 'nik_kk'), $wid,
($val($row, 'jumlah_jiwa') !== null && $val($row, 'jumlah_jiwa') !== '') ? (int)$val($row, 'jumlah_jiwa') : null,
$val($row, 'pekerjaan_kk'),
(float)($val($row, 'penghasilan') ?? 0),
(int)($val($row, 'jumlah_tanggungan') ?? 0),
$enumOrNull($val($row, 'sumber_air'), $enumAir),
$enumOrNull($val($row, 'status_listrik'), $enumListrik),
$enumOrNull($val($row, 'material_dinding'), $enumDinding),
$_SESSION['user_id'] ?? null,
sprintf('POINT(%F %F)', (float)$lng, (float)$lat),
]);
$ok++;
} catch (Exception $e) {
$fail++; $errors[] = "Baris $line: " . $e->getMessage();
}
}
fclose($fh);
logActivity($pdo, 'create', 'warga_miskin', null, "Import CSV: $ok berhasil, $fail gagal");
sendSuccess(['berhasil' => $ok, 'gagal' => $fail, 'errors' => array_slice($errors, 0, 15)], 'Import selesai');
+160
View File
@@ -0,0 +1,160 @@
<?php
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/helpers.php';
require_once __DIR__ . '/../config/activity.php';
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
// Pastikan sesi aktif sebelum membaca role/uid
if (session_status() === PHP_SESSION_NONE) startAppSession();
$role = $_SESSION['role'] ?? '';
$uid = $_SESSION['user_id'] ?? null;
// Mutasi: operator (penuh) atau enumerator (buat & edit data sendiri)
if ($method !== 'GET') requireApiAnyRole(['operator', 'enumerator']);
/** Ubah string kosong jadi NULL (untuk kolom ENUM/opsional). */
function nn($v) { return ($v === '' || $v === null) ? null : $v; }
/** Wilayah_id penugasan enumerator (cocokkan nama wilayah_tugas). */
function enumeratorWilayahId(PDO $pdo): ?int {
$tugas = $_SESSION['wilayah_tugas'] ?? '';
if (!$tugas) return null;
$st = $pdo->prepare("SELECT id FROM wilayah WHERE nama = ? LIMIT 1");
$st->execute([$tugas]);
$id = $st->fetchColumn();
return $id ? (int)$id : null;
}
/** Pastikan record milik enumerator yang sedang login. */
function assertOwn(PDO $pdo, $id, string $role, $uid): void {
if ($role !== 'enumerator') return; // operator bebas
$st = $pdo->prepare("SELECT created_by FROM warga_miskin WHERE id=?");
$st->execute([$id]);
$owner = $st->fetchColumn();
if ((int)$owner !== (int)$uid) sendError('Anda hanya dapat mengubah data yang Anda input', 403);
}
switch ($method) {
case 'GET':
// Enumerator hanya melihat data yang ia input (KF-26)
$where = ($role === 'enumerator') ? "WHERE w.created_by = " . (int)$uid : "";
$stmt = $pdo->query("
SELECT w.id, w.nama_kk, w.nik_kk, w.alamat, w.wilayah_id, w.rt, w.rw,
w.jumlah_jiwa, w.pekerjaan_kk, w.penghasilan, w.jumlah_tanggungan,
w.sumber_air, w.status_listrik, w.material_dinding, w.status_kepemilikan,
w.status_verifikasi, w.created_at,
ST_AsGeoJSON(w.geom) AS geojson,
(SELECT GROUP_CONCAT(jb.kode SEPARATOR ',')
FROM bantuan_sosial bs JOIN jenis_bantuan jb ON jb.id = bs.jenis_bantuan_id
WHERE bs.warga_id = w.id AND bs.is_aktif = 1) AS bantuan_aktif,
(SELECT COUNT(*) FROM foto_rumah f WHERE f.warga_id = w.id) AS jumlah_foto
FROM warga_miskin w
$where
ORDER BY w.id DESC
");
$features = [];
while ($r = $stmt->fetch()) {
$features[] = [
'type' => 'Feature',
'geometry' => json_decode($r['geojson']),
'properties' => [
'id' => (int)$r['id'], 'nama_kk' => $r['nama_kk'], 'nik_kk' => $r['nik_kk'],
'alamat' => $r['alamat'], 'wilayah_id' => $r['wilayah_id'],
'rt' => $r['rt'], 'rw' => $r['rw'], 'jumlah_jiwa' => $r['jumlah_jiwa'],
'pekerjaan_kk' => $r['pekerjaan_kk'], 'penghasilan' => $r['penghasilan'],
'jumlah_tanggungan' => $r['jumlah_tanggungan'], 'sumber_air' => $r['sumber_air'],
'status_listrik' => $r['status_listrik'], 'material_dinding' => $r['material_dinding'],
'status_kepemilikan' => $r['status_kepemilikan'], 'status_verifikasi' => $r['status_verifikasi'],
'bantuan_aktif' => $r['bantuan_aktif'], 'jumlah_foto' => (int)$r['jumlah_foto'],
'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('Nama KK & lokasi wajib diisi');
// Enumerator: wilayah dipaksa ke wilayah penugasannya
$wilayahId = ($role === 'enumerator') ? enumeratorWilayahId($pdo) : nn($d['wilayah_id'] ?? null);
// Enumerator: data baru selalu berstatus "belum" untuk diverifikasi operator
$verif = ($role === 'enumerator') ? 'belum' : ($d['status_verifikasi'] ?? 'belum');
$pdo->prepare("
INSERT INTO warga_miskin
(nama_kk, nik_kk, alamat, wilayah_id, rt, rw, jumlah_jiwa, pekerjaan_kk,
penghasilan, jumlah_tanggungan, sumber_air, status_listrik, material_dinding,
status_kepemilikan, status_verifikasi, created_by, geom)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, ST_GeomFromGeoJSON(?))
")->execute([
$d['nama_kk'], nn($d['nik_kk'] ?? null), nn($d['alamat'] ?? null), $wilayahId,
nn($d['rt'] ?? null), nn($d['rw'] ?? null), nn($d['jumlah_jiwa'] ?? null), nn($d['pekerjaan_kk'] ?? null),
$d['penghasilan'] ?? 0, $d['jumlah_tanggungan'] ?? 0,
nn($d['sumber_air'] ?? null), nn($d['status_listrik'] ?? null), nn($d['material_dinding'] ?? null),
nn($d['status_kepemilikan'] ?? null), $verif, $uid, json_encode($d['geometry']),
]);
$newId = (int)$pdo->lastInsertId();
logActivity($pdo, 'create', 'warga_miskin', $newId, "Tambah warga {$d['nama_kk']}");
sendSuccess(['id' => $newId], 'Warga disimpan', 201);
break;
case 'PUT':
$d = getInput();
$id = $_GET['id'] ?? null;
if (!$id) sendError('ID wajib');
// Mode verifikasi → hanya operator
if (isset($d['status_verifikasi']) && count($d) === 1) {
if ($role !== 'operator') sendError('Hanya operator yang dapat memverifikasi', 403);
$pdo->prepare("UPDATE warga_miskin SET status_verifikasi=? WHERE id=?")->execute([$d['status_verifikasi'], $id]);
logActivity($pdo, 'update', 'warga_miskin', (int)$id, "Verifikasi: {$d['status_verifikasi']}");
sendSuccess(null, 'Status verifikasi diperbarui');
}
assertOwn($pdo, $id, $role, $uid);
// Mode lokasi saja
if (!empty($d['geometry']) && empty($d['nama_kk'])) {
$pdo->prepare("UPDATE warga_miskin SET geom=ST_GeomFromGeoJSON(?) WHERE id=?")->execute([json_encode($d['geometry']), $id]);
logActivity($pdo, 'update', 'warga_miskin', (int)$id, 'Ubah lokasi warga');
sendSuccess(null, 'Lokasi diperbarui');
}
// Mode data lengkap. Enumerator tidak boleh mengubah status verifikasi.
$verifSet = ($role === 'operator') ? ($d['status_verifikasi'] ?? 'belum') : null;
if ($verifSet !== null) {
$sql = "UPDATE warga_miskin SET nama_kk=?, nik_kk=?, alamat=?, rt=?, rw=?, jumlah_jiwa=?, pekerjaan_kk=?,
penghasilan=?, jumlah_tanggungan=?, sumber_air=?, status_listrik=?, material_dinding=?,
status_kepemilikan=?, status_verifikasi=? WHERE id=?";
$params = [$d['nama_kk'], nn($d['nik_kk'] ?? null), nn($d['alamat'] ?? null), nn($d['rt'] ?? null), nn($d['rw'] ?? null),
nn($d['jumlah_jiwa'] ?? null), nn($d['pekerjaan_kk'] ?? null), $d['penghasilan'] ?? 0, $d['jumlah_tanggungan'] ?? 0,
nn($d['sumber_air'] ?? null), nn($d['status_listrik'] ?? null), nn($d['material_dinding'] ?? null),
nn($d['status_kepemilikan'] ?? null), $verifSet, $id];
} else {
$sql = "UPDATE warga_miskin SET nama_kk=?, nik_kk=?, alamat=?, rt=?, rw=?, jumlah_jiwa=?, pekerjaan_kk=?,
penghasilan=?, jumlah_tanggungan=?, sumber_air=?, status_listrik=?, material_dinding=?,
status_kepemilikan=? WHERE id=?";
$params = [$d['nama_kk'], nn($d['nik_kk'] ?? null), nn($d['alamat'] ?? null), nn($d['rt'] ?? null), nn($d['rw'] ?? null),
nn($d['jumlah_jiwa'] ?? null), nn($d['pekerjaan_kk'] ?? null), $d['penghasilan'] ?? 0, $d['jumlah_tanggungan'] ?? 0,
nn($d['sumber_air'] ?? null), nn($d['status_listrik'] ?? null), nn($d['material_dinding'] ?? null),
nn($d['status_kepemilikan'] ?? null), $id];
}
$pdo->prepare($sql)->execute($params);
logActivity($pdo, 'update', 'warga_miskin', (int)$id, 'Edit data warga');
sendSuccess(null, 'Warga diperbarui');
break;
case 'DELETE':
if ($role !== 'operator') sendError('Hanya operator yang dapat menghapus data', 403);
$id = $_GET['id'] ?? null;
if (!$id) sendError('ID wajib');
$pdo->prepare("DELETE FROM warga_miskin WHERE id=?")->execute([$id]);
logActivity($pdo, 'delete', 'warga_miskin', (int)$id, 'Hapus data warga');
sendSuccess(null, 'Warga dihapus');
break;
default:
sendError('Method not allowed', 405);
}
+89
View File
@@ -0,0 +1,89 @@
<?php
/**
* api/wilayah.php — Wilayah (kelurahan) + statistik kemiskinan (KF-06) & CRUD.
*
* GET → FeatureCollection wilayah + persentase_miskin (publik).
* POST → tambah wilayah (operator). {nama, kode_wilayah, jenis, jumlah_penduduk, geometry}
* PUT ?id= → ubah atribut &/atau geometry (operator).
* DELETE ?id= → hapus (operator).
*/
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/helpers.php';
require_once __DIR__ . '/../config/activity.php';
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
if ($method !== 'GET') requireApiAnyRole(['operator']);
function nnw($v) { return ($v === '' || $v === null) ? null : $v; }
switch ($method) {
case 'GET':
$sql = "
SELECT w.id, w.nama, w.jenis, w.kode_wilayah, w.jumlah_penduduk,
COUNT(wm.id) AS jumlah_miskin, ST_AsGeoJSON(w.geom) AS geojson
FROM wilayah w
LEFT JOIN warga_miskin wm ON ST_Contains(w.geom, wm.geom)
GROUP BY w.id ORDER BY w.nama
";
$stmt = $pdo->query($sql);
$features = [];
while ($r = $stmt->fetch()) {
$penduduk = (int)$r['jumlah_penduduk'];
$miskin = (int)$r['jumlah_miskin'];
$persen = $penduduk > 0 ? round($miskin / $penduduk * 100, 2) : 0;
$features[] = [
'type' => 'Feature',
'geometry' => json_decode($r['geojson']),
'properties' => [
'id' => (int)$r['id'], 'nama' => $r['nama'], 'jenis' => $r['jenis'],
'kode_wilayah' => $r['kode_wilayah'], 'jumlah_penduduk' => $penduduk,
'jumlah_miskin' => $miskin, 'persentase_miskin' => $persen,
],
];
}
sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Wilayah');
break;
case 'POST':
$d = getInput();
if (empty($d['nama']) || empty($d['geometry'])) sendError('Nama & geometri wajib diisi');
$pdo->prepare("INSERT INTO wilayah (nama, kode_wilayah, jenis, jumlah_penduduk, geom)
VALUES (?,?,?,?, ST_GeomFromGeoJSON(?))")
->execute([
$d['nama'], nnw($d['kode_wilayah'] ?? null), $d['jenis'] ?? 'kelurahan',
(int)($d['jumlah_penduduk'] ?? 0), json_encode($d['geometry']),
]);
$id = (int)$pdo->lastInsertId();
logActivity($pdo, 'create', 'wilayah', $id, "Tambah wilayah {$d['nama']}");
sendSuccess(['id' => $id], 'Wilayah disimpan', 201);
break;
case 'PUT':
$d = getInput();
$id = $_GET['id'] ?? null;
if (!$id) sendError('ID wajib');
if (!empty($d['geometry'])) {
$pdo->prepare("UPDATE wilayah SET nama=?, kode_wilayah=?, jenis=?, jumlah_penduduk=?, geom=ST_GeomFromGeoJSON(?) WHERE id=?")
->execute([$d['nama'], nnw($d['kode_wilayah'] ?? null), $d['jenis'] ?? 'kelurahan',
(int)($d['jumlah_penduduk'] ?? 0), json_encode($d['geometry']), $id]);
} else {
$pdo->prepare("UPDATE wilayah SET nama=?, kode_wilayah=?, jenis=?, jumlah_penduduk=? WHERE id=?")
->execute([$d['nama'], nnw($d['kode_wilayah'] ?? null), $d['jenis'] ?? 'kelurahan',
(int)($d['jumlah_penduduk'] ?? 0), $id]);
}
logActivity($pdo, 'update', 'wilayah', (int)$id, 'Ubah wilayah');
sendSuccess(null, 'Wilayah diperbarui');
break;
case 'DELETE':
$id = $_GET['id'] ?? null;
if (!$id) sendError('ID wajib');
$pdo->prepare("DELETE FROM wilayah WHERE id=?")->execute([$id]);
logActivity($pdo, 'delete', 'wilayah', (int)$id, 'Hapus wilayah');
sendSuccess(null, 'Wilayah dihapus');
break;
default:
sendError('Method not allowed', 405);
}
+54
View File
@@ -0,0 +1,54 @@
<?php
/**
* api/zona_prioritas.php — Skor prioritas intervensi per kelurahan (KF-20).
* Skor = persen_miskin*2 + gap_bantuan*0.2 + jumlah_miskin*1
* gap_bantuan = 100 - cakupan% (cakupan = warga dibantu / warga miskin)
* Level: skor>35 Tinggi, >20 Sedang, selain itu Rendah.
* GET → GeoJSON wilayah + properti skor/level/komponen.
*/
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/helpers.php';
$pdo = Database::getConnection();
requireApiAnyRole(['operator', 'pimpinan']);
if ($_SERVER['REQUEST_METHOD'] !== 'GET') sendError('Method not allowed', 405);
$sql = "
SELECT w.id, w.nama, w.jumlah_penduduk, ST_AsGeoJSON(w.geom) AS geojson,
COUNT(DISTINCT wm.id) AS miskin,
COUNT(DISTINCT CASE WHEN bs.id IS NOT NULL THEN wm.id END) AS dibantu
FROM wilayah w
LEFT JOIN warga_miskin wm ON ST_Contains(w.geom, wm.geom)
LEFT JOIN bantuan_sosial bs ON bs.warga_id = wm.id AND bs.is_aktif = 1
GROUP BY w.id
ORDER BY w.nama
";
$stmt = $pdo->query($sql);
$features = [];
while ($r = $stmt->fetch()) {
$penduduk = (int)$r['jumlah_penduduk'];
$miskin = (int)$r['miskin'];
$dibantu = (int)$r['dibantu'];
$persen = $penduduk > 0 ? round($miskin / $penduduk * 100, 2) : 0;
$cakupan = $miskin > 0 ? round($dibantu / $miskin * 100, 1) : 0;
$gap = $miskin > 0 ? (100 - $cakupan) : 0;
$skor = round($persen * 2 + $gap * 0.2 + $miskin * 1, 1);
$level = $skor > 35 ? 'Tinggi' : ($skor > 20 ? 'Sedang' : 'Rendah');
$features[] = [
'type' => 'Feature',
'geometry' => json_decode($r['geojson']),
'properties' => [
'id' => (int)$r['id'], 'nama' => $r['nama'],
'jumlah_penduduk' => $penduduk, 'jumlah_miskin' => $miskin,
'dibantu' => $dibantu, 'persentase_miskin' => $persen,
'cakupan_bantuan' => $cakupan, 'skor' => $skor, 'level' => $level,
],
];
}
// urутkan ranking by skor desc
usort($features, fn($a, $b) => $b['properties']['skor'] <=> $a['properties']['skor']);
foreach ($features as $i => &$f) $f['properties']['rank'] = $i + 1;
sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Zona Prioritas');
+364
View File
@@ -0,0 +1,364 @@
/* =========================================================
ADMIN LAYOUT STYLES — sidebar, topbar, main content
========================================================= */
body.admin-page {
display: flex;
min-height: 100vh;
background: var(--bg-base);
}
/* ══════════════════════════════
SIDEBAR
══════════════════════════════ */
.sidebar {
width: var(--sidebar-w);
flex-shrink: 0;
background: var(--sidebar-bg);
border-right: 1px solid var(--sidebar-border);
display: flex;
flex-direction: column;
height: 100vh;
position: fixed;
top: 0; left: 0;
z-index: 100;
overflow-y: auto;
transition: var(--transition);
}
.sidebar-brand {
padding: 24px 20px;
border-bottom: 1px solid var(--sidebar-border);
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.brand-icon {
width: 40px; height: 40px;
background: var(--primary);
border-radius: 10px;
display: flex; align-items: center; justify-content: center;
font-size: 1.1rem;
color: white;
flex-shrink: 0;
box-shadow: var(--shadow-sm);
}
.brand-text {
display: flex;
flex-direction: column;
line-height: 1.3;
}
.brand-name {
font-size: 0.95rem;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.brand-sub {
font-size: 0.75rem;
color: var(--text-muted);
font-weight: 500;
}
.sidebar-nav {
padding: 16px 0;
flex: 1;
}
.nav-section-title {
padding: 16px 20px 8px;
font-size: 0.7rem;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 20px;
color: var(--text-secondary);
font-size: 0.9rem;
font-weight: 500;
text-decoration: none;
border-radius: 0;
transition: var(--transition);
position: relative;
cursor: pointer;
border: none;
background: none;
width: 100%;
text-align: left;
}
.nav-item:hover {
background: #F3F4F6;
color: var(--text-primary);
}
.nav-item.active {
background: #EFF6FF;
color: var(--primary-dark);
}
.nav-item.active::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 4px;
background: var(--primary);
border-radius: 0 2px 2px 0;
}
.nav-item .nav-icon {
width: 20px;
text-align: center;
font-size: 0.95rem;
flex-shrink: 0;
color: inherit;
}
.nav-item .nav-badge {
margin-left: auto;
background: var(--danger);
color: #fff;
font-size: 0.7rem;
font-weight: 700;
padding: 2px 8px;
border-radius: 20px;
}
.sidebar-footer {
border-top: 1px solid var(--sidebar-border);
padding: 16px;
flex-shrink: 0;
background: #FAFAFA;
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
padding: 10px;
border-radius: var(--radius-md);
background: #FFFFFF;
border: 1px solid var(--sidebar-border);
box-shadow: var(--shadow-sm);
}
.user-avatar {
width: 36px; height: 36px;
border-radius: 50%;
background: #E0E7FF;
display: flex; align-items: center; justify-content: center;
font-size: 0.9rem;
font-weight: 700;
color: var(--primary-dark);
flex-shrink: 0;
}
.user-details { flex: 1; overflow: hidden; }
.user-name { font-size: 0.85rem; font-weight: 600; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.user-role { font-size: 0.75rem; color: var(--text-muted); text-transform: capitalize; }
.user-logout {
background: none; border: none; color: var(--text-muted);
cursor: pointer; font-size: 1.1rem; padding: 6px;
transition: all 0.2s;
border-radius: var(--radius-sm);
}
.user-logout:hover { color: var(--danger); background: #FEF2F2; }
/* ══════════════════════════════
MAIN CONTENT AREA
══════════════════════════════ */
.admin-main {
margin-left: var(--sidebar-w);
flex: 1;
display: flex;
flex-direction: column;
min-height: 100vh;
min-width: 0;
}
/* ── Topbar ── */
.topbar {
height: 64px;
background: var(--bg-surface);
border-bottom: 1px solid var(--border-light);
display: flex;
align-items: center;
padding: 0 32px;
gap: 16px;
position: sticky;
top: 0;
z-index: 50;
}
.topbar-title {
font-size: 1.05rem;
font-weight: 600;
color: var(--text-primary);
flex: 1;
}
.topbar-actions {
display: flex;
align-items: center;
gap: 12px;
}
.topbar-time {
font-size: 0.82rem;
font-weight: 500;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
background: #F9FAFB;
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
}
/* ── Content ── */
.admin-content {
flex: 1;
padding: 32px;
overflow-y: auto;
}
/* ══════════════════════════════
DASHBOARD STATS CARDS
══════════════════════════════ */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 20px;
margin-bottom: 32px;
}
.stat-card {
background: var(--bg-surface);
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
padding: 24px;
display: flex;
align-items: flex-start;
gap: 16px;
transition: var(--transition);
box-shadow: var(--shadow-sm);
}
.stat-card:hover { transform: translateY(-3px); box-shadow: var(--shadow-md); border-color: var(--border-hover); }
.stat-icon {
width: 48px; height: 48px;
border-radius: var(--radius-lg);
display: flex; align-items: center; justify-content: center;
font-size: 1.3rem;
flex-shrink: 0;
}
.stat-body { flex: 1; min-width: 0; }
.stat-label { font-size: 0.8rem; color: var(--text-muted); font-weight: 600; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.05em; }
.stat-value { font-size: 1.9rem; font-weight: 800; color: var(--text-primary); line-height: 1; letter-spacing: -0.02em; }
.stat-change { font-size: 0.75rem; color: var(--text-muted); margin-top: 8px; font-weight: 500; }
/* Card color variants (Flat UI style) */
.stat-card.primary .stat-icon { background: #EFF6FF; color: #2563EB; }
.stat-card.success .stat-icon { background: #ECFDF5; color: #059669; }
.stat-card.danger .stat-icon { background: #FEF2F2; color: #DC2626; }
.stat-card.warning .stat-icon { background: #FFFBEB; color: #D97706; }
.stat-card.info .stat-icon { background: #F0F9FF; color: #0284C7; }
.stat-card.violet .stat-icon { background: #F5F3FF; color: #7C3AED; }
/* ══════════════════════════════
DATA TABLE PAGE LAYOUT
══════════════════════════════ */
.page-toolbar {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
flex-wrap: wrap;
}
.page-toolbar .search-box {
flex: 1;
min-width: 250px;
max-width: 360px;
position: relative;
}
.search-box .search-icon {
position: absolute;
left: 14px; top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
font-size: 0.9rem;
}
.search-box input {
width: 100%;
padding: 10px 14px 10px 38px;
background: #FFFFFF;
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: 0.9rem;
outline: none;
transition: var(--transition);
font-family: 'Inter', sans-serif;
box-shadow: var(--shadow-sm);
}
.search-box input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); }
.search-box input::placeholder { color: #9CA3AF; }
/* ══════════════════════════════
MAP PAGE IN ADMIN
══════════════════════════════ */
.admin-map-page .admin-content {
padding: 0;
display: flex;
flex-direction: column;
}
.admin-map-page .topbar {
z-index: 500;
}
#admin-map {
flex: 1;
min-height: calc(100vh - 64px);
}
.map-sidebar-floating {
position: absolute;
top: 16px; left: 16px;
z-index: 400;
width: 280px;
background: #FFFFFF;
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
padding: 16px;
box-shadow: var(--shadow-md);
}
/* ══════════════════════════════
RESPONSIVE
══════════════════════════════ */
@media (max-width: 768px) {
.sidebar { transform: translateX(-100%); }
.sidebar.open { transform: translateX(0); box-shadow: var(--shadow-lg); }
.admin-main { margin-left: 0; }
.stats-grid { grid-template-columns: 1fr; }
}
+215
View File
@@ -0,0 +1,215 @@
/* =========================================================
AUTH PAGE STYLES — login.php
========================================================= */
body.auth-page {
background: #F9FAFB;
min-height: 100vh;
padding: 20px;
overflow-y: auto;
}
/* ── Center Panel (Form) ── */
.auth-center {
width: 100%;
max-width: 440px;
margin: 40px auto;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
background: #FFFFFF;
border-radius: 16px;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.05), 0 8px 10px -6px rgba(0, 0, 0, 0.01);
border: 1px solid #E5E7EB;
}
.auth-form-wrap {
width: 100%;
}
.auth-form-title {
font-size: 1.8rem;
font-weight: 800;
color: #111827;
margin-bottom: 8px;
letter-spacing: -0.03em;
}
.auth-form-subtitle {
color: #6B7280;
font-size: 0.95rem;
margin-bottom: 32px;
}
/* Role selector */
.role-selector {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 32px;
}
.role-card {
position: relative;
cursor: pointer;
}
.role-card input[type="radio"] {
position: absolute;
opacity: 0;
width: 0; height: 0;
}
.role-card-label {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px 12px;
border-radius: 12px;
border: 2px solid #E5E7EB;
background: #FFFFFF;
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
}
.role-card input:checked + .role-card-label {
border-color: #2563EB;
background: #EFF6FF;
}
.role-card-label:hover {
border-color: #D1D5DB;
background: #F9FAFB;
}
.role-card input:checked + .role-card-label:hover {
border-color: #2563EB;
}
.role-card-icon {
font-size: 1.5rem;
line-height: 1;
}
.role-card-name {
font-size: 0.9rem;
font-weight: 600;
color: #111827;
}
.role-card input:checked + .role-card-label .role-card-name {
color: #1D4ED8;
}
.role-card-desc {
font-size: 0.75rem;
color: #6B7280;
}
/* Login form */
.auth-form .form-group { margin-bottom: 20px; }
.auth-form .input-with-icon {
position: relative;
}
.auth-form .input-with-icon .input-icon {
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
color: #9CA3AF;
font-size: 1rem;
pointer-events: none;
}
.auth-form .input-with-icon .form-control {
padding-left: 44px;
height: 46px;
border-radius: 8px;
}
.auth-form .btn-login {
width: 100%;
padding: 14px;
font-size: 1rem;
justify-content: center;
background: #2563EB;
color: #FFFFFF;
border-radius: 8px;
margin-top: 12px;
font-weight: 600;
box-shadow: 0 4px 6px -1px rgba(37, 99, 235, 0.2);
}
.auth-form .btn-login:hover {
background: #1D4ED8;
box-shadow: 0 6px 8px -1px rgba(37, 99, 235, 0.3);
}
.auth-error {
background: #FEF2F2;
border: 1px solid #FECACA;
border-radius: 8px;
padding: 12px 16px;
color: #B91C1C;
font-size: 0.9rem;
margin-bottom: 24px;
display: flex;
align-items: center;
gap: 10px;
}
.auth-hint {
margin-top: 36px;
padding-top: 24px;
border-top: 1px solid #E5E7EB;
}
.auth-hint-title {
font-size: 0.8rem;
font-weight: 700;
color: #6B7280;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 12px;
}
.auth-hint-creds {
display: flex;
flex-direction: column;
gap: 10px;
}
.auth-hint-item {
display: flex;
justify-content: space-between;
align-items: center;
background: #F9FAFB;
border: 1px solid #E5E7EB;
border-radius: 8px;
padding: 10px 14px;
font-size: 0.85rem;
}
.auth-hint-role {
font-weight: 600;
color: #374151;
}
.auth-hint-pass {
font-family: monospace;
color: #4B5563;
background: #E5E7EB;
padding: 4px 10px;
border-radius: 6px;
font-weight: 500;
}
/* Responsive */
@media (max-width: 900px) {
.auth-center { width: 100%; border-left: none; }
}
+248
View File
@@ -0,0 +1,248 @@
/* =========================================================
WEBGIS PROJECT FINAL — Design System (main.css)
========================================================= */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
:root {
/* Colors - Clean Light Theme */
--primary: #2563EB; /* Professional Blue */
--primary-light: #3B82F6;
--primary-dark: #1D4ED8;
--accent: #10B981;
--danger: #EF4444;
--warning: #F59E0B;
--info: #0EA5E9;
/* Backgrounds */
--bg-base: #F9FAFB; /* Very light gray */
--bg-surface: #FFFFFF; /* Pure white */
--bg-elevated: #FFFFFF;
--bg-card: #FFFFFF;
/* Borders */
--border-light: #E5E7EB;
--border-hover: #D1D5DB;
/* Text */
--text-primary: #111827; /* Near black */
--text-secondary: #4B5563; /* Dark gray */
--text-muted: #6B7280; /* Medium gray */
/* Sidebar */
--sidebar-w: 260px;
--sidebar-bg: #FFFFFF;
--sidebar-border: #E5E7EB;
/* Misc */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
--transition: all 0.2s ease-in-out;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body {
font-family: 'Inter', sans-serif;
background: var(--bg-base);
color: var(--text-primary);
font-size: 14px;
line-height: 1.6;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
a { color: var(--primary); text-decoration: none; transition: var(--transition); }
a:hover { color: var(--primary-dark); }
/* ── Scrollbar ── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #CBD5E1; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #94A3B8; }
/* ── Buttons ── */
.btn {
display: inline-flex; align-items: center; gap: 8px;
padding: 8px 16px; border-radius: var(--radius-md); border: 1px solid transparent;
font-size: 0.85rem; font-weight: 500; font-family: 'Inter', sans-serif;
cursor: pointer; transition: var(--transition); white-space: nowrap;
outline: none;
}
.btn-primary { background: var(--primary); color: #fff; box-shadow: var(--shadow-sm); }
.btn-primary:hover { background: var(--primary-dark); }
.btn-success { background: var(--accent); color: #fff; box-shadow: var(--shadow-sm); }
.btn-success:hover { filter: brightness(0.95); }
.btn-danger { background: var(--danger); color: #fff; }
.btn-danger:hover { filter: brightness(0.95); }
.btn-ghost { background: transparent; border: 1px solid var(--border-light); color: var(--text-secondary); }
.btn-ghost:hover { background: #F3F4F6; color: var(--text-primary); border-color: var(--border-hover); }
.btn-sm { padding: 6px 12px; font-size: 0.78rem; }
.btn-icon { padding: 8px; border-radius: var(--radius-sm); }
/* ── Cards ── */
.card {
background: var(--bg-surface);
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
padding: 24px;
box-shadow: var(--shadow-sm);
}
/* ── Form Elements ── */
.form-group { margin-bottom: 16px; }
.form-label { display: block; font-size: 0.82rem; font-weight: 600; color: var(--text-primary); margin-bottom: 6px; }
.form-control {
width: 100%; padding: 10px 14px; border-radius: var(--radius-md);
background: #FFFFFF; border: 1px solid var(--border-light);
color: var(--text-primary); font-size: 0.9rem; font-family: 'Inter', sans-serif;
outline: none; transition: var(--transition); box-shadow: var(--shadow-sm);
}
.form-control:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); }
.form-control::placeholder { color: #9CA3AF; }
select.form-control {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 36px;
}
textarea.form-control { resize: vertical; min-height: 80px; }
/* ── Badges ── */
.badge { display: inline-flex; align-items: center; padding: 4px 10px; border-radius: var(--radius-xl); font-size: 0.75rem; font-weight: 600; }
.badge-primary { background: #EFF6FF; color: #1D4ED8; border: 1px solid #BFDBFE; }
.badge-success { background: #ECFDF5; color: #047857; border: 1px solid #A7F3D0; }
.badge-danger { background: #FEF2F2; color: #B91C1C; border: 1px solid #FECACA; }
.badge-warning { background: #FFFBEB; color: #B45309; border: 1px solid #FDE68A; }
.badge-info { background: #F0F9FF; color: #0369A1; border: 1px solid #BAE6FD; }
/* ── Tables ── */
.table-wrap { overflow-x: auto; border-radius: var(--radius-lg); border: 1px solid var(--border-light); background: var(--bg-surface); box-shadow: var(--shadow-sm); }
.data-table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
.data-table thead th { background: #F9FAFB; padding: 14px 16px; text-align: left; font-size: 0.75rem; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; white-space: nowrap; border-bottom: 1px solid var(--border-light); }
.data-table tbody tr { border-bottom: 1px solid var(--border-light); transition: background 0.15s; }
.data-table tbody tr:last-child { border-bottom: none; }
.data-table tbody tr:hover { background: #F3F4F6; }
.data-table td { padding: 14px 16px; color: var(--text-primary); vertical-align: middle; }
.data-table .actions { display: flex; gap: 6px; }
/* ── Modal ── */
.modal-overlay {
display: none; position: fixed; inset: 0; z-index: 9999;
background: rgba(17, 24, 39, 0.4); backdrop-filter: blur(2px);
align-items: center; justify-content: center;
}
.modal-overlay.active { display: flex; }
.modal {
background: var(--bg-surface); border: 1px solid var(--border-light);
border-radius: var(--radius-lg); padding: 32px;
width: 90%; max-width: 500px; max-height: 90vh; overflow-y: auto;
box-shadow: var(--shadow-lg);
animation: modalIn 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes modalIn { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } }
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
.modal-title { font-size: 1.15rem; font-weight: 700; color: var(--text-primary); }
.modal-close { background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 1.4rem; padding: 4px; border-radius: var(--radius-sm); transition: all 0.15s; }
.modal-close:hover { background: #F3F4F6; color: var(--text-primary); }
.modal-footer { display: flex; gap: 12px; justify-content: flex-end; margin-top: 28px; padding-top: 20px; border-top: 1px solid var(--border-light); }
/* ── Toast ── */
.toast-container { position: fixed; bottom: 24px; right: 24px; z-index: 99999; display: flex; flex-direction: column; gap: 10px; }
.toast { padding: 14px 20px; border-radius: var(--radius-md); font-size: 0.9rem; font-weight: 500; color: #fff; box-shadow: var(--shadow-md); animation: toastIn 0.3s ease; transition: opacity 0.3s; }
.toast.success { background: #10B981; }
.toast.error { background: #EF4444; }
.toast.info { background: #3B82F6; }
@keyframes toastIn { from { opacity: 0; transform: translateX(30px); } to { opacity: 1; transform: translateX(0); } }
/* SPBU realtime snackbar */
.spbu-snackbar {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 99998;
display: grid;
grid-template-columns: 38px minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
width: min(360px, calc(100vw - 32px));
padding: 13px 14px;
color: var(--text-primary);
background: #FFFFFF;
border: 1px solid #A7F3D0;
border-left: 4px solid var(--accent);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
opacity: 0;
pointer-events: none;
transform: translateY(18px);
transition: opacity 0.25s ease, transform 0.25s ease;
}
.spbu-snackbar.show {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.spbu-snackbar-icon {
width: 38px;
height: 38px;
display: grid;
place-items: center;
color: #047857;
background: #ECFDF5;
border: 1px solid #A7F3D0;
border-radius: var(--radius-md);
}
.spbu-snackbar-title {
font-size: 0.84rem;
font-weight: 800;
line-height: 1.2;
}
.spbu-snackbar-meta {
margin-top: 3px;
color: var(--text-muted);
font-size: 0.72rem;
line-height: 1.3;
}
.spbu-snackbar-count {
min-width: 44px;
padding: 5px 8px;
color: #047857;
background: #ECFDF5;
border: 1px solid #A7F3D0;
border-radius: var(--radius-md);
text-align: center;
font-size: 1.05rem;
font-weight: 800;
}
/* ── Page header ── */
.page-header { margin-bottom: 28px; }
.page-header h1 { font-size: 1.6rem; font-weight: 700; color: var(--text-primary); letter-spacing: -0.02em; }
.page-header p { color: var(--text-secondary); margin-top: 6px; font-size: 0.9rem; }
.page-header .breadcrumb { display: flex; gap: 8px; align-items: center; font-size: 0.82rem; color: var(--text-muted); margin-bottom: 12px; }
.page-header .breadcrumb .sep { color: #D1D5DB; }
/* ── Utilities ── */
.text-muted { color: var(--text-muted); }
.text-secondary { color: var(--text-secondary); }
.text-success { color: var(--accent); }
.text-danger { color: var(--danger); }
.text-warning { color: var(--warning); }
.flex { display: flex; }
.flex-center { display: flex; align-items: center; justify-content: center; }
.gap-2 { gap: 8px; }
.gap-3 { gap: 12px; }
.mt-1 { margin-top: 8px; }
.mt-2 { margin-top: 16px; }
.mb-1 { margin-bottom: 8px; }
.mb-2 { margin-bottom: 16px; }
.w-full { width: 100%; }
+765
View File
@@ -0,0 +1,765 @@
/* =========================================================
MAP STYLES - interactive map panels and Leaflet overrides
========================================================= */
.map-page {
height: 100vh;
overflow: hidden;
background: var(--bg-base);
}
.map-wrap {
position: relative;
width: 100%;
height: calc(100vh - 64px);
background: #E5E7EB;
}
#admin-map,
#user-map {
width: 100%;
height: 100%;
background: #E5E7EB;
}
.leaflet-container {
font-family: 'Inter', sans-serif;
background: #E5E7EB;
}
.leaflet-control-attribution {
background: rgba(255, 255, 255, 0.92) !important;
color: var(--text-muted) !important;
border: 1px solid var(--border-light);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-sm);
padding: 3px 8px;
font-size: 0.68rem;
}
.leaflet-control-attribution a {
color: var(--primary-dark) !important;
}
.leaflet-bar {
border: 1px solid var(--border-light) !important;
border-radius: var(--radius-md) !important;
box-shadow: var(--shadow-md) !important;
overflow: hidden;
}
.leaflet-control-zoom a,
.leaflet-draw-toolbar a {
width: 34px !important;
height: 34px !important;
line-height: 34px !important;
background: #FFFFFF !important;
color: var(--text-primary) !important;
border: 0 !important;
border-bottom: 1px solid var(--border-light) !important;
}
.leaflet-control-zoom a:last-child,
.leaflet-draw-toolbar a:last-child {
border-bottom: 0 !important;
}
.leaflet-control-zoom a:hover,
.leaflet-draw-toolbar a:hover {
background: #F3F4F6 !important;
color: var(--primary-dark) !important;
}
/* Layer panel */
.map-sidebar {
position: absolute;
top: 16px;
left: 16px;
z-index: 500;
width: 316px;
max-height: calc(100vh - 112px);
overflow-y: auto;
background: rgba(255, 255, 255, 0.96);
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
padding: 16px;
box-shadow: var(--shadow-lg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.map-panel-header {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 14px;
margin-bottom: 14px;
border-bottom: 1px solid var(--border-light);
}
.map-panel-icon {
width: 40px;
height: 40px;
display: grid;
place-items: center;
flex-shrink: 0;
border-radius: var(--radius-md);
background: #EFF6FF;
color: var(--primary-dark);
border: 1px solid #BFDBFE;
}
.map-panel-header h3 {
margin: 0;
color: var(--text-primary);
font-size: 0.98rem;
line-height: 1.2;
font-weight: 700;
}
.map-panel-header p {
margin: 3px 0 0;
color: var(--text-muted);
font-size: 0.76rem;
line-height: 1.35;
}
.layer-section {
margin-bottom: 16px;
}
.layer-section:last-of-type {
margin-bottom: 12px;
}
.layer-section-title {
margin: 0 0 8px;
color: var(--text-muted);
font-size: 0.68rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.layer-item {
display: grid;
grid-template-columns: 18px 30px minmax(0, 1fr);
align-items: center;
gap: 10px;
min-height: 48px;
padding: 8px 10px;
border: 1px solid transparent;
border-radius: var(--radius-md);
color: var(--text-secondary);
cursor: pointer;
user-select: none;
transition: var(--transition);
}
.layer-item + .layer-item {
margin-top: 4px;
}
.layer-item:hover {
background: #F9FAFB;
border-color: var(--border-light);
color: var(--text-primary);
}
.layer-item input[type="checkbox"] {
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--primary);
cursor: pointer;
}
.layer-color {
--layer-color: var(--primary);
width: 30px;
height: 30px;
display: grid;
place-items: center;
flex-shrink: 0;
color: #FFFFFF;
background: var(--layer-color);
border: 1px solid color-mix(in srgb, var(--layer-color) 72%, #000 28%);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.28);
}
.layer-color i {
font-size: 0.76rem;
}
.layer-point {
border-radius: 50%;
}
.layer-line {
height: 4px;
border-radius: 999px;
align-self: center;
box-shadow: none;
}
.layer-area {
border-radius: 6px;
background: color-mix(in srgb, var(--layer-color) 22%, #FFFFFF 78%);
border-color: var(--layer-color);
}
.layer-hatch {
background:
repeating-linear-gradient(45deg, color-mix(in srgb, var(--layer-color) 78%, #FFFFFF 22%) 0 4px, transparent 4px 8px),
color-mix(in srgb, var(--layer-color) 12%, #FFFFFF 88%);
}
.layer-gradient {
border-radius: 6px;
background: linear-gradient(90deg, #10B981 0%, #F59E0B 50%, #EF4444 100%);
border-color: #D1D5DB;
}
.layer-text {
display: flex;
flex-direction: column;
min-width: 0;
line-height: 1.25;
}
.layer-name {
color: var(--text-primary);
font-size: 0.86rem;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.layer-meta {
margin-top: 2px;
color: var(--text-muted);
font-size: 0.7rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.map-quickstats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
padding-top: 14px;
border-top: 1px solid var(--border-light);
}
.qs-item {
min-width: 0;
padding: 9px 6px;
text-align: center;
background: #F9FAFB;
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
}
.qs-value {
font-size: 1.08rem;
line-height: 1;
font-weight: 800;
}
.qs-label {
margin-top: 3px;
color: var(--text-muted);
font-size: 0.62rem;
font-weight: 700;
}
/* Toolbar */
.map-analysis {
position: absolute;
top: 16px;
left: 50%;
z-index: 500;
display: flex;
align-items: center;
gap: 6px;
max-width: calc(100% - 380px);
padding: 8px;
background: rgba(255, 255, 255, 0.96);
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
transform: translateX(-50%);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.map-analysis button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
min-height: 34px;
padding: 7px 12px;
color: var(--text-secondary);
background: #FFFFFF;
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
font-family: 'Inter', sans-serif;
font-size: 0.78rem;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
box-shadow: var(--shadow-sm);
transition: var(--transition);
}
.map-analysis button:hover {
background: #F3F4F6;
color: var(--text-primary);
border-color: var(--border-hover);
}
.map-analysis button.active {
color: #FFFFFF;
background: var(--primary);
border-color: var(--primary);
}
.map-analysis .map-tool-accent {
color: #047857;
background: #ECFDF5;
border-color: #A7F3D0;
}
.map-analysis .map-tool-accent:hover {
color: #065F46;
background: #D1FAE5;
border-color: #6EE7B7;
}
/* Nearest SPBU panel */
.spbu-panel {
position: absolute;
top: 16px;
right: 16px;
z-index: 500;
width: 310px;
padding: 16px;
background: rgba(255, 255, 255, 0.96);
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.spbu-panel-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
color: var(--text-primary);
font-size: 0.94rem;
font-weight: 800;
}
.spbu-result {
margin-top: 10px;
padding: 12px;
background: #F9FAFB;
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
}
.spbu-result-name {
color: var(--text-primary);
font-size: 0.9rem;
font-weight: 800;
}
.spbu-result-meta {
margin-top: 4px;
color: var(--text-muted);
font-size: 0.75rem;
}
.spbu-result-distance {
margin-top: 8px;
color: var(--primary-dark);
font-size: 1.35rem;
line-height: 1;
font-weight: 800;
}
/* Markers */
.marker-pin {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
color: #FFFFFF;
background: var(--primary);
border: 2px solid #FFFFFF;
border-radius: 50% 50% 50% 0;
box-shadow: 0 6px 12px rgba(17, 24, 39, 0.28);
font-size: 0.9rem;
transform: rotate(-45deg);
}
.marker-pin > * {
transform: rotate(45deg);
}
.div-icon-wrap {
background: transparent;
border: none;
}
/* Popups */
.leaflet-popup-content-wrapper {
color: var(--text-primary) !important;
background: #FFFFFF !important;
border: 1px solid var(--border-light);
border-radius: var(--radius-lg) !important;
box-shadow: var(--shadow-lg);
}
.leaflet-popup-tip {
background: #FFFFFF !important;
}
.leaflet-popup-content {
margin: 13px 16px !important;
font-family: 'Inter', sans-serif;
font-size: 0.85rem;
line-height: 1.5;
}
.popup-title {
margin-bottom: 6px;
color: var(--text-primary);
font-size: 0.98rem;
font-weight: 800;
}
.popup-row {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 5px;
color: var(--text-secondary);
}
.popup-row strong {
color: var(--text-primary);
}
.popup-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.popup-actions .btn {
padding: 5px 10px;
font-size: 0.75rem;
}
/* Legend */
.map-legend {
position: absolute;
right: 16px;
bottom: 16px;
z-index: 500;
min-width: 230px;
padding: 14px;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.96);
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.map-legend-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
color: var(--text-primary);
font-size: 0.82rem;
font-weight: 800;
}
.legend-grid {
display: grid;
gap: 7px;
}
.legend-item {
display: grid;
grid-template-columns: 24px minmax(0, 1fr);
align-items: center;
gap: 8px;
min-height: 22px;
color: var(--text-secondary);
font-size: 0.76rem;
font-weight: 600;
}
.legend-symbol {
--legend-color: var(--primary);
justify-self: center;
background: var(--legend-color);
}
.legend-dot {
width: 12px;
height: 12px;
border: 2px solid #FFFFFF;
border-radius: 50%;
box-shadow: 0 0 0 1px color-mix(in srgb, var(--legend-color) 70%, #000 30%);
}
.legend-line {
width: 22px;
height: 4px;
border-radius: 999px;
}
.legend-area {
width: 16px;
height: 16px;
border: 1px solid var(--legend-color);
border-radius: 4px;
background: color-mix(in srgb, var(--legend-color) 18%, #FFFFFF 82%);
}
.legend-hatch {
width: 16px;
height: 16px;
border: 1px solid var(--legend-color);
border-radius: 4px;
background:
repeating-linear-gradient(45deg, color-mix(in srgb, var(--legend-color) 76%, #FFFFFF 24%) 0 3px, transparent 3px 6px),
color-mix(in srgb, var(--legend-color) 12%, #FFFFFF 88%);
}
/* Modal */
.map-modal {
position: fixed;
inset: 0;
z-index: 9999;
display: none;
align-items: center;
justify-content: center;
background: rgba(17, 24, 39, 0.45);
backdrop-filter: blur(2px);
}
.map-modal.active {
display: flex;
}
.map-modal-content {
width: 90%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
padding: 28px 32px;
background: var(--bg-surface);
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
animation: modalIn 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes modalIn {
from {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* Loading and toast */
.map-loading {
position: absolute;
inset: 0;
z-index: 600;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--text-primary);
background: rgba(249, 250, 251, 0.72);
backdrop-filter: blur(3px);
font-size: 0.95rem;
font-weight: 700;
}
.spinner {
width: 28px;
height: 28px;
border: 3px solid rgba(37, 99, 235, 0.16);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.toast-container {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 99999;
display: flex;
flex-direction: column;
gap: 10px;
}
.toast {
max-width: 320px;
padding: 12px 20px;
color: #FFFFFF;
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
font-size: 0.88rem;
font-weight: 600;
animation: toastIn 0.3s ease;
transition: opacity 0.3s;
}
.toast.success { background: #10B981; }
.toast.error { background: #EF4444; }
.toast.info { background: #3B82F6; }
@keyframes toastIn {
from {
opacity: 0;
transform: translateX(30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.read-only-badge {
position: absolute;
top: 16px;
right: 16px;
z-index: 500;
display: flex;
align-items: center;
gap: 7px;
padding: 8px 13px;
color: #047857;
background: #ECFDF5;
border: 1px solid #A7F3D0;
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
font-size: 0.78rem;
font-weight: 800;
}
/* Responsive */
@media (max-width: 1180px) {
.map-analysis {
left: auto;
right: 16px;
max-width: calc(100% - 364px);
overflow-x: auto;
transform: none;
}
}
@media (max-width: 900px) {
.map-sidebar {
width: 300px;
}
.map-analysis {
top: auto;
right: 16px;
bottom: 16px;
left: 16px;
max-width: none;
justify-content: flex-start;
}
.map-legend {
right: 16px;
bottom: 78px;
}
}
@media (max-width: 768px) {
.map-wrap {
height: calc(100vh - 64px);
}
.map-sidebar {
top: 8px;
left: 8px;
width: calc(100% - 16px);
max-height: 46vh;
padding: 12px;
}
.map-panel-header {
margin-bottom: 10px;
padding-bottom: 10px;
}
.layer-item {
min-height: 44px;
}
.map-quickstats {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.map-analysis {
right: 8px;
bottom: 8px;
left: 8px;
padding: 6px;
overflow-x: auto;
}
.map-analysis button {
min-height: 32px;
padding: 6px 10px;
font-size: 0.74rem;
}
.spbu-panel {
display: none;
}
.map-legend {
right: 8px;
bottom: 66px;
min-width: 210px;
max-width: calc(100% - 16px);
}
.read-only-badge {
top: auto;
right: 8px;
bottom: 66px;
}
}
+141
View File
@@ -0,0 +1,141 @@
/* =========================================================
public-map.js — Peta Publik SKPL
Choropleth (KF-06) + Heatmap (KF-08) + Layer switcher (KF-09)
+ Legenda + Pencarian wilayah (KF-11).
`map` & `APP_BASE` disediakan global oleh peta_publik.php.
========================================================= */
(function () {
const API = APP_BASE + '/api';
let choroplethLayer = null;
let heatLayer = null;
let wilayahFeatures = [];
// ---- Skala warna persentase kemiskinan ----
function colorFor(persen) {
return persen >= 10 ? '#EF4444' // merah — tinggi
: persen >= 5 ? '#FB923C' // oranye — sedang-tinggi
: persen >= 2 ? '#FBBF24' // kuning — sedang
: persen > 0 ? '#A3E635' // hijau muda — rendah
: '#2ECC71'; // hijau — tanpa kasus
}
function styleWilayah(feature) {
const p = feature.properties.persentase_miskin || 0;
return {
fillColor: colorFor(p),
fillOpacity: 0.55,
color: '#374151',
weight: 1.5,
};
}
function popupWilayah(props) {
return `<div style="font-weight:700;margin-bottom:6px;">${props.nama}</div>
<div style="font-size:0.85rem;line-height:1.6;">
Penduduk: <strong>${props.jumlah_penduduk}</strong><br>
Warga miskin: <strong>${props.jumlah_miskin}</strong><br>
Persentase: <strong>${props.persentase_miskin}%</strong>
</div>`;
}
// ---- Choropleth (KF-06) ----
async function loadChoropleth() {
const r = await fetch(`${API}/wilayah.php`);
const j = await r.json();
const data = j.status === 'success' ? j.data : { type: 'FeatureCollection', features: [] };
wilayahFeatures = data.features || [];
choroplethLayer = L.geoJSON(data, {
style: styleWilayah,
onEachFeature: (f, layer) => {
layer.bindPopup(popupWilayah(f.properties));
layer.bindTooltip(f.properties.nama, { sticky: true });
},
}).addTo(map);
if (wilayahFeatures.length) {
map.fitBounds(choroplethLayer.getBounds(), { padding: [40, 40] });
}
return choroplethLayer;
}
// ---- Heatmap (KF-08) ----
async function loadHeatmap() {
const r = await fetch(`${API}/heatmap.php`);
const j = await r.json();
const points = j.status === 'success' ? j.data : [];
heatLayer = L.heatLayer(points, {
radius: 30, blur: 22, maxZoom: 17,
gradient: { 0.2: '#3B82F6', 0.5: '#FBBF24', 0.9: '#EF4444' },
});
return heatLayer;
}
// ---- Legenda (KF-06) ----
function addLegend() {
const legend = L.control({ position: 'bottomleft' });
legend.onAdd = function () {
const div = L.DomUtil.create('div', 'legend');
const grades = [
{ c: '#2ECC71', t: 'Tidak ada kasus' },
{ c: '#A3E635', t: '< 2% miskin' },
{ c: '#FBBF24', t: '2 5%' },
{ c: '#FB923C', t: '5 10%' },
{ c: '#EF4444', t: '> 10% (prioritas)' },
];
div.innerHTML = '<h4>Tingkat Kemiskinan</h4>' +
grades.map(g => `<div><i style="background:${g.c}"></i>${g.t}</div>`).join('');
return div;
};
legend.addTo(map);
}
// ---- Pencarian wilayah (KF-11) ----
function addSearch() {
const ctrl = L.control({ position: 'topright' });
ctrl.onAdd = function () {
const div = L.DomUtil.create('div', 'map-search');
div.innerHTML = `<input type="text" id="searchWilayah" placeholder="🔍 Cari kelurahan..." autocomplete="off">
<div id="searchResults" class="search-results"></div>`;
L.DomEvent.disableClickPropagation(div);
return div;
};
ctrl.addTo(map);
const input = document.getElementById('searchWilayah');
const results = document.getElementById('searchResults');
input.addEventListener('input', function () {
const q = this.value.toLowerCase().trim();
results.innerHTML = '';
if (!q) return;
wilayahFeatures
.filter(f => f.properties.nama.toLowerCase().includes(q))
.slice(0, 6)
.forEach(f => {
const item = document.createElement('div');
item.className = 'search-item';
item.textContent = f.properties.nama;
item.onclick = () => {
const layer = L.geoJSON(f);
map.fitBounds(layer.getBounds(), { padding: [60, 60] });
results.innerHTML = '';
input.value = f.properties.nama;
};
results.appendChild(item);
});
});
}
// ---- Inisialisasi ----
Promise.all([loadChoropleth(), loadHeatmap()]).then(([choro, heat]) => {
// Layer switcher (KF-09)
L.control.layers(null, {
'Batas Kelurahan (Choropleth)': choro,
'Heatmap Kepadatan': heat,
}, { collapsed: false, position: 'topright' }).addTo(map);
addLegend();
addSearch();
});
})();
+127
View File
@@ -0,0 +1,127 @@
/* =========================================================
skpl-layers.js — Layer SKPL reusable untuk peta login.
Choropleth kelurahan (KF-06) + Heatmap (KF-08) + legenda +
layer switcher overlay (KF-09) + pencarian wilayah (KF-11).
Pakai: panggil attachSkplLayers(map) setelah map dibuat.
Butuh leaflet.heat sudah dimuat di halaman.
========================================================= */
(function () {
function injectStyle() {
if (document.getElementById('skpl-layer-style')) return;
const css = `
.skpl-legend{background:#fff;padding:10px 12px;border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,.15);font-size:.76rem;line-height:1.6}
.skpl-legend h4{font-size:.78rem;margin:0 0 6px}
.skpl-legend i{display:inline-block;width:13px;height:13px;border-radius:3px;margin-right:6px;vertical-align:middle}
.skpl-search{background:#fff;border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,.15);padding:6px;width:220px}
.skpl-search input{width:100%;border:1px solid #E5E7EB;border-radius:7px;padding:7px 9px;font-size:.83rem;outline:none}
.skpl-search input:focus{border-color:#2563EB}
.skpl-results .item{padding:7px 9px;font-size:.82rem;cursor:pointer;border-radius:6px}
.skpl-results .item:hover{background:#EFF6FF;color:#1D4ED8}`;
const s = document.createElement('style');
s.id = 'skpl-layer-style';
s.textContent = css;
document.head.appendChild(s);
}
function colorFor(p) {
return p >= 10 ? '#EF4444' : p >= 5 ? '#FB923C' : p >= 2 ? '#FBBF24' : p > 0 ? '#A3E635' : '#2ECC71';
}
window.attachSkplLayers = async function (map) {
if (!map || typeof L === 'undefined') return;
injectStyle();
const API = (typeof APP_BASE !== 'undefined' ? APP_BASE : '') + '/api';
let features = [];
// Choropleth
let choro = null;
try {
const j = await (await fetch(`${API}/wilayah.php`)).json();
const data = j.status === 'success' ? j.data : { type: 'FeatureCollection', features: [] };
features = data.features || [];
choro = L.geoJSON(data, {
style: f => ({
fillColor: colorFor(f.properties.persentase_miskin || 0),
fillOpacity: 0.5, color: '#374151', weight: 1.5,
}),
onEachFeature: (f, layer) => {
const p = f.properties;
layer.bindPopup(`<div style="font-weight:700;margin-bottom:6px;">${p.nama}</div>
<div style="font-size:.85rem;line-height:1.6;">
Penduduk: <strong>${p.jumlah_penduduk}</strong><br>
Warga miskin: <strong>${p.jumlah_miskin}</strong><br>
Persentase: <strong>${p.persentase_miskin}%</strong></div>`);
layer.bindTooltip(p.nama, { sticky: true });
},
});
} catch (e) { /* abaikan */ }
// Heatmap
let heat = null;
try {
const j = await (await fetch(`${API}/heatmap.php`)).json();
const pts = j.status === 'success' ? j.data : [];
if (L.heatLayer) {
heat = L.heatLayer(pts, {
radius: 30, blur: 22, maxZoom: 17,
gradient: { 0.2: '#3B82F6', 0.5: '#FBBF24', 0.9: '#EF4444' },
});
}
} catch (e) { /* abaikan */ }
// Layer switcher overlay
const overlays = {};
if (choro) overlays['🗺️ Choropleth Kelurahan'] = choro;
if (heat) overlays['🔥 Heatmap Kepadatan'] = heat;
if (Object.keys(overlays).length) {
L.control.layers(null, overlays, { collapsed: true, position: 'topright' }).addTo(map);
}
// Legenda
const legend = L.control({ position: 'bottomleft' });
legend.onAdd = function () {
const div = L.DomUtil.create('div', 'skpl-legend');
const g = [
['#2ECC71', 'Tidak ada kasus'], ['#A3E635', '< 2%'],
['#FBBF24', '2 5%'], ['#FB923C', '5 10%'], ['#EF4444', '> 10% (prioritas)'],
];
div.innerHTML = '<h4>Tingkat Kemiskinan</h4>' +
g.map(x => `<div><i style="background:${x[0]}"></i>${x[1]}</div>`).join('');
return div;
};
legend.addTo(map);
// Pencarian wilayah (KF-11)
const search = L.control({ position: 'topright' });
search.onAdd = function () {
const div = L.DomUtil.create('div', 'skpl-search');
div.innerHTML = `<input type="text" placeholder="🔍 Cari kelurahan...">
<div class="skpl-results"></div>`;
L.DomEvent.disableClickPropagation(div);
return div;
};
search.addTo(map);
const root = document.querySelector('.skpl-search');
if (root) {
const input = root.querySelector('input');
const res = root.querySelector('.skpl-results');
input.addEventListener('input', function () {
const q = this.value.toLowerCase().trim();
res.innerHTML = '';
if (!q) return;
features.filter(f => f.properties.nama.toLowerCase().includes(q)).slice(0, 6).forEach(f => {
const it = document.createElement('div');
it.className = 'item';
it.textContent = f.properties.nama;
it.onclick = () => {
map.fitBounds(L.geoJSON(f).getBounds(), { padding: [60, 60] });
res.innerHTML = '';
input.value = f.properties.nama;
};
res.appendChild(it);
});
});
}
};
})();
+75
View File
@@ -0,0 +1,75 @@
/* =========================================================
skpl-map.js — Peta SKPL (operator & pimpinan)
Warga miskin (POI) + fasilitas publik + choropleth + heatmap
(choropleth/heatmap/legenda/pencarian via attachSkplLayers).
Role ditentukan global SKPL_ROLE ('operator' | 'pimpinan').
========================================================= */
(function () {
const API = APP_BASE + '/api';
const ROLE = (typeof SKPL_ROLE !== 'undefined') ? SKPL_ROLE : 'pimpinan';
let map, wargaLayer, fasilitasLayer;
const FAS_ICON = { sekolah: '🏫', puskesmas: '🏥', pasar: '🏪', kantor_kelurahan: '🏛️', rumah_ibadah: '🕌', lainnya: '📍' };
function divIcon(emoji, bg, size = 30) {
return L.divIcon({
className: 'div-icon-wrap',
html: `<div style="background:${bg};width:${size}px;height:${size}px;border-radius:50% 50% 50% 0;transform:rotate(-45deg);display:flex;align-items:center;justify-content:center;box-shadow:0 2px 5px rgba(0,0,0,.3);border:2px solid #fff;"><span style="transform:rotate(45deg);font-size:${size*0.45}px;">${emoji}</span></div>`,
iconSize: [size, size], iconAnchor: [size / 2, size], popupAnchor: [0, -size],
});
}
const wargaIcon = divIcon('👤', '#EF4444');
function wargaPopup(p) {
const vb = { terverifikasi: '#10B981', perlu_tinjauan: '#F59E0B', belum: '#9CA3AF' }[p.status_verifikasi] || '#9CA3AF';
let h = `<div style="min-width:200px;"><div style="font-weight:700;margin-bottom:4px;">${p.nama_kk}</div>`;
if (p.nik_kk) h += `<div style="font-size:.8rem;">NIK: ${p.nik_kk}</div>`;
if (p.alamat) h += `<div style="font-size:.8rem;">${p.alamat}</div>`;
h += `<div style="font-size:.8rem;">Penghasilan: Rp ${Number(p.penghasilan||0).toLocaleString('id-ID')}</div>`;
h += `<div style="font-size:.8rem;">Jiwa: ${p.jumlah_jiwa||'-'} · Tanggungan: ${p.jumlah_tanggungan||0}</div>`;
h += `<div style="font-size:.8rem;">Air: ${p.sumber_air||'-'} · Dinding: ${p.material_dinding||'-'}</div>`;
h += `<div style="font-size:.8rem;">Bantuan: <b>${p.bantuan_aktif||'Belum ada'}</b></div>`;
h += `<div style="font-size:.8rem;">Status: <b style="color:${vb}">${p.status_verifikasi||'belum'}</b>${p.jumlah_foto?' · 📷 '+p.jumlah_foto:''}</div>`;
if (ROLE === 'operator') {
h += `<div style="display:flex;gap:6px;margin-top:8px;">
<button onclick="window.location.href='${APP_BASE}/admin/data_warga.php'" style="flex:1;padding:6px;border:1px solid #2563EB;background:#2563EB;color:#fff;border-radius:6px;cursor:pointer;font-size:.78rem;">Kelola</button>
<button onclick="SKPLMAP_del(${p.id})" style="padding:6px 10px;border:1px solid #EF4444;background:#fff;color:#EF4444;border-radius:6px;cursor:pointer;font-size:.78rem;">Hapus</button>
</div>`;
}
return h + `</div>`;
}
async function loadWarga() {
const j = await (await fetch(`${API}/warga_miskin.php`)).json();
const data = j.status === 'success' ? j.data : { features: [] };
wargaLayer = L.geoJSON(data, {
pointToLayer: (f, ll) => L.marker(ll, { icon: wargaIcon }),
onEachFeature: (f, l) => l.bindPopup(wargaPopup(f.properties)),
}).addTo(map);
}
async function loadFasilitas() {
const j = await (await fetch(`${API}/fasilitas.php`)).json();
const data = j.status === 'success' ? j.data : { features: [] };
fasilitasLayer = L.geoJSON(data, {
pointToLayer: (f, ll) => L.marker(ll, { icon: divIcon(FAS_ICON[f.properties.jenis] || '📍', '#2563EB', 26) }),
onEachFeature: (f, l) => l.bindPopup(`<b>${f.properties.nama}</b><br><span style="font-size:.8rem;color:#6B7280;">${f.properties.jenis.replace('_',' ')}</span>`),
});
}
window.SKPLMAP_del = async function (id) {
if (!confirm('Hapus data warga ini?')) return;
const r = await (await fetch(`${API}/warga_miskin.php?id=${id}`, { method: 'DELETE' })).json();
if (r.status === 'success') { map.removeLayer(wargaLayer); loadWarga(); }
else alert(r.message || 'Gagal');
};
document.addEventListener('DOMContentLoaded', async () => {
map = L.map('skpl-map').setView([-0.0500, 109.3450], 13);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { subdomains: 'abcd', maxZoom: 19, attribution: '© OpenStreetMap, © CARTO' }).addTo(map);
await loadWarga();
await loadFasilitas();
if (window.attachSkplLayers) await window.attachSkplLayers(map);
// tambahkan toggle warga & fasilitas
L.control.layers(null, { '👤 Warga Miskin': wargaLayer, '🏢 Fasilitas Publik': fasilitasLayer }, { collapsed: true, position: 'topright' }).addTo(map);
});
})();
+38
View File
@@ -0,0 +1,38 @@
<?php
/**
* activity.php — Helper pencatatan log aktivitas (KF-04).
*
* logActivity($pdo, 'login');
* logActivity($pdo, 'create', 'warga_miskin', 12, 'Tambah warga baru');
*/
/**
* Catat satu baris aktivitas. Gagal mencatat tidak boleh memutus alur utama,
* jadi error ditangkap diam-diam.
*/
function logActivity(
PDO $pdo,
string $aksi,
?string $tabel = null,
?int $recordId = null,
?string $keterangan = null
): void {
try {
$stmt = $pdo->prepare(
"INSERT INTO activity_log
(user_id, username, aksi, tabel_target, record_id, keterangan, ip_address)
VALUES (?, ?, ?, ?, ?, ?, ?)"
);
$stmt->execute([
$_SESSION['user_id'] ?? null,
$_SESSION['username'] ?? null,
$aksi,
$tabel,
$recordId,
$keterangan,
$_SERVER['REMOTE_ADDR'] ?? null,
]);
} catch (Throwable $e) {
// Abaikan — logging tidak boleh menggagalkan request.
}
}
+46
View File
@@ -0,0 +1,46 @@
<?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 loadEnvFile(): void {
$envFile = __DIR__ . '/../../.env';
if (file_exists($envFile)) {
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (strpos($line, '=') === false || strpos($line, '#') === 0) {
continue;
}
[$key, $value] = explode('=', $line, 2);
$key = trim($key);
$value = trim($value);
if (!getenv($key)) {
putenv("$key=$value");
}
}
}
}
loadEnvFile();
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;
}
+95
View File
@@ -0,0 +1,95 @@
<?php
/**
* auth_check.php — Middleware RBAC untuk 4 role SKPL.
*
* Role: publik | enumerator | operator | pimpinan
*
* Usage:
* require_once __DIR__ . '/../config/auth_check.php';
* requireAnyRole(['operator']); // hanya operator
* requireAnyRole(['operator','pimpinan']); // operator atau pimpinan
* requireRole('operator'); // kompat lama (1 role)
*/
require_once __DIR__ . '/session.php';
if (session_status() === PHP_SESSION_NONE) {
startAppSession();
}
function isLoggedIn(): bool {
return isset($_SESSION['user_id']) && isset($_SESSION['role']);
}
function currentRole(): string {
return $_SESSION['role'] ?? 'publik';
}
/**
* Halaman beranda (landing) per role setelah login.
*/
function roleHome(string $role): string {
return app_url(match ($role) {
'operator' => 'admin/index.php',
'pimpinan' => 'pimpinan/index.php',
'enumerator' => 'enumerator/index.php',
default => 'peta_publik.php',
});
}
function requireLogin(?string $redirect = null): void {
if (!isLoggedIn()) {
$redirect = $redirect ?? app_url('login.php');
header("Location: $redirect");
exit;
}
}
/**
* Izinkan akses hanya jika role pengguna ada di daftar $roles.
* Jika tidak, dialihkan ke beranda role-nya sendiri.
*/
function requireAnyRole(array $roles, ?string $redirect = null): void {
requireLogin($redirect);
if (!in_array(currentRole(), $roles, true)) {
header('Location: ' . roleHome(currentRole()));
exit;
}
}
/**
* Kompatibilitas dengan kode lama: requireRole('operator').
*/
function requireRole(string $role, ?string $redirect = null): void {
requireAnyRole([$role], $redirect);
}
/**
* Cek role tanpa redirect (untuk tampilan kondisional menu).
*/
function hasAnyRole(array $roles): bool {
return isLoggedIn() && in_array(currentRole(), $roles, true);
}
function currentUser(): array {
return [
'id' => $_SESSION['user_id'] ?? null,
'username' => $_SESSION['username'] ?? '',
'email' => $_SESSION['email'] ?? '',
'role' => $_SESSION['role'] ?? '',
'wilayah_tugas'=> $_SESSION['wilayah_tugas'] ?? '',
'nama_lengkap' => $_SESSION['nama_lengkap'] ?? '',
];
}
/**
* Label role yang ramah ditampilkan.
*/
function roleLabel(string $role): string {
return match ($role) {
'operator' => 'Operator',
'pimpinan' => 'Pimpinan',
'enumerator' => 'Enumerator',
'publik' => 'Publik',
default => ucfirst($role),
};
}
+57
View File
@@ -0,0 +1,57 @@
<?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) {
$dbUrl = self::env('DATABASE_URL');
if ($dbUrl) {
$parsed = parse_url($dbUrl);
$host = $parsed['host'] ?? '127.0.0.1';
$port = $parsed['port'] ?? '3306';
$username = $parsed['user'] ?? 'root';
$password = $parsed['pass'] ?? '';
$dbName = ltrim($parsed['path'], '/');
} else {
$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);
// Disable strict mode for GROUP BY to prevent 1055 errors in Coolify's MariaDB
self::$conn->exec("SET SESSION sql_mode=(SELECT REPLACE(@@sql_mode,'ONLY_FULL_GROUP_BY',''))");
// Auto-initialize database if empty
$stmt = self::$conn->query("SHOW TABLES LIKE 'users'");
if ($stmt->rowCount() == 0) {
$sqlFile = __DIR__ . '/../../database/webgis_db.sql';
if (file_exists($sqlFile)) {
$sql = file_get_contents($sqlFile);
self::$conn->exec($sql);
}
}
}
return self::$conn;
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
require_once __DIR__ . '/app.php';
function startAppSession(): void {
if (session_status() === PHP_SESSION_ACTIVE) {
return;
}
$secureEnv = strtolower((string)getenv('SESSION_SECURE'));
$isHttps = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https')
|| $secureEnv === 'true'
|| $secureEnv === '1';
session_set_cookie_params([
'lifetime' => 0,
'path' => app_base_path() ?: '/',
'secure' => $isHttps,
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
}
+161
View File
@@ -0,0 +1,161 @@
<?php
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/auth_check.php';
requireAnyRole(['enumerator']);
$pdo = Database::getConnection();
$uid = currentUser()['id'];
$stmt = $pdo->prepare("
SELECT w.*, wl.nama AS wilayah_nama,
(SELECT COUNT(*) FROM foto_rumah f WHERE f.warga_id=w.id) AS jumlah_foto
FROM warga_miskin w LEFT JOIN wilayah wl ON wl.id=w.wilayah_id
WHERE w.created_by=? ORDER BY w.id DESC
");
$stmt->execute([$uid]);
$rows = $stmt->fetchAll();
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Data Saya — Enumerator</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@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">
<style>
*{margin:0;padding:0;box-sizing:border-box;font-family:'Inter',sans-serif;}
body{background:#F3F4F6;color:#111827;padding-bottom:30px;}
.topbar{position:sticky;top:0;z-index:500;background:linear-gradient(135deg,#2563EB,#1D4ED8);color:#fff;padding:14px 16px;display:flex;align-items:center;gap:12px;}
.topbar a{color:#fff;text-decoration:none;font-size:1.2rem;}
.topbar h1{font-size:1rem;font-weight:700;}
.container{max-width:560px;margin:0 auto;padding:16px;}
.card{background:#fff;border:1px solid #E5E7EB;border-radius:12px;padding:14px;margin-bottom:10px;}
.card .nm{font-weight:700;font-size:0.95rem;}
.card .meta{font-size:0.78rem;color:#6B7280;margin-top:2px;}
.badge{font-size:0.7rem;padding:3px 9px;border-radius:20px;}
.b-ok{background:#D1FAE5;color:#065F46;} .b-no{background:#F3F4F6;color:#6B7280;} .b-rev{background:#FEF3C7;color:#92400E;}
.acts{display:flex;gap:8px;margin-top:10px;}
.acts button{flex:1;padding:9px;border:1px solid #D1D5DB;background:#fff;border-radius:8px;font-size:0.8rem;cursor:pointer;}
.acts button.pri{background:#2563EB;color:#fff;border-color:#2563EB;}
.overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:1000;align-items:flex-end;}
.overlay.show{display:flex;}
.sheet{background:#fff;width:100%;max-width:560px;margin:0 auto;border-radius:16px 16px 0 0;padding:18px;max-height:88vh;overflow-y:auto;}
.sheet h3{font-size:1rem;margin-bottom:12px;}
label{display:block;font-size:0.78rem;font-weight:600;margin-bottom:4px;}
input,select{width:100%;padding:11px;border:1px solid #D1D5DB;border-radius:9px;font-size:0.92rem;margin-bottom:10px;}
.row2{display:grid;grid-template-columns:1fr 1fr;gap:8px;}
.btn-save{width:100%;background:#2563EB;color:#fff;border:none;padding:13px;border-radius:10px;font-weight:700;cursor:pointer;}
.photo-prev{display:flex;flex-wrap:wrap;gap:8px;margin:8px 0;}
.photo-prev .ph{position:relative;}
.photo-prev img{width:70px;height:70px;object-fit:cover;border-radius:8px;border:1px solid #E5E7EB;}
.photo-prev .del{position:absolute;top:-6px;right:-6px;background:#EF4444;color:#fff;border:none;border-radius:50%;width:20px;height:20px;cursor:pointer;}
.toast{position:fixed;top:70px;left:50%;transform:translateX(-50%);background:#111827;color:#fff;padding:10px 18px;border-radius:10px;font-size:0.85rem;z-index:2000;opacity:0;transition:.3s;}
.toast.show{opacity:1;} .toast.err{background:#DC2626;}
.empty{text-align:center;color:#9CA3AF;padding:40px 0;font-size:0.9rem;}
</style>
</head>
<body>
<div class="topbar">
<a href="<?= app_url('enumerator/index.php') ?>"><i class="fas fa-arrow-left"></i></a>
<h1>Data yang Saya Input (<?= count($rows) ?>)</h1>
</div>
<div class="container">
<?php if (!$rows): ?><div class="empty">Belum ada data. <br>Tambahkan via "Input Data Warga Baru".</div><?php endif; ?>
<?php foreach ($rows as $r):
$vb = $r['status_verifikasi']==='terverifikasi' ? 'b-ok' : ($r['status_verifikasi']==='perlu_tinjauan' ? 'b-rev' : 'b-no'); ?>
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:start;">
<div>
<div class="nm"><?= htmlspecialchars($r['nama_kk']) ?></div>
<div class="meta"><?= htmlspecialchars($r['wilayah_nama'] ?: 'Tanpa kelurahan') ?> · Rp <?= number_format($r['penghasilan'],0,',','.') ?> · 📷 <?= $r['jumlah_foto'] ?></div>
</div>
<span class="badge <?= $vb ?>"><?= htmlspecialchars($r['status_verifikasi']) ?></span>
</div>
<div class="acts">
<button onclick='openEdit(<?= json_encode($r, JSON_HEX_APOS|JSON_HEX_QUOT) ?>)'><i class="fas fa-edit"></i> Edit</button>
<button class="pri" onclick="openFoto(<?= $r['id'] ?>)"><i class="fas fa-camera"></i> Foto</button>
</div>
</div>
<?php endforeach; ?>
</div>
<!-- Sheet Edit -->
<div class="overlay" id="editOverlay"><div class="sheet">
<h3>✏️ Edit Data</h3>
<input type="hidden" id="eId">
<label>Nama KK *</label><input id="eNama">
<label>NIK</label><input id="eNik" maxlength="16">
<label>Alamat</label><input id="eAlamat">
<div class="row2"><div><label>Jiwa</label><input id="eJiwa" type="number"></div><div><label>Tanggungan</label><input id="eTang" type="number"></div></div>
<label>Pekerjaan</label><input id="ePek">
<label>Penghasilan (Rp)</label><input id="ePeng" type="number">
<label>Sumber Air</label><select id="eAir"><option value="">-</option><option>SUMUR_BOR</option><option>PDAM</option><option>SUNGAI</option><option>HUJAN</option><option>TIDAK_ADA</option></select>
<label>Material Dinding</label><select id="eDind"><option value="">-</option><option>PERMANEN</option><option>SEMI_PERMANEN</option><option>TIDAK_PERMANEN</option></select>
<button class="btn-save" onclick="saveEdit()">Simpan Perubahan</button>
<button class="btn-save" style="background:#fff;color:#374151;border:1px solid #D1D5DB;margin-top:8px;" onclick="closeSheet('editOverlay')">Batal</button>
</div></div>
<!-- Sheet Foto -->
<div class="overlay" id="fotoOverlay"><div class="sheet">
<h3>📷 Foto Kondisi Rumah</h3>
<div class="photo-prev" id="fotoList"></div>
<input type="file" id="fotoFile" accept="image/*" capture="environment" multiple style="margin-bottom:10px;">
<button class="btn-save" onclick="uploadFoto()">Unggah Foto</button>
<button class="btn-save" style="background:#fff;color:#374151;border:1px solid #D1D5DB;margin-top:8px;" onclick="closeSheet('fotoOverlay')">Tutup</button>
</div></div>
<div class="toast" id="toast"></div>
<script>
const APP_BASE='<?= app_url() ?>';
let fotoWid=null;
function toast(m,t){const e=document.getElementById('toast');e.textContent=m;e.className='toast show'+(t==='err'?' err':'');setTimeout(()=>e.className='toast',2800);}
function closeSheet(id){document.getElementById(id).classList.remove('show');}
function openEdit(r){
document.getElementById('eId').value=r.id;
document.getElementById('eNama').value=r.nama_kk||'';
document.getElementById('eNik').value=r.nik_kk||'';
document.getElementById('eAlamat').value=r.alamat||'';
document.getElementById('eJiwa').value=r.jumlah_jiwa||'';
document.getElementById('eTang').value=r.jumlah_tanggungan||'';
document.getElementById('ePek').value=r.pekerjaan_kk||'';
document.getElementById('ePeng').value=r.penghasilan||'';
document.getElementById('eAir').value=r.sumber_air||'';
document.getElementById('eDind').value=r.material_dinding||'';
document.getElementById('editOverlay').classList.add('show');
}
async function saveEdit(){
const id=document.getElementById('eId').value;
const body={
nama_kk:document.getElementById('eNama').value.trim(),
nik_kk:document.getElementById('eNik').value.trim(),
alamat:document.getElementById('eAlamat').value.trim(),
jumlah_jiwa:document.getElementById('eJiwa').value||null,
jumlah_tanggungan:parseInt(document.getElementById('eTang').value)||0,
pekerjaan_kk:document.getElementById('ePek').value.trim(),
penghasilan:parseFloat(document.getElementById('ePeng').value)||0,
sumber_air:document.getElementById('eAir').value,
material_dinding:document.getElementById('eDind').value
};
if(!body.nama_kk) return toast('Nama wajib','err');
const res=await fetch(APP_BASE+'/api/warga_miskin.php?id='+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const d=await res.json();
if(d.status==='success'){toast('Tersimpan ✓');setTimeout(()=>location.reload(),900);}else toast(d.message||'Gagal','err');
}
async function openFoto(wid){
fotoWid=wid; document.getElementById('fotoOverlay').classList.add('show');
const res=await fetch(APP_BASE+'/api/foto.php?warga_id='+wid); const d=await res.json();
const g=document.getElementById('fotoList');
g.innerHTML=(d.data&&d.data.length)?d.data.map(f=>`<div class="ph"><img src="${f.url}"><button class="del" onclick="delFoto(${f.id})">×</button></div>`).join(''):'<p style="color:#9CA3AF;font-size:0.85rem">Belum ada foto.</p>';
}
async function uploadFoto(){
const inp=document.getElementById('fotoFile'); if(!inp.files.length) return toast('Pilih foto','err');
const fd=new FormData(); fd.append('warga_id',fotoWid);
for(const f of inp.files) fd.append('foto[]',f);
const res=await fetch(APP_BASE+'/api/foto.php',{method:'POST',body:fd}); const d=await res.json();
if(d.status==='success'){toast(d.message);inp.value='';openFoto(fotoWid);}else toast(d.message,'err');
}
async function delFoto(id){const res=await fetch(APP_BASE+'/api/foto.php?id='+id,{method:'DELETE'});const d=await res.json();if(d.status==='success'){toast('Foto dihapus');openFoto(fotoWid);}}
</script>
</body>
</html>
+91
View File
@@ -0,0 +1,91 @@
<?php
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/auth_check.php';
requireAnyRole(['enumerator']);
$pdo = Database::getConnection();
$user = currentUser();
$uid = $user['id'];
$wilayah = $user['wilayah_tugas'] ?: 'Belum ditentukan';
$mine = (int)$pdo->query("SELECT COUNT(*) FROM warga_miskin WHERE created_by=" . (int)$uid)->fetchColumn();
$verif = (int)$pdo->query("SELECT COUNT(*) FROM warga_miskin WHERE created_by=" . (int)$uid . " AND status_verifikasi='terverifikasi'")->fetchColumn();
$belum = $mine - $verif;
$recent = $pdo->prepare("SELECT id, nama_kk, status_verifikasi, created_at FROM warga_miskin WHERE created_by=? ORDER BY id DESC LIMIT 5");
$recent->execute([$uid]);
$recentRows = $recent->fetchAll();
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Enumerator — WebGIS Kemiskinan</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@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">
<style>
* { margin:0; padding:0; box-sizing:border-box; font-family:'Inter',sans-serif; }
body { background:#F3F4F6; color:#111827; padding-bottom:30px; }
.topbar { background:linear-gradient(135deg,#2563EB,#1D4ED8); color:#fff; padding:20px 16px; }
.topbar h1 { font-size:1.1rem; font-weight:700; }
.topbar p { font-size:0.82rem; opacity:0.9; margin-top:2px; }
.container { padding:16px; max-width:560px; margin:0 auto; }
.stat-row { display:grid; grid-template-columns:1fr 1fr 1fr; gap:10px; margin-bottom:14px; }
.stat { background:#fff; border:1px solid #E5E7EB; border-radius:14px; padding:14px 8px; text-align:center; }
.stat .num { font-size:1.5rem; font-weight:800; color:#2563EB; }
.stat .lbl { font-size:0.7rem; color:#6B7280; margin-top:4px; }
.btn-big { display:flex; align-items:center; gap:14px; width:100%; background:#2563EB; color:#fff;
border:none; border-radius:14px; padding:18px; font-size:1rem; font-weight:600; cursor:pointer;
text-decoration:none; margin-bottom:12px; box-shadow:0 4px 12px rgba(37,99,235,0.25); }
.btn-big.green { background:#059669; box-shadow:0 4px 12px rgba(5,150,105,0.25); }
.btn-big.gray { background:#fff; color:#111827; border:1px solid #E5E7EB; box-shadow:none; }
.btn-big i { font-size:1.4rem; }
.card { background:#fff; border:1px solid #E5E7EB; border-radius:14px; padding:16px; margin-bottom:14px; }
.card h3 { font-size:0.9rem; margin-bottom:10px; }
.item { display:flex; justify-content:space-between; align-items:center; padding:9px 0; border-bottom:1px solid #F3F4F6; font-size:0.88rem; }
.item:last-child { border-bottom:none; }
.badge { font-size:0.7rem; padding:3px 9px; border-radius:20px; }
.b-ok { background:#D1FAE5; color:#065F46; } .b-no { background:#F3F4F6; color:#6B7280; }
.logout { display:block; text-align:center; color:#DC2626; font-size:0.85rem; margin-top:8px; text-decoration:none; font-weight:600; }
</style>
</head>
<body>
<div class="topbar">
<h1><i class="fas fa-clipboard-check"></i> Halo, <?= htmlspecialchars($user['nama_lengkap'] ?: $user['username']) ?></h1>
<p>Petugas Lapangan · Wilayah: <?= htmlspecialchars($wilayah) ?></p>
</div>
<div class="container">
<div class="stat-row">
<div class="stat"><div class="num"><?= $mine ?></div><div class="lbl">Input Saya</div></div>
<div class="stat"><div class="num"><?= $verif ?></div><div class="lbl">Terverifikasi</div></div>
<div class="stat"><div class="num"><?= $belum ?></div><div class="lbl">Menunggu</div></div>
</div>
<a href="<?= app_url('enumerator/input.php') ?>" class="btn-big">
<i class="fas fa-plus-circle"></i> <span>Input Data Warga Baru</span>
</a>
<a href="<?= app_url('enumerator/data.php') ?>" class="btn-big gray">
<i class="fas fa-list"></i> <span>Data yang Saya Input</span>
</a>
<a href="<?= app_url('peta_publik.php') ?>" class="btn-big green">
<i class="fas fa-map-marked-alt"></i> <span>Lihat Peta Sebaran</span>
</a>
<div class="card">
<h3>🕘 Input Terbaru</h3>
<?php if ($recentRows): foreach ($recentRows as $r): ?>
<div class="item">
<div><?= htmlspecialchars($r['nama_kk']) ?><div style="font-size:0.72rem;color:#9CA3AF;"><?= date('d M Y H:i', strtotime($r['created_at'])) ?></div></div>
<span class="badge <?= $r['status_verifikasi']==='terverifikasi'?'b-ok':'b-no' ?>"><?= $r['status_verifikasi']==='terverifikasi'?'Terverifikasi':'Menunggu' ?></span>
</div>
<?php endforeach; else: ?>
<p style="color:#9CA3AF;font-size:0.85rem;">Belum ada data. Mulai dengan "Input Data Warga Baru".</p>
<?php endif; ?>
</div>
<a href="<?= app_url('logout.php') ?>" class="logout"><i class="fas fa-sign-out-alt"></i> Keluar</a>
</div>
</body>
</html>
+190
View File
@@ -0,0 +1,190 @@
<?php
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/auth_check.php';
requireAnyRole(['enumerator']);
$user = currentUser();
$wilayah = $user['wilayah_tugas'] ?: 'Belum ditentukan';
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Input Data Warga — Enumerator</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@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="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<style>
*{margin:0;padding:0;box-sizing:border-box;font-family:'Inter',sans-serif;}
body{background:#F3F4F6;color:#111827;padding-bottom:90px;}
.topbar{position:sticky;top:0;z-index:500;background:linear-gradient(135deg,#2563EB,#1D4ED8);color:#fff;padding:14px 16px;display:flex;align-items:center;gap:12px;box-shadow:0 2px 8px rgba(0,0,0,.15);}
.topbar a{color:#fff;text-decoration:none;font-size:1.2rem;}
.topbar h1{font-size:1rem;font-weight:700;}
.topbar .sub{font-size:0.72rem;opacity:.9;}
.container{max-width:560px;margin:0 auto;padding:16px;}
.section{background:#fff;border:1px solid #E5E7EB;border-radius:14px;padding:16px;margin-bottom:14px;}
.section h3{font-size:0.85rem;color:#2563EB;margin-bottom:12px;text-transform:uppercase;letter-spacing:.5px;}
label{display:block;font-size:0.8rem;font-weight:600;margin-bottom:5px;color:#374151;}
input,select{width:100%;padding:12px;border:1px solid #D1D5DB;border-radius:10px;font-size:0.95rem;margin-bottom:12px;outline:none;}
input:focus,select:focus{border-color:#2563EB;}
.row2{display:grid;grid-template-columns:1fr 1fr;gap:10px;}
.gps-btn{width:100%;background:#059669;color:#fff;border:none;padding:14px;border-radius:10px;font-size:0.95rem;font-weight:600;cursor:pointer;margin-bottom:10px;display:flex;align-items:center;justify-content:center;gap:8px;}
#map{height:240px;border-radius:10px;border:1px solid #E5E7EB;margin-bottom:10px;}
.coord{font-size:0.78rem;color:#6B7280;text-align:center;margin-bottom:8px;}
.photo-prev{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px;}
.photo-prev img{width:72px;height:72px;object-fit:cover;border-radius:8px;border:1px solid #E5E7EB;}
.savebar{position:fixed;bottom:0;left:0;right:0;background:#fff;border-top:1px solid #E5E7EB;padding:12px 16px;display:flex;gap:10px;max-width:560px;margin:0 auto;}
.btn-save{flex:1;background:#2563EB;color:#fff;border:none;padding:14px;border-radius:10px;font-size:1rem;font-weight:700;cursor:pointer;}
.btn-save:disabled{opacity:.6;}
.toast{position:fixed;top:70px;left:50%;transform:translateX(-50%);background:#111827;color:#fff;padding:12px 20px;border-radius:10px;font-size:0.88rem;z-index:1000;opacity:0;transition:.3s;}
.toast.show{opacity:1;}
.toast.err{background:#DC2626;}
</style>
</head>
<body>
<div class="topbar">
<a href="<?= app_url('enumerator/index.php') ?>"><i class="fas fa-arrow-left"></i></a>
<div><h1>Input Data Warga</h1><div class="sub">Wilayah: <?= htmlspecialchars($wilayah) ?></div></div>
</div>
<div class="container">
<div class="section">
<h3>📍 Lokasi (Geotagging)</h3>
<button type="button" class="gps-btn" onclick="getGPS()"><i class="fas fa-location-crosshairs"></i> <span id="gpsLabel">Gunakan GPS Otomatis</span></button>
<div id="map"></div>
<div class="coord" id="coordHint">Gunakan GPS, <b>ketuk peta</b>, <b>geser pin</b>, atau ketik koordinat manual di bawah.</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:8px;">
<div><label>Latitude</label><input id="inLat" type="number" step="any" inputmode="decimal" placeholder="-0.0500" onchange="applyManual()"></div>
<div><label>Longitude</label><input id="inLng" type="number" step="any" inputmode="decimal" placeholder="109.3450" onchange="applyManual()"></div>
</div>
</div>
<div class="section">
<h3>👤 Identitas</h3>
<label>Nama Kepala Keluarga *</label><input id="nama" placeholder="Nama lengkap KK">
<label>NIK KK</label><input id="nik" maxlength="16" inputmode="numeric" placeholder="16 digit">
<label>Alamat</label><input id="alamat" placeholder="Alamat tempat tinggal">
<div class="row2"><div><label>RT</label><input id="rt"></div><div><label>RW</label><input id="rw"></div></div>
</div>
<div class="section">
<h3>👨‍👩‍👧 Demografi & Ekonomi</h3>
<div class="row2">
<div><label>Jumlah Jiwa</label><input id="jiwa" type="number" inputmode="numeric"></div>
<div><label>Tanggungan</label><input id="tanggungan" type="number" inputmode="numeric"></div>
</div>
<label>Pekerjaan KK</label><input id="pekerjaan" placeholder="mis. Buruh harian">
<label>Penghasilan / bulan (Rp)</label><input id="penghasilan" type="number" inputmode="numeric">
</div>
<div class="section">
<h3>🏠 Kondisi Rumah</h3>
<label>Sumber Air</label>
<select id="air"><option value="">-</option><option>SUMUR_BOR</option><option>PDAM</option><option>SUNGAI</option><option>HUJAN</option><option>TIDAK_ADA</option></select>
<label>Listrik</label>
<select id="listrik"><option value="">-</option><option>PLN</option><option>NON_PLN</option><option>TIDAK_ADA</option></select>
<label>Material Dinding</label>
<select id="dinding"><option value="">-</option><option>PERMANEN</option><option>SEMI_PERMANEN</option><option>TIDAK_PERMANEN</option></select>
<label>Status Kepemilikan</label>
<select id="kepemilikan"><option value="">-</option><option>MILIK</option><option>SEWA</option><option>NUMPANG</option><option>LAINNYA</option></select>
</div>
<div class="section">
<h3>📷 Foto Kondisi Rumah</h3>
<input type="file" id="foto" accept="image/*" capture="environment" multiple>
<div class="photo-prev" id="photoPrev"></div>
</div>
</div>
<div class="savebar">
<button class="btn-save" id="btnSave" onclick="simpan()"><i class="fas fa-save"></i> Simpan Data</button>
</div>
<div class="toast" id="toast"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const APP_BASE = '<?= app_url() ?>';
let map, marker, lat=null, lng=null;
function initMap(){
map = L.map('map').setView([-0.0500,109.3450],13);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',{subdomains:'abcd',maxZoom:19}).addTo(map);
map.on('click',e=>setPoint(e.latlng.lat,e.latlng.lng,false));
}
function ensureMarker(la,ln){
if(!marker){
marker=L.marker([la,ln],{draggable:true}).addTo(map);
marker.on('dragend',ev=>{const p=ev.target.getLatLng();lat=p.lat;lng=p.lng;syncInputs();});
} else { marker.setLatLng([la,ln]); }
}
function setPoint(la,ln,zoom){
lat=la; lng=ln;
ensureMarker(la,ln);
if(zoom!==false) map.setView([la,ln],16);
syncInputs();
}
function syncInputs(){
document.getElementById('inLat').value = lat.toFixed(6);
document.getElementById('inLng').value = lng.toFixed(6);
document.getElementById('coordHint').innerHTML = `📌 Titik terpasang: ${lat.toFixed(6)}, ${lng.toFixed(6)} — geser pin / ketik untuk koreksi.`;
}
function applyManual(){
const la=parseFloat(document.getElementById('inLat').value), ln=parseFloat(document.getElementById('inLng').value);
if(isNaN(la)||isNaN(ln)){ showToast('Koordinat tidak valid','err'); return; }
setPoint(la,ln);
}
function getGPS(){
const lbl=document.getElementById('gpsLabel');
if(!navigator.geolocation){ toast('GPS tidak didukung perangkat','err'); return; }
lbl.textContent='Mencari lokasi...';
navigator.geolocation.getCurrentPosition(
pos=>{ setPoint(pos.coords.latitude,pos.coords.longitude); lbl.textContent='GPS Terdeteksi ✓'; toast('Lokasi GPS terisi'); },
err=>{ lbl.textContent='Gunakan GPS Otomatis'; toast('Gagal ambil GPS: '+err.message+'. Ketuk peta manual.','err'); },
{ enableHighAccuracy:true, timeout:10000 }
);
}
// preview foto
document.getElementById('foto').addEventListener('change',function(){
const p=document.getElementById('photoPrev'); p.innerHTML='';
[...this.files].slice(0,5).forEach(f=>{ const img=document.createElement('img'); img.src=URL.createObjectURL(f); p.appendChild(img); });
});
function toast(msg,type){ const t=document.getElementById('toast'); t.textContent=msg; t.className='toast show'+(type==='err'?' err':''); setTimeout(()=>t.className='toast',3000); }
async function simpan(){
const nama=document.getElementById('nama').value.trim();
if(!nama) return toast('Nama KK wajib diisi','err');
if(lat===null||lng===null) return toast('Tentukan lokasi (GPS / ketuk peta)','err');
const btn=document.getElementById('btnSave'); btn.disabled=true; btn.innerHTML='Menyimpan...';
const body={
nama_kk:nama, nik_kk:document.getElementById('nik').value.trim(),
alamat:document.getElementById('alamat').value.trim(),
rt:document.getElementById('rt').value.trim(), rw:document.getElementById('rw').value.trim(),
jumlah_jiwa:document.getElementById('jiwa').value||null,
jumlah_tanggungan:parseInt(document.getElementById('tanggungan').value)||0,
pekerjaan_kk:document.getElementById('pekerjaan').value.trim(),
penghasilan:parseFloat(document.getElementById('penghasilan').value)||0,
sumber_air:document.getElementById('air').value, status_listrik:document.getElementById('listrik').value,
material_dinding:document.getElementById('dinding').value, status_kepemilikan:document.getElementById('kepemilikan').value,
geometry:{type:'Point',coordinates:[lng,lat]}
};
try{
const res=await fetch(APP_BASE+'/api/warga_miskin.php',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const d=await res.json();
if(d.status!=='success'){ toast(d.message||'Gagal','err'); btn.disabled=false; btn.innerHTML='<i class="fas fa-save"></i> Simpan Data'; return; }
const wid=d.data.id;
const files=document.getElementById('foto').files;
if(files.length){
const fd=new FormData(); fd.append('warga_id',wid);
for(const f of files) fd.append('foto[]',f);
btn.innerHTML='Mengunggah foto...';
await fetch(APP_BASE+'/api/foto.php',{method:'POST',body:fd});
}
toast('Data tersimpan ✓');
setTimeout(()=>{ window.location.href=APP_BASE+'/enumerator/index.php'; },1200);
}catch(e){ toast('Error: '+e.message,'err'); btn.disabled=false; btn.innerHTML='<i class="fas fa-save"></i> Simpan Data'; }
}
initMap();
</script>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
<?php
require_once __DIR__ . '/config/app.php';
$host = getenv('DB_HOST') ?: 'localhost';
$port = getenv('DB_PORT') ?: 3306;
$user = getenv('DB_USERNAME') ?: 'root';
$pass = getenv('DB_PASSWORD') ?: '';
$db = getenv('DB_DATABASE') ?: 'webgis_db';
try {
$pdo = new PDO("mysql:host=$host;port=$port;dbname=$db;charset=utf8mb4", $user, $pass);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Read SQL file
$sqlFile = __DIR__ . '/../database/webgis_db.sql';
if (!file_exists($sqlFile)) {
die("SQL file not found at: $sqlFile");
}
$sql = file_get_contents($sqlFile);
// Split into statements and execute
$pdo->exec($sql);
echo "Database imported successfully to $db at $host!";
} catch (PDOException $e) {
echo "Connection failed: " . $e->getMessage() . "\n";
}
+10
View File
@@ -0,0 +1,10 @@
<?php
require_once __DIR__ . '/config/session.php';
require_once __DIR__ . '/config/auth_check.php';
startAppSession();
if (isLoggedIn()) {
header('Location: ' . roleHome(currentRole()));
exit;
}
header('Location: login.php');
exit;
+151
View File
@@ -0,0 +1,151 @@
<?php
require_once __DIR__ . '/config/session.php';
require_once __DIR__ . '/config/auth_check.php';
startAppSession();
if (isLoggedIn()) {
header('Location: ' . roleHome(currentRole()));
exit;
}
$error = '';
// KF-01: lockout setelah 5x percobaan gagal (jendela 15 menit)
const MAX_LOGIN_ATTEMPTS = 5;
const LOCKOUT_SECONDS = 900;
$attempts = $_SESSION['login_attempts'] ?? 0;
$lockUntil = $_SESSION['login_lock_until'] ?? 0;
$isLocked = $lockUntil > time();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
require_once __DIR__ . '/config/db.php';
require_once __DIR__ . '/config/activity.php';
if ($isLocked) {
$sisa = (int)ceil(($lockUntil - time()) / 60);
$error = "Terlalu banyak percobaan gagal. Coba lagi dalam {$sisa} menit.";
} else {
$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 && (int)$user['is_active'] !== 1) {
$error = 'Akun ini dinonaktifkan. Hubungi operator.';
} elseif ($user && password_verify($password, $user['password'])) {
// Sukses — reset counter & buat sesi
unset($_SESSION['login_attempts'], $_SESSION['login_lock_until']);
session_regenerate_id(true);
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
$_SESSION['email'] = $user['email'];
$_SESSION['role'] = $user['role'];
$_SESSION['wilayah_tugas'] = $user['wilayah_tugas'];
$_SESSION['nama_lengkap'] = $user['nama_lengkap'];
$pdo->prepare("UPDATE users SET last_login = NOW() WHERE id = ?")->execute([$user['id']]);
logActivity($pdo, 'login', null, null, 'Login berhasil');
header('Location: ' . roleHome($user['role']));
exit;
} else {
// Gagal — naikkan counter
$attempts++;
$_SESSION['login_attempts'] = $attempts;
if ($attempts >= MAX_LOGIN_ATTEMPTS) {
$_SESSION['login_lock_until'] = time() + LOCKOUT_SECONDS;
$error = 'Terlalu banyak percobaan gagal. Akun dikunci sementara 15 menit.';
} else {
$sisa = MAX_LOGIN_ATTEMPTS - $attempts;
$error = "Username atau password salah. Sisa percobaan: {$sisa}.";
}
}
} 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 Pemetaan Kemiskinan</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 WebGIS Pemetaan Kemiskinan Terpadu.</p>
<div style="background:#F0F9FF;border:1px solid #BAE6FD;border-radius:10px;padding:10px 12px;margin-bottom:16px;font-size:0.78rem;color:#0369A1;line-height:1.5;">
<i class="fas fa-circle-info"></i> Hak akses mengikuti akun: <strong>Operator</strong>, <strong>Pimpinan</strong>, atau <strong>Enumerator</strong>.
Masyarakat umum dapat <a href="<?= app_url('peta_publik.php') ?>" style="color:#0369A1;font-weight:700;">lihat peta publik</a> tanpa login.
</div>
<?php if ($error): ?>
<div class="auth-error">
<i class="fas fa-exclamation-circle"></i>
<?= htmlspecialchars($error) ?>
</div>
<?php endif; ?>
<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>
+11
View File
@@ -0,0 +1,11 @@
<?php
require_once __DIR__ . '/config/session.php';
require_once __DIR__ . '/config/db.php';
require_once __DIR__ . '/config/activity.php';
startAppSession();
if (isset($_SESSION['user_id'])) {
try { logActivity(Database::getConnection(), 'logout', null, null, 'Logout'); } catch (Throwable $e) {}
}
session_destroy();
header('Location: ' . app_url('login.php'));
exit;
+209
View File
@@ -0,0 +1,209 @@
<?php
/**
* partials/analisis_body.php — Konten halaman Analisis Spasial (Fase E).
* Di-include oleh admin/analisis.php & pimpinan/analisis.php (butuh $pdo).
* Header sudah memuat Leaflet CSS + APP_BASE. Chart.js & Leaflet JS dimuat di bawah.
*/
$wilOpt = $pdo->query("SELECT id, nama FROM wilayah ORDER BY nama")->fetchAll();
$jenisOpt = $pdo->query("SELECT id, kode FROM jenis_bantuan ORDER BY id")->fetchAll();
?>
<div class="page-header">
<div class="breadcrumb"><span>Analisis</span><span class="sep">/</span><span>Spasial</span></div>
<h1>📈 Analisis Spasial</h1>
<p>Filter multi-kriteria, zona prioritas, proximity, cakupan bantuan, dan tren (KF-19 s/d 23).</p>
</div>
<div class="an-tabs">
<button class="an-tab active" data-tab="filter" onclick="switchTab('filter')"><i class="fas fa-filter"></i> Filter</button>
<button class="an-tab" data-tab="zona" onclick="switchTab('zona')"><i class="fas fa-ranking-star"></i> Zona Prioritas</button>
<button class="an-tab" data-tab="proximity" onclick="switchTab('proximity')"><i class="fas fa-route"></i> Proximity</button>
<button class="an-tab" data-tab="cakupan" onclick="switchTab('cakupan')"><i class="fas fa-hand-holding-heart"></i> Cakupan Bantuan</button>
<button class="an-tab" data-tab="tren" onclick="switchTab('tren')"><i class="fas fa-chart-line"></i> Tren</button>
</div>
<!-- shared map (dipindah antar tab) -->
<div id="mapHolder" style="display:none;"><div id="anMap" style="height:460px;border-radius:12px;"></div></div>
<!-- TAB FILTER -->
<div class="an-pane" id="pane-filter">
<div class="an-grid">
<div class="card an-filter">
<h3>Kriteria</h3>
<label>Kelurahan</label>
<select id="fl-wilayah" class="form-control"><option value="">Semua</option>
<?php foreach ($wilOpt as $w): ?><option value="<?= $w['id'] ?>"><?= htmlspecialchars($w['nama']) ?></option><?php endforeach; ?>
</select>
<label>Sumber Air</label>
<select id="fl-air" class="form-control"><option value="">Semua</option><option>SUMUR_BOR</option><option>PDAM</option><option>SUNGAI</option><option>HUJAN</option><option>TIDAK_ADA</option></select>
<label>Material Dinding</label>
<select id="fl-dinding" class="form-control"><option value="">Semua</option><option>PERMANEN</option><option>SEMI_PERMANEN</option><option>TIDAK_PERMANEN</option></select>
<label>Status Kepemilikan</label>
<select id="fl-kepemilikan" class="form-control"><option value="">Semua</option><option>MILIK</option><option>SEWA</option><option>NUMPANG</option><option>LAINNYA</option></select>
<label>Status Bantuan</label>
<div style="display:flex;gap:6px;">
<select id="fl-bantuan" class="form-control"><option value="">—</option>
<?php foreach ($jenisOpt as $j): ?><option value="<?= $j['id'] ?>"><?= htmlspecialchars($j['kode']) ?></option><?php endforeach; ?>
</select>
<select id="fl-bmode" class="form-control" style="max-width:130px;"><option value="menerima">Menerima</option><option value="tidak">Tidak terima</option></select>
</div>
<label>Verifikasi</label>
<select id="fl-verif" class="form-control"><option value="">Semua</option><option value="belum">Belum</option><option value="terverifikasi">Terverifikasi</option><option value="perlu_tinjauan">Perlu Tinjauan</option></select>
<button class="btn btn-primary" style="width:100%;margin-top:12px;" onclick="runFilter()"><i class="fas fa-search"></i> Terapkan Filter</button>
<button class="btn btn-ghost" style="width:100%;margin-top:6px;" onclick="resetFilter()">Reset</button>
<div id="fl-count" class="an-count">—</div>
</div>
<div class="card" id="filterMapBox"><div class="an-maptarget"></div></div>
</div>
</div>
<!-- TAB ZONA -->
<div class="an-pane" id="pane-zona" style="display:none;">
<div class="an-grid">
<div class="card" id="zonaMapBox"><div class="an-maptarget"></div></div>
<div class="card">
<h3>Ranking Prioritas</h3>
<table class="data-table"><thead><tr><th>#</th><th>Kelurahan</th><th>% Miskin</th><th>Cakupan</th><th>Skor</th><th>Level</th></tr></thead>
<tbody id="zonaTable"></tbody></table>
</div>
</div>
</div>
<!-- TAB PROXIMITY -->
<div class="an-pane" id="pane-proximity" style="display:none;">
<div class="card" style="margin-bottom:14px;display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
<label style="margin:0;">Radius layanan (meter):</label>
<input type="number" id="px-radius" class="form-control" value="1000" style="max-width:140px;">
<button class="btn btn-primary" onclick="runProximity()"><i class="fas fa-route"></i> Hitung</button>
<div id="px-summary" class="an-count" style="margin:0;"></div>
</div>
<div class="card" id="proxMapBox"><div class="an-maptarget"></div></div>
</div>
<!-- TAB CAKUPAN -->
<div class="an-pane" id="pane-cakupan" style="display:none;">
<div class="an-grid">
<div class="card"><h3>Cakupan Keseluruhan</h3><canvas id="chartPie" height="220"></canvas><div id="cak-summary" class="an-count"></div></div>
<div class="card"><h3>Penerima per Jenis Bantuan</h3><canvas id="chartBar" height="220"></canvas></div>
</div>
<div class="card" style="margin-top:14px;">
<h3>Cakupan per Kelurahan</h3>
<table class="data-table"><thead><tr><th>Kelurahan</th><th>Miskin</th><th>Dibantu</th><th>Cakupan</th></tr></thead><tbody id="cakTable"></tbody></table>
</div>
</div>
<!-- TAB TREN -->
<div class="an-pane" id="pane-tren" style="display:none;">
<div class="card"><h3>Tren 12 Bulan Terakhir</h3><canvas id="chartTren" height="110"></canvas></div>
</div>
<style>
.an-tabs{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px;}
.an-tab{background:#fff;border:1px solid #E5E7EB;border-radius:10px;padding:9px 14px;font-size:0.85rem;font-weight:600;cursor:pointer;color:#374151;}
.an-tab.active{background:#2563EB;color:#fff;border-color:#2563EB;}
.an-grid{display:grid;grid-template-columns:1fr 1.6fr;gap:16px;}
.an-filter label{display:block;font-size:0.78rem;font-weight:600;margin:8px 0 3px;}
.an-filter h3,.card h3{font-size:0.95rem;margin-bottom:10px;}
.an-count{margin-top:12px;padding:10px;background:#F0F9FF;border-radius:8px;font-size:0.88rem;font-weight:600;color:#1D4ED8;text-align:center;}
.an-maptarget{min-height:460px;}
@media(max-width:900px){.an-grid{grid-template-columns:1fr;}}
</style>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script>
const ANAPI = APP_BASE + '/api';
let anMap, anLayer, loaded={}, charts={};
function initAnMap(){
if(anMap) return;
anMap = L.map('anMap').setView([-0.0500,109.3450],13);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',{subdomains:'abcd',maxZoom:19}).addTo(anMap);
}
function moveMapTo(boxId){
const box=document.querySelector('#'+boxId+' .an-maptarget');
const holder=document.getElementById('anMap');
box.appendChild(holder); holder.style.height='460px';
initAnMap(); setTimeout(()=>anMap.invalidateSize(),120);
}
function clearLayer(){ if(anLayer){anMap.removeLayer(anLayer);anLayer=null;} }
function switchTab(t){
document.querySelectorAll('.an-tab').forEach(b=>b.classList.toggle('active',b.dataset.tab===t));
document.querySelectorAll('.an-pane').forEach(p=>p.style.display='none');
document.getElementById('pane-'+t).style.display='';
if(t==='filter'){ moveMapTo('filterMapBox'); if(!loaded.filter){runFilter();loaded.filter=1;} }
if(t==='zona'){ moveMapTo('zonaMapBox'); if(!loaded.zona){loadZona();loaded.zona=1;} }
if(t==='proximity'){ moveMapTo('proxMapBox'); if(!loaded.prox){runProximity();loaded.prox=1;} }
if(t==='cakupan' && !loaded.cak){loadCakupan();loaded.cak=1;}
if(t==='tren' && !loaded.tren){loadTren();loaded.tren=1;}
}
// ---- Filter ----
async function runFilter(){
const q=new URLSearchParams();
const add=(k,id)=>{const v=document.getElementById(id).value;if(v)q.set(k,v);};
add('wilayah_id','fl-wilayah'); add('sumber_air','fl-air'); add('material_dinding','fl-dinding');
add('status_kepemilikan','fl-kepemilikan'); add('verif','fl-verif');
const bj=document.getElementById('fl-bantuan').value;
if(bj){ q.set('bantuan_jenis',bj); q.set('bantuan_mode',document.getElementById('fl-bmode').value); }
const d=(await (await fetch(`${ANAPI}/filter.php?`+q.toString())).json()).data;
clearLayer();
anLayer=L.geoJSON(d,{pointToLayer:(f,ll)=>L.circleMarker(ll,{radius:8,color:'#EF4444',fillColor:'#EF4444',fillOpacity:.7,weight:2}),
onEachFeature:(f,l)=>{const p=f.properties;l.bindPopup(`<b>${p.nama_kk}</b><br>${p.kelurahan||'-'}<br>Air: ${p.sumber_air||'-'} · Dinding: ${p.material_dinding||'-'}<br>Bantuan: ${p.bantuan_aktif||'Belum ada'}`);}}).addTo(anMap);
document.getElementById('fl-count').innerHTML=`Ditemukan <b>${d.jumlah}</b> rumah tangga`;
if(d.features.length){try{anMap.fitBounds(anLayer.getBounds(),{padding:[40,40]});}catch(e){}}
}
function resetFilter(){ ['fl-wilayah','fl-air','fl-dinding','fl-kepemilikan','fl-bantuan','fl-verif'].forEach(i=>document.getElementById(i).value=''); document.getElementById('fl-bmode').value='menerima'; runFilter(); }
// ---- Zona ----
const lvlColor={Tinggi:'#EF4444',Sedang:'#FB923C',Rendah:'#10B981'};
async function loadZona(){
const d=(await (await fetch(`${ANAPI}/zona_prioritas.php`)).json()).data;
clearLayer();
anLayer=L.geoJSON(d,{style:f=>({fillColor:lvlColor[f.properties.level]||'#9CA3AF',fillOpacity:.55,color:'#374151',weight:1.5}),
onEachFeature:(f,l)=>{const p=f.properties;l.bindPopup(`<b>#${p.rank} ${p.nama}</b><br>Skor: ${p.skor} (${p.level})<br>% miskin: ${p.persentase_miskin}%<br>Cakupan: ${p.cakupan_bantuan}%`);l.bindTooltip(p.nama,{sticky:true});}}).addTo(anMap);
try{anMap.fitBounds(anLayer.getBounds(),{padding:[40,40]});}catch(e){}
document.getElementById('zonaTable').innerHTML=d.features.map(f=>{const p=f.properties;
return `<tr><td>${p.rank}</td><td>${p.nama}</td><td>${p.persentase_miskin}%</td><td>${p.cakupan_bantuan}%</td><td><b>${p.skor}</b></td><td><span class="badge" style="background:${lvlColor[p.level]}22;color:${lvlColor[p.level]}">${p.level}</span></td></tr>`;}).join('');
}
// ---- Proximity ----
async function runProximity(){
const radius=document.getElementById('px-radius').value||1000;
const d=(await (await fetch(`${ANAPI}/proximity.php?radius=${radius}`)).json()).data;
clearLayer();
anLayer=L.geoJSON(d,{pointToLayer:(f,ll)=>{const iso=f.properties.terisolasi;return L.circleMarker(ll,{radius:8,color:iso?'#EF4444':'#10B981',fillColor:iso?'#EF4444':'#10B981',fillOpacity:.7,weight:2});},
onEachFeature:(f,l)=>{const p=f.properties;l.bindPopup(`<b>${p.nama_kk}</b><br>Terdekat: ${p.fasilitas||'-'} (${p.fasilitas_jenis||'-'})<br>Jarak: ${p.jarak_km} km<br>${p.terisolasi?'<span style="color:#EF4444">⚠ Terisolasi</span>':'<span style="color:#10B981">Terjangkau</span>'}`);}}).addTo(anMap);
try{anMap.fitBounds(anLayer.getBounds(),{padding:[40,40]});}catch(e){}
document.getElementById('px-summary').innerHTML=`Terisolasi: <b>${d.jumlah_terisolasi}</b> / ${d.total} (radius ${d.radius_m} m)`;
}
// ---- Cakupan ----
async function loadCakupan(){
const d=(await (await fetch(`${ANAPI}/cakupan_bantuan.php`)).json()).data;
document.getElementById('cak-summary').innerHTML=`Cakupan: <b>${d.cakupan_persen}%</b> · ${d.dibantu} dibantu, ${d.belum_dibantu} belum`;
charts.pie&&charts.pie.destroy();
charts.pie=new Chart(document.getElementById('chartPie'),{type:'doughnut',
data:{labels:['Dibantu','Belum dibantu'],datasets:[{data:[d.dibantu,d.belum_dibantu],backgroundColor:['#10B981','#E5E7EB']}]},
options:{plugins:{legend:{position:'bottom'}}}});
charts.bar&&charts.bar.destroy();
charts.bar=new Chart(document.getElementById('chartBar'),{type:'bar',
data:{labels:d.per_jenis.map(j=>j.kode),datasets:[{label:'Penerima',data:d.per_jenis.map(j=>j.penerima),backgroundColor:'#2563EB'}]},
options:{plugins:{legend:{display:false}},scales:{y:{beginAtZero:true,ticks:{precision:0}}}}});
document.getElementById('cakTable').innerHTML=d.per_wilayah.map(w=>`<tr><td>${w.nama}</td><td>${w.miskin}</td><td>${w.dibantu}</td><td>${w.cakupan}%</td></tr>`).join('');
}
// ---- Tren ----
async function loadTren(){
const d=(await (await fetch(`${ANAPI}/tren.php`)).json()).data;
charts.tren&&charts.tren.destroy();
charts.tren=new Chart(document.getElementById('chartTren'),{type:'line',
data:{labels:d.labels,datasets:[
{label:'Kumulatif Warga',data:d.kumulatif,borderColor:'#2563EB',backgroundColor:'#2563EB22',fill:true,tension:.3},
{label:'Warga Baru',data:d.warga_baru,borderColor:'#F59E0B',tension:.3},
{label:'Bantuan Baru',data:d.bantuan_baru,borderColor:'#10B981',tension:.3}
]},options:{scales:{y:{beginAtZero:true,ticks:{precision:0}}}}});
}
document.addEventListener('DOMContentLoaded',()=>switchTab('filter'));
</script>
+64
View File
@@ -0,0 +1,64 @@
<?php
/**
* partials/laporan_body.php — Konten halaman Laporan & Cetak (Fase F).
* Di-include oleh admin/pelaporan.php & pimpinan/pelaporan.php (butuh $pdo).
*/
$wilOpt = $pdo->query("SELECT id, nama FROM wilayah ORDER BY nama")->fetchAll();
?>
<div class="page-header">
<div class="breadcrumb"><span>Laporan</span><span class="sep">/</span><span>Cetak</span></div>
<h1>📄 Laporan & Cetak</h1>
<p>Laporan per wilayah, rekap penerima bantuan, dan grafik komparatif antar kelurahan (KF-29/30/31).</p>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:18px;">
<div class="card">
<h3 style="font-size:0.95rem;margin-bottom:10px;">📍 Laporan per Kelurahan</h3>
<p style="font-size:0.84rem;color:var(--text-secondary);margin-bottom:10px;">Pilih kelurahan untuk laporan lengkap (statistik + daftar rumah tangga), siap dicetak/PDF.</p>
<div style="display:flex;gap:8px;">
<select id="lapWilayah" class="form-control">
<?php foreach ($wilOpt as $w): ?><option value="<?= $w['id'] ?>"><?= htmlspecialchars($w['nama']) ?></option><?php endforeach; ?>
</select>
<button class="btn btn-primary" onclick="bukaLaporanWilayah()"><i class="fas fa-file-lines"></i> Buka</button>
</div>
<div id="lapPreview" style="margin-top:12px;font-size:0.85rem;"></div>
</div>
<div class="card">
<h3 style="font-size:0.95rem;margin-bottom:10px;">💳 Rekap Penerima Bantuan</h3>
<p style="font-size:0.84rem;color:var(--text-secondary);margin-bottom:10px;">Daftar seluruh penerima bantuan aktif, dikelompokkan per jenis (PKH, BPNT, dll).</p>
<a class="btn btn-success" href="<?= app_url('api/laporan_bantuan.php?format=print') ?>" target="_blank"><i class="fas fa-file-pdf"></i> Buka Rekap Bantuan</a>
<a class="btn btn-ghost" href="<?= app_url('api/warga_export.php?format=csv') ?>"><i class="fas fa-file-csv"></i> Export Data CSV</a>
</div>
</div>
<div class="card">
<h3 style="font-size:0.95rem;margin-bottom:10px;">📊 Grafik Komparatif Antar Kelurahan</h3>
<div style="height:340px;"><canvas id="cKomparatif"></canvas></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script>
const LAPI = APP_BASE + '/api';
function bukaLaporanWilayah(){
const id = document.getElementById('lapWilayah').value;
window.open(`${LAPI}/laporan_wilayah.php?wilayah_id=${id}&format=print`, '_blank');
}
// preview ringkas saat ganti pilihan
async function previewWilayah(){
const id = document.getElementById('lapWilayah').value;
const d = (await (await fetch(`${LAPI}/laporan_wilayah.php?wilayah_id=${id}&format=json`)).json()).data;
document.getElementById('lapPreview').innerHTML =
`<b>${d.nama}</b> — ${d.miskin} RT miskin (${d.persen}%), cakupan ${d.cakupan}%, ${d.terverif} terverifikasi`;
}
document.getElementById('lapWilayah').addEventListener('change', previewWilayah);
(async ()=>{
const km = (await (await fetch(`${LAPI}/komparatif.php`)).json()).data;
new Chart(cKomparatif,{type:'bar',data:{labels:km.labels,datasets:[
{label:'% Penduduk Miskin',data:km.persentase_miskin,backgroundColor:'#EF4444'},
{label:'Cakupan Bantuan %',data:km.cakupan_bantuan,backgroundColor:'#10B981'},
{label:'% Terverifikasi',data:km.persen_verifikasi,backgroundColor:'#2563EB'}
]},options:{responsive:true,maintainAspectRatio:false,animation:false,scales:{y:{beginAtZero:true}},plugins:{legend:{position:'bottom'}}}});
previewWilayah();
})();
</script>
+63
View File
@@ -0,0 +1,63 @@
<?php
/**
* Peta Publik — akses tanpa login (role Publik).
* Hanya menampilkan data agregat (choropleth & heatmap), tanpa data pribadi warga.
*/
require_once __DIR__ . '/config/app.php';
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Peta Sebaran Kemiskinan — WebGIS Publik</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@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="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<link rel="stylesheet" href="<?= app_url('assets/css/map.css') ?>">
<style>
* { margin:0; padding:0; box-sizing:border-box; font-family:'Inter',sans-serif; }
body { overflow:hidden; }
#public-map { position:absolute; inset:0; }
.pub-header { position:absolute; top:0; left:0; right:0; z-index:1000;
background:linear-gradient(135deg,#1D4ED8,#2563EB); color:#fff; padding:12px 18px;
display:flex; align-items:center; justify-content:space-between; box-shadow:0 2px 8px rgba(0,0,0,0.15); }
.pub-header h1 { font-size:1rem; font-weight:700; }
.pub-header h1 span { font-weight:400; opacity:0.85; font-size:0.82rem; display:block; }
.pub-header a { color:#fff; background:rgba(255,255,255,0.18); padding:8px 14px; border-radius:8px;
text-decoration:none; font-size:0.82rem; font-weight:600; }
.legend { background:#fff; padding:12px 14px; border-radius:10px; box-shadow:0 2px 10px rgba(0,0,0,0.15); font-size:0.78rem; line-height:1.6; }
.legend h4 { font-size:0.8rem; margin-bottom:6px; }
.legend i { display:inline-block; width:14px; height:14px; border-radius:3px; margin-right:6px; vertical-align:middle; }
.map-search { background:#fff; border-radius:10px; box-shadow:0 2px 10px rgba(0,0,0,0.15); padding:6px; width:240px; }
.map-search input { width:100%; border:1px solid #E5E7EB; border-radius:7px; padding:8px 10px; font-size:0.85rem; outline:none; }
.map-search input:focus { border-color:#2563EB; }
.search-results { margin-top:4px; }
.search-item { padding:8px 10px; font-size:0.83rem; cursor:pointer; border-radius:6px; }
.search-item:hover { background:#EFF6FF; color:#1D4ED8; }
.leaflet-control-layers { border-radius:10px !important; box-shadow:0 2px 10px rgba(0,0,0,0.15) !important; font-size:0.83rem; }
/* beri ruang agar kontrol kanan-atas tidak menabrak header */
.leaflet-top.leaflet-right { margin-top:56px; }
</style>
</head>
<body>
<div class="pub-header">
<h1><i class="fas fa-map-marked-alt"></i> Peta Sebaran Kemiskinan
<span>Data agregat publik — Pemerintah Kota</span></h1>
<a href="<?= app_url('login.php') ?>"><i class="fas fa-sign-in-alt"></i> Login Petugas</a>
</div>
<div id="public-map"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
<script>
const APP_BASE = '<?= app_url() ?>';
const map = L.map('public-map', { zoomControl:true }).setView([-0.0500, 109.3450], 13);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19, attribution: '© OpenStreetMap'
}).addTo(map);
// Layer choropleth & heatmap diisi di Fase B (assets/js/public-map.js).
</script>
<script src="<?= app_url('assets/js/public-map.js') ?>?v=1"></script>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
<?php
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/auth_check.php';
requireAnyRole(['pimpinan']);
$pdo = Database::getConnection();
$pageTitle = 'Analisis Spasial';
$activeNav = 'analisis';
$extraHead = '<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">';
require_once __DIR__ . '/partials/header.php';
require_once __DIR__ . '/../partials/analisis_body.php';
require_once __DIR__ . '/partials/footer.php';
+108
View File
@@ -0,0 +1,108 @@
<?php
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/auth_check.php';
requireAnyRole(['pimpinan']);
$pdo = Database::getConnection();
$user = currentUser();
// KPI
$total = (int)$pdo->query("SELECT COUNT(*) FROM warga_miskin")->fetchColumn();
$dibantu = (int)$pdo->query("SELECT COUNT(DISTINCT warga_id) FROM bantuan_sosial WHERE is_aktif=1")->fetchColumn();
$terverif = (int)$pdo->query("SELECT COUNT(*) FROM warga_miskin WHERE status_verifikasi='terverifikasi'")->fetchColumn();
$cakupan = $total > 0 ? round($dibantu / $total * 100, 1) : 0;
// Zona prioritas (skor sama dgn api/zona_prioritas)
$zrows = $pdo->query("
SELECT w.nama, w.jumlah_penduduk,
COUNT(DISTINCT wm.id) AS miskin,
COUNT(DISTINCT CASE WHEN bs.id IS NOT NULL THEN wm.id END) AS dibantu
FROM wilayah w
LEFT JOIN warga_miskin wm ON ST_Contains(w.geom, wm.geom)
LEFT JOIN bantuan_sosial bs ON bs.warga_id=wm.id AND bs.is_aktif=1
GROUP BY w.id
")->fetchAll();
$zona = [];
foreach ($zrows as $r) {
$m = (int)$r['miskin']; $p = (int)$r['jumlah_penduduk'];
$persen = $p > 0 ? round($m/$p*100,2) : 0;
$cak = $m > 0 ? round($r['dibantu']/$m*100,1) : 0;
$gap = $m > 0 ? (100-$cak) : 0;
$skor = round($persen*2 + $gap*0.2 + $m*1, 1);
$level = $skor > 35 ? 'Tinggi' : ($skor > 20 ? 'Sedang' : 'Rendah');
$zona[] = ['nama'=>$r['nama'],'skor'=>$skor,'level'=>$level,'persen'=>$persen,'cakupan'=>$cak];
}
usort($zona, fn($a,$b)=>$b['skor']<=>$a['skor']);
$zonaTinggi = count(array_filter($zona, fn($z)=>$z['level']==='Tinggi'));
$pageTitle = 'Dashboard Eksekutif';
$activeNav = 'dashboard';
$extraHead = '';
require_once __DIR__ . '/partials/header.php';
?>
<div class="page-header" style="margin-bottom:18px;">
<h1 style="font-size:1.4rem;">👋 Selamat datang, <?= htmlspecialchars($user['nama_lengkap'] ?: $user['username']) ?></h1>
<p>Ringkasan eksekutif kondisi kemiskinan & penyaluran bantuan sosial kota.</p>
</div>
<div class="stats-grid">
<div class="stat-card primary"><div class="stat-icon"><i class="fas fa-house-user"></i></div>
<div class="stat-body"><div class="stat-label">Total RT Miskin</div><div class="stat-value"><?= $total ?></div><div class="stat-change"><?= count($zona) ?> kelurahan</div></div></div>
<div class="stat-card info"><div class="stat-icon"><i class="fas fa-hand-holding-heart"></i></div>
<div class="stat-body"><div class="stat-label">Cakupan Bantuan</div><div class="stat-value"><?= $cakupan ?>%</div><div class="stat-change"><?= $dibantu ?> RT dibantu</div></div></div>
<div class="stat-card success"><div class="stat-icon"><i class="fas fa-check-circle"></i></div>
<div class="stat-body"><div class="stat-label">Terverifikasi</div><div class="stat-value"><?= $terverif ?></div><div class="stat-change">dari <?= $total ?> data</div></div></div>
<div class="stat-card danger"><div class="stat-icon"><i class="fas fa-triangle-exclamation"></i></div>
<div class="stat-body"><div class="stat-label">Zona Prioritas Tinggi</div><div class="stat-value"><?= $zonaTinggi ?></div><div class="stat-change">kelurahan</div></div></div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:18px;">
<div class="card"><h3 style="font-size:0.95rem;margin-bottom:10px;">Cakupan Bantuan</h3><canvas id="cPie" height="200"></canvas></div>
<div class="card"><h3 style="font-size:0.95rem;margin-bottom:10px;">Penerima per Jenis Bantuan</h3><canvas id="cBar" height="200"></canvas></div>
</div>
<div style="display:grid;grid-template-columns:1.4fr 1fr;gap:18px;margin-bottom:18px;">
<div class="card"><h3 style="font-size:0.95rem;margin-bottom:10px;">Tren 12 Bulan</h3><canvas id="cTren" height="150"></canvas></div>
<div class="card"><h3 style="font-size:0.95rem;margin-bottom:10px;">Komparatif Kelurahan (radar)</h3><canvas id="cRadar" height="150"></canvas></div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;">
<div class="card">
<h3 style="font-size:0.95rem;margin-bottom:10px;">🏆 Zona Prioritas Teratas</h3>
<?php foreach (array_slice($zona,0,4) as $i=>$z): $c=['Tinggi'=>'#EF4444','Sedang'=>'#FB923C','Rendah'=>'#10B981'][$z['level']]; ?>
<div style="display:flex;justify-content:space-between;align-items:center;padding:9px 0;border-bottom:1px solid #F3F4F6;">
<div><strong>#<?= $i+1 ?> <?= htmlspecialchars($z['nama']) ?></strong>
<div style="font-size:0.74rem;color:#9CA3AF;">% miskin <?= $z['persen'] ?>% · cakupan <?= $z['cakupan'] ?>%</div></div>
<span class="badge" style="background:<?= $c ?>22;color:<?= $c ?>;">Skor <?= $z['skor'] ?> · <?= $z['level'] ?></span>
</div>
<?php endforeach; ?>
</div>
<div class="card" style="display:flex;flex-direction:column;justify-content:center;gap:10px;">
<h3 style="font-size:0.95rem;">Tindak Lanjut</h3>
<a href="<?= app_url('pimpinan/analisis.php') ?>" class="btn btn-primary"><i class="fas fa-chart-line"></i> Buka Analisis Spasial</a>
<a href="<?= app_url('pimpinan/pelaporan.php') ?>" class="btn btn-success"><i class="fas fa-file-pdf"></i> Laporan & Cetak</a>
<a href="<?= app_url('pimpinan/peta.php') ?>" class="btn btn-ghost"><i class="fas fa-map"></i> Peta Interaktif</a>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script>
const DAPI = APP_BASE + '/api';
(async ()=>{
const cak = (await (await fetch(`${DAPI}/cakupan_bantuan.php`)).json()).data;
new Chart(cPie,{type:'doughnut',data:{labels:['Dibantu','Belum'],datasets:[{data:[cak.dibantu,cak.belum_dibantu],backgroundColor:['#10B981','#E5E7EB']}]},options:{plugins:{legend:{position:'bottom'}}}});
new Chart(cBar,{type:'bar',data:{labels:cak.per_jenis.map(j=>j.kode),datasets:[{label:'Penerima',data:cak.per_jenis.map(j=>j.penerima),backgroundColor:'#2563EB'}]},options:{plugins:{legend:{display:false}},scales:{y:{beginAtZero:true,ticks:{precision:0}}}}});
const tr = (await (await fetch(`${DAPI}/tren.php`)).json()).data;
new Chart(cTren,{type:'line',data:{labels:tr.labels,datasets:[
{label:'Kumulatif Warga',data:tr.kumulatif,borderColor:'#2563EB',backgroundColor:'#2563EB22',fill:true,tension:.3},
{label:'Bantuan Baru',data:tr.bantuan_baru,borderColor:'#10B981',tension:.3}
]},options:{scales:{y:{beginAtZero:true,ticks:{precision:0}}}}});
const km = (await (await fetch(`${DAPI}/komparatif.php`)).json()).data;
new Chart(cRadar,{type:'radar',data:{labels:km.labels,datasets:[
{label:'% Miskin',data:km.persentase_miskin,borderColor:'#EF4444',backgroundColor:'#EF444422'},
{label:'Cakupan %',data:km.cakupan_bantuan,borderColor:'#10B981',backgroundColor:'#10B98122'}
]},options:{scales:{r:{beginAtZero:true}}}});
})();
</script>
<?php require_once __DIR__ . '/partials/footer.php'; ?>
+191
View File
@@ -0,0 +1,191 @@
<?php
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/auth_check.php';
requireAnyRole(['pimpinan']);
$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,54 @@
</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';
});
};
</script>
<?= $extraScript ?? '' ?>
</body>
</html>
@@ -0,0 +1,93 @@
<?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 ?? 'Portal Pimpinan') ?> — WebGIS Kemiskinan</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">Pimpinan</span>
</div>
</div>
<nav class="sidebar-nav">
<div class="nav-section-title">Menu</div>
<a href="<?= app_url('pimpinan/index.php') ?>"
class="nav-item <?= ($activeNav==='dashboard') ? 'active' : '' ?>">
<span class="nav-icon"><i class="fas fa-chart-pie"></i></span> Dashboard Eksekutif
</a>
<a href="<?= app_url('pimpinan/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('pimpinan/analisis.php') ?>"
class="nav-item <?= ($activeNav==='analisis') ? 'active' : '' ?>">
<span class="nav-icon"><i class="fas fa-chart-line"></i></span> Analisis Spasial
</a>
<a href="<?= app_url('pimpinan/pelaporan.php') ?>"
class="nav-item <?= ($activeNav==='pelaporan') ? 'active' : '' ?>">
<span class="nav-icon"><i class="fas fa-file-pdf"></i></span> Laporan &amp; Cetak
</a>
<a href="<?= app_url('pimpinan/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">Pimpinan</strong>. Akses penuh data &amp; laporan eksekutif (mode baca).
</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">Pimpinan</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">
+10
View File
@@ -0,0 +1,10 @@
<?php
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/auth_check.php';
requireAnyRole(['pimpinan']);
$pdo = Database::getConnection();
$pageTitle = 'Laporan & Cetak';
$activeNav = 'pelaporan';
require_once __DIR__ . '/partials/header.php';
require_once __DIR__ . '/../partials/laporan_body.php';
require_once __DIR__ . '/partials/footer.php';
+27
View File
@@ -0,0 +1,27 @@
<?php
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/auth_check.php';
requireAnyRole(['pimpinan']);
$pageTitle = 'Peta Interaktif';
$activeNav = 'peta';
$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" style="margin-bottom:12px;">
<h1>🗺️ Peta Pemetaan Kemiskinan</h1>
<p>Sebaran rumah tangga miskin, choropleth tingkat kemiskinan, heatmap kepadatan, dan fasilitas publik.</p>
</div>
<div style="position:relative;">
<div id="skpl-map" style="height:74vh;border-radius:12px;border:1px solid #E5E7EB;z-index:1;"></div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
<script src="<?= app_url('assets/js/skpl-layers.js') ?>?v=2"></script>
<script>const SKPL_ROLE = 'pimpinan';</script>
<script src="<?= app_url('assets/js/skpl-map.js') ?>?v=1"></script>
<?php $extraScript = ''; ?>
<?php require_once __DIR__ . '/partials/footer.php'; ?>
View File