UAS SIG Proyek WebGis

This commit is contained in:
L4zyL4mb
2026-06-10 21:46:56 +07:00
commit df18a7a99a
35 changed files with 8977 additions and 0 deletions
+141
View File
@@ -0,0 +1,141 @@
<?php
// ============================================
// api/jalan.php - CRUD API Data Jalan
// ============================================
require_once '../config.php';
$method = $_SERVER['REQUEST_METHOD'];
$db = getDB();
switch ($method) {
// ── READ: Ambil semua / satu jalan ──────────────────────────────
case 'GET':
if (isset($_GET['id'])) {
$id = intval($_GET['id']);
$stmt = $db->prepare("SELECT * FROM jalan WHERE id = ?");
$stmt->bind_param('i', $id);
$stmt->execute();
$result = $stmt->get_result();
$row = $result->fetch_assoc();
if ($row) {
$row['geometry'] = json_decode($row['geometry']);
echo json_encode(['success' => true, 'data' => $row]);
} else {
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'Data tidak ditemukan']);
}
} else {
$result = $db->query("SELECT * FROM jalan ORDER BY created_at DESC");
$data = [];
while ($row = $result->fetch_assoc()) {
$row['geometry'] = json_decode($row['geometry']);
$data[] = $row;
}
echo json_encode(['success' => true, 'data' => $data]);
}
break;
// ── CREATE: Tambah jalan baru ───────────────────────────────────
case 'POST':
$input = json_decode(file_get_contents('php://input'), true);
if (
empty($input['nama_jalan']) ||
empty($input['status_jalan']) ||
empty($input['geometry']) ||
!isset($input['panjang'])
) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Data tidak lengkap']);
break;
}
$nama_jalan = trim($input['nama_jalan']);
$status_jalan = $input['status_jalan'];
$panjang = floatval($input['panjang']);
$geometry = json_encode($input['geometry']);
$valid_status = ['Nasional', 'Provinsi', 'Kabupaten'];
if (!in_array($status_jalan, $valid_status)) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Status jalan tidak valid']);
break;
}
$stmt = $db->prepare(
"INSERT INTO jalan (nama_jalan, status_jalan, panjang, geometry) VALUES (?, ?, ?, ?)"
);
$stmt->bind_param('ssds', $nama_jalan, $status_jalan, $panjang, $geometry);
if ($stmt->execute()) {
echo json_encode([
'success' => true,
'message' => 'Data jalan berhasil disimpan',
'id' => $db->insert_id
]);
} else {
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Gagal menyimpan data']);
}
break;
// ── UPDATE: Edit jalan ──────────────────────────────────────────
case 'PUT':
$input = json_decode(file_get_contents('php://input'), true);
if (empty($input['id'])) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'ID diperlukan']);
break;
}
$id = intval($input['id']);
$nama_jalan = trim($input['nama_jalan']);
$status_jalan = $input['status_jalan'];
$panjang = floatval($input['panjang']);
$geometry = json_encode($input['geometry']);
$stmt = $db->prepare(
"UPDATE jalan SET nama_jalan=?, status_jalan=?, panjang=?, geometry=? WHERE id=?"
);
$stmt->bind_param('ssdsi', $nama_jalan, $status_jalan, $panjang, $geometry, $id);
if ($stmt->execute()) {
echo json_encode(['success' => true, 'message' => 'Data jalan berhasil diupdate']);
} else {
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Gagal mengupdate data']);
}
break;
// ── DELETE: Hapus jalan ─────────────────────────────────────────
case 'DELETE':
$input = json_decode(file_get_contents('php://input'), true);
$id = intval($input['id'] ?? ($_GET['id'] ?? 0));
if (!$id) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'ID diperlukan']);
break;
}
$stmt = $db->prepare("DELETE FROM jalan WHERE id = ?");
$stmt->bind_param('i', $id);
if ($stmt->execute() && $stmt->affected_rows > 0) {
echo json_encode(['success' => true, 'message' => 'Data jalan berhasil dihapus']);
} else {
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'Data tidak ditemukan']);
}
break;
default:
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'Method tidak diizinkan']);
}
$db->close();
?>
+194
View File
@@ -0,0 +1,194 @@
<?php
// ============================================
// api/kerusakan.php - CRUD API Kerusakan Jalan
// ============================================
require_once '../config.php';
$method = $_SERVER['REQUEST_METHOD'];
$db = getDB();
// Handle sub-resource: ?action=histori&id=X
$action = $_GET['action'] ?? '';
if ($action === 'histori') {
// GET histori untuk kerusakan tertentu
$id = intval($_GET['id'] ?? 0);
if (!$id) { http_response_code(400); echo json_encode(['success'=>false,'message'=>'ID diperlukan']); exit; }
$stmt = $db->prepare("SELECT * FROM histori_kerusakan WHERE kerusakan_id=? ORDER BY tgl_perubahan DESC");
$stmt->bind_param('i', $id);
$stmt->execute();
$result = $stmt->get_result();
$data = [];
while ($row = $result->fetch_assoc()) $data[] = $row;
echo json_encode(['success'=>true, 'data'=>$data]);
$db->close(); exit;
}
if ($action === 'update_status') {
// POST update status + catat histori
$input = json_decode(file_get_contents('php://input'), true);
$id = intval($input['id'] ?? 0);
$status_baru = $input['status_baru'] ?? '';
$catatan = $input['catatan'] ?? '';
if (!$id || !$status_baru) { http_response_code(400); echo json_encode(['success'=>false,'message'=>'ID dan status_baru diperlukan']); exit; }
// Ambil status lama
$stmt = $db->prepare("SELECT status_laporan FROM kerusakan_jalan WHERE id=?");
$stmt->bind_param('i', $id);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
if (!$row) { http_response_code(404); echo json_encode(['success'=>false,'message'=>'Data tidak ditemukan']); exit; }
$status_lama = $row['status_laporan'];
// Update status
$stmt2 = $db->prepare("UPDATE kerusakan_jalan SET status_laporan=? WHERE id=?");
$stmt2->bind_param('si', $status_baru, $id);
$stmt2->execute();
// Catat histori
$stmt3 = $db->prepare("INSERT INTO histori_kerusakan (kerusakan_id, status_lama, status_baru, catatan) VALUES (?,?,?,?)");
$stmt3->bind_param('isss', $id, $status_lama, $status_baru, $catatan);
$stmt3->execute();
echo json_encode(['success'=>true, 'message'=>'Status kerusakan diupdate']);
$db->close(); exit;
}
switch ($method) {
// ── READ ────────────────────────────────────────────────────────
case 'GET':
if (isset($_GET['id'])) {
$id = intval($_GET['id']);
$stmt = $db->prepare("SELECT * FROM kerusakan_jalan WHERE id=?");
$stmt->bind_param('i', $id);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
if ($row) {
echo json_encode(['success'=>true, 'data'=>$row]);
} else {
http_response_code(404);
echo json_encode(['success'=>false, 'message'=>'Data tidak ditemukan']);
}
} else {
// Filter opsional: ?jalan_id=X atau ?status=X
$where = [];
$params = '';
$vals = [];
if (!empty($_GET['jalan_id'])) {
$where[] = 'jalan_id=?'; $params .= 'i'; $vals[] = intval($_GET['jalan_id']);
}
if (!empty($_GET['status'])) {
$where[] = 'status_laporan=?'; $params .= 's'; $vals[] = $_GET['status'];
}
$sql = "SELECT k.*, j.nama_jalan as jalan_nama FROM kerusakan_jalan k LEFT JOIN jalan j ON k.jalan_id=j.id";
if ($where) $sql .= ' WHERE '.implode(' AND ', $where);
$sql .= ' ORDER BY k.tgl_laporan DESC';
$stmt = $db->prepare($sql);
if ($params) $stmt->bind_param($params, ...$vals);
$stmt->execute();
$result = $stmt->get_result();
$data = [];
while ($row = $result->fetch_assoc()) $data[] = $row;
// Hitung kluster per lokasi (radius ~50m)
foreach ($data as &$item) {
$cnt = 0;
foreach ($data as $other) {
$dlat = ($item['lat'] - $other['lat']) * 111000;
$dlng = ($item['lng'] - $other['lng']) * 111000 * cos(deg2rad($item['lat']));
if (sqrt($dlat*$dlat + $dlng*$dlng) < 50) $cnt++;
}
$item['laporan_terdekat'] = $cnt;
}
echo json_encode(['success'=>true, 'data'=>$data]);
}
break;
// ── CREATE ──────────────────────────────────────────────────────
case 'POST':
$input = json_decode(file_get_contents('php://input'), true);
$required = ['deskripsi','tingkat_urgensi','lat','lng'];
foreach ($required as $f) {
if (empty($input[$f]) && $input[$f] !== 0) {
http_response_code(400);
echo json_encode(['success'=>false,'message'=>"Field $f diperlukan"]);
exit;
}
}
$jalan_id = !empty($input['jalan_id']) ? intval($input['jalan_id']) : null;
$nama_jalan_ref = trim($input['nama_jalan_ref'] ?? '');
$deskripsi = trim($input['deskripsi']);
$tingkat_urgensi = $input['tingkat_urgensi'];
$lat = floatval($input['lat']);
$lng = floatval($input['lng']);
$foto_base64 = $input['foto_base64'] ?? null;
$valid_urgensi = ['Rendah','Sedang','Tinggi','Kritis'];
if (!in_array($tingkat_urgensi, $valid_urgensi)) {
http_response_code(400);
echo json_encode(['success'=>false,'message'=>'Tingkat urgensi tidak valid']);
break;
}
$stmt = $db->prepare(
"INSERT INTO kerusakan_jalan (jalan_id, nama_jalan_ref, deskripsi, tingkat_urgensi, lat, lng, foto_base64) VALUES (?,?,?,?,?,?,?)"
);
$stmt->bind_param('isssdds', $jalan_id, $nama_jalan_ref, $deskripsi, $tingkat_urgensi, $lat, $lng, $foto_base64);
if ($stmt->execute()) {
echo json_encode(['success'=>true,'message'=>'Laporan kerusakan berhasil disimpan','id'=>$db->insert_id]);
} else {
http_response_code(500);
echo json_encode(['success'=>false,'message'=>'Gagal menyimpan data']);
}
break;
// ── UPDATE ──────────────────────────────────────────────────────
case 'PUT':
$input = json_decode(file_get_contents('php://input'), true);
$id = intval($input['id'] ?? 0);
if (!$id) { http_response_code(400); echo json_encode(['success'=>false,'message'=>'ID diperlukan']); break; }
$deskripsi = trim($input['deskripsi'] ?? '');
$tingkat_urgensi = $input['tingkat_urgensi'] ?? '';
$nama_jalan_ref = trim($input['nama_jalan_ref'] ?? '');
$stmt = $db->prepare("UPDATE kerusakan_jalan SET deskripsi=?, tingkat_urgensi=?, nama_jalan_ref=? WHERE id=?");
$stmt->bind_param('sssi', $deskripsi, $tingkat_urgensi, $nama_jalan_ref, $id);
if ($stmt->execute()) {
echo json_encode(['success'=>true,'message'=>'Data kerusakan diupdate']);
} else {
http_response_code(500);
echo json_encode(['success'=>false,'message'=>'Gagal mengupdate data']);
}
break;
// ── DELETE ──────────────────────────────────────────────────────
case 'DELETE':
$input = json_decode(file_get_contents('php://input'), true);
$id = intval($input['id'] ?? ($_GET['id'] ?? 0));
if (!$id) { http_response_code(400); echo json_encode(['success'=>false,'message'=>'ID diperlukan']); break; }
$stmt = $db->prepare("DELETE FROM kerusakan_jalan WHERE id=?");
$stmt->bind_param('i', $id);
if ($stmt->execute() && $stmt->affected_rows > 0) {
echo json_encode(['success'=>true,'message'=>'Laporan kerusakan dihapus']);
} else {
http_response_code(404);
echo json_encode(['success'=>false,'message'=>'Data tidak ditemukan']);
}
break;
default:
http_response_code(405);
echo json_encode(['success'=>false,'message'=>'Method tidak diizinkan']);
}
$db->close();
?>
+144
View File
@@ -0,0 +1,144 @@
<?php
// ============================================
// api/tanah.php - CRUD API Data Parsil Tanah
// ============================================
require_once '../config.php';
$method = $_SERVER['REQUEST_METHOD'];
$db = getDB();
switch ($method) {
// ── READ ────────────────────────────────────────────────────────
case 'GET':
if (isset($_GET['id'])) {
$id = intval($_GET['id']);
$stmt = $db->prepare("SELECT * FROM tanah WHERE id = ?");
$stmt->bind_param('i', $id);
$stmt->execute();
$result = $stmt->get_result();
$row = $result->fetch_assoc();
if ($row) {
$row['geometry'] = json_decode($row['geometry']);
echo json_encode(['success' => true, 'data' => $row]);
} else {
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'Data tidak ditemukan']);
}
} else {
$result = $db->query("SELECT * FROM tanah ORDER BY created_at DESC");
$data = [];
while ($row = $result->fetch_assoc()) {
$row['geometry'] = json_decode($row['geometry']);
$data[] = $row;
}
echo json_encode(['success' => true, 'data' => $data]);
}
break;
// ── CREATE ──────────────────────────────────────────────────────
case 'POST':
$input = json_decode(file_get_contents('php://input'), true);
if (
empty($input['kode_kavling']) ||
empty($input['nama_pemilik']) ||
empty($input['status_tanah']) ||
empty($input['geometry']) ||
!isset($input['luas'])
) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Data tidak lengkap']);
break;
}
$kode_kavling = trim($input['kode_kavling']);
$nama_pemilik = trim($input['nama_pemilik']);
$status_tanah = $input['status_tanah'];
$luas = floatval($input['luas']);
$geometry = json_encode($input['geometry']);
$valid_status = ['SHM', 'HGB', 'HGU', 'HP'];
if (!in_array($status_tanah, $valid_status)) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Status tanah tidak valid']);
break;
}
$stmt = $db->prepare(
"INSERT INTO tanah (kode_kavling, nama_pemilik, status_tanah, luas, geometry) VALUES (?, ?, ?, ?, ?)"
);
$stmt->bind_param('sssds', $kode_kavling, $nama_pemilik, $status_tanah, $luas, $geometry);
if ($stmt->execute()) {
echo json_encode([
'success' => true,
'message' => 'Data tanah berhasil disimpan',
'id' => $db->insert_id
]);
} else {
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Gagal menyimpan data']);
}
break;
// ── UPDATE ──────────────────────────────────────────────────────
case 'PUT':
$input = json_decode(file_get_contents('php://input'), true);
if (empty($input['id'])) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'ID diperlukan']);
break;
}
$id = intval($input['id']);
$kode_kavling = trim($input['kode_kavling']);
$nama_pemilik = trim($input['nama_pemilik']);
$status_tanah = $input['status_tanah'];
$luas = floatval($input['luas']);
$geometry = json_encode($input['geometry']);
$stmt = $db->prepare(
"UPDATE tanah SET kode_kavling=?, nama_pemilik=?, status_tanah=?, luas=?, geometry=? WHERE id=?"
);
$stmt->bind_param('sssdsi', $kode_kavling, $nama_pemilik, $status_tanah, $luas, $geometry, $id);
if ($stmt->execute()) {
echo json_encode(['success' => true, 'message' => 'Data tanah berhasil diupdate']);
} else {
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Gagal mengupdate data']);
}
break;
// ── DELETE ──────────────────────────────────────────────────────
case 'DELETE':
$input = json_decode(file_get_contents('php://input'), true);
$id = intval($input['id'] ?? ($_GET['id'] ?? 0));
if (!$id) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'ID diperlukan']);
break;
}
$stmt = $db->prepare("DELETE FROM tanah WHERE id = ?");
$stmt->bind_param('i', $id);
if ($stmt->execute() && $stmt->affected_rows > 0) {
echo json_encode(['success' => true, 'message' => 'Data tanah berhasil dihapus']);
} else {
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'Data tidak ditemukan']);
}
break;
default:
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'Method tidak diizinkan']);
}
$db->close();
?>
+31
View File
@@ -0,0 +1,31 @@
<?php
// ============================================
// config.php - Konfigurasi Database
// ============================================
define('DB_HOST', 'localhost');
define('DB_USER', 'root'); // default Laragon
define('DB_PASS', ''); // default Laragon kosong
define('DB_NAME', 'sig_tugas');
function getDB() {
$conn = new mysqli(DB_HOST, DB_USER, DB_PASS, DB_NAME);
if ($conn->connect_error) {
http_response_code(500);
die(json_encode(['error' => 'Koneksi database gagal: ' . $conn->connect_error]));
}
$conn->set_charset('utf8mb4');
return $conn;
}
// Header CORS & JSON untuk semua API
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit();
}
?>
+80
View File
@@ -0,0 +1,80 @@
-- ============================================
-- DATABASE SETUP - SIG Tugas Kuliah
-- Manajemen Jalan & Parsil Tanah - Pontianak
-- ============================================
CREATE DATABASE IF NOT EXISTS sig_tugas CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE sig_tugas;
-- Tabel Data Jalan (Polyline)
CREATE TABLE IF NOT EXISTS jalan (
id INT AUTO_INCREMENT PRIMARY KEY,
nama_jalan VARCHAR(100) NOT NULL,
status_jalan ENUM('Nasional','Provinsi','Kabupaten') NOT NULL,
panjang DOUBLE NOT NULL DEFAULT 0 COMMENT 'Panjang dalam meter, dihitung otomatis Leaflet',
geometry LONGTEXT NOT NULL COMMENT 'GeoJSON LineString',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- Tabel Data Parsil Tanah (Polygon)
CREATE TABLE IF NOT EXISTS tanah (
id INT AUTO_INCREMENT PRIMARY KEY,
kode_kavling VARCHAR(50) NOT NULL,
nama_pemilik VARCHAR(100) NOT NULL,
status_tanah ENUM('SHM','HGB','HGU','HP') NOT NULL,
luas DOUBLE NOT NULL DEFAULT 0 COMMENT 'Luas dalam meter persegi, dihitung otomatis Leaflet',
geometry LONGTEXT NOT NULL COMMENT 'GeoJSON Polygon',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- ============================================
-- Sample Data - Jalan di Pontianak
-- ============================================
INSERT INTO jalan (nama_jalan, status_jalan, panjang, geometry) VALUES
(
'Jl. Ahmad Yani',
'Nasional',
3250.5,
'{"type":"LineString","coordinates":[[109.3425,-0.0222],[109.3480,-0.0198],[109.3540,-0.0175],[109.3600,-0.0155]]}'
),
(
'Jl. Gajah Mada',
'Provinsi',
1820.3,
'{"type":"LineString","coordinates":[[109.3310,-0.0290],[109.3355,-0.0265],[109.3400,-0.0242]]}'
),
(
'Jl. Tanjungpura',
'Kabupaten',
1100.7,
'{"type":"LineString","coordinates":[[109.3250,-0.0350],[109.3285,-0.0330],[109.3320,-0.0310]]}'
);
-- ============================================
-- Sample Data - Parsil Tanah di Pontianak
-- ============================================
INSERT INTO tanah (kode_kavling, nama_pemilik, status_tanah, luas, geometry) VALUES
(
'KAV-001',
'Budi Santoso',
'SHM',
1250.75,
'{"type":"Polygon","coordinates":[[[109.3350,-0.0400],[109.3370,-0.0400],[109.3370,-0.0420],[109.3350,-0.0420],[109.3350,-0.0400]]]}'
),
(
'KAV-002',
'PT. Maju Bersama',
'HGB',
3400.00,
'{"type":"Polygon","coordinates":[[[109.3280,-0.0450],[109.3310,-0.0450],[109.3310,-0.0480],[109.3280,-0.0480],[109.3280,-0.0450]]]}'
),
(
'KAV-003',
'CV. Agro Kalimantan',
'HGU',
5800.50,
'{"type":"Polygon","coordinates":[[[109.3150,-0.0500],[109.3200,-0.0500],[109.3200,-0.0540],[109.3150,-0.0540],[109.3150,-0.0500]]]}'
);
+45
View File
@@ -0,0 +1,45 @@
-- ============================================
-- DATABASE UPDATE - Tambah Tabel Kerusakan Jalan
-- Jalankan query ini setelah database.sql
-- ============================================
-- Tabel Laporan Kerusakan Jalan (Point)
CREATE TABLE IF NOT EXISTS kerusakan_jalan (
id INT AUTO_INCREMENT PRIMARY KEY,
jalan_id INT NULL COMMENT 'Referensi ke tabel jalan (opsional)',
nama_jalan_ref VARCHAR(100) NULL COMMENT 'Nama jalan jika tidak ada di db',
deskripsi TEXT NOT NULL,
tingkat_urgensi ENUM('Rendah','Sedang','Tinggi','Kritis') NOT NULL DEFAULT 'Sedang',
status_laporan ENUM('Dilaporkan','Diverifikasi','Dalam Perbaikan','Sudah Diperbaiki') NOT NULL DEFAULT 'Dilaporkan',
foto_path VARCHAR(255) NULL COMMENT 'Path relatif foto',
foto_base64 LONGTEXT NULL COMMENT 'Base64 foto (opsional, jika tidak pakai file system)',
lat DOUBLE NOT NULL,
lng DOUBLE NOT NULL,
tgl_laporan TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
tgl_update TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- Tabel Histori Kerusakan Jalan
-- Dipakai untuk merekam perubahan status kerusakan
CREATE TABLE IF NOT EXISTS histori_kerusakan (
id INT AUTO_INCREMENT PRIMARY KEY,
kerusakan_id INT NOT NULL,
status_lama ENUM('Dilaporkan','Diverifikasi','Dalam Perbaikan','Sudah Diperbaiki') NOT NULL,
status_baru ENUM('Dilaporkan','Diverifikasi','Dalam Perbaikan','Sudah Diperbaiki') NOT NULL,
catatan TEXT NULL,
tgl_perubahan TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (kerusakan_id) REFERENCES kerusakan_jalan(id) ON DELETE CASCADE
);
-- Sample Data Kerusakan
INSERT INTO kerusakan_jalan (jalan_id, nama_jalan_ref, deskripsi, tingkat_urgensi, status_laporan, lat, lng) VALUES
(1, 'Jl. Ahmad Yani', 'Lubang besar di tengah jalan, diameter sekitar 50cm', 'Tinggi', 'Diverifikasi', -0.0210, 109.3460),
(1, 'Jl. Ahmad Yani', 'Aspal terkelupas dan berlubang', 'Sedang', 'Dilaporkan', -0.0215, 109.3465),
(1, 'Jl. Ahmad Yani', 'Lubang di pinggir jalan dekat got', 'Sedang', 'Dilaporkan', -0.0212, 109.3462),
(2, 'Jl. Gajah Mada', 'Retak memanjang sepanjang 3 meter', 'Rendah', 'Sudah Diperbaiki', -0.0275, 109.3340);
-- Sample Histori
INSERT INTO histori_kerusakan (kerusakan_id, status_lama, status_baru, catatan) VALUES
(1, 'Dilaporkan', 'Diverifikasi', 'Tim survei sudah turun ke lapangan, kerusakan terkonfirmasi'),
(4, 'Dilaporkan', 'Dalam Perbaikan', 'Jadwal perbaikan: minggu ini'),
(4, 'Dalam Perbaikan', 'Sudah Diperbaiki', 'Perbaikan selesai tanggal 10 April 2026');
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
-- =====================================================
-- WebGIS SPBU Pontianak — Database Schema
-- Jalankan di phpMyAdmin atau MySQL CLI
-- =====================================================
CREATE DATABASE IF NOT EXISTS webgis_spbu
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE webgis_spbu;
CREATE TABLE IF NOT EXISTS spbu (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(150) NOT NULL COMMENT 'Nama SPBU',
no VARCHAR(50) NOT NULL COMMENT 'Nomor SPBU (mis: 64.751.01)',
status TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1=Buka 24 Jam, 0=Terbatas',
latitude DECIMAL(10,8) NOT NULL COMMENT 'Koordinat Latitude',
longitude DECIMAL(11,8) NOT NULL COMMENT 'Koordinat Longitude',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ── Contoh Data SPBU Pontianak ──────────────────────
INSERT INTO spbu (nama, no, status, latitude, longitude) VALUES
('SPBU Jl. Ahmad Yani', '64.751.01', 1, -0.020500, 109.341200),
('SPBU Jl. Gajahmada', '64.751.02', 0, -0.031800, 109.325600),
('SPBU Jl. Tanjungpura', '64.751.03', 1, -0.025300, 109.332400),
('SPBU Jl. Sutan Syahrir', '64.751.04', 0, -0.018200, 109.348700),
('SPBU Jl. Adisucipto', '64.751.05', 1, -0.040100, 109.358900);
+14
View File
@@ -0,0 +1,14 @@
<?php
// db_connect.php - Koneksi Database
$host = "localhost";
$dbname = "webgis_spbu";
$username = "root";
$password = "";
try {
$pdo = new PDO("mysql:host=$host;dbname=$dbname;charset=utf8", $username, $password);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} catch (PDOException $e) {
die(json_encode(["error" => "Koneksi gagal: " . $e->getMessage()]));
}
?>
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0; url=index.php">
<title>WebGIS SPBU Pontianak</title>
<script>
window.location.href = "index.php";
</script>
</head>
<body>
<p>Mengalihkan ke halaman utama... Jika tidak otomatis, klik <a href="index.php">di sini</a>.</p>
</body>
</html>
+397
View File
@@ -0,0 +1,397 @@
<?php
require_once 'db_connect.php';
// AJAX: update koordinat saat marker di-drag
if (isset($_GET['update_coords']) && $_SERVER['REQUEST_METHOD']==='POST') {
header('Content-Type: application/json');
$id = intval($_POST['id']);
$lat = floatval($_POST['lat']);
$lng = floatval($_POST['lng']);
if($id > 0){
$pdo->prepare("UPDATE spbu SET latitude=:lat,longitude=:lng WHERE id=:id")
->execute([':lat'=>$lat,':lng'=>$lng,':id'=>$id]);
echo json_encode(['ok'=>true,'lat'=>$lat,'lng'=>$lng]);
} else {
echo json_encode(['ok'=>false]);
}
exit;
}
// AJAX: hapus dari index.php
if (isset($_GET['delete']) && $_SERVER['REQUEST_METHOD']==='POST') {
header('Content-Type: application/json');
$id = intval($_POST['id']);
$pdo->prepare("DELETE FROM spbu WHERE id=:id")->execute([':id'=>$id]);
echo json_encode(['ok'=>true]);
exit;
}
$spbu_list = $pdo->query("SELECT * FROM spbu ORDER BY nama")->fetchAll(PDO::FETCH_ASSOC);
$total = count($spbu_list);
$total_24 = count(array_filter($spbu_list, fn($s) => $s['status']==1));
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Peta SPBU — WebGIS Pontianak</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#f5f4f0;--card:#fff;--border:#e8e5df;--border2:#d4d0c8;
--text:#1a1915;--muted:#7c7a72;--hint:#b0ada5;
--accent:#e85a1a;--accent-bg:#fdf0eb;--accent-dk:#c44a12;
--green:#1a7a45;--green-bg:#ebf7f0;
--red:#c0392b;--red-bg:#fdf0ef;
--r:10px;--r-sm:7px;
}
html,body{height:100%;font-family:'Plus Jakarta Sans',sans-serif;color:var(--text);font-size:14px}
body{display:flex;flex-direction:column;background:var(--bg)}
/* NAV */
.topnav{background:var(--card);border-bottom:1px solid var(--border);height:54px;padding:0 24px;display:flex;align-items:center;gap:6px;flex-shrink:0;z-index:9999;position:relative}
.brand{display:flex;align-items:center;gap:9px;font-weight:700;font-size:14px;color:var(--text);text-decoration:none;margin-right:auto}
.brand-mark{width:30px;height:30px;background:var(--accent);border-radius:8px;display:grid;place-items:center;color:#fff;font-size:14px;font-weight:800}
.nav-a{padding:5px 13px;border-radius:var(--r-sm);font-size:13px;font-weight:500;color:var(--muted);text-decoration:none;transition:.12s}
.nav-a:hover{background:var(--bg);color:var(--text)}
.nav-a.on{background:var(--accent-bg);color:var(--accent);font-weight:600}
/* STATS BAR */
.statsbar{background:var(--card);border-bottom:1px solid var(--border);padding:0 24px;height:40px;display:flex;align-items:center;gap:18px;flex-shrink:0}
.stat{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
.stat strong{color:var(--text);font-weight:600}
.stat-dot{width:8px;height:8px;border-radius:50%}
/* MAIN */
.main{display:flex;flex:1;overflow:hidden}
/* SIDEBAR */
.sidebar{width:300px;flex-shrink:0;background:var(--card);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden}
.sb-head{padding:14px 16px 10px;border-bottom:1px solid var(--border);flex-shrink:0}
.sb-head h2{font-size:13px;font-weight:600;margin-bottom:9px;color:var(--text)}
.search{width:100%;padding:7px 11px;border:1px solid var(--border2);border-radius:var(--r-sm);font-family:inherit;font-size:13px;color:var(--text);background:#fff;transition:.12s}
.search:focus{outline:none;border-color:var(--accent)}
.sb-list{flex:1;overflow-y:auto;padding:8px}
.sb-list::-webkit-scrollbar{width:3px}
.sb-list::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
.sb-item{border:1px solid var(--border);border-radius:var(--r-sm);padding:10px 12px;margin-bottom:6px;cursor:pointer;transition:.12s}
.sb-item:hover{border-color:var(--border2);background:#fafaf8}
.sb-item.active{border-color:var(--accent);background:var(--accent-bg)}
.sb-name{font-weight:600;font-size:13px;margin-bottom:2px}
.sb-no{font-size:12px;color:var(--muted)}
.sb-badge{display:inline-flex;align-items:center;gap:3px;margin-top:5px;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:600}
.sb-open{background:var(--green-bg);color:var(--green)}
.sb-closed{background:var(--bg);color:var(--hint);border:1px solid var(--border)}
.empty-sb{text-align:center;padding:32px 16px;color:var(--hint);font-size:13px}
/* MAP */
.mapwrap{flex:1;position:relative;overflow:hidden}
#map{width:100%;height:100%}
/* Rapikan tampilan Leaflet Layers Control */
.leaflet-control-layers{
border:1px solid var(--border) !important;
border-radius:var(--r-sm) !important;
box-shadow:0 2px 12px rgba(0,0,0,.09) !important;
font-family:'Plus Jakarta Sans',sans-serif !important;
font-size:13px !important;
}
.leaflet-control-layers-expanded{padding:10px 14px !important}
.leaflet-control-layers-separator{margin:6px 0 !important;border-color:var(--border) !important}
/* TOAST */
.toast{position:absolute;top:12px;left:50%;transform:translateX(-50%) translateY(-60px);z-index:1001;background:#1a1915;color:#fff;font-size:12px;font-weight:500;padding:8px 16px;border-radius:20px;transition:.25s;pointer-events:none;white-space:nowrap}
.toast.show{transform:translateX(-50%) translateY(0)}
/* ATTR PANEL */
#attr-panel{
position:absolute;bottom:0;left:0;right:0;
background:var(--card);border-top:2px solid var(--accent);
transform:translateY(100%);transition:transform .28s cubic-bezier(.4,0,.2,1);
z-index:1000;
}
#attr-panel.open{transform:translateY(0)}
.ap-head{padding:12px 18px 10px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
.ap-title{font-size:13px;font-weight:700;color:var(--accent)}
.ap-actions{display:flex;gap:7px;align-items:center}
.ap-close{background:none;border:1px solid var(--border2);color:var(--muted);border-radius:5px;padding:3px 9px;font-size:12px;cursor:pointer;font-family:inherit;transition:.12s}
.ap-close:hover{border-color:var(--muted)}
.ap-del{background:none;border:1px solid #f0c4bf;color:var(--red);border-radius:5px;padding:3px 9px;font-size:12px;cursor:pointer;font-family:inherit;font-weight:600;transition:.12s}
.ap-del:hover{background:var(--red-bg)}
.ap-edit{background:none;border:1px solid #f0c5ae;color:var(--accent);border-radius:5px;padding:3px 9px;font-size:12px;cursor:pointer;font-family:inherit;font-weight:600;transition:.12s;text-decoration:none}
.ap-edit:hover{background:var(--accent-bg)}
.ap-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:0;padding:12px 18px 16px}
@media(max-width:600px){.ap-grid{grid-template-columns:repeat(2,1fr)}}
.ap-field{padding:6px 10px}
.ap-label{font-size:10px;font-weight:600;color:var(--hint);text-transform:uppercase;letter-spacing:.5px;margin-bottom:3px}
.ap-val{font-size:14px;font-weight:600;color:var(--text)}
.ap-val.green{color:var(--green)}
.ap-val.red{color:var(--red)}
.ap-val.mono{font-size:12px;font-family:'Courier New',monospace;font-weight:500;color:var(--muted)}
.drag-hint{font-size:11px;color:var(--hint);padding:0 18px 10px;display:flex;align-items:center;gap:4px}
@media(max-width:680px){
.sidebar{width:100%;max-height:200px;border-right:none;border-bottom:1px solid var(--border)}
.main{flex-direction:column}
.statsbar .stat:nth-child(n+3){display:none}
}
</style>
</head>
<body>
<nav class="topnav">
<a href="index.php" class="brand"><div class="brand-mark">S</div>WebGIS SPBU Pontianak</a>
<a href="insert.php" class="nav-a">Input Data</a>
<a href="index.php" class="nav-a on">Lihat Peta</a>
</nav>
<div class="statsbar">
<div class="stat"><div class="stat-dot" style="background:var(--accent)"></div>Total <strong><?= $total ?></strong> SPBU</div>
<div class="stat"><div class="stat-dot" style="background:var(--green)"></div>Buka 24 Jam: <strong><?= $total_24 ?></strong></div>
<div class="stat"><div class="stat-dot" style="background:var(--hint)"></div>Terbatas: <strong><?= $total - $total_24 ?></strong></div>
</div>
<div class="main">
<!-- SIDEBAR -->
<div class="sidebar">
<div class="sb-head">
<h2>Daftar SPBU</h2>
<input class="search" id="search" type="text" placeholder="Cari nama atau nomor...">
</div>
<div class="sb-list" id="sb-list">
<?php if(!$total): ?>
<div class="empty-sb">Belum ada data.<br><a href="insert.php" style="color:var(--accent)">Tambahkan →</a></div>
<?php else: foreach($spbu_list as $s): ?>
<div class="sb-item" data-id="<?= $s['id'] ?>">
<div class="sb-name"><?= htmlspecialchars($s['nama']) ?></div>
<div class="sb-no">No. <?= htmlspecialchars($s['no']) ?></div>
<span class="sb-badge <?= $s['status']?'sb-open':'sb-closed' ?>"><?= $s['status']?'● 24 Jam':'○ Terbatas' ?></span>
</div>
<?php endforeach; endif; ?>
</div>
</div>
<!-- MAP -->
<div class="mapwrap">
<div id="map"></div>
<div class="toast" id="toast"></div>
<div id="attr-panel">
<div class="ap-head">
<span class="ap-title">⛽ Informasi SPBU</span>
<div class="ap-actions">
<a href="#" class="ap-edit" id="ap-edit-btn">Edit</a>
<button class="ap-del" id="ap-del-btn">Hapus</button>
<button class="ap-close" onclick="closePanel()">✕ Tutup</button>
</div>
</div>
<div class="ap-grid">
<div class="ap-field"><div class="ap-label">Nama</div><div class="ap-val" id="ap-nama">—</div></div>
<div class="ap-field"><div class="ap-label">Nomor SPBU</div><div class="ap-val" id="ap-no">—</div></div>
<div class="ap-field"><div class="ap-label">Status</div><div class="ap-val" id="ap-status">—</div></div>
<div class="ap-field"><div class="ap-label">Koordinat</div><div class="ap-val mono" id="ap-coords">—</div></div>
</div>
<div class="drag-hint">⟳ Drag marker di peta untuk memperbarui lokasi secara langsung</div>
</div>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const DATA = <?= json_encode($spbu_list, JSON_UNESCAPED_UNICODE) ?>;
// ── Base Layers ──────────────────────────────────────────────────────────────
const osm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap', maxZoom: 19
});
const sat = L.tileLayer(
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
{ attribution: 'Esri', maxZoom: 19 }
);
// ── Layer Groups: pisah SPBU berdasarkan status ──────────────────────────────
const layer24Jam = L.layerGroup(); // SPBU Buka 24 Jam
const layerTerbatas = L.layerGroup(); // SPBU Jam Terbatas
// ── Inisialisasi Peta ────────────────────────────────────────────────────────
const map = L.map('map', {
center: [-0.0263, 109.3425],
zoom: 13,
layers: [osm, layer24Jam, layerTerbatas]
});
// ── Layers Control ───────────────────────────────────────────────────────────
const baseLayers = {
'🗺️ Peta (OpenStreetMap)' : osm,
'🛰️ Satelit (Esri)' : sat
};
const overlays = {
'🟢 SPBU Buka 24 Jam' : layer24Jam,
'⚫ SPBU Jam Terbatas' : layerTerbatas
};
L.control.layers(baseLayers, overlays, { collapsed: false }).addTo(map);
// ── Icon Helper ──────────────────────────────────────────────────────────────
function mkIcon(color, big) {
const s = big ? 34 : 26;
return L.divIcon({
className: '',
html: `<div style="width:${s}px;height:${s}px;border-radius:50%;background:${color};
border:2.5px solid #fff;box-shadow:0 2px 10px rgba(0,0,0,.22);
display:grid;place-items:center;font-size:${big ? 16 : 12}px;transition:.15s;">⛽</div>`,
iconSize : [s, s],
iconAnchor : [s/2, s/2],
popupAnchor: [0, -s/2 - 2]
});
}
const markers = {};
let activeId = null;
// ── Buat Marker & Masukkan ke Layer Group yang Sesuai ────────────────────────
DATA.forEach(spbu => {
const is24 = spbu.status == 1;
const color = is24 ? '#1a7a45' : '#b0ada5';
const m = L.marker([spbu.latitude, spbu.longitude], {
icon : mkIcon(color, false),
draggable: true
});
m.on('click', () => showAttr(spbu.id));
// ── Drag & Drop: simpan koordinat baru ke DB via AJAX ────────────────────
m.on('dragend', function(e) {
const pos = e.target.getLatLng();
const lat = pos.lat.toFixed(6);
const lng = pos.lng.toFixed(6);
const fd = new FormData();
fd.append('id', spbu.id);
fd.append('lat', lat);
fd.append('lng', lng);
fetch('index.php?update_coords=1', { method: 'POST', body: fd })
.then(r => r.json())
.then(res => {
if (res.ok) {
const d = DATA.find(x => x.id == spbu.id);
if (d) { d.latitude = lat; d.longitude = lng; }
if (activeId == spbu.id) {
document.getElementById('ap-coords').textContent = `${lat}, ${lng}`;
}
showToast('📍 Lokasi berhasil diperbarui!');
}
})
.catch(() => showToast('❌ Gagal memperbarui lokasi'));
});
m.addTo(is24 ? layer24Jam : layerTerbatas);
markers[spbu.id] = { marker: m, data: spbu };
});
// ── Fit Bounds ───────────────────────────────────────────────────────────────
if (DATA.length > 0) {
const pts = DATA.map(s => [s.latitude, s.longitude]);
DATA.length === 1
? map.setView(pts[0], 15)
: map.fitBounds(pts, { padding: [40, 40] });
}
// ── Panel Atribut ─────────────────────────────────────────────────────────────
function showAttr(id) {
const obj = markers[id];
if (!obj) return;
const spbu = DATA.find(x => x.id == id) || obj.data;
if (activeId && activeId !== id && markers[activeId]) {
const prev = DATA.find(x => x.id == activeId) || markers[activeId].data;
markers[activeId].marker.setIcon(mkIcon(prev.status == 1 ? '#1a7a45' : '#b0ada5', false));
}
obj.marker.setIcon(mkIcon('#e85a1a', true));
activeId = id;
document.getElementById('ap-nama').textContent = spbu.nama;
document.getElementById('ap-no').textContent = spbu.no;
const st = document.getElementById('ap-status');
if (spbu.status == 1) {
st.textContent = '● 24 Jam'; st.className = 'ap-val green';
} else {
st.textContent = '○ Terbatas'; st.className = 'ap-val red';
}
document.getElementById('ap-coords').textContent =
`${parseFloat(spbu.latitude).toFixed(6)}, ${parseFloat(spbu.longitude).toFixed(6)}`;
document.getElementById('ap-edit-btn').href = `insert.php?edit=${id}`;
document.getElementById('ap-del-btn').onclick = () => deleteSpbu(id, spbu.nama);
document.getElementById('attr-panel').classList.add('open');
map.setView([spbu.latitude, spbu.longitude], Math.max(map.getZoom(), 15), { animate: true });
document.querySelectorAll('.sb-item').forEach(el => el.classList.remove('active'));
const si = document.querySelector(`.sb-item[data-id="${id}"]`);
if (si) { si.classList.add('active'); si.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }
}
function closePanel() {
document.getElementById('attr-panel').classList.remove('open');
if (activeId && markers[activeId]) {
const prev = DATA.find(x => x.id == activeId) || markers[activeId].data;
markers[activeId].marker.setIcon(mkIcon(prev.status == 1 ? '#1a7a45' : '#b0ada5', false));
}
activeId = null;
document.querySelectorAll('.sb-item').forEach(el => el.classList.remove('active'));
}
// ── Hapus SPBU ────────────────────────────────────────────────────────────────
function deleteSpbu(id, nama) {
if (!confirm(`Hapus SPBU "${nama}"?`)) return;
const fd = new FormData();
fd.append('id', id);
fetch('index.php?delete=1', { method: 'POST', body: fd })
.then(r => r.json())
.then(res => {
if (res.ok) {
if (markers[id]) {
const spbu = DATA.find(x => x.id == id) || markers[id].data;
(spbu.status == 1 ? layer24Jam : layerTerbatas).removeLayer(markers[id].marker);
delete markers[id];
}
const si = document.querySelector(`.sb-item[data-id="${id}"]`);
if (si) si.remove();
closePanel();
showToast('✕ SPBU berhasil dihapus');
}
});
}
document.querySelectorAll('.sb-item').forEach(el => {
el.addEventListener('click', () => showAttr(parseInt(el.dataset.id)));
});
document.getElementById('search').addEventListener('input', function() {
const q = this.value.toLowerCase();
document.querySelectorAll('.sb-item').forEach(el => {
const n = el.querySelector('.sb-name').textContent.toLowerCase();
const no = el.querySelector('.sb-no').textContent.toLowerCase();
el.style.display = (n.includes(q) || no.includes(q)) ? '' : 'none';
});
});
function showToast(msg) {
const t = document.getElementById('toast');
t.textContent = msg;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 2500);
}
</script>
</body>
</html>
+293
View File
@@ -0,0 +1,293 @@
<?php
require_once 'db_connect.php';
$message = "";
$edit_data = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
if ($_POST['action'] === 'insert') {
$stmt = $pdo->prepare("INSERT INTO spbu (nama, no, status, latitude, longitude) VALUES (:nama,:no,:status,:lat,:lng)");
$stmt->execute([':nama'=>trim($_POST['nama']),':no'=>trim($_POST['no']),':status'=>isset($_POST['status'])?1:0,':lat'=>floatval($_POST['latitude']),':lng'=>floatval($_POST['longitude'])]);
$message = ['type'=>'success','text'=>'Data SPBU berhasil ditambahkan!'];
}
if ($_POST['action'] === 'update') {
$stmt = $pdo->prepare("UPDATE spbu SET nama=:nama,no=:no,status=:status,latitude=:lat,longitude=:lng WHERE id=:id");
$stmt->execute([':nama'=>trim($_POST['nama']),':no'=>trim($_POST['no']),':status'=>isset($_POST['status'])?1:0,':lat'=>floatval($_POST['latitude']),':lng'=>floatval($_POST['longitude']),':id'=>intval($_POST['id'])]);
$message = ['type'=>'success','text'=>'Data berhasil diperbarui!'];
}
if ($_POST['action'] === 'delete') {
$pdo->prepare("DELETE FROM spbu WHERE id=:id")->execute([':id'=>intval($_POST['id'])]);
$message = ['type'=>'danger','text'=>'Data berhasil dihapus.'];
}
}
if (isset($_GET['edit'])) {
$stmt = $pdo->prepare("SELECT * FROM spbu WHERE id=:id");
$stmt->execute([':id'=>intval($_GET['edit'])]);
$edit_data = $stmt->fetch(PDO::FETCH_ASSOC);
}
$all = $pdo->query("SELECT * FROM spbu ORDER BY id DESC")->fetchAll(PDO::FETCH_ASSOC);
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Input SPBU — WebGIS Pontianak</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#f5f4f0;--card:#fff;--border:#e8e5df;--border2:#d4d0c8;
--text:#1a1915;--muted:#7c7a72;--hint:#b0ada5;
--accent:#e85a1a;--accent-bg:#fdf0eb;--accent-dk:#c44a12;
--green:#1a7a45;--green-bg:#ebf7f0;
--red:#c0392b;--red-bg:#fdf0ef;
--r:10px;--r-sm:7px;
--shadow:0 1px 2px rgba(0,0,0,.05),0 3px 12px rgba(0,0,0,.04);
}
body{font-family:'Plus Jakarta Sans',sans-serif;background:var(--bg);color:var(--text);font-size:14px;line-height:1.5}
/* TOP NAV */
.topnav{background:var(--card);border-bottom:1px solid var(--border);height:54px;padding:0 24px;display:flex;align-items:center;gap:6px;position:sticky;top:0;z-index:9999}
.brand{display:flex;align-items:center;gap:9px;font-weight:700;font-size:14px;color:var(--text);text-decoration:none;margin-right:auto}
.brand-mark{width:30px;height:30px;background:var(--accent);border-radius:8px;display:grid;place-items:center;color:#fff;font-size:14px;font-weight:800;letter-spacing:-.5px}
.nav-a{padding:5px 13px;border-radius:var(--r-sm);font-size:13px;font-weight:500;color:var(--muted);text-decoration:none;transition:.12s}
.nav-a:hover{background:var(--bg);color:var(--text)}
.nav-a.on{background:var(--accent-bg);color:var(--accent);font-weight:600}
/* PAGE */
.page{max-width:1140px;margin:0 auto;padding:24px 20px}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:18px}
@media(max-width:840px){.grid2{grid-template-columns:1fr}}
/* CARD */
.card{background:var(--card);border:1px solid var(--border);border-radius:var(--r);box-shadow:var(--shadow)}
.ch{padding:16px 18px 13px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px}
.ch-icon{width:30px;height:30px;border-radius:var(--r-sm);background:var(--bg);display:grid;place-items:center;font-size:14px;flex-shrink:0}
.ch h2{font-size:13px;font-weight:600;color:var(--text)}
.cb{padding:18px}
/* ALERT */
.alert{display:flex;align-items:center;gap:8px;padding:10px 14px;border-radius:var(--r-sm);margin-bottom:18px;font-size:13px;font-weight:500}
.alert.success{background:var(--green-bg);color:var(--green)}
.alert.danger{background:var(--red-bg);color:var(--red)}
/* MAP */
#map{height:310px;border-radius:var(--r-sm);border:1px solid var(--border);overflow:hidden}
.map-tip{margin-top:8px;font-size:12px;color:var(--hint);display:flex;align-items:center;gap:4px}
.coord-tag{display:none;margin-top:10px;background:var(--accent-bg);border:1px solid #f0c5ae;border-radius:20px;padding:4px 12px;font-size:12px;font-weight:500;color:var(--accent);width:fit-content}
/* FORM */
.fg{margin-bottom:13px}
.fg label{display:block;font-size:11px;font-weight:600;color:var(--hint);text-transform:uppercase;letter-spacing:.5px;margin-bottom:5px}
.fg input[type=text],.fg input[type=number]{width:100%;padding:8px 11px;border:1px solid var(--border2);border-radius:var(--r-sm);font-family:inherit;font-size:13px;color:var(--text);background:#fff;transition:.12s}
.fg input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(232,90,26,.09)}
.cols2{display:grid;grid-template-columns:1fr 1fr;gap:9px}
/* TOGGLE */
.trow{display:flex;align-items:center;gap:9px;padding:8px 0}
.sw{position:relative;width:36px;height:19px;flex-shrink:0}
.sw input{opacity:0;position:absolute;width:0;height:0}
.sw-t{position:absolute;inset:0;background:var(--border2);border-radius:20px;cursor:pointer;transition:.18s}
.sw-t::after{content:'';position:absolute;width:13px;height:13px;background:#fff;border-radius:50%;top:3px;left:3px;transition:.18s}
.sw input:checked~.sw-t{background:var(--green)}
.sw input:checked~.sw-t::after{transform:translateX(17px)}
.trow span{font-size:13px;font-weight:500}
/* BUTTONS */
.btn{display:inline-flex;align-items:center;gap:5px;padding:8px 16px;border-radius:var(--r-sm);font-family:inherit;font-size:13px;font-weight:600;border:none;cursor:pointer;transition:.12s;text-decoration:none}
.btn-p{background:var(--accent);color:#fff}
.btn-p:hover{background:var(--accent-dk)}
.btn-g{background:transparent;color:var(--muted);border:1px solid var(--border2)}
.btn-g:hover{background:var(--bg);color:var(--text)}
.btn-xs{padding:4px 10px;font-size:11px;border-radius:6px}
.btn-edit{background:transparent;color:var(--accent);border:1px solid #f0c5ae}
.btn-edit:hover{background:var(--accent-bg)}
.btn-del{background:transparent;color:var(--red);border:1px solid #f0c4bf}
.btn-del:hover{background:var(--red-bg)}
.btn-row{display:flex;gap:8px;margin-top:16px}
/* TABLE */
.tw{overflow-x:auto}
table{width:100%;border-collapse:collapse;font-size:13px}
th{padding:9px 14px;text-align:left;font-size:11px;font-weight:600;color:var(--hint);text-transform:uppercase;letter-spacing:.5px;border-bottom:1px solid var(--border);white-space:nowrap}
td{padding:10px 14px;border-bottom:1px solid var(--bg);vertical-align:middle}
tr:last-child td{border-bottom:none}
tr:hover td{background:#fafaf8}
.badge{display:inline-flex;align-items:center;gap:4px;padding:3px 9px;border-radius:20px;font-size:11px;font-weight:600}
.b-open{background:var(--green-bg);color:var(--green)}
.b-closed{background:var(--bg);color:var(--hint);border:1px solid var(--border)}
.acts{display:flex;gap:5px}
.mono{font-family:'Courier New',monospace;font-size:12px;color:var(--muted)}
.empty{text-align:center;padding:40px;color:var(--hint)}
.pill{background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:1px 8px;font-size:11px;color:var(--hint);margin-left:6px}
</style>
</head>
<body>
<nav class="topnav">
<a href="index.php" class="brand">
<div class="brand-mark">S</div>
WebGIS SPBU Pontianak
</a>
<a href="insert.php" class="nav-a on">Input Data</a>
<a href="index.php" class="nav-a">Lihat Peta</a>
</nav>
<div class="page">
<?php if($message): ?>
<div class="alert <?= $message['type'] ?>">
<?= $message['type']==='success' ? '✓' : '✕' ?> <?= $message['text'] ?>
</div>
<?php endif; ?>
<div class="grid2">
<div class="card">
<div class="ch"><div class="ch-icon">📍</div><h2>Klik Peta — Tentukan Lokasi</h2></div>
<div class="cb">
<div id="map"></div>
<div class="map-tip">Klik titik mana saja untuk mengisi koordinat otomatis</div>
<div class="coord-tag" id="coord-tag"></div>
</div>
</div>
<div class="card">
<div class="ch">
<div class="ch-icon"><?= $edit_data ? '✏️' : '' ?></div>
<h2><?= $edit_data ? 'Edit Data SPBU' : 'Tambah SPBU Baru' ?></h2>
</div>
<div class="cb">
<form method="POST">
<input type="hidden" name="action" value="<?= $edit_data ? 'update' : 'insert' ?>">
<?php if($edit_data): ?>
<input type="hidden" name="id" value="<?= $edit_data['id'] ?>">
<?php endif; ?>
<div class="fg">
<label>Nama SPBU</label>
<input type="text" name="nama" placeholder="cth: SPBU Jl. Ahmad Yani"
value="<?= htmlspecialchars($edit_data['nama'] ?? '') ?>" required>
</div>
<div class="fg">
<label>Nomor SPBU</label>
<input type="text" name="no" placeholder="cth: 64.751.01"
value="<?= htmlspecialchars($edit_data['no'] ?? '') ?>" required>
</div>
<div class="fg">
<label>Status Operasional</label>
<div class="trow">
<label class="sw">
<input type="checkbox" name="status" id="chk" <?= ($edit_data['status'] ?? 0) ? 'checked' : '' ?>>
<span class="sw-t"></span>
</label>
<span>Buka 24 Jam</span>
</div>
</div>
<div class="fg">
<label>Koordinat</label>
<div class="cols2">
<input type="number" name="latitude" id="inp-lat" step="any" placeholder="Latitude"
value="<?= $edit_data['latitude'] ?? '' ?>" required>
<input type="number" name="longitude" id="inp-lng" step="any" placeholder="Longitude"
value="<?= $edit_data['longitude'] ?? '' ?>" required>
</div>
</div>
<div class="btn-row">
<button type="submit" class="btn btn-p"><?= $edit_data ? '💾 Simpan' : '📍 Simpan SPBU' ?></button>
<?php if($edit_data): ?><a href="insert.php" class="btn btn-g">Batal</a><?php endif; ?>
</div>
</form>
</div>
</div>
</div>
<!-- TABEL -->
<div class="card" style="margin-top:18px">
<div class="ch">
<div class="ch-icon">📋</div>
<h2>Daftar SPBU <span class="pill"><?= count($all) ?> data</span></h2>
</div>
<div class="tw">
<?php if(!count($all)): ?>
<div class="empty">Belum ada data. Tambahkan SPBU lewat form di atas.</div>
<?php else: ?>
<table>
<thead><tr><th>#</th><th>Nama</th><th>No</th><th>Status</th><th>Latitude</th><th>Longitude</th><th>Aksi</th></tr></thead>
<tbody>
<?php foreach($all as $i=>$r): ?>
<tr>
<td style="color:var(--hint)"><?= $i+1 ?></td>
<td style="font-weight:600"><?= htmlspecialchars($r['nama']) ?></td>
<td style="color:var(--muted)"><?= htmlspecialchars($r['no']) ?></td>
<td><span class="badge <?= $r['status']?'b-open':'b-closed' ?>"><?= $r['status']?'● 24 Jam':'○ Terbatas' ?></span></td>
<td class="mono"><?= number_format($r['latitude'],6) ?></td>
<td class="mono"><?= number_format($r['longitude'],6) ?></td>
<td>
<div class="acts">
<a href="?edit=<?= $r['id'] ?>" class="btn btn-xs btn-edit">Edit</a>
<form method="POST" style="display:inline" onsubmit="return confirm('Hapus SPBU ini?')">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="<?= $r['id'] ?>">
<button type="submit" class="btn btn-xs btn-del">Hapus</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const map=L.map('map').setView([-0.0263,109.3425],13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{attribution:'© OSM',maxZoom:19}).addTo(map);
function mkIcon(color,big){
const s=big?32:26;
return L.divIcon({className:'',
html:`<div style="width:${s}px;height:${s}px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 2px 8px rgba(0,0,0,.2);display:grid;place-items:center;font-size:${big?15:12}px;">⛽</div>`,
iconSize:[s,s],iconAnchor:[s/2,s/2],popupAnchor:[0,-s/2]});
}
let marker=null;
<?php if($edit_data): ?>
marker=L.marker([<?= $edit_data['latitude'] ?>,<?= $edit_data['longitude'] ?>],{icon:mkIcon('#e85a1a',true),draggable:true}).addTo(map);
map.setView([<?= $edit_data['latitude'] ?>,<?= $edit_data['longitude'] ?>],16);
marker.on('dragend',e=>{const p=e.target.getLatLng();setCoord(p.lat.toFixed(6),p.lng.toFixed(6));});
setCoord(<?= $edit_data['latitude'] ?>,<?= $edit_data['longitude'] ?>);
<?php endif; ?>
<?= json_encode($all) ?>.forEach(s=>{
L.marker([s.latitude,s.longitude],{icon:mkIcon(s.status==1?'#1a7a45':'#b0ada5',false)})
.addTo(map).bindPopup(`<b>${s.nama}</b><br>${s.no}<br>${s.status==1?'24 Jam':'Terbatas'}`);
});
map.on('click',e=>{
const lat=e.latlng.lat.toFixed(6),lng=e.latlng.lng.toFixed(6);
if(marker) map.removeLayer(marker);
marker=L.marker([lat,lng],{icon:mkIcon('#e85a1a',true),draggable:true}).addTo(map);
marker.on('dragend',ev=>{const p=ev.target.getLatLng();setCoord(p.lat.toFixed(6),p.lng.toFixed(6));});
setCoord(lat,lng);
});
function setCoord(lat,lng){
document.getElementById('inp-lat').value=lat;
document.getElementById('inp-lng').value=lng;
const t=document.getElementById('coord-tag');
t.textContent=`📍 ${lat}, ${lng}`;
t.style.display='block';
}
</script>
</body>
</html>
+414
View File
@@ -0,0 +1,414 @@
<?php
require_once 'db_connect.php';
// AJAX: update koordinat saat marker di-drag
if (isset($_GET['update_coords']) && $_SERVER['REQUEST_METHOD']==='POST') {
header('Content-Type: application/json');
$id = intval($_POST['id']);
$lat = floatval($_POST['lat']);
$lng = floatval($_POST['lng']);
if($id > 0){
$pdo->prepare("UPDATE spbu SET latitude=:lat,longitude=:lng WHERE id=:id")
->execute([':lat'=>$lat,':lng'=>$lng,':id'=>$id]);
echo json_encode(['ok'=>true,'lat'=>$lat,'lng'=>$lng]);
} else {
echo json_encode(['ok'=>false]);
}
exit;
}
// AJAX: hapus dari show.php
if (isset($_GET['delete']) && $_SERVER['REQUEST_METHOD']==='POST') {
header('Content-Type: application/json');
$id = intval($_POST['id']);
$pdo->prepare("DELETE FROM spbu WHERE id=:id")->execute([':id'=>$id]);
echo json_encode(['ok'=>true]);
exit;
}
$spbu_list = $pdo->query("SELECT * FROM spbu ORDER BY nama")->fetchAll(PDO::FETCH_ASSOC);
$total = count($spbu_list);
$total_24 = count(array_filter($spbu_list, fn($s) => $s['status']==1));
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Peta SPBU — WebGIS Pontianak</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#f5f4f0;--card:#fff;--border:#e8e5df;--border2:#d4d0c8;
--text:#1a1915;--muted:#7c7a72;--hint:#b0ada5;
--accent:#e85a1a;--accent-bg:#fdf0eb;--accent-dk:#c44a12;
--green:#1a7a45;--green-bg:#ebf7f0;
--red:#c0392b;--red-bg:#fdf0ef;
--r:10px;--r-sm:7px;
}
html,body{height:100%;font-family:'Plus Jakarta Sans',sans-serif;color:var(--text);font-size:14px}
body{display:flex;flex-direction:column;background:var(--bg)}
/* NAV */
.topnav{background:var(--card);border-bottom:1px solid var(--border);height:54px;padding:0 24px;display:flex;align-items:center;gap:6px;flex-shrink:0;z-index:9999;position:relative}
.brand{display:flex;align-items:center;gap:9px;font-weight:700;font-size:14px;color:var(--text);text-decoration:none;margin-right:auto}
.brand-mark{width:30px;height:30px;background:var(--accent);border-radius:8px;display:grid;place-items:center;color:#fff;font-size:14px;font-weight:800}
.nav-a{padding:5px 13px;border-radius:var(--r-sm);font-size:13px;font-weight:500;color:var(--muted);text-decoration:none;transition:.12s}
.nav-a:hover{background:var(--bg);color:var(--text)}
.nav-a.on{background:var(--accent-bg);color:var(--accent);font-weight:600}
/* STATS BAR */
.statsbar{background:var(--card);border-bottom:1px solid var(--border);padding:0 24px;height:40px;display:flex;align-items:center;gap:18px;flex-shrink:0}
.stat{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
.stat strong{color:var(--text);font-weight:600}
.stat-dot{width:8px;height:8px;border-radius:50%}
/* MAIN */
.main{display:flex;flex:1;overflow:hidden}
/* SIDEBAR */
.sidebar{width:300px;flex-shrink:0;background:var(--card);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden}
.sb-head{padding:14px 16px 10px;border-bottom:1px solid var(--border);flex-shrink:0}
.sb-head h2{font-size:13px;font-weight:600;margin-bottom:9px;color:var(--text)}
.search{width:100%;padding:7px 11px;border:1px solid var(--border2);border-radius:var(--r-sm);font-family:inherit;font-size:13px;color:var(--text);background:#fff;transition:.12s}
.search:focus{outline:none;border-color:var(--accent)}
.sb-list{flex:1;overflow-y:auto;padding:8px}
.sb-list::-webkit-scrollbar{width:3px}
.sb-list::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
.sb-item{border:1px solid var(--border);border-radius:var(--r-sm);padding:10px 12px;margin-bottom:6px;cursor:pointer;transition:.12s}
.sb-item:hover{border-color:var(--border2);background:#fafaf8}
.sb-item.active{border-color:var(--accent);background:var(--accent-bg)}
.sb-name{font-weight:600;font-size:13px;margin-bottom:2px}
.sb-no{font-size:12px;color:var(--muted)}
.sb-badge{display:inline-flex;align-items:center;gap:3px;margin-top:5px;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:600}
.sb-open{background:var(--green-bg);color:var(--green)}
.sb-closed{background:var(--bg);color:var(--hint);border:1px solid var(--border)}
.empty-sb{text-align:center;padding:32px 16px;color:var(--hint);font-size:13px}
/* MAP */
.mapwrap{flex:1;position:relative;overflow:hidden}
#map{width:100%;height:100%}
/* Rapikan tampilan Leaflet Layers Control */
.leaflet-control-layers{
border:1px solid var(--border) !important;
border-radius:var(--r-sm) !important;
box-shadow:0 2px 12px rgba(0,0,0,.09) !important;
font-family:'Plus Jakarta Sans',sans-serif !important;
font-size:13px !important;
}
.leaflet-control-layers-expanded{padding:10px 14px !important}
.leaflet-control-layers-separator{margin:6px 0 !important;border-color:var(--border) !important}
/* TOAST */
.toast{position:absolute;top:12px;left:50%;transform:translateX(-50%) translateY(-60px);z-index:1001;background:#1a1915;color:#fff;font-size:12px;font-weight:500;padding:8px 16px;border-radius:20px;transition:.25s;pointer-events:none;white-space:nowrap}
.toast.show{transform:translateX(-50%) translateY(0)}
/* ATTR PANEL */
#attr-panel{
position:absolute;bottom:0;left:0;right:0;
background:var(--card);border-top:2px solid var(--accent);
transform:translateY(100%);transition:transform .28s cubic-bezier(.4,0,.2,1);
z-index:1000;
}
#attr-panel.open{transform:translateY(0)}
.ap-head{padding:12px 18px 10px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
.ap-title{font-size:13px;font-weight:700;color:var(--accent)}
.ap-actions{display:flex;gap:7px;align-items:center}
.ap-close{background:none;border:1px solid var(--border2);color:var(--muted);border-radius:5px;padding:3px 9px;font-size:12px;cursor:pointer;font-family:inherit;transition:.12s}
.ap-close:hover{border-color:var(--muted)}
.ap-del{background:none;border:1px solid #f0c4bf;color:var(--red);border-radius:5px;padding:3px 9px;font-size:12px;cursor:pointer;font-family:inherit;font-weight:600;transition:.12s}
.ap-del:hover{background:var(--red-bg)}
.ap-edit{background:none;border:1px solid #f0c5ae;color:var(--accent);border-radius:5px;padding:3px 9px;font-size:12px;cursor:pointer;font-family:inherit;font-weight:600;transition:.12s;text-decoration:none}
.ap-edit:hover{background:var(--accent-bg)}
.ap-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:0;padding:12px 18px 16px}
@media(max-width:600px){.ap-grid{grid-template-columns:repeat(2,1fr)}}
.ap-field{padding:6px 10px}
.ap-label{font-size:10px;font-weight:600;color:var(--hint);text-transform:uppercase;letter-spacing:.5px;margin-bottom:3px}
.ap-val{font-size:14px;font-weight:600;color:var(--text)}
.ap-val.green{color:var(--green)}
.ap-val.red{color:var(--red)}
.ap-val.mono{font-size:12px;font-family:'Courier New',monospace;font-weight:500;color:var(--muted)}
.drag-hint{font-size:11px;color:var(--hint);padding:0 18px 10px;display:flex;align-items:center;gap:4px}
@media(max-width:680px){
.sidebar{width:100%;max-height:200px;border-right:none;border-bottom:1px solid var(--border)}
.main{flex-direction:column}
.statsbar .stat:nth-child(n+3){display:none}
}
</style>
</head>
<body>
<nav class="topnav">
<a href="index.php" class="brand"><div class="brand-mark">S</div>WebGIS SPBU Pontianak</a>
<a href="insert.php" class="nav-a">Input Data</a>
<a href="index.php" class="nav-a on">Lihat Peta</a>
</nav>
<div class="statsbar">
<div class="stat"><div class="stat-dot" style="background:var(--accent)"></div>Total <strong><?= $total ?></strong> SPBU</div>
<div class="stat"><div class="stat-dot" style="background:var(--green)"></div>Buka 24 Jam: <strong><?= $total_24 ?></strong></div>
<div class="stat"><div class="stat-dot" style="background:var(--hint)"></div>Terbatas: <strong><?= $total - $total_24 ?></strong></div>
</div>
<div class="main">
<!-- SIDEBAR -->
<div class="sidebar">
<div class="sb-head">
<h2>Daftar SPBU</h2>
<input class="search" id="search" type="text" placeholder="Cari nama atau nomor...">
</div>
<div class="sb-list" id="sb-list">
<?php if(!$total): ?>
<div class="empty-sb">Belum ada data.<br><a href="insert.php" style="color:var(--accent)">Tambahkan →</a></div>
<?php else: foreach($spbu_list as $s): ?>
<div class="sb-item" data-id="<?= $s['id'] ?>">
<div class="sb-name"><?= htmlspecialchars($s['nama']) ?></div>
<div class="sb-no">No. <?= htmlspecialchars($s['no']) ?></div>
<span class="sb-badge <?= $s['status']?'sb-open':'sb-closed' ?>"><?= $s['status']?'● 24 Jam':'○ Terbatas' ?></span>
</div>
<?php endforeach; endif; ?>
</div>
</div>
<!-- MAP -->
<div class="mapwrap">
<div id="map"></div>
<div class="toast" id="toast"></div>
<div id="attr-panel">
<div class="ap-head">
<span class="ap-title">⛽ Informasi SPBU</span>
<div class="ap-actions">
<a href="#" class="ap-edit" id="ap-edit-btn">Edit</a>
<button class="ap-del" id="ap-del-btn">Hapus</button>
<button class="ap-close" onclick="closePanel()">✕ Tutup</button>
</div>
</div>
<div class="ap-grid">
<div class="ap-field"><div class="ap-label">Nama</div><div class="ap-val" id="ap-nama">—</div></div>
<div class="ap-field"><div class="ap-label">Nomor SPBU</div><div class="ap-val" id="ap-no">—</div></div>
<div class="ap-field"><div class="ap-label">Status</div><div class="ap-val" id="ap-status">—</div></div>
<div class="ap-field"><div class="ap-label">Koordinat</div><div class="ap-val mono" id="ap-coords">—</div></div>
</div>
<div class="drag-hint">⟳ Drag marker di peta untuk memperbarui lokasi secara langsung</div>
</div>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const DATA = <?= json_encode($spbu_list, JSON_UNESCAPED_UNICODE) ?>;
// ── Base Layers ──────────────────────────────────────────────────────────────
const osm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap', maxZoom: 19
});
const sat = L.tileLayer(
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
{ attribution: 'Esri', maxZoom: 19 }
);
// ── Layer Groups: pisah SPBU berdasarkan status ──────────────────────────────
// Setiap group berisi marker-marker sesuai jenisnya.
// Pengguna bisa toggle tampilan lewat checkbox di Layers Control.
const layer24Jam = L.layerGroup(); // SPBU Buka 24 Jam
const layerTerbatas = L.layerGroup(); // SPBU Jam Terbatas
// ── Inisialisasi Peta ────────────────────────────────────────────────────────
// Sertakan kedua overlay agar keduanya aktif (ter-centang) saat halaman dimuat
const map = L.map('map', {
center: [-0.0263, 109.3425],
zoom: 13,
layers: [osm, layer24Jam, layerTerbatas]
});
// ── Layers Control ───────────────────────────────────────────────────────────
// baseLayers → radio button, hanya bisa pilih satu base map
// overlays → checkbox, bisa pilih / sembunyikan per jenis SPBU
const baseLayers = {
'🗺️ Peta (OpenStreetMap)' : osm,
'🛰️ Satelit (Esri)' : sat
};
const overlays = {
'🟢 SPBU Buka 24 Jam' : layer24Jam,
'⚫ SPBU Jam Terbatas' : layerTerbatas
};
// collapsed:false → panel langsung terbuka tanpa harus hover
L.control.layers(baseLayers, overlays, { collapsed: false }).addTo(map);
// ── Icon Helper ──────────────────────────────────────────────────────────────
function mkIcon(color, big) {
const s = big ? 34 : 26;
return L.divIcon({
className: '',
html: `<div style="width:${s}px;height:${s}px;border-radius:50%;background:${color};
border:2.5px solid #fff;box-shadow:0 2px 10px rgba(0,0,0,.22);
display:grid;place-items:center;font-size:${big ? 16 : 12}px;transition:.15s;">⛽</div>`,
iconSize : [s, s],
iconAnchor : [s/2, s/2],
popupAnchor: [0, -s/2 - 2]
});
}
const markers = {};
let activeId = null;
// ── Buat Marker & Masukkan ke Layer Group yang Sesuai ────────────────────────
DATA.forEach(spbu => {
const is24 = spbu.status == 1;
const color = is24 ? '#1a7a45' : '#b0ada5';
// draggable:true → aktifkan fitur drag & drop lokasi
const m = L.marker([spbu.latitude, spbu.longitude], {
icon : mkIcon(color, false),
draggable: true
});
// Klik marker → tampilkan panel atribut
m.on('click', () => showAttr(spbu.id));
// ── Drag & Drop: simpan koordinat baru ke DB via AJAX ────────────────────
m.on('dragend', function(e) {
const pos = e.target.getLatLng();
const lat = pos.lat.toFixed(6);
const lng = pos.lng.toFixed(6);
const fd = new FormData();
fd.append('id', spbu.id);
fd.append('lat', lat);
fd.append('lng', lng);
fetch('show.php?update_coords=1', { method: 'POST', body: fd })
.then(r => r.json())
.then(res => {
if (res.ok) {
// Sinkronkan data lokal agar panel atribut juga update
const d = DATA.find(x => x.id == spbu.id);
if (d) { d.latitude = lat; d.longitude = lng; }
if (activeId == spbu.id) {
document.getElementById('ap-coords').textContent = `${lat}, ${lng}`;
}
showToast('📍 Lokasi berhasil diperbarui!');
}
})
.catch(() => showToast('❌ Gagal memperbarui lokasi'));
});
// Masukkan ke layer group yang tepat
m.addTo(is24 ? layer24Jam : layerTerbatas);
markers[spbu.id] = { marker: m, data: spbu };
});
// ── Fit Bounds ───────────────────────────────────────────────────────────────
if (DATA.length > 0) {
const pts = DATA.map(s => [s.latitude, s.longitude]);
DATA.length === 1
? map.setView(pts[0], 15)
: map.fitBounds(pts, { padding: [40, 40] });
}
// ── Panel Atribut ─────────────────────────────────────────────────────────────
function showAttr(id) {
const obj = markers[id];
if (!obj) return;
const spbu = DATA.find(x => x.id == id) || obj.data;
// Reset icon marker yang sebelumnya aktif
if (activeId && activeId !== id && markers[activeId]) {
const prev = DATA.find(x => x.id == activeId) || markers[activeId].data;
markers[activeId].marker.setIcon(mkIcon(prev.status == 1 ? '#1a7a45' : '#b0ada5', false));
}
// Besarkan & warnai marker aktif
obj.marker.setIcon(mkIcon('#e85a1a', true));
activeId = id;
// Isi panel
document.getElementById('ap-nama').textContent = spbu.nama;
document.getElementById('ap-no').textContent = spbu.no;
const st = document.getElementById('ap-status');
if (spbu.status == 1) {
st.textContent = '● 24 Jam'; st.className = 'ap-val green';
} else {
st.textContent = '○ Terbatas'; st.className = 'ap-val red';
}
document.getElementById('ap-coords').textContent =
`${parseFloat(spbu.latitude).toFixed(6)}, ${parseFloat(spbu.longitude).toFixed(6)}`;
document.getElementById('ap-edit-btn').href = `insert.php?edit=${id}`;
document.getElementById('ap-del-btn').onclick = () => deleteSpbu(id, spbu.nama);
document.getElementById('attr-panel').classList.add('open');
map.setView([spbu.latitude, spbu.longitude], Math.max(map.getZoom(), 15), { animate: true });
// Highlight sidebar
document.querySelectorAll('.sb-item').forEach(el => el.classList.remove('active'));
const si = document.querySelector(`.sb-item[data-id="${id}"]`);
if (si) { si.classList.add('active'); si.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }
}
function closePanel() {
document.getElementById('attr-panel').classList.remove('open');
if (activeId && markers[activeId]) {
const prev = DATA.find(x => x.id == activeId) || markers[activeId].data;
markers[activeId].marker.setIcon(mkIcon(prev.status == 1 ? '#1a7a45' : '#b0ada5', false));
}
activeId = null;
document.querySelectorAll('.sb-item').forEach(el => el.classList.remove('active'));
}
// ── Hapus SPBU ────────────────────────────────────────────────────────────────
function deleteSpbu(id, nama) {
if (!confirm(`Hapus SPBU "${nama}"?`)) return;
const fd = new FormData();
fd.append('id', id);
fetch('show.php?delete=1', { method: 'POST', body: fd })
.then(r => r.json())
.then(res => {
if (res.ok) {
if (markers[id]) {
// Hapus dari layer group yang tepat, bukan langsung dari map
const spbu = DATA.find(x => x.id == id) || markers[id].data;
(spbu.status == 1 ? layer24Jam : layerTerbatas).removeLayer(markers[id].marker);
delete markers[id];
}
const si = document.querySelector(`.sb-item[data-id="${id}"]`);
if (si) si.remove();
closePanel();
showToast('✕ SPBU berhasil dihapus');
}
});
}
// ── Sidebar: klik & search ────────────────────────────────────────────────────
document.querySelectorAll('.sb-item').forEach(el => {
el.addEventListener('click', () => showAttr(parseInt(el.dataset.id)));
});
document.getElementById('search').addEventListener('input', function() {
const q = this.value.toLowerCase();
document.querySelectorAll('.sb-item').forEach(el => {
const n = el.querySelector('.sb-name').textContent.toLowerCase();
const no = el.querySelector('.sb-no').textContent.toLowerCase();
el.style.display = (n.includes(q) || no.includes(q)) ? '' : 'none';
});
});
// ── Toast notifikasi ──────────────────────────────────────────────────────────
function showToast(msg) {
const t = document.getElementById('toast');
t.textContent = msg;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 2500);
}
</script>
</body>
</html>
+614
View File
@@ -0,0 +1,614 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Portal WebGIS Pontianak — SIG Hub</title>
<!-- Modern Typography (Plus Jakarta Sans) -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<!-- Font Awesome CDN for micro-icons -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
/* Design Tokens (Light/Bright Mode) */
:root {
--bg-light: hsl(210, 40%, 98%);
--bg-card: hsla(0, 0%, 100%, 0.8);
--border-color: hsla(217, 32%, 18%, 0.06);
--text-main: hsl(222, 47%, 12%);
--text-muted: hsl(215, 15%, 35%);
--text-dim: hsl(215, 15%, 55%);
/* Glowing colors for individual projects (adjusted for light theme readability) */
--color-road-primary: hsl(199, 89%, 42%);
--color-road-bg: hsla(199, 89%, 42%, 0.08);
--color-spbu-primary: hsl(30, 95%, 45%);
--color-spbu-bg: hsla(30, 95%, 45%, 0.08);
--color-poverty-primary: hsl(142, 65%, 32%);
--color-poverty-bg: hsla(142, 65%, 32%, 0.08);
--font-family: 'Plus Jakarta Sans', sans-serif;
}
/* Base Styling */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-family);
background-color: var(--bg-light);
background-image: radial-gradient(at 0% 0%, hsla(210, 100%, 98%, 1) 0, transparent 50%),
radial-gradient(at 50% 0%, hsla(220, 100%, 97%, 1) 0, transparent 50%);
color: var(--text-main);
min-height: 100vh;
overflow-x: hidden;
display: flex;
flex-direction: column;
position: relative;
}
/* Ambient Background Glows */
.bg-ambient {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: -1;
overflow: hidden;
pointer-events: none;
}
.blob {
position: absolute;
border-radius: 50%;
filter: blur(100px);
opacity: 0.12;
animation: float 25s infinite alternate ease-in-out;
}
.blob-1 {
width: 40vw;
height: 40vw;
background: radial-gradient(circle, var(--color-road-primary) 0%, transparent 80%);
top: -10%;
right: 10%;
animation-delay: 0s;
}
.blob-2 {
width: 35vw;
height: 35vw;
background: radial-gradient(circle, var(--color-poverty-primary) 0%, transparent 80%);
bottom: -10%;
left: 10%;
animation-delay: -5s;
}
.blob-3 {
width: 30vw;
height: 30vw;
background: radial-gradient(circle, var(--color-spbu-primary) 0%, transparent 80%);
top: 40%;
left: 45%;
animation-delay: -10s;
}
@keyframes float {
0% { transform: translate(0, 0) scale(1) rotate(0deg); }
50% { transform: translate(6%, 4%) scale(1.05) rotate(180deg); }
100% { transform: translate(-4%, -6%) scale(0.95) rotate(360deg); }
}
/* Grid Overlay Pattern */
.grid-overlay {
position: fixed;
inset: 0;
z-index: -1;
background-image:
linear-gradient(to right, hsla(217, 32%, 18%, 0.03) 1px, transparent 1px),
linear-gradient(to bottom, hsla(217, 32%, 18%, 0.03) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
}
/* Header Panel */
header {
width: 100%;
max-width: 1300px;
margin: 0 auto;
padding: 1.5rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
z-index: 10;
}
.logo-container {
display: flex;
align-items: center;
gap: 12px;
}
.logo-icon {
width: 42px;
height: 42px;
border-radius: 12px;
background: linear-gradient(135deg, hsl(200, 100%, 45%), hsl(260, 100%, 55%));
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
color: white;
box-shadow: 0 4px 15px hsla(200, 100%, 45%, 0.2);
}
.logo-text h1 {
font-size: 1.15rem;
font-weight: 800;
letter-spacing: 0.5px;
color: var(--text-main);
}
.logo-text p {
font-size: 0.75rem;
color: var(--text-dim);
font-weight: 600;
}
.status-panel {
display: flex;
align-items: center;
gap: 20px;
font-size: 0.8rem;
}
.status-badge {
display: flex;
align-items: center;
gap: 8px;
background: hsla(0, 0%, 100%, 0.7);
border: 1px solid var(--border-color);
padding: 6px 14px;
border-radius: 30px;
font-weight: 600;
color: var(--text-muted);
backdrop-filter: blur(10px);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.02);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: hsl(142, 72%, 45%);
position: relative;
}
.status-dot::after {
content: '';
position: absolute;
inset: -4px;
border-radius: 50%;
border: 2px solid hsl(142, 72%, 45%);
opacity: 0.6;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0% { transform: scale(1); opacity: 1; }
100% { transform: scale(2.2); opacity: 0; }
}
/* Main Container */
main {
flex: 1;
width: 100%;
max-width: 1300px;
margin: 0 auto;
padding: 2.5rem 2rem;
display: flex;
flex-direction: column;
justify-content: center;
z-index: 5;
}
/* Hero Section */
.hero {
text-align: center;
margin-bottom: 4rem;
max-width: 700px;
margin-left: auto;
margin-right: auto;
}
.hero-tag {
display: inline-block;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 2px;
background: linear-gradient(135deg, hsla(200, 100%, 45%, 0.08), hsla(260, 100%, 55%, 0.08));
border: 1px solid hsla(200, 100%, 45%, 0.15);
color: hsl(200, 100%, 35%);
padding: 6px 16px;
border-radius: 100px;
margin-bottom: 1.5rem;
}
.hero h2 {
font-size: 2.5rem;
font-weight: 800;
letter-spacing: -0.5px;
line-height: 1.2;
margin-bottom: 1rem;
color: var(--text-main);
}
.hero p {
font-size: 1.05rem;
color: var(--text-muted);
line-height: 1.6;
}
/* Project Cards Grid */
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 2rem;
margin-bottom: 2rem;
}
/* Card Item */
.card {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 24px;
padding: 2.25rem;
display: flex;
flex-direction: column;
justify-content: space-between;
text-decoration: none;
color: inherit;
position: relative;
overflow: hidden;
backdrop-filter: blur(12px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.02);
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
/* Hover effect */
.card:hover {
transform: translateY(-8px);
background-color: #ffffff;
border-color: var(--card-hover-border);
box-shadow:
0 20px 40px -15px rgba(0, 0, 0, 0.06),
0 0 20px -2px var(--card-glow);
}
/* Colors and values specific to each card */
.card-road {
--card-theme-color: var(--color-road-primary);
--card-theme-bg: var(--color-road-bg);
--card-hover-border: hsla(199, 89%, 42%, 0.3);
--card-glow: hsla(199, 89%, 42%, 0.1);
}
.card-spbu {
--card-theme-color: var(--color-spbu-primary);
--card-theme-bg: var(--color-spbu-bg);
--card-hover-border: hsla(30, 95%, 45%, 0.3);
--card-glow: hsla(30, 95%, 45%, 0.1);
}
.card-poverty {
--card-theme-color: var(--color-poverty-primary);
--card-theme-bg: var(--color-poverty-bg);
--card-hover-border: hsla(142, 65%, 32%, 0.3);
--card-glow: hsla(142, 65%, 32%, 0.1);
}
/* Card Top Decoration */
.card-icon-container {
width: 54px;
height: 54px;
border-radius: 16px;
background-color: var(--card-theme-bg);
border: 1px solid rgba(0, 0, 0, 0.02);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
color: var(--card-theme-color);
margin-bottom: 2rem;
transition: transform 0.4s ease;
}
.card:hover .card-icon-container {
transform: scale(1.1) rotate(5deg);
}
.card-body {
margin-bottom: 2.25rem;
}
.card-title {
font-size: 1.35rem;
font-weight: 700;
line-height: 1.3;
margin-bottom: 0.75rem;
color: var(--text-main);
transition: color 0.3s ease;
}
.card:hover .card-title {
color: var(--card-theme-color);
}
.card-desc {
font-size: 0.88rem;
color: var(--text-muted);
line-height: 1.6;
margin-bottom: 1.5rem;
}
/* Badges / Tech tags */
.tech-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag {
font-size: 0.7rem;
font-weight: 600;
color: var(--text-muted);
background-color: hsla(0, 0%, 0%, 0.03);
border: 1px solid rgba(0, 0, 0, 0.04);
padding: 4px 10px;
border-radius: 6px;
}
/* Card Action Link styles */
.card-action {
display: flex;
align-items: center;
justify-content: space-between;
border-top: 1px solid var(--border-color);
padding-top: 1.25rem;
margin-top: auto;
}
.action-text {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-muted);
transition: color 0.3s ease;
}
.card:hover .action-text {
color: var(--text-main);
}
.action-arrow {
width: 36px;
height: 36px;
border-radius: 50%;
background-color: hsla(0, 0%, 0%, 0.03);
border: 1px solid rgba(0, 0, 0, 0.04);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.85rem;
color: var(--text-muted);
transition: all 0.3s ease;
}
.card:hover .action-arrow {
background-color: var(--card-theme-color);
color: white;
border-color: var(--card-theme-color);
transform: translateX(4px);
}
/* Footer */
footer {
width: 100%;
max-width: 1300px;
margin: auto auto 0 auto;
padding: 2rem;
text-align: center;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.8rem;
color: var(--text-dim);
z-index: 10;
}
footer a {
color: var(--text-muted);
text-decoration: none;
transition: color 0.2s;
}
footer a:hover {
color: var(--text-main);
}
/* Responsive Design */
@media (max-width: 768px) {
header {
flex-direction: column;
gap: 1rem;
text-align: center;
padding: 1.5rem 1rem;
}
.status-panel {
width: 100%;
justify-content: center;
}
main {
padding: 1.5rem 1rem;
}
.hero h2 {
font-size: 2rem;
}
.grid {
grid-template-columns: 1fr;
}
footer {
flex-direction: column;
gap: 1rem;
padding: 1.5rem 1rem;
}
}
</style>
</head>
<body>
<!-- Background decorations -->
<div class="bg-ambient">
<div class="blob blob-1"></div>
<div class="blob blob-2"></div>
<div class="blob blob-3"></div>
</div>
<div class="grid-overlay"></div>
<!-- Header -->
<header>
<div class="logo-container">
<div class="logo-icon">
<i class="fa-solid fa-map-location-dot"></i>
</div>
<div class="logo-text">
<h1>GIS HUB PONTIANAK</h1>
<p>Sistem Informasi Geografis Terintegrasi</p>
</div>
</div>
<div class="status-panel">
<div class="status-badge">
<div class="status-dot"></div>
<span>Server Online</span>
</div>
</div>
</header>
<!-- Main Content -->
<main>
<div class="hero">
<span class="hero-tag">Geographic Information System</span>
<h2>Portal Aplikasi WebGIS</h2>
<p>Akses cepat ke berbagai modul pemetaan interaktif Kota Pontianak. Dibuat menggunakan teknologi web modern dan visualisasi data spasial secara real-time.</p>
</div>
<!-- Grid of Projects -->
<div class="grid">
<!-- Card 1: SIG Jalan & Parsil Tanah -->
<a href="http://localhost/SIG/SIG_Tanah_Jalan/" class="card card-road">
<div>
<div class="card-icon-container">
<i class="fa-solid fa-road"></i>
</div>
<div class="card-body">
<h3 class="card-title">SIG Jalan & Parsil Tanah</h3>
<p class="card-desc">Sistem monitoring inventaris jalan, bidang tanah parsil (SHM, HGB, HGU), serta pelaporan tingkat kerusakan jalan terintegrasi dengan Leaflet.draw.</p>
<div class="tech-tags">
<span class="tag">Leaflet.js</span>
<span class="tag">Leaflet.draw</span>
<span class="tag">PHP</span>
<span class="tag">MySQL</span>
</div>
</div>
</div>
<div class="card-action">
<span class="action-text">Buka Aplikasi</span>
<div class="action-arrow">
<i class="fa-solid fa-arrow-right"></i>
</div>
</div>
</a>
<!-- Card 2: SIG SPBU Pontianak -->
<a href="http://localhost/SIG/SPBU_Pontianak/" class="card card-spbu">
<div>
<div class="card-icon-container">
<i class="fa-solid fa-gas-pump"></i>
</div>
<div class="card-body">
<h3 class="card-title">SIG Sebaran SPBU</h3>
<p class="card-desc">Modul pemetaan lokasi sebaran Stasiun Pengisian Bahan Bakar Umum (SPBU) di Kota Pontianak, lengkap dengan input fasilitas dan galeri foto lokasi.</p>
<div class="tech-tags">
<span class="tag">Leaflet.js</span>
<span class="tag">Bootstrap</span>
<span class="tag">PHP</span>
<span class="tag">MySQL</span>
</div>
</div>
</div>
<div class="card-action">
<span class="action-text">Buka Aplikasi</span>
<div class="action-arrow">
<i class="fa-solid fa-arrow-right"></i>
</div>
</div>
</a>
<!-- Card 3: WebGIS PovertyMap -->
<a href="http://localhost/SIG/sig-kemiskinan/" class="card card-poverty">
<div>
<div class="card-icon-container">
<i class="fa-solid fa-people-roof"></i>
</div>
<div class="card-body">
<h3 class="card-title">WebGIS PovertyMap</h3>
<p class="card-desc">WebGIS Monitoring Bantuan Sosial untuk memetakan konsentrasi penduduk miskin, visualisasi demografis, dan penyaluran bantuan sosial tepat saran.</p>
<div class="tech-tags">
<span class="tag">Leaflet.js</span>
<span class="tag">GeoJSON</span>
<span class="tag">Bootstrap 5</span>
<span class="tag">Chart.js</span>
</div>
</div>
</div>
<div class="card-action">
<span class="action-text">Buka Aplikasi</span>
<div class="action-arrow">
<i class="fa-solid fa-arrow-right"></i>
</div>
</div>
</a>
</div>
</main>
<!-- Footer -->
<footer>
<div>
&copy; 2026 <strong>SIG Pontianak Hub</strong>. Semua Hak Cipta Dilindungi.
</div>
<div>
Host: <a href="http://localhost/SIG/" target="_blank">localhost/SIG</a>
</div>
</footer>
</body>
</html>
+132
View File
@@ -0,0 +1,132 @@
# SIG Penduduk Miskin Berbasis Rumah Ibadah
Aplikasi Sistem Informasi Geografis (SIG) untuk pendataan penduduk miskin berdasarkan wilayah radius rumah ibadah terdekat.
---
## 📁 Struktur Folder
```
sig-kemiskinan/
├── index.html ← Halaman utama (Leaflet + JS)
├── database.sql ← Schema + data contoh
├── includes/
│ └── config.php ← Koneksi DB + fungsi utilitas
├── api/
│ ├── rumah_ibadah.php ← CRUD Rumah Ibadah
│ ├── penduduk.php ← CRUD Penduduk Miskin
│ ├── geocode.php ← Reverse Geocoding (Nominatim)
│ └── update_radius.php ← Update radius + reassign
└── README.md
```
---
## ⚙️ Setup
### 1. Persyaratan
- PHP 7.4+ (atau 8.x)
- MySQL 5.7+ / MariaDB
- Web server (Apache/Nginx) atau PHP built-in server
### 2. Database
```sql
-- Import file database.sql ke MySQL:
mysql -u root -p < database.sql
```
Atau lewat phpMyAdmin: Import → pilih `database.sql`
### 3. Konfigurasi DB
Edit `includes/config.php`:
```php
define('DB_HOST', 'localhost');
define('DB_USER', 'root'); // ganti sesuai user MySQL Anda
define('DB_PASS', ''); // ganti dengan password MySQL
define('DB_NAME', 'sig_kemiskinan');
```
### 4. Jalankan
```bash
# Opsi 1: PHP built-in server (development)
cd sig-kemiskinan
php -S localhost:8080
# Opsi 2: Taruh di folder htdocs (XAMPP/WAMP)
# Copy folder ke C:/xampp/htdocs/sig-kemiskinan
# Akses: http://localhost/sig-kemiskinan
```
---
## 🗺️ Cara Penggunaan
### Tambah Rumah Ibadah
1. Klik tombol **"+ Rumah Ibadah"** (pojok kanan atas)
2. Kursor berubah → **klik lokasi di peta**
3. Isi form: Nama, Jenis, PIC, No. WA, Radius
4. Alamat **otomatis terisi** dari reverse geocoding (Nominatim/OSM)
5. Klik **Simpan**
### Tambah Penduduk Miskin
1. Klik tombol **"+ Penduduk"**
2. **Klik lokasi rumah** di peta
3. Isi: Nama KK, Jumlah anggota
4. Sistem otomatis menentukan rumah ibadah terdekat dalam radius
5. **Titik hijau** = dalam radius | **Titik merah** = luar radius
### Ubah Radius
1. Klik marker rumah ibadah → popup → tombol **📐 Radius**
2. Atau klik ikon 📐 di list sidebar
3. **Drag slider** untuk mengubah radius (preview langsung)
4. Klik **Simpan** → semua penduduk di-reassign otomatis
### Logika Overlap
Jika penduduk masuk dalam radius **>1 rumah ibadah**:
→ Diassign ke **rumah ibadah yang paling dekat** (Haversine distance)
---
## 🔌 API Endpoints
| Method | URL | Fungsi |
|--------|-----|--------|
| GET | `api/rumah_ibadah.php` | Semua rumah ibadah + stats |
| POST | `api/rumah_ibadah.php` | Tambah rumah ibadah |
| PUT | `api/rumah_ibadah.php` | Update rumah ibadah |
| DELETE | `api/rumah_ibadah.php?id=X` | Hapus |
| GET | `api/penduduk.php` | Semua penduduk |
| POST | `api/penduduk.php` | Tambah penduduk |
| PUT | `api/penduduk.php` | Update penduduk |
| DELETE | `api/penduduk.php?id=X` | Hapus |
| GET | `api/geocode.php?lat=X&lng=Y` | Reverse geocoding |
| POST | `api/update_radius.php` | Update radius + reassign |
---
## 🧮 Algoritma Radius
```
Jarak = Haversine(lat1, lng1, lat2, lng2) // dalam meter
Untuk setiap penduduk P:
candidates = [ibadah | jarak(P, ibadah) <= ibadah.radius]
if candidates:
assign P → min(candidates, key=jarak)
else:
P.rumah_ibadah_id = NULL // luar radius semua
```
---
## 🖥️ Fitur UI
- Peta Leaflet dengan tile OpenStreetMap
- Sidebar panel: list Rumah Ibadah & Penduduk
- Stats bar: total ibadah, KK, jiwa, tercakup/tidak
- Radius fleksibel via slider (1003000m, default 500m)
- Lingkaran radius real-time di peta
- Reverse geocoding otomatis saat klik peta
- Warna marker penduduk: hijau (dalam radius) / merah (luar)
- Popup informatif + tombol CRUD langsung dari peta
- Responsive mobile-friendly
- Dark theme modern
+75
View File
@@ -0,0 +1,75 @@
<?php
// api/auth.php v2.0
// ============================================================
// Login / Logout / Session Check API
// ============================================================
require_once __DIR__ . '/../includes/config.php';
session_start();
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') exit;
$method = $_SERVER['REQUEST_METHOD'];
$db = getDB();
switch ($method) {
// ── POST /api/auth.php → Login ─────────────────────────
case 'POST':
$input = getInput();
$username = trim($input['username'] ?? '');
$password = $input['password'] ?? '';
if (!$username || !$password) {
jsonResponse(['success' => false, 'error' => 'Username dan password wajib diisi'], 400);
}
$stmt = $db->prepare("SELECT * FROM users WHERE username = ? AND aktif = 1 LIMIT 1");
$stmt->execute([$username]);
$user = $stmt->fetch();
if (!$user || !password_verify($password, $user['password'])) {
jsonResponse(['success' => false, 'error' => 'Username atau password salah'], 401);
}
// Simpan ke session
$_SESSION['user_id'] = $user['id'];
$_SESSION['user'] = [
'id' => $user['id'],
'nama' => $user['nama'],
'username' => $user['username'],
'role' => $user['role'],
];
// Update last_login
$db->prepare("UPDATE users SET last_login = NOW() WHERE id = ?")
->execute([$user['id']]);
jsonResponse([
'success' => true,
'message' => 'Login berhasil',
'user' => $_SESSION['user'],
]);
break;
// ── DELETE /api/auth.php → Logout ─────────────────────
case 'DELETE':
session_destroy();
jsonResponse(['success' => true, 'message' => 'Logout berhasil']);
break;
// ── GET /api/auth.php → Cek session ───────────────────
case 'GET':
if (!isset($_SESSION['user_id'])) {
jsonResponse(['success' => false, 'loggedIn' => false], 200);
}
jsonResponse(['success' => true, 'loggedIn' => true, 'user' => $_SESSION['user']]);
break;
default:
jsonResponse(['success' => false, 'error' => 'Method tidak diizinkan'], 405);
}
+167
View File
@@ -0,0 +1,167 @@
<?php
// api/bantuan.php v2.0
// ============================================================
// API Bantuan Sosial CRUD jenis bantuan & relasi penduduk
// ============================================================
require_once __DIR__ . '/../includes/config.php';
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') exit;
$method = $_SERVER['REQUEST_METHOD'];
$db = getDB();
$action = $_GET['action'] ?? 'pivot'; // 'jenis' | 'pivot'
switch ($method) {
// ════════════════════════════════════════════════════════
// GET
// ════════════════════════════════════════════════════════
case 'GET':
// GET jenis_bantuan (master)
if ($action === 'jenis') {
$rows = $db->query("SELECT * FROM jenis_bantuan ORDER BY kode")->fetchAll();
jsonResponse(['success' => true, 'data' => $rows]);
}
// GET bantuan per penduduk
$pendudukId = $_GET['penduduk_id'] ?? null;
if (!$pendudukId) jsonResponse(['success' => false, 'error' => 'penduduk_id diperlukan'], 400);
$stmt = $db->prepare("
SELECT bp.*, jb.kode, jb.nama AS nama_bantuan, jb.warna_badge, jb.deskripsi
FROM bantuan_penduduk bp
JOIN jenis_bantuan jb ON jb.id = bp.bantuan_id
WHERE bp.penduduk_id = ?
ORDER BY jb.kode
");
$stmt->execute([$pendudukId]);
jsonResponse(['success' => true, 'data' => $stmt->fetchAll()]);
break;
// ════════════════════════════════════════════════════════
// POST Tambah/set bantuan untuk penduduk
// ════════════════════════════════════════════════════════
case 'POST':
$in = getInput();
// Batch upsert terima array bantuans[]
if (isset($in['bantuans']) && is_array($in['bantuans'])) {
$pendudukId = (int)($in['penduduk_id'] ?? 0);
if (!$pendudukId) jsonResponse(['success' => false, 'error' => 'penduduk_id diperlukan'], 400);
// Nonaktifkan semua bantuan lama dulu
$db->prepare("UPDATE bantuan_penduduk SET status = 'tidak_aktif' WHERE penduduk_id = ?")
->execute([$pendudukId]);
foreach ($in['bantuans'] as $b) {
$bantuanId = (int)($b['bantuan_id'] ?? 0);
if (!$bantuanId) continue;
// Cek sudah ada?
$cek = $db->prepare("SELECT id FROM bantuan_penduduk WHERE penduduk_id = ? AND bantuan_id = ?");
$cek->execute([$pendudukId, $bantuanId]);
$existing = $cek->fetch();
if ($existing) {
$db->prepare("
UPDATE bantuan_penduduk
SET status = 'aktif', tanggal_mulai = ?, nominal_bulanan = ?,
nomor_kpj = ?, keterangan = ?, diinput_oleh = ?
WHERE penduduk_id = ? AND bantuan_id = ?
")->execute([
$b['tanggal_mulai'] ?? null,
$b['nominal_bulanan'] ?? null,
$b['nomor_kpj'] ?? null,
$b['keterangan'] ?? null,
$b['diinput_oleh'] ?? 1,
$pendudukId, $bantuanId,
]);
} else {
$db->prepare("
INSERT INTO bantuan_penduduk
(penduduk_id, bantuan_id, status, tanggal_mulai, nominal_bulanan, nomor_kpj, keterangan, diinput_oleh)
VALUES (?,?,'aktif',?,?,?,?,?)
")->execute([
$pendudukId, $bantuanId,
$b['tanggal_mulai'] ?? null,
$b['nominal_bulanan'] ?? null,
$b['nomor_kpj'] ?? null,
$b['keterangan'] ?? null,
$b['diinput_oleh'] ?? 1,
]);
}
}
jsonResponse(['success' => true, 'message' => 'Data bantuan berhasil disimpan']);
}
// Single insert
$required = ['penduduk_id','bantuan_id'];
foreach ($required as $f) {
if (empty($in[$f])) jsonResponse(['success' => false, 'error' => "Field '$f' wajib diisi"], 400);
}
// Upsert
$cek = $db->prepare("SELECT id FROM bantuan_penduduk WHERE penduduk_id = ? AND bantuan_id = ?");
$cek->execute([(int)$in['penduduk_id'], (int)$in['bantuan_id']]);
if ($cek->fetch()) {
jsonResponse(['success' => false, 'error' => 'Bantuan ini sudah tercatat untuk penduduk tersebut. Gunakan PUT untuk update.'], 409);
}
$db->prepare("
INSERT INTO bantuan_penduduk (penduduk_id, bantuan_id, status, tanggal_mulai, nominal_bulanan, nomor_kpj, keterangan, diinput_oleh)
VALUES (?,?,?,?,?,?,?,?)
")->execute([
(int)$in['penduduk_id'],
(int)$in['bantuan_id'],
$in['status'] ?? 'aktif',
$in['tanggal_mulai'] ?? null,
$in['nominal_bulanan'] ?? null,
$in['nomor_kpj'] ?? null,
$in['keterangan'] ?? null,
$in['diinput_oleh'] ?? 1,
]);
jsonResponse(['success' => true, 'id' => $db->lastInsertId(), 'message' => 'Bantuan berhasil ditambahkan']);
break;
// ════════════════════════════════════════════════════════
// PUT Update status bantuan
// ════════════════════════════════════════════════════════
case 'PUT':
$in = getInput();
$id = (int)($in['id'] ?? $_GET['id'] ?? 0);
if (!$id) jsonResponse(['success' => false, 'error' => 'ID diperlukan'], 400);
$db->prepare("
UPDATE bantuan_penduduk
SET status = ?, tanggal_mulai = ?, tanggal_selesai = ?,
nominal_bulanan = ?, nomor_kpj = ?, keterangan = ?
WHERE id = ?
")->execute([
$in['status'] ?? 'aktif',
$in['tanggal_mulai'] ?? null,
$in['tanggal_selesai'] ?? null,
$in['nominal_bulanan'] ?? null,
$in['nomor_kpj'] ?? null,
$in['keterangan'] ?? null,
$id,
]);
jsonResponse(['success' => true, 'message' => 'Status bantuan diperbarui']);
break;
// ════════════════════════════════════════════════════════
// DELETE Hapus relasi bantuan
// ════════════════════════════════════════════════════════
case 'DELETE':
$id = (int)($_GET['id'] ?? getInput()['id'] ?? 0);
if (!$id) jsonResponse(['success' => false, 'error' => 'ID diperlukan'], 400);
$db->prepare("DELETE FROM bantuan_penduduk WHERE id = ?")->execute([$id]);
jsonResponse(['success' => true, 'message' => 'Bantuan dihapus']);
break;
default:
jsonResponse(['success' => false, 'error' => 'Method tidak diizinkan'], 405);
}
+189
View File
@@ -0,0 +1,189 @@
<?php
// api/dashboard_stats.php v2.0
// ============================================================
// API Statistik untuk Dashboard & Grafik
// ============================================================
require_once __DIR__ . '/../includes/config.php';
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') exit;
if ($_SERVER['REQUEST_METHOD'] !== 'GET') jsonResponse(['success'=>false,'error'=>'Method tidak diizinkan'],405);
$db = getDB();
// ── Filter opsional ────────────────────────────────────────
$kelurahan = $_GET['kelurahan'] ?? null;
$kecamatan = $_GET['kecamatan'] ?? null;
$whereBase = "WHERE 1=1";
$params = [];
if ($kelurahan) { $whereBase .= " AND w.kelurahan = ?"; $params[] = $kelurahan; }
if ($kecamatan) { $whereBase .= " AND w.kecamatan = ?"; $params[] = $kecamatan; }
$joinW = "LEFT JOIN wilayah w ON w.id = pm.wilayah_id";
// ═══════════════════════════════════════════════════════════
// 1. RINGKASAN UTAMA (KPI Cards)
// ═══════════════════════════════════════════════════════════
$kpi = $db->prepare("
SELECT
COUNT(pm.id) AS total_kk,
SUM(pm.jumlah_anggota) AS total_jiwa,
SUM(pm.jumlah_anak_sekolah) AS total_anak_sekolah,
SUM(CASE WHEN pm.status_verifikasi='terverifikasi' THEN 1 ELSE 0 END) AS terverifikasi,
SUM(CASE WHEN pm.status_verifikasi='belum_diverifikasi' THEN 1 ELSE 0 END) AS belum_verifikasi,
SUM(CASE WHEN pm.punya_bpjs=1 THEN 1 ELSE 0 END) AS punya_bpjs,
SUM(CASE WHEN pm.kategori_kemiskinan='sangat_miskin' THEN 1 ELSE 0 END) AS sangat_miskin,
SUM(CASE WHEN pm.kategori_kemiskinan='miskin' THEN 1 ELSE 0 END) AS miskin,
SUM(CASE WHEN pm.kategori_kemiskinan='hampir_miskin' THEN 1 ELSE 0 END) AS hampir_miskin,
SUM(CASE WHEN pm.kategori_kemiskinan='mampu' THEN 1 ELSE 0 END) AS mampu,
ROUND(AVG(pm.penghasilan_bulanan),0) AS avg_penghasilan,
SUM(CASE WHEN bp_count.cnt > 0 THEN 1 ELSE 0 END) AS penerima_bantuan,
SUM(CASE WHEN bp_count.cnt = 0 THEN 1 ELSE 0 END) AS belum_bantuan
FROM penduduk_miskin pm
$joinW
LEFT JOIN (
SELECT penduduk_id, COUNT(*) AS cnt
FROM bantuan_penduduk
WHERE status='aktif'
GROUP BY penduduk_id
) bp_count ON bp_count.penduduk_id = pm.id
$whereBase
");
$kpi->execute($params);
$kpiData = $kpi->fetch();
// ═══════════════════════════════════════════════════════════
// 2. DISTRIBUSI KATEGORI KEMISKINAN (Donut Chart)
// ═══════════════════════════════════════════════════════════
$katStmt = $db->prepare("
SELECT pm.kategori_kemiskinan AS label, COUNT(*) AS value
FROM penduduk_miskin pm $joinW $whereBase
GROUP BY pm.kategori_kemiskinan
ORDER BY FIELD(pm.kategori_kemiskinan,'sangat_miskin','miskin','hampir_miskin','mampu')
");
$katStmt->execute($params);
$kategoriChart = $katStmt->fetchAll();
// ═══════════════════════════════════════════════════════════
// 3. KK PER KELURAHAN (Bar Chart)
// ═══════════════════════════════════════════════════════════
$kelStmt = $db->prepare("
SELECT
COALESCE(w.kelurahan,'Tidak Diketahui') AS label,
COUNT(pm.id) AS total_kk,
SUM(pm.jumlah_anggota) AS total_jiwa,
SUM(CASE WHEN bp_c.cnt>0 THEN 1 ELSE 0 END) AS penerima
FROM penduduk_miskin pm $joinW
LEFT JOIN (
SELECT penduduk_id, COUNT(*) AS cnt FROM bantuan_penduduk WHERE status='aktif' GROUP BY penduduk_id
) bp_c ON bp_c.penduduk_id = pm.id
$whereBase
GROUP BY w.kelurahan
ORDER BY total_kk DESC
LIMIT 15
");
$kelStmt->execute($params);
$kelurahanChart = $kelStmt->fetchAll();
// ═══════════════════════════════════════════════════════════
// 4. DISTRIBUSI JENIS BANTUAN (Pie Chart)
// ═══════════════════════════════════════════════════════════
$bantuanStmt = $db->prepare("
SELECT jb.kode, jb.nama AS label, jb.warna_badge AS color, COUNT(bp.id) AS value
FROM bantuan_penduduk bp
JOIN jenis_bantuan jb ON jb.id = bp.bantuan_id
JOIN penduduk_miskin pm ON pm.id = bp.penduduk_id
$joinW
" . str_replace('WHERE 1=1', 'WHERE bp.status=\'aktif\'', $whereBase) . "
GROUP BY jb.id
ORDER BY value DESC
");
$bantuanStmt->execute($params);
$bantuanChart = $bantuanStmt->fetchAll();
// ═══════════════════════════════════════════════════════════
// 5. TREN PENDATAAN PER BULAN (Line Chart) 12 bulan terakhir
// ═══════════════════════════════════════════════════════════
$trenStmt = $db->prepare("
SELECT
DATE_FORMAT(pm.created_at,'%Y-%m') AS bulan,
COUNT(pm.id) AS total
FROM penduduk_miskin pm $joinW $whereBase
AND pm.created_at >= DATE_SUB(NOW(), INTERVAL 12 MONTH)
GROUP BY DATE_FORMAT(pm.created_at,'%Y-%m')
ORDER BY bulan ASC
");
$trenStmt->execute($params);
$trenChart = $trenStmt->fetchAll();
// ═══════════════════════════════════════════════════════════
// 6. DISTRIBUSI PEKERJAAN (Bar horizontal)
// ═══════════════════════════════════════════════════════════
$pekerjaanStmt = $db->prepare("
SELECT pm.status_pekerjaan AS label, COUNT(*) AS value
FROM penduduk_miskin pm $joinW $whereBase
GROUP BY pm.status_pekerjaan
ORDER BY value DESC
");
$pekerjaanStmt->execute($params);
$pekerjaanChart = $pekerjaanStmt->fetchAll();
// ═══════════════════════════════════════════════════════════
// 7. DATA HEATMAP (lat/lng + bobot untuk Leaflet.heat)
// ═══════════════════════════════════════════════════════════
$heatStmt = $db->prepare("
SELECT pm.lat, pm.lng,
CASE pm.kategori_kemiskinan
WHEN 'sangat_miskin' THEN 1.0
WHEN 'miskin' THEN 0.6
WHEN 'hampir_miskin' THEN 0.3
WHEN 'mampu' THEN 0.1
ELSE 0.1
END AS intensity
FROM penduduk_miskin pm $joinW $whereBase
");
$heatStmt->execute($params);
$heatmap = $heatStmt->fetchAll();
// ═══════════════════════════════════════════════════════════
// 8. TOP 10 KK BELUM MENERIMA BANTUAN APAPUN
// ═══════════════════════════════════════════════════════════
$belumBantuanStmt = $db->prepare("
SELECT pm.id, pm.nama_kk, pm.nik_kk, pm.alamat,
pm.kategori_kemiskinan, pm.jumlah_anggota,
pm.status_verifikasi,
COALESCE(w.kelurahan,'') AS kelurahan,
DATEDIFF(NOW(), pm.created_at) AS hari_terdata
FROM penduduk_miskin pm $joinW
$whereBase
AND NOT EXISTS (
SELECT 1 FROM bantuan_penduduk bp
WHERE bp.penduduk_id = pm.id AND bp.status='aktif'
)
ORDER BY pm.kategori_kemiskinan ASC, hari_terdata DESC
LIMIT 10
");
$belumBantuanStmt->execute($params);
$belumBantuan = $belumBantuanStmt->fetchAll();
// ── Statistik Rumah Ibadah (tetap dari v1.1) ──────────────
$ibadahStats = $db->query("
SELECT COUNT(*) AS total_ibadah FROM rumah_ibadah
")->fetch();
jsonResponse([
'success' => true,
'kpi' => $kpiData,
'chart_kategori' => $kategoriChart,
'chart_kelurahan' => $kelurahanChart,
'chart_bantuan' => $bantuanChart,
'chart_tren' => $trenChart,
'chart_pekerjaan' => $pekerjaanChart,
'heatmap' => $heatmap,
'belum_bantuan' => $belumBantuan,
'total_ibadah' => (int)$ibadahStats['total_ibadah'],
'generated_at' => date('Y-m-d H:i:s'),
]);
+118
View File
@@ -0,0 +1,118 @@
<?php
// api/export_excel.php v2.0
// ============================================================
// Export Data Penduduk Miskin ke Excel (format CSV UTF-8 BOM)
// Tidak memerlukan library eksternal
// ============================================================
require_once __DIR__ . '/../includes/config.php';
$db = getDB();
// ── Filter dari query string ───────────────────────────────
$kelurahan = $_GET['kelurahan'] ?? null;
$kecamatan = $_GET['kecamatan'] ?? null;
$kategori = $_GET['kategori'] ?? null;
$verifikasi = $_GET['verifikasi'] ?? null;
$bantuan = $_GET['bantuan'] ?? null;
$where = "WHERE 1=1";
$params = [];
if ($kelurahan) { $where .= " AND w.kelurahan = ?"; $params[] = $kelurahan; }
if ($kecamatan) { $where .= " AND w.kecamatan = ?"; $params[] = $kecamatan; }
if ($kategori) { $where .= " AND pm.kategori_kemiskinan = ?"; $params[] = $kategori; }
if ($verifikasi) { $where .= " AND pm.status_verifikasi = ?"; $params[] = $verifikasi; }
if ($bantuan === 'tidak') {
$where .= " AND (SELECT COUNT(*) FROM bantuan_penduduk bp WHERE bp.penduduk_id = pm.id AND bp.status='aktif') = 0";
} elseif ($bantuan === 'ada') {
$where .= " AND (SELECT COUNT(*) FROM bantuan_penduduk bp WHERE bp.penduduk_id = pm.id AND bp.status='aktif') > 0";
}
$stmt = $db->prepare("
SELECT
pm.nik_kk, pm.nama_kk, pm.no_hp,
pm.alamat, pm.rt, pm.rw,
COALESCE(w.kelurahan,'') AS kelurahan,
COALESCE(w.kecamatan,'') AS kecamatan,
pm.jumlah_anggota, pm.jumlah_anak_sekolah,
pm.penghasilan_bulanan, pm.sumber_penghasilan, pm.status_pekerjaan,
pm.kategori_kemiskinan, pm.kondisi_rumah, pm.kepemilikan_rumah,
CASE pm.punya_bpjs WHEN 1 THEN 'Ya' ELSE 'Tidak' END AS punya_bpjs,
pm.jenis_bpjs,
pm.lat, pm.lng,
pm.status_verifikasi,
COALESCE(u.nama,'') AS nama_surveyor,
DATE_FORMAT(pm.created_at,'%d/%m/%Y %H:%i') AS tgl_input,
(SELECT GROUP_CONCAT(jb.kode ORDER BY jb.kode SEPARATOR ', ')
FROM bantuan_penduduk bp JOIN jenis_bantuan jb ON jb.id = bp.bantuan_id
WHERE bp.penduduk_id = pm.id AND bp.status='aktif') AS bantuan_diterima
FROM penduduk_miskin pm
LEFT JOIN wilayah w ON w.id = pm.wilayah_id
LEFT JOIN users u ON u.id = pm.surveyor_id
$where
ORDER BY w.kelurahan, pm.nama_kk
");
$stmt->execute($params);
$rows = $stmt->fetchAll();
// ── Nama file ─────────────────────────────────────────────
$filter = $kelurahan ? "_" . preg_replace('/\s+/', '-', $kelurahan) : '';
$filename = "data_kemiskinan{$filter}_" . date('Ymd_His') . ".csv";
// ── Output header HTTP ─────────────────────────────────────
header('Content-Type: text/csv; charset=UTF-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Cache-Control: no-cache, no-store, must-revalidate');
header('Pragma: no-cache');
// BOM UTF-8 agar Excel baca karakter Indonesia dengan benar
echo "\xEF\xBB\xBF";
$out = fopen('php://output', 'w');
// ── Header kolom ──────────────────────────────────────────
fputcsv($out, [
'No','NIK KK','Nama KK','No HP',
'Alamat','RT','RW','Kelurahan','Kecamatan',
'Jml Anggota','Anak Sekolah',
'Penghasilan/Bln','Sumber Penghasilan','Status Pekerjaan',
'Kategori Kemiskinan','Kondisi Rumah','Kepemilikan Rumah',
'Punya BPJS','Jenis BPJS',
'Latitude','Longitude',
'Status Verifikasi','Bantuan Diterima',
'Petugas Input','Tgl Input',
], ';');
// ── Data rows ─────────────────────────────────────────────
$no = 1;
foreach ($rows as $r) {
fputcsv($out, [
$no++,
$r['nik_kk'],
$r['nama_kk'],
$r['no_hp'] ?? '',
$r['alamat'] ?? '',
$r['rt'] ?? '',
$r['rw'] ?? '',
$r['kelurahan'],
$r['kecamatan'],
$r['jumlah_anggota'],
$r['jumlah_anak_sekolah'],
$r['penghasilan_bulanan'] ? 'Rp ' . number_format($r['penghasilan_bulanan'],0,',','.') : '',
$r['sumber_penghasilan'] ?? '',
ucwords(str_replace('_',' ', $r['status_pekerjaan'])),
$r['kategori_kemiskinan'] === 'hampir_miskin' ? 'Nyaris Miskin' : ucwords(str_replace('_',' ', $r['kategori_kemiskinan'])),
ucwords(str_replace('_',' ', $r['kondisi_rumah'])),
ucwords(str_replace('_',' ', $r['kepemilikan_rumah'])),
$r['punya_bpjs'],
strtoupper(str_replace('_',' ', $r['jenis_bpjs'])),
$r['lat'],
$r['lng'],
ucwords(str_replace('_',' ', $r['status_verifikasi'])),
$r['bantuan_diterima'] ?? 'Belum Ada',
$r['nama_surveyor'],
$r['tgl_input'],
], ';');
}
fclose($out);
exit;
+246
View File
@@ -0,0 +1,246 @@
<?php
// api/export_pdf.php v2.0
// ============================================================
// Export Laporan PDF menggunakan HTML → browser print
// (Pure PHP, tanpa library eksternal)
// ============================================================
require_once __DIR__ . '/../includes/config.php';
$db = getDB();
// ── Filter ────────────────────────────────────────────────
$kelurahan = $_GET['kelurahan'] ?? null;
$kecamatan = $_GET['kecamatan'] ?? null;
$kategori = $_GET['kategori'] ?? null;
$verifikasi = $_GET['verifikasi'] ?? null;
$where = "WHERE 1=1";
$params = [];
if ($kelurahan) { $where .= " AND w.kelurahan = ?"; $params[] = $kelurahan; }
if ($kecamatan) { $where .= " AND w.kecamatan = ?"; $params[] = $kecamatan; }
if ($kategori) { $where .= " AND pm.kategori_kemiskinan = ?"; $params[] = $kategori; }
if ($verifikasi) { $where .= " AND pm.status_verifikasi = ?"; $params[] = $verifikasi; }
// Data penduduk
$stmt = $db->prepare("
SELECT pm.nik_kk, pm.nama_kk, pm.no_hp, pm.alamat, pm.rt, pm.rw,
COALESCE(w.kelurahan,'') AS kelurahan,
pm.jumlah_anggota, pm.jumlah_anak_sekolah,
pm.penghasilan_bulanan, pm.status_pekerjaan,
pm.kategori_kemiskinan, pm.kondisi_rumah,
CASE pm.punya_bpjs WHEN 1 THEN 'Ya' ELSE 'Tidak' END AS punya_bpjs,
pm.status_verifikasi,
(SELECT GROUP_CONCAT(jb.kode ORDER BY jb.kode SEPARATOR ', ')
FROM bantuan_penduduk bp JOIN jenis_bantuan jb ON jb.id = bp.bantuan_id
WHERE bp.penduduk_id = pm.id AND bp.status='aktif') AS bantuan
FROM penduduk_miskin pm
LEFT JOIN wilayah w ON w.id = pm.wilayah_id
$where
ORDER BY w.kelurahan, pm.kategori_kemiskinan, pm.nama_kk
");
$stmt->execute($params);
$rows = $stmt->fetchAll();
// Statistik ringkas
$statsStmt = $db->prepare("
SELECT COUNT(*) AS total, SUM(pm.jumlah_anggota) AS jiwa,
SUM(CASE WHEN pm.kategori_kemiskinan='sangat_miskin' THEN 1 ELSE 0 END) AS sangat_miskin,
SUM(CASE WHEN pm.kategori_kemiskinan='miskin' THEN 1 ELSE 0 END) AS miskin,
SUM(CASE WHEN pm.kategori_kemiskinan='hampir_miskin' THEN 1 ELSE 0 END) AS hampir_miskin,
SUM(CASE WHEN pm.kategori_kemiskinan='mampu' THEN 1 ELSE 0 END) AS mampu,
SUM(CASE WHEN EXISTS(SELECT 1 FROM bantuan_penduduk bp WHERE bp.penduduk_id=pm.id AND bp.status='aktif') THEN 1 ELSE 0 END) AS penerima
FROM penduduk_miskin pm LEFT JOIN wilayah w ON w.id = pm.wilayah_id $where
");
$statsStmt->execute($params);
$stats = $statsStmt->fetch();
$judul = "LAPORAN DATA PENDUDUK MISKIN";
$filter = $kelurahan ? " Kelurahan $kelurahan" : ($kecamatan ? " Kec. $kecamatan" : " Seluruh Wilayah");
$tgl = date('d F Y');
// Warna kategori
function warnaBg(string $k): string {
return match($k) {
'sangat_miskin' => '#fee2e2',
'miskin' => '#fef3c7',
'hampir_miskin' => '#dcfce7',
'mampu' => '#dbeafe',
default => '#f3f4f6',
};
}
function labelKat(string $k): string {
return match($k) {
'sangat_miskin' => 'Sangat Miskin',
'miskin' => 'Miskin',
'hampir_miskin' => 'Nyaris Miskin',
'mampu' => 'Mampu',
default => $k,
};
}
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<title><?= htmlspecialchars($judul . $filter) ?></title>
<style>
@page { size: A4 landscape; margin: 15mm 10mm; }
@media print {
.no-print { display: none; }
body { font-size: 8pt; }
table { font-size: 7.5pt; }
thead { display: table-header-group; }
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: Arial, sans-serif; font-size: 9pt; color: #111; background: #fff; }
/* Header laporan */
.kop { text-align: center; border-bottom: 3px double #1e3a5f; padding-bottom: 10px; margin-bottom: 14px; }
.kop h1 { font-size: 13pt; font-weight: 900; text-transform: uppercase; color: #1e3a5f; }
.kop h2 { font-size: 11pt; font-weight: 700; margin: 2px 0; }
.kop p { font-size: 8pt; color: #555; }
/* Statistik */
.stat-grid { display: flex; gap: 8px; margin-bottom: 14px; }
.stat-box {
flex: 1; border: 1px solid #ddd; border-radius: 6px;
padding: 7px 10px; text-align: center; background: #f8faff;
}
.stat-box .val { font-size: 16pt; font-weight: 900; color: #1e3a5f; }
.stat-box .lbl { font-size: 7pt; color: #666; text-transform: uppercase; }
/* Tabel */
table { width: 100%; border-collapse: collapse; margin-top: 4px; }
th { background: #1e3a5f; color: #fff; padding: 5px 4px; text-align: center; font-size: 7.5pt; border: 1px solid #1e3a5f; }
td { padding: 4px; border: 1px solid #ddd; vertical-align: top; font-size: 7.5pt; }
tr:nth-child(even) td { background: #f9fafb; }
.badge-sm {
display: inline-block; padding: 1px 5px; border-radius: 3px;
font-size: 6.5pt; font-weight: bold;
}
.c { text-align: center; }
.r { text-align: right; }
/* Footer */
.footer { margin-top: 20px; display: flex; justify-content: space-between; font-size: 8pt; }
.ttd-box { text-align: center; width: 200px; }
.ttd-box .line { border-top: 1px solid #111; margin-top: 50px; padding-top: 3px; font-weight: bold; }
/* Print button */
.no-print { text-align: center; margin: 12px 0; }
.btn-print {
background: #1e3a5f; color: #fff; border: none; padding: 8px 24px;
border-radius: 5px; font-size: 10pt; cursor: pointer; margin-right: 8px;
}
.btn-print:hover { background: #274e81; }
</style>
</head>
<body>
<!-- Print Button -->
<div class="no-print">
<button class="btn-print" onclick="window.print()">🖨 Cetak / Simpan PDF</button>
<button class="btn-print" style="background:#555;" onclick="window.close()">✕ Tutup</button>
</div>
<!-- Kop Surat -->
<div class="kop">
<h1><?= htmlspecialchars($judul) ?></h1>
<h2>SISTEM INFORMASI GEOGRAFIS KEMISKINAN<?= htmlspecialchars($filter) ?></h2>
<p>Dicetak pada: <?= $tgl ?> &nbsp;|&nbsp; Total: <?= count($rows) ?> KK</p>
</div>
<!-- Statistik Ringkas -->
<div class="stat-grid">
<div class="stat-box"><div class="val"><?= number_format($stats['total']) ?></div><div class="lbl">Total KK</div></div>
<div class="stat-box"><div class="val"><?= number_format($stats['jiwa']) ?></div><div class="lbl">Total Jiwa</div></div>
<div class="stat-box" style="background:#fee2e2"><div class="val"><?= number_format($stats['sangat_miskin']) ?></div><div class="lbl">Sangat Miskin</div></div>
<div class="stat-box" style="background:#fef3c7"><div class="val"><?= number_format($stats['miskin']) ?></div><div class="lbl">Miskin</div></div>
<div class="stat-box" style="background:#dcfce7"><div class="val"><?= number_format($stats['hampir_miskin']) ?></div><div class="lbl">Nyaris Miskin</div></div>
<div class="stat-box" style="background:#dbeafe"><div class="val"><?= number_format($stats['mampu']) ?></div><div class="lbl">Mampu</div></div>
<div class="stat-box"><div class="val"><?= number_format($stats['penerima']) ?></div><div class="lbl">Penerima Bantuan</div></div>
<div class="stat-box" style="background:#fff7ed"><div class="val"><?= number_format($stats['total'] - $stats['penerima']) ?></div><div class="lbl">Belum Bantuan</div></div>
</div>
<!-- Tabel Data -->
<table>
<thead>
<tr>
<th style="width:2%">No</th>
<th style="width:11%">NIK / Nama KK</th>
<th style="width:6%">Kelurahan</th>
<th style="width:4%">RT/RW</th>
<th style="width:13%">Alamat</th>
<th style="width:3%">Angg.</th>
<th style="width:3%">Anak</th>
<th style="width:7%">Penghasilan</th>
<th style="width:7%">Pekerjaan</th>
<th style="width:8%">Kategori</th>
<th style="width:7%">Kondisi Rumah</th>
<th style="width:4%">BPJS</th>
<th style="width:11%">Bantuan Diterima</th>
<th style="width:10%">Status Verifikasi</th>
</tr>
</thead>
<tbody>
<?php $no = 1; foreach ($rows as $r): ?>
<tr>
<td class="c"><?= $no++ ?></td>
<td>
<small style="color:#666"><?= htmlspecialchars($r['nik_kk']) ?></small><br>
<strong><?= htmlspecialchars($r['nama_kk']) ?></strong><br>
<small><?= htmlspecialchars($r['no_hp'] ?? '') ?></small>
</td>
<td class="c"><?= htmlspecialchars($r['kelurahan']) ?></td>
<td class="c"><?= ($r['rt'] ? $r['rt'].'/'.$r['rw'] : '') ?></td>
<td><?= htmlspecialchars($r['alamat'] ?? '') ?></td>
<td class="c"><?= $r['jumlah_anggota'] ?></td>
<td class="c"><?= $r['jumlah_anak_sekolah'] ?></td>
<td class="r"><?= $r['penghasilan_bulanan'] ? 'Rp '.number_format($r['penghasilan_bulanan'],0,',','.') : '' ?></td>
<td><?= htmlspecialchars(ucwords(str_replace('_',' ',$r['status_pekerjaan']))) ?></td>
<td class="c">
<span class="badge-sm" style="background:<?= warnaBg($r['kategori_kemiskinan']) ?>">
<?= labelKat($r['kategori_kemiskinan']) ?>
</span>
</td>
<td class="c"><?= htmlspecialchars(ucwords(str_replace('_',' ',$r['kondisi_rumah']))) ?></td>
<td class="c"><?= $r['punya_bpjs'] ?></td>
<td>
<?php if ($r['bantuan']): ?>
<?php foreach (explode(', ', $r['bantuan']) as $b): ?>
<span class="badge-sm" style="background:#dbeafe;color:#1e40af"><?= htmlspecialchars(trim($b)) ?></span>
<?php endforeach; ?>
<?php else: ?>
<span style="color:#dc2626">Belum Ada</span>
<?php endif; ?>
</td>
<td class="c">
<?php
$sv = $r['status_verifikasi'];
$svColor = match($sv) { 'terverifikasi'=>'#dcfce7', 'belum_diverifikasi'=>'#fef3c7', default=>'#fee2e2' };
?>
<span class="badge-sm" style="background:<?= $svColor ?>">
<?= htmlspecialchars(ucwords(str_replace('_',' ',$sv))) ?>
</span>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<!-- Footer / TTD -->
<div class="footer">
<div>
<p>Dicetak: <?= date('d/m/Y H:i') ?> WIB</p>
<p>Filter: <?= htmlspecialchars(implode(', ', array_filter([$kelurahan, $kecamatan, $kategori ? "Kategori: $kategori" : null])) ?: 'Semua Data') ?></p>
<p>Sumber: SIG Kemiskinan v2.0</p>
</div>
<div class="ttd-box">
<p>Pontianak, <?= $tgl ?></p>
<p>Mengetahui,</p>
<div class="line">Kepala Dinas / Pejabat Berwenang</div>
</div>
</div>
</body>
</html>
+171
View File
@@ -0,0 +1,171 @@
<?php
// api/foto.php v2.0
// ============================================================
// API Upload & Kelola Foto Rumah + GPS Geo-tagging
// ============================================================
require_once __DIR__ . '/../includes/config.php';
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') exit;
$method = $_SERVER['REQUEST_METHOD'];
$db = getDB();
// Pastikan folder upload ada
if (!is_dir(UPLOAD_DIR)) mkdir(UPLOAD_DIR, 0755, true);
switch ($method) {
// ════════════════════════════════════════════════════════
// GET Ambil daftar foto per penduduk
// ════════════════════════════════════════════════════════
case 'GET':
$pendudukId = $_GET['penduduk_id'] ?? null;
if (!$pendudukId) {
header('Content-Type: application/json');
jsonResponse(['success' => false, 'error' => 'penduduk_id diperlukan'], 400);
}
$stmt = $db->prepare("
SELECT f.*, u.nama AS nama_petugas
FROM foto_rumah f
LEFT JOIN users u ON u.id = f.diambil_oleh
WHERE f.penduduk_id = ?
ORDER BY f.is_primary DESC, f.created_at ASC
");
$stmt->execute([$pendudukId]);
header('Content-Type: application/json');
jsonResponse(['success' => true, 'data' => $stmt->fetchAll()]);
break;
// ════════════════════════════════════════════════════════
// POST Upload foto baru
// ════════════════════════════════════════════════════════
case 'POST':
header('Content-Type: application/json');
$pendudukId = (int)($_POST['penduduk_id'] ?? 0);
if (!$pendudukId) jsonResponse(['success' => false, 'error' => 'penduduk_id diperlukan'], 400);
// Cek penduduk ada
$cek = $db->prepare("SELECT id FROM penduduk_miskin WHERE id = ?");
$cek->execute([$pendudukId]);
if (!$cek->fetch()) jsonResponse(['success' => false, 'error' => 'Data penduduk tidak ditemukan'], 404);
if (!isset($_FILES['foto']) || $_FILES['foto']['error'] !== UPLOAD_ERR_OK) {
$errCode = $_FILES['foto']['error'] ?? 'no_file';
jsonResponse(['success' => false, 'error' => "Upload gagal (kode: $errCode)"], 400);
}
$file = $_FILES['foto'];
$tmpPath = $file['tmp_name'];
$origName = $file['name'];
$fileSize = $file['size'];
// Validasi ukuran
if ($fileSize > MAX_FOTO_SIZE) {
jsonResponse(['success' => false, 'error' => 'Ukuran file maksimal 5 MB'], 413);
}
// Validasi ekstensi & MIME type
$ext = strtolower(pathinfo($origName, PATHINFO_EXTENSION));
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $tmpPath);
finfo_close($finfo);
$allowedMime = ['image/jpeg','image/png','image/webp'];
if (!in_array($ext, ALLOWED_EXT) || !in_array($mime, $allowedMime)) {
jsonResponse(['success' => false, 'error' => 'Format file tidak didukung (jpg/png/webp)'], 415);
}
// Generate nama file unik
$newName = 'foto_' . $pendudukId . '_' . time() . '_' . bin2hex(random_bytes(4)) . '.' . $ext;
$destPath = UPLOAD_DIR . $newName;
$relPath = UPLOAD_URL . $newName;
if (!move_uploaded_file($tmpPath, $destPath)) {
jsonResponse(['success' => false, 'error' => 'Gagal menyimpan file'], 500);
}
// Cek apakah ini foto pertama → jadikan primary
$countStmt = $db->prepare("SELECT COUNT(*) FROM foto_rumah WHERE penduduk_id = ?");
$countStmt->execute([$pendudukId]);
$isPrimary = ($countStmt->fetchColumn() == 0) ? 1 : 0;
// Koordinat GPS dari POST (dari browser Geolocation atau EXIF)
$latFoto = isset($_POST['lat_foto']) && $_POST['lat_foto'] !== '' ? (float)$_POST['lat_foto'] : null;
$lngFoto = isset($_POST['lng_foto']) && $_POST['lng_foto'] !== '' ? (float)$_POST['lng_foto'] : null;
$accuracy = isset($_POST['accuracy_meter']) && $_POST['accuracy_meter'] !== '' ? (float)$_POST['accuracy_meter']
: (isset($_POST['accuracy']) && $_POST['accuracy'] !== '' ? (float)$_POST['accuracy'] : null);
// Jika user set is_primary = 1, nonaktifkan primary lain dulu
$isPrimaryReq = isset($_POST['is_primary']) && (int)$_POST['is_primary'] === 1;
if ($isPrimaryReq) {
$db->prepare("UPDATE foto_rumah SET is_primary = 0 WHERE penduduk_id = ?")
->execute([$pendudukId]);
$isPrimary = 1;
}
// diambil_oleh: support field name lama (user_id) dan baru (diambil_oleh)
$diambilOleh = $_POST['diambil_oleh'] ?? $_POST['user_id'] ?? null;
$db->prepare("
INSERT INTO foto_rumah (penduduk_id, file_path, lat_foto, lng_foto, accuracy_meter, is_primary, ukuran_kb, keterangan, diambil_oleh)
VALUES (?,?,?,?,?,?,?,?,?)
")->execute([
$pendudukId,
$relPath,
$latFoto,
$lngFoto,
$accuracy,
$isPrimary,
(int)round($fileSize / 1024),
$_POST['keterangan'] ?? null,
$diambilOleh ? (int)$diambilOleh : null,
]);
$newId = (int)$db->lastInsertId();
jsonResponse([
'success' => true,
'id' => $newId,
'file_path' => $relPath,
'is_primary'=> $isPrimary,
'message' => 'Foto berhasil diupload',
]);
break;
// ════════════════════════════════════════════════════════
// DELETE Hapus foto
// ════════════════════════════════════════════════════════
case 'DELETE':
header('Content-Type: application/json');
$id = (int)($_GET['id'] ?? 0);
if (!$id) jsonResponse(['success' => false, 'error' => 'ID diperlukan'], 400);
$stmt = $db->prepare("SELECT * FROM foto_rumah WHERE id = ?");
$stmt->execute([$id]);
$foto = $stmt->fetch();
if (!$foto) jsonResponse(['success' => false, 'error' => 'Foto tidak ditemukan'], 404);
// Hapus file fisik
$filePath = __DIR__ . '/../' . $foto['file_path'];
if (file_exists($filePath)) @unlink($filePath);
$db->prepare("DELETE FROM foto_rumah WHERE id = ?")->execute([$id]);
// Jika foto yang dihapus adalah primary, set foto lain jadi primary
if ($foto['is_primary']) {
$next = $db->prepare("SELECT id FROM foto_rumah WHERE penduduk_id = ? ORDER BY created_at ASC LIMIT 1");
$next->execute([$foto['penduduk_id']]);
$nextFoto = $next->fetch();
if ($nextFoto) {
$db->prepare("UPDATE foto_rumah SET is_primary = 1 WHERE id = ?")->execute([$nextFoto['id']]);
}
}
jsonResponse(['success' => true, 'message' => 'Foto berhasil dihapus']);
break;
default:
header('Content-Type: application/json');
jsonResponse(['success' => false, 'error' => 'Method tidak diizinkan'], 405);
}
+71
View File
@@ -0,0 +1,71 @@
<?php
// api/geocode.php
// Reverse Geocoding via Nominatim (OpenStreetMap)
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
$lat = $_GET['lat'] ?? null;
$lng = $_GET['lng'] ?? null;
if (!$lat || !$lng) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'lat dan lng diperlukan']);
exit;
}
$lat = (float)$lat;
$lng = (float)$lng;
$url = "https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat={$lat}&lon={$lng}&zoom=18&addressdetails=1";
$context = stream_context_create([
'http' => [
'method' => 'GET',
'header' => "User-Agent: SIG-Kemiskinan/1.0 (sig@example.com)\r\n",
'timeout' => 8,
]
]);
$response = @file_get_contents($url, false, $context);
if ($response === false) {
// Fallback: koordinat saja
echo json_encode([
'success' => true,
'alamat' => "Koordinat: {$lat}, {$lng}",
'detail' => null
]);
exit;
}
$data = json_decode($response, true);
if (!$data || isset($data['error'])) {
echo json_encode([
'success' => true,
'alamat' => "Koordinat: {$lat}, {$lng}",
'detail' => null
]);
exit;
}
// Susun alamat yang rapi
$addr = $data['address'] ?? [];
$parts = array_filter([
$addr['road'] ?? $addr['pedestrian'] ?? $addr['path'] ?? null,
$addr['house_number'] ?? null,
$addr['neighbourhood'] ?? $addr['suburb'] ?? null,
$addr['city'] ?? $addr['town'] ?? $addr['village'] ?? $addr['county'] ?? null,
]);
$alamat = !empty($parts)
? implode(', ', $parts)
: ($data['display_name'] ?? "Koordinat: {$lat}, {$lng}");
echo json_encode([
'success' => true,
'alamat' => $alamat,
'display_name' => $data['display_name'] ?? '',
'detail' => $addr
]);
+161
View File
@@ -0,0 +1,161 @@
<?php
// api/notifikasi.php v2.0
// ============================================================
// API Sistem Notifikasi + Generate Otomatis
// ============================================================
require_once __DIR__ . '/../includes/config.php';
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') exit;
$method = $_SERVER['REQUEST_METHOD'];
$db = getDB();
switch ($method) {
// ════════════════════════════════════════════════════════
// GET Ambil notifikasi (dengan auto-generate)
// ════════════════════════════════════════════════════════
case 'GET':
$userId = $_GET['user_id'] ?? null;
$generate = isset($_GET['generate']); // ?generate → trigger auto-create
if ($generate) generateNotifikasi($db);
$sql = "SELECT n.*, pm.nama_kk FROM notifikasi n LEFT JOIN penduduk_miskin pm ON pm.id = n.penduduk_id WHERE 1=1";
$params = [];
if ($userId) {
$sql .= " AND (n.user_id = ? OR n.user_id IS NULL)";
$params[] = $userId;
}
// Filter belum dibaca
if (isset($_GET['unread'])) {
$sql .= " AND n.sudah_dibaca = 0";
}
$sql .= " ORDER BY n.created_at DESC LIMIT 50";
$stmt = $db->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll();
// Hitung unread
$unreadStmt = $db->prepare("SELECT COUNT(*) FROM notifikasi WHERE sudah_dibaca = 0 AND (user_id = ? OR user_id IS NULL)");
$unreadStmt->execute([$userId ?? 0]);
$unreadCount = (int)$unreadStmt->fetchColumn();
jsonResponse(['success' => true, 'data' => $rows, 'unread_count' => $unreadCount]);
break;
// ════════════════════════════════════════════════════════
// PUT Tandai sudah dibaca
// ════════════════════════════════════════════════════════
case 'PUT':
$in = getInput();
$id = $in['id'] ?? 'all';
if ($id === 'all') {
$userId = (int)($in['user_id'] ?? 0);
$db->prepare("UPDATE notifikasi SET sudah_dibaca = 1 WHERE user_id = ? OR user_id IS NULL")
->execute([$userId]);
jsonResponse(['success' => true, 'message' => 'Semua notifikasi ditandai dibaca']);
} else {
$db->prepare("UPDATE notifikasi SET sudah_dibaca = 1 WHERE id = ?")
->execute([(int)$id]);
jsonResponse(['success' => true, 'message' => 'Notifikasi ditandai dibaca']);
}
break;
// ════════════════════════════════════════════════════════
// POST Buat notifikasi manual
// ════════════════════════════════════════════════════════
case 'POST':
$in = getInput();
if (empty($in['judul']) || empty($in['pesan'])) {
jsonResponse(['success' => false, 'error' => 'Judul dan pesan wajib diisi'], 400);
}
$db->prepare("
INSERT INTO notifikasi (user_id, jenis, judul, pesan, penduduk_id)
VALUES (?,?,?,?,?)
")->execute([
$in['user_id'] ?? null,
$in['jenis'] ?? 'info',
$in['judul'],
$in['pesan'],
$in['penduduk_id'] ?? null,
]);
jsonResponse(['success' => true, 'id' => $db->lastInsertId(), 'message' => 'Notifikasi dibuat']);
break;
default:
jsonResponse(['success' => false, 'error' => 'Method tidak diizinkan'], 405);
}
// ============================================================
// FUNGSI: Generate notifikasi otomatis
// ============================================================
function generateNotifikasi(PDO $db): void {
// 1. KK belum terverifikasi
$belumVerif = $db->query("
SELECT id, nama_kk FROM penduduk_miskin
WHERE status_verifikasi = 'belum_diverifikasi'
AND id NOT IN (
SELECT DISTINCT penduduk_id FROM notifikasi
WHERE jenis = 'belum_verifikasi' AND sudah_dibaca = 0
)
LIMIT 20
")->fetchAll();
foreach ($belumVerif as $p) {
$db->prepare("
INSERT INTO notifikasi (jenis, judul, pesan, penduduk_id)
VALUES ('belum_verifikasi','Data Menunggu Verifikasi',?,?)
")->execute(["KK {$p['nama_kk']} belum diverifikasi.", $p['id']]);
}
// 2. KK belum menerima bantuan apapun (sangat miskin)
$belumBantuan = $db->query("
SELECT id, nama_kk FROM penduduk_miskin
WHERE kategori_kemiskinan = 'sangat_miskin'
AND id NOT IN (
SELECT penduduk_id FROM bantuan_penduduk WHERE status = 'aktif'
)
AND id NOT IN (
SELECT DISTINCT penduduk_id FROM notifikasi
WHERE jenis = 'belum_bantuan' AND sudah_dibaca = 0
)
LIMIT 20
")->fetchAll();
foreach ($belumBantuan as $p) {
$db->prepare("
INSERT INTO notifikasi (jenis, judul, pesan, penduduk_id)
VALUES ('belum_bantuan','KK Sangat Miskin Tanpa Bantuan',?,?)
")->execute(["KK {$p['nama_kk']} berkategori sangat miskin namun belum menerima bantuan apapun.", $p['id']]);
}
// 3. Data usang (tidak diupdate > DATA_USANG_HARI hari)
$usang = $db->prepare("
SELECT id, nama_kk, DATEDIFF(NOW(), updated_at) AS hari_usang
FROM penduduk_miskin
WHERE updated_at < DATE_SUB(NOW(), INTERVAL ? DAY)
AND id NOT IN (
SELECT DISTINCT penduduk_id FROM notifikasi
WHERE jenis = 'data_usang' AND sudah_dibaca = 0
)
LIMIT 10
");
$usang->execute([DATA_USANG_HARI]);
foreach ($usang->fetchAll() as $p) {
$db->prepare("
INSERT INTO notifikasi (jenis, judul, pesan, penduduk_id)
VALUES ('data_usang','Data Tidak Diperbarui',?,?)
")->execute(["Data KK {$p['nama_kk']} belum diperbarui selama {$p['hari_usang']} hari.", $p['id']]);
}
}
+308
View File
@@ -0,0 +1,308 @@
<?php
// api/penduduk.php v2.0
// ============================================================
// CRUD API Penduduk Miskin Field diperluas
// ============================================================
require_once __DIR__ . '/../includes/config.php';
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') exit;
$method = $_SERVER['REQUEST_METHOD'];
$db = getDB();
// ── Query SELECT lengkap (dipakai di GET & setelah write) ──
$SELECT_SQL = "
SELECT
pm.*,
w.kelurahan, w.kecamatan, w.kota,
ri.nama AS nama_ibadah,
ri.jenis AS jenis_ibadah,
u.nama AS nama_surveyor,
v.nama AS nama_verifikator,
fp.file_path AS foto_utama,
(
SELECT GROUP_CONCAT(jb.kode ORDER BY jb.kode SEPARATOR ',')
FROM bantuan_penduduk bp
JOIN jenis_bantuan jb ON jb.id = bp.bantuan_id
WHERE bp.penduduk_id = pm.id AND bp.status = 'aktif'
) AS kode_bantuan,
(
SELECT COUNT(*) FROM bantuan_penduduk bp
WHERE bp.penduduk_id = pm.id AND bp.status = 'aktif'
) AS jumlah_bantuan
FROM penduduk_miskin pm
LEFT JOIN wilayah w ON w.id = pm.wilayah_id
LEFT JOIN rumah_ibadah ri ON ri.id = pm.rumah_ibadah_id
LEFT JOIN users u ON u.id = pm.surveyor_id
LEFT JOIN users v ON v.id = pm.verified_by
LEFT JOIN foto_rumah fp ON fp.penduduk_id = pm.id AND fp.is_primary = 1
";
try {
switch ($method) {
// ════════════════════════════════════════════════════════
// GET Ambil data penduduk
// ════════════════════════════════════════════════════════
case 'GET':
$id = $_GET['id'] ?? null;
$kelurahan = $_GET['kelurahan'] ?? null;
$kecamatan = $_GET['kecamatan'] ?? null;
$kategori = $_GET['kategori'] ?? null;
$bantuan = $_GET['bantuan'] ?? null; // 'ada'|'tidak'
$verifikasi = $_GET['verifikasi'] ?? null;
$surveyor = $_GET['surveyor_id']?? null;
$search = $_GET['search'] ?? null;
// GET single
if ($id) {
$stmt = $db->prepare($SELECT_SQL . " WHERE pm.id = ?");
$stmt->execute([$id]);
$row = $stmt->fetch();
if (!$row) jsonResponse(['success' => false, 'error' => 'Data tidak ditemukan'], 404);
// Ambil semua foto
$fotoStmt = $db->prepare("SELECT * FROM foto_rumah WHERE penduduk_id = ? ORDER BY is_primary DESC, created_at ASC");
$fotoStmt->execute([$id]);
$row['fotos'] = $fotoStmt->fetchAll();
// Ambil semua bantuan
$bantuanStmt = $db->prepare("
SELECT bp.*, jb.kode, jb.nama AS nama_bantuan, jb.warna_badge
FROM bantuan_penduduk bp
JOIN jenis_bantuan jb ON jb.id = bp.bantuan_id
WHERE bp.penduduk_id = ?
ORDER BY jb.kode
");
$bantuanStmt->execute([$id]);
$row['bantuans'] = $bantuanStmt->fetchAll();
jsonResponse(['success' => true, 'data' => $row]);
}
// GET list dengan filter
$sql = $SELECT_SQL . " WHERE 1=1";
$params = [];
if ($kelurahan) { $sql .= " AND w.kelurahan = ?"; $params[] = $kelurahan; }
if ($kecamatan) { $sql .= " AND w.kecamatan = ?"; $params[] = $kecamatan; }
if ($kategori) { $sql .= " AND pm.kategori_kemiskinan = ?"; $params[] = $kategori; }
if ($verifikasi) { $sql .= " AND pm.status_verifikasi = ?"; $params[] = $verifikasi; }
if ($surveyor) { $sql .= " AND pm.surveyor_id = ?"; $params[] = $surveyor; }
if ($search) { $sql .= " AND (pm.nama_kk LIKE ? OR pm.nik_kk LIKE ? OR pm.alamat LIKE ?)";
$params[] = "%$search%"; $params[] = "%$search%"; $params[] = "%$search%"; }
if ($bantuan === 'tidak') {
$sql .= " AND (SELECT COUNT(*) FROM bantuan_penduduk bp WHERE bp.penduduk_id = pm.id AND bp.status='aktif') = 0";
} elseif ($bantuan === 'ada') {
$sql .= " AND (SELECT COUNT(*) FROM bantuan_penduduk bp WHERE bp.penduduk_id = pm.id AND bp.status='aktif') > 0";
}
$sql .= " ORDER BY pm.nama_kk ASC";
$stmt = $db->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll();
// Statistik ringkas
$stats = [
'total' => count($rows),
'total_jiwa' => array_sum(array_column($rows, 'jumlah_anggota')),
'penerima_bantuan' => count(array_filter($rows, fn($r) => $r['jumlah_bantuan'] > 0)),
'belum_verifikasi' => count(array_filter($rows, fn($r) => $r['status_verifikasi'] === 'belum_diverifikasi')),
];
jsonResponse(['success' => true, 'data' => $rows, 'stats' => $stats]);
break;
// ════════════════════════════════════════════════════════
// POST Tambah penduduk baru
// ════════════════════════════════════════════════════════
case 'POST':
$in = getInput();
// Validasi wajib
$required = ['nik_kk', 'nama_kk', 'lat', 'lng'];
foreach ($required as $f) {
if (empty($in[$f])) jsonResponse(['success' => false, 'error' => "Field '$f' wajib diisi"], 400);
}
// Cek NIK duplikat
$cek = $db->prepare("SELECT id FROM penduduk_miskin WHERE nik_kk = ?");
$cek->execute([trim($in['nik_kk'])]);
if ($cek->fetch()) jsonResponse(['success' => false, 'error' => 'NIK sudah terdaftar di sistem'], 409);
// Hitung kategori otomatis
$penghasilan = isset($in['penghasilan_bulanan']) && $in['penghasilan_bulanan'] !== ''
? (float)$in['penghasilan_bulanan'] : null;
$kategori = hitungKategori($penghasilan);
$wilayahId = isset($in['wilayah_id']) && $in['wilayah_id'] !== '' ? (int)$in['wilayah_id'] : null;
$surveyorId = isset($in['surveyor_id']) && $in['surveyor_id'] !== '' ? (int)$in['surveyor_id'] : null;
$statusPekerjaan = $in['status_pekerjaan'] ?? 'tidak_bekerja';
$allowedPekerjaan = ['tidak_bekerja','buruh_harian','petani','nelayan','pedagang','pegawai_swasta','wiraswasta','lainnya'];
if (!in_array($statusPekerjaan, $allowedPekerjaan)) {
$statusPekerjaan = 'lainnya';
}
$db->prepare("
INSERT INTO penduduk_miskin (
nik_kk, nama_kk, no_hp, alamat, wilayah_id, rt, rw, lat, lng,
jumlah_anggota, jumlah_anak_sekolah,
penghasilan_bulanan, sumber_penghasilan, status_pekerjaan,
kategori_kemiskinan, kondisi_rumah, kepemilikan_rumah,
punya_bpjs, jenis_bpjs, status_verifikasi, surveyor_id
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
")->execute([
trim($in['nik_kk']),
trim($in['nama_kk']),
$in['no_hp'] ?? null,
$in['alamat'] ?? null,
$wilayahId,
$in['rt'] ?? null,
$in['rw'] ?? null,
(float)$in['lat'],
(float)$in['lng'],
max(1, (int)($in['jumlah_anggota'] ?? 1)),
max(0, (int)($in['jumlah_anak_sekolah'] ?? 0)),
$penghasilan,
$in['sumber_penghasilan'] ?? null,
$statusPekerjaan,
$kategori,
$in['kondisi_rumah'] ?? 'tidak_layak',
$in['kepemilikan_rumah'] ?? 'milik_sendiri',
(int)($in['punya_bpjs'] ?? 0),
$in['jenis_bpjs'] ?? 'tidak_punya',
'belum_diverifikasi',
$surveyorId,
]);
$newId = (int)$db->lastInsertId();
assignRumahIbadah($newId);
// Catat riwayat & notifikasi tidak block jika gagal (misal user_id tidak ada)
try {
catatRiwayat($newId, (int)($surveyorId ?? 1), 'submit', 'Data baru dari survei');
} catch(\Exception $e) { /* abaikan error audit trail */ }
try {
$db->prepare("
INSERT INTO notifikasi (jenis, judul, pesan, penduduk_id)
VALUES ('belum_verifikasi', 'Data Baru Belum Diverifikasi', ?, ?)
")->execute(["Data KK {$in['nama_kk']} (NIK: {$in['nik_kk']}) menunggu verifikasi admin.", $newId]);
} catch(\Exception $e) { /* abaikan error notifikasi */ }
$stmt = $db->prepare($SELECT_SQL . " WHERE pm.id = ?");
$stmt->execute([$newId]);
jsonResponse(['success' => true, 'data' => $stmt->fetch(), 'message' => 'Data penduduk berhasil ditambahkan']);
break;
// ════════════════════════════════════════════════════════
// PUT Update data penduduk
// ════════════════════════════════════════════════════════
case 'PUT':
$in = getInput();
$id = (int)($in['id'] ?? $_GET['id'] ?? 0);
if (!$id) jsonResponse(['success' => false, 'error' => 'ID diperlukan'], 400);
// Ambil data lama untuk audit
$old = $db->prepare("SELECT * FROM penduduk_miskin WHERE id = ?");
$old->execute([$id]);
$dataLama = $old->fetch();
if (!$dataLama) jsonResponse(['success' => false, 'error' => 'Data tidak ditemukan'], 404);
// Hitung kategori ulang jika penghasilan berubah
$penghasilan = array_key_exists('penghasilan_bulanan', $in)
? (($in['penghasilan_bulanan'] !== '') ? (float)$in['penghasilan_bulanan'] : null)
: (float)$dataLama['penghasilan_bulanan'];
$kategori = hitungKategori($penghasilan);
$allowed = [
'nama_kk','no_hp','alamat','wilayah_id','rt','rw','lat','lng',
'jumlah_anggota','jumlah_anak_sekolah','penghasilan_bulanan',
'sumber_penghasilan','status_pekerjaan','kondisi_rumah',
'kepemilikan_rumah','punya_bpjs','jenis_bpjs',
'status_verifikasi','catatan_verifikasi'
];
$fields = []; $values = []; $posChanged = false;
foreach ($allowed as $f) {
if (!array_key_exists($f, $in)) continue;
$fields[] = "$f = ?";
if ($f === 'lat' || $f === 'lng') { $values[] = (float)$in[$f]; $posChanged = true; }
elseif ($f === 'wilayah_id') { $values[] = (isset($in[$f]) && $in[$f] !== '') ? (int)$in[$f] : null; }
elseif ($f === 'status_pekerjaan') {
$statusPekerjaan = $in[$f] ?? 'tidak_bekerja';
$allowedPekerjaan = ['tidak_bekerja','buruh_harian','petani','nelayan','pedagang','pegawai_swasta','wiraswasta','lainnya'];
$values[] = in_array($statusPekerjaan, $allowedPekerjaan) ? $statusPekerjaan : 'lainnya';
}
elseif (in_array($f, ['jumlah_anggota','jumlah_anak_sekolah','punya_bpjs'])) $values[] = (int)$in[$f];
elseif ($f === 'penghasilan_bulanan') $values[] = $penghasilan;
else $values[] = trim((string)$in[$f]);
}
// Update kategori otomatis
$fields[] = "kategori_kemiskinan = ?";
$values[] = $kategori;
// Jika diverifikasi, catat verifikator
if (isset($in['status_verifikasi']) && $in['status_verifikasi'] === 'terverifikasi') {
$fields[] = "verified_by = ?"; $values[] = $in['verified_by'] ?? 1;
$fields[] = "verified_at = NOW()";
}
if (empty($fields)) jsonResponse(['success' => false, 'error' => 'Tidak ada data untuk diupdate'], 400);
$values[] = $id;
$db->prepare("UPDATE penduduk_miskin SET " . implode(', ', $fields) . " WHERE id = ?")
->execute($values);
if ($posChanged) assignRumahIbadah($id);
$aksi = 'update';
if (isset($in['status_verifikasi'])) {
if ($in['status_verifikasi'] === 'terverifikasi') {
$aksi = 'verifikasi';
} elseif ($in['status_verifikasi'] === 'ditolak') {
$aksi = 'tolak';
}
}
try {
catatRiwayat($id, (int)($in['updated_by'] ?? 1), $aksi, $in['catatan_verifikasi'] ?? null, $dataLama);
} catch(\Exception $e) { /* abaikan error audit trail */ }
$stmt = $db->prepare($SELECT_SQL . " WHERE pm.id = ?");
$stmt->execute([$id]);
jsonResponse(['success' => true, 'data' => $stmt->fetch(), 'message' => 'Data berhasil diperbarui']);
break;
// ════════════════════════════════════════════════════════
// DELETE Hapus penduduk
// ════════════════════════════════════════════════════════
case 'DELETE':
$id = (int)($_GET['id'] ?? getInput()['id'] ?? 0);
if (!$id) jsonResponse(['success' => false, 'error' => 'ID diperlukan'], 400);
// Hapus foto fisik terlebih dahulu
$fotos = $db->prepare("SELECT file_path FROM foto_rumah WHERE penduduk_id = ?");
$fotos->execute([$id]);
foreach ($fotos->fetchAll() as $foto) {
$path = __DIR__ . '/../' . $foto['file_path'];
if (file_exists($path)) @unlink($path);
}
catatRiwayat($id, 1, 'hapus', 'Data dihapus oleh admin');
$db->prepare("DELETE FROM penduduk_miskin WHERE id = ?")->execute([$id]);
jsonResponse(['success' => true, 'message' => 'Data penduduk berhasil dihapus']);
break;
default:
jsonResponse(['success' => false, 'error' => 'Method tidak diizinkan'], 405);
}
} catch (\Throwable $e) {
jsonResponse(['success' => false, 'error' => 'Database error: ' . $e->getMessage()], 500);
}
+131
View File
@@ -0,0 +1,131 @@
<?php
// api/rumah_ibadah.php
// CRUD API untuk Rumah Ibadah
require_once __DIR__ . '/../includes/config.php';
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') exit;
$method = $_SERVER['REQUEST_METHOD'];
$db = getDB();
switch ($method) {
// ── GET semua rumah ibadah + summary penduduk
case 'GET':
$id = $_GET['id'] ?? null;
if ($id) {
$stmt = $db->prepare("
SELECT ri.*,
COUNT(pm.id) as jumlah_kk,
COALESCE(SUM(pm.jumlah_anggota), 0) as total_jiwa
FROM rumah_ibadah ri
LEFT JOIN penduduk_miskin pm ON pm.rumah_ibadah_id = ri.id
WHERE ri.id = ?
GROUP BY ri.id
");
$stmt->execute([$id]);
$row = $stmt->fetch();
if ($row) jsonResponse(['success' => true, 'data' => $row]);
else jsonResponse(['success' => false, 'error' => 'Not found'], 404);
}
$rows = $db->query("
SELECT ri.*,
COUNT(pm.id) as jumlah_kk,
COALESCE(SUM(pm.jumlah_anggota), 0) as total_jiwa
FROM rumah_ibadah ri
LEFT JOIN penduduk_miskin pm ON pm.rumah_ibadah_id = ri.id
GROUP BY ri.id
ORDER BY ri.nama ASC
")->fetchAll();
jsonResponse(['success' => true, 'data' => $rows]);
break;
// ── POST buat rumah ibadah baru
case 'POST':
$input = getInput();
$required = ['nama', 'lat', 'lng'];
foreach ($required as $field) {
if (empty($input[$field])) {
jsonResponse(['success' => false, 'error' => "Field '$field' wajib diisi"], 400);
}
}
$stmt = $db->prepare("
INSERT INTO rumah_ibadah (nama, jenis, alamat, pic, no_wa, lat, lng, radius)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
");
$stmt->execute([
trim($input['nama']),
$input['jenis'] ?? 'masjid',
$input['alamat'] ?? '',
$input['pic'] ?? '',
$input['no_wa'] ?? '',
(float)$input['lat'],
(float)$input['lng'],
(int)($input['radius'] ?? 500),
]);
$newId = (int)$db->lastInsertId();
reassignAll();
$stmt2 = $db->prepare("SELECT * FROM rumah_ibadah WHERE id = ?");
$stmt2->execute([$newId]);
jsonResponse(['success' => true, 'data' => $stmt2->fetch(), 'message' => 'Rumah ibadah berhasil ditambahkan']);
break;
// ── PUT update rumah ibadah
case 'PUT':
$input = getInput();
$id = $input['id'] ?? $_GET['id'] ?? null;
if (!$id) jsonResponse(['success' => false, 'error' => 'ID diperlukan'], 400);
$fields = [];
$values = [];
$allowed = ['nama', 'jenis', 'alamat', 'pic', 'no_wa', 'lat', 'lng', 'radius'];
foreach ($allowed as $f) {
if (isset($input[$f])) {
$fields[] = "$f = ?";
$values[] = ($f === 'lat' || $f === 'lng') ? (float)$input[$f] :
($f === 'radius' ? (int)$input[$f] : trim($input[$f]));
}
}
if (empty($fields)) jsonResponse(['success' => false, 'error' => 'Tidak ada data yang diupdate'], 400);
$values[] = $id;
$db->prepare("UPDATE rumah_ibadah SET " . implode(', ', $fields) . " WHERE id = ?")->execute($values);
// Radius berubah → reassign semua
if (isset($input['radius']) || isset($input['lat']) || isset($input['lng'])) {
reassignAll();
}
$stmt = $db->prepare("SELECT * FROM rumah_ibadah WHERE id = ?");
$stmt->execute([$id]);
jsonResponse(['success' => true, 'data' => $stmt->fetch(), 'message' => 'Berhasil diupdate']);
break;
// ── DELETE hapus rumah ibadah
case 'DELETE':
$id = $_GET['id'] ?? getInput()['id'] ?? null;
if (!$id) jsonResponse(['success' => false, 'error' => 'ID diperlukan'], 400);
// Lepas referensi penduduk
$db->prepare("UPDATE penduduk_miskin SET rumah_ibadah_id = NULL, jarak_ke_ibadah = NULL WHERE rumah_ibadah_id = ?")
->execute([$id]);
$db->prepare("DELETE FROM rumah_ibadah WHERE id = ?")->execute([$id]);
reassignAll();
jsonResponse(['success' => true, 'message' => 'Berhasil dihapus']);
break;
default:
jsonResponse(['success' => false, 'error' => 'Method tidak diizinkan'], 405);
}
+29
View File
@@ -0,0 +1,29 @@
<?php
// api/update_radius.php
// Update radius satu rumah ibadah + reassign penduduk
require_once __DIR__ . '/../includes/config.php';
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') exit;
$input = getInput();
$id = $input['id'] ?? null;
$radius = $input['radius'] ?? null;
if (!$id || !$radius) {
jsonResponse(['success' => false, 'error' => 'id dan radius diperlukan'], 400);
}
$radius = max(100, min(5000, (int)$radius)); // clamp 1005000m
$db = getDB();
$db->prepare("UPDATE rumah_ibadah SET radius = ? WHERE id = ?")->execute([$radius, $id]);
reassignAll();
jsonResponse(['success' => true, 'radius' => $radius, 'message' => "Radius diubah ke {$radius}m, penduduk di-reassign"]);
+68
View File
@@ -0,0 +1,68 @@
<?php
// api/wilayah.php v2.0
// ============================================================
// API Data Wilayah (RT/RW/Kelurahan/Kecamatan)
// ============================================================
require_once __DIR__ . '/../includes/config.php';
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') exit;
$method = $_SERVER['REQUEST_METHOD'];
$db = getDB();
switch ($method) {
// ── GET semua wilayah atau per filter ─────────────────
case 'GET':
$kelurahan = $_GET['kelurahan'] ?? null;
$kecamatan = $_GET['kecamatan'] ?? null;
// GET /api/wilayah.php?list=kelurahan → distinct kelurahan
if (isset($_GET['list'])) {
$type = $_GET['list'];
if ($type === 'kelurahan') {
$rows = $db->query("SELECT DISTINCT kelurahan, kecamatan FROM wilayah ORDER BY kelurahan")->fetchAll();
jsonResponse(['success' => true, 'data' => $rows]);
}
if ($type === 'kecamatan') {
$rows = $db->query("SELECT DISTINCT kecamatan FROM wilayah ORDER BY kecamatan")->fetchAll();
jsonResponse(['success' => true, 'data' => array_column($rows, 'kecamatan')]);
}
}
$sql = "SELECT * FROM wilayah WHERE 1=1";
$params = [];
if ($kelurahan) { $sql .= " AND kelurahan = ?"; $params[] = $kelurahan; }
if ($kecamatan) { $sql .= " AND kecamatan = ?"; $params[] = $kecamatan; }
$sql .= " ORDER BY kelurahan, rt, rw";
$stmt = $db->prepare($sql);
$stmt->execute($params);
jsonResponse(['success' => true, 'data' => $stmt->fetchAll()]);
break;
// ── POST tambah wilayah ───────────────────────────────
case 'POST':
$in = getInput();
if (empty($in['kelurahan']) || empty($in['kecamatan'])) {
jsonResponse(['success' => false, 'error' => 'Kelurahan dan kecamatan wajib diisi'], 400);
}
$db->prepare("INSERT INTO wilayah (kelurahan, kecamatan, kota, rt, rw) VALUES (?,?,?,?,?)")
->execute([
trim($in['kelurahan']),
trim($in['kecamatan']),
trim($in['kota'] ?? 'Pontianak'),
$in['rt'] ?? null,
$in['rw'] ?? null,
]);
jsonResponse(['success' => true, 'id' => $db->lastInsertId(), 'message' => 'Wilayah ditambahkan']);
break;
default:
jsonResponse(['success' => false, 'error' => 'Method tidak diizinkan'], 405);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+363
View File
@@ -0,0 +1,363 @@
-- ============================================================
-- SIG Penduduk Miskin v2.0 Database Schema
-- WebGIS Pendataan & Monitoring Bantuan Sosial
-- Update: 2026 | Versi: 2.0
-- ============================================================
CREATE DATABASE IF NOT EXISTS sig_kemiskinan CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE sig_kemiskinan;
SET FOREIGN_KEY_CHECKS = 0;
-- ============================================================
-- TABEL 1: users
-- ============================================================
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(100) NOT NULL,
username VARCHAR(50) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL COMMENT 'bcrypt hash',
role ENUM('admin','petugas','pimpinan') NOT NULL DEFAULT 'petugas',
wilayah_tugas VARCHAR(100) NULL COMMENT 'Kelurahan/RT wilayah tugas petugas',
aktif TINYINT(1) DEFAULT 1,
last_login TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_username (username),
INDEX idx_role (role)
);
-- ============================================================
-- TABEL 2: wilayah
-- ============================================================
CREATE TABLE IF NOT EXISTS wilayah (
id INT AUTO_INCREMENT PRIMARY KEY,
kelurahan VARCHAR(100) NOT NULL,
kecamatan VARCHAR(100) NOT NULL,
kota VARCHAR(100) NOT NULL DEFAULT 'Pontianak',
rt VARCHAR(10) NULL,
rw VARCHAR(10) NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_kelurahan (kelurahan),
INDEX idx_kecamatan (kecamatan)
);
-- ============================================================
-- TABEL 3: rumah_ibadah (Tidak berubah)
-- ============================================================
CREATE TABLE IF NOT EXISTS rumah_ibadah (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(255) NOT NULL,
jenis ENUM('masjid','musholla','gereja','pura','vihara','klenteng','lainnya') DEFAULT 'masjid',
alamat TEXT,
pic VARCHAR(255) COMMENT 'Person In Charge / Penanggung Jawab',
no_wa VARCHAR(20),
lat DECIMAL(10, 8) NOT NULL,
lng DECIMAL(11, 8) NOT NULL,
radius INT DEFAULT 500 COMMENT 'Radius dalam meter',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- ============================================================
-- TABEL 4: penduduk_miskin (DIPERLUAS dari v1.1)
-- ============================================================
CREATE TABLE IF NOT EXISTS penduduk_miskin (
id INT AUTO_INCREMENT PRIMARY KEY,
-- Identitas Kepala Keluarga
nik_kk VARCHAR(16) NOT NULL UNIQUE COMMENT 'NIK KK 16 digit',
nama_kk VARCHAR(255) NOT NULL COMMENT 'Nama Kepala Keluarga',
no_hp VARCHAR(20) NULL COMMENT 'No HP/WhatsApp',
-- Lokasi
alamat TEXT NULL,
wilayah_id INT NULL,
rt VARCHAR(10) NULL,
rw VARCHAR(10) NULL,
lat DECIMAL(10,8) NOT NULL,
lng DECIMAL(11,8) NOT NULL,
-- Data Keluarga
jumlah_anggota INT NOT NULL DEFAULT 1,
jumlah_anak_sekolah INT NOT NULL DEFAULT 0,
-- Data Ekonomi
penghasilan_bulanan DECIMAL(12,2) NULL COMMENT 'Penghasilan per bulan (Rp)',
sumber_penghasilan VARCHAR(255) NULL,
status_pekerjaan ENUM(
'tidak_bekerja','buruh_harian','petani','nelayan',
'pedagang','pegawai_swasta','wiraswasta','lainnya'
) DEFAULT 'tidak_bekerja',
-- Kategori Kemiskinan (dihitung otomatis)
kategori_kemiskinan ENUM('sangat_miskin','miskin','hampir_miskin','mampu') DEFAULT 'sangat_miskin',
-- Kondisi Rumah
kondisi_rumah ENUM('sangat_tidak_layak','tidak_layak','layak') DEFAULT 'tidak_layak',
kepemilikan_rumah ENUM('milik_sendiri','kontrak','menumpang','lainnya') DEFAULT 'milik_sendiri',
-- Status BPJS
punya_bpjs TINYINT(1) DEFAULT 0,
jenis_bpjs ENUM('PBI','mandiri','tidak_punya') DEFAULT 'tidak_punya',
-- Relasi ke Rumah Ibadah (kompatibel v1.1)
rumah_ibadah_id INT NULL,
jarak_ke_ibadah DECIMAL(10,2) NULL COMMENT 'Jarak dalam meter',
-- Status Verifikasi
status_verifikasi ENUM('belum_diverifikasi','terverifikasi','ditolak') DEFAULT 'belum_diverifikasi',
catatan_verifikasi TEXT NULL,
-- Audit Trail
surveyor_id INT NULL COMMENT 'FK users - petugas input',
verified_by INT NULL COMMENT 'FK users - admin verifikator',
verified_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- Foreign Keys
CONSTRAINT fk_pm_wilayah FOREIGN KEY (wilayah_id) REFERENCES wilayah(id) ON DELETE SET NULL,
CONSTRAINT fk_pm_ibadah FOREIGN KEY (rumah_ibadah_id) REFERENCES rumah_ibadah(id) ON DELETE SET NULL,
CONSTRAINT fk_pm_surveyor FOREIGN KEY (surveyor_id) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_pm_verif FOREIGN KEY (verified_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_nik (nik_kk),
INDEX idx_lat_lng (lat, lng),
INDEX idx_kategori (kategori_kemiskinan),
INDEX idx_wilayah (wilayah_id),
INDEX idx_status_v (status_verifikasi),
INDEX idx_surveyor (surveyor_id)
);
-- ============================================================
-- TABEL 5: foto_rumah
-- ============================================================
CREATE TABLE IF NOT EXISTS foto_rumah (
id INT AUTO_INCREMENT PRIMARY KEY,
penduduk_id INT NOT NULL,
file_path VARCHAR(500) NOT NULL COMMENT 'Path relatif dari root web',
lat_foto DECIMAL(10,8) NULL COMMENT 'Koordinat GPS saat foto diambil',
lng_foto DECIMAL(11,8) NULL,
accuracy_meter DECIMAL(8,2) NULL COMMENT 'Akurasi GPS dalam meter',
is_primary TINYINT(1) DEFAULT 0 COMMENT '1 = foto utama (thumbnail peta)',
ukuran_kb INT NULL,
keterangan VARCHAR(255) NULL,
diambil_oleh INT NULL COMMENT 'FK users',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_fr_penduduk FOREIGN KEY (penduduk_id) REFERENCES penduduk_miskin(id) ON DELETE CASCADE,
CONSTRAINT fk_fr_user FOREIGN KEY (diambil_oleh) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_penduduk (penduduk_id),
INDEX idx_primary (penduduk_id, is_primary)
);
-- ============================================================
-- TABEL 6: jenis_bantuan
-- ============================================================
CREATE TABLE IF NOT EXISTS jenis_bantuan (
id INT AUTO_INCREMENT PRIMARY KEY,
kode VARCHAR(20) NOT NULL UNIQUE,
nama VARCHAR(100) NOT NULL,
deskripsi TEXT NULL,
warna_badge VARCHAR(7) DEFAULT '#4f8ef7' COMMENT 'Hex color badge',
aktif TINYINT(1) DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- ============================================================
-- TABEL 7: bantuan_penduduk (Many-to-Many pivot)
-- ============================================================
CREATE TABLE IF NOT EXISTS bantuan_penduduk (
id INT AUTO_INCREMENT PRIMARY KEY,
penduduk_id INT NOT NULL,
bantuan_id INT NOT NULL,
status ENUM('aktif','tidak_aktif','dicabut') DEFAULT 'aktif',
tanggal_mulai DATE NULL,
tanggal_selesai DATE NULL,
nominal_bulanan DECIMAL(12,2) NULL,
nomor_kpj VARCHAR(50) NULL COMMENT 'Nomor kartu/KPJ bantuan',
keterangan TEXT NULL,
diinput_oleh INT NULL COMMENT 'FK users',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_bp_penduduk FOREIGN KEY (penduduk_id) REFERENCES penduduk_miskin(id) ON DELETE CASCADE,
CONSTRAINT fk_bp_bantuan FOREIGN KEY (bantuan_id) REFERENCES jenis_bantuan(id) ON DELETE RESTRICT,
CONSTRAINT fk_bp_user FOREIGN KEY (diinput_oleh) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY uq_penduduk_bantuan (penduduk_id, bantuan_id),
INDEX idx_penduduk (penduduk_id),
INDEX idx_bantuan (bantuan_id),
INDEX idx_status (status)
);
-- ============================================================
-- TABEL 8: riwayat_verifikasi (Audit Trail)
-- ============================================================
CREATE TABLE IF NOT EXISTS riwayat_verifikasi (
id INT AUTO_INCREMENT PRIMARY KEY,
penduduk_id INT NOT NULL,
user_id INT NOT NULL,
aksi ENUM('submit','verifikasi','tolak','update','hapus') NOT NULL,
catatan TEXT NULL,
data_lama JSON NULL COMMENT 'Snapshot data sebelum perubahan',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_rv_penduduk FOREIGN KEY (penduduk_id) REFERENCES penduduk_miskin(id) ON DELETE CASCADE,
CONSTRAINT fk_rv_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT,
INDEX idx_penduduk (penduduk_id),
INDEX idx_user (user_id),
INDEX idx_tanggal (created_at)
);
-- ============================================================
-- TABEL 9: notifikasi
-- ============================================================
CREATE TABLE IF NOT EXISTS notifikasi (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NULL COMMENT 'NULL = broadcast semua admin',
jenis ENUM('belum_bantuan','belum_verifikasi','data_usang','info') DEFAULT 'info',
judul VARCHAR(255) NOT NULL,
pesan TEXT NOT NULL,
penduduk_id INT NULL COMMENT 'Referensi penduduk terkait',
sudah_dibaca TINYINT(1) DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_notif_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_notif_penduduk FOREIGN KEY (penduduk_id) REFERENCES penduduk_miskin(id) ON DELETE SET NULL,
INDEX idx_user_baca (user_id, sudah_dibaca),
INDEX idx_jenis (jenis)
);
-- ============================================================
-- INDEX SPASIAL
-- ============================================================
CREATE INDEX idx_ri_lat_lng ON rumah_ibadah(lat, lng);
SET FOREIGN_KEY_CHECKS = 1;
-- ============================================================
-- DATA AWAL: users
-- ============================================================
INSERT INTO users (nama, username, password, role) VALUES
('Administrator', 'admin', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'admin'),
('Petugas Survei', 'petugas', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'petugas'),
('Kepala Dinas', 'pimpinan', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'pimpinan');
-- Password semua akun: "password" (bcrypt)
-- ============================================================
-- DATA AWAL: wilayah
-- ============================================================
INSERT INTO wilayah (kelurahan, kecamatan, kota, rt, rw) VALUES
('Siantan Tengah', 'Pontianak Utara', 'Pontianak', '01', '01'),
('Siantan Tengah', 'Pontianak Utara', 'Pontianak', '02', '01'),
('Siantan Hilir', 'Pontianak Utara', 'Pontianak', '01', '02'),
('Batu Layang', 'Pontianak Utara', 'Pontianak', '01', '01'),
('Batu Layang', 'Pontianak Utara', 'Pontianak', '02', '02'),
('Saigon', 'Pontianak Timur', 'Pontianak', '01', '01'),
('Saigon', 'Pontianak Timur', 'Pontianak', '02', '01'),
('Tanjung Hulu', 'Pontianak Timur', 'Pontianak', '01', '01'),
('Parit Mayor', 'Pontianak Timur', 'Pontianak', '01', '01'),
('Pontianak Kota', 'Pontianak Kota', 'Pontianak', '01', '01');
-- ============================================================
-- DATA AWAL: rumah_ibadah
-- ============================================================
INSERT INTO rumah_ibadah (nama, jenis, alamat, pic, no_wa, lat, lng, radius) VALUES
('Masjid Al-Ikhlas', 'masjid', 'Jl. Merdeka No. 1, Pontianak', 'Ahmad Fauzi', '081234567890', -0.0263303, 109.3425094, 500),
('Masjid Ar-Rahmat', 'masjid', 'Jl. Gajah Mada No. 45, Pontianak', 'Budi Santoso', '082345678901', -0.0320000, 109.3380000, 500),
('Gereja Bethel', 'gereja', 'Jl. Diponegoro No. 12, Pontianak', 'Pendeta Yusuf', '083456789012', -0.0290000, 109.3450000, 500);
-- ============================================================
-- DATA AWAL: jenis_bantuan
-- ============================================================
INSERT INTO jenis_bantuan (kode, nama, deskripsi, warna_badge) VALUES
('PKH', 'Program Keluarga Harapan', 'Bantuan tunai bersyarat untuk KK miskin', '#22c55e'),
('BPNT', 'Bantuan Pangan Non Tunai', 'Sembako via e-warong setiap 2 bulan', '#3b82f6'),
('KIS', 'Kartu Indonesia Sehat', 'Akses layanan kesehatan gratis (BPJS PBI)', '#ec4899'),
('BLT', 'Bantuan Langsung Tunai', 'Bantuan tunai darurat/sementara dari desa/kel', '#f59e0b'),
('BST', 'Bantuan Sosial Tunai', 'Bantuan sosial tunai dari Kemensos', '#8b5cf6'),
('PIP', 'Program Indonesia Pintar', 'Bantuan pendidikan untuk anak sekolah', '#06b6d4');
-- ============================================================
-- DATA AWAL: penduduk_miskin (contoh)
-- ============================================================
INSERT INTO penduduk_miskin (
nik_kk, nama_kk, no_hp, alamat, wilayah_id, rt, rw,
lat, lng, jumlah_anggota, jumlah_anak_sekolah,
penghasilan_bulanan, sumber_penghasilan, status_pekerjaan,
kategori_kemiskinan, kondisi_rumah, kepemilikan_rumah,
punya_bpjs, jenis_bpjs, status_verifikasi, surveyor_id
) VALUES
('6171010101010001', 'Suyatno', '081200000001', 'Jl. Merdeka Gg. III No. 5', 1, '01', '01', -0.0270000, 109.3430000, 4, 2, 850000, 'Buruh bangunan', 'buruh_harian', 'miskin', 'tidak_layak', 'menumpang', 1, 'PBI', 'terverifikasi', 1),
('6171010101010002', 'Rohani', '081200000002', 'Jl. Merdeka Gg. IV No. 2', 1, '02', '01', -0.0255000, 109.3420000, 3, 1, 450000, 'Jualan kecil', 'pedagang', 'sangat_miskin', 'sangat_tidak_layak','menumpang', 1, 'PBI', 'terverifikasi', 1),
('6171010101010003', 'Hamid Husin', '081200000003', 'Jl. Gajah Mada Gg. II No. 8', 2, '01', '02', -0.0315000, 109.3375000, 5, 3, 1200000, 'Ojek online', 'buruh_harian', 'mampu', 'tidak_layak', 'kontrak', 0, 'tidak_punya','belum_diverifikasi',2),
('6171010101010004', 'Yanti Susanti', '081200000004', 'Jl. Veteran No. 99', 3, '01', '01', -0.0500000, 109.3600000, 2, 0, 300000, 'Tidak ada', 'tidak_bekerja', 'sangat_miskin', 'sangat_tidak_layak','menumpang', 1, 'PBI', 'terverifikasi', 1),
('6171010101010005', 'Supriyadi', '081200000005', 'Jl. Budi Utomo No. 12', 4, '02', '01', -0.0340000, 109.3510000, 6, 4, 600000, 'Petani kecil', 'petani', 'miskin', 'tidak_layak', 'milik_sendiri', 1, 'PBI', 'terverifikasi', 1),
('6171010101010006', 'Marlina', '081200000006', 'Gg. Damai No. 3', 5, '01', '02', -0.0410000, 109.3480000, 3, 1, 200000, 'Buruh cuci', 'buruh_harian', 'sangat_miskin', 'sangat_tidak_layak','menumpang', 0, 'tidak_punya','belum_diverifikasi',2),
('6171010101010007', 'Junaidi', '081200000007', 'Jl. Khatulistiwa No. 45', 6, '01', '01', -0.0380000, 109.3550000, 4, 2, 950000, 'Nelayan', 'nelayan', 'miskin', 'tidak_layak', 'milik_sendiri', 1, 'mandiri', 'terverifikasi', 1),
('6171010101010008', 'Siti Aminah', '081200000008', 'Gg. Melati No. 7', 7, '02', '02', -0.0290000, 109.3395000, 5, 3, 400000, 'Pembantu RT', 'buruh_harian', 'sangat_miskin', 'sangat_tidak_layak','menumpang', 1, 'PBI', 'belum_diverifikasi',2),
('6171010101010009', 'Rahman', '081200000009', 'Jl. Tanjung Pura No. 88', 8, '01', '01', -0.0220000, 109.3460000, 3, 1, 750000, 'Pedagang kaki lima','pedagang', 'miskin', 'tidak_layak', 'kontrak', 0, 'tidak_punya','terverifikasi', 1),
('6171010101010010', 'Dewi Kartika', '081200000010', 'Gg. Bahagia No. 2', 9, '01', '01', -0.0450000, 109.3530000, 4, 2, 500000, 'Buruh pabrik', 'buruh_harian', 'sangat_miskin', 'tidak_layak', 'kontrak', 1, 'PBI', 'terverifikasi', 1);
-- ============================================================
-- DATA AWAL: bantuan_penduduk
-- ============================================================
INSERT INTO bantuan_penduduk (penduduk_id, bantuan_id, status, tanggal_mulai, nominal_bulanan, diinput_oleh) VALUES
(1, 1, 'aktif', '2024-01-01', 500000, 1), -- Suyatno: PKH
(1, 2, 'aktif', '2024-01-01', 200000, 1), -- Suyatno: BPNT
(2, 1, 'aktif', '2023-07-01', 500000, 1), -- Rohani: PKH
(2, 3, 'aktif', '2023-07-01', 0, 1), -- Rohani: KIS
(4, 1, 'aktif', '2024-03-01', 500000, 1), -- Yanti: PKH
(4, 3, 'aktif', '2024-03-01', 0, 1), -- Yanti: KIS
(5, 2, 'aktif', '2024-02-01', 200000, 1), -- Supriyadi: BPNT
(7, 3, 'aktif', '2023-09-01', 0, 1), -- Junaidi: KIS
(9, 2, 'aktif', '2024-01-01', 200000, 1), -- Rahman: BPNT
(10, 1, 'aktif','2024-04-01', 500000, 1), -- Dewi: PKH
(10, 3, 'aktif','2024-04-01', 0, 1); -- Dewi: KIS
-- ============================================================
-- Update rumah_ibadah assignment (kompatibel v1.1)
-- ============================================================
UPDATE penduduk_miskin pm
SET
rumah_ibadah_id = (
SELECT ri.id FROM rumah_ibadah ri
WHERE (
6371000 * ACOS(GREATEST(-1, LEAST(1,
COS(RADIANS(pm.lat)) * COS(RADIANS(ri.lat)) *
COS(RADIANS(ri.lng) - RADIANS(pm.lng)) +
SIN(RADIANS(pm.lat)) * SIN(RADIANS(ri.lat))
)))
) <= ri.radius
ORDER BY (
6371000 * ACOS(GREATEST(-1, LEAST(1,
COS(RADIANS(pm.lat)) * COS(RADIANS(ri.lat)) *
COS(RADIANS(ri.lng) - RADIANS(pm.lng)) +
SIN(RADIANS(pm.lat)) * SIN(RADIANS(ri.lat))
)))
) ASC
LIMIT 1
),
jarak_ke_ibadah = (
SELECT ROUND(
6371000 * ACOS(GREATEST(-1, LEAST(1,
COS(RADIANS(pm.lat)) * COS(RADIANS(ri.lat)) *
COS(RADIANS(ri.lng) - RADIANS(pm.lng)) +
SIN(RADIANS(pm.lat)) * SIN(RADIANS(ri.lat))
))), 2
) FROM rumah_ibadah ri
WHERE (
6371000 * ACOS(GREATEST(-1, LEAST(1,
COS(RADIANS(pm.lat)) * COS(RADIANS(ri.lat)) *
COS(RADIANS(ri.lng) - RADIANS(pm.lng)) +
SIN(RADIANS(pm.lat)) * SIN(RADIANS(ri.lat))
)))
) <= ri.radius
LIMIT 1
);
+30
View File
@@ -0,0 +1,30 @@
<?php
// includes/auth.php v2.0
// ============================================================
// Middleware Autentikasi berbasis PHP Session
// ============================================================
require_once __DIR__ . '/config.php';
session_start();
// ── Cek apakah user sudah login ───────────────────────────
function requireAuth(array $allowedRoles = []): array {
if (!isset($_SESSION['user_id'])) {
jsonResponse(['success' => false, 'error' => 'Unauthorized silakan login', 'redirect' => '/index.html'], 401);
}
$user = $_SESSION['user'];
if (!empty($allowedRoles) && !in_array($user['role'], $allowedRoles)) {
jsonResponse(['success' => false, 'error' => 'Akses ditolak untuk role: ' . $user['role']], 403);
}
return $user;
}
// ── Ambil user saat ini (null jika belum login) ───────────
function currentUser(): ?array {
return $_SESSION['user'] ?? null;
}
// ── Cek apakah sudah login (tanpa redirect) ───────────────
function isLoggedIn(): bool {
return isset($_SESSION['user_id']);
}
+144
View File
@@ -0,0 +1,144 @@
<?php
// includes/config.php v2.0
// ============================================================
// Konfigurasi Database & Konstanta Aplikasi
// ============================================================
// ── Database ──────────────────────────────────────────────
define('DB_HOST', 'localhost');
define('DB_USER', 'root');
define('DB_PASS', '');
define('DB_NAME', 'sig_kemiskinan');
define('DB_CHARSET', 'utf8mb4');
// ── Aplikasi ──────────────────────────────────────────────
define('APP_NAME', 'SIG Kemiskinan v2.0');
define('NOMINATIM_URL', 'https://nominatim.openstreetmap.org');
// ── Upload Foto ────────────────────────────────────────────
define('UPLOAD_DIR', __DIR__ . '/../assets/img/uploads/');
define('UPLOAD_URL', 'assets/img/uploads/');
define('MAX_FOTO_SIZE', 5 * 1024 * 1024); // 5 MB
define('ALLOWED_EXT', ['jpg','jpeg','png','webp']);
// ── Threshold Kategori Kemiskinan (Rp/bulan) ──────────────
// Disesuaikan dengan standar BPS & UMR Kalimantan Barat 2024
define('BATAS_SANGAT_MISKIN', 1500000); // < 1.5jt → sangat_miskin
define('BATAS_MISKIN', 3000000); // < 3jt → miskin
define('BATAS_HAMPIR_MISKIN', 5000000); // < 5jt → hampir_miskin
// >= 5jt → mampu
// ── Notifikasi ────────────────────────────────────────────
define('DATA_USANG_HARI', 30); // data > 30 hari belum update = notif usang
// ============================================================
// FUNGSI: Koneksi Database (Singleton PDO)
// ============================================================
function getDB(): PDO {
static $pdo = null;
if ($pdo === null) {
$dsn = "mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=" . DB_CHARSET;
$options = [
PDO::ATTR_ERRMODE => 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, $options);
} catch (PDOException $e) {
http_response_code(500);
die(json_encode(['success' => false, 'error' => 'Database connection failed: ' . $e->getMessage()]));
}
}
return $pdo;
}
// ============================================================
// FUNGSI: Response JSON
// ============================================================
function jsonResponse(mixed $data, int $code = 200): void {
http_response_code($code);
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($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
// ============================================================
// FUNGSI: Baca Input (JSON body atau $_POST)
// ============================================================
function getInput(): array {
$raw = file_get_contents('php://input');
$data = json_decode($raw, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$data = $_POST;
}
return $data ?? [];
}
// ============================================================
// FUNGSI: Hitung Jarak Haversine (meter)
// ============================================================
function haversine(float $lat1, float $lng1, float $lat2, float $lng2): float {
$R = 6371000;
$dLat = deg2rad($lat2 - $lat1);
$dLng = deg2rad($lng2 - $lng1);
$a = sin($dLat/2)**2 + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLng/2)**2;
return $R * 2 * atan2(sqrt($a), sqrt(1 - $a));
}
// ============================================================
// FUNGSI: Hitung Kategori Kemiskinan Otomatis
// ============================================================
function hitungKategori(?float $penghasilan): string {
if ($penghasilan === null || $penghasilan < BATAS_SANGAT_MISKIN) return 'sangat_miskin';
if ($penghasilan < BATAS_MISKIN) return 'miskin';
if ($penghasilan < BATAS_HAMPIR_MISKIN) return 'hampir_miskin';
return 'mampu';
}
// ============================================================
// FUNGSI: Assign Rumah Ibadah (kompatibel v1.1)
// ============================================================
function assignRumahIbadah(int $pendudukId): void {
try {
$db = getDB();
$p = $db->prepare("SELECT lat, lng FROM penduduk_miskin WHERE id = ?");
$p->execute([$pendudukId]);
$row = $p->fetch();
if (!$row) return;
$ibadahList = $db->query("SELECT id, lat, lng, radius FROM rumah_ibadah")->fetchAll();
$closest = null;
$closestDist = PHP_FLOAT_MAX;
foreach ($ibadahList as $ri) {
$dist = haversine((float)$row['lat'], (float)$row['lng'], (float)$ri['lat'], (float)$ri['lng']);
if ($dist <= $ri['radius'] && $dist < $closestDist) {
$closest = $ri['id'];
$closestDist = $dist;
}
}
$db->prepare("UPDATE penduduk_miskin SET rumah_ibadah_id = ?, jarak_ke_ibadah = ? WHERE id = ?")
->execute([$closest, $closest ? round($closestDist, 2) : null, $pendudukId]);
} catch (\Exception $e) { /* abaikan error assign ibadah */ }
}
function reassignAll(): void {
$db = getDB();
$all = $db->query("SELECT id FROM penduduk_miskin")->fetchAll();
foreach ($all as $row) assignRumahIbadah((int)$row['id']);
}
// ============================================================
// FUNGSI: Catat Riwayat Verifikasi
// ============================================================
function catatRiwayat(int $pendudukId, int $userId, string $aksi, ?string $catatan = null, ?array $dataLama = null): void {
$db = getDB();
$db->prepare("
INSERT INTO riwayat_verifikasi (penduduk_id, user_id, aksi, catatan, data_lama)
VALUES (?, ?, ?, ?, ?)
")->execute([$pendudukId, $userId, $aksi, $catatan, $dataLama ? json_encode($dataLama, JSON_UNESCAPED_UNICODE) : null]);
}
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
<?php
require_once __DIR__ . '/includes/config.php';
header('Content-Type: text/plain');
try {
$db = getDB();
// 1. Update ENUM to include 'mampu'
echo "Updating ENUM for kategori_kemiskinan...\n";
$db->exec("ALTER TABLE penduduk_miskin MODIFY kategori_kemiskinan ENUM('sangat_miskin','miskin','hampir_miskin','mampu') DEFAULT 'sangat_miskin'");
echo "ENUM updated successfully!\n\n";
// 2. Re-calculate all existing records
echo "Re-calculating categories for existing records...\n";
$stmt = $db->query("SELECT id, penghasilan_bulanan FROM penduduk_miskin");
$rows = $stmt->fetchAll();
$update = $db->prepare("UPDATE penduduk_miskin SET kategori_kemiskinan = ? WHERE id = ?");
$counts = ['sangat_miskin' => 0, 'miskin' => 0, 'hampir_miskin' => 0, 'mampu' => 0];
foreach ($rows as $row) {
$p = $row['penghasilan_bulanan'] !== null ? (float)$row['penghasilan_bulanan'] : null;
if ($p === null || $p < BATAS_SANGAT_MISKIN) $kat = 'sangat_miskin';
elseif ($p < BATAS_MISKIN) $kat = 'miskin';
elseif ($p < BATAS_HAMPIR_MISKIN) $kat = 'hampir_miskin';
else $kat = 'mampu';
$update->execute([$kat, $row['id']]);
$counts[$kat]++;
}
echo "Done! Updated " . count($rows) . " records:\n";
foreach ($counts as $kat => $c) {
echo " $kat: $c\n";
}
} catch (\Exception $e) {
echo "ERROR: " . $e->getMessage() . "\n";
}