diff --git a/index.html b/index.html
new file mode 100644
index 0000000..53beb15
--- /dev/null
+++ b/index.html
@@ -0,0 +1,293 @@
+
+
+
+
+
+
+
';
+}
+
+function require_login(array $roles = []): void {
+ requireLogin($roles);
+}
diff --git a/webgis-kemiskinan/admin/bantuan.php b/webgis-kemiskinan/admin/bantuan.php
new file mode 100644
index 0000000..d81e8ef
--- /dev/null
+++ b/webgis-kemiskinan/admin/bantuan.php
@@ -0,0 +1,155 @@
+prepare("SELECT nik,id_keluarga FROM penduduk WHERE nik=:nik AND status_hidup='hidup'");
+ $pddk->execute([':nik'=>$nik]);
+ $pddk = $pddk->fetch();
+ if (!$pddk) throw new Exception("Penduduk dengan NIK $nik tidak ditemukan / meninggal.");
+
+ $bukti = null;
+ if (!empty($_FILES['bukti']['name'])) {
+ $bukti = uploadFile($_FILES['bukti'], UPLOAD_BUKTI);
+ }
+ $stmt = $pdo->prepare("
+ INSERT INTO histori_bantuan (nik,id_jenis_bantuan,id_keluarga,rumah_ibadah_id,tanggal,jumlah,nilai_rupiah,status_penyaluran,catatan,bukti_file,disalurkan_oleh)
+ VALUES(:nik,:jb,:kk,:ri,:tgl,:jml,:rp,:status,:cat,:bukti,:user)
+ ");
+ $stmt->execute([
+ ':nik'=>$nik, ':jb'=>$idJb, ':kk'=>$pddk['id_keluarga'],
+ ':ri'=>$_SESSION['rumah_ibadah_id'] ?: null,
+ ':tgl'=>$tgl, ':jml'=>$_POST['jumlah']??'' ?: null,
+ ':rp'=>$_POST['nilai_rupiah']??'' ?: null,
+ ':status'=>$_POST['status_penyaluran']??'disalurkan',
+ ':cat'=>$_POST['catatan']??'' ?: null,
+ ':bukti'=>$bukti, ':user'=>$_SESSION['user_id'],
+ ]);
+ logAktivitas($pdo, (int)$_SESSION['user_id'], 'CREATE', 'histori_bantuan', (string)$pdo->lastInsertId());
+ $msg = "Bantuan untuk NIK $nik berhasil dicatat.";
+ } catch(Exception $e) { $err = $e->getMessage(); }
+ }
+}
+
+$jenisList = $pdo->query("SELECT * FROM jenis_bantuan WHERE aktif=1 ORDER BY kategori,nama")->fetchAll();
+
+render_header('Catat Bantuan');
+?>
+
π Catat Penyaluran Bantuan
+
= h($msg) ?>
+
= h($err) ?>
+
+
+
+
+
+
+
π Bantuan Terakhir Disalurkan
+
+ Penerima Bantuan Tanggal
+
+ query("
+ SELECT p.nama_lengkap, jb.nama AS nama_bantuan, hb.tanggal
+ FROM histori_bantuan hb
+ JOIN penduduk p ON p.nik=hb.nik
+ JOIN jenis_bantuan jb ON jb.id=hb.id_jenis_bantuan
+ WHERE hb.status_penyaluran='disalurkan'
+ ORDER BY hb.created_at DESC LIMIT 10
+ ")->fetchAll();
+ foreach ($recent as $r): ?>
+
+ = h($r['nama_lengkap']) ?>
+ = h($r['nama_bantuan']) ?>
+ = date('d/m/Y', strtotime($r['tanggal'])) ?>
+
+
+
+
+
+
+
+
+
+
+
diff --git a/webgis-kemiskinan/admin/belum_dibantu.php b/webgis-kemiskinan/admin/belum_dibantu.php
new file mode 100644
index 0000000..136e493
--- /dev/null
+++ b/webgis-kemiskinan/admin/belum_dibantu.php
@@ -0,0 +1,71 @@
+query("
+ SELECT p.nik, p.nama_lengkap, p.status_ekonomi, p.kondisi_kesehatan,
+ p.umur, p.pekerjaan, p.penghasilan, p.alamat,
+ k.no_kk, k.nama_kk, ri.nama AS nama_ri, ri.jenis AS jenis_ri
+ FROM penduduk p
+ LEFT JOIN keluarga k ON k.id=p.id_keluarga
+ LEFT JOIN rumah_ibadah ri ON ri.id=k.rumah_ibadah_id
+ WHERE p.status_hidup='hidup'
+ AND p.status_ekonomi IN ('miskin','rentan')
+ AND NOT EXISTS(
+ SELECT 1 FROM histori_bantuan hb
+ WHERE hb.nik=p.nik AND hb.status_penyaluran='disalurkan'
+ )
+ ORDER BY
+ CASE p.status_ekonomi WHEN 'miskin' THEN 1 ELSE 2 END,
+ CASE p.kondisi_kesehatan WHEN 'sakit_parah' THEN 1 WHEN 'disabilitas' THEN 2 WHEN 'sakit_ringan' THEN 3 ELSE 4 END,
+ p.nama_lengkap
+ ")->fetchAll();
+} catch(Exception $e) {}
+
+render_header('Belum Pernah Dibantu');
+?>
+
β οΈ Warga Belum Pernah Menerima Bantuan
+
+
+ Ditemukan = count($rows) ?> jiwa dengan status ekonomi miskin/rentan yang belum pernah menerima bantuan apapun. Prioritaskan yang kondisi kesehatannya kritis.
+
+
+
+
+
+ Nama NIK Status Kesehatan Umur Pekerjaan Rumah Ibadah
+ Aksi
+
+
+
+ >
+
+ = $kritis ? 'π₯ ' : '' ?>= h($r['nama_lengkap']) ?>
+ = $r['status_ekonomi']==='miskin' && $r['kondisi_kesehatan']==='sakit_parah' ? 'KRITIS ' : '' ?>
+
+ = h($r['nik']) ?>
+ = h($r['status_ekonomi']) ?>
+ = h(str_replace('_',' ',$r['kondisi_kesehatan'])) ?>
+ = $r['umur'] ?> th
+ = h($r['pekerjaan'] ?: '-') ?>
+ = h($r['nama_ri'] ?: '-') ?>
+
+
+
+ π Beri Bantuan
+
+
+
+
+
+
+ π Semua warga miskin/rentan sudah pernah dibantu!
+
+
+
+
+
diff --git a/webgis-kemiskinan/admin/forgot.php b/webgis-kemiskinan/admin/forgot.php
new file mode 100644
index 0000000..e6c582e
--- /dev/null
+++ b/webgis-kemiskinan/admin/forgot.php
@@ -0,0 +1,92 @@
+
+
+
+
+
+
Lupa Password β WebGIS Pengentasan Kemiskinan
+
+
+
+prepare("SELECT id FROM users WHERE username = :u AND email = :e LIMIT 1");
+ $stmt->execute([':u' => $username, ':e' => $email]);
+ $user = $stmt->fetch();
+
+ if ($user) {
+ $hash = password_hash($new_password, PASSWORD_DEFAULT);
+ $update = $pdo->prepare("UPDATE users SET password_hash = :p WHERE id = :id");
+ $update->execute([':p' => $hash, ':id' => $user['id']]);
+ $success = 'Password berhasil direset! Silakan login dengan password baru Anda.';
+ } else {
+ $error = 'Username dan Email tidak cocok atau tidak ditemukan.';
+ }
+ }
+}
+?>
+
+
+
π
+
Reset Password
+
Masukkan Username & Email Terdaftar
+
+
+
+
= h($error) ?>
+
+
+
= h($success) ?>
+
+
+
+
+ Username
+
+
+
+ Email Terdaftar
+
+
+
+ Password Baru
+
+
+
+ π Reset Password
+
+
+
+
+
+
diff --git a/webgis-kemiskinan/admin/histori_bantuan.php b/webgis-kemiskinan/admin/histori_bantuan.php
new file mode 100644
index 0000000..e64fe3c
--- /dev/null
+++ b/webgis-kemiskinan/admin/histori_bantuan.php
@@ -0,0 +1,48 @@
+query("
+ SELECT hb.id, hb.nik, p.nama_lengkap, hb.tanggal,
+ jb.nama AS nama_bantuan, jb.kategori,
+ hb.jumlah, hb.nilai_rupiah, hb.status_penyaluran,
+ ri.nama AS nama_ri, u.nama AS disalurkan_oleh
+ FROM histori_bantuan hb
+ JOIN penduduk p ON p.nik=hb.nik
+ JOIN jenis_bantuan jb ON jb.id=hb.id_jenis_bantuan
+ LEFT JOIN rumah_ibadah ri ON ri.id=hb.rumah_ibadah_id
+ LEFT JOIN users u ON u.id=hb.disalurkan_oleh
+ ORDER BY hb.tanggal DESC
+ LIMIT 500
+ ")->fetchAll();
+} catch(Exception $e) {}
+
+render_header('Histori Bantuan');
+?>
+
π Histori Penyaluran Bantuan
+
+
+
+ # Nama Penerima Jenis Bantuan Jumlah
+ Tanggal Rumah Ibadah Disalurkan Oleh Status
+
+
+
+
+ = $r['id'] ?>
+ = h($r['nama_lengkap']) ?>= h($r['nik']) ?>
+ = h($r['nama_bantuan']) ?> (= h($r['kategori']) ?>)
+ = h($r['jumlah'] ?? '-') ?>= $r['nilai_rupiah'] ? 'Rp '.number_format($r['nilai_rupiah'],0,',','.').' ' : '' ?>
+ = date('d/m/Y', strtotime($r['tanggal'])) ?>
+ = h($r['nama_ri'] ?? '-') ?>
+ = h($r['disalurkan_oleh'] ?? '-') ?>
+ = h($r['status_penyaluran']) ?>
+
+
+ Belum ada data
+
+
+
+
diff --git a/webgis-kemiskinan/admin/index.php b/webgis-kemiskinan/admin/index.php
new file mode 100644
index 0000000..df9ca0e
--- /dev/null
+++ b/webgis-kemiskinan/admin/index.php
@@ -0,0 +1,128 @@
+query("
+ SELECT
+ (SELECT COUNT(*) FROM penduduk WHERE status_ekonomi='miskin' AND status_hidup='hidup') AS miskin,
+ (SELECT COUNT(*) FROM penduduk WHERE status_ekonomi='rentan' AND status_hidup='hidup') AS rentan,
+ (SELECT COUNT(*) FROM penduduk WHERE status_hidup='hidup') AS total_pddk,
+ (SELECT COUNT(*) FROM keluarga) AS total_kk,
+ (SELECT COUNT(*) FROM rumah_ibadah) AS total_ri,
+ (SELECT COUNT(*) FROM laporan WHERE status='pending') AS laporan_pending,
+ (SELECT COUNT(*) FROM laporan WHERE tingkat_urgensi='darurat' AND status NOT IN ('selesai','ditolak')) AS laporan_darurat,
+ (SELECT COUNT(*) FROM histori_bantuan WHERE MONTH(tanggal)=MONTH(CURDATE()) AND YEAR(tanggal)=YEAR(CURDATE()) AND status_penyaluran='disalurkan') AS bantuan_bulan,
+ (SELECT COUNT(*) FROM penduduk WHERE status_hidup='hidup' AND status_ekonomi IN ('miskin','rentan')
+ AND NOT EXISTS(SELECT 1 FROM histori_bantuan hb WHERE hb.nik=penduduk.nik AND hb.status_penyaluran='disalurkan')
+ ) AS belum_dibantu
+ ")->fetch();
+} catch(Exception $e) {}
+
+// Laporan terbaru
+$laporan_terbaru = [];
+try {
+ $laporan_terbaru = $pdo->query("
+ SELECT l.id, l.tingkat_urgensi, l.kategori, l.status, l.created_at,
+ l.nama_pelapor, p.nama_lengkap AS nama_terdampak
+ FROM laporan l
+ LEFT JOIN penduduk p ON p.nik = l.nik_terdampak
+ ORDER BY
+ CASE l.tingkat_urgensi WHEN 'darurat' THEN 1 WHEN 'tinggi' THEN 2 ELSE 3 END,
+ l.created_at DESC
+ LIMIT 8
+ ")->fetchAll();
+} catch(Exception $e) {}
+
+// Prioritas belum dibantu
+$prioritas = [];
+try {
+ $prioritas = $pdo->query("
+ SELECT p.nik, p.nama_lengkap, p.status_ekonomi, p.kondisi_kesehatan,
+ p.alamat, k.no_kk, ri.nama AS nama_ri
+ FROM penduduk p
+ LEFT JOIN keluarga k ON k.id=p.id_keluarga
+ LEFT JOIN rumah_ibadah ri ON ri.id=k.rumah_ibadah_id
+ WHERE p.status_hidup='hidup'
+ AND p.status_ekonomi='miskin'
+ AND p.kondisi_kesehatan IN ('sakit_parah','disabilitas')
+ AND NOT EXISTS(SELECT 1 FROM histori_bantuan hb WHERE hb.nik=p.nik AND hb.status_penyaluran='disalurkan')
+ ORDER BY CASE p.kondisi_kesehatan WHEN 'sakit_parah' THEN 1 ELSE 2 END
+ LIMIT 5
+ ")->fetchAll();
+} catch(Exception $e) {}
+
+render_header('Dashboard');
+?>
+
+
π Dashboard Monitoring
+
+
+
+
= $stats['miskin'] ?? 0 ?>
Warga Miskin
+
= $stats['rentan'] ?? 0 ?>
Warga Rentan
+
= $stats['total_kk'] ?? 0 ?>
Total KK
+
= $stats['bantuan_bulan'] ?? 0 ?>
Bantuan Bulan Ini
+
= $stats['laporan_pending'] ?? 0 ?>
Laporan Pending
+
= $stats['belum_dibantu'] ?? 0 ?>
Belum Pernah Dibantu
+
+
+ 0): ?>
+
+
π¨
+
+
= $stats['laporan_darurat'] ?> LAPORAN DARURAT belum ditangani!
+
Segera tindak lanjuti laporan kondisi darurat dari masyarakat.
+
+
Lihat β
+
+
+
+
+
+
+
+
+
+ Pelapor/Terdampak Urgensi Status
+
+
+
+ = h($l['nama_terdampak'] ?? $l['nama_pelapor'] ?? 'Anonim') ?>
+ = h($l['tingkat_urgensi']) ?>
+ = h($l['status']) ?>
+
+
+ Belum ada laporan
+
+
+
+
+
+
+
+
π₯ Prioritas KRITIS (Belum Dibantu)
+
Semua β
+
+
+ Nama Status Kesehatan
+
+
+
+ = h($p['nama_lengkap']) ?>
+ = h($p['status_ekonomi']) ?>
+ = h($p['kondisi_kesehatan']) ?>
+
+
+ π Tidak ada kasus kritis saat ini
+
+
+
+
+
+
+
diff --git a/webgis-kemiskinan/admin/init_admin.php b/webgis-kemiskinan/admin/init_admin.php
new file mode 100644
index 0000000..3d9b7e6
--- /dev/null
+++ b/webgis-kemiskinan/admin/init_admin.php
@@ -0,0 +1,32 @@
+prepare("SELECT id FROM users WHERE username=:u");
+ $cek->execute([':u'=>$uname]);
+ if (!$cek->fetch()) {
+ $pdo->prepare("INSERT INTO users (role_id,nama,username,password_hash,rumah_ibadah_id) VALUES(:r,:n,:u,:p,:ri)")
+ ->execute([':r'=>$role,':n'=>$nama,':u'=>$uname,':p'=>$hash,':ri'=>$ri]);
+ $created++;
+ echo "β
User '$uname' dibuat.
";
+ } else {
+ echo "βΉοΈ User '$uname' sudah ada β dilewati.
";
+ }
+}
+
+echo "
$created user baru dibuat. ";
+echo "
Password semua akun demo:
password";
+echo "
Segera hapus file ini setelah digunakan! ";
+echo "
β Ke halaman login ";
diff --git a/webgis-kemiskinan/admin/keluarga.php b/webgis-kemiskinan/admin/keluarga.php
new file mode 100644
index 0000000..357ac70
--- /dev/null
+++ b/webgis-kemiskinan/admin/keluarga.php
@@ -0,0 +1,159 @@
+prepare("DELETE FROM keluarga WHERE id=:id")->execute([':id'=>$id]);
+ logAktivitas($pdo, (int)$_SESSION['user_id'], 'DELETE', 'keluarga', (string)$id);
+ $msg = "Data keluarga #$id dihapus.";
+ } catch(Exception $e) { $err = 'Gagal hapus: '.$e->getMessage(); }
+}
+
+// SAVE
+if ($canEdit && isset($_POST['save'])) {
+ $nokk = trim($_POST['no_kk'] ?? '');
+ $nama = trim($_POST['nama_kk'] ?? '');
+ $idEdit = (int)($_POST['id_edit'] ?? 0);
+ if (!preg_match('/^\d{16}$/', $nokk)) $err = 'No KK harus 16 digit.';
+ elseif (strlen($nama) < 3) $err = 'Nama KK terlalu pendek.';
+ else {
+ try {
+ $data = [
+ ':nokk' => $nokk, ':nama' => $nama,
+ ':almt' => $_POST['alamat']??'' ?: null,
+ ':kel' => $_POST['kelurahan']??'' ?: null,
+ ':kec' => $_POST['kecamatan']??'' ?: null,
+ ':kota' => $_POST['kota']??'' ?: null,
+ ':lat' => $_POST['latitude']??'' ?: null,
+ ':lng' => $_POST['longitude']??'' ?: null,
+ ':se' => $_POST['status_ekonomi']??'rentan',
+ ':ri' => $_POST['rumah_ibadah_id']??'' ?: null,
+ ];
+ if ($idEdit) {
+ $data[':id'] = $idEdit;
+ $pdo->prepare("UPDATE keluarga SET no_kk=:nokk,nama_kk=:nama,alamat=:almt,kelurahan=:kel,
+ kecamatan=:kec,kota=:kota,latitude=:lat,longitude=:lng,status_ekonomi=:se,
+ rumah_ibadah_id=:ri WHERE id=:id")->execute($data);
+ $msg = "Data KK $nama diperbarui.";
+ logAktivitas($pdo, (int)$_SESSION['user_id'], 'UPDATE', 'keluarga', (string)$idEdit);
+ } else {
+ $pdo->prepare("INSERT INTO keluarga (no_kk,nama_kk,alamat,kelurahan,kecamatan,kota,latitude,longitude,status_ekonomi,rumah_ibadah_id)
+ VALUES(:nokk,:nama,:almt,:kel,:kec,:kota,:lat,:lng,:se,:ri)")->execute($data);
+ $msg = "Keluarga $nama berhasil ditambahkan.";
+ logAktivitas($pdo, (int)$_SESSION['user_id'], 'CREATE', 'keluarga', (string)$pdo->lastInsertId());
+ }
+ } catch(Exception $e) { $err = $e->getMessage(); }
+ }
+}
+
+$edit = null;
+if (isset($_GET['edit'])) {
+ $s = $pdo->prepare("SELECT * FROM keluarga WHERE id=:id");
+ $s->execute([':id'=>(int)$_GET['edit']]);
+ $edit = $s->fetch() ?: null;
+}
+
+$search = trim($_GET['s'] ?? '');
+$where = []; $params = [];
+if ($search) { $where[] = '(k.nama_kk LIKE :q OR k.no_kk LIKE :q2)'; $params[':q']='%'.$search.'%'; $params[':q2']='%'.$search.'%'; }
+$whereStr = $where ? 'WHERE '.implode(' AND ',$where) : '';
+$rows = $pdo->prepare("
+ SELECT k.*, ri.nama AS nama_ri,
+ (SELECT COUNT(*) FROM penduduk p WHERE p.id_keluarga=k.id) AS jml_anggota
+ FROM keluarga k
+ LEFT JOIN rumah_ibadah ri ON ri.id=k.rumah_ibadah_id
+ $whereStr ORDER BY k.nama_kk LIMIT 300
+");
+$rows->execute($params);
+$rows = $rows->fetchAll();
+$riList = $pdo->query("SELECT id,nama,jenis FROM rumah_ibadah ORDER BY nama")->fetchAll();
+
+render_header('Keluarga (KK)');
+?>
+
π Data Keluarga (Kartu Keluarga)
+
+
= h($msg) ?>
+
= h($err) ?>
+
+
+
+
+
= $edit ? 'βοΈ Edit KK' : 'β Tambah KK' ?>
+
+
+
+
+ = $edit ? 'πΎ Update' : 'β Simpan' ?>
+ Batal
+
+
+
+
+
+
+
+
+ π
+
+
+
+
+ No KK Nama KK Status Anggota Rumah Ibadah = $canEdit?'Aksi ':'' ?>
+
+
+
+ = h($r['no_kk']) ?>
+ = h($r['nama_kk']) ?> = h($r['kecamatan']??'') ?>
+ = h($r['status_ekonomi']) ?>
+ = $r['jml_anggota'] ?> jiwa
+ = h($r['nama_ri'] ?? '-') ?>
+
+
+ βοΈ
+ ποΈ
+
+
+
+
+ Belum ada data
+
+
+
+
+
+
diff --git a/webgis-kemiskinan/admin/laporan.php b/webgis-kemiskinan/admin/laporan.php
new file mode 100644
index 0000000..bc0581a
--- /dev/null
+++ b/webgis-kemiskinan/admin/laporan.php
@@ -0,0 +1,167 @@
+ (int)$_SESSION['user_id'], 'role' => $_SESSION['role']];
+$filter = $_GET['filter'] ?? 'semua';
+$msg = ''; $err = '';
+
+// Update status laporan
+if (isset($_POST['update_status']) && in_array($currentUser['role'], ['admin','pengurus'])) {
+ $id = (int)($_POST['lap_id'] ?? 0);
+ $status = $_POST['status'] ?? '';
+ $catatan = trim($_POST['catatan'] ?? '');
+ $valid = ['diverifikasi','diproses','selesai','ditolak'];
+ if ($id > 0 && in_array($status, $valid)) {
+ try {
+ $sets = ['status=:s','updated_at=NOW()'];
+ $params = [':s'=>$status,':id'=>$id];
+ if ($status === 'diverifikasi') { $sets[] = 'diverifikasi_oleh=:v'; $params[':v'] = $currentUser['id']; }
+ if ($status === 'diproses') { $sets[] = 'ditangani_oleh=:t'; $params[':t'] = $currentUser['id']; }
+ if ($status === 'selesai') { $sets[] = 'tanggal_selesai=NOW()'; }
+ if ($catatan) { $sets[] = 'catatan_verifikasi=:c'; $params[':c'] = $catatan; }
+ $pdo->prepare("UPDATE laporan SET ".implode(',',$sets)." WHERE id=:id")->execute($params);
+ logAktivitas($pdo, $currentUser['id'], 'UPDATE', 'laporan', (string)$id, null, ['status'=>$status]);
+ $msg = "Status laporan #$id diperbarui ke '$status'.";
+ } catch(Exception $e) { $err = $e->getMessage(); }
+ }
+}
+
+// Ambil laporan
+$where = [];
+$params = [];
+if ($filter === 'pending') { $where[] = "status='pending'"; }
+if ($filter === 'darurat') { $where[] = "tingkat_urgensi IN ('darurat','tinggi')"; }
+if ($filter === 'diproses') { $where[] = "status IN ('diverifikasi','diproses')"; }
+if ($filter === 'selesai') { $where[] = "status='selesai'"; }
+$whereStr = $where ? 'WHERE '.implode(' AND ',$where) : '';
+
+$rows = [];
+try {
+ $rows = $pdo->query("
+ SELECT l.*, p.nama_lengkap AS nama_terdampak, u.nama AS nama_verifikator
+ FROM laporan l
+ LEFT JOIN penduduk p ON p.nik=l.nik_terdampak
+ LEFT JOIN users u ON u.id=l.diverifikasi_oleh
+ $whereStr
+ ORDER BY CASE l.tingkat_urgensi WHEN 'darurat' THEN 1 WHEN 'tinggi' THEN 2 ELSE 3 END,
+ CASE l.status WHEN 'pending' THEN 1 WHEN 'diverifikasi' THEN 2 ELSE 3 END,
+ l.created_at DESC
+ ")->fetchAll();
+} catch(Exception $e) { $err = $e->getMessage(); }
+
+render_header('Manajemen Laporan');
+?>
+
+
π© Laporan Masyarakat
+
+
= h($msg) ?>
+
= h($err) ?>
+
+
+ 'Semua','pending'=>'Pending','darurat'=>'π΄ Darurat','diproses'=>'Diproses','selesai'=>'Selesai'] as $k=>$v): ?>
+
= $v ?>
+
+
+
+
+
+
+
+ # Pelapor Terdampak Kategori
+ Urgensi Status Tanggal Aksi
+
+
+
+
+
+ = $r['id'] ?>
+ = h($r['nama_pelapor'] ?: 'Anonim') ?>
+ = h($r['nama_terdampak'] ?? ($r['nik_terdampak'] ?? '-')) ?>
+ = h(str_replace('_',' ',$r['kategori'])) ?>
+ = h($r['tingkat_urgensi']) ?>
+ = h($r['status']) ?>
+ = date('d/m/y H:i', strtotime($r['created_at'])) ?>
+
+
+
+ Update
+
+
+ Detail
+
+
+
+
+ Tidak ada laporan
+
+
+
+
+
+
+
+
+
Update Status Laporan #
+
+
+
+ Status Baru
+
+ Diverifikasi
+ Diproses
+ Selesai
+ Ditolak
+
+
+
+ Catatan
+
+
+
+ Batal
+ Simpan
+
+
+
+
+
+
+
+
+
+
Detail Laporan
+ β
+
+
+
+
+
+
+
+
diff --git a/webgis-kemiskinan/admin/log.php b/webgis-kemiskinan/admin/log.php
new file mode 100644
index 0000000..acca980
--- /dev/null
+++ b/webgis-kemiskinan/admin/log.php
@@ -0,0 +1,33 @@
+query("
+ SELECT l.*, u.nama AS nama_user
+ FROM log_aktivitas l
+ LEFT JOIN users u ON u.id=l.user_id
+ ORDER BY l.created_at DESC
+ LIMIT 200
+")->fetchAll();
+
+render_header('Log Aktivitas');
+?>
+
π Log Aktivitas Sistem
+
+
+ Waktu User Aksi Tabel Record IP
+
+
+
+ = date('d/m/y H:i:s', strtotime($r['created_at'])) ?>
+ = h($r['nama_user'] ?? 'Sistem') ?>
+ = h($r['aksi']) ?>
+ = h($r['tabel'] ?? '-') ?>
+ = h($r['record_id'] ?? '-') ?>
+ = h($r['ip_address'] ?? '-') ?>
+
+
+
+
+
+
diff --git a/webgis-kemiskinan/admin/login.php b/webgis-kemiskinan/admin/login.php
new file mode 100644
index 0000000..fd4eaa1
--- /dev/null
+++ b/webgis-kemiskinan/admin/login.php
@@ -0,0 +1,89 @@
+
+
+
+
+
+
Login β WebGIS Pengentasan Kemiskinan
+
+
+
+ 'index.php',
+ 'pengurus' => 'index.php',
+ 'pimpinan' => 'index.php',
+ 'masyarakat' => '../index.html',
+ ];
+ header('Location: ' . ($redirect[$result['role']] ?? 'index.php'));
+ exit;
+ }
+ $error = $result['message'];
+}
+?>
+
+
+
πΊοΈ
+
WebGIS Kemiskinan
+
Berbasis Masyarakat & Rumah Ibadah
+
+
+
+
= h($error) ?>
+
+
+
+
+ Username
+
+
+
+ Password
+
+
+
+ π Masuk
+
+
+
+
Akun Demo (password: password)
+
admin Administrator
+
pengurus1 Pengurus Masjid
+
pimpinan1 Pimpinan Daerah
+
warga1 Masyarakat
+
+
+
+
+
+
diff --git a/webgis-kemiskinan/admin/logout.php b/webgis-kemiskinan/admin/logout.php
new file mode 100644
index 0000000..8e0c539
--- /dev/null
+++ b/webgis-kemiskinan/admin/logout.php
@@ -0,0 +1,6 @@
+prepare("DELETE FROM penduduk WHERE nik=:nik")->execute([':nik'=>$nik]);
+ logAktivitas($pdo, (int)$_SESSION['user_id'], 'DELETE', 'penduduk', $nik);
+ $msg = "Penduduk NIK $nik dihapus.";
+ } catch(Exception $e) { $err = $e->getMessage(); }
+}
+
+// SAVE
+if ($canEdit && isset($_POST['save'])) {
+ $nik = trim($_POST['nik'] ?? '');
+ $nama = trim($_POST['nama_lengkap'] ?? '');
+ $tgl = $_POST['tanggal_lahir'] ?? '';
+ $nikEdit = $_POST['nik_edit'] ?? '';
+
+ if (!preg_match('/^\d{16}$/', $nik)) $err = 'NIK harus 16 digit.';
+ elseif (strlen($nama) < 3) $err = 'Nama terlalu pendek.';
+ else {
+ try {
+ $data = [
+ ':kk'=>$_POST['id_keluarga']?:(null),
+ ':nama'=>$nama, ':jk'=>$_POST['jenis_kelamin']??'L',
+ ':tgl'=>$tgl, ':sk'=>$_POST['status_keluarga']??'anggota_lain',
+ ':sp'=>$_POST['status_perkawinan']??'belum_kawin',
+ ':sh'=>$_POST['status_hidup']??'hidup',
+ ':ptk'=>$_POST['pendidikan_terakhir']??'tidak_sekolah',
+ ':spd'=>$_POST['status_pendidikan']??'tidak_sekolah',
+ ':pkr'=>$_POST['pekerjaan']??'' ?: null,
+ ':pgn'=>(float)($_POST['penghasilan']??0),
+ ':se'=>$_POST['status_ekonomi']??'rentan',
+ ':kkh'=>$_POST['kondisi_kesehatan']??'sehat',
+ ':ckkh'=>$_POST['catatan_kesehatan']??'' ?: null,
+ ':tlp'=>$_POST['no_telp']??'' ?: null,
+ ':almt'=>$_POST['alamat']??'' ?: null,
+ ':lat'=>$_POST['latitude']??'' ?: null,
+ ':lng'=>$_POST['longitude']??'' ?: null,
+ ];
+ if ($nikEdit) {
+ $data[':nik'] = $nikEdit;
+ $pdo->prepare("UPDATE penduduk SET id_keluarga=:kk,nama_lengkap=:nama,jenis_kelamin=:jk,tanggal_lahir=:tgl,
+ status_keluarga=:sk,status_perkawinan=:sp,status_hidup=:sh,pendidikan_terakhir=:ptk,status_pendidikan=:spd,
+ pekerjaan=:pkr,penghasilan=:pgn,status_ekonomi=:se,kondisi_kesehatan=:kkh,catatan_kesehatan=:ckkh,
+ no_telp=:tlp,alamat=:almt,latitude=:lat,longitude=:lng WHERE nik=:nik")->execute($data);
+ $msg = "Data penduduk $nama diperbarui.";
+ } else {
+ $data[':nik'] = $nik;
+ $pdo->prepare("INSERT INTO penduduk (nik,id_keluarga,nama_lengkap,jenis_kelamin,tanggal_lahir,
+ status_keluarga,status_perkawinan,status_hidup,pendidikan_terakhir,status_pendidikan,
+ pekerjaan,penghasilan,status_ekonomi,kondisi_kesehatan,catatan_kesehatan,no_telp,alamat,latitude,longitude)
+ VALUES(:nik,:kk,:nama,:jk,:tgl,:sk,:sp,:sh,:ptk,:spd,:pkr,:pgn,:se,:kkh,:ckkh,:tlp,:almt,:lat,:lng)")->execute($data);
+ $msg = "Penduduk $nama berhasil ditambahkan.";
+ }
+ logAktivitas($pdo, (int)$_SESSION['user_id'], $nikEdit?'UPDATE':'CREATE', 'penduduk', $nik);
+ } catch(Exception $e) { $err = $e->getMessage(); }
+ }
+}
+
+$edit = null;
+if (isset($_GET['edit'])) {
+ $stmt = $pdo->prepare("SELECT * FROM penduduk WHERE nik=:nik");
+ $stmt->execute([':nik'=>$_GET['edit']]);
+ $edit = $stmt->fetch() ?: null;
+}
+
+// List penduduk
+$search = trim($_GET['s'] ?? '');
+$se = $_GET['se'] ?? '';
+$where = []; $params = [];
+if ($search) { $where[] = '(p.nama_lengkap LIKE :q OR p.nik LIKE :q2)'; $params[':q']='%'.$search.'%'; $params[':q2']='%'.$search.'%'; }
+if ($se) { $where[] = 'p.status_ekonomi=:se'; $params[':se']=$se; }
+$whereStr = $where ? 'WHERE '.implode(' AND ',$where) : '';
+
+$rows = $pdo->prepare("
+ SELECT p.nik,p.nama_lengkap,p.umur,p.jenis_kelamin,p.status_ekonomi,p.kondisi_kesehatan,
+ p.pekerjaan,p.status_hidup,k.no_kk,ri.nama AS nama_ri,
+ (SELECT COUNT(*) FROM histori_bantuan hb WHERE hb.nik=p.nik AND hb.status_penyaluran='disalurkan') AS jml_bantuan
+ FROM penduduk p
+ LEFT JOIN keluarga k ON k.id=p.id_keluarga
+ LEFT JOIN rumah_ibadah ri ON ri.id=k.rumah_ibadah_id
+ $whereStr
+ ORDER BY CASE p.status_ekonomi WHEN 'miskin' THEN 1 WHEN 'rentan' THEN 2 ELSE 3 END, p.nama_lengkap
+ LIMIT 300
+");
+$rows->execute($params);
+$rows = $rows->fetchAll();
+
+// Keluarga untuk dropdown
+$kkList = $pdo->query("SELECT id, no_kk, nama_kk FROM keluarga ORDER BY nama_kk")->fetchAll();
+
+render_header('Manajemen Penduduk');
+?>
+
π₯ Manajemen Penduduk
+
+
= h($msg) ?>
+
= h($err) ?>
+
+
+
+
+
= $edit ? 'βοΈ Edit: '.h($edit['nama_lengkap']) : 'β Tambah Penduduk' ?>
+
+
+
+
+
+
πΎ = $edit ? 'Update' : 'Simpan' ?>
+
Batal
+
+
+
+
+
+
+
+
+
+
+
+ Semua Status
+ >Miskin
+ >Rentan
+ >Mampu
+
+ Cari
+ Reset
+
+
= count($rows) ?> data
+
+
+ NIK Nama Umur/JK Status Ekonomi Kesehatan Bantuan Rumah Ibadah Aksi
+
+
+
+ = h($r['nik']) ?>
+ = h($r['nama_lengkap']) ?> = $r['status_hidup']==='meninggal'?'β meninggal ':'' ?>
+ = $r['umur'] ?>th / = $r['jenis_kelamin']==='L'?'β':'β' ?>
+ = h($r['status_ekonomi']) ?>
+ = h(str_replace('_',' ',$r['kondisi_kesehatan'])) ?>
+ = $r['jml_bantuan'] > 0 ? "β
{$r['jml_bantuan']}x " : "β οΈ Belum " ?>
+ = h($r['nama_ri'] ?? '-') ?>
+
+
+ Edit
+ Bantu
+
+ Hapus
+
+
+
+
+
+ Tidak ada data
+
+
+
+
+
+
+
diff --git a/webgis-kemiskinan/admin/rumah_ibadah.php b/webgis-kemiskinan/admin/rumah_ibadah.php
new file mode 100644
index 0000000..ccbe5dc
--- /dev/null
+++ b/webgis-kemiskinan/admin/rumah_ibadah.php
@@ -0,0 +1,141 @@
+prepare("DELETE FROM rumah_ibadah WHERE id=:id")->execute([':id'=>$id]);
+ logAktivitas($pdo, (int)$_SESSION['user_id'], 'DELETE', 'rumah_ibadah', (string)$id);
+ $msg = "Rumah ibadah #$id dihapus.";
+ } catch(Exception $e) { $err = 'Gagal hapus: '.$e->getMessage(); }
+}
+
+// SAVE
+if ($canEdit && isset($_POST['save'])) {
+ $nama = trim($_POST['nama'] ?? '');
+ $lat = $_POST['latitude'] ?? '';
+ $lng = $_POST['longitude'] ?? '';
+ $idEdit = (int)($_POST['id_edit'] ?? 0);
+ if (strlen($nama) < 3) $err = 'Nama terlalu pendek.';
+ elseif (!is_numeric($lat) || !is_numeric($lng)) $err = 'Latitude/Longitude tidak valid.';
+ else {
+ try {
+ $data = [
+ ':nama' => $nama,
+ ':jenis' => $_POST['jenis']??'Lainnya',
+ ':kontak'=> $_POST['kontak']??'' ?: null,
+ ':rad' => (float)($_POST['radius_meter']??300),
+ ':lat' => (float)$lat, ':lng' => (float)$lng,
+ ':almt' => $_POST['alamat']??'' ?: null,
+ ':kel' => $_POST['kelurahan']??'' ?: null,
+ ':kec' => $_POST['kecamatan']??'' ?: null,
+ ':kota' => $_POST['kota']??'' ?: null,
+ ':desk' => $_POST['deskripsi']??'' ?: null,
+ ];
+ if ($idEdit) {
+ $data[':id'] = $idEdit;
+ $pdo->prepare("UPDATE rumah_ibadah SET nama=:nama,jenis=:jenis,kontak=:kontak,radius_meter=:rad,
+ latitude=:lat,longitude=:lng,alamat=:almt,kelurahan=:kel,kecamatan=:kec,kota=:kota,deskripsi=:desk
+ WHERE id=:id")->execute($data);
+ $msg = "$nama diperbarui.";
+ logAktivitas($pdo, (int)$_SESSION['user_id'], 'UPDATE', 'rumah_ibadah', (string)$idEdit);
+ } else {
+ $pdo->prepare("INSERT INTO rumah_ibadah (nama,jenis,kontak,radius_meter,latitude,longitude,alamat,kelurahan,kecamatan,kota,deskripsi)
+ VALUES(:nama,:jenis,:kontak,:rad,:lat,:lng,:almt,:kel,:kec,:kota,:desk)")->execute($data);
+ $msg = "$nama berhasil ditambahkan.";
+ logAktivitas($pdo, (int)$_SESSION['user_id'], 'CREATE', 'rumah_ibadah', (string)$pdo->lastInsertId());
+ }
+ } catch(Exception $e) { $err = $e->getMessage(); }
+ }
+}
+
+$edit = null;
+if (isset($_GET['edit'])) {
+ $s = $pdo->prepare("SELECT * FROM rumah_ibadah WHERE id=:id");
+ $s->execute([':id'=>(int)$_GET['edit']]);
+ $edit = $s->fetch() ?: null;
+}
+
+$rows = $pdo->query("
+ SELECT ri.*,
+ (SELECT COUNT(*) FROM keluarga k WHERE k.rumah_ibadah_id=ri.id) AS jml_kk
+ FROM rumah_ibadah ri ORDER BY ri.nama
+")->fetchAll();
+
+$jenisOpts = ['Masjid','Musholla','Gereja Protestan','Gereja Katolik','Pura','Vihara','Klenteng','Lainnya'];
+
+render_header('Rumah Ibadah');
+?>
+
π Data Rumah Ibadah
+
+
= h($msg) ?>
+
= h($err) ?>
+
+
+
+
+
= $edit ? 'βοΈ Edit' : 'β Tambah Rumah Ibadah' ?>
+
+
+
+
+ = $edit ? 'πΎ Update' : 'β Simpan' ?>
+ Batal
+
+
+
+
+
+
+ Nama Jenis Alamat Radius KK = $canEdit?'Aksi ':'' ?>
+
+
+
+ = h($r['nama']) ?> = h($r['kontak']??'') ?>
+ = h($r['jenis']) ?>
+ = h($r['kelurahan']??'') ?>= $r['kecamatan'] ? ', '.$r['kecamatan'] : '' ?>
+ = $r['radius_meter'] ?>m
+ = $r['jml_kk'] ?> KK
+
+
+ βοΈ
+ ποΈ
+
+
+
+
+ Belum ada data
+
+
+
+
+
diff --git a/webgis-kemiskinan/admin/users.php b/webgis-kemiskinan/admin/users.php
new file mode 100644
index 0000000..66539aa
--- /dev/null
+++ b/webgis-kemiskinan/admin/users.php
@@ -0,0 +1,163 @@
+prepare("UPDATE users SET aktif = 1 - aktif WHERE id=:id")->execute([':id'=>$id]);
+ logAktivitas($pdo, (int)$_SESSION['user_id'], 'UPDATE', 'users', (string)$id);
+ $msg = "Status user #$id diperbarui.";
+ } catch(Exception $e) { $err = $e->getMessage(); }
+ } else { $err = 'Tidak dapat mengubah status akun sendiri.'; }
+}
+
+// RESET PASSWORD
+if (isset($_POST['reset_pw'])) {
+ $id = (int)($_POST['user_id'] ?? 0);
+ $pw = $_POST['new_password'] ?? '';
+ if (strlen($pw) < 6) $err = 'Password minimal 6 karakter.';
+ else {
+ $hash = password_hash($pw, PASSWORD_BCRYPT);
+ $pdo->prepare("UPDATE users SET password_hash=:h WHERE id=:id")->execute([':h'=>$hash,':id'=>$id]);
+ logAktivitas($pdo, (int)$_SESSION['user_id'], 'UPDATE', 'users', (string)$id, null, ['action'=>'reset_password']);
+ $msg = "Password user #$id berhasil direset.";
+ }
+}
+
+// SAVE USER BARU
+if (isset($_POST['save'])) {
+ $uname = trim($_POST['username'] ?? '');
+ $nama = trim($_POST['nama'] ?? '');
+ $pw = $_POST['password'] ?? '';
+ $idEdit = (int)($_POST['id_edit'] ?? 0);
+ if (strlen($uname) < 3) $err = 'Username minimal 3 karakter.';
+ elseif (strlen($nama) < 3) $err = 'Nama minimal 3 karakter.';
+ elseif (!$idEdit && strlen($pw) < 6) $err = 'Password minimal 6 karakter.';
+ else {
+ try {
+ if ($idEdit) {
+ $pdo->prepare("UPDATE users SET nama=:nama,role_id=:rid,email=:em,no_telp=:tlp,rumah_ibadah_id=:ri WHERE id=:id")
+ ->execute([':nama'=>$nama,':rid'=>(int)$_POST['role_id'],':em'=>$_POST['email']??'' ?: null,
+ ':tlp'=>$_POST['no_telp']??'' ?: null,':ri'=>$_POST['rumah_ibadah_id']??'' ?: null,':id'=>$idEdit]);
+ $msg = "User $nama diperbarui.";
+ logAktivitas($pdo, (int)$_SESSION['user_id'], 'UPDATE', 'users', (string)$idEdit);
+ } else {
+ $hash = password_hash($pw, PASSWORD_BCRYPT);
+ $pdo->prepare("INSERT INTO users (role_id,nama,username,email,password_hash,no_telp,rumah_ibadah_id)
+ VALUES(:rid,:nama,:uname,:em,:hash,:tlp,:ri)")
+ ->execute([':rid'=>(int)$_POST['role_id'],':nama'=>$nama,':uname'=>$uname,
+ ':em'=>$_POST['email']??'' ?: null,':hash'=>$hash,
+ ':tlp'=>$_POST['no_telp']??'' ?: null,':ri'=>$_POST['rumah_ibadah_id']??'' ?: null]);
+ $msg = "User $uname berhasil dibuat.";
+ logAktivitas($pdo, (int)$_SESSION['user_id'], 'CREATE', 'users', (string)$pdo->lastInsertId());
+ }
+ } catch(Exception $e) { $err = $e->getMessage(); }
+ }
+}
+
+$edit = null;
+if (isset($_GET['edit'])) {
+ $s = $pdo->prepare("SELECT * FROM users WHERE id=:id");
+ $s->execute([':id'=>(int)$_GET['edit']]);
+ $edit = $s->fetch() ?: null;
+}
+
+$rows = $pdo->query("
+ SELECT u.*, r.nama AS role_nama, ri.nama AS nama_ri
+ FROM users u
+ JOIN roles r ON r.id=u.role_id
+ LEFT JOIN rumah_ibadah ri ON ri.id=u.rumah_ibadah_id
+ ORDER BY u.role_id, u.nama
+")->fetchAll();
+
+$roles = $pdo->query("SELECT * FROM roles ORDER BY id")->fetchAll();
+$riList = $pdo->query("SELECT id,nama FROM rumah_ibadah ORDER BY nama")->fetchAll();
+
+render_header('Manajemen User');
+?>
+
π₯ Manajemen User
+
+
= h($msg) ?>
+
= h($err) ?>
+
+
+
+
+
+
+
+
π Reset Password
+
+
+ Password Baru *
+
+ π Reset
+
+
+
+
+
+
+
+ Nama Username Role Rumah Ibadah Status Aksi
+
+
+ >
+ = h($r['nama']) ?> = h($r['email']??'') ?>
+ = h($r['username']) ?>
+ = h($r['role_nama']) ?>
+ = h($r['nama_ri'] ?? '-') ?>
+ = $r['aktif'] ? 'Aktif ' : 'Nonaktif ' ?>
+
+ βοΈ
+
+
+ = $r['aktif'] ? 'π«' : 'β
' ?>
+
+
+
+
+
+
+
+
+
+
diff --git a/webgis-kemiskinan/index.html b/webgis-kemiskinan/index.html
new file mode 100644
index 0000000..f6f7313
--- /dev/null
+++ b/webgis-kemiskinan/index.html
@@ -0,0 +1,1849 @@
+
+
+
+
+
+
WebGIS Pengentasan Kemiskinan β Berbasis Masyarakat & Rumah Ibadah
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Siapapun bisa melaporkan kondisi tetangga yang membutuhkan bantuan. Identitas pelapor bersifat opsional.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/webgis-kemiskinan/php/api/auth.php b/webgis-kemiskinan/php/api/auth.php
new file mode 100644
index 0000000..1a5c719
--- /dev/null
+++ b/webgis-kemiskinan/php/api/auth.php
@@ -0,0 +1,54 @@
+ $result['role'],
+ 'user_id' => $_SESSION['user_id'],
+ 'nama' => $_SESSION['user_nama'],
+ ]);
+}
+
+if ($method === 'POST' && $action === 'logout') {
+ doLogout($pdo);
+ jsonResponse(true, 'Logout berhasil.');
+}
+
+if ($method === 'GET' && $action === 'me') {
+ $user = getApiUser($pdo);
+ if (!$user) jsonResponse(false, 'Belum login.', [], 401);
+ $detail = $pdo->prepare("SELECT u.id, u.nama, u.username, u.email, r.nama AS role, u.rumah_ibadah_id, ri.nama AS nama_ri FROM users u JOIN roles r ON r.id=u.role_id LEFT JOIN rumah_ibadah ri ON ri.id=u.rumah_ibadah_id WHERE u.id=:id");
+ $detail->execute([':id' => $user['id']]);
+ jsonResponse(true, 'OK', ['data' => $detail->fetch()]);
+}
+
+jsonResponse(false, 'Action tidak dikenal.', [], 400);
diff --git a/webgis-kemiskinan/php/api/bantuan.php b/webgis-kemiskinan/php/api/bantuan.php
new file mode 100644
index 0000000..edc00bd
--- /dev/null
+++ b/webgis-kemiskinan/php/api/bantuan.php
@@ -0,0 +1,239 @@
+query("SELECT * FROM jenis_bantuan WHERE aktif=1 ORDER BY kategori, nama")->fetchAll();
+ jsonResponse(true, 'OK', ['data' => $rows]);
+ }
+
+ if ($action === 'histori') {
+ $nik = sanitizeStr($_GET['nik'] ?? '');
+
+ if ($nik && validateNIK($nik)) {
+ // Histori per penduduk
+ $stmt = $pdo->prepare("
+ SELECT hb.*, jb.nama AS nama_bantuan, jb.kategori, jb.satuan,
+ ri.nama AS nama_rumah_ibadah, u.nama AS disalurkan_oleh_nama
+ FROM histori_bantuan hb
+ JOIN jenis_bantuan jb ON jb.id = hb.id_jenis_bantuan
+ LEFT JOIN rumah_ibadah ri ON ri.id = hb.rumah_ibadah_id
+ LEFT JOIN users u ON u.id = hb.disalurkan_oleh
+ WHERE hb.nik = :nik
+ ORDER BY hb.tanggal DESC
+ ");
+ $stmt->execute([':nik' => $nik]);
+ $data = $stmt->fetchAll();
+
+ // Ringkasan
+ $summary = [
+ 'total_bantuan' => count($data),
+ 'kategori' => [],
+ ];
+ foreach ($data as $row) {
+ $summary['kategori'][$row['kategori']] = ($summary['kategori'][$row['kategori']] ?? 0) + 1;
+ }
+
+ jsonResponse(true, 'OK', ['data' => $data, 'summary' => $summary]);
+ }
+
+ // List semua histori (admin/pengurus)
+ if (!canViewAll($currentUser)) jsonResponse(false, 'Akses ditolak.', [], 403);
+
+ $where = [];
+ $params = [];
+ if (!empty($_GET['bulan'])) { $where[] = 'MONTH(hb.tanggal) = :bln'; $params[':bln'] = (int)$_GET['bulan']; }
+ if (!empty($_GET['tahun'])) { $where[] = 'YEAR(hb.tanggal) = :thn'; $params[':thn'] = (int)$_GET['tahun']; }
+ if (!empty($_GET['kategori'])) { $where[] = 'jb.kategori = :kat'; $params[':kat'] = $_GET['kategori']; }
+
+ $whereStr = $where ? 'WHERE ' . implode(' AND ', $where) : '';
+
+ $stmt = $pdo->prepare("
+ SELECT hb.id, hb.nik, p.nama_lengkap, hb.tanggal,
+ jb.nama AS nama_bantuan, jb.kategori,
+ hb.jumlah, hb.nilai_rupiah, hb.status_penyaluran,
+ ri.nama AS nama_ri
+ FROM histori_bantuan hb
+ JOIN penduduk p ON p.nik = hb.nik
+ JOIN jenis_bantuan jb ON jb.id = hb.id_jenis_bantuan
+ LEFT JOIN rumah_ibadah ri ON ri.id = hb.rumah_ibadah_id
+ $whereStr
+ ORDER BY hb.tanggal DESC
+ LIMIT 1000
+ ");
+ $stmt->execute($params);
+ jsonResponse(true, 'OK', ['data' => $stmt->fetchAll()]);
+ }
+
+ if ($action === 'belum_dibantu') {
+ // Warga miskin yang BELUM pernah menerima bantuan
+ $rows = $pdo->query("
+ SELECT p.nik, p.nama_lengkap, p.status_ekonomi, p.kondisi_kesehatan,
+ p.pekerjaan, p.alamat, p.latitude, p.longitude,
+ k.nama_kk, k.no_kk, ri.nama AS nama_ri
+ FROM penduduk p
+ LEFT JOIN keluarga k ON k.id = p.id_keluarga
+ LEFT JOIN rumah_ibadah ri ON ri.id = k.rumah_ibadah_id
+ WHERE p.status_hidup = 'hidup'
+ AND p.status_ekonomi IN ('miskin', 'rentan')
+ AND NOT EXISTS (
+ SELECT 1 FROM histori_bantuan hb
+ WHERE hb.nik = p.nik AND hb.status_penyaluran = 'disalurkan'
+ )
+ ORDER BY
+ CASE p.status_ekonomi WHEN 'miskin' THEN 1 ELSE 2 END,
+ CASE p.kondisi_kesehatan WHEN 'sakit_parah' THEN 1 WHEN 'disabilitas' THEN 2 ELSE 3 END
+ ")->fetchAll();
+
+ jsonResponse(true, 'OK', ['data' => $rows, 'total' => count($rows)]);
+ }
+
+ if ($action === 'dashboard_stats') {
+ // Statistik untuk dashboard
+ $stats = $pdo->query("
+ SELECT
+ (SELECT COUNT(*) FROM penduduk WHERE status_ekonomi='miskin' AND status_hidup='hidup') AS total_miskin,
+ (SELECT COUNT(*) FROM penduduk WHERE status_ekonomi='rentan' AND status_hidup='hidup') AS total_rentan,
+ (SELECT COUNT(*) FROM penduduk WHERE status_ekonomi='mampu' AND status_hidup='hidup') AS total_mampu,
+ (SELECT COUNT(*) FROM keluarga) AS total_keluarga,
+ (SELECT COUNT(*) FROM rumah_ibadah) AS total_rumah_ibadah,
+ (SELECT COUNT(*) FROM laporan WHERE status='pending') AS laporan_pending,
+ (SELECT COUNT(*) FROM laporan WHERE status='diproses') AS laporan_diproses,
+ (SELECT COUNT(*) FROM histori_bantuan WHERE MONTH(tanggal)=MONTH(CURDATE()) AND status_penyaluran='disalurkan') AS bantuan_bulan_ini,
+ (SELECT COUNT(*) FROM penduduk WHERE status_hidup='hidup' AND status_ekonomi IN ('miskin','rentan')
+ AND NOT EXISTS (SELECT 1 FROM histori_bantuan hb WHERE hb.nik=penduduk.nik AND hb.status_penyaluran='disalurkan')
+ ) AS belum_pernah_dibantu
+ ")->fetch();
+
+ // Bantuan per kategori bulan ini
+ $per_kategori = $pdo->query("
+ SELECT jb.kategori, COUNT(*) AS jumlah
+ FROM histori_bantuan hb
+ JOIN jenis_bantuan jb ON jb.id = hb.id_jenis_bantuan
+ WHERE MONTH(hb.tanggal) = MONTH(CURDATE())
+ AND hb.status_penyaluran = 'disalurkan'
+ GROUP BY jb.kategori
+ ")->fetchAll();
+
+ jsonResponse(true, 'OK', ['data' => $stats, 'per_kategori' => $per_kategori]);
+ }
+
+ jsonResponse(false, 'Action tidak dikenal.', [], 400);
+}
+
+// ============================================================
+// POST: salurkan bantuan
+// ============================================================
+if ($method === 'POST' && $action === 'salurkan') {
+ if (!canEdit($currentUser)) jsonResponse(false, 'Akses ditolak.', [], 403);
+
+ $body = json_decode(file_get_contents('php://input'), true) ?? $_POST;
+
+ $nik = sanitizeStr($body['nik'] ?? '');
+ $idJenis = (int)($body['id_jenis_bantuan'] ?? 0);
+ $tanggal = sanitizeStr($body['tanggal'] ?? date('Y-m-d'));
+ $jumlah = sanitizeStr($body['jumlah'] ?? '');
+ $status = sanitizeStr($body['status_penyaluran'] ?? 'dijadwalkan');
+
+ if (!validateNIK($nik)) jsonResponse(false, 'NIK tidak valid.', [], 400);
+ if ($idJenis <= 0) jsonResponse(false, 'Jenis bantuan harus dipilih.', [], 400);
+ if (!strtotime($tanggal)) jsonResponse(false, 'Format tanggal tidak valid.', [], 400);
+
+ // Cek penduduk ada
+ $cek = $pdo->prepare("SELECT nik, id_keluarga FROM penduduk WHERE nik = :nik AND status_hidup='hidup'");
+ $cek->execute([':nik' => $nik]);
+ $pddk = $cek->fetch();
+ if (!$pddk) jsonResponse(false, 'Penduduk tidak ditemukan atau sudah meninggal.', [], 404);
+
+ // Cek jenis bantuan ada
+ $cekJb = $pdo->prepare("SELECT id FROM jenis_bantuan WHERE id = :id AND aktif=1");
+ $cekJb->execute([':id' => $idJenis]);
+ if (!$cekJb->fetch()) jsonResponse(false, 'Jenis bantuan tidak ditemukan.', [], 404);
+
+ $buktiNama = null;
+ if (!empty($_FILES['bukti']['name'])) {
+ try { $buktiNama = uploadFile($_FILES['bukti'], UPLOAD_BUKTI); }
+ catch (RuntimeException $e) { jsonResponse(false, $e->getMessage(), [], 400); }
+ }
+
+ $stmt = $pdo->prepare("
+ INSERT INTO histori_bantuan
+ (nik, id_jenis_bantuan, id_keluarga, rumah_ibadah_id, tanggal,
+ jumlah, nilai_rupiah, status_penyaluran, catatan, bukti_file, disalurkan_oleh)
+ VALUES
+ (:nik,:jb,:kk,:ri,:tgl,:jml,:rp,:status,:cat,:bukti,:user)
+ ");
+ $stmt->execute([
+ ':nik' => $nik,
+ ':jb' => $idJenis,
+ ':kk' => $pddk['id_keluarga'],
+ ':ri' => $currentUser['rumah_ibadah_id'],
+ ':tgl' => $tanggal,
+ ':jml' => $jumlah ?: null,
+ ':rp' => !empty($body['nilai_rupiah']) ? (float)$body['nilai_rupiah'] : null,
+ ':status' => in_array($status, ['dijadwalkan','disalurkan','dibatalkan']) ? $status : 'dijadwalkan',
+ ':cat' => sanitizeStr($body['catatan'] ?? '') ?: null,
+ ':bukti' => $buktiNama,
+ ':user' => $currentUser['id'],
+ ]);
+
+ $hbId = (int)$pdo->lastInsertId();
+ logAktivitas($pdo, $currentUser['id'], 'CREATE', 'histori_bantuan', (string)$hbId, null, $body);
+
+ // Notifikasi ke pimpinan
+ $pimpinan = $pdo->query("SELECT id FROM users WHERE role_id=3")->fetchAll();
+ foreach ($pimpinan as $pm) {
+ $pdo->prepare("INSERT INTO notifikasi (user_id, judul, isi, tipe, ref_id) VALUES (:u,:j,:i,'bantuan',:r)")
+ ->execute([':u' => $pm['id'], ':j' => 'Bantuan Disalurkan', ':i' => "Bantuan untuk NIK $nik telah dicatat.", ':r' => $hbId]);
+ }
+
+ jsonResponse(true, 'Bantuan berhasil dicatat.', ['id' => $hbId], 201);
+}
+
+// ============================================================
+// PUT: update status penyaluran
+// ============================================================
+if ($method === 'PUT') {
+ if (!canEdit($currentUser)) jsonResponse(false, 'Akses ditolak.', [], 403);
+
+ $id = (int)($_GET['id'] ?? 0);
+ $body = json_decode(file_get_contents('php://input'), true) ?? [];
+ $status = sanitizeStr($body['status_penyaluran'] ?? '');
+
+ if (!in_array($status, ['dijadwalkan','disalurkan','dibatalkan'])) {
+ jsonResponse(false, 'Status tidak valid.', [], 400);
+ }
+
+ $old = $pdo->prepare("SELECT * FROM histori_bantuan WHERE id = :id");
+ $old->execute([':id' => $id]);
+ if (!$old->fetch()) jsonResponse(false, 'Data tidak ditemukan.', [], 404);
+
+ $pdo->prepare("UPDATE histori_bantuan SET status_penyaluran=:s WHERE id=:id")
+ ->execute([':s' => $status, ':id' => $id]);
+
+ logAktivitas($pdo, $currentUser['id'], 'UPDATE', 'histori_bantuan', (string)$id);
+ jsonResponse(true, 'Status penyaluran diperbarui.', ['id' => $id]);
+}
+
+jsonResponse(false, 'Method tidak didukung.', [], 405);
diff --git a/webgis-kemiskinan/php/api/keluarga.php b/webgis-kemiskinan/php/api/keluarga.php
new file mode 100644
index 0000000..d77038c
--- /dev/null
+++ b/webgis-kemiskinan/php/api/keluarga.php
@@ -0,0 +1,183 @@
+prepare("
+ SELECT k.*,
+ ri.nama AS nama_ri, ri.jenis AS jenis_ri,
+ COUNT(p.nik) AS jumlah_anggota_db,
+ SUM(CASE WHEN p.kondisi_kesehatan IN ('sakit_parah','disabilitas') THEN 1 ELSE 0 END) AS anggota_sakit
+ FROM keluarga k
+ LEFT JOIN rumah_ibadah ri ON ri.id = k.rumah_ibadah_id
+ LEFT JOIN penduduk p ON p.id_keluarga = k.id AND p.status_hidup='hidup'
+ $whereStr
+ GROUP BY k.id
+ ORDER BY CASE k.status_ekonomi WHEN 'miskin' THEN 1 WHEN 'rentan' THEN 2 ELSE 3 END ASC
+ ");
+ $rows->execute($params);
+ jsonResponse(true, 'OK', ['data' => $rows->fetchAll()]);
+ }
+
+ if ($action === 'detail') {
+ $id = (int)($_GET['id'] ?? 0);
+ $stmt = $pdo->prepare("SELECT k.*, ri.nama AS nama_ri FROM keluarga k LEFT JOIN rumah_ibadah ri ON ri.id=k.rumah_ibadah_id WHERE k.id=:id");
+ $stmt->execute([':id' => $id]);
+ $kk = $stmt->fetch();
+ if (!$kk) jsonResponse(false, 'Keluarga tidak ditemukan.', [], 404);
+
+ $anggota = $pdo->prepare("SELECT * FROM penduduk WHERE id_keluarga=:id ORDER BY CASE status_keluarga WHEN 'kepala_keluarga' THEN 1 ELSE 2 END, tanggal_lahir");
+ $anggota->execute([':id' => $id]);
+
+ $histori = $pdo->prepare("
+ SELECT hb.tanggal, jb.nama AS bantuan, hb.jumlah, hb.status_penyaluran, p.nama_lengkap
+ FROM histori_bantuan hb
+ JOIN penduduk p ON p.nik=hb.nik
+ JOIN jenis_bantuan jb ON jb.id=hb.id_jenis_bantuan
+ WHERE hb.id_keluarga=:id
+ ORDER BY hb.tanggal DESC
+ LIMIT 50
+ ");
+ $histori->execute([':id' => $id]);
+
+ jsonResponse(true, 'OK', ['data' => $kk, 'anggota' => $anggota->fetchAll(), 'histori' => $histori->fetchAll()]);
+ }
+
+ if ($action === 'geojson') {
+ $stmt = $pdo->query("
+ SELECT k.id, k.no_kk, k.nama_kk, k.status_ekonomi, k.latitude, k.longitude,
+ COUNT(p.nik) AS jml_anggota, ri.nama AS nama_ri
+ FROM keluarga k
+ LEFT JOIN penduduk p ON p.id_keluarga=k.id AND p.status_hidup='hidup'
+ LEFT JOIN rumah_ibadah ri ON ri.id=k.rumah_ibadah_id
+ WHERE k.latitude IS NOT NULL AND k.longitude IS NOT NULL
+ GROUP BY k.id
+ ");
+ $rows = $stmt->fetchAll();
+ $features = [];
+ foreach ($rows as $r) {
+ $features[] = [
+ 'type' => 'Feature',
+ 'geometry' => ['type'=>'Point','coordinates'=>[(float)$r['longitude'],(float)$r['latitude']]],
+ 'properties' => array_merge($r, [
+ 'color' => match($r['status_ekonomi']){'miskin'=>'#ef4444','rentan'=>'#f59e0b',default=>'#22c55e'},
+ 'layer' => 'keluarga'
+ ])
+ ];
+ }
+ header('Content-Type: application/json');
+ echo json_encode(['type'=>'FeatureCollection','features'=>$features], JSON_UNESCAPED_UNICODE);
+ exit;
+ }
+
+ jsonResponse(false, 'Action tidak dikenal.', [], 400);
+}
+
+if ($method === 'POST') {
+ if (!canEdit($currentUser)) jsonResponse(false, 'Akses ditolak.', [], 403);
+ $body = json_decode(file_get_contents('php://input'), true) ?? $_POST;
+
+ $noKk = sanitizeStr($body['no_kk'] ?? '');
+ $namaKk = sanitizeStr($body['nama_kk'] ?? '');
+ if (strlen($noKk) !== 16) jsonResponse(false, 'No KK harus 16 digit.', [], 400);
+ if (strlen($namaKk) < 3) jsonResponse(false, 'Nama KK terlalu pendek.', [], 400);
+
+ $cek = $pdo->prepare("SELECT id FROM keluarga WHERE no_kk=:nkk");
+ $cek->execute([':nkk' => $noKk]);
+ if ($cek->fetch()) jsonResponse(false, 'No KK sudah terdaftar.', [], 409);
+
+ $stmt = $pdo->prepare("
+ INSERT INTO keluarga (no_kk, nama_kk, alamat, kelurahan, kecamatan, kota, latitude, longitude, status_ekonomi, rumah_ibadah_id)
+ VALUES (:nkk,:nkk_nama,:almt,:kel,:kec,:kota,:lat,:lng,:se,:ri)
+ ");
+ $stmt->execute([
+ ':nkk' => $noKk,
+ ':nkk_nama' => $namaKk,
+ ':almt' => sanitizeStr($body['alamat'] ?? '') ?: null,
+ ':kel' => sanitizeStr($body['kelurahan'] ?? '') ?: null,
+ ':kec' => sanitizeStr($body['kecamatan'] ?? '') ?: null,
+ ':kota' => sanitizeStr($body['kota'] ?? '') ?: null,
+ ':lat' => !empty($body['latitude']) ? (float)$body['latitude'] : null,
+ ':lng' => !empty($body['longitude']) ? (float)$body['longitude'] : null,
+ ':se' => sanitizeStr($body['status_ekonomi'] ?? 'rentan'),
+ ':ri' => !empty($body['rumah_ibadah_id']) ? (int)$body['rumah_ibadah_id'] : null,
+ ]);
+ $newId = (int)$pdo->lastInsertId();
+ logAktivitas($pdo, $currentUser['id']??null, 'CREATE', 'keluarga', (string)$newId, null, $body);
+ jsonResponse(true, 'Keluarga berhasil ditambahkan.', ['id' => $newId], 201);
+}
+
+if ($method === 'PUT') {
+ if (!canEdit($currentUser)) jsonResponse(false, 'Akses ditolak.', [], 403);
+ $id = (int)($_GET['id'] ?? 0);
+ $body = json_decode(file_get_contents('php://input'), true) ?? [];
+ if ($id <= 0) jsonResponse(false, 'ID tidak valid.', [], 400);
+
+ if ($action === 'update_koordinat') {
+ $lat = (float)($body['latitude'] ?? 0);
+ $lng = (float)($body['longitude'] ?? 0);
+ if (!$lat || !$lng) jsonResponse(false, 'Koordinat tidak valid.', [], 400);
+
+ $pdo->prepare("UPDATE keluarga SET latitude=:lat, longitude=:lng WHERE id=:id")
+ ->execute([':lat'=>$lat, ':lng'=>$lng, ':id'=>$id]);
+ logAktivitas($pdo, $currentUser['id']??null, 'UPDATE', 'keluarga', (string)$id, null, ['lat'=>$lat,'lng'=>$lng]);
+ jsonResponse(true, 'Koordinat diperbarui.', ['id' => $id]);
+ }
+
+ $pdo->prepare("
+ UPDATE keluarga SET
+ nama_kk=:nkk, alamat=:almt, kelurahan=:kel, kecamatan=:kec,
+ kota=:kota, latitude=:lat, longitude=:lng,
+ status_ekonomi=:se, rumah_ibadah_id=:ri
+ WHERE id=:id
+ ")->execute([
+ ':nkk' => sanitizeStr($body['nama_kk'] ?? ''),
+ ':almt' => sanitizeStr($body['alamat'] ?? '') ?: null,
+ ':kel' => sanitizeStr($body['kelurahan'] ?? '') ?: null,
+ ':kec' => sanitizeStr($body['kecamatan'] ?? '') ?: null,
+ ':kota' => sanitizeStr($body['kota'] ?? '') ?: null,
+ ':lat' => !empty($body['latitude']) ? (float)$body['latitude'] : null,
+ ':lng' => !empty($body['longitude']) ? (float)$body['longitude'] : null,
+ ':se' => sanitizeStr($body['status_ekonomi'] ?? 'rentan'),
+ ':ri' => !empty($body['rumah_ibadah_id']) ? (int)$body['rumah_ibadah_id'] : null,
+ ':id' => $id,
+ ]);
+ logAktivitas($pdo, $currentUser['id']??null, 'UPDATE', 'keluarga', (string)$id);
+ jsonResponse(true, 'Keluarga diperbarui.', ['id' => $id]);
+}
+
+if ($method === 'DELETE') {
+ if (!isAdmin($currentUser)) jsonResponse(false, 'Hanya admin yang bisa menghapus keluarga.', [], 403);
+ $id = (int)($_GET['id'] ?? 0);
+ // Cek apakah masih ada anggota
+ $jml = (int)$pdo->prepare("SELECT COUNT(*) FROM penduduk WHERE id_keluarga=:id")->execute([':id'=>$id]);
+ if ($jml > 0) jsonResponse(false, 'Tidak bisa dihapus: masih ada anggota keluarga.', [], 409);
+ $pdo->prepare("DELETE FROM keluarga WHERE id=:id")->execute([':id' => $id]);
+ logAktivitas($pdo, $currentUser['id']??null, 'DELETE', 'keluarga', (string)$id);
+ jsonResponse(true, 'Keluarga dihapus.');
+}
+
+jsonResponse(false, 'Method tidak didukung.', [], 405);
diff --git a/webgis-kemiskinan/php/api/laporan.php b/webgis-kemiskinan/php/api/laporan.php
new file mode 100644
index 0000000..a9092a0
--- /dev/null
+++ b/webgis-kemiskinan/php/api/laporan.php
@@ -0,0 +1,255 @@
+prepare("
+ SELECT l.*,
+ p.nama_lengkap AS nama_terdampak,
+ u.nama AS nama_verifikator
+ FROM laporan l
+ LEFT JOIN penduduk p ON p.nik = l.nik_terdampak
+ LEFT JOIN users u ON u.id = l.diverifikasi_oleh
+ $whereStr
+ ORDER BY
+ CASE l.tingkat_urgensi WHEN 'darurat' THEN 1 WHEN 'tinggi' THEN 2 WHEN 'sedang' THEN 3 ELSE 4 END ASC,
+ CASE l.status WHEN 'pending' THEN 1 WHEN 'diverifikasi' THEN 2 ELSE 3 END ASC,
+ l.created_at DESC
+ LIMIT 500
+ ");
+ $rows->execute($params);
+ jsonResponse(true, 'OK', ['data' => $rows->fetchAll(), 'total' => $rows->rowCount()]);
+ }
+
+ if ($action === 'detail') {
+ $id = (int)($_GET['id'] ?? 0);
+ $stmt = $pdo->prepare("
+ SELECT l.*, p.nama_lengkap AS nama_terdampak, p.status_ekonomi,
+ p.kondisi_kesehatan, p.alamat AS alamat_penduduk
+ FROM laporan l
+ LEFT JOIN penduduk p ON p.nik = l.nik_terdampak
+ WHERE l.id = :id
+ ");
+ $stmt->execute([':id' => $id]);
+ $data = $stmt->fetch();
+ if (!$data) jsonResponse(false, 'Laporan tidak ditemukan.', [], 404);
+ jsonResponse(true, 'OK', ['data' => $data]);
+ }
+
+ if ($action === 'geojson') {
+ // Untuk layer marker laporan di peta
+ $stmt = $pdo->query("
+ SELECT id, nama_pelapor, pelapor_id, kategori, tingkat_urgensi, status,
+ latitude, longitude, alamat, created_at
+ FROM laporan
+ WHERE latitude IS NOT NULL AND longitude IS NOT NULL
+ AND status NOT IN ('selesai','ditolak')
+ ");
+ $rows = $stmt->fetchAll();
+
+ $features = [];
+ foreach ($rows as $r) {
+ $color = match($r['tingkat_urgensi']) {
+ 'darurat' => '#dc2626',
+ 'tinggi' => '#ea580c',
+ 'sedang' => '#ca8a04',
+ default => '#2563eb',
+ };
+ $features[] = [
+ 'type' => 'Feature',
+ 'geometry' => ['type' => 'Point', 'coordinates' => [(float)$r['longitude'], (float)$r['latitude']]],
+ 'properties' => array_merge($r, ['color' => $color, 'layer' => 'laporan'])
+ ];
+ }
+ header('Content-Type: application/json');
+ echo json_encode(['type' => 'FeatureCollection', 'features' => $features], JSON_UNESCAPED_UNICODE);
+ exit;
+ }
+
+ if ($action === 'stats') {
+ $stats = $pdo->query("
+ SELECT
+ SUM(status='pending') AS pending,
+ SUM(status='diverifikasi') AS diverifikasi,
+ SUM(status='diproses') AS diproses,
+ SUM(status='selesai') AS selesai,
+ SUM(tingkat_urgensi='darurat') AS darurat,
+ COUNT(*) AS total
+ FROM laporan
+ ")->fetch();
+ jsonResponse(true, 'OK', ['data' => $stats]);
+ }
+
+ jsonResponse(false, 'Action tidak dikenal.', [], 400);
+}
+
+// ============================================================
+// POST: buat laporan baru (bisa dilakukan masyarakat umum)
+// ============================================================
+if ($method === 'POST') {
+ $deskripsi = sanitizeStr($_POST['deskripsi'] ?? '', 2000);
+ $kategori = sanitizeStr($_POST['kategori'] ?? 'butuh_bantuan_lain');
+ $urgensi = sanitizeStr($_POST['tingkat_urgensi'] ?? 'sedang');
+ $lat = !empty($_POST['latitude']) ? (float)$_POST['latitude'] : null;
+ $lng = !empty($_POST['longitude']) ? (float)$_POST['longitude'] : null;
+
+ if (strlen($deskripsi) < 10) jsonResponse(false, 'Deskripsi laporan terlalu singkat.', [], 400);
+
+ $fotoNama = null;
+ if (!empty($_FILES['foto']['name'])) {
+ try {
+ $fotoNama = uploadFile($_FILES['foto'], UPLOAD_LAPORAN, ALLOWED_IMG_EXT);
+ } catch (RuntimeException $e) {
+ jsonResponse(false, $e->getMessage(), [], 400);
+ }
+ }
+
+ $stmt = $pdo->prepare("
+ INSERT INTO laporan
+ (nama_pelapor, pelapor_id, nik_terdampak, deskripsi, kategori, tingkat_urgensi,
+ latitude, longitude, alamat, foto, status)
+ VALUES
+ (:np, :pid, :nik, :dsk, :kat, :urg, :lat, :lng, :almt, :foto, 'pending')
+ ");
+ $stmt->execute([
+ ':np' => sanitizeStr($_POST['nama_pelapor'] ?? '') ?: null,
+ ':pid' => $currentUser['id'] ?? null,
+ ':nik' => sanitizeStr($_POST['nik_terdampak'] ?? '') ?: null,
+ ':dsk' => $deskripsi,
+ ':kat' => $kategori,
+ ':urg' => $urgensi,
+ ':lat' => $lat,
+ ':lng' => $lng,
+ ':almt' => sanitizeStr($_POST['alamat'] ?? '') ?: null,
+ ':foto' => $fotoNama,
+ ]);
+
+ $laporanId = (int)$pdo->lastInsertId();
+
+ // Kirim notifikasi ke pengurus jika darurat
+ if (in_array($urgensi, ['darurat', 'tinggi'])) {
+ $pengurus = $pdo->query("SELECT id FROM users WHERE role_id = 2")->fetchAll();
+ foreach ($pengurus as $p) {
+ $pdo->prepare("
+ INSERT INTO notifikasi (user_id, judul, isi, tipe, ref_id)
+ VALUES (:uid, :judul, :isi, 'laporan', :ref)
+ ")->execute([
+ ':uid' => $p['id'],
+ ':judul' => 'π¨ Laporan ' . strtoupper($urgensi) . ' masuk!',
+ ':isi' => "Ada laporan baru kategori '$kategori'. Segera ditangani.",
+ ':ref' => $laporanId,
+ ]);
+ }
+ }
+
+ logAktivitas($pdo, $currentUser['id'] ?? null, 'LAPORAN', 'laporan', (string)$laporanId);
+
+ jsonResponse(true, 'Laporan berhasil dikirim. Terima kasih!', ['id' => $laporanId], 201);
+}
+
+// ============================================================
+// PUT: Update status laporan (hanya pengurus/admin)
+// ============================================================
+if ($method === 'PUT') {
+ if (!canVerify($currentUser)) jsonResponse(false, 'Akses ditolak.', [], 403);
+
+ $id = (int)($_GET['id'] ?? 0);
+ if ($id <= 0) jsonResponse(false, 'ID tidak valid.', [], 400);
+
+ $body = json_decode(file_get_contents('php://input'), true) ?? [];
+ $status = sanitizeStr($body['status'] ?? '');
+ $valid = ['diverifikasi', 'diproses', 'selesai', 'ditolak'];
+
+ if (!in_array($status, $valid)) jsonResponse(false, 'Status tidak valid.', [], 400);
+
+ $old = $pdo->prepare("SELECT * FROM laporan WHERE id = :id");
+ $old->execute([':id' => $id]);
+ $dataLama = $old->fetch();
+ if (!$dataLama) jsonResponse(false, 'Laporan tidak ditemukan.', [], 404);
+
+ $sets = ['status = :status', 'updated_at = NOW()'];
+ $params = [':id' => $id, ':status' => $status];
+
+ if ($status === 'diverifikasi' && $currentUser) {
+ $sets[] = 'diverifikasi_oleh = :verif';
+ $params[':verif'] = $currentUser['id'];
+ }
+ if ($status === 'diproses' && $currentUser) {
+ $sets[] = 'ditangani_oleh = :tangani';
+ $params[':tangani'] = $currentUser['id'];
+ }
+ if ($status === 'selesai') {
+ $sets[] = 'tanggal_selesai = NOW()';
+ }
+ if (!empty($body['catatan_verifikasi'])) {
+ $sets[] = 'catatan_verifikasi = :catatan';
+ $params[':catatan'] = sanitizeStr($body['catatan_verifikasi'], 1000);
+ }
+
+ $pdo->prepare("UPDATE laporan SET " . implode(', ', $sets) . " WHERE id = :id")->execute($params);
+ logAktivitas($pdo, $currentUser['id'], 'UPDATE', 'laporan', (string)$id, $dataLama, $body);
+
+ jsonResponse(true, "Status laporan diperbarui menjadi '$status'.", ['id' => $id]);
+}
+
+// ============================================================
+// PUT: Update koordinat (via drag map)
+// ============================================================
+if ($method === 'PUT' && $action === 'update_koordinat') {
+ if (!canVerify($currentUser)) jsonResponse(false, 'Akses ditolak.', [], 403);
+
+ $id = (int)($_GET['id'] ?? 0);
+ $body = json_decode(file_get_contents('php://input'), true);
+
+ $lat = isset($body['latitude']) ? (float)$body['latitude'] : null;
+ $lng = isset($body['longitude']) ? (float)$body['longitude'] : null;
+
+ if (!$lat || !$lng) jsonResponse(false, 'Latitude dan Longitude wajib diisi.', [], 400);
+
+ $pdo->prepare("UPDATE laporan SET latitude = :lat, longitude = :lng, updated_at = NOW() WHERE id = :id")->execute([
+ ':lat' => $lat,
+ ':lng' => $lng,
+ ':id' => $id
+ ]);
+
+ logAktivitas($pdo, $currentUser['id'], 'UPDATE_KOORDINAT', 'laporan', (string)$id);
+ jsonResponse(true, 'Koordinat laporan berhasil diperbarui.', ['id' => $id]);
+}
+
+jsonResponse(false, 'Method tidak didukung.', [], 405);
diff --git a/webgis-kemiskinan/php/api/penduduk.php b/webgis-kemiskinan/php/api/penduduk.php
new file mode 100644
index 0000000..18bdf90
--- /dev/null
+++ b/webgis-kemiskinan/php/api/penduduk.php
@@ -0,0 +1,328 @@
+prepare($sql);
+ $stmt->execute($params);
+ $rows = $stmt->fetchAll();
+
+ // Hitung prioritas untuk setiap penduduk
+ foreach ($rows as &$r) {
+ $belumDibantu = (int)$r['jumlah_bantuan_diterima'] === 0;
+ $r['prioritas_bantuan'] = hitungPrioritasBantuan($r['status_ekonomi'], $r['kondisi_kesehatan'], $belumDibantu);
+ }
+ unset($r);
+
+ jsonResponse(true, 'OK', ['data' => $rows, 'total' => count($rows)]);
+ }
+
+ if ($action === 'detail') {
+ $nik = sanitizeStr($_GET['nik'] ?? '');
+ if (!validateNIK($nik)) jsonResponse(false, 'NIK tidak valid.', [], 400);
+
+ $stmt = $pdo->prepare("
+ SELECT p.*, k.no_kk, k.nama_kk, ri.nama AS nama_ri
+ FROM penduduk p
+ LEFT JOIN keluarga k ON k.id = p.id_keluarga
+ LEFT JOIN rumah_ibadah ri ON ri.id = k.rumah_ibadah_id
+ WHERE p.nik = :nik
+ ");
+ $stmt->execute([':nik' => $nik]);
+ $penduduk = $stmt->fetch();
+ if (!$penduduk) jsonResponse(false, 'Data tidak ditemukan.', [], 404);
+
+ // Histori bantuan
+ $stmtHB = $pdo->prepare("
+ SELECT hb.*, jb.nama AS nama_bantuan, jb.kategori, ri.nama AS nama_ri
+ FROM histori_bantuan hb
+ JOIN jenis_bantuan jb ON jb.id = hb.id_jenis_bantuan
+ LEFT JOIN rumah_ibadah ri ON ri.id = hb.rumah_ibadah_id
+ WHERE hb.nik = :nik
+ ORDER BY hb.tanggal DESC
+ ");
+ $stmtHB->execute([':nik' => $nik]);
+ $histori = $stmtHB->fetchAll();
+
+ // Pelatihan
+ $stmtPl = $pdo->prepare("SELECT * FROM riwayat_pelatihan WHERE nik = :nik ORDER BY tanggal_mulai DESC");
+ $stmtPl->execute([':nik' => $nik]);
+ $pelatihan = $stmtPl->fetchAll();
+
+ // Validasi pendidikan
+ $validasi = validasiPendidikanUmur(
+ (int)$penduduk['umur'],
+ $penduduk['pendidikan_terakhir'],
+ $penduduk['status_pendidikan']
+ );
+
+ $belumDibantu = empty($histori);
+ $penduduk['prioritas_bantuan'] = hitungPrioritasBantuan($penduduk['status_ekonomi'], $penduduk['kondisi_kesehatan'], $belumDibantu);
+
+ jsonResponse(true, 'OK', [
+ 'data' => $penduduk,
+ 'histori' => $histori,
+ 'pelatihan' => $pelatihan,
+ 'validasi' => $validasi,
+ ]);
+ }
+
+ if ($action === 'geojson') {
+ // Untuk layer Leaflet
+ $stmt = $pdo->query("
+ SELECT p.nik, p.nama_lengkap, p.status_ekonomi, p.kondisi_kesehatan,
+ p.latitude, p.longitude,
+ k.nama_kk,
+ (SELECT COUNT(*) FROM histori_bantuan hb WHERE hb.nik=p.nik AND hb.status_penyaluran='disalurkan') AS jml_bantuan
+ FROM penduduk p
+ LEFT JOIN keluarga k ON k.id = p.id_keluarga
+ WHERE p.latitude IS NOT NULL AND p.longitude IS NOT NULL
+ AND p.status_hidup = 'hidup'
+ ");
+ $rows = $stmt->fetchAll();
+
+ $features = [];
+ foreach ($rows as $r) {
+ $color = match($r['status_ekonomi']) {
+ 'miskin' => '#ef4444',
+ 'rentan' => '#f59e0b',
+ default => '#22c55e'
+ };
+ $features[] = [
+ 'type' => 'Feature',
+ 'geometry' => [
+ 'type' => 'Point',
+ 'coordinates' => [(float)$r['longitude'], (float)$r['latitude']]
+ ],
+ 'properties' => array_merge($r, ['color' => $color])
+ ];
+ }
+
+ header('Content-Type: application/json');
+ echo json_encode(['type' => 'FeatureCollection', 'features' => $features], JSON_UNESCAPED_UNICODE);
+ exit;
+ }
+
+ jsonResponse(false, 'Action tidak dikenal.', [], 400);
+}
+
+// ============================================================
+// POST - Create
+// ============================================================
+if ($method === 'POST') {
+ if (!canEdit($currentUser)) jsonResponse(false, 'Akses ditolak.', [], 403);
+
+ $body = json_decode(file_get_contents('php://input'), true) ?? $_POST;
+
+ $nik = sanitizeStr($body['nik'] ?? '');
+ $nama = sanitizeStr($body['nama_lengkap'] ?? '');
+ $jk = sanitizeStr($body['jenis_kelamin'] ?? '');
+ $tgl = sanitizeStr($body['tanggal_lahir'] ?? '');
+ $idKk = !empty($body['id_keluarga']) ? (int)$body['id_keluarga'] : null;
+
+ if (!validateNIK($nik)) jsonResponse(false, 'Format NIK harus 16 digit angka.', [], 400);
+ if (strlen($nama) < 3) jsonResponse(false, 'Nama terlalu pendek.', [], 400);
+ if (!in_array($jk, ['L','P'])) jsonResponse(false, 'Jenis kelamin tidak valid.', [], 400);
+ if (!strtotime($tgl)) jsonResponse(false, 'Format tanggal lahir tidak valid.', [], 400);
+
+ // Cek duplikat NIK
+ $cek = $pdo->prepare("SELECT nik FROM penduduk WHERE nik = :nik");
+ $cek->execute([':nik' => $nik]);
+ if ($cek->fetch()) jsonResponse(false, 'NIK sudah terdaftar.', [], 409);
+
+ // Hitung umur untuk validasi pendidikan
+ $umur = (int)(new DateTime())->diff(new DateTime($tgl))->y;
+ $statusPendidikan = sanitizeStr($body['status_pendidikan'] ?? 'tidak_sekolah');
+ $pendidikan = sanitizeStr($body['pendidikan_terakhir'] ?? 'tidak_sekolah');
+ $validasi = validasiPendidikanUmur($umur, $pendidikan, $statusPendidikan);
+
+ $lat = !empty($body['latitude']) ? (float)$body['latitude'] : null;
+ $lng = !empty($body['longitude']) ? (float)$body['longitude'] : null;
+ if ($lat !== null && !validateCoord((string)$lat, 'lat')) jsonResponse(false, 'Latitude tidak valid.', [], 400);
+ if ($lng !== null && !validateCoord((string)$lng, 'lng')) jsonResponse(false, 'Longitude tidak valid.', [], 400);
+
+ $stmt = $pdo->prepare("
+ INSERT INTO penduduk
+ (nik, id_keluarga, nama_lengkap, jenis_kelamin, tanggal_lahir,
+ status_keluarga, status_perkawinan, status_hidup,
+ pendidikan_terakhir, status_pendidikan,
+ pekerjaan, penghasilan, status_ekonomi,
+ kondisi_kesehatan, catatan_kesehatan,
+ no_telp, alamat, latitude, longitude)
+ VALUES
+ (:nik,:kk,:nama,:jk,:tgl,
+ :sk,:sp,:sh,
+ :ptk,:spd,
+ :pkr,:pgn,:se,
+ :kkh,:ckkh,
+ :tlp,:almt,:lat,:lng)
+ ");
+ $stmt->execute([
+ ':nik' => $nik,
+ ':kk' => $idKk,
+ ':nama' => $nama,
+ ':jk' => $jk,
+ ':tgl' => $tgl,
+ ':sk' => sanitizeStr($body['status_keluarga'] ?? 'anggota_lain'),
+ ':sp' => sanitizeStr($body['status_perkawinan'] ?? 'belum_kawin'),
+ ':sh' => sanitizeStr($body['status_hidup'] ?? 'hidup'),
+ ':ptk' => $pendidikan,
+ ':spd' => $statusPendidikan,
+ ':pkr' => sanitizeStr($body['pekerjaan'] ?? '') ?: null,
+ ':pgn' => !empty($body['penghasilan']) ? (float)$body['penghasilan'] : 0,
+ ':se' => sanitizeStr($body['status_ekonomi'] ?? 'rentan'),
+ ':kkh' => sanitizeStr($body['kondisi_kesehatan'] ?? 'sehat'),
+ ':ckkh' => sanitizeStr($body['catatan_kesehatan'] ?? '') ?: null,
+ ':tlp' => sanitizeStr($body['no_telp'] ?? '') ?: null,
+ ':almt' => sanitizeStr($body['alamat'] ?? '') ?: null,
+ ':lat' => $lat,
+ ':lng' => $lng,
+ ]);
+
+ logAktivitas($pdo, $currentUser['id'] ?? null, 'CREATE', 'penduduk', $nik, null, $body);
+
+ jsonResponse(true, 'Penduduk berhasil ditambahkan.', [
+ 'nik' => $nik,
+ 'validasi' => $validasi
+ ], 201);
+}
+
+// ============================================================
+// PUT - Update
+// ============================================================
+if ($method === 'PUT') {
+ if (!canEdit($currentUser)) jsonResponse(false, 'Akses ditolak.', [], 403);
+
+ $nik = sanitizeStr($_GET['nik'] ?? '');
+ if (!validateNIK($nik)) jsonResponse(false, 'NIK tidak valid.', [], 400);
+
+ $body = json_decode(file_get_contents('php://input'), true) ?? [];
+
+ // Ambil data lama
+ $old = $pdo->prepare("SELECT * FROM penduduk WHERE nik = :nik");
+ $old->execute([':nik' => $nik]);
+ $dataLama = $old->fetch();
+ if (!$dataLama) jsonResponse(false, 'Data tidak ditemukan.', [], 404);
+
+ $sets = [];
+ $params = [':nik' => $nik];
+
+ $fieldMap = [
+ 'id_keluarga' => ':kk', 'nama_lengkap' => ':nama', 'jenis_kelamin' => ':jk',
+ 'tanggal_lahir' => ':tgl', 'status_keluarga' => ':sk', 'status_perkawinan' => ':sp',
+ 'status_hidup' => ':sh', 'pendidikan_terakhir' => ':ptk', 'status_pendidikan' => ':spd',
+ 'pekerjaan' => ':pkr', 'penghasilan' => ':pgn', 'status_ekonomi' => ':se',
+ 'kondisi_kesehatan' => ':kkh', 'catatan_kesehatan' => ':ckkh',
+ 'no_telp' => ':tlp', 'alamat' => ':almt', 'latitude' => ':lat', 'longitude' => ':lng',
+ ];
+
+ foreach ($fieldMap as $field => $param) {
+ if (array_key_exists($field, $body)) {
+ $sets[] = "$field = $param";
+ $params[$param] = $body[$field] === '' ? null : $body[$field];
+ }
+ }
+
+ if (empty($sets)) jsonResponse(false, 'Tidak ada data yang diubah.', [], 400);
+
+ $sql = "UPDATE penduduk SET " . implode(', ', $sets) . " WHERE nik = :nik";
+ $pdo->prepare($sql)->execute($params);
+
+ logAktivitas($pdo, $currentUser['id'] ?? null, 'UPDATE', 'penduduk', $nik, $dataLama, $body);
+
+ jsonResponse(true, 'Data penduduk berhasil diperbarui.', ['nik' => $nik]);
+}
+
+// ============================================================
+// DELETE
+// ============================================================
+if ($method === 'DELETE') {
+ if (!isAdmin($currentUser)) jsonResponse(false, 'Hanya admin yang bisa menghapus data.', [], 403);
+
+ $nik = sanitizeStr($_GET['nik'] ?? '');
+ if (!validateNIK($nik)) jsonResponse(false, 'NIK tidak valid.', [], 400);
+
+ $old = $pdo->prepare("SELECT * FROM penduduk WHERE nik = :nik");
+ $old->execute([':nik' => $nik]);
+ $dataLama = $old->fetch();
+ if (!$dataLama) jsonResponse(false, 'Data tidak ditemukan.', [], 404);
+
+ $pdo->prepare("DELETE FROM penduduk WHERE nik = :nik")->execute([':nik' => $nik]);
+ logAktivitas($pdo, $currentUser['id'] ?? null, 'DELETE', 'penduduk', $nik, $dataLama, null);
+
+ jsonResponse(true, 'Data penduduk dihapus.');
+}
+
+jsonResponse(false, 'Method tidak didukung.', [], 405);
diff --git a/webgis-kemiskinan/php/api/pesan.php b/webgis-kemiskinan/php/api/pesan.php
new file mode 100644
index 0000000..6f9af42
--- /dev/null
+++ b/webgis-kemiskinan/php/api/pesan.php
@@ -0,0 +1,127 @@
+prepare("
+ SELECT p.*, u.nama AS pengirim, u.role_id,
+ ri.nama AS nama_ri
+ FROM pesan p
+ JOIN users u ON u.id = p.dari_user_id
+ LEFT JOIN rumah_ibadah ri ON ri.id = p.ke_rumah_ibadah_id
+ WHERE p.ke_rumah_ibadah_id = :ri
+ ORDER BY p.created_at ASC
+ LIMIT 200
+ ");
+ $stmt->execute([':ri' => $riId]);
+ } else {
+ // Pesan langsung ke user
+ $stmt = $pdo->prepare("
+ SELECT p.*, u.nama AS pengirim
+ FROM pesan p
+ JOIN users u ON u.id = p.dari_user_id
+ WHERE p.ke_user_id = :uid OR p.dari_user_id = :uid2
+ ORDER BY p.created_at ASC
+ LIMIT 200
+ ");
+ $stmt->execute([':uid' => $currentUser['id'], ':uid2' => $currentUser['id']]);
+ }
+ $pesan = $stmt->fetchAll();
+
+ // Tandai sudah dibaca
+ if ($riId) {
+ $pdo->prepare("UPDATE pesan SET dibaca=1 WHERE ke_rumah_ibadah_id=:ri AND dari_user_id != :uid")
+ ->execute([':ri' => $riId, ':uid' => $currentUser['id']]);
+ } else {
+ $pdo->prepare("UPDATE pesan SET dibaca=1 WHERE ke_user_id=:uid")
+ ->execute([':uid' => $currentUser['id']]);
+ }
+
+ jsonResponse(true, 'OK', ['data' => $pesan]);
+ }
+
+ if ($action === 'notifikasi') {
+ $stmt = $pdo->prepare("
+ SELECT * FROM notifikasi
+ WHERE user_id = :uid
+ ORDER BY created_at DESC
+ LIMIT 30
+ ");
+ $stmt->execute([':uid' => $currentUser['id']]);
+ $notif = $stmt->fetchAll();
+ $unread = (int)$pdo->prepare("SELECT COUNT(*) FROM notifikasi WHERE user_id=:uid AND dibaca=0")
+ ->execute([':uid' => $currentUser['id']]);
+ jsonResponse(true, 'OK', ['data' => $notif, 'unread' => array_sum(array_column($notif, 'dibaca') === array_map(fn($r) => $r['dibaca'] == 0, $notif))]);
+ }
+
+ if ($action === 'pengurus_list') {
+ // Daftar pengurus untuk pilihan chat
+ $rows = $pdo->query("
+ SELECT u.id, u.nama, u.email, ri.nama AS nama_ri, ri.jenis AS jenis_ri
+ FROM users u
+ LEFT JOIN rumah_ibadah ri ON ri.id = u.rumah_ibadah_id
+ WHERE u.role_id IN (1,2) AND u.aktif=1
+ ORDER BY u.nama
+ ")->fetchAll();
+ jsonResponse(true, 'OK', ['data' => $rows]);
+ }
+
+ jsonResponse(false, 'Action tidak dikenal.', [], 400);
+}
+
+if ($method === 'POST') {
+ // Kirim pesan
+ $body = json_decode(file_get_contents('php://input'), true) ?? $_POST;
+ $isi = sanitizeStr($body['isi'] ?? '', 2000);
+ if (strlen(trim($isi)) < 1) jsonResponse(false, 'Pesan tidak boleh kosong.', [], 400);
+
+ $keUserId = !empty($body['ke_user_id']) ? (int)$body['ke_user_id'] : null;
+ $keRiId = !empty($body['ke_rumah_ibadah_id']) ? (int)$body['ke_rumah_ibadah_id'] : null;
+
+ if (!$keUserId && !$keRiId) jsonResponse(false, 'Tujuan pesan harus diisi.', [], 400);
+
+ $stmt = $pdo->prepare("
+ INSERT INTO pesan (dari_user_id, ke_user_id, ke_rumah_ibadah_id, isi)
+ VALUES (:dari, :ke_user, :ke_ri, :isi)
+ ");
+ $stmt->execute([
+ ':dari' => $currentUser['id'],
+ ':ke_user' => $keUserId,
+ ':ke_ri' => $keRiId,
+ ':isi' => $isi,
+ ]);
+
+ $newId = (int)$pdo->lastInsertId();
+
+ // Notifikasi ke penerima
+ if ($keUserId) {
+ $pdo->prepare("INSERT INTO notifikasi (user_id, judul, isi, tipe, ref_id) VALUES (:u,:j,:i,'pesan',:r)")
+ ->execute([':u'=>$keUserId, ':j'=>"Pesan dari {$currentUser['role']}", ':i'=>substr($isi,0,100), ':r'=>$newId]);
+ }
+
+ jsonResponse(true, 'Pesan terkirim.', ['id' => $newId], 201);
+}
+
+jsonResponse(false, 'Method tidak didukung.', [], 405);
diff --git a/webgis-kemiskinan/php/api/rumah_ibadah.php b/webgis-kemiskinan/php/api/rumah_ibadah.php
new file mode 100644
index 0000000..6ddb5d8
--- /dev/null
+++ b/webgis-kemiskinan/php/api/rumah_ibadah.php
@@ -0,0 +1,111 @@
+query("
+ SELECT ri.*,
+ COUNT(k.id) AS jumlah_kk,
+ SUM(CASE WHEN k.status_ekonomi='miskin' THEN 1 ELSE 0 END) AS kk_miskin
+ FROM rumah_ibadah ri
+ LEFT JOIN keluarga k ON k.rumah_ibadah_id = ri.id
+ GROUP BY ri.id
+ ORDER BY ri.nama
+ ")->fetchAll();
+ jsonResponse(true, 'OK', ['data' => $rows]);
+ }
+ if ($action === 'geojson') {
+ $rows = $pdo->query("SELECT * FROM rumah_ibadah")->fetchAll();
+ $features = [];
+ foreach ($rows as $r) {
+ $features[] = [
+ 'type' => 'Feature',
+ 'geometry' => ['type'=>'Point','coordinates'=>[(float)$r['longitude'],(float)$r['latitude']]],
+ 'properties' => $r
+ ];
+ }
+ header('Content-Type: application/json');
+ echo json_encode(['type'=>'FeatureCollection','features'=>$features], JSON_UNESCAPED_UNICODE);
+ exit;
+ }
+ jsonResponse(false, 'Action tidak dikenal.', [], 400);
+}
+
+if ($method === 'POST') {
+ if (!canEdit($currentUser)) jsonResponse(false, 'Akses ditolak.', [], 403);
+ $body = json_decode(file_get_contents('php://input'), true) ?? $_POST;
+ $nama = sanitizeStr($body['nama'] ?? '');
+ $lat = (float)($body['latitude'] ?? 0);
+ $lng = (float)($body['longitude'] ?? 0);
+ if (strlen($nama) < 3) jsonResponse(false, 'Nama terlalu pendek.', [], 400);
+ if (!$lat || !$lng) jsonResponse(false, 'Koordinat wajib diisi.', [], 400);
+
+ $stmt = $pdo->prepare("
+ INSERT INTO rumah_ibadah (nama, jenis, kontak, radius_meter, latitude, longitude, alamat, kelurahan, kecamatan)
+ VALUES (:nama,:jenis,:kontak,:radius,:lat,:lng,:alamat,:kel,:kec)
+ ");
+ $stmt->execute([
+ ':nama' => $nama,
+ ':jenis' => sanitizeStr($body['jenis'] ?? 'Lainnya'),
+ ':kontak' => sanitizeStr($body['kontak'] ?? '') ?: null,
+ ':radius' => (float)($body['radius_meter'] ?? 300),
+ ':lat' => $lat, ':lng' => $lng,
+ ':alamat' => sanitizeStr($body['alamat'] ?? '') ?: null,
+ ':kel' => sanitizeStr($body['kelurahan'] ?? '') ?: null,
+ ':kec' => sanitizeStr($body['kecamatan'] ?? '') ?: null,
+ ]);
+ $newId = (int)$pdo->lastInsertId();
+ logAktivitas($pdo, $currentUser['id']??null, 'CREATE', 'rumah_ibadah', (string)$newId, null, $body);
+ jsonResponse(true, 'Rumah ibadah ditambahkan.', ['id' => $newId], 201);
+}
+
+if ($method === 'PUT') {
+ if (!canEdit($currentUser)) jsonResponse(false, 'Akses ditolak.', [], 403);
+ $id = (int)($_GET['id'] ?? 0);
+ $body = json_decode(file_get_contents('php://input'), true) ?? [];
+ if ($action === 'update_koordinat') {
+ $lat = (float)($body['latitude'] ?? 0);
+ $lng = (float)($body['longitude'] ?? 0);
+ if (!$lat || !$lng) jsonResponse(false, 'Koordinat tidak valid.', [], 400);
+
+ $pdo->prepare("UPDATE rumah_ibadah SET latitude=:lat, longitude=:lng WHERE id=:id")
+ ->execute([':lat'=>$lat, ':lng'=>$lng, ':id'=>$id]);
+ logAktivitas($pdo, $currentUser['id']??null, 'UPDATE', 'rumah_ibadah', (string)$id, null, ['lat'=>$lat,'lng'=>$lng]);
+ jsonResponse(true, 'Koordinat diperbarui.', ['id' => $id]);
+ }
+
+ $pdo->prepare("
+ UPDATE rumah_ibadah SET nama=:nama,jenis=:jenis,kontak=:kontak,radius_meter=:radius,
+ latitude=:lat,longitude=:lng,alamat=:alamat,kelurahan=:kel,kecamatan=:kec WHERE id=:id
+ ")->execute([
+ ':nama'=>sanitizeStr($body['nama']??''), ':jenis'=>sanitizeStr($body['jenis']??'Lainnya'),
+ ':kontak'=>sanitizeStr($body['kontak']??'')?: null, ':radius'=>(float)($body['radius_meter']??300),
+ ':lat'=>(float)($body['latitude']??0), ':lng'=>(float)($body['longitude']??0),
+ ':alamat'=>sanitizeStr($body['alamat']??'')?: null, ':kel'=>sanitizeStr($body['kelurahan']??'')?: null,
+ ':kec'=>sanitizeStr($body['kecamatan']??'')?: null, ':id'=>$id,
+ ]);
+ logAktivitas($pdo, $currentUser['id']??null, 'UPDATE', 'rumah_ibadah', (string)$id);
+ jsonResponse(true, 'Rumah ibadah diperbarui.', ['id' => $id]);
+}
+
+if ($method === 'DELETE') {
+ if (!isAdmin($currentUser)) jsonResponse(false, 'Hanya admin.', [], 403);
+ $id = (int)($_GET['id'] ?? 0);
+ $pdo->prepare("DELETE FROM rumah_ibadah WHERE id=:id")->execute([':id'=>$id]);
+ logAktivitas($pdo, $currentUser['id']??null, 'DELETE', 'rumah_ibadah', (string)$id);
+ jsonResponse(true, 'Dihapus.');
+}
+
+jsonResponse(false, 'Method tidak didukung.', [], 405);
diff --git a/webgis-kemiskinan/php/koneksi.php b/webgis-kemiskinan/php/koneksi.php
new file mode 100644
index 0000000..1a729a5
--- /dev/null
+++ b/webgis-kemiskinan/php/koneksi.php
@@ -0,0 +1,177 @@
+ PDO::ERRMODE_EXCEPTION,
+ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+ PDO::ATTR_EMULATE_PREPARES => false,
+];
+
+try {
+ $pdo = new PDO($dsn, DB_USER, DB_PASS, $pdoOptions);
+} catch (PDOException $e) {
+ http_response_code(500);
+ header('Content-Type: application/json');
+ // Jangan expose detail error di produksi
+ $errMsg = (defined('APP_ENV') && APP_ENV === 'development') ? $e->getMessage() : 'Hubungi administrator sistem.';
+ echo json_encode([
+ 'success' => false,
+ 'message' => 'Koneksi database gagal: ' . DB_NAME . '@' . DB_HOST . ' tidak ditemukan. Pastikan database sudah dibuat dan nama database benar.',
+ 'error' => $errMsg
+ ]);
+ exit;
+}
+
+// ============================================================
+// Helper: Response JSON
+// ============================================================
+
+function jsonResponse(bool $success, string $message, array $data = [], int $httpCode = 200): void {
+ http_response_code($httpCode);
+ header('Content-Type: application/json; charset=utf-8');
+ header('Access-Control-Allow-Origin: *');
+ header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
+ header('Access-Control-Allow-Headers: Content-Type, Authorization');
+ echo json_encode(array_merge(['success' => $success, 'message' => $message], $data), JSON_UNESCAPED_UNICODE);
+ exit;
+}
+
+// ============================================================
+// Helper: Sanitasi & Validasi
+// ============================================================
+
+function h(string $s): string {
+ return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
+}
+
+function sanitizeStr(?string $v, int $max = 255): string {
+ return mb_substr(trim($v ?? ''), 0, $max);
+}
+
+function validateNIK(string $nik): bool {
+ return preg_match('/^\d{16}$/', $nik) === 1;
+}
+
+function validateCoord(string $val, string $type = 'lat'): bool {
+ $f = (float)$val;
+ return $type === 'lat' ? ($f >= -90 && $f <= 90) : ($f >= -180 && $f <= 180);
+}
+
+// ============================================================
+// Helper: Upload File
+// ============================================================
+
+function uploadFile(array $file, string $destDir, array $allowedExt = null): ?string {
+ if ($allowedExt === null) $allowedExt = ALLOWED_FILE_EXT;
+ if ($file['error'] !== UPLOAD_ERR_OK) return null;
+ if ($file['size'] > MAX_FILE_SIZE) throw new RuntimeException('Ukuran file melebihi 5MB.');
+ $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
+ if (!in_array($ext, $allowedExt, true)) throw new RuntimeException("Format file .$ext tidak diizinkan.");
+ $filename = uniqid('img_', true) . '.' . $ext;
+ $dest = rtrim($destDir, '/') . '/' . $filename;
+ if (!move_uploaded_file($file['tmp_name'], $dest)) throw new RuntimeException('Gagal menyimpan file.');
+ return $filename;
+}
+
+// ============================================================
+// Helper: Log Aktivitas
+// ============================================================
+
+function logAktivitas(PDO $pdo, ?int $userId, string $aksi, string $tabel = '', string $recordId = '', $dataLama = null, $dataBaru = null): void {
+ try {
+ $stmt = $pdo->prepare("
+ INSERT INTO log_aktivitas (user_id, aksi, tabel, record_id, data_lama, data_baru, ip_address, user_agent)
+ VALUES (:u, :a, :t, :r, :dl, :db, :ip, :ua)
+ ");
+ $stmt->execute([
+ ':u' => $userId,
+ ':a' => $aksi,
+ ':t' => $tabel,
+ ':r' => $recordId,
+ ':dl' => $dataLama ? json_encode($dataLama, JSON_UNESCAPED_UNICODE) : null,
+ ':db' => $dataBaru ? json_encode($dataBaru, JSON_UNESCAPED_UNICODE) : null,
+ ':ip' => $_SERVER['REMOTE_ADDR'] ?? null,
+ ':ua' => substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255),
+ ]);
+ } catch (Exception) { /* log gagal tidak menghentikan proses */ }
+}
+
+// ============================================================
+// Validasi Logika Pendidikan vs Umur
+// ============================================================
+
+function validasiPendidikanUmur(int $umur, string $pendidikan, string $statusPendidikan): array {
+ $warnings = [];
+ $errors = [];
+
+ if ($umur >= 7 && $umur <= 12) {
+ if ($statusPendidikan !== 'sekolah') {
+ $warnings[] = "Anak usia $umur tahun semestinya masih sekolah SD.";
+ }
+ } elseif ($umur >= 13 && $umur <= 15) {
+ if ($statusPendidikan !== 'sekolah') {
+ $warnings[] = "Anak usia $umur tahun semestinya masih sekolah SMP.";
+ }
+ } elseif ($umur >= 16 && $umur <= 18) {
+ if ($statusPendidikan !== 'sekolah') {
+ $warnings[] = "Anak usia $umur tahun semestinya masih sekolah SMA/SMK.";
+ }
+ }
+ // Umur > 18: bebas, boleh tidak sekolah, boleh pelatihan
+
+ return ['warnings' => $warnings, 'errors' => $errors];
+}
+
+// ============================================================
+// Prioritas Bantuan
+// ============================================================
+
+function hitungPrioritasBantuan(string $statusEkonomi, string $kondisiKesehatan, bool $belumPernahDibantu): string {
+ if ($statusEkonomi === 'miskin' && in_array($kondisiKesehatan, ['sakit_parah', 'disabilitas'])) {
+ return 'KRITIS';
+ }
+ if ($statusEkonomi === 'miskin' && $belumPernahDibantu) {
+ return 'TINGGI';
+ }
+ if ($statusEkonomi === 'miskin') {
+ return 'SEDANG';
+ }
+ if ($statusEkonomi === 'rentan' && in_array($kondisiKesehatan, ['sakit_parah', 'disabilitas'])) {
+ return 'SEDANG';
+ }
+ return 'RENDAH';
+}
diff --git a/webgis-kemiskinan/php/middleware/auth.php b/webgis-kemiskinan/php/middleware/auth.php
new file mode 100644
index 0000000..895d014
--- /dev/null
+++ b/webgis-kemiskinan/php/middleware/auth.php
@@ -0,0 +1,125 @@
+ true,
+ 'cookie_samesite' => 'Strict',
+ 'use_strict_mode' => true,
+ ]);
+}
+
+// ============================================================
+// Fungsi login / logout
+// ============================================================
+
+function doLogin(PDO $pdo, string $username, string $password): array {
+ $stmt = $pdo->prepare("
+ SELECT u.*, r.nama AS role_nama
+ FROM users u
+ JOIN roles r ON r.id = u.role_id
+ WHERE u.username = :u AND u.aktif = 1
+ LIMIT 1
+ ");
+ $stmt->execute([':u' => $username]);
+ $user = $stmt->fetch();
+
+ if (!$user || !password_verify($password, $user['password_hash'])) {
+ return ['success' => false, 'message' => 'Username atau password salah.'];
+ }
+
+ // Update last_login
+ $pdo->prepare("UPDATE users SET last_login = NOW() WHERE id = :id")
+ ->execute([':id' => $user['id']]);
+
+ $_SESSION['user_id'] = $user['id'];
+ $_SESSION['user_nama'] = $user['nama'];
+ $_SESSION['role'] = $user['role_nama'];
+ $_SESSION['role_id'] = $user['role_id'];
+ $_SESSION['rumah_ibadah_id'] = $user['rumah_ibadah_id'];
+ session_regenerate_id(true);
+
+ logAktivitas($pdo, $user['id'], 'LOGIN', 'users', (string)$user['id']);
+
+ return ['success' => true, 'role' => $user['role_nama']];
+}
+
+function doLogout(PDO $pdo): void {
+ if (isset($_SESSION['user_id'])) {
+ logAktivitas($pdo, $_SESSION['user_id'], 'LOGOUT', 'users', (string)$_SESSION['user_id']);
+ }
+ session_destroy();
+}
+
+// ============================================================
+// Guard: wajib login
+// ============================================================
+
+function requireLogin(array $allowedRoles = []): array {
+ if (empty($_SESSION['user_id'])) {
+ // Jika request API
+ if (!empty($_SERVER['HTTP_ACCEPT']) && strpos($_SERVER['HTTP_ACCEPT'], 'application/json') !== false) {
+ jsonResponse(false, 'Sesi habis. Silakan login kembali.', [], 401);
+ }
+ header('Location: ' . APP_URL . '/admin/login.php');
+ exit;
+ }
+ if (!empty($allowedRoles) && !in_array($_SESSION['role'], $allowedRoles, true)) {
+ if (!empty($_SERVER['HTTP_ACCEPT']) && strpos($_SERVER['HTTP_ACCEPT'], 'application/json') !== false) {
+ jsonResponse(false, 'Tidak punya akses.', [], 403);
+ }
+ header('Location: ' . APP_URL . '/admin/forbidden.php');
+ exit;
+ }
+ return [
+ 'id' => (int)$_SESSION['user_id'],
+ 'nama' => $_SESSION['user_nama'],
+ 'role' => $_SESSION['role'],
+ 'role_id' => (int)$_SESSION['role_id'],
+ 'rumah_ibadah_id' => $_SESSION['rumah_ibadah_id'] ? (int)$_SESSION['rumah_ibadah_id'] : null,
+ ];
+}
+
+// ============================================================
+// Guard API: token-based (opsional, untuk mobile app nanti)
+// ============================================================
+
+function getApiUser(PDO $pdo): ?array {
+ // Cek session dulu
+ if (!empty($_SESSION['user_id'])) {
+ return [
+ 'id' => (int)$_SESSION['user_id'],
+ 'role' => $_SESSION['role'],
+ 'role_id' => (int)$_SESSION['role_id'],
+ 'rumah_ibadah_id' => $_SESSION['rumah_ibadah_id'] ? (int)$_SESSION['rumah_ibadah_id'] : null,
+ ];
+ }
+ return null; // belum login = null
+}
+
+// ============================================================
+// Cek izin per fitur
+// ============================================================
+
+function canEdit(?array $user): bool {
+ if (!$user) return false;
+ return in_array($user['role'], ['admin', 'pengurus'], true);
+}
+
+function canVerify(?array $user): bool {
+ if (!$user) return false;
+ return in_array($user['role'], ['admin', 'pengurus'], true);
+}
+
+function canViewAll(?array $user): bool {
+ if (!$user) return false;
+ return in_array($user['role'], ['admin', 'pengurus', 'pimpinan'], true);
+}
+
+function isAdmin(?array $user): bool {
+ return $user && $user['role'] === 'admin';
+}
diff --git a/webgis-kemiskinan/sql/schema.sql b/webgis-kemiskinan/sql/schema.sql
new file mode 100644
index 0000000..8009642
--- /dev/null
+++ b/webgis-kemiskinan/sql/schema.sql
@@ -0,0 +1,384 @@
+-- ============================================================
+-- WebGIS Pengentasan Kemiskinan Berbasis Masyarakat & Rumah Ibadah
+-- Schema Lengkap v2.0 β FIXED VERSION
+-- Engine: MySQL / MariaDB | Charset: utf8mb4
+-- ============================================================
+
+-- PENTING: Buat database terlebih dahulu jika belum ada
+CREATE DATABASE IF NOT EXISTS webgis_kemiskinan CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+USE webgis_kemiskinan;
+
+SET FOREIGN_KEY_CHECKS = 0;
+SET NAMES utf8mb4;
+
+-- ============================================================
+-- DROP TABLES (urutan terbalik dari dependensi FK)
+-- ============================================================
+DROP TABLE IF EXISTS notifikasi;
+DROP TABLE IF EXISTS log_aktivitas;
+DROP TABLE IF EXISTS pesan;
+DROP TABLE IF EXISTS laporan;
+DROP TABLE IF EXISTS histori_bantuan;
+DROP TABLE IF EXISTS jenis_bantuan;
+DROP TABLE IF EXISTS riwayat_pelatihan;
+DROP TABLE IF EXISTS penduduk;
+DROP TABLE IF EXISTS keluarga;
+DROP TABLE IF EXISTS rumah_ibadah;
+DROP TABLE IF EXISTS users;
+DROP TABLE IF EXISTS roles;
+DROP TABLE IF EXISTS jalan_lines;
+DROP TABLE IF EXISTS parsil_tanah;
+
+-- ============================================================
+-- 1. ROLES & USERS
+-- ============================================================
+
+CREATE TABLE roles (
+ id TINYINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ nama VARCHAR(50) NOT NULL UNIQUE,
+ label VARCHAR(80) NOT NULL,
+ deskripsi TEXT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+INSERT INTO roles (nama, label) VALUES
+ ('admin', 'Administrator Sistem'),
+ ('pengurus', 'Pengurus Rumah Ibadah'),
+ ('pimpinan', 'Pimpinan Daerah'),
+ ('masyarakat', 'Masyarakat Umum');
+
+-- ============================================================
+-- 2. RUMAH IBADAH (dibuat sebelum users karena users FK ke sini)
+-- ============================================================
+
+CREATE TABLE rumah_ibadah (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ nama VARCHAR(200) NOT NULL,
+ jenis ENUM('Masjid','Musholla','Gereja Protestan','Gereja Katolik','Pura','Vihara','Klenteng','Lainnya') NOT NULL DEFAULT 'Lainnya',
+ kontak VARCHAR(120) NULL,
+ radius_meter DOUBLE NOT NULL DEFAULT 300,
+ latitude DECIMAL(10,7) NOT NULL,
+ longitude DECIMAL(10,7) NOT NULL,
+ alamat TEXT NULL,
+ kelurahan VARCHAR(120) NULL,
+ kecamatan VARCHAR(120) NULL,
+ kota VARCHAR(120) NULL,
+ deskripsi TEXT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ INDEX idx_ri_jenis (jenis),
+ INDEX idx_ri_latlng (latitude, longitude)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ============================================================
+-- 3. USERS (setelah roles & rumah_ibadah)
+-- ============================================================
+
+CREATE TABLE users (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ role_id TINYINT UNSIGNED NOT NULL DEFAULT 4,
+ nama VARCHAR(150) NOT NULL,
+ username VARCHAR(60) NOT NULL UNIQUE,
+ email VARCHAR(150) NULL UNIQUE,
+ password_hash VARCHAR(255) NOT NULL,
+ no_telp VARCHAR(20) NULL,
+ foto VARCHAR(255) NULL,
+ aktif TINYINT(1) NOT NULL DEFAULT 1,
+ rumah_ibadah_id INT UNSIGNED NULL,
+ last_login DATETIME NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ INDEX idx_users_role (role_id),
+ CONSTRAINT fk_users_role FOREIGN KEY (role_id) REFERENCES roles(id),
+ CONSTRAINT fk_users_ri FOREIGN KEY (rumah_ibadah_id) REFERENCES rumah_ibadah(id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ============================================================
+-- 4. KELUARGA (KK)
+-- ============================================================
+
+CREATE TABLE keluarga (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ no_kk VARCHAR(20) NOT NULL UNIQUE,
+ nama_kk VARCHAR(200) NOT NULL,
+ alamat TEXT NULL,
+ kelurahan VARCHAR(120) NULL,
+ kecamatan VARCHAR(120) NULL,
+ kota VARCHAR(120) NULL,
+ latitude DECIMAL(10,7) NULL,
+ longitude DECIMAL(10,7) NULL,
+ status_ekonomi ENUM('miskin','rentan','mampu') NOT NULL DEFAULT 'rentan',
+ rumah_ibadah_id INT UNSIGNED NULL,
+ jarak_ri_meter DOUBLE NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ INDEX idx_kk_status (status_ekonomi),
+ INDEX idx_kk_latlng (latitude, longitude),
+ INDEX idx_kk_ri (rumah_ibadah_id),
+ CONSTRAINT fk_kk_ri FOREIGN KEY (rumah_ibadah_id) REFERENCES rumah_ibadah(id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ============================================================
+-- 5. PENDUDUK
+-- ============================================================
+
+CREATE TABLE penduduk (
+ nik VARCHAR(16) NOT NULL PRIMARY KEY,
+ id_keluarga INT UNSIGNED NULL,
+ nama_lengkap VARCHAR(200) NOT NULL,
+ jenis_kelamin ENUM('L','P') NOT NULL,
+ tanggal_lahir DATE NOT NULL,
+ umur TINYINT UNSIGNED GENERATED ALWAYS AS (
+ TIMESTAMPDIFF(YEAR, tanggal_lahir, CURDATE())
+ ) VIRTUAL,
+ status_keluarga ENUM('kepala_keluarga','istri','anak','menantu','cucu','orang_tua','mertua','anggota_lain') NOT NULL DEFAULT 'anggota_lain',
+ status_perkawinan ENUM('belum_kawin','kawin','cerai_hidup','cerai_mati') NOT NULL DEFAULT 'belum_kawin',
+ status_hidup ENUM('hidup','meninggal') NOT NULL DEFAULT 'hidup',
+ pendidikan_terakhir ENUM('tidak_sekolah','SD','SMP','SMA','SMK','D1','D2','D3','S1','S2','S3') NOT NULL DEFAULT 'tidak_sekolah',
+ status_pendidikan ENUM('sekolah','tidak_sekolah','lulus') NOT NULL DEFAULT 'tidak_sekolah',
+ pekerjaan VARCHAR(150) NULL,
+ penghasilan DECIMAL(15,2) NULL DEFAULT 0,
+ status_ekonomi ENUM('miskin','rentan','mampu') NOT NULL DEFAULT 'rentan',
+ kondisi_kesehatan ENUM('sehat','sakit_ringan','sakit_parah','disabilitas') NOT NULL DEFAULT 'sehat',
+ catatan_kesehatan TEXT NULL,
+ no_telp VARCHAR(20) NULL,
+ alamat TEXT NULL,
+ latitude DECIMAL(10,7) NULL,
+ longitude DECIMAL(10,7) NULL,
+ foto VARCHAR(255) NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ INDEX idx_pddk_keluarga (id_keluarga),
+ INDEX idx_pddk_status_ek (status_ekonomi),
+ INDEX idx_pddk_status_hid (status_hidup),
+ INDEX idx_pddk_latlng (latitude, longitude),
+ CONSTRAINT fk_pddk_keluarga FOREIGN KEY (id_keluarga) REFERENCES keluarga(id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ============================================================
+-- 6. RIWAYAT PELATIHAN
+-- ============================================================
+
+CREATE TABLE riwayat_pelatihan (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ nik VARCHAR(16) NOT NULL,
+ nama_pelatihan VARCHAR(200) NOT NULL,
+ penyelenggara VARCHAR(150) NULL,
+ tanggal_mulai DATE NULL,
+ tanggal_selesai DATE NULL,
+ sertifikat VARCHAR(255) NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ INDEX idx_platihan_nik (nik),
+ CONSTRAINT fk_platihan_nik FOREIGN KEY (nik) REFERENCES penduduk(nik) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ============================================================
+-- 7. JENIS BANTUAN (master)
+-- ============================================================
+
+CREATE TABLE jenis_bantuan (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ nama VARCHAR(150) NOT NULL,
+ kategori ENUM('sembako','pendidikan','kesehatan','ekonomi','perumahan','lainnya') NOT NULL DEFAULT 'lainnya',
+ sumber VARCHAR(150) NULL,
+ satuan VARCHAR(50) NULL,
+ deskripsi TEXT NULL,
+ aktif TINYINT(1) NOT NULL DEFAULT 1,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+INSERT INTO jenis_bantuan (nama, kategori, sumber, satuan) VALUES
+ ('Sembako Bulanan', 'sembako', 'Rumah Ibadah', '1 paket/bulan'),
+ ('Beasiswa Pendidikan', 'pendidikan','BAZNAS', 'Rp/semester'),
+ ('Bantuan Kesehatan', 'kesehatan', 'Puskesmas', '1 kali/kunjungan'),
+ ('Modal Usaha Mikro', 'ekonomi', 'Baznas', 'Rp/program'),
+ ('Renovasi Rumah', 'perumahan', 'Pemerintah', 'paket/unit');
+
+-- ============================================================
+-- 8. HISTORI BANTUAN
+-- ============================================================
+
+CREATE TABLE histori_bantuan (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ nik VARCHAR(16) NOT NULL,
+ id_jenis_bantuan INT UNSIGNED NOT NULL,
+ id_keluarga INT UNSIGNED NULL,
+ rumah_ibadah_id INT UNSIGNED NULL,
+ tanggal DATE NOT NULL,
+ jumlah VARCHAR(100) NULL,
+ nilai_rupiah DECIMAL(15,2) NULL,
+ status_penyaluran ENUM('dijadwalkan','disalurkan','dibatalkan') NOT NULL DEFAULT 'dijadwalkan',
+ catatan TEXT NULL,
+ bukti_file VARCHAR(255) NULL,
+ disalurkan_oleh INT UNSIGNED NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ INDEX idx_hb_nik (nik),
+ INDEX idx_hb_tanggal (tanggal),
+ INDEX idx_hb_status (status_penyaluran),
+ CONSTRAINT fk_hb_nik FOREIGN KEY (nik) REFERENCES penduduk(nik) ON DELETE CASCADE,
+ CONSTRAINT fk_hb_jenis FOREIGN KEY (id_jenis_bantuan) REFERENCES jenis_bantuan(id),
+ CONSTRAINT fk_hb_kk FOREIGN KEY (id_keluarga) REFERENCES keluarga(id) ON DELETE SET NULL,
+ CONSTRAINT fk_hb_ri FOREIGN KEY (rumah_ibadah_id) REFERENCES rumah_ibadah(id) ON DELETE SET NULL,
+ CONSTRAINT fk_hb_user FOREIGN KEY (disalurkan_oleh) REFERENCES users(id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ============================================================
+-- 9. LAPORAN MASYARAKAT
+-- ============================================================
+
+CREATE TABLE laporan (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ nama_pelapor VARCHAR(150) NULL,
+ pelapor_id INT UNSIGNED NULL,
+ nik_terdampak VARCHAR(16) NULL,
+ deskripsi TEXT NOT NULL,
+ kategori ENUM('darurat_pangan','darurat_kesehatan','darurat_ekonomi','butuh_bantuan_lain') NOT NULL DEFAULT 'butuh_bantuan_lain',
+ tingkat_urgensi ENUM('rendah','sedang','tinggi','darurat') NOT NULL DEFAULT 'sedang',
+ latitude DECIMAL(10,7) NULL,
+ longitude DECIMAL(10,7) NULL,
+ alamat TEXT NULL,
+ foto VARCHAR(255) NULL,
+ status ENUM('pending','diverifikasi','diproses','selesai','ditolak') NOT NULL DEFAULT 'pending',
+ catatan_verifikasi TEXT NULL,
+ diverifikasi_oleh INT UNSIGNED NULL,
+ ditangani_oleh INT UNSIGNED NULL,
+ tanggal_selesai DATETIME NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ INDEX idx_lap_status (status),
+ INDEX idx_lap_urgensi (tingkat_urgensi),
+ INDEX idx_lap_latlng (latitude, longitude),
+ CONSTRAINT fk_lap_nik FOREIGN KEY (nik_terdampak) REFERENCES penduduk(nik) ON DELETE SET NULL,
+ CONSTRAINT fk_lap_pelapor FOREIGN KEY (pelapor_id) REFERENCES users(id) ON DELETE SET NULL,
+ CONSTRAINT fk_lap_verif FOREIGN KEY (diverifikasi_oleh) REFERENCES users(id) ON DELETE SET NULL,
+ CONSTRAINT fk_lap_tangan FOREIGN KEY (ditangani_oleh) REFERENCES users(id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ============================================================
+-- 10. PESAN / CHAT ANTAR PENGURUS
+-- ============================================================
+
+CREATE TABLE pesan (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ dari_user_id INT UNSIGNED NOT NULL,
+ ke_user_id INT UNSIGNED NULL,
+ ke_rumah_ibadah_id INT UNSIGNED NULL,
+ isi TEXT NOT NULL,
+ dibaca TINYINT(1) NOT NULL DEFAULT 0,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ INDEX idx_pesan_dari (dari_user_id),
+ INDEX idx_pesan_ke (ke_user_id),
+ CONSTRAINT fk_pesan_dari FOREIGN KEY (dari_user_id) REFERENCES users(id) ON DELETE CASCADE,
+ CONSTRAINT fk_pesan_ke FOREIGN KEY (ke_user_id) REFERENCES users(id) ON DELETE SET NULL,
+ CONSTRAINT fk_pesan_ri FOREIGN KEY (ke_rumah_ibadah_id) REFERENCES rumah_ibadah(id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ============================================================
+-- 11. LOG AKTIVITAS (audit trail)
+-- ============================================================
+
+CREATE TABLE log_aktivitas (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ user_id INT UNSIGNED NULL,
+ aksi VARCHAR(50) NOT NULL,
+ tabel VARCHAR(60) NULL,
+ record_id VARCHAR(32) NULL,
+ data_lama JSON NULL,
+ data_baru JSON NULL,
+ ip_address VARCHAR(45) NULL,
+ user_agent VARCHAR(255) NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ INDEX idx_log_user (user_id),
+ INDEX idx_log_tabel (tabel),
+ INDEX idx_log_aksi (aksi),
+ INDEX idx_log_time (created_at),
+ CONSTRAINT fk_log_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ============================================================
+-- 12. LAYER SPASIAL TAMBAHAN
+-- ============================================================
+
+CREATE TABLE jalan_lines (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ nama_jalan VARCHAR(200) NOT NULL,
+ status_jalan ENUM('Nasional','Provinsi','Kabupaten','Desa') NOT NULL DEFAULT 'Kabupaten',
+ panjang_meter DOUBLE NOT NULL DEFAULT 0,
+ geojson LONGTEXT NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ INDEX idx_jalan_status (status_jalan)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE parsil_tanah (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ status_kepemilikan ENUM('SHM','HGB','HGU','HP','Wakaf','Lainnya') NOT NULL DEFAULT 'SHM',
+ luas_m2 DOUBLE NOT NULL DEFAULT 0,
+ geojson LONGTEXT NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ INDEX idx_parsil_status (status_kepemilikan)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ============================================================
+-- 13. NOTIFIKASI
+-- ============================================================
+
+CREATE TABLE notifikasi (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ user_id INT UNSIGNED NOT NULL,
+ judul VARCHAR(200) NOT NULL,
+ isi TEXT NOT NULL,
+ tipe ENUM('laporan','bantuan','sistem','pesan') NOT NULL DEFAULT 'sistem',
+ ref_id INT UNSIGNED NULL,
+ dibaca TINYINT(1) NOT NULL DEFAULT 0,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ INDEX idx_notif_user (user_id),
+ INDEX idx_notif_dibaca (dibaca),
+ CONSTRAINT fk_notif_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+SET FOREIGN_KEY_CHECKS = 1;
+
+-- ============================================================
+-- DATA DUMMY
+-- ============================================================
+
+INSERT IGNORE INTO users (role_id, nama, username, email, password_hash) VALUES
+ (1, 'Administrator', 'admin', 'admin@webgis.local', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi'),
+ (2, 'H. Ahmad Fauzi', 'pengurus1', 'pengurus1@webgis.local', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi'),
+ (3, 'Camat Pontianak Selatan','pimpinan1', 'pimpinan@webgis.local', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi'),
+ (4, 'Budi Santoso', 'warga1', 'warga1@webgis.local', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi');
+
+INSERT IGNORE INTO rumah_ibadah (nama, jenis, kontak, radius_meter, latitude, longitude, alamat, kelurahan, kecamatan) VALUES
+ ('Masjid Al-Falah', 'Masjid', '08113001234', 400, -0.0200, 109.3410, 'Jl. Gajah Mada No. 1', 'Benua Melayu Darat', 'Pontianak Selatan'),
+ ('Masjid Raya Mujahidin', 'Masjid', '08113005678', 600, -0.0250, 109.3430, 'Jl. Ahmad Yani No. 10', 'Akcaya', 'Pontianak Selatan'),
+ ('GKE Pontianak', 'Gereja Protestan', '08113009999', 300, -0.0280, 109.3480, 'Jl. Nusa Indah', 'Sungai Bangkong', 'Pontianak Kota'),
+ ('Vihara Bodhicitta', 'Vihara', '08113004321', 250, -0.0310, 109.3520, 'Jl. Tanjungpura No. 5', 'Mariana', 'Pontianak Kota');
+
+INSERT IGNORE INTO keluarga (no_kk, nama_kk, alamat, kelurahan, kecamatan, latitude, longitude, status_ekonomi, rumah_ibadah_id) VALUES
+ ('6171010101010001', 'Ahmad Firdaus', 'Jl. Melati No. 3', 'Benua Melayu Darat', 'Pontianak Selatan', -0.0210, 109.3415, 'miskin', 1),
+ ('6171010101010002', 'Siti Rahma', 'Jl. Kenanga No. 5', 'Benua Melayu Darat', 'Pontianak Selatan', -0.0220, 109.3420, 'miskin', 1),
+ ('6171010101010003', 'Budi Hermawan','Jl. Mawar No. 7', 'Akcaya', 'Pontianak Selatan', -0.0240, 109.3435, 'rentan', 2),
+ ('6171010101010004', 'Maria Goretti','Jl. Nusa Indah No.2', 'Sungai Bangkong', 'Pontianak Kota', -0.0275, 109.3475, 'miskin', 3),
+ ('6171010101010005', 'Tan Ah Kow', 'Jl. Tanjungpura 12', 'Mariana', 'Pontianak Kota', -0.0305, 109.3515, 'rentan', 4);
+
+INSERT IGNORE INTO penduduk (nik, id_keluarga, nama_lengkap, jenis_kelamin, tanggal_lahir, status_keluarga, status_perkawinan, status_ekonomi, pekerjaan, penghasilan, kondisi_kesehatan, alamat, latitude, longitude) VALUES
+ ('6171010101010001', 1, 'Ahmad Firdaus', 'L', '1980-03-15', 'kepala_keluarga', 'kawin', 'miskin', 'Buruh Harian', 800000, 'sehat', 'Jl. Melati No. 3', -0.0210, 109.3415),
+ ('6171010101010002', 1, 'Siti Aisyah', 'P', '1983-07-22', 'istri', 'kawin', 'miskin', 'Ibu Rumah Tangga', 0, 'sehat', 'Jl. Melati No. 3', -0.0210, 109.3415),
+ ('6171010101010003', 1, 'Reza Firdaus', 'L', '2010-11-05', 'anak', 'belum_kawin', 'miskin', 'Pelajar', 0, 'sehat', 'Jl. Melati No. 3', -0.0210, 109.3415),
+ ('6171020202020001', 2, 'Siti Rahma', 'P', '1975-01-10', 'kepala_keluarga', 'cerai_mati', 'miskin', 'Pedagang Kecil', 500000, 'sakit_ringan','Jl. Kenanga No. 5', -0.0220, 109.3420),
+ ('6171020202020002', 2, 'Dewi Rahma', 'P', '2008-05-30', 'anak', 'belum_kawin', 'miskin', 'Pelajar', 0, 'sehat', 'Jl. Kenanga No. 5', -0.0220, 109.3420),
+ ('6171030303030001', 3, 'Budi Hermawan', 'L', '1990-09-20', 'kepala_keluarga', 'kawin', 'rentan', 'Ojek Online', 1500000, 'sehat', 'Jl. Mawar No. 7', -0.0240, 109.3435),
+ ('6171040404040001', 4, 'Yohanes Goretti', 'L', '1970-12-01', 'kepala_keluarga', 'kawin', 'miskin', 'Tidak bekerja', 0, 'sakit_parah', 'Jl. Nusa Indah No.2', -0.0275, 109.3475),
+ ('6171050505050001', 5, 'Tan Ah Kow', 'L', '1968-06-18', 'kepala_keluarga', 'kawin', 'rentan', 'Pedagang', 1200000, 'sehat', 'Jl. Tanjungpura 12', -0.0305, 109.3515);
+
+INSERT IGNORE INTO histori_bantuan (nik, id_jenis_bantuan, id_keluarga, rumah_ibadah_id, tanggal, jumlah, status_penyaluran, catatan) VALUES
+ ('6171010101010001', 1, 1, 1, CURDATE() - INTERVAL 30 DAY, '1 paket sembako', 'disalurkan', 'Penyaluran rutin bulan lalu'),
+ ('6171020202020001', 2, 2, 1, CURDATE() - INTERVAL 15 DAY, 'Rp 500.000', 'disalurkan', 'Bantuan pendidikan anak'),
+ ('6171040404040001', 3, 4, 3, CURDATE() - INTERVAL 7 DAY, '1 kali pemeriksaan', 'disalurkan', 'Kunjungan kesehatan darurat');
+
+INSERT IGNORE INTO laporan (nama_pelapor, nik_terdampak, deskripsi, kategori, tingkat_urgensi, latitude, longitude, alamat, status) VALUES
+ ('Anonim', '6171020202020001', 'Ibu Siti sudah 3 hari tidak makan, anaknya juga sakit', 'darurat_pangan', 'darurat', -0.0220, 109.3420, 'Jl. Kenanga No. 5', 'pending'),
+ ('Pak RT 03', NULL, 'Ada lansia sebatang kara tidak ada yang merawat di RT 03','butuh_bantuan_lain','tinggi', -0.0265, 109.3460, 'RT 03 Sungai Bangkong','diverifikasi'),
+ ('Maria Magdalena', '6171040404040001', 'Pak Yohanes sakit parah, tidak ada biaya berobat', 'darurat_kesehatan', 'darurat', -0.0275, 109.3475, 'Jl. Nusa Indah No.2', 'diproses');
diff --git a/webgis-kemiskinan/update_db.php b/webgis-kemiskinan/update_db.php
new file mode 100644
index 0000000..ce2aa3c
--- /dev/null
+++ b/webgis-kemiskinan/update_db.php
@@ -0,0 +1,11 @@
+exec("ALTER TABLE laporan ADD COLUMN pelapor_id INT UNSIGNED NULL AFTER nama_pelapor");
+ $pdo->exec("ALTER TABLE laporan ADD CONSTRAINT fk_lap_pelapor FOREIGN KEY (pelapor_id) REFERENCES users(id) ON DELETE SET NULL");
+ echo "Kolom pelapor_id berhasil ditambahkan.\n";
+} catch (PDOException $e) {
+ echo "Error atau kolom sudah ada: " . $e->getMessage() . "\n";
+}
diff --git a/webgis-kemiskinan/uploads/bukti/.gitkeep b/webgis-kemiskinan/uploads/bukti/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/webgis-kemiskinan/uploads/foto_laporan/.gitkeep b/webgis-kemiskinan/uploads/foto_laporan/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/webgis-kemiskinan/uploads/foto_profil/.gitkeep b/webgis-kemiskinan/uploads/foto_profil/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/webgis-spbu/.codex b/webgis-spbu/.codex
new file mode 100644
index 0000000..e69de29
diff --git a/webgis-spbu/Dockerfile b/webgis-spbu/Dockerfile
new file mode 100644
index 0000000..6d60883
--- /dev/null
+++ b/webgis-spbu/Dockerfile
@@ -0,0 +1,10 @@
+FROM php:8.2-apache
+
+RUN docker-php-ext-install mysqli pdo pdo_mysql \
+ && a2enmod rewrite
+
+COPY . /var/www/html/
+
+RUN mkdir -p /var/www/html/uploads \
+ && chown -R www-data:www-data /var/www/html/uploads \
+ && chown -R www-data:www-data /var/www/html
\ No newline at end of file
diff --git a/webgis-spbu/README.md b/webgis-spbu/README.md
new file mode 100644
index 0000000..d133633
--- /dev/null
+++ b/webgis-spbu/README.md
@@ -0,0 +1,32 @@
+# WebGIS Sebaran Penduduk Miskin + Rumah Ibadah
+
+Stack: PHP (XAMPP), MySQL/MariaDB, LeafletJS + Leaflet.draw (tanpa framework).
+
+## Fitur
+- Peta interaktif (fullscreen) + layer: rumah ibadah, radius, penduduk miskin.
+- CRUD lewat aksi di peta (klik untuk point, popup untuk edit/hapus, edit lokasi + drag).
+- Reverse geocoding (server proxy `php/reverse_geocode.php` ke Nominatim).
+- Auto-relasi penduduk miskin β rumah ibadah terdekat **dalam radius** (jarak dihitung otomatis).
+- Marker penduduk miskin:
+ - Merah: Belum dibantu
+ - Kuning: Menunggu verifikasi
+ - Hijau: Sudah dibantu
+ - Outline: menunjukkan inside/outside radius rumah ibadah
+- Form penduduk miskin mendukung anggota keluarga (dinamis) + upload bukti (jpg/png/webp/pdf).
+- Admin panel (login) + CRUD dasar + import/export CSV.
+
+## Instalasi (XAMPP)
+1. Pastikan folder project berada di `htdocs`, misalnya: `C:\\xampp\\htdocs\\webgis-spbu`.
+2. Buat database `webgis_spbu` di phpMyAdmin.
+3. Import schema: `sql/schema_webgis_spbu.sql`.
+4. Sesuaikan konfigurasi DB di `php/koneksi.php`.
+5. Jalankan Apache + MySQL di XAMPP.
+6. Buka aplikasi:
+ - WebGIS: `http://localhost/webgis-spbu/`
+ - Admin: `http://localhost/webgis-spbu/admin/login.php`
+ - Jika belum ada user, jalankan sekali: `http://localhost/webgis-spbu/admin/init_admin.php`
+
+## Catatan Upload Bukti
+- File disimpan ke `uploads/bukti/`.
+- Pastikan folder ini writable oleh Apache.
+
diff --git a/webgis-spbu/admin/_auth.php b/webgis-spbu/admin/_auth.php
new file mode 100644
index 0000000..4a22ccb
--- /dev/null
+++ b/webgis-spbu/admin/_auth.php
@@ -0,0 +1,18 @@
+
';
+ echo '
' . h($title) . ' ';
+ echo '
';
+ echo '';
+ echo '
';
+ echo '
';
+}
+
+function render_footer() {
+ echo '
';
+}
+
diff --git a/webgis-spbu/admin/index.php b/webgis-spbu/admin/index.php
new file mode 100644
index 0000000..6570ce4
--- /dev/null
+++ b/webgis-spbu/admin/index.php
@@ -0,0 +1,53 @@
+ 0,
+ 'penduduk' => 0,
+ 'dibantu' => 0,
+ 'belum' => 0,
+ 'verifikasi' => 0
+];
+
+try {
+ $counts['rumah_ibadah'] = (int)$pdo->query("SELECT COUNT(*) FROM rumah_ibadah_points")->fetchColumn();
+ $cols = $pdo->query("SHOW COLUMNS FROM pemukiman_miskin_points")->fetchAll(PDO::FETCH_COLUMN, 0);
+ $hasNew = in_array('status_bantuan', $cols, true);
+ $counts['penduduk'] = (int)$pdo->query("SELECT COUNT(*) FROM pemukiman_miskin_points")->fetchColumn();
+ if ($hasNew) {
+ $counts['dibantu'] = (int)$pdo->query("SELECT COUNT(*) FROM pemukiman_miskin_points WHERE status_bantuan='Sudah dibantu'")->fetchColumn();
+ $counts['belum'] = (int)$pdo->query("SELECT COUNT(*) FROM pemukiman_miskin_points WHERE status_bantuan='Belum dibantu'")->fetchColumn();
+ $counts['verifikasi'] = (int)$pdo->query("SELECT COUNT(*) FROM pemukiman_miskin_points WHERE status_bantuan='Menunggu verifikasi'")->fetchColumn();
+ }
+} catch (Exception $e) {
+}
+
+render_header('Admin Dashboard');
+?>
+
+
+
+
= (int)$counts['rumah_ibadah'] ?>
+
Rumah Ibadah
+
+
+
= (int)$counts['penduduk'] ?>
+
Penduduk Miskin
+
+
+
= (int)$counts['dibantu'] ?>
+
Sudah dibantu
+
+
+
= (int)$counts['belum'] ?>
+
Belum dibantu
+
+
+
+ Menu admin ini melengkapi WebGIS utama: `index.html`.
+
+
+
+
diff --git a/webgis-spbu/admin/init_admin.php b/webgis-spbu/admin/init_admin.php
new file mode 100644
index 0000000..08b8c8a
--- /dev/null
+++ b/webgis-spbu/admin/init_admin.php
@@ -0,0 +1,34 @@
+exec("
+ CREATE TABLE IF NOT EXISTS users (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ name VARCHAR(120) NOT NULL,
+ username VARCHAR(60) NOT NULL UNIQUE,
+ password_hash VARCHAR(255) NOT NULL,
+ role VARCHAR(20) NOT NULL DEFAULT 'operator',
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
+ ");
+
+ $count = (int)$pdo->query("SELECT COUNT(*) FROM users")->fetchColumn();
+ if ($count > 0) {
+ echo "Users sudah ada. Silakan login di
login.php .";
+ exit;
+ }
+
+ $username = 'admin';
+ $password = 'admin123';
+ $hash = password_hash($password, PASSWORD_DEFAULT);
+ $stmt = $pdo->prepare("INSERT INTO users (name, username, password_hash, role) VALUES (:n,:u,:p,'admin')");
+ $stmt->execute([':n' => 'Administrator', ':u' => $username, ':p' => $hash]);
+
+ echo "Admin dibuat. Username:
admin , Password:
admin123 . Lanjut login:
login.php ";
+} catch (Exception $e) {
+ echo "Gagal init admin: " . htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8');
+}
+
diff --git a/webgis-spbu/admin/login.php b/webgis-spbu/admin/login.php
new file mode 100644
index 0000000..c5870e1
--- /dev/null
+++ b/webgis-spbu/admin/login.php
@@ -0,0 +1,62 @@
+prepare("SELECT id, name, username, password_hash, role FROM users WHERE username = :u LIMIT 1");
+ $stmt->execute([':u' => $username]);
+ $u = $stmt->fetch();
+ if ($u && password_verify($password, $u['password_hash'])) {
+ $_SESSION['user'] = ['id' => (int)$u['id'], 'name' => $u['name'], 'username' => $u['username'], 'role' => $u['role']];
+ header('Location: index.php');
+ exit;
+ }
+ $error = 'Username atau password salah.';
+ } catch (Exception $e) {
+ $error = $e->getMessage();
+ }
+}
+
+?>
+
+
+
+
+
+
Login Admin
+
+
+
+
+
+
Login Admin
+
+
= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?>
+
+ Username
+
+ Password
+
+ Masuk
+
+
+
+
+
diff --git a/webgis-spbu/admin/logout.php b/webgis-spbu/admin/logout.php
new file mode 100644
index 0000000..c8f7435
--- /dev/null
+++ b/webgis-spbu/admin/logout.php
@@ -0,0 +1,6 @@
+query("SHOW COLUMNS FROM pemukiman_miskin_points")->fetchAll(PDO::FETCH_COLUMN, 0);
+ $hasNew = in_array('kk_nama', $cols, true);
+ $out = fopen('php://output', 'w');
+ if ($hasNew) {
+ fputcsv($out, ['id','kk_nama','nik','jumlah_anggota','lat','lng','alamat','kelurahan','kecamatan','status_bantuan','jenis_bantuan','tanggal_bantuan','rumah_ibadah_id','jarak_meter']);
+ $rows = $pdo->query("SELECT id, kk_nama, nik, jumlah_anggota, latitude, longitude, address, kelurahan, kecamatan, status_bantuan, jenis_bantuan, tanggal_bantuan, rumah_ibadah_id, jarak_meter FROM pemukiman_miskin_points ORDER BY id ASC")->fetchAll();
+ foreach ($rows as $r) {
+ fputcsv($out, [
+ $r['id'], $r['kk_nama'], $r['nik'], $r['jumlah_anggota'],
+ $r['latitude'], $r['longitude'], $r['address'], $r['kelurahan'], $r['kecamatan'],
+ $r['status_bantuan'], $r['jenis_bantuan'], $r['tanggal_bantuan'],
+ $r['rumah_ibadah_id'], $r['jarak_meter']
+ ]);
+ }
+ } else {
+ fputcsv($out, ['id','nama','lat','lng','alamat','rumah_ibadah_id']);
+ $rows = $pdo->query("SELECT id, nama, latitude, longitude, address, rumah_ibadah_id FROM pemukiman_miskin_points ORDER BY id ASC")->fetchAll();
+ foreach ($rows as $r) fputcsv($out, [$r['id'],$r['nama'],$r['latitude'],$r['longitude'],$r['address'],$r['rumah_ibadah_id']]);
+ }
+ fclose($out);
+ exit;
+}
+
+$msg = '';
+$err = '';
+
+// Delete
+if (isset($_GET['delete'])) {
+ $id = (int)$_GET['delete'];
+ try {
+ $stmt = $pdo->prepare("DELETE FROM pemukiman_miskin_points WHERE id=:id");
+ $stmt->execute([':id' => $id]);
+ $msg = 'Berhasil dihapus.';
+ } catch (Exception $e) {
+ $err = $e->getMessage();
+ }
+}
+
+// Import CSV (minimal)
+if (isset($_POST['import_csv'])) {
+ if (!isset($_FILES['csv_file']) || ($_FILES['csv_file']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
+ $err = 'CSV tidak ditemukan.';
+ } else {
+ try {
+ $cols = $pdo->query("SHOW COLUMNS FROM pemukiman_miskin_points")->fetchAll(PDO::FETCH_COLUMN, 0);
+ $hasNew = in_array('kk_nama', $cols, true);
+ $fp = fopen($_FILES['csv_file']['tmp_name'], 'r');
+ $header = fgetcsv($fp);
+ if (!$header) throw new Exception('Header CSV kosong.');
+ $map = [];
+ foreach ($header as $i => $hcol) $map[strtolower(trim($hcol))] = $i;
+ $pdo->beginTransaction();
+ $ins = $hasNew
+ ? $pdo->prepare("INSERT INTO pemukiman_miskin_points (kk_nama, nik, jumlah_anggota, latitude, longitude, address, kelurahan, kecamatan, status_bantuan, jenis_bantuan, tanggal_bantuan) VALUES (:kk,:nik,:j,:lat,:lng,:a,:kel,:kec,:s,:jb,:tb)")
+ : $pdo->prepare("INSERT INTO pemukiman_miskin_points (nama, latitude, longitude, address) VALUES (:kk,:lat,:lng,:a)");
+ $n = 0;
+ while (($row = fgetcsv($fp)) !== false) {
+ $kk = $row[$map['kk_nama'] ?? $map['nama'] ?? -1] ?? '';
+ $lat = $row[$map['lat'] ?? $map['latitude'] ?? -1] ?? '';
+ $lng = $row[$map['lng'] ?? $map['longitude'] ?? -1] ?? '';
+ if (trim($kk) === '' || trim($lat) === '' || trim($lng) === '') continue;
+ if ($hasNew) {
+ $ins->execute([
+ ':kk' => $kk,
+ ':nik' => ($row[$map['nik'] ?? -1] ?? '') ?: null,
+ ':j' => (int)($row[$map['jumlah_anggota'] ?? -1] ?? 0),
+ ':lat' => $lat,
+ ':lng' => $lng,
+ ':a' => ($row[$map['alamat'] ?? $map['address'] ?? -1] ?? '') ?: null,
+ ':kel' => ($row[$map['kelurahan'] ?? -1] ?? '') ?: null,
+ ':kec' => ($row[$map['kecamatan'] ?? -1] ?? '') ?: null,
+ ':s' => ($row[$map['status_bantuan'] ?? -1] ?? 'Belum dibantu') ?: 'Belum dibantu',
+ ':jb' => ($row[$map['jenis_bantuan'] ?? -1] ?? '') ?: null,
+ ':tb' => ($row[$map['tanggal_bantuan'] ?? -1] ?? '') ?: null
+ ]);
+ } else {
+ $ins->execute([
+ ':kk' => $kk,
+ ':lat' => $lat,
+ ':lng' => $lng,
+ ':a' => ($row[$map['alamat'] ?? $map['address'] ?? -1] ?? '') ?: null
+ ]);
+ }
+ $n++;
+ }
+ $pdo->commit();
+ fclose($fp);
+ $msg = "Import selesai: $n baris.";
+ } catch (Exception $e) {
+ if ($pdo->inTransaction()) $pdo->rollBack();
+ $err = $e->getMessage();
+ }
+ }
+}
+
+// Simple edit/create (without anggota details)
+if (isset($_POST['save_penduduk'])) {
+ $id = (int)($_POST['id'] ?? 0);
+ $kk = trim($_POST['kk_nama'] ?? '');
+ $nik = trim($_POST['nik'] ?? '');
+ $jml = (int)($_POST['jumlah_anggota'] ?? 0);
+ $lat = $_POST['latitude'] ?? null;
+ $lng = $_POST['longitude'] ?? null;
+ $addr = trim($_POST['address'] ?? '');
+ $kel = trim($_POST['kelurahan'] ?? '');
+ $kec = trim($_POST['kecamatan'] ?? '');
+ $status = trim($_POST['status_bantuan'] ?? 'Belum dibantu');
+ $jb = trim($_POST['jenis_bantuan'] ?? '');
+ $tb = trim($_POST['tanggal_bantuan'] ?? '');
+
+ if ($kk === '' || $lat === null || $lng === null) $err = 'Data belum lengkap (nama/lat/lng).';
+ else {
+ try {
+ $cols = $pdo->query("SHOW COLUMNS FROM pemukiman_miskin_points")->fetchAll(PDO::FETCH_COLUMN, 0);
+ $hasNew = in_array('kk_nama', $cols, true);
+ if (!$hasNew) throw new Exception('Schema belum mendukung fitur penduduk detail. Jalankan schema terbaru.');
+
+ if ($id > 0) {
+ $stmt = $pdo->prepare("UPDATE pemukiman_miskin_points SET kk_nama=:kk, nik=:nik, jumlah_anggota=:j, latitude=:lat, longitude=:lng, address=:a, kelurahan=:kel, kecamatan=:kec, status_bantuan=:s, jenis_bantuan=:jb, tanggal_bantuan=:tb WHERE id=:id");
+ $stmt->execute([':kk'=>$kk,':nik'=>$nik ?: null,':j'=>$jml,':lat'=>$lat,':lng'=>$lng,':a'=>$addr ?: null,':kel'=>$kel ?: null,':kec'=>$kec ?: null,':s'=>$status ?: 'Belum dibantu',':jb'=>$jb ?: null,':tb'=>$tb ?: null,':id'=>$id]);
+ $msg = 'Berhasil diupdate.';
+ } else {
+ $stmt = $pdo->prepare("INSERT INTO pemukiman_miskin_points (kk_nama, nik, jumlah_anggota, latitude, longitude, address, kelurahan, kecamatan, status_bantuan, jenis_bantuan, tanggal_bantuan) VALUES (:kk,:nik,:j,:lat,:lng,:a,:kel,:kec,:s,:jb,:tb)");
+ $stmt->execute([':kk'=>$kk,':nik'=>$nik ?: null,':j'=>$jml,':lat'=>$lat,':lng'=>$lng,':a'=>$addr ?: null,':kel'=>$kel ?: null,':kec'=>$kec ?: null,':s'=>$status ?: 'Belum dibantu',':jb'=>$jb ?: null,':tb'=>$tb ?: null]);
+ $msg = 'Berhasil ditambahkan.';
+ }
+ } catch (Exception $e) { $err = $e->getMessage(); }
+ }
+}
+
+$edit = null;
+if (isset($_GET['edit'])) {
+ $id = (int)$_GET['edit'];
+ $stmt = $pdo->prepare("SELECT * FROM pemukiman_miskin_points WHERE id=:id");
+ $stmt->execute([':id' => $id]);
+ $edit = $stmt->fetch() ?: null;
+}
+
+$rows = [];
+try {
+ $cols = $pdo->query("SHOW COLUMNS FROM pemukiman_miskin_points")->fetchAll(PDO::FETCH_COLUMN, 0);
+ $hasNew = in_array('kk_nama', $cols, true);
+ if ($hasNew) {
+ $rows = $pdo->query("
+ SELECT p.*, r.nama AS rumah_ibadah_nama
+ FROM pemukiman_miskin_points p
+ LEFT JOIN rumah_ibadah_points r ON r.id=p.rumah_ibadah_id
+ ORDER BY p.id ASC
+ ")->fetchAll();
+ } else {
+ $rows = $pdo->query("SELECT * FROM pemukiman_miskin_points ORDER BY id ASC")->fetchAll();
+ }
+} catch (Exception $e) {}
+
+render_header('Admin Penduduk Miskin');
+?>
+
+
+
= $edit ? 'Edit' : 'Tambah' ?> Penduduk Miskin
+
+
+
= h($msg) ?>
+
= h($err) ?>
+
+
+
+
+
+
+ Jumlah Anggota
+
+
+
+ Status Bantuan
+
+
+ >= h($s) ?>
+
+
+
+
+
+
+
+ Alamat
+
+
+
+
+
+
+
+
+
+
+
Import CSV
+
+
+ Import
+ Kolom minimal: `kk_nama`/`nama`, `lat`, `lng`.
+
+
+
+
+
Daftar Penduduk Miskin
+
+
+
+ ID Kepala Keluarga Status Rumah Ibadah Koordinat Aksi
+
+
+
+
+
+ = (int)$r['id'] ?>
+ = h($r['kk_nama'] ?? $r['nama'] ?? '') ?>
+ = h($r['status_bantuan'] ?? '-') ?>
+ = h($r['rumah_ibadah_nama'] ?? '-') ?>
+ = h(($r['latitude'] ?? '') . ', ' . ($r['longitude'] ?? '')) ?>
+
+ Edit
+ Hapus
+
+
+
+
+
+
+
+
diff --git a/webgis-spbu/admin/rumah_ibadah.php b/webgis-spbu/admin/rumah_ibadah.php
new file mode 100644
index 0000000..c5606c7
--- /dev/null
+++ b/webgis-spbu/admin/rumah_ibadah.php
@@ -0,0 +1,166 @@
+prepare("DELETE FROM rumah_ibadah_points WHERE id=:id");
+ $stmt->execute([':id' => $id]);
+ $msg = 'Berhasil dihapus.';
+ } catch (Exception $e) {
+ $err = $e->getMessage();
+ }
+}
+
+// Save (create/update)
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $id = (int)($_POST['id'] ?? 0);
+ $nama = trim($_POST['nama'] ?? '');
+ $jenis = trim($_POST['jenis'] ?? 'Lainnya');
+ $kontak = trim($_POST['kontak'] ?? '');
+ $radius = (float)($_POST['radius_meter'] ?? 300);
+ $lat = $_POST['latitude'] ?? null;
+ $lng = $_POST['longitude'] ?? null;
+ $address = trim($_POST['address'] ?? '');
+
+ if ($nama === '' || $radius <= 0 || $lat === null || $lng === null) {
+ $err = 'Data belum lengkap (nama/radius/lat/lng).';
+ } else {
+ try {
+ $cols = $pdo->query("SHOW COLUMNS FROM rumah_ibadah_points")->fetchAll(PDO::FETCH_COLUMN, 0);
+ $hasJenis = in_array('jenis', $cols, true);
+ $hasKontak = in_array('kontak', $cols, true);
+
+ if ($id > 0) {
+ if ($hasJenis && $hasKontak) {
+ $stmt = $pdo->prepare("UPDATE rumah_ibadah_points SET nama=:n, jenis=:j, kontak=:k, radius_meter=:r, latitude=:lat, longitude=:lng, address=:a WHERE id=:id");
+ $stmt->execute([':n'=>$nama,':j'=>$jenis ?: 'Lainnya',':k'=>$kontak ?: null,':r'=>$radius,':lat'=>$lat,':lng'=>$lng,':a'=>$address ?: null,':id'=>$id]);
+ } else {
+ $stmt = $pdo->prepare("UPDATE rumah_ibadah_points SET nama=:n, radius_meter=:r, latitude=:lat, longitude=:lng, address=:a WHERE id=:id");
+ $stmt->execute([':n'=>$nama,':r'=>$radius,':lat'=>$lat,':lng'=>$lng,':a'=>$address ?: null,':id'=>$id]);
+ }
+ $msg = 'Berhasil diupdate.';
+ } else {
+ if ($hasJenis && $hasKontak) {
+ $stmt = $pdo->prepare("INSERT INTO rumah_ibadah_points (nama, jenis, kontak, radius_meter, latitude, longitude, address) VALUES (:n,:j,:k,:r,:lat,:lng,:a)");
+ $stmt->execute([':n'=>$nama,':j'=>$jenis ?: 'Lainnya',':k'=>$kontak ?: null,':r'=>$radius,':lat'=>$lat,':lng'=>$lng,':a'=>$address ?: null]);
+ } else {
+ $stmt = $pdo->prepare("INSERT INTO rumah_ibadah_points (nama, radius_meter, latitude, longitude, address) VALUES (:n,:r,:lat,:lng,:a)");
+ $stmt->execute([':n'=>$nama,':r'=>$radius,':lat'=>$lat,':lng'=>$lng,':a'=>$address ?: null]);
+ }
+ $msg = 'Berhasil ditambahkan.';
+ }
+ } catch (Exception $e) {
+ $err = $e->getMessage();
+ }
+ }
+}
+
+$edit = null;
+if (isset($_GET['edit'])) {
+ $id = (int)$_GET['edit'];
+ $stmt = $pdo->prepare("SELECT * FROM rumah_ibadah_points WHERE id=:id");
+ $stmt->execute([':id' => $id]);
+ $edit = $stmt->fetch() ?: null;
+}
+
+$rows = [];
+try {
+ $rows = $pdo->query("
+ SELECT r.*,
+ (SELECT COUNT(*) FROM pemukiman_miskin_points p WHERE p.rumah_ibadah_id=r.id) AS binaan_count
+ FROM rumah_ibadah_points r
+ ORDER BY r.id ASC
+ ")->fetchAll();
+} catch (Exception $e) {}
+
+render_header('Admin Rumah Ibadah');
+?>
+
+
+
= $edit ? 'Edit' : 'Tambah' ?> Rumah Ibadah
+
Reset
+
+
= h($msg) ?>
+
= h($err) ?>
+
+
+
+
+
+ Nama
+
+
+
+ Jenis
+
+
+ >= h($j) ?>
+
+
+
+
+ Kontak
+
+
+
+
+
+
+
+
+
+
+
Daftar Rumah Ibadah
+
+
+
+ ID Nama Jenis Radius Binaan Koordinat Aksi
+
+
+
+
+
+ = (int)$r['id'] ?>
+ = h($r['nama'] ?? '') ?>
+ = h($r['jenis'] ?? 'Lainnya') ?>
+ = (int)($r['radius_meter'] ?? 0) ?> m
+ = (int)($r['binaan_count'] ?? 0) ?>
+ = h(($r['latitude'] ?? '') . ', ' . ($r['longitude'] ?? '')) ?>
+
+ Edit
+ Hapus
+
+
+
+
+
+
+
+
diff --git a/webgis-spbu/css/style.css b/webgis-spbu/css/style.css
new file mode 100644
index 0000000..58913d7
--- /dev/null
+++ b/webgis-spbu/css/style.css
@@ -0,0 +1,779 @@
+:root {
+ --bg: #0d1520;
+ --bg-soft: #10182c;
+ --bg-card: #141e2e;
+ --card: #ffffff;
+ --text: #e5e7eb;
+ --text-secondary: #cbd5e1;
+ --muted: #8b94a5;
+ --line: #e2e8f0;
+ --primary: #3b82f6;
+ --primary-dark: #1d4ed8;
+ --primary-light: #60a5fa;
+ --secondary: #6b7280;
+ --danger: #ef4444;
+ --success: #10b981;
+ --warning: #f59e0b;
+ --shadow: 0 20px 50px rgba(0, 0, 0, 0.25);
+ --shadow-sm: 0 4px 12px rgba(0, 0, 0, 0.1);
+ --shadow-lg: 0 25px 60px rgba(0, 0, 0, 0.3);
+ --radius: 16px;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html, body {
+ height: 100%;
+ margin: 0;
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
+ background: #0f172a;
+ color: #0f172a;
+}
+
+body.crosshair #map {
+ cursor: crosshair;
+}
+
+.hidden {
+ display: none !important;
+}
+
+.app-shell {
+ display: grid;
+ grid-template-columns: 320px 1fr;
+ height: 100vh;
+}
+
+.sidebar {
+ background: var(--bg);
+ color: var(--text);
+ padding: 20px;
+ overflow-y: auto;
+ border-right: 1px solid rgba(255,255,255,0.05);
+ box-shadow: inset 0 0 40px rgba(0,0,0,0.2);
+}
+.sidebar::-webkit-scrollbar { width: 6px; }
+.sidebar::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 6px; }
+
+.btn-back {
+ display: block;
+ text-align: center;
+ background: rgba(255,255,255,0.05);
+ border: 1px solid rgba(255,255,255,0.1);
+ color: var(--text-secondary);
+ border-radius: 12px;
+ padding: 10px;
+ font-size: 13px;
+ margin-bottom: 20px;
+ text-decoration: none;
+ font-weight: 600;
+ transition: all 0.2s;
+}
+.btn-back:hover { background: rgba(255,255,255,0.1); color: #fff; }
+
+.brand {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ margin-bottom: 24px;
+ padding-bottom: 18px;
+ border-bottom: 1px solid rgba(255,255,255,0.1);
+}
+
+.brand-badge {
+ width: 56px;
+ height: 56px;
+ border-radius: 14px;
+ display: grid;
+ place-items: center;
+ background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
+ color: #fff;
+ font-weight: 900;
+ font-size: 16px;
+ box-shadow: 0 8px 24px rgba(59, 130, 246, 0.3);
+ letter-spacing: -0.5px;
+}
+
+.brand h1 {
+ font-family: 'Outfit', sans-serif;
+ font-size: 20px;
+ margin: 0;
+ font-weight: 800;
+ background: linear-gradient(to right, #60a5fa, #34d399);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ letter-spacing: -0.5px;
+}
+
+.brand p {
+ margin: 4px 0 0;
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 500;
+ letter-spacing: 0.3px;
+}
+
+.mode-info {
+ padding: 14px 16px;
+ background: linear-gradient(135deg, rgba(59, 130, 246, 0.15) 0%, rgba(139, 92, 246, 0.1) 100%);
+ border: 1px solid rgba(147, 197, 253, 0.3);
+ border-radius: 12px;
+ margin-bottom: 22px;
+ font-size: 14px;
+ color: #e0f2fe;
+ font-weight: 600;
+}
+
+.action-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+ margin-bottom: 24px;
+}
+
+.btn {
+ appearance: none;
+ border: 0;
+ padding: 12px 10px;
+ border-radius: 12px;
+ font-weight: 600;
+ font-size: 12px;
+ cursor: pointer;
+ transition: all .2s cubic-bezier(0.4, 0, 0.2, 1);
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+ text-align: center;
+}
+.btn i { font-size: 16px; margin-bottom: 2px; }
+
+.btn:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 8px 20px rgba(0,0,0,0.25);
+}
+
+.btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ transform: none;
+}
+
+.btn-primary {
+ background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
+ color: #fff;
+}
+
+.btn-secondary {
+ background: rgba(255,255,255,0.03);
+ color: var(--text-secondary);
+ border: 1px solid rgba(255,255,255,0.08);
+}
+.btn-secondary:hover {
+ background: rgba(255,255,255,0.08);
+ color: #fff;
+ border-color: rgba(255,255,255,0.15);
+}
+
+.btn-danger {
+ background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
+ color: #fff;
+}
+
+.btn.is-active {
+ outline: 2px solid rgba(147, 197, 253, 0.6);
+ outline-offset: 2px;
+ box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.2), 0 4px 12px rgba(0,0,0,0.15);
+}
+
+.stats {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+ margin-bottom: 24px;
+}
+
+.stat-card {
+ background: linear-gradient(135deg, rgba(255,255,255,0.08) 0%, rgba(255,255,255,0.04) 100%);
+ border: 1px solid rgba(255,255,255,0.12);
+ border-radius: 14px;
+ padding: 16px 18px;
+ display: grid;
+ gap: 4px;
+ backdrop-filter: blur(8px);
+ transition: all .2s ease;
+}
+
+.stat-value {
+ font-size: 28px;
+ font-weight: 900;
+ color: #fff;
+ letter-spacing: -0.5px;
+}
+
+.stat-label {
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0.2px;
+}
+
+.panel {
+ background: rgba(255,255,255,0.02);
+ border: 1px solid rgba(255,255,255,0.06);
+ border-radius: 14px;
+ padding: 16px 18px;
+ margin-bottom: 16px;
+ backdrop-filter: blur(8px);
+}
+
+.panel h2 {
+ font-size: 14px;
+ margin: 0 0 12px;
+ color: #fff;
+ font-weight: 800;
+ letter-spacing: -0.3px;
+}
+
+.legend-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin: 8px 0;
+ font-size: 13px;
+ color: var(--muted);
+ font-weight: 500;
+}
+
+.legend-sep {
+ margin-top: 12px;
+}
+
+.swatch {
+ width: 16px;
+ height: 16px;
+ border-radius: 5px;
+ display: inline-block;
+}
+
+.nasional { background: #ef4444; }
+.provinsi { background: #3b82f6; }
+.kabupaten { background: #22c55e; }
+.spbu24 { background: #22c55e; }
+.spbuNon { background: #ef4444; }
+.shm { background: #f59e0b; }
+.hgb { background: #8b5cf6; }
+.hgu { background: #14b8a6; }
+.hp { background: #94a3b8; }
+.ri { background: #7c3aed; }
+.pmGreen { background: #16a34a; }
+.pmRed { background: #dc2626; }
+
+.help-box {
+ background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(139, 92, 246, 0.05) 100%);
+ border: 1px solid rgba(147, 197, 253, 0.3);
+ color: #e0f2fe;
+ padding: 16px 18px;
+ border-radius: 12px;
+ font-size: 13px;
+ font-weight: 500;
+ line-height: 1.6;
+ backdrop-filter: blur(8px);
+}
+
+.list {
+ display: grid;
+ gap: 8px;
+ max-height: 220px;
+ overflow: auto;
+ padding-right: 6px;
+}
+
+.list::-webkit-scrollbar {
+ width: 10px;
+}
+
+.list::-webkit-scrollbar-thumb {
+ background: rgba(148, 163, 184, 0.35);
+ border-radius: 999px;
+ border: 2px solid rgba(15, 23, 42, 0.3);
+}
+
+.list-item {
+ width: 100%;
+ text-align: left;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ background: rgba(255, 255, 255, 0.05);
+ color: #e5e7eb;
+ border-radius: 12px;
+ padding: 11px 13px;
+ cursor: pointer;
+ transition: all .2s ease;
+}
+
+.list-item:hover {
+ transform: translateX(4px);
+ border-color: rgba(255, 255, 255, 0.2);
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.list-item.is-active {
+ border-color: rgba(147, 197, 253, 0.55);
+ background: rgba(37, 99, 235, 0.16);
+}
+
+.list-title {
+ font-weight: 800;
+ font-size: 13px;
+ color: #fff;
+ letter-spacing: -0.2px;
+}
+
+.list-meta {
+ margin-top: 4px;
+ font-size: 11px;
+ color: var(--muted);
+ font-weight: 500;
+}
+
+.list-empty {
+ font-size: 13px;
+ color: #cbd5e1;
+ padding: 10px 12px;
+ border-radius: 14px;
+ border: 1px dashed rgba(255, 255, 255, 0.14);
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.spbu-icon {
+ background: transparent;
+ border: 0;
+}
+
+.spbu-dot {
+ width: 14px;
+ height: 14px;
+ border-radius: 999px;
+ border: 2px solid #ffffff;
+ box-shadow: 0 10px 18px rgba(15, 23, 42, 0.22);
+}
+
+.spbu-dot--green { background: #22c55e; }
+.spbu-dot--red { background: #ef4444; }
+
+.pm-icon {
+ background: transparent;
+ border: 0;
+}
+
+.pm-hit {
+ width: var(--pm-size, 30px);
+ height: var(--pm-size, 30px);
+ border-radius: 999px;
+ background: #0f172a;
+ border: 2px solid #ffffff;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 0 14px 22px rgba(15, 23, 42, 0.22);
+ cursor: grab;
+ touch-action: none;
+}
+
+.pm-hit--green {
+ background: #16a34a;
+}
+
+.pm-hit--red {
+ background: #dc2626;
+}
+
+.pm-hit:active {
+ cursor: grabbing;
+}
+
+.pm-svg {
+ display: block;
+}
+
+.ri-icon {
+ background: transparent;
+ border: 0;
+}
+
+.ri-hit {
+ width: 24px;
+ height: 24px;
+ border-radius: 10px;
+ background: #7c3aed;
+ border: 2px solid #ffffff;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 0 12px 18px rgba(15, 23, 42, 0.18);
+}
+
+.ri-svg {
+ display: block;
+}
+
+.map-area {
+ position: relative;
+ background: #e2e8f0;
+}
+
+.map-toolbar {
+ position: absolute;
+ top: 14px;
+ right: 14px;
+ z-index: 1200;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 8px;
+ border-radius: 16px;
+ background: rgba(255, 255, 255, 0.92);
+ border: 1px solid rgba(226, 232, 240, 0.9);
+ box-shadow: 0 14px 30px rgba(15, 23, 42, 0.12);
+ backdrop-filter: blur(8px);
+}
+
+.map-toolbar-row {
+ display: flex;
+ gap: 8px;
+}
+
+.map-tool {
+ border: 0;
+ background: #0f172a;
+ color: #fff;
+ padding: 9px 12px;
+ border-radius: 14px;
+ cursor: pointer;
+ font-weight: 800;
+ font-size: 13px;
+ transition: transform .15s ease, opacity .15s ease;
+}
+
+.map-tool:hover {
+ transform: translateY(-1px);
+ opacity: 0.96;
+}
+
+.map-tool:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+ transform: none;
+}
+
+.map-tool.is-active {
+ background: linear-gradient(135deg, #0f172a 0%, #1f2a44 100%);
+ box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.12);
+}
+
+.edit-bar {
+ position: absolute;
+ bottom: 14px;
+ right: 14px;
+ z-index: 800;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 10px 10px 12px;
+ border-radius: 18px;
+ background: rgba(255, 255, 255, 0.94);
+ border: 1px solid rgba(226, 232, 240, 0.95);
+ box-shadow: 0 14px 30px rgba(15, 23, 42, 0.14);
+ backdrop-filter: blur(8px);
+}
+
+.edit-bar-title {
+ font-weight: 900;
+ font-size: 13px;
+ color: #0f172a;
+ margin-right: 2px;
+}
+
+.edit-bar-btn {
+ border: 0;
+ background: #e2e8f0;
+ color: #0f172a;
+ padding: 9px 12px;
+ border-radius: 14px;
+ cursor: pointer;
+ font-weight: 900;
+ font-size: 13px;
+ transition: transform .15s ease, opacity .15s ease;
+}
+
+.edit-bar-btn.primary {
+ background: linear-gradient(135deg, var(--primary), var(--primary-dark));
+ color: #fff;
+}
+
+.edit-bar-btn.danger {
+ background: linear-gradient(135deg, var(--danger), #b91c1c);
+ color: #fff;
+}
+
+.edit-bar-btn.is-active {
+ outline: 2px solid rgba(15, 23, 42, 0.22);
+ box-shadow: 0 0 0 5px rgba(239, 68, 68, 0.12), 0 14px 30px rgba(15, 23, 42, 0.12);
+}
+
+.edit-bar-btn:hover {
+ transform: translateY(-1px);
+ opacity: 0.96;
+}
+
+.edit-bar-btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ transform: none;
+}
+
+#map {
+ width: 100%;
+ height: 100%;
+ position: relative;
+ z-index: 0;
+}
+
+.leaflet-control-container .leaflet-draw {
+ display: none !important;
+}
+
+.modal {
+ position: fixed;
+ inset: 0;
+ background: rgba(15, 23, 42, 0.54);
+ display: grid;
+ place-items: center;
+ z-index: 9999;
+ padding: 20px;
+}
+
+.modal-card {
+ width: 100%;
+ max-width: 540px;
+ background: #fff;
+ border-radius: 22px;
+ box-shadow: var(--shadow);
+ overflow: hidden;
+}
+
+.modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 18px 20px;
+ border-bottom: 1px solid var(--line);
+ background: linear-gradient(180deg, #f8fafc 0%, #fff 100%);
+}
+
+.modal-header h3 {
+ margin: 0;
+ font-size: 18px;
+}
+
+.close-btn {
+ border: 0;
+ background: transparent;
+ font-size: 30px;
+ line-height: 1;
+ cursor: pointer;
+ color: #475569;
+}
+
+#featureForm {
+ padding: 18px 20px 20px;
+}
+
+.form-section {
+ display: grid;
+ gap: 10px;
+}
+
+.form-section h4 {
+ margin: 0 0 6px;
+ font-size: 15px;
+ color: #0f172a;
+}
+
+label {
+ font-size: 13px;
+ font-weight: 700;
+ color: #334155;
+}
+
+input, textarea, select {
+ width: 100%;
+ border: 1px solid #cbd5e1;
+ background: #fff;
+ border-radius: 14px;
+ padding: 11px 12px;
+ font: inherit;
+ color: #0f172a;
+}
+
+input[readonly] {
+ background: #f8fafc;
+ color: #475569;
+}
+
+.checkbox-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 10px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ background: rgba(255,255,255,0.03);
+ border-radius: 10px;
+ margin-bottom: 6px;
+ font-size: 13px;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+.checkbox-row:hover { background: rgba(255,255,255,0.06); color: #fff; }
+
+.checkbox-row input {
+ accent-color: var(--primary);
+ width: 16px;
+ height: 16px;
+ cursor: pointer;
+}
+
+.grid-2 {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+}
+
+.notice {
+ margin: 0 0 14px;
+ padding: 12px 14px;
+ border-radius: 14px;
+ background: #eff6ff;
+ color: #1d4ed8;
+ border: 1px solid #bfdbfe;
+ font-size: 14px;
+ line-height: 1.45;
+}
+
+.modal-actions {
+ display: flex;
+ gap: 10px;
+ margin-top: 18px;
+}
+
+.modal-actions .btn {
+ flex: 1;
+ box-shadow: none;
+}
+
+.leaflet-popup-content-wrapper {
+ border-radius: 14px;
+}
+
+.leaflet-popup-content {
+ margin: 12px 14px;
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+.popup-actions {
+ display: flex;
+ gap: 8px;
+ margin-top: 10px;
+ flex-wrap: wrap;
+}
+
+.popup-btn {
+ border: 0;
+ padding: 7px 10px;
+ border-radius: 10px;
+ cursor: pointer;
+ font-weight: 700;
+ color: #fff;
+}
+
+.popup-btn.edit { background: #2563eb; }
+.popup-btn.spatial { background: #7c3aed; }
+.popup-btn.delete { background: #dc2626; }
+
+.toast {
+ position: fixed;
+ left: calc(320px + 16px);
+ bottom: 16px;
+ z-index: 10000;
+ max-width: min(520px, calc(100vw - 32px));
+ padding: 12px 14px;
+ border-radius: 16px;
+ box-shadow: 0 18px 45px rgba(15, 23, 42, 0.22);
+ border: 1px solid rgba(226, 232, 240, 0.9);
+ background: rgba(255, 255, 255, 0.96);
+ color: #0f172a;
+ font-weight: 700;
+ line-height: 1.35;
+ cursor: pointer;
+ word-break: break-word;
+}
+
+.toast.toast-success {
+ border-color: rgba(34, 197, 94, 0.35);
+}
+
+.toast.toast-error {
+ border-color: rgba(239, 68, 68, 0.35);
+}
+
+.toast.toast-info {
+ border-color: rgba(59, 130, 246, 0.35);
+}
+
+@media (max-width: 960px) {
+ .app-shell {
+ grid-template-columns: 1fr;
+ grid-template-rows: auto 1fr;
+ }
+
+ .sidebar {
+ border-right: 0;
+ border-bottom: 1px solid rgba(255,255,255,0.05);
+ padding: 20px;
+ }
+
+ .action-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .stats {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+
+ .map-area {
+ min-height: 60vh;
+ }
+
+ .map-toolbar {
+ top: 10px;
+ right: 10px;
+ left: 10px;
+ justify-content: space-between;
+ }
+
+ .edit-bar {
+ left: 10px;
+ right: 10px;
+ bottom: 10px;
+ justify-content: space-between;
+ }
+
+ .toast {
+ left: 16px;
+ }
+}
diff --git a/webgis-spbu/fetch_geojson.js b/webgis-spbu/fetch_geojson.js
new file mode 100644
index 0000000..0dde7e3
--- /dev/null
+++ b/webgis-spbu/fetch_geojson.js
@@ -0,0 +1,61 @@
+const https = require('https');
+const fs = require('fs');
+
+const kecamatans = [
+ "Kecamatan Pontianak Barat",
+ "Kecamatan Pontianak Kota",
+ "Kecamatan Pontianak Selatan",
+ "Kecamatan Pontianak Tenggara",
+ "Kecamatan Pontianak Timur",
+ "Kecamatan Pontianak Utara"
+];
+
+const featureCollection = {
+ type: "FeatureCollection",
+ features: []
+};
+
+let completed = 0;
+
+function fetchGeojson(name) {
+ const url = `https://nominatim.openstreetmap.org/search.php?q=${encodeURIComponent(name)}&polygon_geojson=1&format=jsonv2`;
+ const options = {
+ headers: { 'User-Agent': 'WebGIS-SPBU-App/1.0' }
+ };
+
+ https.get(url, options, (res) => {
+ let data = '';
+ res.on('data', chunk => data += chunk);
+ res.on('end', () => {
+ const results = JSON.parse(data);
+ const geomResult = results.find(r => r.geojson && r.category === 'boundary' && r.type === 'administrative');
+ if (geomResult) {
+ featureCollection.features.push({
+ type: "Feature",
+ properties: {
+ Kecamatan: name.replace('Kecamatan ', ''),
+ Name: name
+ },
+ geometry: geomResult.geojson
+ });
+ console.log("Fetched " + name);
+ } else {
+ console.log("Not found for " + name);
+ }
+ completed++;
+ if (completed === kecamatans.length) {
+ fs.writeFileSync('js/pontianak_kecamatan.geojson', JSON.stringify(featureCollection, null, 2));
+ console.log("All done! Saved to js/pontianak_kecamatan.geojson");
+ }
+ });
+ }).on('error', (e) => {
+ console.error(e);
+ });
+}
+
+// Nominatim has a usage policy of 1 request per second. We will stagger the requests.
+kecamatans.forEach((kec, i) => {
+ setTimeout(() => {
+ fetchGeojson(kec);
+ }, i * 1500); // 1.5 seconds apart
+});
diff --git a/webgis-spbu/fetch_osm.js b/webgis-spbu/fetch_osm.js
new file mode 100644
index 0000000..b8dbbd1
--- /dev/null
+++ b/webgis-spbu/fetch_osm.js
@@ -0,0 +1,29 @@
+const https = require('https');
+const fs = require('fs');
+
+const query = `[out:json];
+area["name"="Pontianak"]->.searchArea;
+(
+ relation["admin_level"="6"](area.searchArea);
+);
+out geom;`;
+
+const options = {
+ hostname: 'overpass-api.de',
+ path: '/api/interpreter',
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ }
+};
+
+const req = https.request(options, (res) => {
+ let data = '';
+ res.on('data', (chunk) => { data += chunk; });
+ res.on('end', () => {
+ fs.writeFileSync('osm_pontianak.json', data);
+ console.log('Done downloading OSM data.');
+ });
+});
+req.write(query);
+req.end();
diff --git a/webgis-spbu/index.html b/webgis-spbu/index.html
new file mode 100644
index 0000000..2184e29
--- /dev/null
+++ b/webgis-spbu/index.html
@@ -0,0 +1,359 @@
+
+
+
+
+
+
WebGIS SPBU, Jalan, Parsil, Rumah Ibadah, dan Pemukiman
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Edit Lokasi
+
Hapus Vertex
+
Simpan Lokasi
+
Batal
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Jalan
+
+ Nama Jalan
+
+
+ Status
+
+ Nasional
+ Provinsi
+ Kabupaten
+
+
+
+
+
+
Parsil
+
+ Status Kepemilikan
+
+ SHM
+ HGB
+ HGU
+ HP
+
+
+
+
+
+
+
+
+
+
+
+ Simpan Data
+ Batal
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/webgis-spbu/js/app.js b/webgis-spbu/js/app.js
new file mode 100644
index 0000000..6dd6e0a
--- /dev/null
+++ b/webgis-spbu/js/app.js
@@ -0,0 +1,2874 @@
+// ================= CONFIG =================
+const INITIAL_CENTER = [-0.0263, 109.3425];
+const INITIAL_ZOOM = 13;
+
+// ================= MAP =================
+const map = L.map('map').setView(INITIAL_CENTER, INITIAL_ZOOM);
+L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+ maxZoom: 20,
+ attribution: '© OpenStreetMap contributors'
+}).addTo(map);
+
+// ================= GROUPS =================
+const editableGroup = L.featureGroup().addTo(map); // pending draw layer (unsaved)
+const points24Layer = L.layerGroup().addTo(map);
+const pointsNon24Layer = L.layerGroup().addTo(map);
+const jalanLayer = L.layerGroup().addTo(map);
+const parsilLayer = L.layerGroup().addTo(map);
+const pemukimanLayer = L.layerGroup().addTo(map);
+const rumahIbadahLayer = L.layerGroup().addTo(map);
+const rumahIbadahRadiusLayer = L.layerGroup().addTo(map);
+const sektorLayer = L.layerGroup().addTo(map);
+
+// ================= STATE =================
+const state = {
+ featureType: null, // point | jalan | parsil
+ currentLayer: null, // LatLng | L.Layer
+ pendingLayer: null, // L.Layer (polyline/polygon) before saved
+ activeDrawer: null,
+ pointFilter: 'all', // all | 24 | non24
+ pointsData: [],
+ pemukimanData: [],
+ rumahIbadahData: [],
+ counts: { points: 0, jalan: 0, parsil: 0, pemukiman: 0, rumahIbadah: 0, sektor: 0 },
+ indices: {
+ points: new Map(),
+ jalan: new Map(),
+ parsil: new Map(),
+ pemukiman: new Map(),
+ rumahIbadah: new Map(),
+ sektor: new Map()
+ },
+ selected: {
+ points: null,
+ jalan: null,
+ parsil: null,
+ pemukiman: null,
+ rumahIbadah: null
+ },
+ editing: null, // { type, id, layer, backup } for Edit Data (modal)
+ locationEditing: null // { type, id, layer, backup, attrs, translate, vertexDeleteMode }
+};
+
+const warned = new Set();
+function warnOnce(key, message) {
+ if (warned.has(key)) return;
+ warned.add(key);
+ showToast(message, 'error', 6500);
+}
+
+// ================= DRAW (kept, but toolbar is hidden via CSS) =================
+const drawControl = new L.Control.Draw({
+ draw: {
+ polyline: true,
+ polygon: true,
+ marker: false,
+ rectangle: false,
+ circle: false,
+ circlemarker: false
+ },
+ edit: { featureGroup: editableGroup }
+});
+map.addControl(drawControl);
+
+// ================= DOM HELPERS =================
+const el = {
+ modal: document.getElementById('featureModal'),
+ modalTitle: document.getElementById('modalTitle'),
+ modeInfo: document.getElementById('modeInfo'),
+ toast: document.getElementById('toast'),
+ countPoints: document.getElementById('countPoints'),
+ countJalan: document.getElementById('countJalan'),
+ countParsil: document.getElementById('countParsil'),
+ countRumahIbadah: document.getElementById('countRumahIbadah'),
+ countPemukiman: document.getElementById('countPemukiman'),
+ countSektor: document.getElementById('countSektor'),
+ togglePoints: document.getElementById('togglePoints'),
+ toggleJalan: document.getElementById('toggleJalan'),
+ toggleParsil: document.getElementById('toggleParsil'),
+ toggleRumahIbadah: document.getElementById('toggleRumahIbadah'),
+ togglePemukiman: document.getElementById('togglePemukiman'),
+ toggleSektor: document.getElementById('toggleSektor'),
+ pointsList: document.getElementById('pointsList'),
+ jalanList: document.getElementById('jalanList'),
+ parsilList: document.getElementById('parsilList'),
+ rumahIbadahList: document.getElementById('rumahIbadahList'),
+ pemukimanList: document.getElementById('pemukimanList'),
+ sektorList: document.getElementById('sektorList'),
+ btnModePoint: document.getElementById('btnModePoint'),
+ btnModeJalan: document.getElementById('btnModeJalan'),
+ btnModeParsil: document.getElementById('btnModeParsil'),
+ btnModeRumahIbadah: document.getElementById('btnModeRumahIbadah'),
+ btnModePemukiman: document.getElementById('btnModePemukiman'),
+ btnResetMode: document.getElementById('btnResetMode'),
+ btnResetView: document.getElementById('btnResetView'),
+ btnMyLocation: document.getElementById('btnMyLocation'),
+ btnFilter24: document.getElementById('btnFilter24'),
+ btnFilterNon24: document.getElementById('btnFilterNon24'),
+ editBar: document.getElementById('editBar'),
+ editBarTitle: document.getElementById('editBarTitle'),
+ btnSaveLocation: document.getElementById('btnSaveLocation'),
+ btnCancelLocation: document.getElementById('btnCancelLocation'),
+ btnDeleteVertex: document.getElementById('btnDeleteVertex'),
+ pointFields: document.getElementById('pointFields'),
+ roadFields: document.getElementById('roadFields'),
+ parsilFields: document.getElementById('parsilFields'),
+ rumahIbadahFields: document.getElementById('rumahIbadahFields'),
+ pemukimanFields: document.getElementById('pemukimanFields'),
+ featureForm: document.getElementById('featureForm'),
+ formNotice: document.getElementById('formNotice'),
+ btnSubmitFeature: document.getElementById('btnSubmitFeature'),
+ statusJalan: document.getElementById('statusJalan'),
+ statusParsil: document.getElementById('statusParsil'),
+ namaPoint: document.getElementById('namaPoint'),
+ noPoint: document.getElementById('noPoint'),
+ deskripsiPoint: document.getElementById('deskripsiPoint'),
+ status24Jam: document.getElementById('status24Jam'),
+ latPoint: document.getElementById('latPoint'),
+ lngPoint: document.getElementById('lngPoint'),
+ namaJalan: document.getElementById('namaJalan'),
+
+ // Rumah Ibadah
+ namaRumahIbadah: document.getElementById('namaRumahIbadah'),
+ jenisRumahIbadah: document.getElementById('jenisRumahIbadah'),
+ kontakRumahIbadah: document.getElementById('kontakRumahIbadah'),
+ radiusRumahIbadah: document.getElementById('radiusRumahIbadah'),
+ alamatRumahIbadah: document.getElementById('alamatRumahIbadah'),
+ latRumahIbadah: document.getElementById('latRumahIbadah'),
+ lngRumahIbadah: document.getElementById('lngRumahIbadah'),
+
+ // Pemukiman Miskin
+ kkNamaPemukiman: document.getElementById('kkNamaPemukiman'),
+ nikPemukiman: document.getElementById('nikPemukiman'),
+ jumlahAnggotaPemukiman: document.getElementById('jumlahAnggotaPemukiman'),
+ statusBantuanPemukiman: document.getElementById('statusBantuanPemukiman'),
+ jenisBantuanPemukiman: document.getElementById('jenisBantuanPemukiman'),
+ tanggalBantuanPemukiman: document.getElementById('tanggalBantuanPemukiman'),
+ alamatPemukiman: document.getElementById('alamatPemukiman'),
+ kelurahanPemukiman: document.getElementById('kelurahanPemukiman'),
+ kecamatanPemukiman: document.getElementById('kecamatanPemukiman'),
+ latPemukiman: document.getElementById('latPemukiman'),
+ lngPemukiman: document.getElementById('lngPemukiman')
+};
+
+let toastTimer = null;
+function showToast(message, type = 'info', timeoutMs = 3500) {
+ if (!el.toast) return;
+ if (!el.toast.__dismissBound) {
+ el.toast.__dismissBound = true;
+ el.toast.title = 'Klik untuk menutup';
+ el.toast.addEventListener('click', () => {
+ el.toast.classList.add('hidden');
+ });
+ }
+ if (toastTimer) window.clearTimeout(toastTimer);
+
+ el.toast.classList.remove('hidden', 'toast-success', 'toast-error', 'toast-info');
+ el.toast.classList.add(`toast-${type}`);
+ el.toast.textContent = message;
+
+ toastTimer = window.setTimeout(() => {
+ el.toast.classList.add('hidden');
+ }, timeoutMs);
+}
+
+function setFormNotice(message, type = 'info') {
+ if (!el.formNotice) return;
+ el.formNotice.textContent = message || '';
+ el.formNotice.classList.remove('hidden');
+ el.formNotice.style.borderColor =
+ type === 'error' ? '#fecaca' :
+ type === 'success' ? '#bbf7d0' :
+ '#bfdbfe';
+ el.formNotice.style.background =
+ type === 'error' ? '#fef2f2' :
+ type === 'success' ? '#f0fdf4' :
+ '#eff6ff';
+ el.formNotice.style.color =
+ type === 'error' ? '#991b1b' :
+ type === 'success' ? '#166534' :
+ '#1d4ed8';
+}
+
+function clearFormNotice() {
+ if (!el.formNotice) return;
+ el.formNotice.textContent = '';
+ el.formNotice.classList.add('hidden');
+}
+
+function setSubmitting(isSubmitting) {
+ if (!el.btnSubmitFeature) return;
+ el.btnSubmitFeature.disabled = Boolean(isSubmitting);
+ el.btnSubmitFeature.textContent = isSubmitting ? 'Menyimpan...' : 'Simpan Data';
+}
+
+function escapeHtml(value) {
+ return String(value ?? '')
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+}
+
+function parseApiJson(response) {
+ // Support both {success:true} and {status:"success"} styles.
+ const success = Boolean(response?.success) || response?.status === 'success';
+ const message =
+ response?.message ||
+ (success ? 'Berhasil' : 'Gagal memproses permintaan');
+ return { success, message };
+}
+
+async function fetchMaybeJson(url, options) {
+ const res = await fetch(url, options);
+ const text = await res.text();
+ try {
+ return { res, json: JSON.parse(text), text };
+ } catch {
+ return { res, json: null, text };
+ }
+}
+
+function getJalanColor(status) {
+ if (status === 'Nasional') return '#ef4444';
+ if (status === 'Provinsi') return '#3b82f6';
+ if (status === 'Kabupaten') return '#22c55e';
+ return '#64748b';
+}
+
+function getParsilColor(status) {
+ if (status === 'SHM') return '#f59e0b';
+ if (status === 'HGB') return '#8b5cf6';
+ if (status === 'HGU') return '#14b8a6';
+ if (status === 'HP') return '#94a3b8';
+ return '#cbd5e1';
+}
+
+function makeSpbuIcon(is24) {
+ const dotClass = is24 ? 'spbu-dot spbu-dot--green' : 'spbu-dot spbu-dot--red';
+ return L.divIcon({
+ className: 'spbu-icon',
+ html: `
`,
+ iconSize: [18, 18],
+ iconAnchor: [9, 9],
+ popupAnchor: [0, -8]
+ });
+}
+
+function makePemukimanIcon({ isEdit = false, isCovered = false } = {}) {
+ // Keep it distinct from SPBU but not too big/flashy.
+ const size = isEdit ? 34 : 26;
+ const inner = isEdit ? 20 : 16;
+ const hitClass = isCovered ? 'pm-hit pm-hit--green' : 'pm-hit pm-hit--red';
+ const html = `
+
+ `;
+
+ return L.divIcon({
+ className: 'pm-icon',
+ html,
+ iconSize: [size, size],
+ iconAnchor: [size / 2, size / 2],
+ popupAnchor: [0, -size / 2]
+ });
+}
+
+function makeRumahIbadahIcon() {
+ // Simple + recognizable icon (not too "perfect").
+ const size = 24;
+ const html = `
+
+ `;
+
+ return L.divIcon({
+ className: 'ri-icon',
+ html,
+ iconSize: [size, size],
+ iconAnchor: [size / 2, size / 2],
+ popupAnchor: [0, -size / 2]
+ });
+}
+
+function computePemukimanCoverage(latlng) {
+ if (!latlng || !Array.isArray(state.rumahIbadahData) || state.rumahIbadahData.length === 0) {
+ return { covered: null, rumahIbadahId: null, jarakMeter: null };
+ }
+
+ let best = null;
+ for (const row of state.rumahIbadahData) {
+ const lat = Number(row?.latitude);
+ const lng = Number(row?.longitude);
+ const radius = Number(row?.radius_meter);
+ if (!Number.isFinite(lat) || !Number.isFinite(lng) || !Number.isFinite(radius) || radius <= 0) continue;
+
+ const d = map.distance(latlng, L.latLng(lat, lng));
+ if (d <= radius) {
+ if (!best || d < best.jarakMeter) {
+ best = { rumahIbadahId: row?.id ?? null, jarakMeter: d };
+ }
+ }
+ }
+
+ if (!best) return { covered: false, rumahIbadahId: null, jarakMeter: null };
+ return { covered: true, rumahIbadahId: best.rumahIbadahId, jarakMeter: best.jarakMeter };
+}
+
+
+
+function updateStats() {
+ if (el.countPoints) el.countPoints.textContent = state.counts.points;
+ if (el.countJalan) el.countJalan.textContent = state.counts.jalan;
+ if (el.countParsil) el.countParsil.textContent = state.counts.parsil;
+ if (el.countRumahIbadah) el.countRumahIbadah.textContent = state.counts.rumahIbadah;
+ if (el.countPemukiman) el.countPemukiman.textContent = state.counts.pemukiman;
+ if (el.countSektor) el.countSektor.textContent = state.counts.sektor;
+
+ // Robust update for any additional stat cards (e.g., Rumah Ibadah, Pemukiman Miskin)
+ try {
+ const cards = document.querySelectorAll('.stat-card');
+ for (const card of cards) {
+ const labelEl = card.querySelector('.stat-label');
+ const valueEl = card.querySelector('.stat-value');
+ const label = String(labelEl?.textContent ?? '').trim().toLowerCase();
+ if (!label || !valueEl) continue;
+
+ if (label.includes('rumah') && label.includes('ibadah')) {
+ valueEl.textContent = String(state.counts.rumahIbadah ?? 0);
+ }
+ if (label.includes('pemukiman')) {
+ valueEl.textContent = String(state.counts.pemukiman ?? 0);
+ }
+ }
+ } catch {
+ // ignore
+ }
+}
+
+function isSpbu24(row) {
+ const v = row?.status_24jam;
+ if (v === true) return true;
+ if (typeof v === 'number') return v === 1;
+
+ const s = String(v ?? '').trim().toLowerCase();
+ if (!s) return false;
+ if (s === '1' || s === 'ya' || s === 'yes') return true;
+ if (s === '0' || s === 'tidak' || s === 'no') return false;
+
+ const has24 = s.includes('24');
+ const isNeg = s.includes('tidak') || s.includes('non');
+ if (has24 && isNeg) return false;
+ if (has24) return true;
+ return false;
+}
+
+function applyPointFilter() {
+ const mode = state.pointFilter || 'all';
+ if (el.btnFilter24) el.btnFilter24.classList.toggle('is-active', mode === '24');
+ if (el.btnFilterNon24) el.btnFilterNon24.classList.toggle('is-active', mode === 'non24');
+
+ const shouldShow = (row) => {
+ if (mode === '24') return isSpbu24(row);
+ if (mode === 'non24') return !isSpbu24(row);
+ return true;
+ };
+
+ // Map visibility is handled by applyLayerVisibility based on state.pointFilter
+ applyLayerVisibility();
+
+ const filteredRows = Array.isArray(state.pointsData)
+ ? state.pointsData.filter((row) => shouldShow(row))
+ : [];
+ renderPointsList(filteredRows);
+}
+
+function togglePointFilter(next) {
+ state.pointFilter = state.pointFilter === next ? 'all' : next;
+ if (el.togglePoints) el.togglePoints.checked = true;
+ applyLayerVisibility();
+ applyPointFilter();
+
+ const rows = Array.isArray(state.pointsData) ? state.pointsData : [];
+ const n24 = rows.filter((r) => isSpbu24(r)).length;
+ const nNon = rows.length - n24;
+ if (state.pointFilter === '24') showToast(`Filter: SPBU 24 Jam (${n24})`, 'info', 2200);
+ else if (state.pointFilter === 'non24') showToast(`Filter: SPBU Tidak 24 Jam (${nNon})`, 'info', 2200);
+ else showToast(`Filter: Semua SPBU (${rows.length})`, 'info', 1800);
+}
+
+function applyLayerVisibility() {
+ if (el.togglePoints) {
+ if (!el.togglePoints.checked) {
+ map.removeLayer(points24Layer);
+ map.removeLayer(pointsNon24Layer);
+ } else {
+ const mode = state.pointFilter || 'all';
+ if (mode === '24') {
+ points24Layer.addTo(map);
+ map.removeLayer(pointsNon24Layer);
+ } else if (mode === 'non24') {
+ pointsNon24Layer.addTo(map);
+ map.removeLayer(points24Layer);
+ } else {
+ points24Layer.addTo(map);
+ pointsNon24Layer.addTo(map);
+ }
+ }
+ }
+
+ if (el.toggleJalan) {
+ if (el.toggleJalan.checked) jalanLayer.addTo(map);
+ else map.removeLayer(jalanLayer);
+ }
+
+ if (el.toggleParsil) {
+ if (el.toggleParsil.checked) parsilLayer.addTo(map);
+ else map.removeLayer(parsilLayer);
+ }
+
+ if (el.toggleRumahIbadah) {
+ if (el.toggleRumahIbadah.checked) {
+ rumahIbadahLayer.addTo(map);
+ rumahIbadahRadiusLayer.addTo(map);
+ } else {
+ map.removeLayer(rumahIbadahLayer);
+ map.removeLayer(rumahIbadahRadiusLayer);
+ }
+ }
+
+ if (el.togglePemukiman) {
+ if (el.togglePemukiman.checked) pemukimanLayer.addTo(map);
+ else map.removeLayer(pemukimanLayer);
+ }
+
+ if (el.toggleSektor) {
+ if (el.toggleSektor.checked) sektorLayer.addTo(map);
+ else map.removeLayer(sektorLayer);
+ }
+
+ updateStats();
+}
+
+function setActiveModeButton(type) {
+ const buttons = [
+ el.btnModePoint,
+ el.btnModeJalan,
+ el.btnModeParsil,
+ el.btnModeRumahIbadah,
+ el.btnModePemukiman
+ ].filter(Boolean);
+ for (const btn of buttons) btn.classList.remove('is-active');
+ if (type === 'point' && el.btnModePoint) el.btnModePoint.classList.add('is-active');
+ if (type === 'jalan' && el.btnModeJalan) el.btnModeJalan.classList.add('is-active');
+ if (type === 'parsil' && el.btnModeParsil) el.btnModeParsil.classList.add('is-active');
+ if (type === 'rumahIbadah' && el.btnModeRumahIbadah) el.btnModeRumahIbadah.classList.add('is-active');
+ if (type === 'pemukiman' && el.btnModePemukiman) el.btnModePemukiman.classList.add('is-active');
+}
+
+function updateModeInfo() {
+ if (!el.modeInfo) return;
+
+ if (state.locationEditing?.type === 'point') {
+ el.modeInfo.textContent = 'Mode: Edit Lokasi Point (geser marker)';
+ if (el.btnResetMode) el.btnResetMode.disabled = false;
+ return;
+ }
+
+ if (state.locationEditing?.type === 'jalan') {
+ el.modeInfo.textContent = 'Mode: Edit Lokasi Jalan (drag garis / geser vertex)';
+ if (el.btnResetMode) el.btnResetMode.disabled = false;
+ return;
+ }
+
+ if (state.locationEditing?.type === 'parsil') {
+ el.modeInfo.textContent = 'Mode: Edit Lokasi Parsil (drag area / geser vertex)';
+ if (el.btnResetMode) el.btnResetMode.disabled = false;
+ return;
+ }
+
+ if (state.locationEditing?.type === 'pemukiman') {
+ el.modeInfo.textContent = 'Mode: Edit Lokasi Pemukiman Miskin (geser marker)';
+ if (el.btnResetMode) el.btnResetMode.disabled = false;
+ return;
+ }
+
+ if (state.locationEditing?.type === 'rumahIbadah') {
+ el.modeInfo.textContent = 'Mode: Edit Lokasi Rumah Ibadah (geser marker)';
+ if (el.btnResetMode) el.btnResetMode.disabled = false;
+ return;
+ }
+
+ if (state.editing?.type === 'point') {
+ el.modeInfo.textContent = 'Mode: Edit Point (geser marker / ubah atribut)';
+ if (el.btnResetMode) el.btnResetMode.disabled = false;
+ return;
+ }
+
+ if (state.editing?.type === 'jalan') {
+ el.modeInfo.textContent = 'Mode: Edit Jalan (geser titik garis)';
+ if (el.btnResetMode) el.btnResetMode.disabled = false;
+ return;
+ }
+
+ if (state.editing?.type === 'parsil') {
+ el.modeInfo.textContent = 'Mode: Edit Parsil (geser titik poligon)';
+ if (el.btnResetMode) el.btnResetMode.disabled = false;
+ return;
+ }
+
+ if (state.editing?.type === 'rumahIbadah') {
+ el.modeInfo.textContent = 'Mode: Edit Rumah Ibadah (geser marker / ubah atribut)';
+ if (el.btnResetMode) el.btnResetMode.disabled = false;
+ return;
+ }
+
+ if (state.editing?.type === 'pemukiman') {
+ el.modeInfo.textContent = 'Mode: Edit Pemukiman Miskin (geser marker / ubah atribut)';
+ if (el.btnResetMode) el.btnResetMode.disabled = false;
+ return;
+ }
+
+ if (state.featureType === 'point') {
+ el.modeInfo.textContent = 'Mode: Tambah Point (klik peta)';
+ if (el.btnResetMode) el.btnResetMode.disabled = false;
+ return;
+ }
+
+ if (state.featureType === 'jalan') {
+ el.modeInfo.textContent = 'Mode: Tambah Jalan (gambar garis)';
+ if (el.btnResetMode) el.btnResetMode.disabled = false;
+ return;
+ }
+
+ if (state.featureType === 'parsil') {
+ el.modeInfo.textContent = 'Mode: Tambah Parsil (gambar poligon)';
+ if (el.btnResetMode) el.btnResetMode.disabled = false;
+ return;
+ }
+
+ if (state.featureType === 'rumahIbadah') {
+ el.modeInfo.textContent = 'Mode: Tambah Rumah Ibadah (klik peta)';
+ if (el.btnResetMode) el.btnResetMode.disabled = false;
+ return;
+ }
+
+ if (state.featureType === 'pemukiman') {
+ el.modeInfo.textContent = 'Mode: Tambah Pemukiman Miskin (klik peta)';
+ if (el.btnResetMode) el.btnResetMode.disabled = false;
+ return;
+ }
+
+ el.modeInfo.textContent = 'Mode: Tidak aktif';
+ if (el.btnResetMode) el.btnResetMode.disabled = true;
+}
+
+function clearPendingLayer() {
+ if (state.pendingLayer) {
+ editableGroup.removeLayer(state.pendingLayer);
+ state.pendingLayer = null;
+ }
+ state.currentLayer = null;
+}
+
+function showSection(type) {
+ const setEnabled = (section, enabled) => {
+ if (!section) return;
+ section.querySelectorAll('input, textarea, select, button').forEach((node) => {
+ node.disabled = !enabled;
+ });
+ };
+
+ el.pointFields?.classList.add('hidden');
+ el.roadFields?.classList.add('hidden');
+ el.parsilFields?.classList.add('hidden');
+ el.rumahIbadahFields?.classList.add('hidden');
+ el.pemukimanFields?.classList.add('hidden');
+ setEnabled(el.pointFields, false);
+ setEnabled(el.roadFields, false);
+ setEnabled(el.parsilFields, false);
+ setEnabled(el.rumahIbadahFields, false);
+ setEnabled(el.pemukimanFields, false);
+
+ if (type === 'point') {
+ el.pointFields?.classList.remove('hidden');
+ setEnabled(el.pointFields, true);
+ }
+ if (type === 'jalan') {
+ el.roadFields?.classList.remove('hidden');
+ setEnabled(el.roadFields, true);
+ }
+ if (type === 'parsil') {
+ el.parsilFields?.classList.remove('hidden');
+ setEnabled(el.parsilFields, true);
+ }
+
+ if (type === 'rumahIbadah') {
+ el.rumahIbadahFields?.classList.remove('hidden');
+ setEnabled(el.rumahIbadahFields, true);
+ }
+
+ if (type === 'pemukiman') {
+ el.pemukimanFields?.classList.remove('hidden');
+ setEnabled(el.pemukimanFields, true);
+ }
+}
+
+function openModal() {
+ if (!el.modal) return;
+
+ if (state.featureType === 'point') el.modalTitle.textContent = 'Input Data Point SPBU';
+ if (state.featureType === 'jalan') el.modalTitle.textContent = 'Input Data Jalan';
+ if (state.featureType === 'parsil') el.modalTitle.textContent = 'Input Data Parsil';
+ if (state.featureType === 'rumahIbadah') el.modalTitle.textContent = 'Input Data Rumah Ibadah';
+ if (state.featureType === 'pemukiman') el.modalTitle.textContent = 'Input Data Pemukiman Miskin';
+
+ clearFormNotice();
+ setSubmitting(false);
+ if (el.btnSubmitFeature) {
+ el.btnSubmitFeature.textContent = state.editing ? 'Update Data' : 'Simpan Data';
+ }
+ showSection(state.featureType);
+ el.modal.classList.remove('hidden');
+
+ // focus first input of the active section
+ if (state.featureType === 'point') el.namaPoint?.focus();
+ if (state.featureType === 'jalan') el.namaJalan?.focus();
+ if (state.featureType === 'parsil') el.statusParsil?.focus();
+ if (state.featureType === 'rumahIbadah') el.namaRumahIbadah?.focus();
+ if (state.featureType === 'pemukiman') el.kkNamaPemukiman?.focus();
+}
+
+function backupLayerGeometry(layer) {
+ if (layer?.getLatLng && layer?.setLatLng) {
+ const ll = layer.getLatLng();
+ return { kind: 'marker', latlng: [ll.lat, ll.lng] };
+ }
+
+ if (layer?.getLatLngs && layer?.setLatLngs) {
+ const toTuple = (latlng) => [latlng.lat, latlng.lng];
+ const clone = (latlngs) =>
+ Array.isArray(latlngs)
+ ? latlngs.map((v) => (Array.isArray(v) ? clone(v) : toTuple(v)))
+ : latlngs;
+ return { kind: 'path', latlngs: clone(layer.getLatLngs()) };
+ }
+
+ return { kind: 'unknown' };
+}
+
+function restoreLayerGeometry(layer, backup) {
+ if (!layer || !backup) return;
+
+ if (backup.kind === 'marker' && Array.isArray(backup.latlng)) {
+ layer.setLatLng(L.latLng(backup.latlng[0], backup.latlng[1]));
+ return;
+ }
+
+ if (backup.kind === 'path' && backup.latlngs && layer.setLatLngs) {
+ const toLatLng = (pair) => L.latLng(pair[0], pair[1]);
+ const rebuild = (latlngs) =>
+ Array.isArray(latlngs)
+ ? latlngs.map((v) => (Array.isArray(v) && typeof v[0] === 'number' ? toLatLng(v) : rebuild(v)))
+ : latlngs;
+ layer.setLatLngs(rebuild(backup.latlngs));
+ }
+}
+
+function enableLayerEditing(layer) {
+ if (!layer) return;
+ if (layer.dragging && layer.dragging.enable) {
+ layer.dragging.enable();
+ }
+ if (layer.editing && layer.editing.enable) {
+ layer.editing.enable();
+ }
+}
+
+function disableLayerEditing(layer) {
+ if (!layer) return;
+ if (layer.dragging && layer.dragging.disable) {
+ layer.dragging.disable();
+ }
+ if (layer.editing && layer.editing.disable) {
+ layer.editing.disable();
+ }
+}
+
+function startEditPoint(id, marker, row) {
+ resetMode();
+ const backup = backupLayerGeometry(marker);
+ backup.is24 = Number(row?.status_24jam) === 1;
+ state.editing = { type: 'point', id: Number(id), layer: marker, backup };
+ state.featureType = 'point';
+ state.currentLayer = null;
+
+ el.namaPoint.value = row?.nama ?? '';
+ el.noPoint.value = row?.no ?? '';
+ el.deskripsiPoint.value = row?.deskripsi ?? '';
+ el.status24Jam.checked = Number(row?.status_24jam) === 1;
+
+ const ll = marker.getLatLng();
+ el.latPoint.value = ll.lat;
+ el.lngPoint.value = ll.lng;
+
+ marker.setIcon(makeSpbuIcon(el.status24Jam.checked));
+ if (marker.__spbuDragHandler) {
+ marker.off('drag', marker.__spbuDragHandler);
+ marker.off('dragend', marker.__spbuDragHandler);
+ }
+ marker.__spbuDragHandler = () => {
+ const current = marker.getLatLng();
+ el.latPoint.value = current.lat;
+ el.lngPoint.value = current.lng;
+ };
+ marker.on('drag', marker.__spbuDragHandler);
+ marker.on('dragend', marker.__spbuDragHandler);
+
+ enableLayerEditing(marker);
+ updateModeInfo();
+ setActiveListItem('points', id);
+ setFormNotice('Edit: geser marker jika perlu, lalu klik "Update Data".', 'info');
+ openModal();
+}
+
+function startEditJalan(id, layer, props) {
+ resetMode();
+ const backup = backupLayerGeometry(layer);
+ backup.status = props?.status_jalan ?? 'Nasional';
+ state.editing = { type: 'jalan', id: Number(id), layer, backup };
+ state.featureType = 'jalan';
+ state.currentLayer = null;
+
+ el.namaJalan.value = props?.nama_jalan ?? '';
+ el.statusJalan.value = props?.status_jalan ?? 'Nasional';
+
+ enableLayerEditing(layer);
+ updateModeInfo();
+ setActiveListItem('jalan', id);
+ setFormNotice('Edit: geser titik garis, panjang akan dihitung otomatis saat update.', 'info');
+ openModal();
+}
+
+function startEditParsil(id, layer, props) {
+ resetMode();
+ const backup = backupLayerGeometry(layer);
+ backup.status = props?.status_kepemilikan ?? 'SHM';
+ state.editing = { type: 'parsil', id: Number(id), layer, backup };
+ state.featureType = 'parsil';
+ state.currentLayer = null;
+
+ el.statusParsil.value = props?.status_kepemilikan ?? 'SHM';
+
+ enableLayerEditing(layer);
+ updateModeInfo();
+ setActiveListItem('parsil', id);
+ setFormNotice('Edit: geser titik poligon, luas akan dihitung otomatis saat update.', 'info');
+ openModal();
+}
+
+function startEditRumahIbadah(id, marker, row) {
+ resetMode();
+ const backup = backupLayerGeometry(marker);
+ state.editing = { type: 'rumahIbadah', id: Number(id), layer: marker, backup };
+ state.featureType = 'rumahIbadah';
+ state.currentLayer = null;
+
+ el.namaRumahIbadah.value = row?.nama ?? '';
+ el.jenisRumahIbadah.value = row?.jenis ?? 'Lainnya';
+ el.kontakRumahIbadah.value = row?.kontak ?? '';
+ el.radiusRumahIbadah.value = row?.radius_meter ?? '';
+ el.alamatRumahIbadah.value = row?.address ?? '';
+
+ const ll = marker.getLatLng();
+ el.latRumahIbadah.value = ll.lat;
+ el.lngRumahIbadah.value = ll.lng;
+
+ if (marker.__riDragHandler) {
+ marker.off('drag', marker.__riDragHandler);
+ marker.off('dragend', marker.__riDragHandler);
+ }
+ marker.__riDragHandler = () => {
+ const current = marker.getLatLng();
+ el.latRumahIbadah.value = current.lat;
+ el.lngRumahIbadah.value = current.lng;
+ };
+ marker.on('drag', marker.__riDragHandler);
+ marker.on('dragend', marker.__riDragHandler);
+
+ enableLayerEditing(marker);
+ updateModeInfo();
+ setActiveListItem('rumahIbadah', id);
+ setFormNotice('Edit: geser marker jika perlu, lalu klik "Update Data".', 'info');
+ openModal();
+}
+
+function startEditPemukiman(id, marker, row) {
+ resetMode();
+ const backup = backupLayerGeometry(marker);
+ const ibadahId = row?.rumah_ibadah_id;
+ backup.covered = ibadahId !== null && ibadahId !== undefined && String(ibadahId).trim() !== '' && String(ibadahId) !== '0';
+ state.editing = { type: 'pemukiman', id: Number(id), layer: marker, backup, dragEase: null };
+ state.featureType = 'pemukiman';
+ state.currentLayer = null;
+
+ el.kkNamaPemukiman.value = row?.kk_nama ?? row?.nama ?? '';
+ el.nikPemukiman.value = row?.nik ?? '';
+ el.jumlahAnggotaPemukiman.value = row?.jumlah_anggota ?? '';
+ el.statusBantuanPemukiman.value = row?.status_bantuan ?? 'Belum dibantu';
+ el.jenisBantuanPemukiman.value = row?.jenis_bantuan ?? '';
+ el.tanggalBantuanPemukiman.value = row?.tanggal_bantuan ?? '';
+ el.alamatPemukiman.value = row?.address ?? '';
+ el.kelurahanPemukiman.value = row?.kelurahan ?? '';
+ el.kecamatanPemukiman.value = row?.kecamatan ?? '';
+
+ const ll = marker.getLatLng();
+ el.latPemukiman.value = ll.lat;
+ el.lngPemukiman.value = ll.lng;
+
+ marker.setIcon(makePemukimanIcon({ isEdit: true, isCovered: Boolean(backup.covered) }));
+ if (marker.__pmDragHandler) {
+ marker.off('drag', marker.__pmDragHandler);
+ marker.off('dragend', marker.__pmDragHandler);
+ }
+ marker.__currentCovered = Boolean(backup.covered);
+ marker.__pmDragHandler = () => {
+ const current = marker.getLatLng();
+ el.latPemukiman.value = current.lat;
+ el.lngPemukiman.value = current.lng;
+
+ const cov = computePemukimanCoverage(current);
+ if (cov.covered !== null && marker.__currentCovered !== cov.covered) {
+ marker.__currentCovered = cov.covered;
+ marker.setIcon(makePemukimanIcon({ isEdit: true, isCovered: cov.covered === true }));
+ }
+ };
+ marker.on('drag', marker.__pmDragHandler);
+ marker.on('dragend', marker.__pmDragHandler);
+
+ enableLayerEditing(marker);
+ updateModeInfo();
+ setActiveListItem('pemukiman', id);
+ setFormNotice('Edit: geser marker jika perlu, lalu klik "Update Data".', 'info');
+ openModal();
+}
+
+async function deleteFeature(type, id) {
+ const label =
+ type === 'point' ? 'point SPBU' :
+ type === 'jalan' ? 'jalan' :
+ type === 'parsil' ? 'parsil' :
+ type === 'rumahIbadah' ? 'rumah ibadah' :
+ 'pemukiman miskin';
+ const ok = window.confirm(`Hapus data ${label} ini?`);
+ if (!ok) return;
+
+ const url =
+ type === 'point' ? 'php/delete_point.php' :
+ type === 'jalan' ? 'php/delete_jalan.php' :
+ type === 'parsil' ? 'php/delete_parsil.php' :
+ type === 'rumahIbadah' ? 'php/delete_rumah_ibadah.php' :
+ 'php/delete_pemukiman_miskin.php';
+
+ try {
+ const { res, json, text } = await fetchMaybeJson(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ id: Number(id) })
+ });
+
+ if (!json) throw new Error(text || 'Respon server tidak valid saat hapus data.');
+ const api = parseApiJson(json);
+ if (!api.success) throw new Error(api.message);
+ if (!res.ok) throw new Error(api.message || `HTTP ${res.status}`);
+
+ showToast('Data berhasil dihapus.', 'success');
+ if (type === 'point') await loadPoints();
+ if (type === 'jalan') await loadJalan();
+ if (type === 'parsil') await loadParsil();
+ if (type === 'rumahIbadah') {
+ await loadPemukimanMiskin();
+ await loadRumahIbadah();
+ }
+ if (type === 'pemukiman') {
+ await loadPemukimanMiskin();
+ await loadRumahIbadah();
+ }
+ } catch (e) {
+ showToast(e?.message || String(e), 'error', 5000);
+ }
+}
+
+function closeModal({ keepMode = false } = {}) {
+ if (!el.modal) return;
+
+ // Cancel edit: restore geometry
+ if (state.editing?.layer && state.editing?.backup) {
+ const { type, layer, backup } = state.editing;
+ restoreLayerGeometry(layer, backup);
+
+ if (type === 'point' && layer.setIcon && typeof backup?.is24 === 'boolean') {
+ layer.setIcon(makeSpbuIcon(backup.is24));
+ }
+
+ if (type === 'jalan' && layer.setStyle && backup?.status) {
+ layer.setStyle({ color: getJalanColor(backup.status), weight: 4, opacity: 0.95 });
+ }
+
+ if (type === 'parsil' && layer.setStyle && backup?.status) {
+ layer.setStyle({ color: '#0f172a', weight: 2, fillColor: getParsilColor(backup.status), fillOpacity: 0.35 });
+ }
+
+ if (type === 'pemukiman' && layer.setIcon) {
+ layer.setIcon(makePemukimanIcon({ isCovered: Boolean(backup?.covered) }));
+ if (layer.__pmDragHandler) {
+ layer.off('drag', layer.__pmDragHandler);
+ layer.off('dragend', layer.__pmDragHandler);
+ layer.__pmDragHandler = null;
+ }
+ if (state.editing?.dragEase?.disable) state.editing.dragEase.disable();
+ }
+
+ if (type === 'rumahIbadah' && layer.setIcon) {
+ layer.setIcon(makeRumahIbadahIcon());
+ if (layer.__riDragHandler) {
+ layer.off('drag', layer.__riDragHandler);
+ layer.off('dragend', layer.__riDragHandler);
+ layer.__riDragHandler = null;
+ }
+ }
+
+ disableLayerEditing(layer);
+ state.editing = null;
+ }
+
+ el.modal.classList.add('hidden');
+ el.featureForm?.reset();
+ clearFormNotice();
+ setSubmitting(false);
+ clearPendingLayer();
+
+ if (!keepMode) resetMode({ skipModalClose: true });
+}
+
+function resetMode({ skipModalClose = false } = {}) {
+ if (!skipModalClose && el.modal && !el.modal.classList.contains('hidden')) {
+ // Close modal first, but don't recurse into resetMode again.
+ closeModal({ keepMode: true });
+ }
+
+ if (state.locationEditing) {
+ cancelLocationEdit({ silent: true });
+ }
+
+ if (state.activeDrawer) {
+ state.activeDrawer.disable();
+ state.activeDrawer = null;
+ }
+
+ state.featureType = null;
+ document.body.classList.remove('crosshair');
+ setActiveModeButton(null);
+ updateModeInfo();
+}
+
+function setMode(type) {
+ resetMode();
+ state.editing = null;
+ state.locationEditing = null;
+ state.featureType = type;
+ setActiveModeButton(type);
+ updateModeInfo();
+
+ if (type === 'point' || type === 'rumahIbadah' || type === 'pemukiman') {
+ document.body.classList.add('crosshair');
+ return;
+ }
+
+ if (type === 'jalan') {
+ state.activeDrawer = new L.Draw.Polyline(map, {
+ shapeOptions: { color: getJalanColor(el.statusJalan?.value), weight: 4, opacity: 0.95 }
+ });
+ state.activeDrawer.enable();
+ return;
+ }
+
+ if (type === 'parsil') {
+ state.activeDrawer = new L.Draw.Polygon(map, {
+ allowIntersection: false,
+ showArea: true,
+ shapeOptions: { color: '#0f172a', weight: 2, fillColor: getParsilColor(el.statusParsil?.value), fillOpacity: 0.35 }
+ });
+ state.activeDrawer.enable();
+ }
+}
+
+// expose for inline onclick
+window.setMode = setMode;
+window.resetMode = resetMode;
+window.closeModal = closeModal;
+
+function showEditBar(title) {
+ if (!el.editBar) return;
+ if (el.editBarTitle) el.editBarTitle.textContent = title || 'Edit Lokasi';
+ el.editBar.classList.remove('hidden');
+}
+
+function hideEditBar() {
+ if (!el.editBar) return;
+ el.editBar.classList.add('hidden');
+}
+
+function updateDeleteVertexUi() {
+ if (!el.btnDeleteVertex) return;
+ const type = state.locationEditing?.type ?? null;
+ const shouldShow = type === 'jalan' || type === 'parsil';
+ el.btnDeleteVertex.classList.toggle('hidden', !shouldShow);
+
+ if (!shouldShow) {
+ el.btnDeleteVertex.classList.remove('is-active');
+ el.btnDeleteVertex.textContent = 'Hapus Vertex';
+ } else {
+ const enabled = Boolean(state.locationEditing?.vertexDeleteMode);
+ el.btnDeleteVertex.classList.toggle('is-active', enabled);
+ el.btnDeleteVertex.textContent = enabled ? 'Hapus Vertex: ON' : 'Hapus Vertex';
+ }
+}
+
+function setVertexDeleteMode(enabled) {
+ if (!state.locationEditing) return;
+ state.locationEditing.vertexDeleteMode = Boolean(enabled);
+ updateDeleteVertexUi();
+ if (state.locationEditing.vertexDeleteMode) {
+ showToast('Mode hapus vertex aktif: klik vertex (titik putih) untuk menghapus.', 'info', 6000);
+ }
+}
+
+function refreshEditingHandles(layer) {
+ if (!layer?.editing?.disable || !layer?.editing?.enable) return;
+ const wasEnabled = layer.editing.enabled?.();
+ if (wasEnabled === false) return;
+ layer.editing.disable();
+ layer.editing.enable();
+}
+
+function deleteNearestVertex(layer, latlng) {
+ if (!layer?.getLatLngs || !layer?.setLatLngs) return false;
+
+ const clickPt = map.latLngToContainerPoint(latlng);
+ const maxDistPx = 18;
+ const type = state.locationEditing?.type;
+
+ if (type === 'parsil') {
+ const rings = layer.getLatLngs();
+ const ring = Array.isArray(rings?.[0]) ? rings[0] : null;
+ if (!ring) return { ok: false, reason: 'not_found' };
+ if (ring.length <= 3) return { ok: false, reason: 'min' };
+
+ let bestIdx = -1;
+ let bestDist = Infinity;
+ for (let i = 0; i < ring.length; i++) {
+ const d = clickPt.distanceTo(map.latLngToContainerPoint(ring[i]));
+ if (d < bestDist) {
+ bestDist = d;
+ bestIdx = i;
+ }
+ }
+ if (bestDist > maxDistPx || bestIdx < 0) return { ok: false, reason: 'not_found' };
+
+ ring.splice(bestIdx, 1);
+ layer.setLatLngs([ring]);
+ if (layer.redraw) layer.redraw();
+ refreshEditingHandles(layer);
+ return { ok: true };
+ }
+
+ // polyline
+ let latlngs = layer.getLatLngs();
+ if (Array.isArray(latlngs?.[0])) latlngs = latlngs[0]; // guard for MultiLineString
+ if (!Array.isArray(latlngs)) return { ok: false, reason: 'not_found' };
+ if (latlngs.length <= 2) return { ok: false, reason: 'min' };
+
+ let bestIdx = -1;
+ let bestDist = Infinity;
+ for (let i = 0; i < latlngs.length; i++) {
+ const d = clickPt.distanceTo(map.latLngToContainerPoint(latlngs[i]));
+ if (d < bestDist) {
+ bestDist = d;
+ bestIdx = i;
+ }
+ }
+ if (bestDist > maxDistPx || bestIdx < 0) return { ok: false, reason: 'not_found' };
+
+ latlngs.splice(bestIdx, 1);
+ layer.setLatLngs(latlngs);
+ if (layer.redraw) layer.redraw();
+ refreshEditingHandles(layer);
+ return { ok: true };
+}
+
+function enableLayerTranslation(layer) {
+ if (!layer) return null;
+
+ const ctx = {
+ isDragging: false,
+ startPoint: null,
+ originLatLngs: null,
+ originPoints: null,
+ raf: null,
+ pendingDelta: null,
+ original: {
+ dragging: map.dragging.enabled(),
+ boxZoom: map.boxZoom?.enabled?.() ?? false,
+ doubleClickZoom: map.doubleClickZoom?.enabled?.() ?? false,
+ touchZoom: map.touchZoom?.enabled?.() ?? false
+ }
+ };
+
+ const cloneLatLngs = (latlngs) => {
+ if (!Array.isArray(latlngs)) return latlngs;
+ return latlngs.map((v) => (Array.isArray(v) ? cloneLatLngs(v) : L.latLng(v.lat, v.lng)));
+ };
+
+ const toPoints = (latlngs) => {
+ if (!Array.isArray(latlngs)) return latlngs;
+ return latlngs.map((v) => (Array.isArray(v) ? toPoints(v) : map.latLngToLayerPoint(v)));
+ };
+
+ const setTranslatedFromPoints = (originPoints, delta) => {
+ const apply = (pts) => {
+ if (!Array.isArray(pts)) return pts;
+ return pts.map((v) => {
+ if (Array.isArray(v)) return apply(v);
+ return map.layerPointToLatLng(v.add(delta));
+ });
+ };
+ layer.setLatLngs(apply(originPoints));
+ };
+
+ const scheduleUpdate = () => {
+ if (!ctx.isDragging || !ctx.pendingDelta) return;
+ if (ctx.raf) return;
+ ctx.raf = window.requestAnimationFrame(() => {
+ ctx.raf = null;
+ if (!ctx.isDragging || !ctx.pendingDelta) return;
+ setTranslatedFromPoints(ctx.originPoints, ctx.pendingDelta);
+ if (layer.redraw) layer.redraw();
+ });
+ };
+
+ const onDown = (e) => {
+ // Avoid capturing drag from vertex markers (edit handles)
+ if (e?.originalEvent?.target && String(e.originalEvent.target.className || '').includes('leaflet-editing-icon')) {
+ return;
+ }
+ ctx.isDragging = true;
+
+ ctx.startPoint = map.latLngToLayerPoint(e.latlng);
+ ctx.originLatLngs = cloneLatLngs(layer.getLatLngs());
+ ctx.originPoints = toPoints(ctx.originLatLngs);
+ ctx.pendingDelta = L.point(0, 0);
+
+ map.dragging.disable();
+ map.boxZoom?.disable?.();
+ map.doubleClickZoom?.disable?.();
+ map.touchZoom?.disable?.();
+
+ if (e?.originalEvent) L.DomEvent.stop(e.originalEvent);
+ };
+
+ const onMove = (e) => {
+ if (!ctx.isDragging) return;
+ const current = map.latLngToLayerPoint(e.latlng);
+ ctx.pendingDelta = current.subtract(ctx.startPoint);
+ scheduleUpdate();
+ };
+
+ const onUp = () => {
+ if (!ctx.isDragging) return;
+ ctx.isDragging = false;
+ ctx.startPoint = null;
+ ctx.originLatLngs = null;
+ ctx.originPoints = null;
+ ctx.pendingDelta = null;
+ if (ctx.raf) {
+ window.cancelAnimationFrame(ctx.raf);
+ ctx.raf = null;
+ }
+
+ if (ctx.original.dragging) map.dragging.enable();
+ if (ctx.original.boxZoom) map.boxZoom?.enable?.();
+ if (ctx.original.doubleClickZoom) map.doubleClickZoom?.enable?.();
+ if (ctx.original.touchZoom) map.touchZoom?.enable?.();
+ };
+
+ layer.on('mousedown touchstart', onDown);
+ map.on('mousemove touchmove', onMove);
+ map.on('mouseup touchend touchcancel', onUp);
+
+ return {
+ disable: () => {
+ layer.off('mousedown touchstart', onDown);
+ map.off('mousemove touchmove', onMove);
+ map.off('mouseup touchend touchcancel', onUp);
+ if (ctx.raf) {
+ window.cancelAnimationFrame(ctx.raf);
+ ctx.raf = null;
+ }
+ if (ctx.original.dragging) map.dragging.enable();
+ if (ctx.original.boxZoom) map.boxZoom?.enable?.();
+ if (ctx.original.doubleClickZoom) map.doubleClickZoom?.enable?.();
+ if (ctx.original.touchZoom) map.touchZoom?.enable?.();
+ }
+ };
+}
+
+async function saveLocationEdit() {
+ if (!state.locationEditing) return;
+
+ const { type, id, layer, attrs } = state.locationEditing;
+ try {
+ if (type === 'point') {
+ const ll = layer.getLatLng();
+ const payload = {
+ id,
+ nama: attrs.nama,
+ no: attrs.no,
+ deskripsi: attrs.deskripsi,
+ status_24jam: attrs.status_24jam,
+ latitude: ll.lat,
+ longitude: ll.lng
+ };
+
+ const { res, json, text } = await fetchMaybeJson('php/update_point.php', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload)
+ });
+
+ if (!json) throw new Error(text || 'Respon server tidak valid saat update lokasi point.');
+ const api = parseApiJson(json);
+ if (!api.success) throw new Error(api.message);
+ if (!res.ok) throw new Error(api.message || `HTTP ${res.status}`);
+ }
+
+ if (type === 'jalan') {
+ const payload = {
+ id,
+ nama_jalan: attrs.nama_jalan,
+ status_jalan: attrs.status_jalan,
+ panjang_meter: hitungPanjang(layer),
+ geometry: layer.toGeoJSON().geometry
+ };
+
+ const { res, json, text } = await fetchMaybeJson('php/update_jalan.php', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload)
+ });
+
+ if (!json) throw new Error(text || 'Respon server tidak valid saat update lokasi jalan.');
+ const api = parseApiJson(json);
+ if (!api.success) throw new Error(api.message);
+ if (!res.ok) throw new Error(api.message || `HTTP ${res.status}`);
+ }
+
+ if (type === 'parsil') {
+ const payload = {
+ id,
+ status_kepemilikan: attrs.status_kepemilikan,
+ luas_m2: hitungLuas(layer),
+ geometry: layer.toGeoJSON().geometry
+ };
+
+ const { res, json, text } = await fetchMaybeJson('php/update_parsil.php', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload)
+ });
+
+ if (!json) throw new Error(text || 'Respon server tidak valid saat update lokasi parsil.');
+ const api = parseApiJson(json);
+ if (!api.success) throw new Error(api.message);
+ if (!res.ok) throw new Error(api.message || `HTTP ${res.status}`);
+ }
+
+ if (type === 'pemukiman') {
+ const ll = layer.getLatLng();
+ const payload = { id, latitude: ll.lat, longitude: ll.lng };
+
+ const { res, json, text } = await fetchMaybeJson('php/update_pemukiman_miskin_location.php', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload)
+ });
+
+ if (!json) throw new Error(text || 'Respon server tidak valid saat update lokasi pemukiman miskin.');
+ const api = parseApiJson(json);
+ if (!api.success) throw new Error(api.message);
+ if (!res.ok) throw new Error(api.message || `HTTP ${res.status}`);
+ }
+
+ if (type === 'rumahIbadah') {
+ const ll = layer.getLatLng();
+ const payload = {
+ id,
+ nama: attrs.nama,
+ jenis: attrs.jenis,
+ kontak: attrs.kontak,
+ radius_meter: attrs.radius_meter,
+ latitude: ll.lat,
+ longitude: ll.lng,
+ address: attrs.address
+ };
+
+ const { res, json, text } = await fetchMaybeJson('php/update_rumah_ibadah.php', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload)
+ });
+
+ if (!json) throw new Error(text || 'Respon server tidak valid saat update lokasi rumah ibadah.');
+ const api = parseApiJson(json);
+ if (!api.success) throw new Error(api.message);
+ if (!res.ok) throw new Error(api.message || `HTTP ${res.status}`);
+ }
+
+ showToast('Lokasi berhasil diupdate.', 'success');
+ state.locationEditing.vertexDeleteMode = false;
+ cancelLocationEdit({ silent: true });
+
+ if (type === 'point') await loadPoints();
+ if (type === 'jalan') await loadJalan();
+ if (type === 'parsil') await loadParsil();
+ if (type === 'pemukiman') {
+ await loadPemukimanMiskin();
+ await loadRumahIbadah();
+ }
+ if (type === 'rumahIbadah') {
+ await loadPemukimanMiskin();
+ await loadRumahIbadah();
+ }
+ } catch (e) {
+ showToast(e?.message || String(e), 'error', 5000);
+ }
+}
+
+function cancelLocationEdit({ silent = false } = {}) {
+ if (!state.locationEditing) return;
+ const { type, layer, backup, translate, dragEase } = state.locationEditing;
+ if (translate && translate.disable) translate.disable();
+ if (dragEase && dragEase.disable) dragEase.disable();
+ state.locationEditing.vertexDeleteMode = false;
+ updateDeleteVertexUi();
+ restoreLayerGeometry(layer, backup);
+
+ if (type === 'jalan' && layer.setStyle && backup?.status) {
+ layer.setStyle({ color: getJalanColor(backup.status), weight: 4, opacity: 0.95 });
+ }
+ if (type === 'parsil' && layer.setStyle && backup?.status) {
+ layer.setStyle({ color: '#0f172a', weight: 2, fillColor: getParsilColor(backup.status), fillOpacity: 0.35 });
+ }
+ if (type === 'point' && layer.setIcon && typeof backup?.is24 === 'boolean') {
+ layer.setIcon(makeSpbuIcon(backup.is24));
+ }
+
+ if (type === 'pemukiman' && layer.setIcon) {
+ layer.setIcon(makePemukimanIcon({ isCovered: Boolean(backup?.covered) }));
+ if (layer.__pmCoverHandler) {
+ layer.off('drag', layer.__pmCoverHandler);
+ layer.off('dragend', layer.__pmCoverHandler);
+ layer.__pmCoverHandler = null;
+ }
+ }
+
+ if (type === 'rumahIbadah' && layer.setIcon) {
+ layer.setIcon(makeRumahIbadahIcon());
+ }
+
+ disableLayerEditing(layer);
+ hideEditBar();
+ state.locationEditing = null;
+ updateDeleteVertexUi();
+ updateModeInfo();
+ if (!silent) showToast('Edit lokasi dibatalkan.', 'info');
+}
+
+function startEditLocationPoint(id, marker, row) {
+ resetMode();
+ const backup = backupLayerGeometry(marker);
+ backup.is24 = Number(row?.status_24jam) === 1;
+ state.locationEditing = {
+ type: 'point',
+ id: Number(id),
+ layer: marker,
+ backup,
+ vertexDeleteMode: false,
+ attrs: {
+ nama: row?.nama ?? '',
+ no: row?.no ?? '',
+ deskripsi: row?.deskripsi ?? '',
+ status_24jam: Number(row?.status_24jam) === 1 ? 1 : 0
+ }
+ };
+
+ enableLayerEditing(marker); // enables dragging
+ showEditBar('Edit Lokasi: Point');
+ updateDeleteVertexUi();
+ updateModeInfo();
+ showToast('Geser marker ke posisi baru, lalu klik "Simpan Lokasi".', 'info', 6000);
+}
+
+function startEditLocationPemukiman(id, marker, row) {
+ resetMode();
+ const backup = backupLayerGeometry(marker);
+ const ibadahId = row?.rumah_ibadah_id;
+ backup.covered = ibadahId !== null && ibadahId !== undefined && String(ibadahId).trim() !== '' && String(ibadahId) !== '0';
+ state.locationEditing = {
+ type: 'pemukiman',
+ id: Number(id),
+ layer: marker,
+ backup,
+ vertexDeleteMode: false,
+ attrs: {}
+ };
+
+ marker.setIcon(makePemukimanIcon({ isEdit: true, isCovered: Boolean(backup.covered) }));
+ enableLayerEditing(marker);
+
+ if (marker.__pmCoverHandler) {
+ marker.off('drag', marker.__pmCoverHandler);
+ marker.off('dragend', marker.__pmCoverHandler);
+ }
+ marker.__currentCovered = Boolean(backup.covered);
+ marker.__pmCoverHandler = () => {
+ const current = marker.getLatLng();
+ const cov = computePemukimanCoverage(current);
+ if (cov.covered !== null && marker.__currentCovered !== cov.covered) {
+ marker.__currentCovered = cov.covered;
+ marker.setIcon(makePemukimanIcon({ isEdit: true, isCovered: cov.covered === true }));
+ }
+ };
+ marker.on('drag', marker.__pmCoverHandler);
+ marker.on('dragend', marker.__pmCoverHandler);
+ marker.__pmCoverHandler();
+
+ showEditBar('Edit Lokasi: Pemukiman Miskin');
+ updateDeleteVertexUi();
+ updateModeInfo();
+
+ const label = escapeHtml(row?.kk_nama ?? row?.nama ?? `ID ${id}`);
+ showToast(`Geser marker pemukiman (${label}) ke posisi baru, lalu klik "Simpan Lokasi".`, 'info', 6500);
+}
+
+function startEditLocationRumahIbadah(id, marker, row) {
+ resetMode();
+ const radiusVal = Number(row?.radius_meter);
+ if (!Number.isFinite(radiusVal) || radiusVal <= 0) {
+ showToast('Radius rumah ibadah tidak valid. Silakan Edit Data dulu untuk isi radius.', 'error', 5500);
+ return;
+ }
+ const backup = backupLayerGeometry(marker);
+ state.locationEditing = {
+ type: 'rumahIbadah',
+ id: Number(id),
+ layer: marker,
+ backup,
+ vertexDeleteMode: false,
+ attrs: {
+ nama: row?.nama ?? 'Rumah Ibadah',
+ jenis: row?.jenis ?? 'Lainnya',
+ kontak: row?.kontak ?? '',
+ radius_meter: radiusVal,
+ address: row?.address ?? ''
+ }
+ };
+
+ enableLayerEditing(marker);
+ showEditBar('Edit Lokasi: Rumah Ibadah');
+ updateDeleteVertexUi();
+ updateModeInfo();
+
+ const label = escapeHtml(row?.nama ?? `ID ${id}`);
+ showToast(`Geser marker rumah ibadah (${label}) ke posisi baru, lalu klik "Simpan Lokasi".`, 'info', 6500);
+}
+
+function startEditLocationJalan(id, layer, props) {
+ resetMode();
+ const backup = backupLayerGeometry(layer);
+ backup.status = props?.status_jalan ?? 'Nasional';
+ state.locationEditing = {
+ type: 'jalan',
+ id: Number(id),
+ layer,
+ backup,
+ vertexDeleteMode: false,
+ attrs: {
+ nama_jalan: props?.nama_jalan ?? 'Jalan',
+ status_jalan: props?.status_jalan ?? 'Nasional'
+ }
+ };
+
+ enableLayerEditing(layer);
+ layer.setStyle?.({ color: getJalanColor(props?.status_jalan), weight: 4, opacity: 0.95 });
+ state.locationEditing.translate = enableLayerTranslation(layer);
+
+ showEditBar('Edit Lokasi: Jalan');
+ updateDeleteVertexUi();
+ updateModeInfo();
+ showToast('Geser garis untuk pindah, atau geser vertex untuk ubah bentuk. Lalu simpan.', 'info', 7000);
+}
+
+function startEditLocationParsil(id, layer, props) {
+ resetMode();
+ const backup = backupLayerGeometry(layer);
+ backup.status = props?.status_kepemilikan ?? 'SHM';
+ state.locationEditing = {
+ type: 'parsil',
+ id: Number(id),
+ layer,
+ backup,
+ vertexDeleteMode: false,
+ attrs: {
+ status_kepemilikan: props?.status_kepemilikan ?? 'SHM'
+ }
+ };
+
+ enableLayerEditing(layer);
+ layer.setStyle?.({ color: '#0f172a', weight: 2, fillColor: getParsilColor(props?.status_kepemilikan), fillOpacity: 0.35 });
+ state.locationEditing.translate = enableLayerTranslation(layer);
+
+ showEditBar('Edit Lokasi: Parsil');
+ updateDeleteVertexUi();
+ updateModeInfo();
+ showToast('Geser area untuk pindah, atau geser vertex untuk ubah bentuk. Lalu simpan.', 'info', 7000);
+}
+
+// ================= DRAW EVENT =================
+map.on(L.Draw.Event.CREATED, function (e) {
+ state.currentLayer = e.layer;
+ state.pendingLayer = e.layer;
+ editableGroup.addLayer(e.layer);
+
+ // Disable drawer while modal is open
+ if (state.activeDrawer) {
+ state.activeDrawer.disable();
+ state.activeDrawer = null;
+ }
+
+ if (!state.featureType) {
+ if (e.layerType === 'polyline') state.featureType = 'jalan';
+ if (e.layerType === 'polygon') state.featureType = 'parsil';
+ setActiveModeButton(state.featureType);
+ updateModeInfo();
+ }
+
+ // Apply initial style based on selected status
+ if (state.featureType === 'jalan' && e.layer.setStyle) {
+ e.layer.setStyle({ color: getJalanColor(el.statusJalan?.value), weight: 4, opacity: 0.95 });
+ }
+ if (state.featureType === 'parsil' && e.layer.setStyle) {
+ e.layer.setStyle({ color: '#0f172a', weight: 2, fillColor: getParsilColor(el.statusParsil?.value), fillOpacity: 0.35 });
+ }
+
+ openModal();
+});
+
+// ================= POINT =================
+map.on('click', function (e) {
+ if (state.locationEditing?.vertexDeleteMode && (state.locationEditing.type === 'jalan' || state.locationEditing.type === 'parsil')) {
+ const result = deleteNearestVertex(state.locationEditing.layer, e.latlng);
+ if (result?.ok) {
+ showToast('Vertex dihapus.', 'success');
+ } else if (result?.reason === 'min') {
+ const min = state.locationEditing.type === 'jalan' ? 2 : 3;
+ showToast(`Tidak bisa menghapus (minimal ${min} vertex).`, 'error', 2500);
+ }
+ return;
+ }
+
+ if (state.featureType !== 'point' && state.featureType !== 'rumahIbadah' && state.featureType !== 'pemukiman') return;
+ if (el.modal && !el.modal.classList.contains('hidden')) return;
+
+ state.currentLayer = e.latlng;
+ if (state.featureType === 'point') {
+ el.latPoint.value = e.latlng.lat;
+ el.lngPoint.value = e.latlng.lng;
+ }
+ if (state.featureType === 'rumahIbadah') {
+ el.latRumahIbadah.value = e.latlng.lat;
+ el.lngRumahIbadah.value = e.latlng.lng;
+ }
+ if (state.featureType === 'pemukiman') {
+ el.latPemukiman.value = e.latlng.lat;
+ el.lngPemukiman.value = e.latlng.lng;
+ }
+ openModal();
+});
+
+// ================= SUBMIT =================
+el.featureForm.addEventListener('submit', async function (e) {
+ e.preventDefault();
+
+ const submittingType = state.featureType;
+
+ if (!submittingType) {
+ showToast('Mode belum dipilih.', 'error');
+ return;
+ }
+
+ if (!state.currentLayer && !state.editing?.layer) {
+ showToast('Layer tidak ada!', 'error');
+ return;
+ }
+
+ try {
+ clearFormNotice();
+ setSubmitting(true);
+
+ // UPDATE mode
+ if (state.editing && state.editing.type === submittingType) {
+ if (submittingType === 'point') {
+ const marker = state.editing.layer;
+ const ll = marker.getLatLng();
+ const payload = {
+ id: state.editing.id,
+ nama: el.namaPoint.value,
+ no: el.noPoint.value,
+ deskripsi: el.deskripsiPoint.value,
+ status_24jam: el.status24Jam.checked ? 1 : 0,
+ latitude: ll.lat,
+ longitude: ll.lng
+ };
+
+ const { res, json, text } = await fetchMaybeJson('php/update_point.php', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload)
+ });
+
+ if (!json) throw new Error(text || 'Respon server tidak valid saat update point.');
+ const api = parseApiJson(json);
+ if (!api.success) throw new Error(api.message);
+ if (!res.ok) throw new Error(api.message || `HTTP ${res.status}`);
+ }
+
+ if (submittingType === 'jalan') {
+ const layer = state.editing.layer;
+ const payload = {
+ id: state.editing.id,
+ nama_jalan: el.namaJalan.value,
+ status_jalan: el.statusJalan.value,
+ panjang_meter: hitungPanjang(layer),
+ geometry: layer.toGeoJSON().geometry
+ };
+
+ const { res, json, text } = await fetchMaybeJson('php/update_jalan.php', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload)
+ });
+
+ if (!json) throw new Error(text || 'Respon server tidak valid saat update jalan.');
+ const api = parseApiJson(json);
+ if (!api.success) throw new Error(api.message);
+ if (!res.ok) throw new Error(api.message || `HTTP ${res.status}`);
+ }
+
+ if (submittingType === 'parsil') {
+ const layer = state.editing.layer;
+ const payload = {
+ id: state.editing.id,
+ status_kepemilikan: el.statusParsil.value,
+ luas_m2: hitungLuas(layer),
+ geometry: layer.toGeoJSON().geometry
+ };
+
+ const { res, json, text } = await fetchMaybeJson('php/update_parsil.php', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload)
+ });
+
+ if (!json) throw new Error(text || 'Respon server tidak valid saat update parsil.');
+ const api = parseApiJson(json);
+ if (!api.success) throw new Error(api.message);
+ if (!res.ok) throw new Error(api.message || `HTTP ${res.status}`);
+ }
+
+ if (submittingType === 'rumahIbadah') {
+ const marker = state.editing.layer;
+ const ll = marker.getLatLng();
+ const payload = {
+ id: state.editing.id,
+ nama: el.namaRumahIbadah.value,
+ jenis: el.jenisRumahIbadah.value,
+ kontak: el.kontakRumahIbadah.value,
+ radius_meter: el.radiusRumahIbadah.value,
+ latitude: ll.lat,
+ longitude: ll.lng,
+ address: el.alamatRumahIbadah.value
+ };
+
+ const { res, json, text } = await fetchMaybeJson('php/update_rumah_ibadah.php', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload)
+ });
+
+ if (!json) throw new Error(text || 'Respon server tidak valid saat update rumah ibadah.');
+ const api = parseApiJson(json);
+ if (!api.success) throw new Error(api.message);
+ if (!res.ok) throw new Error(api.message || `HTTP ${res.status}`);
+ }
+
+ if (submittingType === 'pemukiman') {
+ const marker = state.editing.layer;
+ const ll = marker.getLatLng();
+ const payload = {
+ id: state.editing.id,
+ kk_nama: el.kkNamaPemukiman.value,
+ nik: el.nikPemukiman.value,
+ jumlah_anggota: el.jumlahAnggotaPemukiman.value,
+ latitude: ll.lat,
+ longitude: ll.lng,
+ address: el.alamatPemukiman.value,
+ kelurahan: el.kelurahanPemukiman.value,
+ kecamatan: el.kecamatanPemukiman.value,
+ status_bantuan: el.statusBantuanPemukiman.value,
+ jenis_bantuan: el.jenisBantuanPemukiman.value,
+ tanggal_bantuan: el.tanggalBantuanPemukiman.value,
+ anggota: []
+ };
+
+ const { res, json, text } = await fetchMaybeJson('php/update_pemukiman_miskin.php', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload)
+ });
+
+ if (!json) throw new Error(text || 'Respon server tidak valid saat update pemukiman miskin.');
+ const api = parseApiJson(json);
+ if (!api.success) throw new Error(api.message);
+ if (!res.ok) throw new Error(api.message || `HTTP ${res.status}`);
+ }
+
+ showToast('Data berhasil diupdate.', 'success');
+ if (state.editing?.dragEase?.disable) state.editing.dragEase.disable();
+ disableLayerEditing(state.editing.layer);
+ state.editing = null;
+ closeModal();
+
+ if (submittingType === 'point') await loadPoints();
+ if (submittingType === 'jalan') await loadJalan();
+ if (submittingType === 'parsil') await loadParsil();
+ if (submittingType === 'rumahIbadah') {
+ await loadPemukimanMiskin();
+ await loadRumahIbadah();
+ }
+ if (submittingType === 'pemukiman') await loadPemukimanMiskin();
+ return;
+ }
+
+ // POINT
+ if (submittingType === 'point') {
+ const payload = {
+ nama: el.namaPoint.value,
+ no: el.noPoint.value,
+ deskripsi: el.deskripsiPoint.value,
+ status_24jam: el.status24Jam.checked ? 1 : 0,
+ latitude: el.latPoint.value,
+ longitude: el.lngPoint.value
+ };
+
+ const { res, json, text } = await fetchMaybeJson('php/create_point.php', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload)
+ });
+
+ if (!json) throw new Error(text || 'Respon server tidak valid saat menyimpan point.');
+ const api = parseApiJson(json);
+ if (!api.success) throw new Error(api.message);
+ if (!res.ok) throw new Error(api.message || `HTTP ${res.status}`);
+ }
+
+ // JALAN
+ if (submittingType === 'jalan') {
+ const layer = state.currentLayer;
+
+ const payload = {
+ nama_jalan: el.namaJalan.value,
+ status_jalan: el.statusJalan.value,
+ panjang_meter: hitungPanjang(layer),
+ geometry: layer.toGeoJSON().geometry
+ };
+
+ const { res, json, text } = await fetchMaybeJson('php/create_jalan.php', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload)
+ });
+
+ if (!json) throw new Error(text || 'Respon server tidak valid saat menyimpan jalan.');
+ const api = parseApiJson(json);
+ if (!api.success) throw new Error(api.message);
+ if (!res.ok) throw new Error(api.message || `HTTP ${res.status}`);
+ }
+
+ // PARSIL
+ if (submittingType === 'parsil') {
+ const layer = state.currentLayer;
+
+ const payload = {
+ status_kepemilikan: el.statusParsil.value,
+ luas_m2: hitungLuas(layer),
+ geometry: layer.toGeoJSON().geometry
+ };
+
+ const { res, json, text } = await fetchMaybeJson('php/create_parsil.php', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload)
+ });
+
+ if (!json) throw new Error(text || 'Respon server tidak valid saat menyimpan parsil.');
+ const api = parseApiJson(json);
+ if (!api.success) throw new Error(api.message);
+ if (!res.ok) throw new Error(api.message || `HTTP ${res.status}`);
+ }
+
+ // RUMAH IBADAH
+ if (submittingType === 'rumahIbadah') {
+ const payload = {
+ nama: el.namaRumahIbadah.value,
+ jenis: el.jenisRumahIbadah.value,
+ kontak: el.kontakRumahIbadah.value,
+ radius_meter: el.radiusRumahIbadah.value,
+ latitude: el.latRumahIbadah.value,
+ longitude: el.lngRumahIbadah.value,
+ address: el.alamatRumahIbadah.value
+ };
+
+ const { res, json, text } = await fetchMaybeJson('php/create_rumah_ibadah.php', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload)
+ });
+
+ if (!json) throw new Error(text || 'Respon server tidak valid saat menyimpan rumah ibadah.');
+ const api = parseApiJson(json);
+ if (!api.success) throw new Error(api.message);
+ if (!res.ok) throw new Error(api.message || `HTTP ${res.status}`);
+ }
+
+ // PEMUKIMAN MISKIN
+ if (submittingType === 'pemukiman') {
+ const payload = {
+ kk_nama: el.kkNamaPemukiman.value,
+ nik: el.nikPemukiman.value,
+ jumlah_anggota: el.jumlahAnggotaPemukiman.value,
+ latitude: el.latPemukiman.value,
+ longitude: el.lngPemukiman.value,
+ address: el.alamatPemukiman.value,
+ kelurahan: el.kelurahanPemukiman.value,
+ kecamatan: el.kecamatanPemukiman.value,
+ status_bantuan: el.statusBantuanPemukiman.value,
+ jenis_bantuan: el.jenisBantuanPemukiman.value,
+ tanggal_bantuan: el.tanggalBantuanPemukiman.value,
+ anggota: []
+ };
+
+ const { res, json, text } = await fetchMaybeJson('php/create_pemukiman_miskin.php', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload)
+ });
+
+ if (!json) throw new Error(text || 'Respon server tidak valid saat menyimpan pemukiman miskin.');
+ const api = parseApiJson(json);
+ if (!api.success) throw new Error(api.message);
+ if (!res.ok) throw new Error(api.message || `HTTP ${res.status}`);
+ }
+
+ showToast('Data berhasil disimpan.', 'success');
+ closeModal();
+
+ if (submittingType === 'point') await loadPoints();
+ if (submittingType === 'jalan') await loadJalan();
+ if (submittingType === 'parsil') await loadParsil();
+ if (submittingType === 'rumahIbadah') {
+ await loadPemukimanMiskin();
+ await loadRumahIbadah();
+ }
+ if (submittingType === 'pemukiman') await loadPemukimanMiskin();
+ } catch (err) {
+ const msg = err?.message || String(err);
+ setFormNotice(msg, 'error');
+ showToast(msg, 'error', 5000);
+ } finally {
+ setSubmitting(false);
+ }
+});
+
+// live style update for pending layer
+el.statusJalan?.addEventListener('change', function () {
+ const target =
+ state.pendingLayer && state.featureType === 'jalan'
+ ? state.pendingLayer
+ : state.editing?.type === 'jalan'
+ ? state.editing.layer
+ : null;
+
+ if (target && target.setStyle) {
+ target.setStyle({ color: getJalanColor(el.statusJalan.value), weight: 4, opacity: 0.95 });
+ }
+});
+
+el.statusParsil?.addEventListener('change', function () {
+ const target =
+ state.pendingLayer && state.featureType === 'parsil'
+ ? state.pendingLayer
+ : state.editing?.type === 'parsil'
+ ? state.editing.layer
+ : null;
+
+ if (target && target.setStyle) {
+ target.setStyle({ fillColor: getParsilColor(el.statusParsil.value), fillOpacity: 0.35 });
+ }
+});
+
+el.status24Jam?.addEventListener('change', function () {
+ if (state.editing?.type === 'point' && state.editing.layer?.setIcon) {
+ state.editing.layer.setIcon(makeSpbuIcon(el.status24Jam.checked));
+ }
+});
+
+// ================= LOAD LAYERS =================
+function setActiveListItem(kind, id) {
+ if (kind === 'points' && el.pointsList) {
+ const items = el.pointsList.querySelectorAll('.list-item');
+ items.forEach((node) => node.classList.toggle('is-active', node.dataset.id === String(id)));
+ state.selected.points = id;
+ }
+ if (kind === 'jalan' && el.jalanList) {
+ const items = el.jalanList.querySelectorAll('.list-item');
+ items.forEach((node) => node.classList.toggle('is-active', node.dataset.id === String(id)));
+ state.selected.jalan = id;
+ }
+ if (kind === 'parsil' && el.parsilList) {
+ const items = el.parsilList.querySelectorAll('.list-item');
+ items.forEach((node) => node.classList.toggle('is-active', node.dataset.id === String(id)));
+ state.selected.parsil = id;
+ }
+
+ if (kind === 'rumahIbadah' && el.rumahIbadahList) {
+ const items = el.rumahIbadahList.querySelectorAll('.list-item');
+ items.forEach((node) => node.classList.toggle('is-active', node.dataset.id === String(id)));
+ state.selected.rumahIbadah = id;
+ }
+
+ if (kind === 'pemukiman' && el.pemukimanList) {
+ const items = el.pemukimanList.querySelectorAll('.list-item');
+ items.forEach((node) => node.classList.toggle('is-active', node.dataset.id === String(id)));
+ state.selected.pemukiman = id;
+ }
+}
+
+function renderEmptyList(container, text) {
+ if (!container) return;
+ container.innerHTML = '';
+ const div = document.createElement('div');
+ div.className = 'list-empty';
+ div.textContent = text;
+ container.appendChild(div);
+}
+
+function renderPointsList(rows) {
+ if (!el.pointsList) return;
+ if (!Array.isArray(rows) || rows.length === 0) {
+ renderEmptyList(el.pointsList, 'Belum ada data SPBU.');
+ return;
+ }
+
+ el.pointsList.innerHTML = '';
+ for (const row of rows) {
+ const id = row?.id ?? '';
+ const nama = row?.nama ?? 'SPBU';
+ const no = row?.no ?? '';
+
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'list-item';
+ btn.dataset.id = String(id);
+
+ const title = document.createElement('div');
+ title.className = 'list-title';
+ title.textContent = nama;
+
+ const meta = document.createElement('div');
+ meta.className = 'list-meta';
+ meta.textContent = no ? `No: ${no}` : 'Klik untuk zoom';
+
+ btn.appendChild(title);
+ btn.appendChild(meta);
+
+ btn.addEventListener('click', () => {
+ const lat = parseFloat(row?.latitude);
+ const lng = parseFloat(row?.longitude);
+ if (!Number.isFinite(lat) || !Number.isFinite(lng)) return;
+
+ if (el.togglePoints) el.togglePoints.checked = true;
+ applyLayerVisibility();
+
+ const record = state.indices.points.get(String(id));
+ map.flyTo([lat, lng], Math.max(map.getZoom(), 17), { duration: 0.9 });
+ if (record?.layer?.openPopup) record.layer.openPopup();
+ if (record?.layer?.setStyle) {
+ record.layer.setStyle({ radius: 10 });
+ window.setTimeout(() => record.layer.setStyle({ radius: 7 }), 900);
+ }
+ setActiveListItem('points', id);
+ });
+
+ el.pointsList.appendChild(btn);
+ }
+}
+
+function renderJalanList(features) {
+ if (!el.jalanList) return;
+ if (!Array.isArray(features) || features.length === 0) {
+ renderEmptyList(el.jalanList, 'Belum ada data jalan.');
+ return;
+ }
+
+ el.jalanList.innerHTML = '';
+ for (const f of features) {
+ const props = f?.properties ?? {};
+ const id = props?.id ?? '';
+ const nama = props?.nama_jalan ?? 'Jalan';
+ const status = props?.status_jalan ?? '';
+ const panjang = Number.isFinite(Number(props?.panjang_meter))
+ ? `${Number(props.panjang_meter).toFixed(0)} m`
+ : '';
+
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'list-item';
+ btn.dataset.id = String(id);
+
+ const title = document.createElement('div');
+ title.className = 'list-title';
+ title.textContent = nama;
+
+ const meta = document.createElement('div');
+ meta.className = 'list-meta';
+ meta.textContent = [status, panjang].filter(Boolean).join(' β’ ') || 'Klik untuk zoom';
+
+ btn.appendChild(title);
+ btn.appendChild(meta);
+
+ btn.addEventListener('click', () => {
+ if (el.toggleJalan) el.toggleJalan.checked = true;
+ applyLayerVisibility();
+
+ const record = state.indices.jalan.get(String(id));
+ const layer = record?.layer;
+ if (layer?.getBounds) {
+ map.fitBounds(layer.getBounds(), { padding: [30, 30] });
+ }
+ if (layer?.openPopup) layer.openPopup();
+ setActiveListItem('jalan', id);
+ });
+
+ el.jalanList.appendChild(btn);
+ }
+}
+
+function renderParsilList(features) {
+ if (!el.parsilList) return;
+ if (!Array.isArray(features) || features.length === 0) {
+ renderEmptyList(el.parsilList, 'Belum ada data parsil.');
+ return;
+ }
+
+ el.parsilList.innerHTML = '';
+ for (const f of features) {
+ const props = f?.properties ?? {};
+ const id = props?.id ?? '';
+ const status = props?.status_kepemilikan ?? '';
+ const luas = Number.isFinite(Number(props?.luas_m2))
+ ? `${Number(props.luas_m2).toFixed(0)} mΒ²`
+ : '';
+
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'list-item';
+ btn.dataset.id = String(id);
+
+ const title = document.createElement('div');
+ title.className = 'list-title';
+ title.textContent = `Parsil ${status || ''}`.trim();
+
+ const meta = document.createElement('div');
+ meta.className = 'list-meta';
+ meta.textContent = luas ? `Luas: ${luas}` : 'Klik untuk zoom';
+
+ btn.appendChild(title);
+ btn.appendChild(meta);
+
+ btn.addEventListener('click', () => {
+ if (el.toggleParsil) el.toggleParsil.checked = true;
+ applyLayerVisibility();
+
+ const record = state.indices.parsil.get(String(id));
+ const layer = record?.layer;
+ if (layer?.getBounds) {
+ map.fitBounds(layer.getBounds(), { padding: [30, 30] });
+ }
+ if (layer?.openPopup) layer.openPopup();
+ setActiveListItem('parsil', id);
+ });
+
+ el.parsilList.appendChild(btn);
+ }
+}
+
+function renderRumahIbadahList(rows) {
+ if (!el.rumahIbadahList) return;
+ if (!Array.isArray(rows) || rows.length === 0) {
+ renderEmptyList(el.rumahIbadahList, 'Belum ada data rumah ibadah.');
+ return;
+ }
+
+ el.rumahIbadahList.innerHTML = '';
+ for (const row of rows) {
+ const lat = parseFloat(row?.latitude);
+ const lng = parseFloat(row?.longitude);
+ const id = String(row?.id ?? (Number.isFinite(lat) && Number.isFinite(lng) ? `${lat},${lng}` : ''));
+ const nama = row?.nama ?? 'Rumah Ibadah';
+ const jenis = row?.jenis ?? '';
+ const radius = Number(row?.radius_meter);
+ const metaText = [jenis, Number.isFinite(radius) && radius > 0 ? `Radius: ${radius} m` : ''].filter(Boolean).join(' β’ ') || 'Klik untuk zoom';
+
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'list-item';
+ btn.dataset.id = String(id);
+
+ const title = document.createElement('div');
+ title.className = 'list-title';
+ title.textContent = nama;
+
+ const meta = document.createElement('div');
+ meta.className = 'list-meta';
+ meta.textContent = metaText;
+
+ btn.appendChild(title);
+ btn.appendChild(meta);
+
+ btn.addEventListener('click', () => {
+ if (!Number.isFinite(lat) || !Number.isFinite(lng)) return;
+
+ if (el.toggleRumahIbadah) el.toggleRumahIbadah.checked = true;
+ applyLayerVisibility();
+
+ const record = state.indices.rumahIbadah.get(String(id));
+ map.flyTo([lat, lng], Math.max(map.getZoom(), 17), { duration: 0.9 });
+ if (record?.layer?.openPopup) record.layer.openPopup();
+ setActiveListItem('rumahIbadah', id);
+ });
+
+ el.rumahIbadahList.appendChild(btn);
+ }
+}
+
+function renderPemukimanList(rows) {
+ if (!el.pemukimanList) return;
+ if (!Array.isArray(rows) || rows.length === 0) {
+ renderEmptyList(el.pemukimanList, 'Belum ada data pemukiman miskin.');
+ return;
+ }
+
+ el.pemukimanList.innerHTML = '';
+ for (const row of rows) {
+ const lat = parseFloat(row?.latitude);
+ const lng = parseFloat(row?.longitude);
+ const id = String(row?.id ?? (Number.isFinite(lat) && Number.isFinite(lng) ? `${lat},${lng}` : ''));
+ const nama = row?.kk_nama ?? row?.nama ?? 'Pemukiman Miskin';
+ const status = row?.status_bantuan ?? '';
+ const ibadah = row?.rumah_ibadah_nama ?? '';
+ const metaText = [status, ibadah ? `Binaan: ${ibadah}` : ''].filter(Boolean).join(' β’ ') || 'Klik untuk zoom';
+
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'list-item';
+ btn.dataset.id = String(id);
+
+ const title = document.createElement('div');
+ title.className = 'list-title';
+ title.textContent = nama;
+
+ const meta = document.createElement('div');
+ meta.className = 'list-meta';
+ meta.textContent = metaText;
+
+ btn.appendChild(title);
+ btn.appendChild(meta);
+
+ btn.addEventListener('click', () => {
+ if (!Number.isFinite(lat) || !Number.isFinite(lng)) return;
+
+ if (el.togglePemukiman) el.togglePemukiman.checked = true;
+ applyLayerVisibility();
+
+ const record = state.indices.pemukiman.get(String(id));
+ map.flyTo([lat, lng], Math.max(map.getZoom(), 17), { duration: 0.9 });
+ if (record?.layer?.openPopup) record.layer.openPopup();
+ setActiveListItem('pemukiman', id);
+ });
+
+ el.pemukimanList.appendChild(btn);
+ }
+}
+
+async function loadPoints() {
+ points24Layer.clearLayers();
+ pointsNon24Layer.clearLayers();
+ state.counts.points = 0;
+ state.indices.points.clear();
+
+ let rows = [];
+ try {
+ const res = await fetch('php/read_point.php', { cache: 'no-store' });
+ rows = await res.json();
+ } catch {
+ rows = [];
+ }
+
+ let count = 0;
+ if (Array.isArray(rows)) {
+ for (const row of rows) {
+ const lat = parseFloat(row?.latitude);
+ const lng = parseFloat(row?.longitude);
+ if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue;
+
+ const id = String(row?.id ?? `${lat},${lng}`);
+ const nama = escapeHtml(row?.nama ?? 'SPBU');
+ const no = escapeHtml(row?.no ?? '-');
+ const deskripsi = escapeHtml(row?.deskripsi ?? '');
+ const is24 = isSpbu24(row);
+ const status24 = is24 ? 'Ya' : 'Tidak';
+
+ const marker = L.marker([lat, lng], { icon: makeSpbuIcon(is24), draggable: true });
+ marker.on('add', () => {
+ if (marker.dragging && marker.dragging.disable) {
+ marker.dragging.disable();
+ }
+ });
+
+ const popupEl = document.createElement('div');
+ popupEl.innerHTML = `
+
${nama}
+
No: ${no}
+
24 Jam: ${status24}
+ ${deskripsi ? `
${deskripsi}
` : ''}
+ `;
+
+ const actions = document.createElement('div');
+ actions.className = 'popup-actions';
+ const btnEditData = document.createElement('button');
+ btnEditData.type = 'button';
+ btnEditData.className = 'popup-btn edit';
+ btnEditData.textContent = 'Edit Data';
+ const btnEditLoc = document.createElement('button');
+ btnEditLoc.type = 'button';
+ btnEditLoc.className = 'popup-btn spatial';
+ btnEditLoc.textContent = 'Edit Lokasi';
+ const btnDelete = document.createElement('button');
+ btnDelete.type = 'button';
+ btnDelete.className = 'popup-btn delete';
+ btnDelete.textContent = 'Hapus';
+ actions.appendChild(btnEditData);
+ actions.appendChild(btnEditLoc);
+ actions.appendChild(btnDelete);
+ popupEl.appendChild(actions);
+
+ btnEditData.addEventListener('click', () => startEditPoint(id, marker, row));
+ btnEditLoc.addEventListener('click', () => startEditLocationPoint(id, marker, row));
+ btnDelete.addEventListener('click', () => deleteFeature('point', id));
+
+ marker.bindPopup(popupEl);
+ marker.addTo(is24 ? points24Layer : pointsNon24Layer);
+ marker.on('click', () => setActiveListItem('points', id));
+ state.indices.points.set(String(id), { layer: marker, data: row });
+ count += 1;
+ }
+ }
+
+ state.counts.points = count;
+ state.pointsData = Array.isArray(rows) ? rows : [];
+ updateStats();
+ applyPointFilter();
+}
+
+async function loadJalan() {
+ jalanLayer.clearLayers();
+ state.counts.jalan = 0;
+ state.indices.jalan.clear();
+
+ let fc = null;
+ try {
+ const res = await fetch('php/read_jalan.php', { cache: 'no-store' });
+ fc = await res.json();
+ } catch {
+ fc = null;
+ }
+ if (!fc || fc.type !== 'FeatureCollection' || !Array.isArray(fc.features)) {
+ updateStats();
+ renderJalanList([]);
+ return;
+ }
+
+ const geo = L.geoJSON(fc, {
+ style: (feature) => {
+ const status = feature?.properties?.status_jalan;
+ return { color: getJalanColor(status), weight: 4, opacity: 0.95 };
+ },
+ onEachFeature: (feature, layer) => {
+ const props = feature?.properties ?? {};
+ const id = String(props?.id ?? '');
+ const nama = escapeHtml(props.nama_jalan ?? 'Jalan');
+ const status = escapeHtml(props.status_jalan ?? '-');
+ const panjang = Number.isFinite(Number(props.panjang_meter))
+ ? `${Number(props.panjang_meter).toFixed(1)} m`
+ : '-';
+
+ const popupEl = document.createElement('div');
+ popupEl.innerHTML = `
+
${nama}
+
Status: ${status}
+
Panjang: ${panjang}
+ `;
+
+ const actions = document.createElement('div');
+ actions.className = 'popup-actions';
+ const btnEditData = document.createElement('button');
+ btnEditData.type = 'button';
+ btnEditData.className = 'popup-btn edit';
+ btnEditData.textContent = 'Edit Data';
+ const btnEditLoc = document.createElement('button');
+ btnEditLoc.type = 'button';
+ btnEditLoc.className = 'popup-btn spatial';
+ btnEditLoc.textContent = 'Edit Lokasi';
+ const btnDelete = document.createElement('button');
+ btnDelete.type = 'button';
+ btnDelete.className = 'popup-btn delete';
+ btnDelete.textContent = 'Hapus';
+ actions.appendChild(btnEditData);
+ actions.appendChild(btnEditLoc);
+ actions.appendChild(btnDelete);
+ popupEl.appendChild(actions);
+
+ btnEditData.addEventListener('click', () => startEditJalan(id, layer, props));
+ btnEditLoc.addEventListener('click', () => startEditLocationJalan(id, layer, props));
+ btnDelete.addEventListener('click', () => deleteFeature('jalan', id));
+
+ layer.bindPopup(popupEl);
+
+ if (id) {
+ state.indices.jalan.set(id, { layer, data: props });
+ layer.on('click', () => setActiveListItem('jalan', id));
+ }
+ }
+ });
+
+ geo.addTo(jalanLayer);
+ state.counts.jalan = fc.features.length;
+ updateStats();
+ renderJalanList(fc.features);
+}
+
+async function loadParsil() {
+ parsilLayer.clearLayers();
+ state.counts.parsil = 0;
+ state.indices.parsil.clear();
+
+ let fc = null;
+ try {
+ const res = await fetch('php/read_parsil.php', { cache: 'no-store' });
+ fc = await res.json();
+ } catch {
+ fc = null;
+ }
+ if (!fc || fc.type !== 'FeatureCollection' || !Array.isArray(fc.features)) {
+ updateStats();
+ renderParsilList([]);
+ return;
+ }
+
+ const geo = L.geoJSON(fc, {
+ style: (feature) => {
+ const status = feature?.properties?.status_kepemilikan;
+ return {
+ color: '#0f172a',
+ weight: 2,
+ fillColor: getParsilColor(status),
+ fillOpacity: 0.35
+ };
+ },
+ onEachFeature: (feature, layer) => {
+ const props = feature?.properties ?? {};
+ const status = escapeHtml(props.status_kepemilikan ?? '-');
+ const luas = Number.isFinite(Number(props.luas_m2))
+ ? `${Number(props.luas_m2).toFixed(1)} mΒ²`
+ : '-';
+
+ const id = String(props?.id ?? '');
+ const popupEl = document.createElement('div');
+ popupEl.innerHTML = `
+
Parsil
+
Status: ${status}
+
Luas: ${luas}
+ `;
+
+ const actions = document.createElement('div');
+ actions.className = 'popup-actions';
+ const btnEditData = document.createElement('button');
+ btnEditData.type = 'button';
+ btnEditData.className = 'popup-btn edit';
+ btnEditData.textContent = 'Edit Data';
+ const btnEditLoc = document.createElement('button');
+ btnEditLoc.type = 'button';
+ btnEditLoc.className = 'popup-btn spatial';
+ btnEditLoc.textContent = 'Edit Lokasi';
+ const btnDelete = document.createElement('button');
+ btnDelete.type = 'button';
+ btnDelete.className = 'popup-btn delete';
+ btnDelete.textContent = 'Hapus';
+ actions.appendChild(btnEditData);
+ actions.appendChild(btnEditLoc);
+ actions.appendChild(btnDelete);
+ popupEl.appendChild(actions);
+
+ btnEditData.addEventListener('click', () => startEditParsil(id, layer, props));
+ btnEditLoc.addEventListener('click', () => startEditLocationParsil(id, layer, props));
+ btnDelete.addEventListener('click', () => deleteFeature('parsil', id));
+
+ layer.bindPopup(popupEl);
+ if (id) {
+ state.indices.parsil.set(id, { layer, data: props });
+ layer.on('click', () => setActiveListItem('parsil', id));
+ }
+ }
+ });
+
+ geo.addTo(parsilLayer);
+ state.counts.parsil = fc.features.length;
+ updateStats();
+ renderParsilList(fc.features);
+}
+
+async function loadPemukimanMiskin() {
+ pemukimanLayer.clearLayers();
+ state.counts.pemukiman = 0;
+ state.indices.pemukiman.clear();
+
+ let rows = [];
+ try {
+ const { res, json, text } = await fetchMaybeJson('php/read_pemukiman_miskin.php', { cache: 'no-store' });
+ if (!res.ok) {
+ warnOnce('pemukiman_fetch', `Gagal load Pemukiman Miskin (HTTP ${res.status}). Cek koneksi database / endpoint.`);
+ rows = [];
+ } else if (!Array.isArray(json)) {
+ warnOnce('pemukiman_parse', text ? `Respon pemukiman bukan JSON: ${text.slice(0, 120)}` : 'Respon pemukiman bukan JSON.');
+ rows = [];
+ } else {
+ rows = json;
+ }
+ } catch (e) {
+ warnOnce('pemukiman_err', `Gagal load Pemukiman Miskin: ${e?.message || String(e)}`);
+ rows = [];
+ }
+
+ let count = 0;
+ if (Array.isArray(rows)) {
+ for (const row of rows) {
+ const lat = parseFloat(row?.latitude);
+ const lng = parseFloat(row?.longitude);
+ if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue;
+
+ const id = String(row?.id ?? `${lat},${lng}`);
+ const nama = escapeHtml(row?.kk_nama ?? row?.nama ?? 'Pemukiman Miskin');
+ const status = escapeHtml(row?.status_bantuan ?? '-');
+ const ibadah = escapeHtml(row?.rumah_ibadah_nama ?? '-');
+ const jarakNum = Number(row?.jarak_meter);
+ const jarak = Number.isFinite(jarakNum) ? `${jarakNum.toFixed(0)} m` : '-';
+ const ibadahId = row?.rumah_ibadah_id;
+ const covered = ibadahId !== null && ibadahId !== undefined && String(ibadahId).trim() !== '' && String(ibadahId) !== '0';
+
+ const marker = L.marker([lat, lng], { icon: makePemukimanIcon({ isCovered: covered }), draggable: true, autoPan: true, riseOnHover: true });
+ marker.on('add', () => {
+ if (marker.dragging && marker.dragging.disable) marker.dragging.disable();
+ });
+
+ const popupEl = document.createElement('div');
+ popupEl.innerHTML = `
+
${nama}
+
Status: ${status}
+
Rumah Ibadah: ${ibadah}
+
Jarak: ${jarak}
+ `;
+
+ const actions = document.createElement('div');
+ actions.className = 'popup-actions';
+ const btnEditData = document.createElement('button');
+ btnEditData.type = 'button';
+ btnEditData.className = 'popup-btn edit';
+ btnEditData.textContent = 'Edit Data';
+ const btnEditLoc = document.createElement('button');
+ btnEditLoc.type = 'button';
+ btnEditLoc.className = 'popup-btn spatial';
+ btnEditLoc.textContent = 'Edit Lokasi';
+ const btnDelete = document.createElement('button');
+ btnDelete.type = 'button';
+ btnDelete.className = 'popup-btn delete';
+ btnDelete.textContent = 'Hapus';
+
+ actions.appendChild(btnEditData);
+ actions.appendChild(btnEditLoc);
+ actions.appendChild(btnDelete);
+ popupEl.appendChild(actions);
+
+ btnEditData.addEventListener('click', () => startEditPemukiman(id, marker, row));
+ btnEditLoc.addEventListener('click', () => startEditLocationPemukiman(id, marker, row));
+ btnDelete.addEventListener('click', () => deleteFeature('pemukiman', id));
+
+ marker.bindPopup(popupEl);
+ marker.addTo(pemukimanLayer);
+ state.indices.pemukiman.set(String(id), { layer: marker, data: row });
+ marker.on('click', () => setActiveListItem('pemukiman', id));
+ count += 1;
+ }
+ }
+
+ state.counts.pemukiman = count;
+ state.pemukimanData = Array.isArray(rows) ? rows : [];
+ updateStats();
+ renderPemukimanList(state.pemukimanData);
+}
+
+async function loadRumahIbadah() {
+ rumahIbadahLayer.clearLayers();
+ rumahIbadahRadiusLayer.clearLayers();
+ state.indices.rumahIbadah.clear();
+
+ let rows = [];
+ try {
+ const { res, json, text } = await fetchMaybeJson('php/read_rumah_ibadah.php', { cache: 'no-store' });
+ if (!res.ok) {
+ warnOnce('ibadah_fetch', `Gagal load Rumah Ibadah (HTTP ${res.status}). Cek koneksi database / endpoint.`);
+ rows = [];
+ } else if (!Array.isArray(json)) {
+ warnOnce('ibadah_parse', text ? `Respon rumah ibadah bukan JSON: ${text.slice(0, 120)}` : 'Respon rumah ibadah bukan JSON.');
+ rows = [];
+ } else {
+ rows = json;
+ }
+ } catch (e) {
+ warnOnce('ibadah_err', `Gagal load Rumah Ibadah: ${e?.message || String(e)}`);
+ rows = [];
+ }
+
+ if (!Array.isArray(rows)) rows = [];
+ state.rumahIbadahData = rows;
+
+ let count = 0;
+ for (const row of rows) {
+ const lat = parseFloat(row?.latitude);
+ const lng = parseFloat(row?.longitude);
+ if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue;
+
+ const id = String(row?.id ?? `${lat},${lng}`);
+ const nama = escapeHtml(row?.nama ?? 'Rumah Ibadah');
+ const jenis = escapeHtml(row?.jenis ?? '-');
+ const kontak = escapeHtml(row?.kontak ?? '-');
+ const radius = Number(row?.radius_meter);
+ const binaan = row?.binaan_count != null ? escapeHtml(String(row.binaan_count)) : null;
+
+ const marker = L.marker([lat, lng], { icon: makeRumahIbadahIcon(), riseOnHover: true });
+ const popupEl = document.createElement('div');
+ popupEl.innerHTML = `
+
${nama}
+
Jenis: ${jenis}
+
Kontak: ${kontak}
+ ${Number.isFinite(radius) ? `
Radius: ${radius} m
` : ''}
+ ${binaan !== null ? `
Binaan: ${binaan}
` : ''}
+ `;
+
+ const actions = document.createElement('div');
+ actions.className = 'popup-actions';
+ const btnEditData = document.createElement('button');
+ btnEditData.type = 'button';
+ btnEditData.className = 'popup-btn edit';
+ btnEditData.textContent = 'Edit Data';
+ const btnEditLoc = document.createElement('button');
+ btnEditLoc.type = 'button';
+ btnEditLoc.className = 'popup-btn spatial';
+ btnEditLoc.textContent = 'Edit Lokasi';
+ const btnDelete = document.createElement('button');
+ btnDelete.type = 'button';
+ btnDelete.className = 'popup-btn delete';
+ btnDelete.textContent = 'Hapus';
+
+ actions.appendChild(btnEditData);
+ actions.appendChild(btnEditLoc);
+ actions.appendChild(btnDelete);
+ popupEl.appendChild(actions);
+
+ btnEditData.addEventListener('click', () => startEditRumahIbadah(id, marker, row));
+ btnEditLoc.addEventListener('click', () => startEditLocationRumahIbadah(id, marker, row));
+ btnDelete.addEventListener('click', () => deleteFeature('rumahIbadah', id));
+
+ marker.bindPopup(popupEl);
+ marker.addTo(rumahIbadahLayer);
+
+ if (Number.isFinite(radius) && radius > 0) {
+ const circle = L.circle([lat, lng], {
+ radius,
+ color: '#2563eb',
+ weight: 2,
+ opacity: 0.7,
+ fillColor: '#60a5fa',
+ fillOpacity: 0.08
+ });
+ circle.addTo(rumahIbadahRadiusLayer);
+ }
+
+ state.indices.rumahIbadah.set(id, { layer: marker, data: row });
+ marker.on('click', () => setActiveListItem('rumahIbadah', id));
+ count += 1;
+ }
+
+ state.counts.rumahIbadah = count;
+ updateStats();
+ renderRumahIbadahList(rows);
+}
+
+async function loadSektor() {
+ sektorLayer.clearLayers();
+ state.counts.sektor = 0;
+ state.indices.sektor.clear();
+
+ let fc = null;
+ try {
+ const res = await fetch('js/pontianak_kecamatan.geojson', { cache: 'no-store' });
+ if (!res.ok) throw new Error('Network response was not ok');
+ fc = await res.json();
+ } catch (e) {
+ console.error('Failed to load Sektor Kecamatan:', e);
+ fc = null;
+ }
+
+ if (!fc || fc.type !== 'FeatureCollection' || !Array.isArray(fc.features)) {
+ updateStats();
+ renderSektorList([]);
+ return;
+ }
+
+ const colors = ['#f43f5e', '#a855f7', '#3b82f6', '#10b981', '#f59e0b', '#64748b'];
+ let count = 0;
+
+ const geo = L.geoJSON(fc, {
+ style: (feature) => {
+ return {
+ color: '#1e293b',
+ weight: 1,
+ fillColor: colors[count % colors.length],
+ fillOpacity: 0.25
+ };
+ },
+ onEachFeature: (feature, layer) => {
+ const name = feature?.properties?.Kecamatan ?? feature?.properties?.Name ?? 'Kecamatan';
+ const id = name.replace(/\s+/g, '-').toLowerCase();
+
+ const popupEl = document.createElement('div');
+ popupEl.innerHTML = `
+
${name}
+
Tipe: Sektor Kecamatan
+ `;
+
+ layer.bindPopup(popupEl);
+
+ if (id) {
+ state.indices.sektor.set(id, { layer, data: feature.properties });
+ }
+ count++;
+ }
+ });
+
+ geo.addTo(sektorLayer);
+ state.counts.sektor = count;
+ updateStats();
+ renderSektorList(fc.features);
+}
+
+function renderSektorList(features) {
+ if (!el.sektorList) return;
+ if (!Array.isArray(features) || features.length === 0) {
+ renderEmptyList(el.sektorList, 'Belum ada data sektor kecamatan.');
+ return;
+ }
+
+ el.sektorList.innerHTML = '';
+ for (const f of features) {
+ const props = f?.properties ?? {};
+ const name = props?.Kecamatan ?? props?.Name ?? 'Kecamatan';
+ const id = name.replace(/\s+/g, '-').toLowerCase();
+
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'list-item';
+ btn.dataset.id = String(id);
+
+ const title = document.createElement('div');
+ title.className = 'list-title';
+ title.textContent = name;
+
+ const meta = document.createElement('div');
+ meta.className = 'list-meta';
+ meta.textContent = 'Klik untuk zoom';
+
+ btn.appendChild(title);
+ btn.appendChild(meta);
+
+ btn.addEventListener('click', () => {
+ if (el.toggleSektor) el.toggleSektor.checked = true;
+ applyLayerVisibility();
+
+ const record = state.indices.sektor.get(String(id));
+ const layer = record?.layer;
+ if (layer?.getBounds) {
+ map.fitBounds(layer.getBounds(), { padding: [30, 30] });
+ }
+ if (layer?.openPopup) layer.openPopup();
+ setActiveListItem('sektor', id);
+ });
+
+ el.sektorList.appendChild(btn);
+ }
+}
+
+async function loadAll() {
+ try {
+ await Promise.all([loadPoints(), loadJalan(), loadParsil(), loadPemukimanMiskin(), loadRumahIbadah(), loadSektor()]);
+ } catch {
+ updateStats();
+ }
+}
+
+// ================= MAP TOOLBAR =================
+el.btnResetView?.addEventListener('click', function () {
+ map.setView(INITIAL_CENTER, INITIAL_ZOOM);
+});
+
+el.btnMyLocation?.addEventListener('click', function () {
+ if (!navigator.geolocation) {
+ showToast('Browser tidak mendukung geolocation.', 'error');
+ return;
+ }
+
+ el.btnMyLocation.disabled = true;
+ el.btnMyLocation.textContent = 'Mencari...';
+
+ const restore = () => {
+ el.btnMyLocation.disabled = false;
+ el.btnMyLocation.textContent = 'Lokasi Saya';
+ };
+
+ navigator.geolocation.getCurrentPosition(
+ (pos) => {
+ const lat = pos.coords.latitude;
+ const lng = pos.coords.longitude;
+ map.flyTo([lat, lng], Math.max(map.getZoom(), 15), { duration: 1.2 });
+ showToast('Lokasi ditemukan.', 'success');
+ restore();
+ },
+ (err) => {
+ if (err?.code === 1) {
+ showToast('Permintaan lokasi dibatalkan.', 'info');
+ } else {
+ showToast(err?.message || 'Gagal mendapatkan lokasi.', 'error');
+ }
+ restore();
+ },
+ { enableHighAccuracy: true, timeout: 10000 }
+ );
+});
+
+el.btnFilter24?.addEventListener('click', () => togglePointFilter('24'));
+el.btnFilterNon24?.addEventListener('click', () => togglePointFilter('non24'));
+
+// ================= EDIT BAR =================
+el.btnSaveLocation?.addEventListener('click', saveLocationEdit);
+el.btnCancelLocation?.addEventListener('click', () => cancelLocationEdit());
+el.btnDeleteVertex?.addEventListener('click', () => {
+ if (!state.locationEditing) return;
+ setVertexDeleteMode(!state.locationEditing.vertexDeleteMode);
+});
+
+// ================= UI EVENTS =================
+el.togglePoints?.addEventListener('change', applyLayerVisibility);
+el.toggleJalan?.addEventListener('change', applyLayerVisibility);
+el.toggleParsil?.addEventListener('change', applyLayerVisibility);
+el.toggleRumahIbadah?.addEventListener('change', applyLayerVisibility);
+el.togglePemukiman?.addEventListener('change', applyLayerVisibility);
+el.toggleSektor?.addEventListener('change', applyLayerVisibility);
+
+// Prevent toolbar clicks from being treated as map clicks/drag.
+try {
+ const toolbar = document.querySelector('.map-toolbar');
+ if (toolbar && window.L?.DomEvent) {
+ L.DomEvent.disableClickPropagation(toolbar);
+ L.DomEvent.disableScrollPropagation(toolbar);
+ }
+} catch {
+ // ignore
+}
+
+document.addEventListener('keydown', function (e) {
+ if (e.key === 'Escape') resetMode();
+});
+
+// ================= INIT =================
+updateModeInfo();
+applyLayerVisibility();
+loadAll();
+
+if (window.location.protocol === 'file:') {
+ showToast('Buka aplikasi lewat server (contoh: http://localhost/webgis-spbu/) supaya tombol Simpan & Load data berfungsi.', 'error', 7000);
+}
+
+// ================= UTIL =================
+function hitungPanjang(layer) {
+ const latlngs = layer.getLatLngs();
+ let total = 0;
+ for (let i = 0; i < latlngs.length - 1; i++) {
+ total += map.distance(latlngs[i], latlngs[i + 1]);
+ }
+ return total;
+}
+
+function hitungLuas(layer) {
+ return L.GeometryUtil.geodesicArea(layer.getLatLngs()[0]);
+}
diff --git a/webgis-spbu/js/pontianak_kecamatan.geojson b/webgis-spbu/js/pontianak_kecamatan.geojson
new file mode 100644
index 0000000..b766024
--- /dev/null
+++ b/webgis-spbu/js/pontianak_kecamatan.geojson
@@ -0,0 +1,4341 @@
+{
+ "type": "FeatureCollection",
+ "features": [
+ {
+ "type": "Feature",
+ "properties": {
+ "Kecamatan": "Pontianak Barat",
+ "Name": "Kecamatan Pontianak Barat"
+ },
+ "geometry": {
+ "type": "Polygon",
+ "coordinates": [
+ [
+ [
+ 109.2741676,
+ -0.0298708
+ ],
+ [
+ 109.2817778,
+ -0.0423889
+ ],
+ [
+ 109.2820175,
+ -0.0426144
+ ],
+ [
+ 109.2823883,
+ -0.0432466
+ ],
+ [
+ 109.2824075,
+ -0.0433642
+ ],
+ [
+ 109.2837928,
+ -0.0419721
+ ],
+ [
+ 109.2844354,
+ -0.0414061
+ ],
+ [
+ 109.2847768,
+ -0.0410838
+ ],
+ [
+ 109.2855369,
+ -0.0403664
+ ],
+ [
+ 109.2877745,
+ -0.0381716
+ ],
+ [
+ 109.2894039,
+ -0.0365312
+ ],
+ [
+ 109.2901842,
+ -0.0357804
+ ],
+ [
+ 109.291676,
+ -0.0342671
+ ],
+ [
+ 109.293007,
+ -0.0330657
+ ],
+ [
+ 109.2946594,
+ -0.0317142
+ ],
+ [
+ 109.2954512,
+ -0.0311135
+ ],
+ [
+ 109.2969429,
+ -0.0300161
+ ],
+ [
+ 109.2979986,
+ -0.0293114
+ ],
+ [
+ 109.2997771,
+ -0.0280869
+ ],
+ [
+ 109.3012803,
+ -0.0270588
+ ],
+ [
+ 109.3020331,
+ -0.0266799
+ ],
+ [
+ 109.3030245,
+ -0.0261809
+ ],
+ [
+ 109.3049523,
+ -0.0252914
+ ],
+ [
+ 109.3066735,
+ -0.0245406
+ ],
+ [
+ 109.3076718,
+ -0.0241478
+ ],
+ [
+ 109.3093815,
+ -0.0234663
+ ],
+ [
+ 109.3111831,
+ -0.022727
+ ],
+ [
+ 109.3130764,
+ -0.0221263
+ ],
+ [
+ 109.3155091,
+ -0.0214447
+ ],
+ [
+ 109.3179575,
+ -0.0208518
+ ],
+ [
+ 109.3183433,
+ -0.0207632
+ ],
+ [
+ 109.3199613,
+ -0.0204628
+ ],
+ [
+ 109.3208678,
+ -0.020278
+ ],
+ [
+ 109.323484,
+ -0.0197698
+ ],
+ [
+ 109.3247348,
+ -0.0195849
+ ],
+ [
+ 109.3256757,
+ -0.0193886
+ ],
+ [
+ 109.3265613,
+ -0.0192301
+ ],
+ [
+ 109.3265648,
+ -0.0192132
+ ],
+ [
+ 109.3266373,
+ -0.0192009
+ ],
+ [
+ 109.3278158,
+ -0.0190013
+ ],
+ [
+ 109.3298309,
+ -0.01863
+ ],
+ [
+ 109.3305057,
+ -0.0185213
+ ],
+ [
+ 109.3311534,
+ -0.0183674
+ ],
+ [
+ 109.3317202,
+ -0.0181862
+ ],
+ [
+ 109.3322388,
+ -0.0178681
+ ],
+ [
+ 109.332296,
+ -0.017833
+ ],
+ [
+ 109.3328178,
+ -0.0174255
+ ],
+ [
+ 109.3329617,
+ -0.0171809
+ ],
+ [
+ 109.3331866,
+ -0.0168458
+ ],
+ [
+ 109.3335225,
+ -0.0164315
+ ],
+ [
+ 109.3347632,
+ -0.0147765
+ ],
+ [
+ 109.3302446,
+ -0.0110535
+ ],
+ [
+ 109.3233285,
+ -0.0051419
+ ],
+ [
+ 109.3218891,
+ -0.0042186
+ ],
+ [
+ 109.3201467,
+ -0.0031008
+ ],
+ [
+ 109.3159294,
+ -0.0012995
+ ],
+ [
+ 109.3152322,
+ -0.0011241
+ ],
+ [
+ 109.3141567,
+ -0.0006571
+ ],
+ [
+ 109.3127499,
+ 0
+ ],
+ [
+ 109.311816,
+ 0.0003522
+ ],
+ [
+ 109.3102666,
+ 0.0008174
+ ],
+ [
+ 109.3088671,
+ 0.001147
+ ],
+ [
+ 109.3075518,
+ 0.0014411
+ ],
+ [
+ 109.3074637,
+ 0.0014584
+ ],
+ [
+ 109.3062181,
+ 0.0016332
+ ],
+ [
+ 109.3045298,
+ 0.0018846
+ ],
+ [
+ 109.3030103,
+ 0.0020375
+ ],
+ [
+ 109.2990579,
+ 0.0020382
+ ],
+ [
+ 109.2989681,
+ 0.0020386
+ ],
+ [
+ 109.2972853,
+ 0.0023256
+ ],
+ [
+ 109.2962287,
+ 0.0025397
+ ],
+ [
+ 109.2951365,
+ 0.0029556
+ ],
+ [
+ 109.2939708,
+ 0.0034306
+ ],
+ [
+ 109.2920061,
+ 0.0043241
+ ],
+ [
+ 109.2909361,
+ 0.004795
+ ],
+ [
+ 109.2888059,
+ 0.0055106
+ ],
+ [
+ 109.2876731,
+ 0.0057957
+ ],
+ [
+ 109.2872372,
+ 0.0059046
+ ],
+ [
+ 109.2872431,
+ 0.0058348
+ ],
+ [
+ 109.287219,
+ 0.0052928
+ ],
+ [
+ 109.2872127,
+ 0.0052026
+ ],
+ [
+ 109.287164,
+ 0.0047531
+ ],
+ [
+ 109.2869677,
+ 0.0033198
+ ],
+ [
+ 109.2869032,
+ 0.0026901
+ ],
+ [
+ 109.2867996,
+ 0.0018829
+ ],
+ [
+ 109.2867818,
+ 0.0017943
+ ],
+ [
+ 109.2867727,
+ 0.0017044
+ ],
+ [
+ 109.2867781,
+ 0.0016144
+ ],
+ [
+ 109.2867974,
+ 0.0014346
+ ],
+ [
+ 109.2868389,
+ 0.0012588
+ ],
+ [
+ 109.2868574,
+ 0.001079
+ ],
+ [
+ 109.2868597,
+ 0.0008078
+ ],
+ [
+ 109.2868372,
+ 0.0005375
+ ],
+ [
+ 109.2867826,
+ 0.0001802
+ ],
+ [
+ 109.2867711,
+ 0
+ ],
+ [
+ 109.2865973,
+ -0.0016183
+ ],
+ [
+ 109.2865473,
+ -0.00234
+ ],
+ [
+ 109.2865525,
+ -0.0034251
+ ],
+ [
+ 109.2865934,
+ -0.0040568
+ ],
+ [
+ 109.286891,
+ -0.005932
+ ],
+ [
+ 109.2870362,
+ -0.0061501
+ ],
+ [
+ 109.2870704,
+ -0.0062792
+ ],
+ [
+ 109.2870619,
+ -0.0064341
+ ],
+ [
+ 109.2870678,
+ -0.0067275
+ ],
+ [
+ 109.2870394,
+ -0.0074126
+ ],
+ [
+ 109.2869764,
+ -0.0079056
+ ],
+ [
+ 109.2869507,
+ -0.0083359
+ ],
+ [
+ 109.286908,
+ -0.0085941
+ ],
+ [
+ 109.2869251,
+ -0.008749
+ ],
+ [
+ 109.2868994,
+ -0.0089727
+ ],
+ [
+ 109.2867274,
+ -0.0094967
+ ],
+ [
+ 109.2864207,
+ -0.010255
+ ],
+ [
+ 109.286207,
+ -0.0108832
+ ],
+ [
+ 109.2857027,
+ -0.0120879
+ ],
+ [
+ 109.2856343,
+ -0.0123633
+ ],
+ [
+ 109.2856498,
+ -0.0126656
+ ],
+ [
+ 109.2857454,
+ -0.013112
+ ],
+ [
+ 109.2858394,
+ -0.013439
+ ],
+ [
+ 109.2858907,
+ -0.0136542
+ ],
+ [
+ 109.2859078,
+ -0.0138263
+ ],
+ [
+ 109.2859078,
+ -0.0141361
+ ],
+ [
+ 109.2856599,
+ -0.0152462
+ ],
+ [
+ 109.2855317,
+ -0.0157884
+ ],
+ [
+ 109.2854719,
+ -0.01621
+ ],
+ [
+ 109.2853949,
+ -0.0165112
+ ],
+ [
+ 109.2853522,
+ -0.0167264
+ ],
+ [
+ 109.2853607,
+ -0.0168899
+ ],
+ [
+ 109.2853865,
+ -0.0170974
+ ],
+ [
+ 109.2854202,
+ -0.0172678
+ ],
+ [
+ 109.2855062,
+ -0.0175185
+ ],
+ [
+ 109.2856343,
+ -0.0180681
+ ],
+ [
+ 109.2856428,
+ -0.0182065
+ ],
+ [
+ 109.285693,
+ -0.0184926
+ ],
+ [
+ 109.2840229,
+ -0.0191278
+ ],
+ [
+ 109.2831696,
+ -0.0194969
+ ],
+ [
+ 109.2824637,
+ -0.0198748
+ ],
+ [
+ 109.2819788,
+ -0.0201957
+ ],
+ [
+ 109.2816028,
+ -0.0204718
+ ],
+ [
+ 109.2810977,
+ -0.0209085
+ ],
+ [
+ 109.2806164,
+ -0.021369
+ ],
+ [
+ 109.2801262,
+ -0.0219905
+ ],
+ [
+ 109.279986,
+ -0.0221682
+ ],
+ [
+ 109.2792587,
+ -0.0231284
+ ],
+ [
+ 109.2788416,
+ -0.0237571
+ ],
+ [
+ 109.2780275,
+ -0.0248326
+ ],
+ [
+ 109.2774155,
+ -0.0256573
+ ],
+ [
+ 109.2768985,
+ -0.0263668
+ ],
+ [
+ 109.2753548,
+ -0.0284346
+ ],
+ [
+ 109.2746608,
+ -0.0293029
+ ],
+ [
+ 109.2741676,
+ -0.0298708
+ ]
+ ]
+ ]
+ }
+ },
+ {
+ "type": "Feature",
+ "properties": {
+ "Kecamatan": "Pontianak Kota",
+ "Name": "Kecamatan Pontianak Kota"
+ },
+ "geometry": {
+ "type": "Polygon",
+ "coordinates": [
+ [
+ [
+ 109.2847768,
+ -0.0410838
+ ],
+ [
+ 109.2854654,
+ -0.0418774
+ ],
+ [
+ 109.2912339,
+ -0.0479454
+ ],
+ [
+ 109.2928786,
+ -0.0494856
+ ],
+ [
+ 109.2954541,
+ -0.0519818
+ ],
+ [
+ 109.2969562,
+ -0.0536265
+ ],
+ [
+ 109.2967851,
+ -0.0537501
+ ],
+ [
+ 109.2976312,
+ -0.0546628
+ ],
+ [
+ 109.2988322,
+ -0.0559363
+ ],
+ [
+ 109.2993805,
+ -0.0565177
+ ],
+ [
+ 109.3010491,
+ -0.0583858
+ ],
+ [
+ 109.3020901,
+ -0.0596693
+ ],
+ [
+ 109.3022279,
+ -0.0598262
+ ],
+ [
+ 109.3022574,
+ -0.0599481
+ ],
+ [
+ 109.3021762,
+ -0.0600297
+ ],
+ [
+ 109.3021447,
+ -0.0600409
+ ],
+ [
+ 109.3020909,
+ -0.0601048
+ ],
+ [
+ 109.3027708,
+ -0.0610007
+ ],
+ [
+ 109.3028551,
+ -0.0611401
+ ],
+ [
+ 109.3029897,
+ -0.0612932
+ ],
+ [
+ 109.3030176,
+ -0.0613191
+ ],
+ [
+ 109.3031603,
+ -0.0614871
+ ],
+ [
+ 109.3030844,
+ -0.0615552
+ ],
+ [
+ 109.3030177,
+ -0.0615507
+ ],
+ [
+ 109.3028158,
+ -0.0617142
+ ],
+ [
+ 109.3027943,
+ -0.0617138
+ ],
+ [
+ 109.3026567,
+ -0.0618154
+ ],
+ [
+ 109.3027397,
+ -0.0619222
+ ],
+ [
+ 109.3035489,
+ -0.0629014
+ ],
+ [
+ 109.3035057,
+ -0.062943
+ ],
+ [
+ 109.3038028,
+ -0.063297
+ ],
+ [
+ 109.3038142,
+ -0.0633093
+ ],
+ [
+ 109.303901,
+ -0.0632202
+ ],
+ [
+ 109.3039174,
+ -0.0632362
+ ],
+ [
+ 109.3039418,
+ -0.06326
+ ],
+ [
+ 109.304418,
+ -0.0627711
+ ],
+ [
+ 109.3048763,
+ -0.0623214
+ ],
+ [
+ 109.3051618,
+ -0.0620501
+ ],
+ [
+ 109.3054074,
+ -0.0618017
+ ],
+ [
+ 109.3058971,
+ -0.0612806
+ ],
+ [
+ 109.3062625,
+ -0.060928
+ ],
+ [
+ 109.3068222,
+ -0.0603441
+ ],
+ [
+ 109.307052,
+ -0.0600842
+ ],
+ [
+ 109.3073876,
+ -0.0597801
+ ],
+ [
+ 109.3079658,
+ -0.0592124
+ ],
+ [
+ 109.3088212,
+ -0.0583383
+ ],
+ [
+ 109.3090999,
+ -0.0580588
+ ],
+ [
+ 109.3094366,
+ -0.0577304
+ ],
+ [
+ 109.3097031,
+ -0.0574532
+ ],
+ [
+ 109.3099535,
+ -0.0571801
+ ],
+ [
+ 109.3103145,
+ -0.0568254
+ ],
+ [
+ 109.3108064,
+ -0.0563258
+ ],
+ [
+ 109.3112523,
+ -0.0558642
+ ],
+ [
+ 109.311915,
+ -0.0552312
+ ],
+ [
+ 109.3120756,
+ -0.0550587
+ ],
+ [
+ 109.312266,
+ -0.0548779
+ ],
+ [
+ 109.3125087,
+ -0.0546304
+ ],
+ [
+ 109.3128267,
+ -0.0543274
+ ],
+ [
+ 109.3134837,
+ -0.0536685
+ ],
+ [
+ 109.3137662,
+ -0.0533943
+ ],
+ [
+ 109.3141862,
+ -0.0529743
+ ],
+ [
+ 109.3149631,
+ -0.0522093
+ ],
+ [
+ 109.3153546,
+ -0.0518168
+ ],
+ [
+ 109.3157471,
+ -0.0514348
+ ],
+ [
+ 109.3160641,
+ -0.0511319
+ ],
+ [
+ 109.3163242,
+ -0.050868
+ ],
+ [
+ 109.3164851,
+ -0.0507048
+ ],
+ [
+ 109.3170869,
+ -0.0501227
+ ],
+ [
+ 109.3179756,
+ -0.0492379
+ ],
+ [
+ 109.3186224,
+ -0.0485719
+ ],
+ [
+ 109.3189877,
+ -0.0482141
+ ],
+ [
+ 109.3191926,
+ -0.0479904
+ ],
+ [
+ 109.319911,
+ -0.0473094
+ ],
+ [
+ 109.3207602,
+ -0.0464545
+ ],
+ [
+ 109.3211552,
+ -0.0460768
+ ],
+ [
+ 109.3216095,
+ -0.0456494
+ ],
+ [
+ 109.3220834,
+ -0.0451772
+ ],
+ [
+ 109.3228932,
+ -0.0443521
+ ],
+ [
+ 109.3233364,
+ -0.0439435
+ ],
+ [
+ 109.324009,
+ -0.0432884
+ ],
+ [
+ 109.3244139,
+ -0.0428659
+ ],
+ [
+ 109.3246355,
+ -0.042667
+ ],
+ [
+ 109.3256531,
+ -0.0417228
+ ],
+ [
+ 109.3259959,
+ -0.0414094
+ ],
+ [
+ 109.326772,
+ -0.0407138
+ ],
+ [
+ 109.3272589,
+ -0.0403027
+ ],
+ [
+ 109.3278399,
+ -0.0397916
+ ],
+ [
+ 109.3282882,
+ -0.0393675
+ ],
+ [
+ 109.328663,
+ -0.0390689
+ ],
+ [
+ 109.3287735,
+ -0.0389692
+ ],
+ [
+ 109.3291713,
+ -0.0386371
+ ],
+ [
+ 109.3291974,
+ -0.0386153
+ ],
+ [
+ 109.3292121,
+ -0.038603
+ ],
+ [
+ 109.3292679,
+ -0.0385487
+ ],
+ [
+ 109.3296128,
+ -0.0382584
+ ],
+ [
+ 109.3301362,
+ -0.0377614
+ ],
+ [
+ 109.3306744,
+ -0.0372892
+ ],
+ [
+ 109.3314281,
+ -0.0366439
+ ],
+ [
+ 109.3317914,
+ -0.0363262
+ ],
+ [
+ 109.3321746,
+ -0.0360669
+ ],
+ [
+ 109.3328349,
+ -0.0355404
+ ],
+ [
+ 109.3328738,
+ -0.0354048
+ ],
+ [
+ 109.3329821,
+ -0.0351436
+ ],
+ [
+ 109.3368442,
+ -0.0314183
+ ],
+ [
+ 109.3374914,
+ -0.0308116
+ ],
+ [
+ 109.3381077,
+ -0.0303547
+ ],
+ [
+ 109.3388549,
+ -0.0298384
+ ],
+ [
+ 109.3397734,
+ -0.0291555
+ ],
+ [
+ 109.3403635,
+ -0.0287867
+ ],
+ [
+ 109.3410273,
+ -0.0283631
+ ],
+ [
+ 109.342041,
+ -0.0277706
+ ],
+ [
+ 109.3429071,
+ -0.0271666
+ ],
+ [
+ 109.343135,
+ -0.026948
+ ],
+ [
+ 109.3435191,
+ -0.0267085
+ ],
+ [
+ 109.343795,
+ -0.0265511
+ ],
+ [
+ 109.3441519,
+ -0.0263715
+ ],
+ [
+ 109.3444295,
+ -0.0262517
+ ],
+ [
+ 109.3447145,
+ -0.0260852
+ ],
+ [
+ 109.344759,
+ -0.02608
+ ],
+ [
+ 109.3456443,
+ -0.0256293
+ ],
+ [
+ 109.3454961,
+ -0.0252998
+ ],
+ [
+ 109.3454518,
+ -0.0252213
+ ],
+ [
+ 109.3441828,
+ -0.0231404
+ ],
+ [
+ 109.3441358,
+ -0.0230634
+ ],
+ [
+ 109.3440808,
+ -0.0229924
+ ],
+ [
+ 109.3435265,
+ -0.0223999
+ ],
+ [
+ 109.3434614,
+ -0.0223379
+ ],
+ [
+ 109.3428486,
+ -0.0218527
+ ],
+ [
+ 109.3408488,
+ -0.0202696
+ ],
+ [
+ 109.3382402,
+ -0.0178908
+ ],
+ [
+ 109.3376923,
+ -0.0173912
+ ],
+ [
+ 109.3354561,
+ -0.0153519
+ ],
+ [
+ 109.3353888,
+ -0.015292
+ ],
+ [
+ 109.3351802,
+ -0.0151202
+ ],
+ [
+ 109.3347632,
+ -0.0147765
+ ],
+ [
+ 109.3335225,
+ -0.0164315
+ ],
+ [
+ 109.3331866,
+ -0.0168458
+ ],
+ [
+ 109.3329617,
+ -0.0171809
+ ],
+ [
+ 109.3328178,
+ -0.0174255
+ ],
+ [
+ 109.332296,
+ -0.017833
+ ],
+ [
+ 109.3322388,
+ -0.0178681
+ ],
+ [
+ 109.3317202,
+ -0.0181862
+ ],
+ [
+ 109.3311534,
+ -0.0183674
+ ],
+ [
+ 109.3305057,
+ -0.0185213
+ ],
+ [
+ 109.3298309,
+ -0.01863
+ ],
+ [
+ 109.3278158,
+ -0.0190013
+ ],
+ [
+ 109.3266373,
+ -0.0192009
+ ],
+ [
+ 109.3265648,
+ -0.0192132
+ ],
+ [
+ 109.3265613,
+ -0.0192301
+ ],
+ [
+ 109.3256757,
+ -0.0193886
+ ],
+ [
+ 109.3247348,
+ -0.0195849
+ ],
+ [
+ 109.323484,
+ -0.0197698
+ ],
+ [
+ 109.3208678,
+ -0.020278
+ ],
+ [
+ 109.3199613,
+ -0.0204628
+ ],
+ [
+ 109.3183433,
+ -0.0207632
+ ],
+ [
+ 109.3179575,
+ -0.0208518
+ ],
+ [
+ 109.3155091,
+ -0.0214447
+ ],
+ [
+ 109.3130764,
+ -0.0221263
+ ],
+ [
+ 109.3111831,
+ -0.022727
+ ],
+ [
+ 109.3093815,
+ -0.0234663
+ ],
+ [
+ 109.3076718,
+ -0.0241478
+ ],
+ [
+ 109.3066735,
+ -0.0245406
+ ],
+ [
+ 109.3049523,
+ -0.0252914
+ ],
+ [
+ 109.3030245,
+ -0.0261809
+ ],
+ [
+ 109.3020331,
+ -0.0266799
+ ],
+ [
+ 109.3012803,
+ -0.0270588
+ ],
+ [
+ 109.2997771,
+ -0.0280869
+ ],
+ [
+ 109.2979986,
+ -0.0293114
+ ],
+ [
+ 109.2969429,
+ -0.0300161
+ ],
+ [
+ 109.2954512,
+ -0.0311135
+ ],
+ [
+ 109.2946594,
+ -0.0317142
+ ],
+ [
+ 109.293007,
+ -0.0330657
+ ],
+ [
+ 109.291676,
+ -0.0342671
+ ],
+ [
+ 109.2901842,
+ -0.0357804
+ ],
+ [
+ 109.2894039,
+ -0.0365312
+ ],
+ [
+ 109.2877745,
+ -0.0381716
+ ],
+ [
+ 109.2855369,
+ -0.0403664
+ ],
+ [
+ 109.2847768,
+ -0.0410838
+ ]
+ ]
+ ]
+ }
+ },
+ {
+ "type": "Feature",
+ "properties": {
+ "Kecamatan": "Pontianak Selatan",
+ "Name": "Kecamatan Pontianak Selatan"
+ },
+ "geometry": {
+ "type": "Polygon",
+ "coordinates": [
+ [
+ [
+ 109.3039418,
+ -0.06326
+ ],
+ [
+ 109.3040054,
+ -0.063322
+ ],
+ [
+ 109.3044946,
+ -0.0636438
+ ],
+ [
+ 109.3045361,
+ -0.0637234
+ ],
+ [
+ 109.3049351,
+ -0.0640178
+ ],
+ [
+ 109.3053255,
+ -0.0642953
+ ],
+ [
+ 109.3052846,
+ -0.0643656
+ ],
+ [
+ 109.3055087,
+ -0.0645136
+ ],
+ [
+ 109.3052998,
+ -0.064778
+ ],
+ [
+ 109.3061783,
+ -0.0654232
+ ],
+ [
+ 109.3058331,
+ -0.065886
+ ],
+ [
+ 109.3054797,
+ -0.0663854
+ ],
+ [
+ 109.30574,
+ -0.0665836
+ ],
+ [
+ 109.3054372,
+ -0.0668934
+ ],
+ [
+ 109.3053554,
+ -0.06683
+ ],
+ [
+ 109.3052578,
+ -0.0669674
+ ],
+ [
+ 109.3053089,
+ -0.0670067
+ ],
+ [
+ 109.305153,
+ -0.0671676
+ ],
+ [
+ 109.3050995,
+ -0.0671251
+ ],
+ [
+ 109.304986,
+ -0.0672429
+ ],
+ [
+ 109.3050482,
+ -0.0672933
+ ],
+ [
+ 109.304738,
+ -0.0676115
+ ],
+ [
+ 109.3055505,
+ -0.0686349
+ ],
+ [
+ 109.3055934,
+ -0.0687063
+ ],
+ [
+ 109.3052043,
+ -0.0691588
+ ],
+ [
+ 109.3066775,
+ -0.0701559
+ ],
+ [
+ 109.3078647,
+ -0.0709735
+ ],
+ [
+ 109.3072989,
+ -0.071628
+ ],
+ [
+ 109.3078743,
+ -0.0721731
+ ],
+ [
+ 109.3078029,
+ -0.0723348
+ ],
+ [
+ 109.3075147,
+ -0.0730326
+ ],
+ [
+ 109.3071244,
+ -0.0741664
+ ],
+ [
+ 109.3067034,
+ -0.0752978
+ ],
+ [
+ 109.3064932,
+ -0.0758295
+ ],
+ [
+ 109.3065801,
+ -0.0762906
+ ],
+ [
+ 109.3065942,
+ -0.0767611
+ ],
+ [
+ 109.3066128,
+ -0.0772192
+ ],
+ [
+ 109.3078922,
+ -0.0779615
+ ],
+ [
+ 109.3107796,
+ -0.07969
+ ],
+ [
+ 109.3108333,
+ -0.0797222
+ ],
+ [
+ 109.3202778,
+ -0.0852778
+ ],
+ [
+ 109.3206711,
+ -0.0857694
+ ],
+ [
+ 109.3224725,
+ -0.0880212
+ ],
+ [
+ 109.323928,
+ -0.0854277
+ ],
+ [
+ 109.3219873,
+ -0.0832884
+ ],
+ [
+ 109.3233608,
+ -0.0812877
+ ],
+ [
+ 109.3249679,
+ -0.0790044
+ ],
+ [
+ 109.3256936,
+ -0.0779265
+ ],
+ [
+ 109.3265094,
+ -0.0766657
+ ],
+ [
+ 109.327624,
+ -0.0749794
+ ],
+ [
+ 109.3288739,
+ -0.0731236
+ ],
+ [
+ 109.3305545,
+ -0.0705345
+ ],
+ [
+ 109.3310448,
+ -0.0698008
+ ],
+ [
+ 109.3318211,
+ -0.0688343
+ ],
+ [
+ 109.3331777,
+ -0.0672767
+ ],
+ [
+ 109.3338397,
+ -0.0664927
+ ],
+ [
+ 109.3343637,
+ -0.0658721
+ ],
+ [
+ 109.335146,
+ -0.0649085
+ ],
+ [
+ 109.3360536,
+ -0.0638581
+ ],
+ [
+ 109.3365677,
+ -0.0632845
+ ],
+ [
+ 109.3369769,
+ -0.0627779
+ ],
+ [
+ 109.3373884,
+ -0.062291
+ ],
+ [
+ 109.3377636,
+ -0.0618735
+ ],
+ [
+ 109.3379068,
+ -0.0617045
+ ],
+ [
+ 109.3380895,
+ -0.0615305
+ ],
+ [
+ 109.3385002,
+ -0.0610596
+ ],
+ [
+ 109.3394758,
+ -0.0599561
+ ],
+ [
+ 109.3405819,
+ -0.0586716
+ ],
+ [
+ 109.3413518,
+ -0.0577879
+ ],
+ [
+ 109.3418814,
+ -0.0572063
+ ],
+ [
+ 109.3429633,
+ -0.0559294
+ ],
+ [
+ 109.3440063,
+ -0.0547281
+ ],
+ [
+ 109.3449202,
+ -0.0536878
+ ],
+ [
+ 109.3455534,
+ -0.0529496
+ ],
+ [
+ 109.3457278,
+ -0.0527463
+ ],
+ [
+ 109.3464373,
+ -0.0519604
+ ],
+ [
+ 109.3464601,
+ -0.0519351
+ ],
+ [
+ 109.3465392,
+ -0.0518475
+ ],
+ [
+ 109.3469412,
+ -0.0513682
+ ],
+ [
+ 109.3476533,
+ -0.0504927
+ ],
+ [
+ 109.3479601,
+ -0.0499631
+ ],
+ [
+ 109.348686,
+ -0.0488562
+ ],
+ [
+ 109.3492012,
+ -0.0480329
+ ],
+ [
+ 109.3495006,
+ -0.0475738
+ ],
+ [
+ 109.3496942,
+ -0.0473342
+ ],
+ [
+ 109.3513827,
+ -0.0455163
+ ],
+ [
+ 109.3518138,
+ -0.0450794
+ ],
+ [
+ 109.3519166,
+ -0.0449509
+ ],
+ [
+ 109.3519252,
+ -0.0449124
+ ],
+ [
+ 109.3516639,
+ -0.0446868
+ ],
+ [
+ 109.3516454,
+ -0.044664
+ ],
+ [
+ 109.3516482,
+ -0.0446268
+ ],
+ [
+ 109.3516796,
+ -0.0445926
+ ],
+ [
+ 109.3522985,
+ -0.0439893
+ ],
+ [
+ 109.3527954,
+ -0.0435049
+ ],
+ [
+ 109.3542469,
+ -0.042217
+ ],
+ [
+ 109.3543235,
+ -0.0421357
+ ],
+ [
+ 109.3551133,
+ -0.0414936
+ ],
+ [
+ 109.3551747,
+ -0.0414386
+ ],
+ [
+ 109.3541897,
+ -0.0404159
+ ],
+ [
+ 109.3525049,
+ -0.0384155
+ ],
+ [
+ 109.3524501,
+ -0.038344
+ ],
+ [
+ 109.3512119,
+ -0.0365673
+ ],
+ [
+ 109.3499861,
+ -0.0344603
+ ],
+ [
+ 109.3499437,
+ -0.0343806
+ ],
+ [
+ 109.3472764,
+ -0.0292533
+ ],
+ [
+ 109.3467671,
+ -0.0281247
+ ],
+ [
+ 109.3456443,
+ -0.0256293
+ ],
+ [
+ 109.344759,
+ -0.02608
+ ],
+ [
+ 109.3447145,
+ -0.0260852
+ ],
+ [
+ 109.3444295,
+ -0.0262517
+ ],
+ [
+ 109.3441519,
+ -0.0263715
+ ],
+ [
+ 109.343795,
+ -0.0265511
+ ],
+ [
+ 109.3435191,
+ -0.0267085
+ ],
+ [
+ 109.343135,
+ -0.026948
+ ],
+ [
+ 109.3429071,
+ -0.0271666
+ ],
+ [
+ 109.342041,
+ -0.0277706
+ ],
+ [
+ 109.3410273,
+ -0.0283631
+ ],
+ [
+ 109.3403635,
+ -0.0287867
+ ],
+ [
+ 109.3397734,
+ -0.0291555
+ ],
+ [
+ 109.3388549,
+ -0.0298384
+ ],
+ [
+ 109.3381077,
+ -0.0303547
+ ],
+ [
+ 109.3374914,
+ -0.0308116
+ ],
+ [
+ 109.3368442,
+ -0.0314183
+ ],
+ [
+ 109.3329821,
+ -0.0351436
+ ],
+ [
+ 109.3328738,
+ -0.0354048
+ ],
+ [
+ 109.3328349,
+ -0.0355404
+ ],
+ [
+ 109.3321746,
+ -0.0360669
+ ],
+ [
+ 109.3317914,
+ -0.0363262
+ ],
+ [
+ 109.3314281,
+ -0.0366439
+ ],
+ [
+ 109.3306744,
+ -0.0372892
+ ],
+ [
+ 109.3301362,
+ -0.0377614
+ ],
+ [
+ 109.3296128,
+ -0.0382584
+ ],
+ [
+ 109.3292679,
+ -0.0385487
+ ],
+ [
+ 109.3292121,
+ -0.038603
+ ],
+ [
+ 109.3291974,
+ -0.0386153
+ ],
+ [
+ 109.3291713,
+ -0.0386371
+ ],
+ [
+ 109.3287735,
+ -0.0389692
+ ],
+ [
+ 109.328663,
+ -0.0390689
+ ],
+ [
+ 109.3282882,
+ -0.0393675
+ ],
+ [
+ 109.3278399,
+ -0.0397916
+ ],
+ [
+ 109.3272589,
+ -0.0403027
+ ],
+ [
+ 109.326772,
+ -0.0407138
+ ],
+ [
+ 109.3259959,
+ -0.0414094
+ ],
+ [
+ 109.3256531,
+ -0.0417228
+ ],
+ [
+ 109.3246355,
+ -0.042667
+ ],
+ [
+ 109.3244139,
+ -0.0428659
+ ],
+ [
+ 109.324009,
+ -0.0432884
+ ],
+ [
+ 109.3233364,
+ -0.0439435
+ ],
+ [
+ 109.3228932,
+ -0.0443521
+ ],
+ [
+ 109.3220834,
+ -0.0451772
+ ],
+ [
+ 109.3216095,
+ -0.0456494
+ ],
+ [
+ 109.3211552,
+ -0.0460768
+ ],
+ [
+ 109.3207602,
+ -0.0464545
+ ],
+ [
+ 109.319911,
+ -0.0473094
+ ],
+ [
+ 109.3191926,
+ -0.0479904
+ ],
+ [
+ 109.3189877,
+ -0.0482141
+ ],
+ [
+ 109.3186224,
+ -0.0485719
+ ],
+ [
+ 109.3179756,
+ -0.0492379
+ ],
+ [
+ 109.3170869,
+ -0.0501227
+ ],
+ [
+ 109.3164851,
+ -0.0507048
+ ],
+ [
+ 109.3163242,
+ -0.050868
+ ],
+ [
+ 109.3160641,
+ -0.0511319
+ ],
+ [
+ 109.3157471,
+ -0.0514348
+ ],
+ [
+ 109.3153546,
+ -0.0518168
+ ],
+ [
+ 109.3149631,
+ -0.0522093
+ ],
+ [
+ 109.3141862,
+ -0.0529743
+ ],
+ [
+ 109.3137662,
+ -0.0533943
+ ],
+ [
+ 109.3134837,
+ -0.0536685
+ ],
+ [
+ 109.3128267,
+ -0.0543274
+ ],
+ [
+ 109.3125087,
+ -0.0546304
+ ],
+ [
+ 109.312266,
+ -0.0548779
+ ],
+ [
+ 109.3120756,
+ -0.0550587
+ ],
+ [
+ 109.311915,
+ -0.0552312
+ ],
+ [
+ 109.3112523,
+ -0.0558642
+ ],
+ [
+ 109.3108064,
+ -0.0563258
+ ],
+ [
+ 109.3103145,
+ -0.0568254
+ ],
+ [
+ 109.3099535,
+ -0.0571801
+ ],
+ [
+ 109.3097031,
+ -0.0574532
+ ],
+ [
+ 109.3094366,
+ -0.0577304
+ ],
+ [
+ 109.3090999,
+ -0.0580588
+ ],
+ [
+ 109.3088212,
+ -0.0583383
+ ],
+ [
+ 109.3079658,
+ -0.0592124
+ ],
+ [
+ 109.3073876,
+ -0.0597801
+ ],
+ [
+ 109.307052,
+ -0.0600842
+ ],
+ [
+ 109.3068222,
+ -0.0603441
+ ],
+ [
+ 109.3062625,
+ -0.060928
+ ],
+ [
+ 109.3058971,
+ -0.0612806
+ ],
+ [
+ 109.3054074,
+ -0.0618017
+ ],
+ [
+ 109.3051618,
+ -0.0620501
+ ],
+ [
+ 109.3048763,
+ -0.0623214
+ ],
+ [
+ 109.304418,
+ -0.0627711
+ ],
+ [
+ 109.3039418,
+ -0.06326
+ ]
+ ]
+ ]
+ }
+ },
+ {
+ "type": "Feature",
+ "properties": {
+ "Kecamatan": "Pontianak Tenggara",
+ "Name": "Kecamatan Pontianak Tenggara"
+ },
+ "geometry": {
+ "type": "Polygon",
+ "coordinates": [
+ [
+ [
+ 109.3219826,
+ -0.0941803
+ ],
+ [
+ 109.3295691,
+ -0.0948928
+ ],
+ [
+ 109.3336111,
+ -0.0952778
+ ],
+ [
+ 109.3358541,
+ -0.0950771
+ ],
+ [
+ 109.3372231,
+ -0.0950771
+ ],
+ [
+ 109.3380013,
+ -0.095006
+ ],
+ [
+ 109.3429523,
+ -0.0981948
+ ],
+ [
+ 109.3434103,
+ -0.0976464
+ ],
+ [
+ 109.3453547,
+ -0.0950914
+ ],
+ [
+ 109.3457895,
+ -0.0944185
+ ],
+ [
+ 109.3468384,
+ -0.0926226
+ ],
+ [
+ 109.3476632,
+ -0.0912256
+ ],
+ [
+ 109.3480241,
+ -0.0906143
+ ],
+ [
+ 109.3486415,
+ -0.0896007
+ ],
+ [
+ 109.3503423,
+ -0.0869972
+ ],
+ [
+ 109.3523262,
+ -0.0839272
+ ],
+ [
+ 109.3540143,
+ -0.0814739
+ ],
+ [
+ 109.354886,
+ -0.0802671
+ ],
+ [
+ 109.3549645,
+ -0.0801569
+ ],
+ [
+ 109.3550382,
+ -0.0800433
+ ],
+ [
+ 109.3554734,
+ -0.0793574
+ ],
+ [
+ 109.356482,
+ -0.0775941
+ ],
+ [
+ 109.3578695,
+ -0.0752683
+ ],
+ [
+ 109.3593585,
+ -0.0728458
+ ],
+ [
+ 109.3609939,
+ -0.070362
+ ],
+ [
+ 109.3614586,
+ -0.0695875
+ ],
+ [
+ 109.361472,
+ -0.0695652
+ ],
+ [
+ 109.3615186,
+ -0.0694875
+ ],
+ [
+ 109.3615413,
+ -0.0694463
+ ],
+ [
+ 109.3626025,
+ -0.0675195
+ ],
+ [
+ 109.3633345,
+ -0.0660554
+ ],
+ [
+ 109.3642757,
+ -0.0639162
+ ],
+ [
+ 109.3648557,
+ -0.0626993
+ ],
+ [
+ 109.3656768,
+ -0.0609347
+ ],
+ [
+ 109.3660333,
+ -0.060186
+ ],
+ [
+ 109.3668818,
+ -0.0594587
+ ],
+ [
+ 109.3688871,
+ -0.0577676
+ ],
+ [
+ 109.3675808,
+ -0.0556382
+ ],
+ [
+ 109.365504,
+ -0.0522526
+ ],
+ [
+ 109.3646661,
+ -0.051077
+ ],
+ [
+ 109.3646114,
+ -0.0510054
+ ],
+ [
+ 109.3623441,
+ -0.0484363
+ ],
+ [
+ 109.3605651,
+ -0.0466462
+ ],
+ [
+ 109.3604994,
+ -0.0465847
+ ],
+ [
+ 109.3602786,
+ -0.0463937
+ ],
+ [
+ 109.3583193,
+ -0.0446985
+ ],
+ [
+ 109.3551902,
+ -0.0414546
+ ],
+ [
+ 109.3551747,
+ -0.0414386
+ ],
+ [
+ 109.3551133,
+ -0.0414936
+ ],
+ [
+ 109.3543235,
+ -0.0421357
+ ],
+ [
+ 109.3542469,
+ -0.042217
+ ],
+ [
+ 109.3527954,
+ -0.0435049
+ ],
+ [
+ 109.3522985,
+ -0.0439893
+ ],
+ [
+ 109.3516796,
+ -0.0445926
+ ],
+ [
+ 109.3516482,
+ -0.0446268
+ ],
+ [
+ 109.3516454,
+ -0.044664
+ ],
+ [
+ 109.3516639,
+ -0.0446868
+ ],
+ [
+ 109.3519252,
+ -0.0449124
+ ],
+ [
+ 109.3519166,
+ -0.0449509
+ ],
+ [
+ 109.3518138,
+ -0.0450794
+ ],
+ [
+ 109.3513827,
+ -0.0455163
+ ],
+ [
+ 109.3496942,
+ -0.0473342
+ ],
+ [
+ 109.3495006,
+ -0.0475738
+ ],
+ [
+ 109.3492012,
+ -0.0480329
+ ],
+ [
+ 109.348686,
+ -0.0488562
+ ],
+ [
+ 109.3479601,
+ -0.0499631
+ ],
+ [
+ 109.3476533,
+ -0.0504927
+ ],
+ [
+ 109.3469412,
+ -0.0513682
+ ],
+ [
+ 109.3465392,
+ -0.0518475
+ ],
+ [
+ 109.3464601,
+ -0.0519351
+ ],
+ [
+ 109.3464373,
+ -0.0519604
+ ],
+ [
+ 109.3457278,
+ -0.0527463
+ ],
+ [
+ 109.3455534,
+ -0.0529496
+ ],
+ [
+ 109.3449202,
+ -0.0536878
+ ],
+ [
+ 109.3440063,
+ -0.0547281
+ ],
+ [
+ 109.3429633,
+ -0.0559294
+ ],
+ [
+ 109.3418814,
+ -0.0572063
+ ],
+ [
+ 109.3413518,
+ -0.0577879
+ ],
+ [
+ 109.3405819,
+ -0.0586716
+ ],
+ [
+ 109.3394758,
+ -0.0599561
+ ],
+ [
+ 109.3385002,
+ -0.0610596
+ ],
+ [
+ 109.3380895,
+ -0.0615305
+ ],
+ [
+ 109.3379068,
+ -0.0617045
+ ],
+ [
+ 109.3377636,
+ -0.0618735
+ ],
+ [
+ 109.3373884,
+ -0.062291
+ ],
+ [
+ 109.3369769,
+ -0.0627779
+ ],
+ [
+ 109.3365677,
+ -0.0632845
+ ],
+ [
+ 109.3360536,
+ -0.0638581
+ ],
+ [
+ 109.335146,
+ -0.0649085
+ ],
+ [
+ 109.3343637,
+ -0.0658721
+ ],
+ [
+ 109.3338397,
+ -0.0664927
+ ],
+ [
+ 109.3331777,
+ -0.0672767
+ ],
+ [
+ 109.3318211,
+ -0.0688343
+ ],
+ [
+ 109.3310448,
+ -0.0698008
+ ],
+ [
+ 109.3305545,
+ -0.0705345
+ ],
+ [
+ 109.3288739,
+ -0.0731236
+ ],
+ [
+ 109.327624,
+ -0.0749794
+ ],
+ [
+ 109.3265094,
+ -0.0766657
+ ],
+ [
+ 109.3256936,
+ -0.0779265
+ ],
+ [
+ 109.3249679,
+ -0.0790044
+ ],
+ [
+ 109.3233608,
+ -0.0812877
+ ],
+ [
+ 109.3219873,
+ -0.0832884
+ ],
+ [
+ 109.323928,
+ -0.0854277
+ ],
+ [
+ 109.3224725,
+ -0.0880212
+ ],
+ [
+ 109.3225,
+ -0.0880556
+ ],
+ [
+ 109.322213,
+ -0.090812
+ ],
+ [
+ 109.3220872,
+ -0.0922036
+ ],
+ [
+ 109.3219826,
+ -0.0941803
+ ]
+ ]
+ ]
+ }
+ },
+ {
+ "type": "Feature",
+ "properties": {
+ "Kecamatan": "Pontianak Timur",
+ "Name": "Kecamatan Pontianak Timur"
+ },
+ "geometry": {
+ "type": "Polygon",
+ "coordinates": [
+ [
+ [
+ 109.3441358,
+ -0.0230634
+ ],
+ [
+ 109.3441828,
+ -0.0231404
+ ],
+ [
+ 109.3454518,
+ -0.0252213
+ ],
+ [
+ 109.3454961,
+ -0.0252998
+ ],
+ [
+ 109.3456443,
+ -0.0256293
+ ],
+ [
+ 109.3467671,
+ -0.0281247
+ ],
+ [
+ 109.3472764,
+ -0.0292533
+ ],
+ [
+ 109.3499437,
+ -0.0343806
+ ],
+ [
+ 109.3499861,
+ -0.0344603
+ ],
+ [
+ 109.3512119,
+ -0.0365673
+ ],
+ [
+ 109.3524501,
+ -0.038344
+ ],
+ [
+ 109.3525049,
+ -0.0384155
+ ],
+ [
+ 109.3541897,
+ -0.0404159
+ ],
+ [
+ 109.3551747,
+ -0.0414386
+ ],
+ [
+ 109.3551902,
+ -0.0414546
+ ],
+ [
+ 109.3583193,
+ -0.0446985
+ ],
+ [
+ 109.3602786,
+ -0.0463937
+ ],
+ [
+ 109.3604994,
+ -0.0465847
+ ],
+ [
+ 109.3605651,
+ -0.0466462
+ ],
+ [
+ 109.3623441,
+ -0.0484363
+ ],
+ [
+ 109.3646114,
+ -0.0510054
+ ],
+ [
+ 109.3646661,
+ -0.051077
+ ],
+ [
+ 109.365504,
+ -0.0522526
+ ],
+ [
+ 109.3675808,
+ -0.0556382
+ ],
+ [
+ 109.3688871,
+ -0.0577676
+ ],
+ [
+ 109.3690209,
+ -0.0576547
+ ],
+ [
+ 109.3711601,
+ -0.0605996
+ ],
+ [
+ 109.3721512,
+ -0.0619472
+ ],
+ [
+ 109.3727454,
+ -0.0626365
+ ],
+ [
+ 109.3728214,
+ -0.0627078
+ ],
+ [
+ 109.3728927,
+ -0.0627838
+ ],
+ [
+ 109.373014,
+ -0.0626725
+ ],
+ [
+ 109.3730252,
+ -0.0626828
+ ],
+ [
+ 109.3736081,
+ -0.0621553
+ ],
+ [
+ 109.3756858,
+ -0.0604779
+ ],
+ [
+ 109.3771061,
+ -0.0593951
+ ],
+ [
+ 109.3781823,
+ -0.0586108
+ ],
+ [
+ 109.379165,
+ -0.0579008
+ ],
+ [
+ 109.3850615,
+ -0.0534934
+ ],
+ [
+ 109.3849499,
+ -0.0533736
+ ],
+ [
+ 109.385,
+ -0.0533333
+ ],
+ [
+ 109.3827939,
+ -0.0502926
+ ],
+ [
+ 109.3853475,
+ -0.0483388
+ ],
+ [
+ 109.385312,
+ -0.0482918
+ ],
+ [
+ 109.3832415,
+ -0.0455524
+ ],
+ [
+ 109.3826096,
+ -0.0445867
+ ],
+ [
+ 109.3807742,
+ -0.041782
+ ],
+ [
+ 109.379931,
+ -0.0404682
+ ],
+ [
+ 109.3796015,
+ -0.0398511
+ ],
+ [
+ 109.3793912,
+ -0.0398332
+ ],
+ [
+ 109.3792735,
+ -0.0397085
+ ],
+ [
+ 109.3785355,
+ -0.0389598
+ ],
+ [
+ 109.3785278,
+ -0.0388552
+ ],
+ [
+ 109.3785829,
+ -0.0387652
+ ],
+ [
+ 109.3788427,
+ -0.0383412
+ ],
+ [
+ 109.3794268,
+ -0.0372837
+ ],
+ [
+ 109.3793626,
+ -0.0370342
+ ],
+ [
+ 109.3795801,
+ -0.0366741
+ ],
+ [
+ 109.3797405,
+ -0.036421
+ ],
+ [
+ 109.3799877,
+ -0.0363718
+ ],
+ [
+ 109.3798499,
+ -0.0358204
+ ],
+ [
+ 109.3798406,
+ -0.0356332
+ ],
+ [
+ 109.3798019,
+ -0.0354817
+ ],
+ [
+ 109.3796694,
+ -0.0351452
+ ],
+ [
+ 109.3795458,
+ -0.0346794
+ ],
+ [
+ 109.3793739,
+ -0.0342816
+ ],
+ [
+ 109.37906,
+ -0.0336908
+ ],
+ [
+ 109.3789501,
+ -0.0334502
+ ],
+ [
+ 109.3788401,
+ -0.0331204
+ ],
+ [
+ 109.3787213,
+ -0.0327846
+ ],
+ [
+ 109.3790362,
+ -0.0326955
+ ],
+ [
+ 109.379464,
+ -0.0325886
+ ],
+ [
+ 109.3795383,
+ -0.032541
+ ],
+ [
+ 109.379721,
+ -0.0323912
+ ],
+ [
+ 109.3803611,
+ -0.0318056
+ ],
+ [
+ 109.3806111,
+ -0.0315556
+ ],
+ [
+ 109.3810254,
+ -0.0313242
+ ],
+ [
+ 109.3812778,
+ -0.031
+ ],
+ [
+ 109.381584,
+ -0.0307835
+ ],
+ [
+ 109.3818611,
+ -0.0304167
+ ],
+ [
+ 109.3820556,
+ -0.0299444
+ ],
+ [
+ 109.3821187,
+ -0.0299219
+ ],
+ [
+ 109.3821667,
+ -0.0295556
+ ],
+ [
+ 109.3825079,
+ -0.028604
+ ],
+ [
+ 109.3823594,
+ -0.0285683
+ ],
+ [
+ 109.382849,
+ -0.0267905
+ ],
+ [
+ 109.3828966,
+ -0.0266063
+ ],
+ [
+ 109.3831516,
+ -0.0251057
+ ],
+ [
+ 109.3819197,
+ -0.0248593
+ ],
+ [
+ 109.3795667,
+ -0.0244078
+ ],
+ [
+ 109.3779504,
+ -0.0237898
+ ],
+ [
+ 109.3760728,
+ -0.0231005
+ ],
+ [
+ 109.3754204,
+ -0.0226324
+ ],
+ [
+ 109.3752554,
+ -0.0225587
+ ],
+ [
+ 109.3751719,
+ -0.0225256
+ ],
+ [
+ 109.3729755,
+ -0.021726
+ ],
+ [
+ 109.3709272,
+ -0.0210488
+ ],
+ [
+ 109.3688531,
+ -0.0204563
+ ],
+ [
+ 109.3687655,
+ -0.0204369
+ ],
+ [
+ 109.3653059,
+ -0.0198814
+ ],
+ [
+ 109.3638714,
+ -0.0197918
+ ],
+ [
+ 109.362974,
+ -0.0198291
+ ],
+ [
+ 109.3621662,
+ -0.0198626
+ ],
+ [
+ 109.3603708,
+ -0.0199259
+ ],
+ [
+ 109.3574238,
+ -0.0202494
+ ],
+ [
+ 109.3561128,
+ -0.0205619
+ ],
+ [
+ 109.3543387,
+ -0.0212074
+ ],
+ [
+ 109.3525924,
+ -0.0216327
+ ],
+ [
+ 109.3525039,
+ -0.0216473
+ ],
+ [
+ 109.3496218,
+ -0.0220307
+ ],
+ [
+ 109.3483183,
+ -0.0222042
+ ],
+ [
+ 109.3473387,
+ -0.0223345
+ ],
+ [
+ 109.3454627,
+ -0.0225339
+ ],
+ [
+ 109.3453741,
+ -0.0225466
+ ],
+ [
+ 109.3441986,
+ -0.0229965
+ ],
+ [
+ 109.3441358,
+ -0.0230634
+ ]
+ ]
+ ]
+ }
+ },
+ {
+ "type": "Feature",
+ "properties": {
+ "Kecamatan": "Pontianak Utara",
+ "Name": "Kecamatan Pontianak Utara"
+ },
+ "geometry": {
+ "type": "Polygon",
+ "coordinates": [
+ [
+ [
+ 109.2872372,
+ 0.0059046
+ ],
+ [
+ 109.2876731,
+ 0.0057957
+ ],
+ [
+ 109.2888059,
+ 0.0055106
+ ],
+ [
+ 109.2909361,
+ 0.004795
+ ],
+ [
+ 109.2920061,
+ 0.0043241
+ ],
+ [
+ 109.2939708,
+ 0.0034306
+ ],
+ [
+ 109.2951365,
+ 0.0029556
+ ],
+ [
+ 109.2962287,
+ 0.0025397
+ ],
+ [
+ 109.2972853,
+ 0.0023256
+ ],
+ [
+ 109.2989681,
+ 0.0020386
+ ],
+ [
+ 109.2990579,
+ 0.0020382
+ ],
+ [
+ 109.3030103,
+ 0.0020375
+ ],
+ [
+ 109.3045298,
+ 0.0018846
+ ],
+ [
+ 109.3062181,
+ 0.0016332
+ ],
+ [
+ 109.3074637,
+ 0.0014584
+ ],
+ [
+ 109.3075518,
+ 0.0014411
+ ],
+ [
+ 109.3088671,
+ 0.001147
+ ],
+ [
+ 109.3102666,
+ 0.0008174
+ ],
+ [
+ 109.311816,
+ 0.0003522
+ ],
+ [
+ 109.3127499,
+ 0
+ ],
+ [
+ 109.3141567,
+ -0.0006571
+ ],
+ [
+ 109.3152322,
+ -0.0011241
+ ],
+ [
+ 109.3159294,
+ -0.0012995
+ ],
+ [
+ 109.3201467,
+ -0.0031008
+ ],
+ [
+ 109.3218891,
+ -0.0042186
+ ],
+ [
+ 109.3233285,
+ -0.0051419
+ ],
+ [
+ 109.3302446,
+ -0.0110535
+ ],
+ [
+ 109.3347632,
+ -0.0147765
+ ],
+ [
+ 109.3351802,
+ -0.0151202
+ ],
+ [
+ 109.3353888,
+ -0.015292
+ ],
+ [
+ 109.3354561,
+ -0.0153519
+ ],
+ [
+ 109.3376923,
+ -0.0173912
+ ],
+ [
+ 109.3382402,
+ -0.0178908
+ ],
+ [
+ 109.3408488,
+ -0.0202696
+ ],
+ [
+ 109.3428486,
+ -0.0218527
+ ],
+ [
+ 109.3434614,
+ -0.0223379
+ ],
+ [
+ 109.3435265,
+ -0.0223999
+ ],
+ [
+ 109.3440808,
+ -0.0229924
+ ],
+ [
+ 109.3441358,
+ -0.0230634
+ ],
+ [
+ 109.3441986,
+ -0.0229965
+ ],
+ [
+ 109.3453741,
+ -0.0225466
+ ],
+ [
+ 109.3454627,
+ -0.0225339
+ ],
+ [
+ 109.3473387,
+ -0.0223345
+ ],
+ [
+ 109.3483183,
+ -0.0222042
+ ],
+ [
+ 109.3496218,
+ -0.0220307
+ ],
+ [
+ 109.3525039,
+ -0.0216473
+ ],
+ [
+ 109.3525924,
+ -0.0216327
+ ],
+ [
+ 109.3543387,
+ -0.0212074
+ ],
+ [
+ 109.3561128,
+ -0.0205619
+ ],
+ [
+ 109.3574238,
+ -0.0202494
+ ],
+ [
+ 109.3603708,
+ -0.0199259
+ ],
+ [
+ 109.3621662,
+ -0.0198626
+ ],
+ [
+ 109.362974,
+ -0.0198291
+ ],
+ [
+ 109.3638714,
+ -0.0197918
+ ],
+ [
+ 109.3653059,
+ -0.0198814
+ ],
+ [
+ 109.3687655,
+ -0.0204369
+ ],
+ [
+ 109.3688531,
+ -0.0204563
+ ],
+ [
+ 109.3709272,
+ -0.0210488
+ ],
+ [
+ 109.3729755,
+ -0.021726
+ ],
+ [
+ 109.3751719,
+ -0.0225256
+ ],
+ [
+ 109.3752554,
+ -0.0225587
+ ],
+ [
+ 109.3754204,
+ -0.0226324
+ ],
+ [
+ 109.3760728,
+ -0.0231005
+ ],
+ [
+ 109.3767856,
+ -0.0219637
+ ],
+ [
+ 109.3770102,
+ -0.0210272
+ ],
+ [
+ 109.3769805,
+ -0.0205935
+ ],
+ [
+ 109.3770388,
+ -0.020193
+ ],
+ [
+ 109.376996,
+ -0.0197081
+ ],
+ [
+ 109.3771433,
+ -0.0193611
+ ],
+ [
+ 109.3774143,
+ -0.0187003
+ ],
+ [
+ 109.3777233,
+ -0.0182773
+ ],
+ [
+ 109.3779372,
+ -0.0178589
+ ],
+ [
+ 109.377961,
+ -0.0174692
+ ],
+ [
+ 109.3780323,
+ -0.0169415
+ ],
+ [
+ 109.3781368,
+ -0.0165755
+ ],
+ [
+ 109.3787833,
+ -0.0161381
+ ],
+ [
+ 109.3788261,
+ -0.015948
+ ],
+ [
+ 109.3786217,
+ -0.0156866
+ ],
+ [
+ 109.3783745,
+ -0.0155297
+ ],
+ [
+ 109.3779134,
+ -0.0154726
+ ],
+ [
+ 109.3776377,
+ -0.0153966
+ ],
+ [
+ 109.3775569,
+ -0.0152112
+ ],
+ [
+ 109.3778088,
+ -0.0150115
+ ],
+ [
+ 109.3779657,
+ -0.0148975
+ ],
+ [
+ 109.3785789,
+ -0.0146265
+ ],
+ [
+ 109.37875,
+ -0.0144221
+ ],
+ [
+ 109.378964,
+ -0.0140798
+ ],
+ [
+ 109.379002,
+ -0.0136092
+ ],
+ [
+ 109.3788974,
+ -0.0134571
+ ],
+ [
+ 109.378655,
+ -0.0134286
+ ],
+ [
+ 109.378346,
+ -0.0135142
+ ],
+ [
+ 109.3778232,
+ -0.0138454
+ ],
+ [
+ 109.3777277,
+ -0.0138697
+ ],
+ [
+ 109.3776633,
+ -0.0138402
+ ],
+ [
+ 109.3775828,
+ -0.0136846
+ ],
+ [
+ 109.3776044,
+ -0.0135855
+ ],
+ [
+ 109.3780691,
+ -0.0131897
+ ],
+ [
+ 109.3782023,
+ -0.0130387
+ ],
+ [
+ 109.3784541,
+ -0.0126906
+ ],
+ [
+ 109.3784506,
+ -0.0123305
+ ],
+ [
+ 109.3783353,
+ -0.0121796
+ ],
+ [
+ 109.3778077,
+ -0.0122271
+ ],
+ [
+ 109.3774511,
+ -0.0123174
+ ],
+ [
+ 109.3773798,
+ -0.0120275
+ ],
+ [
+ 109.3775842,
+ -0.0115046
+ ],
+ [
+ 109.3777211,
+ -0.0112821
+ ],
+ [
+ 109.3779954,
+ -0.0110605
+ ],
+ [
+ 109.3779208,
+ -0.010802
+ ],
+ [
+ 109.3778685,
+ -0.0105358
+ ],
+ [
+ 109.3778637,
+ -0.0101508
+ ],
+ [
+ 109.3778257,
+ -0.0095661
+ ],
+ [
+ 109.3778115,
+ -0.0089671
+ ],
+ [
+ 109.3777259,
+ -0.0087675
+ ],
+ [
+ 109.3774549,
+ -0.0079593
+ ],
+ [
+ 109.3774472,
+ -0.0078927
+ ],
+ [
+ 109.3774427,
+ -0.0078892
+ ],
+ [
+ 109.3774307,
+ -0.0078906
+ ],
+ [
+ 109.377407,
+ -0.0078033
+ ],
+ [
+ 109.3773748,
+ -0.0077191
+ ],
+ [
+ 109.3772344,
+ -0.0073863
+ ],
+ [
+ 109.3770468,
+ -0.006782
+ ],
+ [
+ 109.3769079,
+ -0.0063523
+ ],
+ [
+ 109.3768599,
+ -0.0062759
+ ],
+ [
+ 109.3768036,
+ -0.0062057
+ ],
+ [
+ 109.3767392,
+ -0.0061432
+ ],
+ [
+ 109.3765984,
+ -0.0060309
+ ],
+ [
+ 109.3765378,
+ -0.0059648
+ ],
+ [
+ 109.3764821,
+ -0.0058938
+ ],
+ [
+ 109.3764461,
+ -0.0058115
+ ],
+ [
+ 109.3764169,
+ -0.0057261
+ ],
+ [
+ 109.3763555,
+ -0.005462
+ ],
+ [
+ 109.3763317,
+ -0.0053752
+ ],
+ [
+ 109.3762582,
+ -0.0052102
+ ],
+ [
+ 109.3760494,
+ -0.0048099
+ ],
+ [
+ 109.3757244,
+ -0.0042679
+ ],
+ [
+ 109.375691,
+ -0.0041849
+ ],
+ [
+ 109.3756792,
+ -0.004096
+ ],
+ [
+ 109.3756943,
+ -0.0040083
+ ],
+ [
+ 109.3757328,
+ -0.0039274
+ ],
+ [
+ 109.3758387,
+ -0.0037814
+ ],
+ [
+ 109.3758771,
+ -0.0037014
+ ],
+ [
+ 109.3758822,
+ -0.0036113
+ ],
+ [
+ 109.3758846,
+ -0.0034657
+ ],
+ [
+ 109.3758402,
+ -0.003323
+ ],
+ [
+ 109.3757609,
+ -0.0031426
+ ],
+ [
+ 109.3757456,
+ -0.0030881
+ ],
+ [
+ 109.3757456,
+ -0.0030182
+ ],
+ [
+ 109.3757595,
+ -0.0029846
+ ],
+ [
+ 109.3757929,
+ -0.002944
+ ],
+ [
+ 109.3758917,
+ -0.0028677
+ ],
+ [
+ 109.3759674,
+ -0.0027941
+ ],
+ [
+ 109.3759947,
+ -0.0027328
+ ],
+ [
+ 109.3760051,
+ -0.0026704
+ ],
+ [
+ 109.375974,
+ -0.0025862
+ ],
+ [
+ 109.3759254,
+ -0.0025107
+ ],
+ [
+ 109.3758577,
+ -0.0024525
+ ],
+ [
+ 109.3757837,
+ -0.0024013
+ ],
+ [
+ 109.3756283,
+ -0.0023105
+ ],
+ [
+ 109.3754667,
+ -0.0022319
+ ],
+ [
+ 109.3752916,
+ -0.0021913
+ ],
+ [
+ 109.3752031,
+ -0.0021769
+ ],
+ [
+ 109.3750243,
+ -0.0021588
+ ],
+ [
+ 109.3749358,
+ -0.0021454
+ ],
+ [
+ 109.3748501,
+ -0.0021186
+ ],
+ [
+ 109.3747668,
+ -0.002085
+ ],
+ [
+ 109.3746874,
+ -0.0020428
+ ],
+ [
+ 109.3746115,
+ -0.0019955
+ ],
+ [
+ 109.3738221,
+ -0.0012565
+ ],
+ [
+ 109.3736165,
+ -0.0010811
+ ],
+ [
+ 109.3734182,
+ -0.0008974
+ ],
+ [
+ 109.3732969,
+ -0.0007641
+ ],
+ [
+ 109.3732508,
+ -0.0006868
+ ],
+ [
+ 109.3732089,
+ -0.0006069
+ ],
+ [
+ 109.373172,
+ -0.0005245
+ ],
+ [
+ 109.373143,
+ -0.0004391
+ ],
+ [
+ 109.3730706,
+ -0.0001778
+ ],
+ [
+ 109.373022,
+ 0.0000718
+ ],
+ [
+ 109.3729981,
+ 0.000342
+ ],
+ [
+ 109.3729593,
+ 0.0004221
+ ],
+ [
+ 109.3728963,
+ 0.0004847
+ ],
+ [
+ 109.3728196,
+ 0.0005317
+ ],
+ [
+ 109.3727507,
+ 0.0005883
+ ],
+ [
+ 109.3726979,
+ 0.0006603
+ ],
+ [
+ 109.3726209,
+ 0.0008238
+ ],
+ [
+ 109.3725903,
+ 0.0009085
+ ],
+ [
+ 109.3724879,
+ 0.0012553
+ ],
+ [
+ 109.3723913,
+ 0.0014072
+ ],
+ [
+ 109.3722877,
+ 0.0015443
+ ],
+ [
+ 109.3722597,
+ 0.0017229
+ ],
+ [
+ 109.3722108,
+ 0.0018969
+ ],
+ [
+ 109.3721685,
+ 0.0020724
+ ],
+ [
+ 109.3721455,
+ 0.0022517
+ ],
+ [
+ 109.3720933,
+ 0.0027918
+ ],
+ [
+ 109.3720682,
+ 0.0029708
+ ],
+ [
+ 109.3720285,
+ 0.0031471
+ ],
+ [
+ 109.3719757,
+ 0.00332
+ ],
+ [
+ 109.3719148,
+ 0.0034901
+ ],
+ [
+ 109.3718455,
+ 0.0036569
+ ],
+ [
+ 109.3717671,
+ 0.0038196
+ ],
+ [
+ 109.3717186,
+ 0.0039931
+ ],
+ [
+ 109.3716922,
+ 0.0041721
+ ],
+ [
+ 109.371671,
+ 0.0043516
+ ],
+ [
+ 109.3716052,
+ 0.0047071
+ ],
+ [
+ 109.3715607,
+ 0.005066
+ ],
+ [
+ 109.3715275,
+ 0.0052438
+ ],
+ [
+ 109.3714828,
+ 0.0054189
+ ],
+ [
+ 109.3713289,
+ 0.0061255
+ ],
+ [
+ 109.3713044,
+ 0.0063047
+ ],
+ [
+ 109.3712868,
+ 0.0064846
+ ],
+ [
+ 109.3712643,
+ 0.006664
+ ],
+ [
+ 109.3712291,
+ 0.0068404
+ ],
+ [
+ 109.3711297,
+ 0.0069895
+ ],
+ [
+ 109.3708403,
+ 0.0072002
+ ],
+ [
+ 109.370784,
+ 0.0073701
+ ],
+ [
+ 109.3707283,
+ 0.0079097
+ ],
+ [
+ 109.3707042,
+ 0.0080889
+ ],
+ [
+ 109.370667,
+ 0.0082657
+ ],
+ [
+ 109.3706209,
+ 0.0084404
+ ],
+ [
+ 109.3705734,
+ 0.0086148
+ ],
+ [
+ 109.3705164,
+ 0.0087863
+ ],
+ [
+ 109.3704513,
+ 0.0089548
+ ],
+ [
+ 109.3703658,
+ 0.0091123
+ ],
+ [
+ 109.3701986,
+ 0.0091609
+ ],
+ [
+ 109.3700189,
+ 0.0091596
+ ],
+ [
+ 109.3698471,
+ 0.0091989
+ ],
+ [
+ 109.3697117,
+ 0.0093173
+ ],
+ [
+ 109.3695706,
+ 0.0094284
+ ],
+ [
+ 109.3694128,
+ 0.0095149
+ ],
+ [
+ 109.3693578,
+ 0.009676
+ ],
+ [
+ 109.3694532,
+ 0.0098261
+ ],
+ [
+ 109.3695639,
+ 0.0099676
+ ],
+ [
+ 109.3696069,
+ 0.010141
+ ],
+ [
+ 109.3695992,
+ 0.0103215
+ ],
+ [
+ 109.3695851,
+ 0.0105016
+ ],
+ [
+ 109.3696127,
+ 0.0106792
+ ],
+ [
+ 109.3696883,
+ 0.0108431
+ ],
+ [
+ 109.3697431,
+ 0.0110149
+ ],
+ [
+ 109.3698665,
+ 0.011543
+ ],
+ [
+ 109.3699002,
+ 0.0117207
+ ],
+ [
+ 109.3699193,
+ 0.0119003
+ ],
+ [
+ 109.3699181,
+ 0.0120811
+ ],
+ [
+ 109.3698722,
+ 0.0122545
+ ],
+ [
+ 109.3698467,
+ 0.0124318
+ ],
+ [
+ 109.3699285,
+ 0.0125907
+ ],
+ [
+ 109.3700339,
+ 0.0127371
+ ],
+ [
+ 109.3701097,
+ 0.0129004
+ ],
+ [
+ 109.3702259,
+ 0.0132427
+ ],
+ [
+ 109.3702917,
+ 0.0134109
+ ],
+ [
+ 109.3703681,
+ 0.0135745
+ ],
+ [
+ 109.3704524,
+ 0.0137341
+ ],
+ [
+ 109.3705297,
+ 0.0138974
+ ],
+ [
+ 109.3705801,
+ 0.0140689
+ ],
+ [
+ 109.3704971,
+ 0.0142137
+ ],
+ [
+ 109.3703383,
+ 0.0142979
+ ],
+ [
+ 109.3702191,
+ 0.0144233
+ ],
+ [
+ 109.3702203,
+ 0.0146013
+ ],
+ [
+ 109.370293,
+ 0.0147661
+ ],
+ [
+ 109.3703359,
+ 0.0149397
+ ],
+ [
+ 109.370325,
+ 0.0153012
+ ],
+ [
+ 109.3703084,
+ 0.0154812
+ ],
+ [
+ 109.3702725,
+ 0.0156584
+ ],
+ [
+ 109.3702313,
+ 0.0158344
+ ],
+ [
+ 109.3702081,
+ 0.0160134
+ ],
+ [
+ 109.3702343,
+ 0.0161905
+ ],
+ [
+ 109.3703115,
+ 0.016353
+ ],
+ [
+ 109.3704059,
+ 0.0165069
+ ],
+ [
+ 109.3704908,
+ 0.0166663
+ ],
+ [
+ 109.3705658,
+ 0.0168305
+ ],
+ [
+ 109.3706273,
+ 0.0170004
+ ],
+ [
+ 109.3706741,
+ 0.0171748
+ ],
+ [
+ 109.3706925,
+ 0.0173544
+ ],
+ [
+ 109.3706906,
+ 0.0177162
+ ],
+ [
+ 109.3706797,
+ 0.0180777
+ ],
+ [
+ 109.3706406,
+ 0.0182533
+ ],
+ [
+ 109.3705487,
+ 0.0184077
+ ],
+ [
+ 109.3704869,
+ 0.0185751
+ ],
+ [
+ 109.3704969,
+ 0.0187543
+ ],
+ [
+ 109.3705982,
+ 0.0189017
+ ],
+ [
+ 109.3707083,
+ 0.0190441
+ ],
+ [
+ 109.3707731,
+ 0.0192122
+ ],
+ [
+ 109.3708037,
+ 0.0193903
+ ],
+ [
+ 109.3708578,
+ 0.0197479
+ ],
+ [
+ 109.3709194,
+ 0.0202869
+ ],
+ [
+ 109.3709333,
+ 0.020467
+ ],
+ [
+ 109.3709799,
+ 0.0206413
+ ],
+ [
+ 109.3710169,
+ 0.0208169
+ ],
+ [
+ 109.370967,
+ 0.0209858
+ ],
+ [
+ 109.3708205,
+ 0.0210841
+ ],
+ [
+ 109.3706487,
+ 0.0211354
+ ],
+ [
+ 109.3704911,
+ 0.0212146
+ ],
+ [
+ 109.3685986,
+ 0.021837
+ ],
+ [
+ 109.3653292,
+ 0.022765
+ ],
+ [
+ 109.3619028,
+ 0.0240499
+ ],
+ [
+ 109.3571058,
+ 0.0258202
+ ],
+ [
+ 109.3521819,
+ 0.0269911
+ ],
+ [
+ 109.3484619,
+ 0.0279787
+ ],
+ [
+ 109.3336119,
+ 0.0319211
+ ],
+ [
+ 109.3213982,
+ 0.0349248
+ ],
+ [
+ 109.3097594,
+ 0.0377872
+ ],
+ [
+ 109.3087415,
+ 0.0381168
+ ],
+ [
+ 109.3075042,
+ 0.0365847
+ ],
+ [
+ 109.3069516,
+ 0.0362024
+ ],
+ [
+ 109.3064506,
+ 0.0357025
+ ],
+ [
+ 109.3042403,
+ 0.0347293
+ ],
+ [
+ 109.30347,
+ 0.0338443
+ ],
+ [
+ 109.2994279,
+ 0.0322397
+ ],
+ [
+ 109.2983288,
+ 0.0309507
+ ],
+ [
+ 109.2979084,
+ 0.0302325
+ ],
+ [
+ 109.2976719,
+ 0.0297053
+ ],
+ [
+ 109.2968118,
+ 0.0271588
+ ],
+ [
+ 109.2960776,
+ 0.0252037
+ ],
+ [
+ 109.2956724,
+ 0.024062
+ ],
+ [
+ 109.2951035,
+ 0.0221229
+ ],
+ [
+ 109.2941818,
+ 0.0187902
+ ],
+ [
+ 109.2927546,
+ 0.0148399
+ ],
+ [
+ 109.2920294,
+ 0.0127309
+ ],
+ [
+ 109.2904892,
+ 0.0084894
+ ],
+ [
+ 109.2872372,
+ 0.0059046
+ ]
+ ]
+ ]
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/webgis-spbu/migrate_now.php b/webgis-spbu/migrate_now.php
new file mode 100644
index 0000000..afd323d
--- /dev/null
+++ b/webgis-spbu/migrate_now.php
@@ -0,0 +1,10 @@
+exec("ALTER TABLE rumah_ibadah_points ADD COLUMN IF NOT EXISTS jenis VARCHAR(30) NOT NULL DEFAULT 'Lainnya' AFTER nama");
+ $pdo->exec("ALTER TABLE rumah_ibadah_points ADD COLUMN IF NOT EXISTS kontak VARCHAR(120) NULL AFTER jenis");
+ echo "Columns added successfully.\n";
+} catch (PDOException $e) {
+ echo "Failed to add columns: " . $e->getMessage() . "\n";
+}
diff --git a/webgis-spbu/osm_pontianak.json b/webgis-spbu/osm_pontianak.json
new file mode 100644
index 0000000..d657cb7
--- /dev/null
+++ b/webgis-spbu/osm_pontianak.json
@@ -0,0 +1,9 @@
+
+
+
406 Not Acceptable
+
+
Not Acceptable
+
An appropriate representation of the requested resource could not be found on this server.
+
+
Apache/2.4.67 (Debian) Server at overpass-api.de Port 443
+
diff --git a/webgis-spbu/osm_to_geojson_draft.js b/webgis-spbu/osm_to_geojson_draft.js
new file mode 100644
index 0000000..2173a22
--- /dev/null
+++ b/webgis-spbu/osm_to_geojson_draft.js
@@ -0,0 +1,40 @@
+const fs = require('fs');
+
+const data = JSON.parse(fs.readFileSync('osm_pontianak.json', 'utf8'));
+
+const geojson = {
+ type: 'FeatureCollection',
+ features: []
+};
+
+data.elements.forEach(el => {
+ if (el.type === 'relation' && el.tags && el.tags.admin_level === '6') {
+ const name = el.tags.name;
+ // Extract polygons from relation members
+ const coordinates = [];
+ el.members.forEach(member => {
+ if (member.type === 'way' && member.geometry) {
+ const wayCoords = member.geometry.map(pt => [pt.lon, pt.lat]);
+ coordinates.push(wayCoords);
+ }
+ });
+
+ // Assemble into MultiPolygon or Polygon (simplistic)
+ if (coordinates.length > 0) {
+ geojson.features.push({
+ type: 'Feature',
+ properties: {
+ kecamatan: name,
+ ...el.tags
+ },
+ geometry: {
+ type: 'Polygon',
+ coordinates: coordinates
+ }
+ });
+ }
+ }
+});
+
+// Since OSM returns fragments of outer and inner rings in relations, a naive conversion might have topological issues in Leaflet (like crossing lines if not ordered).
+// We should use an existing library like osmtogeojson. Let's install it and use it.
diff --git a/webgis-spbu/php/create_jalan.php b/webgis-spbu/php/create_jalan.php
new file mode 100644
index 0000000..a3ee5e2
--- /dev/null
+++ b/webgis-spbu/php/create_jalan.php
@@ -0,0 +1,49 @@
+false,"message"=>"Data kosong"]);
+ exit;
+}
+
+try {
+ $geojson = json_encode($data['geometry'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+
+ try {
+ $stmt = $pdo->prepare("
+ INSERT INTO jalan_lines
+ (nama_jalan, status_jalan, panjang_meter, geojson)
+ VALUES (?, ?, ?, ?)
+ ");
+
+ $stmt->execute([
+ $data['nama_jalan'],
+ $data['status_jalan'],
+ $data['panjang_meter'],
+ $geojson
+ ]);
+ } catch (Exception $inner) {
+ // Fallback untuk skema spatial: simpan ke kolom `geom` jika `geojson` tidak ada.
+ $stmt = $pdo->prepare("
+ INSERT INTO jalan_lines
+ (nama_jalan, status_jalan, panjang_meter, geom)
+ VALUES (?, ?, ?, ST_GeomFromGeoJSON(?))
+ ");
+
+ $stmt->execute([
+ $data['nama_jalan'],
+ $data['status_jalan'],
+ $data['panjang_meter'],
+ $geojson
+ ]);
+ }
+
+ echo json_encode(["success"=>true]);
+
+} catch (Exception $e) {
+ echo json_encode(["success"=>false,"message"=>$e->getMessage()]);
+}
+?>
diff --git a/webgis-spbu/php/create_parsil.php b/webgis-spbu/php/create_parsil.php
new file mode 100644
index 0000000..f140f50
--- /dev/null
+++ b/webgis-spbu/php/create_parsil.php
@@ -0,0 +1,65 @@
+ false, "status" => "error", "message" => "Payload JSON kosong atau tidak valid."]);
+ exit;
+}
+
+$status_kepemilikan = trim($data['status_kepemilikan'] ?? '');
+$luas_m2 = (float)($data['luas_m2'] ?? 0);
+$geometry = $data['geometry'] ?? null;
+
+if ($status_kepemilikan === '' || !$geometry) {
+ echo json_encode(["success" => false, "status" => "error", "message" => "Data parsil belum lengkap (status/geometry)."]);
+ exit;
+}
+
+try {
+ $geomJson = json_encode($geometry, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+
+ try {
+ $stmt = $pdo->prepare("
+ INSERT INTO parsil_tanah (status_kepemilikan, luas_m2, geojson)
+ VALUES (:status_kepemilikan, :luas_m2, :geojson)
+ ");
+
+ $stmt->execute([
+ ':status_kepemilikan' => $status_kepemilikan,
+ ':luas_m2' => $luas_m2,
+ ':geojson' => $geomJson
+ ]);
+ } catch (Exception $inner) {
+ // Fallback untuk skema spatial: simpan ke kolom `geom` jika `geojson` tidak ada.
+ try {
+ $stmt = $pdo->prepare("
+ INSERT INTO parsil_tanah (status_kepemilikan, luas_m2, geom)
+ VALUES (:status_kepemilikan, :luas_m2, ST_GeomFromGeoJSON(:geom))
+ ");
+
+ $stmt->execute([
+ ':status_kepemilikan' => $status_kepemilikan,
+ ':luas_m2' => $luas_m2,
+ ':geom' => $geomJson
+ ]);
+ } catch (Exception $inner2) {
+ // Last resort: minimal insert.
+ $stmt = $pdo->prepare("
+ INSERT INTO parsil_tanah (geom)
+ VALUES (ST_GeomFromGeoJSON(:geom))
+ ");
+ $stmt->execute([':geom' => $geomJson]);
+ }
+ }
+
+ echo json_encode(["success" => true, "status" => "success", "message" => "Area Parsil berhasil disimpan."]);
+
+} catch (Exception $e) {
+ echo json_encode(["success" => false, "status" => "error", "message" => $e->getMessage()]);
+}
+?>
diff --git a/webgis-spbu/php/create_pemukiman_miskin.php b/webgis-spbu/php/create_pemukiman_miskin.php
new file mode 100644
index 0000000..92db675
--- /dev/null
+++ b/webgis-spbu/php/create_pemukiman_miskin.php
@@ -0,0 +1,192 @@
+ $_POST['kk_nama'] ?? '',
+ 'nik' => $_POST['nik'] ?? '',
+ 'jumlah_anggota' => $_POST['jumlah_anggota'] ?? '',
+ 'latitude' => $_POST['latitude'] ?? null,
+ 'longitude' => $_POST['longitude'] ?? null,
+ 'address' => $_POST['address'] ?? '',
+ 'kelurahan' => $_POST['kelurahan'] ?? '',
+ 'kecamatan' => $_POST['kecamatan'] ?? '',
+ 'status_bantuan' => $_POST['status_bantuan'] ?? 'Belum dibantu',
+ 'jenis_bantuan' => $_POST['jenis_bantuan'] ?? '',
+ 'tanggal_bantuan' => $_POST['tanggal_bantuan'] ?? '',
+ 'anggota' => $_POST['anggota'] ?? '[]'
+ ];
+ }
+ $json = json_decode(file_get_contents('php://input'), true);
+ return is_array($json) ? $json : null;
+}
+
+function compute_nearest_ibadah(PDO $pdo, $lat, $lng) {
+ $stmt = $pdo->query("SELECT id, latitude, longitude, radius_meter FROM rumah_ibadah_points");
+ $rows = $stmt->fetchAll();
+ $best = null;
+ foreach ($rows as $r) {
+ $rLat = (float)$r['latitude'];
+ $rLng = (float)$r['longitude'];
+ $radius = (float)$r['radius_meter'];
+ if ($radius <= 0) continue;
+ $d = haversine_m($lat, $lng, $rLat, $rLng);
+ if ($d <= $radius) {
+ if ($best === null || $d < $best['jarak_meter']) {
+ $best = ['rumah_ibadah_id' => (int)$r['id'], 'jarak_meter' => $d];
+ }
+ }
+ }
+ return $best ?: ['rumah_ibadah_id' => null, 'jarak_meter' => null];
+}
+
+function save_upload_if_any() {
+ if (!isset($_FILES['bukti_file']) || !is_array($_FILES['bukti_file'])) return null;
+ if (($_FILES['bukti_file']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) return null;
+
+ $dir = __DIR__ . '/../uploads/bukti';
+ if (!is_dir($dir)) @mkdir($dir, 0775, true);
+ if (!is_dir($dir)) return null;
+
+ $orig = (string)($_FILES['bukti_file']['name'] ?? 'file');
+ $ext = strtolower(pathinfo($orig, PATHINFO_EXTENSION));
+ $allowed = ['jpg','jpeg','png','webp','pdf'];
+ if ($ext && !in_array($ext, $allowed, true)) return null;
+
+ $safeBase = preg_replace('/[^a-zA-Z0-9._-]+/', '_', pathinfo($orig, PATHINFO_FILENAME));
+ $safeBase = trim($safeBase, '_');
+ if ($safeBase === '') $safeBase = 'bukti';
+ $filename = $safeBase . '_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . ($ext ? ('.' . $ext) : '');
+ $dest = $dir . '/' . $filename;
+
+ if (!move_uploaded_file($_FILES['bukti_file']['tmp_name'], $dest)) return null;
+ return 'uploads/bukti/' . $filename;
+}
+
+$data = get_payload();
+if (!$data) {
+ echo json_encode(["success" => false, "message" => "Payload kosong atau tidak valid."]);
+ exit;
+}
+
+$kk_nama = trim($data['kk_nama'] ?? $data['nama'] ?? '');
+$nik = trim($data['nik'] ?? '');
+$jumlah_anggota = (int)($data['jumlah_anggota'] ?? 0);
+$latitude = $data['latitude'] ?? null;
+$longitude = $data['longitude'] ?? null;
+$address = trim($data['address'] ?? '');
+$kelurahan = trim($data['kelurahan'] ?? '');
+$kecamatan = trim($data['kecamatan'] ?? '');
+$status_bantuan = trim($data['status_bantuan'] ?? 'Belum dibantu');
+$jenis_bantuan = trim($data['jenis_bantuan'] ?? '');
+$tanggal_bantuan = trim($data['tanggal_bantuan'] ?? '');
+$anggotaRaw = $data['anggota'] ?? [];
+
+$validStatus = ['Belum dibantu', 'Sudah dibantu', 'Menunggu verifikasi'];
+if (!in_array($status_bantuan, $validStatus, true)) $status_bantuan = 'Belum dibantu';
+
+if ($kk_nama === '' || $latitude === null || $longitude === null) {
+ echo json_encode(["success" => false, "message" => "Data penduduk miskin belum lengkap (nama/lat/lng)."]);
+ exit;
+}
+
+try {
+ $lat = (float)$latitude;
+ $lng = (float)$longitude;
+ $nearest = compute_nearest_ibadah($pdo, $lat, $lng);
+ $uploadPath = save_upload_if_any();
+
+ $pdo->beginTransaction();
+ // Backward-compat: some installs still have old column `nama`.
+ // Prefer new schema if columns exist.
+ $cols = $pdo->query("SHOW COLUMNS FROM pemukiman_miskin_points")->fetchAll(PDO::FETCH_COLUMN, 0);
+ $hasNew = in_array('kk_nama', $cols, true);
+ if ($hasNew) {
+ $stmt = $pdo->prepare("
+ INSERT INTO pemukiman_miskin_points
+ (kk_nama, nik, jumlah_anggota, latitude, longitude, address, kelurahan, kecamatan,
+ status_bantuan, jenis_bantuan, tanggal_bantuan, bukti_file, jarak_meter, rumah_ibadah_id)
+ VALUES
+ (:kk_nama, :nik, :jumlah_anggota, :latitude, :longitude, :address, :kelurahan, :kecamatan,
+ :status_bantuan, :jenis_bantuan, :tanggal_bantuan, :bukti_file, :jarak_meter, :rumah_ibadah_id)
+ ");
+ $stmt->execute([
+ ':kk_nama' => $kk_nama,
+ ':nik' => ($nik === '' ? null : $nik),
+ ':jumlah_anggota' => $jumlah_anggota,
+ ':latitude' => $lat,
+ ':longitude' => $lng,
+ ':address' => ($address === '' ? null : $address),
+ ':kelurahan' => ($kelurahan === '' ? null : $kelurahan),
+ ':kecamatan' => ($kecamatan === '' ? null : $kecamatan),
+ ':status_bantuan' => $status_bantuan,
+ ':jenis_bantuan' => ($jenis_bantuan === '' ? null : $jenis_bantuan),
+ ':tanggal_bantuan' => ($tanggal_bantuan === '' ? null : $tanggal_bantuan),
+ ':bukti_file' => $uploadPath,
+ ':jarak_meter' => $nearest['jarak_meter'],
+ ':rumah_ibadah_id' => $nearest['rumah_ibadah_id']
+ ]);
+ } else {
+ $stmt = $pdo->prepare("
+ INSERT INTO pemukiman_miskin_points (nama, latitude, longitude, address, rumah_ibadah_id)
+ VALUES (:nama, :latitude, :longitude, :address, :rumah_ibadah_id)
+ ");
+ $stmt->execute([
+ ':nama' => $kk_nama,
+ ':latitude' => $lat,
+ ':longitude' => $lng,
+ ':address' => ($address === '' ? null : $address),
+ ':rumah_ibadah_id' => $nearest['rumah_ibadah_id']
+ ]);
+ }
+
+ $newId = (int)$pdo->lastInsertId();
+
+ // anggota keluarga (optional)
+ $anggota = is_string($anggotaRaw) ? json_decode($anggotaRaw, true) : $anggotaRaw;
+ if (is_array($anggota) && count($anggota) > 0 && $hasNew) {
+ $ins = $pdo->prepare("
+ INSERT INTO anggota_keluarga (penduduk_id, nama, hubungan, umur, pekerjaan, keterangan)
+ VALUES (:penduduk_id, :nama, :hubungan, :umur, :pekerjaan, :keterangan)
+ ");
+ foreach ($anggota as $a) {
+ $n = trim((string)($a['nama'] ?? ''));
+ $h = trim((string)($a['hubungan'] ?? ''));
+ if ($n === '' || $h === '') continue;
+ $ins->execute([
+ ':penduduk_id' => $newId,
+ ':nama' => $n,
+ ':hubungan' => $h,
+ ':umur' => isset($a['umur']) && $a['umur'] !== '' ? (int)$a['umur'] : null,
+ ':pekerjaan' => ($a['pekerjaan'] ?? '') === '' ? null : (string)$a['pekerjaan'],
+ ':keterangan' => ($a['keterangan'] ?? '') === '' ? null : (string)$a['keterangan']
+ ]);
+ }
+ }
+
+ $pdo->commit();
+ echo json_encode([
+ "success" => true,
+ "id" => $newId,
+ "rumah_ibadah_id" => $nearest['rumah_ibadah_id'],
+ "jarak_meter" => $nearest['jarak_meter'],
+ "message" => "Penduduk miskin berhasil ditambahkan"
+ ]);
+} catch (Exception $e) {
+ if ($pdo->inTransaction()) $pdo->rollBack();
+ echo json_encode(["success" => false, "message" => $e->getMessage()]);
+}
diff --git a/webgis-spbu/php/create_point.php b/webgis-spbu/php/create_point.php
new file mode 100644
index 0000000..1b160b8
--- /dev/null
+++ b/webgis-spbu/php/create_point.php
@@ -0,0 +1,124 @@
+ false, "status" => "error", "message" => "Payload JSON kosong atau tidak valid."]);
+ exit;
+}
+
+$nama = trim($data['nama'] ?? '');
+$no = trim($data['no'] ?? '');
+$deskripsi = trim($data['deskripsi'] ?? '');
+$status_24jam = isset($data['status_24jam']) ? (int)$data['status_24jam'] : 0;
+$latitude = $data['latitude'] ?? null;
+$longitude = $data['longitude'] ?? null;
+
+if ($nama === '' || $no === '' || $latitude === null || $longitude === null) {
+ echo json_encode(["success" => false, "status" => "error", "message" => "Data point belum lengkap (nama/no/lat/lng)."]);
+ exit;
+}
+
+$pointGeojson = json_encode([
+ "type" => "Point",
+ "coordinates" => [(float)$longitude, (float)$latitude]
+], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+
+// Optional legacy field if client ever sends it
+$geomPayload = $data['geom'] ?? null;
+if (!is_string($geomPayload) || $geomPayload === '') {
+ $geomPayload = $pointGeojson;
+}
+
+try {
+ // Preferred schema (non-spatial, consistent with update_point.php)
+ try {
+ $stmt = $pdo->prepare("
+ INSERT INTO spbu_points (nama, no, deskripsi, status_24jam, latitude, longitude)
+ VALUES (:nama, :no, :deskripsi, :status_24jam, :latitude, :longitude)
+ ");
+
+ $stmt->execute([
+ ':nama' => $nama,
+ ':no' => $no,
+ ':deskripsi' => $deskripsi,
+ ':status_24jam' => $status_24jam,
+ ':latitude' => $latitude,
+ ':longitude' => $longitude
+ ]);
+
+ echo json_encode([
+ "success" => true,
+ "status" => "success",
+ "message" => "Point SPBU berhasil ditambahkan",
+ "id" => (int)$pdo->lastInsertId()
+ ]);
+ exit;
+ } catch (Exception $e1) {
+ // continue to fallback attempts
+ }
+
+ // Fallback schema: `geom` stored as LONGTEXT (GeoJSON string)
+ try {
+ $stmt = $pdo->prepare("
+ INSERT INTO spbu_points (nama, no, deskripsi, status_24jam, latitude, longitude, geom)
+ VALUES (:nama, :no, :deskripsi, :status_24jam, :latitude, :longitude, :geom)
+ ");
+
+ $stmt->execute([
+ ':nama' => $nama,
+ ':no' => $no,
+ ':deskripsi' => $deskripsi,
+ ':status_24jam' => $status_24jam,
+ ':latitude' => $latitude,
+ ':longitude' => $longitude,
+ ':geom' => $geomPayload
+ ]);
+
+ echo json_encode([
+ "success" => true,
+ "status" => "success",
+ "message" => "Point SPBU berhasil ditambahkan",
+ "id" => (int)$pdo->lastInsertId()
+ ]);
+ exit;
+ } catch (Exception $e2) {
+ // continue to fallback attempts
+ }
+
+ // Fallback schema: only (nama, geom) where geom is LONGTEXT GeoJSON
+ try {
+ $stmt = $pdo->prepare("INSERT INTO spbu_points (nama, geom) VALUES (:nama, :geom)");
+ $stmt->execute([
+ ':nama' => $nama,
+ ':geom' => $geomPayload
+ ]);
+
+ echo json_encode([
+ "success" => true,
+ "status" => "success",
+ "message" => "Point SPBU berhasil ditambahkan",
+ "id" => (int)$pdo->lastInsertId()
+ ]);
+ exit;
+ } catch (Exception $e3) {
+ // continue to fallback attempts
+ }
+
+ // Fallback schema: `geom` is spatial GEOMETRY/POINT
+ $stmt = $pdo->prepare("INSERT INTO spbu_points (nama, geom) VALUES (:nama, ST_GeomFromGeoJSON(:geom))");
+ $stmt->execute([
+ ':nama' => $nama,
+ ':geom' => $pointGeojson
+ ]);
+
+ echo json_encode([
+ "success" => true,
+ "status" => "success",
+ "message" => "Point SPBU berhasil ditambahkan",
+ "id" => (int)$pdo->lastInsertId()
+ ]);
+} catch (Exception $e) {
+ echo json_encode(["success" => false, "status" => "error", "message" => $e->getMessage()]);
+}
diff --git a/webgis-spbu/php/create_rumah_ibadah.php b/webgis-spbu/php/create_rumah_ibadah.php
new file mode 100644
index 0000000..dccc4f5
--- /dev/null
+++ b/webgis-spbu/php/create_rumah_ibadah.php
@@ -0,0 +1,60 @@
+ false, "message" => "Payload JSON kosong atau tidak valid."]);
+ exit;
+}
+
+$nama = trim($data['nama'] ?? '');
+$jenis = trim($data['jenis'] ?? 'Lainnya');
+$kontak = trim($data['kontak'] ?? '');
+$radius_meter = (float)($data['radius_meter'] ?? 0);
+$latitude = $data['latitude'] ?? null;
+$longitude = $data['longitude'] ?? null;
+$address = trim($data['address'] ?? '');
+
+if ($nama === '' || $radius_meter <= 0 || $latitude === null || $longitude === null) {
+ echo json_encode(["success" => false, "message" => "Data rumah ibadah belum lengkap (nama/radius/lat/lng)."]);
+ exit;
+}
+
+try {
+ $cols = $pdo->query("SHOW COLUMNS FROM rumah_ibadah_points")->fetchAll(PDO::FETCH_COLUMN, 0);
+ $hasJenis = in_array('jenis', $cols, true);
+ $hasKontak = in_array('kontak', $cols, true);
+
+ if ($hasJenis && $hasKontak) {
+ $stmt = $pdo->prepare("
+ INSERT INTO rumah_ibadah_points (nama, jenis, kontak, radius_meter, latitude, longitude, address)
+ VALUES (:nama, :jenis, :kontak, :radius_meter, :latitude, :longitude, :address)
+ ");
+ $stmt->execute([
+ ':nama' => $nama,
+ ':jenis' => ($jenis === '' ? 'Lainnya' : $jenis),
+ ':kontak' => ($kontak === '' ? null : $kontak),
+ ':radius_meter' => $radius_meter,
+ ':latitude' => $latitude,
+ ':longitude' => $longitude,
+ ':address' => ($address === '' ? null : $address)
+ ]);
+ } else {
+ $stmt = $pdo->prepare("
+ INSERT INTO rumah_ibadah_points (nama, radius_meter, latitude, longitude, address)
+ VALUES (:nama, :radius_meter, :latitude, :longitude, :address)
+ ");
+ $stmt->execute([
+ ':nama' => $nama,
+ ':radius_meter' => $radius_meter,
+ ':latitude' => $latitude,
+ ':longitude' => $longitude,
+ ':address' => ($address === '' ? null : $address)
+ ]);
+ }
+
+ echo json_encode(["success" => true, "id" => (int)$pdo->lastInsertId(), "message" => "Rumah ibadah berhasil ditambahkan"]);
+} catch (Exception $e) {
+ echo json_encode(["success" => false, "message" => $e->getMessage()]);
+}
diff --git a/webgis-spbu/php/delete_jalan.php b/webgis-spbu/php/delete_jalan.php
new file mode 100644
index 0000000..bf63df6
--- /dev/null
+++ b/webgis-spbu/php/delete_jalan.php
@@ -0,0 +1,16 @@
+ false, "message" => "ID tidak valid"]);
+ exit;
+}
+
+$stmt = $pdo->prepare("DELETE FROM jalan_lines WHERE id = ?");
+$stmt->execute([$id]);
+
+echo json_encode(["success" => true, "message" => "Data jalan berhasil dihapus"]);
\ No newline at end of file
diff --git a/webgis-spbu/php/delete_parsil.php b/webgis-spbu/php/delete_parsil.php
new file mode 100644
index 0000000..d5b3e60
--- /dev/null
+++ b/webgis-spbu/php/delete_parsil.php
@@ -0,0 +1,16 @@
+ false, "message" => "ID tidak valid"]);
+ exit;
+}
+
+$stmt = $pdo->prepare("DELETE FROM parsil_tanah WHERE id = ?");
+$stmt->execute([$id]);
+
+echo json_encode(["success" => true, "message" => "Data parsil berhasil dihapus"]);
\ No newline at end of file
diff --git a/webgis-spbu/php/delete_pemukiman_miskin.php b/webgis-spbu/php/delete_pemukiman_miskin.php
new file mode 100644
index 0000000..cc70ab5
--- /dev/null
+++ b/webgis-spbu/php/delete_pemukiman_miskin.php
@@ -0,0 +1,33 @@
+ false, "message" => "ID tidak valid"]);
+ exit;
+}
+
+try {
+ // best-effort delete uploaded proof file
+ try {
+ $cols = $pdo->query("SHOW COLUMNS FROM pemukiman_miskin_points")->fetchAll(PDO::FETCH_COLUMN, 0);
+ if (in_array('bukti_file', $cols, true)) {
+ $stmt = $pdo->prepare("SELECT bukti_file FROM pemukiman_miskin_points WHERE id = ?");
+ $stmt->execute([$id]);
+ $path = $stmt->fetchColumn();
+ if ($path && is_string($path) && strpos($path, 'uploads/bukti/') === 0) {
+ $full = __DIR__ . '/../' . $path;
+ if (is_file($full)) @unlink($full);
+ }
+ }
+ } catch (Exception $e) { /* ignore */ }
+
+ $stmt = $pdo->prepare("DELETE FROM pemukiman_miskin_points WHERE id = ?");
+ $stmt->execute([$id]);
+ echo json_encode(["success" => true, "message" => "Pemukiman miskin berhasil dihapus"]);
+} catch (Exception $e) {
+ echo json_encode(["success" => false, "message" => $e->getMessage()]);
+}
diff --git a/webgis-spbu/php/delete_point.php b/webgis-spbu/php/delete_point.php
new file mode 100644
index 0000000..566c920
--- /dev/null
+++ b/webgis-spbu/php/delete_point.php
@@ -0,0 +1,16 @@
+ false, "message" => "ID tidak valid"]);
+ exit;
+}
+
+$stmt = $pdo->prepare("DELETE FROM spbu_points WHERE id = ?");
+$stmt->execute([$id]);
+
+echo json_encode(["success" => true, "message" => "Data point berhasil dihapus"]);
\ No newline at end of file
diff --git a/webgis-spbu/php/delete_rumah_ibadah.php b/webgis-spbu/php/delete_rumah_ibadah.php
new file mode 100644
index 0000000..4508624
--- /dev/null
+++ b/webgis-spbu/php/delete_rumah_ibadah.php
@@ -0,0 +1,19 @@
+ false, "message" => "ID tidak valid"]);
+ exit;
+}
+
+try {
+ $stmt = $pdo->prepare("DELETE FROM rumah_ibadah_points WHERE id = ?");
+ $stmt->execute([$id]);
+ echo json_encode(["success" => true, "message" => "Rumah ibadah berhasil dihapus"]);
+} catch (Exception $e) {
+ echo json_encode(["success" => false, "message" => $e->getMessage()]);
+}
diff --git a/webgis-spbu/php/koneksi.php b/webgis-spbu/php/koneksi.php
new file mode 100644
index 0000000..cf301d1
--- /dev/null
+++ b/webgis-spbu/php/koneksi.php
@@ -0,0 +1,27 @@
+ PDO::ERRMODE_EXCEPTION,
+ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+ PDO::ATTR_EMULATE_PREPARES => false,
+];
+
+try {
+ $pdo = new PDO($dsn, $user, $pass, $options);
+} catch (PDOException $e) {
+ http_response_code(500);
+ echo json_encode([
+ "success" => false,
+ "message" => "Koneksi database gagal: " . $e->getMessage()
+ ]);
+ exit;
+}
+?>
\ No newline at end of file
diff --git a/webgis-spbu/php/read_anggota_keluarga.php b/webgis-spbu/php/read_anggota_keluarga.php
new file mode 100644
index 0000000..40c538d
--- /dev/null
+++ b/webgis-spbu/php/read_anggota_keluarga.php
@@ -0,0 +1,24 @@
+prepare("
+ SELECT id, penduduk_id, nama, hubungan, umur, pekerjaan, keterangan
+ FROM anggota_keluarga
+ WHERE penduduk_id = :id
+ ORDER BY id ASC
+ ");
+ $stmt->execute([':id' => $penduduk_id]);
+ $rows = $stmt->fetchAll();
+ echo json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+} catch (Exception $e) {
+ echo json_encode([]);
+}
+
diff --git a/webgis-spbu/php/read_jalan.php b/webgis-spbu/php/read_jalan.php
new file mode 100644
index 0000000..7e97bcc
--- /dev/null
+++ b/webgis-spbu/php/read_jalan.php
@@ -0,0 +1,51 @@
+fetch()) {
+ $geometry = json_decode($row['geojson'] ?? '', true);
+ if (!$geometry) {
+ continue;
+ }
+
+ $features[] = [
+ "type" => "Feature",
+ "geometry" => $geometry,
+ "properties" => [
+ "id" => (int)($row['id'] ?? 0),
+ "nama_jalan" => $row['nama_jalan'] ?? null,
+ "status_jalan" => $row['status_jalan'] ?? null,
+ "panjang_meter" => isset($row['panjang_meter']) ? (float)$row['panjang_meter'] : null
+ ]
+ ];
+ }
+ return $features;
+}
+
+try {
+ // Preferred schema: geojson column.
+ $stmt = $pdo->query("SELECT id, nama_jalan, status_jalan, panjang_meter, geojson FROM jalan_lines ORDER BY id ASC");
+ $features = rowsToFeatures($stmt);
+} catch (Exception $e) {
+ // Fallback schema: spatial geom.
+ $stmt = $pdo->query("
+ SELECT
+ id,
+ nama_jalan,
+ status_jalan,
+ panjang_meter,
+ ST_AsGeoJSON(geom) AS geojson
+ FROM jalan_lines
+ ORDER BY id ASC
+ ");
+ $features = rowsToFeatures($stmt);
+}
+
+echo json_encode([
+ "type" => "FeatureCollection",
+ "features" => $features
+], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
diff --git a/webgis-spbu/php/read_parsil.php b/webgis-spbu/php/read_parsil.php
new file mode 100644
index 0000000..b9d0f7e
--- /dev/null
+++ b/webgis-spbu/php/read_parsil.php
@@ -0,0 +1,49 @@
+fetch()) {
+ $geometry = json_decode($row['geojson'] ?? '', true);
+ if (!$geometry) {
+ continue;
+ }
+
+ $features[] = [
+ "type" => "Feature",
+ "geometry" => $geometry,
+ "properties" => [
+ "id" => (int)($row['id'] ?? 0),
+ "status_kepemilikan" => $row['status_kepemilikan'] ?? null,
+ "luas_m2" => isset($row['luas_m2']) ? (float)$row['luas_m2'] : null
+ ]
+ ];
+ }
+ return $features;
+}
+
+try {
+ // Preferred schema: geojson column.
+ $stmt = $pdo->query("SELECT id, status_kepemilikan, luas_m2, geojson FROM parsil_tanah ORDER BY id ASC");
+ $features = rowsToFeatures($stmt);
+} catch (Exception $e) {
+ // Fallback schema: spatial geom.
+ $stmt = $pdo->query("
+ SELECT
+ id,
+ status_kepemilikan,
+ luas_m2,
+ ST_AsGeoJSON(geom) AS geojson
+ FROM parsil_tanah
+ ORDER BY id ASC
+ ");
+ $features = rowsToFeatures($stmt);
+}
+
+echo json_encode([
+ "type" => "FeatureCollection",
+ "features" => $features
+], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
diff --git a/webgis-spbu/php/read_pemukiman_miskin.php b/webgis-spbu/php/read_pemukiman_miskin.php
new file mode 100644
index 0000000..bc5b48b
--- /dev/null
+++ b/webgis-spbu/php/read_pemukiman_miskin.php
@@ -0,0 +1,127 @@
+ (int)($r['id'] ?? 0),
+ 'jarak_meter' => $d,
+ 'row' => $r
+ ];
+ }
+ }
+ }
+ return $best;
+}
+
+// Prefer new schema; fall back to old schema if needed.
+$cols = $pdo->query("SHOW COLUMNS FROM pemukiman_miskin_points")->fetchAll(PDO::FETCH_COLUMN, 0);
+$hasNew = in_array('kk_nama', $cols, true);
+
+$hasJarak = in_array('jarak_meter', $cols, true);
+$hasRumahIbadahId = in_array('rumah_ibadah_id', $cols, true);
+
+// Fetch rumah ibadah once; used for real-time coverage computation server-side.
+$ibadah = [];
+try {
+ $stmtI = $pdo->query("SELECT id, nama, jenis, kontak, radius_meter, latitude, longitude FROM rumah_ibadah_points");
+ $ibadah = $stmtI->fetchAll();
+} catch (Exception $e) {
+ $ibadah = [];
+}
+
+// Load pemukiman rows
+$rows = [];
+try {
+ if ($hasNew) {
+ $stmt = $pdo->query("
+ SELECT p.*,
+ (
+ SELECT COUNT(*) FROM anggota_keluarga a WHERE a.penduduk_id = p.id
+ ) AS anggota_count
+ FROM pemukiman_miskin_points p
+ ORDER BY p.id ASC
+ ");
+ $rows = $stmt->fetchAll();
+ } else {
+ $stmt = $pdo->query("SELECT p.* FROM pemukiman_miskin_points p ORDER BY p.id ASC");
+ $rows = $stmt->fetchAll();
+ }
+} catch (Exception $e) {
+ $rows = [];
+}
+
+// Best-effort: keep stored rumah_ibadah_id/jarak_meter in sync with latest radius rules.
+$upd = null;
+try {
+ if ($hasRumahIbadahId && $hasJarak) {
+ $upd = $pdo->prepare("UPDATE pemukiman_miskin_points SET rumah_ibadah_id = :rid, jarak_meter = :jarak WHERE id = :id");
+ } elseif ($hasRumahIbadahId) {
+ $upd = $pdo->prepare("UPDATE pemukiman_miskin_points SET rumah_ibadah_id = :rid WHERE id = :id");
+ }
+} catch (Exception $e) {
+ $upd = null;
+}
+
+foreach ($rows as &$p) {
+ $lat = isset($p['latitude']) ? (float)$p['latitude'] : null;
+ $lng = isset($p['longitude']) ? (float)$p['longitude'] : null;
+ if ($lat === null || $lng === null) continue;
+
+ $best = compute_nearest_ibadah($ibadah, $lat, $lng);
+ $rid = $best ? (int)$best['rumah_ibadah_id'] : null;
+ $jarak = $best ? (float)$best['jarak_meter'] : null;
+ $storedRid = isset($p['rumah_ibadah_id']) ? (int)$p['rumah_ibadah_id'] : null;
+
+ // overwrite output with authoritative computed coverage
+ $p['rumah_ibadah_id'] = $rid;
+ if ($hasJarak) $p['jarak_meter'] = $jarak;
+
+ if ($best && isset($best['row']) && is_array($best['row'])) {
+ $p['rumah_ibadah_nama'] = $best['row']['nama'] ?? null;
+ $p['rumah_ibadah_jenis'] = $best['row']['jenis'] ?? null;
+ $p['rumah_ibadah_kontak'] = $best['row']['kontak'] ?? null;
+ $p['rumah_ibadah_radius'] = $best['row']['radius_meter'] ?? null;
+ } else {
+ $p['rumah_ibadah_nama'] = null;
+ $p['rumah_ibadah_jenis'] = null;
+ $p['rumah_ibadah_kontak'] = null;
+ $p['rumah_ibadah_radius'] = null;
+ }
+
+ // best-effort DB sync
+ try {
+ if ($upd && ((string)$storedRid !== (string)$rid)) {
+ if ($hasRumahIbadahId && $hasJarak) {
+ $upd->execute([':rid' => $rid, ':jarak' => $jarak, ':id' => (int)$p['id']]);
+ } elseif ($hasRumahIbadahId) {
+ $upd->execute([':rid' => $rid, ':id' => (int)$p['id']]);
+ }
+ }
+ } catch (Exception $e) {
+ // ignore
+ }
+}
+unset($p);
+
+echo json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
diff --git a/webgis-spbu/php/read_point.php b/webgis-spbu/php/read_point.php
new file mode 100644
index 0000000..ef9bc05
--- /dev/null
+++ b/webgis-spbu/php/read_point.php
@@ -0,0 +1,96 @@
+= 2) {
+ // GeoJSON: [lng, lat]
+ $row['longitude'] = $coords[0];
+ $row['latitude'] = $coords[1];
+ }
+ }
+ }
+ }
+ return $rows;
+}
+
+try {
+ // Preferred schema: has latitude/longitude columns.
+ $stmt = $pdo->query("
+ SELECT id, nama, no, deskripsi, status_24jam, latitude, longitude
+ FROM spbu_points
+ ORDER BY id ASC
+ ");
+ $rows = $stmt->fetchAll();
+ echo json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ exit;
+} catch (Exception $e) {
+ // continue
+}
+
+try {
+ // Fallback schema: latitude/longitude exists, geom stored as LONGTEXT GeoJSON.
+ $stmt = $pdo->query("
+ SELECT id, nama, no, deskripsi, status_24jam, latitude, longitude, geom
+ FROM spbu_points
+ ORDER BY id ASC
+ ");
+ $rows = $stmt->fetchAll();
+ $rows = normalizePointRows($rows);
+ echo json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ exit;
+} catch (Exception $e) {
+ // continue
+}
+
+try {
+ // Fallback schema: only geom LONGTEXT; derive latitude/longitude from GeoJSON.
+ $stmt = $pdo->query("
+ SELECT
+ id,
+ nama,
+ NULL AS no,
+ NULL AS deskripsi,
+ 0 AS status_24jam,
+ geom
+ FROM spbu_points
+ ORDER BY id ASC
+ ");
+ $rows = $stmt->fetchAll();
+ $rows = normalizePointRows($rows);
+ echo json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ exit;
+} catch (Exception $e) {
+ // continue
+}
+
+try {
+ // Fallback schema: spatial geom
+ $stmt = $pdo->query("
+ SELECT
+ id,
+ nama,
+ NULL AS no,
+ NULL AS deskripsi,
+ 0 AS status_24jam,
+ ST_Y(geom) AS latitude,
+ ST_X(geom) AS longitude
+ FROM spbu_points
+ ORDER BY id ASC
+ ");
+ $rows = $stmt->fetchAll();
+ echo json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ exit;
+} catch (Exception $e) {
+ // continue
+}
+
+// Last resort
+$stmt = $pdo->query("SELECT * FROM spbu_points ORDER BY id ASC");
+$rows = $stmt->fetchAll();
+echo json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
diff --git a/webgis-spbu/php/read_rumah_ibadah.php b/webgis-spbu/php/read_rumah_ibadah.php
new file mode 100644
index 0000000..e11c3af
--- /dev/null
+++ b/webgis-spbu/php/read_rumah_ibadah.php
@@ -0,0 +1,17 @@
+query("
+ SELECT r.*,
+ (
+ SELECT COUNT(*)
+ FROM pemukiman_miskin_points p
+ WHERE p.rumah_ibadah_id = r.id
+ ) AS binaan_count
+ FROM rumah_ibadah_points r
+ ORDER BY r.id ASC
+");
+$rows = $stmt->fetchAll();
+echo json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
diff --git a/webgis-spbu/php/reverse_geocode.php b/webgis-spbu/php/reverse_geocode.php
new file mode 100644
index 0000000..bcebbe5
--- /dev/null
+++ b/webgis-spbu/php/reverse_geocode.php
@@ -0,0 +1,42 @@
+ false, "message" => "lat/lng wajib diisi"]);
+ exit;
+}
+
+// Nominatim usage policy requires a valid User-Agent identifying the application.
+$url = "https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=" . urlencode($lat) . "&lon=" . urlencode($lng);
+
+try {
+ $ch = curl_init($url);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 8);
+ curl_setopt($ch, CURLOPT_TIMEOUT, 12);
+ curl_setopt($ch, CURLOPT_HTTPHEADER, [
+ "User-Agent: webgis-spbu/1.0 (XAMPP PHP reverse geocode)"
+ ]);
+ $resp = curl_exec($ch);
+ $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $err = curl_error($ch);
+ curl_close($ch);
+
+ if ($resp === false || $code < 200 || $code >= 300) {
+ echo json_encode(["success" => false, "message" => $err ?: ("HTTP " . $code)]);
+ exit;
+ }
+
+ $json = json_decode($resp, true);
+ $display = $json['display_name'] ?? null;
+ echo json_encode([
+ "success" => true,
+ "address" => $display,
+ "raw" => $json
+ ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+} catch (Exception $e) {
+ echo json_encode(["success" => false, "message" => $e->getMessage()]);
+}
diff --git a/webgis-spbu/php/update_jalan.php b/webgis-spbu/php/update_jalan.php
new file mode 100644
index 0000000..5448f51
--- /dev/null
+++ b/webgis-spbu/php/update_jalan.php
@@ -0,0 +1,63 @@
+ false, "message" => "Data kosong"]);
+ exit;
+}
+
+$id = (int)($data['id'] ?? 0);
+$nama_jalan = trim($data['nama_jalan'] ?? '');
+$status_jalan = trim($data['status_jalan'] ?? '');
+$panjang_meter = (float)($data['panjang_meter'] ?? 0);
+$geometry = $data['geometry'] ?? null;
+
+if ($id <= 0 || $nama_jalan === '' || $status_jalan === '' || !$geometry) {
+ echo json_encode(["success" => false, "message" => "Data update jalan belum lengkap"]);
+ exit;
+}
+
+$sql = "UPDATE jalan_lines
+ SET nama_jalan = :nama_jalan,
+ status_jalan = :status_jalan,
+ panjang_meter = :panjang_meter,
+ geojson = :geojson
+ WHERE id = :id";
+
+$geojson = json_encode($geometry, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+
+try {
+ $stmt = $pdo->prepare($sql);
+ $stmt->execute([
+ ':nama_jalan' => $nama_jalan,
+ ':status_jalan' => $status_jalan,
+ ':panjang_meter' => $panjang_meter,
+ ':geojson' => $geojson,
+ ':id' => $id
+ ]);
+
+ echo json_encode(["success" => true, "message" => "Data jalan berhasil diupdate"]);
+ exit;
+} catch (Exception $inner) {
+ // fallback to spatial geom schema
+}
+
+$stmt = $pdo->prepare("UPDATE jalan_lines
+ SET nama_jalan = :nama_jalan,
+ status_jalan = :status_jalan,
+ panjang_meter = :panjang_meter,
+ geom = ST_GeomFromGeoJSON(:geojson)
+ WHERE id = :id");
+
+$stmt->execute([
+ ':nama_jalan' => $nama_jalan,
+ ':status_jalan' => $status_jalan,
+ ':panjang_meter' => $panjang_meter,
+ ':geojson' => $geojson,
+ ':id' => $id
+]);
+
+echo json_encode(["success" => true, "message" => "Data jalan berhasil diupdate"]);
diff --git a/webgis-spbu/php/update_parsil.php b/webgis-spbu/php/update_parsil.php
new file mode 100644
index 0000000..0609153
--- /dev/null
+++ b/webgis-spbu/php/update_parsil.php
@@ -0,0 +1,58 @@
+ false, "message" => "Data kosong"]);
+ exit;
+}
+
+$id = (int)($data['id'] ?? 0);
+$status_kepemilikan = trim($data['status_kepemilikan'] ?? '');
+$luas_m2 = (float)($data['luas_m2'] ?? 0);
+$geometry = $data['geometry'] ?? null;
+
+if ($id <= 0 || $status_kepemilikan === '' || !$geometry) {
+ echo json_encode(["success" => false, "message" => "Data update parsil belum lengkap"]);
+ exit;
+}
+
+$sql = "UPDATE parsil_tanah
+ SET status_kepemilikan = :status_kepemilikan,
+ luas_m2 = :luas_m2,
+ geojson = :geojson
+ WHERE id = :id";
+
+$geojson = json_encode($geometry, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+
+try {
+ $stmt = $pdo->prepare($sql);
+ $stmt->execute([
+ ':status_kepemilikan' => $status_kepemilikan,
+ ':luas_m2' => $luas_m2,
+ ':geojson' => $geojson,
+ ':id' => $id
+ ]);
+
+ echo json_encode(["success" => true, "message" => "Data parsil berhasil diupdate"]);
+ exit;
+} catch (Exception $inner) {
+ // fallback to spatial geom schema
+}
+
+$stmt = $pdo->prepare("UPDATE parsil_tanah
+ SET status_kepemilikan = :status_kepemilikan,
+ luas_m2 = :luas_m2,
+ geom = ST_GeomFromGeoJSON(:geojson)
+ WHERE id = :id");
+
+$stmt->execute([
+ ':status_kepemilikan' => $status_kepemilikan,
+ ':luas_m2' => $luas_m2,
+ ':geojson' => $geojson,
+ ':id' => $id
+]);
+
+echo json_encode(["success" => true, "message" => "Data parsil berhasil diupdate"]);
diff --git a/webgis-spbu/php/update_pemukiman_miskin.php b/webgis-spbu/php/update_pemukiman_miskin.php
new file mode 100644
index 0000000..46e4ad8
--- /dev/null
+++ b/webgis-spbu/php/update_pemukiman_miskin.php
@@ -0,0 +1,213 @@
+ $_POST['id'] ?? 0,
+ 'kk_nama' => $_POST['kk_nama'] ?? '',
+ 'nik' => $_POST['nik'] ?? '',
+ 'jumlah_anggota' => $_POST['jumlah_anggota'] ?? '',
+ 'latitude' => $_POST['latitude'] ?? null,
+ 'longitude' => $_POST['longitude'] ?? null,
+ 'address' => $_POST['address'] ?? '',
+ 'kelurahan' => $_POST['kelurahan'] ?? '',
+ 'kecamatan' => $_POST['kecamatan'] ?? '',
+ 'status_bantuan' => $_POST['status_bantuan'] ?? 'Belum dibantu',
+ 'jenis_bantuan' => $_POST['jenis_bantuan'] ?? '',
+ 'tanggal_bantuan' => $_POST['tanggal_bantuan'] ?? '',
+ 'anggota' => $_POST['anggota'] ?? '[]'
+ ];
+ }
+ $json = json_decode(file_get_contents('php://input'), true);
+ return is_array($json) ? $json : null;
+}
+
+function compute_nearest_ibadah(PDO $pdo, $lat, $lng) {
+ $stmt = $pdo->query("SELECT id, latitude, longitude, radius_meter FROM rumah_ibadah_points");
+ $rows = $stmt->fetchAll();
+ $best = null;
+ foreach ($rows as $r) {
+ $rLat = (float)$r['latitude'];
+ $rLng = (float)$r['longitude'];
+ $radius = (float)$r['radius_meter'];
+ if ($radius <= 0) continue;
+ $d = haversine_m($lat, $lng, $rLat, $rLng);
+ if ($d <= $radius) {
+ if ($best === null || $d < $best['jarak_meter']) {
+ $best = ['rumah_ibadah_id' => (int)$r['id'], 'jarak_meter' => $d];
+ }
+ }
+ }
+ return $best ?: ['rumah_ibadah_id' => null, 'jarak_meter' => null];
+}
+
+function save_upload_if_any() {
+ if (!isset($_FILES['bukti_file']) || !is_array($_FILES['bukti_file'])) return null;
+ if (($_FILES['bukti_file']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) return null;
+ $dir = __DIR__ . '/../uploads/bukti';
+ if (!is_dir($dir)) @mkdir($dir, 0775, true);
+ if (!is_dir($dir)) return null;
+
+ $orig = (string)($_FILES['bukti_file']['name'] ?? 'file');
+ $ext = strtolower(pathinfo($orig, PATHINFO_EXTENSION));
+ $allowed = ['jpg','jpeg','png','webp','pdf'];
+ if ($ext && !in_array($ext, $allowed, true)) return null;
+
+ $safeBase = preg_replace('/[^a-zA-Z0-9._-]+/', '_', pathinfo($orig, PATHINFO_FILENAME));
+ $safeBase = trim($safeBase, '_');
+ if ($safeBase === '') $safeBase = 'bukti';
+ $filename = $safeBase . '_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . ($ext ? ('.' . $ext) : '');
+ $dest = $dir . '/' . $filename;
+ if (!move_uploaded_file($_FILES['bukti_file']['tmp_name'], $dest)) return null;
+ return 'uploads/bukti/' . $filename;
+}
+
+$data = get_payload();
+if (!$data) {
+ echo json_encode(["success" => false, "message" => "Payload kosong atau tidak valid."]);
+ exit;
+}
+
+$id = (int)($data['id'] ?? 0);
+$kk_nama = trim($data['kk_nama'] ?? $data['nama'] ?? '');
+$nik = trim($data['nik'] ?? '');
+$jumlah_anggota = (int)($data['jumlah_anggota'] ?? 0);
+$latitude = $data['latitude'] ?? null;
+$longitude = $data['longitude'] ?? null;
+$address = trim($data['address'] ?? '');
+$kelurahan = trim($data['kelurahan'] ?? '');
+$kecamatan = trim($data['kecamatan'] ?? '');
+$status_bantuan = trim($data['status_bantuan'] ?? 'Belum dibantu');
+$jenis_bantuan = trim($data['jenis_bantuan'] ?? '');
+$tanggal_bantuan = trim($data['tanggal_bantuan'] ?? '');
+$anggotaRaw = $data['anggota'] ?? [];
+
+$validStatus = ['Belum dibantu', 'Sudah dibantu', 'Menunggu verifikasi'];
+if (!in_array($status_bantuan, $validStatus, true)) $status_bantuan = 'Belum dibantu';
+
+if ($id <= 0 || $kk_nama === '' || $latitude === null || $longitude === null) {
+ echo json_encode(["success" => false, "message" => "Data update penduduk miskin belum lengkap."]);
+ exit;
+}
+
+try {
+ $lat = (float)$latitude;
+ $lng = (float)$longitude;
+ $nearest = compute_nearest_ibadah($pdo, $lat, $lng);
+ $uploadPath = save_upload_if_any();
+
+ $cols = $pdo->query("SHOW COLUMNS FROM pemukiman_miskin_points")->fetchAll(PDO::FETCH_COLUMN, 0);
+ $hasNew = in_array('kk_nama', $cols, true);
+
+ $pdo->beginTransaction();
+
+ if ($hasNew) {
+ if ($uploadPath === null) {
+ $stmt = $pdo->prepare("SELECT bukti_file FROM pemukiman_miskin_points WHERE id = :id");
+ $stmt->execute([':id' => $id]);
+ $uploadPath = $stmt->fetchColumn() ?: null;
+ }
+
+ $stmt = $pdo->prepare("
+ UPDATE pemukiman_miskin_points
+ SET kk_nama = :kk_nama,
+ nik = :nik,
+ jumlah_anggota = :jumlah_anggota,
+ latitude = :latitude,
+ longitude = :longitude,
+ address = :address,
+ kelurahan = :kelurahan,
+ kecamatan = :kecamatan,
+ status_bantuan = :status_bantuan,
+ jenis_bantuan = :jenis_bantuan,
+ tanggal_bantuan = :tanggal_bantuan,
+ bukti_file = :bukti_file,
+ jarak_meter = :jarak_meter,
+ rumah_ibadah_id = :rumah_ibadah_id
+ WHERE id = :id
+ ");
+ $stmt->execute([
+ ':kk_nama' => $kk_nama,
+ ':nik' => ($nik === '' ? null : $nik),
+ ':jumlah_anggota' => $jumlah_anggota,
+ ':latitude' => $lat,
+ ':longitude' => $lng,
+ ':address' => ($address === '' ? null : $address),
+ ':kelurahan' => ($kelurahan === '' ? null : $kelurahan),
+ ':kecamatan' => ($kecamatan === '' ? null : $kecamatan),
+ ':status_bantuan' => $status_bantuan,
+ ':jenis_bantuan' => ($jenis_bantuan === '' ? null : $jenis_bantuan),
+ ':tanggal_bantuan' => ($tanggal_bantuan === '' ? null : $tanggal_bantuan),
+ ':bukti_file' => $uploadPath,
+ ':jarak_meter' => $nearest['jarak_meter'],
+ ':rumah_ibadah_id' => $nearest['rumah_ibadah_id'],
+ ':id' => $id
+ ]);
+
+ // anggota keluarga (replace)
+ $pdo->prepare("DELETE FROM anggota_keluarga WHERE penduduk_id = :id")->execute([':id' => $id]);
+ $anggota = is_string($anggotaRaw) ? json_decode($anggotaRaw, true) : $anggotaRaw;
+ if (is_array($anggota) && count($anggota) > 0) {
+ $ins = $pdo->prepare("
+ INSERT INTO anggota_keluarga (penduduk_id, nama, hubungan, umur, pekerjaan, keterangan)
+ VALUES (:penduduk_id, :nama, :hubungan, :umur, :pekerjaan, :keterangan)
+ ");
+ foreach ($anggota as $a) {
+ $n = trim((string)($a['nama'] ?? ''));
+ $h = trim((string)($a['hubungan'] ?? ''));
+ if ($n === '' || $h === '') continue;
+ $ins->execute([
+ ':penduduk_id' => $id,
+ ':nama' => $n,
+ ':hubungan' => $h,
+ ':umur' => isset($a['umur']) && $a['umur'] !== '' ? (int)$a['umur'] : null,
+ ':pekerjaan' => ($a['pekerjaan'] ?? '') === '' ? null : (string)$a['pekerjaan'],
+ ':keterangan' => ($a['keterangan'] ?? '') === '' ? null : (string)$a['keterangan']
+ ]);
+ }
+ }
+ } else {
+ $stmt = $pdo->prepare("
+ UPDATE pemukiman_miskin_points
+ SET nama = :nama,
+ latitude = :latitude,
+ longitude = :longitude,
+ address = :address,
+ rumah_ibadah_id = :rumah_ibadah_id
+ WHERE id = :id
+ ");
+ $stmt->execute([
+ ':nama' => $kk_nama,
+ ':latitude' => $lat,
+ ':longitude' => $lng,
+ ':address' => ($address === '' ? null : $address),
+ ':rumah_ibadah_id' => $nearest['rumah_ibadah_id'],
+ ':id' => $id
+ ]);
+ }
+
+ $pdo->commit();
+ echo json_encode([
+ "success" => true,
+ "rumah_ibadah_id" => $nearest['rumah_ibadah_id'],
+ "jarak_meter" => $nearest['jarak_meter'],
+ "message" => "Penduduk miskin berhasil diupdate"
+ ]);
+} catch (Exception $e) {
+ if ($pdo->inTransaction()) $pdo->rollBack();
+ echo json_encode(["success" => false, "message" => $e->getMessage()]);
+}
diff --git a/webgis-spbu/php/update_pemukiman_miskin_location.php b/webgis-spbu/php/update_pemukiman_miskin_location.php
new file mode 100644
index 0000000..8a53362
--- /dev/null
+++ b/webgis-spbu/php/update_pemukiman_miskin_location.php
@@ -0,0 +1,95 @@
+query("SELECT id, latitude, longitude, radius_meter FROM rumah_ibadah_points");
+ $rows = $stmt->fetchAll();
+ $best = null;
+ foreach ($rows as $r) {
+ $rLat = (float)$r['latitude'];
+ $rLng = (float)$r['longitude'];
+ $radius = (float)$r['radius_meter'];
+ if ($radius <= 0) continue;
+ $d = haversine_m($lat, $lng, $rLat, $rLng);
+ if ($d <= $radius) {
+ if ($best === null || $d < $best['jarak_meter']) {
+ $best = ['rumah_ibadah_id' => (int)$r['id'], 'jarak_meter' => $d];
+ }
+ }
+ }
+ return $best ?: ['rumah_ibadah_id' => null, 'jarak_meter' => null];
+}
+
+$data = json_decode(file_get_contents('php://input'), true);
+if (!is_array($data)) {
+ echo json_encode(["success" => false, "message" => "Payload JSON kosong atau tidak valid."]);
+ exit;
+}
+
+$id = (int)($data['id'] ?? 0);
+$lat = isset($data['latitude']) ? (float)$data['latitude'] : null;
+$lng = isset($data['longitude']) ? (float)$data['longitude'] : null;
+if ($id <= 0 || $lat === null || $lng === null) {
+ echo json_encode(["success" => false, "message" => "Data update lokasi belum lengkap (id/lat/lng)."]);
+ exit;
+}
+
+try {
+ $nearest = compute_nearest_ibadah($pdo, $lat, $lng);
+ $cols = $pdo->query("SHOW COLUMNS FROM pemukiman_miskin_points")->fetchAll(PDO::FETCH_COLUMN, 0);
+ $hasNew = in_array('kk_nama', $cols, true);
+
+ if ($hasNew) {
+ $stmt = $pdo->prepare("
+ UPDATE pemukiman_miskin_points
+ SET latitude = :lat,
+ longitude = :lng,
+ jarak_meter = :jarak_meter,
+ rumah_ibadah_id = :rumah_ibadah_id
+ WHERE id = :id
+ ");
+ $stmt->execute([
+ ':lat' => $lat,
+ ':lng' => $lng,
+ ':jarak_meter' => $nearest['jarak_meter'],
+ ':rumah_ibadah_id' => $nearest['rumah_ibadah_id'],
+ ':id' => $id
+ ]);
+ } else {
+ $stmt = $pdo->prepare("
+ UPDATE pemukiman_miskin_points
+ SET latitude = :lat,
+ longitude = :lng,
+ rumah_ibadah_id = :rumah_ibadah_id
+ WHERE id = :id
+ ");
+ $stmt->execute([
+ ':lat' => $lat,
+ ':lng' => $lng,
+ ':rumah_ibadah_id' => $nearest['rumah_ibadah_id'],
+ ':id' => $id
+ ]);
+ }
+
+ echo json_encode([
+ "success" => true,
+ "rumah_ibadah_id" => $nearest['rumah_ibadah_id'],
+ "jarak_meter" => $nearest['jarak_meter'],
+ "message" => "Lokasi penduduk miskin berhasil diupdate"
+ ]);
+} catch (Exception $e) {
+ echo json_encode(["success" => false, "message" => $e->getMessage()]);
+}
+
diff --git a/webgis-spbu/php/update_point.php b/webgis-spbu/php/update_point.php
new file mode 100644
index 0000000..018ac70
--- /dev/null
+++ b/webgis-spbu/php/update_point.php
@@ -0,0 +1,115 @@
+ false, "message" => "Data kosong"]);
+ exit;
+}
+
+$id = (int)($data['id'] ?? 0);
+$nama = trim($data['nama'] ?? '');
+$no = trim($data['no'] ?? '');
+$deskripsi = trim($data['deskripsi'] ?? '');
+$status_24jam = isset($data['status_24jam']) ? (int)$data['status_24jam'] : 0;
+$latitude = $data['latitude'] ?? null;
+$longitude = $data['longitude'] ?? null;
+
+if ($id <= 0 || $nama === '' || $no === '' || $latitude === null || $longitude === null) {
+ echo json_encode(["success" => false, "message" => "Data update point belum lengkap"]);
+ exit;
+}
+
+$sql = "UPDATE spbu_points
+ SET nama = :nama,
+ no = :no,
+ deskripsi = :deskripsi,
+ status_24jam = :status_24jam,
+ latitude = :latitude,
+ longitude = :longitude
+ WHERE id = :id";
+
+$pointGeojson = json_encode([
+ "type" => "Point",
+ "coordinates" => [(float)$longitude, (float)$latitude]
+], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+
+try {
+ $stmt = $pdo->prepare($sql);
+ $stmt->execute([
+ ':nama' => $nama,
+ ':no' => $no,
+ ':deskripsi' => $deskripsi,
+ ':status_24jam' => $status_24jam,
+ ':latitude' => $latitude,
+ ':longitude' => $longitude,
+ ':id' => $id
+ ]);
+
+ echo json_encode(["success" => true, "message" => "Data point berhasil diupdate"]);
+ exit;
+} catch (Exception $inner) {
+ // continue fallback
+}
+
+// Fallback schema: geom LONGTEXT
+try {
+ $stmt = $pdo->prepare("UPDATE spbu_points
+ SET nama = :nama,
+ no = :no,
+ deskripsi = :deskripsi,
+ status_24jam = :status_24jam,
+ latitude = :latitude,
+ longitude = :longitude,
+ geom = :geom
+ WHERE id = :id");
+
+ $stmt->execute([
+ ':nama' => $nama,
+ ':no' => $no,
+ ':deskripsi' => $deskripsi,
+ ':status_24jam' => $status_24jam,
+ ':latitude' => $latitude,
+ ':longitude' => $longitude,
+ ':geom' => $pointGeojson,
+ ':id' => $id
+ ]);
+
+ echo json_encode(["success" => true, "message" => "Data point berhasil diupdate"]);
+ exit;
+} catch (Exception $inner2) {
+ // continue fallback
+}
+
+// Fallback schema: only (nama, geom) where geom is LONGTEXT GeoJSON
+try {
+ $stmt = $pdo->prepare("UPDATE spbu_points
+ SET nama = :nama,
+ geom = :geom
+ WHERE id = :id");
+ $stmt->execute([
+ ':nama' => $nama,
+ ':geom' => $pointGeojson,
+ ':id' => $id
+ ]);
+
+ echo json_encode(["success" => true, "message" => "Data point berhasil diupdate"]);
+ exit;
+} catch (Exception $inner3) {
+ // continue fallback
+}
+
+// Fallback schema: spatial geom
+$stmt = $pdo->prepare("UPDATE spbu_points
+ SET nama = :nama,
+ geom = ST_GeomFromGeoJSON(:geom)
+ WHERE id = :id");
+$stmt->execute([
+ ':nama' => $nama,
+ ':geom' => $pointGeojson,
+ ':id' => $id
+]);
+
+echo json_encode(["success" => true, "message" => "Data point berhasil diupdate"]);
diff --git a/webgis-spbu/php/update_rumah_ibadah.php b/webgis-spbu/php/update_rumah_ibadah.php
new file mode 100644
index 0000000..e9c91a3
--- /dev/null
+++ b/webgis-spbu/php/update_rumah_ibadah.php
@@ -0,0 +1,75 @@
+ false, "message" => "Payload JSON kosong atau tidak valid."]);
+ exit;
+}
+
+$id = (int)($data['id'] ?? 0);
+$nama = trim($data['nama'] ?? '');
+$jenis = trim($data['jenis'] ?? 'Lainnya');
+$kontak = trim($data['kontak'] ?? '');
+$radius_meter = (float)($data['radius_meter'] ?? 0);
+$latitude = $data['latitude'] ?? null;
+$longitude = $data['longitude'] ?? null;
+$address = trim($data['address'] ?? '');
+
+if ($id <= 0 || $nama === '' || $radius_meter <= 0 || $latitude === null || $longitude === null) {
+ echo json_encode(["success" => false, "message" => "Data update rumah ibadah belum lengkap."]);
+ exit;
+}
+
+try {
+ $cols = $pdo->query("SHOW COLUMNS FROM rumah_ibadah_points")->fetchAll(PDO::FETCH_COLUMN, 0);
+ $hasJenis = in_array('jenis', $cols, true);
+ $hasKontak = in_array('kontak', $cols, true);
+
+ if ($hasJenis && $hasKontak) {
+ $stmt = $pdo->prepare("
+ UPDATE rumah_ibadah_points
+ SET nama = :nama,
+ jenis = :jenis,
+ kontak = :kontak,
+ radius_meter = :radius_meter,
+ latitude = :latitude,
+ longitude = :longitude,
+ address = :address
+ WHERE id = :id
+ ");
+ $stmt->execute([
+ ':nama' => $nama,
+ ':jenis' => ($jenis === '' ? 'Lainnya' : $jenis),
+ ':kontak' => ($kontak === '' ? null : $kontak),
+ ':radius_meter' => $radius_meter,
+ ':latitude' => $latitude,
+ ':longitude' => $longitude,
+ ':address' => ($address === '' ? null : $address),
+ ':id' => $id
+ ]);
+ } else {
+ $stmt = $pdo->prepare("
+ UPDATE rumah_ibadah_points
+ SET nama = :nama,
+ radius_meter = :radius_meter,
+ latitude = :latitude,
+ longitude = :longitude,
+ address = :address
+ WHERE id = :id
+ ");
+ $stmt->execute([
+ ':nama' => $nama,
+ ':radius_meter' => $radius_meter,
+ ':latitude' => $latitude,
+ ':longitude' => $longitude,
+ ':address' => ($address === '' ? null : $address),
+ ':id' => $id
+ ]);
+ }
+
+ echo json_encode(["success" => true, "message" => "Rumah ibadah berhasil diupdate"]);
+} catch (Exception $e) {
+ echo json_encode(["success" => false, "message" => $e->getMessage()]);
+}
diff --git a/webgis-spbu/sql/schema_webgis_spbu.sql b/webgis-spbu/sql/schema_webgis_spbu.sql
new file mode 100644
index 0000000..28f9b1b
--- /dev/null
+++ b/webgis-spbu/sql/schema_webgis_spbu.sql
@@ -0,0 +1,466 @@
+-- Schema WebGIS SPBU + Jalan + Parsil (MySQL / MariaDB)
+-- Catatan:
+-- - Data LINE/POLYGON disimpan sebagai GeoJSON geometry (tipe: geojson) di kolom LONGTEXT `geojson`.
+-- - Panjang (meter) dan luas (m2) dihitung otomatis di LeafletJS, bukan input manual.
+
+CREATE TABLE IF NOT EXISTS spbu_points (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ nama VARCHAR(150) NOT NULL,
+ no VARCHAR(60) NOT NULL,
+ deskripsi TEXT NULL,
+ status_24jam TINYINT(1) NOT NULL DEFAULT 0,
+ latitude DECIMAL(10,7) NOT NULL,
+ longitude DECIMAL(10,7) NOT NULL,
+ -- opsional (kompatibilitas): simpan GeoJSON Point sebagai string
+ geom LONGTEXT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ INDEX idx_spbu_status24 (status_24jam),
+ INDEX idx_spbu_latlng (latitude, longitude)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS jalan_lines (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ nama_jalan VARCHAR(200) NOT NULL,
+ status_jalan VARCHAR(20) NOT NULL, -- Nasional | Provinsi | Kabupaten
+ panjang_meter DOUBLE NOT NULL DEFAULT 0,
+ geojson LONGTEXT NOT NULL, -- GeoJSON geometry: { "type":"LineString", "coordinates":[...] }
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ INDEX idx_jalan_status (status_jalan)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS parsil_tanah (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ status_kepemilikan VARCHAR(10) NOT NULL, -- SHM | HGB | HGU | HP
+ luas_m2 DOUBLE NOT NULL DEFAULT 0,
+ geojson LONGTEXT NOT NULL, -- GeoJSON geometry: { "type":"Polygon", "coordinates":[...] }
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ INDEX idx_parsil_status (status_kepemilikan)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS rumah_ibadah_points (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ nama VARCHAR(200) NOT NULL,
+ jenis VARCHAR(30) NOT NULL DEFAULT 'Lainnya', -- Masjid|Musholla|Gereja|Katolik|Pura|Vihara|Klenteng|Lainnya
+ kontak VARCHAR(120) NULL,
+ radius_meter DOUBLE NOT NULL DEFAULT 300,
+ latitude DECIMAL(10,7) NOT NULL,
+ longitude DECIMAL(10,7) NOT NULL,
+ address TEXT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ INDEX idx_ibadah_jenis (jenis),
+ INDEX idx_ibadah_latlng (latitude, longitude)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS pemukiman_miskin_points (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ kk_nama VARCHAR(200) NOT NULL,
+ nik VARCHAR(32) NULL,
+ jumlah_anggota INT NOT NULL DEFAULT 0,
+ latitude DECIMAL(10,7) NOT NULL,
+ longitude DECIMAL(10,7) NOT NULL,
+ address TEXT NULL,
+ kelurahan VARCHAR(120) NULL,
+ kecamatan VARCHAR(120) NULL,
+ status_bantuan VARCHAR(24) NOT NULL DEFAULT 'Belum dibantu', -- Belum dibantu|Sudah dibantu|Menunggu verifikasi
+ jenis_bantuan VARCHAR(120) NULL,
+ tanggal_bantuan DATE NULL,
+ bukti_file VARCHAR(255) NULL,
+ jarak_meter DOUBLE NULL,
+ -- id rumah ibadah yang menaungi (hasil pencarian radius terdekat)
+ rumah_ibadah_id INT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ INDEX idx_miskin_latlng (latitude, longitude),
+ INDEX idx_miskin_ibadah (rumah_ibadah_id),
+ INDEX idx_miskin_status (status_bantuan),
+ CONSTRAINT fk_miskin_ibadah FOREIGN KEY (rumah_ibadah_id) REFERENCES rumah_ibadah_points(id)
+ ON DELETE SET NULL ON UPDATE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS anggota_keluarga (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ penduduk_id INT NOT NULL,
+ nama VARCHAR(200) NOT NULL,
+ hubungan VARCHAR(60) NOT NULL,
+ umur INT NULL,
+ pekerjaan VARCHAR(120) NULL,
+ keterangan VARCHAR(255) NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ INDEX idx_anggota_penduduk (penduduk_id),
+ CONSTRAINT fk_anggota_penduduk FOREIGN KEY (penduduk_id) REFERENCES pemukiman_miskin_points(id)
+ ON DELETE CASCADE ON UPDATE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- (Opsional) User login untuk admin/operator.
+CREATE TABLE IF NOT EXISTS users (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ name VARCHAR(120) NOT NULL,
+ username VARCHAR(60) NOT NULL UNIQUE,
+ password_hash VARCHAR(255) NOT NULL,
+ role VARCHAR(20) NOT NULL DEFAULT 'operator', -- admin|operator
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- Jika ingin strict value, bisa pakai ENUM, misal:
+-- status_jalan ENUM('Nasional','Provinsi','Kabupaten')
+-- status_kepemilikan ENUM('SHM','HGB','HGU','HP')
+
+
+-- ==========================================================
+-- MIGRATION (ALTER) UNTUK DATABASE LAMA (TANPA HILANGKAN DATA)
+-- ==========================================================
+-- Kapan perlu menjalankan bagian ini?
+-- - Jika database Anda dibuat dari project lama (kolom/tipe berbeda),
+-- misalnya masih pakai kolom spatial `geom` atau belum punya kolom
+-- `latitude/longitude/status_24jam/geojson`.
+-- - Jika database Anda sudah dibuat pakai schema di atas (CREATE TABLE),
+-- Anda biasanya TIDAK perlu ALTER apa-apa.
+--
+-- Cara pakai:
+-- - Jalankan file ini di phpMyAdmin / MySQL client.
+-- - Bagian CREATE TABLE aman (IF NOT EXISTS).
+-- - Bagian MIGRATION ini aman (cek INFORMATION_SCHEMA sebelum ALTER).
+
+DELIMITER $$
+
+DROP PROCEDURE IF EXISTS migrate_webgis_spbu $$
+CREATE PROCEDURE migrate_webgis_spbu()
+BEGIN
+ -- ----------------------
+ -- spbu_points
+ -- ----------------------
+ IF EXISTS (
+ SELECT 1 FROM information_schema.tables
+ WHERE table_schema = DATABASE() AND table_name = 'spbu_points'
+ ) THEN
+
+ -- Kolom atribut inti
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'spbu_points' AND column_name = 'no'
+ ) THEN
+ ALTER TABLE spbu_points ADD COLUMN no VARCHAR(60) NULL AFTER nama;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'spbu_points' AND column_name = 'deskripsi'
+ ) THEN
+ ALTER TABLE spbu_points ADD COLUMN deskripsi TEXT NULL AFTER no;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'spbu_points' AND column_name = 'status_24jam'
+ ) THEN
+ ALTER TABLE spbu_points ADD COLUMN status_24jam TINYINT(1) NOT NULL DEFAULT 0 AFTER deskripsi;
+ END IF;
+
+ -- Lat/Lng: untuk menjaga data lama, dibuat NULL-able dulu.
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'spbu_points' AND column_name = 'latitude'
+ ) THEN
+ ALTER TABLE spbu_points ADD COLUMN latitude DECIMAL(10,7) NULL AFTER status_24jam;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'spbu_points' AND column_name = 'longitude'
+ ) THEN
+ ALTER TABLE spbu_points ADD COLUMN longitude DECIMAL(10,7) NULL AFTER latitude;
+ END IF;
+
+ -- Kolom geom (opsional) untuk kompatibilitas legacy
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'spbu_points' AND column_name = 'geom'
+ ) THEN
+ ALTER TABLE spbu_points ADD COLUMN geom LONGTEXT NULL AFTER longitude;
+ END IF;
+
+ -- Index (jika belum ada)
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.statistics
+ WHERE table_schema = DATABASE() AND table_name = 'spbu_points' AND index_name = 'idx_spbu_status24'
+ ) THEN
+ ALTER TABLE spbu_points ADD INDEX idx_spbu_status24 (status_24jam);
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.statistics
+ WHERE table_schema = DATABASE() AND table_name = 'spbu_points' AND index_name = 'idx_spbu_latlng'
+ ) THEN
+ ALTER TABLE spbu_points ADD INDEX idx_spbu_latlng (latitude, longitude);
+ END IF;
+
+ -- Backfill latitude/longitude dari geom GeoJSON (jika geom berisi JSON valid)
+ -- Catatan: ini hanya mengisi yang masih NULL supaya tidak menimpa data baru.
+ IF EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'spbu_points' AND column_name = 'geom'
+ ) THEN
+ -- Optional: JSON_VALID/JSON_EXTRACT bisa tidak tersedia pada versi tertentu.
+ -- Best-effort: jika fungsi tidak didukung, blok ini di-skip tanpa menghentikan migrasi.
+ BEGIN
+ DECLARE CONTINUE HANDLER FOR SQLEXCEPTION BEGIN END;
+ UPDATE spbu_points
+ SET
+ longitude = CAST(JSON_UNQUOTE(JSON_EXTRACT(geom, '$.coordinates[0]')) AS DECIMAL(10,7)),
+ latitude = CAST(JSON_UNQUOTE(JSON_EXTRACT(geom, '$.coordinates[1]')) AS DECIMAL(10,7))
+ WHERE (latitude IS NULL OR longitude IS NULL)
+ AND geom IS NOT NULL
+ AND JSON_VALID(geom) = 1
+ AND JSON_UNQUOTE(JSON_EXTRACT(geom, '$.type')) = 'Point';
+ END;
+ END IF;
+ END IF;
+
+ -- ----------------------
+ -- jalan_lines
+ -- ----------------------
+ IF EXISTS (
+ SELECT 1 FROM information_schema.tables
+ WHERE table_schema = DATABASE() AND table_name = 'jalan_lines'
+ ) THEN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'jalan_lines' AND column_name = 'panjang_meter'
+ ) THEN
+ ALTER TABLE jalan_lines ADD COLUMN panjang_meter DOUBLE NOT NULL DEFAULT 0 AFTER status_jalan;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'jalan_lines' AND column_name = 'geojson'
+ ) THEN
+ ALTER TABLE jalan_lines ADD COLUMN geojson LONGTEXT NULL AFTER panjang_meter;
+ END IF;
+
+ -- Backfill geojson dari kolom spatial geom (jika ada)
+ IF EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'jalan_lines' AND column_name = 'geom'
+ ) THEN
+ BEGIN
+ DECLARE CONTINUE HANDLER FOR SQLEXCEPTION BEGIN END;
+ UPDATE jalan_lines
+ SET geojson = ST_AsGeoJSON(geom)
+ WHERE (geojson IS NULL OR geojson = '') AND geom IS NOT NULL;
+ END;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.statistics
+ WHERE table_schema = DATABASE() AND table_name = 'jalan_lines' AND index_name = 'idx_jalan_status'
+ ) THEN
+ ALTER TABLE jalan_lines ADD INDEX idx_jalan_status (status_jalan);
+ END IF;
+ END IF;
+
+ -- ----------------------
+ -- parsil_tanah
+ -- ----------------------
+ IF EXISTS (
+ SELECT 1 FROM information_schema.tables
+ WHERE table_schema = DATABASE() AND table_name = 'parsil_tanah'
+ ) THEN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'parsil_tanah' AND column_name = 'luas_m2'
+ ) THEN
+ ALTER TABLE parsil_tanah ADD COLUMN luas_m2 DOUBLE NOT NULL DEFAULT 0 AFTER status_kepemilikan;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'parsil_tanah' AND column_name = 'geojson'
+ ) THEN
+ ALTER TABLE parsil_tanah ADD COLUMN geojson LONGTEXT NULL AFTER luas_m2;
+ END IF;
+
+ -- Backfill geojson dari kolom spatial geom (jika ada)
+ IF EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'parsil_tanah' AND column_name = 'geom'
+ ) THEN
+ BEGIN
+ DECLARE CONTINUE HANDLER FOR SQLEXCEPTION BEGIN END;
+ UPDATE parsil_tanah
+ SET geojson = ST_AsGeoJSON(geom)
+ WHERE (geojson IS NULL OR geojson = '') AND geom IS NOT NULL;
+ END;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.statistics
+ WHERE table_schema = DATABASE() AND table_name = 'parsil_tanah' AND index_name = 'idx_parsil_status'
+ ) THEN
+ ALTER TABLE parsil_tanah ADD INDEX idx_parsil_status (status_kepemilikan);
+ END IF;
+ END IF;
+
+ -- ----------------------
+ -- rumah_ibadah_points
+ -- ----------------------
+ IF EXISTS (
+ SELECT 1 FROM information_schema.tables
+ WHERE table_schema = DATABASE() AND table_name = 'rumah_ibadah_points'
+ ) THEN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'rumah_ibadah_points' AND column_name = 'jenis'
+ ) THEN
+ ALTER TABLE rumah_ibadah_points ADD COLUMN jenis VARCHAR(30) NOT NULL DEFAULT 'Lainnya' AFTER nama;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'rumah_ibadah_points' AND column_name = 'kontak'
+ ) THEN
+ ALTER TABLE rumah_ibadah_points ADD COLUMN kontak VARCHAR(120) NULL AFTER jenis;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'rumah_ibadah_points' AND column_name = 'radius_meter'
+ ) THEN
+ ALTER TABLE rumah_ibadah_points ADD COLUMN radius_meter DOUBLE NOT NULL DEFAULT 300 AFTER kontak;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'rumah_ibadah_points' AND column_name = 'address'
+ ) THEN
+ ALTER TABLE rumah_ibadah_points ADD COLUMN address TEXT NULL AFTER longitude;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.statistics
+ WHERE table_schema = DATABASE() AND table_name = 'rumah_ibadah_points' AND index_name = 'idx_ibadah_latlng'
+ ) THEN
+ ALTER TABLE rumah_ibadah_points ADD INDEX idx_ibadah_latlng (latitude, longitude);
+ END IF;
+ END IF;
+
+ -- ----------------------
+ -- pemukiman_miskin_points
+ -- ----------------------
+ IF EXISTS (
+ SELECT 1 FROM information_schema.tables
+ WHERE table_schema = DATABASE() AND table_name = 'pemukiman_miskin_points'
+ ) THEN
+ -- Backward compat: kalau masih pakai kolom `nama`, migrasikan ke `kk_nama`
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'pemukiman_miskin_points' AND column_name = 'kk_nama'
+ ) THEN
+ ALTER TABLE pemukiman_miskin_points ADD COLUMN kk_nama VARCHAR(200) NULL AFTER id;
+ BEGIN
+ DECLARE CONTINUE HANDLER FOR SQLEXCEPTION BEGIN END;
+ UPDATE pemukiman_miskin_points
+ SET kk_nama = COALESCE(NULLIF(kk_nama, ''), NULLIF(nama, ''))
+ WHERE (kk_nama IS NULL OR kk_nama = '');
+ END;
+ -- jadikan NOT NULL kalau sudah terisi
+ BEGIN
+ DECLARE CONTINUE HANDLER FOR SQLEXCEPTION BEGIN END;
+ ALTER TABLE pemukiman_miskin_points MODIFY kk_nama VARCHAR(200) NOT NULL;
+ END;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'pemukiman_miskin_points' AND column_name = 'nik'
+ ) THEN
+ ALTER TABLE pemukiman_miskin_points ADD COLUMN nik VARCHAR(32) NULL AFTER kk_nama;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'pemukiman_miskin_points' AND column_name = 'jumlah_anggota'
+ ) THEN
+ ALTER TABLE pemukiman_miskin_points ADD COLUMN jumlah_anggota INT NOT NULL DEFAULT 0 AFTER nik;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'pemukiman_miskin_points' AND column_name = 'kelurahan'
+ ) THEN
+ ALTER TABLE pemukiman_miskin_points ADD COLUMN kelurahan VARCHAR(120) NULL AFTER address;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'pemukiman_miskin_points' AND column_name = 'kecamatan'
+ ) THEN
+ ALTER TABLE pemukiman_miskin_points ADD COLUMN kecamatan VARCHAR(120) NULL AFTER kelurahan;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'pemukiman_miskin_points' AND column_name = 'status_bantuan'
+ ) THEN
+ ALTER TABLE pemukiman_miskin_points ADD COLUMN status_bantuan VARCHAR(24) NOT NULL DEFAULT 'Belum dibantu' AFTER kecamatan;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'pemukiman_miskin_points' AND column_name = 'jenis_bantuan'
+ ) THEN
+ ALTER TABLE pemukiman_miskin_points ADD COLUMN jenis_bantuan VARCHAR(120) NULL AFTER status_bantuan;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'pemukiman_miskin_points' AND column_name = 'tanggal_bantuan'
+ ) THEN
+ ALTER TABLE pemukiman_miskin_points ADD COLUMN tanggal_bantuan DATE NULL AFTER jenis_bantuan;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'pemukiman_miskin_points' AND column_name = 'bukti_file'
+ ) THEN
+ ALTER TABLE pemukiman_miskin_points ADD COLUMN bukti_file VARCHAR(255) NULL AFTER tanggal_bantuan;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'pemukiman_miskin_points' AND column_name = 'jarak_meter'
+ ) THEN
+ ALTER TABLE pemukiman_miskin_points ADD COLUMN jarak_meter DOUBLE NULL AFTER bukti_file;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'pemukiman_miskin_points' AND column_name = 'rumah_ibadah_id'
+ ) THEN
+ ALTER TABLE pemukiman_miskin_points ADD COLUMN rumah_ibadah_id INT NULL AFTER jarak_meter;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.statistics
+ WHERE table_schema = DATABASE() AND table_name = 'pemukiman_miskin_points' AND index_name = 'idx_miskin_ibadah'
+ ) THEN
+ ALTER TABLE pemukiman_miskin_points ADD INDEX idx_miskin_ibadah (rumah_ibadah_id);
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.statistics
+ WHERE table_schema = DATABASE() AND table_name = 'pemukiman_miskin_points' AND index_name = 'idx_miskin_latlng'
+ ) THEN
+ ALTER TABLE pemukiman_miskin_points ADD INDEX idx_miskin_latlng (latitude, longitude);
+ END IF;
+ END IF;
+END $$
+
+-- Jalankan migrasi (aman dijalankan berulang)
+CALL migrate_webgis_spbu() $$
+DROP PROCEDURE IF EXISTS migrate_webgis_spbu $$
+
+DELIMITER ;
diff --git a/webgis-spbu/uploads/bukti/.gitkeep b/webgis-spbu/uploads/bukti/.gitkeep
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/webgis-spbu/uploads/bukti/.gitkeep
@@ -0,0 +1 @@
+