Coba Commit

This commit is contained in:
naukyy
2026-06-12 22:46:13 +07:00
parent f167f233b7
commit 66bb4c4c7c
126 changed files with 1613 additions and 866 deletions
+13
View File
@@ -0,0 +1,13 @@
RewriteEngine On
# Specific file mappings from old API names to new refactored endpoint names
RewriteRule ^api/kawasan_kumuh\.php$ endpoints/api_kawasan.php [L,QSA]
RewriteRule ^api/rumah_ibadah\.php$ endpoints/api_fasilitas.php [L,QSA]
RewriteRule ^api/users\.php$ endpoints/api_pengguna.php [L,QSA]
RewriteRule ^api/spbu_terdekat\.php$ endpoints/api_spbu_nearby.php [L,QSA]
RewriteRule ^api/blank_spot\.php$ endpoints/area_blank.php [L,QSA]
RewriteRule ^api/check_srid\.php$ endpoints/cek_srid.php [L,QSA]
RewriteRule ^api/fix_srid\.php$ endpoints/perbaiki_srid.php [L,QSA]
# General pattern mapping: api/filename.php -> endpoints/api_filename.php
RewriteRule ^api/([a-zA-Z0-9_]+)\.php$ endpoints/api_$1.php [L,QSA]
+2
View File
@@ -0,0 +1,2 @@
<?php
require_once __DIR__ . '/../endpoints/area_blank.php';
+2
View File
@@ -0,0 +1,2 @@
<?php
require_once __DIR__ . '/../endpoints/cek_srid.php';
+2
View File
@@ -0,0 +1,2 @@
<?php
require_once __DIR__ . '/../endpoints/perbaiki_srid.php';
+2
View File
@@ -0,0 +1,2 @@
<?php
require_once __DIR__ . '/../endpoints/api_jalan.php';
+2
View File
@@ -0,0 +1,2 @@
<?php
require_once __DIR__ . '/../endpoints/api_kavling.php';
+2
View File
@@ -0,0 +1,2 @@
<?php
require_once __DIR__ . '/../endpoints/api_kawasan.php';
+2
View File
@@ -0,0 +1,2 @@
<?php
require_once __DIR__ . '/../endpoints/api_laporan.php';
+2
View File
@@ -0,0 +1,2 @@
<?php
require_once __DIR__ . '/../endpoints/api_fasilitas.php';
+2
View File
@@ -0,0 +1,2 @@
<?php
require_once __DIR__ . '/../endpoints/api_spbu.php';
+2
View File
@@ -0,0 +1,2 @@
<?php
require_once __DIR__ . '/../endpoints/api_spbu_status.php';
+2
View File
@@ -0,0 +1,2 @@
<?php
require_once __DIR__ . '/../endpoints/api_spbu_nearby.php';
+2
View File
@@ -0,0 +1,2 @@
<?php
require_once __DIR__ . '/../endpoints/api_statistik.php';
+2
View File
@@ -0,0 +1,2 @@
<?php
require_once __DIR__ . '/../endpoints/api_ulasan.php';
+2
View File
@@ -0,0 +1,2 @@
<?php
require_once __DIR__ . '/../endpoints/api_pengguna.php';
+2
View File
@@ -0,0 +1,2 @@
<?php
require_once __DIR__ . '/../endpoints/api_warga_miskin.php';
+58
View File
@@ -0,0 +1,58 @@
<?php
/**
* Singleton PDO connection.
*
* Coolify: set DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, and DB_PASSWORD
* from the database service credentials.
*/
require_once __DIR__ . '/env_loader.php';
class Database {
private static $conn = null;
private static function env(string $key, string $default = ''): string {
$value = getenv($key);
return ($value === false || $value === '') ? $default : $value;
}
public static function getConnection() {
if (self::$conn === null) {
$dbUrl = self::env('DATABASE_URL');
if ($dbUrl) {
$parsed = parse_url($dbUrl);
$host = $parsed['host'] ?? '127.0.0.1';
$port = $parsed['port'] ?? '3306';
$username = $parsed['user'] ?? 'root';
$password = $parsed['pass'] ?? '';
$dbName = ltrim($parsed['path'], '/');
} else {
$host = self::env('DB_HOST', '127.0.0.1');
$port = self::env('DB_PORT', '3306');
$dbName = self::env('DB_DATABASE', 'webgis_db');
$username = self::env('DB_USERNAME', 'root');
$password = self::env('DB_PASSWORD', '');
}
self::$conn = new PDO(
"mysql:host={$host};port={$port};dbname={$dbName};charset=utf8mb4",
$username,
$password
);
self::$conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
self::$conn->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
// Disable strict mode for GROUP BY to prevent 1055 errors in Coolify's MariaDB
self::$conn->exec("SET SESSION sql_mode=(SELECT REPLACE(@@sql_mode,'ONLY_FULL_GROUP_BY',''))");
// Auto-initialize database if empty
$stmt = self::$conn->query("SHOW TABLES LIKE 'users'");
if ($stmt->rowCount() == 0) {
$sqlFile = __DIR__ . '/../../db_scripts/webgis_naufal_zaky.sql';
if (file_exists($sqlFile)) {
$sql = file_get_contents($sqlFile);
self::$conn->exec($sql);
}
}
}
return self::$conn;
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
/**
* Simple .env loader.
* Reads the .env file from the repository root and sets environment variables
* via putenv() so getenv() calls throughout the app work correctly.
* Only loads once per request.
*/
function loadEnv(): void {
static $loaded = false;
if ($loaded) return;
// Walk up from webgis_app/config to the repository root
$envFile = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . '.env';
if (!file_exists($envFile)) {
$loaded = true;
return;
}
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if (!empty($lines)) {
if (substr($lines[0], 0, 3) === "\xEF\xBB\xBF") {
$lines[0] = substr($lines[0], 3);
}
}
foreach ($lines as $line) {
$line = trim($line);
// Skip comments
if ($line === '' || $line[0] === '#') continue;
if (strpos($line, '=') === false) continue;
[$key, $value] = explode('=', $line, 2);
$key = trim($key);
$value = trim($value);
// Remove surrounding quotes if present
if (strlen($value) >= 2 &&
(($value[0] === '"' && $value[-1] === '"') ||
($value[0] === "'" && $value[-1] === "'"))) {
$value = substr($value, 1, -1);
}
// Only set if not already defined in the environment (server env wins)
if (getenv($key) === false) {
putenv("{$key}={$value}");
$_ENV[$key] = $value;
$_SERVER[$key] = $value;
}
}
$loaded = true;
}
// Auto-load on include
loadEnv();
@@ -0,0 +1,66 @@
<?php
/**
* auth_check.php — Middleware: pastikan user sudah login dan role sesuai.
* Usage: require_once __DIR__ . '/../core_config/middleware_auth.php';
* requireRole('admin'); // atau 'user'
*/
require_once __DIR__ . '/session_mgr.php';
if (session_status() === PHP_SESSION_NONE) {
startAppSession();
}
function isLoggedIn(): bool {
return isset($_SESSION['user_id']) && isset($_SESSION['role']);
}
function requireLogin(?string $redirect = null): void {
if (!isLoggedIn()) {
$redirect = $redirect ?? app_url('login.php');
header("Location: $redirect");
exit;
}
}
function requireRole(string $role, ?string $redirect = null): void {
requireLogin($redirect);
if ($_SESSION['role'] !== $role) {
// Redirect ke dashboard yang sesuai role-nya
if ($_SESSION['role'] === 'admin') {
header('Location: ' . app_url('panel_admin/index.php'));
} else {
header('Location: ' . app_url('panel_user/index.php'));
}
exit;
}
}
function currentUser(): array {
return [
'id' => $_SESSION['user_id'] ?? null,
'username' => $_SESSION['username'] ?? '',
'role' => $_SESSION['role'] ?? '',
'nama_lengkap'=> $_SESSION['nama_lengkap']?? '',
];
}
/**
* API-safe auth helpers: return JSON 401/403 instead of redirecting.
* Use these in endpoints called via fetch/AJAX.
*/
function requireApiLogin(): void {
if (!isLoggedIn()) {
http_response_code(401);
echo json_encode(['status' => 'error', 'message' => 'Autentikasi diperlukan']);
exit;
}
}
function requireApiRole(string $role): void {
requireApiLogin();
if ($_SESSION['role'] !== $role) {
http_response_code(403);
echo json_encode(['status' => 'error', 'message' => 'Akses ditolak: role tidak sesuai']);
exit;
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
require_once __DIR__ . '/settings.php';
function startAppSession(): void {
if (session_status() === PHP_SESSION_ACTIVE) {
return;
}
$secureEnv = strtolower((string)getenv('SESSION_SECURE'));
$isHttps = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https')
|| $secureEnv === 'true'
|| $secureEnv === '1';
session_set_cookie_params([
'lifetime' => 0,
'path' => app_base_path() ?: '/',
'secure' => $isHttps,
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
}
+29
View File
@@ -0,0 +1,29 @@
<?php
/**
* Application path helpers.
*
* Set APP_BASE_PATH to the subdirectory that serves webgis_app.
* In this repository's Coolify image the final app runs at /webgis_app.
*/
require_once __DIR__ . '/env_loader.php';
function app_base_path(): string {
$basePath = getenv('APP_BASE_PATH');
if ($basePath === false) {
$basePath = '/project/webgis_app';
}
$basePath = trim($basePath);
if ($basePath === '' || $basePath === '/') {
return '';
}
return '/' . trim($basePath, '/');
}
function app_url(string $path = ''): string {
if ($path === '') {
return app_base_path();
}
$path = '/' . ltrim($path, '/');
return app_base_path() . $path;
}
+113
View File
@@ -0,0 +1,113 @@
<?php
/**
* api_fasilitas.php
* Endpoint: CRUD untuk layer Rumah Ibadah (Point).
*/
require_once __DIR__ . '/../core_config/database.php';
require_once __DIR__ . '/../core_config/middleware_auth.php';
header('Content-Type: application/json; charset=utf-8');
function sendSuccess($data = null, $message = 'Success', $code = 200) {
http_response_code($code);
echo json_encode(['status' => 'success', 'message' => $message, 'data' => $data]);
exit();
}
function sendError($message = 'Error', $code = 400) {
http_response_code($code);
echo json_encode(['status' => 'error', 'message' => $message]);
exit();
}
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
switch ($method) {
case 'GET':
try {
$stmt = $pdo->query(
"SELECT id, nama, agama, radius_bantuan_meter, created_at,
ST_AsGeoJSON(geom) AS geojson FROM rumah_ibadah"
);
$features = [];
while ($row = $stmt->fetch()) {
$features[] = [
'type' => 'Feature',
'geometry' => json_decode($row['geojson']),
'properties' => [
'id' => $row['id'],
'nama' => $row['nama'],
'agama' => $row['agama'],
'radius_bantuan_meter' => $row['radius_bantuan_meter'],
'created_at' => $row['created_at'],
],
];
}
sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Rumah Ibadah berhasil diambil');
} catch (PDOException $e) {
sendError('Gagal mengambil data Rumah Ibadah: ' . $e->getMessage(), 500);
}
break;
case 'POST':
requireApiRole('admin');
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['nama']) || !isset($input['agama']) || !isset($input['geometry'])) {
sendError('Nama, agama, dan geometri wajib diisi');
}
try {
$stmt = $pdo->prepare(
"INSERT INTO rumah_ibadah (nama, agama, radius_bantuan_meter, geom)
VALUES (:nama, :agama, :radius_bantuan_meter, ST_GeomFromGeoJSON(:geometry))"
);
$stmt->execute([
':nama' => $input['nama'],
':agama' => $input['agama'],
':radius_bantuan_meter' => $input['radius_bantuan_meter'] ?? 1000,
':geometry' => json_encode($input['geometry']),
]);
sendSuccess(['id' => $pdo->lastInsertId()], 'Data Rumah Ibadah berhasil disimpan', 201);
} catch (PDOException $e) {
sendError('Gagal menyimpan Rumah Ibadah: ' . $e->getMessage(), 500);
}
break;
case 'PUT':
requireApiRole('admin');
$id = $_GET['id'] ?? null;
if (!$id) sendError('ID Rumah Ibadah wajib disertakan');
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) sendError('Data tidak valid');
try {
$stmt = $pdo->prepare(
"UPDATE rumah_ibadah SET nama = :nama, agama = :agama, radius_bantuan_meter = :radius_bantuan_meter WHERE id = :id"
);
$stmt->execute([
':nama' => $input['nama'],
':agama' => $input['agama'],
':radius_bantuan_meter' => $input['radius_bantuan_meter'] ?? 1000,
':id' => $id,
]);
sendSuccess(null, 'Data Rumah Ibadah berhasil diperbarui');
} catch (PDOException $e) {
sendError('Gagal memperbarui Rumah Ibadah: ' . $e->getMessage(), 500);
}
break;
case 'DELETE':
requireApiRole('admin');
$id = $_GET['id'] ?? null;
if (!$id) sendError('ID Rumah Ibadah wajib disertakan');
try {
$stmt = $pdo->prepare("DELETE FROM rumah_ibadah WHERE id = :id");
$stmt->execute([':id' => $id]);
sendSuccess(null, 'Data Rumah Ibadah berhasil dihapus');
} catch (PDOException $e) {
sendError('Gagal menghapus Rumah Ibadah: ' . $e->getMessage(), 500);
}
break;
default:
sendError('Method not allowed', 405);
}
+88
View File
@@ -0,0 +1,88 @@
<?php
/**
* api_jalan.php
* Endpoint: CRUD untuk layer Jalan (LineString).
*/
require_once __DIR__ . '/../core_config/database.php';
require_once __DIR__ . '/../core_config/middleware_auth.php';
header('Content-Type: application/json; charset=utf-8');
function sendSuccess($data = null, $message = 'Success', $code = 200) {
http_response_code($code);
echo json_encode(['status' => 'success', 'message' => $message, 'data' => $data]);
exit();
}
function sendError($message = 'Error', $code = 400) {
http_response_code($code);
echo json_encode(['status' => 'error', 'message' => $message]);
exit();
}
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
switch ($method) {
case 'GET':
try {
$stmt = $pdo->query(
"SELECT id, nama, jenis_jalan, created_at, ST_AsGeoJSON(geom) AS geojson FROM jalan"
);
$features = [];
while ($row = $stmt->fetch()) {
$features[] = [
'type' => 'Feature',
'geometry' => json_decode($row['geojson']),
'properties' => [
'id' => $row['id'],
'nama' => $row['nama'],
'jenis_jalan' => $row['jenis_jalan'],
'created_at' => $row['created_at'],
],
];
}
sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Jalan berhasil diambil');
} catch (PDOException $e) {
sendError('Gagal mengambil data Jalan: ' . $e->getMessage(), 500);
}
break;
case 'POST':
requireApiRole('admin');
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['nama']) || !isset($input['geometry'])) {
sendError('Nama dan geometri wajib diisi');
}
try {
$stmt = $pdo->prepare(
"INSERT INTO jalan (nama, jenis_jalan, geom)
VALUES (:nama, :jenis_jalan, ST_GeomFromGeoJSON(:geometry))"
);
$stmt->execute([
':nama' => $input['nama'],
':jenis_jalan' => $input['jenis_jalan'] ?? 'Lokal',
':geometry' => json_encode($input['geometry']),
]);
sendSuccess(['id' => $pdo->lastInsertId()], 'Data Jalan berhasil disimpan', 201);
} catch (PDOException $e) {
sendError('Gagal menyimpan Jalan: ' . $e->getMessage(), 500);
}
break;
case 'DELETE':
requireApiRole('admin');
$id = $_GET['id'] ?? null;
if (!$id) sendError('ID Jalan wajib disertakan');
try {
$stmt = $pdo->prepare("DELETE FROM jalan WHERE id = :id");
$stmt->execute([':id' => $id]);
sendSuccess(null, 'Data Jalan berhasil dihapus');
} catch (PDOException $e) {
sendError('Gagal menghapus Jalan: ' . $e->getMessage(), 500);
}
break;
default:
sendError('Method not allowed', 405);
}
+91
View File
@@ -0,0 +1,91 @@
<?php
/**
* api_kavling.php
* Endpoint: CRUD untuk layer Kavling (Polygon).
*/
require_once __DIR__ . '/../core_config/database.php';
require_once __DIR__ . '/../core_config/middleware_auth.php';
header('Content-Type: application/json; charset=utf-8');
function sendSuccess($data = null, $message = 'Success', $code = 200) {
http_response_code($code);
echo json_encode(['status' => 'success', 'message' => $message, 'data' => $data]);
exit();
}
function sendError($message = 'Error', $code = 400) {
http_response_code($code);
echo json_encode(['status' => 'error', 'message' => $message]);
exit();
}
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
switch ($method) {
case 'GET':
try {
$stmt = $pdo->query(
"SELECT id, nama_pemilik, status_kepemilikan, luas, created_at,
ST_AsGeoJSON(geom) AS geojson FROM kavling"
);
$features = [];
while ($row = $stmt->fetch()) {
$features[] = [
'type' => 'Feature',
'geometry' => json_decode($row['geojson']),
'properties' => [
'id' => $row['id'],
'nama_pemilik' => $row['nama_pemilik'],
'status_kepemilikan' => $row['status_kepemilikan'],
'luas' => $row['luas'],
'created_at' => $row['created_at'],
],
];
}
sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Kavling berhasil diambil');
} catch (PDOException $e) {
sendError('Gagal mengambil data Kavling: ' . $e->getMessage(), 500);
}
break;
case 'POST':
requireApiRole('admin');
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['nama_pemilik']) || !isset($input['geometry'])) {
sendError('Nama pemilik dan geometri wajib diisi');
}
try {
$stmt = $pdo->prepare(
"INSERT INTO kavling (nama_pemilik, status_kepemilikan, luas, geom)
VALUES (:nama_pemilik, :status_kepemilikan, :luas, ST_GeomFromGeoJSON(:geometry))"
);
$stmt->execute([
':nama_pemilik' => $input['nama_pemilik'],
':status_kepemilikan' => $input['status_kepemilikan'] ?? 'SHM',
':luas' => $input['luas'] ?? 0,
':geometry' => json_encode($input['geometry']),
]);
sendSuccess(['id' => $pdo->lastInsertId()], 'Data Kavling berhasil disimpan', 201);
} catch (PDOException $e) {
sendError('Gagal menyimpan Kavling: ' . $e->getMessage(), 500);
}
break;
case 'DELETE':
requireApiRole('admin');
$id = $_GET['id'] ?? null;
if (!$id) sendError('ID Kavling wajib disertakan');
try {
$stmt = $pdo->prepare("DELETE FROM kavling WHERE id = :id");
$stmt->execute([':id' => $id]);
sendSuccess(null, 'Data Kavling berhasil dihapus');
} catch (PDOException $e) {
sendError('Gagal menghapus Kavling: ' . $e->getMessage(), 500);
}
break;
default:
sendError('Method not allowed', 405);
}
+86
View File
@@ -0,0 +1,86 @@
<?php
/**
* api_kawasan.php
* Endpoint: CRUD untuk layer Kawasan Kumuh (Polygon).
*/
require_once __DIR__ . '/../core_config/database.php';
require_once __DIR__ . '/../core_config/middleware_auth.php';
header('Content-Type: application/json; charset=utf-8');
function sendSuccess($data = null, $message = 'Success', $code = 200) {
http_response_code($code);
echo json_encode(['status' => 'success', 'message' => $message, 'data' => $data]);
exit();
}
function sendError($message = 'Error', $code = 400) {
http_response_code($code);
echo json_encode(['status' => 'error', 'message' => $message]);
exit();
}
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
switch ($method) {
case 'GET':
try {
$stmt = $pdo->query(
"SELECT id, nama_kawasan, created_at, ST_AsGeoJSON(geom) AS geojson FROM kawasan_kumuh"
);
$features = [];
while ($row = $stmt->fetch()) {
$features[] = [
'type' => 'Feature',
'geometry' => json_decode($row['geojson']),
'properties' => [
'id' => $row['id'],
'nama_kawasan' => $row['nama_kawasan'],
'created_at' => $row['created_at'],
],
];
}
sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Kawasan Kumuh berhasil diambil');
} catch (PDOException $e) {
sendError('Gagal mengambil data Kawasan Kumuh: ' . $e->getMessage(), 500);
}
break;
case 'POST':
requireApiRole('admin');
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['nama_kawasan']) || !isset($input['geometry'])) {
sendError('Nama kawasan dan geometri wajib diisi');
}
try {
$stmt = $pdo->prepare(
"INSERT INTO kawasan_kumuh (nama_kawasan, geom)
VALUES (:nama_kawasan, ST_GeomFromGeoJSON(:geometry))"
);
$stmt->execute([
':nama_kawasan' => $input['nama_kawasan'],
':geometry' => json_encode($input['geometry']),
]);
sendSuccess(['id' => $pdo->lastInsertId()], 'Data Kawasan Kumuh berhasil disimpan', 201);
} catch (PDOException $e) {
sendError('Gagal menyimpan Kawasan Kumuh: ' . $e->getMessage(), 500);
}
break;
case 'DELETE':
requireApiRole('admin');
$id = $_GET['id'] ?? null;
if (!$id) sendError('ID Kawasan Kumuh wajib disertakan');
try {
$stmt = $pdo->prepare("DELETE FROM kawasan_kumuh WHERE id = :id");
$stmt->execute([':id' => $id]);
sendSuccess(null, 'Data Kawasan Kumuh berhasil dihapus');
} catch (PDOException $e) {
sendError('Gagal menghapus Kawasan Kumuh: ' . $e->getMessage(), 500);
}
break;
default:
sendError('Method not allowed', 405);
}
+117
View File
@@ -0,0 +1,117 @@
<?php
/**
* api_laporan.php
* Endpoint: CRUD untuk Laporan Warga.
*/
require_once __DIR__ . '/../core_config/database.php';
require_once __DIR__ . '/../core_config/middleware_auth.php';
require_once __DIR__ . '/../core_config/session_mgr.php';
header('Content-Type: application/json; charset=utf-8');
function sendSuccess($data = null, $message = 'Success', $code = 200) {
http_response_code($code);
echo json_encode(['status' => 'success', 'message' => $message, 'data' => $data]);
exit();
}
function sendError($message = 'Error', $code = 400) {
http_response_code($code);
echo json_encode(['status' => 'error', 'message' => $message]);
exit();
}
startAppSession();
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
switch ($method) {
case 'GET':
requireApiLogin();
try {
$userId = $_SESSION['user_id'] ?? null;
$role = $_SESSION['role'] ?? 'user';
if ($role === 'admin') {
$stmt = $pdo->query(
"SELECT lw.id, lw.user_id, u.username, lw.kategori, lw.deskripsi,
lw.status, lw.created_at, ST_AsGeoJSON(lw.geometry) AS geojson
FROM laporan_warga lw
JOIN users u ON u.id = lw.user_id
ORDER BY lw.created_at DESC"
);
} else {
$stmt = $pdo->prepare(
"SELECT id, user_id, kategori, deskripsi, status, created_at,
ST_AsGeoJSON(geometry) AS geojson
FROM laporan_warga WHERE user_id = :uid ORDER BY created_at DESC"
);
$stmt->execute([':uid' => $userId]);
}
$rows = $stmt->fetchAll();
foreach ($rows as &$r) {
$r['geojson'] = json_decode($r['geojson']);
}
sendSuccess($rows, 'Data Laporan berhasil diambil');
} catch (PDOException $e) {
sendError('Gagal mengambil laporan: ' . $e->getMessage(), 500);
}
break;
case 'POST':
requireApiLogin();
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['kategori']) || !isset($input['deskripsi']) || !isset($input['geometry'])) {
sendError('Kategori, deskripsi, dan geometri wajib diisi');
}
$userId = $_SESSION['user_id'];
try {
$stmt = $pdo->prepare(
"INSERT INTO laporan_warga (user_id, kategori, deskripsi, geometry)
VALUES (:user_id, :kategori, :deskripsi, ST_GeomFromGeoJSON(:geometry))"
);
$stmt->execute([
':user_id' => $userId,
':kategori' => $input['kategori'],
':deskripsi'=> $input['deskripsi'],
':geometry' => json_encode($input['geometry']),
]);
sendSuccess(['id' => $pdo->lastInsertId()], 'Laporan berhasil dikirim', 201);
} catch (PDOException $e) {
sendError('Gagal menyimpan laporan: ' . $e->getMessage(), 500);
}
break;
case 'PUT':
requireApiRole('admin');
$input = json_decode(file_get_contents('php://input'), true);
$id = $_GET['id'] ?? null;
if (!$id || !isset($input['status'])) sendError('ID dan status wajib disertakan');
$allowed = ['menunggu', 'diproses', 'selesai', 'ditolak'];
if (!in_array($input['status'], $allowed)) sendError('Status tidak valid');
try {
$stmt = $pdo->prepare("UPDATE laporan_warga SET status = :status WHERE id = :id");
$stmt->execute([':status' => $input['status'], ':id' => $id]);
sendSuccess(null, 'Status laporan berhasil diperbarui');
} catch (PDOException $e) {
sendError('Gagal memperbarui laporan: ' . $e->getMessage(), 500);
}
break;
case 'DELETE':
requireApiRole('admin');
$id = $_GET['id'] ?? null;
if (!$id) sendError('ID laporan wajib disertakan');
try {
$stmt = $pdo->prepare("DELETE FROM laporan_warga WHERE id = :id");
$stmt->execute([':id' => $id]);
sendSuccess(null, 'Laporan berhasil dihapus');
} catch (PDOException $e) {
sendError('Gagal menghapus laporan: ' . $e->getMessage(), 500);
}
break;
default:
sendError('Method not allowed', 405);
}
+77
View File
@@ -0,0 +1,77 @@
<?php
/**
* api_pengguna.php
* Endpoint: Manajemen data Pengguna (hanya Admin).
*/
require_once __DIR__ . '/../core_config/database.php';
require_once __DIR__ . '/../core_config/middleware_auth.php';
header('Content-Type: application/json; charset=utf-8');
function sendSuccess($data = null, $message = 'Success', $code = 200) {
http_response_code($code);
echo json_encode(['status' => 'success', 'message' => $message, 'data' => $data]);
exit();
}
function sendError($message = 'Error', $code = 400) {
http_response_code($code);
echo json_encode(['status' => 'error', 'message' => $message]);
exit();
}
requireApiRole('admin');
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
switch ($method) {
case 'GET':
try {
$stmt = $pdo->query(
"SELECT id, username, role, nama_lengkap, created_at FROM users ORDER BY created_at DESC"
);
sendSuccess($stmt->fetchAll(), 'Data Pengguna berhasil diambil');
} catch (PDOException $e) {
sendError('Gagal mengambil data pengguna: ' . $e->getMessage(), 500);
}
break;
case 'POST':
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['username'], $input['password'])) {
sendError('Username dan password wajib diisi');
}
try {
$hash = password_hash($input['password'], PASSWORD_BCRYPT);
$stmt = $pdo->prepare(
"INSERT INTO users (username, password, role, nama_lengkap)
VALUES (:username, :password, :role, :nama_lengkap)"
);
$stmt->execute([
':username' => $input['username'],
':password' => $hash,
':role' => $input['role'] ?? 'user',
':nama_lengkap'=> $input['nama_lengkap'] ?? null,
]);
sendSuccess(['id' => $pdo->lastInsertId()], 'Pengguna berhasil dibuat', 201);
} catch (PDOException $e) {
sendError('Gagal membuat pengguna: ' . $e->getMessage(), 500);
}
break;
case 'DELETE':
$id = $_GET['id'] ?? null;
if (!$id) sendError('ID pengguna wajib disertakan');
try {
$stmt = $pdo->prepare("DELETE FROM users WHERE id = :id");
$stmt->execute([':id' => $id]);
sendSuccess(null, 'Pengguna berhasil dihapus');
} catch (PDOException $e) {
sendError('Gagal menghapus pengguna: ' . $e->getMessage(), 500);
}
break;
default:
sendError('Method not allowed', 405);
}
+91
View File
@@ -0,0 +1,91 @@
<?php
/**
* api_spbu.php
* Endpoint: CRUD untuk layer SPBU (Point).
*/
require_once __DIR__ . '/../core_config/database.php';
require_once __DIR__ . '/../core_config/middleware_auth.php';
header('Content-Type: application/json; charset=utf-8');
function sendSuccess($data = null, $message = 'Success', $code = 200) {
http_response_code($code);
echo json_encode(['status' => 'success', 'message' => $message, 'data' => $data]);
exit();
}
function sendError($message = 'Error', $code = 400) {
http_response_code($code);
echo json_encode(['status' => 'error', 'message' => $message]);
exit();
}
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
switch ($method) {
case 'GET':
try {
$stmt = $pdo->query(
"SELECT id, nama, deskripsi, buka_24_jam, created_at,
ST_AsGeoJSON(geom) AS geojson FROM spbu"
);
$features = [];
while ($row = $stmt->fetch()) {
$features[] = [
'type' => 'Feature',
'geometry' => json_decode($row['geojson']),
'properties' => [
'id' => $row['id'],
'nama' => $row['nama'],
'deskripsi' => $row['deskripsi'],
'buka_24_jam' => (bool) $row['buka_24_jam'],
'created_at' => $row['created_at'],
],
];
}
sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data SPBU berhasil diambil');
} catch (PDOException $e) {
sendError('Gagal mengambil data SPBU: ' . $e->getMessage(), 500);
}
break;
case 'POST':
requireApiRole('admin');
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['nama']) || !isset($input['geometry'])) {
sendError('Nama dan geometri wajib diisi');
}
try {
$stmt = $pdo->prepare(
"INSERT INTO spbu (nama, deskripsi, buka_24_jam, geom)
VALUES (:nama, :deskripsi, :buka_24_jam, ST_GeomFromGeoJSON(:geometry))"
);
$stmt->execute([
':nama' => $input['nama'],
':deskripsi' => $input['deskripsi'] ?? '',
':buka_24_jam' => isset($input['buka_24_jam']) && $input['buka_24_jam'] ? 1 : 0,
':geometry' => json_encode($input['geometry']),
]);
sendSuccess(['id' => $pdo->lastInsertId()], 'Data SPBU berhasil disimpan', 201);
} catch (PDOException $e) {
sendError('Gagal menyimpan SPBU: ' . $e->getMessage(), 500);
}
break;
case 'DELETE':
requireApiRole('admin');
$id = $_GET['id'] ?? null;
if (!$id) sendError('ID SPBU wajib disertakan');
try {
$stmt = $pdo->prepare("DELETE FROM spbu WHERE id = :id");
$stmt->execute([':id' => $id]);
sendSuccess(null, 'Data SPBU berhasil dihapus');
} catch (PDOException $e) {
sendError('Gagal menghapus SPBU: ' . $e->getMessage(), 500);
}
break;
default:
sendError('Method not allowed', 405);
}
+62
View File
@@ -0,0 +1,62 @@
<?php
/**
* api_spbu_nearby.php
* Endpoint: Mencari SPBU terdekat dari titik koordinat pengguna.
* Query params: lat, lng, radius (meter, default 5000)
*/
require_once __DIR__ . '/../core_config/database.php';
header('Content-Type: application/json; charset=utf-8');
function sendSuccess($data = null, $message = 'Success', $code = 200) {
http_response_code($code);
echo json_encode(['status' => 'success', 'message' => $message, 'data' => $data]);
exit();
}
function sendError($message = 'Error', $code = 400) {
http_response_code($code);
echo json_encode(['status' => 'error', 'message' => $message]);
exit();
}
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
if ($method !== 'GET') { sendError('Method not allowed', 405); }
$lat = isset($_GET['lat']) ? (float) $_GET['lat'] : null;
$lng = isset($_GET['lng']) ? (float) $_GET['lng'] : null;
$radius = isset($_GET['radius']) ? (int) $_GET['radius'] : 5000;
if ($lat === null || $lng === null) { sendError('Parameter lat dan lng wajib disertakan'); }
try {
$stmt = $pdo->prepare(
"SELECT id, nama, deskripsi, buka_24_jam, created_at,
ST_AsGeoJSON(geom) AS geojson,
ST_Distance_Sphere(geom, ST_GeomFromText(:point)) AS jarak_meter
FROM spbu
WHERE ST_Distance_Sphere(geom, ST_GeomFromText(:point)) <= :radius
ORDER BY jarak_meter ASC"
);
$stmt->execute([':point' => "POINT({$lng} {$lat})", ':radius' => $radius]);
$features = [];
while ($row = $stmt->fetch()) {
$features[] = [
'type' => 'Feature',
'geometry' => json_decode($row['geojson']),
'properties' => [
'id' => $row['id'],
'nama' => $row['nama'],
'deskripsi' => $row['deskripsi'],
'buka_24_jam' => (bool) $row['buka_24_jam'],
'jarak_meter' => round($row['jarak_meter'], 2),
'created_at' => $row['created_at'],
],
];
}
sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'SPBU Terdekat berhasil diambil');
} catch (PDOException $e) {
sendError('Gagal mengambil SPBU Terdekat: ' . $e->getMessage(), 500);
}
+41
View File
@@ -0,0 +1,41 @@
<?php
/**
* api_spbu_status.php
* Endpoint: Mengambil ringkasan status SPBU (berapa 24 jam, berapa tidak).
*/
require_once __DIR__ . '/../core_config/database.php';
header('Content-Type: application/json; charset=utf-8');
function sendSuccess($data = null, $message = 'Success', $code = 200) {
http_response_code($code);
echo json_encode(['status' => 'success', 'message' => $message, 'data' => $data]);
exit();
}
function sendError($message = 'Error', $code = 400) {
http_response_code($code);
echo json_encode(['status' => 'error', 'message' => $message]);
exit();
}
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
if ($method !== 'GET') {
sendError('Method not allowed', 405);
}
try {
$stmt = $pdo->query(
"SELECT
COUNT(*) AS total,
SUM(buka_24_jam = 1) AS buka_24_jam,
SUM(buka_24_jam = 0) AS tidak_24_jam
FROM spbu"
);
$summary = $stmt->fetch();
sendSuccess($summary, 'Status SPBU berhasil diambil');
} catch (PDOException $e) {
sendError('Gagal mengambil status SPBU: ' . $e->getMessage(), 500);
}
+65
View File
@@ -0,0 +1,65 @@
<?php
/**
* api_statistik.php
* Endpoint: Mengembalikan ringkasan statistik keseluruhan data WebGIS.
*/
require_once __DIR__ . '/../core_config/database.php';
header('Content-Type: application/json; charset=utf-8');
function sendSuccess($data = null, $message = 'Success', $code = 200) {
http_response_code($code);
echo json_encode(['status' => 'success', 'message' => $message, 'data' => $data]);
exit();
}
function sendError($message = 'Error', $code = 400) {
http_response_code($code);
echo json_encode(['status' => 'error', 'message' => $message]);
exit();
}
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
if ($method !== 'GET') { sendError('Method not allowed', 405); }
try {
$counts = [];
$tables = [
'spbu' => 'total_spbu',
'rumah_ibadah' => 'total_rumah_ibadah',
'jalan' => 'total_jalan',
'kavling' => 'total_kavling',
'kawasan_kumuh' => 'total_kawasan_kumuh',
'warga_miskin' => 'total_warga_miskin',
'laporan_warga' => 'total_laporan',
'users' => 'total_users',
];
foreach ($tables as $table => $key) {
$row = $pdo->query("SELECT COUNT(*) AS cnt FROM `{$table}`")->fetch();
$counts[$key] = (int) $row['cnt'];
}
// Additional SPBU breakdown
$spbuStatus = $pdo->query(
"SELECT SUM(buka_24_jam=1) AS buka_24_jam, SUM(buka_24_jam=0) AS tidak_24_jam FROM spbu"
)->fetch();
$counts['spbu_buka_24_jam'] = (int) $spbuStatus['buka_24_jam'];
$counts['spbu_tidak_24_jam'] = (int) $spbuStatus['tidak_24_jam'];
// Laporan by status
$laporanStatus = $pdo->query(
"SELECT status, COUNT(*) AS cnt FROM laporan_warga GROUP BY status"
)->fetchAll();
$counts['laporan_by_status'] = [];
foreach ($laporanStatus as $row) {
$counts['laporan_by_status'][$row['status']] = (int) $row['cnt'];
}
sendSuccess($counts, 'Statistik berhasil diambil');
} catch (PDOException $e) {
sendError('Gagal mengambil statistik: ' . $e->getMessage(), 500);
}
+100
View File
@@ -0,0 +1,100 @@
<?php
/**
* api_ulasan.php
* Endpoint: CRUD untuk Ulasan Fasilitas (SPBU / Rumah Ibadah).
*/
require_once __DIR__ . '/../core_config/database.php';
require_once __DIR__ . '/../core_config/middleware_auth.php';
require_once __DIR__ . '/../core_config/session_mgr.php';
header('Content-Type: application/json; charset=utf-8');
function sendSuccess($data = null, $message = 'Success', $code = 200) {
http_response_code($code);
echo json_encode(['status' => 'success', 'message' => $message, 'data' => $data]);
exit();
}
function sendError($message = 'Error', $code = 400) {
http_response_code($code);
echo json_encode(['status' => 'error', 'message' => $message]);
exit();
}
startAppSession();
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
switch ($method) {
case 'GET':
$tipe = $_GET['tipe'] ?? null;
$fid = $_GET['fasilitas_id'] ?? null;
try {
if ($tipe && $fid) {
$stmt = $pdo->prepare(
"SELECT uf.id, uf.user_id, u.username, uf.fasilitas_tipe,
uf.fasilitas_id, uf.rating, uf.komentar, uf.created_at
FROM ulasan_fasilitas uf
JOIN users u ON u.id = uf.user_id
WHERE uf.fasilitas_tipe = :tipe AND uf.fasilitas_id = :fid
ORDER BY uf.created_at DESC"
);
$stmt->execute([':tipe' => $tipe, ':fid' => $fid]);
} else {
$stmt = $pdo->query(
"SELECT uf.id, uf.user_id, u.username, uf.fasilitas_tipe,
uf.fasilitas_id, uf.rating, uf.komentar, uf.created_at
FROM ulasan_fasilitas uf
JOIN users u ON u.id = uf.user_id
ORDER BY uf.created_at DESC"
);
}
sendSuccess($stmt->fetchAll(), 'Data Ulasan berhasil diambil');
} catch (PDOException $e) {
sendError('Gagal mengambil ulasan: ' . $e->getMessage(), 500);
}
break;
case 'POST':
requireApiLogin();
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['fasilitas_tipe'], $input['fasilitas_id'], $input['rating'])) {
sendError('fasilitas_tipe, fasilitas_id, dan rating wajib diisi');
}
$allowed = ['spbu', 'rumah_ibadah'];
if (!in_array($input['fasilitas_tipe'], $allowed)) sendError('fasilitas_tipe tidak valid');
if ($input['rating'] < 1 || $input['rating'] > 5) sendError('Rating harus antara 1-5');
try {
$stmt = $pdo->prepare(
"INSERT INTO ulasan_fasilitas (user_id, fasilitas_tipe, fasilitas_id, rating, komentar)
VALUES (:user_id, :fasilitas_tipe, :fasilitas_id, :rating, :komentar)"
);
$stmt->execute([
':user_id' => $_SESSION['user_id'],
':fasilitas_tipe'=> $input['fasilitas_tipe'],
':fasilitas_id' => $input['fasilitas_id'],
':rating' => $input['rating'],
':komentar' => $input['komentar'] ?? null,
]);
sendSuccess(['id' => $pdo->lastInsertId()], 'Ulasan berhasil disimpan', 201);
} catch (PDOException $e) {
sendError('Gagal menyimpan ulasan: ' . $e->getMessage(), 500);
}
break;
case 'DELETE':
requireApiRole('admin');
$id = $_GET['id'] ?? null;
if (!$id) sendError('ID ulasan wajib disertakan');
try {
$stmt = $pdo->prepare("DELETE FROM ulasan_fasilitas WHERE id = :id");
$stmt->execute([':id' => $id]);
sendSuccess(null, 'Ulasan berhasil dihapus');
} catch (PDOException $e) {
sendError('Gagal menghapus ulasan: ' . $e->getMessage(), 500);
}
break;
default:
sendError('Method not allowed', 405);
}
+91
View File
@@ -0,0 +1,91 @@
<?php
/**
* api_warga_miskin.php
* Endpoint: CRUD untuk layer Warga Miskin (Point).
*/
require_once __DIR__ . '/../core_config/database.php';
require_once __DIR__ . '/../core_config/middleware_auth.php';
header('Content-Type: application/json; charset=utf-8');
function sendSuccess($data = null, $message = 'Success', $code = 200) {
http_response_code($code);
echo json_encode(['status' => 'success', 'message' => $message, 'data' => $data]);
exit();
}
function sendError($message = 'Error', $code = 400) {
http_response_code($code);
echo json_encode(['status' => 'error', 'message' => $message]);
exit();
}
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
switch ($method) {
case 'GET':
try {
$stmt = $pdo->query(
"SELECT id, nama_kk, penghasilan, jumlah_tanggungan, created_at,
ST_AsGeoJSON(geom) AS geojson FROM warga_miskin"
);
$features = [];
while ($row = $stmt->fetch()) {
$features[] = [
'type' => 'Feature',
'geometry' => json_decode($row['geojson']),
'properties' => [
'id' => $row['id'],
'nama_kk' => $row['nama_kk'],
'penghasilan' => $row['penghasilan'],
'jumlah_tanggungan' => $row['jumlah_tanggungan'],
'created_at' => $row['created_at'],
],
];
}
sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Warga Miskin berhasil diambil');
} catch (PDOException $e) {
sendError('Gagal mengambil data Warga Miskin: ' . $e->getMessage(), 500);
}
break;
case 'POST':
requireApiRole('admin');
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['nama_kk']) || !isset($input['geometry'])) {
sendError('Nama KK dan geometri wajib diisi');
}
try {
$stmt = $pdo->prepare(
"INSERT INTO warga_miskin (nama_kk, penghasilan, jumlah_tanggungan, geom)
VALUES (:nama_kk, :penghasilan, :jumlah_tanggungan, ST_GeomFromGeoJSON(:geometry))"
);
$stmt->execute([
':nama_kk' => $input['nama_kk'],
':penghasilan' => $input['penghasilan'] ?? 0,
':jumlah_tanggungan' => $input['jumlah_tanggungan'] ?? 0,
':geometry' => json_encode($input['geometry']),
]);
sendSuccess(['id' => $pdo->lastInsertId()], 'Data Warga Miskin berhasil disimpan', 201);
} catch (PDOException $e) {
sendError('Gagal menyimpan Warga Miskin: ' . $e->getMessage(), 500);
}
break;
case 'DELETE':
requireApiRole('admin');
$id = $_GET['id'] ?? null;
if (!$id) sendError('ID Warga Miskin wajib disertakan');
try {
$stmt = $pdo->prepare("DELETE FROM warga_miskin WHERE id = :id");
$stmt->execute([':id' => $id]);
sendSuccess(null, 'Data Warga Miskin berhasil dihapus');
} catch (PDOException $e) {
sendError('Gagal menghapus Warga Miskin: ' . $e->getMessage(), 500);
}
break;
default:
sendError('Method not allowed', 405);
}
+98
View File
@@ -0,0 +1,98 @@
<?php
/**
* area_blank.php
* Endpoint: Blank Spot — area tanpa sinyal seluler.
* Menggunakan tabel kawasan_kumuh sebagai proxy (tidak ada tabel blank_spot tersendiri).
* Mengembalikan FeatureCollection kosong jika tabel belum ada.
*/
require_once __DIR__ . '/../core_config/database.php';
require_once __DIR__ . '/../core_config/middleware_auth.php';
header('Content-Type: application/json; charset=utf-8');
function sendSuccess($data = null, $message = 'Success', $code = 200) {
http_response_code($code);
echo json_encode(['status' => 'success', 'message' => $message, 'data' => $data]);
exit();
}
function sendError($message = 'Error', $code = 400) {
http_response_code($code);
echo json_encode(['status' => 'error', 'message' => $message]);
exit();
}
$pdo = Database::getConnection();
$method = $_SERVER['REQUEST_METHOD'];
switch ($method) {
case 'GET':
try {
// Check if blank_spot table exists, otherwise return empty collection
$check = $pdo->query("SHOW TABLES LIKE 'blank_spot'")->rowCount();
if ($check === 0) {
sendSuccess(
['type' => 'FeatureCollection', 'features' => []],
'Tabel blank_spot belum tersedia'
);
}
$stmt = $pdo->query(
"SELECT id, nama, keterangan, created_at, ST_AsGeoJSON(geom) AS geojson FROM blank_spot"
);
$features = [];
while ($row = $stmt->fetch()) {
$features[] = [
'type' => 'Feature',
'geometry' => json_decode($row['geojson']),
'properties' => [
'id' => $row['id'],
'nama' => $row['nama'],
'keterangan' => $row['keterangan'],
'created_at' => $row['created_at'],
],
];
}
sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Blank Spot berhasil diambil');
} catch (PDOException $e) {
sendError('Gagal mengambil data Blank Spot: ' . $e->getMessage(), 500);
}
break;
case 'POST':
requireApiRole('admin');
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['nama']) || !isset($input['geometry'])) {
sendError('Nama dan geometri wajib diisi');
}
try {
$stmt = $pdo->prepare(
"INSERT INTO blank_spot (nama, keterangan, geom)
VALUES (:nama, :keterangan, ST_GeomFromGeoJSON(:geometry))"
);
$stmt->execute([
':nama' => $input['nama'],
':keterangan' => $input['keterangan'] ?? '',
':geometry' => json_encode($input['geometry']),
]);
sendSuccess(['id' => $pdo->lastInsertId()], 'Data Blank Spot berhasil disimpan', 201);
} catch (PDOException $e) {
sendError('Gagal menyimpan Blank Spot: ' . $e->getMessage(), 500);
}
break;
case 'DELETE':
requireApiRole('admin');
$id = $_GET['id'] ?? null;
if (!$id) sendError('ID Blank Spot wajib disertakan');
try {
$stmt = $pdo->prepare("DELETE FROM blank_spot WHERE id = :id");
$stmt->execute([':id' => $id]);
sendSuccess(null, 'Data Blank Spot berhasil dihapus');
} catch (PDOException $e) {
sendError('Gagal menghapus Blank Spot: ' . $e->getMessage(), 500);
}
break;
default:
sendError('Method not allowed', 405);
}
+35
View File
@@ -0,0 +1,35 @@
<?php
/**
* cek_srid.php
* Utility: Cek SRID (Spatial Reference ID) dari semua kolom geometri.
* Hanya untuk Admin / debugging.
*/
require_once __DIR__ . '/../core_config/database.php';
require_once __DIR__ . '/../core_config/middleware_auth.php';
header('Content-Type: application/json; charset=utf-8');
requireApiRole('admin');
$pdo = Database::getConnection();
try {
$tables = ['spbu', 'rumah_ibadah', 'jalan', 'kavling', 'kawasan_kumuh', 'warga_miskin'];
$results = [];
foreach ($tables as $table) {
$stmt = $pdo->query(
"SELECT ST_SRID(geom) AS srid, COUNT(*) AS jumlah
FROM `{$table}`
GROUP BY ST_SRID(geom)"
);
$results[$table] = $stmt->fetchAll();
}
http_response_code(200);
echo json_encode(['status' => 'success', 'data' => $results]);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => $e->getMessage()]);
}
+33
View File
@@ -0,0 +1,33 @@
<?php
/**
* perbaiki_srid.php
* Utility: Perbaiki SRID semua geometri ke SRID 0 (tidak terdefinisi, kompatibel dengan MySQL).
* Hanya untuk Admin / debugging.
*/
require_once __DIR__ . '/../core_config/database.php';
require_once __DIR__ . '/../core_config/middleware_auth.php';
header('Content-Type: application/json; charset=utf-8');
requireApiRole('admin');
$pdo = Database::getConnection();
try {
$tables = ['spbu', 'rumah_ibadah', 'jalan', 'kavling', 'kawasan_kumuh', 'warga_miskin'];
$updated = [];
foreach ($tables as $table) {
$affected = $pdo->exec(
"UPDATE `{$table}` SET geom = ST_GeomFromWKB(ST_AsWKB(geom), 0) WHERE ST_SRID(geom) != 0"
);
$updated[$table] = ['rows_updated' => $affected];
}
http_response_code(200);
echo json_encode(['status' => 'success', 'message' => 'SRID diperbaiki', 'data' => $updated]);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => $e->getMessage()]);
}
+28
View File
@@ -0,0 +1,28 @@
<?php
require_once __DIR__ . '/core_config/settings.php';
$host = getenv('DB_HOST') ?: 'localhost';
$port = getenv('DB_PORT') ?: 3306;
$user = getenv('DB_USERNAME') ?: 'root';
$pass = getenv('DB_PASSWORD') ?: '';
$db = getenv('DB_DATABASE') ?: 'webgis_db';
try {
$pdo = new PDO("mysql:host=$host;port=$port;dbname=$db;charset=utf8mb4", $user, $pass);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Read SQL file
$sqlFile = __DIR__ . '/../database/webgis_db.sql';
if (!file_exists($sqlFile)) {
die("SQL file not found at: $sqlFile");
}
$sql = file_get_contents($sqlFile);
// Split into statements and execute
$pdo->exec($sql);
echo "Database imported successfully to $db at $host!";
} catch (PDOException $e) {
echo "Connection failed: " . $e->getMessage() . "\n";
}
+13
View File
@@ -0,0 +1,13 @@
<?php
require_once __DIR__ . '/core_config/session_mgr.php';
startAppSession();
if (isset($_SESSION['user_id'])) {
if ($_SESSION['role'] === 'admin') {
header('Location: panel_admin/index.php');
} else {
header('Location: panel_user/index.php');
}
exit;
}
header('Location: login.php');
exit;
+130
View File
@@ -0,0 +1,130 @@
<?php
require_once __DIR__ . '/core_config/session_mgr.php';
startAppSession();
if (isset($_SESSION['user_id'])) {
header($_SESSION['role'] === 'admin' ? 'Location: panel_admin/index.php' : 'Location: panel_user/index.php');
exit;
}
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
require_once __DIR__ . '/core_config/database.php';
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
if ($username && $password) {
$pdo = Database::getConnection();
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = ? LIMIT 1");
$stmt->execute([$username]);
$user = $stmt->fetch();
if ($user && password_verify($password, $user['password'])) {
session_regenerate_id(true);
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
$_SESSION['role'] = $user['role'];
$_SESSION['nama_lengkap'] = $user['nama_lengkap'];
header($user['role'] === 'admin' ? 'Location: panel_admin/index.php' : 'Location: panel_user/index.php');
exit;
}
$error = 'Username atau password salah. Silakan coba lagi.';
} else {
$error = 'Username dan password wajib diisi.';
}
}
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - WebGIS Smart City</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<link rel="stylesheet" href="public_assets/css/global.css">
<link rel="stylesheet" href="public_assets/css/auth.css">
</head>
<body class="auth-page">
<div class="auth-center">
<div class="auth-form-wrap">
<h2 class="auth-form-title">Selamat Datang</h2>
<p class="auth-form-subtitle">Masuk ke sistem WebGIS Smart City Pontianak.</p>
<div class="role-selector">
<label class="role-card">
<input type="radio" name="role_display" value="admin" checked>
<div class="role-card-label">
<div class="role-card-icon"><i class="fas fa-user-shield"></i></div>
<div class="role-card-name">Admin</div>
<div class="role-card-desc">Kelola semua data</div>
</div>
</label>
<label class="role-card">
<input type="radio" name="role_display" value="user">
<div class="role-card-label">
<div class="role-card-icon"><i class="fas fa-user"></i></div>
<div class="role-card-name">Pengguna</div>
<div class="role-card-desc">Lihat & analisis peta</div>
</div>
</label>
</div>
<?php if ($error): ?>
<div class="auth-error">
<i class="fas fa-exclamation-circle"></i>
<?= htmlspecialchars($error) ?>
</div>
<?php endif; ?>
<form method="POST" class="auth-form">
<div class="form-group">
<label class="form-label">Username</label>
<div class="input-with-icon">
<i class="fas fa-user input-icon"></i>
<input type="text" name="username" class="form-control"
placeholder="Masukkan username"
value="<?= htmlspecialchars($_POST['username'] ?? '') ?>"
required autocomplete="username">
</div>
</div>
<div class="form-group">
<label class="form-label">Password</label>
<div class="input-with-icon">
<i class="fas fa-lock input-icon"></i>
<input type="password" name="password" class="form-control"
placeholder="Masukkan password"
required autocomplete="current-password"
id="passwordInput">
<button type="button" class="input-icon" style="right:12px;left:auto;cursor:pointer;background:none;border:none;color:var(--text-muted);" onclick="togglePass()">
<i class="fas fa-eye" id="eyeIcon"></i>
</button>
</div>
</div>
<button type="submit" class="btn btn-primary btn-login">
<i class="fas fa-sign-in-alt"></i>
Masuk ke Sistem
</button>
</form>
</div>
</div>
<script>
function togglePass() {
const inp = document.getElementById('passwordInput');
const ico = document.getElementById('eyeIcon');
if (inp.type === 'password') {
inp.type = 'text';
ico.className = 'fas fa-eye-slash';
} else {
inp.type = 'password';
ico.className = 'fas fa-eye';
}
}
</script>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
<?php
require_once __DIR__ . '/core_config/session_mgr.php';
startAppSession();
session_destroy();
header('Location: ' . app_url('login.php'));
exit;
+172
View File
@@ -0,0 +1,172 @@
<?php
require_once __DIR__ . '/../core_config/database.php';
require_once __DIR__ . '/../core_config/middleware_auth.php';
requireRole('admin');
$pdo = Database::getConnection();
// Fetch summary stats
$stats = $pdo->query("SELECT
(SELECT COUNT(*) FROM spbu) as spbu,
(SELECT COUNT(*) FROM jalan) as jalan,
(SELECT COUNT(*) FROM kavling) as kavling,
(SELECT COUNT(*) FROM rumah_ibadah) as rumah_ibadah,
(SELECT COUNT(*) FROM warga_miskin) as warga_miskin,
(SELECT COUNT(*) FROM kawasan_kumuh) as kawasan_kumuh,
(SELECT COUNT(*) FROM spbu WHERE buka_24_jam=1) as spbu_24jam,
(SELECT COUNT(*) FROM users) as total_users
")->fetch();
// Kawasan merah
$merahResult = $pdo->query("SELECT COUNT(*) as cnt FROM (
SELECT k.id FROM kawasan_kumuh k
LEFT JOIN warga_miskin w ON ST_Contains(k.geom, w.geom)
GROUP BY k.id HAVING COUNT(w.id) > 3
) sub")->fetch();
$kawasanMerah = (int)($merahResult['cnt'] ?? 0);
// Warga miskin di luar semua radius bantuan rumah ibadah
$blankCount = $pdo->query("
SELECT COUNT(*) as cnt
FROM warga_miskin w
WHERE NOT EXISTS (
SELECT 1
FROM rumah_ibadah ri
WHERE ST_Distance_Sphere(ST_GeomFromText(ST_AsText(w.geom), 4326), ST_GeomFromText(ST_AsText(ri.geom), 4326)) <= ri.radius_bantuan_meter
)
")->fetch();
$blankSpot = (int)($blankCount['cnt'] ?? 0);
// Recent data
$recentSpbu = $pdo->query("SELECT nama, buka_24_jam, created_at FROM spbu ORDER BY created_at DESC LIMIT 5")->fetchAll();
$recentWarga = $pdo->query("SELECT nama_kk, penghasilan, jumlah_tanggungan, created_at FROM warga_miskin ORDER BY created_at DESC LIMIT 5")->fetchAll();
$pageTitle = 'Dashboard';
$activeNav = 'dashboard';
require_once __DIR__ . '/partials/header.php';
?>
<!-- ── Quick Access Map ── -->
<div class="card" style="background: #EFF6FF; border: 1px solid #BFDBFE; margin-bottom: 24px;">
<div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:16px;">
<div>
<h3 style="font-size:1.1rem; font-weight:700; margin-bottom:6px; color:#1E3A8A;">🗺️ Buka Peta Interaktif</h3>
<p style="color:#3B82F6; font-size:0.88rem; max-width:500px;">
Tambahkan, edit, dan analisis data spasial langsung di peta. Mendukung drawing tool untuk semua layer data.
</p>
</div>
<div style="display:flex; gap:10px;">
<a href="<?= app_url('panel_admin/peta_admin.php') ?>" class="btn btn-primary" style="box-shadow:0 4px 6px -1px rgba(37,99,235,0.2);">
<i class="fas fa-map-marked-alt"></i> Buka Peta Admin
</a>
</div>
</div>
</div>
<!-- ── Stats Grid ── -->
<div class="stats-grid">
<div class="stat-card primary">
<div class="stat-icon"><i class="fas fa-gas-pump"></i></div>
<div class="stat-body">
<div class="stat-label">Total SPBU</div>
<div class="stat-value" id="cnt-spbu"><?= $stats['spbu'] ?></div>
<div class="stat-change"><?= $stats['spbu_24jam'] ?> buka 24 jam</div>
</div>
</div>
<div class="stat-card info">
<div class="stat-icon"><i class="fas fa-road"></i></div>
<div class="stat-body">
<div class="stat-label">Ruas Jalan</div>
<div class="stat-value"><?= $stats['jalan'] ?></div>
<div class="stat-change">Segmen terpetakan</div>
</div>
</div>
<div class="stat-card success">
<div class="stat-icon"><i class="fas fa-mosque"></i></div>
<div class="stat-body">
<div class="stat-label">Rumah Ibadah</div>
<div class="stat-value"><?= $stats['rumah_ibadah'] ?></div>
<div class="stat-change">5 agama tercakup</div>
</div>
</div>
<div class="stat-card warning">
<div class="stat-icon"><i class="fas fa-users"></i></div>
<div class="stat-body">
<div class="stat-label">Warga Miskin</div>
<div class="stat-value"><?= $stats['warga_miskin'] ?></div>
<div class="stat-change"><?= $blankSpot ?> blank spot</div>
</div>
</div>
<div class="stat-card danger">
<div class="stat-icon"><i class="fas fa-exclamation-triangle"></i></div>
<div class="stat-body">
<div class="stat-label">Kawasan Rawan</div>
<div class="stat-value"><?= $kawasanMerah ?></div>
<div class="stat-change">dari <?= $stats['kawasan_kumuh'] ?> kawasan</div>
</div>
</div>
<div class="stat-card violet">
<div class="stat-icon"><i class="fas fa-vector-square"></i></div>
<div class="stat-body">
<div class="stat-label">Kavling / Parsil</div>
<div class="stat-value"><?= $stats['kavling'] ?></div>
<div class="stat-change">Bidang tanah tercatat</div>
</div>
</div>
</div>
<!-- ── Content Row ── -->
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 24px;">
<!-- Recent SPBU -->
<div class="card">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:16px;">
<h3 style="font-size:0.95rem; font-weight:700;">⛽ SPBU Terbaru</h3>
<a href="<?= app_url('panel_admin/kelola_spbu.php') ?>" class="btn btn-ghost btn-sm">Lihat Semua</a>
</div>
<?php if ($recentSpbu): ?>
<div style="display:flex; flex-direction:column; gap:8px;">
<?php foreach ($recentSpbu as $s): ?>
<div style="display:flex; align-items:center; justify-content:space-between; padding:10px 12px; background:#F9FAFB; border:1px solid #E5E7EB; border-radius:8px;">
<div>
<div style="font-weight:600; font-size:0.88rem;"><?= htmlspecialchars($s['nama']) ?></div>
<div style="font-size:0.75rem; color:var(--text-muted);"><?= date('d M Y', strtotime($s['created_at'])) ?></div>
</div>
<span class="badge <?= $s['buka_24_jam'] ? 'badge-success' : 'badge-danger' ?>">
<?= $s['buka_24_jam'] ? '24 Jam' : 'Terbatas' ?>
</span>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<p style="color:var(--text-muted); text-align:center; padding:20px 0; font-size:0.85rem;">Belum ada data SPBU</p>
<?php endif; ?>
</div>
<!-- Recent Warga Miskin -->
<div class="card">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:16px;">
<h3 style="font-size:0.95rem; font-weight:700;">👥 Warga Terdaftar</h3>
<a href="<?= app_url('panel_admin/kelola_warga.php') ?>" class="btn btn-ghost btn-sm">Lihat Semua</a>
</div>
<?php if ($recentWarga): ?>
<div style="display:flex; flex-direction:column; gap:8px;">
<?php foreach ($recentWarga as $w): ?>
<div style="display:flex; align-items:center; justify-content:space-between; padding:10px 12px; background:#F9FAFB; border:1px solid #E5E7EB; border-radius:8px;">
<div>
<div style="font-weight:600; font-size:0.88rem;"><?= htmlspecialchars($w['nama_kk']) ?></div>
<div style="font-size:0.75rem; color:var(--text-muted);">Rp <?= number_format($w['penghasilan'],0,',','.') ?>/bln · <?= $w['jumlah_tanggungan'] ?> tanggungan</div>
</div>
<span class="badge badge-warning"><?= $w['jumlah_tanggungan'] ?> org</span>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<p style="color:var(--text-muted); text-align:center; padding:20px 0; font-size:0.85rem;">Belum ada data warga</p>
<?php endif; ?>
</div>
</div>
<?php require_once __DIR__ . '/partials/footer.php'; ?>
+140
View File
@@ -0,0 +1,140 @@
<?php
require_once __DIR__ . '/../core_config/database.php';
require_once __DIR__ . '/../core_config/middleware_auth.php';
requireRole('admin');
$pdo = Database::getConnection();
$rows = $pdo->query("SELECT id,nama,agama,radius_bantuan_meter,created_at FROM rumah_ibadah ORDER BY id DESC")->fetchAll();
$pageTitle='Rumah Ibadah'; $activeNav='rumah';
$extraHead = '<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">';
require_once __DIR__ . '/partials/header.php';
$agamaIcon = ['Islam'=>'🕌','Kristen'=>'⛪','Katolik'=>'⛪','Hindu'=>'🛕','Buddha'=>'☸️','Konghucu'=>'🏯'];
$agamaBadge = ['Islam'=>'badge-success','Kristen'=>'badge-info','Katolik'=>'badge-primary','Hindu'=>'badge-warning','Buddha'=>'badge-danger','Konghucu'=>'badge-warning'];
$grouped = [];
foreach($agamaIcon as $ag => $ic) { $grouped[$ag] = []; }
foreach($rows as $r) {
if(!isset($grouped[$r['agama']])) $grouped[$r['agama']] = [];
$grouped[$r['agama']][] = $r;
}
?>
<div class="page-header">
<div class="breadcrumb"><span>Admin</span><span class="sep">/</span><span>Rumah Ibadah</span></div>
<h1>🕌 Manajemen Rumah Ibadah</h1>
<p>Kelola data rumah ibadah untuk 5 agama yang diakui di Indonesia.</p>
</div>
<div class="page-toolbar">
<div class="search-box"><i class="fas fa-search search-icon"></i><input type="text" id="searchInput" placeholder="Cari rumah ibadah..." oninput="filterTableMulti()"></div>
<button class="btn btn-primary" onclick="openModal('modalTambah')"><i class="fas fa-plus"></i> Tambah</button>
</div>
<?php foreach($grouped as $agama => $list): ?>
<h3 style="margin-top: 24px; margin-bottom: 12px; font-size: 1.1rem; color: var(--text-primary); border-bottom: 2px solid var(--border-light); padding-bottom: 8px;">
<?= $agamaIcon[$agama] ?? '🏛' ?> <?= $agama ?> <span class="badge badge-info" style="margin-left: 8px;"><?= count($list) ?></span>
</h3>
<div class="table-wrap" style="margin-bottom: 32px;">
<table class="data-table ri-table">
<thead><tr><th>#</th><th>Nama</th><th>Radius Bantuan</th><th>Ditambahkan</th><th>Aksi</th></tr></thead>
<tbody>
<?php foreach($list as $i=>$r): ?>
<tr>
<td style="color:var(--text-muted)"><?=$i+1?></td>
<td><strong><?=htmlspecialchars($r['nama'])?></strong></td>
<td><span class="badge badge-info"><?= number_format((int)$r['radius_bantuan_meter'], 0, ',', '.') ?> m</span></td>
<td style="color:var(--text-muted);font-size:0.8rem;"><?=date('d M Y',strtotime($r['created_at']))?></td>
<td><div class="actions">
<button class="btn btn-ghost btn-sm btn-icon" onclick="editRow(<?=htmlspecialchars(json_encode($r))?>)"><i class="fas fa-edit"></i></button>
<button class="btn btn-danger btn-sm btn-icon" onclick="hapus(<?=$r['id']?>,'<?=addslashes($r['nama'])?>')"><i class="fas fa-trash"></i></button>
</div></td>
</tr>
<?php endforeach; ?>
<?php if(!$list):?><tr><td colspan="5" style="text-align:center;padding:30px;color:var(--text-muted);">Belum ada data <?=$agama?>.</td></tr><?php endif;?>
</tbody>
</table></div>
<?php endforeach; ?>
<div class="modal-overlay" id="modalTambah"><div class="modal">
<div class="modal-header"><span class="modal-title">🕌 Tambah Rumah Ibadah</span><button class="modal-close" onclick="closeModal('modalTambah')">×</button></div>
<form id="formTambah">
<div class="form-group"><label class="form-label">Nama *</label><input type="text" name="nama" class="form-control" placeholder="Nama rumah ibadah" required></div>
<div class="form-group"><label class="form-label">Agama *</label>
<select name="agama" class="form-control">
<option value="Islam">🕌 Islam</option><option value="Kristen">⛪ Kristen</option>
<option value="Katolik">⛪ Katolik</option><option value="Hindu">🛕 Hindu</option>
<option value="Buddha">☸️ Buddha</option><option value="Konghucu">🏯 Konghucu</option>
</select>
</div>
<div class="form-group"><label class="form-label">Radius Bantuan (meter) *</label><input type="number" name="radius_bantuan_meter" class="form-control" min="100" max="10000" step="50" value="1000" required></div>
<div class="form-group"><label class="form-label">Pilih Lokasi di Peta</label>
<div id="miniMapTambah" style="height:200px;border-radius:8px;border:1px solid #E5E7EB;margin-bottom:10px;z-index:1;"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<input type="number" name="lat" id="latTambah" class="form-control" placeholder="Latitude" step="any" readonly style="background:#F9FAFB;">
<input type="number" name="lng" id="lngTambah" class="form-control" placeholder="Longitude" step="any" readonly style="background:#F9FAFB;">
</div>
</div>
</form>
<div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal('modalTambah')">Batal</button><button class="btn btn-primary" onclick="simpan()"><i class="fas fa-save"></i> Simpan</button></div>
</div></div>
<div class="modal-overlay" id="modalEdit"><div class="modal">
<div class="modal-header"><span class="modal-title">✏️ Edit Rumah Ibadah</span><button class="modal-close" onclick="closeModal('modalEdit')">×</button></div>
<input type="hidden" id="editId">
<div class="form-group"><label class="form-label">Nama *</label><input type="text" id="editNama" class="form-control" required></div>
<div class="form-group"><label class="form-label">Agama</label>
<select id="editAgama" class="form-control">
<option value="Islam">🕌 Islam</option><option value="Kristen">⛪ Kristen</option>
<option value="Katolik">⛪ Katolik</option><option value="Hindu">🛕 Hindu</option>
<option value="Buddha">☸️ Buddha</option><option value="Konghucu">🏯 Konghucu</option>
</select>
</div>
<div class="form-group"><label class="form-label">Radius Bantuan (meter) *</label><input type="number" id="editRadius" class="form-control" min="100" max="10000" step="50" required></div>
<div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal('modalEdit')">Batal</button><button class="btn btn-primary" onclick="update()"><i class="fas fa-save"></i> Perbarui</button></div>
</div></div>
<?php $extraScript = <<<'JS'
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const API=APP_BASE + '/api/rumah_ibadah.php';
let miniMap = null, miniMarker = null;
// Override openModal to initialize map
const originalOpenModal = window.openModal;
window.openModal = function(id) {
originalOpenModal(id);
if(id === 'modalTambah') {
setTimeout(() => {
if(!miniMap) {
miniMap = L.map('miniMapTambah').setView([-0.0583, 109.3448], 13);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png').addTo(miniMap);
miniMap.on('click', function(e) {
const lat = e.latlng.lat;
const lng = e.latlng.lng;
if(!miniMarker) miniMarker = L.marker([lat, lng]).addTo(miniMap);
else miniMarker.setLatLng([lat, lng]);
document.getElementById('latTambah').value = lat.toFixed(6);
document.getElementById('lngTambah').value = lng.toFixed(6);
});
} else {
miniMap.invalidateSize();
}
}, 100);
}
};
function filterTableMulti() {
const v = document.getElementById('searchInput').value.toLowerCase();
document.querySelectorAll('.ri-table').forEach(tbl => {
tbl.querySelectorAll('tbody tr').forEach(tr => {
if(tr.cells.length > 1) tr.style.display = tr.innerText.toLowerCase().includes(v) ? '' : 'none';
});
});
}
function normalizeRadius(v){ const n=parseInt(v,10); if(Number.isNaN(n)) return 1000; return Math.max(100, Math.min(10000, n)); }
function editRow(r){ document.getElementById('editId').value=r.id; document.getElementById('editNama').value=r.nama; document.getElementById('editAgama').value=r.agama; document.getElementById('editRadius').value=normalizeRadius(r.radius_bantuan_meter); openModal('modalEdit'); }
async function simpan(){ const f=document.getElementById('formTambah'); if(!f.nama.value){showToast('Nama wajib!','error');return;}
const lat=parseFloat(f.lat.value)||(-0.03), lng=parseFloat(f.lng.value)||(109.34);
const res=await fetch(API,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({nama:f.nama.value,agama:f.agama.value,radius_bantuan_meter:normalizeRadius(f.radius_bantuan_meter.value),geometry:{type:'Point',coordinates:[lng,lat]}})}); const d=await res.json(); if(d.status==='success'){showToast('Rumah ibadah disimpan!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); }
async function update(){ const id=document.getElementById('editId').value; const res=await fetch(API+'?id='+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({nama:document.getElementById('editNama').value,agama:document.getElementById('editAgama').value,radius_bantuan_meter:normalizeRadius(document.getElementById('editRadius').value)})}); const d=await res.json(); if(d.status==='success'){showToast('Diperbarui!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); }
async function hapus(id,nama){ if(!confirm(`Hapus "${nama}"?`))return; const res=await fetch(API+'?id='+id,{method:'DELETE'}); const d=await res.json(); if(d.status==='success'){showToast('Dihapus!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); }
</script>
JS; ?>
<?php require_once __DIR__ . '/partials/footer.php'; ?>
+79
View File
@@ -0,0 +1,79 @@
<?php
require_once __DIR__ . '/../core_config/database.php';
require_once __DIR__ . '/../core_config/middleware_auth.php';
requireRole('admin');
$pdo = Database::getConnection();
$rows = $pdo->query("SELECT id,nama,jenis_jalan,created_at FROM jalan ORDER BY id DESC")->fetchAll();
$pageTitle='Data Jalan'; $activeNav='jalan';
require_once __DIR__ . '/partials/header.php';
?>
<div class="page-header">
<div class="breadcrumb"><span>Admin</span><span class="sep">/</span><span>Data Jalan</span></div>
<h1>🛣️ Manajemen Data Jalan</h1>
<p>Kelola data ruas jalan yang terpetakan dalam sistem.</p>
</div>
<div class="page-toolbar">
<div class="search-box"><i class="fas fa-search search-icon"></i><input type="text" id="searchInput" placeholder="Cari jalan..." oninput="filterTable('searchInput','jalanTable')"></div>
<button class="btn btn-primary" onclick="openModal('modalTambah')"><i class="fas fa-plus"></i> Tambah Jalan</button>
</div>
<div class="table-wrap">
<table class="data-table" id="jalanTable">
<thead><tr><th>#</th><th>Nama Jalan</th><th>Jenis</th><th>Ditambahkan</th><th>Aksi</th></tr></thead>
<tbody>
<?php foreach($rows as $i=>$r): ?>
<tr>
<td style="color:var(--text-muted)"><?=$i+1?></td>
<td><strong><?=htmlspecialchars($r['nama'])?></strong></td>
<td><span class="badge badge-info"><?=htmlspecialchars($r['jenis_jalan']??'-')?></span></td>
<td style="color:var(--text-muted);font-size:0.8rem;"><?=date('d M Y',strtotime($r['created_at']))?></td>
<td><div class="actions">
<button class="btn btn-ghost btn-sm btn-icon" onclick="editRow(<?=htmlspecialchars(json_encode($r))?>)"><i class="fas fa-edit"></i></button>
<button class="btn btn-danger btn-sm btn-icon" onclick="hapus(<?=$r['id']?>,'<?=addslashes($r['nama'])?>')"><i class="fas fa-trash"></i></button>
</div></td>
</tr>
<?php endforeach; ?>
<?php if(!$rows):?><tr><td colspan="5" style="text-align:center;padding:40px;color:var(--text-muted);">Belum ada data jalan.</td></tr><?php endif;?>
</tbody>
</table></div>
<div class="modal-overlay" id="modalTambah"><div class="modal">
<div class="modal-header"><span class="modal-title">🛣️ Tambah Jalan</span><button class="modal-close" onclick="closeModal('modalTambah')">×</button></div>
<form id="formTambah">
<div class="form-group"><label class="form-label">Nama Jalan *</label><input type="text" name="nama" class="form-control" placeholder="Nama ruas jalan" required></div>
<div class="form-group"><label class="form-label">Jenis Jalan *</label>
<select name="jenis_jalan" class="form-control">
<option value="Jalan Arteri">Jalan Arteri</option>
<option value="Jalan Kolektor">Jalan Kolektor</option>
<option value="Jalan Lokal" selected>Jalan Lokal</option>
<option value="Jalan Lingkungan">Jalan Lingkungan</option>
<option value="Jalan Tol">Jalan Tol</option>
</select>
</div>
<p style="font-size:0.82rem;color:var(--text-muted);background:rgba(59,130,246,0.08);border:1px solid rgba(59,130,246,0.2);border-radius:8px;padding:10px;"><i class="fas fa-info-circle"></i> Geometri jalan (LineString) hanya bisa ditambahkan melalui <a href="<?= app_url('panel_admin/peta_admin.php') ?>" style="color:var(--primary-light);">Peta Interaktif</a>.</p>
</form>
<div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal('modalTambah')">Batal</button><button class="btn btn-primary" onclick="simpan()"><i class="fas fa-save"></i> Simpan (Tanpa Geometri)</button></div>
</div></div>
<div class="modal-overlay" id="modalEdit"><div class="modal">
<div class="modal-header"><span class="modal-title">✏️ Edit Jalan</span><button class="modal-close" onclick="closeModal('modalEdit')">×</button></div>
<input type="hidden" id="editId">
<div class="form-group"><label class="form-label">Nama Jalan *</label><input type="text" id="editNama" class="form-control" required></div>
<div class="form-group"><label class="form-label">Jenis Jalan</label>
<select id="editJenis" class="form-control">
<option value="Jalan Arteri">Jalan Arteri</option><option value="Jalan Kolektor">Jalan Kolektor</option>
<option value="Jalan Lokal">Jalan Lokal</option><option value="Jalan Lingkungan">Jalan Lingkungan</option><option value="Jalan Tol">Jalan Tol</option>
</select>
</div>
<div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal('modalEdit')">Batal</button><button class="btn btn-primary" onclick="update()"><i class="fas fa-save"></i> Perbarui</button></div>
</div></div>
<?php $extraScript = <<<'JS'
<script>
const API=APP_BASE + '/api/jalan.php';
function editRow(r){ document.getElementById('editId').value=r.id; document.getElementById('editNama').value=r.nama; document.getElementById('editJenis').value=r.jenis_jalan||'Jalan Lokal'; openModal('modalEdit'); }
async function simpan(){ const f=document.getElementById('formTambah'); const res=await fetch(API,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({nama:f.nama.value,jenis_jalan:f.jenis_jalan.value,geometry:{type:'LineString',coordinates:[]}})}); const d=await res.json(); if(d.status==='success'){showToast('Jalan disimpan!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); }
async function update(){ const id=document.getElementById('editId').value; const res=await fetch(API+'?id='+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({nama:document.getElementById('editNama').value,jenis_jalan:document.getElementById('editJenis').value})}); const d=await res.json(); if(d.status==='success'){showToast('Jalan diperbarui!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); }
async function hapus(id,nama){ if(!confirm(`Hapus jalan "${nama}"?`))return; const res=await fetch(API+'?id='+id,{method:'DELETE'}); const d=await res.json(); if(d.status==='success'){showToast('Jalan dihapus!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); }
</script>
JS; ?>
<?php require_once __DIR__ . '/partials/footer.php'; ?>
+82
View File
@@ -0,0 +1,82 @@
<?php
require_once __DIR__ . '/../core_config/database.php';
require_once __DIR__ . '/../core_config/middleware_auth.php';
requireRole('admin');
$pdo = Database::getConnection();
$rows = $pdo->query("SELECT id,nama_pemilik,status_kepemilikan,luas,created_at FROM kavling ORDER BY id DESC")->fetchAll();
$pageTitle='Data Kavling'; $activeNav='kavling';
require_once __DIR__ . '/partials/header.php';
$statusColors = ['SHM'=>'badge-success','HGB'=>'badge-info','HGU'=>'badge-warning','HP'=>'badge-primary'];
$statusFull = ['SHM'=>'Sertifikat Hak Milik','HGB'=>'Hak Guna Bangunan','HGU'=>'Hak Guna Usaha','HP'=>'Hak Pakai'];
?>
<div class="page-header">
<div class="breadcrumb"><span>Admin</span><span class="sep">/</span><span>Kavling</span></div>
<h1>🏘️ Manajemen Kavling / Parsil Tanah</h1>
<p>Kelola data bidang tanah berdasarkan status kepemilikan sertifikat.</p>
</div>
<div class="page-toolbar">
<div class="search-box"><i class="fas fa-search search-icon"></i><input type="text" id="searchInput" placeholder="Cari kavling..." oninput="filterTable('searchInput','kavTable')"></div>
<button class="btn btn-primary" onclick="openModal('modalTambah')"><i class="fas fa-plus"></i> Tambah Kavling</button>
</div>
<div class="table-wrap">
<table class="data-table" id="kavTable">
<thead><tr><th>#</th><th>Nama Pemilik</th><th>Status Kepemilikan</th><th>Luas (m²)</th><th>Ditambahkan</th><th>Aksi</th></tr></thead>
<tbody>
<?php foreach($rows as $i=>$r): $sk=$r['status_kepemilikan']; ?>
<tr>
<td style="color:var(--text-muted)"><?=$i+1?></td>
<td><strong><?=htmlspecialchars($r['nama_pemilik'])?></strong></td>
<td><span class="badge <?=$statusColors[$sk]??'badge-primary'?>" title="<?=$statusFull[$sk]??$sk?>"><?=$sk?></span></td>
<td><?=$r['luas'] ? number_format($r['luas'],0,',','.') : '-'?></td>
<td style="color:var(--text-muted);font-size:0.8rem;"><?=date('d M Y',strtotime($r['created_at']))?></td>
<td><div class="actions">
<button class="btn btn-ghost btn-sm btn-icon" onclick="editRow(<?=htmlspecialchars(json_encode($r))?>)"><i class="fas fa-edit"></i></button>
<button class="btn btn-danger btn-sm btn-icon" onclick="hapus(<?=$r['id']?>,'<?=addslashes($r['nama_pemilik'])?>')"><i class="fas fa-trash"></i></button>
</div></td>
</tr>
<?php endforeach; ?>
<?php if(!$rows):?><tr><td colspan="6" style="text-align:center;padding:40px;color:var(--text-muted);">Belum ada data kavling.</td></tr><?php endif;?>
</tbody>
</table></div>
<div class="modal-overlay" id="modalTambah"><div class="modal">
<div class="modal-header"><span class="modal-title">🏘️ Tambah Kavling</span><button class="modal-close" onclick="closeModal('modalTambah')">×</button></div>
<form id="formTambah">
<div class="form-group"><label class="form-label">Nama Pemilik *</label><input type="text" name="nama_pemilik" class="form-control" placeholder="Nama lengkap pemilik" required></div>
<div class="form-group"><label class="form-label">Status Kepemilikan *</label>
<select name="status_kepemilikan" class="form-control">
<option value="SHM">Sertifikat Hak Milik (SHM)</option>
<option value="HGB">Sertifikat Hak Guna Bangunan (HGB)</option>
<option value="HGU">Sertifikat Hak Guna Usaha (HGU)</option>
<option value="HP">Sertifikat Hak Pakai (HP)</option>
</select>
</div>
<div class="form-group"><label class="form-label">Luas (m²)</label><input type="number" name="luas" class="form-control" placeholder="Luas bidang tanah" min="0" step="0.01"></div>
<p style="font-size:0.82rem;color:var(--text-muted);background:rgba(59,130,246,0.08);border:1px solid rgba(59,130,246,0.2);border-radius:8px;padding:10px;"><i class="fas fa-info-circle"></i> Polygon kavling bisa digambar di <a href="<?= app_url('panel_admin/peta_admin.php') ?>" style="color:var(--primary-light);">Peta Interaktif</a>.</p>
</form>
<div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal('modalTambah')">Batal</button><button class="btn btn-primary" onclick="simpan()"><i class="fas fa-save"></i> Simpan</button></div>
</div></div>
<div class="modal-overlay" id="modalEdit"><div class="modal">
<div class="modal-header"><span class="modal-title">✏️ Edit Kavling</span><button class="modal-close" onclick="closeModal('modalEdit')">×</button></div>
<input type="hidden" id="editId">
<div class="form-group"><label class="form-label">Nama Pemilik *</label><input type="text" id="editNama" class="form-control" required></div>
<div class="form-group"><label class="form-label">Status Kepemilikan</label>
<select id="editStatus" class="form-control">
<option value="SHM">SHM</option><option value="HGB">HGB</option><option value="HGU">HGU</option><option value="HP">HP</option>
</select>
</div>
<div class="form-group"><label class="form-label">Luas (m²)</label><input type="number" id="editLuas" class="form-control" min="0" step="0.01"></div>
<div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal('modalEdit')">Batal</button><button class="btn btn-primary" onclick="update()"><i class="fas fa-save"></i> Perbarui</button></div>
</div></div>
<?php $extraScript = <<<'JS'
<script>
const API=APP_BASE + '/api/kavling.php';
function editRow(r){ document.getElementById('editId').value=r.id; document.getElementById('editNama').value=r.nama_pemilik; document.getElementById('editStatus').value=r.status_kepemilikan; document.getElementById('editLuas').value=r.luas||0; openModal('modalEdit'); }
async function simpan(){ const f=document.getElementById('formTambah'); if(!f.nama_pemilik.value){showToast('Nama pemilik wajib diisi!','error');return;} const res=await fetch(API,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({nama_pemilik:f.nama_pemilik.value,status_kepemilikan:f.status_kepemilikan.value,luas:parseFloat(f.luas.value)||0,geometry:{type:'Polygon',coordinates:[[]]}})}); const d=await res.json(); if(d.status==='success'){showToast('Kavling disimpan!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); }
async function update(){ const id=document.getElementById('editId').value; const res=await fetch(API+'?id='+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({nama_pemilik:document.getElementById('editNama').value,status_kepemilikan:document.getElementById('editStatus').value,luas:parseFloat(document.getElementById('editLuas').value)||0})}); const d=await res.json(); if(d.status==='success'){showToast('Kavling diperbarui!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); }
async function hapus(id,nama){ if(!confirm(`Hapus kavling milik "${nama}"?`))return; const res=await fetch(API+'?id='+id,{method:'DELETE'}); const d=await res.json(); if(d.status==='success'){showToast('Kavling dihapus!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); }
</script>
JS; ?>
<?php require_once __DIR__ . '/partials/footer.php'; ?>
+57
View File
@@ -0,0 +1,57 @@
<?php
require_once __DIR__ . '/../core_config/database.php';
require_once __DIR__ . '/../core_config/middleware_auth.php';
requireRole('admin');
$pdo = Database::getConnection();
$rows = $pdo->query("SELECT k.id, k.nama_kawasan, k.created_at, COUNT(w.id) as jumlah_warga
FROM kawasan_kumuh k LEFT JOIN warga_miskin w ON ST_Contains(k.geom, w.geom)
GROUP BY k.id ORDER BY k.id DESC")->fetchAll();
$pageTitle='Kawasan Kumuh'; $activeNav='kawasan';
require_once __DIR__ . '/partials/header.php';
?>
<div class="page-header">
<div class="breadcrumb"><span>Admin</span><span class="sep">/</span><span>Kawasan Kumuh</span></div>
<h1>⚠️ Manajemen Kawasan Kumuh</h1>
<p>Kelola data kawasan analisis spasial kepadatan warga miskin (choropleth).</p>
</div>
<div class="page-toolbar">
<div class="search-box"><i class="fas fa-search search-icon"></i><input type="text" id="searchInput" placeholder="Cari kawasan..." oninput="filterTable('searchInput','kawasanTable')"></div>
<a href="<?= app_url('panel_admin/peta_admin.php') ?>" class="btn btn-primary"><i class="fas fa-map"></i> Gambar di Peta</a>
</div>
<div class="table-wrap">
<table class="data-table" id="kawasanTable">
<thead><tr><th>#</th><th>Nama Kawasan</th><th>Warga Terdampak</th><th>Status</th><th>Ditambahkan</th><th>Aksi</th></tr></thead>
<tbody>
<?php foreach($rows as $i=>$r): $cnt=(int)$r['jumlah_warga']; $status=$cnt>3?'Rawan Kumuh':($cnt>0?'Perlu Perhatian':'Aman'); $badge=$cnt>3?'badge-danger':($cnt>0?'badge-warning':'badge-success'); ?>
<tr>
<td style="color:var(--text-muted)"><?=$i+1?></td>
<td><strong><?=htmlspecialchars($r['nama_kawasan'])?></strong></td>
<td style="text-align:center;font-size:1.1rem;font-weight:700;"><?=$cnt?></td>
<td><span class="badge <?=$badge?>"><?=$status?></span></td>
<td style="color:var(--text-muted);font-size:0.8rem;"><?=date('d M Y',strtotime($r['created_at']))?></td>
<td><div class="actions">
<button class="btn btn-ghost btn-sm btn-icon" onclick="editRow(<?=htmlspecialchars(json_encode($r))?>)"><i class="fas fa-edit"></i></button>
<button class="btn btn-danger btn-sm btn-icon" onclick="hapus(<?=$r['id']?>,'<?=addslashes($r['nama_kawasan'])?>')"><i class="fas fa-trash"></i></button>
</div></td>
</tr>
<?php endforeach; ?>
<?php if(!$rows):?><tr><td colspan="6" style="text-align:center;padding:40px;color:var(--text-muted);">Belum ada data kawasan. Gambar polygon di peta untuk menambahkan.</td></tr><?php endif;?>
</tbody>
</table></div>
<div class="modal-overlay" id="modalEdit"><div class="modal">
<div class="modal-header"><span class="modal-title">✏️ Edit Kawasan</span><button class="modal-close" onclick="closeModal('modalEdit')">×</button></div>
<input type="hidden" id="editId">
<div class="form-group"><label class="form-label">Nama Kawasan *</label><input type="text" id="editNama" class="form-control" required></div>
<div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal('modalEdit')">Batal</button><button class="btn btn-primary" onclick="update()"><i class="fas fa-save"></i> Perbarui</button></div>
</div></div>
<?php $extraScript = <<<'JS'
<script>
const API=APP_BASE + '/api/kawasan_kumuh.php';
function editRow(r){ document.getElementById('editId').value=r.id; document.getElementById('editNama').value=r.nama_kawasan; openModal('modalEdit'); }
async function update(){ const id=document.getElementById('editId').value; const res=await fetch(API+'?id='+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({nama_kawasan:document.getElementById('editNama').value})}); const d=await res.json(); if(d.status==='success'){showToast('Kawasan diperbarui!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); }
async function hapus(id,nama){ if(!confirm(`Hapus kawasan "${nama}"?`))return; const res=await fetch(API+'?id='+id,{method:'DELETE'}); const d=await res.json(); if(d.status==='success'){showToast('Kawasan dihapus!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); }
</script>
JS; ?>
<?php require_once __DIR__ . '/partials/footer.php'; ?>
+191
View File
@@ -0,0 +1,191 @@
<?php
require_once __DIR__ . '/../core_config/database.php';
require_once __DIR__ . '/../core_config/middleware_auth.php';
requireRole('admin');
$pdo = Database::getConnection();
$sql = "SELECT l.id, l.kategori, l.deskripsi, l.status, l.created_at,
u.nama_lengkap, u.username, ST_X(l.geometry) as lng, ST_Y(l.geometry) as lat
FROM laporan_warga l
JOIN users u ON l.user_id = u.id
ORDER BY l.id DESC";
$rows = $pdo->query($sql)->fetchAll();
$pageTitle = 'Laporan Warga';
$activeNav = 'laporan';
$extraHead = '<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">';
require_once __DIR__ . '/partials/header.php';
function badgeStatus($status) {
if($status === 'menunggu') return 'badge-warning';
if($status === 'diproses') return 'badge-info';
if($status === 'selesai') return 'badge-success';
if($status === 'ditolak') return 'badge-danger';
return 'badge-primary';
}
?>
<div class="page-header">
<div class="breadcrumb"><span>Admin</span><span class="sep">/</span><span>Laporan Warga</span></div>
<h1>📢 Validasi Laporan Warga</h1>
<p>Kelola dan perbarui status laporan kerusakan infrastruktur dari warga.</p>
</div>
<div class="page-toolbar">
<div class="search-box">
<i class="fas fa-search search-icon"></i>
<input type="text" id="searchInput" placeholder="Cari laporan..." oninput="filterTable('searchInput','lapTable')">
</div>
</div>
<div class="table-wrap">
<table class="data-table" id="lapTable">
<thead>
<tr>
<th>#</th>
<th>Pelapor</th>
<th>Kategori</th>
<th>Deskripsi</th>
<th>Status</th>
<th>Tanggal</th>
<th>Aksi</th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $i => $r): ?>
<tr>
<td style="color:var(--text-muted)"><?= $i+1 ?></td>
<td><strong><?= htmlspecialchars($r['nama_lengkap'] ?: $r['username']) ?></strong></td>
<td><?= htmlspecialchars($r['kategori']) ?></td>
<td style="color:var(--text-secondary); max-width: 250px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
<?= htmlspecialchars($r['deskripsi']) ?>
</td>
<td><span class="badge <?= badgeStatus($r['status']) ?>"><?= ucfirst($r['status']) ?></span></td>
<td style="color:var(--text-muted); font-size:0.8rem;"><?= date('d M Y', strtotime($r['created_at'])) ?></td>
<td>
<div class="actions">
<button class="btn btn-ghost btn-sm btn-icon" onclick='lihatLokasi(<?= json_encode($r) ?>)' title="Lihat Lokasi"><i class="fas fa-map-marker-alt" style="color: #3B82F6;"></i></button>
<button class="btn btn-ghost btn-sm btn-icon" onclick='editStatus(<?= json_encode($r) ?>)' title="Ubah Status"><i class="fas fa-edit"></i></button>
<button class="btn btn-danger btn-sm btn-icon" onclick="hapus(<?= $r['id'] ?>)" title="Hapus Laporan"><i class="fas fa-trash"></i></button>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php if (!$rows): ?>
<tr><td colspan="7" style="text-align:center;padding:40px;color:var(--text-muted);">Belum ada laporan dari warga.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<!-- Modal Ubah Status -->
<div class="modal-overlay" id="modalStatus">
<div class="modal">
<div class="modal-header">
<span class="modal-title">✏️ Perbarui Status Laporan</span>
<button class="modal-close" onclick="closeModal('modalStatus')">×</button>
</div>
<input type="hidden" id="editId">
<div class="form-group">
<label class="form-label">Ubah Status</label>
<select id="editStatusSelect" class="form-control">
<option value="menunggu">Menunggu</option>
<option value="diproses">Diproses</option>
<option value="selesai">Selesai</option>
<option value="ditolak">Ditolak</option>
</select>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeModal('modalStatus')">Batal</button>
<button class="btn btn-primary" onclick="updateStatus()"><i class="fas fa-save"></i> Simpan</button>
</div>
</div>
</div>
<!-- Modal Lihat Peta -->
<div class="modal-overlay" id="modalMap">
<div class="modal">
<div class="modal-header">
<span class="modal-title">📍 Lokasi Laporan</span>
<button class="modal-close" onclick="closeModal('modalMap')">×</button>
</div>
<div style="padding-bottom: 12px; font-size: 0.9rem;">
<strong id="mapKategori"></strong><br>
<span style="color: var(--text-secondary);" id="mapDeskripsi"></span>
</div>
<div id="miniMapLihat" style="height:300px;border-radius:8px;border:1px solid #E5E7EB;"></div>
</div>
</div>
<?php $extraScript = <<<'JS'
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const API = APP_BASE + '/api/laporan.php';
function editStatus(r) {
document.getElementById('editId').value = r.id;
document.getElementById('editStatusSelect').value = r.status;
openModal('modalStatus');
}
async function updateStatus() {
const id = document.getElementById('editId').value;
const status = document.getElementById('editStatusSelect').value;
const res = await fetch(API + '?id=' + id, {
method:'PUT',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({ status: status })
});
const d = await res.json();
if(d.status === 'success') {
showToast('Status diperbarui!');
setTimeout(() => location.reload(), 900);
} else {
showToast(d.message,'error');
}
}
async function hapus(id) {
if(!confirm('Hapus laporan secara permanen?')) return;
const res = await fetch(API + '?id=' + id, { method:'DELETE' });
const d = await res.json();
if(d.status === 'success') {
showToast('Laporan dihapus!');
setTimeout(() => location.reload(), 900);
} else {
showToast(d.message,'error');
}
}
let mapInstance = null, mapMarker = null;
const originalOpenModal = window.openModal;
window.openModal = function(id) {
originalOpenModal(id);
if(id === 'modalMap') {
setTimeout(() => {
if(mapInstance) mapInstance.invalidateSize();
}, 100);
}
};
function lihatLokasi(r) {
document.getElementById('mapKategori').innerText = r.kategori;
document.getElementById('mapDeskripsi').innerText = r.deskripsi;
openModal('modalMap');
setTimeout(() => {
if(!mapInstance) {
mapInstance = L.map('miniMapLihat');
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png').addTo(mapInstance);
}
if(mapMarker) mapInstance.removeLayer(mapMarker);
const latLng = [parseFloat(r.lat), parseFloat(r.lng)];
mapMarker = L.marker(latLng).addTo(mapInstance);
mapInstance.setView(latLng, 16);
}, 150);
}
</script>
JS; ?>
<?php require_once __DIR__ . '/partials/footer.php'; ?>
+164
View File
@@ -0,0 +1,164 @@
<?php
require_once __DIR__ . '/../core_config/database.php';
require_once __DIR__ . '/../core_config/middleware_auth.php';
requireRole('admin');
$pdo = Database::getConnection();
$rows = $pdo->query("SELECT id, nama, deskripsi, buka_24_jam, created_at FROM spbu ORDER BY id DESC")->fetchAll();
$pageTitle = 'Data SPBU'; $activeNav = 'spbu';
$extraHead = '<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">';
require_once __DIR__ . '/partials/header.php';
?>
<div class="page-header">
<div class="breadcrumb"><span>Admin</span><span class="sep">/</span><span>Data SPBU</span></div>
<h1>⛽ Manajemen Data SPBU</h1>
<p>Kelola data Stasiun Pengisian Bahan Bakar Umum (SPBU) di wilayah pemetaan.</p>
</div>
<div class="page-toolbar">
<div class="search-box">
<i class="fas fa-search search-icon"></i>
<input type="text" id="searchInput" placeholder="Cari SPBU..." oninput="filterTable('searchInput','spbuTable')">
</div>
<button class="btn btn-primary" onclick="openModal('modalTambah')">
<i class="fas fa-plus"></i> Tambah SPBU
</button>
</div>
<div class="table-wrap">
<table class="data-table" id="spbuTable">
<thead><tr>
<th>#</th><th>Nama SPBU</th><th>Deskripsi</th><th>Status</th><th>Ditambahkan</th><th>Aksi</th>
</tr></thead>
<tbody>
<?php foreach ($rows as $i => $r): ?>
<tr>
<td style="color:var(--text-muted)"><?= $i+1 ?></td>
<td><strong><?= htmlspecialchars($r['nama']) ?></strong></td>
<td style="color:var(--text-secondary)"><?= htmlspecialchars($r['deskripsi'] ?: '-') ?></td>
<td><span class="badge <?= $r['buka_24_jam'] ? 'badge-success' : 'badge-warning' ?>"><?= $r['buka_24_jam'] ? '✅ 24 Jam' : '🕐 Terbatas' ?></span></td>
<td style="color:var(--text-muted); font-size:0.8rem;"><?= date('d M Y, H:i', strtotime($r['created_at'])) ?></td>
<td><div class="actions">
<button class="btn btn-ghost btn-sm btn-icon" onclick="editRow(<?= htmlspecialchars(json_encode($r)) ?>)" title="Edit"><i class="fas fa-edit"></i></button>
<button class="btn btn-danger btn-sm btn-icon" onclick="hapus(<?= $r['id'] ?>,'<?= addslashes($r['nama']) ?>')" title="Hapus"><i class="fas fa-trash"></i></button>
</div></td>
</tr>
<?php endforeach; ?>
<?php if (!$rows): ?><tr><td colspan="6" style="text-align:center;padding:40px;color:var(--text-muted);">Belum ada data SPBU. <a href="#" onclick="openModal('modalTambah')">Tambah sekarang</a></td></tr><?php endif; ?>
</tbody>
</table>
</div>
<!-- Modal Tambah -->
<div class="modal-overlay" id="modalTambah">
<div class="modal">
<div class="modal-header">
<span class="modal-title">⛽ Tambah SPBU Baru</span>
<button class="modal-close" onclick="closeModal('modalTambah')">×</button>
</div>
<form id="formTambah">
<div class="form-group"><label class="form-label">Nama SPBU *</label><input type="text" name="nama" class="form-control" placeholder="Nama SPBU" required></div>
<div class="form-group"><label class="form-label">Deskripsi</label><textarea name="deskripsi" class="form-control" placeholder="Keterangan tambahan..."></textarea></div>
<div class="form-group"><label class="form-label" style="display:flex;align-items:center;gap:10px;cursor:pointer;"><input type="checkbox" name="buka_24_jam" id="cb24jam" style="width:16px;height:16px;accent-color:var(--accent);"> Buka 24 Jam</label></div>
<div class="form-group">
<label class="form-label">Pilih Lokasi di Peta *</label>
<div id="miniMapTambah" style="height:200px;border-radius:8px;border:1px solid #E5E7EB;margin-bottom:10px;z-index:1;"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<input type="number" name="lat" id="latTambah" class="form-control" placeholder="Latitude" step="any" required readonly style="background:#F9FAFB;">
<input type="number" name="lng" id="lngTambah" class="form-control" placeholder="Longitude" step="any" required readonly style="background:#F9FAFB;">
</div>
</div>
</form>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeModal('modalTambah')">Batal</button>
<button class="btn btn-primary" onclick="simpan()"><i class="fas fa-save"></i> Simpan</button>
</div>
</div></div>
<!-- Modal Edit -->
<div class="modal-overlay" id="modalEdit">
<div class="modal">
<div class="modal-header">
<span class="modal-title">✏️ Edit Data SPBU</span>
<button class="modal-close" onclick="closeModal('modalEdit')">×</button>
</div>
<form id="formEdit">
<input type="hidden" name="id" id="editId">
<div class="form-group"><label class="form-label">Nama SPBU *</label><input type="text" name="nama" id="editNama" class="form-control" required></div>
<div class="form-group"><label class="form-label">Deskripsi</label><textarea name="deskripsi" id="editDesk" class="form-control"></textarea></div>
<div class="form-group"><label class="form-label" style="display:flex;align-items:center;gap:10px;cursor:pointer;"><input type="checkbox" id="editCb24" style="width:16px;height:16px;accent-color:var(--accent);"> Buka 24 Jam</label></div>
</form>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeModal('modalEdit')">Batal</button>
<button class="btn btn-primary" onclick="update()"><i class="fas fa-save"></i> Perbarui</button>
</div>
</div></div>
<?php $extraScript = <<<'JS'
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const API = APP_BASE + '/api/spbu.php';
let miniMap = null, miniMarker = null;
const originalOpenModal = window.openModal;
window.openModal = function(id) {
originalOpenModal(id);
if(id === 'modalTambah') {
setTimeout(() => {
if(!miniMap) {
miniMap = L.map('miniMapTambah').setView([-0.0583, 109.3448], 13);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png').addTo(miniMap);
miniMap.on('click', function(e) {
const lat = e.latlng.lat;
const lng = e.latlng.lng;
if(!miniMarker) miniMarker = L.marker([lat, lng]).addTo(miniMap);
else miniMarker.setLatLng([lat, lng]);
document.getElementById('latTambah').value = lat.toFixed(6);
document.getElementById('lngTambah').value = lng.toFixed(6);
});
} else {
miniMap.invalidateSize();
}
}, 100);
}
};
function editRow(r) {
document.getElementById('editId').value = r.id;
document.getElementById('editNama').value = r.nama;
document.getElementById('editDesk').value = r.deskripsi || '';
document.getElementById('editCb24').checked = !!r.buka_24_jam;
openModal('modalEdit');
}
async function simpan() {
const f = document.getElementById('formTambah');
const lat = parseFloat(f.lat.value), lng = parseFloat(f.lng.value);
if (!f.nama.value || isNaN(lat) || isNaN(lng)) { showToast('Lengkapi nama dan pilih lokasi di peta!','error'); return; }
const res = await fetch(API, { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ nama:f.nama.value, deskripsi:f.deskripsi.value,
buka_24_jam: f.querySelector('[name=buka_24_jam]').checked ? 1:0,
geometry:{ type:'Point', coordinates:[lng,lat] }}) });
const d = await res.json();
if(d.status==='success') { showToast('SPBU berhasil disimpan!'); setTimeout(()=>location.reload(),900); }
else showToast(d.message,'error');
}
async function update() {
const id = document.getElementById('editId').value;
const res = await fetch(API+'?id='+id, { method:'PUT', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ nama:document.getElementById('editNama').value,
deskripsi:document.getElementById('editDesk').value,
buka_24_jam: document.getElementById('editCb24').checked ? 1:0 }) });
const d = await res.json();
if(d.status==='success') { showToast('SPBU berhasil diperbarui!'); setTimeout(()=>location.reload(),900); }
else showToast(d.message,'error');
}
async function hapus(id, nama) {
if(!confirm(`Hapus SPBU "${nama}"?`)) return;
const res = await fetch(API+'?id='+id, { method:'DELETE' });
const d = await res.json();
if(d.status==='success') { showToast('SPBU dihapus!'); setTimeout(()=>location.reload(),900); }
else showToast(d.message,'error');
}
</script>
JS; ?>
<?php require_once __DIR__ . '/partials/footer.php'; ?>
+101
View File
@@ -0,0 +1,101 @@
<?php
require_once __DIR__ . '/../core_config/database.php';
require_once __DIR__ . '/../core_config/middleware_auth.php';
requireRole('admin');
$pdo = Database::getConnection();
$rows = $pdo->query("SELECT id,nama_kk,penghasilan,jumlah_tanggungan,created_at FROM warga_miskin ORDER BY id DESC")->fetchAll();
$pageTitle='Warga Miskin'; $activeNav='warga';
$extraHead = '<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">';
require_once __DIR__ . '/partials/header.php';
?>
<div class="page-header">
<div class="breadcrumb"><span>Admin</span><span class="sep">/</span><span>Warga Miskin</span></div>
<h1>👥 Manajemen Data Warga Miskin</h1>
<p>Kelola data kepala keluarga yang terdaftar sebagai penerima bantuan sosial.</p>
</div>
<div class="page-toolbar">
<div class="search-box"><i class="fas fa-search search-icon"></i><input type="text" id="searchInput" placeholder="Cari warga..." oninput="filterTable('searchInput','wargaTable')"></div>
<button class="btn btn-primary" onclick="openModal('modalTambah')"><i class="fas fa-plus"></i> Tambah Data</button>
</div>
<div class="table-wrap">
<table class="data-table" id="wargaTable">
<thead><tr><th>#</th><th>Nama KK</th><th>Penghasilan/Bln</th><th>Tanggungan</th><th>Ditambahkan</th><th>Aksi</th></tr></thead>
<tbody>
<?php foreach($rows as $i=>$r): ?>
<tr>
<td style="color:var(--text-muted)"><?=$i+1?></td>
<td><strong><?=htmlspecialchars($r['nama_kk'])?></strong></td>
<td>Rp <?=number_format($r['penghasilan'],0,',','.')?></td>
<td><span class="badge <?=$r['jumlah_tanggungan']>=4?'badge-danger':($r['jumlah_tanggungan']>=2?'badge-warning':'badge-success')?>"><?=$r['jumlah_tanggungan']?> orang</span></td>
<td style="color:var(--text-muted);font-size:0.8rem;"><?=date('d M Y',strtotime($r['created_at']))?></td>
<td><div class="actions">
<button class="btn btn-ghost btn-sm btn-icon" onclick="editRow(<?=htmlspecialchars(json_encode($r))?>)"><i class="fas fa-edit"></i></button>
<button class="btn btn-danger btn-sm btn-icon" onclick="hapus(<?=$r['id']?>,'<?=addslashes($r['nama_kk'])?>')"><i class="fas fa-trash"></i></button>
</div></td>
</tr>
<?php endforeach; ?>
<?php if(!$rows):?><tr><td colspan="6" style="text-align:center;padding:40px;color:var(--text-muted);">Belum ada data.</td></tr><?php endif;?>
</tbody>
</table></div>
<div class="modal-overlay" id="modalTambah"><div class="modal">
<div class="modal-header"><span class="modal-title">👥 Tambah Warga Miskin</span><button class="modal-close" onclick="closeModal('modalTambah')">×</button></div>
<form id="formTambah">
<div class="form-group"><label class="form-label">Nama Kepala Keluarga *</label><input type="text" name="nama_kk" class="form-control" placeholder="Nama lengkap KK" required></div>
<div class="form-group"><label class="form-label">Penghasilan per Bulan (Rp)</label><input type="number" name="penghasilan" class="form-control" placeholder="0" min="0"></div>
<div class="form-group"><label class="form-label">Jumlah Tanggungan (jiwa)</label><input type="number" name="jumlah_tanggungan" class="form-control" placeholder="0" min="0" max="20"></div>
<div class="form-group"><label class="form-label">Pilih Lokasi di Peta *</label>
<div id="miniMapTambah" style="height:200px;border-radius:8px;border:1px solid #E5E7EB;margin-bottom:10px;z-index:1;"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<input type="number" name="lat" id="latTambah" class="form-control" placeholder="Latitude" step="any" required readonly style="background:#F9FAFB;">
<input type="number" name="lng" id="lngTambah" class="form-control" placeholder="Longitude" step="any" required readonly style="background:#F9FAFB;">
</div>
</div>
</form>
<div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal('modalTambah')">Batal</button><button class="btn btn-primary" onclick="simpan()"><i class="fas fa-save"></i> Simpan</button></div>
</div></div>
<div class="modal-overlay" id="modalEdit"><div class="modal">
<div class="modal-header"><span class="modal-title">✏️ Edit Data Warga</span><button class="modal-close" onclick="closeModal('modalEdit')">×</button></div>
<input type="hidden" id="editId">
<div class="form-group"><label class="form-label">Nama KK *</label><input type="text" id="editNama" class="form-control" required></div>
<div class="form-group"><label class="form-label">Penghasilan/Bln (Rp)</label><input type="number" id="editPenghasilan" class="form-control" min="0"></div>
<div class="form-group"><label class="form-label">Jumlah Tanggungan</label><input type="number" id="editTanggungan" class="form-control" min="0" max="20"></div>
<div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal('modalEdit')">Batal</button><button class="btn btn-primary" onclick="update()"><i class="fas fa-save"></i> Perbarui</button></div>
</div></div>
<?php $extraScript = <<<'JS'
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const API=APP_BASE + '/api/warga_miskin.php';
let miniMap = null, miniMarker = null;
const originalOpenModal = window.openModal;
window.openModal = function(id) {
originalOpenModal(id);
if(id === 'modalTambah') {
setTimeout(() => {
if(!miniMap) {
miniMap = L.map('miniMapTambah').setView([-0.0583, 109.3448], 13);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png').addTo(miniMap);
miniMap.on('click', function(e) {
const lat = e.latlng.lat;
const lng = e.latlng.lng;
if(!miniMarker) miniMarker = L.marker([lat, lng]).addTo(miniMap);
else miniMarker.setLatLng([lat, lng]);
document.getElementById('latTambah').value = lat.toFixed(6);
document.getElementById('lngTambah').value = lng.toFixed(6);
});
} else {
miniMap.invalidateSize();
}
}, 100);
}
};
function editRow(r){ document.getElementById('editId').value=r.id; document.getElementById('editNama').value=r.nama_kk; document.getElementById('editPenghasilan').value=r.penghasilan; document.getElementById('editTanggungan').value=r.jumlah_tanggungan; openModal('modalEdit'); }
async function simpan(){ const f=document.getElementById('formTambah'); if(!f.nama_kk.value){showToast('Nama KK wajib!','error');return;} const lat=parseFloat(f.lat.value), lng=parseFloat(f.lng.value); if(isNaN(lat)||isNaN(lng)){showToast('Silakan pilih lokasi di peta!','error');return;} const res=await fetch(API,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({nama_kk:f.nama_kk.value,penghasilan:parseFloat(f.penghasilan.value)||0,jumlah_tanggungan:parseInt(f.jumlah_tanggungan.value)||0,geometry:{type:'Point',coordinates:[lng,lat]}})}); const d=await res.json(); if(d.status==='success'){showToast('Data disimpan!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); }
async function update(){ const id=document.getElementById('editId').value; const res=await fetch(API+'?id='+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({nama_kk:document.getElementById('editNama').value,penghasilan:parseFloat(document.getElementById('editPenghasilan').value)||0,jumlah_tanggungan:parseInt(document.getElementById('editTanggungan').value)||0})}); const d=await res.json(); if(d.status==='success'){showToast('Diperbarui!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); }
async function hapus(id,nama){ if(!confirm(`Hapus data "${nama}"?`))return; const res=await fetch(API+'?id='+id,{method:'DELETE'}); const d=await res.json(); if(d.status==='success'){showToast('Dihapus!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); }
</script>
JS; ?>
<?php require_once __DIR__ . '/partials/footer.php'; ?>
@@ -0,0 +1,179 @@
<?php
require_once __DIR__ . '/../core_config/database.php';
require_once __DIR__ . '/../core_config/middleware_auth.php';
requireRole('admin');
$pdo = Database::getConnection();
$rows = $pdo->query("SELECT id, username, role, nama_lengkap, created_at FROM users ORDER BY id ASC")->fetchAll();
$pageTitle = 'Kelola Users';
$activeNav = 'users';
require_once __DIR__ . '/partials/header.php';
?>
<div class="page-header">
<div class="breadcrumb"><span>Admin</span><span class="sep">/</span><span>Users</span></div>
<h1>👤 Manajemen User Sistem</h1>
<p>Kelola akun administrator dan pengguna yang dapat masuk ke WebGIS Smart City.</p>
</div>
<div class="page-toolbar">
<div class="search-box">
<i class="fas fa-search search-icon"></i>
<input type="text" id="searchInput" placeholder="Cari user..." oninput="filterTable('searchInput','usersTable')">
</div>
<button class="btn btn-primary" onclick="openModal('modalTambah')">
<i class="fas fa-plus"></i> Tambah User
</button>
</div>
<div class="table-wrap">
<table class="data-table" id="usersTable">
<thead><tr>
<th>#</th><th>Username</th><th>Nama Lengkap</th><th>Role</th><th>Dibuat</th><th>Aksi</th>
</tr></thead>
<tbody>
<?php foreach ($rows as $i => $r): ?>
<tr>
<td style="color:var(--text-muted)"><?= $i+1 ?></td>
<td><strong><?= htmlspecialchars($r['username']) ?></strong></td>
<td><?= htmlspecialchars($r['nama_lengkap'] ?: '-') ?></td>
<td>
<?php if ($r['role'] === 'admin'): ?>
<span class="badge badge-danger">👨‍💼 Admin</span>
<?php else: ?>
<span class="badge badge-info">👤 Pengguna</span>
<?php endif; ?>
</td>
<td style="color:var(--text-muted); font-size:0.8rem;"><?= date('d M Y', strtotime($r['created_at'])) ?></td>
<td><div class="actions">
<button class="btn btn-ghost btn-sm btn-icon" onclick='editRow(<?= json_encode($r, JSON_HEX_APOS|JSON_HEX_QUOT) ?>)' title="Edit">
<i class="fas fa-edit"></i>
</button>
<button class="btn btn-danger btn-sm btn-icon" onclick="hapus(<?= $r['id'] ?>,'<?= addslashes($r['username']) ?>')" title="Hapus">
<i class="fas fa-trash"></i>
</button>
</div></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<!-- Modal Tambah -->
<div class="modal-overlay" id="modalTambah">
<div class="modal">
<div class="modal-header">
<span class="modal-title">👤 Tambah User Baru</span>
<button class="modal-close" onclick="closeModal('modalTambah')">×</button>
</div>
<form id="formTambah">
<div class="form-group"><label class="form-label">Username *</label><input type="text" name="username" id="tUsername" class="form-control" placeholder="username unik" required></div>
<div class="form-group"><label class="form-label">Nama Lengkap</label><input type="text" name="nama_lengkap" id="tNama" class="form-control" placeholder="Nama lengkap"></div>
<div class="form-group"><label class="form-label">Role *</label>
<select name="role" id="tRole" class="form-control">
<option value="user">👤 Pengguna (read-only)</option>
<option value="admin">👨‍💼 Administrator</option>
</select>
</div>
<div class="form-group"><label class="form-label">Password *</label><input type="password" name="password" id="tPassword" class="form-control" placeholder="Minimal 6 karakter" required minlength="6"></div>
</form>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeModal('modalTambah')">Batal</button>
<button class="btn btn-primary" onclick="simpan()"><i class="fas fa-save"></i> Simpan</button>
</div>
</div>
</div>
<!-- Modal Edit -->
<div class="modal-overlay" id="modalEdit">
<div class="modal">
<div class="modal-header">
<span class="modal-title">✏️ Edit User</span>
<button class="modal-close" onclick="closeModal('modalEdit')">×</button>
</div>
<input type="hidden" id="editId">
<div class="form-group"><label class="form-label">Username *</label><input type="text" id="editUsername" class="form-control" required></div>
<div class="form-group"><label class="form-label">Nama Lengkap</label><input type="text" id="editNama" class="form-control"></div>
<div class="form-group"><label class="form-label">Role *</label>
<select id="editRole" class="form-control">
<option value="user">👤 Pengguna</option>
<option value="admin">👨‍💼 Administrator</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Password Baru</label>
<input type="password" id="editPassword" class="form-control" placeholder="Kosongkan jika tidak ingin mengubah" minlength="6">
<div style="font-size:0.72rem;color:var(--text-muted);margin-top:4px;">Kosongkan jika tidak ingin mengubah password.</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeModal('modalEdit')">Batal</button>
<button class="btn btn-primary" onclick="update()"><i class="fas fa-save"></i> Perbarui</button>
</div>
</div>
</div>
<?php $extraScript = <<<'JS'
<script>
const API = APP_BASE + '/api/users.php';
function editRow(r) {
document.getElementById('editId').value = r.id;
document.getElementById('editUsername').value = r.username;
document.getElementById('editNama').value = r.nama_lengkap || '';
document.getElementById('editRole').value = r.role;
document.getElementById('editPassword').value = '';
openModal('modalEdit');
}
async function simpan() {
const u = document.getElementById('tUsername').value.trim();
const p = document.getElementById('tPassword').value;
if (!u) return showToast('Username wajib!', 'error');
if (p.length < 6) return showToast('Password minimal 6 karakter!', 'error');
const res = await fetch(API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: u,
nama_lengkap: document.getElementById('tNama').value,
role: document.getElementById('tRole').value,
password: p
})
});
const d = await res.json();
if (d.status === 'success') { showToast('User berhasil ditambahkan!'); setTimeout(() => location.reload(), 900); }
else showToast(d.message || 'Gagal', 'error');
}
async function update() {
const id = document.getElementById('editId').value;
const u = document.getElementById('editUsername').value.trim();
const p = document.getElementById('editPassword').value;
if (!u) return showToast('Username wajib!', 'error');
if (p && p.length < 6) return showToast('Password minimal 6 karakter!', 'error');
const body = {
username: u,
nama_lengkap: document.getElementById('editNama').value,
role: document.getElementById('editRole').value
};
if (p) body.password = p;
const res = await fetch(API + '?id=' + id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const d = await res.json();
if (d.status === 'success') { showToast('User diperbarui!'); setTimeout(() => location.reload(), 900); }
else showToast(d.message || 'Gagal', 'error');
}
async function hapus(id, username) {
if (id == 1) return showToast('Admin utama (ID=1) tidak dapat dihapus!', 'error');
if (!confirm(`Hapus user "${username}"?`)) return;
const res = await fetch(API + '?id=' + id, { method: 'DELETE' });
const d = await res.json();
if (d.status === 'success') { showToast('User dihapus!'); setTimeout(() => location.reload(), 900); }
else showToast(d.message || 'Gagal', 'error');
}
</script>
JS; ?>
<?php require_once __DIR__ . '/partials/footer.php'; ?>
+119
View File
@@ -0,0 +1,119 @@
</div><!-- /admin-content -->
</main><!-- /admin-main -->
<!-- Toast Container -->
<div class="toast-container" id="toastContainer"></div>
<script>
// ── Clock ──
function updateClock() {
const now = new Date();
document.getElementById('clockText').textContent =
now.toLocaleTimeString('id-ID', { hour:'2-digit', minute:'2-digit', second:'2-digit' });
}
updateClock();
setInterval(updateClock, 1000);
// ── Toast utility ──
window.showToast = function(msg, type = 'success') {
const t = document.createElement('div');
t.className = `toast ${type}`;
const icons = { success: 'check-circle', error: 'exclamation-circle', info: 'info-circle' };
t.innerHTML = `<i class="fas fa-${icons[type]||'info-circle'}" style="margin-right:8px;"></i>${msg}`;
document.getElementById('toastContainer').appendChild(t);
setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 400); }, 3000);
};
// ── Modal helpers ──
window.openModal = function(id) {
document.getElementById(id)?.classList.add('active');
document.body.style.overflow = 'hidden';
};
window.closeModal = function(id) {
document.getElementById(id)?.classList.remove('active');
document.body.style.overflow = '';
};
// Close modal on overlay click
document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', e => {
if (e.target === overlay) closeModal(overlay.id);
});
});
// ── Search filter ──
window.filterTable = function(inputId, tableId) {
const q = document.getElementById(inputId)?.value.toLowerCase() || '';
document.querySelectorAll(`#${tableId} tbody tr`).forEach(row => {
row.style.display = row.textContent.toLowerCase().includes(q) ? '' : 'none';
});
};
// Realtime SPBU snackbar
(function initSpbuRealtimeSnackbar() {
const API_URL = APP_BASE + '/api/spbu_status.php';
const REFRESH_MS = 60000;
const SHOW_MS = 7000;
let lastOpen = null;
let hideTimer = null;
function ensureSnackbar() {
let el = document.getElementById('spbuRealtimeSnackbar');
if (el) return el;
el = document.createElement('div');
el.id = 'spbuRealtimeSnackbar';
el.className = 'spbu-snackbar';
el.innerHTML = `
<div class="spbu-snackbar-icon"><i class="fas fa-gas-pump"></i></div>
<div>
<div class="spbu-snackbar-title" id="spbuSnackbarTitle">SPBU sedang buka</div>
<div class="spbu-snackbar-meta" id="spbuSnackbarMeta">Memuat status terbaru...</div>
</div>
<div class="spbu-snackbar-count" id="spbuSnackbarCount">-</div>
`;
document.body.appendChild(el);
return el;
}
function showSnackbar(data) {
const el = ensureSnackbar();
const open = Number(data.spbu_buka || 0);
const total = Number(data.total_spbu || 0);
const closed = Number(data.spbu_tutup || Math.max(0, total - open));
const time = data.server_time ? data.server_time.slice(11, 16) : new Date().toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' });
document.getElementById('spbuSnackbarTitle').textContent = `${open} SPBU sedang buka`;
document.getElementById('spbuSnackbarMeta').textContent = `${closed} tutup/terbatas dari ${total} total - update ${time} WIB`;
document.getElementById('spbuSnackbarCount').textContent = open;
el.classList.add('show');
clearTimeout(hideTimer);
hideTimer = setTimeout(() => el.classList.remove('show'), SHOW_MS);
}
async function refreshStatus(forceShow = false) {
try {
const res = await fetch(API_URL, { cache: 'no-store' });
const json = await res.json();
if (json.status !== 'success' || !json.data) return;
const open = Number(json.data.spbu_buka || 0);
if (forceShow || lastOpen === null || open !== lastOpen) {
showSnackbar(json.data);
}
lastOpen = open;
} catch (e) {
// Snackbar is informational; keep the page quiet if polling fails.
}
}
document.addEventListener('DOMContentLoaded', () => {
refreshStatus(true);
setInterval(() => refreshStatus(false), REFRESH_MS);
});
})();
</script>
<?= $extraScript ?? '' ?>
</body>
</html>
+110
View File
@@ -0,0 +1,110 @@
<?php
/**
* Admin Layout — sidebar + topbar
* Include di awal setiap halaman admin setelah auth check.
* Variables expected from parent: $pageTitle, $activeNav
*/
$user = currentUser();
$initial = strtoupper(substr($user['nama_lengkap'] ?: $user['username'], 0, 1));
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($pageTitle ?? 'Admin Panel') ?> — WebGIS Smart City</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<link rel="stylesheet" href="<?= app_url('public_assets/css/global.css') ?>?v=20260610-spbu-snackbar">
<link rel="stylesheet" href="<?= app_url('public_assets/css/panel.css') ?>">
<script>const APP_BASE = '<?= app_url() ?>';</script>
<?= $extraHead ?? '' ?>
</head>
<body class="admin-page <?= $bodyClass ?? '' ?>">
<!-- ══ SIDEBAR ══ -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-brand">
<div class="brand-icon">🗺️</div>
<div class="brand-text">
<span class="brand-name">WebGIS</span>
<span class="brand-sub">Smart City Admin</span>
</div>
</div>
<nav class="sidebar-nav">
<div class="nav-section-title">Utama</div>
<a href="<?= app_url('panel_admin/index.php') ?>"
class="nav-item <?= ($activeNav==='dashboard') ? 'active' : '' ?>">
<span class="nav-icon"><i class="fas fa-chart-pie"></i></span> Dashboard
</a>
<a href="<?= app_url('panel_admin/peta_admin.php') ?>"
class="nav-item <?= ($activeNav==='peta') ? 'active' : '' ?>">
<span class="nav-icon"><i class="fas fa-map-marked-alt"></i></span> Peta Interaktif
</a>
<div class="nav-section-title">Data Spasial</div>
<a href="<?= app_url('panel_admin/kelola_spbu.php') ?>"
class="nav-item <?= ($activeNav==='spbu') ? 'active' : '' ?>">
<span class="nav-icon"><i class="fas fa-gas-pump"></i></span> SPBU
</a>
<a href="<?= app_url('panel_admin/kelola_jalan.php') ?>"
class="nav-item <?= ($activeNav==='jalan') ? 'active' : '' ?>">
<span class="nav-icon"><i class="fas fa-road"></i></span> Jalan
</a>
<a href="<?= app_url('panel_admin/kelola_kavling.php') ?>"
class="nav-item <?= ($activeNav==='kavling') ? 'active' : '' ?>">
<span class="nav-icon"><i class="fas fa-vector-square"></i></span> Kavling / Parsil
</a>
<a href="<?= app_url('panel_admin/kelola_fasilitas.php') ?>"
class="nav-item <?= ($activeNav==='rumah') ? 'active' : '' ?>">
<span class="nav-icon"><i class="fas fa-mosque"></i></span> Rumah Ibadah
</a>
<a href="<?= app_url('panel_admin/kelola_warga.php') ?>"
class="nav-item <?= ($activeNav==='warga') ? 'active' : '' ?>">
<span class="nav-icon"><i class="fas fa-users"></i></span> Warga Miskin
</a>
<a href="<?= app_url('panel_admin/kelola_kawasan.php') ?>"
class="nav-item <?= ($activeNav==='kawasan') ? 'active' : '' ?>">
<span class="nav-icon"><i class="fas fa-draw-polygon"></i></span> Kawasan Kumuh
</a>
<a href="<?= app_url('panel_admin/kelola_laporan.php') ?>"
class="nav-item <?= ($activeNav==='laporan') ? 'active' : '' ?>">
<span class="nav-icon"><i class="fas fa-bullhorn"></i></span> Laporan Warga
</a>
<div class="nav-section-title">Manajemen</div>
<a href="<?= app_url('panel_admin/manajemen_pengguna.php') ?>"
class="nav-item <?= ($activeNav==='users') ? 'active' : '' ?>">
<span class="nav-icon"><i class="fas fa-user-shield"></i></span> Kelola Users
</a>
</nav>
<div class="sidebar-footer">
<div class="user-info">
<div class="user-avatar"><?= $initial ?></div>
<div class="user-details">
<div class="user-name"><?= htmlspecialchars($user['nama_lengkap'] ?: $user['username']) ?></div>
<div class="user-role">Administrator</div>
</div>
<a href="<?= app_url('logout.php') ?>" class="user-logout" title="Keluar">
<i class="fas fa-sign-out-alt"></i>
</a>
</div>
</div>
</aside>
<!-- ══ MAIN CONTENT ══ -->
<main class="admin-main">
<div class="topbar">
<span class="topbar-title"><?= htmlspecialchars($pageTitle ?? 'Dashboard') ?></span>
<div class="topbar-actions">
<div class="topbar-time" id="clock">
<i class="fas fa-clock"></i> <span id="clockText"></span>
</div>
<a href="<?= app_url('panel_admin/peta_admin.php') ?>" class="btn btn-primary btn-sm">
<i class="fas fa-map"></i> Buka Peta
</a>
</div>
</div>
<div class="admin-content">
+173
View File
@@ -0,0 +1,173 @@
<?php
require_once __DIR__ . '/../core_config/database.php';
require_once __DIR__ . '/../core_config/middleware_auth.php';
requireRole('admin');
$pageTitle = 'Peta Interaktif';
$activeNav = 'peta';
$bodyClass = 'admin-map-page map-page';
$extraHead = '
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.css">
<link rel="stylesheet" href="' . app_url('public_assets/css/map.css') . '?v=20260610-2">
';
require_once __DIR__ . '/partials/header.php';
?>
<div class="map-wrap">
<div id="admin-map"></div>
<aside class="map-sidebar">
<div class="map-panel-header">
<div class="map-panel-icon"><i class="fas fa-layer-group"></i></div>
<div>
<h3>Layer Peta</h3>
<p>Kontrol data yang tampil di peta</p>
</div>
</div>
<div class="layer-section">
<div class="layer-section-title">Infrastruktur</div>
<label class="layer-item">
<input type="checkbox" id="ly-spbu" checked>
<span class="layer-color layer-point" style="--layer-color:#F59E0B"><i class="fas fa-gas-pump"></i></span>
<span class="layer-text">
<span class="layer-name">SPBU</span>
<span class="layer-meta">Stasiun pengisian bahan bakar</span>
</span>
</label>
<label class="layer-item">
<input type="checkbox" id="ly-jalan" checked>
<span class="layer-color layer-line" style="--layer-color:#3B82F6"></span>
<span class="layer-text">
<span class="layer-name">Jalan</span>
<span class="layer-meta">Ruas jalan utama dan lokal</span>
</span>
</label>
<label class="layer-item">
<input type="checkbox" id="ly-kavling" checked>
<span class="layer-color layer-area" style="--layer-color:#7C3AED"></span>
<span class="layer-text">
<span class="layer-name">Kavling / Parsil</span>
<span class="layer-meta">Batas bidang tanah</span>
</span>
</label>
</div>
<div class="layer-section">
<div class="layer-section-title">Sosial</div>
<label class="layer-item">
<input type="checkbox" id="ly-rumah" checked>
<span class="layer-color layer-point" style="--layer-color:#8B5CF6"><i class="fas fa-mosque"></i></span>
<span class="layer-text">
<span class="layer-name">Rumah Ibadah</span>
<span class="layer-meta">Fasilitas keagamaan</span>
</span>
</label>
<label class="layer-item">
<input type="checkbox" id="ly-warga" checked>
<span class="layer-color layer-point" style="--layer-color:#EF4444"><i class="fas fa-users"></i></span>
<span class="layer-text">
<span class="layer-name">Warga Miskin</span>
<span class="layer-meta">Sebaran keluarga penerima bantuan</span>
</span>
</label>
<label class="layer-item">
<input type="checkbox" id="ly-kawasan">
<span class="layer-color layer-area layer-hatch" style="--layer-color:#DC2626"></span>
<span class="layer-text">
<span class="layer-name">Kawasan Kumuh</span>
<span class="layer-meta">Area prioritas penanganan</span>
</span>
</label>
</div>
<div class="layer-section">
<div class="layer-section-title">Analisis</div>
<label class="layer-item">
<input type="checkbox" id="ly-choropleth">
<span class="layer-color layer-gradient"></span>
<span class="layer-text">
<span class="layer-name">Choropleth Kepadatan</span>
<span class="layer-meta">Kepadatan warga miskin per kawasan</span>
</span>
</label>
<label class="layer-item">
<input type="checkbox" id="ly-blankspot">
<span class="layer-color layer-point" style="--layer-color:#F97316"><i class="fas fa-location-dot"></i></span>
<span class="layer-text">
<span class="layer-name">Blank Spot Bansos</span>
<span class="layer-meta">Titik jauh dari fasilitas bantuan</span>
</span>
</label>
</div>
<div class="map-quickstats">
<div class="qs-item"><div class="qs-value" id="qs-spbu" style="color:#F59E0B">-</div><div class="qs-label">SPBU</div></div>
<div class="qs-item"><div class="qs-value" id="qs-jalan" style="color:#3B82F6">-</div><div class="qs-label">Jalan</div></div>
<div class="qs-item"><div class="qs-value" id="qs-kavling" style="color:#7C3AED">-</div><div class="qs-label">Kavling</div></div>
<div class="qs-item"><div class="qs-value" id="qs-warga" style="color:#EF4444">-</div><div class="qs-label">Warga</div></div>
</div>
</aside>
<div class="map-analysis">
<button data-draw="spbu" title="Klik lalu klik di peta untuk menaruh SPBU"><i class="fas fa-gas-pump"></i> SPBU</button>
<button data-draw="jalan" title="Klik beberapa titik untuk menggambar jalan"><i class="fas fa-road"></i> Jalan</button>
<button data-draw="kavling" title="Klik titik sudut polygon untuk menggambar kavling"><i class="fas fa-vector-square"></i> Kavling</button>
<button data-draw="kawasan" title="Gambar polygon kawasan kumuh"><i class="fas fa-draw-polygon"></i> Kawasan</button>
<button data-draw="rumah" title="Taruh marker rumah ibadah"><i class="fas fa-mosque"></i> Rumah Ibadah</button>
<button data-draw="warga" title="Taruh marker warga miskin"><i class="fas fa-user"></i> Warga</button>
<button id="btn-find-spbu" class="map-tool-accent" title="Cari SPBU terdekat dari pusat peta"><i class="fas fa-search-location"></i> SPBU Terdekat</button>
</div>
<div class="spbu-panel" id="spbuPanel" style="display:none;">
<div class="spbu-panel-title">
<i class="fas fa-search-location"></i> SPBU Terdekat
<button class="modal-close" id="btn-close-spbu" style="margin-left:auto;background:none;border:none;color:var(--text-muted);cursor:pointer;font-size:1.2rem;">&times;</button>
</div>
<div id="spbuPanelResult"></div>
</div>
<div class="map-legend">
<div class="map-legend-title"><i class="fas fa-list-ul"></i> Legenda</div>
<div class="legend-grid">
<div class="legend-item"><span class="legend-symbol legend-dot" style="--legend-color:#10B981"></span><span>SPBU 24 Jam</span></div>
<div class="legend-item"><span class="legend-symbol legend-dot" style="--legend-color:#F59E0B"></span><span>SPBU Terbatas</span></div>
<div class="legend-item"><span class="legend-symbol legend-line" style="--legend-color:#3B82F6"></span><span>Jalan</span></div>
<div class="legend-item"><span class="legend-symbol legend-area" style="--legend-color:#7C3AED"></span><span>Kavling</span></div>
<div class="legend-item"><span class="legend-symbol legend-dot" style="--legend-color:#8B5CF6"></span><span>Rumah Ibadah</span></div>
<div class="legend-item"><span class="legend-symbol legend-dot" style="--legend-color:#EF4444"></span><span>Warga Miskin</span></div>
<div class="legend-item"><span class="legend-symbol legend-hatch" style="--legend-color:#DC2626"></span><span>Kawasan Kumuh</span></div>
<div class="legend-item"><span class="legend-symbol legend-dot" style="--legend-color:#F97316"></span><span>Blank Spot</span></div>
</div>
</div>
<div class="map-loading" id="loadingOverlay" style="display:none;">
<div class="spinner"></div>
<span>Memuat data...</span>
</div>
<div class="map-modal" id="formModal">
<div class="map-modal-content">
<div class="modal-header" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<span class="modal-title" id="formModalTitle">Tambah Data</span>
<button class="modal-close" onclick="closeFormModal()" style="background:none;border:none;color:var(--text-muted);cursor:pointer;font-size:1.3rem;">&times;</button>
</div>
<div id="formModalBody"></div>
</div>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.js"></script>
<script src="<?= app_url('public_assets/js/peta-admin.js') ?>?v=20260612-fix"></script>
<?php $extraScript = ''; ?>
<?php require_once __DIR__ . '/partials/footer.php'; ?>
+191
View File
@@ -0,0 +1,191 @@
<?php
require_once __DIR__ . '/../core_config/database.php';
require_once __DIR__ . '/../core_config/middleware_auth.php';
requireRole('user');
$pdo = Database::getConnection();
// Fetch user's own reports
$stmt = $pdo->prepare("SELECT id, kategori, deskripsi, status, created_at FROM laporan_warga WHERE user_id = ? ORDER BY id DESC");
$stmt->execute([$_SESSION['user_id']]);
$rows = $stmt->fetchAll();
$pageTitle = 'Laporan Warga';
$activeNav = 'laporan';
$extraHead = '<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">';
require_once __DIR__ . '/partials/header.php';
function badgeStatus($status) {
if($status === 'menunggu') return 'badge-warning';
if($status === 'diproses') return 'badge-info';
if($status === 'selesai') return 'badge-success';
if($status === 'ditolak') return 'badge-danger';
return 'badge-primary';
}
?>
<div class="page-header">
<div class="breadcrumb"><span>User</span><span class="sep">/</span><span>Laporan Warga</span></div>
<h1>📢 Laporan Fasilitas & Infrastruktur</h1>
<p>Laporkan masalah infrastruktur seperti jalan rusak, banjir, atau fasilitas umum yang terbengkalai.</p>
</div>
<div class="page-toolbar">
<div class="search-box">
<i class="fas fa-search search-icon"></i>
<input type="text" id="searchInput" placeholder="Cari laporan saya..." oninput="filterTable('searchInput','lapTable')">
</div>
<button class="btn btn-primary" onclick="openModal('modalTambah')">
<i class="fas fa-plus"></i> Buat Laporan Baru
</button>
</div>
<div class="table-wrap">
<table class="data-table" id="lapTable">
<thead>
<tr>
<th>#</th>
<th>Kategori</th>
<th>Deskripsi Singkat</th>
<th>Status</th>
<th>Tanggal</th>
<th>Aksi</th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $i => $r): ?>
<tr>
<td style="color:var(--text-muted)"><?= $i+1 ?></td>
<td><strong><?= htmlspecialchars($r['kategori']) ?></strong></td>
<td style="color:var(--text-secondary); max-width: 250px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
<?= htmlspecialchars($r['deskripsi']) ?>
</td>
<td><span class="badge <?= badgeStatus($r['status']) ?>"><?= ucfirst($r['status']) ?></span></td>
<td style="color:var(--text-muted); font-size:0.8rem;"><?= date('d M Y, H:i', strtotime($r['created_at'])) ?></td>
<td>
<div class="actions">
<?php if($r['status'] === 'menunggu'): ?>
<button class="btn btn-danger btn-sm btn-icon" onclick="hapus(<?= $r['id'] ?>)" title="Batalkan Laporan"><i class="fas fa-trash"></i></button>
<?php else: ?>
<span style="font-size:0.75rem; color:var(--text-muted);">Dikunci</span>
<?php endif; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php if (!$rows): ?>
<tr>
<td colspan="6" style="text-align:center;padding:40px;color:var(--text-muted);">
Anda belum pernah membuat laporan.
</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
<!-- Modal Tambah Laporan -->
<div class="modal-overlay" id="modalTambah">
<div class="modal">
<div class="modal-header">
<span class="modal-title">📢 Buat Laporan Baru</span>
<button class="modal-close" onclick="closeModal('modalTambah')">×</button>
</div>
<form id="formTambah">
<div class="form-group">
<label class="form-label">Kategori Masalah *</label>
<select name="kategori" class="form-control" required>
<option value="Jalan Rusak">Jalan Rusak</option>
<option value="Banjir">Banjir</option>
<option value="Fasilitas Terbengkalai">Fasilitas Terbengkalai</option>
<option value="Lampu Jalan Mati">Lampu Jalan Mati</option>
<option value="Lainnya">Lainnya</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Deskripsi Lengkap *</label>
<textarea name="deskripsi" class="form-control" placeholder="Jelaskan detail masalahnya..." required rows="3"></textarea>
</div>
<div class="form-group">
<label class="form-label">Pilih Lokasi di Peta *</label>
<div id="miniMapTambah" style="height:200px;border-radius:8px;border:1px solid #E5E7EB;margin-bottom:10px;z-index:1;"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<input type="number" name="lat" id="latTambah" class="form-control" placeholder="Latitude" step="any" required readonly style="background:#F9FAFB;">
<input type="number" name="lng" id="lngTambah" class="form-control" placeholder="Longitude" step="any" required readonly style="background:#F9FAFB;">
</div>
</div>
</form>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeModal('modalTambah')">Batal</button>
<button class="btn btn-primary" onclick="simpan()"><i class="fas fa-paper-plane"></i> Kirim Laporan</button>
</div>
</div>
</div>
<?php $extraScript = <<<'JS'
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const API = APP_BASE + '/api/laporan.php';
let miniMap = null, miniMarker = null;
const originalOpenModal = window.openModal;
window.openModal = function(id) {
originalOpenModal(id);
if(id === 'modalTambah') {
setTimeout(() => {
if(!miniMap) {
miniMap = L.map('miniMapTambah').setView([-0.0583, 109.3448], 13);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png').addTo(miniMap);
miniMap.on('click', function(e) {
const lat = e.latlng.lat;
const lng = e.latlng.lng;
if(!miniMarker) miniMarker = L.marker([lat, lng]).addTo(miniMap);
else miniMarker.setLatLng([lat, lng]);
document.getElementById('latTambah').value = lat.toFixed(6);
document.getElementById('lngTambah').value = lng.toFixed(6);
});
} else {
miniMap.invalidateSize();
}
}, 100);
}
};
async function simpan() {
const f = document.getElementById('formTambah');
const lat = parseFloat(f.lat.value), lng = parseFloat(f.lng.value);
if (!f.kategori.value || !f.deskripsi.value || isNaN(lat) || isNaN(lng)) {
showToast('Lengkapi kategori, deskripsi, dan pilih lokasi!','error');
return;
}
const res = await fetch(API, {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({
kategori: f.kategori.value,
deskripsi: f.deskripsi.value,
geometry: { type:'Point', coordinates:[lng, lat] }
})
});
const d = await res.json();
if(d.status === 'success') {
showToast('Laporan berhasil dikirim!');
setTimeout(() => location.reload(), 900);
} else {
showToast(d.message,'error');
}
}
async function hapus(id) {
if(!confirm('Apakah Anda yakin ingin membatalkan laporan ini?')) return;
const res = await fetch(API + '?id=' + id, { method:'DELETE' });
const d = await res.json();
if(d.status === 'success') {
showToast('Laporan dibatalkan!');
setTimeout(() => location.reload(), 900);
} else {
showToast(d.message,'error');
}
}
</script>
JS; ?>
<?php require_once __DIR__ . '/partials/footer.php'; ?>
+116
View File
@@ -0,0 +1,116 @@
<?php
require_once __DIR__ . '/../core_config/database.php';
require_once __DIR__ . '/../core_config/middleware_auth.php';
requireRole('user');
$pdo = Database::getConnection();
$stats = $pdo->query("SELECT
(SELECT COUNT(*) FROM spbu) as spbu,
(SELECT COUNT(*) FROM jalan) as jalan,
(SELECT COUNT(*) FROM kavling) as kavling,
(SELECT COUNT(*) FROM rumah_ibadah) as rumah_ibadah,
(SELECT COUNT(*) FROM warga_miskin) as warga_miskin,
(SELECT COUNT(*) FROM kawasan_kumuh) as kawasan_kumuh,
(SELECT COUNT(*) FROM spbu WHERE buka_24_jam=1) as spbu_24jam
")->fetch();
// Hitung kawasan merah (rawan)
$merahResult = $pdo->query("SELECT COUNT(*) as cnt FROM (
SELECT k.id FROM kawasan_kumuh k
LEFT JOIN warga_miskin w ON ST_Contains(k.geom, w.geom)
GROUP BY k.id HAVING COUNT(w.id) > 3
) sub")->fetch();
$kawasanMerah = (int)($merahResult['cnt'] ?? 0);
// Hitung blank spot
$blankCount = $pdo->query("
SELECT COUNT(*) as cnt
FROM warga_miskin w
WHERE NOT EXISTS (
SELECT 1
FROM rumah_ibadah ri
WHERE ST_Distance_Sphere(ST_GeomFromText(ST_AsText(w.geom), 4326), ST_GeomFromText(ST_AsText(ri.geom), 4326)) <= ri.radius_bantuan_meter
)
")->fetch();
$blankSpot = (int)($blankCount['cnt'] ?? 0);
$pageTitle = 'Beranda';
$activeNav = 'dashboard';
require_once __DIR__ . '/partials/header.php';
?>
<div class="card" style="background: #FFFFFF; border: 1px solid #E5E7EB; border-radius: var(--radius-lg); padding: 24px; margin-bottom: 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.05);">
<h2 style="font-size:1.4rem;font-weight:700;margin-bottom:6px;color:var(--text-primary);">👋 Selamat Datang, <?= htmlspecialchars(currentUser()['nama_lengkap'] ?: currentUser()['username']) ?>!</h2>
<p style="color:var(--text-secondary);font-size:0.9rem;max-width:600px;">
Anda masuk sebagai <strong>Pengguna</strong>. Anda dapat melihat seluruh data spasial, analisis, dan berpartisipasi dengan mengirim Laporan Warga atau memberikan ulasan fasilitas.
</p>
</div>
<div class="stats-grid">
<div class="stat-card primary">
<div class="stat-icon"><i class="fas fa-gas-pump"></i></div>
<div class="stat-body">
<div class="stat-label">Total SPBU</div>
<div class="stat-value"><?= $stats['spbu'] ?></div>
<div class="stat-change"><?= $stats['spbu_24jam'] ?> buka 24 jam</div>
</div>
</div>
<div class="stat-card info">
<div class="stat-icon"><i class="fas fa-road"></i></div>
<div class="stat-body">
<div class="stat-label">Ruas Jalan</div>
<div class="stat-value"><?= $stats['jalan'] ?></div>
<div class="stat-change">Segmen terpetakan</div>
</div>
</div>
<div class="stat-card success">
<div class="stat-icon"><i class="fas fa-mosque"></i></div>
<div class="stat-body">
<div class="stat-label">Rumah Ibadah</div>
<div class="stat-value"><?= $stats['rumah_ibadah'] ?></div>
<div class="stat-change">Lokasi tercatat</div>
</div>
</div>
<div class="stat-card warning">
<div class="stat-icon"><i class="fas fa-users"></i></div>
<div class="stat-body">
<div class="stat-label">Warga Miskin</div>
<div class="stat-value"><?= $stats['warga_miskin'] ?></div>
<div class="stat-change"><?= $blankSpot ?> blank spot</div>
</div>
</div>
<div class="stat-card danger">
<div class="stat-icon"><i class="fas fa-exclamation-triangle"></i></div>
<div class="stat-body">
<div class="stat-label">Kawasan Rawan</div>
<div class="stat-value"><?= $kawasanMerah ?></div>
<div class="stat-change">dari <?= $stats['kawasan_kumuh'] ?> kawasan</div>
</div>
</div>
<div class="stat-card violet">
<div class="stat-icon"><i class="fas fa-vector-square"></i></div>
<div class="stat-body">
<div class="stat-label">Kavling / Parsil</div>
<div class="stat-value"><?= $stats['kavling'] ?></div>
<div class="stat-change">Bidang tanah</div>
</div>
</div>
</div>
<div class="card" style="background: #FFFFFF; border: 1px solid #E5E7EB; border-radius: var(--radius-lg); padding: 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.05);">
<div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:16px;">
<div>
<h3 style="font-size:1.1rem; font-weight:700; margin-bottom:6px;">🗺️ Buka Peta Interaktif</h3>
<p style="color:var(--text-secondary); font-size:0.88rem; max-width:500px;">
Lihat semua layer data spasial: SPBU, jalan, kavling, rumah ibadah, warga miskin, kawasan kumuh, dan analisis spasial.
</p>
</div>
<div style="display:flex; gap:10px;">
<a href="<?= app_url('panel_user/peta_user.php') ?>" class="btn btn-success">
<i class="fas fa-map-marked-alt"></i> Buka Peta
</a>
</div>
</div>
</div>
<?php require_once __DIR__ . '/partials/footer.php'; ?>
+119
View File
@@ -0,0 +1,119 @@
</div><!-- /admin-content -->
</main><!-- /admin-main -->
<!-- Toast Container -->
<div class="toast-container" id="toastContainer"></div>
<script>
// ── Clock ──
function updateClock() {
const now = new Date();
document.getElementById('clockText').textContent =
now.toLocaleTimeString('id-ID', { hour:'2-digit', minute:'2-digit', second:'2-digit' });
}
updateClock();
setInterval(updateClock, 1000);
// ── Toast utility ──
window.showToast = function(msg, type = 'success') {
const t = document.createElement('div');
t.className = `toast ${type}`;
const icons = { success: 'check-circle', error: 'exclamation-circle', info: 'info-circle' };
t.innerHTML = `<i class="fas fa-${icons[type]||'info-circle'}" style="margin-right:8px;"></i>${msg}`;
document.getElementById('toastContainer').appendChild(t);
setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 400); }, 3000);
};
// ── Modal helpers ──
window.openModal = function(id) {
document.getElementById(id)?.classList.add('active');
document.body.style.overflow = 'hidden';
};
window.closeModal = function(id) {
document.getElementById(id)?.classList.remove('active');
document.body.style.overflow = '';
};
// Close modal on overlay click
document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', e => {
if (e.target === overlay) closeModal(overlay.id);
});
});
// ── Search filter ──
window.filterTable = function(inputId, tableId) {
const q = document.getElementById(inputId)?.value.toLowerCase() || '';
document.querySelectorAll(`#${tableId} tbody tr`).forEach(row => {
row.style.display = row.textContent.toLowerCase().includes(q) ? '' : 'none';
});
};
// Realtime SPBU snackbar
(function initSpbuRealtimeSnackbar() {
const API_URL = APP_BASE + '/api/spbu_status.php';
const REFRESH_MS = 60000;
const SHOW_MS = 7000;
let lastOpen = null;
let hideTimer = null;
function ensureSnackbar() {
let el = document.getElementById('spbuRealtimeSnackbar');
if (el) return el;
el = document.createElement('div');
el.id = 'spbuRealtimeSnackbar';
el.className = 'spbu-snackbar';
el.innerHTML = `
<div class="spbu-snackbar-icon"><i class="fas fa-gas-pump"></i></div>
<div>
<div class="spbu-snackbar-title" id="spbuSnackbarTitle">SPBU sedang buka</div>
<div class="spbu-snackbar-meta" id="spbuSnackbarMeta">Memuat status terbaru...</div>
</div>
<div class="spbu-snackbar-count" id="spbuSnackbarCount">-</div>
`;
document.body.appendChild(el);
return el;
}
function showSnackbar(data) {
const el = ensureSnackbar();
const open = Number(data.spbu_buka || 0);
const total = Number(data.total_spbu || 0);
const closed = Number(data.spbu_tutup || Math.max(0, total - open));
const time = data.server_time ? data.server_time.slice(11, 16) : new Date().toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' });
document.getElementById('spbuSnackbarTitle').textContent = `${open} SPBU sedang buka`;
document.getElementById('spbuSnackbarMeta').textContent = `${closed} tutup/terbatas dari ${total} total - update ${time} WIB`;
document.getElementById('spbuSnackbarCount').textContent = open;
el.classList.add('show');
clearTimeout(hideTimer);
hideTimer = setTimeout(() => el.classList.remove('show'), SHOW_MS);
}
async function refreshStatus(forceShow = false) {
try {
const res = await fetch(API_URL, { cache: 'no-store' });
const json = await res.json();
if (json.status !== 'success' || !json.data) return;
const open = Number(json.data.spbu_buka || 0);
if (forceShow || lastOpen === null || open !== lastOpen) {
showSnackbar(json.data);
}
lastOpen = open;
} catch (e) {
// Snackbar is informational; keep the page quiet if polling fails.
}
}
document.addEventListener('DOMContentLoaded', () => {
refreshStatus(true);
setInterval(() => refreshStatus(false), REFRESH_MS);
});
})();
</script>
<?= $extraScript ?? '' ?>
</body>
</html>
+85
View File
@@ -0,0 +1,85 @@
<?php
/**
* User Layout — sidebar + topbar (read-only mode, beda style dari admin)
* Variables expected from parent: $pageTitle, $activeNav
*/
$user = currentUser();
$initial = strtoupper(substr($user['nama_lengkap'] ?: $user['username'], 0, 1));
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($pageTitle ?? 'User Portal') ?> — WebGIS Smart City</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<link rel="stylesheet" href="<?= app_url('public_assets/css/global.css') ?>?v=20260610-spbu-snackbar">
<link rel="stylesheet" href="<?= app_url('public_assets/css/panel.css') ?>">
<script>const APP_BASE = '<?= app_url() ?>';</script>
<?= $extraHead ?? '' ?>
</head>
<body class="admin-page user-portal <?= $bodyClass ?? '' ?>">
<!-- ══ SIDEBAR ══ -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-brand">
<div class="brand-icon" style="background:linear-gradient(135deg,#10B981,#059669);">🗺️</div>
<div class="brand-text">
<span class="brand-name">WebGIS</span>
<span class="brand-sub">Pengguna</span>
</div>
</div>
<nav class="sidebar-nav">
<div class="nav-section-title">Menu</div>
<a href="<?= app_url('panel_user/index.php') ?>"
class="nav-item <?= ($activeNav==='dashboard') ? 'active' : '' ?>">
<span class="nav-icon"><i class="fas fa-chart-pie"></i></span> Beranda
</a>
<a href="<?= app_url('panel_user/peta_user.php') ?>"
class="nav-item <?= ($activeNav==='peta') ? 'active' : '' ?>">
<span class="nav-icon"><i class="fas fa-map-marked-alt"></i></span> Peta Interaktif
</a>
<a href="<?= app_url('panel_user/buat_laporan.php') ?>"
class="nav-item <?= ($activeNav==='laporan') ? 'active' : '' ?>">
<span class="nav-icon"><i class="fas fa-bullhorn"></i></span> Laporan Warga
</a>
<div class="nav-section-title">Informasi</div>
<div class="user-info" style="background:rgba(16,185,129,0.08);border-color:rgba(16,185,129,0.2);">
<i class="fas fa-info-circle" style="color:#059669;font-size:1.1rem;"></i>
<div style="font-size:0.78rem;color:var(--text-secondary);line-height:1.4;">
Anda masuk sebagai <strong style="color:#059669">Pengguna</strong>. Data hanya dapat dilihat, tidak dapat diubah.
</div>
</div>
</nav>
<div class="sidebar-footer">
<div class="user-info">
<div class="user-avatar" style="background:linear-gradient(135deg,#10B981,#059669);"><?= $initial ?></div>
<div class="user-details">
<div class="user-name"><?= htmlspecialchars($user['nama_lengkap'] ?: $user['username']) ?></div>
<div class="user-role">Pengguna</div>
</div>
<a href="<?= app_url('logout.php') ?>" class="user-logout" title="Keluar">
<i class="fas fa-sign-out-alt"></i>
</a>
</div>
</div>
</aside>
<!-- ══ MAIN CONTENT ══ -->
<main class="admin-main">
<div class="topbar">
<span class="topbar-title"><?= htmlspecialchars($pageTitle ?? 'Beranda') ?></span>
<div class="topbar-actions">
<div class="topbar-time" id="clock">
<i class="fas fa-clock"></i> <span id="clockText"></span>
</div>
<span class="badge badge-success" style="font-size:0.7rem;padding:5px 10px;">
<i class="fas fa-eye"></i> Mode Baca
</span>
</div>
</div>
<div class="admin-content">
+161
View File
@@ -0,0 +1,161 @@
<?php
require_once __DIR__ . '/../core_config/database.php';
require_once __DIR__ . '/../core_config/middleware_auth.php';
requireRole('user');
$pageTitle = 'Peta Interaktif';
$activeNav = 'peta';
$bodyClass = 'admin-map-page map-page';
$extraHead = '
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<link rel="stylesheet" href="' . app_url('public_assets/css/map.css') . '?v=20260610-2">
';
require_once __DIR__ . '/partials/header.php';
?>
<div class="map-wrap">
<div id="user-map"></div>
<div class="read-only-badge">
<i class="fas fa-eye"></i> Mode Baca
</div>
<aside class="map-sidebar">
<div class="map-panel-header">
<div class="map-panel-icon"><i class="fas fa-layer-group"></i></div>
<div>
<h3>Layer Peta</h3>
<p>Kontrol data yang tampil di peta</p>
</div>
</div>
<div class="layer-section">
<div class="layer-section-title">Infrastruktur</div>
<label class="layer-item">
<input type="checkbox" id="ly-spbu" checked>
<span class="layer-color layer-point" style="--layer-color:#F59E0B"><i class="fas fa-gas-pump"></i></span>
<span class="layer-text">
<span class="layer-name">SPBU</span>
<span class="layer-meta">Stasiun pengisian bahan bakar</span>
</span>
</label>
<label class="layer-item">
<input type="checkbox" id="ly-jalan" checked>
<span class="layer-color layer-line" style="--layer-color:#3B82F6"></span>
<span class="layer-text">
<span class="layer-name">Jalan</span>
<span class="layer-meta">Ruas jalan utama dan lokal</span>
</span>
</label>
<label class="layer-item">
<input type="checkbox" id="ly-kavling" checked>
<span class="layer-color layer-area" style="--layer-color:#7C3AED"></span>
<span class="layer-text">
<span class="layer-name">Kavling / Parsil</span>
<span class="layer-meta">Batas bidang tanah</span>
</span>
</label>
</div>
<div class="layer-section">
<div class="layer-section-title">Sosial</div>
<label class="layer-item">
<input type="checkbox" id="ly-rumah" checked>
<span class="layer-color layer-point" style="--layer-color:#8B5CF6"><i class="fas fa-mosque"></i></span>
<span class="layer-text">
<span class="layer-name">Rumah Ibadah</span>
<span class="layer-meta">Fasilitas keagamaan</span>
</span>
</label>
<label class="layer-item">
<input type="checkbox" id="ly-warga" checked>
<span class="layer-color layer-point" style="--layer-color:#EF4444"><i class="fas fa-users"></i></span>
<span class="layer-text">
<span class="layer-name">Warga Miskin</span>
<span class="layer-meta">Sebaran keluarga penerima bantuan</span>
</span>
</label>
<label class="layer-item">
<input type="checkbox" id="ly-kawasan">
<span class="layer-color layer-area layer-hatch" style="--layer-color:#DC2626"></span>
<span class="layer-text">
<span class="layer-name">Kawasan Kumuh</span>
<span class="layer-meta">Area prioritas penanganan</span>
</span>
</label>
</div>
<div class="layer-section">
<div class="layer-section-title">Analisis</div>
<label class="layer-item">
<input type="checkbox" id="ly-choropleth">
<span class="layer-color layer-gradient"></span>
<span class="layer-text">
<span class="layer-name">Choropleth Kepadatan</span>
<span class="layer-meta">Kepadatan warga miskin per kawasan</span>
</span>
</label>
<label class="layer-item">
<input type="checkbox" id="ly-blankspot">
<span class="layer-color layer-point" style="--layer-color:#F97316"><i class="fas fa-location-dot"></i></span>
<span class="layer-text">
<span class="layer-name">Blank Spot Bansos</span>
<span class="layer-meta">Titik jauh dari fasilitas bantuan</span>
</span>
</label>
</div>
<div class="map-quickstats">
<div class="qs-item"><div class="qs-value" id="qs-spbu" style="color:#F59E0B">-</div><div class="qs-label">SPBU</div></div>
<div class="qs-item"><div class="qs-value" id="qs-jalan" style="color:#3B82F6">-</div><div class="qs-label">Jalan</div></div>
<div class="qs-item"><div class="qs-value" id="qs-kavling" style="color:#7C3AED">-</div><div class="qs-label">Kavling</div></div>
<div class="qs-item"><div class="qs-value" id="qs-warga" style="color:#EF4444">-</div><div class="qs-label">Warga</div></div>
</div>
</aside>
<div class="map-analysis">
<button id="btn-find-spbu" class="map-tool-accent" title="Cari SPBU terdekat dari pusat peta">
<i class="fas fa-search-location"></i> SPBU Terdekat
</button>
</div>
<div class="spbu-panel" id="spbuPanel" style="display:none;">
<div class="spbu-panel-title">
<i class="fas fa-search-location"></i> SPBU Terdekat
<button class="modal-close" id="btn-close-spbu" style="margin-left:auto;background:none;border:none;color:var(--text-muted);cursor:pointer;font-size:1.2rem;">&times;</button>
</div>
<div id="spbuPanelResult"></div>
</div>
<div class="map-legend">
<div class="map-legend-title"><i class="fas fa-list-ul"></i> Legenda</div>
<div class="legend-grid">
<div class="legend-item"><span class="legend-symbol legend-dot" style="--legend-color:#10B981"></span><span>SPBU 24 Jam</span></div>
<div class="legend-item"><span class="legend-symbol legend-dot" style="--legend-color:#F59E0B"></span><span>SPBU Terbatas</span></div>
<div class="legend-item"><span class="legend-symbol legend-line" style="--legend-color:#3B82F6"></span><span>Jalan</span></div>
<div class="legend-item"><span class="legend-symbol legend-area" style="--legend-color:#7C3AED"></span><span>Kavling</span></div>
<div class="legend-item"><span class="legend-symbol legend-dot" style="--legend-color:#8B5CF6"></span><span>Rumah Ibadah</span></div>
<div class="legend-item"><span class="legend-symbol legend-dot" style="--legend-color:#EF4444"></span><span>Warga Miskin</span></div>
<div class="legend-item"><span class="legend-symbol legend-hatch" style="--legend-color:#DC2626"></span><span>Kawasan Kumuh</span></div>
<div class="legend-item"><span class="legend-symbol legend-dot" style="--legend-color:#F97316"></span><span>Blank Spot</span></div>
</div>
</div>
<div class="map-loading" id="loadingOverlay" style="display:none;">
<div class="spinner"></div>
<span>Memuat data...</span>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="<?= app_url('public_assets/js/peta-user.js') ?>?v=20260612-fix"></script>
<?php $extraScript = ''; ?>
<?php require_once __DIR__ . '/partials/footer.php'; ?>
+215
View File
@@ -0,0 +1,215 @@
/* =========================================================
AUTH PAGE STYLES — login.php
========================================================= */
body.auth-page {
background: #F9FAFB;
min-height: 100vh;
padding: 20px;
overflow-y: auto;
}
/* ── Center Panel (Form) ── */
.auth-center {
width: 100%;
max-width: 440px;
margin: 40px auto;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
background: #FFFFFF;
border-radius: 16px;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.05), 0 8px 10px -6px rgba(0, 0, 0, 0.01);
border: 1px solid #E5E7EB;
}
.auth-form-wrap {
width: 100%;
}
.auth-form-title {
font-size: 1.8rem;
font-weight: 800;
color: #111827;
margin-bottom: 8px;
letter-spacing: -0.03em;
}
.auth-form-subtitle {
color: #6B7280;
font-size: 0.95rem;
margin-bottom: 32px;
}
/* Role selector */
.role-selector {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 32px;
}
.role-card {
position: relative;
cursor: pointer;
}
.role-card input[type="radio"] {
position: absolute;
opacity: 0;
width: 0; height: 0;
}
.role-card-label {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px 12px;
border-radius: 12px;
border: 2px solid #E5E7EB;
background: #FFFFFF;
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
}
.role-card input:checked + .role-card-label {
border-color: #2563EB;
background: #EFF6FF;
}
.role-card-label:hover {
border-color: #D1D5DB;
background: #F9FAFB;
}
.role-card input:checked + .role-card-label:hover {
border-color: #2563EB;
}
.role-card-icon {
font-size: 1.5rem;
line-height: 1;
}
.role-card-name {
font-size: 0.9rem;
font-weight: 600;
color: #111827;
}
.role-card input:checked + .role-card-label .role-card-name {
color: #1D4ED8;
}
.role-card-desc {
font-size: 0.75rem;
color: #6B7280;
}
/* Login form */
.auth-form .form-group { margin-bottom: 20px; }
.auth-form .input-with-icon {
position: relative;
}
.auth-form .input-with-icon .input-icon {
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
color: #9CA3AF;
font-size: 1rem;
pointer-events: none;
}
.auth-form .input-with-icon .form-control {
padding-left: 44px;
height: 46px;
border-radius: 8px;
}
.auth-form .btn-login {
width: 100%;
padding: 14px;
font-size: 1rem;
justify-content: center;
background: #2563EB;
color: #FFFFFF;
border-radius: 8px;
margin-top: 12px;
font-weight: 600;
box-shadow: 0 4px 6px -1px rgba(37, 99, 235, 0.2);
}
.auth-form .btn-login:hover {
background: #1D4ED8;
box-shadow: 0 6px 8px -1px rgba(37, 99, 235, 0.3);
}
.auth-error {
background: #FEF2F2;
border: 1px solid #FECACA;
border-radius: 8px;
padding: 12px 16px;
color: #B91C1C;
font-size: 0.9rem;
margin-bottom: 24px;
display: flex;
align-items: center;
gap: 10px;
}
.auth-hint {
margin-top: 36px;
padding-top: 24px;
border-top: 1px solid #E5E7EB;
}
.auth-hint-title {
font-size: 0.8rem;
font-weight: 700;
color: #6B7280;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 12px;
}
.auth-hint-creds {
display: flex;
flex-direction: column;
gap: 10px;
}
.auth-hint-item {
display: flex;
justify-content: space-between;
align-items: center;
background: #F9FAFB;
border: 1px solid #E5E7EB;
border-radius: 8px;
padding: 10px 14px;
font-size: 0.85rem;
}
.auth-hint-role {
font-weight: 600;
color: #374151;
}
.auth-hint-pass {
font-family: monospace;
color: #4B5563;
background: #E5E7EB;
padding: 4px 10px;
border-radius: 6px;
font-weight: 500;
}
/* Responsive */
@media (max-width: 900px) {
.auth-center { width: 100%; border-left: none; }
}
+248
View File
@@ -0,0 +1,248 @@
/* =========================================================
WEBGIS PROJECT FINAL — Design System (main.css)
========================================================= */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
:root {
/* Colors - Clean Light Theme */
--primary: #2563EB; /* Professional Blue */
--primary-light: #3B82F6;
--primary-dark: #1D4ED8;
--accent: #10B981;
--danger: #EF4444;
--warning: #F59E0B;
--info: #0EA5E9;
/* Backgrounds */
--bg-base: #F9FAFB; /* Very light gray */
--bg-surface: #FFFFFF; /* Pure white */
--bg-elevated: #FFFFFF;
--bg-card: #FFFFFF;
/* Borders */
--border-light: #E5E7EB;
--border-hover: #D1D5DB;
/* Text */
--text-primary: #111827; /* Near black */
--text-secondary: #4B5563; /* Dark gray */
--text-muted: #6B7280; /* Medium gray */
/* Sidebar */
--sidebar-w: 260px;
--sidebar-bg: #FFFFFF;
--sidebar-border: #E5E7EB;
/* Misc */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
--transition: all 0.2s ease-in-out;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body {
font-family: 'Inter', sans-serif;
background: var(--bg-base);
color: var(--text-primary);
font-size: 14px;
line-height: 1.6;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
a { color: var(--primary); text-decoration: none; transition: var(--transition); }
a:hover { color: var(--primary-dark); }
/* ── Scrollbar ── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #CBD5E1; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #94A3B8; }
/* ── Buttons ── */
.btn {
display: inline-flex; align-items: center; gap: 8px;
padding: 8px 16px; border-radius: var(--radius-md); border: 1px solid transparent;
font-size: 0.85rem; font-weight: 500; font-family: 'Inter', sans-serif;
cursor: pointer; transition: var(--transition); white-space: nowrap;
outline: none;
}
.btn-primary { background: var(--primary); color: #fff; box-shadow: var(--shadow-sm); }
.btn-primary:hover { background: var(--primary-dark); }
.btn-success { background: var(--accent); color: #fff; box-shadow: var(--shadow-sm); }
.btn-success:hover { filter: brightness(0.95); }
.btn-danger { background: var(--danger); color: #fff; }
.btn-danger:hover { filter: brightness(0.95); }
.btn-ghost { background: transparent; border: 1px solid var(--border-light); color: var(--text-secondary); }
.btn-ghost:hover { background: #F3F4F6; color: var(--text-primary); border-color: var(--border-hover); }
.btn-sm { padding: 6px 12px; font-size: 0.78rem; }
.btn-icon { padding: 8px; border-radius: var(--radius-sm); }
/* ── Cards ── */
.card {
background: var(--bg-surface);
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
padding: 24px;
box-shadow: var(--shadow-sm);
}
/* ── Form Elements ── */
.form-group { margin-bottom: 16px; }
.form-label { display: block; font-size: 0.82rem; font-weight: 600; color: var(--text-primary); margin-bottom: 6px; }
.form-control {
width: 100%; padding: 10px 14px; border-radius: var(--radius-md);
background: #FFFFFF; border: 1px solid var(--border-light);
color: var(--text-primary); font-size: 0.9rem; font-family: 'Inter', sans-serif;
outline: none; transition: var(--transition); box-shadow: var(--shadow-sm);
}
.form-control:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); }
.form-control::placeholder { color: #9CA3AF; }
select.form-control {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 36px;
}
textarea.form-control { resize: vertical; min-height: 80px; }
/* ── Badges ── */
.badge { display: inline-flex; align-items: center; padding: 4px 10px; border-radius: var(--radius-xl); font-size: 0.75rem; font-weight: 600; }
.badge-primary { background: #EFF6FF; color: #1D4ED8; border: 1px solid #BFDBFE; }
.badge-success { background: #ECFDF5; color: #047857; border: 1px solid #A7F3D0; }
.badge-danger { background: #FEF2F2; color: #B91C1C; border: 1px solid #FECACA; }
.badge-warning { background: #FFFBEB; color: #B45309; border: 1px solid #FDE68A; }
.badge-info { background: #F0F9FF; color: #0369A1; border: 1px solid #BAE6FD; }
/* ── Tables ── */
.table-wrap { overflow-x: auto; border-radius: var(--radius-lg); border: 1px solid var(--border-light); background: var(--bg-surface); box-shadow: var(--shadow-sm); }
.data-table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
.data-table thead th { background: #F9FAFB; padding: 14px 16px; text-align: left; font-size: 0.75rem; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; white-space: nowrap; border-bottom: 1px solid var(--border-light); }
.data-table tbody tr { border-bottom: 1px solid var(--border-light); transition: background 0.15s; }
.data-table tbody tr:last-child { border-bottom: none; }
.data-table tbody tr:hover { background: #F3F4F6; }
.data-table td { padding: 14px 16px; color: var(--text-primary); vertical-align: middle; }
.data-table .actions { display: flex; gap: 6px; }
/* ── Modal ── */
.modal-overlay {
display: none; position: fixed; inset: 0; z-index: 9999;
background: rgba(17, 24, 39, 0.4); backdrop-filter: blur(2px);
align-items: center; justify-content: center;
}
.modal-overlay.active { display: flex; }
.modal {
background: var(--bg-surface); border: 1px solid var(--border-light);
border-radius: var(--radius-lg); padding: 32px;
width: 90%; max-width: 500px; max-height: 90vh; overflow-y: auto;
box-shadow: var(--shadow-lg);
animation: modalIn 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes modalIn { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } }
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
.modal-title { font-size: 1.15rem; font-weight: 700; color: var(--text-primary); }
.modal-close { background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 1.4rem; padding: 4px; border-radius: var(--radius-sm); transition: all 0.15s; }
.modal-close:hover { background: #F3F4F6; color: var(--text-primary); }
.modal-footer { display: flex; gap: 12px; justify-content: flex-end; margin-top: 28px; padding-top: 20px; border-top: 1px solid var(--border-light); }
/* ── Toast ── */
.toast-container { position: fixed; bottom: 24px; right: 24px; z-index: 99999; display: flex; flex-direction: column; gap: 10px; }
.toast { padding: 14px 20px; border-radius: var(--radius-md); font-size: 0.9rem; font-weight: 500; color: #fff; box-shadow: var(--shadow-md); animation: toastIn 0.3s ease; transition: opacity 0.3s; }
.toast.success { background: #10B981; }
.toast.error { background: #EF4444; }
.toast.info { background: #3B82F6; }
@keyframes toastIn { from { opacity: 0; transform: translateX(30px); } to { opacity: 1; transform: translateX(0); } }
/* SPBU realtime snackbar */
.spbu-snackbar {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 99998;
display: grid;
grid-template-columns: 38px minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
width: min(360px, calc(100vw - 32px));
padding: 13px 14px;
color: var(--text-primary);
background: #FFFFFF;
border: 1px solid #A7F3D0;
border-left: 4px solid var(--accent);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
opacity: 0;
pointer-events: none;
transform: translateY(18px);
transition: opacity 0.25s ease, transform 0.25s ease;
}
.spbu-snackbar.show {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.spbu-snackbar-icon {
width: 38px;
height: 38px;
display: grid;
place-items: center;
color: #047857;
background: #ECFDF5;
border: 1px solid #A7F3D0;
border-radius: var(--radius-md);
}
.spbu-snackbar-title {
font-size: 0.84rem;
font-weight: 800;
line-height: 1.2;
}
.spbu-snackbar-meta {
margin-top: 3px;
color: var(--text-muted);
font-size: 0.72rem;
line-height: 1.3;
}
.spbu-snackbar-count {
min-width: 44px;
padding: 5px 8px;
color: #047857;
background: #ECFDF5;
border: 1px solid #A7F3D0;
border-radius: var(--radius-md);
text-align: center;
font-size: 1.05rem;
font-weight: 800;
}
/* ── Page header ── */
.page-header { margin-bottom: 28px; }
.page-header h1 { font-size: 1.6rem; font-weight: 700; color: var(--text-primary); letter-spacing: -0.02em; }
.page-header p { color: var(--text-secondary); margin-top: 6px; font-size: 0.9rem; }
.page-header .breadcrumb { display: flex; gap: 8px; align-items: center; font-size: 0.82rem; color: var(--text-muted); margin-bottom: 12px; }
.page-header .breadcrumb .sep { color: #D1D5DB; }
/* ── Utilities ── */
.text-muted { color: var(--text-muted); }
.text-secondary { color: var(--text-secondary); }
.text-success { color: var(--accent); }
.text-danger { color: var(--danger); }
.text-warning { color: var(--warning); }
.flex { display: flex; }
.flex-center { display: flex; align-items: center; justify-content: center; }
.gap-2 { gap: 8px; }
.gap-3 { gap: 12px; }
.mt-1 { margin-top: 8px; }
.mt-2 { margin-top: 16px; }
.mb-1 { margin-bottom: 8px; }
.mb-2 { margin-bottom: 16px; }
.w-full { width: 100%; }
+765
View File
@@ -0,0 +1,765 @@
/* =========================================================
MAP STYLES - interactive map panels and Leaflet overrides
========================================================= */
.map-page {
height: 100vh;
overflow: hidden;
background: var(--bg-base);
}
.map-wrap {
position: relative;
width: 100%;
height: calc(100vh - 64px);
background: #E5E7EB;
}
#admin-map,
#user-map {
width: 100%;
height: 100%;
background: #E5E7EB;
}
.leaflet-container {
font-family: 'Inter', sans-serif;
background: #E5E7EB;
}
.leaflet-control-attribution {
background: rgba(255, 255, 255, 0.92) !important;
color: var(--text-muted) !important;
border: 1px solid var(--border-light);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-sm);
padding: 3px 8px;
font-size: 0.68rem;
}
.leaflet-control-attribution a {
color: var(--primary-dark) !important;
}
.leaflet-bar {
border: 1px solid var(--border-light) !important;
border-radius: var(--radius-md) !important;
box-shadow: var(--shadow-md) !important;
overflow: hidden;
}
.leaflet-control-zoom a,
.leaflet-draw-toolbar a {
width: 34px !important;
height: 34px !important;
line-height: 34px !important;
background: #FFFFFF !important;
color: var(--text-primary) !important;
border: 0 !important;
border-bottom: 1px solid var(--border-light) !important;
}
.leaflet-control-zoom a:last-child,
.leaflet-draw-toolbar a:last-child {
border-bottom: 0 !important;
}
.leaflet-control-zoom a:hover,
.leaflet-draw-toolbar a:hover {
background: #F3F4F6 !important;
color: var(--primary-dark) !important;
}
/* Layer panel */
.map-sidebar {
position: absolute;
top: 16px;
left: 16px;
z-index: 500;
width: 316px;
max-height: calc(100vh - 112px);
overflow-y: auto;
background: rgba(255, 255, 255, 0.96);
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
padding: 16px;
box-shadow: var(--shadow-lg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.map-panel-header {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 14px;
margin-bottom: 14px;
border-bottom: 1px solid var(--border-light);
}
.map-panel-icon {
width: 40px;
height: 40px;
display: grid;
place-items: center;
flex-shrink: 0;
border-radius: var(--radius-md);
background: #EFF6FF;
color: var(--primary-dark);
border: 1px solid #BFDBFE;
}
.map-panel-header h3 {
margin: 0;
color: var(--text-primary);
font-size: 0.98rem;
line-height: 1.2;
font-weight: 700;
}
.map-panel-header p {
margin: 3px 0 0;
color: var(--text-muted);
font-size: 0.76rem;
line-height: 1.35;
}
.layer-section {
margin-bottom: 16px;
}
.layer-section:last-of-type {
margin-bottom: 12px;
}
.layer-section-title {
margin: 0 0 8px;
color: var(--text-muted);
font-size: 0.68rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.layer-item {
display: grid;
grid-template-columns: 18px 30px minmax(0, 1fr);
align-items: center;
gap: 10px;
min-height: 48px;
padding: 8px 10px;
border: 1px solid transparent;
border-radius: var(--radius-md);
color: var(--text-secondary);
cursor: pointer;
user-select: none;
transition: var(--transition);
}
.layer-item + .layer-item {
margin-top: 4px;
}
.layer-item:hover {
background: #F9FAFB;
border-color: var(--border-light);
color: var(--text-primary);
}
.layer-item input[type="checkbox"] {
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--primary);
cursor: pointer;
}
.layer-color {
--layer-color: var(--primary);
width: 30px;
height: 30px;
display: grid;
place-items: center;
flex-shrink: 0;
color: #FFFFFF;
background: var(--layer-color);
border: 1px solid color-mix(in srgb, var(--layer-color) 72%, #000 28%);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.28);
}
.layer-color i {
font-size: 0.76rem;
}
.layer-point {
border-radius: 50%;
}
.layer-line {
height: 4px;
border-radius: 999px;
align-self: center;
box-shadow: none;
}
.layer-area {
border-radius: 6px;
background: color-mix(in srgb, var(--layer-color) 22%, #FFFFFF 78%);
border-color: var(--layer-color);
}
.layer-hatch {
background:
repeating-linear-gradient(45deg, color-mix(in srgb, var(--layer-color) 78%, #FFFFFF 22%) 0 4px, transparent 4px 8px),
color-mix(in srgb, var(--layer-color) 12%, #FFFFFF 88%);
}
.layer-gradient {
border-radius: 6px;
background: linear-gradient(90deg, #10B981 0%, #F59E0B 50%, #EF4444 100%);
border-color: #D1D5DB;
}
.layer-text {
display: flex;
flex-direction: column;
min-width: 0;
line-height: 1.25;
}
.layer-name {
color: var(--text-primary);
font-size: 0.86rem;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.layer-meta {
margin-top: 2px;
color: var(--text-muted);
font-size: 0.7rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.map-quickstats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
padding-top: 14px;
border-top: 1px solid var(--border-light);
}
.qs-item {
min-width: 0;
padding: 9px 6px;
text-align: center;
background: #F9FAFB;
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
}
.qs-value {
font-size: 1.08rem;
line-height: 1;
font-weight: 800;
}
.qs-label {
margin-top: 3px;
color: var(--text-muted);
font-size: 0.62rem;
font-weight: 700;
}
/* Toolbar */
.map-analysis {
position: absolute;
top: 16px;
left: 50%;
z-index: 500;
display: flex;
align-items: center;
gap: 6px;
max-width: calc(100% - 380px);
padding: 8px;
background: rgba(255, 255, 255, 0.96);
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
transform: translateX(-50%);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.map-analysis button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
min-height: 34px;
padding: 7px 12px;
color: var(--text-secondary);
background: #FFFFFF;
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
font-family: 'Inter', sans-serif;
font-size: 0.78rem;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
box-shadow: var(--shadow-sm);
transition: var(--transition);
}
.map-analysis button:hover {
background: #F3F4F6;
color: var(--text-primary);
border-color: var(--border-hover);
}
.map-analysis button.active {
color: #FFFFFF;
background: var(--primary);
border-color: var(--primary);
}
.map-analysis .map-tool-accent {
color: #047857;
background: #ECFDF5;
border-color: #A7F3D0;
}
.map-analysis .map-tool-accent:hover {
color: #065F46;
background: #D1FAE5;
border-color: #6EE7B7;
}
/* Nearest SPBU panel */
.spbu-panel {
position: absolute;
top: 16px;
right: 16px;
z-index: 500;
width: 310px;
padding: 16px;
background: rgba(255, 255, 255, 0.96);
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.spbu-panel-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
color: var(--text-primary);
font-size: 0.94rem;
font-weight: 800;
}
.spbu-result {
margin-top: 10px;
padding: 12px;
background: #F9FAFB;
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
}
.spbu-result-name {
color: var(--text-primary);
font-size: 0.9rem;
font-weight: 800;
}
.spbu-result-meta {
margin-top: 4px;
color: var(--text-muted);
font-size: 0.75rem;
}
.spbu-result-distance {
margin-top: 8px;
color: var(--primary-dark);
font-size: 1.35rem;
line-height: 1;
font-weight: 800;
}
/* Markers */
.marker-pin {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
color: #FFFFFF;
background: var(--primary);
border: 2px solid #FFFFFF;
border-radius: 50% 50% 50% 0;
box-shadow: 0 6px 12px rgba(17, 24, 39, 0.28);
font-size: 0.9rem;
transform: rotate(-45deg);
}
.marker-pin > * {
transform: rotate(45deg);
}
.div-icon-wrap {
background: transparent;
border: none;
}
/* Popups */
.leaflet-popup-content-wrapper {
color: var(--text-primary) !important;
background: #FFFFFF !important;
border: 1px solid var(--border-light);
border-radius: var(--radius-lg) !important;
box-shadow: var(--shadow-lg);
}
.leaflet-popup-tip {
background: #FFFFFF !important;
}
.leaflet-popup-content {
margin: 13px 16px !important;
font-family: 'Inter', sans-serif;
font-size: 0.85rem;
line-height: 1.5;
}
.popup-title {
margin-bottom: 6px;
color: var(--text-primary);
font-size: 0.98rem;
font-weight: 800;
}
.popup-row {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 5px;
color: var(--text-secondary);
}
.popup-row strong {
color: var(--text-primary);
}
.popup-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.popup-actions .btn {
padding: 5px 10px;
font-size: 0.75rem;
}
/* Legend */
.map-legend {
position: absolute;
right: 16px;
bottom: 16px;
z-index: 500;
min-width: 230px;
padding: 14px;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.96);
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.map-legend-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
color: var(--text-primary);
font-size: 0.82rem;
font-weight: 800;
}
.legend-grid {
display: grid;
gap: 7px;
}
.legend-item {
display: grid;
grid-template-columns: 24px minmax(0, 1fr);
align-items: center;
gap: 8px;
min-height: 22px;
color: var(--text-secondary);
font-size: 0.76rem;
font-weight: 600;
}
.legend-symbol {
--legend-color: var(--primary);
justify-self: center;
background: var(--legend-color);
}
.legend-dot {
width: 12px;
height: 12px;
border: 2px solid #FFFFFF;
border-radius: 50%;
box-shadow: 0 0 0 1px color-mix(in srgb, var(--legend-color) 70%, #000 30%);
}
.legend-line {
width: 22px;
height: 4px;
border-radius: 999px;
}
.legend-area {
width: 16px;
height: 16px;
border: 1px solid var(--legend-color);
border-radius: 4px;
background: color-mix(in srgb, var(--legend-color) 18%, #FFFFFF 82%);
}
.legend-hatch {
width: 16px;
height: 16px;
border: 1px solid var(--legend-color);
border-radius: 4px;
background:
repeating-linear-gradient(45deg, color-mix(in srgb, var(--legend-color) 76%, #FFFFFF 24%) 0 3px, transparent 3px 6px),
color-mix(in srgb, var(--legend-color) 12%, #FFFFFF 88%);
}
/* Modal */
.map-modal {
position: fixed;
inset: 0;
z-index: 9999;
display: none;
align-items: center;
justify-content: center;
background: rgba(17, 24, 39, 0.45);
backdrop-filter: blur(2px);
}
.map-modal.active {
display: flex;
}
.map-modal-content {
width: 90%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
padding: 28px 32px;
background: var(--bg-surface);
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
animation: modalIn 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes modalIn {
from {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* Loading and toast */
.map-loading {
position: absolute;
inset: 0;
z-index: 600;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--text-primary);
background: rgba(249, 250, 251, 0.72);
backdrop-filter: blur(3px);
font-size: 0.95rem;
font-weight: 700;
}
.spinner {
width: 28px;
height: 28px;
border: 3px solid rgba(37, 99, 235, 0.16);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.toast-container {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 99999;
display: flex;
flex-direction: column;
gap: 10px;
}
.toast {
max-width: 320px;
padding: 12px 20px;
color: #FFFFFF;
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
font-size: 0.88rem;
font-weight: 600;
animation: toastIn 0.3s ease;
transition: opacity 0.3s;
}
.toast.success { background: #10B981; }
.toast.error { background: #EF4444; }
.toast.info { background: #3B82F6; }
@keyframes toastIn {
from {
opacity: 0;
transform: translateX(30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.read-only-badge {
position: absolute;
top: 16px;
right: 16px;
z-index: 500;
display: flex;
align-items: center;
gap: 7px;
padding: 8px 13px;
color: #047857;
background: #ECFDF5;
border: 1px solid #A7F3D0;
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
font-size: 0.78rem;
font-weight: 800;
}
/* Responsive */
@media (max-width: 1180px) {
.map-analysis {
left: auto;
right: 16px;
max-width: calc(100% - 364px);
overflow-x: auto;
transform: none;
}
}
@media (max-width: 900px) {
.map-sidebar {
width: 300px;
}
.map-analysis {
top: auto;
right: 16px;
bottom: 16px;
left: 16px;
max-width: none;
justify-content: flex-start;
}
.map-legend {
right: 16px;
bottom: 78px;
}
}
@media (max-width: 768px) {
.map-wrap {
height: calc(100vh - 64px);
}
.map-sidebar {
top: 8px;
left: 8px;
width: calc(100% - 16px);
max-height: 46vh;
padding: 12px;
}
.map-panel-header {
margin-bottom: 10px;
padding-bottom: 10px;
}
.layer-item {
min-height: 44px;
}
.map-quickstats {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.map-analysis {
right: 8px;
bottom: 8px;
left: 8px;
padding: 6px;
overflow-x: auto;
}
.map-analysis button {
min-height: 32px;
padding: 6px 10px;
font-size: 0.74rem;
}
.spbu-panel {
display: none;
}
.map-legend {
right: 8px;
bottom: 66px;
min-width: 210px;
max-width: calc(100% - 16px);
}
.read-only-badge {
top: auto;
right: 8px;
bottom: 66px;
}
}
+364
View File
@@ -0,0 +1,364 @@
/* =========================================================
ADMIN LAYOUT STYLES — sidebar, topbar, main content
========================================================= */
body.admin-page {
display: flex;
min-height: 100vh;
background: var(--bg-base);
}
/* ══════════════════════════════
SIDEBAR
══════════════════════════════ */
.sidebar {
width: var(--sidebar-w);
flex-shrink: 0;
background: var(--sidebar-bg);
border-right: 1px solid var(--sidebar-border);
display: flex;
flex-direction: column;
height: 100vh;
position: fixed;
top: 0; left: 0;
z-index: 100;
overflow-y: auto;
transition: var(--transition);
}
.sidebar-brand {
padding: 24px 20px;
border-bottom: 1px solid var(--sidebar-border);
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.brand-icon {
width: 40px; height: 40px;
background: var(--primary);
border-radius: 10px;
display: flex; align-items: center; justify-content: center;
font-size: 1.1rem;
color: white;
flex-shrink: 0;
box-shadow: var(--shadow-sm);
}
.brand-text {
display: flex;
flex-direction: column;
line-height: 1.3;
}
.brand-name {
font-size: 0.95rem;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.brand-sub {
font-size: 0.75rem;
color: var(--text-muted);
font-weight: 500;
}
.sidebar-nav {
padding: 16px 0;
flex: 1;
}
.nav-section-title {
padding: 16px 20px 8px;
font-size: 0.7rem;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 20px;
color: var(--text-secondary);
font-size: 0.9rem;
font-weight: 500;
text-decoration: none;
border-radius: 0;
transition: var(--transition);
position: relative;
cursor: pointer;
border: none;
background: none;
width: 100%;
text-align: left;
}
.nav-item:hover {
background: #F3F4F6;
color: var(--text-primary);
}
.nav-item.active {
background: #EFF6FF;
color: var(--primary-dark);
}
.nav-item.active::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 4px;
background: var(--primary);
border-radius: 0 2px 2px 0;
}
.nav-item .nav-icon {
width: 20px;
text-align: center;
font-size: 0.95rem;
flex-shrink: 0;
color: inherit;
}
.nav-item .nav-badge {
margin-left: auto;
background: var(--danger);
color: #fff;
font-size: 0.7rem;
font-weight: 700;
padding: 2px 8px;
border-radius: 20px;
}
.sidebar-footer {
border-top: 1px solid var(--sidebar-border);
padding: 16px;
flex-shrink: 0;
background: #FAFAFA;
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
padding: 10px;
border-radius: var(--radius-md);
background: #FFFFFF;
border: 1px solid var(--sidebar-border);
box-shadow: var(--shadow-sm);
}
.user-avatar {
width: 36px; height: 36px;
border-radius: 50%;
background: #E0E7FF;
display: flex; align-items: center; justify-content: center;
font-size: 0.9rem;
font-weight: 700;
color: var(--primary-dark);
flex-shrink: 0;
}
.user-details { flex: 1; overflow: hidden; }
.user-name { font-size: 0.85rem; font-weight: 600; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.user-role { font-size: 0.75rem; color: var(--text-muted); text-transform: capitalize; }
.user-logout {
background: none; border: none; color: var(--text-muted);
cursor: pointer; font-size: 1.1rem; padding: 6px;
transition: all 0.2s;
border-radius: var(--radius-sm);
}
.user-logout:hover { color: var(--danger); background: #FEF2F2; }
/* ══════════════════════════════
MAIN CONTENT AREA
══════════════════════════════ */
.admin-main {
margin-left: var(--sidebar-w);
flex: 1;
display: flex;
flex-direction: column;
min-height: 100vh;
min-width: 0;
}
/* ── Topbar ── */
.topbar {
height: 64px;
background: var(--bg-surface);
border-bottom: 1px solid var(--border-light);
display: flex;
align-items: center;
padding: 0 32px;
gap: 16px;
position: sticky;
top: 0;
z-index: 50;
}
.topbar-title {
font-size: 1.05rem;
font-weight: 600;
color: var(--text-primary);
flex: 1;
}
.topbar-actions {
display: flex;
align-items: center;
gap: 12px;
}
.topbar-time {
font-size: 0.82rem;
font-weight: 500;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
background: #F9FAFB;
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
}
/* ── Content ── */
.admin-content {
flex: 1;
padding: 32px;
overflow-y: auto;
}
/* ══════════════════════════════
DASHBOARD STATS CARDS
══════════════════════════════ */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 20px;
margin-bottom: 32px;
}
.stat-card {
background: var(--bg-surface);
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
padding: 24px;
display: flex;
align-items: flex-start;
gap: 16px;
transition: var(--transition);
box-shadow: var(--shadow-sm);
}
.stat-card:hover { transform: translateY(-3px); box-shadow: var(--shadow-md); border-color: var(--border-hover); }
.stat-icon {
width: 48px; height: 48px;
border-radius: var(--radius-lg);
display: flex; align-items: center; justify-content: center;
font-size: 1.3rem;
flex-shrink: 0;
}
.stat-body { flex: 1; min-width: 0; }
.stat-label { font-size: 0.8rem; color: var(--text-muted); font-weight: 600; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.05em; }
.stat-value { font-size: 1.9rem; font-weight: 800; color: var(--text-primary); line-height: 1; letter-spacing: -0.02em; }
.stat-change { font-size: 0.75rem; color: var(--text-muted); margin-top: 8px; font-weight: 500; }
/* Card color variants (Flat UI style) */
.stat-card.primary .stat-icon { background: #EFF6FF; color: #2563EB; }
.stat-card.success .stat-icon { background: #ECFDF5; color: #059669; }
.stat-card.danger .stat-icon { background: #FEF2F2; color: #DC2626; }
.stat-card.warning .stat-icon { background: #FFFBEB; color: #D97706; }
.stat-card.info .stat-icon { background: #F0F9FF; color: #0284C7; }
.stat-card.violet .stat-icon { background: #F5F3FF; color: #7C3AED; }
/* ══════════════════════════════
DATA TABLE PAGE LAYOUT
══════════════════════════════ */
.page-toolbar {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
flex-wrap: wrap;
}
.page-toolbar .search-box {
flex: 1;
min-width: 250px;
max-width: 360px;
position: relative;
}
.search-box .search-icon {
position: absolute;
left: 14px; top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
font-size: 0.9rem;
}
.search-box input {
width: 100%;
padding: 10px 14px 10px 38px;
background: #FFFFFF;
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: 0.9rem;
outline: none;
transition: var(--transition);
font-family: 'Inter', sans-serif;
box-shadow: var(--shadow-sm);
}
.search-box input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); }
.search-box input::placeholder { color: #9CA3AF; }
/* ══════════════════════════════
MAP PAGE IN ADMIN
══════════════════════════════ */
.admin-map-page .admin-content {
padding: 0;
display: flex;
flex-direction: column;
}
.admin-map-page .topbar {
z-index: 500;
}
#admin-map {
flex: 1;
min-height: calc(100vh - 64px);
}
.map-sidebar-floating {
position: absolute;
top: 16px; left: 16px;
z-index: 400;
width: 280px;
background: #FFFFFF;
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
padding: 16px;
box-shadow: var(--shadow-md);
}
/* ══════════════════════════════
RESPONSIVE
══════════════════════════════ */
@media (max-width: 768px) {
.sidebar { transform: translateX(-100%); }
.sidebar.open { transform: translateX(0); box-shadow: var(--shadow-lg); }
.admin-main { margin-left: 0; }
.stats-grid { grid-template-columns: 1fr; }
}
+532
View File
@@ -0,0 +1,532 @@
/* =========================================================
admin-map.js — Full WebGIS Map (admin mode: edit & draw)
========================================================= */
const API = APP_BASE + '/api';
const STAT_API = APP_BASE + '/api/statistik.php';
const state = {
map: null,
layers: {
spbu: null, jalan: null, kavling: null,
kawasan: null, rumah: null, warga: null,
choropleth: null, blankSpot: null, spbuRoute: null
},
activeDraw: null, // 'spbu' | 'jalan' | 'kavling' | 'kawasan' | 'rumah' | 'warga'
drawHandlers: {},
pendingGeometry: null
};
const COLORS = {
spbu: '#F59E0B',
spbu24: '#10B981',
jalan: '#3B82F6',
kavling: '#8B5CF6',
kawasan: '#EF4444',
rumah: '#A855F7',
warga: '#EF4444',
blankSpot: '#F97316'
};
/* ── Icon factory ── */
function makeIcon(emoji, bgColor, size = 34) {
return L.divIcon({
className: 'div-icon-wrap',
html: `<div class="marker-pin" style="background:${bgColor};width:${size}px;height:${size}px;"><span>${emoji}</span></div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size],
popupAnchor: [0, -size]
});
}
const ICONS = {
spbu24: makeIcon('⛽', COLORS.spbu24),
spbu: makeIcon('⛽', COLORS.spbu),
rumah: makeIcon('🕌', COLORS.rumah), // default (Islam)
warga: makeIcon('👤', COLORS.warga),
blankSpot: makeIcon('⚠️', COLORS.blankSpot, 36)
};
/* ── Per-religion icon colors & emojis ── */
const AGAMA_EMOJI = {
'Islam': '🕌',
'Kristen': '⛪',
'Katolik': '⛪',
'Hindu': '🛕',
'Buddha': '☯️',
'Konghucu': '🏯'
};
const AGAMA_COLOR = {
'Islam': '#6D28D9',
'Kristen': '#2563EB',
'Katolik': '#1D4ED8',
'Hindu': '#D97706',
'Buddha': '#F59E0B',
'Konghucu': '#DC2626'
};
function getRumahIcon(agama) {
const emoji = AGAMA_EMOJI[agama] || '🛐';
const color = AGAMA_COLOR[agama] || COLORS.rumah;
return makeIcon(emoji, color);
}
/* ── Toast ── */
function toast(msg, type = 'success') {
const c = document.getElementById('toastContainer');
const t = document.createElement('div');
t.className = `toast ${type}`;
t.innerHTML = `<i class="fas fa-${type === 'success' ? 'check-circle' : (type === 'error' ? 'exclamation-circle' : 'info-circle')}" style="margin-right:8px;"></i>${msg}`;
c.appendChild(t);
setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 400); }, 3000);
}
/* ── Initialize map ── */
function initMap() {
state.map = L.map('admin-map', { zoomControl: false })
.setView([-0.0583, 109.3448], 13);
L.control.zoom({ position: 'bottomright' }).addTo(state.map);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap, © CARTO',
subdomains: 'abcd',
maxZoom: 20
}).addTo(state.map);
}
/* ── Fetch GeoJSON ── */
async function fetchLayer(path) {
const r = await fetch(`${API}/${path}`);
const j = await r.json();
return j.status === 'success' ? j.data : { type: 'FeatureCollection', features: [] };
}
function formatKm(value) {
return Number(value || 0).toLocaleString('id-ID', {
minimumFractionDigits: 0,
maximumFractionDigits: 2
});
}
/* ── Render: SPBU (Point) ── */
function renderSpbu(data) {
if (state.layers.spbu) state.map.removeLayer(state.layers.spbu);
state.layers.spbu = L.geoJSON(data, {
pointToLayer: (f, ll) => L.marker(ll, { icon: f.properties.buka_24_jam ? ICONS.spbu24 : ICONS.spbu }),
onEachFeature: (f, l) => bindPopup(l, 'spbu', f.properties)
});
if (document.getElementById('ly-spbu')?.checked) state.layers.spbu.addTo(state.map);
}
/* ── Render: Jalan (LineString) ── */
function renderJalan(data) {
if (state.layers.jalan) state.map.removeLayer(state.layers.jalan);
state.layers.jalan = L.geoJSON(data, {
style: { color: COLORS.jalan, weight: 5, opacity: 0.85 },
onEachFeature: (f, l) => bindPopup(l, 'jalan', f.properties)
});
if (document.getElementById('ly-jalan')?.checked) state.layers.jalan.addTo(state.map);
}
/* ── Render: Kavling (Polygon) ── */
function renderKavling(data) {
if (state.layers.kavling) state.map.removeLayer(state.layers.kavling);
state.layers.kavling = L.geoJSON(data, {
style: { color: COLORS.kavling, fillColor: COLORS.kavling, fillOpacity: 0.3, weight: 2 },
onEachFeature: (f, l) => bindPopup(l, 'kavling', f.properties)
});
if (document.getElementById('ly-kavling')?.checked) state.layers.kavling.addTo(state.map);
}
/* ── Render: Kawasan Kumuh (Polygon) ── */
function renderKawasan(data) {
if (state.layers.kawasan) state.map.removeLayer(state.layers.kawasan);
state.layers.kawasan = L.geoJSON(data, {
style: { color: COLORS.kawasan, fillColor: COLORS.kawasan, fillOpacity: 0.25, weight: 2, dashArray: '6 4' },
onEachFeature: (f, l) => bindPopup(l, 'kawasan', f.properties)
});
if (document.getElementById('ly-kawasan')?.checked) state.layers.kawasan.addTo(state.map);
}
/* ── Render: Rumah Ibadah (Point) — icon berubah sesuai agama ── */
function renderRumah(data) {
if (state.layers.rumah) state.map.removeLayer(state.layers.rumah);
state.layers.rumah = L.featureGroup();
(data.features || []).forEach((f) => {
if (!f.geometry || f.geometry.type !== 'Point') return;
const props = f.properties || {};
const agama = props.agama || 'Islam';
const agamaColor = AGAMA_COLOR[agama] || COLORS.rumah;
const ll = L.latLng(f.geometry.coordinates[1], f.geometry.coordinates[0]);
const circle = L.circle(ll, {
radius: Number(props.radius_bantuan_meter || 1000),
color: agamaColor,
fillColor: agamaColor,
fillOpacity: 0.10,
opacity: 0.60,
weight: 2
});
const marker = L.marker(ll, { icon: getRumahIcon(agama) });
bindPopup(circle, 'rumah', props);
bindPopup(marker, 'rumah', props);
state.layers.rumah.addLayer(circle);
state.layers.rumah.addLayer(marker);
});
if (document.getElementById('ly-rumah')?.checked) state.layers.rumah.addTo(state.map);
}
/* ── Render: Warga Miskin (Point) ── */
function renderWarga(data) {
if (state.layers.warga) state.map.removeLayer(state.layers.warga);
state.layers.warga = L.geoJSON(data, {
pointToLayer: (f, ll) => L.marker(ll, { icon: ICONS.warga }),
onEachFeature: (f, l) => bindPopup(l, 'warga', f.properties)
});
if (document.getElementById('ly-warga')?.checked) state.layers.warga.addTo(state.map);
}
/* ── Render: Choropleth (kawasan + count warga) ── */
function renderChoropleth(data) {
if (state.layers.choropleth) state.map.removeLayer(state.layers.choropleth);
state.layers.choropleth = L.geoJSON(data, {
style: (f) => {
const c = f.properties.jumlah_warga;
let color = '#10B981';
if (c > 0) color = '#F59E0B';
if (c > 3) color = '#EF4444';
return { color, fillColor: color, fillOpacity: 0.35, weight: 3 };
},
onEachFeature: (f, l) => {
const c = f.properties.jumlah_warga;
l.bindPopup(`<div class="popup-title">${f.properties.nama_kawasan}</div>
<div class="popup-row">Jumlah Warga: <strong>${c}</strong></div>
<div class="popup-row">Total Tanggungan: <strong>${f.properties.total_tanggungan}</strong></div>`);
}
});
if (document.getElementById('ly-choropleth')?.checked) state.layers.choropleth.addTo(state.map);
}
/* ── Render: Blank Spot (warga yang jauh dari rumah ibadah) ── */
function renderBlankSpot(data) {
if (state.layers.blankSpot) state.map.removeLayer(state.layers.blankSpot);
state.layers.blankSpot = L.geoJSON(data, {
pointToLayer: (f, ll) => L.marker(ll, { icon: ICONS.blankSpot }),
onEachFeature: (f, l) => {
l.bindPopup(`<div class="popup-title">⚠️ ${f.properties.nama_kk}</div>
<div class="popup-row">Jarak ke RI terdekat: <strong>${formatKm(f.properties.jarak_km)} km</strong></div>
<div class="popup-row">Radius bantuan: <strong>${formatKm(f.properties.radius_km)} km</strong></div>
<div class="popup-row">Di luar radius: <strong>${formatKm(f.properties.selisih_km)} km</strong></div>
<div class="popup-row">Rumah ibadah terdekat: <strong>${f.properties.rumah_ibadah_terdekat || '-'}</strong></div>
<div class="popup-row" style="color:#F97316">Blank spot di luar radius bantuan</div>`);
}
});
if (document.getElementById('ly-blankspot')?.checked) state.layers.blankSpot.addTo(state.map);
}
/* ── Popup binding helper ── */
function bindPopup(layer, type, props) {
let html = `<div class="popup-title">${props.nama || props.nama_pemilik || props.nama_kk || props.nama_kawasan || 'Tanpa Nama'}</div>`;
if (type === 'spbu') {
html += `<div class="popup-row">24 Jam: <strong style="color:${props.buka_24_jam ? '#10B981' : '#EF4444'}">${props.buka_24_jam ? 'Ya' : 'Tidak'}</strong></div>`;
if (props.deskripsi) html += `<div class="popup-row">${props.deskripsi}</div>`;
} else if (type === 'jalan') {
html += `<div class="popup-row">Jenis: <strong>${props.jenis_jalan || '-'}</strong></div>`;
} else if (type === 'kavling') {
html += `<div class="popup-row">Status: <strong>${props.status_kepemilikan || '-'}</strong></div>`;
html += `<div class="popup-row">Luas: <strong>${props.luas ? props.luas + ' m²' : '-'}</strong></div>`;
} else if (type === 'rumah') {
html += `<div class="popup-row">Agama: <strong>${props.agama || '-'}</strong></div>`;
html += `<div class="popup-row">Radius Bantuan: <strong>${formatKm(props.radius_bantuan_km)} km</strong></div>`;
} else if (type === 'warga') {
html += `<div class="popup-row">Penghasilan: <strong>Rp ${Number(props.penghasilan || 0).toLocaleString('id-ID')}</strong></div>`;
html += `<div class="popup-row">Tanggungan: <strong>${props.jumlah_tanggungan || 0} org</strong></div>`;
}
// Map type to the correct admin manage page
const kelolaMap = {
spbu: 'kelola_spbu.php',
jalan: 'kelola_jalan.php',
kavling: 'kelola_kavling.php',
kawasan: 'kelola_kawasan.php',
rumah: 'kelola_fasilitas.php',
warga: 'kelola_warga.php'
};
const kelolaPage = kelolaMap[type] || '';
html += `<div class="popup-actions">
<button class="btn btn-danger btn-sm" onclick="deleteFeature('${type}', ${props.id})"><i class="fas fa-trash"></i> Hapus</button>
${kelolaPage ? `<button class="btn btn-ghost btn-sm" onclick="window.location.href=APP_BASE+'/panel_admin/${kelolaPage}'"><i class="fas fa-edit"></i> Kelola</button>` : ''}
</div>`;
layer.bindPopup(html);
}
/* ── Delete from map ── */
window.deleteFeature = async function (type, id) {
if (!confirm('Yakin hapus data ini?')) return;
const ep = { spbu: 'spbu.php', jalan: 'jalan.php', kavling: 'kavling.php', kawasan: 'kawasan_kumuh.php', rumah: 'rumah_ibadah.php', warga: 'warga_miskin.php' }[type];
if (!ep) return;
const r = await fetch(`${API}/${ep}?id=${id}`, { method: 'DELETE' });
const j = await r.json();
if (j.status === 'success') { toast('Data dihapus!'); loadAll(); }
else toast(j.message, 'error');
};
/* ── Layer toggling ── */
function bindLayerToggles() {
const map = {
'ly-spbu': 'spbu', 'ly-jalan': 'jalan', 'ly-kavling': 'kavling',
'ly-kawasan': 'kawasan', 'ly-rumah': 'rumah', 'ly-warga': 'warga',
'ly-choropleth': 'choropleth', 'ly-blankspot': 'blankSpot'
};
Object.entries(map).forEach(([id, key]) => {
document.getElementById(id)?.addEventListener('change', (e) => {
const layer = state.layers[key];
if (!layer) return;
if (e.target.checked) state.map.addLayer(layer);
else state.map.removeLayer(layer);
});
});
}
/* ── Drawing tool activation ── */
function activateDraw(type) {
if (state.activeDraw) deactivateDraw();
state.activeDraw = type;
// Update UI button state
document.querySelectorAll('[data-draw]').forEach(b => b.classList.remove('active'));
document.querySelector(`[data-draw="${type}"]`)?.classList.add('active');
// Create appropriate L.Draw handler
let handler = null;
if (type === 'spbu' || type === 'rumah' || type === 'warga') {
handler = new L.Draw.Marker(state.map);
} else if (type === 'jalan') {
handler = new L.Draw.Polyline(state.map, { shapeOptions: { color: COLORS.jalan, weight: 5 } });
} else if (type === 'kavling' || type === 'kawasan') {
handler = new L.Draw.Polygon(state.map, { shapeOptions: { color: type === 'kavling' ? COLORS.kavling : COLORS.kawasan } });
}
state.drawHandlers[type] = handler;
handler.enable();
}
/* ── Deactivate drawing ── */
function deactivateDraw() {
if (state.activeDraw && state.drawHandlers[state.activeDraw]) {
state.drawHandlers[state.activeDraw].disable();
}
state.activeDraw = null;
document.querySelectorAll('[data-draw]').forEach(b => b.classList.remove('active'));
}
/* ── Draw event listener ── */
function bindDrawEvent() {
state.map.on(L.Draw.Event.CREATED, (e) => {
const geom = e.layer.toGeoJSON().geometry;
state.pendingGeometry = geom;
openFormModal(state.activeDraw, geom);
});
state.map.on(L.Draw.Event.DRAWSTOP, () => {
// User cancelled drawing
document.querySelectorAll('[data-draw]').forEach(b => b.classList.remove('active'));
state.activeDraw = null;
});
}
/* ── Form modal untuk input data baru ── */
function openFormModal(type, geometry) {
const modal = document.getElementById('formModal');
const title = document.getElementById('formModalTitle');
const body = document.getElementById('formModalBody');
const titles = { spbu: '⛽ Tambah SPBU', jalan: '🛣️ Tambah Jalan', kavling: '🏘️ Tambah Kavling',
kawasan: '⚠️ Tambah Kawasan Kumuh', rumah: '🕌 Tambah Rumah Ibadah', warga: '👤 Tambah Warga Miskin' };
title.textContent = titles[type] || 'Tambah Data';
let formHtml = '';
if (type === 'spbu') {
formHtml = `
<div class="form-group"><label class="form-label">Nama SPBU *</label><input type="text" id="f-nama" class="form-control" required></div>
<div class="form-group"><label class="form-label">Deskripsi</label><textarea id="f-deskripsi" class="form-control"></textarea></div>
<div class="form-group"><label class="form-label" style="display:flex;align-items:center;gap:8px;cursor:pointer;"><input type="checkbox" id="f-24" style="accent-color:var(--primary);"> Buka 24 Jam</label></div>`;
} else if (type === 'jalan') {
formHtml = `
<div class="form-group"><label class="form-label">Nama Jalan *</label><input type="text" id="f-nama" class="form-control" required></div>
<div class="form-group"><label class="form-label">Jenis Jalan</label>
<select id="f-jenis" class="form-control">
<option>Jalan Arteri</option><option>Jalan Kolektor</option>
<option selected>Jalan Lokal</option><option>Jalan Lingkungan</option>
</select></div>`;
} else if (type === 'kavling') {
formHtml = `
<div class="form-group"><label class="form-label">Nama Pemilik *</label><input type="text" id="f-nama" class="form-control" required></div>
<div class="form-group"><label class="form-label">Status Kepemilikan *</label>
<select id="f-status" class="form-control">
<option value="SHM">SHM</option><option value="HGB">HGB</option>
<option value="HGU">HGU</option><option value="HP">HP</option>
</select></div>
<div class="form-group"><label class="form-label">Luas (m²)</label><input type="number" id="f-luas" class="form-control" min="0" step="0.01"></div>`;
} else if (type === 'kawasan') {
formHtml = `
<div class="form-group"><label class="form-label">Nama Kawasan *</label><input type="text" id="f-nama" class="form-control" required></div>`;
} else if (type === 'rumah') {
formHtml = `
<div class="form-group"><label class="form-label">Nama *</label><input type="text" id="f-nama" class="form-control" required></div>
<div class="form-group"><label class="form-label">Agama *</label>
<select id="f-agama" class="form-control" onchange="updateRumahModalTitle(this.value)">
<option>Islam</option><option>Kristen</option><option>Katolik</option>
<option>Hindu</option><option>Buddha</option><option>Konghucu</option>
</select></div>
<div class="form-group"><label class="form-label">Radius Bantuan (meter) *</label><input type="number" id="f-radius" class="form-control" min="100" max="10000" step="50" value="1000" required></div>`;
} else if (type === 'warga') {
formHtml = `
<div class="form-group"><label class="form-label">Nama Kepala Keluarga *</label><input type="text" id="f-nama" class="form-control" required></div>
<div class="form-group"><label class="form-label">Penghasilan/Bln (Rp)</label><input type="number" id="f-penghasilan" class="form-control" min="0"></div>
<div class="form-group"><label class="form-label">Jumlah Tanggungan</label><input type="number" id="f-tanggungan" class="form-control" min="0" max="20"></div>`;
}
body.innerHTML = formHtml + `
<div class="modal-footer" style="margin-top:20px;display:flex;gap:10px;justify-content:flex-end;">
<button class="btn btn-ghost" onclick="closeFormModal()">Batal</button>
<button class="btn btn-primary" id="btn-simpan" onclick="saveFeature('${type}')"><i class="fas fa-save"></i> Simpan</button>
</div>`;
modal.classList.add('active');
}
window.closeFormModal = function () {
document.getElementById('formModal').classList.remove('active');
state.pendingGeometry = null;
deactivateDraw();
};
/* ── Update draw modal title when agama changes ── */
window.updateRumahModalTitle = function(agama) {
const emoji = AGAMA_EMOJI[agama] || '🛐';
document.getElementById('formModalTitle').textContent = `${emoji} Tambah Rumah Ibadah`;
};
/* ── Save feature to backend ── */
window.saveFeature = async function (type) {
const ep = { spbu: 'spbu.php', jalan: 'jalan.php', kavling: 'kavling.php', kawasan: 'kawasan_kumuh.php', rumah: 'rumah_ibadah.php', warga: 'warga_miskin.php' }[type];
if (!ep) return;
let payload = { geometry: state.pendingGeometry };
const v = (id) => document.getElementById(id)?.value;
if (type === 'spbu') {
if (!v('f-nama')) return toast('Nama wajib!', 'error');
payload = { ...payload, nama: v('f-nama'), deskripsi: v('f-deskripsi') || '', buka_24_jam: document.getElementById('f-24').checked ? 1 : 0 };
} else if (type === 'jalan') {
if (!v('f-nama')) return toast('Nama wajib!', 'error');
payload = { ...payload, nama: v('f-nama'), jenis_jalan: v('f-jenis') };
} else if (type === 'kavling') {
if (!v('f-nama')) return toast('Nama wajib!', 'error');
payload = { ...payload, nama_pemilik: v('f-nama'), status_kepemilikan: v('f-status'), luas: parseFloat(v('f-luas')) || 0 };
} else if (type === 'kawasan') {
if (!v('f-nama')) return toast('Nama wajib!', 'error');
payload = { ...payload, nama_kawasan: v('f-nama') };
} else if (type === 'rumah') {
if (!v('f-nama')) return toast('Nama wajib!', 'error');
payload = { ...payload, nama: v('f-nama'), agama: v('f-agama'), radius_bantuan_meter: Math.max(100, Math.min(10000, parseInt(v('f-radius'), 10) || 1000)) };
} else if (type === 'warga') {
if (!v('f-nama')) return toast('Nama wajib!', 'error');
payload = { ...payload, nama_kk: v('f-nama'), penghasilan: parseFloat(v('f-penghasilan')) || 0, jumlah_tanggungan: parseInt(v('f-tanggungan')) || 0 };
}
try {
const r = await fetch(`${API}/${ep}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const j = await r.json();
if (j.status === 'success') { toast('Data berhasil disimpan!'); closeFormModal(); loadAll(); }
else toast(j.message || 'Gagal menyimpan', 'error');
} catch (e) { toast('Error: ' + e.message, 'error'); }
};
/* ── Analysis: SPBU Terdekat ── */
async function findNearestSpbu() {
const center = state.map.getCenter();
document.getElementById('spbuPanelResult').innerHTML = '<div class="spinner"></div> Mencari...';
document.getElementById('spbuPanel').style.display = 'block';
try {
const r = await fetch(`${API}/spbu_terdekat.php?lat=${center.lat}&lng=${center.lng}`);
const j = await r.json();
if (j.status === 'success' && j.data) {
const p = j.data.properties;
const coords = j.data.geometry.coordinates;
const html = `<div class="spbu-result">
<div class="spbu-result-name">⛽ ${p.nama}</div>
<div class="spbu-result-meta">${p.buka_24_jam ? '🟢 24 Jam' : '🟡 Terbatas'}</div>
<div class="spbu-result-distance">${p.jarak_km} km</div>
</div>`;
document.getElementById('spbuPanelResult').innerHTML = html;
// Show marker
if (state.layers.spbuRoute) state.map.removeLayer(state.layers.spbuRoute);
state.layers.spbuRoute = L.marker([coords[1], coords[0]], { icon: ICONS.spbu }).addTo(state.map)
.bindPopup(`<b>${p.nama}</b><br>${p.jarak_km} km dari pusat peta`).openPopup();
} else {
document.getElementById('spbuPanelResult').innerHTML = `<div class="spbu-result">${j.message || 'Tidak ada SPBU'}</div>`;
}
} catch (e) {
document.getElementById('spbuPanelResult').innerHTML = `<div class="spbu-result">Error: ${e.message}</div>`;
}
}
/* ── Load all layers ── */
async function loadAll() {
document.getElementById('loadingOverlay').style.display = 'flex';
try {
const [spbu, jalan, kavling, kawasan, rumah, warga, stat, blank] = await Promise.all([
fetchLayer('spbu.php'),
fetchLayer('jalan.php'),
fetchLayer('kavling.php'),
fetchLayer('kawasan_kumuh.php'),
fetchLayer('rumah_ibadah.php'),
fetchLayer('warga_miskin.php'),
fetch(STAT_API).then(r => r.json()).then(j => j.data).catch(() => null),
fetchLayer('blank_spot.php')
]);
renderSpbu(spbu); renderJalan(jalan); renderKavling(kavling);
renderKawasan(kawasan); renderRumah(rumah); renderWarga(warga);
if (stat && stat.choropleth) renderChoropleth(stat.choropleth);
if (blank) renderBlankSpot(blank);
// Update quick stats
if (stat && stat.counts) {
document.getElementById('qs-spbu').textContent = stat.counts.total_spbu;
document.getElementById('qs-jalan').textContent = stat.counts.total_jalan;
document.getElementById('qs-kavling').textContent = stat.counts.total_kavling;
document.getElementById('qs-warga').textContent = stat.counts.total_warga_miskin;
}
} catch (e) {
toast('Gagal load data: ' + e.message, 'error');
} finally {
document.getElementById('loadingOverlay').style.display = 'none';
}
}
/* ── Init ── */
document.addEventListener('DOMContentLoaded', () => {
initMap();
loadAll();
bindLayerToggles();
bindDrawEvent();
document.querySelectorAll('[data-draw]').forEach(btn => {
btn.addEventListener('click', () => {
const type = btn.dataset.draw;
if (state.activeDraw === type) deactivateDraw();
else activateDraw(type);
});
});
document.getElementById('btn-find-spbu')?.addEventListener('click', findNearestSpbu);
document.getElementById('btn-close-spbu')?.addEventListener('click', () => {
document.getElementById('spbuPanel').style.display = 'none';
if (state.layers.spbuRoute) { state.map.removeLayer(state.layers.spbuRoute); state.layers.spbuRoute = null; }
});
});
+383
View File
@@ -0,0 +1,383 @@
/* =========================================================
user-map.js — Read-only WebGIS Map (user mode)
========================================================= */
const API = APP_BASE + '/api';
const STAT_API = APP_BASE + '/api/statistik.php';
const state = {
map: null,
layers: {
spbu: null, jalan: null, kavling: null,
kawasan: null, rumah: null, warga: null,
choropleth: null, blankSpot: null, spbuRoute: null
}
};
const COLORS = {
spbu: '#F59E0B',
spbu24: '#10B981',
jalan: '#3B82F6',
kavling: '#8B5CF6',
kawasan: '#EF4444',
rumah: '#A855F7',
warga: '#EF4444',
blankSpot: '#F97316'
};
function makeIcon(emoji, bgColor, size = 34) {
return L.divIcon({
className: 'div-icon-wrap',
html: `<div class="marker-pin" style="background:${bgColor};width:${size}px;height:${size}px;"><span>${emoji}</span></div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size],
popupAnchor: [0, -size]
});
}
const ICONS = {
spbu24: makeIcon('⛽', COLORS.spbu24),
spbu: makeIcon('⛽', COLORS.spbu),
rumah: makeIcon('🕌', COLORS.rumah), // default (Islam)
warga: makeIcon('👤', COLORS.warga),
blankSpot: makeIcon('⚠️', COLORS.blankSpot, 36)
};
/* ── Per-religion icon colors & emojis ── */
const AGAMA_EMOJI = {
'Islam': '🕌',
'Kristen': '⛪',
'Katolik': '⛪',
'Hindu': '🛕',
'Buddha': '☯️',
'Konghucu': '🏯'
};
const AGAMA_COLOR = {
'Islam': '#6D28D9',
'Kristen': '#2563EB',
'Katolik': '#1D4ED8',
'Hindu': '#D97706',
'Buddha': '#F59E0B',
'Konghucu': '#DC2626'
};
function getRumahIcon(agama) {
const emoji = AGAMA_EMOJI[agama] || '🛐';
const color = AGAMA_COLOR[agama] || COLORS.rumah;
return makeIcon(emoji, color);
}
function toast(msg, type = 'success') {
const c = document.getElementById('toastContainer');
const t = document.createElement('div');
t.className = `toast ${type}`;
t.innerHTML = `<i class="fas fa-${type === 'success' ? 'check-circle' : 'info-circle'}" style="margin-right:8px;"></i>${msg}`;
c.appendChild(t);
setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 400); }, 3000);
}
function initMap() {
state.map = L.map('user-map', { zoomControl: false })
.setView([-0.0583, 109.3448], 13);
L.control.zoom({ position: 'bottomright' }).addTo(state.map);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap, © CARTO',
subdomains: 'abcd',
maxZoom: 20
}).addTo(state.map);
}
async function fetchLayer(path) {
const r = await fetch(`${API}/${path}`);
const j = await r.json();
return j.status === 'success' ? j.data : { type: 'FeatureCollection', features: [] };
}
function formatKm(value) {
return Number(value || 0).toLocaleString('id-ID', {
minimumFractionDigits: 0,
maximumFractionDigits: 2
});
}
function bindReadOnlyPopup(layer, type, props) {
let html = `<div class="popup-title">${props.nama || props.nama_pemilik || props.nama_kk || props.nama_kawasan || 'Tanpa Nama'}</div>`;
if (type === 'spbu') {
html += `<div class="popup-row">24 Jam: <strong style="color:${props.buka_24_jam ? '#10B981' : '#EF4444'}">${props.buka_24_jam ? 'Ya' : 'Tidak'}</strong></div>`;
if (props.deskripsi) html += `<div class="popup-row">${props.deskripsi}</div>`;
html += `<div class="popup-row">Rating: <strong>${props.avg_rating > 0 ? '⭐ ' + props.avg_rating + ' (' + props.total_ulasan + ' ulasan)' : 'Belum ada ulasan'}</strong></div>`;
html += `<button class="btn btn-primary btn-sm" style="margin-top:10px;width:100%;" onclick="bukaReview('spbu', ${props.id}, '${props.nama}')">Beri Ulasan</button>`;
} else if (type === 'jalan') {
html += `<div class="popup-row">Jenis: <strong>${props.jenis_jalan || '-'}</strong></div>`;
} else if (type === 'kavling') {
html += `<div class="popup-row">Status: <strong>${props.status_kepemilikan || '-'}</strong></div>`;
html += `<div class="popup-row">Luas: <strong>${props.luas ? props.luas + ' m²' : '-'}</strong></div>`;
} else if (type === 'rumah') {
html += `<div class="popup-row">Agama: <strong>${props.agama || '-'}</strong></div>`;
html += `<div class="popup-row">Radius Bantuan: <strong>${formatKm(props.radius_bantuan_km)} km</strong></div>`;
html += `<div class="popup-row">Rating: <strong>${props.avg_rating > 0 ? '⭐ ' + props.avg_rating + ' (' + props.total_ulasan + ' ulasan)' : 'Belum ada ulasan'}</strong></div>`;
html += `<button class="btn btn-primary btn-sm" style="margin-top:10px;width:100%;" onclick="bukaReview('rumah_ibadah', ${props.id}, '${props.nama}')">Beri Ulasan</button>`;
} else if (type === 'warga') {
html += `<div class="popup-row">Penghasilan: <strong>Rp ${Number(props.penghasilan || 0).toLocaleString('id-ID')}</strong></div>`;
html += `<div class="popup-row">Tanggungan: <strong>${props.jumlah_tanggungan || 0} org</strong></div>`;
}
layer.bindPopup(html);
}
// Ulasan Modal Logic
window.bukaReview = function(tipe, id, nama) {
let m = document.getElementById('modalReview');
if(!m) {
m = document.createElement('div');
m.id = 'modalReview';
m.className = 'modal-overlay';
m.innerHTML = `
<div class="modal">
<div class="modal-header">
<span class="modal-title">⭐ Beri Ulasan</span>
<button class="modal-close" onclick="document.getElementById('modalReview').style.display='none'">×</button>
</div>
<div style="padding:15px;">
<h4 id="revNama" style="margin-top:0; color:var(--text-primary);"></h4>
<input type="hidden" id="revTipe"><input type="hidden" id="revId">
<div class="form-group">
<label class="form-label">Rating (1-5)</label>
<select id="revRating" class="form-control">
<option value="5">⭐⭐⭐⭐⭐ Sangat Baik</option>
<option value="4">⭐⭐⭐⭐ Baik</option>
<option value="3">⭐⭐⭐ Cukup</option>
<option value="2">⭐⭐ Buruk</option>
<option value="1">⭐ Sangat Buruk</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Komentar</label>
<textarea id="revKomen" class="form-control" rows="3" placeholder="Tuliskan pengalaman Anda..."></textarea>
</div>
<button class="btn btn-primary" style="width:100%" onclick="submitReview()">Kirim Ulasan</button>
</div>
</div>`;
document.body.appendChild(m);
}
document.getElementById('revTipe').value = tipe;
document.getElementById('revId').value = id;
document.getElementById('revNama').innerText = "Mengulas: " + nama;
document.getElementById('revRating').value = "5";
document.getElementById('revKomen').value = "";
m.style.display = 'flex';
};
window.submitReview = async function() {
const tipe = document.getElementById('revTipe').value;
const id = document.getElementById('revId').value;
const rating = document.getElementById('revRating').value;
const komen = document.getElementById('revKomen').value;
const res = await fetch(APP_BASE + '/api/ulasan.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fasilitas_tipe: tipe, fasilitas_id: id, rating: rating, komentar: komen })
});
const j = await res.json();
if(j.status === 'success') {
toast('Ulasan berhasil dikirim!');
document.getElementById('modalReview').style.display = 'none';
loadAll(); // reload data
} else {
toast(j.message, 'error');
}
};
function renderSpbu(data) {
if (state.layers.spbu) state.map.removeLayer(state.layers.spbu);
state.layers.spbu = L.geoJSON(data, {
pointToLayer: (f, ll) => L.marker(ll, { icon: f.properties.buka_24_jam ? ICONS.spbu24 : ICONS.spbu }),
onEachFeature: (f, l) => bindReadOnlyPopup(l, 'spbu', f.properties)
});
if (document.getElementById('ly-spbu')?.checked) state.layers.spbu.addTo(state.map);
}
function renderJalan(data) {
if (state.layers.jalan) state.map.removeLayer(state.layers.jalan);
state.layers.jalan = L.geoJSON(data, {
style: { color: COLORS.jalan, weight: 5, opacity: 0.85 },
onEachFeature: (f, l) => bindReadOnlyPopup(l, 'jalan', f.properties)
});
if (document.getElementById('ly-jalan')?.checked) state.layers.jalan.addTo(state.map);
}
function renderKavling(data) {
if (state.layers.kavling) state.map.removeLayer(state.layers.kavling);
state.layers.kavling = L.geoJSON(data, {
style: { color: COLORS.kavling, fillColor: COLORS.kavling, fillOpacity: 0.3, weight: 2 },
onEachFeature: (f, l) => bindReadOnlyPopup(l, 'kavling', f.properties)
});
if (document.getElementById('ly-kavling')?.checked) state.layers.kavling.addTo(state.map);
}
function renderKawasan(data) {
if (state.layers.kawasan) state.map.removeLayer(state.layers.kawasan);
state.layers.kawasan = L.geoJSON(data, {
style: { color: COLORS.kawasan, fillColor: COLORS.kawasan, fillOpacity: 0.25, weight: 2, dashArray: '6 4' },
onEachFeature: (f, l) => bindReadOnlyPopup(l, 'kawasan', f.properties)
});
if (document.getElementById('ly-kawasan')?.checked) state.layers.kawasan.addTo(state.map);
}
function renderRumah(data) {
if (state.layers.rumah) state.map.removeLayer(state.layers.rumah);
state.layers.rumah = L.featureGroup();
(data.features || []).forEach((f) => {
if (!f.geometry || f.geometry.type !== 'Point') return;
const props = f.properties || {};
const agama = props.agama || 'Islam';
const agamaColor = AGAMA_COLOR[agama] || COLORS.rumah;
const ll = L.latLng(f.geometry.coordinates[1], f.geometry.coordinates[0]);
const circle = L.circle(ll, {
radius: Number(props.radius_bantuan_meter || 1000),
color: agamaColor,
fillColor: agamaColor,
fillOpacity: 0.10,
opacity: 0.60,
weight: 2
});
const marker = L.marker(ll, { icon: getRumahIcon(agama) });
bindReadOnlyPopup(circle, 'rumah', props);
bindReadOnlyPopup(marker, 'rumah', props);
state.layers.rumah.addLayer(circle);
state.layers.rumah.addLayer(marker);
});
if (document.getElementById('ly-rumah')?.checked) state.layers.rumah.addTo(state.map);
}
function renderWarga(data) {
if (state.layers.warga) state.map.removeLayer(state.layers.warga);
state.layers.warga = L.geoJSON(data, {
pointToLayer: (f, ll) => L.marker(ll, { icon: ICONS.warga }),
onEachFeature: (f, l) => bindReadOnlyPopup(l, 'warga', f.properties)
});
if (document.getElementById('ly-warga')?.checked) state.layers.warga.addTo(state.map);
}
function renderChoropleth(data) {
if (state.layers.choropleth) state.map.removeLayer(state.layers.choropleth);
state.layers.choropleth = L.geoJSON(data, {
style: (f) => {
const c = f.properties.jumlah_warga;
let color = '#10B981';
if (c > 0) color = '#F59E0B';
if (c > 3) color = '#EF4444';
return { color, fillColor: color, fillOpacity: 0.35, weight: 3 };
},
onEachFeature: (f, l) => {
l.bindPopup(`<div class="popup-title">${f.properties.nama_kawasan}</div>
<div class="popup-row">Jumlah Warga: <strong>${f.properties.jumlah_warga}</strong></div>
<div class="popup-row">Total Tanggungan: <strong>${f.properties.total_tanggungan}</strong></div>`);
}
});
if (document.getElementById('ly-choropleth')?.checked) state.layers.choropleth.addTo(state.map);
}
function renderBlankSpot(data) {
if (state.layers.blankSpot) state.map.removeLayer(state.layers.blankSpot);
state.layers.blankSpot = L.geoJSON(data, {
pointToLayer: (f, ll) => L.marker(ll, { icon: ICONS.blankSpot }),
onEachFeature: (f, l) => {
l.bindPopup(`<div class="popup-title">⚠️ ${f.properties.nama_kk}</div>
<div class="popup-row">Jarak ke RI terdekat: <strong>${formatKm(f.properties.jarak_km)} km</strong></div>
<div class="popup-row">Radius bantuan: <strong>${formatKm(f.properties.radius_km)} km</strong></div>
<div class="popup-row">Di luar radius: <strong>${formatKm(f.properties.selisih_km)} km</strong></div>
<div class="popup-row">Rumah ibadah terdekat: <strong>${f.properties.rumah_ibadah_terdekat || '-'}</strong></div>
<div class="popup-row" style="color:#F97316">Blank spot di luar radius bantuan</div>`);
}
});
if (document.getElementById('ly-blankspot')?.checked) state.layers.blankSpot.addTo(state.map);
}
function bindLayerToggles() {
const map = {
'ly-spbu': 'spbu', 'ly-jalan': 'jalan', 'ly-kavling': 'kavling',
'ly-kawasan': 'kawasan', 'ly-rumah': 'rumah', 'ly-warga': 'warga',
'ly-choropleth': 'choropleth', 'ly-blankspot': 'blankSpot'
};
Object.entries(map).forEach(([id, key]) => {
document.getElementById(id)?.addEventListener('change', (e) => {
const layer = state.layers[key];
if (!layer) return;
if (e.target.checked) state.map.addLayer(layer);
else state.map.removeLayer(layer);
});
});
}
async function findNearestSpbu() {
const center = state.map.getCenter();
document.getElementById('spbuPanelResult').innerHTML = '<div class="spinner"></div> Mencari...';
document.getElementById('spbuPanel').style.display = 'block';
try {
const r = await fetch(`${API}/spbu_terdekat.php?lat=${center.lat}&lng=${center.lng}`);
const j = await r.json();
if (j.status === 'success' && j.data) {
const p = j.data.properties;
const coords = j.data.geometry.coordinates;
document.getElementById('spbuPanelResult').innerHTML = `<div class="spbu-result">
<div class="spbu-result-name">⛽ ${p.nama}</div>
<div class="spbu-result-meta">${p.buka_24_jam ? '🟢 24 Jam' : '🟡 Terbatas'}</div>
<div class="spbu-result-distance">${p.jarak_km} km</div>
</div>`;
if (state.layers.spbuRoute) state.map.removeLayer(state.layers.spbuRoute);
state.layers.spbuRoute = L.marker([coords[1], coords[0]], { icon: ICONS.spbu }).addTo(state.map)
.bindPopup(`<b>${p.nama}</b><br>${p.jarak_km} km dari pusat peta`).openPopup();
} else {
document.getElementById('spbuPanelResult').innerHTML = `<div class="spbu-result">${j.message || 'Tidak ada SPBU'}</div>`;
}
} catch (e) {
document.getElementById('spbuPanelResult').innerHTML = `<div class="spbu-result">Error: ${e.message}</div>`;
}
}
async function loadAll() {
document.getElementById('loadingOverlay').style.display = 'flex';
try {
const [spbu, jalan, kavling, kawasan, rumah, warga, stat, blank] = await Promise.all([
fetchLayer('spbu.php'),
fetchLayer('jalan.php'),
fetchLayer('kavling.php'),
fetchLayer('kawasan_kumuh.php'),
fetchLayer('rumah_ibadah.php'),
fetchLayer('warga_miskin.php'),
fetch(STAT_API).then(r => r.json()).then(j => j.data).catch(() => null),
fetchLayer('blank_spot.php')
]);
renderSpbu(spbu); renderJalan(jalan); renderKavling(kavling);
renderKawasan(kawasan); renderRumah(rumah); renderWarga(warga);
if (stat && stat.choropleth) renderChoropleth(stat.choropleth);
if (blank) renderBlankSpot(blank);
if (stat && stat.counts) {
document.getElementById('qs-spbu').textContent = stat.counts.total_spbu;
document.getElementById('qs-jalan').textContent = stat.counts.total_jalan;
document.getElementById('qs-kavling').textContent = stat.counts.total_kavling;
document.getElementById('qs-warga').textContent = stat.counts.total_warga_miskin;
}
} catch (e) {
toast('Gagal load data: ' + e.message, 'error');
} finally {
document.getElementById('loadingOverlay').style.display = 'none';
}
}
document.addEventListener('DOMContentLoaded', () => {
initMap();
loadAll();
bindLayerToggles();
document.getElementById('btn-find-spbu')?.addEventListener('click', findNearestSpbu);
document.getElementById('btn-close-spbu')?.addEventListener('click', () => {
document.getElementById('spbuPanel').style.display = 'none';
if (state.layers.spbuRoute) { state.map.removeLayer(state.layers.spbuRoute); state.layers.spbuRoute = null; }
});
});