Initial commit
This commit is contained in:
+15
@@ -0,0 +1,15 @@
|
|||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
api/config/database.local.php
|
||||||
|
*.sql
|
||||||
|
!database.sql
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
*.db
|
||||||
|
*.bak
|
||||||
|
*.backup
|
||||||
|
node_modules/
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# WebGIS Poverty
|
||||||
|
|
||||||
|
Proyek WebGIS sederhana untuk visualisasi data kemiskinan dan fasilitas.
|
||||||
|
|
||||||
|
**Persyaratan**
|
||||||
|
- PHP + Apache (mis. XAMPP)
|
||||||
|
- MySQL
|
||||||
|
|
||||||
|
**Setup singkat**
|
||||||
|
1. Tempatkan folder proyek di `htdocs` (sudah di c:\xampp\htdocs...).
|
||||||
|
2. Mulai Apache dan MySQL (XAMPP).
|
||||||
|
3. Buat database bernama `webgis_poverty` atau sesuaikan `api/config/database.local.php`.
|
||||||
|
4. Buka [index.html](index.html) di browser.
|
||||||
|
|
||||||
|
**File konfigurasi penting**
|
||||||
|
- `api/config/database.local.php` — konfigurasi koneksi database lokal.
|
||||||
|
- `api/config/database.local.php.example` — contoh konfigurasi.
|
||||||
|
|
||||||
|
**Endpoint API**
|
||||||
|
- `api/data_jalan.php`
|
||||||
|
- `api/data_parsil.php`
|
||||||
|
- `api/log_bantuan.php`
|
||||||
|
- `api/penduduk_miskin.php`
|
||||||
|
- `api/rumah_ibadah.php`
|
||||||
|
- `api/spbu.php`
|
||||||
|
|
||||||
|
**Frontend**
|
||||||
|
- CSS: `css/app.css`
|
||||||
|
- JS: `js/app-core.js`, `js/feature-*.js`
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<IfModule mod_authz_core.c>
|
||||||
|
Require all denied
|
||||||
|
</IfModule>
|
||||||
|
<IfModule !mod_authz_core.c>
|
||||||
|
Deny from all
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
# Disable directory listing
|
||||||
|
Options -Indexes
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'host' => 'localhost',
|
||||||
|
'user' => 'root',
|
||||||
|
'pass' => 'password',
|
||||||
|
'name' => 'webgis_poverty',
|
||||||
|
'charset' => 'utf8mb4',
|
||||||
|
];
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
// api/config/database.php
|
||||||
|
|
||||||
|
mysqli_report(MYSQLI_REPORT_OFF);
|
||||||
|
|
||||||
|
if (!function_exists('loadDatabaseConfig')) {
|
||||||
|
function loadDatabaseConfig(): array {
|
||||||
|
$config = [
|
||||||
|
'host' => getenv('DB_HOST') ?: 'localhost',
|
||||||
|
'user' => getenv('DB_USER') ?: 'root',
|
||||||
|
'pass' => getenv('DB_PASS') ?: '',
|
||||||
|
'name' => getenv('DB_NAME') ?: 'webgis_poverty',
|
||||||
|
'charset' => getenv('DB_CHARSET') ?: 'utf8mb4',
|
||||||
|
];
|
||||||
|
|
||||||
|
$localConfigFile = __DIR__ . '/database.local.php';
|
||||||
|
if (is_file($localConfigFile)) {
|
||||||
|
$localConfig = require $localConfigFile;
|
||||||
|
if (is_array($localConfig)) {
|
||||||
|
$config = array_merge($config, array_intersect_key($localConfig, $config));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$dbConfig = loadDatabaseConfig();
|
||||||
|
|
||||||
|
define('DB_HOST', $dbConfig['host']);
|
||||||
|
define('DB_USER', $dbConfig['user']);
|
||||||
|
define('DB_PASS', $dbConfig['pass']);
|
||||||
|
define('DB_NAME', $dbConfig['name']);
|
||||||
|
define('DB_CHARSET', $dbConfig['charset']);
|
||||||
|
|
||||||
|
function getDB(): mysqli {
|
||||||
|
static $conn = null;
|
||||||
|
if ($conn === null) {
|
||||||
|
$conn = new mysqli(DB_HOST, DB_USER, DB_PASS, DB_NAME);
|
||||||
|
if ($conn->connect_error) {
|
||||||
|
error_log('DB connection failed: ' . $conn->connect_error);
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Koneksi database gagal'
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$conn->set_charset(DB_CHARSET);
|
||||||
|
}
|
||||||
|
return $conn;
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
// api/data_jalan.php
|
||||||
|
require_once __DIR__ . '/config/database.php';
|
||||||
|
require_once __DIR__ . '/helpers/response.php';
|
||||||
|
setCORSHeaders();
|
||||||
|
|
||||||
|
$db = getDB();
|
||||||
|
$method = getMethod();
|
||||||
|
requireWriteAuth($method);
|
||||||
|
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||||
|
|
||||||
|
switch ($method) {
|
||||||
|
|
||||||
|
case 'GET':
|
||||||
|
if ($id > 0) {
|
||||||
|
$stmt = $db->prepare(
|
||||||
|
"SELECT id, nama_jalan, status_jalan,
|
||||||
|
panjang_meter, keterangan,
|
||||||
|
CAST(geojson AS CHAR) AS geojson,
|
||||||
|
created_at
|
||||||
|
FROM data_jalan WHERE id = ?"
|
||||||
|
);
|
||||||
|
$stmt->bind_param('i', $id);
|
||||||
|
$stmt->execute();
|
||||||
|
$row = $stmt->get_result()->fetch_assoc();
|
||||||
|
if (!$row) sendError('Data tidak ditemukan', 404);
|
||||||
|
// Parse geojson string menjadi object
|
||||||
|
$row['geojson'] = json_decode($row['geojson']);
|
||||||
|
$row['panjang_meter'] = (float)$row['panjang_meter'];
|
||||||
|
sendSuccess($row);
|
||||||
|
} else {
|
||||||
|
$rows = [];
|
||||||
|
$res = $db->query(
|
||||||
|
"SELECT id, nama_jalan, status_jalan,
|
||||||
|
panjang_meter, keterangan,
|
||||||
|
CAST(geojson AS CHAR) AS geojson,
|
||||||
|
created_at
|
||||||
|
FROM data_jalan ORDER BY created_at DESC"
|
||||||
|
);
|
||||||
|
while ($r = $res->fetch_assoc()) {
|
||||||
|
$r['geojson'] = json_decode($r['geojson']);
|
||||||
|
$r['panjang_meter'] = (float)$r['panjang_meter'];
|
||||||
|
$rows[] = $r;
|
||||||
|
}
|
||||||
|
sendSuccess($rows);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'POST':
|
||||||
|
$body = getBody();
|
||||||
|
foreach (['nama_jalan','status_jalan','geojson','panjang_meter'] as $f) {
|
||||||
|
if (!isset($body[$f])) sendError("Field '$f' wajib diisi");
|
||||||
|
}
|
||||||
|
// Validasi GeoJSON LineString
|
||||||
|
$geo = is_array($body['geojson'])
|
||||||
|
? $body['geojson']
|
||||||
|
: json_decode($body['geojson'], true);
|
||||||
|
if (!$geo || $geo['type'] !== 'LineString') {
|
||||||
|
sendError('GeoJSON harus bertipe LineString');
|
||||||
|
}
|
||||||
|
$geoStr = json_encode($geo);
|
||||||
|
$panjang = (float)$body['panjang_meter'];
|
||||||
|
|
||||||
|
$stmt = $db->prepare(
|
||||||
|
"INSERT INTO data_jalan
|
||||||
|
(nama_jalan, status_jalan, panjang_meter, keterangan, geojson)
|
||||||
|
VALUES (?, ?, ?, ?, ?)"
|
||||||
|
);
|
||||||
|
$stmt->bind_param('ssdss',
|
||||||
|
$body['nama_jalan'], $body['status_jalan'],
|
||||||
|
$panjang, $body['keterangan'], $geoStr
|
||||||
|
);
|
||||||
|
executeOrFail($stmt, 'Gagal menyimpan data jalan');
|
||||||
|
sendSuccess(['id' => $db->insert_id], 'Data jalan berhasil disimpan', 201);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'PUT':
|
||||||
|
if ($id <= 0) sendError('ID wajib disertakan');
|
||||||
|
$body = getBody();
|
||||||
|
$geo = is_array($body['geojson'])
|
||||||
|
? $body['geojson']
|
||||||
|
: json_decode($body['geojson'], true);
|
||||||
|
$geoStr = json_encode($geo);
|
||||||
|
$panjang = (float)$body['panjang_meter'];
|
||||||
|
|
||||||
|
$stmt = $db->prepare(
|
||||||
|
"UPDATE data_jalan
|
||||||
|
SET nama_jalan=?, status_jalan=?,
|
||||||
|
panjang_meter=?, keterangan=?, geojson=?
|
||||||
|
WHERE id=?"
|
||||||
|
);
|
||||||
|
$stmt->bind_param('ssdssi',
|
||||||
|
$body['nama_jalan'], $body['status_jalan'],
|
||||||
|
$panjang, $body['keterangan'], $geoStr, $id
|
||||||
|
);
|
||||||
|
executeOrFail($stmt, 'Gagal memperbarui data jalan');
|
||||||
|
sendSuccess(null, 'Data jalan berhasil diperbarui');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'DELETE':
|
||||||
|
if ($id <= 0) sendError('ID wajib disertakan');
|
||||||
|
$stmt = $db->prepare("DELETE FROM data_jalan WHERE id=?");
|
||||||
|
$stmt->bind_param('i', $id);
|
||||||
|
executeOrFail($stmt, 'Gagal menghapus data jalan');
|
||||||
|
$stmt->affected_rows > 0
|
||||||
|
? sendSuccess(null, 'Data jalan berhasil dihapus')
|
||||||
|
: sendError('Data tidak ditemukan', 404);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
sendError('Method tidak diizinkan', 405);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
// api/data_parsil.php
|
||||||
|
require_once __DIR__ . '/config/database.php';
|
||||||
|
require_once __DIR__ . '/helpers/response.php';
|
||||||
|
setCORSHeaders();
|
||||||
|
|
||||||
|
$db = getDB();
|
||||||
|
$method = getMethod();
|
||||||
|
requireWriteAuth($method);
|
||||||
|
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||||
|
|
||||||
|
switch ($method) {
|
||||||
|
|
||||||
|
case 'GET':
|
||||||
|
if ($id > 0) {
|
||||||
|
$stmt = $db->prepare(
|
||||||
|
"SELECT id, nama_pemilik, no_sertifikat, jenis_hak,
|
||||||
|
luas_m2, keterangan,
|
||||||
|
CAST(geojson AS CHAR) AS geojson,
|
||||||
|
created_at
|
||||||
|
FROM data_parsil WHERE id = ?"
|
||||||
|
);
|
||||||
|
$stmt->bind_param('i', $id);
|
||||||
|
$stmt->execute();
|
||||||
|
$row = $stmt->get_result()->fetch_assoc();
|
||||||
|
if (!$row) sendError('Data tidak ditemukan', 404);
|
||||||
|
$row['geojson'] = json_decode($row['geojson']);
|
||||||
|
$row['luas_m2'] = (float)$row['luas_m2'];
|
||||||
|
sendSuccess($row);
|
||||||
|
} else {
|
||||||
|
$rows = [];
|
||||||
|
$res = $db->query(
|
||||||
|
"SELECT id, nama_pemilik, no_sertifikat, jenis_hak,
|
||||||
|
luas_m2, keterangan,
|
||||||
|
CAST(geojson AS CHAR) AS geojson,
|
||||||
|
created_at
|
||||||
|
FROM data_parsil ORDER BY created_at DESC"
|
||||||
|
);
|
||||||
|
while ($r = $res->fetch_assoc()) {
|
||||||
|
$r['geojson'] = json_decode($r['geojson']);
|
||||||
|
$r['luas_m2'] = (float)$r['luas_m2'];
|
||||||
|
$rows[] = $r;
|
||||||
|
}
|
||||||
|
sendSuccess($rows);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'POST':
|
||||||
|
$body = getBody();
|
||||||
|
foreach (['nama_pemilik','jenis_hak','geojson','luas_m2'] as $f) {
|
||||||
|
if (!isset($body[$f])) sendError("Field '$f' wajib diisi");
|
||||||
|
}
|
||||||
|
$geo = is_array($body['geojson'])
|
||||||
|
? $body['geojson']
|
||||||
|
: json_decode($body['geojson'], true);
|
||||||
|
if (!$geo || $geo['type'] !== 'Polygon') {
|
||||||
|
sendError('GeoJSON harus bertipe Polygon');
|
||||||
|
}
|
||||||
|
$geoStr = json_encode($geo);
|
||||||
|
$luas = (float)$body['luas_m2'];
|
||||||
|
|
||||||
|
$stmt = $db->prepare(
|
||||||
|
"INSERT INTO data_parsil
|
||||||
|
(nama_pemilik, no_sertifikat, jenis_hak, luas_m2, keterangan, geojson)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)"
|
||||||
|
);
|
||||||
|
$stmt->bind_param('sssdss',
|
||||||
|
$body['nama_pemilik'], $body['no_sertifikat'],
|
||||||
|
$body['jenis_hak'], $luas,
|
||||||
|
$body['keterangan'], $geoStr
|
||||||
|
);
|
||||||
|
executeOrFail($stmt, 'Gagal menyimpan data parsil');
|
||||||
|
sendSuccess(['id' => $db->insert_id], 'Data parsil berhasil disimpan', 201);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'PUT':
|
||||||
|
if ($id <= 0) sendError('ID wajib disertakan');
|
||||||
|
$body = getBody();
|
||||||
|
$geo = is_array($body['geojson'])
|
||||||
|
? $body['geojson']
|
||||||
|
: json_decode($body['geojson'], true);
|
||||||
|
$geoStr = json_encode($geo);
|
||||||
|
$luas = (float)$body['luas_m2'];
|
||||||
|
|
||||||
|
$stmt = $db->prepare(
|
||||||
|
"UPDATE data_parsil
|
||||||
|
SET nama_pemilik=?, no_sertifikat=?, jenis_hak=?,
|
||||||
|
luas_m2=?, keterangan=?, geojson=?
|
||||||
|
WHERE id=?"
|
||||||
|
);
|
||||||
|
$stmt->bind_param('sssdssi',
|
||||||
|
$body['nama_pemilik'], $body['no_sertifikat'],
|
||||||
|
$body['jenis_hak'], $luas,
|
||||||
|
$body['keterangan'], $geoStr, $id
|
||||||
|
);
|
||||||
|
executeOrFail($stmt, 'Gagal memperbarui data parsil');
|
||||||
|
sendSuccess(null, 'Data parsil berhasil diperbarui');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'DELETE':
|
||||||
|
if ($id <= 0) sendError('ID wajib disertakan');
|
||||||
|
$stmt = $db->prepare("DELETE FROM data_parsil WHERE id=?");
|
||||||
|
$stmt->bind_param('i', $id);
|
||||||
|
executeOrFail($stmt, 'Gagal menghapus data parsil');
|
||||||
|
$stmt->affected_rows > 0
|
||||||
|
? sendSuccess(null, 'Data parsil berhasil dihapus')
|
||||||
|
: sendError('Data tidak ditemukan', 404);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
sendError('Method tidak diizinkan', 405);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
// api/helpers/response.php
|
||||||
|
|
||||||
|
function setCORSHeaders(): void {
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('Vary: Origin');
|
||||||
|
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||||
|
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-API-Token');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
|
||||||
|
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||||
|
$allowed = false;
|
||||||
|
if ($origin !== '') {
|
||||||
|
$origin = trim($origin);
|
||||||
|
$allowedOrigins = parseAllowedOrigins(getenv('CORS_ALLOWED_ORIGINS') ?: '');
|
||||||
|
if (!empty($allowedOrigins)) {
|
||||||
|
$allowed = in_array($origin, $allowedOrigins, true);
|
||||||
|
} else {
|
||||||
|
$allowed = (bool)preg_match('/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/', $origin);
|
||||||
|
}
|
||||||
|
if ($allowed) {
|
||||||
|
header('Access-Control-Allow-Origin: ' . $origin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getMethod() === 'OPTIONS') {
|
||||||
|
http_response_code(($origin !== '' && !$allowed) ? 403 : 204);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAllowedOrigins(string $raw): array {
|
||||||
|
$parts = array_map('trim', explode(',', $raw));
|
||||||
|
return array_values(array_filter($parts, static fn ($v) => $v !== ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJSON(mixed $data, int $code = 200): void {
|
||||||
|
http_response_code($code);
|
||||||
|
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendSuccess(mixed $data = null, string $message = 'OK', int $code = 200): void {
|
||||||
|
sendJSON(['status' => 'success', 'message' => $message, 'data' => $data], $code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendError(string $message, int $code = 400): void {
|
||||||
|
sendJSON(['status' => 'error', 'message' => $message], $code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireWriteAuth(string $method): void {
|
||||||
|
if (!in_array($method, ['POST', 'PUT', 'DELETE'], true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$expected = (string)(getenv('API_WRITE_TOKEN') ?: '');
|
||||||
|
if ($expected === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$provided = (string)($_SERVER['HTTP_X_API_TOKEN'] ?? '');
|
||||||
|
if ($provided === '') {
|
||||||
|
$auth = (string)($_SERVER['HTTP_AUTHORIZATION'] ?? '');
|
||||||
|
if (preg_match('/Bearer\s+(.+)/i', $auth, $m)) {
|
||||||
|
$provided = trim($m[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($provided === '' || !hash_equals($expected, $provided)) {
|
||||||
|
sendError('Unauthorized', 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeOrFail(mysqli_stmt $stmt, string $defaultMessage = 'Operasi database gagal'): void {
|
||||||
|
if ($stmt->execute()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$errno = $stmt->errno;
|
||||||
|
$error = $stmt->error;
|
||||||
|
error_log('DB stmt error [' . $errno . ']: ' . $error);
|
||||||
|
|
||||||
|
if ($errno === 1062) {
|
||||||
|
sendError('Data duplikat terdeteksi', 409);
|
||||||
|
}
|
||||||
|
if ($errno === 1451 || $errno === 1452) {
|
||||||
|
sendError('Relasi data tidak valid', 422);
|
||||||
|
}
|
||||||
|
sendError($defaultMessage, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBody(): array {
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
$data = json_decode($raw, true);
|
||||||
|
return is_array($data) ? $data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMethod(): string {
|
||||||
|
return strtoupper($_SERVER['REQUEST_METHOD']);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
// api/log_bantuan.php
|
||||||
|
require_once __DIR__ . '/config/database.php';
|
||||||
|
require_once __DIR__ . '/helpers/response.php';
|
||||||
|
setCORSHeaders();
|
||||||
|
|
||||||
|
$db = getDB();
|
||||||
|
$method = getMethod();
|
||||||
|
requireWriteAuth($method);
|
||||||
|
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||||
|
$pmId = isset($_GET['penduduk_miskin_id']) ? (int)$_GET['penduduk_miskin_id'] : 0;
|
||||||
|
|
||||||
|
switch ($method) {
|
||||||
|
|
||||||
|
case 'GET':
|
||||||
|
if ($pmId > 0) {
|
||||||
|
// Semua log untuk satu penduduk miskin
|
||||||
|
$stmt = $db->prepare(
|
||||||
|
"SELECT lb.*,
|
||||||
|
ri.nama AS nama_rumah_ibadah,
|
||||||
|
ri.jenis AS jenis_rumah_ibadah
|
||||||
|
FROM log_bantuan lb
|
||||||
|
JOIN rumah_ibadah ri ON ri.id = lb.rumah_ibadah_id
|
||||||
|
WHERE lb.penduduk_miskin_id = ?
|
||||||
|
ORDER BY lb.tanggal_bantuan DESC"
|
||||||
|
);
|
||||||
|
$stmt->bind_param('i', $pmId);
|
||||||
|
$stmt->execute();
|
||||||
|
$rows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
||||||
|
sendSuccess($rows);
|
||||||
|
} elseif ($id > 0) {
|
||||||
|
$stmt = $db->prepare(
|
||||||
|
"SELECT lb.*,
|
||||||
|
ri.nama AS nama_rumah_ibadah,
|
||||||
|
pm.nama AS nama_penduduk
|
||||||
|
FROM log_bantuan lb
|
||||||
|
JOIN rumah_ibadah ri ON ri.id = lb.rumah_ibadah_id
|
||||||
|
JOIN penduduk_miskin pm ON pm.id = lb.penduduk_miskin_id
|
||||||
|
WHERE lb.id = ?"
|
||||||
|
);
|
||||||
|
$stmt->bind_param('i', $id);
|
||||||
|
$stmt->execute();
|
||||||
|
$row = $stmt->get_result()->fetch_assoc();
|
||||||
|
$row ? sendSuccess($row) : sendError('Data tidak ditemukan', 404);
|
||||||
|
} else {
|
||||||
|
// Semua log bantuan dengan statistik
|
||||||
|
$sql = "SELECT lb.*,
|
||||||
|
ri.nama AS nama_rumah_ibadah,
|
||||||
|
pm.nama AS nama_penduduk
|
||||||
|
FROM log_bantuan lb
|
||||||
|
JOIN rumah_ibadah ri ON ri.id = lb.rumah_ibadah_id
|
||||||
|
JOIN penduduk_miskin pm ON pm.id = lb.penduduk_miskin_id
|
||||||
|
ORDER BY lb.tanggal_bantuan DESC
|
||||||
|
LIMIT 200";
|
||||||
|
$rows = $db->query($sql)->fetch_all(MYSQLI_ASSOC);
|
||||||
|
sendSuccess($rows);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'POST':
|
||||||
|
$body = getBody();
|
||||||
|
foreach (['penduduk_miskin_id','rumah_ibadah_id','tipe_bantuan','tanggal_bantuan'] as $f) {
|
||||||
|
if (empty($body[$f])) sendError("Field '$f' wajib diisi");
|
||||||
|
}
|
||||||
|
$nilai = isset($body['nilai_bantuan']) && $body['nilai_bantuan'] !== ''
|
||||||
|
? (float)$body['nilai_bantuan'] : null;
|
||||||
|
|
||||||
|
$stmt = $db->prepare(
|
||||||
|
"INSERT INTO log_bantuan
|
||||||
|
(penduduk_miskin_id, rumah_ibadah_id, tipe_bantuan,
|
||||||
|
sub_kategori, keterangan, tanggal_bantuan, nilai_bantuan)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||||
|
);
|
||||||
|
$stmt->bind_param(
|
||||||
|
'iissssd',
|
||||||
|
$body['penduduk_miskin_id'],
|
||||||
|
$body['rumah_ibadah_id'],
|
||||||
|
$body['tipe_bantuan'],
|
||||||
|
$body['sub_kategori'],
|
||||||
|
$body['keterangan'],
|
||||||
|
$body['tanggal_bantuan'],
|
||||||
|
$nilai
|
||||||
|
);
|
||||||
|
executeOrFail($stmt, 'Gagal mencatat log bantuan');
|
||||||
|
sendSuccess(['id' => $db->insert_id], 'Log bantuan berhasil dicatat', 201);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'DELETE':
|
||||||
|
if ($id <= 0) sendError('ID wajib disertakan');
|
||||||
|
$stmt = $db->prepare("DELETE FROM log_bantuan WHERE id=?");
|
||||||
|
$stmt->bind_param('i', $id);
|
||||||
|
executeOrFail($stmt, 'Gagal menghapus log bantuan');
|
||||||
|
$stmt->affected_rows > 0
|
||||||
|
? sendSuccess(null, 'Log bantuan berhasil dihapus')
|
||||||
|
: sendError('Data tidak ditemukan', 404);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
sendError('Method tidak diizinkan', 405);
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
<?php
|
||||||
|
// api/penduduk_miskin.php
|
||||||
|
require_once __DIR__ . '/config/database.php';
|
||||||
|
require_once __DIR__ . '/helpers/response.php';
|
||||||
|
setCORSHeaders();
|
||||||
|
|
||||||
|
$db = getDB();
|
||||||
|
$method = getMethod();
|
||||||
|
requireWriteAuth($method);
|
||||||
|
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||||
|
|
||||||
|
// Endpoint khusus: recalc proximity semua penduduk miskin sekaligus
|
||||||
|
if ($method === 'POST' && isset($_GET['action']) && $_GET['action'] === 'recalc_all') {
|
||||||
|
requireWriteAuth('POST');
|
||||||
|
$allPm = $db->query("SELECT id, latitude, longitude FROM penduduk_miskin");
|
||||||
|
$updated = 0;
|
||||||
|
while ($pm = $allPm->fetch_assoc()) {
|
||||||
|
$prox = hitungProximity($db, (float)$pm['latitude'], (float)$pm['longitude']);
|
||||||
|
$statusP = $prox['status'];
|
||||||
|
$riId = $prox['rumah_ibadah_id'];
|
||||||
|
$stmt = $db->prepare(
|
||||||
|
"UPDATE penduduk_miskin SET status_proximity=?, rumah_ibadah_id=? WHERE id=?"
|
||||||
|
);
|
||||||
|
$stmt->bind_param('sii', $statusP, $riId, $pm['id']);
|
||||||
|
$stmt->execute();
|
||||||
|
$updated++;
|
||||||
|
}
|
||||||
|
sendSuccess(['updated' => $updated], "Proximity $updated data diperbarui");
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ($method) {
|
||||||
|
|
||||||
|
// ── GET ───────────────────────────────────────────────────────────
|
||||||
|
case 'GET':
|
||||||
|
if ($id > 0) {
|
||||||
|
$stmt = $db->prepare(
|
||||||
|
"SELECT pm.*,
|
||||||
|
ri.nama AS nama_rumah_ibadah,
|
||||||
|
ri.jenis AS jenis_rumah_ibadah
|
||||||
|
FROM penduduk_miskin pm
|
||||||
|
LEFT JOIN rumah_ibadah ri ON ri.id = pm.rumah_ibadah_id
|
||||||
|
WHERE pm.id = ?"
|
||||||
|
);
|
||||||
|
$stmt->bind_param('i', $id);
|
||||||
|
$stmt->execute();
|
||||||
|
$row = $stmt->get_result()->fetch_assoc();
|
||||||
|
if (!$row) sendError('Data tidak ditemukan', 404);
|
||||||
|
$row['latitude'] = (float)$row['latitude'];
|
||||||
|
$row['longitude'] = (float)$row['longitude'];
|
||||||
|
|
||||||
|
// Ambil ringkasan log bantuan
|
||||||
|
$stmtLog = $db->prepare(
|
||||||
|
"SELECT tipe_bantuan, COUNT(*) AS jumlah
|
||||||
|
FROM log_bantuan
|
||||||
|
WHERE penduduk_miskin_id = ?
|
||||||
|
GROUP BY tipe_bantuan"
|
||||||
|
);
|
||||||
|
$stmtLog->bind_param('i', $id);
|
||||||
|
$stmtLog->execute();
|
||||||
|
$logs = [];
|
||||||
|
$resLog = $stmtLog->get_result();
|
||||||
|
while ($l = $resLog->fetch_assoc()) $logs[] = $l;
|
||||||
|
$row['ringkasan_bantuan'] = $logs;
|
||||||
|
|
||||||
|
sendSuccess($row);
|
||||||
|
} else {
|
||||||
|
// GET semua + proximity recalculation cache
|
||||||
|
$sql = "SELECT pm.id, pm.nama, pm.no_wa, pm.nik, pm.alamat,
|
||||||
|
pm.jumlah_anggota, pm.latitude, pm.longitude,
|
||||||
|
pm.status_proximity, pm.rumah_ibadah_id,
|
||||||
|
ri.nama AS nama_rumah_ibadah,
|
||||||
|
-- hitung berapa kali dapat bantuan
|
||||||
|
(SELECT COUNT(*) FROM log_bantuan lb
|
||||||
|
WHERE lb.penduduk_miskin_id = pm.id) AS total_bantuan,
|
||||||
|
(SELECT COUNT(*) FROM log_bantuan lb
|
||||||
|
WHERE lb.penduduk_miskin_id = pm.id
|
||||||
|
AND lb.tipe_bantuan = 'Pemberdayaan') AS bantuan_pemberdayaan,
|
||||||
|
(SELECT COUNT(*) FROM log_bantuan lb
|
||||||
|
WHERE lb.penduduk_miskin_id = pm.id
|
||||||
|
AND lb.tipe_bantuan = 'Konsumtif') AS bantuan_konsumtif
|
||||||
|
FROM penduduk_miskin pm
|
||||||
|
LEFT JOIN rumah_ibadah ri ON ri.id = pm.rumah_ibadah_id
|
||||||
|
ORDER BY pm.created_at DESC";
|
||||||
|
$result = $db->query($sql);
|
||||||
|
$rows = [];
|
||||||
|
while ($r = $result->fetch_assoc()) {
|
||||||
|
$r['latitude'] = (float)$r['latitude'];
|
||||||
|
$r['longitude'] = (float)$r['longitude'];
|
||||||
|
$r['total_bantuan'] = (int)$r['total_bantuan'];
|
||||||
|
$r['bantuan_pemberdayaan'] = (int)$r['bantuan_pemberdayaan'];
|
||||||
|
$r['bantuan_konsumtif'] = (int)$r['bantuan_konsumtif'];
|
||||||
|
$rows[] = $r;
|
||||||
|
}
|
||||||
|
sendSuccess($rows);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ── POST ──────────────────────────────────────────────────────────
|
||||||
|
case 'POST':
|
||||||
|
$body = getBody();
|
||||||
|
foreach (['nama', 'latitude', 'longitude'] as $f) {
|
||||||
|
if (empty($body[$f]) && $body[$f] !== 0) sendError("Field '$f' wajib diisi");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hitung proximity ke semua rumah ibadah
|
||||||
|
$proximity = hitungProximity($db, $body['latitude'], $body['longitude']);
|
||||||
|
|
||||||
|
$stmt = $db->prepare(
|
||||||
|
"INSERT INTO penduduk_miskin
|
||||||
|
(nama, no_wa, nik, alamat, jumlah_anggota,
|
||||||
|
latitude, longitude, status_proximity, rumah_ibadah_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||||
|
);
|
||||||
|
$jumlah = isset($body['jumlah_anggota']) ? (int)$body['jumlah_anggota'] : 1;
|
||||||
|
$statusP = $proximity['status'];
|
||||||
|
$riId = $proximity['rumah_ibadah_id'];
|
||||||
|
|
||||||
|
$stmt->bind_param(
|
||||||
|
'ssssiddsi',
|
||||||
|
$body['nama'], $body['no_wa'], $body['nik'],
|
||||||
|
$body['alamat'], $jumlah,
|
||||||
|
$body['latitude'], $body['longitude'],
|
||||||
|
$statusP, $riId
|
||||||
|
);
|
||||||
|
executeOrFail($stmt, 'Gagal menyimpan data penduduk miskin');
|
||||||
|
$newId = $db->insert_id;
|
||||||
|
sendSuccess(
|
||||||
|
['id' => $newId, 'proximity' => $proximity],
|
||||||
|
'Penduduk Miskin berhasil disimpan',
|
||||||
|
201
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ── PUT ───────────────────────────────────────────────────────────
|
||||||
|
case 'PUT':
|
||||||
|
if ($id <= 0) sendError('ID wajib disertakan');
|
||||||
|
$body = getBody();
|
||||||
|
$proximity = hitungProximity($db, $body['latitude'], $body['longitude']);
|
||||||
|
$statusP = $proximity['status'];
|
||||||
|
$riId = $proximity['rumah_ibadah_id'];
|
||||||
|
$jumlah = isset($body['jumlah_anggota']) ? (int)$body['jumlah_anggota'] : 1;
|
||||||
|
|
||||||
|
$stmt = $db->prepare(
|
||||||
|
"UPDATE penduduk_miskin
|
||||||
|
SET nama=?, no_wa=?, nik=?, alamat=?, jumlah_anggota=?,
|
||||||
|
latitude=?, longitude=?, status_proximity=?, rumah_ibadah_id=?
|
||||||
|
WHERE id=?"
|
||||||
|
);
|
||||||
|
$stmt->bind_param(
|
||||||
|
'ssssiddsii',
|
||||||
|
$body['nama'], $body['no_wa'], $body['nik'],
|
||||||
|
$body['alamat'], $jumlah,
|
||||||
|
$body['latitude'], $body['longitude'],
|
||||||
|
$statusP, $riId, $id
|
||||||
|
);
|
||||||
|
executeOrFail($stmt, 'Gagal memperbarui data penduduk miskin');
|
||||||
|
sendSuccess(['proximity' => $proximity], 'Data berhasil diperbarui');
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ── DELETE ────────────────────────────────────────────────────────
|
||||||
|
case 'DELETE':
|
||||||
|
if ($id <= 0) sendError('ID wajib disertakan');
|
||||||
|
$stmt = $db->prepare("DELETE FROM penduduk_miskin WHERE id=?");
|
||||||
|
$stmt->bind_param('i', $id);
|
||||||
|
executeOrFail($stmt, 'Gagal menghapus data penduduk miskin');
|
||||||
|
$stmt->affected_rows > 0
|
||||||
|
? sendSuccess(null, 'Data berhasil dihapus')
|
||||||
|
: sendError('Data tidak ditemukan', 404);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
sendError('Method tidak diizinkan', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// HELPER: Hitung proximity penduduk miskin terhadap semua rumah ibadah
|
||||||
|
// Menggunakan Haversine formula di PHP untuk validasi server-side
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
function hitungProximity(mysqli $db, float $lat, float $lng): array {
|
||||||
|
$result = $db->query(
|
||||||
|
"SELECT id, nama, latitude, longitude, radius_meter FROM rumah_ibadah"
|
||||||
|
);
|
||||||
|
$status = 'luar_radius';
|
||||||
|
$riId = null;
|
||||||
|
$riNama = null;
|
||||||
|
$minDistAll = PHP_INT_MAX; // jarak ke RI terdekat (apapun statusnya)
|
||||||
|
$minDistIn = PHP_INT_MAX; // jarak ke RI terdekat yang dalam radius
|
||||||
|
|
||||||
|
while ($ri = $result->fetch_assoc()) {
|
||||||
|
$dist = haversine($lat, $lng, (float)$ri['latitude'], (float)$ri['longitude']);
|
||||||
|
|
||||||
|
// Lacak jarak terdekat secara keseluruhan (untuk field jarak_meter)
|
||||||
|
if ($dist < $minDistAll) {
|
||||||
|
$minDistAll = $dist;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cek apakah titik ini masuk dalam radius RI ini
|
||||||
|
if ($dist <= (int)$ri['radius_meter'] && $dist < $minDistIn) {
|
||||||
|
$minDistIn = $dist;
|
||||||
|
$status = 'dalam_radius';
|
||||||
|
$riId = (int)$ri['id'];
|
||||||
|
$riNama = $ri['nama'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => $status,
|
||||||
|
'rumah_ibadah_id' => $riId,
|
||||||
|
'nama_rumah_ibadah' => $riNama,
|
||||||
|
'jarak_meter' => round($minDistAll, 2)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function haversine(float $lat1, float $lng1, float $lat2, float $lng2): float {
|
||||||
|
$R = 6371000; // radius bumi dalam meter
|
||||||
|
$dLat = deg2rad($lat2 - $lat1);
|
||||||
|
$dLng = deg2rad($lng2 - $lng1);
|
||||||
|
$a = sin($dLat/2)**2
|
||||||
|
+ cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLng/2)**2;
|
||||||
|
return $R * 2 * atan2(sqrt($a), sqrt(1-$a));
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
// api/rumah_ibadah.php
|
||||||
|
require_once __DIR__ . '/config/database.php';
|
||||||
|
require_once __DIR__ . '/helpers/response.php';
|
||||||
|
setCORSHeaders();
|
||||||
|
|
||||||
|
$db = getDB();
|
||||||
|
$method = getMethod();
|
||||||
|
requireWriteAuth($method);
|
||||||
|
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||||
|
|
||||||
|
switch ($method) {
|
||||||
|
|
||||||
|
// ── GET: ambil semua atau satu data ──────────────────────────────
|
||||||
|
case 'GET':
|
||||||
|
if ($id > 0) {
|
||||||
|
$stmt = $db->prepare(
|
||||||
|
"SELECT id, nama, jenis, alamat, no_wa,
|
||||||
|
latitude, longitude, radius_meter,
|
||||||
|
created_at
|
||||||
|
FROM rumah_ibadah WHERE id = ?"
|
||||||
|
);
|
||||||
|
$stmt->bind_param('i', $id);
|
||||||
|
$stmt->execute();
|
||||||
|
$row = $stmt->get_result()->fetch_assoc();
|
||||||
|
if ($row) {
|
||||||
|
$row['latitude'] = (float)$row['latitude'];
|
||||||
|
$row['longitude'] = (float)$row['longitude'];
|
||||||
|
$row['radius_meter'] = (int)$row['radius_meter'];
|
||||||
|
}
|
||||||
|
$row ? sendSuccess($row) : sendError('Data tidak ditemukan', 404);
|
||||||
|
} else {
|
||||||
|
$sql = "SELECT id, nama, jenis, alamat, no_wa,
|
||||||
|
latitude, longitude, radius_meter,
|
||||||
|
created_at
|
||||||
|
FROM rumah_ibadah ORDER BY created_at DESC";
|
||||||
|
$result = $db->query($sql);
|
||||||
|
$rows = [];
|
||||||
|
while ($r = $result->fetch_assoc()) {
|
||||||
|
$r['latitude'] = (float)$r['latitude'];
|
||||||
|
$r['longitude'] = (float)$r['longitude'];
|
||||||
|
$r['radius_meter'] = (int)$r['radius_meter'];
|
||||||
|
$rows[] = $r;
|
||||||
|
}
|
||||||
|
sendSuccess($rows);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ── POST: tambah data baru ────────────────────────────────────────
|
||||||
|
case 'POST':
|
||||||
|
$body = getBody();
|
||||||
|
$required = ['nama', 'jenis', 'latitude', 'longitude', 'radius_meter'];
|
||||||
|
foreach ($required as $field) {
|
||||||
|
if (empty($body[$field]) && $body[$field] !== 0) {
|
||||||
|
sendError("Field '$field' wajib diisi");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$stmt = $db->prepare(
|
||||||
|
"INSERT INTO rumah_ibadah
|
||||||
|
(nama, jenis, alamat, no_wa, latitude, longitude, radius_meter)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||||
|
);
|
||||||
|
$stmt->bind_param(
|
||||||
|
'ssssddi',
|
||||||
|
$body['nama'],
|
||||||
|
$body['jenis'],
|
||||||
|
$body['alamat'],
|
||||||
|
$body['no_wa'],
|
||||||
|
$body['latitude'],
|
||||||
|
$body['longitude'],
|
||||||
|
$body['radius_meter']
|
||||||
|
);
|
||||||
|
executeOrFail($stmt, 'Gagal menyimpan rumah ibadah');
|
||||||
|
$newId = $db->insert_id;
|
||||||
|
sendSuccess(['id' => $newId], 'Rumah Ibadah berhasil disimpan', 201);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ── PUT: update data ──────────────────────────────────────────────
|
||||||
|
case 'PUT':
|
||||||
|
if ($id <= 0) sendError('ID wajib disertakan');
|
||||||
|
$body = getBody();
|
||||||
|
$stmt = $db->prepare(
|
||||||
|
"UPDATE rumah_ibadah
|
||||||
|
SET nama=?, jenis=?, alamat=?, no_wa=?,
|
||||||
|
latitude=?, longitude=?, radius_meter=?
|
||||||
|
WHERE id=?"
|
||||||
|
);
|
||||||
|
$stmt->bind_param(
|
||||||
|
'ssssddii',
|
||||||
|
$body['nama'], $body['jenis'],
|
||||||
|
$body['alamat'], $body['no_wa'],
|
||||||
|
$body['latitude'], $body['longitude'],
|
||||||
|
$body['radius_meter'], $id
|
||||||
|
);
|
||||||
|
executeOrFail($stmt, 'Gagal memperbarui rumah ibadah');
|
||||||
|
sendSuccess(null, 'Rumah Ibadah berhasil diperbarui');
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ── DELETE: hapus data ────────────────────────────────────────────
|
||||||
|
case 'DELETE':
|
||||||
|
if ($id <= 0) sendError('ID wajib disertakan');
|
||||||
|
$stmt = $db->prepare("DELETE FROM rumah_ibadah WHERE id=?");
|
||||||
|
$stmt->bind_param('i', $id);
|
||||||
|
executeOrFail($stmt, 'Gagal menghapus rumah ibadah');
|
||||||
|
$stmt->affected_rows > 0
|
||||||
|
? sendSuccess(null, 'Rumah Ibadah berhasil dihapus')
|
||||||
|
: sendError('Data tidak ditemukan', 404);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
sendError('Method tidak diizinkan', 405);
|
||||||
|
}
|
||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
// api/spbu.php
|
||||||
|
require_once __DIR__ . '/config/database.php';
|
||||||
|
require_once __DIR__ . '/helpers/response.php';
|
||||||
|
setCORSHeaders();
|
||||||
|
|
||||||
|
$db = getDB();
|
||||||
|
$method = getMethod();
|
||||||
|
requireWriteAuth($method);
|
||||||
|
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||||
|
|
||||||
|
switch ($method) {
|
||||||
|
|
||||||
|
case 'GET':
|
||||||
|
if ($id > 0) {
|
||||||
|
$stmt = $db->prepare(
|
||||||
|
"SELECT id, nama, no_wa, alamat, buka_24jam,
|
||||||
|
latitude, longitude, created_at
|
||||||
|
FROM spbu WHERE id = ?"
|
||||||
|
);
|
||||||
|
$stmt->bind_param('i', $id);
|
||||||
|
$stmt->execute();
|
||||||
|
$row = $stmt->get_result()->fetch_assoc();
|
||||||
|
if (!$row) sendError('Data tidak ditemukan', 404);
|
||||||
|
$row['latitude'] = (float)$row['latitude'];
|
||||||
|
$row['longitude'] = (float)$row['longitude'];
|
||||||
|
$row['buka_24jam']= (bool)$row['buka_24jam'];
|
||||||
|
sendSuccess($row);
|
||||||
|
} else {
|
||||||
|
$rows = [];
|
||||||
|
if (isset($_GET['buka_24jam'])) {
|
||||||
|
$val = (int)$_GET['buka_24jam'];
|
||||||
|
$stmt = $db->prepare(
|
||||||
|
"SELECT id, nama, no_wa, alamat, buka_24jam,
|
||||||
|
latitude, longitude, created_at
|
||||||
|
FROM spbu WHERE buka_24jam = ? ORDER BY nama"
|
||||||
|
);
|
||||||
|
$stmt->bind_param('i', $val);
|
||||||
|
$stmt->execute();
|
||||||
|
$res = $stmt->get_result();
|
||||||
|
} else {
|
||||||
|
$res = $db->query(
|
||||||
|
"SELECT id, nama, no_wa, alamat, buka_24jam,
|
||||||
|
latitude, longitude, created_at
|
||||||
|
FROM spbu ORDER BY nama"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
while ($r = $res->fetch_assoc()) {
|
||||||
|
$r['latitude'] = (float)$r['latitude'];
|
||||||
|
$r['longitude'] = (float)$r['longitude'];
|
||||||
|
$r['buka_24jam']= (bool)$r['buka_24jam'];
|
||||||
|
$rows[] = $r;
|
||||||
|
}
|
||||||
|
sendSuccess($rows);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'POST':
|
||||||
|
$body = getBody();
|
||||||
|
foreach (['nama','latitude','longitude'] as $f) {
|
||||||
|
if (empty($body[$f]) && $body[$f] !== 0) sendError("Field '$f' wajib diisi");
|
||||||
|
}
|
||||||
|
$buka = isset($body['buka_24jam']) ? (int)(bool)$body['buka_24jam'] : 0;
|
||||||
|
$stmt = $db->prepare(
|
||||||
|
"INSERT INTO spbu (nama, no_wa, alamat, buka_24jam, latitude, longitude)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)"
|
||||||
|
);
|
||||||
|
$stmt->bind_param('sssidd',
|
||||||
|
$body['nama'], $body['no_wa'], $body['alamat'],
|
||||||
|
$buka, $body['latitude'], $body['longitude']
|
||||||
|
);
|
||||||
|
executeOrFail($stmt, 'Gagal menyimpan data SPBU');
|
||||||
|
sendSuccess(['id' => $db->insert_id], 'SPBU berhasil disimpan', 201);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'PUT':
|
||||||
|
if ($id <= 0) sendError('ID wajib disertakan');
|
||||||
|
$body = getBody();
|
||||||
|
$buka = isset($body['buka_24jam']) ? (int)(bool)$body['buka_24jam'] : 0;
|
||||||
|
$stmt = $db->prepare(
|
||||||
|
"UPDATE spbu
|
||||||
|
SET nama=?, no_wa=?, alamat=?, buka_24jam=?, latitude=?, longitude=?
|
||||||
|
WHERE id=?"
|
||||||
|
);
|
||||||
|
$stmt->bind_param('sssiddi',
|
||||||
|
$body['nama'], $body['no_wa'], $body['alamat'],
|
||||||
|
$buka, $body['latitude'], $body['longitude'], $id
|
||||||
|
);
|
||||||
|
executeOrFail($stmt, 'Gagal memperbarui data SPBU');
|
||||||
|
sendSuccess(null, 'SPBU berhasil diperbarui');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'DELETE':
|
||||||
|
if ($id <= 0) sendError('ID wajib disertakan');
|
||||||
|
$stmt = $db->prepare("DELETE FROM spbu WHERE id=?");
|
||||||
|
$stmt->bind_param('i', $id);
|
||||||
|
executeOrFail($stmt, 'Gagal menghapus data SPBU');
|
||||||
|
$stmt->affected_rows > 0
|
||||||
|
? sendSuccess(null, 'SPBU berhasil dihapus')
|
||||||
|
: sendError('Data tidak ditemukan', 404);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
sendError('Method tidak diizinkan', 405);
|
||||||
|
}
|
||||||
+895
@@ -0,0 +1,895 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #eef3f8;
|
||||||
|
--panel: rgba(255, 255, 255, .92);
|
||||||
|
--panel-strong: #ffffff;
|
||||||
|
--line: #d7e0ea;
|
||||||
|
--line-strong: #bcc9d7;
|
||||||
|
--text: #0f172a;
|
||||||
|
--muted: #5b6b82;
|
||||||
|
--muted-2: #7d8ca2;
|
||||||
|
--accent: #165dff;
|
||||||
|
--accent-2: #0ea5e9;
|
||||||
|
--accent-soft: #eaf1ff;
|
||||||
|
--shadow: 0 12px 36px rgba(15, 23, 42, .12);
|
||||||
|
--shadow-soft: 0 8px 18px rgba(15, 23, 42, .08);
|
||||||
|
--radius: 18px;
|
||||||
|
--radius-sm: 12px;
|
||||||
|
--font: 'Inter', sans-serif;
|
||||||
|
--font-head: 'Space Grotesk', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font);
|
||||||
|
color: var(--text);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(22, 93, 255, .12), transparent 32%),
|
||||||
|
radial-gradient(circle at right center, rgba(14, 165, 233, .10), transparent 28%),
|
||||||
|
linear-gradient(180deg, #f6f9fc 0%, var(--bg) 100%);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 64px 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
#header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 0 18px;
|
||||||
|
background: rgba(255, 255, 255, .8);
|
||||||
|
border-bottom: 1px solid rgba(215, 224, 234, .9);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
box-shadow: 0 1px 0 rgba(255, 255, 255, .7) inset;
|
||||||
|
position: relative;
|
||||||
|
z-index: 4000;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-title {
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -.04em;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search {
|
||||||
|
position: relative;
|
||||||
|
width: min(500px, 100%);
|
||||||
|
flex: 1 1 360px;
|
||||||
|
max-width: 500px;
|
||||||
|
z-index: 4001;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(255, 255, 255, .96);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--muted-2);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-input {
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-input::placeholder {
|
||||||
|
color: var(--muted-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 5000;
|
||||||
|
display: none;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 255, 255, .98);
|
||||||
|
box-shadow: 0 18px 40px rgba(15, 23, 42, .16);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results.show {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 2px 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results-hint {
|
||||||
|
padding: 0 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results-title {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results-count {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: #f8fbff;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
transition: all .14s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-item:hover,
|
||||||
|
.search-result-item.active {
|
||||||
|
border-color: #cfe0ff;
|
||||||
|
background: #eef5ff;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-icon {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-name {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-sub {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-type {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border: 1px solid #cfe0ff;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-empty {
|
||||||
|
padding: 14px 12px;
|
||||||
|
border: 1px dashed #cbd6e3;
|
||||||
|
border-radius: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
background: #fbfcfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-empty strong {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all .16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab:hover {
|
||||||
|
background: #f3f7fb;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab.active {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: #cfe0ff;
|
||||||
|
box-shadow: 0 0 0 1px rgba(22, 93, 255, .04) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coord-display,
|
||||||
|
.status-pill {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(255, 255, 255, .86);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coord-display {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill strong {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 362px;
|
||||||
|
min-height: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#map-wrap {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#map {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#instruction-banner {
|
||||||
|
position: absolute;
|
||||||
|
left: 18px;
|
||||||
|
top: 18px;
|
||||||
|
z-index: 500;
|
||||||
|
background: rgba(15, 23, 42, .92);
|
||||||
|
color: #fff;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: all .18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#instruction-banner.show {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel {
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--panel);
|
||||||
|
border-left: 1px solid rgba(215, 224, 234, .95);
|
||||||
|
box-shadow: -1px 0 0 rgba(255, 255, 255, .75) inset;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-head {
|
||||||
|
padding: 16px 16px 12px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, .95), rgba(255, 255, 255, .82));
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-head h2 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
letter-spacing: -.04em;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-body {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 14px;
|
||||||
|
background: linear-gradient(180deg, rgba(247, 250, 253, .8), rgba(242, 246, 250, .88));
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-body::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-body::-webkit-scrollbar-thumb {
|
||||||
|
background: #c7d3e0;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted-2);
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-item,
|
||||||
|
.data-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--panel-strong);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-item {
|
||||||
|
padding: 9px 11px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
transition: all .16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-item:hover,
|
||||||
|
.data-item:hover {
|
||||||
|
border-color: #bcd0ff;
|
||||||
|
box-shadow: 0 10px 20px rgba(22, 93, 255, .08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-item.checked {
|
||||||
|
background: linear-gradient(180deg, #f8fbff, #eef5ff);
|
||||||
|
border-color: #cfe0ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-item.checked .layer-check {
|
||||||
|
color: #fff;
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-check {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1.5px solid #b8c5d4;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 11px;
|
||||||
|
color: transparent;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-dot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-count {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--muted);
|
||||||
|
background: #f4f7fb;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
border: 1.5px dashed #aebfda;
|
||||||
|
background: rgba(255, 255, 255, .92);
|
||||||
|
color: var(--muted);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 11px 12px;
|
||||||
|
margin-top: 6px;
|
||||||
|
transition: all .16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add:hover,
|
||||||
|
.btn-add.active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-item {
|
||||||
|
padding: 10px 11px;
|
||||||
|
transition: all .16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f3f7fb;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-name {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-sub {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-badge {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-green {
|
||||||
|
background: #e6f8ec;
|
||||||
|
color: #15803d;
|
||||||
|
border-color: #b9ebc9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-red {
|
||||||
|
background: #feecec;
|
||||||
|
color: #dc2626;
|
||||||
|
border-color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-blue {
|
||||||
|
background: #eaf1ff;
|
||||||
|
color: #165dff;
|
||||||
|
border-color: #cfe0ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-amber {
|
||||||
|
background: #fff4df;
|
||||||
|
color: #d97706;
|
||||||
|
border-color: #f9deb0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-purple {
|
||||||
|
background: #f1eaff;
|
||||||
|
color: #7c3aed;
|
||||||
|
border-color: #ddd2ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px dashed #cbd6e3;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: rgba(255, 255, 255, .7);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 2px solid rgba(22, 93, 255, .18);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin .8s linear infinite;
|
||||||
|
margin-right: 8px;
|
||||||
|
vertical-align: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(15, 23, 42, .42);
|
||||||
|
z-index: 2000;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#modal-overlay.open {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
#modal {
|
||||||
|
width: min(720px, 100%);
|
||||||
|
max-height: min(90vh, 860px);
|
||||||
|
background: var(--panel-strong);
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: 0 24px 70px rgba(15, 23, 42, .28);
|
||||||
|
overflow: hidden;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-head {
|
||||||
|
padding: 18px 20px 14px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: linear-gradient(180deg, #fff, #fbfdff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 18px;
|
||||||
|
letter-spacing: -.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#modal-body {
|
||||||
|
padding: 18px 20px;
|
||||||
|
overflow: auto;
|
||||||
|
background: #fbfcfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
#modal-footer {
|
||||||
|
padding: 14px 20px 18px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input,
|
||||||
|
.form-select {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 11px 12px;
|
||||||
|
outline: none;
|
||||||
|
transition: all .14s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus,
|
||||||
|
.form-select:focus {
|
||||||
|
border-color: #8eb3ff;
|
||||||
|
box-shadow: 0 0 0 4px rgba(22, 93, 255, .10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-coord,
|
||||||
|
.form-calc {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 11px 12px;
|
||||||
|
background: #eef5ff;
|
||||||
|
color: #1140b8;
|
||||||
|
border: 1px solid #d7e5ff;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-drag-hint,
|
||||||
|
.proximity-info {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-drag-hint {
|
||||||
|
background: #f3f7fb;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.proximity-info.dalam {
|
||||||
|
background: #e6f8ec;
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proximity-info.luar {
|
||||||
|
background: #feecec;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: all .16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 12px 22px rgba(22, 93, 255, .18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #0d4ae0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: #fff;
|
||||||
|
border-color: var(--line);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #ef4444;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#toast-container {
|
||||||
|
position: fixed;
|
||||||
|
right: 18px;
|
||||||
|
bottom: 18px;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
z-index: 3000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
min-width: 240px;
|
||||||
|
max-width: 360px;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
animation: toast-in .2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.info {
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.success {
|
||||||
|
background: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.error {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes toast-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
#main {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel {
|
||||||
|
grid-template-rows: auto 360px;
|
||||||
|
border-left: none;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
#header {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
height: auto;
|
||||||
|
padding: 12px 14px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search {
|
||||||
|
order: 3;
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
margin-left: 0;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs {
|
||||||
|
width: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel {
|
||||||
|
grid-template-rows: auto minmax(320px, 48vh);
|
||||||
|
}
|
||||||
|
|
||||||
|
#modal {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
CREATE DATABASE IF NOT EXISTS webgis_poverty;
|
||||||
|
USE webgis_poverty;
|
||||||
|
|
||||||
|
CREATE TABLE `rumah_ibadah` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`nama` varchar(255) NOT NULL,
|
||||||
|
`jenis` varchar(100) NOT NULL,
|
||||||
|
`alamat` text,
|
||||||
|
`no_wa` varchar(20),
|
||||||
|
`latitude` double NOT NULL,
|
||||||
|
`longitude` double NOT NULL,
|
||||||
|
`radius_meter` int(11) NOT NULL DEFAULT 0,
|
||||||
|
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE `penduduk_miskin` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`nama` varchar(255) NOT NULL,
|
||||||
|
`no_wa` varchar(20),
|
||||||
|
`nik` varchar(20),
|
||||||
|
`alamat` text,
|
||||||
|
`jumlah_anggota` int(11) NOT NULL DEFAULT 1,
|
||||||
|
`latitude` double NOT NULL,
|
||||||
|
`longitude` double NOT NULL,
|
||||||
|
`status_proximity` varchar(50),
|
||||||
|
`rumah_ibadah_id` int(11),
|
||||||
|
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
FOREIGN KEY (`rumah_ibadah_id`) REFERENCES `rumah_ibadah` (`id`) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE `log_bantuan` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`penduduk_miskin_id` int(11) NOT NULL,
|
||||||
|
`rumah_ibadah_id` int(11) NOT NULL,
|
||||||
|
`tipe_bantuan` varchar(100) NOT NULL,
|
||||||
|
`sub_kategori` varchar(100),
|
||||||
|
`keterangan` text,
|
||||||
|
`tanggal_bantuan` date NOT NULL,
|
||||||
|
`nilai_bantuan` double,
|
||||||
|
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
FOREIGN KEY (`penduduk_miskin_id`) REFERENCES `penduduk_miskin` (`id`) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (`rumah_ibadah_id`) REFERENCES `rumah_ibadah` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE `data_jalan` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`nama_jalan` varchar(255) NOT NULL,
|
||||||
|
`status_jalan` varchar(100) NOT NULL,
|
||||||
|
`panjang_meter` double NOT NULL,
|
||||||
|
`keterangan` text,
|
||||||
|
`geojson` longtext NOT NULL,
|
||||||
|
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE `data_parsil` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`nama_pemilik` varchar(255) NOT NULL,
|
||||||
|
`no_sertifikat` varchar(100),
|
||||||
|
`jenis_hak` varchar(100) NOT NULL,
|
||||||
|
`luas_m2` double NOT NULL,
|
||||||
|
`keterangan` text,
|
||||||
|
`geojson` longtext NOT NULL,
|
||||||
|
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE `spbu` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`nama` varchar(255) NOT NULL,
|
||||||
|
`no_wa` varchar(20),
|
||||||
|
`alamat` text,
|
||||||
|
`buka_24jam` tinyint(1) NOT NULL DEFAULT 0,
|
||||||
|
`latitude` double NOT NULL,
|
||||||
|
`longitude` double NOT NULL,
|
||||||
|
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>WebGIS - Poverty Mapping & Spatial Management</title>
|
||||||
|
|
||||||
|
<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=Inter:wght@400;500;600;700;800&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css" />
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css" />
|
||||||
|
<link rel="stylesheet" href="css/app.css" />
|
||||||
|
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/Turf.js/6.5.0/turf.min.js"></script>
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<header id="header">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="brand-title">WebGIS Poverty</div>
|
||||||
|
<div class="brand-sub">Sistem Informasi Geografis Berbasis Web</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-tabs" role="tablist" aria-label="Fitur utama">
|
||||||
|
<button class="nav-tab active" onclick="switchTab('kemiskinan')">Kemiskinan</button>
|
||||||
|
<button class="nav-tab" onclick="switchTab('spbu')">SPBU</button>
|
||||||
|
<button class="nav-tab" onclick="switchTab('jalan')">Jalan</button>
|
||||||
|
<button class="nav-tab" onclick="switchTab('parsil')">Parsil</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header-search" id="header-search">
|
||||||
|
<div class="search-box">
|
||||||
|
<span class="search-icon">⌕</span>
|
||||||
|
<input id="search-input" type="search" autocomplete="off" placeholder="Telusuri Webgis Poverty" aria-label="Cari objek peta" />
|
||||||
|
</div>
|
||||||
|
<div class="search-results" id="search-results" aria-label="Hasil pencarian"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header-right">
|
||||||
|
<div class="coord-display" id="coord-display">Lat: - | Lng: -</div>
|
||||||
|
<div class="status-pill"><span id="status-zoom">Zoom: 13</span></div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="main">
|
||||||
|
<section id="map-wrap">
|
||||||
|
<div id="instruction-banner"></div>
|
||||||
|
<div id="map"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside id="panel">
|
||||||
|
<div id="panel-head">
|
||||||
|
<h2 id="panel-title">Rumah Ibadah & Kemiskinan</h2>
|
||||||
|
<!-- <div class="panel-meta">
|
||||||
|
<span>Drag marker untuk update lokasi</span>
|
||||||
|
<span>Draw polyline untuk jalan</span>
|
||||||
|
<span>Draw polygon untuk parsil</span>
|
||||||
|
</div> -->
|
||||||
|
</div>
|
||||||
|
<div id="panel-body">
|
||||||
|
<div class="loading"><span class="spinner"></span>Memuat data...</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="modal-overlay" aria-hidden="true">
|
||||||
|
<div id="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||||||
|
<div class="modal-head"><h3 id="modal-title">Modal</h3></div>
|
||||||
|
<div id="modal-body"></div>
|
||||||
|
<div id="modal-footer"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="toast-container"></div>
|
||||||
|
|
||||||
|
<script src="js/app-core.js"></script>
|
||||||
|
<script src="js/feature-kemiskinan.js"></script>
|
||||||
|
<script src="js/feature-spbu.js"></script>
|
||||||
|
<script src="js/feature-jalan.js"></script>
|
||||||
|
<script src="js/feature-parsil.js"></script>
|
||||||
|
<script>
|
||||||
|
renderPanel('kemiskinan');
|
||||||
|
preloadFeatureLayers();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+462
@@ -0,0 +1,462 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const API_BASE = 'api';
|
||||||
|
const CENTER = [-0.0263, 109.3425];
|
||||||
|
|
||||||
|
const map = L.map('map', { zoomControl: false }).setView(CENTER, 13);
|
||||||
|
L.control.zoom({ position: 'topleft' }).addTo(map);
|
||||||
|
|
||||||
|
const baseLayers = {
|
||||||
|
'OSM Standard': L.tileLayer(
|
||||||
|
'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
{ attribution: '© OpenStreetMap', maxZoom: 19 }
|
||||||
|
),
|
||||||
|
'CartoDB Light': L.tileLayer(
|
||||||
|
'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
|
||||||
|
{ attribution: '© CartoDB', maxZoom: 19 }
|
||||||
|
),
|
||||||
|
'Satellite (Esri)': L.tileLayer(
|
||||||
|
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||||
|
{ attribution: '© Esri', maxZoom: 19 }
|
||||||
|
)
|
||||||
|
};
|
||||||
|
baseLayers['OSM Standard'].addTo(map);
|
||||||
|
L.control.layers(baseLayers, {}, { position: 'bottomright', collapsed: true }).addTo(map);
|
||||||
|
|
||||||
|
const layers = {
|
||||||
|
rumahIbadah: L.layerGroup().addTo(map),
|
||||||
|
ibadahRadius: L.layerGroup().addTo(map),
|
||||||
|
pendudukMiskin: L.layerGroup().addTo(map),
|
||||||
|
spbu24: L.layerGroup().addTo(map),
|
||||||
|
spbuNon24: L.layerGroup().addTo(map),
|
||||||
|
jalanNasional: L.layerGroup().addTo(map),
|
||||||
|
jalanProvinsi: L.layerGroup().addTo(map),
|
||||||
|
jalanKabupaten: L.layerGroup().addTo(map),
|
||||||
|
parsilSHM: L.layerGroup().addTo(map),
|
||||||
|
parsilHGB: L.layerGroup().addTo(map),
|
||||||
|
parsilHGU: L.layerGroup().addTo(map),
|
||||||
|
parsilHP: L.layerGroup().addTo(map)
|
||||||
|
};
|
||||||
|
|
||||||
|
const layerVisible = {
|
||||||
|
rumahIbadah: true, ibadahRadius: true, pendudukMiskin: true,
|
||||||
|
spbu24: true, spbuNon24: true,
|
||||||
|
jalanNasional: true, jalanProvinsi: true, jalanKabupaten: true,
|
||||||
|
parsilSHM: true, parsilHGB: true, parsilHGU: true, parsilHP: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchEntries = [];
|
||||||
|
let searchActiveIndex = -1;
|
||||||
|
const searchPointZoom = 15;
|
||||||
|
const searchIntentPrefix = {
|
||||||
|
jalan: 'jalan',
|
||||||
|
spbu: 'spbu',
|
||||||
|
rumahibadah: 'ri',
|
||||||
|
pendudukmiskin: 'pm',
|
||||||
|
parsil: 'parsil'
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawnItems = new L.FeatureGroup().addTo(map);
|
||||||
|
const drawControl = new L.Control.Draw({
|
||||||
|
draw: {
|
||||||
|
polyline: { shapeOptions: { color: '#2563eb', weight: 3 } },
|
||||||
|
polygon: { shapeOptions: { color: '#7c3aed', weight: 2, fillOpacity: 0.15 } },
|
||||||
|
rectangle: false, circle: false, circlemarker: false, marker: false
|
||||||
|
},
|
||||||
|
edit: { featureGroup: drawnItems, remove: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
let pendingGeoJSON = null;
|
||||||
|
let pendingLength = null;
|
||||||
|
let pendingArea = null;
|
||||||
|
let currentTab = 'kemiskinan';
|
||||||
|
let geometryEditState = null;
|
||||||
|
let _mapClickCb = null;
|
||||||
|
|
||||||
|
function toast(msg, type = 'info') {
|
||||||
|
const t = document.createElement('div');
|
||||||
|
t.className = `toast ${type}`;
|
||||||
|
t.textContent = msg;
|
||||||
|
document.getElementById('toast-container').appendChild(t);
|
||||||
|
setTimeout(() => t.remove(), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openModal(title, bodyHTML, footerHTML) {
|
||||||
|
document.getElementById('modal-title').textContent = title;
|
||||||
|
document.getElementById('modal-body').innerHTML = bodyHTML;
|
||||||
|
document.getElementById('modal-footer').innerHTML = footerHTML;
|
||||||
|
document.getElementById('modal-overlay').classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('modal-overlay').classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('modal-overlay').addEventListener('click', e => {
|
||||||
|
if (e.target.id === 'modal-overlay') closeModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function callAPI(ep, method = 'GET', body = null) {
|
||||||
|
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
||||||
|
if (body) opts.body = JSON.stringify(body);
|
||||||
|
try {
|
||||||
|
return await (await fetch(`${API_BASE}/${ep}`, opts)).json();
|
||||||
|
} catch (err) {
|
||||||
|
return { status: 'error', message: 'Koneksi gagal' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showInstruction(msg) {
|
||||||
|
const el = document.getElementById('instruction-banner');
|
||||||
|
el.textContent = '🖱 ' + msg;
|
||||||
|
el.classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideInstruction() {
|
||||||
|
document.getElementById('instruction-banner').classList.remove('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
if (s == null) return '';
|
||||||
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSearchText(text) {
|
||||||
|
return String(text || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFKD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/[^a-z0-9\s]/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSearchIntent(query) {
|
||||||
|
const q = normalizeSearchText(query);
|
||||||
|
if (!q) return null;
|
||||||
|
const aliases = [
|
||||||
|
{ key: 'jalan', terms: ['jalan', 'road', 'street'] },
|
||||||
|
{ key: 'spbu', terms: ['spbu', 'pom', 'bensin', 'bbm'] },
|
||||||
|
{ key: 'rumahibadah', terms: ['masjid', 'gereja', 'pura', 'vihara', 'klenteng', 'ibadah'] },
|
||||||
|
{ key: 'pendudukmiskin', terms: ['miskin', 'penduduk', 'warga', 'keluarga'] },
|
||||||
|
{ key: 'parsil', terms: ['parsil', 'tanah', 'sertifikat', 'bidang', 'lahan'] }
|
||||||
|
];
|
||||||
|
for (const alias of aliases) {
|
||||||
|
if (alias.terms.some(term => q.includes(term))) return alias.key;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSearchEntries(prefix) {
|
||||||
|
for (let i = searchEntries.length - 1; i >= 0; i--) {
|
||||||
|
if (searchEntries[i].key.startsWith(prefix + ':')) searchEntries.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerSearchEntries(prefix, items) {
|
||||||
|
clearSearchEntries(prefix);
|
||||||
|
items.forEach(item => searchEntries.push(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSearchMatches(query) {
|
||||||
|
const q = normalizeSearchText(query);
|
||||||
|
if (!q) return [];
|
||||||
|
const tokens = q.split(' ');
|
||||||
|
const intent = getSearchIntent(q);
|
||||||
|
const prefix = intent ? searchIntentPrefix[intent] : null;
|
||||||
|
const pool = prefix
|
||||||
|
? searchEntries.filter(entry => entry.key.startsWith(prefix + ':'))
|
||||||
|
: searchEntries;
|
||||||
|
|
||||||
|
return pool
|
||||||
|
.map(entry => {
|
||||||
|
let score = 0;
|
||||||
|
const entryLabel = normalizeSearchText(entry.label);
|
||||||
|
const entryType = normalizeSearchText(entry.typeLabel);
|
||||||
|
const entryText = entry.searchText;
|
||||||
|
if (entry.searchText === q) score += 120;
|
||||||
|
if (entry.searchText.startsWith(q)) score += 80;
|
||||||
|
if (entryLabel.startsWith(q)) score += 90;
|
||||||
|
if (entryType.startsWith(q)) score += 60;
|
||||||
|
if (entry.searchText.includes(q)) score += 50;
|
||||||
|
const tokenHits = tokens.filter(token => entryText.includes(token) || entryLabel.includes(token)).length;
|
||||||
|
score += tokenHits * 12;
|
||||||
|
if (tokens.every(token => entryText.includes(token) || entryLabel.includes(token))) score += 40;
|
||||||
|
if (tokens[0] && (entryText.split(' ').some(word => word.startsWith(tokens[0])) || entryLabel.split(' ').some(word => word.startsWith(tokens[0])))) score += 15;
|
||||||
|
if (intent) {
|
||||||
|
if (entry.key.startsWith(intent + ':')) score += 120;
|
||||||
|
if (entryType.includes(intent)) score += 110;
|
||||||
|
if (intent === 'jalan' && entryLabel.startsWith('jalan')) score += 40;
|
||||||
|
if (intent === 'rumahibadah' && /(masjid|gereja|pura|vihara|klenteng)/.test(entryText)) score += 35;
|
||||||
|
if (intent === 'spbu' && /(spbu|pom|bensin|bbm)/.test(entryText)) score += 35;
|
||||||
|
if (intent === 'pendudukmiskin' && /(miskin|penduduk|warga)/.test(entryText)) score += 35;
|
||||||
|
if (intent === 'parsil' && /(parsil|tanah|sertifikat|bidang)/.test(entryText)) score += 35;
|
||||||
|
}
|
||||||
|
return { ...entry, score };
|
||||||
|
})
|
||||||
|
.filter(entry => entry.score > 0)
|
||||||
|
.sort((a, b) => b.score - a.score || a.label.localeCompare(b.label))
|
||||||
|
.slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSearchResults(query) {
|
||||||
|
const panel = document.getElementById('search-results');
|
||||||
|
if (!panel) return;
|
||||||
|
const matches = getSearchMatches(query);
|
||||||
|
if (!matches.length || !normalizeSearchText(query)) {
|
||||||
|
searchActiveIndex = -1;
|
||||||
|
panel.classList.remove('show');
|
||||||
|
panel.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchActiveIndex < 0 || searchActiveIndex >= matches.length) searchActiveIndex = 0;
|
||||||
|
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="search-results-head">
|
||||||
|
<div class="search-results-title">Rekomendasi</div>
|
||||||
|
</div>
|
||||||
|
<div class="search-results-list">
|
||||||
|
${matches.map((item, index) => `
|
||||||
|
<button type="button" class="search-result-item ${index === searchActiveIndex ? 'active' : ''}" data-index="${index}">
|
||||||
|
<div class="search-result-icon">${item.icon}</div>
|
||||||
|
<div class="search-result-info">
|
||||||
|
<div class="search-result-name">${esc(item.label)}</div>
|
||||||
|
<div class="search-result-sub">${esc(item.subtitle)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="search-result-type">${esc(item.typeLabel)}</div>
|
||||||
|
</button>`).join('')}
|
||||||
|
</div>`;
|
||||||
|
panel.classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSearchResults() {
|
||||||
|
const panel = document.getElementById('search-results');
|
||||||
|
if (!panel) return;
|
||||||
|
panel.classList.remove('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSearchResult(item) {
|
||||||
|
const input = document.getElementById('search-input');
|
||||||
|
if (input) input.value = item.label;
|
||||||
|
closeSearchResults();
|
||||||
|
item.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSearchBar() {
|
||||||
|
const input = document.getElementById('search-input');
|
||||||
|
const panel = document.getElementById('search-results');
|
||||||
|
const wrapper = document.getElementById('header-search');
|
||||||
|
if (!input || !panel || !wrapper) return;
|
||||||
|
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
searchActiveIndex = 0;
|
||||||
|
renderSearchResults(input.value);
|
||||||
|
});
|
||||||
|
input.addEventListener('focus', () => renderSearchResults(input.value));
|
||||||
|
input.addEventListener('keydown', e => {
|
||||||
|
const matches = getSearchMatches(input.value);
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const active = matches[searchActiveIndex] || matches[0];
|
||||||
|
if (active) selectSearchResult(active);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!matches.length) return;
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
searchActiveIndex = Math.min(searchActiveIndex + 1, matches.length - 1);
|
||||||
|
renderSearchResults(input.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
searchActiveIndex = Math.max(searchActiveIndex - 1, 0);
|
||||||
|
renderSearchResults(input.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
panel.addEventListener('mousedown', e => {
|
||||||
|
const item = e.target.closest('.search-result-item');
|
||||||
|
if (!item) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const matches = getSearchMatches(input.value);
|
||||||
|
const selected = matches[Number(item.dataset.index)];
|
||||||
|
if (selected) selectSearchResult(selected);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
if (!wrapper.contains(e.target)) closeSearchResults();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onceMapClick(cb) {
|
||||||
|
if (_mapClickCb) map.off('click', _mapClickCb);
|
||||||
|
_mapClickCb = function (e) {
|
||||||
|
_mapClickCb = null;
|
||||||
|
cb(e.latlng.lat, e.latlng.lng);
|
||||||
|
};
|
||||||
|
map.once('click', _mapClickCb);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelMapClick() {
|
||||||
|
if (_mapClickCb) {
|
||||||
|
map.off('click', _mapClickCb);
|
||||||
|
_mapClickCb = null;
|
||||||
|
}
|
||||||
|
hideInstruction();
|
||||||
|
document.querySelectorAll('.btn-add.active').forEach(b => b.classList.remove('active'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchTab(tab) {
|
||||||
|
currentTab = tab;
|
||||||
|
cancelMapClick();
|
||||||
|
document.querySelectorAll('.nav-tab').forEach((el, i) => {
|
||||||
|
el.classList.toggle('active', ['kemiskinan', 'spbu', 'jalan', 'parsil'][i] === tab);
|
||||||
|
});
|
||||||
|
try { map.removeControl(drawControl); } catch (e) { }
|
||||||
|
const titles = { kemiskinan: 'Ibadah & Kemiskinan', spbu: 'SPBU', jalan: 'Data Jalan', parsil: 'Parsil Tanah' };
|
||||||
|
document.getElementById('panel-title').textContent = titles[tab];
|
||||||
|
renderPanel(tab);
|
||||||
|
if (tab === 'jalan' || tab === 'parsil') map.addControl(drawControl);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderPanel(tab) {
|
||||||
|
const pb = document.getElementById('panel-body');
|
||||||
|
pb.innerHTML = '<div class="loading"><span class="spinner"></span>Memuat data…</div>';
|
||||||
|
if (tab === 'kemiskinan') await renderKemiskinanPanel(pb);
|
||||||
|
if (tab === 'spbu') await renderSpbuPanel(pb);
|
||||||
|
if (tab === 'jalan') await renderJalanPanel(pb);
|
||||||
|
if (tab === 'parsil') await renderParsilPanel(pb);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function preloadFeatureLayers() {
|
||||||
|
const sandbox = document.createElement('div');
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
renderSpbuPanel(sandbox),
|
||||||
|
renderJalanPanel(sandbox),
|
||||||
|
renderParsilPanel(sandbox)
|
||||||
|
]);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Gagal memuat sebagian layer awal:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findGeomLayer(type, id) {
|
||||||
|
const keys = type === 'jalan'
|
||||||
|
? ['jalanNasional', 'jalanProvinsi', 'jalanKabupaten']
|
||||||
|
: ['parsilSHM', 'parsilHGB', 'parsilHGU', 'parsilHP'];
|
||||||
|
for (const key of keys) {
|
||||||
|
let found = null;
|
||||||
|
layers[key].eachLayer(layer => {
|
||||||
|
if (layer._dataId === id) found = layer;
|
||||||
|
});
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGeometryEditHandler() {
|
||||||
|
return drawControl?._toolbars?.edit?._modes?.edit?.handler || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGeometryGroupKey(type, data) {
|
||||||
|
return type === 'jalan' ? `jalan${data.status_jalan}` : `parsil${data.jenis_hak}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneEditableLayer(type, sourceLayer) {
|
||||||
|
if (type === 'jalan') {
|
||||||
|
const coords = sourceLayer.getLatLngs().map(ll => [ll.lat, ll.lng]);
|
||||||
|
return L.polyline(coords, { ...sourceLayer.options });
|
||||||
|
}
|
||||||
|
const ring = sourceLayer.getLatLngs()[0].map(ll => [ll.lat, ll.lng]);
|
||||||
|
return L.polygon(ring, { ...sourceLayer.options });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGeometryLayer(type, data) {
|
||||||
|
if (type === 'jalan') {
|
||||||
|
const cfg = jalanCfg[data.status_jalan] || jalanCfg.Kabupaten;
|
||||||
|
const coords = (data.geojson?.coordinates || []).map(c => [c[1], c[0]]);
|
||||||
|
return L.polyline(coords, { color: cfg.color, weight: cfg.weight, opacity: 0.9 });
|
||||||
|
}
|
||||||
|
const cfg = parsilCfg[data.jenis_hak] || parsilCfg.SHM;
|
||||||
|
const coords = ((data.geojson?.coordinates || [])[0] || []).map(c => [c[1], c[0]]);
|
||||||
|
return L.polygon(coords, { color: cfg.color, fillColor: cfg.color, fillOpacity: 0.18, weight: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelGeometryEdit() {
|
||||||
|
const handler = getGeometryEditHandler();
|
||||||
|
if (handler) handler.disable();
|
||||||
|
drawnItems.clearLayers();
|
||||||
|
geometryEditState = null;
|
||||||
|
switchTab(currentTab);
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusGeom(id, type) {
|
||||||
|
const keys = type === 'jalan'
|
||||||
|
? ['jalanNasional', 'jalanProvinsi', 'jalanKabupaten']
|
||||||
|
: ['parsilSHM', 'parsilHGB', 'parsilHGU', 'parsilHP'];
|
||||||
|
for (const k of keys) {
|
||||||
|
layers[k].eachLayer(l => {
|
||||||
|
if (l._dataId === id) {
|
||||||
|
map.flyToBounds(l.getBounds(), { padding: [50, 50], duration: 1 });
|
||||||
|
l.openPopup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusMarkerById(layerKey, id, zoom = searchPointZoom) {
|
||||||
|
const layer = layers[layerKey];
|
||||||
|
if (!layer) return;
|
||||||
|
let target = null;
|
||||||
|
layer.eachLayer(marker => {
|
||||||
|
if (marker._dataId === id) target = marker;
|
||||||
|
});
|
||||||
|
if (!target) return;
|
||||||
|
map.flyTo(target.getLatLng(), zoom, { duration: 1 });
|
||||||
|
map.once('moveend', () => target.openPopup());
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLayer(key, el) {
|
||||||
|
layerVisible[key] = !layerVisible[key];
|
||||||
|
el.classList.toggle('checked', layerVisible[key]);
|
||||||
|
el.querySelector('.layer-check').textContent = layerVisible[key] ? '✓' : '';
|
||||||
|
layerVisible[key] ? map.addLayer(layers[key]) : map.removeLayer(layers[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatusCount() {
|
||||||
|
let total = 0;
|
||||||
|
Object.values(layers).forEach(lg => { if (map.hasLayer(lg)) lg.eachLayer(() => total++); });
|
||||||
|
document.getElementById('status-count').textContent = `Total fitur: ${total}`;
|
||||||
|
document.getElementById('status-zoom').textContent = `Zoom: ${map.getZoom()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
map.on('mousemove', e => {
|
||||||
|
document.getElementById('coord-display').textContent = `Lat: ${e.latlng.lat.toFixed(6)} | Lng: ${e.latlng.lng.toFixed(6)}`;
|
||||||
|
});
|
||||||
|
map.on('zoomend', () => {
|
||||||
|
document.getElementById('status-zoom').textContent = `Zoom: ${map.getZoom()}`;
|
||||||
|
});
|
||||||
|
map.on(L.Draw.Event.CREATED, function (e) {
|
||||||
|
const { layer, layerType: type } = e;
|
||||||
|
if (type === 'polyline') {
|
||||||
|
const lls = layer.getLatLngs();
|
||||||
|
pendingLength = turf.length(turf.lineString(lls.map(c => [c.lng, c.lat])), { units: 'meters' });
|
||||||
|
pendingGeoJSON = { type: 'LineString', coordinates: lls.map(c => [c.lng, c.lat]) };
|
||||||
|
drawnItems.addLayer(layer);
|
||||||
|
openJalanForm();
|
||||||
|
}
|
||||||
|
if (type === 'polygon') {
|
||||||
|
const lls = layer.getLatLngs()[0];
|
||||||
|
const ring = [...lls.map(c => [c.lng, c.lat]), [lls[0].lng, lls[0].lat]];
|
||||||
|
pendingArea = turf.area(turf.polygon([ring]));
|
||||||
|
pendingGeoJSON = { type: 'Polygon', coordinates: [ring] };
|
||||||
|
drawnItems.addLayer(layer);
|
||||||
|
openParsilForm();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
initSearchBar();
|
||||||
|
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
const jalanCfg = {
|
||||||
|
Nasional: { color: '#dc2626', weight: 5 },
|
||||||
|
Provinsi: { color: '#d97706', weight: 3.5 },
|
||||||
|
Kabupaten: { color: '#2563eb', weight: 2.5 }
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderJalanPanel(pb) {
|
||||||
|
return callAPI('data_jalan.php').then(res => {
|
||||||
|
const data = res.data || [];
|
||||||
|
const cnt = k => data.filter(d => d.status_jalan === k).length;
|
||||||
|
|
||||||
|
pb.innerHTML = `
|
||||||
|
<div class="layer-section">
|
||||||
|
<div class="layer-section-title">Filter Layer</div>
|
||||||
|
${['Nasional', 'Provinsi', 'Kabupaten'].map(s => `
|
||||||
|
<div class="layer-item ${layerVisible['jalan' + s] ? 'checked' : ''}" onclick="toggleLayer('jalan${s}',this)">
|
||||||
|
<div class="layer-check">${layerVisible['jalan' + s] ? '✓' : ''}</div>
|
||||||
|
<div class="layer-dot" style="background:${jalanCfg[s].color};border-radius:2px;height:4px;width:18px;border-radius:2px"></div>
|
||||||
|
<div class="layer-label">Jalan ${s}</div>
|
||||||
|
<div class="layer-count">${cnt(s)}</div>
|
||||||
|
</div>`).join('')}
|
||||||
|
</div>
|
||||||
|
<div class="layer-section">
|
||||||
|
<div class="layer-section-title">Cara Menambahkan Data Jalan</div>
|
||||||
|
<div style="font-size:11px;color:var(--text2);line-height:1.9;padding:10px 12px;background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius)">
|
||||||
|
1. Klik ikon <b style="color:var(--accent)">polyline</b> di toolbar kiri peta<br>
|
||||||
|
2. Klik titik-titik rute jalan di peta<br>
|
||||||
|
3. <b>Double-klik</b> untuk selesai menggambar<br>
|
||||||
|
4. Form simpan + panjang otomatis muncul
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layer-section">
|
||||||
|
<div class="layer-section-title">Data Jalan (${data.length})</div>
|
||||||
|
<div class="data-list">
|
||||||
|
${data.length ? data.map(d => `
|
||||||
|
<div class="data-item" onclick="focusGeom(${d.id},'jalan')">
|
||||||
|
<div class="item-icon">🛣</div>
|
||||||
|
<div class="item-info">
|
||||||
|
<div class="item-name">${esc(d.nama_jalan)}</div>
|
||||||
|
<div class="item-sub">${(d.panjang_meter / 1000).toFixed(3)} km</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:5px">
|
||||||
|
<div class="item-badge" style="background:${jalanCfg[d.status_jalan].color}18;color:${jalanCfg[d.status_jalan].color};border:1px solid ${jalanCfg[d.status_jalan].color}44">${d.status_jalan.toUpperCase()}</div>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="event.stopPropagation();editJalan(${d.id})">✏</button>
|
||||||
|
<button class="btn btn-danger btn-sm" onclick="event.stopPropagation();deleteJalan(${d.id})">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>`).join('') : '<div class="loading">Belum ada data jalan</div>'}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
loadJalanLayer(data);
|
||||||
|
registerSearchEntries('jalan', data.map(d => ({
|
||||||
|
key: `jalan:${d.id}`,
|
||||||
|
label: d.nama_jalan,
|
||||||
|
subtitle: `Jalan ${d.status_jalan} · ${(d.panjang_meter / 1000).toFixed(3)} km${d.keterangan ? ` · ${d.keterangan}` : ''}`,
|
||||||
|
typeLabel: 'Jalan',
|
||||||
|
icon: '🛣',
|
||||||
|
searchText: normalizeSearchText(`${d.nama_jalan} ${d.status_jalan} jalan`),
|
||||||
|
focus: () => focusGeom(d.id, 'jalan')
|
||||||
|
})));
|
||||||
|
updateStatusCount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadJalanLayer(data) {
|
||||||
|
['Nasional', 'Provinsi', 'Kabupaten'].forEach(k => layers['jalan' + k].clearLayers());
|
||||||
|
data.forEach(d => {
|
||||||
|
const cfg = jalanCfg[d.status_jalan], coords = d.geojson.coordinates.map(c => [c[1], c[0]]);
|
||||||
|
const line = L.polyline(coords, { color: cfg.color, weight: cfg.weight, opacity: 0.9 })
|
||||||
|
.bindPopup(`<h4>🛣 ${esc(d.nama_jalan)}</h4>
|
||||||
|
<p><b>Status:</b> Jalan ${d.status_jalan}</p>
|
||||||
|
<p><b>Panjang:</b> ${(d.panjang_meter / 1000).toFixed(3)} km</p>
|
||||||
|
${d.keterangan ? `<p>${esc(d.keterangan)}</p>` : ''}
|
||||||
|
<div class="popup-actions"><button class="btn btn-ghost btn-sm" onclick="editJalan(${d.id})">✏ Edit</button><button class="btn btn-danger btn-sm" onclick="deleteJalan(${d.id})">✕ Hapus</button></div>`);
|
||||||
|
line._dataId = d.id;
|
||||||
|
line._groupKey = 'jalan' + d.status_jalan;
|
||||||
|
layers['jalan' + d.status_jalan].addLayer(line);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openJalanForm() {
|
||||||
|
openModal('Simpan Data Jalan', `
|
||||||
|
<div class="form-calc">📏 Panjang: <b>${pendingLength.toFixed(1)} m</b> = ${(pendingLength / 1000).toFixed(3)} km </div>
|
||||||
|
<div class="form-group"><label class="form-label">Nama Jalan *</label>
|
||||||
|
<input class="form-input" id="jl-nama" placeholder="Jl. Ahmad Yani…"/></div>
|
||||||
|
<div class="form-group"><label class="form-label">Status Jalan *</label>
|
||||||
|
<select class="form-select" id="jl-status">
|
||||||
|
<option value="Nasional">Jalan Nasional</option>
|
||||||
|
<option value="Provinsi">Jalan Provinsi</option>
|
||||||
|
<option value="Kabupaten">Jalan Kabupaten</option>
|
||||||
|
</select></div>
|
||||||
|
<div class="form-group"><label class="form-label">Keterangan</label>
|
||||||
|
<input class="form-input" id="jl-ket" placeholder="Opsional…"/></div>`,
|
||||||
|
`<button class="btn btn-ghost" onclick="closeModal();drawnItems.clearLayers()">Batal</button>
|
||||||
|
<button class="btn btn-primary" onclick="saveJalan()">💾 Simpan</button>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveJalan() {
|
||||||
|
const body = {
|
||||||
|
nama_jalan: document.getElementById('jl-nama').value.trim(),
|
||||||
|
status_jalan: document.getElementById('jl-status').value,
|
||||||
|
panjang_meter: pendingLength,
|
||||||
|
keterangan: document.getElementById('jl-ket').value,
|
||||||
|
geojson: pendingGeoJSON
|
||||||
|
};
|
||||||
|
if (!body.nama_jalan) return toast('Nama wajib diisi', 'error');
|
||||||
|
const res = await callAPI('data_jalan.php', 'POST', body);
|
||||||
|
if (res.status === 'success') {
|
||||||
|
toast(res.message, 'success');
|
||||||
|
closeModal();
|
||||||
|
drawnItems.clearLayers();
|
||||||
|
pendingGeoJSON = null;
|
||||||
|
pendingLength = null;
|
||||||
|
switchTab('jalan');
|
||||||
|
} else {
|
||||||
|
toast(res.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editJalan(id) {
|
||||||
|
map.closePopup();
|
||||||
|
const res = await callAPI(`data_jalan.php?id=${id}`);
|
||||||
|
const data = res.data;
|
||||||
|
const sourceLayer = findGeomLayer('jalan', id);
|
||||||
|
if (!data) return toast('Data jalan tidak ditemukan', 'error');
|
||||||
|
|
||||||
|
const groupKey = getGeometryGroupKey('jalan', data);
|
||||||
|
const editLayer = sourceLayer ? cloneEditableLayer('jalan', sourceLayer) : buildGeometryLayer('jalan', data);
|
||||||
|
editLayer._dataId = id;
|
||||||
|
editLayer._groupKey = groupKey;
|
||||||
|
|
||||||
|
if (sourceLayer) layers[groupKey].removeLayer(sourceLayer);
|
||||||
|
drawnItems.clearLayers();
|
||||||
|
drawnItems.addLayer(editLayer);
|
||||||
|
|
||||||
|
geometryEditState = { type: 'jalan', id, groupKey, sourceLayer, editLayer };
|
||||||
|
if (editLayer.editing && editLayer.editing.enable) {
|
||||||
|
editLayer.editing.enable();
|
||||||
|
} else {
|
||||||
|
const handler = getGeometryEditHandler();
|
||||||
|
if (!handler) {
|
||||||
|
drawnItems.clearLayers();
|
||||||
|
if (sourceLayer) layers[groupKey].addLayer(sourceLayer);
|
||||||
|
geometryEditState = null;
|
||||||
|
return toast('Mode edit geometri belum siap', 'error');
|
||||||
|
}
|
||||||
|
handler.enable();
|
||||||
|
}
|
||||||
|
map.fitBounds(editLayer.getBounds(), { padding: [50, 50], duration: 1 });
|
||||||
|
document.getElementById('panel-title').textContent = 'Edit Data Jalan';
|
||||||
|
document.getElementById('panel-body').innerHTML = `
|
||||||
|
<div class="layer-section">
|
||||||
|
<div class="layer-section-title">Edit Jalan</div>
|
||||||
|
<div style="font-size:11px;line-height:1.7;color:var(--text2);padding:10px 12px;background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:12px">
|
||||||
|
Geser titik-titik garis di peta lalu simpan perubahan.
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Nama Jalan *</label>
|
||||||
|
<input class="form-input" id="jl-edit-nama" value="${esc(data.nama_jalan)}" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Status Jalan *</label>
|
||||||
|
<select class="form-select" id="jl-edit-status">
|
||||||
|
${['Nasional', 'Provinsi', 'Kabupaten'].map(s => `<option value="${s}"${data.status_jalan === s ? ' selected' : ''}>Jalan ${s}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Panjang Otomatis</label>
|
||||||
|
<input class="form-input" id="jl-edit-panjang" value="${(data.panjang_meter / 1000).toFixed(3)} km" disabled />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Keterangan</label>
|
||||||
|
<input class="form-input" id="jl-edit-ket" value="${esc(data.keterangan || '')}" />
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
document.getElementById('panel-body').insertAdjacentHTML('beforeend', `
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
<button class="btn btn-ghost" style="flex:1" onclick="cancelGeometryEdit()">Batal</button>
|
||||||
|
<button class="btn btn-primary" style="flex:1" onclick="saveJalanEdit(${id})">Simpan Perubahan</button>
|
||||||
|
</div>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveJalanEdit(id) {
|
||||||
|
const state = geometryEditState;
|
||||||
|
if (!state || state.type !== 'jalan') return;
|
||||||
|
const layer = state.editLayer;
|
||||||
|
const coords = layer.getLatLngs().map(ll => [ll.lng, ll.lat]);
|
||||||
|
const panjang_meter = turf.length(turf.lineString(coords), { units: 'meters' });
|
||||||
|
const body = {
|
||||||
|
nama_jalan: document.getElementById('jl-edit-nama').value.trim(),
|
||||||
|
status_jalan: document.getElementById('jl-edit-status').value,
|
||||||
|
panjang_meter,
|
||||||
|
keterangan: document.getElementById('jl-edit-ket').value,
|
||||||
|
geojson: { type: 'LineString', coordinates: coords }
|
||||||
|
};
|
||||||
|
if (!body.nama_jalan) return toast('Nama wajib diisi', 'error');
|
||||||
|
const res = await callAPI(`data_jalan.php?id=${id}`, 'PUT', body);
|
||||||
|
if (res.status === 'success') {
|
||||||
|
toast(res.message, 'success');
|
||||||
|
if (layer.editing && layer.editing.disable) layer.editing.disable();
|
||||||
|
const handler = getGeometryEditHandler();
|
||||||
|
if (handler) handler.disable();
|
||||||
|
drawnItems.clearLayers();
|
||||||
|
geometryEditState = null;
|
||||||
|
switchTab('jalan');
|
||||||
|
} else {
|
||||||
|
toast(res.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteJalan(id) {
|
||||||
|
if (!confirm('Hapus?')) return;
|
||||||
|
const r = await callAPI(`data_jalan.php?id=${id}`, 'DELETE');
|
||||||
|
if (r.status === 'success') {
|
||||||
|
toast('Dihapus', 'success');
|
||||||
|
map.closePopup();
|
||||||
|
switchTab('jalan');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
function renderKemiskinanPanel(pb) {
|
||||||
|
return Promise.all([callAPI('rumah_ibadah.php'), callAPI('penduduk_miskin.php')]).then(([riRes, pmRes]) => {
|
||||||
|
const ri = riRes.data || [];
|
||||||
|
const pm = pmRes.data || [];
|
||||||
|
|
||||||
|
pb.innerHTML = `
|
||||||
|
<div class="layer-section">
|
||||||
|
<div class="layer-section-title">Visibilitas Layer</div>
|
||||||
|
${mkLayerItem('rumahIbadah', '#f59e0b', 'Rumah Ibadah', ri.length)}
|
||||||
|
${mkLayerItem('ibadahRadius', '#fbbf24', 'Radius Jangkauan', ri.length, true)}
|
||||||
|
${mkLayerItem('pendudukMiskin', '#16a34a', 'Penduduk Miskin', pm.length)}
|
||||||
|
</div>
|
||||||
|
<div class="layer-section">
|
||||||
|
<div class="layer-section-title">Tambah Data</div>
|
||||||
|
<button class="btn-add" id="btn-add-ri" onclick="startAdd('ri')">+ Tambah Rumah Ibadah</button>
|
||||||
|
<button class="btn-add" id="btn-add-pm" onclick="startAdd('pm')" style="margin-top:5px">+ Tambah Penduduk Miskin</button>
|
||||||
|
</div>
|
||||||
|
<div class="layer-section">
|
||||||
|
<div class="layer-section-title">Rumah Ibadah (${ri.length})</div>
|
||||||
|
<div class="data-list">
|
||||||
|
${ri.length ? ri.map(r => `
|
||||||
|
<div class="data-item" onclick="map.flyTo([${r.latitude},${r.longitude}],17)">
|
||||||
|
<div class="item-icon">⛪</div>
|
||||||
|
<div class="item-info">
|
||||||
|
<div class="item-name">${esc(r.nama)}</div>
|
||||||
|
<div class="item-sub">${r.jenis} · R=${r.radius_meter}m</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:4px">
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="event.stopPropagation();editRI(${r.id})">✏</button>
|
||||||
|
<button class="btn btn-danger btn-sm" onclick="event.stopPropagation();deleteRI(${r.id})">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>`).join('') : '<div class="loading">Belum ada data</div>'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layer-section">
|
||||||
|
<div class="layer-section-title">Penduduk Miskin (${pm.length})</div>
|
||||||
|
<div class="data-list">
|
||||||
|
${pm.length ? pm.map(p => `
|
||||||
|
<div class="data-item" onclick="map.flyTo([${p.latitude},${p.longitude}],17)">
|
||||||
|
<div class="item-icon">${pmEmoji(p)}</div>
|
||||||
|
<div class="item-info">
|
||||||
|
<div class="item-name">${esc(p.nama)}</div>
|
||||||
|
<div class="item-sub">${p.total_bantuan} bantuan · ${p.jumlah_anggota} jiwa</div>
|
||||||
|
</div>
|
||||||
|
<div class="item-badge ${p.status_proximity === 'dalam_radius' ? 'badge-green' : 'badge-red'}">
|
||||||
|
${p.status_proximity === 'dalam_radius' ? 'DALAM' : 'LUAR'}
|
||||||
|
</div>
|
||||||
|
</div>`).join('') : '<div class="loading">Belum ada data</div>'}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
loadRILayer(ri);
|
||||||
|
loadPMLayer(pm);
|
||||||
|
registerSearchEntries('ri', ri.map(r => ({
|
||||||
|
key: `ri:${r.id}`,
|
||||||
|
label: r.nama,
|
||||||
|
subtitle: `Rumah Ibadah · ${r.jenis}${r.alamat ? ` · ${r.alamat}` : ''}`,
|
||||||
|
typeLabel: 'Rumah Ibadah',
|
||||||
|
icon: '⛪',
|
||||||
|
searchText: normalizeSearchText(`${r.nama} ${r.jenis} rumah ibadah`),
|
||||||
|
focus: () => focusMarkerById('rumahIbadah', r.id)
|
||||||
|
})));
|
||||||
|
registerSearchEntries('pm', pm.map(p => ({
|
||||||
|
key: `pm:${p.id}`,
|
||||||
|
label: p.nama,
|
||||||
|
subtitle: `Penduduk Miskin · ${p.jumlah_anggota} jiwa${p.alamat ? ` · ${p.alamat}` : ''}`,
|
||||||
|
typeLabel: 'Penduduk Miskin',
|
||||||
|
icon: '👤',
|
||||||
|
searchText: normalizeSearchText(`${p.nama} ${p.nik || ''} penduduk miskin`),
|
||||||
|
focus: () => focusMarkerById('pendudukMiskin', p.id)
|
||||||
|
})));
|
||||||
|
updateStatusCount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mkLayerItem(key, color, label, count, isRing = false) {
|
||||||
|
const dot = isRing
|
||||||
|
? `<div class="layer-dot" style="background:transparent;border:2px solid ${color}"></div>`
|
||||||
|
: `<div class="layer-dot" style="background:${color}"></div>`;
|
||||||
|
return `<div class="layer-item ${layerVisible[key] ? 'checked' : ''}" onclick="toggleLayer('${key}',this)">
|
||||||
|
<div class="layer-check">${layerVisible[key] ? '✓' : ''}</div>${dot}
|
||||||
|
<div class="layer-label">${label}</div>
|
||||||
|
<div class="layer-count">${count}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pmEmoji(p) {
|
||||||
|
if (p.bantuan_pemberdayaan > 0 && p.bantuan_konsumtif > 0) return '🔗';
|
||||||
|
if (p.bantuan_pemberdayaan > 0) return '📚';
|
||||||
|
if (p.bantuan_konsumtif > 0) return '🛍';
|
||||||
|
return '👤';
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadRILayer(data) {
|
||||||
|
layers.rumahIbadah.clearLayers();
|
||||||
|
layers.ibadahRadius.clearLayers();
|
||||||
|
|
||||||
|
data.forEach(r => {
|
||||||
|
const circle = L.circle([r.latitude, r.longitude], {
|
||||||
|
radius: r.radius_meter,
|
||||||
|
color: '#f59e0b', fillColor: '#fef3c7', fillOpacity: 0.18,
|
||||||
|
weight: 1.5, dashArray: '6,4'
|
||||||
|
});
|
||||||
|
layers.ibadahRadius.addLayer(circle);
|
||||||
|
|
||||||
|
const icon = L.divIcon({
|
||||||
|
className: '',
|
||||||
|
html: `<div style="width:32px;height:32px;background:#f59e0b;border:3px solid #fff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:15px;box-shadow:0 3px 10px rgba(0,0,0,.2);cursor:grab;">⛪</div>`,
|
||||||
|
iconSize: [32, 32], iconAnchor: [16, 16]
|
||||||
|
});
|
||||||
|
|
||||||
|
const marker = L.marker([r.latitude, r.longitude], { icon, draggable: true }).bindPopup(riPopup(r));
|
||||||
|
marker._dataId = r.id;
|
||||||
|
marker.on('dragend', async function (ev) {
|
||||||
|
const { lat, lng } = ev.target.getLatLng();
|
||||||
|
circle.setLatLng([lat, lng]);
|
||||||
|
const res = await callAPI(`rumah_ibadah.php?id=${r.id}`, 'PUT', {
|
||||||
|
nama: r.nama, jenis: r.jenis, alamat: r.alamat,
|
||||||
|
no_wa: r.no_wa, latitude: lat, longitude: lng,
|
||||||
|
radius_meter: r.radius_meter
|
||||||
|
});
|
||||||
|
if (res.status === 'success') {
|
||||||
|
r.latitude = lat;
|
||||||
|
r.longitude = lng;
|
||||||
|
toast(`📍 ${r.nama} dipindahkan & koordinat diperbarui`, 'success');
|
||||||
|
await recalcAllProximity();
|
||||||
|
} else {
|
||||||
|
toast('Gagal update lokasi: ' + res.message, 'error');
|
||||||
|
marker.setLatLng([r.latitude, r.longitude]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
layers.rumahIbadah.addLayer(marker);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function riPopup(r) {
|
||||||
|
return `<h4>⛪ ${esc(r.nama)}</h4>
|
||||||
|
<p><b>Jenis:</b> ${r.jenis}</p>
|
||||||
|
<p><b>Radius:</b> ${r.radius_meter} m</p>
|
||||||
|
${r.alamat ? `<p><b>Alamat:</b> ${esc(r.alamat)}</p>` : ''}
|
||||||
|
${r.no_wa ? `<p><b>WA:</b> ${r.no_wa}</p>` : ''}
|
||||||
|
<div class="popup-drag-hint">↔ Seret marker untuk pindahkan lokasi — auto-save</div>
|
||||||
|
<div class="popup-actions">
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="editRI(${r.id})">✏ Edit</button>
|
||||||
|
<button class="btn btn-danger btn-sm" onclick="deleteRI(${r.id})">✕ Hapus</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPMLayer(data) {
|
||||||
|
layers.pendudukMiskin.clearLayers();
|
||||||
|
|
||||||
|
data.forEach(p => {
|
||||||
|
const inR = p.status_proximity === 'dalam_radius';
|
||||||
|
const color = inR ? '#16a34a' : '#dc2626';
|
||||||
|
const hasBoth = p.bantuan_pemberdayaan > 0 && p.bantuan_konsumtif > 0;
|
||||||
|
const hasPemb = p.bantuan_pemberdayaan > 0;
|
||||||
|
const hasKons = p.bantuan_konsumtif > 0;
|
||||||
|
|
||||||
|
let dot = '';
|
||||||
|
if (hasBoth || hasPemb) dot = `<div style="position:absolute;top:-3px;right:-3px;width:9px;height:9px;background:#2563eb;border:1.5px solid #fff;border-radius:50%"></div>`;
|
||||||
|
if (hasKons && !hasPemb) dot = `<div style="position:absolute;top:-3px;right:-3px;width:9px;height:9px;background:#f59e0b;border:1.5px solid #fff;border-radius:50%"></div>`;
|
||||||
|
|
||||||
|
const icon = L.divIcon({
|
||||||
|
className: '',
|
||||||
|
html: `<div style="position:relative"><div style="width:26px;height:26px;background:${color};border:3px solid #fff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;box-shadow:0 3px 10px ${color}66;cursor:grab;">👤</div>${dot}</div>`,
|
||||||
|
iconSize: [26, 26], iconAnchor: [13, 13]
|
||||||
|
});
|
||||||
|
|
||||||
|
const marker = L.marker([p.latitude, p.longitude], { icon, draggable: true }).bindPopup(pmPopup(p));
|
||||||
|
marker._dataId = p.id;
|
||||||
|
marker.on('dragend', async function (ev) {
|
||||||
|
const { lat, lng } = ev.target.getLatLng();
|
||||||
|
const res = await callAPI(`penduduk_miskin.php?id=${p.id}`, 'PUT', {
|
||||||
|
nama: p.nama, no_wa: p.no_wa, nik: p.nik,
|
||||||
|
alamat: p.alamat, jumlah_anggota: p.jumlah_anggota,
|
||||||
|
latitude: lat, longitude: lng
|
||||||
|
});
|
||||||
|
if (res.status === 'success') {
|
||||||
|
const prox = res.data?.proximity;
|
||||||
|
const statusMsg = prox?.status === 'dalam_radius' ? '✅ Dalam radius' : '❌ Luar radius';
|
||||||
|
toast(`📍 ${p.nama} dipindahkan — ${statusMsg}`, 'success');
|
||||||
|
switchTab('kemiskinan');
|
||||||
|
} else {
|
||||||
|
toast('Gagal update: ' + res.message, 'error');
|
||||||
|
marker.setLatLng([p.latitude, p.longitude]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
layers.pendudukMiskin.addLayer(marker);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pmPopup(p) {
|
||||||
|
const inR = p.status_proximity === 'dalam_radius';
|
||||||
|
return `<h4>👤 ${esc(p.nama)}</h4>
|
||||||
|
<div class="proximity-info ${inR ? 'dalam' : 'luar'}">
|
||||||
|
${inR ? `✅ Dalam radius — ${esc(p.nama_rumah_ibadah || '')}` : '❌ Di luar radius rumah ibadah'}
|
||||||
|
</div>
|
||||||
|
${p.no_wa ? `<p><b>WA:</b> ${p.no_wa}</p>` : ''}
|
||||||
|
${p.nik ? `<p><b>NIK:</b> ${p.nik}</p>` : ''}
|
||||||
|
${p.alamat ? `<p><b>Alamat:</b> ${esc(p.alamat)}</p>` : ''}
|
||||||
|
<p><b>Keluarga:</b> ${p.jumlah_anggota} jiwa</p>
|
||||||
|
<p><b>Bantuan:</b> 📚${p.bantuan_pemberdayaan} · 🛍${p.bantuan_konsumtif}</p>
|
||||||
|
<div class="popup-drag-hint">↔ Seret marker untuk pindahkan lokasi — auto-save</div>
|
||||||
|
<div class="popup-actions">
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="openLogBantuan(${p.id},'${esc(p.nama)}')">📋 Log Bantuan</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="editPM(${p.id})">✏ Edit</button>
|
||||||
|
<button class="btn btn-danger btn-sm" onclick="deletePM(${p.id})">✕</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAdd(type) {
|
||||||
|
cancelMapClick();
|
||||||
|
const btnId = type === 'ri' ? 'btn-add-ri' : 'btn-add-pm';
|
||||||
|
const label = type === 'ri' ? 'lokasi Rumah Ibadah' : 'lokasi Penduduk Miskin';
|
||||||
|
document.getElementById(btnId).classList.add('active');
|
||||||
|
showInstruction(`Klik di peta untuk menentukan ${label}`);
|
||||||
|
onceMapClick((lat, lng) => {
|
||||||
|
document.getElementById(btnId).classList.remove('active');
|
||||||
|
hideInstruction();
|
||||||
|
if (type === 'ri') openRIForm(null, lat, lng);
|
||||||
|
else openPMForm(null, lat, lng);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRIForm(data, lat, lng) {
|
||||||
|
const isEdit = !!data;
|
||||||
|
const fLat = isEdit ? data.latitude : lat;
|
||||||
|
const fLng = isEdit ? data.longitude : lng;
|
||||||
|
openModal(isEdit ? 'Edit Rumah Ibadah' : 'Tambah Rumah Ibadah', `
|
||||||
|
<div class="form-coord">📍 Koordinat: ${fLat.toFixed(7)}, ${fLng.toFixed(7)}</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Nama Rumah Ibadah *</label>
|
||||||
|
<input class="form-input" id="ri-nama" value="${esc(isEdit ? data.nama : '')}" placeholder="Masjid Al-Ikhlas…"/>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Jenis *</label>
|
||||||
|
<select class="form-select" id="ri-jenis">
|
||||||
|
${['Masjid', 'Gereja', 'Pura', 'Vihara', 'Klenteng', 'Lainnya'].map(j =>
|
||||||
|
`<option value="${j}"${isEdit && data.jenis === j ? ' selected' : ''}>${j}</option>`
|
||||||
|
).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Radius (meter) *</label>
|
||||||
|
<input class="form-input" id="ri-radius" type="number" value="${isEdit ? data.radius_meter : 500}" min="50" max="10000"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Alamat</label>
|
||||||
|
<input class="form-input" id="ri-alamat" value="${esc(isEdit ? data.alamat || '' : '')}" placeholder="Jl. …"/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">No. WhatsApp</label>
|
||||||
|
<input class="form-input" id="ri-nowa" value="${esc(isEdit ? data.no_wa || '' : '')}" placeholder="081234…"/>
|
||||||
|
</div>`,
|
||||||
|
`<button class="btn btn-ghost" onclick="closeModal()">Batal</button>
|
||||||
|
<button class="btn btn-primary" onclick="saveRI(${isEdit ? data.id : 'null'},${fLat},${fLng})">💾 Simpan</button>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRI(id, lat, lng) {
|
||||||
|
const body = {
|
||||||
|
nama: document.getElementById('ri-nama').value.trim(),
|
||||||
|
jenis: document.getElementById('ri-jenis').value,
|
||||||
|
alamat: document.getElementById('ri-alamat').value.trim(),
|
||||||
|
no_wa: document.getElementById('ri-nowa').value.trim(),
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lng,
|
||||||
|
radius_meter: parseInt(document.getElementById('ri-radius').value)
|
||||||
|
};
|
||||||
|
if (!body.nama) return toast('Nama wajib diisi', 'error');
|
||||||
|
const res = await callAPI(id ? `rumah_ibadah.php?id=${id}` : 'rumah_ibadah.php', id ? 'PUT' : 'POST', body);
|
||||||
|
if (res.status === 'success') {
|
||||||
|
toast(res.message, 'success');
|
||||||
|
closeModal();
|
||||||
|
await recalcAllProximity();
|
||||||
|
switchTab('kemiskinan');
|
||||||
|
} else {
|
||||||
|
toast(res.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPMForm(data, lat, lng) {
|
||||||
|
const isEdit = !!data;
|
||||||
|
const fLat = isEdit ? data.latitude : lat;
|
||||||
|
const fLng = isEdit ? data.longitude : lng;
|
||||||
|
openModal(isEdit ? 'Edit Penduduk Miskin' : 'Tambah Penduduk Miskin', `
|
||||||
|
<div class="form-coord">📍 Koordinat: ${fLat.toFixed(7)}, ${fLng.toFixed(7)}</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Nama Lengkap *</label>
|
||||||
|
<input class="form-input" id="pm-nama" value="${esc(isEdit ? data.nama : '')}" placeholder="Ahmad Subarjo…"/>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">No. WhatsApp</label>
|
||||||
|
<input class="form-input" id="pm-nowa" value="${esc(isEdit ? data.no_wa || '' : '')}" placeholder="081234…"/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">NIK</label>
|
||||||
|
<input class="form-input" id="pm-nik" value="${esc(isEdit ? data.nik || '' : '')}" placeholder="617101…"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Alamat</label>
|
||||||
|
<input class="form-input" id="pm-alamat" value="${esc(isEdit ? data.alamat || '' : '')}" placeholder="Jl. …"/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Jumlah Anggota Keluarga</label>
|
||||||
|
<input class="form-input" id="pm-jml" type="number" value="${isEdit ? data.jumlah_anggota : 1}" min="1" max="20"/>
|
||||||
|
</div>`,
|
||||||
|
`<button class="btn btn-ghost" onclick="closeModal()">Batal</button>
|
||||||
|
<button class="btn btn-primary" onclick="savePM(${isEdit ? data.id : 'null'},${fLat},${fLng})">💾 Simpan</button>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePM(id, lat, lng) {
|
||||||
|
const body = {
|
||||||
|
nama: document.getElementById('pm-nama').value.trim(),
|
||||||
|
no_wa: document.getElementById('pm-nowa').value.trim(),
|
||||||
|
nik: document.getElementById('pm-nik').value.trim(),
|
||||||
|
alamat: document.getElementById('pm-alamat').value.trim(),
|
||||||
|
jumlah_anggota: parseInt(document.getElementById('pm-jml').value) || 1,
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lng
|
||||||
|
};
|
||||||
|
if (!body.nama) return toast('Nama wajib diisi', 'error');
|
||||||
|
const res = await callAPI(id ? `penduduk_miskin.php?id=${id}` : 'penduduk_miskin.php', id ? 'PUT' : 'POST', body);
|
||||||
|
if (res.status === 'success') {
|
||||||
|
toast(res.message, 'success');
|
||||||
|
closeModal();
|
||||||
|
switchTab('kemiskinan');
|
||||||
|
} else {
|
||||||
|
toast(res.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editRI(id) {
|
||||||
|
map.closePopup();
|
||||||
|
const res = await callAPI(`rumah_ibadah.php?id=${id}`);
|
||||||
|
if (res.data) openRIForm(res.data, res.data.latitude, res.data.longitude);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRI(id) {
|
||||||
|
if (!confirm('Hapus rumah ibadah ini?')) return;
|
||||||
|
const res = await callAPI(`rumah_ibadah.php?id=${id}`, 'DELETE');
|
||||||
|
if (res.status === 'success') {
|
||||||
|
toast('Dihapus', 'success');
|
||||||
|
map.closePopup();
|
||||||
|
await recalcAllProximity();
|
||||||
|
switchTab('kemiskinan');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editPM(id) {
|
||||||
|
map.closePopup();
|
||||||
|
const res = await callAPI(`penduduk_miskin.php?id=${id}`);
|
||||||
|
if (res.data) openPMForm(res.data, res.data.latitude, res.data.longitude);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePM(id) {
|
||||||
|
if (!confirm('Hapus data ini?')) return;
|
||||||
|
const res = await callAPI(`penduduk_miskin.php?id=${id}`, 'DELETE');
|
||||||
|
if (res.status === 'success') {
|
||||||
|
toast('Dihapus', 'success');
|
||||||
|
map.closePopup();
|
||||||
|
await recalcAllProximity();
|
||||||
|
switchTab('kemiskinan');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openLogBantuan(pmId, pmNama) {
|
||||||
|
map.closePopup();
|
||||||
|
const [logRes, riRes] = await Promise.all([
|
||||||
|
callAPI(`log_bantuan.php?penduduk_miskin_id=${pmId}`),
|
||||||
|
callAPI('rumah_ibadah.php')
|
||||||
|
]);
|
||||||
|
const logs = logRes.data || [];
|
||||||
|
const ris = riRes.data || [];
|
||||||
|
|
||||||
|
openModal(`Log Bantuan — ${pmNama}`, `
|
||||||
|
<div style="margin-bottom:16px">
|
||||||
|
<div class="layer-section-title">Riwayat Bantuan</div>
|
||||||
|
${logs.length ? logs.map(l => `
|
||||||
|
<div class="log-item ${l.tipe_bantuan === 'Pemberdayaan' ? 'pemberdayaan' : 'konsumtif'}">
|
||||||
|
<div class="log-type">${l.tipe_bantuan === 'Pemberdayaan' ? '📚 Pemberdayaan' : '🛍 Konsumtif'}</div>
|
||||||
|
<div class="log-detail">${esc(l.sub_kategori) || ''} ${l.keterangan ? '— ' + esc(l.keterangan) : ''}</div>
|
||||||
|
<div class="log-date">📅 ${l.tanggal_bantuan} · ${esc(l.nama_rumah_ibadah)}</div>
|
||||||
|
</div>`).join('') : '<div class="loading">Belum ada riwayat bantuan</div>'}
|
||||||
|
</div>
|
||||||
|
<div class="layer-section-title">Tambah Bantuan Baru</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Dari Rumah Ibadah *</label>
|
||||||
|
<select class="form-select" id="lb-ri">
|
||||||
|
${ris.map(r => `<option value="${r.id}">${esc(r.nama)} (${r.jenis})</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Tipe Bantuan *</label>
|
||||||
|
<select class="form-select" id="lb-tipe">
|
||||||
|
<option value="Pemberdayaan">📚 Pemberdayaan (Pelatihan)</option>
|
||||||
|
<option value="Konsumtif">🛍 Konsumtif (Makan/Barang)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Tanggal *</label>
|
||||||
|
<input class="form-input" id="lb-tgl" type="date" value="${new Date().toISOString().slice(0, 10)}"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Sub Kategori</label>
|
||||||
|
<input class="form-input" id="lb-sub" placeholder="Pelatihan menjahit / Sembako…"/>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Keterangan</label>
|
||||||
|
<input class="form-input" id="lb-ket" placeholder="Opsional"/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Nilai (Rp)</label>
|
||||||
|
<input class="form-input" id="lb-nilai" type="number" placeholder="0"/>
|
||||||
|
</div>
|
||||||
|
</div>`,
|
||||||
|
`<button class="btn btn-ghost" onclick="closeModal()">Tutup</button>
|
||||||
|
<button class="btn btn-primary" onclick="saveLog(${pmId})">➕ Tambah Log</button>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLog(pmId) {
|
||||||
|
const body = {
|
||||||
|
penduduk_miskin_id: pmId,
|
||||||
|
rumah_ibadah_id: parseInt(document.getElementById('lb-ri').value),
|
||||||
|
tipe_bantuan: document.getElementById('lb-tipe').value,
|
||||||
|
tanggal_bantuan: document.getElementById('lb-tgl').value,
|
||||||
|
sub_kategori: document.getElementById('lb-sub').value,
|
||||||
|
keterangan: document.getElementById('lb-ket').value,
|
||||||
|
nilai_bantuan: document.getElementById('lb-nilai').value || null
|
||||||
|
};
|
||||||
|
const res = await callAPI('log_bantuan.php', 'POST', body);
|
||||||
|
if (res.status === 'success') {
|
||||||
|
toast('Log dicatat!', 'success');
|
||||||
|
closeModal();
|
||||||
|
switchTab('kemiskinan');
|
||||||
|
} else {
|
||||||
|
toast(res.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recalcAllProximity() {
|
||||||
|
await callAPI('penduduk_miskin.php?action=recalc_all', 'POST');
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
const parsilCfg = {
|
||||||
|
SHM: { color: '#16a34a', label: 'Sertifikat Hak Milik' },
|
||||||
|
HGB: { color: '#2563eb', label: 'Hak Guna Bangunan' },
|
||||||
|
HGU: { color: '#d97706', label: 'Hak Guna Usaha' },
|
||||||
|
HP: { color: '#7c3aed', label: 'Hak Pakai' }
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderParsilPanel(pb) {
|
||||||
|
return callAPI('data_parsil.php').then(res => {
|
||||||
|
const data = res.data || [];
|
||||||
|
const cnt = k => data.filter(d => d.jenis_hak === k).length;
|
||||||
|
|
||||||
|
pb.innerHTML = `
|
||||||
|
<div class="layer-section">
|
||||||
|
<div class="layer-section-title">Filter Layer</div>
|
||||||
|
${Object.entries(parsilCfg).map(([k, v]) => `
|
||||||
|
<div class="layer-item ${layerVisible['parsil' + k] ? 'checked' : ''}" onclick="toggleLayer('parsil${k}',this)">
|
||||||
|
<div class="layer-check">${layerVisible['parsil' + k] ? '✓' : ''}</div>
|
||||||
|
<div class="layer-dot" style="background:${v.color}"></div>
|
||||||
|
<div class="layer-label">${k} — ${v.label}</div>
|
||||||
|
<div class="layer-count">${cnt(k)}</div>
|
||||||
|
</div>`).join('')}
|
||||||
|
</div>
|
||||||
|
<div class="layer-section">
|
||||||
|
<div class="layer-section-title">Cara Menambahkan Data Parsil</div>
|
||||||
|
<div style="font-size:11px;color:var(--text2);line-height:1.9;padding:10px 12px;background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius)">
|
||||||
|
1. Klik ikon <b style="color:var(--accent)">polygon</b> di toolbar kiri peta<br>
|
||||||
|
2. Klik titik-titik batas bidang tanah<br>
|
||||||
|
3. Klik titik awal / <b>double-klik</b> untuk menutup<br>
|
||||||
|
4. Form simpan + luas otomatis muncul
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layer-section">
|
||||||
|
<div class="layer-section-title">Data Parsil (${data.length})</div>
|
||||||
|
<div class="data-list">
|
||||||
|
${data.length ? data.map(d => `
|
||||||
|
<div class="data-item" onclick="focusGeom(${d.id},'parsil')">
|
||||||
|
<div class="item-icon">📐</div>
|
||||||
|
<div class="item-info">
|
||||||
|
<div class="item-name">${esc(d.nama_pemilik)}</div>
|
||||||
|
<div class="item-sub">${d.luas_m2.toFixed(1)} m² · ${d.no_sertifikat || '—'}</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:5px">
|
||||||
|
<div class="item-badge" style="background:${parsilCfg[d.jenis_hak].color}18;color:${parsilCfg[d.jenis_hak].color};border:1px solid ${parsilCfg[d.jenis_hak].color}44">${d.jenis_hak}</div>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="event.stopPropagation();editParsil(${d.id})">✏</button>
|
||||||
|
<button class="btn btn-danger btn-sm" onclick="event.stopPropagation();deleteParsil(${d.id})">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>`).join('') : '<div class="loading">Belum ada data parsil</div>'}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
loadParsilLayer(data);
|
||||||
|
registerSearchEntries('parsil', data.map(d => ({
|
||||||
|
key: `parsil:${d.id}`,
|
||||||
|
label: d.nama_pemilik,
|
||||||
|
subtitle: `Parsil ${d.jenis_hak} · ${d.luas_m2.toFixed(1)} m²${d.no_sertifikat ? ` · ${d.no_sertifikat}` : ''}`,
|
||||||
|
typeLabel: 'Parsil',
|
||||||
|
icon: '📐',
|
||||||
|
searchText: normalizeSearchText(`${d.nama_pemilik} ${d.jenis_hak} parsil tanah`),
|
||||||
|
focus: () => focusGeom(d.id, 'parsil')
|
||||||
|
})));
|
||||||
|
updateStatusCount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadParsilLayer(data) {
|
||||||
|
['SHM', 'HGB', 'HGU', 'HP'].forEach(k => layers['parsil' + k].clearLayers());
|
||||||
|
data.forEach(d => {
|
||||||
|
const cfg = parsilCfg[d.jenis_hak], coords = d.geojson.coordinates[0].map(c => [c[1], c[0]]);
|
||||||
|
const poly = L.polygon(coords, { color: cfg.color, fillColor: cfg.color, fillOpacity: 0.18, weight: 2 })
|
||||||
|
.bindPopup(`<h4>📐 ${esc(d.nama_pemilik)}</h4>
|
||||||
|
<p><b>Jenis Hak:</b> ${d.jenis_hak} — ${cfg.label}</p>
|
||||||
|
<p><b>Luas:</b> ${d.luas_m2.toFixed(2)} m² (${(d.luas_m2 / 10000).toFixed(4)} ha)</p>
|
||||||
|
${d.no_sertifikat ? `<p><b>No. Sertifikat:</b> ${d.no_sertifikat}</p>` : ''}
|
||||||
|
${d.keterangan ? `<p>${esc(d.keterangan)}</p>` : ''}
|
||||||
|
<div class="popup-actions"><button class="btn btn-ghost btn-sm" onclick="editParsil(${d.id})">✏ Edit</button><button class="btn btn-danger btn-sm" onclick="deleteParsil(${d.id})">✕ Hapus</button></div>`);
|
||||||
|
poly._dataId = d.id;
|
||||||
|
poly._groupKey = 'parsil' + d.jenis_hak;
|
||||||
|
layers['parsil' + d.jenis_hak].addLayer(poly);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openParsilForm() {
|
||||||
|
openModal('Simpan Parsil Tanah', `
|
||||||
|
<div class="form-calc">📐 Luas: <b>${pendingArea.toFixed(2)} m²</b> = ${(pendingArea / 10000).toFixed(4)} ha </div>
|
||||||
|
<div class="form-group"><label class="form-label">Nama Pemilik *</label>
|
||||||
|
<input class="form-input" id="ps-nama" placeholder="Budi Santoso…"/></div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label class="form-label">Jenis Hak *</label>
|
||||||
|
<select class="form-select" id="ps-hak">
|
||||||
|
<option value="SHM">SHM — Hak Milik</option>
|
||||||
|
<option value="HGB">HGB — Guna Bangunan</option>
|
||||||
|
<option value="HGU">HGU — Guna Usaha</option>
|
||||||
|
<option value="HP">HP — Hak Pakai</option>
|
||||||
|
</select></div>
|
||||||
|
<div class="form-group"><label class="form-label">No. Sertifikat</label>
|
||||||
|
<input class="form-input" id="ps-sert" placeholder="00001/Kel/…"/></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label class="form-label">Keterangan</label>
|
||||||
|
<input class="form-input" id="ps-ket" placeholder="Opsional…"/></div>`,
|
||||||
|
`<button class="btn btn-ghost" onclick="closeModal();drawnItems.clearLayers()">Batal</button>
|
||||||
|
<button class="btn btn-primary" onclick="saveParsil()">💾 Simpan</button>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveParsil() {
|
||||||
|
const body = {
|
||||||
|
nama_pemilik: document.getElementById('ps-nama').value.trim(),
|
||||||
|
jenis_hak: document.getElementById('ps-hak').value,
|
||||||
|
no_sertifikat: document.getElementById('ps-sert').value,
|
||||||
|
luas_m2: pendingArea,
|
||||||
|
keterangan: document.getElementById('ps-ket').value,
|
||||||
|
geojson: pendingGeoJSON
|
||||||
|
};
|
||||||
|
if (!body.nama_pemilik) return toast('Nama wajib diisi', 'error');
|
||||||
|
const res = await callAPI('data_parsil.php', 'POST', body);
|
||||||
|
if (res.status === 'success') {
|
||||||
|
toast(res.message, 'success');
|
||||||
|
closeModal();
|
||||||
|
drawnItems.clearLayers();
|
||||||
|
pendingGeoJSON = null;
|
||||||
|
pendingArea = null;
|
||||||
|
switchTab('parsil');
|
||||||
|
} else {
|
||||||
|
toast(res.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editParsil(id) {
|
||||||
|
map.closePopup();
|
||||||
|
const res = await callAPI(`data_parsil.php?id=${id}`);
|
||||||
|
const data = res.data;
|
||||||
|
const sourceLayer = findGeomLayer('parsil', id);
|
||||||
|
if (!data) return toast('Data parsil tidak ditemukan', 'error');
|
||||||
|
|
||||||
|
const groupKey = getGeometryGroupKey('parsil', data);
|
||||||
|
const editLayer = sourceLayer ? cloneEditableLayer('parsil', sourceLayer) : buildGeometryLayer('parsil', data);
|
||||||
|
editLayer._dataId = id;
|
||||||
|
editLayer._groupKey = groupKey;
|
||||||
|
|
||||||
|
if (sourceLayer) layers[groupKey].removeLayer(sourceLayer);
|
||||||
|
drawnItems.clearLayers();
|
||||||
|
drawnItems.addLayer(editLayer);
|
||||||
|
|
||||||
|
geometryEditState = { type: 'parsil', id, groupKey, sourceLayer, editLayer };
|
||||||
|
if (editLayer.editing && editLayer.editing.enable) {
|
||||||
|
editLayer.editing.enable();
|
||||||
|
} else {
|
||||||
|
const handler = getGeometryEditHandler();
|
||||||
|
if (!handler) {
|
||||||
|
drawnItems.clearLayers();
|
||||||
|
if (sourceLayer) layers[groupKey].addLayer(sourceLayer);
|
||||||
|
geometryEditState = null;
|
||||||
|
return toast('Mode edit geometri belum siap', 'error');
|
||||||
|
}
|
||||||
|
handler.enable();
|
||||||
|
}
|
||||||
|
map.fitBounds(editLayer.getBounds(), { padding: [50, 50], duration: 1 });
|
||||||
|
document.getElementById('panel-title').textContent = 'Edit Parsil Tanah';
|
||||||
|
document.getElementById('panel-body').innerHTML = `
|
||||||
|
<div class="layer-section">
|
||||||
|
<div class="layer-section-title">Edit Parsil</div>
|
||||||
|
<div style="font-size:11px;line-height:1.7;color:var(--text2);padding:10px 12px;background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:12px">
|
||||||
|
Geser titik-titik batas bidang di peta lalu simpan perubahan.
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Nama Pemilik *</label>
|
||||||
|
<input class="form-input" id="ps-edit-nama" value="${esc(data.nama_pemilik)}" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Jenis Hak *</label>
|
||||||
|
<select class="form-select" id="ps-edit-hak">
|
||||||
|
${Object.entries(parsilCfg).map(([k, v]) => `<option value="${k}"${data.jenis_hak === k ? ' selected' : ''}>${k} — ${v.label}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Luas Otomatis</label>
|
||||||
|
<input class="form-input" id="ps-edit-luas" value="${data.luas_m2.toFixed(2)} m²" disabled />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">No. Sertifikat</label>
|
||||||
|
<input class="form-input" id="ps-edit-sert" value="${esc(data.no_sertifikat || '')}" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Keterangan</label>
|
||||||
|
<input class="form-input" id="ps-edit-ket" value="${esc(data.keterangan || '')}" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
document.getElementById('panel-body').insertAdjacentHTML('beforeend', `
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
<button class="btn btn-ghost" style="flex:1" onclick="cancelGeometryEdit()">Batal</button>
|
||||||
|
<button class="btn btn-primary" style="flex:1" onclick="saveParsilEdit(${id})">Simpan Perubahan</button>
|
||||||
|
</div>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveParsilEdit(id) {
|
||||||
|
const state = geometryEditState;
|
||||||
|
if (!state || state.type !== 'parsil') return;
|
||||||
|
const layer = state.editLayer;
|
||||||
|
const ringLatLngs = layer.getLatLngs()[0];
|
||||||
|
const ring = ringLatLngs.map(ll => [ll.lng, ll.lat]);
|
||||||
|
ring.push([ringLatLngs[0].lng, ringLatLngs[0].lat]);
|
||||||
|
const luas_m2 = turf.area(turf.polygon([ring]));
|
||||||
|
const body = {
|
||||||
|
nama_pemilik: document.getElementById('ps-edit-nama').value.trim(),
|
||||||
|
jenis_hak: document.getElementById('ps-edit-hak').value,
|
||||||
|
no_sertifikat: document.getElementById('ps-edit-sert').value,
|
||||||
|
luas_m2,
|
||||||
|
keterangan: document.getElementById('ps-edit-ket').value,
|
||||||
|
geojson: { type: 'Polygon', coordinates: [ring] }
|
||||||
|
};
|
||||||
|
if (!body.nama_pemilik) return toast('Nama wajib diisi', 'error');
|
||||||
|
const res = await callAPI(`data_parsil.php?id=${id}`, 'PUT', body);
|
||||||
|
if (res.status === 'success') {
|
||||||
|
toast(res.message, 'success');
|
||||||
|
if (layer.editing && layer.editing.disable) layer.editing.disable();
|
||||||
|
const handler = getGeometryEditHandler();
|
||||||
|
if (handler) handler.disable();
|
||||||
|
drawnItems.clearLayers();
|
||||||
|
geometryEditState = null;
|
||||||
|
switchTab('parsil');
|
||||||
|
} else {
|
||||||
|
toast(res.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteParsil(id) {
|
||||||
|
if (!confirm('Hapus?')) return;
|
||||||
|
const r = await callAPI(`data_parsil.php?id=${id}`, 'DELETE');
|
||||||
|
if (r.status === 'success') {
|
||||||
|
toast('Dihapus', 'success');
|
||||||
|
map.closePopup();
|
||||||
|
switchTab('parsil');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
function renderSpbuPanel(pb) {
|
||||||
|
return callAPI('spbu.php').then(res => {
|
||||||
|
const data = res.data || [];
|
||||||
|
const d24 = data.filter(s => s.buka_24jam);
|
||||||
|
const dn24 = data.filter(s => !s.buka_24jam);
|
||||||
|
|
||||||
|
pb.innerHTML = `
|
||||||
|
<div class="layer-section">
|
||||||
|
<div class="layer-section-title">Filter Layer</div>
|
||||||
|
${mkLayerItem('spbu24', '#16a34a', 'SPBU Buka 24 Jam', d24.length)}
|
||||||
|
${mkLayerItem('spbuNon24', '#94a3b8', 'SPBU Tidak 24 Jam', dn24.length)}
|
||||||
|
</div>
|
||||||
|
<div class="layer-section">
|
||||||
|
<div class="layer-section-title">Tambah Data</div>
|
||||||
|
<button class="btn-add" id="btn-add-spbu" onclick="startAddSPBU()">+ Tambah SPBU</button>
|
||||||
|
</div>
|
||||||
|
<div class="layer-section">
|
||||||
|
<div class="layer-section-title">Daftar SPBU (${data.length})</div>
|
||||||
|
<div class="data-list">
|
||||||
|
${data.length ? data.map(s => `
|
||||||
|
<div class="data-item" onclick="map.flyTo([${s.latitude},${s.longitude}],17)">
|
||||||
|
<div class="item-icon">⛽</div>
|
||||||
|
<div class="item-info">
|
||||||
|
<div class="item-name">${esc(s.nama)}</div>
|
||||||
|
<div class="item-sub">${s.no_wa || '—'}</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:5px">
|
||||||
|
<div class="item-badge ${s.buka_24jam ? 'badge-green' : 'badge-amber'}">${s.buka_24jam ? '24 JAM' : 'TERBATAS'}</div>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="event.stopPropagation();editSPBU(${s.id})">✏</button>
|
||||||
|
<button class="btn btn-danger btn-sm" onclick="event.stopPropagation();deleteSPBU(${s.id})">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>`).join('') : '<div class="loading">Belum ada data</div>'}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
loadSPBULayer(data);
|
||||||
|
registerSearchEntries('spbu', data.map(s => ({
|
||||||
|
key: `spbu:${s.id}`,
|
||||||
|
label: s.nama,
|
||||||
|
subtitle: `SPBU · ${s.buka_24jam ? '24 Jam' : 'Tidak 24 Jam'}${s.alamat ? ` · ${s.alamat}` : ''}`,
|
||||||
|
typeLabel: 'SPBU',
|
||||||
|
icon: '⛽',
|
||||||
|
searchText: normalizeSearchText(`${s.nama} ${s.no_wa || ''} spbu`),
|
||||||
|
focus: () => focusMarkerById(s.buka_24jam ? 'spbu24' : 'spbuNon24', s.id)
|
||||||
|
})));
|
||||||
|
updateStatusCount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSPBULayer(data) {
|
||||||
|
layers.spbu24.clearLayers();
|
||||||
|
layers.spbuNon24.clearLayers();
|
||||||
|
data.forEach(s => {
|
||||||
|
const is24 = s.buka_24jam;
|
||||||
|
const color = is24 ? '#16a34a' : '#64748b';
|
||||||
|
const icon = L.divIcon({
|
||||||
|
className: '',
|
||||||
|
html: `<div style="width:30px;height:30px;background:${color};border:3px solid #fff;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:15px;box-shadow:0 3px 10px rgba(0,0,0,.2);cursor:grab;">⛽</div>`,
|
||||||
|
iconSize: [30, 30], iconAnchor: [15, 15]
|
||||||
|
});
|
||||||
|
const marker = L.marker([s.latitude, s.longitude], { icon, draggable: true }).bindPopup(`<h4>⛽ ${esc(s.nama)}</h4>
|
||||||
|
<p><b>Status:</b> ${is24 ? '<span style="color:#16a34a;font-weight:600">✅ Buka 24 Jam</span>' : '⏰ Tidak 24 Jam'}</p>
|
||||||
|
${s.no_wa ? `<p><b>WA:</b> ${s.no_wa}</p>` : ''}
|
||||||
|
${s.alamat ? `<p><b>Alamat:</b> ${esc(s.alamat)}</p>` : ''}
|
||||||
|
<div class="popup-drag-hint">↔ Seret marker untuk pindahkan lokasi — auto-save</div>
|
||||||
|
<div class="popup-actions">
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="editSPBU(${s.id})">✏ Edit</button>
|
||||||
|
<button class="btn btn-danger btn-sm" onclick="deleteSPBU(${s.id})">✕ Hapus</button>
|
||||||
|
</div>`);
|
||||||
|
marker._dataId = s.id;
|
||||||
|
|
||||||
|
marker.on('dragend', async function (ev) {
|
||||||
|
const { lat, lng } = ev.target.getLatLng();
|
||||||
|
const res = await callAPI(`spbu.php?id=${s.id}`, 'PUT', {
|
||||||
|
nama: s.nama, no_wa: s.no_wa, alamat: s.alamat,
|
||||||
|
buka_24jam: s.buka_24jam, latitude: lat, longitude: lng
|
||||||
|
});
|
||||||
|
if (res.status === 'success') {
|
||||||
|
s.latitude = lat;
|
||||||
|
s.longitude = lng;
|
||||||
|
toast(`📍 ${s.nama} dipindahkan`, 'success');
|
||||||
|
} else {
|
||||||
|
toast('Gagal update: ' + res.message, 'error');
|
||||||
|
marker.setLatLng([s.latitude, s.longitude]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
is24 ? layers.spbu24.addLayer(marker) : layers.spbuNon24.addLayer(marker);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAddSPBU() {
|
||||||
|
cancelMapClick();
|
||||||
|
document.getElementById('btn-add-spbu').classList.add('active');
|
||||||
|
showInstruction('Klik di peta untuk menentukan lokasi SPBU');
|
||||||
|
onceMapClick((lat, lng) => {
|
||||||
|
document.getElementById('btn-add-spbu').classList.remove('active');
|
||||||
|
hideInstruction();
|
||||||
|
openSPBUForm(null, lat, lng);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSPBUForm(data, lat, lng) {
|
||||||
|
const isEdit = !!data;
|
||||||
|
const fLat = isEdit ? data.latitude : lat;
|
||||||
|
const fLng = isEdit ? data.longitude : lng;
|
||||||
|
openModal(isEdit ? 'Edit SPBU' : 'Tambah SPBU', `
|
||||||
|
<div class="form-coord">📍 Koordinat: ${fLat.toFixed(7)}, ${fLng.toFixed(7)}</div>
|
||||||
|
<div class="form-group"><label class="form-label">Nama SPBU *</label>
|
||||||
|
<input class="form-input" id="spbu-nama" value="${esc(isEdit ? data.nama : '')}" placeholder="SPBU 64.751.01"/></div>
|
||||||
|
<div class="form-group"><label class="form-label">No. WhatsApp</label>
|
||||||
|
<input class="form-input" id="spbu-nowa" value="${esc(isEdit ? data.no_wa || '' : '')}" placeholder="081234…"/></div>
|
||||||
|
<div class="form-group"><label class="form-label">Alamat</label>
|
||||||
|
<input class="form-input" id="spbu-alamat" value="${esc(isEdit ? data.alamat || '' : '')}" placeholder="Jl. …"/></div>
|
||||||
|
<div class="form-group"><label class="form-label">Status Operasional</label>
|
||||||
|
<select class="form-select" id="spbu-24">
|
||||||
|
<option value="1"${isEdit && data.buka_24jam ? ' selected' : ''}>✅ Buka 24 Jam</option>
|
||||||
|
<option value="0"${isEdit && !data.buka_24jam ? ' selected' : ''}>⏰ Tidak 24 Jam</option>
|
||||||
|
</select></div>`,
|
||||||
|
`<button class="btn btn-ghost" onclick="closeModal()">Batal</button>
|
||||||
|
<button class="btn btn-primary" onclick="saveSPBU(${isEdit ? data.id : 'null'},${fLat},${fLng})">💾 Simpan</button>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSPBU(id, lat, lng) {
|
||||||
|
const body = {
|
||||||
|
nama: document.getElementById('spbu-nama').value.trim(),
|
||||||
|
no_wa: document.getElementById('spbu-nowa').value.trim(),
|
||||||
|
alamat: document.getElementById('spbu-alamat').value.trim(),
|
||||||
|
buka_24jam: document.getElementById('spbu-24').value === '1',
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lng
|
||||||
|
};
|
||||||
|
if (!body.nama) return toast('Nama wajib diisi', 'error');
|
||||||
|
const res = await callAPI(id ? `spbu.php?id=${id}` : 'spbu.php', id ? 'PUT' : 'POST', body);
|
||||||
|
if (res.status === 'success') {
|
||||||
|
toast(res.message, 'success');
|
||||||
|
closeModal();
|
||||||
|
switchTab('spbu');
|
||||||
|
} else {
|
||||||
|
toast(res.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editSPBU(id) {
|
||||||
|
map.closePopup();
|
||||||
|
const r = await callAPI(`spbu.php?id=${id}`);
|
||||||
|
if (r.data) openSPBUForm(r.data, r.data.latitude, r.data.longitude);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSPBU(id) {
|
||||||
|
if (!confirm('Hapus SPBU?')) return;
|
||||||
|
const r = await callAPI(`spbu.php?id=${id}`, 'DELETE');
|
||||||
|
if (r.status === 'success') {
|
||||||
|
toast('Dihapus', 'success');
|
||||||
|
map.closePopup();
|
||||||
|
switchTab('spbu');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user