add files

This commit is contained in:
2026-06-10 19:23:50 +07:00
parent acbd9a4842
commit a9239d18cc
41 changed files with 7839 additions and 0 deletions
+136
View File
@@ -0,0 +1,136 @@
<?php
// ============================================================
// api.php REST API untuk WebGIS SPBU Pontianak
// Endpoint:
// GET api.php → ambil semua SPBU
// POST api.php → tambah SPBU baru
// PUT api.php?id=N → update SPBU
// DELETE api.php?id=N → hapus SPBU
// ============================================================
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { exit(0); }
require_once 'config.php';
$method = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int) $_GET['id'] : null;
// ── Ambil semua SPBU ─────────────────────────────────────────
if ($method === 'GET') {
$conn = getConnection();
$res = $conn->query(
"SELECT id, nama_spbu, nomor_wa, status_24jam,
latitude, longitude, created_at
FROM spbu ORDER BY created_at DESC"
);
$data = [];
while ($row = $res->fetch_assoc()) {
$row['id'] = (int) $row['id'];
$row['latitude'] = (float) $row['latitude'];
$row['longitude'] = (float) $row['longitude'];
$data[] = $row;
}
echo json_encode(['status' => 'success', 'data' => $data]);
$conn->close();
exit;
}
// ── Tambah SPBU baru ─────────────────────────────────────────
if ($method === 'POST') {
$body = json_decode(file_get_contents('php://input'), true);
$nama = trim($body['nama_spbu'] ?? '');
$wa = trim($body['nomor_wa'] ?? '');
$status = in_array($body['status_24jam'] ?? '', ['yes','no'])
? $body['status_24jam'] : 'no';
$lat = isset($body['latitude']) ? (float) $body['latitude'] : null;
$lng = isset($body['longitude']) ? (float) $body['longitude'] : null;
if (!$nama || !$wa || $lat === null || $lng === null) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Data tidak lengkap']);
exit;
}
$conn = getConnection();
$stmt = $conn->prepare(
"INSERT INTO spbu (nama_spbu, nomor_wa, status_24jam, latitude, longitude)
VALUES (?, ?, ?, ?, ?)"
);
$stmt->bind_param('sssdd', $nama, $wa, $status, $lat, $lng);
if ($stmt->execute()) {
echo json_encode([
'status' => 'success',
'message' => 'SPBU berhasil ditambahkan',
'id' => $stmt->insert_id
]);
} else {
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
}
$stmt->close();
$conn->close();
exit;
}
// ── Update SPBU ──────────────────────────────────────────────
if ($method === 'PUT' && $id) {
$body = json_decode(file_get_contents('php://input'), true);
$nama = trim($body['nama_spbu'] ?? '');
$wa = trim($body['nomor_wa'] ?? '');
$status = in_array($body['status_24jam'] ?? '', ['yes','no'])
? $body['status_24jam'] : 'no';
$lat = isset($body['latitude']) ? (float) $body['latitude'] : null;
$lng = isset($body['longitude']) ? (float) $body['longitude'] : null;
if (!$nama || !$wa || $lat === null || $lng === null) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Data tidak lengkap']);
exit;
}
$conn = getConnection();
$stmt = $conn->prepare(
"UPDATE spbu SET nama_spbu=?, nomor_wa=?, status_24jam=?,
latitude=?, longitude=?
WHERE id=?"
);
$stmt->bind_param('sssddi', $nama, $wa, $status, $lat, $lng, $id);
if ($stmt->execute()) {
echo json_encode(['status' => 'success', 'message' => 'SPBU berhasil diupdate']);
} else {
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
}
$stmt->close();
$conn->close();
exit;
}
// ── Hapus SPBU ───────────────────────────────────────────────
if ($method === 'DELETE' && $id) {
$conn = getConnection();
$stmt = $conn->prepare("DELETE FROM spbu WHERE id=?");
$stmt->bind_param('i', $id);
if ($stmt->execute()) {
echo json_encode(['status' => 'success', 'message' => 'SPBU berhasil dihapus']);
} else {
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
}
$stmt->close();
$conn->close();
exit;
}
http_response_code(405);
echo json_encode(['status' => 'error', 'message' => 'Method tidak diizinkan']);
+22
View File
@@ -0,0 +1,22 @@
<?php
// ============================================================
// config.php Konfigurasi Koneksi Database
// ============================================================
define('DB_HOST', getenv('DB_HOST') ?: 'localhost');
define('DB_USER', getenv('DB_USER') ?: 'root');
define('DB_PASS', getenv('DB_PASSWORD') ?: '');
define('DB_NAME', getenv('DB_NAME') ?: 'webgis_spbu');
function getConnection(): mysqli {
$conn = new mysqli(DB_HOST, DB_USER, DB_PASS, DB_NAME);
if ($conn->connect_error) {
http_response_code(500);
die(json_encode([
'status' => 'error',
'message' => 'Koneksi database gagal: ' . $conn->connect_error
]));
}
$conn->set_charset('utf8mb4');
return $conn;
}
+32
View File
@@ -0,0 +1,32 @@
-- ============================================================
-- WebGIS SPBU Pontianak
-- Database Structure
-- ============================================================
CREATE DATABASE IF NOT EXISTS webgis_spbu
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE webgis_spbu;
CREATE TABLE IF NOT EXISTS spbu (
id INT(11) NOT NULL AUTO_INCREMENT,
nama_spbu VARCHAR(150) NOT NULL,
nomor_wa VARCHAR(20) NOT NULL,
status_24jam ENUM('yes','no') NOT NULL DEFAULT 'no',
latitude DECIMAL(10,8) NOT NULL,
longitude DECIMAL(11,8) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ============================================================
-- Sample data beberapa SPBU di Pontianak
-- ============================================================
INSERT INTO spbu (nama_spbu, nomor_wa, status_24jam, latitude, longitude) VALUES
('SPBU Pertamina Jl. Ahmad Yani', '6281234567890', 'yes', -0.0262619, 109.3425923),
('SPBU Shell Jl. Tanjungpura', '6281298765432', 'no', -0.0318750, 109.3319870),
('SPBU Pertamina Jl. Gajahmada', '6281211223344', 'yes', -0.0393160, 109.3266190),
('SPBU Pertamina Jl. Sultan Hamid II', '6281355667788', 'no', -0.0159870, 109.3571200);
+976
View File
@@ -0,0 +1,976 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>WebGIS SPBU — Kota Pontianak</title>
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet"/>
<style>
/* ══════════════════════════════════════
RESET & VARIABLES
══════════════════════════════════════ */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d1117;
--surface: #161b22;
--surface2: #21262d;
--border: #30363d;
--text: #e6edf3;
--muted: #8b949e;
--accent: #f97316;
--accent-dk: #c2591a;
--green: #3fb950;
--red: #f85149;
--radius: 12px;
--shadow: 0 8px 32px rgba(0,0,0,.55);
}
html, body {
height: 100%;
font-family: 'Plus Jakarta Sans', sans-serif;
background: var(--bg);
color: var(--text);
}
/* ══════════════════════════════════════
LAYOUT
══════════════════════════════════════ */
.app {
display: grid;
grid-template-columns: 360px 1fr;
grid-template-rows: 60px 1fr;
height: 100vh;
}
/* ── Header ─────────────────────────── */
header {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 14px;
padding: 0 24px;
background: var(--surface);
border-bottom: 1px solid var(--border);
z-index: 10;
}
.logo-icon {
width: 34px; height: 34px;
background: var(--accent);
border-radius: 8px;
display: grid; place-items: center;
font-size: 18px;
}
header h1 {
font-size: 15px; font-weight: 700;
letter-spacing: -.2px;
}
header h1 span { color: var(--accent); }
.header-meta {
margin-left: auto;
font-family: 'Space Mono', monospace;
font-size: 11px;
color: var(--muted);
background: var(--surface2);
padding: 5px 12px;
border-radius: 20px;
border: 1px solid var(--border);
}
.dot-live {
display: inline-block;
width: 7px; height: 7px;
border-radius: 50%;
background: var(--green);
margin-right: 6px;
animation: pulse 1.8s infinite;
}
@keyframes pulse {
0%,100% { opacity: 1; }
50% { opacity: .35; }
}
/* ── Sidebar ─────────────────────────── */
aside {
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
padding: 18px 20px 14px;
border-bottom: 1px solid var(--border);
}
.sidebar-header h2 {
font-size: 13px; font-weight: 600;
text-transform: uppercase;
letter-spacing: .8px;
color: var(--muted);
margin-bottom: 4px;
}
.count-badge {
font-family: 'Space Mono', monospace;
font-size: 26px; font-weight: 700;
color: var(--accent);
line-height: 1;
}
.count-badge small { font-size: 12px; color: var(--muted); font-family: inherit; }
/* stats row */
.stats-row {
display: flex; gap: 8px;
padding: 12px 20px;
border-bottom: 1px solid var(--border);
}
.stat-pill {
flex: 1; text-align: center;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 4px;
font-size: 11px; color: var(--muted);
}
.stat-pill strong { display: block; font-size: 18px; font-weight: 700; margin-bottom: 2px; }
.stat-pill.green strong { color: var(--green); }
.stat-pill.red strong { color: var(--red); }
/* search */
.search-wrap {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
}
.search-wrap input {
width: 100%;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 12px 8px 34px;
color: var(--text);
font-size: 13px;
font-family: inherit;
outline: none;
transition: border-color .2s;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%238b949e' stroke-width='2'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='m21 21-4.35-4.35'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: 10px center;
}
.search-wrap input:focus { border-color: var(--accent); }
/* list */
#spbu-list {
flex: 1;
overflow-y: auto;
padding: 8px 10px;
}
#spbu-list::-webkit-scrollbar { width: 4px; }
#spbu-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
.spbu-card {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 13px 15px;
margin-bottom: 8px;
cursor: pointer;
transition: border-color .2s, transform .15s;
position: relative;
}
.spbu-card:hover {
border-color: var(--accent);
transform: translateX(3px);
}
.spbu-card.active { border-color: var(--accent); background: #1d2029; }
.card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
.card-name { font-size: 13px; font-weight: 600; line-height: 1.35; }
.badge {
font-size: 10px; font-weight: 700; padding: 2px 8px;
border-radius: 20px; white-space: nowrap; flex-shrink: 0;
letter-spacing: .4px; text-transform: uppercase;
}
.badge.yes { background: rgba(63,185,80,.18); color: var(--green); border: 1px solid var(--green); }
.badge.no { background: rgba(248,81,73,.15); color: var(--red); border: 1px solid var(--red); }
.card-wa {
font-size: 11px; color: var(--muted); margin-top: 5px;
display: flex; align-items: center; gap: 5px;
}
.card-wa svg { flex-shrink: 0; }
.card-actions {
display: flex; gap: 6px; margin-top: 10px;
}
.btn-sm {
flex: 1; padding: 5px 0;
font-size: 11px; font-weight: 600;
border-radius: 6px; cursor: pointer;
border: 1px solid var(--border);
background: var(--surface);
color: var(--muted);
font-family: inherit;
transition: all .18s;
}
.btn-sm:hover { background: var(--accent); color: #fff; border-color: var(--accent); }
.btn-sm.danger:hover { background: var(--red); border-color: var(--red); }
.btn-sm.move:hover { background: #f97316; border-color: #f97316; color: #fff; }
/* add-new CTA */
.cta-box {
padding: 14px 16px;
border-top: 1px solid var(--border);
}
.btn-add {
width: 100%;
background: var(--accent);
color: #fff;
border: none;
border-radius: var(--radius);
padding: 11px;
font-size: 13px; font-weight: 700;
font-family: inherit;
cursor: pointer;
transition: background .2s, transform .15s;
display: flex; align-items: center; justify-content: center; gap: 7px;
}
.btn-add:hover { background: var(--accent-dk); transform: translateY(-1px); }
/* ── Map ─────────────────────────────── */
#map {
width: 100%; height: 100%;
background: #1a1a2e;
}
/* ══════════════════════════════════════
MODAL
══════════════════════════════════════ */
.overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,.7);
backdrop-filter: blur(4px);
z-index: 1000;
display: flex; align-items: center; justify-content: center;
opacity: 0; pointer-events: none;
transition: opacity .25s;
}
.overlay.open { opacity: 1; pointer-events: all; }
.modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
padding: 28px 28px 24px;
width: 420px; max-width: 95vw;
box-shadow: var(--shadow);
transform: translateY(12px);
transition: transform .25s;
}
.overlay.open .modal { transform: translateY(0); }
.modal h3 {
font-size: 16px; font-weight: 700; margin-bottom: 20px;
display: flex; align-items: center; gap: 10px;
}
.modal h3 .icon {
width: 32px; height: 32px;
background: var(--accent);
border-radius: 8px;
display: grid; place-items: center;
font-size: 16px;
}
.form-group { margin-bottom: 16px; }
.form-group label {
display: block; font-size: 12px; font-weight: 600;
color: var(--muted); margin-bottom: 6px;
text-transform: uppercase; letter-spacing: .5px;
}
.form-group input,
.form-group select {
width: 100%;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 14px;
color: var(--text);
font-size: 14px;
font-family: inherit;
outline: none;
transition: border-color .2s;
appearance: none;
}
.form-group input:focus,
.form-group select:focus { border-color: var(--accent); }
.coord-row {
display: grid; grid-template-columns: 1fr 1fr; gap: 10px;
}
.form-actions {
display: flex; gap: 10px; margin-top: 20px;
}
.btn-cancel {
flex: 1; padding: 11px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--muted);
font-size: 13px; font-weight: 600;
font-family: inherit; cursor: pointer;
transition: all .2s;
}
.btn-cancel:hover { border-color: var(--red); color: var(--red); }
.btn-save {
flex: 2; padding: 11px;
background: var(--accent);
border: none; border-radius: var(--radius);
color: #fff;
font-size: 13px; font-weight: 700;
font-family: inherit; cursor: pointer;
transition: background .2s;
}
.btn-save:hover { background: var(--accent-dk); }
/* coord hint */
.coord-hint {
font-size: 11px; color: var(--muted);
background: var(--surface2);
border: 1px dashed var(--border);
border-radius: 8px;
padding: 8px 12px; margin-bottom: 16px;
display: flex; align-items: center; gap: 7px;
}
/* ── Floating bar pindah titik ───────── */
#move-bar {
position: absolute;
bottom: 32px; left: 50%; transform: translateX(-50%) translateY(80px);
z-index: 900;
background: var(--surface);
border: 1.5px solid var(--accent);
border-radius: 40px;
padding: 10px 16px 10px 20px;
display: flex; align-items: center; gap: 12px;
box-shadow: 0 8px 28px rgba(0,0,0,.6), 0 0 0 4px rgba(249,115,22,.12);
opacity: 0; pointer-events: none;
transition: transform .3s cubic-bezier(.34,1.56,.64,1), opacity .25s;
white-space: nowrap;
}
#move-bar.active {
transform: translateX(-50%) translateY(0);
opacity: 1; pointer-events: all;
}
.move-bar-label {
font-size: 12px; font-weight: 600; color: var(--accent);
display: flex; align-items: center; gap: 7px;
}
.move-bar-label .pulse-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--accent);
animation: pulse 1.2s infinite;
}
.btn-confirm {
padding: 6px 16px;
background: var(--green); border: none;
border-radius: 20px; color: #fff;
font-size: 12px; font-weight: 700;
font-family: inherit; cursor: pointer;
transition: opacity .2s;
}
.btn-confirm:hover { opacity: .85; }
.btn-cancel-move {
padding: 6px 14px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 20px; color: var(--muted);
font-size: 12px; font-weight: 600;
font-family: inherit; cursor: pointer;
transition: all .2s;
}
.btn-cancel-move:hover { border-color: var(--red); color: var(--red); }
/* ── Toast ───────────────────────────── */
#toast {
position: fixed; bottom: 24px; right: 24px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px 18px;
font-size: 13px; font-weight: 500;
box-shadow: var(--shadow);
z-index: 9999;
transform: translateY(20px);
opacity: 0;
transition: all .3s;
display: flex; align-items: center; gap: 9px;
}
#toast.show { transform: translateY(0); opacity: 1; }
#toast.success { border-color: var(--green); }
#toast.error { border-color: var(--red); }
/* ── Leaflet popup custom ────────────── */
.leaflet-popup-content-wrapper {
background: var(--surface) !important;
border: 1px solid var(--border) !important;
border-radius: 12px !important;
box-shadow: var(--shadow) !important;
color: var(--text) !important;
}
.leaflet-popup-tip { background: var(--surface) !important; }
.leaflet-popup-content { margin: 14px 16px !important; }
.popup-title { font-weight: 700; font-size: 14px; margin-bottom: 6px; }
.popup-wa { font-size: 12px; color: var(--muted); }
.popup-status { margin-top: 8px; }
/* empty state */
.empty-state {
text-align: center;
padding: 40px 20px;
color: var(--muted);
font-size: 13px;
}
.empty-state .emoji { font-size: 36px; margin-bottom: 10px; }
@media (max-width: 640px) {
.app { grid-template-columns: 1fr; grid-template-rows: 60px 1fr 260px; }
aside { border-right: none; border-bottom: 1px solid var(--border); }
}
</style>
</head>
<body>
<div class="app">
<!-- ═══════════ HEADER ═══════════ -->
<header>
<div class="logo-icon"></div>
<h1>WebGIS <span>SPBU</span> Pontianak</h1>
<div class="header-meta">
<span class="dot-live"></span>Live Map
</div>
</header>
<!-- ═══════════ SIDEBAR ═══════════ -->
<aside>
<div class="sidebar-header">
<h2>Total SPBU</h2>
<div class="count-badge" id="total-count">0 <small>titik</small></div>
</div>
<div class="stats-row">
<div class="stat-pill green">
<strong id="stat-24">0</strong>
24 Jam
</div>
<div class="stat-pill red">
<strong id="stat-non24">0</strong>
Non-24 Jam
</div>
</div>
<div class="search-wrap">
<input type="text" id="search-input" placeholder="Cari nama SPBU…" oninput="filterList()"/>
</div>
<div id="spbu-list">
<div class="empty-state">
<div class="emoji">🗺️</div>
Memuat data…
</div>
</div>
<div class="cta-box">
<button class="btn-add" onclick="openAddMode()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Klik Peta untuk Tambah SPBU
</button>
</div>
</aside>
<!-- ═══════════ MAP ═══════════ -->
<div id="map" style="position:relative;">
<!-- Floating bar konfirmasi pindah titik -->
<div id="move-bar">
<div class="move-bar-label">
<span class="pulse-dot"></span>
Geser pin ke lokasi baru
</div>
<button class="btn-confirm" onclick="confirmMove()">✅ Konfirmasi</button>
<button class="btn-cancel-move" onclick="cancelMove()">✕ Batal</button>
</div>
</div>
</div>
<!-- ═══════════ MODAL FORM ═══════════ -->
<div class="overlay" id="modal-overlay">
<div class="modal">
<h3>
<span class="icon"></span>
<span id="modal-title">Tambah SPBU</span>
</h3>
<div class="coord-hint" id="coord-hint">
📍 Koordinat diambil dari titik yang Anda klik di peta
</div>
<div class="form-group">
<label>Nama SPBU</label>
<input type="text" id="f-nama" placeholder="Contoh: SPBU Pertamina Jl. Ahmad Yani"/>
</div>
<div class="form-group">
<label>Nomor WhatsApp</label>
<input type="text" id="f-wa" placeholder="Contoh: 6281234567890"/>
</div>
<div class="form-group">
<label>Status 24 Jam</label>
<select id="f-status">
<option value="yes">✅ Ya Buka 24 Jam</option>
<option value="no">❌ Tidak Jam Terbatas</option>
</select>
</div>
<div class="coord-row">
<div class="form-group">
<label>Latitude</label>
<input type="number" id="f-lat" step="0.000001" placeholder="-0.0262619"/>
</div>
<div class="form-group">
<label>Longitude</label>
<input type="number" id="f-lng" step="0.000001" placeholder="109.3425923"/>
</div>
</div>
<div class="form-actions">
<button class="btn-cancel" onclick="closeModal()">Batal</button>
<button class="btn-save" onclick="saveSpbu()">💾 Simpan</button>
</div>
</div>
</div>
<!-- Toast -->
<div id="toast"></div>
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
// ═══════════════════════════════════════════════════════════
// STATE
// ═══════════════════════════════════════════════════════════
let spbuData = [];
let markers = {}; // id → marker
let editId = null;
let addMode = false;
let tempMarker = null;
// ═══════════════════════════════════════════════════════════
// MAP INIT — center: Pontianak
// ═══════════════════════════════════════════════════════════
const map = L.map('map', {
center: [-0.0263, 109.3425],
zoom: 13,
zoomControl: false
});
L.control.zoom({ position: 'bottomright' }).addTo(map);
// Tile layer OpenStreetMap
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© <a href="https://openstreetmap.org">OpenStreetMap</a> contributors',
maxZoom: 19
}).addTo(map);
// ── Custom icons ──────────────────────────────────────────
function makeIcon(is24) {
return L.divIcon({
className: '',
html: `<div style="
width:36px;height:36px;
background:${is24 ? '#3fb950' : '#f97316'};
border:3px solid #fff;
border-radius:50% 50% 50% 0;
transform:rotate(-45deg);
box-shadow:0 3px 10px rgba(0,0,0,.5);
"></div>`,
iconSize: [36, 36],
iconAnchor: [18, 36],
popupAnchor: [0, -38]
});
}
const tempIcon = L.divIcon({
className: '',
html: `<div style="
width:34px;height:34px;
background:#f97316;
border:3px solid #fff;
border-radius:50% 50% 50% 0;
transform:rotate(-45deg);
box-shadow:0 3px 14px rgba(249,115,22,.7);
animation: blink .7s infinite alternate;
"></div>
<style>@keyframes blink{from{opacity:1}to{opacity:.4}}</style>`,
iconSize: [34, 34], iconAnchor: [17, 34], popupAnchor: [0, -36]
});
// ═══════════════════════════════════════════════════════════
// API CALLS
// ═══════════════════════════════════════════════════════════
const API = 'api.php';
async function fetchAll() {
try {
const r = await fetch(API);
const j = await r.json();
if (j.status === 'success') {
spbuData = j.data;
renderList();
renderMarkers();
updateStats();
}
} catch(e) { showToast('Gagal memuat data. Pastikan server berjalan.', 'error'); }
}
async function saveSpbu() {
const nama = document.getElementById('f-nama').value.trim();
const wa = document.getElementById('f-wa').value.trim();
const status = document.getElementById('f-status').value;
const lat = parseFloat(document.getElementById('f-lat').value);
const lng = parseFloat(document.getElementById('f-lng').value);
if (!nama || !wa || isNaN(lat) || isNaN(lng)) {
showToast('Harap isi semua field dengan benar!', 'error'); return;
}
const payload = { nama_spbu: nama, nomor_wa: wa, status_24jam: status, latitude: lat, longitude: lng };
try {
const r = await fetch(API + (editId ? `?id=${editId}` : ''), {
method : editId ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify(payload)
});
const j = await r.json();
if (j.status === 'success') {
showToast(editId ? 'SPBU berhasil diperbarui! ✏️' : 'SPBU berhasil ditambahkan! ⛽', 'success');
closeModal();
fetchAll();
} else {
showToast('Gagal menyimpan: ' + j.message, 'error');
}
} catch(e) { showToast('Error koneksi ke server.', 'error'); }
}
async function deleteSpbu(id) {
if (!confirm('Yakin hapus SPBU ini?')) return;
try {
const r = await fetch(`${API}?id=${id}`, { method: 'DELETE' });
const j = await r.json();
if (j.status === 'success') {
showToast('SPBU berhasil dihapus! 🗑️', 'success');
fetchAll();
}
} catch(e) { showToast('Gagal menghapus.', 'error'); }
}
// ═══════════════════════════════════════════════════════════
// RENDER
// ═══════════════════════════════════════════════════════════
function renderMarkers() {
// clear old
Object.values(markers).forEach(m => map.removeLayer(m));
markers = {};
spbuData.forEach(s => {
const m = L.marker([s.latitude, s.longitude], { icon: makeIcon(s.status_24jam === 'yes') })
.addTo(map)
.bindPopup(popupHTML(s));
m.on('click', () => highlightCard(s.id));
markers[s.id] = m;
});
}
function popupHTML(s) {
return `
<div class="popup-title">⛽ ${escHtml(s.nama_spbu)}</div>
<div class="popup-wa">
📱 ${escHtml(s.nomor_wa)}
</div>
<div class="popup-status">
<span class="badge ${s.status_24jam}">
${s.status_24jam === 'yes' ? '✅ Buka 24 Jam' : '❌ Jam Terbatas'}
</span>
</div>
<div style="margin-top:10px;display:flex;gap:6px;flex-wrap:wrap">
<button onclick="openEdit(${s.id})" style="flex:1;padding:6px 0;font-size:11px;font-weight:600;border-radius:6px;cursor:pointer;border:1px solid #30363d;background:#21262d;color:#e6edf3;font-family:inherit">✏️ Edit</button>
<button onclick="startMovePin(${s.id})" style="flex:1;padding:6px 0;font-size:11px;font-weight:600;border-radius:6px;cursor:pointer;border:1px solid #f97316;background:rgba(249,115,22,.1);color:#f97316;font-family:inherit">📌 Pindahkan</button>
<button onclick="deleteSpbu(${s.id})" style="flex:1;padding:6px 0;font-size:11px;font-weight:600;border-radius:6px;cursor:pointer;border:1px solid #f85149;background:rgba(248,81,73,.1);color:#f85149;font-family:inherit">🗑️ Hapus</button>
</div>`;
}
function renderList(filter = '') {
const list = document.getElementById('spbu-list');
const q = filter.toLowerCase();
const filtered = spbuData.filter(s => s.nama_spbu.toLowerCase().includes(q));
if (!filtered.length) {
list.innerHTML = `<div class="empty-state"><div class="emoji">${filter ? '🔍' : '📍'}</div>${filter ? 'Tidak ditemukan' : 'Belum ada data SPBU'}</div>`;
return;
}
list.innerHTML = filtered.map(s => `
<div class="spbu-card" id="card-${s.id}" onclick="flyTo(${s.id})">
<div class="card-top">
<div class="card-name">${escHtml(s.nama_spbu)}</div>
<span class="badge ${s.status_24jam}">${s.status_24jam === 'yes' ? '24 Jam' : 'Non-24'}</span>
</div>
<div class="card-wa">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07A19.5 19.5 0 013.07 9.8 19.79 19.79 0 01.01 1.18C0 .1.68-.15 1.91.08A2 2 0 013.9 2.05v3a2 2 0 01-1.45 1.93 16 16 0 006.39 6.39A2 2 0 0110.77 12h.1l.07.06"/></svg>
${escHtml(s.nomor_wa)}
</div>
<div class="card-actions">
<button class="btn-sm" onclick="event.stopPropagation();openEdit(${s.id})">✏️ Edit</button>
<button class="btn-sm move" onclick="event.stopPropagation();startMovePin(${s.id})">📌 Pindahkan</button>
<button class="btn-sm danger" onclick="event.stopPropagation();deleteSpbu(${s.id})">🗑️ Hapus</button>
</div>
</div>`
).join('');
}
function updateStats() {
const total = spbuData.length;
const yes = spbuData.filter(s => s.status_24jam === 'yes').length;
document.getElementById('total-count').innerHTML = `${total} <small>titik</small>`;
document.getElementById('stat-24').textContent = yes;
document.getElementById('stat-non24').textContent = total - yes;
}
// ═══════════════════════════════════════════════════════════
// MAP INTERACTIONS
// ═══════════════════════════════════════════════════════════
let addModeActive = false;
function openAddMode() {
addModeActive = true;
map.getContainer().style.cursor = 'crosshair';
showToast('🗺️ Klik pada peta untuk menentukan lokasi SPBU', 'success');
}
map.on('click', function(e) {
if (!addModeActive) return;
addModeActive = false;
map.getContainer().style.cursor = '';
if (tempMarker) map.removeLayer(tempMarker);
tempMarker = L.marker([e.latlng.lat, e.latlng.lng], { icon: tempIcon }).addTo(map);
openModal(null, e.latlng.lat, e.latlng.lng);
});
function flyTo(id) {
const s = spbuData.find(x => x.id === id);
if (!s) return;
map.flyTo([s.latitude, s.longitude], 16, { duration: 1.2 });
setTimeout(() => {
if (markers[id]) markers[id].openPopup();
}, 1300);
highlightCard(id);
}
function highlightCard(id) {
document.querySelectorAll('.spbu-card').forEach(c => c.classList.remove('active'));
const card = document.getElementById(`card-${id}`);
if (card) {
card.classList.add('active');
card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
// ═══════════════════════════════════════════════════════════
// MODAL
// ═══════════════════════════════════════════════════════════
function openModal(id = null, lat = '', lng = '') {
editId = id;
const overlay = document.getElementById('modal-overlay');
document.getElementById('modal-title').textContent = id ? 'Edit SPBU' : 'Tambah SPBU Baru';
if (id) {
const s = spbuData.find(x => x.id == id);
if (!s) { showToast('Data SPBU tidak ditemukan!', 'error'); return; }
document.getElementById('f-nama').value = s.nama_spbu;
document.getElementById('f-wa').value = s.nomor_wa;
document.getElementById('f-status').value = s.status_24jam;
document.getElementById('f-lat').value = s.latitude;
document.getElementById('f-lng').value = s.longitude;
document.getElementById('coord-hint').style.display = 'none';
} else {
document.getElementById('f-nama').value = '';
document.getElementById('f-wa').value = '';
document.getElementById('f-status').value = 'no';
document.getElementById('f-lat').value = lat;
document.getElementById('f-lng').value = lng;
document.getElementById('coord-hint').style.display = 'flex';
}
overlay.classList.add('open');
}
function openEdit(id) {
id = parseInt(id);
if (markers[id]) markers[id].closePopup();
openModal(id);
}
function closeModal(cancelled = true) {
document.getElementById('modal-overlay').classList.remove('open');
editId = null;
if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; }
}
// ═══════════════════════════════════════════════════════════
// PINDAHKAN TITIK drag mode tanpa modal
// ═══════════════════════════════════════════════════════════
let _moveId = null;
let _moveOriginal = null;
function startMovePin(id) {
id = parseInt(id);
const s = spbuData.find(x => x.id == id);
if (!s) return;
// Tutup popup jika terbuka
if (markers[id]) markers[id].closePopup();
_moveId = id;
_moveOriginal = { lat: s.latitude, lng: s.longitude };
const m = markers[id];
m.dragging.enable();
// Ganti icon jadi warna biru/terang sebagai penanda aktif
m.setIcon(L.divIcon({
className: '',
html: `<div style="
width:40px;height:40px;
background:#3b82f6;
border:3px solid #fff;
border-radius:50% 50% 50% 0;
transform:rotate(-45deg);
box-shadow:0 0 0 6px rgba(59,130,246,.3),0 4px 14px rgba(0,0,0,.5);
"></div>`,
iconSize: [40,40], iconAnchor: [20,40], popupAnchor: [0,-42]
}));
// Zoom ke marker agar mudah drag
map.flyTo([s.latitude, s.longitude], 16, { duration: .8 });
// Tampilkan floating bar
document.getElementById('move-bar').classList.add('active');
showToast('📌 Geser pin biru ke lokasi baru lalu klik Konfirmasi', 'success');
}
async function confirmMove() {
if (!_moveId) return;
const m = markers[_moveId];
const ll = m.getLatLng();
const s = spbuData.find(x => x.id == _moveId);
try {
const r = await fetch(`api.php?id=${_moveId}`, {
method : 'PUT',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify({
nama_spbu : s.nama_spbu,
nomor_wa : s.nomor_wa,
status_24jam: s.status_24jam,
latitude : ll.lat,
longitude : ll.lng
})
});
const j = await r.json();
if (j.status === 'success') {
showToast('📍 Lokasi SPBU berhasil dipindahkan!', 'success');
_endMoveMode(false);
fetchAll();
} else {
showToast('Gagal menyimpan: ' + j.message, 'error');
}
} catch(e) { showToast('Error koneksi ke server.', 'error'); }
}
function cancelMove() {
_endMoveMode(true);
showToast('Pemindahan dibatalkan', 'error');
}
function _endMoveMode(restore) {
if (!_moveId) return;
const m = markers[_moveId];
m.dragging.disable();
// Kembalikan posisi & icon asli jika dibatalkan
if (restore && _moveOriginal) {
m.setLatLng([_moveOriginal.lat, _moveOriginal.lng]);
}
const s = spbuData.find(x => x.id == _moveId);
if (s) m.setIcon(makeIcon(s.status_24jam === 'yes'));
document.getElementById('move-bar').classList.remove('active');
_moveId = null;
_moveOriginal = null;
}
document.getElementById('modal-overlay').addEventListener('click', function(e) {
if (e.target === this) closeModal();
});
// ═══════════════════════════════════════════════════════════
// SEARCH
// ═══════════════════════════════════════════════════════════
function filterList() {
const q = document.getElementById('search-input').value;
renderList(q);
}
// ═══════════════════════════════════════════════════════════
// TOAST
// ═══════════════════════════════════════════════════════════
let toastTimer;
function showToast(msg, type = 'success') {
const t = document.getElementById('toast');
t.textContent = msg;
t.className = `show ${type}`;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { t.className = ''; }, 3200);
}
// ═══════════════════════════════════════════════════════════
// UTILS
// ═══════════════════════════════════════════════════════════
function escHtml(str) {
return String(str)
.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ═══════════════════════════════════════════════════════════
// INIT
// ═══════════════════════════════════════════════════════════
// Ekspos ke window agar bisa dipanggil dari dalam popup Leaflet
window.openEdit = openEdit;
window.deleteSpbu = deleteSpbu;
window.startMovePin = startMovePin;
fetchAll();
</script>
</body>
</html>