chore: sync repository snapshot

This commit is contained in:
GuavaPopper
2026-06-11 14:53:35 +07:00
parent 529d8bcc70
commit 3bed7457ec
88 changed files with 13102 additions and 152 deletions
+6
View File
@@ -0,0 +1,6 @@
.git
.gitignore
.gitattributes
.env
*.md
docker-compose.yml
+4
View File
@@ -0,0 +1,4 @@
DB_HOST=localhost
DB_NAME=spbu_baru
DB_USER=root
DB_PASS=
+2
View File
@@ -0,0 +1,2 @@
* text=auto
*.sh text eol=lf
+11
View File
@@ -0,0 +1,11 @@
# OS/editor files
.DS_Store
Thumbs.db
.vscode/
.idea/
# Logs
*.log
# Environment (kredensial — jangan pernah di-commit)
.env
+25
View File
@@ -0,0 +1,25 @@
# WebGIS SPBU — vanilla PHP + HTML statis (tanpa Composer/npm, tanpa build step),
# jadi cukup single-stage (multi-stage di coolify-deployment.md Aturan #5 tidak
# relevan untuk project ini).
#
# Coolify: deploy sebagai Application dengan Dockerfile buildpack, lalu isi
# Ports Exposes = 8000 secara manual (Aturan #3 — EXPOSE di bawah tidak dibaca).
FROM php:8.3-cli
RUN docker-php-ext-install pdo_mysql \
&& mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
WORKDIR /app
COPY . .
# Normalisasi CRLF→LF untuk jaga-jaga kalau repo ter-checkout dengan CRLF.
RUN sed -i 's/\r$//' docker/entrypoint.sh \
&& chmod +x docker/entrypoint.sh
# Concurrency untuk PHP built-in server (default-nya single process).
ENV PHP_CLI_SERVER_WORKERS=8
EXPOSE 8000
ENTRYPOINT ["/app/docker/entrypoint.sh"]
CMD ["php", "-S", "0.0.0.0:8000", "-t", "/app"]
+27
View File
@@ -0,0 +1,27 @@
<?php
// ── SHARED DATABASE CONFIGURATION ───────────────────────────────────────────
// Digunakan oleh semua endpoint API. Kredensial di-resolve lewat api/env.php:
// env var asli (Coolify/Docker) menang atas file .env, dan .env opsional.
require_once __DIR__ . '/env.php';
$db = dbConfig();
// ── KONEKSI PDO ───────────────────────────────────────────────────────────────
try {
$pdo = new PDO(
"mysql:host={$db['host']};port={$db['port']};dbname={$db['dbname']};charset=utf8mb4",
$db['username'],
$db['password'],
[
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(['success' => false, 'message' => 'Koneksi database gagal: ' . $e->getMessage()]);
exit();
}
+49
View File
@@ -0,0 +1,49 @@
<?php
// ── DELETE SPBU ───────────────────────────────────────────────────────────────
require_once __DIR__ . '/config.php';
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: DELETE, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit();
}
// Terima DELETE atau POST dengan _method=DELETE
$method = $_SERVER['REQUEST_METHOD'];
$body = file_get_contents('php://input');
$data = json_decode($body, true);
if ($method !== 'DELETE' && !($method === 'POST' && isset($data['_method']) && $data['_method'] === 'DELETE')) {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'Method not allowed']);
exit();
}
// ── VALIDASI ID ───────────────────────────────────────────────────────────────
$id = intval($data['id'] ?? 0);
if ($id <= 0) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'ID tidak valid']);
exit();
}
// ── HAPUS DATA ────────────────────────────────────────────────────────────────
try {
$stmt = $pdo->prepare("DELETE FROM spbu WHERE id = :id");
$stmt->execute([':id' => $id]);
if ($stmt->rowCount() === 0) {
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'Data tidak ditemukan']);
} else {
echo json_encode(['success' => true, 'message' => 'Data SPBU berhasil dihapus']);
}
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Gagal menghapus data: ' . $e->getMessage()]);
}
+56
View File
@@ -0,0 +1,56 @@
<?php
// ── RESOLUSI ENVIRONMENT VARIABLE ────────────────────────────────────────────
// Dipakai oleh api/config.php dan docker/init-db.php. Prioritas nilai mengikuti
// perilaku Coolify (lihat coolify-deployment.md Aturan #1):
// env var asli (Coolify/Docker) > file .env di root project > default.
// File .env opsional — di dalam container memang tidak ada.
function loadEnvFile(string $path): array
{
if (!is_file($path)) {
return [];
}
$vars = [];
foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
if (str_starts_with(trim($line), '#') || !str_contains($line, '=')) continue;
[$key, $value] = explode('=', $line, 2);
$vars[trim($key)] = trim($value);
}
return $vars;
}
function env(string $key): ?string
{
static $fileVars = null;
if ($fileVars === null) {
$fileVars = loadEnvFile(__DIR__ . '/../.env');
}
$real = getenv($key);
if ($real !== false && $real !== '') {
return $real;
}
return ($fileVars[$key] ?? '') !== '' ? $fileVars[$key] : null;
}
// Nama variabel project ini (DB_NAME/DB_USER/DB_PASS) maupun alias gaya Laravel
// (DB_DATABASE/DB_USERNAME/DB_PASSWORD) sama-sama diterima — lihat Aturan #2.
function dbConfig(): array
{
$envAny = function (array $keys, string $default): string {
foreach ($keys as $key) {
$value = env($key);
if ($value !== null) return $value;
}
return $default;
};
return [
'host' => $envAny(['DB_HOST'], 'localhost'),
'port' => $envAny(['DB_PORT'], '3306'),
'dbname' => $envAny(['DB_NAME', 'DB_DATABASE'], ''),
'username' => $envAny(['DB_USER', 'DB_USERNAME'], 'root'),
'password' => $envAny(['DB_PASS', 'DB_PASSWORD'], ''),
];
}
+23
View File
@@ -0,0 +1,23 @@
<?php
// ── GET SPBU ──────────────────────────────────────────────────────────────────
require_once __DIR__ . '/config.php';
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit();
}
// ── AMBIL DATA ────────────────────────────────────────────────────────────────
try {
$stmt = $pdo->query("SELECT * FROM spbu ORDER BY created_at DESC");
$rows = $stmt->fetchAll();
echo json_encode($rows);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Gagal mengambil data: ' . $e->getMessage()]);
}
+69
View File
@@ -0,0 +1,69 @@
<?php
// ── SAVE SPBU ─────────────────────────────────────────────────────────────────
require_once __DIR__ . '/config.php';
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit();
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'Method not allowed']);
exit();
}
// ── BACA JSON BODY ─────────────────────────────────────────────────────────────
$body = file_get_contents('php://input');
$data = json_decode($body, true);
if (!$data) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Data tidak valid (JSON parse error)']);
exit();
}
// ── VALIDASI ──────────────────────────────────────────────────────────────────
$nama_spbu = trim($data['nama_spbu'] ?? '');
$nomor_spbu = trim($data['nomor_spbu'] ?? '');
$status = trim($data['status'] ?? '');
$latitude = floatval($data['latitude'] ?? 0);
$longitude = floatval($data['longitude'] ?? 0);
$valid_status = ['Buka 24 Jam', 'Tidak Buka 24 Jam'];
if (empty($nama_spbu)) { echo json_encode(['success' => false, 'message' => 'Nama SPBU tidak boleh kosong']); exit(); }
if (empty($nomor_spbu)) { echo json_encode(['success' => false, 'message' => 'Nomor SPBU tidak boleh kosong']); exit(); }
if (!in_array($status, $valid_status)) { echo json_encode(['success' => false, 'message' => 'Status tidak valid']); exit(); }
if ($latitude === 0.0 && $longitude === 0.0) { echo json_encode(['success' => false, 'message' => 'Koordinat tidak valid']); exit(); }
// ── INSERT DATA ───────────────────────────────────────────────────────────────
try {
$stmt = $pdo->prepare("
INSERT INTO spbu (nama_spbu, nomor_spbu, status, latitude, longitude, created_at)
VALUES (:nama_spbu, :nomor_spbu, :status, :latitude, :longitude, NOW())
");
$stmt->execute([
':nama_spbu' => $nama_spbu,
':nomor_spbu' => $nomor_spbu,
':status' => $status,
':latitude' => $latitude,
':longitude' => $longitude,
]);
echo json_encode([
'success' => true,
'message' => 'Data SPBU berhasil disimpan',
'id' => $pdo->lastInsertId()
]);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Gagal menyimpan data: ' . $e->getMessage()]);
}
+79
View File
@@ -0,0 +1,79 @@
<?php
// ── UPDATE SPBU ───────────────────────────────────────────────────────────────
require_once __DIR__ . '/config.php';
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, PUT, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit();
}
$method = $_SERVER['REQUEST_METHOD'];
if ($method !== 'POST' && $method !== 'PUT') {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'Method not allowed']);
exit();
}
// ── BACA JSON BODY ─────────────────────────────────────────────────────────────
$body = file_get_contents('php://input');
$data = json_decode($body, true);
if (!$data) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Data tidak valid (JSON parse error)']);
exit();
}
// ── VALIDASI ──────────────────────────────────────────────────────────────────
$id = intval($data['id'] ?? 0);
$nama_spbu = trim($data['nama_spbu'] ?? '');
$nomor_spbu = trim($data['nomor_spbu'] ?? '');
$status = trim($data['status'] ?? '');
$latitude = floatval($data['latitude'] ?? 0);
$longitude = floatval($data['longitude'] ?? 0);
$valid_status = ['Buka 24 Jam', 'Tidak Buka 24 Jam'];
if ($id <= 0) { echo json_encode(['success' => false, 'message' => 'ID tidak valid']); exit(); }
if (empty($nama_spbu)) { echo json_encode(['success' => false, 'message' => 'Nama SPBU tidak boleh kosong']); exit(); }
if (empty($nomor_spbu)) { echo json_encode(['success' => false, 'message' => 'Nomor SPBU tidak boleh kosong']); exit(); }
if (!in_array($status, $valid_status)) { echo json_encode(['success' => false, 'message' => 'Status tidak valid']); exit(); }
if ($latitude === 0.0 && $longitude === 0.0) { echo json_encode(['success' => false, 'message' => 'Koordinat tidak valid']); exit(); }
// ── UPDATE DATA ───────────────────────────────────────────────────────────────
try {
$stmt = $pdo->prepare("
UPDATE spbu
SET nama_spbu = :nama_spbu,
nomor_spbu = :nomor_spbu,
status = :status,
latitude = :latitude,
longitude = :longitude
WHERE id = :id
");
$stmt->execute([
':id' => $id,
':nama_spbu' => $nama_spbu,
':nomor_spbu' => $nomor_spbu,
':status' => $status,
':latitude' => $latitude,
':longitude' => $longitude,
]);
if ($stmt->rowCount() === 0) {
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'Data tidak ditemukan atau tidak ada perubahan']);
} else {
echo json_encode(['success' => true, 'message' => 'Data SPBU berhasil diperbarui']);
}
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Gagal memperbarui data: ' . $e->getMessage()]);
}
+33
View File
@@ -0,0 +1,33 @@
-- ============================================================
-- WebGIS SPBU - Database Schema
-- Jalankan script ini di phpMyAdmin atau MySQL CLI
-- ============================================================
-- Buat database (jika belum ada)
CREATE DATABASE IF NOT EXISTS spbu_baru
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE spbu_baru;
-- Buat tabel spbu
CREATE TABLE IF NOT EXISTS spbu (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
nama_spbu VARCHAR(255) NOT NULL,
nomor_spbu VARCHAR(50) NOT NULL,
status ENUM('Buka 24 Jam', 'Tidak Buka 24 Jam') NOT NULL,
latitude DECIMAL(10, 7) NOT NULL,
longitude DECIMAL(10, 7) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
INDEX idx_status (status),
INDEX idx_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- Contoh data awal (opsional, hapus jika tidak diperlukan)
-- ============================================================
INSERT INTO spbu (nama_spbu, nomor_spbu, status, latitude, longitude) VALUES
('SPBU Jl. Ahmad Yani', '64.751.01', 'Buka 24 Jam', -0.0600366, 109.3458023),
('SPBU Jl. Gajah Mada', '64.751.02', 'Tidak Buka 24 Jam', -0.0596729, 109.3469671);
+29
View File
@@ -0,0 +1,29 @@
# Verifikasi lokal sebelum push ke Coolify. Topologi meniru Coolify persis:
# MySQL terpisah dengan database `default` + user non-root `mysql` (sekaligus
# menguji bahwa init-db tidak butuh privilege CREATE DATABASE).
services:
app:
build: .
ports:
- "8000:8000"
environment:
DB_HOST: db
DB_PORT: "3306"
DB_NAME: default
DB_USER: mysql
DB_PASS: secret
depends_on:
- db
db:
image: mysql:8.0
environment:
MYSQL_DATABASE: default
MYSQL_USER: mysql
MYSQL_PASSWORD: secret
MYSQL_ROOT_PASSWORD: rootsecret
volumes:
- db-data:/var/lib/mysql
volumes:
db-data:
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
set -e
php /app/docker/init-db.php
exec "$@"
+62
View File
@@ -0,0 +1,62 @@
<?php
// ── INIT DATABASE (dijalankan entrypoint pada SETIAP container start) ────────
// Pengganti "migrate + seed" dari coolify-deployment.md Aturan #6:
// 1. Tunggu database siap (retry ±60 detik).
// 2. Kalau tabel `spbu` sudah ada → skip, supaya idempoten antar restart
// (INSERT seed di schema.sql tidak boleh jalan dua kali).
// 3. Kalau belum → eksekusi database/schema.sql, dengan melewati statement
// CREATE DATABASE/USE — database sudah disediakan Coolify dan user
// aplikasi tidak punya privilege CREATE DATABASE.
require __DIR__ . '/../api/env.php';
$db = dbConfig();
$dsn = "mysql:host={$db['host']};port={$db['port']};dbname={$db['dbname']};charset=utf8mb4";
echo "Waiting for database at {$db['host']}:{$db['port']}...\n";
$pdo = null;
$maxAttempts = 30;
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
try {
$pdo = new PDO($dsn, $db['username'], $db['password'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_TIMEOUT => 3,
]);
break;
} catch (PDOException $e) {
if ($attempt === $maxAttempts) {
fwrite(STDERR, "Database {$db['host']}:{$db['port']} unreachable after {$maxAttempts} attempts: {$e->getMessage()}\n");
exit(1);
}
sleep(2);
}
}
echo "Database connection OK.\n";
if ($pdo->query("SHOW TABLES LIKE 'spbu'")->fetch() !== false) {
echo "Table 'spbu' already exists, skipping schema init.\n";
exit(0);
}
echo "Initializing schema from database/schema.sql...\n";
$sql = file_get_contents(__DIR__ . '/../database/schema.sql');
// Buang baris komentar penuh supaya pemecahan per `;` tetap aman.
$lines = array_filter(
explode("\n", $sql),
fn ($line) => !str_starts_with(trim($line), '--')
);
$statements = array_filter(array_map('trim', explode(';', implode("\n", $lines))));
foreach ($statements as $statement) {
if (preg_match('/^(CREATE\s+DATABASE|USE)\b/i', $statement)) {
continue;
}
$pdo->exec($statement);
}
echo "Schema initialized: table 'spbu' created and seeded.\n";
+212
View File
@@ -0,0 +1,212 @@
<!DOCTYPE html>
<html lang="id" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WebGIS SPBU</title>
<!-- Leaflet -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<!-- DaisyUI + Tailwind -->
<link href="https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<!-- Plus Jakarta Sans Font -->
<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:ital,wght@0,200..800;1,200..800&display=swap" rel="stylesheet">
<style>
* {
font-family: 'Plus Jakarta Sans', sans-serif;
}
body {
font-weight: 500;
}
h1, h2, h3, h4, h5, h6, .btn, .card-title, .stat-value {
font-weight: 700 !important;
letter-spacing: -0.02em;
}
#bgMap {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
opacity: 0.5;
filter: saturate(1.2) brightness(0.7);
}
.hero {
position: relative;
z-index: 10;
background: transparent !important;
}
/* Glass effect for content */
.hero-content {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(12px);
padding: 3rem;
border-radius: 2rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.3);
}
[data-theme="dark"] .hero-content {
background: rgba(15, 23, 42, 0.7);
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* Custom Leaflet Control Layers Styling */
.leaflet-control-layers {
border-radius: 12px !important;
border: 1px solid rgba(255, 255, 255, 0.2) !important;
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.15) !important;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
background: rgba(255, 255, 255, 0.85) !important;
padding: 8px 12px !important;
font-family: inherit !important;
font-weight: 700 !important;
font-size: 0.85rem !important;
transition: all 0.3s ease !important;
}
.leaflet-control-layers:hover {
background: rgba(255, 255, 255, 0.95) !important;
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.25) !important;
}
.leaflet-control-layers-list label {
margin-bottom: 4px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px;
border-radius: 6px;
font-weight: 600 !important;
}
.leaflet-control-layers-list label:hover {
background: rgba(0, 0, 0, 0.05);
}
.leaflet-control-layers-separator {
margin: 8px 0 !important;
border-top: 1px solid rgba(0, 0, 0, 0.1) !important;
}
.leaflet-control-layers-selector {
accent-color: #2563eb;
width: 16px;
height: 16px;
margin-top: 0 !important;
}
</style>
</head>
<body class="min-h-screen bg-base-200 flex flex-col items-center justify-center overflow-hidden">
<!-- Background Map -->
<div id="bgMap"></div>
<div class="hero min-h-screen">
<div class="hero-content text-center flex-col gap-2">
<!-- Logo -->
<div class="text-7xl mb-2"></div>
<h1 class="text-4xl font-bold tracking-tight">WebGIS SPBU</h1>
<p class="max-w-sm text-base-content/60 mt-2 mb-6 leading-relaxed">
Sistem Informasi Geografis untuk pemetaan dan pengelolaan
titik-titik Stasiun Pengisian Bahan Bakar Umum.
</p>
<!-- Buttons -->
<div class="flex flex-col sm:flex-row gap-3">
<a href="pages/input.html" class="btn btn-primary btn-lg gap-2">
<i class="fa-solid fa-map-location-dot"></i>
Input Data SPBU
</a>
<a href="pages/showdata.html" class="btn btn-outline btn-lg gap-2">
<i class="fa-solid fa-table-list"></i>
Lihat Data &amp; Peta
</a>
</div>
<!-- Redirect progress -->
<div class="mt-8 w-72">
<p class="text-xs text-base-content/40 mb-2">Mengarahkan ke halaman Input dalam 3 detik...</p>
<progress class="progress progress-primary w-full" id="redirectProgress" value="0" max="100"></progress>
</div>
</div>
</div>
<script>
// Progress bar logic
let val = 0;
const bar = document.getElementById('redirectProgress');
const tick = setInterval(() => {
val += 100 / 50; // Slower for effect (5s)
bar.value = val;
if (val >= 100) clearInterval(tick);
}, 100);
setTimeout(() => { window.location.href = 'pages/input.html'; }, 5000);
// Background Map logic
const map = L.map('bgMap', {
zoomControl: false,
attributionControl: false,
dragging: false,
scrollWheelZoom: false,
doubleClickZoom: false
}).setView([-0.0606, 109.345], 15);
// Satellite Base
const satellite = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
maxZoom: 19
}).addTo(map);
// Standard Base (Optional)
const osm = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19
});
// Layer Groups
const layer24h = L.layerGroup().addTo(map);
const layerRegular = L.layerGroup().addTo(map);
// Control (visible but subtle)
L.control.layers({ "Satelit": satellite, "Street": osm },
{ "Buka 24 Jam": layer24h, "Normal": layerRegular },
{ position: 'bottomleft' }).addTo(map);
// Fetch SPBU points to show on background
fetch('api/get_spbu.php')
.then(r => r.json())
.then(data => {
const markers = [];
data.forEach(spbu => {
const is24 = spbu.status === 'Buka 24 Jam';
const group = is24 ? layer24h : layerRegular;
const color = is24 ? "#22c55e" : "#3b82f6";
L.circleMarker([+spbu.latitude, +spbu.longitude], {
radius: 6,
fillColor: color,
color: "#fff",
weight: 2,
opacity: 1,
fillOpacity: 0.8
}).addTo(group);
markers.push([+spbu.latitude, +spbu.longitude]);
});
if (markers.length) {
map.fitBounds(markers, { padding: [50, 50] });
}
// Subtle animation: slow pan
let lat = map.getCenter().lat;
setInterval(() => {
lat += 0.00001;
map.panTo([lat, map.getCenter().lng], { animate: true, duration: 1 });
}, 1000);
})
.catch(() => {});
</script>
</body>
</html>
+683
View File
@@ -0,0 +1,683 @@
<!DOCTYPE html>
<html lang="id" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Input Data SPBU - WebGIS</title>
<!-- Leaflet -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<!-- DaisyUI + Tailwind -->
<link href="https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<!-- Plus Jakarta Sans Font -->
<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:ital,wght@0,200..800;1,200..800&display=swap" rel="stylesheet">
<style>
* {
font-family: 'Plus Jakarta Sans', sans-serif;
}
body {
font-weight: 500;
}
h1, h2, h3, h4, h5, h6, .btn, .card-title, .stat-value {
font-weight: 700 !important;
letter-spacing: -0.01em;
}
html, body { height: 100%; }
body { display: flex; flex-direction: column; overflow: hidden; }
.map-wrapper { flex: 1; position: relative; min-height: 0; }
#map { width: 100%; height: 100%; }
/* Drag cursor for Leaflet marker */
.leaflet-marker-draggable { cursor: grab !important; }
.leaflet-dragging .leaflet-marker-draggable { cursor: grabbing !important; }
/* Spinner inside leaflet popup */
@keyframes _spin { to { transform: rotate(360deg); } }
._spin { animation: _spin .7s linear infinite; }
/* Custom Leaflet Control Layers Styling */
.leaflet-control-layers {
border-radius: 12px !important;
border: 1px solid rgba(255, 255, 255, 0.2) !important;
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.15) !important;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
background: rgba(255, 255, 255, 0.85) !important;
padding: 8px 12px !important;
font-family: inherit !important;
font-weight: 700 !important;
font-size: 0.85rem !important;
transition: all 0.3s ease !important;
}
.leaflet-control-layers:hover {
background: rgba(255, 255, 255, 0.95) !important;
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.25) !important;
}
.leaflet-control-layers-list label {
margin-bottom: 4px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px;
border-radius: 6px;
font-weight: 600 !important;
}
.leaflet-control-layers-list label:hover {
background: rgba(0, 0, 0, 0.05);
}
.leaflet-control-layers-separator {
margin: 8px 0 !important;
border-top: 1px solid rgba(0, 0, 0, 0.1) !important;
}
.leaflet-control-layers-selector {
accent-color: #2563eb;
width: 16px;
height: 16px;
margin-top: 0 !important;
}
</style>
</head>
<body>
<!-- ═══ NAVBAR ═══════════════════════════════════════════════════════════ -->
<div class="navbar bg-base-100 border-b border-base-200 shadow-sm flex-shrink-0 z-[1000]">
<div class="flex-1">
<a href="../index.html" class="btn btn-ghost text-lg font-bold gap-2 text-primary">
<i class="fa-solid fa-gas-pump"></i>
WebGIS SPBU
</a>
</div>
<div class="flex-none">
<ul class="menu menu-horizontal px-1 gap-1">
<li>
<a href="input.html"
class="font-semibold bg-primary/10 text-primary rounded-lg">
<i class="fa-solid fa-map-location-dot"></i> Input Data
</a>
</li>
<li>
<a href="showdata.html" class="font-medium">
<i class="fa-solid fa-table-list"></i> Lihat Data
</a>
</li>
</ul>
</div>
</div>
<!-- ═══ MAP ══════════════════════════════════════════════════════════════ -->
<div class="map-wrapper">
<div id="map"></div>
<!-- Normal hint -->
<div id="mapHint"
class="absolute bottom-8 left-1/2 -translate-x-1/2 z-[999] pointer-events-none
badge badge-primary badge-lg py-3 px-4 gap-2 shadow-md">
<i class="fa-solid fa-hand-pointer"></i>
Klik peta untuk tambah SPBU &bull; Drag marker untuk pindah lokasi
</div>
<!-- Drag hint -->
<div id="dragHint"
class="absolute bottom-8 left-1/2 -translate-x-1/2 z-[999] pointer-events-none hidden
badge badge-warning badge-lg py-3 px-4 gap-2 shadow-md">
<i class="fa-solid fa-arrows-up-down-left-right"></i>
Lepaskan untuk menyimpan lokasi baru
</div>
</div>
<!-- ═══ MODAL: INPUT (dialog DaisyUI) ════════════════════════════════════ -->
<dialog id="inputModal" class="modal">
<div class="modal-box">
<!-- Header -->
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
id="btnCloseModal">✕</button>
<h3 class="font-bold text-lg flex items-center gap-2 mb-1">
<i class="fa-solid fa-gas-pump text-primary"></i>
Tambah Data SPBU
</h3>
<p class="text-sm text-base-content/50 mb-4">Isi atribut titik lokasi baru</p>
<!-- Koordinat -->
<div class="alert alert-info py-2 mb-4 text-sm">
<i class="fa-solid fa-location-crosshairs"></i>
<span id="coordText">Memuat koordinat...</span>
</div>
<!-- Form -->
<label class="form-control mb-3">
<div class="label"><span class="label-text font-semibold">Nama SPBU</span></div>
<input type="text" id="namaSPBU" class="input input-bordered w-full"
placeholder="Contoh: SPBU Jl. Ahmad Yani" autocomplete="off">
</label>
<label class="form-control mb-3">
<div class="label"><span class="label-text font-semibold">Nomor SPBU</span></div>
<input type="text" id="nomorSPBU" class="input input-bordered w-full"
placeholder="Contoh: 64.751.01" autocomplete="off">
</label>
<label class="form-control mb-4">
<div class="label"><span class="label-text font-semibold">Status Operasional</span></div>
<select id="statusSPBU" class="select select-bordered w-full">
<option value="">-- Pilih Status --</option>
<option value="Buka 24 Jam">☀️🌙 Buka 24 Jam</option>
<option value="Tidak Buka 24 Jam">⏰ Tidak Buka 24 Jam</option>
</select>
</label>
<div class="modal-action">
<button class="btn" id="btnCancel">
<i class="fa-solid fa-xmark"></i> Batal
</button>
<button class="btn btn-primary" id="btnSimpan">
<i class="fa-solid fa-floppy-disk"></i> Simpan
</button>
</div>
</div>
<!-- Backdrop click = close -->
<form method="dialog" class="modal-backdrop">
<button id="btnBackdropInput">close</button>
</form>
</dialog>
<!-- ═══ MODAL: EDIT ═══════════════════════════════════════════════════════ -->
<dialog id="editModal" class="modal">
<div class="modal-box">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
id="btnCloseEditModal">✕</button>
<h3 class="font-bold text-lg flex items-center gap-2 mb-1 text-warning">
<i class="fa-solid fa-pen-to-square"></i>
Edit Data SPBU
</h3>
<p class="text-sm text-base-content/50 mb-4">Perbarui atribut titik lokasi</p>
<div class="alert alert-info py-2 mb-4 text-sm">
<i class="fa-solid fa-location-crosshairs"></i>
<span id="editCoordText">Memuat koordinat...</span>
</div>
<label class="form-control mb-3">
<div class="label"><span class="label-text font-semibold">Nama SPBU</span></div>
<input type="text" id="editNamaSPBU" class="input input-bordered w-full"
placeholder="Contoh: SPBU Jl. Ahmad Yani" autocomplete="off">
</label>
<label class="form-control mb-3">
<div class="label"><span class="label-text font-semibold">Nomor SPBU</span></div>
<input type="text" id="editNomorSPBU" class="input input-bordered w-full"
placeholder="Contoh: 64.751.01" autocomplete="off">
</label>
<label class="form-control mb-4">
<div class="label"><span class="label-text font-semibold">Status Operasional</span></div>
<select id="editStatusSPBU" class="select select-bordered w-full">
<option value="">-- Pilih Status --</option>
<option value="Buka 24 Jam">☀️🌙 Buka 24 Jam</option>
<option value="Tidak Buka 24 Jam">⏰ Tidak Buka 24 Jam</option>
</select>
</label>
<div class="modal-action">
<button class="btn" id="btnCancelEdit">
<i class="fa-solid fa-xmark"></i> Batal
</button>
<button class="btn btn-warning text-white" id="btnUpdate">
<i class="fa-solid fa-floppy-disk"></i> Perbarui
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button id="btnBackdropEdit">close</button>
</form>
</dialog>
<!-- ═══ MODAL: KONFIRMASI HAPUS ══════════════════════════════════════════ -->
<dialog id="deleteModal" class="modal">
<div class="modal-box text-center max-w-sm">
<div class="text-5xl mb-3">🗑️</div>
<h3 class="font-bold text-lg mb-1">Hapus Data SPBU?</h3>
<p class="text-sm text-base-content/60 mb-1">
Data <strong id="deleteSpbuName"></strong> akan dihapus secara permanen.
</p>
<p class="text-sm text-error mb-5">⚠️ Tindakan ini tidak dapat dibatalkan.</p>
<div class="modal-action justify-center">
<button class="btn" id="btnCancelDelete">
<i class="fa-solid fa-xmark"></i> Batal
</button>
<button class="btn btn-error" id="btnConfirmDelete">
<i class="fa-solid fa-trash"></i> Ya, Hapus
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button id="btnBackdropDelete">close</button>
</form>
</dialog>
<!-- ═══ TOAST CONTAINER ════════════════════════════════════════════════════ -->
<div class="toast toast-end toast-bottom z-[99999]" id="toastContainer"></div>
<!-- ═══ JAVASCRIPT ════════════════════════════════════════════════════════ -->
<script>
// ── CONFIG ──────────────────────────────────────────────────────────────
const API = '../api';
// ── MODAL REFS ─────────────────────────────────────────────────────────
const inputModal = document.getElementById('inputModal');
const editModal = document.getElementById('editModal');
const deleteModal = document.getElementById('deleteModal');
// ── MAP INIT ────────────────────────────────────────────────────────────
const map = L.map('map', {
center: [-0.06058602521208944, 109.34498961534207],
zoom: 16
});
// 1. Base Maps
const osm = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
});
const esriSatellite = L.layerGroup([
L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
maxZoom: 19,
attribution: 'Tiles &copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community'
}),
L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}', {
maxZoom: 19
}),
L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Transportation/MapServer/tile/{z}/{y}/{x}', {
maxZoom: 19
})
]).addTo(map);
// 2. Layer Groups (Overlays)
const layer24h = L.layerGroup().addTo(map);
const layerRegular = L.layerGroup().addTo(map);
const baseMaps = {
"Satelit (Hybrid)": esriSatellite,
"Standard Map": osm
};
const overlays = {
"SPBU Buka 24 Jam": layer24h,
"SPBU Tidak Buka 24 Jam": layerRegular
};
L.control.layers(baseMaps, overlays, { collapsed: false }).addTo(map);
function createSpbuIcon(color = '#2563eb') {
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 42" width="32" height="42">
<path d="M16 0C7.163 0 0 7.163 0 16c0 12 16 26 16 26S32 28 32 16C32 7.163 24.837 0 16 0z"
fill="${color}" stroke="white" stroke-width="2"/>
<text x="16" y="21" text-anchor="middle" fill="white"
font-size="13" font-family="Arial" font-weight="bold">⛽</text>
</svg>`;
return L.divIcon({ html: svg, className: '', iconSize:[32,42], iconAnchor:[16,42], popupAnchor:[0,-44] });
}
// ── STATE ───────────────────────────────────────────────────────────────
let pendingLatLng = null;
let tempMarker = null;
const spbuMap = new Map(); // id => { marker, data }
// ── POPUP CONTENT ───────────────────────────────────────────────────────
function buildPopupContent(spbu) {
const isOpen = spbu.status === 'Buka 24 Jam';
const badgeCls = isOpen ? 'badge-success' : 'badge-error';
const color = isOpen ? '#16a34a' : '#dc2626';
const lat = parseFloat(spbu.latitude).toFixed(6);
const lng = parseFloat(spbu.longitude).toFixed(6);
return `
<div style="font-family:ui-sans-serif,system-ui,sans-serif;min-width:210px">
<div style="font-weight:700;font-size:0.95rem;margin-bottom:5px">⛽ ${spbu.nama_spbu}</div>
<div style="font-size:0.8rem;color:#6b7280;margin-bottom:6px">
<b>No:</b> ${spbu.nomor_spbu}
</div>
<span class="badge ${badgeCls}" style="font-size:0.72rem">
${isOpen ? '🟢' : '🔴'} ${spbu.status}
</span>
<div style="font-size:0.7rem;color:#9ca3af;margin-top:5px">${lat}, ${lng}</div>
<div style="display:flex;gap:6px;margin-top:10px;padding-top:8px;border-top:1px solid #f3f4f6">
<button class="btn btn-xs btn-outline btn-primary"
onclick="openEditModal(${spbu.id})">
<i class="fa-solid fa-pen-to-square"></i> Edit
</button>
<button class="btn btn-xs btn-outline btn-error"
onclick="openDeleteConfirm(${spbu.id})">
<i class="fa-solid fa-trash"></i> Hapus
</button>
</div>
</div>`;
}
// ── LOAD DATA ───────────────────────────────────────────────────────────
function loadExistingData() {
fetch(`${API}/get_spbu.php`)
.then(r => r.json())
.then(data => data.forEach(addSpbuMarker))
.catch(() => console.log('Tidak dapat memuat data dari server.'));
}
function addSpbuMarker(spbu) {
const isOpen = spbu.status === 'Buka 24 Jam';
const color = isOpen ? '#16a34a' : '#dc2626';
const group = isOpen ? layer24h : layerRegular;
const m = L.marker([parseFloat(spbu.latitude), parseFloat(spbu.longitude)], {
icon: createSpbuIcon(color),
draggable: true
})
.addTo(group)
.bindPopup(buildPopupContent(spbu), { maxWidth: 270 });
const id = parseInt(spbu.id);
spbuMap.set(id, { marker: m, data: { ...spbu } });
// ── DRAG START
m.on('dragstart', function () {
this.setOpacity(0.55);
map.closePopup();
document.getElementById('dragHint').classList.remove('hidden');
document.getElementById('mapHint').classList.add('hidden');
});
// ── DRAG END → update DB
m.on('dragend', function (e) {
this.setOpacity(1);
document.getElementById('dragHint').classList.add('hidden');
document.getElementById('mapHint').classList.remove('hidden');
const newLatLng = e.target.getLatLng();
const entry = spbuMap.get(id);
if (!entry) return;
// Loading popup
this.bindPopup(`
<div style="font-family:ui-sans-serif,system-ui,sans-serif;
padding:12px;text-align:center;min-width:160px">
<span class="loading loading-spinner loading-sm text-warning"></span>
<span style="font-size:0.84rem;margin-left:6px;vertical-align:middle">
Menyimpan lokasi baru...
</span>
</div>`, { maxWidth: 270 }).openPopup();
fetch(`${API}/update_spbu.php`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id,
nama_spbu : entry.data.nama_spbu,
nomor_spbu : entry.data.nomor_spbu,
status : entry.data.status,
latitude : newLatLng.lat,
longitude : newLatLng.lng
})
})
.then(r => r.json())
.then(res => {
if (res.success) {
entry.data.latitude = newLatLng.lat;
entry.data.longitude = newLatLng.lng;
this.setPopupContent(buildPopupContent(entry.data)).openPopup();
showToast(`📍 Lokasi ${entry.data.nama_spbu} berhasil dipindahkan!`, 'success');
} else {
this.setLatLng([parseFloat(entry.data.latitude), parseFloat(entry.data.longitude)]);
this.setPopupContent(buildPopupContent(entry.data)).openPopup();
showToast('Gagal memperbarui lokasi: ' + (res.message || 'Error'), 'error');
}
})
.catch(() => {
this.setLatLng([parseFloat(entry.data.latitude), parseFloat(entry.data.longitude)]);
this.setPopupContent(buildPopupContent(entry.data)).openPopup();
showToast('Tidak dapat terhubung ke server!', 'error');
});
});
}
loadExistingData();
// ── MAP CLICK → buka modal tambah ──────────────────────────────────────
map.on('click', function (e) {
pendingLatLng = e.latlng;
if (tempMarker) map.removeLayer(tempMarker);
tempMarker = L.marker(e.latlng, {
icon: createSpbuIcon('#d97706'), opacity: 0.85
}).addTo(map);
document.getElementById('coordText').textContent =
`Lat: ${e.latlng.lat.toFixed(6)}, Lng: ${e.latlng.lng.toFixed(6)}`;
document.getElementById('namaSPBU').value = '';
document.getElementById('nomorSPBU').value = '';
document.getElementById('statusSPBU').value = '';
inputModal.showModal();
setTimeout(() => document.getElementById('namaSPBU').focus(), 300);
});
// ── CLOSE INPUT MODAL ──────────────────────────────────────────────────
function closeModal(removeTemp = true) {
inputModal.close();
if (removeTemp && tempMarker) { map.removeLayer(tempMarker); tempMarker = null; }
pendingLatLng = null;
}
document.getElementById('btnCloseModal').addEventListener('click', () => closeModal());
document.getElementById('btnCancel').addEventListener('click', () => closeModal());
// Backdrop closes via method=dialog; cleanup state via 'close' event
inputModal.addEventListener('close', () => {
if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; }
pendingLatLng = null;
});
// ── SIMPAN DATA ────────────────────────────────────────────────────────
document.getElementById('btnSimpan').addEventListener('click', function () {
const nama = document.getElementById('namaSPBU').value.trim();
const nomor = document.getElementById('nomorSPBU').value.trim();
const status = document.getElementById('statusSPBU').value;
if (!nama) { document.getElementById('namaSPBU').focus(); showToast('Nama SPBU tidak boleh kosong!', 'error'); return; }
if (!nomor) { document.getElementById('nomorSPBU').focus(); showToast('Nomor SPBU tidak boleh kosong!', 'error'); return; }
if (!status) { document.getElementById('statusSPBU').focus(); showToast('Pilih status operasional!', 'error'); return; }
const btn = document.getElementById('btnSimpan');
btn.disabled = true;
btn.innerHTML = '<span class="loading loading-spinner loading-sm"></span> Menyimpan...';
fetch(`${API}/save_spbu.php`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nama_spbu: nama, nomor_spbu: nomor, status,
latitude: pendingLatLng.lat, longitude: pendingLatLng.lng })
})
.then(r => r.json())
.then(res => {
btn.disabled = false;
btn.innerHTML = '<i class="fa-solid fa-floppy-disk"></i> Simpan';
if (res.success) {
if (tempMarker) map.removeLayer(tempMarker);
tempMarker = null;
addSpbuMarker({ id: res.id, nama_spbu: nama, nomor_spbu: nomor, status,
latitude: pendingLatLng.lat, longitude: pendingLatLng.lng });
const entry = spbuMap.get(parseInt(res.id));
if (entry) entry.marker.openPopup();
inputModal.close();
pendingLatLng = null;
showToast('Data SPBU berhasil disimpan! ✅', 'success');
} else {
showToast('Gagal menyimpan: ' + (res.message || 'Error server'), 'error');
}
})
.catch(() => {
btn.disabled = false;
btn.innerHTML = '<i class="fa-solid fa-floppy-disk"></i> Simpan';
showToast('Tidak dapat terhubung ke server PHP!', 'error');
});
});
// ── TOAST ──────────────────────────────────────────────────────────────
function showToast(msg, type = 'info') {
const t = document.createElement('div');
const cls = type === 'success' ? 'alert-success' : type === 'error' ? 'alert-error' : 'alert-info';
t.className = `alert ${cls} shadow-md`;
t.innerHTML = `<span>${msg}</span>`;
document.getElementById('toastContainer').appendChild(t);
setTimeout(() => t.remove(), 3500);
}
// ── EDIT MODAL ─────────────────────────────────────────────────────────
let editingId = null;
function openEditModal(id) {
const entry = spbuMap.get(id);
if (!entry) return;
editingId = id;
const { data } = entry;
document.getElementById('editNamaSPBU').value = data.nama_spbu;
document.getElementById('editNomorSPBU').value = data.nomor_spbu;
document.getElementById('editStatusSPBU').value = data.status;
document.getElementById('editCoordText').textContent =
`Lat: ${parseFloat(data.latitude).toFixed(6)}, Lng: ${parseFloat(data.longitude).toFixed(6)}`;
map.closePopup();
editModal.showModal();
setTimeout(() => document.getElementById('editNamaSPBU').focus(), 300);
}
function closeEditModal() { editModal.close(); editingId = null; }
document.getElementById('btnCloseEditModal').addEventListener('click', closeEditModal);
document.getElementById('btnCancelEdit').addEventListener('click', closeEditModal);
editModal.addEventListener('close', () => { editingId = null; });
document.getElementById('btnUpdate').addEventListener('click', function () {
const nama = document.getElementById('editNamaSPBU').value.trim();
const nomor = document.getElementById('editNomorSPBU').value.trim();
const status = document.getElementById('editStatusSPBU').value;
if (!nama) { showToast('Nama SPBU tidak boleh kosong!', 'error'); document.getElementById('editNamaSPBU').focus(); return; }
if (!nomor) { showToast('Nomor SPBU tidak boleh kosong!', 'error'); document.getElementById('editNomorSPBU').focus(); return; }
if (!status) { showToast('Pilih status operasional!', 'error'); document.getElementById('editStatusSPBU').focus(); return; }
const entry = spbuMap.get(editingId);
if (!entry) return;
const btn = document.getElementById('btnUpdate');
btn.disabled = true;
btn.innerHTML = '<span class="loading loading-spinner loading-sm"></span> Menyimpan...';
fetch(`${API}/update_spbu.php`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: editingId, nama_spbu: nama, nomor_spbu: nomor, status,
latitude: parseFloat(entry.data.latitude),
longitude: parseFloat(entry.data.longitude)
})
})
.then(r => r.json())
.then(res => {
btn.disabled = false;
btn.innerHTML = '<i class="fa-solid fa-floppy-disk"></i> Perbarui';
if (res.success) {
const oldStatus = entry.data.status;
entry.data.nama_spbu = nama;
entry.data.nomor_spbu = nomor;
entry.data.status = status;
const color = status === 'Buka 24 Jam' ? '#16a34a' : '#dc2626';
entry.marker.setIcon(createSpbuIcon(color));
entry.marker.setPopupContent(buildPopupContent(entry.data));
// Move to correct layer group if status changed
if (oldStatus !== status) {
const oldGroup = oldStatus === 'Buka 24 Jam' ? layer24h : layerRegular;
const newGroup = status === 'Buka 24 Jam' ? layer24h : layerRegular;
oldGroup.removeLayer(entry.marker);
newGroup.addLayer(entry.marker);
}
closeEditModal();
showToast('Data SPBU berhasil diperbarui! ✏️', 'success');
} else {
showToast('Gagal memperbarui: ' + (res.message || 'Error server'), 'error');
}
})
.catch(() => {
btn.disabled = false;
btn.innerHTML = '<i class="fa-solid fa-floppy-disk"></i> Perbarui';
showToast('Tidak dapat terhubung ke server PHP!', 'error');
});
});
// ── DELETE CONFIRM ─────────────────────────────────────────────────────
let deletingId = null;
function openDeleteConfirm(id) {
const entry = spbuMap.get(id);
if (!entry) return;
deletingId = id;
document.getElementById('deleteSpbuName').textContent = entry.data.nama_spbu;
map.closePopup();
deleteModal.showModal();
}
function closeDeleteModal() { deleteModal.close(); deletingId = null; }
document.getElementById('btnCancelDelete').addEventListener('click', closeDeleteModal);
deleteModal.addEventListener('close', () => { deletingId = null; });
document.getElementById('btnConfirmDelete').addEventListener('click', function () {
if (!deletingId) return;
const entry = spbuMap.get(deletingId);
if (!entry) return;
const btn = document.getElementById('btnConfirmDelete');
btn.disabled = true;
btn.innerHTML = '<span class="loading loading-spinner loading-sm"></span> Menghapus...';
fetch(`${API}/delete_spbu.php`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: deletingId, _method: 'DELETE' })
})
.then(r => r.json())
.then(res => {
btn.disabled = false;
btn.innerHTML = '<i class="fa-solid fa-trash"></i> Ya, Hapus';
if (res.success) {
map.removeLayer(entry.marker);
spbuMap.delete(deletingId);
closeDeleteModal();
showToast('Data SPBU berhasil dihapus! 🗑️', 'success');
} else {
showToast('Gagal menghapus: ' + (res.message || 'Error server'), 'error');
}
})
.catch(() => {
btn.disabled = false;
btn.innerHTML = '<i class="fa-solid fa-trash"></i> Ya, Hapus';
showToast('Tidak dapat terhubung ke server!', 'error');
});
});
</script>
</body>
</html>
+575
View File
@@ -0,0 +1,575 @@
<!DOCTYPE html>
<html lang="id" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Data SPBU - WebGIS</title>
<!-- Leaflet -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<!-- DaisyUI + Tailwind -->
<link href="https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<!-- Plus Jakarta Sans Font -->
<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:ital,wght@0,200..800;1,200..800&display=swap" rel="stylesheet">
<style>
* {
font-family: 'Plus Jakarta Sans', sans-serif;
}
body {
font-weight: 500;
}
h1, h2, h3, h4, h5, h6, .btn, .card-title, .stat-value {
font-weight: 700 !important;
letter-spacing: -0.01em;
}
#previewMap { height: 380px; width: 100%; }
/* Custom Leaflet Control Layers Styling */
.leaflet-control-layers {
border-radius: 12px !important;
border: 1px solid rgba(255, 255, 255, 0.2) !important;
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.15) !important;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
background: rgba(255, 255, 255, 0.85) !important;
padding: 8px 12px !important;
font-family: inherit !important;
font-weight: 700 !important;
font-size: 0.85rem !important;
transition: all 0.3s ease !important;
}
.leaflet-control-layers:hover {
background: rgba(255, 255, 255, 0.95) !important;
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.25) !important;
}
.leaflet-control-layers-list label {
margin-bottom: 4px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px;
border-radius: 6px;
font-weight: 600 !important;
}
.leaflet-control-layers-list label:hover {
background: rgba(0, 0, 0, 0.05);
}
.leaflet-control-layers-separator {
margin: 8px 0 !important;
border-top: 1px solid rgba(0, 0, 0, 0.1) !important;
}
.leaflet-control-layers-selector {
accent-color: #2563eb;
width: 16px;
height: 16px;
margin-top: 0 !important;
}
</style>
</head>
<body class="min-h-screen bg-base-200">
<!-- ═══ NAVBAR ═══════════════════════════════════════════════════════════ -->
<div class="navbar bg-base-100 border-b border-base-200 shadow-sm sticky top-0 z-[1000]">
<div class="flex-1">
<a href="../index.html" class="btn btn-ghost text-lg font-bold gap-2 text-primary">
<i class="fa-solid fa-gas-pump"></i>
WebGIS SPBU
</a>
</div>
<div class="flex-none">
<ul class="menu menu-horizontal px-1 gap-1">
<li>
<a href="input.html" class="font-medium">
<i class="fa-solid fa-map-location-dot"></i> Input Data
</a>
</li>
<li>
<a href="showdata.html"
class="font-semibold bg-primary/10 text-primary rounded-lg">
<i class="fa-solid fa-table-list"></i> Lihat Data
</a>
</li>
</ul>
</div>
</div>
<!-- ═══ MAIN ══════════════════════════════════════════════════════════════ -->
<main class="max-w-screen-xl mx-auto px-4 py-6">
<!-- Page header -->
<div class="flex flex-wrap items-center justify-between gap-3 mb-6">
<div>
<h1 class="text-2xl font-bold">📊 Data SPBU</h1>
<p class="text-sm text-base-content/50 mt-1">
Daftar seluruh titik SPBU yang telah terdata dalam sistem
</p>
</div>
<a href="input.html" class="btn btn-primary gap-2">
<i class="fa-solid fa-plus"></i> Tambah SPBU
</a>
</div>
<!-- Stats -->
<div class="stats stats-horizontal shadow bg-base-100 w-full mb-6 overflow-x-auto">
<div class="stat">
<div class="stat-figure text-primary">
<i class="fa-solid fa-gas-pump text-3xl"></i>
</div>
<div class="stat-title">Total SPBU</div>
<div class="stat-value text-primary" id="statTotal"></div>
</div>
<div class="stat">
<div class="stat-figure text-success">
<i class="fa-solid fa-clock-rotate-left text-3xl"></i>
</div>
<div class="stat-title">Buka 24 Jam</div>
<div class="stat-value text-success" id="stat24j"></div>
</div>
<div class="stat">
<div class="stat-figure text-error">
<i class="fa-solid fa-circle-xmark text-3xl"></i>
</div>
<div class="stat-title">Tidak 24 Jam</div>
<div class="stat-value text-error" id="statNon24"></div>
</div>
</div>
<!-- Content grid -->
<div class="grid grid-cols-1 lg:grid-cols-[1fr_380px] gap-5 items-start">
<!-- ── TABEL ─────────────────────────────────────────────────── -->
<div class="card bg-base-100 shadow">
<div class="card-body p-0">
<!-- Card header -->
<div class="flex items-center justify-between px-5 pt-5 pb-3 border-b border-base-200">
<h2 class="card-title text-base">
<i class="fa-solid fa-list text-primary"></i>
Daftar SPBU
</h2>
<span class="text-xs text-base-content/50" id="totalBadge">Memuat...</span>
</div>
<!-- Search -->
<div class="px-5 py-3 border-b border-base-200">
<label class="input input-bordered input-sm flex items-center gap-2 w-full">
<i class="fa-solid fa-magnifying-glass text-base-content/40"></i>
<input type="text" id="searchInput" class="grow"
placeholder="Cari nama atau nomor SPBU...">
</label>
</div>
<!-- Table -->
<div id="loadingTable" class="flex items-center justify-center gap-3 py-16
text-sm text-base-content/50">
<span class="loading loading-spinner loading-md"></span>
Memuat data...
</div>
<div class="overflow-x-auto">
<table class="table table-zebra table-sm" id="dataTable" style="display:none">
<thead>
<tr class="text-xs text-base-content/60">
<th>#</th>
<th>Nama SPBU</th>
<th>Nomor</th>
<th>Status</th>
<th>Koordinat</th>
<th class="text-center">Aksi</th>
</tr>
</thead>
<tbody id="tableBody"></tbody>
</table>
</div>
<!-- Empty state -->
<div id="emptyState" class="text-center py-16 hidden">
<div class="text-4xl mb-3">📭</div>
<p class="font-semibold text-base-content/60">Belum ada data SPBU</p>
<p class="text-sm text-base-content/40 mt-1">
Tambahkan SPBU melalui halaman Input Data
</p>
</div>
</div>
</div>
<!-- ── SIDEBAR ───────────────────────────────────────────────── -->
<div class="flex flex-col gap-5">
<!-- Mini Map -->
<div class="card bg-base-100 shadow overflow-hidden">
<div class="card-body p-0">
<div class="flex items-center gap-2 px-4 py-3 border-b border-base-200">
<i class="fa-solid fa-map text-primary"></i>
<span class="font-semibold text-sm">Peta Sebaran</span>
</div>
<div id="previewMap"></div>
<div class="flex gap-4 px-4 py-2 border-t border-base-200 text-xs text-base-content/60">
<span class="flex items-center gap-1">
<span class="inline-block w-2.5 h-2.5 rounded-full bg-success"></span>
Buka 24 Jam
</span>
<span class="flex items-center gap-1">
<span class="inline-block w-2.5 h-2.5 rounded-full bg-error"></span>
Tidak 24 Jam
</span>
</div>
</div>
</div>
<!-- Quick List -->
<div class="card bg-base-100 shadow">
<div class="card-body p-0">
<div class="flex items-center gap-2 px-4 py-3 border-b border-base-200">
<i class="fa-solid fa-list-ul text-primary"></i>
<span class="font-semibold text-sm">Daftar Cepat</span>
</div>
<div class="max-h-80 overflow-y-auto" id="quickList">
<div class="flex items-center justify-center gap-2 py-10
text-sm text-base-content/40">
<span class="loading loading-spinner loading-sm"></span>
Memuat...
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- ═══ MODAL: KONFIRMASI HAPUS ══════════════════════════════════════════ -->
<dialog id="confirmModal" class="modal">
<div class="modal-box max-w-sm">
<!-- Header merah -->
<div class="flex items-center gap-3 mb-5">
<div class="bg-error/10 text-error rounded-full w-11 h-11 flex items-center justify-center flex-shrink-0">
<i class="fa-solid fa-triangle-exclamation text-lg"></i>
</div>
<div>
<h3 class="font-bold">Hapus Data SPBU</h3>
<p class="text-xs text-base-content/50">Tindakan ini tidak dapat dibatalkan</p>
</div>
<button class="btn btn-sm btn-circle btn-ghost ml-auto" id="btnTutupKonfirmasi"></button>
</div>
<p class="text-sm text-base-content/70 mb-2">
Apakah kamu yakin ingin menghapus data SPBU berikut?
</p>
<div class="bg-base-200 border-l-4 border-error rounded px-3 py-2 font-semibold mb-4"
id="confirmName">—</div>
<div class="alert alert-error alert-sm py-2 text-xs mb-4">
<i class="fa-solid fa-circle-info"></i>
Data yang dihapus tidak bisa dikembalikan.
</div>
<div class="modal-action mt-0">
<button class="btn btn-sm" id="btnBatal">
<i class="fa-solid fa-xmark"></i> Batal
</button>
<button class="btn btn-sm btn-error" id="btnYaHapus">
<i class="fa-solid fa-trash"></i> Ya, Hapus
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<!-- ═══ TOAST ══════════════════════════════════════════════════════════════ -->
<div class="toast toast-end toast-bottom z-[99999]" id="toastContainer"></div>
<!-- ═══ JAVASCRIPT ════════════════════════════════════════════════════════ -->
<script>
const API = '../api';
// ── MAP ─────────────────────────────────────────────────────────────────
const previewMap = L.map('previewMap', {
center: [-0.0606, 109.345],
zoom: 14
});
// 1. Base Maps
const osm = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
});
const esriSatellite = L.layerGroup([
L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
maxZoom: 19,
attribution: 'Tiles &copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community'
}),
L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}', {
maxZoom: 19
}),
L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Transportation/MapServer/tile/{z}/{y}/{x}', {
maxZoom: 19
})
]).addTo(previewMap);
// 2. Layer Groups (Overlays)
const layer24h = L.layerGroup().addTo(previewMap);
const layerRegular = L.layerGroup().addTo(previewMap);
const baseMaps = {
"Satelit (Hybrid)": esriSatellite,
"Standard Map": osm
};
const overlays = {
"SPBU Buka 24 Jam": layer24h,
"SPBU Tidak Buka 24 Jam": layerRegular
};
L.control.layers(baseMaps, overlays, { collapsed: true }).addTo(previewMap);
function makeIcon(color) {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 42" width="32" height="42">
<path d="M16 0C7.163 0 0 7.163 0 16c0 12 16 26 16 26S32 28 32 16C32 7.163 24.837 0 16 0z"
fill="${color}" stroke="white" stroke-width="2"/>
<text x="16" y="21" text-anchor="middle" fill="white"
font-size="13" font-family="Arial" font-weight="bold">⛽</text>
</svg>`;
return L.divIcon({ html: svg, className: '', iconSize:[32,42], iconAnchor:[16,42], popupAnchor:[0,-44] });
}
// ── STATE ────────────────────────────────────────────────────────────────
let allData = [];
let mapMarkers = {};
// ── MODAL ────────────────────────────────────────────────────────────────
const confirmModal = document.getElementById('confirmModal');
// ── HELPERS ──────────────────────────────────────────────────────────────
function esc(str) {
const d = document.createElement('div');
d.appendChild(document.createTextNode(str));
return d.innerHTML;
}
function showToast(msg, type = 'info') {
const t = document.createElement('div');
const cls = type === 'success' ? 'alert-success' : type === 'error' ? 'alert-error' : 'alert-info';
t.className = `alert ${cls} shadow-md`;
t.innerHTML = `<span>${msg}</span>`;
document.getElementById('toastContainer').appendChild(t);
setTimeout(() => t.remove(), 3500);
}
// ── LOAD ─────────────────────────────────────────────────────────────────
function loadData() {
document.getElementById('loadingTable').style.display = 'flex';
document.getElementById('dataTable').style.display = 'none';
document.getElementById('emptyState').classList.add('hidden');
// Clear previous layer groups
layer24h.clearLayers();
layerRegular.clearLayers();
mapMarkers = {};
fetch(`${API}/get_spbu.php`)
.then(r => r.json())
.then(data => {
allData = data;
renderStats(data);
renderTable(data);
renderMarkers(data);
renderQuickList(data);
})
.catch(() => {
document.getElementById('loadingTable').style.display = 'none';
document.getElementById('emptyState').classList.remove('hidden');
document.getElementById('totalBadge').textContent = 'Gagal memuat data';
renderStats([]);
});
}
// ── STATS ────────────────────────────────────────────────────────────────
function renderStats(data) {
const total = data.length;
const buka = data.filter(d => d.status === 'Buka 24 Jam').length;
document.getElementById('statTotal').textContent = total;
document.getElementById('stat24j').textContent = buka;
document.getElementById('statNon24').textContent = total - buka;
document.getElementById('totalBadge').textContent = total + ' data';
}
// ── TABLE ────────────────────────────────────────────────────────────────
function renderTable(data) {
document.getElementById('loadingTable').style.display = 'none';
if (!data.length) {
document.getElementById('emptyState').classList.remove('hidden');
document.getElementById('dataTable').style.display = 'none';
return;
}
document.getElementById('emptyState').classList.add('hidden');
document.getElementById('dataTable').style.display = 'table';
document.getElementById('tableBody').innerHTML = data.map((spbu, i) => {
const is24 = spbu.status === 'Buka 24 Jam';
const badgeCls = is24 ? 'badge-success' : 'badge-error';
const badgeIco = is24 ? '🟢' : '🔴';
const lat = parseFloat(spbu.latitude).toFixed(5);
const lng = parseFloat(spbu.longitude).toFixed(5);
const namaJS = spbu.nama_spbu.replace(/'/g, "\\'");
return `
<tr class="hover cursor-pointer">
<td class="text-base-content/50 text-xs font-semibold"
onclick="focusMap(${spbu.latitude},${spbu.longitude},${spbu.id})">${i+1}</td>
<td onclick="focusMap(${spbu.latitude},${spbu.longitude},${spbu.id})">
<span class="font-semibold">${esc(spbu.nama_spbu)}</span>
</td>
<td class="text-xs text-base-content/60"
onclick="focusMap(${spbu.latitude},${spbu.longitude},${spbu.id})">
${esc(spbu.nomor_spbu)}
</td>
<td onclick="focusMap(${spbu.latitude},${spbu.longitude},${spbu.id})">
<span class="badge ${badgeCls} badge-sm gap-1">${badgeIco} ${esc(spbu.status)}</span>
</td>
<td class="font-mono text-xs text-base-content/50"
onclick="focusMap(${spbu.latitude},${spbu.longitude},${spbu.id})">
${lat}<br>${lng}
</td>
<td class="text-center">
<button class="btn btn-xs btn-outline btn-error gap-1"
onclick="bukaKonfirmasi(${spbu.id}, '${namaJS}')">
<i class="fa-solid fa-trash"></i> Hapus
</button>
</td>
</tr>`;
}).join('');
}
// ── MARKERS ───────────────────────────────────────────────────────────────
function renderMarkers(data) {
const bounds = [];
data.forEach(spbu => {
const is24 = spbu.status === 'Buka 24 Jam';
const color = is24 ? '#16a34a' : '#dc2626';
const group = is24 ? layer24h : layerRegular;
const m = L.marker([+spbu.latitude, +spbu.longitude], { icon: makeIcon(color) })
.addTo(group)
.bindPopup(`
<div style="font-family:ui-sans-serif,system-ui,sans-serif;min-width:150px">
<b>⛽ ${esc(spbu.nama_spbu)}</b><br>
<span style="font-size:0.78rem;color:#6b7280">No: ${esc(spbu.nomor_spbu)}</span><br>
<span class="badge ${spbu.status === 'Buka 24 Jam' ? 'badge-success' : 'badge-error'}"
style="font-size:0.7rem;margin-top:5px">${esc(spbu.status)}</span>
</div>`);
mapMarkers[spbu.id] = m;
bounds.push([+spbu.latitude, +spbu.longitude]);
});
if (bounds.length) previewMap.fitBounds(bounds, { padding: [28, 28] });
}
function focusMap(lat, lng, id) {
previewMap.flyTo([lat, lng], 17, { animate: true, duration: 1 });
if (mapMarkers[id]) mapMarkers[id].openPopup();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
// ── QUICK LIST ─────────────────────────────────────────────────────────
function renderQuickList(data) {
const el = document.getElementById('quickList');
if (!data.length) {
el.innerHTML = `<div class="text-center py-10 text-sm text-base-content/40">
📭 Belum ada data</div>`;
return;
}
el.innerHTML = data.map(spbu => {
const is24 = spbu.status === 'Buka 24 Jam';
return `
<div class="flex gap-3 items-start px-4 py-3 border-b border-base-200
hover:bg-base-200 cursor-pointer transition-colors"
onclick="focusMap(${spbu.latitude},${spbu.longitude},${spbu.id})">
<span class="text-xl mt-0.5"></span>
<div>
<div class="font-semibold text-sm">${esc(spbu.nama_spbu)}</div>
<div class="text-xs text-base-content/50">${esc(spbu.nomor_spbu)}</div>
<span class="badge ${is24 ? 'badge-success' : 'badge-error'} badge-sm mt-1">
${is24 ? '🟢' : '🔴'} ${esc(spbu.status)}
</span>
</div>
</div>`;
}).join('');
}
// ── SEARCH ────────────────────────────────────────────────────────────
document.getElementById('searchInput').addEventListener('input', function () {
const q = this.value.toLowerCase();
const filtered = allData.filter(d =>
d.nama_spbu.toLowerCase().includes(q) ||
d.nomor_spbu.toLowerCase().includes(q)
);
renderTable(filtered);
document.getElementById('totalBadge').textContent = filtered.length + ' data';
});
// ── HAPUS ─────────────────────────────────────────────────────────────
let pendingId = null;
function bukaKonfirmasi(id, nama) {
pendingId = id;
document.getElementById('confirmName').textContent = '⛽ ' + nama;
confirmModal.showModal();
}
function tutupKonfirmasi() { confirmModal.close(); pendingId = null; }
document.getElementById('btnBatal').addEventListener('click', tutupKonfirmasi);
document.getElementById('btnTutupKonfirmasi').addEventListener('click', tutupKonfirmasi);
confirmModal.addEventListener('close', () => { pendingId = null; });
document.getElementById('btnYaHapus').addEventListener('click', function () {
if (!pendingId) return;
const btn = this;
btn.disabled = true;
btn.innerHTML = '<span class="loading loading-spinner loading-xs"></span> Menghapus...';
fetch(`${API}/delete_spbu.php`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: pendingId, _method: 'DELETE' })
})
.then(r => r.json())
.then(res => {
btn.disabled = false;
btn.innerHTML = '<i class="fa-solid fa-trash"></i> Ya, Hapus';
if (res.success) {
tutupKonfirmasi();
showToast('Data SPBU berhasil dihapus! 🗑️', 'success');
loadData();
} else {
showToast('Gagal: ' + (res.message || 'Error server'), 'error');
}
})
.catch(() => {
btn.disabled = false;
btn.innerHTML = '<i class="fa-solid fa-trash"></i> Ya, Hapus';
showToast('Tidak dapat terhubung ke server!', 'error');
});
});
// ── INIT ─────────────────────────────────────────────────────────────
loadData();
</script>
</body>
</html>