Initial commit

This commit is contained in:
Trebleif
2026-06-11 13:54:32 +07:00
commit 7773461914
7 changed files with 3925 additions and 0 deletions
BIN
View File
Binary file not shown.
+935
View File
@@ -0,0 +1,935 @@
<?php
// ============================================================
// api.php — REST API WebGIS Rumah Ibadah
// Endpoint: /api.php?resource=rumah_ibadah&action=...
// /api.php?resource=lokasi_bantuan&action=...
// /api.php?resource=sumbangan&action=...
// /api.php?resource=penyaluran&action=...
// ============================================================
require_once 'koneksi.php';
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');
// Preflight CORS
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
$method = $_SERVER['REQUEST_METHOD'];
$resource = $_GET['resource'] ?? '';
$action = $_GET['action'] ?? 'list';
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
$body = json_decode(file_get_contents('php://input'), true) ?? [];
// ============================================================
// ROUTER
// ============================================================
try {
switch ($resource) {
case 'rumah_ibadah':
handleRumahIbadah($method, $action, $id, $body);
break;
case 'lokasi_bantuan':
handleLokasiBantuan($method, $action, $id, $body);
break;
case 'sumbangan':
handleSumbangan($method, $action, $id, $body);
break;
case 'penyaluran':
handlePenyaluran($method, $action, $id, $body);
break;
case 'spbu':
handleSpbu($method, $action, $id, $body);
break;
case 'jalan':
handleJalan($method, $action, $id, $body);
break;
case 'parsil':
handleParsil($method, $action, $id, $body);
break;
case 'setup':
handleSetup();
break;
default:
jsonError('Resource tidak ditemukan.', 404);
}
} catch (PDOException $e) {
jsonError('Database error: ' . $e->getMessage(), 500);
} catch (Exception $e) {
jsonError($e->getMessage(), 400);
}
// ============================================================
// RUMAH IBADAH
// ============================================================
function handleRumahIbadah(string $method, string $action, ?int $id, array $body): void {
$db = getDB();
// GET LIST
if ($method === 'GET' && $action === 'list') {
$rows = $db->query(
"SELECT id, nama, jenis, alamat, narahubung, latitude, longitude, radius_aktif,
created_at
FROM rumah_ibadah
ORDER BY nama ASC"
)->fetchAll();
jsonOk($rows);
}
// GET DETAIL + jumlah penerima dalam radius
elseif ($method === 'GET' && $action === 'detail' && $id) {
$stmt = $db->prepare(
"SELECT id, nama, jenis, alamat, narahubung, latitude, longitude,
radius_aktif, created_at
FROM rumah_ibadah WHERE id = ?"
);
$stmt->execute([$id]);
$ri = $stmt->fetch();
if (!$ri) jsonError('Data tidak ditemukan.', 404);
// Hitung penerima dalam radius
$stmtCount = $db->prepare(
"SELECT COUNT(*) AS total
FROM lokasi_bantuan lb, rumah_ibadah ri
WHERE ri.id = ?
AND ST_Distance_Sphere(lb.koordinat, ri.koordinat) <= ri.radius_aktif"
);
$stmtCount->execute([$id]);
$ri['jumlah_dalam_radius'] = (int)$stmtCount->fetchColumn();
jsonOk($ri);
}
// POST CREATE
elseif ($method === 'POST' && $action === 'create') {
$v = validasiRI($body);
$stmt = $db->prepare(
"INSERT INTO rumah_ibadah
(nama, jenis, alamat, narahubung, latitude, longitude, koordinat, radius_aktif)
VALUES
(:nama, :jenis, :alamat, :narahubung, :lat, :lng,
ST_GeomFromText(:titik, 4326), :radius)"
);
$stmt->execute([
':nama' => $v['nama'],
':jenis' => $v['jenis'],
':alamat' => $v['alamat'],
':narahubung' => $v['narahubung'],
':lat' => $v['latitude'],
':lng' => $v['longitude'],
':titik' => "POINT({$v['latitude']} {$v['longitude']})",
':radius' => $v['radius_aktif'],
]);
$newId = (int)$db->lastInsertId();
jsonOk(['message' => 'Rumah ibadah berhasil ditambahkan.', 'id' => $newId], 201);
}
// PUT UPDATE
elseif ($method === 'PUT' && $action === 'update' && $id) {
$v = validasiRI($body);
$stmt = $db->prepare(
"UPDATE rumah_ibadah SET
nama = :nama, jenis = :jenis, alamat = :alamat,
narahubung = :narahubung, latitude = :lat, longitude = :lng,
koordinat = ST_GeomFromText(:titik, 4326), radius_aktif = :radius
WHERE id = :id"
);
$stmt->execute([
':nama' => $v['nama'],
':jenis' => $v['jenis'],
':alamat' => $v['alamat'],
':narahubung' => $v['narahubung'],
':lat' => $v['latitude'],
':lng' => $v['longitude'],
':titik' => "POINT({$v['latitude']} {$v['longitude']})",
':radius' => $v['radius_aktif'],
':id' => $id,
]);
jsonOk(['message' => 'Data rumah ibadah berhasil diperbarui.']);
}
// DELETE
elseif ($method === 'DELETE' && $action === 'delete' && $id) {
$stmt = $db->prepare("DELETE FROM rumah_ibadah WHERE id = ?");
$stmt->execute([$id]);
jsonOk(['message' => 'Rumah ibadah berhasil dihapus.']);
}
else {
jsonError('Aksi tidak dikenali.', 400);
}
}
function validasiRI(array $b): array {
$nama = trim($b['nama'] ?? '');
$jenis = trim($b['jenis'] ?? '');
$alamat = trim($b['alamat'] ?? '');
$lat = isset($b['latitude']) ? (float)$b['latitude'] : null;
$lng = isset($b['longitude']) ? (float)$b['longitude'] : null;
$radius = isset($b['radius_aktif']) ? (int)$b['radius_aktif'] : 500;
$jenisList = ['Masjid','Gereja','Pura','Vihara','Kelenteng'];
if (!$nama) throw new Exception('Nama wajib diisi.');
if (!in_array($jenis, $jenisList)) throw new Exception('Jenis tidak valid.');
if (!$alamat) throw new Exception('Alamat wajib diisi.');
if ($lat === null || $lat < -90 || $lat > 90) throw new Exception('Latitude tidak valid.');
if ($lng === null || $lng < -180 || $lng > 180) throw new Exception('Longitude tidak valid.');
if ($radius < 0 || $radius > 1500) throw new Exception('Radius harus antara 01500 meter.');
return [
'nama' => $nama,
'jenis' => $jenis,
'alamat' => $alamat,
'narahubung' => trim($b['narahubung'] ?? ''),
'latitude' => $lat,
'longitude' => $lng,
'radius_aktif'=> $radius,
];
}
// ============================================================
// LOKASI BANTUAN
// ============================================================
function handleLokasiBantuan(string $method, string $action, ?int $id, array $body): void {
$db = getDB();
$radius = isset($_GET['radius']) ? (int)$_GET['radius'] : 500;
$radius = max(0, min(1500, $radius));
// GET LIST + status warna
if ($method === 'GET' && $action === 'list') {
// Cek apakah ada rumah ibadah
$adaRI = (int)$db->query("SELECT COUNT(*) FROM rumah_ibadah")->fetchColumn();
if ($adaRI === 0) {
// Kembalikan list tanpa status jarak
$rows = $db->query(
"SELECT id, nama_kk, jml_anggota, kategori, alamat, keterangan,
latitude, longitude, NULL AS jarak_meter,
NULL AS nama_ri, NULL AS id_ri, 'MERAH' AS status_warna
FROM lokasi_bantuan ORDER BY nama_kk ASC"
)->fetchAll();
jsonOk($rows);
return;
}
// Hitung jarak ke rumah ibadah terdekat per titik
$rows = $db->query(
"SELECT lb.id, lb.nama_kk, lb.jml_anggota, lb.kategori,
lb.alamat, lb.keterangan, lb.latitude, lb.longitude
FROM lokasi_bantuan lb ORDER BY lb.nama_kk ASC"
)->fetchAll();
$result = [];
$stmtJarak = $db->prepare(
"SELECT ri.id AS id_ri, ri.nama AS nama_ri, ri.radius_aktif,
ST_Distance_Sphere(lb.koordinat, ri.koordinat) AS jarak_meter
FROM lokasi_bantuan lb
CROSS JOIN rumah_ibadah ri
WHERE lb.id = ?
ORDER BY jarak_meter ASC
LIMIT 1"
);
foreach ($rows as $lb) {
$stmtJarak->execute([$lb['id']]);
$terdekat = $stmtJarak->fetch();
$jarak = $terdekat ? (float)$terdekat['jarak_meter'] : null;
$lb['jarak_meter'] = $terdekat ? round($jarak, 1) : null;
$lb['nama_ri'] = $terdekat['nama_ri'] ?? null;
$lb['id_ri'] = $terdekat['id_ri'] ?? null;
$lb['radius_ri'] = $terdekat['radius_aktif'] ?? null;
// HIJAU = dalam jangkauan, MERAH = di luar jangkauan
$lb['status_warna'] = ($terdekat && $jarak <= $radius) ? 'HIJAU' : 'MERAH';
$result[] = $lb;
}
jsonOk($result);
}
// GET DETAIL
elseif ($method === 'GET' && $action === 'detail' && $id) {
$stmt = $db->prepare("SELECT * FROM lokasi_bantuan WHERE id = ?");
$stmt->execute([$id]);
$lb = $stmt->fetch();
if (!$lb) jsonError('Data tidak ditemukan.', 404);
jsonOk($lb);
}
// POST CREATE
elseif ($method === 'POST' && $action === 'create') {
$adaRI = (int)$db->query("SELECT COUNT(*) FROM rumah_ibadah")->fetchColumn();
if ($adaRI === 0) {
jsonError('Tambahkan minimal satu rumah ibadah terlebih dahulu.', 422);
}
$v = validasiLB($body);
$stmt = $db->prepare(
"INSERT INTO lokasi_bantuan
(nama_kk, jml_anggota, kategori, alamat, keterangan, latitude, longitude, koordinat)
VALUES
(:nama_kk, :jml, :kategori, :alamat, :ket, :lat, :lng,
ST_GeomFromText(:titik, 4326))"
);
$stmt->execute([
':nama_kk' => $v['nama_kk'],
':jml' => $v['jml_anggota'],
':kategori' => $v['kategori'],
':alamat' => $v['alamat'],
':ket' => $v['keterangan'],
':lat' => $v['latitude'],
':lng' => $v['longitude'],
':titik' => "POINT({$v['latitude']} {$v['longitude']})",
]);
$newId = (int)$db->lastInsertId();
jsonOk(['message' => 'Lokasi penerima bantuan berhasil ditambahkan.', 'id' => $newId], 201);
}
// PUT UPDATE
elseif ($method === 'PUT' && $action === 'update' && $id) {
$v = validasiLB($body);
$stmt = $db->prepare(
"UPDATE lokasi_bantuan SET
nama_kk = :nama_kk, jml_anggota = :jml, kategori = :kategori,
alamat = :alamat, keterangan = :ket,
latitude = :lat, longitude = :lng,
koordinat = ST_GeomFromText(:titik, 4326)
WHERE id = :id"
);
$stmt->execute([
':nama_kk' => $v['nama_kk'],
':jml' => $v['jml_anggota'],
':kategori' => $v['kategori'],
':alamat' => $v['alamat'],
':ket' => $v['keterangan'],
':lat' => $v['latitude'],
':lng' => $v['longitude'],
':titik' => "POINT({$v['latitude']} {$v['longitude']})",
':id' => $id,
]);
jsonOk(['message' => 'Data lokasi bantuan berhasil diperbarui.']);
}
// DELETE
elseif ($method === 'DELETE' && $action === 'delete' && $id) {
$stmt = $db->prepare("DELETE FROM lokasi_bantuan WHERE id = ?");
$stmt->execute([$id]);
jsonOk(['message' => 'Lokasi bantuan berhasil dihapus.']);
}
else {
jsonError('Aksi tidak dikenali.', 400);
}
}
function validasiLB(array $b): array {
$nama_kk = trim($b['nama_kk'] ?? '');
$jml = isset($b['jml_anggota']) ? (int)$b['jml_anggota'] : 1;
$kategori = trim($b['kategori'] ?? '');
$alamat = trim($b['alamat'] ?? '');
$lat = isset($b['latitude']) ? (float)$b['latitude'] : null;
$lng = isset($b['longitude']) ? (float)$b['longitude'] : null;
$katList = ['Kemiskinan','Bencana','Lansia','Lainnya'];
if (!$nama_kk) throw new Exception('Nama kepala keluarga wajib diisi.');
if ($jml < 1) throw new Exception('Jumlah anggota minimal 1.');
if (!in_array($kategori, $katList)) throw new Exception('Kategori tidak valid.');
if (!$alamat) throw new Exception('Alamat wajib diisi.');
if ($lat === null || $lat < -90 || $lat > 90) throw new Exception('Latitude tidak valid.');
if ($lng === null || $lng < -180 || $lng > 180) throw new Exception('Longitude tidak valid.');
return [
'nama_kk' => $nama_kk,
'jml_anggota' => $jml,
'kategori' => $kategori,
'alamat' => $alamat,
'keterangan' => trim($b['keterangan'] ?? ''),
'latitude' => $lat,
'longitude' => $lng,
];
}
// ============================================================
// SUMBANGAN (Stok donasi di rumah ibadah)
// ============================================================
function handleSumbangan(string $method, string $action, ?int $id, array $body): void {
$db = getDB();
$riId = isset($_GET['rumah_ibadah_id']) ? (int)$_GET['rumah_ibadah_id'] : null;
// GET LIST by rumah_ibadah_id
if ($method === 'GET' && $action === 'list') {
if (!$riId) jsonError('Parameter rumah_ibadah_id diperlukan.', 400);
$stmt = $db->prepare(
"SELECT s.id, s.rumah_ibadah_id, s.jenis, s.jumlah, s.satuan, s.keterangan,
s.created_at,
COALESCE(SUM(p.jumlah_disalurkan), 0) AS total_disalurkan,
(s.jumlah - COALESCE(SUM(p.jumlah_disalurkan), 0)) AS sisa
FROM sumbangan s
LEFT JOIN penyaluran_bantuan p ON p.sumbangan_id = s.id
WHERE s.rumah_ibadah_id = ?
GROUP BY s.id
ORDER BY s.created_at DESC"
);
$stmt->execute([$riId]);
jsonOk($stmt->fetchAll());
}
// POST CREATE
elseif ($method === 'POST' && $action === 'create') {
$v = validasiSumbangan($body);
// Cek rumah ibadah ada
$cek = $db->prepare("SELECT id FROM rumah_ibadah WHERE id = ?");
$cek->execute([$v['rumah_ibadah_id']]);
if (!$cek->fetch()) jsonError('Rumah ibadah tidak ditemukan.', 404);
$stmt = $db->prepare(
"INSERT INTO sumbangan (rumah_ibadah_id, jenis, jumlah, satuan, keterangan)
VALUES (:ri_id, :jenis, :jumlah, :satuan, :ket)"
);
$stmt->execute([
':ri_id' => $v['rumah_ibadah_id'],
':jenis' => $v['jenis'],
':jumlah' => $v['jumlah'],
':satuan' => $v['satuan'],
':ket' => $v['keterangan'],
]);
jsonOk(['message' => 'Sumbangan berhasil ditambahkan.', 'id' => (int)$db->lastInsertId()], 201);
}
// PUT UPDATE
elseif ($method === 'PUT' && $action === 'update' && $id) {
$v = validasiSumbangan($body);
$stmt = $db->prepare(
"UPDATE sumbangan SET jenis=:jenis, jumlah=:jumlah, satuan=:satuan, keterangan=:ket
WHERE id=:id"
);
$stmt->execute([
':jenis' => $v['jenis'],
':jumlah' => $v['jumlah'],
':satuan' => $v['satuan'],
':ket' => $v['keterangan'],
':id' => $id,
]);
jsonOk(['message' => 'Sumbangan berhasil diperbarui.']);
}
// DELETE
elseif ($method === 'DELETE' && $action === 'delete' && $id) {
// Cek apakah sudah ada penyaluran
$cek = $db->prepare("SELECT COUNT(*) FROM penyaluran_bantuan WHERE sumbangan_id = ?");
$cek->execute([$id]);
if ((int)$cek->fetchColumn() > 0) {
jsonError('Sumbangan ini sudah memiliki riwayat penyaluran dan tidak dapat dihapus.', 422);
}
$db->prepare("DELETE FROM sumbangan WHERE id = ?")->execute([$id]);
jsonOk(['message' => 'Sumbangan berhasil dihapus.']);
}
else {
jsonError('Aksi tidak dikenali.', 400);
}
}
function validasiSumbangan(array $b): array {
$riId = isset($b['rumah_ibadah_id']) ? (int)$b['rumah_ibadah_id'] : 0;
$jenis = trim($b['jenis'] ?? '');
$jumlah = isset($b['jumlah']) ? (float)$b['jumlah'] : null;
$satuan = trim($b['satuan'] ?? '');
if (!$riId) throw new Exception('rumah_ibadah_id wajib diisi.');
if (!$jenis) throw new Exception('Jenis sumbangan wajib diisi.');
if ($jumlah === null || $jumlah <= 0) throw new Exception('Jumlah harus lebih dari 0.');
if (!$satuan) throw new Exception('Satuan wajib diisi.');
return [
'rumah_ibadah_id' => $riId,
'jenis' => $jenis,
'jumlah' => $jumlah,
'satuan' => $satuan,
'keterangan' => trim($b['keterangan'] ?? ''),
];
}
// ============================================================
// PENYALURAN BANTUAN
// ============================================================
function handlePenyaluran(string $method, string $action, ?int $id, array $body): void {
$db = getDB();
$lbId = isset($_GET['lokasi_bantuan_id']) ? (int)$_GET['lokasi_bantuan_id'] : null;
$riId = isset($_GET['rumah_ibadah_id']) ? (int)$_GET['rumah_ibadah_id'] : null;
// GET LIST riwayat by lokasi_bantuan atau rumah_ibadah
if ($method === 'GET' && $action === 'list') {
$where = '';
$params = [];
if ($lbId) { $where = 'WHERE p.lokasi_bantuan_id = ?'; $params[] = $lbId; }
elseif ($riId) { $where = 'WHERE s.rumah_ibadah_id = ?'; $params[] = $riId; }
$stmt = $db->prepare(
"SELECT p.id, p.sumbangan_id, p.lokasi_bantuan_id, p.jumlah_disalurkan,
p.tanggal, p.keterangan, p.created_at,
s.jenis AS jenis_sumbangan, s.satuan,
lb.nama_kk, ri.nama AS nama_ri
FROM penyaluran_bantuan p
JOIN sumbangan s ON s.id = p.sumbangan_id
JOIN rumah_ibadah ri ON ri.id = s.rumah_ibadah_id
JOIN lokasi_bantuan lb ON lb.id = p.lokasi_bantuan_id
$where
ORDER BY p.created_at DESC"
);
$stmt->execute($params);
jsonOk($stmt->fetchAll());
}
// GET sumbangan tersedia untuk lokasi bantuan tertentu (semua rumah ibadah dalam jangkauan)
elseif ($method === 'GET' && $action === 'tersedia' && $lbId) {
$stmtRI = $db->prepare(
"SELECT ri.id, ri.nama, ri.radius_aktif,
ST_Distance_Sphere(lb.koordinat, ri.koordinat) AS jarak
FROM lokasi_bantuan lb
CROSS JOIN rumah_ibadah ri
WHERE lb.id = ?
ORDER BY jarak ASC"
);
$stmtRI->execute([$lbId]);
$riList = $stmtRI->fetchAll();
if (!$riList) jsonError('Tidak ada rumah ibadah di sistem.', 404);
$radiusGlobal = isset($_GET['radius']) ? (int)$_GET['radius'] : 500;
$tersedia = [];
$stmtS = $db->prepare(
"SELECT s.id, s.jenis, s.satuan, s.keterangan,
s.jumlah,
COALESCE(SUM(p.jumlah_disalurkan), 0) AS total_disalurkan,
(s.jumlah - COALESCE(SUM(p.jumlah_disalurkan), 0)) AS sisa
FROM sumbangan s
LEFT JOIN penyaluran_bantuan p ON p.sumbangan_id = s.id
WHERE s.rumah_ibadah_id = ?
GROUP BY s.id
HAVING sisa > 0
ORDER BY s.jenis ASC"
);
foreach ($riList as $ri) {
$batasRadius = isset($_GET['radius']) ? $radiusGlobal : (int)$ri['radius_aktif'];
if ((float)$ri['jarak'] <= $batasRadius) {
// Ambil stok
$stmtS->execute([$ri['id']]);
$sumbangan = $stmtS->fetchAll();
$tersedia[] = [
'rumah_ibadah' => ['id' => $ri['id'], 'nama' => $ri['nama']],
'jarak_meter' => round((float)$ri['jarak'], 1),
'sumbangan' => $sumbangan
];
}
}
if (empty($tersedia)) {
jsonError('Lokasi bantuan ini berada di luar jangkauan rumah ibadah manapun.', 422);
}
jsonOk($tersedia);
}
// POST CREATE penyaluran baru
elseif ($method === 'POST' && $action === 'create') {
$sumbanganId = isset($body['sumbangan_id']) ? (int)$body['sumbangan_id'] : 0;
$lbIdPost = isset($body['lokasi_bantuan_id']) ? (int)$body['lokasi_bantuan_id'] : 0;
$jumlah = isset($body['jumlah_disalurkan']) ? (float)$body['jumlah_disalurkan'] : 0;
$tanggal = trim($body['tanggal'] ?? date('Y-m-d'));
$ket = trim($body['keterangan'] ?? '');
if (!$sumbanganId) throw new Exception('sumbangan_id wajib diisi.');
if (!$lbIdPost) throw new Exception('lokasi_bantuan_id wajib diisi.');
if ($jumlah <= 0) throw new Exception('Jumlah disalurkan harus lebih dari 0.');
// Cek stok mencukupi
$stmtSisa = $db->prepare(
"SELECT s.jumlah, s.jenis, s.satuan,
(s.jumlah - COALESCE(SUM(p.jumlah_disalurkan), 0)) AS sisa
FROM sumbangan s
LEFT JOIN penyaluran_bantuan p ON p.sumbangan_id = s.id
WHERE s.id = ?
GROUP BY s.id"
);
$stmtSisa->execute([$sumbanganId]);
$stok = $stmtSisa->fetch();
if (!$stok) throw new Exception('Sumbangan tidak ditemukan.');
if ((float)$stok['sisa'] < $jumlah) {
throw new Exception("Stok {$stok['jenis']} tidak mencukupi. Sisa: {$stok['sisa']} {$stok['satuan']}.");
}
$stmt = $db->prepare(
"INSERT INTO penyaluran_bantuan
(sumbangan_id, lokasi_bantuan_id, jumlah_disalurkan, tanggal, keterangan)
VALUES (?, ?, ?, ?, ?)"
);
$stmt->execute([$sumbanganId, $lbIdPost, $jumlah, $tanggal, $ket]);
jsonOk(['message' => 'Penyaluran bantuan berhasil dicatat.', 'id' => (int)$db->lastInsertId()], 201);
}
else {
jsonError('Aksi tidak dikenali.', 400);
}
}
// ============================================================
// SPBU
// ============================================================
function handleSpbu(string $method, string $action, ?int $id, array $body): void {
$db = getDB();
// GET LIST
if ($method === 'GET' && $action === 'list') {
$rows = $db->query(
"SELECT id, nama, buka_24_jam, kontak, latitude, longitude, created_at
FROM spbu
ORDER BY nama ASC"
)->fetchAll();
jsonOk($rows);
}
// POST CREATE
elseif ($method === 'POST' && $action === 'create') {
$v = validasiSpbu($body);
$stmt = $db->prepare(
"INSERT INTO spbu (nama, buka_24_jam, kontak, latitude, longitude, koordinat)
VALUES (:nama, :buka_24_jam, :kontak, :lat, :lng, ST_GeomFromText(:titik, 4326))"
);
$stmt->execute([
':nama' => $v['nama'],
':buka_24_jam' => $v['buka_24_jam'],
':kontak' => $v['kontak'],
':lat' => $v['latitude'],
':lng' => $v['longitude'],
':titik' => "POINT({$v['latitude']} {$v['longitude']})"
]);
jsonOk(['message' => 'SPBU berhasil ditambahkan.', 'id' => (int)$db->lastInsertId()], 201);
}
// PUT UPDATE (Bisa update koordinat saja saat drag-and-drop, atau full data)
elseif ($method === 'PUT' && $action === 'update' && $id) {
// Cek jika update koordinat saja (drag and drop)
if (isset($body['latitude']) && isset($body['longitude']) && !isset($body['nama'])) {
$lat = (float)$body['latitude'];
$lng = (float)$body['longitude'];
$stmt = $db->prepare(
"UPDATE spbu SET latitude = :lat, longitude = :lng, koordinat = ST_GeomFromText(:titik, 4326) WHERE id = :id"
);
$stmt->execute([
':lat' => $lat,
':lng' => $lng,
':titik' => "POINT({$lat} {$lng})",
':id' => $id
]);
jsonOk(['message' => 'Posisi SPBU berhasil diperbarui.']);
return;
}
// Full update
$v = validasiSpbu($body);
$stmt = $db->prepare(
"UPDATE spbu SET nama = :nama, buka_24_jam = :buka_24_jam, kontak = :kontak,
latitude = :lat, longitude = :lng, koordinat = ST_GeomFromText(:titik, 4326)
WHERE id = :id"
);
$stmt->execute([
':nama' => $v['nama'],
':buka_24_jam' => $v['buka_24_jam'],
':kontak' => $v['kontak'],
':lat' => $v['latitude'],
':lng' => $v['longitude'],
':titik' => "POINT({$v['latitude']} {$v['longitude']})",
':id' => $id
]);
jsonOk(['message' => 'Data SPBU berhasil diperbarui.']);
}
// DELETE
elseif ($method === 'DELETE' && $action === 'delete' && $id) {
$db->prepare("DELETE FROM spbu WHERE id = ?")->execute([$id]);
jsonOk(['message' => 'SPBU berhasil dihapus.']);
}
else {
jsonError('Aksi tidak dikenali.', 400);
}
}
function validasiSpbu(array $b): array {
$nama = trim($b['nama'] ?? '');
$lat = isset($b['latitude']) ? (float)$b['latitude'] : null;
$lng = isset($b['longitude']) ? (float)$b['longitude'] : null;
if (!$nama) throw new Exception('Nama SPBU wajib diisi.');
if ($lat === null || $lat < -90 || $lat > 90) throw new Exception('Latitude tidak valid.');
if ($lng === null || $lng < -180 || $lng > 180) throw new Exception('Longitude tidak valid.');
return [
'nama' => $nama,
'buka_24_jam' => isset($b['buka_24_jam']) ? (int)$b['buka_24_jam'] : 0,
'kontak' => trim($b['kontak'] ?? ''),
'latitude' => $lat,
'longitude' => $lng,
];
}
// ============================================================
// SETUP — Buat tabel otomatis jika belum ada
// ============================================================
function handleSetup(): void {
$db = getDB();
$db->exec("
CREATE TABLE IF NOT EXISTS rumah_ibadah (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(255) NOT NULL,
jenis ENUM('Masjid','Gereja','Pura','Vihara','Kelenteng') NOT NULL,
alamat TEXT NOT NULL,
narahubung VARCHAR(255) DEFAULT '',
latitude DECIMAL(10,7) NOT NULL,
longitude DECIMAL(10,7) NOT NULL,
koordinat POINT NOT NULL SRID 4326,
radius_aktif INT NOT NULL DEFAULT 500,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
SPATIAL INDEX (koordinat)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");
$db->exec("
CREATE TABLE IF NOT EXISTS lokasi_bantuan (
id INT AUTO_INCREMENT PRIMARY KEY,
nama_kk VARCHAR(255) NOT NULL,
jml_anggota INT NOT NULL DEFAULT 1,
kategori ENUM('Kemiskinan','Bencana','Lansia','Lainnya') NOT NULL,
alamat TEXT NOT NULL,
keterangan TEXT,
latitude DECIMAL(10,7) NOT NULL,
longitude DECIMAL(10,7) NOT NULL,
koordinat POINT NOT NULL SRID 4326,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
SPATIAL INDEX (koordinat)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");
$db->exec("
CREATE TABLE IF NOT EXISTS sumbangan (
id INT AUTO_INCREMENT PRIMARY KEY,
rumah_ibadah_id INT NOT NULL,
jenis VARCHAR(100) NOT NULL,
jumlah DECIMAL(12,2) NOT NULL,
satuan VARCHAR(30) NOT NULL,
keterangan TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (rumah_ibadah_id) REFERENCES rumah_ibadah(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");
$db->exec("
CREATE TABLE IF NOT EXISTS penyaluran_bantuan (
id INT AUTO_INCREMENT PRIMARY KEY,
sumbangan_id INT NOT NULL,
lokasi_bantuan_id INT NOT NULL,
jumlah_disalurkan DECIMAL(12,2) NOT NULL,
tanggal DATE NOT NULL,
keterangan TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (sumbangan_id) REFERENCES sumbangan(id) ON DELETE RESTRICT,
FOREIGN KEY (lokasi_bantuan_id) REFERENCES lokasi_bantuan(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");
$db->exec("
CREATE TABLE IF NOT EXISTS spbu (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(255) NOT NULL,
buka_24_jam TINYINT(1) NOT NULL DEFAULT 0,
kontak VARCHAR(100) DEFAULT '',
latitude DECIMAL(10,7) NOT NULL,
longitude DECIMAL(10,7) NOT NULL,
koordinat POINT NOT NULL SRID 4326,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
SPATIAL INDEX (koordinat)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");
$db->exec("
CREATE TABLE IF NOT EXISTS jalan (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(255) NOT NULL,
status VARCHAR(100) NOT NULL,
panjang DECIMAL(12,2) NOT NULL DEFAULT 0,
geom LINESTRING NOT NULL SRID 4326,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
SPATIAL INDEX (geom)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");
$db->exec("
CREATE TABLE IF NOT EXISTS parsil (
id INT AUTO_INCREMENT PRIMARY KEY,
nama_pemilik VARCHAR(255) NOT NULL,
status VARCHAR(100) NOT NULL,
luas DECIMAL(12,2) NOT NULL DEFAULT 0,
geom POLYGON NOT NULL SRID 4326,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
SPATIAL INDEX (geom)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");
jsonOk(['message' => 'Tabel berhasil dibuat / sudah ada.']);
}
// ============================================================
// HELPER
// ============================================================
function jsonOk(mixed $data, int $code = 200): void {
http_response_code($code);
echo json_encode($data, JSON_UNESCAPED_UNICODE);
exit;
}
function jsonError(string $msg, int $code = 400): void {
http_response_code($code);
echo json_encode(['error' => $msg], JSON_UNESCAPED_UNICODE);
exit;
}
// ============================================================
// JALAN & PARSIL
// ============================================================
function handleJalan(string $method, string $action, ?int $id, array $body): void {
$db = getDB();
if ($method === 'GET' && $action === 'list') {
$rows = $db->query(
"SELECT id, nama, status, panjang,
ST_AsGeoJSON(geom) AS geom_json,
created_at
FROM jalan"
)->fetchAll();
foreach ($rows as &$r) {
$r['geom'] = json_decode($r['geom_json'], true);
unset($r['geom_json']);
}
jsonOk($rows);
}
elseif ($method === 'POST' && $action === 'create') {
if (empty($body['nama']) || empty($body['status']) || empty($body['geom'])) {
jsonError('Data jalan tidak lengkap.');
}
$geomJson = json_encode($body['geom']);
$stmt = $db->prepare(
"INSERT INTO jalan (nama, status, panjang, geom)
VALUES (:nama, :status, :panjang, ST_GeomFromGeoJSON(:geom, 2, 4326))"
);
$stmt->execute([
':nama' => $body['nama'],
':status' => $body['status'],
':panjang' => $body['panjang'] ?? 0,
':geom' => $geomJson
]);
jsonOk(['message' => 'Data jalan berhasil disimpan.', 'id' => $db->lastInsertId()]);
}
elseif ($method === 'PUT' && $action === 'update' && $id) {
$geomJson = isset($body['geom']) ? json_encode($body['geom']) : null;
if ($geomJson) {
$stmt = $db->prepare(
"UPDATE jalan SET
nama = :nama, status = :status, panjang = :panjang,
geom = ST_GeomFromGeoJSON(:geom, 2, 4326)
WHERE id = :id"
);
$stmt->execute([
':nama' => $body['nama'],
':status' => $body['status'],
':panjang' => $body['panjang'],
':geom' => $geomJson,
':id' => $id
]);
} else {
$stmt = $db->prepare("UPDATE jalan SET nama = :nama, status = :status WHERE id = :id");
$stmt->execute([
':nama' => $body['nama'],
':status' => $body['status'],
':id' => $id
]);
}
jsonOk(['message' => 'Data jalan berhasil diperbarui.']);
}
elseif ($method === 'DELETE' && $action === 'delete' && $id) {
$stmt = $db->prepare("DELETE FROM jalan WHERE id = ?");
$stmt->execute([$id]);
jsonOk(['message' => 'Data jalan dihapus.']);
}
}
function handleParsil(string $method, string $action, ?int $id, array $body): void {
$db = getDB();
if ($method === 'GET' && $action === 'list') {
$rows = $db->query(
"SELECT id, nama_pemilik, status, luas,
ST_AsGeoJSON(geom) AS geom_json,
created_at
FROM parsil"
)->fetchAll();
foreach ($rows as &$r) {
$r['geom'] = json_decode($r['geom_json'], true);
unset($r['geom_json']);
}
jsonOk($rows);
}
elseif ($method === 'POST' && $action === 'create') {
if (empty($body['nama_pemilik']) || empty($body['status']) || empty($body['geom'])) {
jsonError('Data parsil tidak lengkap.');
}
$geomJson = json_encode($body['geom']);
$stmt = $db->prepare(
"INSERT INTO parsil (nama_pemilik, status, luas, geom)
VALUES (:nama_pemilik, :status, :luas, ST_GeomFromGeoJSON(:geom, 2, 4326))"
);
$stmt->execute([
':nama_pemilik' => $body['nama_pemilik'],
':status' => $body['status'],
':luas' => $body['luas'] ?? 0,
':geom' => $geomJson
]);
jsonOk(['message' => 'Data parsil berhasil disimpan.', 'id' => $db->lastInsertId()]);
}
elseif ($method === 'PUT' && $action === 'update' && $id) {
$geomJson = isset($body['geom']) ? json_encode($body['geom']) : null;
if ($geomJson) {
$stmt = $db->prepare(
"UPDATE parsil SET
nama_pemilik = :nama_pemilik, status = :status, luas = :luas,
geom = ST_GeomFromGeoJSON(:geom, 2, 4326)
WHERE id = :id"
);
$stmt->execute([
':nama_pemilik' => $body['nama_pemilik'],
':status' => $body['status'],
':luas' => $body['luas'],
':geom' => $geomJson,
':id' => $id
]);
} else {
$stmt = $db->prepare("UPDATE parsil SET nama_pemilik = :nama_pemilik, status = :status WHERE id = :id");
$stmt->execute([
':nama_pemilik' => $body['nama_pemilik'],
':status' => $body['status'],
':id' => $id
]);
}
jsonOk(['message' => 'Data parsil berhasil diperbarui.']);
}
elseif ($method === 'DELETE' && $action === 'delete' && $id) {
$stmt = $db->prepare("DELETE FROM parsil WHERE id = ?");
$stmt->execute([$id]);
jsonOk(['message' => 'Data parsil dihapus.']);
}
}
+206
View File
@@ -0,0 +1,206 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard WebGIS Partisipasi</title>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" />
<style>
/* ===== RESET ===== */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Segoe UI', Arial, sans-serif;
background: #f0f2f5;
color: #333;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* ===== HEADER ===== */
header {
background: linear-gradient(135deg, #1a6b3c, #2d9e5f);
color: #fff;
padding: 15px 25px;
display: flex;
align-items: center;
gap: 15px;
box-shadow: 0 4px 10px rgba(0, 0, 0, .2);
}
header h1 {
font-size: 1.4rem;
font-weight: 700;
}
header small {
font-size: .85rem;
opacity: .9;
}
/* ===== MAIN CONTENT ===== */
main {
flex: 1;
padding: 40px 20px;
display: flex;
flex-direction: column;
align-items: center;
}
.welcome-text {
text-align: center;
margin-bottom: 40px;
}
.welcome-text h2 {
font-size: 2rem;
color: #1a6b3c;
margin-bottom: 10px;
}
.welcome-text p {
font-size: 1rem;
color: #666;
max-width: 600px;
margin: 0 auto;
}
/* ===== CARDS ===== */
.cards-container {
display: flex;
gap: 25px;
flex-wrap: wrap;
justify-content: center;
max-width: 1000px;
}
.card {
background: #fff;
border-radius: 12px;
width: 300px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, .1);
transition: transform 0.3s ease, box-shadow 0.3s ease;
text-decoration: none;
color: inherit;
display: flex;
flex-direction: column;
}
.card:hover {
transform: translateY(-8px);
box-shadow: 0 12px 20px rgba(0, 0, 0, .15);
}
.card-icon {
height: 140px;
display: flex;
align-items: center;
justify-content: center;
font-size: 4rem;
color: #fff;
}
.bg-green { background: linear-gradient(135deg, #1a6b3c, #2d9e5f); }
.bg-orange { background: linear-gradient(135deg, #e08020, #f59e42); }
.bg-blue { background: linear-gradient(135deg, #1976d2, #42a5f5); }
.card-content {
padding: 20px;
text-align: center;
flex: 1;
}
.card-content h3 {
font-size: 1.2rem;
margin-bottom: 10px;
color: #333;
}
.card-content p {
font-size: 0.9rem;
color: #777;
line-height: 1.5;
}
.card-footer {
background: #f8f9fa;
padding: 12px;
text-align: center;
font-weight: 600;
font-size: 0.9rem;
border-top: 1px solid #eee;
}
.card:hover .card-footer {
background: #e8f5e9;
color: #1a6b3c;
}
.card.card-orange:hover .card-footer { background: #fff3e0; color: #e08020; }
.card.card-blue:hover .card-footer { background: #e3f2fd; color: #1976d2; }
</style>
</head>
<body>
<header>
<i class="fa-solid fa-map-location-dot fa-2x"></i>
<div>
<h1>Portal WebGIS Terpadu</h1>
<small>Sistem Informasi Geografis Multi-Sektor</small>
</div>
</header>
<main>
<div class="welcome-text">
<h2>Selamat Datang</h2>
<p>Silakan pilih modul peta yang ingin Anda akses di bawah ini. Setiap modul memiliki fungsi dan data pemetaan yang spesifik.</p>
</div>
<div class="cards-container">
<!-- Card 1: Bantuan -->
<a href="map_bantuan.html" class="card">
<div class="card-icon bg-green">
<i class="fa-solid fa-hands-holding-child"></i>
</div>
<div class="card-content">
<h3>Peta Bantuan & Rumah Ibadah</h3>
<p>Pemetaan partisipasi rumah ibadah dalam pengentasan kemiskinan dan distribusi bantuan.</p>
</div>
<div class="card-footer">Buka Peta <i class="fa-solid fa-arrow-right"></i></div>
</a>
<!-- Card 2: SPBU -->
<a href="map_spbu.html" class="card card-orange">
<div class="card-icon bg-orange">
<i class="fa-solid fa-gas-pump"></i>
</div>
<div class="card-content">
<h3>Peta SPBU</h3>
<p>Pemetaan lokasi Stasiun Pengisian Bahan Bakar Umum (SPBU), status operasional 24 jam, dan info kontak.</p>
</div>
<div class="card-footer" style="color: #e08020;">Buka Peta <i class="fa-solid fa-arrow-right"></i></div>
</a>
<!-- Card 3: Parsil -->
<a href="map_parsil.html" class="card card-blue">
<div class="card-icon bg-blue">
<i class="fa-solid fa-road"></i>
</div>
<div class="card-content">
<h3>Peta Parsil & Jalan</h3>
<p>Informasi jaringan jalan dan bidang tanah (parsil) untuk keperluan perencanaan wilayah.</p>
</div>
<div class="card-footer" style="color: #1976d2;">Buka Peta <i class="fa-solid fa-arrow-right"></i></div>
</a>
</div>
</main>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
<?php
// ============================================================
// KONFIGURASI DATABASE — Sesuaikan dengan Laragon Anda
// ============================================================
define('DB_HOST', '127.0.0.1');
define('DB_PORT', '3306');
define('DB_NAME', 'webgis_rumah_ibadah');
define('DB_USER', 'root');
define('DB_PASS', ''); // Laragon default: kosong
define('DB_CHAR', 'utf8mb4');
function getDB(): PDO {
static $pdo = null;
if ($pdo === null) {
$dsn = 'mysql:host=' . DB_HOST . ';port=' . DB_PORT
. ';dbname=' . DB_NAME . ';charset=' . DB_CHAR;
try {
$pdo = new PDO($dsn, DB_USER, DB_PASS, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
} catch (PDOException $e) {
http_response_code(500);
header('Content-Type: application/json');
echo json_encode(['error' => 'Koneksi database gagal: ' . $e->getMessage()]);
exit;
}
}
return $pdo;
}
+1822
View File
File diff suppressed because it is too large Load Diff
+462
View File
@@ -0,0 +1,462 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebGIS Parsil & Jalan</title>
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" />
<!-- Leaflet Geoman CSS -->
<link rel="stylesheet" href="https://unpkg.com/@geoman-io/leaflet-geoman-free@latest/dist/leaflet-geoman.css" />
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Segoe UI', Arial, sans-serif; height: 100vh; display: flex; flex-direction: column; background: #f0f2f5; }
header { background: linear-gradient(135deg, #1976d2, #42a5f5); color: #fff; padding: 10px 18px; display: flex; align-items: center; justify-content: space-between; box-shadow: 0 2px 8px rgba(0, 0, 0, .3); z-index: 1000; flex-shrink: 0; }
header .title-area { display: flex; align-items: center; gap: 12px; }
header h1 { font-size: 1rem; font-weight: 700; }
header small { font-size: .7rem; opacity: .85; }
.back-btn { color: white; text-decoration: none; font-size: 0.9rem; border: 1px solid rgba(255,255,255,0.5); padding: 5px 10px; border-radius: 5px; transition: background 0.2s;}
.back-btn:hover { background: rgba(255,255,255,0.2); }
.app-body { display: flex; flex: 1; overflow: hidden; position: relative; }
#sidebar { width: 320px; background: #fff; display: flex; flex-direction: column; border-right: 1px solid #dde; z-index: 900; transition: transform .3s; flex-shrink: 0; }
#sidebar.hide { transform: translateX(-100%); position: absolute; top: 0; left: 0; height: 100%; box-shadow: 4px 0 12px rgba(0, 0, 0, .2); }
/* Sidebar Tabs */
.sb-tabs { display: flex; background: #fff; border-bottom: 1px solid #ddd; flex-shrink: 0; }
.sb-tab { flex: 1; text-align: center; padding: 12px 0; font-size: 0.85rem; font-weight: 700; color: #777; cursor: pointer; border-bottom: 3px solid transparent; transition: all 0.2s; }
.sb-tab:hover { background: #f8f8f8; color: #333; }
.sb-tab.active { color: #1976d2; border-bottom: 3px solid #1976d2; background: #f0f7ff; }
.sb-head { background: #1976d2; color: #fff; padding: 10px 14px; font-weight: 700; font-size: .88rem; }
.list-wrap { padding: 10px; flex: 1; overflow-y: auto; background: #fafafa; }
.list-item { background: #fff; border: 1px solid #eee; padding: 10px; border-radius: 6px; margin-bottom: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.05); }
.list-item h4 { margin-bottom: 4px; color: #333; font-size: 0.95rem; }
.list-item .attr { font-size: 0.8rem; color: #666; margin-bottom: 2px; }
.list-item .actions { margin-top: 8px; text-align: right; }
#map { flex: 1; z-index: 1; }
#btn-toggle { position: absolute; top: 10px; left: 0; z-index: 950; background: #1976d2; color: #fff; border: none; padding: 8px 7px; border-radius: 0 6px 6px 0; cursor: pointer; font-size: 1rem; box-shadow: 2px 0 6px rgba(0, 0, 0, .25); }
/* Buttons & Forms */
.btn { padding: 6px 12px; border: none; border-radius: 4px; cursor: pointer; font-size: .8rem; font-weight: 600; display: inline-flex; align-items: center; gap: 6px; transition: opacity .2s; }
.btn:hover { opacity: .85; }
.btn-red { background: #d93025; color: #fff; }
.btn-blue { background: #1976d2; color: #fff; }
.btn-green { background: #1a6b3c; color: #fff; }
.btn-gray { background: #e0e0e0; color: #333; }
.fg { margin-bottom: 12px; }
.fg label { display: block; font-size: .8rem; font-weight: 600; margin-bottom: 4px; color: #555; }
.fg input, .fg select { width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; font-family: inherit; font-size: .85rem; }
.fg input[readonly] { background: #eee; color: #555; }
/* Modal Overlay */
.overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,.5); display: none; align-items: center; justify-content: center; z-index: 9999; }
.overlay.on { display: flex; }
.modal { background: #fff; padding: 20px; border-radius: 8px; width: 90%; max-width: 400px; box-shadow: 0 4px 15px rgba(0,0,0,.3); }
.modal h2 { margin-bottom: 15px; font-size: 1.2rem; color: #333; border-bottom: 1px solid #eee; padding-bottom: 10px; }
.modal-footer { margin-top: 20px; text-align: right; border-top: 1px solid #eee; padding-top: 15px; }
/* Toast */
#toast { position: fixed; bottom: 20px; right: 20px; padding: 10px 20px; border-radius: 5px; color: #fff; font-size: .9rem; opacity: 0; pointer-events: none; transition: opacity .3s; z-index: 10000; box-shadow: 0 2px 10px rgba(0,0,0,.3); }
#toast.on { opacity: 1; pointer-events: auto; }
#toast.ok { background: #1a6b3c; }
#toast.err { background: #d93025; }
.empty { text-align: center; color: #888; font-size: 0.9rem; padding: 20px 0; }
</style>
</head>
<body>
<header>
<div class="title-area">
<i class="fa-solid fa-road fa-lg"></i>
<div>
<h1>WebGIS Parsil & Jalan</h1>
<small>Pemetaan Bidang Tanah & Jaringan Jalan</small>
</div>
</div>
<a href="index.html" class="back-btn"><i class="fa-solid fa-arrow-left"></i> Kembali ke Dashboard</a>
</header>
<div class="app-body">
<div id="sidebar">
<div class="sb-tabs">
<div class="sb-tab active" id="tab-jalan" onclick="gantiTab('jalan')"><i class="fa-solid fa-road"></i> Data Jalan</div>
<div class="sb-tab" id="tab-parsil" onclick="gantiTab('parsil')"><i class="fa-solid fa-draw-polygon"></i> Data Parsil</div>
</div>
<!-- Konten Jalan -->
<div id="content-jalan" style="display: flex; flex-direction: column; flex: 1; overflow: hidden;">
<div class="sb-head"><i class="fa-solid fa-list"></i> Daftar Jalan</div>
<div class="list-wrap" id="list-jalan">
<div class="empty"><i class="fa-solid fa-spinner fa-spin"></i> Memuat...</div>
</div>
</div>
<!-- Konten Parsil -->
<div id="content-parsil" style="display: none; flex-direction: column; flex: 1; overflow: hidden;">
<div class="sb-head" style="background:#27ae60;"><i class="fa-solid fa-list"></i> Daftar Parsil (Kavling)</div>
<div class="list-wrap" id="list-parsil">
<div class="empty"><i class="fa-solid fa-spinner fa-spin"></i> Memuat...</div>
</div>
</div>
</div>
<div id="map"></div>
<button id="btn-toggle" onclick="toggleSidebar()"><i class="fa-solid fa-bars"></i></button>
</div>
<!-- MODAL FORM JALAN -->
<div class="overlay" id="ovl-jalan">
<div class="modal">
<h2><i class="fa-solid fa-road"></i> Data Jalan</h2>
<div class="fg">
<label>Nama Jalan</label>
<input type="text" id="j-nama" placeholder="Contoh: Jl. Ahmad Yani">
</div>
<div class="fg">
<label>Status Jalan</label>
<select id="j-status">
<option value="Nasional">Jalan Nasional</option>
<option value="Provinsi">Jalan Provinsi</option>
<option value="Kabupaten">Jalan Kabupaten</option>
</select>
</div>
<div class="fg">
<label>Panjang (Otomatis)</label>
<input type="text" id="j-panjang" readonly>
</div>
<div class="modal-footer">
<button class="btn btn-gray" onclick="batalSimpan('jalan')">Batal</button>
<button class="btn btn-blue" onclick="simpanData('jalan')"><i class="fa-solid fa-save"></i> Simpan</button>
</div>
</div>
</div>
<!-- MODAL FORM PARSIL -->
<div class="overlay" id="ovl-parsil">
<div class="modal">
<h2><i class="fa-solid fa-draw-polygon"></i> Data Parsil Tanah</h2>
<div class="fg">
<label>Nama Pemilik</label>
<input type="text" id="p-nama" placeholder="Nama pemilik tanah">
</div>
<div class="fg">
<label>Status Kepemilikan</label>
<select id="p-status">
<option value="SHM">Sertifikat Hak Milik (SHM)</option>
<option value="HGB">Sertifikat Hak Guna Bangunan (HGB)</option>
<option value="HGU">Sertifikat Hak Guna Usaha (HGU)</option>
<option value="HP">Sertifikat Hak Pakai (HP)</option>
</select>
</div>
<div class="fg">
<label>Luas (Otomatis)</label>
<input type="text" id="p-luas" readonly>
</div>
<div class="modal-footer">
<button class="btn btn-gray" onclick="batalSimpan('parsil')">Batal</button>
<button class="btn btn-green" onclick="simpanData('parsil')"><i class="fa-solid fa-save"></i> Simpan</button>
</div>
</div>
</div>
<div id="toast"></div>
<!-- Scripts -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/@geoman-io/leaflet-geoman-free@latest/dist/leaflet-geoman.min.js"></script>
<script src="https://unpkg.com/@turf/turf/turf.min.js"></script>
<script>
const API = 'api.php';
let map;
let layerJalan = L.layerGroup();
let layerParsil = L.layerGroup();
// State
let tempLayer = null;
let tempGeoJSON = null;
let activeId = null;
// Config warna
const warnaJalan = {
'Nasional': '#d32f2f', // Merah
'Provinsi': '#1976d2', // Biru
'Kabupaten': '#388e3c' // Hijau
};
const warnaParsil = {
'SHM': '#27ae60', // Hijau
'HGB': '#2980b9', // Biru
'HGU': '#e67e22', // Orange
'HP': '#8e44ad' // Ungu
};
function initMap() {
map = L.map('map').setView([-0.0380, 109.3440], 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap'
}).addTo(map);
layerJalan.addTo(map);
layerParsil.addTo(map);
// Inisialisasi Leaflet-Geoman (Drawing toolbar)
map.pm.addControls({
position: 'topright',
drawMarker: false,
drawCircleMarker: false,
drawCircle: false,
drawRectangle: false,
drawText: false,
drawPolygon: true, // Untuk Parsil
drawLine: true, // Untuk Jalan
editMode: true,
dragMode: true,
cutPolygon: false,
removalMode: true
});
// Translate geoman
map.pm.setLang('id');
// Event saat selesai menggambar
map.on('pm:create', (e) => {
const layer = e.layer;
const shape = e.shape;
const geojson = layer.toGeoJSON().geometry;
tempLayer = layer;
tempGeoJSON = geojson;
activeId = null;
if (shape === 'Line') {
// Hitung panjang dengan turf
const length = turf.length(geojson, {units: 'meters'});
document.getElementById('j-nama').value = '';
document.getElementById('j-status').value = 'Nasional';
document.getElementById('j-panjang').value = length.toFixed(2);
document.getElementById('ovl-jalan').classList.add('on');
} else if (shape === 'Polygon') {
// Hitung luas dengan turf
const area = turf.area(geojson); // in square meters
document.getElementById('p-nama').value = '';
document.getElementById('p-status').value = 'SHM';
document.getElementById('p-luas').value = area.toFixed(2);
document.getElementById('ovl-parsil').classList.add('on');
}
});
// Event saat menghapus via Geoman toolbar
map.on('pm:remove', async (e) => {
const layer = e.layer;
if (layer.feature && layer.feature.properties) {
const props = layer.feature.properties;
const res = await fetch(`${API}?resource=${props.type}&action=delete&id=${props.id}`, { method: 'DELETE' });
if(res.ok) {
toast('Data berhasil dihapus dari database.', 'ok');
muatData();
}
}
});
// Event saat mengedit (drag/vertex)
map.on('pm:globaleditmodetoggled', (e) => {
if(!e.enabled) {
// Selesai edit, kita harus simpan perubahan yang terjadi.
// Cara paling mudah: tambahkan event layer.on('pm:update') ke setiap layer saat diload
}
});
}
async function muatData() {
try {
// Muat Jalan
const resJ = await fetch(`${API}?resource=jalan&action=list`);
const dataJ = await resJ.json();
layerJalan.clearLayers();
let htmlJ = '';
dataJ.forEach(j => {
if(!j.geom) return;
const l = L.geoJSON(j.geom, {
style: {
color: warnaJalan[j.status] || '#333',
weight: 5,
opacity: 0.8
}
});
// Bind properties for geoman removal/update
l.eachLayer(lyr => {
lyr.feature = { properties: { type: 'jalan', id: j.id, nama: j.nama, status: j.status, panjang: j.panjang } };
lyr.bindPopup(`<b>${j.nama}</b><br>Status: ${j.status}<br>Panjang: ${j.panjang} m`);
lyr.on('pm:update', async (e) => {
updateGeom('jalan', j.id, j.nama, j.status, e.layer.toGeoJSON().geometry);
});
});
l.addTo(layerJalan);
htmlJ += `
<div class="list-item">
<h4>${j.nama}</h4>
<div class="attr"><i class="fa-solid fa-tag"></i> ${j.status}</div>
<div class="attr"><i class="fa-solid fa-ruler"></i> ${j.panjang} m</div>
</div>`;
});
document.getElementById('list-jalan').innerHTML = htmlJ || '<div class="empty">Belum ada data jalan.</div>';
// Muat Parsil
const resP = await fetch(`${API}?resource=parsil&action=list`);
const dataP = await resP.json();
layerParsil.clearLayers();
let htmlP = '';
dataP.forEach(p => {
if(!p.geom) return;
const l = L.geoJSON(p.geom, {
style: {
color: warnaParsil[p.status] || '#333',
fillColor: warnaParsil[p.status] || '#333',
fillOpacity: 0.4,
weight: 2
}
});
l.eachLayer(lyr => {
lyr.feature = { properties: { type: 'parsil', id: p.id, nama: p.nama_pemilik, status: p.status, luas: p.luas } };
lyr.bindPopup(`<b>Pemilik: ${p.nama_pemilik}</b><br>Status: ${p.status}<br>Luas: ${p.luas}`);
lyr.on('pm:update', async (e) => {
updateGeom('parsil', p.id, p.nama_pemilik, p.status, e.layer.toGeoJSON().geometry);
});
});
l.addTo(layerParsil);
htmlP += `
<div class="list-item">
<h4>${p.nama_pemilik}</h4>
<div class="attr"><i class="fa-solid fa-file-signature"></i> ${p.status}</div>
<div class="attr"><i class="fa-solid fa-maximize"></i> ${p.luas} m²</div>
</div>`;
});
document.getElementById('list-parsil').innerHTML = htmlP || '<div class="empty">Belum ada data parsil.</div>';
} catch (e) {
console.error(e);
}
}
async function updateGeom(type, id, nama, status, geojson) {
let payload = {};
if(type === 'jalan') {
const len = turf.length(geojson, {units: 'meters'});
payload = { nama, status, panjang: len.toFixed(2), geom: geojson };
} else {
const area = turf.area(geojson);
payload = { nama_pemilik: nama, status, luas: area.toFixed(2), geom: geojson };
}
try {
const res = await fetch(`${API}?resource=${type}&action=update&id=${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if(res.ok) {
toast('Geometri diperbarui!', 'ok');
muatData();
}
} catch(e) { toast('Gagal update', 'err'); }
}
async function simpanData(type) {
let payload = {};
if (type === 'jalan') {
const nama = document.getElementById('j-nama').value.trim();
const status = document.getElementById('j-status').value;
const panjang = document.getElementById('j-panjang').value;
if(!nama) return alert('Nama wajib diisi');
payload = { nama, status, panjang, geom: tempGeoJSON };
} else {
const nama = document.getElementById('p-nama').value.trim();
const status = document.getElementById('p-status').value;
const luas = document.getElementById('p-luas').value;
if(!nama) return alert('Nama pemilik wajib diisi');
payload = { nama_pemilik: nama, status, luas, geom: tempGeoJSON };
}
try {
const res = await fetch(`${API}?resource=${type}&action=create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const json = await res.json();
if(res.ok) {
toast('Berhasil disimpan!', 'ok');
document.getElementById(`ovl-${type}`).classList.remove('on');
if (tempLayer) { map.removeLayer(tempLayer); tempLayer = null; }
muatData();
} else {
toast(json.error || 'Gagal menyimpan', 'err');
}
} catch(e) {
toast('Network error', 'err');
}
}
function batalSimpan(type) {
document.getElementById(`ovl-${type}`).classList.remove('on');
if (tempLayer) {
map.removeLayer(tempLayer);
tempLayer = null;
}
}
function toggleSidebar() {
document.getElementById('sidebar').classList.toggle('hide');
}
function gantiTab(tab) {
document.getElementById('tab-jalan').classList.remove('active');
document.getElementById('tab-parsil').classList.remove('active');
document.getElementById('content-jalan').style.display = 'none';
document.getElementById('content-parsil').style.display = 'none';
document.getElementById(`tab-${tab}`).classList.add('active');
document.getElementById(`content-${tab}`).style.display = 'flex';
}
let toastT;
function toast(msg, tipe = '') {
const el = document.getElementById('toast');
el.textContent = msg; el.className = 'on ' + tipe;
clearTimeout(toastT);
toastT = setTimeout(() => el.className = '', 3000);
}
window.onload = () => {
initMap();
muatData();
};
</script>
</body>
</html>
+469
View File
@@ -0,0 +1,469 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebGIS SPBU</title>
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" />
<style>
/* Menggunakan style dasar yang sama dengan map_bantuan */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Segoe UI', Arial, sans-serif; height: 100vh; display: flex; flex-direction: column; background: #f0f2f5; }
header { background: linear-gradient(135deg, #e08020, #f59e42); color: #fff; padding: 10px 18px; display: flex; align-items: center; gap: 12px; box-shadow: 0 2px 8px rgba(0, 0, 0, .3); z-index: 1000; flex-shrink: 0; justify-content: space-between;}
header .title-area { display: flex; align-items: center; gap: 12px; }
header h1 { font-size: 1rem; font-weight: 700; }
header small { font-size: .7rem; opacity: .85; }
.back-btn { color: white; text-decoration: none; font-size: 0.9rem; border: 1px solid rgba(255,255,255,0.5); padding: 5px 10px; border-radius: 5px; transition: background 0.2s;}
.back-btn:hover { background: rgba(255,255,255,0.2); }
.app-body { display: flex; flex: 1; overflow: hidden; position: relative; }
#sidebar { width: 280px; background: #fff; display: flex; flex-direction: column; border-right: 1px solid #dde; z-index: 900; transition: transform .3s; flex-shrink: 0; }
#sidebar.hide { transform: translateX(-100%); position: absolute; top: 0; left: 0; height: 100%; box-shadow: 4px 0 12px rgba(0, 0, 0, .2); }
.sb-head { background: #e08020; color: #fff; padding: 10px 14px; font-weight: 700; font-size: .88rem; }
.filter-box { padding: 10px 14px; border-bottom: 1px solid #eee; background: #fff3e0; }
.filter-box label { display: flex; align-items: center; gap: 8px; font-size: 0.85rem; font-weight: 600; color: #e08020; cursor: pointer; }
.filter-box input[type="checkbox"] { accent-color: #e08020; width: 16px; height: 16px; cursor: pointer;}
.btn-row { padding: 10px 14px; border-bottom: 1px solid #eee; }
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 8px 12px; border: none; border-radius: 6px; cursor: pointer; font-size: .8rem; font-weight: 700; transition: filter .15s; width: 100%; color: white;}
.btn:hover { filter: brightness(.9); }
.btn-orange { background: #e08020; }
.btn-green { background: #1a6b3c; }
.btn-gray { background: #e0e0e0; color: #333; }
.btn-red { background: #d93025; }
.sb-search { padding: 7px 14px; border-bottom: 1px solid #eee; }
.sb-search input { width: 100%; padding: 7px 10px; border: 1px solid #ccc; border-radius: 6px; font-size: .82rem; }
#sb-list { overflow-y: auto; flex: 1; }
.list-item { padding: 10px 14px; border-bottom: 1px solid #f0f0f0; cursor: pointer; display: flex; align-items: center; gap: 9px; transition: background .15s; }
.list-item:hover { background: #fff8f0; }
.list-item .ico { font-size: 1.3rem; width: 26px; text-align: center; }
.list-item .inf { flex: 1; }
.list-item .inf .nm { font-weight: 700; font-size: .84rem; }
.list-item .inf .jn { font-size: .7rem; color: #777; margin-top: 2px;}
.badge { font-size: .65rem; padding: 2px 6px; border-radius: 10px; font-weight: 700; white-space: nowrap; }
.badge.green { background: #e8f5e9; color: #1a6b3c; }
.badge.red { background: #fdecea; color: #d93025; }
.empty { padding: 16px; text-align: center; color: #bbb; font-size: .82rem; }
#map { flex: 1; z-index: 1; }
#btn-toggle { position: absolute; top: 10px; left: 0; z-index: 950; background: #e08020; color: #fff; border: none; padding: 8px 7px; border-radius: 0 6px 6px 0; cursor: pointer; font-size: 1rem; box-shadow: 2px 0 6px rgba(0, 0, 0, .25); }
/* Modal */
.overlay { display: none; position: fixed; inset: 0; background: rgba(0, 0, 0, .5); z-index: 2000; align-items: center; justify-content: center; }
.overlay.on { display: flex; }
.modal { background: #fff; border-radius: 12px; width: 92%; max-width: 450px; max-height: 92vh; overflow-y: auto; padding: 22px; box-shadow: 0 8px 32px rgba(0, 0, 0, .25); }
.modal h2 { font-size: 1rem; color: #e08020; margin-bottom: 14px; border-bottom: 2px solid #fff3e0; padding-bottom: 8px; }
.fg { margin-bottom: 12px; }
.fg label { display: block; font-size: .78rem; font-weight: 700; color: #444; margin-bottom: 4px; }
.fg input, .fg select { width: 100%; padding: 8px 11px; border: 1px solid #ccc; border-radius: 6px; font-size: .84rem; }
.fg input:focus, .fg select:focus { border-color: #e08020; outline: none; box-shadow: 0 0 0 3px rgba(224, 128, 32, .1); }
.fg-row { display: flex; gap: 8px; }
.fg-row .fg { flex: 1; }
.fg-hint { font-size: .7rem; color: #aaa; margin-top: 3px; }
.err { color: #d93025; font-size: .75rem; margin-top: 3px; display: none; }
.err.on { display: block; }
.modal-footer { display: flex; gap: 8px; justify-content: flex-end; margin-top: 14px; }
.btn-petaclick { background: #fff3e0; color: #e08020; border: 1px dashed #e08020; width: 100%; margin-top: 4px; }
.btn-petaclick.active { background: #e08020; color: #fff; }
#toast { position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%); background: #323232; color: #fff; padding: 9px 20px; border-radius: 8px; font-size: .84rem; z-index: 3000; opacity: 0; transition: opacity .3s; pointer-events: none; }
#toast.on { opacity: 1; }
#toast.ok { background: #1a6b3c; }
#toast.err { background: #d93025; }
.legenda { background: rgba(255, 255, 255, .95); padding: 9px 13px; border-radius: 8px; font-size: .77rem; box-shadow: 0 2px 8px rgba(0, 0, 0, .15); }
.legenda h4 { font-size: .78rem; margin-bottom: 5px; }
.leg-row { display: flex; align-items: center; gap: 7px; margin-top: 3px; }
.dot { width: 13px; height: 13px; border-radius: 50%; display: inline-block; border: 2px solid rgba(0, 0, 0, .15); }
</style>
</head>
<body>
<header>
<div class="title-area">
<i class="fa-solid fa-gas-pump fa-lg"></i>
<div>
<h1>WebGIS Lokasi SPBU</h1>
<small>Informasi Operasional & Pemetaan SPBU</small>
</div>
</div>
<a href="index.html" class="back-btn"><i class="fa-solid fa-arrow-left"></i> Kembali ke Dashboard</a>
</header>
<div class="app-body">
<div id="sidebar">
<div class="sb-head"><i class="fa-solid fa-list-ul"></i> Daftar SPBU</div>
<div class="filter-box">
<label>
<input type="checkbox" id="filter-24j" onchange="loadSPBU()">
Tampilkan hanya SPBU 24 Jam
</label>
</div>
<div class="btn-row">
<button class="btn btn-orange" onclick="bukaForm()">
<i class="fa-solid fa-plus"></i> Tambah SPBU
</button>
</div>
<div class="sb-search">
<input type="text" id="cari-input" placeholder="🔍 Cari SPBU..." oninput="filterList(this.value)">
</div>
<div id="sb-list">
<div class="empty"><i class="fa-solid fa-spinner fa-spin"></i> Memuat...</div>
</div>
</div>
<div id="map"></div>
<button id="btn-toggle" onclick="toggleSidebar()"><i class="fa-solid fa-bars"></i></button>
</div>
<!-- Modal Form -->
<div class="overlay" id="ovl-form">
<div class="modal">
<h2 id="form-title"><i class="fa-solid fa-gas-pump"></i> Tambah SPBU</h2>
<input type="hidden" id="f-id">
<div class="fg">
<label>Nama SPBU <span style="color:red">*</span></label>
<input type="text" id="f-nama" placeholder="Contoh: SPBU Pertamina 14.213.11">
<div class="err" id="err-nama">Nama wajib diisi.</div>
</div>
<div class="fg-row">
<div class="fg">
<label>Buka 24 Jam?</label>
<select id="f-buka">
<option value="1">Ya, 24 Jam</option>
<option value="0" selected>Tidak</option>
</select>
</div>
<div class="fg">
<label>Kontak / Telp</label>
<input type="text" id="f-kontak" placeholder="0812...">
</div>
</div>
<div class="fg-row">
<div class="fg">
<label>Latitude <span style="color:red">*</span></label>
<input type="number" id="f-lat" step="any" placeholder="-0.0354">
</div>
<div class="fg">
<label>Longitude <span style="color:red">*</span></label>
<input type="number" id="f-lng" step="any" placeholder="109.3424">
</div>
</div>
<div class="err" id="err-coord">Koordinat tidak valid.</div>
<button class="btn btn-petaclick" id="btn-pc" onclick="aktifKlik()">
<i class="fa-solid fa-map-pin"></i> Pilih Koordinat dari Peta
</button>
<p class="fg-hint">Tutup form lalu klik langsung di peta untuk memilih koordinat.</p>
<div class="modal-footer">
<button class="btn btn-gray" style="width:auto" onclick="tutupForm()">Batal</button>
<button class="btn btn-orange" style="width:auto" onclick="simpan()"><i class="fa-solid fa-floppy-disk"></i> Simpan</button>
</div>
</div>
</div>
<div id="toast">Notifikasi</div>
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const API_URL = 'api.php?resource=spbu';
let map, markersLayer;
let modeKlik = false;
let dataGlobal = [];
// Icons
const iconGreen = L.icon({
iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-green.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png',
iconSize: [25, 41], iconAnchor: [12, 41], popupAnchor: [1, -34], shadowSize: [41, 41]
});
const iconRed = L.icon({
iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-red.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png',
iconSize: [25, 41], iconAnchor: [12, 41], popupAnchor: [1, -34], shadowSize: [41, 41]
});
function initMap() {
// Default center Pontianak (menyesuaikan koordinat map_bantuan)
map = L.map('map').setView([-0.0380, 109.3440], 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap'
}).addTo(map);
markersLayer = L.layerGroup().addTo(map);
map.on('click', (e) => {
if (!modeKlik) return;
document.getElementById('f-lat').value = e.latlng.lat.toFixed(6);
document.getElementById('f-lng').value = e.latlng.lng.toFixed(6);
// Matikan mode klik
modeKlik = false;
document.getElementById('btn-pc').classList.remove('active');
document.getElementById('btn-pc').innerHTML = '<i class="fa-solid fa-map-pin"></i> Pilih Koordinat dari Peta';
// Buka form lagi
document.getElementById('ovl-form').classList.add('on');
});
// Legenda
const legend = L.control({ position: 'bottomright' });
legend.onAdd = function () {
const div = L.DomUtil.create('div', 'legenda');
div.innerHTML = `
<h4>Legenda SPBU</h4>
<div class="leg-row"><div class="dot" style="background:#28a745"></div> 24 Jam</div>
<div class="leg-row"><div class="dot" style="background:#dc3545"></div> Tidak 24 Jam</div>
`;
return div;
};
legend.addTo(map);
}
async function loadSPBU() {
try {
const res = await fetch(`${API_URL}&action=list`);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Gagal memuat data');
dataGlobal = data;
renderData();
} catch (e) {
showToast(e.message, true);
document.getElementById('sb-list').innerHTML = `<div class="empty">Gagal memuat data.</div>`;
}
}
function renderData() {
markersLayer.clearLayers();
const list = document.getElementById('sb-list');
const q = document.getElementById('cari-input').value.toLowerCase();
const filter24 = document.getElementById('filter-24j').checked;
let html = '';
let count = 0;
dataGlobal.forEach(d => {
// Filter 24 Jam
if (filter24 && d.buka_24_jam != 1) return;
// Filter Pencarian
if (q && !d.nama.toLowerCase().includes(q)) return;
count++;
const is24 = d.buka_24_jam == 1;
// List HTML
html += `
<div class="list-item" onclick="flyTo(${d.latitude}, ${d.longitude})">
<div class="ico" style="color: ${is24 ? '#1a6b3c' : '#d93025'}"><i class="fa-solid fa-gas-pump"></i></div>
<div class="inf">
<div class="nm">${d.nama}</div>
<div class="jn"><i class="fa-solid fa-phone"></i> ${d.kontak || '-'}</div>
</div>
<div class="badge ${is24 ? 'green' : 'red'}">${is24 ? '24 Jam' : 'Non-24 Jam'}</div>
</div>
`;
// Map Marker (Make it draggable)
const marker = L.marker([d.latitude, d.longitude], {
icon: is24 ? iconGreen : iconRed,
draggable: true // FITUR DRAG AND DROP
});
marker.bindPopup(`
<div style="font-family:'Segoe UI',Arial; min-width:200px">
<h4 style="margin:0 0 5px; color:#e08020; font-size:14px;"><i class="fa-solid fa-gas-pump"></i> ${d.nama}</h4>
<div style="font-size:12px; margin-bottom:10px;">
<span class="badge ${is24 ? 'green' : 'red'}" style="display:inline-block; margin-bottom:5px;">${is24 ? '24 Jam' : 'Non-24 Jam'}</span><br>
<i class="fa-solid fa-phone"></i> Kontak: <b>${d.kontak || '-'}</b>
</div>
<div style="display:flex; gap:5px; padding-top:8px; border-top:1px solid #eee">
<button class="btn btn-orange btn-sm" onclick="editData(${d.id})" style="flex:1; padding:4px"><i class="fa-solid fa-pen"></i> Edit</button>
<button class="btn btn-red btn-sm" onclick="hapusData(${d.id})" style="padding:4px 8px"><i class="fa-solid fa-trash"></i></button>
</div>
<div style="font-size:10px; color:#999; margin-top:8px; text-align:center;">
<i class="fa-solid fa-arrows-up-down-left-right"></i> Drag marker untuk memindahkan
</div>
</div>
`);
// Event ketika marker selesai di-drag
marker.on('dragend', function(event) {
const newLatLng = event.target.getLatLng();
updatePosisiSPBU(d.id, newLatLng.lat, newLatLng.lng);
});
markersLayer.addLayer(marker);
});
if (count === 0) {
html = `<div class="empty">Tidak ada SPBU ditemukan.</div>`;
}
list.innerHTML = html;
}
async function updatePosisiSPBU(id, lat, lng) {
try {
const res = await fetch(`${API_URL}&action=update&id=${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ latitude: lat, longitude: lng })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
showToast('Posisi SPBU diperbarui.');
// Update data lokal
const item = dataGlobal.find(x => x.id == id);
if (item) {
item.latitude = lat;
item.longitude = lng;
}
} catch (e) {
showToast('Gagal memindahkan marker: ' + e.message, true);
loadSPBU(); // Kembalikan ke posisi awal jika gagal
}
}
function filterList() { renderData(); }
function flyTo(lat, lng) { map.flyTo([lat, lng], 16); }
function toggleSidebar() {
document.getElementById('sidebar').classList.toggle('hide');
}
// --- FORM LOGIC ---
function bukaForm() {
document.getElementById('form-title').innerHTML = '<i class="fa-solid fa-gas-pump"></i> Tambah SPBU';
document.getElementById('f-id').value = '';
document.getElementById('f-nama').value = '';
document.getElementById('f-buka').value = '0';
document.getElementById('f-kontak').value = '';
document.getElementById('f-lat').value = '';
document.getElementById('f-lng').value = '';
document.getElementById('err-nama').classList.remove('on');
document.getElementById('err-coord').classList.remove('on');
document.getElementById('ovl-form').classList.add('on');
}
function tutupForm() {
document.getElementById('ovl-form').classList.remove('on');
modeKlik = false;
}
function editData(id) {
const d = dataGlobal.find(x => x.id == id);
if (!d) return;
map.closePopup();
document.getElementById('form-title').innerHTML = '<i class="fa-solid fa-pen"></i> Edit SPBU';
document.getElementById('f-id').value = d.id;
document.getElementById('f-nama').value = d.nama;
document.getElementById('f-buka').value = d.buka_24_jam;
document.getElementById('f-kontak').value = d.kontak;
document.getElementById('f-lat').value = d.latitude;
document.getElementById('f-lng').value = d.longitude;
document.getElementById('err-nama').classList.remove('on');
document.getElementById('err-coord').classList.remove('on');
document.getElementById('ovl-form').classList.add('on');
}
function aktifKlik() {
modeKlik = true;
document.getElementById('btn-pc').classList.add('active');
document.getElementById('btn-pc').innerHTML = '<i class="fa-solid fa-crosshairs"></i> Menunggu Klik di Peta...';
document.getElementById('ovl-form').classList.remove('on'); // Sembunyikan form sementara
showToast('Klik di area peta untuk menentukan koordinat SPBU.');
}
async function simpan() {
const id = document.getElementById('f-id').value;
const payload = {
nama: document.getElementById('f-nama').value,
buka_24_jam: document.getElementById('f-buka').value,
kontak: document.getElementById('f-kontak').value,
latitude: document.getElementById('f-lat').value,
longitude: document.getElementById('f-lng').value
};
let valid = true;
if (!payload.nama) { document.getElementById('err-nama').classList.add('on'); valid = false; }
else { document.getElementById('err-nama').classList.remove('on'); }
if (!payload.latitude || !payload.longitude) { document.getElementById('err-coord').classList.add('on'); valid = false; }
else { document.getElementById('err-coord').classList.remove('on'); }
if (!valid) return;
const url = id ? `${API_URL}&action=update&id=${id}` : `${API_URL}&action=create`;
const method = id ? 'PUT' : 'POST';
try {
const res = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
showToast(data.message);
tutupForm();
loadSPBU();
} catch (e) {
showToast(e.message, true);
}
}
async function hapusData(id) {
if (!confirm('Hapus SPBU ini?')) return;
try {
const res = await fetch(`${API_URL}&action=delete&id=${id}`, { method: 'DELETE' });
const data = await res.json();
if (!res.ok) throw new Error(data.error);
showToast(data.message);
map.closePopup();
loadSPBU();
} catch (e) {
showToast(e.message, true);
}
}
function showToast(msg, isErr = false) {
const t = document.getElementById('toast');
t.textContent = msg;
t.className = 'on ' + (isErr ? 'err' : 'ok');
setTimeout(() => t.className = '', 3000);
}
// Init
initMap();
loadSPBU();
</script>
</body>
</html>