Initial WebGIS portal project

This commit is contained in:
2026-06-10 19:53:39 +07:00
commit c0aa8e09d5
62 changed files with 9013 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
.git
.env
**/.git
**/.env
Leaflet2/uploads
*.log
node_modules
vendor
tmp
+16
View File
@@ -0,0 +1,16 @@
APP_PORT=8080
DB_PORT=3307
DB_HOST=db
DB_DATABASE=webgis_miskin
DB_USERNAME=webgis_user
DB_PASSWORD=webgis_password
DB_CHARSET=utf8mb4
SPBU_DB_HOST=db
SPBU_DB_DATABASE=webgis_spbu
SPBU_DB_USERNAME=webgis_user
SPBU_DB_PASSWORD=webgis_password
DB_SETUP_USERNAME=root
MYSQL_ROOT_PASSWORD=root_password
+10
View File
@@ -0,0 +1,10 @@
.env
**/.env
*.log
node_modules/
vendor/
Leaflet2/uploads/
tmp/
.DS_Store
Thumbs.db
+112
View File
@@ -0,0 +1,112 @@
# Langkah Belajar Docker
File Docker sudah disiapkan, tetapi belum dijalankan.
## File yang Dibuat
```text
Dockerfile
docker-compose.yml
.env.example
.dockerignore
docker/mysql-init/99-grants.sql
```
## Fungsi Setiap File
`Dockerfile`
Membuat image aplikasi PHP Apache. File ini memasang extension PHP yang dibutuhkan project, seperti `pdo_mysql`, `mysqli`, dan `curl`.
`docker-compose.yml`
Mengatur dua service:
```text
app = Apache + PHP + semua file project
db = MySQL 8.0
```
`.env.example`
Template konfigurasi port, username, password, dan nama database. File ini boleh masuk Git karena tidak berisi password production.
`.dockerignore`
Mengatur file/folder yang tidak perlu ikut masuk image Docker, seperti `.git`, `.env`, upload, log, `vendor`, dan `node_modules`.
`docker/mysql-init/99-grants.sql`
Memberi akses user `webgis_user` ke dua database:
```text
webgis_spbu
webgis_miskin
```
## Urutan Menjalankan Nanti
Jangan jalankan dulu kalau masih belajar struktur. Saat sudah siap, urutannya:
1. Salin file environment:
```bash
copy .env.example .env
```
2. Buka `.env`, lalu pelajari nilai berikut:
```text
APP_PORT=8080
DB_PORT=3307
DB_USERNAME=webgis_user
DB_PASSWORD=webgis_password
MYSQL_ROOT_PASSWORD=root_password
```
3. Build dan jalankan container:
```bash
docker compose up -d --build
```
4. Buka aplikasi:
```text
http://localhost:8080
```
5. Cek container:
```bash
docker compose ps
```
6. Lihat log jika ada error:
```bash
docker compose logs -f
```
7. Matikan container:
```bash
docker compose down
```
8. Reset database Docker dari awal:
```bash
docker compose down -v
docker compose up -d --build
```
## Catatan Penting
Kalau kamu mengubah `DB_USERNAME` di `.env`, sesuaikan juga isi:
```text
docker/mysql-init/99-grants.sql
```
Untuk production, ganti semua password default di `.env`.
+16
View File
@@ -0,0 +1,16 @@
FROM php:8.2-apache
RUN apt-get update \
&& apt-get install -y --no-install-recommends libcurl4-openssl-dev \
&& docker-php-ext-install pdo_mysql mysqli curl \
&& a2enmod rewrite \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /var/www/html
COPY . /var/www/html/
RUN mkdir -p /var/www/html/Leaflet2/uploads/bukti \
&& chown -R www-data:www-data /var/www/html/Leaflet2/uploads
EXPOSE 80
+72
View File
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json');
require_once __DIR__ . '/db.php';
$input = json_decode(file_get_contents('php://input'), true);
if (!is_array($input)) {
http_response_code(400);
echo json_encode([
'success' => false,
'message' => 'Format request tidak valid.',
]);
exit;
}
$nama = trim((string) ($input['nama'] ?? ''));
$no = trim((string) ($input['no'] ?? ''));
$status24jam = trim((string) ($input['status_24jam'] ?? ''));
$latitude = (float) ($input['latitude'] ?? 0);
$longitude = (float) ($input['longitude'] ?? 0);
if ($nama === '' || $no === '' || $status24jam === '') {
http_response_code(422);
echo json_encode([
'success' => false,
'message' => 'Nama, no, dan status wajib diisi.',
]);
exit;
}
if (!in_array($status24jam, ['24jam', 'tidak'], true)) {
http_response_code(422);
echo json_encode([
'success' => false,
'message' => 'Status harus 24jam atau tidak.',
]);
exit;
}
$stmt = $connection->prepare('INSERT INTO spbu_points (nama, no, status_24jam, latitude, longitude) VALUES (?, ?, ?, ?, ?)');
if (!$stmt) {
http_response_code(500);
echo json_encode([
'success' => false,
'message' => 'Gagal menyiapkan query: ' . $connection->error,
]);
exit;
}
$stmt->bind_param('sssdd', $nama, $no, $status24jam, $latitude, $longitude);
if (!$stmt->execute()) {
http_response_code(500);
echo json_encode([
'success' => false,
'message' => 'Gagal menyimpan data: ' . $stmt->error,
]);
$stmt->close();
exit;
}
$stmt->close();
echo json_encode([
'success' => true,
'message' => 'Data berhasil disimpan.',
'id' => $connection->insert_id,
]);
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
mysqli_report(MYSQLI_REPORT_OFF);
$host = getenv('SPBU_DB_HOST') ?: getenv('DB_HOST') ?: '127.0.0.1';
$username = getenv('SPBU_DB_USERNAME') ?: getenv('DB_USERNAME') ?: 'root';
$password = getenv('SPBU_DB_PASSWORD') ?: getenv('DB_PASSWORD') ?: '';
$database = getenv('SPBU_DB_DATABASE') ?: 'webgis_spbu';
try {
$connection = new mysqli($host, $username, $password, $database);
} catch (Throwable $exception) {
http_response_code(500);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'message' => 'Koneksi database gagal. Pastikan MySQL XAMPP sedang berjalan.',
'detail' => $exception->getMessage(),
]);
exit;
}
if (!$connection || $connection->connect_errno) {
http_response_code(500);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'message' => 'Koneksi database gagal. Pastikan MySQL XAMPP sedang berjalan.',
]);
exit;
}
$connection->set_charset('utf8mb4');
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
$host = getenv('SPBU_DB_HOST') ?: getenv('DB_HOST') ?: '127.0.0.1';
$database = getenv('SPBU_DB_DATABASE') ?: 'webgis_spbu';
$username = getenv('SPBU_DB_USERNAME') ?: getenv('DB_USERNAME') ?: 'root';
$password = getenv('SPBU_DB_PASSWORD') ?: getenv('DB_PASSWORD') ?: '';
$charset = getenv('DB_CHARSET') ?: 'utf8mb4';
set_exception_handler(static function (Throwable $exception): void {
if (!headers_sent()) {
http_response_code(500);
header('Content-Type: application/json');
}
echo json_encode([
'success' => false,
'message' => 'Terjadi kesalahan server.',
'detail' => $exception->getMessage(),
]);
exit;
});
$dsn = "mysql:host={$host};dbname={$database};charset={$charset}";
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
$pdo = new PDO($dsn, $username, $password, $options);
} catch (PDOException $exception) {
http_response_code(500);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'message' => 'Koneksi database gagal: ' . $exception->getMessage(),
]);
exit;
}
+68
View File
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json');
require_once __DIR__ . '/db.php';
$input = json_decode(file_get_contents('php://input'), true);
if (!is_array($input)) {
http_response_code(400);
echo json_encode([
'success' => false,
'message' => 'Format request tidak valid.',
]);
exit;
}
$id = (int) ($input['id'] ?? 0);
if ($id <= 0) {
http_response_code(422);
echo json_encode([
'success' => false,
'message' => 'ID titik tidak valid.',
]);
exit;
}
$stmt = $connection->prepare('DELETE FROM spbu_points WHERE id = ?');
if (!$stmt) {
http_response_code(500);
echo json_encode([
'success' => false,
'message' => 'Gagal menyiapkan query: ' . $connection->error,
]);
exit;
}
$stmt->bind_param('i', $id);
if (!$stmt->execute()) {
http_response_code(500);
echo json_encode([
'success' => false,
'message' => 'Gagal menghapus data: ' . $stmt->error,
]);
$stmt->close();
exit;
}
if ($stmt->affected_rows < 1) {
http_response_code(404);
echo json_encode([
'success' => false,
'message' => 'Data dengan ID tersebut tidak ditemukan.',
]);
$stmt->close();
exit;
}
$stmt->close();
echo json_encode([
'success' => true,
'message' => 'Data berhasil dihapus.',
]);
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db_pdo.php';
require_once __DIR__ . '/spatial_helpers.php';
$input = get_json_input();
$namaPemilik = trim((string) ($input['nama_pemilik'] ?? ''));
$statusKepemilikan = trim((string) ($input['status_kepemilikan'] ?? ''));
$luasM2 = (float) ($input['luas_m2'] ?? 0);
$wkt = sanitize_wkt((string) ($input['wkt'] ?? ''), 'POLYGON');
$allowedStatus = ['SHM', 'HGB', 'HGU', 'HP'];
if ($namaPemilik === '' || !in_array($statusKepemilikan, $allowedStatus, true)) {
json_response(422, [
'success' => false,
'message' => 'Nama pemilik wajib diisi dan status harus SHM/HGB/HGU/HP.',
]);
}
if ($luasM2 <= 0) {
json_response(422, [
'success' => false,
'message' => 'Luas tanah harus lebih dari 0 m2.',
]);
}
$sql = 'INSERT INTO parsil_tanah (nama_pemilik, status_kepemilikan, luas_m2, geom) VALUES (:nama_pemilik, :status_kepemilikan, :luas_m2, ST_GeomFromText(:wkt))';
$stmt = $pdo->prepare($sql);
$stmt->execute([
':nama_pemilik' => $namaPemilik,
':status_kepemilikan' => $statusKepemilikan,
':luas_m2' => $luasM2,
':wkt' => $wkt,
]);
json_response(201, [
'success' => true,
'message' => 'Data parsil tanah berhasil disimpan.',
'id' => (int) $pdo->lastInsertId(),
]);
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db_pdo.php';
require_once __DIR__ . '/spatial_helpers.php';
$input = get_json_input();
$id = (int) ($input['id'] ?? 0);
if ($id <= 0) {
json_response(422, [
'success' => false,
'message' => 'ID parsil tidak valid.',
]);
}
$stmt = $pdo->prepare('DELETE FROM parsil_tanah WHERE id = :id');
$stmt->execute([':id' => $id]);
if ($stmt->rowCount() < 1) {
json_response(404, [
'success' => false,
'message' => 'Data parsil tidak ditemukan.',
]);
}
json_response(200, [
'success' => true,
'message' => 'Data parsil tanah berhasil dihapus.',
]);
+22
View File
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db_pdo.php';
require_once __DIR__ . '/spatial_helpers.php';
$sql = 'SELECT id, nama_pemilik, status_kepemilikan, luas_m2, ST_AsGeoJSON(geom) AS geometry_geojson, created_at, updated_at FROM parsil_tanah ORDER BY id DESC';
$stmt = $pdo->query($sql);
$rows = $stmt->fetchAll();
$data = [];
foreach ($rows as $row) {
$row['id'] = (int) $row['id'];
$row['luas_m2'] = (float) $row['luas_m2'];
$row['geometry_geojson'] = json_decode((string) $row['geometry_geojson'], true);
$data[] = $row;
}
json_response(200, [
'success' => true,
'data' => $data,
]);
+62
View File
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db_pdo.php';
require_once __DIR__ . '/spatial_helpers.php';
$input = get_json_input();
$id = (int) ($input['id'] ?? 0);
$namaPemilik = trim((string) ($input['nama_pemilik'] ?? ''));
$statusKepemilikan = trim((string) ($input['status_kepemilikan'] ?? ''));
$luasM2 = (float) ($input['luas_m2'] ?? 0);
$wkt = sanitize_wkt((string) ($input['wkt'] ?? ''), 'POLYGON');
$allowedStatus = ['SHM', 'HGB', 'HGU', 'HP'];
if ($id <= 0) {
json_response(422, [
'success' => false,
'message' => 'ID parsil tidak valid.',
]);
}
if ($namaPemilik === '' || !in_array($statusKepemilikan, $allowedStatus, true)) {
json_response(422, [
'success' => false,
'message' => 'Nama pemilik wajib diisi dan status harus SHM/HGB/HGU/HP.',
]);
}
if ($luasM2 <= 0) {
json_response(422, [
'success' => false,
'message' => 'Luas tanah harus lebih dari 0 m2.',
]);
}
$sql = 'UPDATE parsil_tanah SET nama_pemilik = :nama_pemilik, status_kepemilikan = :status_kepemilikan, luas_m2 = :luas_m2, geom = ST_GeomFromText(:wkt), updated_at = CURRENT_TIMESTAMP WHERE id = :id';
$stmt = $pdo->prepare($sql);
$stmt->execute([
':id' => $id,
':nama_pemilik' => $namaPemilik,
':status_kepemilikan' => $statusKepemilikan,
':luas_m2' => $luasM2,
':wkt' => $wkt,
]);
if ($stmt->rowCount() < 1) {
$checkStmt = $pdo->prepare('SELECT id FROM parsil_tanah WHERE id = :id LIMIT 1');
$checkStmt->execute([':id' => $id]);
if (!$checkStmt->fetch()) {
json_response(404, [
'success' => false,
'message' => 'Data parsil tidak ditemukan.',
]);
}
}
json_response(200, [
'success' => true,
'message' => 'Data parsil tanah berhasil diperbarui.',
]);
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json');
require_once __DIR__ . '/db.php';
$sql = 'SELECT id, nama, no, status_24jam, latitude, longitude, created_at FROM spbu_points ORDER BY id DESC';
$result = $connection->query($sql);
if (!$result) {
http_response_code(500);
echo json_encode([
'success' => false,
'message' => 'Gagal mengambil data: ' . $connection->error,
]);
exit;
}
$points = [];
while ($row = $result->fetch_assoc()) {
$points[] = $row;
}
echo json_encode([
'success' => true,
'data' => $points,
]);
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db_pdo.php';
require_once __DIR__ . '/spatial_helpers.php';
$input = get_json_input();
$namaJalan = trim((string) ($input['nama_jalan'] ?? ''));
$status = trim((string) ($input['status'] ?? ''));
$panjangMeter = (float) ($input['panjang_meter'] ?? 0);
$wkt = sanitize_wkt((string) ($input['wkt'] ?? ''), 'LINESTRING');
$allowedStatus = ['nasional', 'provinsi', 'kabupaten'];
if ($namaJalan === '' || !in_array($status, $allowedStatus, true)) {
json_response(422, [
'success' => false,
'message' => 'Nama jalan wajib diisi dan status harus nasional/provinsi/kabupaten.',
]);
}
if ($panjangMeter <= 0) {
json_response(422, [
'success' => false,
'message' => 'Panjang jalan harus lebih dari 0 meter.',
]);
}
$sql = 'INSERT INTO jalan (nama_jalan, status, panjang_meter, geom) VALUES (:nama_jalan, :status, :panjang_meter, ST_GeomFromText(:wkt))';
$stmt = $pdo->prepare($sql);
$stmt->execute([
':nama_jalan' => $namaJalan,
':status' => $status,
':panjang_meter' => $panjangMeter,
':wkt' => $wkt,
]);
json_response(201, [
'success' => true,
'message' => 'Data jalan berhasil disimpan.',
'id' => (int) $pdo->lastInsertId(),
]);
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db_pdo.php';
require_once __DIR__ . '/spatial_helpers.php';
$input = get_json_input();
$id = (int) ($input['id'] ?? 0);
if ($id <= 0) {
json_response(422, [
'success' => false,
'message' => 'ID jalan tidak valid.',
]);
}
$stmt = $pdo->prepare('DELETE FROM jalan WHERE id = :id');
$stmt->execute([':id' => $id]);
if ($stmt->rowCount() < 1) {
json_response(404, [
'success' => false,
'message' => 'Data jalan tidak ditemukan.',
]);
}
json_response(200, [
'success' => true,
'message' => 'Data jalan berhasil dihapus.',
]);
+22
View File
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db_pdo.php';
require_once __DIR__ . '/spatial_helpers.php';
$sql = 'SELECT id, nama_jalan, status, panjang_meter, ST_AsGeoJSON(geom) AS geometry_geojson, created_at, updated_at FROM jalan ORDER BY id DESC';
$stmt = $pdo->query($sql);
$rows = $stmt->fetchAll();
$data = [];
foreach ($rows as $row) {
$row['id'] = (int) $row['id'];
$row['panjang_meter'] = (float) $row['panjang_meter'];
$row['geometry_geojson'] = json_decode((string) $row['geometry_geojson'], true);
$data[] = $row;
}
json_response(200, [
'success' => true,
'data' => $data,
]);
+62
View File
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db_pdo.php';
require_once __DIR__ . '/spatial_helpers.php';
$input = get_json_input();
$id = (int) ($input['id'] ?? 0);
$namaJalan = trim((string) ($input['nama_jalan'] ?? ''));
$status = trim((string) ($input['status'] ?? ''));
$panjangMeter = (float) ($input['panjang_meter'] ?? 0);
$wkt = sanitize_wkt((string) ($input['wkt'] ?? ''), 'LINESTRING');
$allowedStatus = ['nasional', 'provinsi', 'kabupaten'];
if ($id <= 0) {
json_response(422, [
'success' => false,
'message' => 'ID jalan tidak valid.',
]);
}
if ($namaJalan === '' || !in_array($status, $allowedStatus, true)) {
json_response(422, [
'success' => false,
'message' => 'Nama jalan wajib diisi dan status harus nasional/provinsi/kabupaten.',
]);
}
if ($panjangMeter <= 0) {
json_response(422, [
'success' => false,
'message' => 'Panjang jalan harus lebih dari 0 meter.',
]);
}
$sql = 'UPDATE jalan SET nama_jalan = :nama_jalan, status = :status, panjang_meter = :panjang_meter, geom = ST_GeomFromText(:wkt), updated_at = CURRENT_TIMESTAMP WHERE id = :id';
$stmt = $pdo->prepare($sql);
$stmt->execute([
':id' => $id,
':nama_jalan' => $namaJalan,
':status' => $status,
':panjang_meter' => $panjangMeter,
':wkt' => $wkt,
]);
if ($stmt->rowCount() < 1) {
$checkStmt = $pdo->prepare('SELECT id FROM jalan WHERE id = :id LIMIT 1');
$checkStmt->execute([':id' => $id]);
if (!$checkStmt->fetch()) {
json_response(404, [
'success' => false,
'message' => 'Data jalan tidak ditemukan.',
]);
}
}
json_response(200, [
'success' => true,
'message' => 'Data jalan berhasil diperbarui.',
]);
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
function json_response(int $statusCode, array $payload): void
{
http_response_code($statusCode);
header('Content-Type: application/json');
echo json_encode($payload);
exit;
}
function get_json_input(): array
{
$input = json_decode(file_get_contents('php://input'), true);
if (!is_array($input)) {
json_response(400, [
'success' => false,
'message' => 'Format request tidak valid.',
]);
}
return $input;
}
function sanitize_wkt(string $wkt, string $expectedType): string
{
$trimmed = trim($wkt);
if ($trimmed === '') {
json_response(422, [
'success' => false,
'message' => 'Geometri WKT wajib diisi.',
]);
}
$normalized = strtoupper($trimmed);
if (strpos($normalized, $expectedType . '(') !== 0 && strpos($normalized, $expectedType . ' (') !== 0) {
json_response(422, [
'success' => false,
'message' => 'Tipe geometri tidak sesuai. Diharapkan ' . $expectedType . '.',
]);
}
return $trimmed;
}
+110
View File
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json');
require_once __DIR__ . '/db.php';
$input = json_decode(file_get_contents('php://input'), true);
if (!is_array($input)) {
http_response_code(400);
echo json_encode([
'success' => false,
'message' => 'Format request tidak valid.',
]);
exit;
}
$id = (int) ($input['id'] ?? 0);
$latitude = (float) ($input['latitude'] ?? 0);
$longitude = (float) ($input['longitude'] ?? 0);
if ($id <= 0) {
http_response_code(422);
echo json_encode([
'success' => false,
'message' => 'ID titik tidak valid.',
]);
exit;
}
if (!is_finite($latitude) || !is_finite($longitude)) {
http_response_code(422);
echo json_encode([
'success' => false,
'message' => 'Koordinat latitude atau longitude tidak valid.',
]);
exit;
}
$stmt = $connection->prepare('UPDATE spbu_points SET latitude = ?, longitude = ? WHERE id = ?');
if (!$stmt) {
http_response_code(500);
echo json_encode([
'success' => false,
'message' => 'Gagal menyiapkan query update: ' . $connection->error,
]);
exit;
}
$stmt->bind_param('ddi', $latitude, $longitude, $id);
if (!$stmt->execute()) {
http_response_code(500);
echo json_encode([
'success' => false,
'message' => 'Gagal memperbarui posisi: ' . $stmt->error,
]);
$stmt->close();
exit;
}
if ($stmt->affected_rows < 1) {
$stmt->close();
$checkStmt = $connection->prepare('SELECT id FROM spbu_points WHERE id = ? LIMIT 1');
if (!$checkStmt) {
http_response_code(500);
echo json_encode([
'success' => false,
'message' => 'Gagal memverifikasi data titik: ' . $connection->error,
]);
exit;
}
$checkStmt->bind_param('i', $id);
if (!$checkStmt->execute()) {
http_response_code(500);
echo json_encode([
'success' => false,
'message' => 'Gagal memverifikasi data titik: ' . $checkStmt->error,
]);
$checkStmt->close();
exit;
}
$checkResult = $checkStmt->get_result();
if ($checkResult->num_rows < 1) {
http_response_code(404);
echo json_encode([
'success' => false,
'message' => 'Data dengan ID tersebut tidak ditemukan.',
]);
$checkStmt->close();
exit;
}
$checkStmt->close();
}
$stmt->close();
echo json_encode([
'success' => true,
'message' => 'Posisi titik berhasil diperbarui.',
]);
File diff suppressed because one or more lines are too long
+155
View File
@@ -0,0 +1,155 @@
(function () {
'use strict';
const DEFAULT_SOURCE = 'assets/data/Kecamatan.json';
const DEFAULT_ANCHOR = {
lat: -0.06054796552220232,
lng: 109.34490231930592
};
const DEFAULT_GEO_BOUNDS = {
south: -0.0981948,
west: 109.2741676,
north: 0.0381168,
east: 109.3853475
};
const DEFAULT_CALIBRATION = {
scaleX: 1,
scaleY: 1,
offsetLat: 0,
offsetLng: 0
};
function walkCoordinates(coordinates, callback) {
if (!Array.isArray(coordinates)) {
return;
}
if (coordinates.length >= 2 && typeof coordinates[0] === 'number' && typeof coordinates[1] === 'number') {
callback(coordinates[0], coordinates[1]);
return;
}
coordinates.forEach((child) => walkCoordinates(child, callback));
}
function getCoordinateBounds(data) {
const bounds = {
minX: Infinity,
minY: Infinity,
maxX: -Infinity,
maxY: -Infinity
};
function addGeometry(geometry) {
if (!geometry) {
return;
}
if (geometry.type === 'GeometryCollection') {
(geometry.geometries || []).forEach(addGeometry);
return;
}
walkCoordinates(geometry.coordinates, (x, y) => {
bounds.minX = Math.min(bounds.minX, x);
bounds.minY = Math.min(bounds.minY, y);
bounds.maxX = Math.max(bounds.maxX, x);
bounds.maxY = Math.max(bounds.maxY, y);
});
}
if (data.type === 'FeatureCollection') {
(data.features || []).forEach((feature) => addGeometry(feature.geometry));
} else if (data.type === 'Feature') {
addGeometry(data.geometry);
} else {
addGeometry(data);
}
if (!Number.isFinite(bounds.minX) || !Number.isFinite(bounds.minY)) {
return null;
}
return bounds;
}
function createBoundingBoxTransform(sourceBounds, targetBounds, calibration) {
const sourceWidth = sourceBounds.maxX - sourceBounds.minX;
const sourceHeight = sourceBounds.maxY - sourceBounds.minY;
const targetCenterLng = (targetBounds.west + targetBounds.east) / 2 + calibration.offsetLng;
const targetCenterLat = (targetBounds.south + targetBounds.north) / 2 + calibration.offsetLat;
const targetWidth = (targetBounds.east - targetBounds.west) * calibration.scaleX;
const targetHeight = (targetBounds.north - targetBounds.south) * calibration.scaleY;
return function coordsToLatLng(coordinates) {
const xRatio = ((coordinates[0] - sourceBounds.minX) / sourceWidth) - 0.5;
const yRatio = ((coordinates[1] - sourceBounds.minY) / sourceHeight) - 0.5;
const lng = targetCenterLng + (xRatio * targetWidth);
const lat = targetCenterLat + (yRatio * targetHeight);
return L.latLng(lat, lng);
};
}
window.addKecamatanLayer = async function addKecamatanLayer(map, options) {
const settings = Object.assign({
source: DEFAULT_SOURCE,
anchor: DEFAULT_ANCHOR,
targetBounds: DEFAULT_GEO_BOUNDS,
calibration: DEFAULT_CALIBRATION,
fitBounds: false
}, options || {});
if (!map || typeof L === 'undefined') {
return null;
}
if (!map.getPane('kecamatanPane')) {
map.createPane('kecamatanPane');
map.getPane('kecamatanPane').style.zIndex = 350;
}
const response = await fetch(settings.source, {
headers: { 'Accept': 'application/json' }
});
if (!response.ok) {
throw new Error('Gagal memuat data batas kecamatan.');
}
const data = await response.json();
const coordinateBounds = getCoordinateBounds(data);
if (!coordinateBounds) {
throw new Error('Data batas kecamatan tidak memiliki koordinat valid.');
}
const layer = L.geoJSON(data, {
coordsToLatLng: createBoundingBoxTransform(coordinateBounds, settings.targetBounds, settings.calibration),
pane: 'kecamatanPane',
style: {
color: '#0f766e',
weight: 2,
opacity: 0.9,
fillColor: '#14b8a6',
fillOpacity: 0.08,
dashArray: '6 4'
},
onEachFeature: function (feature, featureLayer) {
const name = feature && feature.properties && (
feature.properties.nama ||
feature.properties.NAMA ||
feature.properties.KECAMATAN ||
feature.properties.kecamatan
);
featureLayer.bindPopup(name ? `Kecamatan: ${name}` : 'Batas Kecamatan');
}
}).addTo(map);
if (settings.fitBounds && layer.getBounds().isValid()) {
map.fitBounds(layer.getBounds().pad(0.12), { maxZoom: 15 });
}
return layer;
};
}());
File diff suppressed because it is too large Load Diff
+622
View File
@@ -0,0 +1,622 @@
function setStatus(element, message, type) {
element.textContent = message;
element.classList.remove('error', 'success');
if (type) {
element.classList.add(type);
}
}
function roadColorByStatus(status) {
if (status === 'nasional') {
return '#dc2626';
}
if (status === 'provinsi') {
return '#2563eb';
}
return '#16a34a';
}
function parcelColorByStatus(status) {
if (status === 'SHM') {
return '#ea580c';
}
if (status === 'HGB') {
return '#0ea5e9';
}
if (status === 'HGU') {
return '#22c55e';
}
return '#a855f7';
}
function calculatePolylineLengthMeters(latlngs) {
let total = 0;
for (let index = 1; index < latlngs.length; index += 1) {
total += latlngs[index - 1].distanceTo(latlngs[index]);
}
return total;
}
function calculatePolygonAreaMeters(latlngs) {
if (window.L && L.GeometryUtil && typeof L.GeometryUtil.geodesicArea === 'function') {
return L.GeometryUtil.geodesicArea(latlngs);
}
const earthRadius = 6378137;
const toRad = (degree) => degree * Math.PI / 180;
let area = 0;
for (let i = 0; i < latlngs.length; i += 1) {
const p1 = latlngs[i];
const p2 = latlngs[(i + 1) % latlngs.length];
area += toRad(p2.lng - p1.lng) * (2 + Math.sin(toRad(p1.lat)) + Math.sin(toRad(p2.lat)));
}
return Math.abs(area * earthRadius * earthRadius / 2);
}
function polylineToWkt(latlngs) {
const coordinates = latlngs.map((point) => `${point.lng} ${point.lat}`).join(', ');
return `LINESTRING(${coordinates})`;
}
function polygonToWkt(latlngs) {
const ring = latlngs.map((point) => `${point.lng} ${point.lat}`);
if (ring[0] !== ring[ring.length - 1]) {
ring.push(ring[0]);
}
return `POLYGON((${ring.join(', ')}))`;
}
async function parseJsonResponse(response) {
const text = await response.text();
try {
return JSON.parse(text);
} catch (error) {
throw new Error('Response server bukan JSON valid.');
}
}
document.addEventListener('DOMContentLoaded', async () => {
const map = L.map('map').setView([-0.0605, 109.3449], 13);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; OpenStreetMap contributors'
}).addTo(map);
if (typeof window.addKecamatanLayer === 'function') {
window.addKecamatanLayer(map, { fitBounds: true }).catch((error) => {
console.warn('Data kecamatan belum termuat:', error);
});
}
const editableLayer = new L.FeatureGroup();
map.addLayer(editableLayer);
const modalBackdrop = document.getElementById('modal-backdrop');
const modalTitle = document.getElementById('modal-title');
const featureForm = document.getElementById('feature-form');
const roadFields = document.getElementById('road-fields');
const parcelFields = document.getElementById('parcel-fields');
const formStatus = document.getElementById('form-status');
const cancelButton = document.getElementById('btn-cancel');
let pendingLayer = null;
let pendingType = null;
let pendingMode = 'create';
function closeModal(shouldRemovePendingLayer) {
modalBackdrop.classList.remove('open');
modalBackdrop.setAttribute('aria-hidden', 'true');
setStatus(formStatus, '', '');
if (shouldRemovePendingLayer && pendingMode === 'create' && pendingLayer) {
editableLayer.removeLayer(pendingLayer);
}
pendingLayer = null;
pendingType = null;
pendingMode = 'create';
featureForm.reset();
}
function openRoadModal(layer, mode, initialData) {
pendingLayer = layer;
pendingType = 'road';
pendingMode = mode;
modalTitle.textContent = mode === 'create' ? 'Tambah Data Jalan' : 'Update Data Jalan';
roadFields.hidden = false;
parcelFields.hidden = true;
const latlngs = layer.getLatLngs();
const lengthMeters = calculatePolylineLengthMeters(latlngs);
document.getElementById('nama_jalan').value = initialData && initialData.nama_jalan ? initialData.nama_jalan : '';
document.getElementById('status_jalan').value = initialData && initialData.status ? initialData.status : 'nasional';
document.getElementById('panjang_meter').value = lengthMeters.toFixed(2);
modalBackdrop.classList.add('open');
modalBackdrop.setAttribute('aria-hidden', 'false');
}
function openParcelModal(layer, mode, initialData) {
pendingLayer = layer;
pendingType = 'parcel';
pendingMode = mode;
modalTitle.textContent = mode === 'create' ? 'Tambah Data Parsil Tanah' : 'Update Data Parsil Tanah';
roadFields.hidden = true;
parcelFields.hidden = false;
const latlngs = layer.getLatLngs()[0];
const areaM2 = calculatePolygonAreaMeters(latlngs);
document.getElementById('nama_pemilik').value = initialData && initialData.nama_pemilik ? initialData.nama_pemilik : '';
document.getElementById('status_kepemilikan').value = initialData && initialData.status_kepemilikan ? initialData.status_kepemilikan : 'SHM';
document.getElementById('luas_m2').value = areaM2.toFixed(2);
modalBackdrop.classList.add('open');
modalBackdrop.setAttribute('aria-hidden', 'false');
}
function attachRoadPopup(layer) {
const props = layer.featureProps;
layer.setStyle({
color: roadColorByStatus(props.status),
weight: 5
});
layer.bindPopup(`
<b>Jalan:</b> ${props.nama_jalan}<br>
<b>Status:</b> ${props.status}<br>
<b>Panjang:</b> ${Number(props.panjang_meter).toFixed(2)} m
<div class="popup-actions">
<button type="button" data-action="edit">Edit</button>
<button type="button" class="btn-danger" data-action="delete">Hapus</button>
</div>
<div class="status" data-role="popup-status"></div>
`);
layer.on('popupopen', (event) => {
const popupElement = event.popup.getElement();
const editButton = popupElement.querySelector('[data-action="edit"]');
const deleteButton = popupElement.querySelector('[data-action="delete"]');
const popupStatus = popupElement.querySelector('[data-role="popup-status"]');
editButton.addEventListener('click', () => {
map.closePopup();
openRoadModal(layer, 'update', layer.featureProps);
});
deleteButton.addEventListener('click', async () => {
if (!window.confirm('Hapus data jalan ini?')) {
return;
}
setStatus(popupStatus, 'Menghapus data...', '');
deleteButton.disabled = true;
try {
const response = await fetch('api/road_delete.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({ id: layer.featureId })
});
const result = await parseJsonResponse(response);
if (!response.ok || !result.success) {
throw new Error(result.message || 'Gagal menghapus data jalan.');
}
editableLayer.removeLayer(layer);
map.closePopup();
} catch (error) {
setStatus(popupStatus, error.message || 'Gagal menghapus data.', 'error');
deleteButton.disabled = false;
}
});
});
}
function attachParcelPopup(layer) {
const props = layer.featureProps;
layer.setStyle({
color: parcelColorByStatus(props.status_kepemilikan),
fillColor: parcelColorByStatus(props.status_kepemilikan),
fillOpacity: 0.35,
weight: 3
});
layer.bindPopup(`
<b>Pemilik:</b> ${props.nama_pemilik}<br>
<b>Status:</b> ${props.status_kepemilikan}<br>
<b>Luas:</b> ${Number(props.luas_m2).toFixed(2)}
<div class="popup-actions">
<button type="button" data-action="edit">Edit</button>
<button type="button" class="btn-danger" data-action="delete">Hapus</button>
</div>
<div class="status" data-role="popup-status"></div>
`);
layer.on('popupopen', (event) => {
const popupElement = event.popup.getElement();
const editButton = popupElement.querySelector('[data-action="edit"]');
const deleteButton = popupElement.querySelector('[data-action="delete"]');
const popupStatus = popupElement.querySelector('[data-role="popup-status"]');
editButton.addEventListener('click', () => {
map.closePopup();
openParcelModal(layer, 'update', layer.featureProps);
});
deleteButton.addEventListener('click', async () => {
if (!window.confirm('Hapus data parsil ini?')) {
return;
}
setStatus(popupStatus, 'Menghapus data...', '');
deleteButton.disabled = true;
try {
const response = await fetch('api/parcel_delete.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({ id: layer.featureId })
});
const result = await parseJsonResponse(response);
if (!response.ok || !result.success) {
throw new Error(result.message || 'Gagal menghapus data parsil.');
}
editableLayer.removeLayer(layer);
map.closePopup();
} catch (error) {
setStatus(popupStatus, error.message || 'Gagal menghapus data.', 'error');
deleteButton.disabled = false;
}
});
});
}
async function loadRoads() {
const response = await fetch('api/road_read.php', {
headers: {
'Accept': 'application/json'
}
});
const result = await parseJsonResponse(response);
if (!response.ok || !result.success) {
throw new Error(result.message || 'Gagal memuat data jalan.');
}
result.data.forEach((item) => {
if (!item.geometry_geojson) {
return;
}
const layer = L.geoJSON(item.geometry_geojson).getLayers()[0];
if (!layer) {
return;
}
layer.featureType = 'road';
layer.featureId = item.id;
layer.featureProps = {
nama_jalan: item.nama_jalan,
status: item.status,
panjang_meter: item.panjang_meter
};
attachRoadPopup(layer);
editableLayer.addLayer(layer);
});
}
async function loadParcels() {
const response = await fetch('api/parcel_read.php', {
headers: {
'Accept': 'application/json'
}
});
const result = await parseJsonResponse(response);
if (!response.ok || !result.success) {
throw new Error(result.message || 'Gagal memuat data parsil.');
}
result.data.forEach((item) => {
if (!item.geometry_geojson) {
return;
}
const layer = L.geoJSON(item.geometry_geojson).getLayers()[0];
if (!layer) {
return;
}
layer.featureType = 'parcel';
layer.featureId = item.id;
layer.featureProps = {
nama_pemilik: item.nama_pemilik,
status_kepemilikan: item.status_kepemilikan,
luas_m2: item.luas_m2
};
attachParcelPopup(layer);
editableLayer.addLayer(layer);
});
}
const drawControl = new L.Control.Draw({
position: 'topleft',
draw: {
polyline: {
shapeOptions: {
color: '#dc2626',
weight: 5
}
},
polygon: {
allowIntersection: false,
showArea: true,
shapeOptions: {
color: '#ea580c',
fillOpacity: 0.35,
weight: 3
}
},
rectangle: false,
circle: false,
marker: false,
circlemarker: false
},
edit: {
featureGroup: editableLayer,
remove: true
}
});
map.addControl(drawControl);
map.on(L.Draw.Event.CREATED, (event) => {
const layer = event.layer;
const geometryType = event.layerType;
editableLayer.addLayer(layer);
if (geometryType === 'polyline') {
openRoadModal(layer, 'create', null);
return;
}
if (geometryType === 'polygon') {
openParcelModal(layer, 'create', null);
return;
}
editableLayer.removeLayer(layer);
});
map.on(L.Draw.Event.EDITED, async (event) => {
const layers = event.layers.getLayers();
for (const layer of layers) {
try {
if (layer.featureType === 'road') {
const lengthMeters = calculatePolylineLengthMeters(layer.getLatLngs());
const payload = {
id: layer.featureId,
nama_jalan: layer.featureProps.nama_jalan,
status: layer.featureProps.status,
panjang_meter: Number(lengthMeters.toFixed(2)),
wkt: polylineToWkt(layer.getLatLngs())
};
const response = await fetch('api/road_update.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(payload)
});
const result = await parseJsonResponse(response);
if (!response.ok || !result.success) {
throw new Error(result.message || 'Gagal update geometri jalan.');
}
layer.featureProps.panjang_meter = payload.panjang_meter;
attachRoadPopup(layer);
}
if (layer.featureType === 'parcel') {
const areaM2 = calculatePolygonAreaMeters(layer.getLatLngs()[0]);
const payload = {
id: layer.featureId,
nama_pemilik: layer.featureProps.nama_pemilik,
status_kepemilikan: layer.featureProps.status_kepemilikan,
luas_m2: Number(areaM2.toFixed(2)),
wkt: polygonToWkt(layer.getLatLngs()[0])
};
const response = await fetch('api/parcel_update.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(payload)
});
const result = await parseJsonResponse(response);
if (!response.ok || !result.success) {
throw new Error(result.message || 'Gagal update geometri parsil.');
}
layer.featureProps.luas_m2 = payload.luas_m2;
attachParcelPopup(layer);
}
} catch (error) {
window.alert(error.message || 'Gagal menyimpan update geometri.');
}
}
});
map.on(L.Draw.Event.DELETED, async (event) => {
const layers = event.layers.getLayers();
for (const layer of layers) {
if (!layer.featureType || !layer.featureId) {
continue;
}
try {
const endpoint = layer.featureType === 'road' ? 'api/road_delete.php' : 'api/parcel_delete.php';
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({ id: layer.featureId })
});
const result = await parseJsonResponse(response);
if (!response.ok || !result.success) {
throw new Error(result.message || 'Gagal menghapus data.');
}
} catch (error) {
window.alert(error.message || 'Gagal sinkron delete ke database.');
}
}
});
featureForm.addEventListener('submit', async (event) => {
event.preventDefault();
if (!pendingLayer || !pendingType) {
return;
}
setStatus(formStatus, 'Menyimpan data...', '');
try {
if (pendingType === 'road') {
const namaJalan = document.getElementById('nama_jalan').value.trim();
const statusJalan = document.getElementById('status_jalan').value;
const panjangMeter = Number(document.getElementById('panjang_meter').value);
const wkt = polylineToWkt(pendingLayer.getLatLngs());
const endpoint = pendingMode === 'create' ? 'api/road_create.php' : 'api/road_update.php';
const payload = {
nama_jalan: namaJalan,
status: statusJalan,
panjang_meter: panjangMeter,
wkt: wkt
};
if (pendingMode === 'update') {
payload.id = pendingLayer.featureId;
}
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(payload)
});
const result = await parseJsonResponse(response);
if (!response.ok || !result.success) {
throw new Error(result.message || 'Gagal menyimpan data jalan.');
}
pendingLayer.featureType = 'road';
pendingLayer.featureId = pendingMode === 'create' ? result.id : pendingLayer.featureId;
pendingLayer.featureProps = {
nama_jalan: namaJalan,
status: statusJalan,
panjang_meter: panjangMeter
};
attachRoadPopup(pendingLayer);
}
if (pendingType === 'parcel') {
const namaPemilik = document.getElementById('nama_pemilik').value.trim();
const statusKepemilikan = document.getElementById('status_kepemilikan').value;
const luasM2 = Number(document.getElementById('luas_m2').value);
const wkt = polygonToWkt(pendingLayer.getLatLngs()[0]);
const endpoint = pendingMode === 'create' ? 'api/parcel_create.php' : 'api/parcel_update.php';
const payload = {
nama_pemilik: namaPemilik,
status_kepemilikan: statusKepemilikan,
luas_m2: luasM2,
wkt: wkt
};
if (pendingMode === 'update') {
payload.id = pendingLayer.featureId;
}
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(payload)
});
const result = await parseJsonResponse(response);
if (!response.ok || !result.success) {
throw new Error(result.message || 'Gagal menyimpan data parsil.');
}
pendingLayer.featureType = 'parcel';
pendingLayer.featureId = pendingMode === 'create' ? result.id : pendingLayer.featureId;
pendingLayer.featureProps = {
nama_pemilik: namaPemilik,
status_kepemilikan: statusKepemilikan,
luas_m2: luasM2
};
attachParcelPopup(pendingLayer);
}
setStatus(formStatus, 'Data berhasil disimpan.', 'success');
setTimeout(() => closeModal(false), 350);
} catch (error) {
setStatus(formStatus, error.message || 'Gagal menyimpan data.', 'error');
}
});
cancelButton.addEventListener('click', () => closeModal(true));
modalBackdrop.addEventListener('click', (event) => {
if (event.target === modalBackdrop) {
closeModal(true);
}
});
try {
await loadRoads();
await loadParcels();
if (editableLayer.getLayers().length > 0) {
map.fitBounds(editableLayer.getBounds().pad(0.2));
}
} catch (error) {
window.alert(error.message || 'Gagal memuat data spasial.');
}
});
+372
View File
@@ -0,0 +1,372 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WebGIS SPBU</title>
<link rel="stylesheet" href="assets/vendor/leaflet/leaflet.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.css">
<style>
:root {
--bg-top: #f0f9ff;
--bg-bottom: #e2e8f0;
--panel: rgba(255, 255, 255, 0.92);
--line: #cbd5e1;
--text: #0f172a;
--muted: #334155;
--accent: #16a34a;
--accent-dark: #166534;
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
margin: 0;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(180deg, var(--bg-top) 0%, var(--bg-bottom) 100%);
color: var(--text);
overflow: hidden;
}
.app {
height: 100dvh;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.header {
padding: 14px 18px;
border-bottom: 1px solid var(--line);
background: var(--panel);
backdrop-filter: blur(4px);
z-index: 500;
}
.header h1 {
margin: 0 0 6px;
font-size: 20px;
line-height: 1.2;
}
.header p {
margin: 0;
color: var(--muted);
font-size: 14px;
}
.legend {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 8px;
font-size: 12px;
color: #223055;
}
.legend span {
display: inline-flex;
align-items: center;
gap: 6px;
background: #ffffff;
border: 1px solid var(--line);
border-radius: 999px;
padding: 5px 10px;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
}
.map-wrap {
flex: 1;
min-height: 0;
position: relative;
}
#map {
height: 100%;
width: 100%;
background: #dbeafe;
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: #ffffff;
color: var(--text);
}
.leaflet-popup-content {
margin: 12px 14px;
}
.form-popup {
min-width: 260px;
}
.form-popup h3 {
margin: 0 0 12px;
font-size: 16px;
}
.field {
margin-bottom: 10px;
}
.field label {
display: block;
font-size: 12px;
margin-bottom: 5px;
color: var(--muted);
}
.field input,
.field select {
width: 100%;
padding: 9px 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: #ffffff;
color: var(--text);
}
.field input:focus,
.field select:focus {
outline: 2px solid rgba(22, 163, 74, 0.25);
border-color: var(--accent);
}
.actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.actions button {
flex: 1;
padding: 10px 12px;
border: 0;
border-radius: 8px;
cursor: pointer;
font-weight: 700;
}
.btn-save {
background: var(--accent);
color: #f0fdf4;
}
.btn-save:hover {
background: var(--accent-dark);
}
.btn-cancel {
background: #e2e8f0;
color: #1e293b;
}
.btn-delete {
background: #dc2626;
color: #ffffff;
margin-top: 8px;
width: 100%;
}
.btn-delete:hover {
background: #b91c1c;
}
.point-popup .meta {
font-size: 12px;
color: #475569;
margin-top: 8px;
}
.status {
font-size: 12px;
margin-top: 10px;
color: #475569;
}
.status.error {
color: #b91c1c;
}
.status.success {
color: #166534;
}
/* Perkecil vertex saat menggambar line/polygon */
.leaflet-div-icon.leaflet-editing-icon {
width: 8px !important;
height: 8px !important;
margin-left: -4px !important;
margin-top: -4px !important;
border-radius: 50%;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(11, 19, 43, 0.38);
display: none;
align-items: center;
justify-content: center;
padding: 16px;
z-index: 2000;
}
.modal-backdrop.open {
display: flex;
}
.modal {
width: min(460px, 100%);
background: #ffffff;
border-radius: 14px;
border: 1px solid var(--line);
box-shadow: 0 20px 55px rgba(17, 24, 39, 0.28);
padding: 16px;
}
.modal h3 {
margin: 0 0 12px;
}
.field input[readonly] {
background: #eef2ff;
}
.popup-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.popup-actions button {
flex: 1;
font-size: 12px;
padding: 8px;
border: 0;
border-radius: 8px;
cursor: pointer;
}
.btn-popup-edit {
background: #16a34a;
color: #ffffff;
}
.btn-popup-refine {
background: #f59e0b;
color: #111827;
}
.btn-popup-save {
background: #0f766e;
color: #ffffff;
}
.btn-popup-cancel {
background: #64748b;
color: #ffffff;
}
.btn-popup-delete {
background: #dc2626;
color: #ffffff;
}
@media (max-width: 640px) {
.header h1 {
font-size: 18px;
}
.header p {
font-size: 13px;
}
}
</style>
</head>
<body>
<div class="app">
<header class="header">
<h1>WebGIS SPBU</h1>
<p>Klik peta untuk titik SPBU. Gunakan toolbar kiri peta untuk menggambar jalan dan parsil.</p>
<div class="legend">
<span><i class="dot" style="background:#dc2626"></i>Jalan Nasional</span>
<span><i class="dot" style="background:#2563eb"></i>Jalan Provinsi</span>
<span><i class="dot" style="background:#16a34a"></i>Jalan Kabupaten</span>
</div>
</header>
<div class="map-wrap">
<div id="map"></div>
</div>
</div>
<div id="modal-backdrop" class="modal-backdrop" aria-hidden="true">
<div class="modal">
<h3 id="modal-title">Form Data</h3>
<form id="feature-form">
<div id="road-fields" hidden>
<div class="field">
<label for="nama_jalan">Nama Jalan</label>
<input id="nama_jalan" name="nama_jalan" type="text" required>
</div>
<div class="field">
<label for="status_jalan">Status Jalan</label>
<select id="status_jalan" name="status_jalan" required>
<option value="nasional">Jalan Nasional</option>
<option value="provinsi">Jalan Provinsi</option>
<option value="kabupaten">Jalan Kabupaten</option>
</select>
</div>
<div class="field">
<label for="panjang_meter">Panjang Jalan (meter)</label>
<input id="panjang_meter" name="panjang_meter" type="text" readonly>
</div>
</div>
<div id="parcel-fields" hidden>
<div class="field">
<label for="nama_pemilik">Nama Pemilik</label>
<input id="nama_pemilik" name="nama_pemilik" type="text" required>
</div>
<div class="field">
<label for="status_kepemilikan">Status Kepemilikan</label>
<select id="status_kepemilikan" name="status_kepemilikan" required>
<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="field">
<label for="luas_m2">Luas Tanah (meter persegi)</label>
<input id="luas_m2" name="luas_m2" type="text" readonly>
</div>
</div>
<div class="actions">
<button type="submit" class="btn-save">Simpan</button>
<button type="button" id="btn-cancel" class="btn-cancel">Batal</button>
</div>
<div id="form-status" class="status"></div>
</form>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script src="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.js"></script>
<script src="assets/js/kecamatan-layer.js"></script>
<script src="assets/js/map.js"></script>
</body>
</html>
+296
View File
@@ -0,0 +1,296 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WebGIS Spasial Jalan dan Parsil</title>
<link rel="stylesheet" href="assets/vendor/leaflet/leaflet.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.css">
<style>
:root {
--ink: #0b132b;
--sky: #f5f7ff;
--line: #d3d9eb;
--panel: #ffffff;
--accent: #0f766e;
--accent-dark: #115e59;
--danger: #b91c1c;
--muted: #3f4d73;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
font-family: "Trebuchet MS", "Segoe UI", sans-serif;
color: var(--ink);
background: radial-gradient(circle at top left, #e0f2fe 0%, #f8fafc 45%, #f1f5f9 100%);
}
.app {
height: 100dvh;
display: grid;
grid-template-rows: auto 1fr;
}
.header {
display: flex;
gap: 16px;
justify-content: space-between;
align-items: center;
padding: 12px 18px;
border-bottom: 1px solid var(--line);
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(6px);
}
.header h1 {
margin: 0;
font-size: 20px;
}
.header p {
margin: 4px 0 0;
color: var(--muted);
font-size: 13px;
}
.legend {
display: flex;
gap: 10px;
flex-wrap: wrap;
font-size: 12px;
color: #223055;
}
.legend span {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 999px;
padding: 6px 10px;
}
.dot {
width: 10px;
height: 10px;
border-radius: 999px;
display: inline-block;
}
.map-wrap {
position: relative;
min-height: 0;
}
#map {
width: 100%;
height: 100%;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(11, 19, 43, 0.38);
display: none;
align-items: center;
justify-content: center;
padding: 16px;
z-index: 2000;
}
.modal-backdrop.open {
display: flex;
}
.modal {
width: min(460px, 100%);
background: var(--panel);
border-radius: 14px;
border: 1px solid var(--line);
box-shadow: 0 20px 55px rgba(17, 24, 39, 0.28);
padding: 16px;
}
.modal h3 {
margin: 0 0 12px;
}
.field {
margin-bottom: 10px;
}
.field label {
display: block;
font-size: 12px;
margin-bottom: 4px;
color: var(--muted);
}
.field input,
.field select {
width: 100%;
padding: 9px 10px;
border-radius: 8px;
border: 1px solid var(--line);
font-size: 14px;
color: var(--ink);
}
.field input[readonly] {
background: #eef2ff;
}
.actions {
display: flex;
gap: 10px;
margin-top: 12px;
}
button {
border: 0;
border-radius: 8px;
padding: 10px 12px;
cursor: pointer;
font-weight: 700;
}
.btn-primary {
flex: 1;
background: var(--accent);
color: #f0fdfa;
}
.btn-primary:hover {
background: var(--accent-dark);
}
.btn-secondary {
background: #e2e8f0;
color: #1e293b;
}
.btn-danger {
background: var(--danger);
color: #fff;
}
.status {
font-size: 12px;
margin-top: 8px;
color: #334155;
min-height: 16px;
}
.status.error {
color: #b91c1c;
}
.status.success {
color: #166534;
}
.popup-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.popup-actions button {
flex: 1;
font-size: 12px;
padding: 8px;
}
@media (max-width: 700px) {
.header {
flex-direction: column;
align-items: flex-start;
}
}
</style>
</head>
<body>
<div class="app">
<header class="header">
<div>
<h1>WebGIS Manajemen Jalan & Parsil</h1>
<p>Gunakan toolbar kiri peta untuk gambar polyline/polygon, edit, atau hapus geometri.</p>
</div>
<div class="legend">
<span><i class="dot" style="background:#dc2626"></i>Jalan Nasional</span>
<span><i class="dot" style="background:#2563eb"></i>Jalan Provinsi</span>
<span><i class="dot" style="background:#16a34a"></i>Jalan Kabupaten</span>
</div>
</header>
<div class="map-wrap">
<div id="map"></div>
</div>
</div>
<div id="modal-backdrop" class="modal-backdrop" aria-hidden="true">
<div class="modal">
<h3 id="modal-title">Form Data</h3>
<form id="feature-form">
<div id="road-fields" hidden>
<div class="field">
<label for="nama_jalan">Nama Jalan</label>
<input id="nama_jalan" name="nama_jalan" type="text" required>
</div>
<div class="field">
<label for="status_jalan">Status Jalan</label>
<select id="status_jalan" name="status_jalan" required>
<option value="nasional">Jalan Nasional</option>
<option value="provinsi">Jalan Provinsi</option>
<option value="kabupaten">Jalan Kabupaten</option>
</select>
</div>
<div class="field">
<label for="panjang_meter">Panjang Jalan (meter)</label>
<input id="panjang_meter" name="panjang_meter" type="text" readonly>
</div>
</div>
<div id="parcel-fields" hidden>
<div class="field">
<label for="nama_pemilik">Nama Pemilik</label>
<input id="nama_pemilik" name="nama_pemilik" type="text" required>
</div>
<div class="field">
<label for="status_kepemilikan">Status Kepemilikan</label>
<select id="status_kepemilikan" name="status_kepemilikan" required>
<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="field">
<label for="luas_m2">Luas Tanah (meter persegi)</label>
<input id="luas_m2" name="luas_m2" type="text" readonly>
</div>
</div>
<div class="actions">
<button type="submit" class="btn-primary">Simpan</button>
<button type="button" id="btn-cancel" class="btn-secondary">Batal</button>
</div>
<div id="form-status" class="status"></div>
</form>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script src="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.js"></script>
<script src="assets/js/kecamatan-layer.js"></script>
<script src="assets/js/spatial.js"></script>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
CREATE DATABASE IF NOT EXISTS webgis_spbu;
USE webgis_spbu;
CREATE TABLE IF NOT EXISTS spbu_points (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(150) NOT NULL,
no VARCHAR(50) NOT NULL,
status_24jam ENUM('24jam', 'tidak') NOT NULL,
latitude DECIMAL(10, 7) NOT NULL,
longitude DECIMAL(10, 7) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
+24
View File
@@ -0,0 +1,24 @@
CREATE DATABASE IF NOT EXISTS webgis_spbu;
USE webgis_spbu;
CREATE TABLE IF NOT EXISTS jalan (
id INT AUTO_INCREMENT PRIMARY KEY,
nama_jalan VARCHAR(150) NOT NULL,
status ENUM('nasional', 'provinsi', 'kabupaten') NOT NULL,
panjang_meter DOUBLE NOT NULL,
geom LINESTRING NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
SPATIAL INDEX idx_jalan_geom (geom)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS parsil_tanah (
id INT AUTO_INCREMENT PRIMARY KEY,
nama_pemilik VARCHAR(150) NOT NULL,
status_kepemilikan ENUM('SHM', 'HGB', 'HGU', 'HP') NOT NULL,
luas_m2 DOUBLE NOT NULL,
geom POLYGON NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
SPATIAL INDEX idx_parsil_geom (geom)
) ENGINE=InnoDB;
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
function start_admin_session(): void
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
}
function is_admin_logged_in(): bool
{
start_admin_session();
return !empty($_SESSION['admin_id']);
}
function require_admin_login(bool $jsonResponse = false): void
{
if (is_admin_logged_in()) {
return;
}
if ($jsonResponse) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'message' => 'Sesi login tidak valid. Silakan login ulang.',
]);
} else {
header('Location: login.php');
}
exit;
}
function current_admin_name(): string
{
start_admin_session();
return (string)($_SESSION['admin_name'] ?? $_SESSION['admin_username'] ?? 'Admin');
}
function ensure_admin_table(PDO $pdo): void
{
$pdo->exec("
CREATE TABLE IF NOT EXISTS admins (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
nama VARCHAR(100) NOT NULL DEFAULT 'Administrator',
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
$count = (int)$pdo->query("SELECT COUNT(*) FROM admins")->fetchColumn();
if ($count === 0) {
$stmt = $pdo->prepare("INSERT INTO admins (username, password_hash, nama) VALUES (?, ?, ?)");
$stmt->execute([
'admin',
password_hash('admin123', PASSWORD_DEFAULT),
'Administrator',
]);
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
require_once 'db_pdo.php';
require_once 'auth.php';
require_once 'penduduk_feature_helper.php';
require_admin_login(true);
header('Content-Type: application/json');
$input = $_POST ?: (json_decode(file_get_contents('php://input'), true) ?: []);
if (empty($input['penduduk_id']) || empty($input['jenis_bantuan']) || empty($input['tanggal_bantuan'])) {
echo json_encode(['success' => false, 'message' => 'Penduduk, jenis bantuan, dan tanggal wajib diisi.']);
exit;
}
try {
ensurePendudukFeatureSchema($pdo);
$buktiFile = saveUploadedEvidence('bukti_file');
$stmt = $pdo->prepare("
INSERT INTO riwayat_bantuan (penduduk_id, jenis_bantuan, tanggal_bantuan, nilai_bantuan, pemberi_bantuan, catatan, bukti_file)
VALUES (?, ?, ?, ?, ?, ?, ?)
");
$stmt->execute([
$input['penduduk_id'],
$input['jenis_bantuan'],
normalizeDateOrNull($input['tanggal_bantuan']) ?? date('Y-m-d'),
$input['nilai_bantuan'] ?? null,
$input['pemberi_bantuan'] ?? 'Admin',
$input['catatan'] ?? null,
$buktiFile,
]);
$update = $pdo->prepare("
UPDATE penduduk_miskin
SET status_bantuan = 'Sudah dibantu', jenis_bantuan = ?, tanggal_bantuan = ?
WHERE id = ?
");
$update->execute([
$input['jenis_bantuan'],
normalizeDateOrNull($input['tanggal_bantuan']) ?? date('Y-m-d'),
$input['penduduk_id'],
]);
updatePendudukPriority($pdo, (int)$input['penduduk_id']);
echo json_encode(['success' => true, 'id' => $pdo->lastInsertId()]);
} catch (Throwable $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
+59
View File
@@ -0,0 +1,59 @@
<?php
require_once 'db_pdo.php';
require_once 'auth.php';
require_once 'penduduk_feature_helper.php';
require_admin_login(true);
header('Content-Type: application/json');
try {
ensurePendudukFeatureSchema($pdo);
refreshAllPriorityScores($pdo);
$stats = [];
$ri = $pdo->query("SELECT COUNT(*) as total FROM rumah_ibadah")->fetch();
$stats['total_rumah_ibadah'] = $ri['total'];
$pm = $pdo->query("SELECT COUNT(*) as total FROM penduduk_miskin")->fetch();
$stats['total_penduduk'] = $pm['total'];
$pm_sudah = $pdo->query("SELECT COUNT(*) as total FROM penduduk_miskin WHERE status_bantuan = 'Sudah dibantu'")->fetch();
$stats['sudah_dibantu'] = $pm_sudah['total'];
$pm_belum = $pdo->query("SELECT COUNT(*) as total FROM penduduk_miskin WHERE status_bantuan = 'Belum dibantu'")->fetch();
$stats['belum_dibantu'] = $pm_belum['total'];
$pm_tunggu = $pdo->query("SELECT COUNT(*) as total FROM penduduk_miskin WHERE status_bantuan = 'Menunggu verifikasi'")->fetch();
$stats['menunggu_verifikasi'] = $pm_tunggu['total'];
$blank = $pdo->query("SELECT COUNT(*) as total FROM penduduk_miskin WHERE is_blank_spot = 1")->fetch();
$stats['luar_radius'] = $blank['total'];
$prioritas = $pdo->query("SELECT COUNT(*) as total FROM penduduk_miskin WHERE skor_prioritas >= 70")->fetch();
$stats['prioritas_tinggi'] = $prioritas['total'];
$survei = $pdo->query("SELECT COUNT(*) as total FROM penduduk_miskin WHERE tindak_lanjut IN ('Sudah disurvei', 'Sudah diverifikasi')")->fetch();
$stats['sudah_survei'] = $survei['total'];
$stats['top_prioritas'] = $pdo->query("
SELECT id, kk_nama, kategori_kebutuhan, tindak_lanjut, skor_prioritas
FROM penduduk_miskin
ORDER BY skor_prioritas DESC, id DESC
LIMIT 5
")->fetchAll();
$stats['kebutuhan_terbanyak'] = $pdo->query("
SELECT COALESCE(NULLIF(kategori_kebutuhan, ''), 'Belum diisi') AS kategori, COUNT(*) AS total
FROM penduduk_miskin
GROUP BY COALESCE(NULLIF(kategori_kebutuhan, ''), 'Belum diisi')
ORDER BY total DESC
LIMIT 5
")->fetchAll();
echo json_encode([
'success' => true,
'data' => $stats
]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
$host = getenv('DB_HOST') ?: '127.0.0.1';
$database = getenv('DB_DATABASE') ?: 'webgis_miskin';
$username = getenv('DB_USERNAME') ?: 'root';
$password = getenv('DB_PASSWORD') ?: '';
$charset = getenv('DB_CHARSET') ?: 'utf8mb4';
set_exception_handler(static function (Throwable $exception): void {
if (!headers_sent()) {
http_response_code(500);
header('Content-Type: application/json');
}
echo json_encode([
'success' => false,
'message' => 'Terjadi kesalahan server.',
'detail' => $exception->getMessage(),
]);
exit;
});
$dsn = "mysql:host={$host};dbname={$database};charset={$charset}";
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
$pdo = new PDO($dsn, $username, $password, $options);
} catch (PDOException $exception) {
http_response_code(500);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'message' => 'Koneksi database gagal: ' . $exception->getMessage(),
]);
exit;
}
+145
View File
@@ -0,0 +1,145 @@
<?php
/**
* Menghitung jarak antara dua koordinat menggunakan formula Haversine.
* @param float $lat1 Latitude titik pertama
* @param float $lon1 Longitude titik pertama
* @param float $lat2 Latitude titik kedua
* @param float $lon2 Longitude titik kedua
* @return float Jarak dalam satuan meter
*/
function calculateHaversineDistance($lat1, $lon1, $lat2, $lon2) {
$earthRadius = 6371000; // Radius bumi dalam meter
$latFrom = deg2rad($lat1);
$lonFrom = deg2rad($lon1);
$latTo = deg2rad($lat2);
$lonTo = deg2rad($lon2);
$latDelta = $latTo - $latFrom;
$lonDelta = $lonTo - $lonFrom;
$angle = 2 * asin(sqrt(pow(sin($latDelta / 2), 2) +
cos($latFrom) * cos($latTo) * pow(sin($lonDelta / 2), 2)));
return $angle * $earthRadius;
}
function calculateOsrmRouteDistance($lat1, $lon1, $lat2, $lon2): ?float
{
$coordinates = sprintf(
'%.8F,%.8F;%.8F,%.8F',
$lon1,
$lat1,
$lon2,
$lat2
);
$url = 'https://router.project-osrm.org/route/v1/driving/' . $coordinates . '?overview=false&alternatives=false&steps=false';
$ch = curl_init($url);
if ($ch === false) {
return null;
}
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 4,
CURLOPT_TIMEOUT => 8,
CURLOPT_USERAGENT => 'WebGIS-Peduli/1.0',
]);
$response = curl_exec($ch);
$httpCode = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($response === false || $httpCode !== 200) {
return null;
}
$json = json_decode($response, true);
if (!is_array($json) || ($json['code'] ?? '') !== 'Ok') {
return null;
}
$distance = $json['routes'][0]['distance'] ?? null;
return is_numeric($distance) ? (float)$distance : null;
}
function calculateRoadDistance($lat1, $lon1, $lat2, $lon2): ?float
{
return calculateOsrmRouteDistance((float)$lat1, (float)$lon1, (float)$lat2, (float)$lon2);
}
function ensurePendudukDistanceColumns(PDO $pdo): void
{
$stmt = $pdo->query("SHOW COLUMNS FROM penduduk_miskin LIKE 'jarak_rute_m'");
if ($stmt->fetch() === false) {
$pdo->exec("ALTER TABLE penduduk_miskin ADD COLUMN jarak_rute_m DECIMAL(10, 2) NULL AFTER jarak_m");
}
}
function calculatePendudukCoverage(PDO $pdo, float $lat, float $lng): array
{
$stmtRi = $pdo->query("SELECT id, lat, lng, radius FROM rumah_ibadah");
$rumahIbadah = $stmtRi->fetchAll(PDO::FETCH_ASSOC);
$nearest = null;
$nearestCovered = null;
foreach ($rumahIbadah as $ri) {
$distance = calculateHaversineDistance($lat, $lng, (float)$ri['lat'], (float)$ri['lng']);
$result = [
'ibadah_id' => (int)$ri['id'],
'ri_lat' => (float)$ri['lat'],
'ri_lng' => (float)$ri['lng'],
'jarak_m' => round($distance, 2),
'distance' => $distance,
];
if ($nearest === null || $distance < $nearest['distance']) {
$nearest = $result;
}
if ($distance <= (float)$ri['radius'] && ($nearestCovered === null || $distance < $nearestCovered['distance'])) {
$nearestCovered = $result;
}
}
$selected = $nearestCovered ?? $nearest;
$routeDistance = null;
if ($selected !== null) {
$routeDistance = calculateRoadDistance($lat, $lng, $selected['ri_lat'], $selected['ri_lng']);
}
return [
'ibadah_id' => $selected['ibadah_id'] ?? null,
'jarak_m' => $selected['jarak_m'] ?? null,
'jarak_rute_m' => $routeDistance !== null ? round($routeDistance, 2) : null,
'is_blank_spot' => $nearestCovered === null ? 1 : 0,
];
}
function recalculateAllPendudukCoverage(PDO $pdo): void
{
ensurePendudukDistanceColumns($pdo);
$stmt = $pdo->query("SELECT id, lat, lng FROM penduduk_miskin");
$penduduk = $stmt->fetchAll(PDO::FETCH_ASSOC);
$update = $pdo->prepare("
UPDATE penduduk_miskin
SET ibadah_id = ?, jarak_m = ?, jarak_rute_m = ?, is_blank_spot = ?
WHERE id = ?
");
foreach ($penduduk as $row) {
$coverage = calculatePendudukCoverage($pdo, (float)$row['lat'], (float)$row['lng']);
$update->execute([
$coverage['ibadah_id'],
$coverage['jarak_m'],
$coverage['jarak_rute_m'],
$coverage['is_blank_spot'],
$row['id'],
]);
}
}
+95
View File
@@ -0,0 +1,95 @@
<?php
require_once 'db_pdo.php';
require_once 'haversine_helper.php';
require_once 'penduduk_feature_helper.php';
require_once 'auth.php';
require_admin_login(true);
header('Content-Type: application/json');
$input = readPendudukInput();
if (!$input) {
echo json_encode(['success' => false, 'message' => 'Input tidak valid']);
exit;
}
try {
ensurePendudukDistanceColumns($pdo);
ensurePendudukFeatureSchema($pdo);
$input_lat = floatval($input['lat']);
$input_lng = floatval($input['lng']);
$coverage = calculatePendudukCoverage($pdo, $input_lat, $input_lng);
$buktiFile = saveUploadedEvidence('bukti_file');
$stmt = $pdo->prepare("
INSERT INTO penduduk_miskin
(kk_nama, kk_nik, alamat, lat, lng, ibadah_id, jarak_m, jarak_rute_m, is_blank_spot, status_bantuan, kategori_kebutuhan, deskripsi_kebutuhan, tindak_lanjut, kondisi_rumah, penghasilan, catatan_survei, bukti_file, jenis_bantuan, tanggal_bantuan)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
");
$stmt->execute([
$input['kk_nama'],
$input['kk_nik'] ?? null,
$input['alamat'],
$input['lat'],
$input['lng'],
$coverage['ibadah_id'],
$coverage['jarak_m'],
$coverage['jarak_rute_m'],
$coverage['is_blank_spot'],
$input['status_bantuan'] ?? 'Belum dibantu',
$input['kategori_kebutuhan'] ?? null,
$input['deskripsi_kebutuhan'] ?? null,
$input['tindak_lanjut'] ?? 'Belum disurvei',
$input['kondisi_rumah'] ?? null,
normalizeNonNegativeIntOrNull($input['penghasilan'] ?? '', 'Penghasilan'),
$input['catatan_survei'] ?? null,
$buktiFile,
$input['jenis_bantuan'] ?? null,
normalizeDateOrNull($input['tanggal_bantuan'] ?? null)
]);
$penduduk_id = $pdo->lastInsertId();
// 4. Insert anggota keluarga if exist
if (!empty($input['anggota']) && is_array($input['anggota'])) {
$stmtAnggota = $pdo->prepare("INSERT INTO anggota_keluarga (penduduk_id, nama, hubungan, umur, pekerjaan) VALUES (?, ?, ?, ?, ?)");
foreach ($input['anggota'] as $anggota) {
$stmtAnggota->execute([
$penduduk_id,
$anggota['nama'],
$anggota['hubungan'],
$anggota['umur'] ?? null,
$anggota['pekerjaan'] ?? null
]);
}
}
if (($input['status_bantuan'] ?? '') === 'Sudah dibantu' && !empty($input['jenis_bantuan'])) {
$stmtRiwayat = $pdo->prepare("
INSERT INTO riwayat_bantuan (penduduk_id, jenis_bantuan, tanggal_bantuan, pemberi_bantuan, catatan, bukti_file)
VALUES (?, ?, ?, ?, ?, ?)
");
$stmtRiwayat->execute([
$penduduk_id,
$input['jenis_bantuan'],
normalizeDateOrNull($input['tanggal_bantuan'] ?? null) ?? date('Y-m-d'),
'Admin',
'Dicatat dari form penduduk.',
$buktiFile,
]);
}
updatePendudukPriority($pdo, (int)$penduduk_id);
echo json_encode([
'success' => true,
'id' => $penduduk_id,
'ibadah_id' => $coverage['ibadah_id'],
'jarak_m' => $coverage['jarak_m'],
'jarak_rute_m' => $coverage['jarak_rute_m'],
'is_blank_spot' => $coverage['is_blank_spot']
]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
+22
View File
@@ -0,0 +1,22 @@
<?php
require_once 'db_pdo.php';
require_once 'auth.php';
require_admin_login(true);
header('Content-Type: application/json');
$input = json_decode(file_get_contents('php://input'), true);
if (!$input || empty($input['id'])) {
echo json_encode(['success' => false, 'message' => 'Input tidak valid']);
exit;
}
try {
$stmt = $pdo->prepare("DELETE FROM penduduk_miskin WHERE id=?");
$stmt->execute([$input['id']]);
// Note: anggota_keluarga will be deleted automatically due to ON DELETE CASCADE
echo json_encode(['success' => true]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
+179
View File
@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
function ensurePendudukFeatureSchema(PDO $pdo): void
{
$columns = [
'kategori_kebutuhan' => "ALTER TABLE penduduk_miskin ADD COLUMN kategori_kebutuhan VARCHAR(100) NULL AFTER status_bantuan",
'deskripsi_kebutuhan' => "ALTER TABLE penduduk_miskin ADD COLUMN deskripsi_kebutuhan TEXT NULL AFTER kategori_kebutuhan",
'tindak_lanjut' => "ALTER TABLE penduduk_miskin ADD COLUMN tindak_lanjut VARCHAR(60) NOT NULL DEFAULT 'Belum disurvei' AFTER deskripsi_kebutuhan",
'kondisi_rumah' => "ALTER TABLE penduduk_miskin ADD COLUMN kondisi_rumah VARCHAR(80) NULL AFTER tindak_lanjut",
'penghasilan' => "ALTER TABLE penduduk_miskin ADD COLUMN penghasilan INT NULL AFTER kondisi_rumah",
'catatan_survei' => "ALTER TABLE penduduk_miskin ADD COLUMN catatan_survei TEXT NULL AFTER penghasilan",
'bukti_file' => "ALTER TABLE penduduk_miskin ADD COLUMN bukti_file VARCHAR(255) NULL AFTER catatan_survei",
'skor_prioritas' => "ALTER TABLE penduduk_miskin ADD COLUMN skor_prioritas INT NOT NULL DEFAULT 0 AFTER bukti_file",
];
foreach ($columns as $column => $sql) {
$stmt = $pdo->query("SHOW COLUMNS FROM penduduk_miskin LIKE " . $pdo->quote($column));
if ($stmt->fetch() === false) {
$pdo->exec($sql);
}
}
$pdo->exec("
CREATE TABLE IF NOT EXISTS riwayat_bantuan (
id INT AUTO_INCREMENT PRIMARY KEY,
penduduk_id INT NOT NULL,
jenis_bantuan VARCHAR(120) NOT NULL,
tanggal_bantuan DATE NOT NULL,
nilai_bantuan VARCHAR(80) NULL,
pemberi_bantuan VARCHAR(120) NULL,
catatan TEXT NULL,
bukti_file VARCHAR(255) NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (penduduk_id) REFERENCES penduduk_miskin(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
}
function normalizeDateOrNull(?string $value): ?string
{
$value = trim((string)$value);
return $value === '' || $value === '0000-00-00' ? null : $value;
}
function normalizeNonNegativeIntOrNull(mixed $value, string $label): ?int
{
$value = trim((string)$value);
if ($value === '') {
return null;
}
if (!is_numeric($value) || (float)$value < 0) {
throw new InvalidArgumentException($label . ' tidak boleh bernilai negatif.');
}
return (int)$value;
}
function calculatePriorityScore(array $penduduk, array $anggota = []): int
{
$score = 0;
$anggotaCount = count($anggota);
$penghasilan = isset($penduduk['penghasilan']) ? (int)$penduduk['penghasilan'] : 0;
if (($penduduk['status_bantuan'] ?? '') === 'Belum dibantu') $score += 30;
if (($penduduk['status_bantuan'] ?? '') === 'Menunggu verifikasi') $score += 15;
if ((int)($penduduk['is_blank_spot'] ?? 0) === 1) $score += 15;
$score += min(20, $anggotaCount * 4);
foreach ($anggota as $a) {
$umur = isset($a['umur']) ? (int)$a['umur'] : null;
if ($umur !== null && ($umur < 6 || $umur >= 60)) {
$score += 5;
}
}
if ($penghasilan > 0 && $penghasilan <= 1000000) $score += 15;
elseif ($penghasilan > 1000000 && $penghasilan <= 2000000) $score += 8;
$kondisi = strtolower((string)($penduduk['kondisi_rumah'] ?? ''));
if (str_contains($kondisi, 'tidak layak')) $score += 15;
elseif (str_contains($kondisi, 'rentan')) $score += 8;
$kebutuhan = strtolower((string)($penduduk['kategori_kebutuhan'] ?? ''));
if (in_array($kebutuhan, ['kesehatan', 'rumah tidak layak', 'bantuan darurat'], true)) $score += 10;
return min(100, max(0, $score));
}
function getPriorityLabel(int $score): string
{
if ($score >= 70) return 'Prioritas Tinggi';
if ($score >= 40) return 'Prioritas Sedang';
return 'Prioritas Rendah';
}
function saveUploadedEvidence(string $fieldName): ?string
{
if (empty($_FILES[$fieldName]) || !is_array($_FILES[$fieldName])) {
return null;
}
$file = $_FILES[$fieldName];
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
return null;
}
if (($file['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) {
throw new RuntimeException('Upload bukti gagal.');
}
if (($file['size'] ?? 0) > 3 * 1024 * 1024) {
throw new RuntimeException('Ukuran bukti maksimal 3 MB.');
}
$allowed = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
'application/pdf' => 'pdf',
];
$mime = mime_content_type($file['tmp_name']);
if (!isset($allowed[$mime])) {
throw new RuntimeException('Format bukti harus JPG, PNG, WEBP, atau PDF.');
}
$dir = __DIR__ . '/../uploads/bukti';
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
throw new RuntimeException('Folder upload tidak bisa dibuat.');
}
$filename = date('YmdHis') . '-' . bin2hex(random_bytes(6)) . '.' . $allowed[$mime];
$target = $dir . '/' . $filename;
if (!move_uploaded_file($file['tmp_name'], $target)) {
throw new RuntimeException('Bukti tidak bisa disimpan.');
}
return 'uploads/bukti/' . $filename;
}
function readPendudukInput(): array
{
if (str_starts_with((string)($_SERVER['CONTENT_TYPE'] ?? ''), 'multipart/form-data')) {
$input = $_POST;
$input['anggota'] = json_decode((string)($_POST['anggota'] ?? '[]'), true) ?: [];
return $input;
}
return json_decode(file_get_contents('php://input'), true) ?: [];
}
function updatePendudukPriority(PDO $pdo, int $pendudukId): void
{
$stmt = $pdo->prepare("SELECT * FROM penduduk_miskin WHERE id = ?");
$stmt->execute([$pendudukId]);
$penduduk = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$penduduk) return;
$stmtAnggota = $pdo->prepare("SELECT * FROM anggota_keluarga WHERE penduduk_id = ?");
$stmtAnggota->execute([$pendudukId]);
$anggota = $stmtAnggota->fetchAll(PDO::FETCH_ASSOC);
$score = calculatePriorityScore($penduduk, $anggota);
$update = $pdo->prepare("UPDATE penduduk_miskin SET skor_prioritas = ? WHERE id = ?");
$update->execute([$score, $pendudukId]);
}
function refreshAllPriorityScores(PDO $pdo): void
{
ensurePendudukFeatureSchema($pdo);
$ids = $pdo->query("SELECT id FROM penduduk_miskin")->fetchAll(PDO::FETCH_COLUMN);
foreach ($ids as $id) {
updatePendudukPriority($pdo, (int)$id);
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
require_once 'db_pdo.php';
require_once 'auth.php';
require_once 'penduduk_feature_helper.php';
require_admin_login(true);
header('Content-Type: application/json');
try {
ensurePendudukFeatureSchema($pdo);
refreshAllPriorityScores($pdo);
// Join with rumah_ibadah to get name
$stmt = $pdo->query("
SELECT p.*, r.nama as rumah_ibadah_nama
FROM penduduk_miskin p
LEFT JOIN rumah_ibadah r ON p.ibadah_id = r.id
ORDER BY p.skor_prioritas DESC, p.id DESC
");
$data = $stmt->fetchAll();
// Get all anggota
$stmtAnggota = $pdo->query("SELECT * FROM anggota_keluarga");
$anggota = $stmtAnggota->fetchAll();
// Map anggota to penduduk
$anggotaByPenduduk = [];
foreach ($anggota as $a) {
$anggotaByPenduduk[$a['penduduk_id']][] = $a;
}
$stmtRiwayat = $pdo->query("SELECT * FROM riwayat_bantuan ORDER BY tanggal_bantuan DESC, id DESC");
$riwayat = $stmtRiwayat->fetchAll();
$riwayatByPenduduk = [];
foreach ($riwayat as $r) {
$riwayatByPenduduk[$r['penduduk_id']][] = $r;
}
foreach ($data as &$d) {
$d['anggota'] = $anggotaByPenduduk[$d['id']] ?? [];
$d['riwayat_bantuan'] = $riwayatByPenduduk[$d['id']] ?? [];
$d['label_prioritas'] = getPriorityLabel((int)($d['skor_prioritas'] ?? 0));
}
echo json_encode([
'success' => true,
'data' => $data
]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
+84
View File
@@ -0,0 +1,84 @@
<?php
require_once 'db_pdo.php';
require_once 'haversine_helper.php';
require_once 'penduduk_feature_helper.php';
require_once 'auth.php';
require_admin_login(true);
header('Content-Type: application/json');
$input = readPendudukInput();
if (!$input || empty($input['id'])) {
echo json_encode(['success' => false, 'message' => 'Input tidak valid']);
exit;
}
try {
ensurePendudukDistanceColumns($pdo);
ensurePendudukFeatureSchema($pdo);
$input_lat = floatval($input['lat']);
$input_lng = floatval($input['lng']);
$coverage = calculatePendudukCoverage($pdo, $input_lat, $input_lng);
$buktiFile = saveUploadedEvidence('bukti_file');
if ($buktiFile === null && !empty($input['existing_bukti_file'])) {
$buktiFile = $input['existing_bukti_file'];
}
$stmt = $pdo->prepare("
UPDATE penduduk_miskin
SET kk_nama=?, kk_nik=?, alamat=?, lat=?, lng=?, status_bantuan=?, kategori_kebutuhan=?, deskripsi_kebutuhan=?, tindak_lanjut=?, kondisi_rumah=?, penghasilan=?, catatan_survei=?, bukti_file=?, jenis_bantuan=?, tanggal_bantuan=?, ibadah_id=?, jarak_m=?, jarak_rute_m=?, is_blank_spot=?
WHERE id=?
");
$stmt->execute([
$input['kk_nama'],
$input['kk_nik'] ?? null,
$input['alamat'],
$input['lat'],
$input['lng'],
$input['status_bantuan'] ?? 'Belum dibantu',
$input['kategori_kebutuhan'] ?? null,
$input['deskripsi_kebutuhan'] ?? null,
$input['tindak_lanjut'] ?? 'Belum disurvei',
$input['kondisi_rumah'] ?? null,
normalizeNonNegativeIntOrNull($input['penghasilan'] ?? '', 'Penghasilan'),
$input['catatan_survei'] ?? null,
$buktiFile,
$input['jenis_bantuan'] ?? null,
normalizeDateOrNull($input['tanggal_bantuan'] ?? null),
$coverage['ibadah_id'],
$coverage['jarak_m'],
$coverage['jarak_rute_m'],
$coverage['is_blank_spot'],
$input['id']
]);
// 4. Delete old anggota and insert new ones
$pdo->prepare("DELETE FROM anggota_keluarga WHERE penduduk_id=?")->execute([$input['id']]);
if (!empty($input['anggota']) && is_array($input['anggota'])) {
$stmtAnggota = $pdo->prepare("INSERT INTO anggota_keluarga (penduduk_id, nama, hubungan, umur, pekerjaan) VALUES (?, ?, ?, ?, ?)");
foreach ($input['anggota'] as $anggota) {
$stmtAnggota->execute([
$input['id'],
$anggota['nama'],
$anggota['hubungan'],
$anggota['umur'] ?? null,
$anggota['pekerjaan'] ?? null
]);
}
}
updatePendudukPriority($pdo, (int)$input['id']);
echo json_encode([
'success' => true,
'ibadah_id' => $coverage['ibadah_id'],
'jarak_m' => $coverage['jarak_m'],
'jarak_rute_m' => $coverage['jarak_rute_m'],
'is_blank_spot' => $coverage['is_blank_spot']
]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
+38
View File
@@ -0,0 +1,38 @@
<?php
require_once 'db_pdo.php';
require_once 'auth.php';
require_once 'haversine_helper.php';
require_once 'penduduk_feature_helper.php';
require_once 'rumah_ibadah_helper.php';
require_admin_login(true);
header('Content-Type: application/json');
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) {
echo json_encode(['success' => false, 'message' => 'Input tidak valid']);
exit;
}
try {
assertRumahIbadahNotDuplicate($pdo, $input);
$stmt = $pdo->prepare("INSERT INTO rumah_ibadah (nama, jenis, kontak, alamat, lat, lng, radius) VALUES (?, ?, ?, ?, ?, ?, ?)");
$stmt->execute([
$input['nama'],
$input['jenis'],
$input['kontak'],
$input['alamat'],
$input['lat'],
$input['lng'],
$input['radius'] ?? 500
]);
$id = $pdo->lastInsertId();
recalculateAllPendudukCoverage($pdo);
refreshAllPriorityScores($pdo);
echo json_encode(['success' => true, 'id' => $id]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
+26
View File
@@ -0,0 +1,26 @@
<?php
require_once 'db_pdo.php';
require_once 'auth.php';
require_once 'haversine_helper.php';
require_once 'penduduk_feature_helper.php';
require_admin_login(true);
header('Content-Type: application/json');
$input = json_decode(file_get_contents('php://input'), true);
if (!$input || empty($input['id'])) {
echo json_encode(['success' => false, 'message' => 'Input tidak valid']);
exit;
}
try {
$stmt = $pdo->prepare("DELETE FROM rumah_ibadah WHERE id=?");
$stmt->execute([$input['id']]);
recalculateAllPendudukCoverage($pdo);
refreshAllPriorityScores($pdo);
echo json_encode(['success' => true]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
function assertRumahIbadahNotDuplicate(PDO $pdo, array $input, ?int $ignoreId = null): void
{
$nama = trim((string)($input['nama'] ?? ''));
$jenis = trim((string)($input['jenis'] ?? ''));
$lat = round((float)($input['lat'] ?? 0), 6);
$lng = round((float)($input['lng'] ?? 0), 6);
$allowedJenis = ['Masjid', 'Gereja', 'Pura', 'Vihara', 'Klenteng', 'Lainnya'];
if ($nama === '' || $jenis === '' || !isset($input['lat'], $input['lng'])) {
throw new InvalidArgumentException('Nama, jenis, dan lokasi rumah ibadah wajib diisi.');
}
if (!in_array($jenis, $allowedJenis, true)) {
throw new InvalidArgumentException('Jenis rumah ibadah tidak valid.');
}
$sql = "
SELECT id
FROM rumah_ibadah
WHERE LOWER(TRIM(nama)) = LOWER(TRIM(?))
AND jenis = ?
AND ROUND(lat, 6) = ?
AND ROUND(lng, 6) = ?
";
$params = [$nama, $jenis, $lat, $lng];
if ($ignoreId !== null) {
$sql .= " AND id <> ?";
$params[] = $ignoreId;
}
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
if ($stmt->fetch()) {
throw new InvalidArgumentException('Rumah ibadah dengan nama, jenis, dan lokasi yang sama sudah ada.');
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
require_once 'db_pdo.php';
require_once 'auth.php';
require_admin_login(true);
header('Content-Type: application/json');
try {
$stmt = $pdo->query("SELECT * FROM rumah_ibadah ORDER BY nama ASC");
$data = $stmt->fetchAll();
echo json_encode([
'success' => true,
'data' => $data
]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
+38
View File
@@ -0,0 +1,38 @@
<?php
require_once 'db_pdo.php';
require_once 'auth.php';
require_once 'haversine_helper.php';
require_once 'penduduk_feature_helper.php';
require_once 'rumah_ibadah_helper.php';
require_admin_login(true);
header('Content-Type: application/json');
$input = json_decode(file_get_contents('php://input'), true);
if (!$input || empty($input['id'])) {
echo json_encode(['success' => false, 'message' => 'Input tidak valid']);
exit;
}
try {
assertRumahIbadahNotDuplicate($pdo, $input, (int)$input['id']);
$stmt = $pdo->prepare("UPDATE rumah_ibadah SET nama=?, jenis=?, kontak=?, alamat=?, lat=?, lng=?, radius=? WHERE id=?");
$stmt->execute([
$input['nama'],
$input['jenis'],
$input['kontak'],
$input['alamat'],
$input['lat'],
$input['lng'],
$input['radius'],
$input['id']
]);
recalculateAllPendudukCoverage($pdo);
refreshAllPriorityScores($pdo);
echo json_encode(['success' => true]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/auth.php';
require_admin_login(true);
header('Content-Type: application/json');
$host = getenv('DB_HOST') ?: '127.0.0.1';
$username = getenv('DB_SETUP_USERNAME') ?: getenv('DB_USERNAME') ?: 'root';
$password = getenv('DB_SETUP_PASSWORD') ?: getenv('DB_PASSWORD') ?: '';
$charset = getenv('DB_CHARSET') ?: 'utf8mb4';
$schemaPath = __DIR__ . '/../sql/miskin_schema.sql';
$sql = file_get_contents($schemaPath);
if ($sql === false) {
echo json_encode([
'success' => false,
'message' => 'File skema database tidak ditemukan.',
]);
exit;
}
try {
$dsn = "mysql:host={$host};charset={$charset}";
$pdo = new PDO($dsn, $username, $password, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
$pdo->exec($sql);
echo json_encode([
'success' => true,
'message' => 'Database webgis_miskin berhasil di-reset.',
]);
} catch (PDOException $exception) {
http_response_code(500);
echo json_encode([
'success' => false,
'message' => 'Gagal reset database.',
'error' => $exception->getMessage(),
]);
}
+213
View File
@@ -0,0 +1,213 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
color: #172033;
background: #eef2f7;
font-family: Arial, Helvetica, sans-serif;
line-height: 1.5;
}
.guide-page {
width: min(1040px, calc(100% - 32px));
margin: 28px auto;
background: #ffffff;
border-radius: 14px;
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.13);
padding: 32px;
}
.guide-hero {
display: flex;
justify-content: space-between;
gap: 24px;
border-bottom: 2px solid #1f2937;
padding-bottom: 22px;
margin-bottom: 24px;
}
.eyebrow {
margin: 0 0 8px;
color: #4f46e5;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
h1,
h2 {
margin: 0;
}
h1 {
font-size: 2.2rem;
}
h2 {
margin-bottom: 14px;
font-size: 1.25rem;
}
section {
margin-top: 26px;
}
.lead {
max-width: 720px;
color: #475467;
font-size: 1rem;
}
.print-btn {
align-self: flex-start;
border: 0;
border-radius: 10px;
background: #4f46e5;
color: white;
cursor: pointer;
font-weight: 800;
padding: 11px 15px;
white-space: nowrap;
}
.access-panel {
border: 1px solid #d8dee9;
border-radius: 12px;
background: #f8fafc;
padding: 20px;
}
.info-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.info-grid div,
.legend-grid div {
border: 1px solid #d8dee9;
border-radius: 10px;
background: #ffffff;
padding: 14px;
}
.info-grid span {
display: block;
color: #667085;
font-size: 0.82rem;
margin-bottom: 6px;
}
.info-grid strong {
overflow-wrap: anywhere;
}
.note {
color: #667085;
font-size: 0.9rem;
margin: 12px 0 0;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
border: 1px solid #d8dee9;
padding: 10px;
text-align: left;
vertical-align: top;
}
th {
background: #f1f5f9;
}
code {
background: #eef2ff;
border-radius: 6px;
color: #3730a3;
font-weight: 700;
padding: 2px 6px;
}
.steps ol {
padding-left: 22px;
}
.steps li,
ul li {
margin-bottom: 8px;
}
.legend-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.legend-grid strong {
display: block;
margin-bottom: 4px;
}
.legend-grid p {
color: #667085;
margin: 0;
}
.dot {
display: inline-block;
width: 13px;
height: 13px;
border-radius: 50%;
margin-right: 8px;
}
.dot.worship {
background: #4f46e5;
}
.dot.reached {
background: #10b981;
}
.dot.outside {
background: #ef4444;
}
@media (max-width: 800px) {
.guide-hero,
.info-grid,
.legend-grid {
grid-template-columns: 1fr;
flex-direction: column;
}
.guide-page {
padding: 22px;
}
}
@media print {
body {
background: white;
}
.guide-page {
width: 100%;
margin: 0;
padding: 0;
box-shadow: none;
border-radius: 0;
}
.print-btn {
display: none;
}
}
+179
View File
@@ -0,0 +1,179 @@
:root {
--primary: #4f46e5;
--primary-hover: #4338ca;
--dark: #1e293b;
--gray: #64748b;
--gray-light: #e2e8f0;
--light: #f8fafc;
--white: #ffffff;
--danger: #ef4444;
--shadow-lg: 0 18px 40px rgba(15, 23, 42, 0.12);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
body {
min-height: 100vh;
color: var(--dark);
background:
radial-gradient(circle at top left, rgba(79, 70, 229, 0.16), transparent 34%),
linear-gradient(135deg, #f8fafc 0%, #eef2ff 52%, #f8fafc 100%);
}
.login-page {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
}
.login-panel {
width: min(100%, 430px);
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(226, 232, 240, 0.9);
border-radius: 18px;
box-shadow: var(--shadow-lg);
padding: 34px;
}
.brand-mark {
width: 56px;
height: 56px;
border-radius: 16px;
display: grid;
place-items: center;
color: var(--white);
background: var(--primary);
font-size: 1.45rem;
margin-bottom: 22px;
}
.eyebrow {
color: var(--primary);
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 8px;
}
h1 {
font-size: 1.8rem;
line-height: 1.2;
margin-bottom: 10px;
}
.subtitle {
color: var(--gray);
line-height: 1.6;
margin-bottom: 24px;
}
.alert-error {
display: flex;
gap: 10px;
align-items: center;
color: #991b1b;
background-color: #fee2e2;
border: 1px solid #fecaca;
border-radius: 12px;
padding: 12px 14px;
margin-bottom: 18px;
font-size: 0.9rem;
}
.login-form {
display: grid;
gap: 16px;
}
.input-group {
display: grid;
gap: 8px;
}
.input-group span {
color: var(--dark);
font-size: 0.9rem;
font-weight: 700;
}
.input-shell {
height: 48px;
display: flex;
align-items: center;
gap: 12px;
border: 1px solid var(--gray-light);
border-radius: 12px;
background: var(--light);
padding: 0 14px;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
}
.input-shell:focus-within {
background: var(--white);
border-color: rgba(79, 70, 229, 0.45);
box-shadow: 0 0 0 4px rgba(79, 70, 229, 0.1);
}
.input-shell i {
color: var(--primary);
}
.input-shell input {
width: 100%;
border: 0;
outline: 0;
background: transparent;
color: var(--dark);
font-size: 0.96rem;
}
.btn-login {
height: 48px;
border: 0;
border-radius: 12px;
background: var(--primary);
color: var(--white);
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
cursor: pointer;
font-size: 0.98rem;
font-weight: 800;
margin-top: 4px;
transition: background-color 0.2s ease, transform 0.2s ease;
}
.btn-login:hover {
background: var(--primary-hover);
transform: translateY(-1px);
}
.login-hint {
color: var(--gray);
font-size: 0.86rem;
margin-top: 18px;
text-align: center;
}
@media (max-width: 520px) {
.login-page {
padding: 16px;
}
.login-panel {
padding: 26px;
border-radius: 16px;
}
h1 {
font-size: 1.5rem;
}
}
+135
View File
@@ -0,0 +1,135 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
color: #172033;
background: #eef2f7;
font-family: Arial, Helvetica, sans-serif;
font-size: 12px;
}
.report-page {
width: min(1180px, calc(100% - 32px));
margin: 24px auto;
background: #fff;
padding: 28px;
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.12);
}
.report-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
border-bottom: 2px solid #1f2937;
padding-bottom: 18px;
margin-bottom: 18px;
}
.eyebrow {
margin: 0 0 6px;
color: #4f46e5;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
h1 {
margin: 0 0 8px;
font-size: 26px;
}
.print-btn {
border: 0;
border-radius: 8px;
background: #4f46e5;
color: white;
cursor: pointer;
font-weight: 700;
padding: 10px 14px;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
margin-bottom: 14px;
}
.summary-grid div {
border: 1px solid #d8dee9;
border-left: 4px solid #4f46e5;
padding: 12px;
}
.summary-grid span {
display: block;
color: #667085;
margin-bottom: 6px;
}
.summary-grid strong {
font-size: 22px;
}
.report-note {
background: #f8fafc;
border: 1px solid #d8dee9;
padding: 10px 12px;
margin-bottom: 16px;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
border: 1px solid #d8dee9;
padding: 8px;
vertical-align: top;
}
th {
background: #f1f5f9;
text-align: left;
}
small {
color: #667085;
}
.empty {
text-align: center;
color: #667085;
padding: 24px;
}
@media print {
body {
background: white;
}
.report-page {
width: 100%;
margin: 0;
padding: 0;
box-shadow: none;
}
.print-btn {
display: none;
}
table {
page-break-inside: auto;
}
tr {
page-break-inside: avoid;
page-break-after: auto;
}
}
File diff suppressed because it is too large Load Diff
+793
View File
@@ -0,0 +1,793 @@
// Globals
let map;
let layerRI, layerPM, layerRadius;
let riData = [];
let pmData = [];
let pickingMode = null; // 'ri' or 'pm'
let tempMarker = null;
let currentView = 'dashboard';
let searchTerm = '';
const rumahIbadahIconConfig = {
Masjid: { icon: 'fa-mosque', className: 'mosque' },
Gereja: { icon: 'fa-church', className: 'church' },
Pura: { icon: 'fa-gopuram', className: 'temple' },
Vihara: { icon: 'fa-vihara', className: 'vihara' },
Klenteng: { icon: 'fa-torii-gate', className: 'klenteng' },
Lainnya: { icon: 'fa-place-of-worship', className: 'other' }
};
function getRumahIbadahIcon(jenis) {
const config = rumahIbadahIconConfig[jenis] || rumahIbadahIconConfig.Lainnya;
return L.divIcon({
className: 'ri-div-icon',
html: `<div class="ri-marker ri-${config.className}" title="${jenis || 'Rumah Ibadah'}"><i class="fas ${config.icon}"></i></div>`,
iconSize: [36, 46],
iconAnchor: [18, 43],
popupAnchor: [0, -43]
});
}
// PM Icons are now circleMarkers generated inline in renderPM
document.addEventListener('DOMContentLoaded', async () => {
await initMap();
initUI();
loadData();
});
async function initMap() {
map = L.map('map', {
zoomControl: false,
preferCanvas: true
}).setView([-0.060547, 109.344902], 13);
window.map = map;
L.control.zoom({ position: 'bottomright' }).addTo(map);
const tileOptions = {
keepBuffer: 6,
updateWhenIdle: false,
updateWhenZooming: false,
updateInterval: 100
};
const osm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
...tileOptions,
attribution: '&copy; OpenStreetMap contributors'
});
const satelit = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
...tileOptions,
attribution: 'Tiles &copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community'
});
const darkMap = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
...tileOptions,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
subdomains: 'abcd',
maxZoom: 20
});
osm.addTo(map);
const baseMaps = {
"OSM Standard": osm,
"Citra Satelit": satelit,
"Dark Mode": darkMap
};
layerRadius = L.featureGroup().addTo(map);
layerRI = L.featureGroup().addTo(map);
layerPM = L.featureGroup().addTo(map);
const overlays = {
"Radius Layanan": layerRadius,
"Rumah Ibadah": layerRI,
"Penduduk Miskin": layerPM
};
L.control.layers(baseMaps, overlays, { position: 'topright' }).addTo(map);
// Map Click for Picking Location
map.on('click', async (e) => {
if (!pickingMode) return;
const lat = e.latlng.lat;
const lng = e.latlng.lng;
if (tempMarker) map.removeLayer(tempMarker);
tempMarker = L.marker([lat, lng]).addTo(map);
if (pickingMode === 'ri') {
document.getElementById('ri-lat').value = lat.toFixed(6);
document.getElementById('ri-lng').value = lng.toFixed(6);
document.getElementById('ri-alamat').value = 'Mencari alamat...';
document.getElementById('ri-alamat').value = await reverseGeocode(lat, lng);
document.getElementById('modal-ri').classList.add('show');
} else if (pickingMode === 'pm') {
document.getElementById('pm-lat').value = lat.toFixed(6);
document.getElementById('pm-lng').value = lng.toFixed(6);
document.getElementById('pm-alamat').value = 'Mencari alamat...';
document.getElementById('pm-alamat').value = await reverseGeocode(lat, lng);
document.getElementById('modal-pm').classList.add('show');
}
pickingMode = null;
if(tempMarker) map.removeLayer(tempMarker);
});
}
function initUI() {
// Sidebar Toggle
document.getElementById('toggle-sidebar').addEventListener('click', () => {
const sidebar = document.getElementById('sidebar');
if (window.innerWidth <= 768) {
sidebar.classList.toggle('open');
} else {
sidebar.classList.toggle('collapsed');
}
});
document.querySelectorAll('.menu-item[data-view]').forEach(item => {
item.addEventListener('click', () => switchView(item.dataset.view));
});
// Menus
const openAddRI = () => {
switchView('map');
document.getElementById('form-ri').reset();
document.getElementById('ri-id').value = '';
document.getElementById('modal-ri-title').innerText = 'Tambah Rumah Ibadah';
document.getElementById('modal-ri').classList.add('show');
};
document.getElementById('menu-add-ri').addEventListener('click', openAddRI);
document.getElementById('fab-add-ri')?.addEventListener('click', openAddRI);
const openAddPM = () => {
switchView('map');
document.getElementById('form-pm').reset();
document.getElementById('pm-id').value = '';
document.getElementById('anggota-container').innerHTML = '';
document.getElementById('bantuan-fields').style.display = 'none';
document.getElementById('modal-pm-title').innerText = 'Tambah Penduduk Miskin';
document.getElementById('modal-pm').classList.add('show');
};
document.getElementById('menu-add-pm').addEventListener('click', openAddPM);
document.getElementById('fab-add-pm')?.addEventListener('click', openAddPM);
document.getElementById('dashboard-add-ri')?.addEventListener('click', openAddRI);
document.getElementById('dashboard-add-pm')?.addEventListener('click', openAddPM);
document.getElementById('dashboard-open-map')?.addEventListener('click', () => switchView('map'));
// Filters
document.getElementById('filter-status').addEventListener('change', renderMapLayers);
document.getElementById('filter-jenis').addEventListener('change', renderMapLayers);
document.getElementById('filter-kebutuhan').addEventListener('change', renderPM);
// Search
document.getElementById('search-input').addEventListener('input', () => {
searchTerm = getSearchTerm();
renderMapLayers();
});
document.getElementById('search-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
applyMapSearch(true);
}
});
document.getElementById('search-button').addEventListener('click', () => applyMapSearch(true));
// Location
document.getElementById('btn-my-location').addEventListener('click', () => {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(position) => {
const lat = position.coords.latitude;
const lng = position.coords.longitude;
map.flyTo([lat, lng], 16);
L.marker([lat, lng]).addTo(map).bindPopup('Lokasi Anda').openPopup();
},
(err) => alert('Gagal mendapatkan lokasi: ' + err.message)
);
} else {
alert('Geolocation tidak didukung browser ini.');
}
});
switchView(currentView);
}
function switchView(view) {
currentView = view;
const mainContent = document.querySelector('.main-content');
document.querySelectorAll('.view-panel').forEach(panel => {
panel.classList.toggle('active', panel.id === `${view}-view`);
});
document.querySelectorAll('.menu-item[data-view]').forEach(item => {
item.classList.toggle('active', item.dataset.view === view);
});
mainContent?.classList.toggle('map-mode', view === 'map');
mainContent?.classList.toggle('dashboard-mode', view !== 'map');
document.querySelector('.search-bar')?.classList.toggle('hidden', view !== 'map');
document.querySelector('.map-header-controls')?.classList.toggle('hidden', view !== 'map');
if (window.innerWidth <= 768) {
document.getElementById('sidebar').classList.remove('open');
}
if (view === 'map' && map) {
refreshMapSize();
}
}
function refreshMapSize() {
[0, 100, 300].forEach(delay => {
setTimeout(() => map.invalidateSize({ pan: false }), delay);
});
}
function getSearchTerm() {
return document.getElementById('search-input').value.trim().toLowerCase();
}
function textIncludes(value, term) {
return String(value || '').toLowerCase().includes(term);
}
function matchesRI(ri) {
if (!searchTerm) return true;
return [
ri.nama,
ri.jenis,
ri.kontak,
ri.alamat
].some(value => textIncludes(value, searchTerm));
}
function matchesPM(pm) {
if (!searchTerm) return true;
const anggotaMatch = Array.isArray(pm.anggota) && pm.anggota.some(anggota => [
anggota.nama,
anggota.hubungan,
anggota.pekerjaan
].some(value => textIncludes(value, searchTerm)));
return anggotaMatch || [
pm.kk_nama,
pm.kk_nik,
pm.alamat,
pm.status_bantuan,
pm.jenis_bantuan,
pm.kategori_kebutuhan,
pm.deskripsi_kebutuhan,
pm.tindak_lanjut,
pm.kondisi_rumah,
pm.rumah_ibadah_nama
].some(value => textIncludes(value, searchTerm));
}
function getFilteredRI() {
const filterJenis = document.getElementById('filter-jenis').value;
return riData.filter(ri => {
if (filterJenis !== 'all' && ri.jenis !== filterJenis) return false;
return matchesRI(ri);
});
}
function getFilteredPM() {
const filterStatus = document.getElementById('filter-status').value;
const filterKebutuhan = document.getElementById('filter-kebutuhan').value;
return pmData.filter(pm => {
if (filterStatus !== 'all' && pm.status_bantuan !== filterStatus) return false;
if (filterKebutuhan !== 'all' && pm.kategori_kebutuhan !== filterKebutuhan) return false;
return matchesPM(pm);
});
}
function renderMapLayers() {
renderRI();
renderPM();
}
function applyMapSearch(focusResult = false) {
searchTerm = getSearchTerm();
renderMapLayers();
if (focusResult) {
switchView('map');
focusSearchResults();
}
}
function focusSearchResults() {
const results = [
...getFilteredRI().map(item => [parseFloat(item.lat), parseFloat(item.lng)]),
...getFilteredPM().map(item => [parseFloat(item.lat), parseFloat(item.lng)])
].filter(([lat, lng]) => Number.isFinite(lat) && Number.isFinite(lng));
if (!searchTerm || results.length === 0) {
alert(searchTerm ? 'Data tidak ditemukan di peta.' : 'Masukkan kata kunci pencarian.');
return;
}
if (results.length === 1) {
map.flyTo(results[0], 16);
return;
}
map.fitBounds(L.latLngBounds(results), { padding: [40, 40], maxZoom: 16 });
}
function pickLocation(mode) {
switchView('map');
pickingMode = mode;
closeModal('modal-' + mode);
}
function closeModal(id) {
document.getElementById(id).classList.remove('show');
}
async function reverseGeocode(lat, lng) {
try {
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}`);
const data = await res.json();
return data.display_name || 'Alamat tidak ditemukan';
} catch (e) {
return 'Gagal memuat alamat';
}
}
function toggleBantuanFields() {
const status = document.getElementById('pm-status').value;
document.getElementById('bantuan-fields').style.display = status === 'Sudah dibantu' ? 'block' : 'none';
}
function addAnggotaField(data = null) {
const container = document.getElementById('anggota-container');
const id = Date.now() + Math.floor(Math.random() * 100);
const html = `
<div class="dynamic-item" id="anggota-${id}">
<button type="button" class="btn-remove" onclick="document.getElementById('anggota-${id}').remove()">X</button>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
<div class="form-group">
<label>Nama</label>
<input type="text" class="form-control a-nama" value="${data ? data.nama : ''}" required>
</div>
<div class="form-group">
<label>Hubungan</label>
<input type="text" class="form-control a-hubungan" value="${data ? data.hubungan : ''}" placeholder="Anak/Istri/dll" required>
</div>
<div class="form-group">
<label>Umur</label>
<input type="number" class="form-control a-umur" value="${data ? data.umur : ''}">
</div>
<div class="form-group">
<label>Pekerjaan</label>
<input type="text" class="form-control a-pekerjaan" value="${data && data.pekerjaan ? data.pekerjaan : ''}">
</div>
</div>
</div>
`;
container.insertAdjacentHTML('beforeend', html);
}
// Data Fetching and Rendering
async function loadData() {
await loadStats();
await fetchRI();
await fetchPM();
}
async function loadStats() {
try {
const res = await fetch('api/dashboard_stats.php');
const json = await res.json();
if(json.success) {
document.getElementById('stat-ri').innerText = json.data.total_rumah_ibadah;
document.getElementById('stat-pm').innerText = json.data.total_penduduk;
document.getElementById('stat-sudah').innerText = json.data.sudah_dibantu;
document.getElementById('stat-belum').innerText = json.data.belum_dibantu;
document.getElementById('stat-menunggu').innerText = json.data.menunggu_verifikasi || 0;
document.getElementById('stat-prioritas').innerText = json.data.prioritas_tinggi || 0;
document.getElementById('stat-luar-radius').innerText = json.data.luar_radius || 0;
document.getElementById('summary-sudah').innerText = json.data.sudah_dibantu;
document.getElementById('summary-belum').innerText = json.data.belum_dibantu;
document.getElementById('summary-menunggu').innerText = json.data.menunggu_verifikasi || 0;
renderDashboardLists(json.data);
}
} catch(e) { console.error(e); }
}
function renderDashboardLists(stats) {
const priorityList = document.getElementById('priority-list');
const needList = document.getElementById('need-list');
if (priorityList) {
const items = stats.top_prioritas || [];
priorityList.innerHTML = items.length ? items.map(item => `
<div class="priority-item">
<div class="priority-score">${item.skor_prioritas || 0}</div>
<div>
<strong>${item.kk_nama}</strong>
<p>${item.kategori_kebutuhan || 'Kebutuhan belum diisi'} - ${item.tindak_lanjut || 'Belum disurvei'}</p>
</div>
</div>
`).join('') : '<div class="need-item"><p>Belum ada data prioritas.</p></div>';
}
if (needList) {
const items = stats.kebutuhan_terbanyak || [];
needList.innerHTML = items.length ? items.map(item => `
<div class="need-item">
<strong>${item.kategori}</strong>
<p>${item.total} keluarga membutuhkan kategori ini</p>
</div>
`).join('') : '<div class="need-item"><p>Belum ada data kebutuhan.</p></div>';
}
}
async function fetchRI() {
try {
const res = await fetch('api/rumah_ibadah_read.php');
const json = await res.json();
if(json.success) {
riData = json.data;
renderRI();
}
} catch(e) { console.error(e); }
}
async function fetchPM() {
try {
const res = await fetch('api/penduduk_read.php');
const json = await res.json();
if(json.success) {
pmData = json.data;
renderPM();
}
} catch(e) { console.error(e); }
}
function renderRI() {
layerRI.clearLayers();
layerRadius.clearLayers();
getFilteredRI().forEach(ri => {
const lat = parseFloat(ri.lat);
const lng = parseFloat(ri.lng);
const rad = parseInt(ri.radius) || 500;
// Circle
L.circle([lat, lng], {
color: '#4f46e5',
fillColor: '#4f46e5',
fillOpacity: 0.1,
radius: rad,
interactive: false
}).addTo(layerRadius);
// Marker
const marker = L.marker([lat, lng], { icon: getRumahIbadahIcon(ri.jenis) }).addTo(layerRI);
marker.feature = ri;
let popup = `
<div class="popup-header">
<h3>${ri.nama}</h3>
<p>${ri.jenis}</p>
</div>
<div class="popup-body">
<div class="popup-row"><div class="label">Kontak</div><div class="value">${ri.kontak || '-'}</div></div>
<div class="popup-row"><div class="label">Radius</div><div class="value">${rad} m</div></div>
<div class="popup-row" style="flex-direction: column;"><div class="label" style="width:100%">Alamat</div><div class="value" style="font-size:0.8rem; font-weight:400">${ri.alamat}</div></div>
<div class="popup-actions">
<button class="btn-edit" onclick='editRI(${JSON.stringify(ri)})'>Edit</button>
<button class="btn-delete" onclick='deleteRI(${ri.id})'>Hapus</button>
</div>
</div>
`;
marker.bindPopup(popup);
});
layerRadius.eachLayer(circle => circle.bringToBack());
}
function renderPM() {
layerPM.clearLayers();
getFilteredPM().forEach(pm => {
const lat = parseFloat(pm.lat);
const lng = parseFloat(pm.lng);
const anggotaCount = pm.anggota ? pm.anggota.length : 0;
let badgeClass = 'badge-danger';
if(pm.status_bantuan === 'Sudah dibantu') badgeClass = 'badge-success';
else if(pm.status_bantuan === 'Menunggu verifikasi') badgeClass = 'badge-warning';
const isBlankSpot = pm.is_blank_spot == 1;
const pmColor = isBlankSpot ? '#ef4444' : '#10b981'; // Merah (Blank Spot), Hijau (Terjangkau)
const riwayatHtml = Array.isArray(pm.riwayat_bantuan) && pm.riwayat_bantuan.length
? pm.riwayat_bantuan.slice(0, 3).map(r => `${r.tanggal_bantuan}: ${r.jenis_bantuan}`).join('<br>')
: '-';
const buktiHtml = pm.bukti_file
? `<a href="${pm.bukti_file}" target="_blank">Lihat bukti</a>`
: '-';
const marker = L.circleMarker([lat, lng], {
radius: 7,
fillColor: pmColor,
color: '#ffffff',
weight: 1.5,
opacity: 1,
fillOpacity: 0.9
}).addTo(layerPM);
marker.feature = pm;
let popup = `
<div class="popup-header ${pm.status_bantuan === 'Sudah dibantu' ? 'success' : pm.status_bantuan === 'Menunggu verifikasi' ? 'warning' : 'danger'}">
<h3>Keluarga ${pm.kk_nama}</h3>
<span class="badge ${badgeClass}">${pm.status_bantuan}</span>
</div>
<div class="popup-body">
<div class="popup-row"><div class="label">Jumlah Anggota</div><div class="value">${anggotaCount} orang</div></div>
<div class="popup-row"><div class="label">Rumah Ibadah Terdekat</div><div class="value">${pm.rumah_ibadah_nama || '-'}</div></div>
<div class="popup-row"><div class="label">Prioritas</div><div class="value">${pm.skor_prioritas || 0} (${pm.label_prioritas || '-'})</div></div>
<div class="popup-row"><div class="label">Kebutuhan</div><div class="value">${pm.kategori_kebutuhan || '-'}</div></div>
<div class="popup-row"><div class="label">Tindak Lanjut</div><div class="value">${pm.tindak_lanjut || 'Belum disurvei'}</div></div>
<div class="popup-row"><div class="label">Jarak Lurus</div><div class="value">${pm.jarak_m ? pm.jarak_m + ' meter' : '-'} ${isBlankSpot ? '<i style="color:red">(Di luar radius)</i>' : '<i style="color:green">(Terjangkau)</i>'}</div></div>
<div class="popup-row"><div class="label">Jarak Rute</div><div class="value">${pm.jarak_rute_m ? pm.jarak_rute_m + ' meter' : '-'}</div></div>
${pm.status_bantuan === 'Sudah dibantu' ? `<div class="popup-row"><div class="label">Bantuan</div><div class="value">${pm.jenis_bantuan} (${pm.tanggal_bantuan})</div></div>` : ''}
<div class="popup-row"><div class="label">Riwayat</div><div class="value">${riwayatHtml}</div></div>
<div class="popup-row"><div class="label">Bukti</div><div class="value">${buktiHtml}</div></div>
<div class="popup-row" style="flex-direction: column;"><div class="label" style="width:100%">Alamat</div><div class="value" style="font-size:0.8rem; font-weight:400">${pm.alamat}</div></div>
<div class="popup-actions">
<button class="btn-edit" onclick='editPM(${JSON.stringify(pm).replace(/'/g, "\\'")})'>Edit</button>
<button class="btn-edit" onclick='openBantuanModal(${pm.id})'>Bantuan</button>
<button class="btn-delete" onclick='deletePM(${pm.id})'>Hapus</button>
</div>
</div>
`;
marker.bindPopup(popup);
});
layerPM.bringToFront();
}
// CRUD Operations
async function saveRI() {
const id = document.getElementById('ri-id').value;
const data = {
nama: document.getElementById('ri-nama').value,
jenis: document.getElementById('ri-jenis').value,
kontak: document.getElementById('ri-kontak').value,
lat: document.getElementById('ri-lat').value,
lng: document.getElementById('ri-lng').value,
alamat: document.getElementById('ri-alamat').value,
radius: document.getElementById('ri-radius').value
};
if(!data.nama || !data.lat) { alert("Isi form dengan lengkap"); return; }
const url = id ? 'api/rumah_ibadah_update.php' : 'api/rumah_ibadah_create.php';
if(id) data.id = id;
try {
const res = await fetch(url, {
method: 'POST',
body: JSON.stringify(data)
});
const json = await res.json();
if(json.success) {
closeModal('modal-ri');
await fetchRI();
await fetchPM();
await loadStats();
} else {
alert(json.message);
}
} catch(e) { alert('Error saving data'); }
}
async function savePM() {
const id = document.getElementById('pm-id').value;
const data = new FormData();
const anggota = [];
data.append('kk_nama', document.getElementById('pm-nama').value);
data.append('kk_nik', document.getElementById('pm-nik').value);
data.append('lat', document.getElementById('pm-lat').value);
data.append('lng', document.getElementById('pm-lng').value);
data.append('alamat', document.getElementById('pm-alamat').value);
data.append('kategori_kebutuhan', document.getElementById('pm-kategori-kebutuhan').value);
data.append('deskripsi_kebutuhan', document.getElementById('pm-deskripsi-kebutuhan').value);
data.append('kondisi_rumah', document.getElementById('pm-kondisi-rumah').value);
data.append('penghasilan', document.getElementById('pm-penghasilan').value);
data.append('status_bantuan', document.getElementById('pm-status').value);
data.append('jenis_bantuan', document.getElementById('pm-jenis-bantuan').value);
data.append('tanggal_bantuan', document.getElementById('pm-tanggal-bantuan').value);
data.append('tindak_lanjut', document.getElementById('pm-tindak-lanjut').value);
data.append('catatan_survei', document.getElementById('pm-catatan-survei').value);
data.append('existing_bukti_file', document.getElementById('pm-existing-bukti-file').value);
const buktiFile = document.getElementById('pm-bukti-file').files[0];
if (buktiFile) data.append('bukti_file', buktiFile);
if(!data.get('kk_nama') || !data.get('lat')) { alert("Isi form dengan lengkap"); return; }
if (data.get('penghasilan') !== '' && Number(data.get('penghasilan')) < 0) {
alert('Penghasilan tidak boleh bernilai negatif.');
document.getElementById('pm-penghasilan').focus();
return;
}
const items = document.querySelectorAll('.dynamic-item');
items.forEach(el => {
anggota.push({
nama: el.querySelector('.a-nama').value,
hubungan: el.querySelector('.a-hubungan').value,
umur: el.querySelector('.a-umur').value,
pekerjaan: el.querySelector('.a-pekerjaan').value
});
});
data.append('anggota', JSON.stringify(anggota));
const url = id ? 'api/penduduk_update.php' : 'api/penduduk_create.php';
if(id) data.append('id', id);
try {
const res = await fetch(url, {
method: 'POST',
body: data
});
const json = await res.json();
if(json.success) {
closeModal('modal-pm');
await fetchPM();
await loadStats();
if(json.ibadah_id) {
const ri = riData.find(r => r.id == json.ibadah_id);
if(ri) alert(`Warga otomatis dimasukkan ke binaan: ${ri.nama}`);
}
} else {
alert(json.message);
}
} catch(e) { alert('Error saving data'); }
}
function editRI(data) {
document.getElementById('ri-id').value = data.id;
document.getElementById('ri-nama').value = data.nama;
document.getElementById('ri-jenis').value = data.jenis;
document.getElementById('ri-kontak').value = data.kontak;
document.getElementById('ri-lat').value = data.lat;
document.getElementById('ri-lng').value = data.lng;
document.getElementById('ri-alamat').value = data.alamat;
document.getElementById('ri-radius').value = data.radius;
document.getElementById('modal-ri-title').innerText = 'Edit Rumah Ibadah';
document.getElementById('modal-ri').classList.add('show');
}
function editPM(data) {
document.getElementById('pm-id').value = data.id;
document.getElementById('pm-nama').value = data.kk_nama;
document.getElementById('pm-nik').value = data.kk_nik;
document.getElementById('pm-lat').value = data.lat;
document.getElementById('pm-lng').value = data.lng;
document.getElementById('pm-alamat').value = data.alamat;
document.getElementById('pm-kategori-kebutuhan').value = data.kategori_kebutuhan || '';
document.getElementById('pm-deskripsi-kebutuhan').value = data.deskripsi_kebutuhan || '';
document.getElementById('pm-kondisi-rumah').value = data.kondisi_rumah || '';
document.getElementById('pm-penghasilan').value = data.penghasilan || '';
document.getElementById('pm-status').value = data.status_bantuan;
document.getElementById('pm-jenis-bantuan').value = data.jenis_bantuan;
document.getElementById('pm-tanggal-bantuan').value = data.tanggal_bantuan;
document.getElementById('pm-tindak-lanjut').value = data.tindak_lanjut || 'Belum disurvei';
document.getElementById('pm-catatan-survei').value = data.catatan_survei || '';
document.getElementById('pm-existing-bukti-file').value = data.bukti_file || '';
document.getElementById('pm-bukti-file').value = '';
toggleBantuanFields();
document.getElementById('anggota-container').innerHTML = '';
if(data.anggota && data.anggota.length > 0) {
data.anggota.forEach(a => addAnggotaField(a));
}
document.getElementById('modal-pm-title').innerText = 'Edit Penduduk Miskin';
document.getElementById('modal-pm').classList.add('show');
}
function setBantuanAlert(message = '', type = 'error') {
const alertBox = document.getElementById('bantuan-alert');
if (!alertBox) return;
alertBox.textContent = message;
alertBox.hidden = !message;
alertBox.classList.toggle('success', type === 'success');
}
function openBantuanModal(id) {
const penerima = pmData.find(pm => String(pm.id) === String(id));
const form = document.getElementById('form-bantuan');
form.reset();
setBantuanAlert();
document.getElementById('bantuan-penduduk-id').value = id;
document.getElementById('bantuan-penerima').value = penerima ? `Keluarga ${penerima.kk_nama}` : `ID ${id}`;
document.getElementById('bantuan-jenis').value = penerima?.kategori_kebutuhan || '';
document.getElementById('bantuan-tanggal').value = new Date().toISOString().slice(0, 10);
document.getElementById('bantuan-pemberi').value = 'Admin';
document.getElementById('bantuan-bukti-file').value = '';
document.getElementById('btn-save-bantuan').disabled = false;
document.getElementById('modal-bantuan').classList.add('show');
}
async function saveBantuan() {
const id = document.getElementById('bantuan-penduduk-id').value;
const jenis = document.getElementById('bantuan-jenis').value.trim();
const tanggal = document.getElementById('bantuan-tanggal').value;
const nilai = document.getElementById('bantuan-nilai').value;
const pemberi = document.getElementById('bantuan-pemberi').value.trim() || 'Admin';
const catatan = document.getElementById('bantuan-catatan').value.trim();
const buktiFile = document.getElementById('bantuan-bukti-file').files[0];
const saveButton = document.getElementById('btn-save-bantuan');
if (!id || !jenis || !tanggal) {
setBantuanAlert('Keluarga penerima, jenis bantuan, dan tanggal wajib diisi.');
return;
}
const formData = new FormData();
formData.append('penduduk_id', id);
formData.append('jenis_bantuan', jenis);
formData.append('tanggal_bantuan', tanggal);
formData.append('nilai_bantuan', nilai || '');
formData.append('pemberi_bantuan', pemberi);
formData.append('catatan', catatan || '');
if (buktiFile) formData.append('bukti_file', buktiFile);
try {
saveButton.disabled = true;
saveButton.innerText = 'Menyimpan...';
const res = await fetch('api/bantuan_create.php', { method: 'POST', body: formData });
const json = await res.json();
if (json.success) {
closeModal('modal-bantuan');
await fetchPM();
await loadStats();
renderMapLayers();
} else {
setBantuanAlert(json.message || 'Gagal menambah bantuan.');
}
} catch(e) {
setBantuanAlert('Terjadi kesalahan saat menyimpan bantuan.');
} finally {
saveButton.disabled = false;
saveButton.innerText = 'Simpan Bantuan';
}
}
async function deleteRI(id) {
if(!confirm('Hapus rumah ibadah ini?')) return;
try {
const res = await fetch('api/rumah_ibadah_delete.php', { method:'POST', body:JSON.stringify({id}) });
const json = await res.json();
if(json.success) { fetchRI(); fetchPM(); loadStats(); }
} catch(e) { alert('Error'); }
}
async function deletePM(id) {
if(!confirm('Hapus penduduk miskin ini?')) return;
try {
const res = await fetch('api/penduduk_delete.php', { method:'POST', body:JSON.stringify({id}) });
const json = await res.json();
if(json.success) { fetchPM(); loadStats(); }
} catch(e) { alert('Error'); }
}
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0;url=index.php">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebGIS Peduli</title>
</head>
<body>
<script>window.location.replace('index.php');</script>
<p>Mengalihkan ke halaman sistem...</p>
</body>
</html>
+498
View File
@@ -0,0 +1,498 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/api/auth.php';
require_admin_login();
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebGIS Sebaran Penduduk Miskin</title>
<!-- Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Leaflet CSS -->
<link rel="stylesheet" href="assets/vendor/leaflet/leaflet.css" />
<!-- Custom CSS -->
<link rel="stylesheet" href="assets/css/style.css?v=<?= filemtime(__DIR__ . '/assets/css/style.css') ?>">
</head>
<body>
<div class="dashboard-container">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<i class="fas fa-map-marked-alt fa-2x" style="color: var(--primary);"></i>
<h2>WebGIS Peduli</h2>
</div>
<div class="sidebar-menu">
<div class="menu-item active" id="menu-dashboard" data-view="dashboard">
<i class="fas fa-home"></i> Dashboard
</div>
<div class="menu-item" id="menu-map" data-view="map">
<i class="fas fa-map"></i> Peta
</div>
<div class="menu-item" id="menu-add-ri">
<i class="fas fa-plus-circle"></i> Tambah Rumah Ibadah
</div>
<div class="menu-item" id="menu-add-pm">
<i class="fas fa-user-plus"></i> Tambah Penduduk
</div>
<a class="menu-item" href="laporan.php">
<i class="fas fa-file-lines"></i> Laporan
</a>
</div>
</aside>
<!-- Main Content -->
<main class="main-content dashboard-mode">
<!-- Topbar -->
<header class="topbar">
<button id="toggle-sidebar" class="btn-icon"><i class="fas fa-bars"></i></button>
<div class="search-bar hidden">
<input type="text" id="search-input" placeholder="Cari nama warga / rumah ibadah...">
<button id="search-button" type="button"><i class="fas fa-search"></i></button>
</div>
<div class="map-header-controls hidden">
<div class="header-filter">
<i class="fas fa-circle-check"></i>
<label>Status</label>
<select id="filter-status">
<option value="all">Semua Status</option>
<option value="Sudah dibantu">Sudah Dibantu</option>
<option value="Belum dibantu">Belum Dibantu</option>
<option value="Menunggu verifikasi">Menunggu Verifikasi</option>
</select>
</div>
<div class="header-filter">
<i class="fas fa-place-of-worship"></i>
<label>Jenis</label>
<select id="filter-jenis">
<option value="all">Semua Jenis</option>
<option value="Masjid">Masjid</option>
<option value="Gereja">Gereja</option>
<option value="Pura">Pura</option>
<option value="Vihara">Vihara</option>
<option value="Klenteng">Klenteng</option>
</select>
</div>
<div class="header-filter">
<i class="fas fa-hand-holding-heart"></i>
<label>Kebutuhan</label>
<select id="filter-kebutuhan">
<option value="all">Semua Kebutuhan</option>
<option value="Sembako">Sembako</option>
<option value="Pendidikan">Pendidikan</option>
<option value="Kesehatan">Kesehatan</option>
<option value="Modal Usaha">Modal Usaha</option>
<option value="Rumah Tidak Layak">Rumah Tidak Layak</option>
<option value="Bantuan Darurat">Bantuan Darurat</option>
</select>
</div>
</div>
<div class="user-profile">
<span><?= htmlspecialchars(current_admin_name(), ENT_QUOTES, 'UTF-8') ?></span>
<div class="avatar"><i class="fas fa-user-shield"></i></div>
<a class="logout-link" href="logout.php" title="Logout"><i class="fas fa-right-from-bracket"></i></a>
</div>
</header>
<!-- Dashboard -->
<section id="dashboard-view" class="view-panel dashboard-view active">
<div class="dashboard-page-header">
<div>
<p class="eyebrow">Ringkasan Data</p>
<h1>Dashboard WebGIS Peduli</h1>
<p>Pantau sebaran warga, status bantuan, dan cakupan layanan rumah ibadah dari satu halaman.</p>
</div>
<div class="dashboard-header-actions">
<button class="btn btn-primary" id="dashboard-open-map"><i class="fas fa-map-location-dot"></i> Buka Peta</button>
</div>
</div>
<div class="stats-container" id="stats-container">
<div class="stat-card">
<h3>Rumah Ibadah</h3>
<div class="value" id="stat-ri">0</div>
</div>
<div class="stat-card">
<h3>Total Warga</h3>
<div class="value" id="stat-pm">0</div>
</div>
<div class="stat-card success">
<h3>Sudah Dibantu</h3>
<div class="value" id="stat-sudah">0</div>
</div>
<div class="stat-card warning">
<h3>Menunggu</h3>
<div class="value" id="stat-menunggu">0</div>
</div>
<div class="stat-card danger">
<h3>Belum Dibantu</h3>
<div class="value" id="stat-belum">0</div>
</div>
<div class="stat-card warning">
<h3>Prioritas Tinggi</h3>
<div class="value" id="stat-prioritas">0</div>
</div>
<div class="stat-card danger">
<h3>Luar Radius</h3>
<div class="value" id="stat-luar-radius">0</div>
</div>
</div>
<div class="dashboard-grid">
<div class="dashboard-card status-card">
<div class="card-title">
<h2>Status Bantuan</h2>
<span>Data keluarga miskin</span>
</div>
<div class="status-list">
<div class="status-item">
<span class="status-dot success"></span>
<div>
<strong id="summary-sudah">0</strong>
<p>Sudah menerima bantuan</p>
</div>
</div>
<div class="status-item">
<span class="status-dot warning"></span>
<div>
<strong id="summary-menunggu">0</strong>
<p>Menunggu verifikasi</p>
</div>
</div>
<div class="status-item">
<span class="status-dot danger"></span>
<div>
<strong id="summary-belum">0</strong>
<p>Belum menerima bantuan</p>
</div>
</div>
</div>
</div>
<div class="dashboard-card">
<div class="card-title">
<h2>Aksi Cepat</h2>
<span>Input dan pemetaan data</span>
</div>
<div class="quick-actions">
<button class="quick-action" id="dashboard-add-ri">
<i class="fas fa-place-of-worship"></i>
<span>Tambah Rumah Ibadah</span>
</button>
<button class="quick-action" id="dashboard-add-pm">
<i class="fas fa-user-plus"></i>
<span>Tambah Penduduk</span>
</button>
<a class="quick-action" href="laporan.php?prioritas=tinggi">
<i class="fas fa-file-export"></i>
<span>Cetak Laporan Prioritas</span>
</a>
</div>
</div>
<div class="dashboard-card map-summary-card">
<div class="card-title">
<h2>Legenda Peta</h2>
<span>Indikator cakupan layanan</span>
</div>
<div class="legend-list">
<div><span class="legend-marker ri"></span> Rumah ibadah dan radius layanan</div>
<div><span class="legend-marker reached"></span> Warga dalam radius layanan</div>
<div><span class="legend-marker blank"></span> Warga di luar radius layanan</div>
</div>
</div>
<div class="dashboard-card">
<div class="card-title">
<h2>Prioritas Bantuan</h2>
<span>Urutan paling mendesak</span>
</div>
<div class="priority-list" id="priority-list"></div>
</div>
<div class="dashboard-card">
<div class="card-title">
<h2>Kebutuhan Terbanyak</h2>
<span>Basis penyaluran bantuan</span>
</div>
<div class="need-list" id="need-list"></div>
</div>
</div>
</section>
<!-- Peta -->
<section id="map-view" class="view-panel map-view">
<div id="map" class="map-fullscreen"></div>
<button id="btn-my-location" class="fab-location" title="Lokasi Saya"><i class="fas fa-crosshairs"></i></button>
<div class="fab-container">
<button class="fab-main" title="Tambah Data"><i class="fas fa-plus"></i></button>
<div class="fab-menu">
<button class="fab-item" id="fab-add-ri" title="Tambah Rumah Ibadah"><i class="fas fa-place-of-worship"></i></button>
<button class="fab-item" id="fab-add-pm" title="Tambah Penduduk Miskin"><i class="fas fa-user-plus"></i></button>
</div>
</div>
</section>
</main>
</div>
<!-- Modal Rumah Ibadah -->
<div class="modal-backdrop" id="modal-ri">
<div class="modal-content">
<div class="modal-header">
<h2 id="modal-ri-title">Tambah Rumah Ibadah</h2>
<button class="btn-close" onclick="closeModal('modal-ri')"><i class="fas fa-times"></i></button>
</div>
<div class="modal-body">
<form id="form-ri">
<input type="hidden" id="ri-id">
<div class="form-group">
<label>Nama Rumah Ibadah</label>
<input type="text" id="ri-nama" class="form-control" required>
</div>
<div class="form-group">
<label>Jenis</label>
<select id="ri-jenis" class="form-control" required>
<option value="Masjid">Masjid</option>
<option value="Gereja">Gereja</option>
<option value="Pura">Pura</option>
<option value="Vihara">Vihara</option>
<option value="Klenteng">Klenteng</option>
<option value="Lainnya">Lainnya</option>
</select>
</div>
<div class="form-group">
<label>Kontak</label>
<input type="text" id="ri-kontak" class="form-control">
</div>
<div class="form-group" style="display: flex; gap: 10px;">
<div style="flex: 1;">
<label>Latitude</label>
<input type="number" step="any" id="ri-lat" class="form-control" required readonly>
</div>
<div style="flex: 1;">
<label>Longitude</label>
<input type="number" step="any" id="ri-lng" class="form-control" required readonly>
</div>
<div style="display: flex; align-items: flex-end;">
<button type="button" class="btn btn-secondary" onclick="pickLocation('ri')"><i class="fas fa-map-marker-alt"></i></button>
</div>
</div>
<div class="form-group">
<label>Alamat (Otomatis)</label>
<textarea id="ri-alamat" class="form-control" rows="2" readonly></textarea>
</div>
<div class="form-group">
<label>Radius Pelayanan (meter)</label>
<input type="number" id="ri-radius" class="form-control" value="500" required>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('modal-ri')">Batal</button>
<button class="btn btn-primary" onclick="saveRI()">Simpan</button>
</div>
</div>
</div>
<!-- Modal Penduduk Miskin -->
<div class="modal-backdrop" id="modal-pm">
<div class="modal-content">
<div class="modal-header">
<h2 id="modal-pm-title">Tambah Penduduk Miskin</h2>
<button class="btn-close" onclick="closeModal('modal-pm')"><i class="fas fa-times"></i></button>
</div>
<div class="modal-body">
<form id="form-pm">
<input type="hidden" id="pm-id">
<h3 style="margin-bottom: 12px; border-bottom: 1px solid #eee; padding-bottom: 8px;">Data Kepala Keluarga</h3>
<div class="form-group">
<label>Nama Kepala Keluarga</label>
<input type="text" id="pm-nama" class="form-control" required>
</div>
<div class="form-group">
<label>NIK</label>
<input type="text" id="pm-nik" class="form-control">
</div>
<div class="form-group" style="display: flex; gap: 10px;">
<div style="flex: 1;">
<label>Latitude</label>
<input type="number" step="any" id="pm-lat" class="form-control" required readonly>
</div>
<div style="flex: 1;">
<label>Longitude</label>
<input type="number" step="any" id="pm-lng" class="form-control" required readonly>
</div>
<div style="display: flex; align-items: flex-end;">
<button type="button" class="btn btn-secondary" onclick="pickLocation('pm')"><i class="fas fa-map-marker-alt"></i></button>
</div>
</div>
<div class="form-group">
<label>Alamat</label>
<textarea id="pm-alamat" class="form-control" rows="2" readonly></textarea>
</div>
<h3 style="margin: 20px 0 12px; border-bottom: 1px solid #eee; padding-bottom: 8px;">Kebutuhan & Kondisi</h3>
<div class="form-group">
<label>Kategori Kebutuhan</label>
<select id="pm-kategori-kebutuhan" class="form-control">
<option value="">Pilih Kebutuhan</option>
<option value="Sembako">Sembako</option>
<option value="Pendidikan">Pendidikan</option>
<option value="Kesehatan">Kesehatan</option>
<option value="Modal Usaha">Modal Usaha</option>
<option value="Rumah Tidak Layak">Rumah Tidak Layak</option>
<option value="Bantuan Darurat">Bantuan Darurat</option>
</select>
</div>
<div class="form-group">
<label>Deskripsi Kebutuhan</label>
<textarea id="pm-deskripsi-kebutuhan" class="form-control" rows="2" placeholder="Contoh: butuh sembako bulanan, biaya obat, seragam sekolah"></textarea>
</div>
<div class="form-group" style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
<div>
<label>Kondisi Rumah</label>
<select id="pm-kondisi-rumah" class="form-control">
<option value="">Pilih Kondisi</option>
<option value="Layak">Layak</option>
<option value="Rentan">Rentan</option>
<option value="Tidak Layak">Tidak Layak</option>
</select>
</div>
<div>
<label>Penghasilan / Bulan</label>
<input type="number" id="pm-penghasilan" class="form-control" min="0" inputmode="numeric" placeholder="0">
</div>
</div>
<h3 style="margin: 20px 0 12px; border-bottom: 1px solid #eee; padding-bottom: 8px;">Status Bantuan</h3>
<div class="form-group">
<label>Status</label>
<select id="pm-status" class="form-control" onchange="toggleBantuanFields()" required>
<option value="Belum dibantu">Belum Dibantu</option>
<option value="Menunggu verifikasi">Menunggu Verifikasi</option>
<option value="Sudah dibantu">Sudah Dibantu</option>
</select>
</div>
<div id="bantuan-fields" style="display: none;">
<div class="form-group">
<label>Jenis Bantuan</label>
<input type="text" id="pm-jenis-bantuan" class="form-control">
</div>
<div class="form-group">
<label>Tanggal Bantuan</label>
<input type="date" id="pm-tanggal-bantuan" class="form-control">
</div>
</div>
<h3 style="margin: 20px 0 12px; border-bottom: 1px solid #eee; padding-bottom: 8px;">Survey Lapangan</h3>
<div class="form-group">
<label>Status Tindak Lanjut</label>
<select id="pm-tindak-lanjut" class="form-control">
<option value="Belum disurvei">Belum Disurvei</option>
<option value="Perlu survei">Perlu Survei</option>
<option value="Sudah disurvei">Sudah Disurvei</option>
<option value="Sudah diverifikasi">Sudah Diverifikasi</option>
<option value="Sedang diproses">Sedang Diproses</option>
<option value="Sudah dibantu">Sudah Dibantu</option>
</select>
</div>
<div class="form-group">
<label>Catatan Survey</label>
<textarea id="pm-catatan-survei" class="form-control" rows="2"></textarea>
</div>
<div class="form-group">
<label>Bukti / Foto / Dokumen</label>
<input type="file" id="pm-bukti-file" class="form-control" accept="image/*,.pdf">
<input type="hidden" id="pm-existing-bukti-file">
</div>
<h3 style="margin: 20px 0 12px; border-bottom: 1px solid #eee; padding-bottom: 8px; display: flex; justify-content: space-between;">
Anggota Keluarga
<button type="button" class="btn btn-secondary" style="padding: 4px 8px; font-size: 0.8rem;" onclick="addAnggotaField()"><i class="fas fa-plus"></i> Tambah</button>
</h3>
<div id="anggota-container" class="dynamic-list">
<!-- Dynamic fields here -->
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('modal-pm')">Batal</button>
<button class="btn btn-primary" onclick="savePM()">Simpan</button>
</div>
</div>
</div>
<!-- Modal Riwayat Bantuan -->
<div class="modal-backdrop" id="modal-bantuan">
<div class="modal-content modal-content-sm">
<div class="modal-header">
<div>
<p class="modal-kicker">Penyaluran Bantuan</p>
<h2 id="modal-bantuan-title">Tambah Riwayat Bantuan</h2>
</div>
<button class="btn-close" onclick="closeModal('modal-bantuan')"><i class="fas fa-times"></i></button>
</div>
<div class="modal-body">
<form id="form-bantuan">
<input type="hidden" id="bantuan-penduduk-id">
<div class="form-group">
<label>Keluarga Penerima</label>
<input type="text" id="bantuan-penerima" class="form-control" readonly>
</div>
<div class="form-group">
<label>Jenis Bantuan</label>
<input type="text" id="bantuan-jenis" class="form-control" list="bantuan-jenis-list" placeholder="Contoh: Sembako bulanan" required>
<datalist id="bantuan-jenis-list">
<option value="Sembako">
<option value="Pendidikan">
<option value="Kesehatan">
<option value="Modal Usaha">
<option value="Renovasi Rumah">
<option value="Bantuan Darurat">
</datalist>
</div>
<div class="form-grid two-cols">
<div class="form-group">
<label>Tanggal Bantuan</label>
<input type="date" id="bantuan-tanggal" class="form-control" required>
</div>
<div class="form-group">
<label>Nilai Bantuan</label>
<input type="number" id="bantuan-nilai" class="form-control" min="0" placeholder="Opsional">
</div>
</div>
<div class="form-group">
<label>Pemberi Bantuan</label>
<input type="text" id="bantuan-pemberi" class="form-control" value="Admin">
</div>
<div class="form-group">
<label>Catatan</label>
<textarea id="bantuan-catatan" class="form-control" rows="3" placeholder="Contoh: bantuan diserahkan langsung ke rumah penerima"></textarea>
</div>
<div class="form-group">
<label>Bukti Bantuan</label>
<input type="file" id="bantuan-bukti-file" class="form-control" accept="image/*,.pdf">
<p class="form-hint">Opsional, bisa berupa foto penyerahan atau dokumen PDF.</p>
</div>
<div class="form-alert" id="bantuan-alert" hidden></div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('modal-bantuan')">Batal</button>
<button class="btn btn-primary" id="btn-save-bantuan" onclick="saveBantuan()">Simpan Bantuan</button>
</div>
</div>
</div>
<!-- Scripts -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script src="assets/js/app.js?v=<?= filemtime(__DIR__ . '/assets/js/app.js') ?>"></script>
</body>
</html>
+132
View File
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/api/db_pdo.php';
require_once __DIR__ . '/api/auth.php';
require_once __DIR__ . '/api/penduduk_feature_helper.php';
require_admin_login();
ensurePendudukFeatureSchema($pdo);
refreshAllPriorityScores($pdo);
$status = trim((string)($_GET['status'] ?? ''));
$priority = trim((string)($_GET['prioritas'] ?? ''));
$where = [];
$params = [];
if ($status !== '') {
$where[] = 'p.status_bantuan = ?';
$params[] = $status;
}
if ($priority === 'tinggi') {
$where[] = 'p.skor_prioritas >= 70';
} elseif ($priority === 'sedang') {
$where[] = 'p.skor_prioritas BETWEEN 40 AND 69';
} elseif ($priority === 'rendah') {
$where[] = 'p.skor_prioritas < 40';
}
$sql = "
SELECT p.*, r.nama AS rumah_ibadah_nama
FROM penduduk_miskin p
LEFT JOIN rumah_ibadah r ON p.ibadah_id = r.id
";
if ($where) {
$sql .= ' WHERE ' . implode(' AND ', $where);
}
$sql .= ' ORDER BY p.skor_prioritas DESC, p.id DESC';
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$summary = [
'total' => count($rows),
'tinggi' => count(array_filter($rows, fn($r) => (int)$r['skor_prioritas'] >= 70)),
'belum' => count(array_filter($rows, fn($r) => $r['status_bantuan'] === 'Belum dibantu')),
'luar' => count(array_filter($rows, fn($r) => (int)$r['is_blank_spot'] === 1)),
];
function e(?string $value): string
{
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
}
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Laporan Prioritas Bantuan</title>
<link rel="stylesheet" href="assets/css/report.css?v=<?= filemtime(__DIR__ . '/assets/css/report.css') ?>">
</head>
<body>
<main class="report-page">
<header class="report-header">
<div>
<p class="eyebrow">WebGIS Peduli</p>
<h1>Laporan Prioritas Bantuan</h1>
<p>Dicetak pada <?= date('d/m/Y H:i') ?> oleh <?= e(current_admin_name()) ?></p>
</div>
<button onclick="window.print()" class="print-btn">Cetak / Simpan PDF</button>
</header>
<section class="summary-grid">
<div><span>Total Data</span><strong><?= $summary['total'] ?></strong></div>
<div><span>Prioritas Tinggi</span><strong><?= $summary['tinggi'] ?></strong></div>
<div><span>Belum Dibantu</span><strong><?= $summary['belum'] ?></strong></div>
<div><span>Luar Radius</span><strong><?= $summary['luar'] ?></strong></div>
</section>
<section class="report-note">
<strong>Keterangan:</strong> Skor prioritas dihitung dari status bantuan, jumlah tanggungan, lansia/balita,
penghasilan, kondisi rumah, kebutuhan mendesak, dan posisi terhadap radius layanan.
</section>
<table>
<thead>
<tr>
<th>No</th>
<th>Kepala Keluarga</th>
<th>Prioritas</th>
<th>Kebutuhan</th>
<th>Status</th>
<th>Tindak Lanjut</th>
<th>Rumah Ibadah</th>
<th>Jarak</th>
<th>Catatan</th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $i => $row): ?>
<tr>
<td><?= $i + 1 ?></td>
<td>
<strong><?= e($row['kk_nama']) ?></strong><br>
<small><?= e($row['alamat']) ?></small>
</td>
<td>
<strong><?= (int)$row['skor_prioritas'] ?></strong><br>
<small><?= e(getPriorityLabel((int)$row['skor_prioritas'])) ?></small>
</td>
<td><?= e($row['kategori_kebutuhan'] ?: 'Belum diisi') ?></td>
<td><?= e($row['status_bantuan']) ?></td>
<td><?= e($row['tindak_lanjut'] ?: 'Belum disurvei') ?></td>
<td><?= e($row['rumah_ibadah_nama'] ?: '-') ?></td>
<td>
Lurus: <?= e((string)$row['jarak_m']) ?> m<br>
<small>Rute: <?= e((string)($row['jarak_rute_m'] ?? '-')) ?> m</small>
</td>
<td><?= e($row['catatan_survei'] ?: $row['deskripsi_kebutuhan'] ?: '-') ?></td>
</tr>
<?php endforeach; ?>
<?php if (!$rows): ?>
<tr><td colspan="9" class="empty">Tidak ada data sesuai filter.</td></tr>
<?php endif; ?>
</tbody>
</table>
</main>
</body>
</html>
+106
View File
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/api/db_pdo.php';
require_once __DIR__ . '/api/auth.php';
start_admin_session();
ensure_admin_table($pdo);
if (is_admin_logged_in()) {
header('Location: index.php');
exit;
}
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$token = (string)($_POST['csrf_token'] ?? '');
$username = trim((string)($_POST['username'] ?? ''));
$password = (string)($_POST['password'] ?? '');
if (!hash_equals((string)$_SESSION['csrf_token'], $token)) {
$error = 'Sesi form tidak valid. Muat ulang halaman lalu coba lagi.';
} elseif ($username === '' || $password === '') {
$error = 'Username dan password wajib diisi.';
} else {
$stmt = $pdo->prepare("SELECT * FROM admins WHERE username = ? AND is_active = 1 LIMIT 1");
$stmt->execute([$username]);
$admin = $stmt->fetch();
if ($admin && password_verify($password, $admin['password_hash'])) {
session_regenerate_id(true);
$_SESSION['admin_id'] = (int)$admin['id'];
$_SESSION['admin_username'] = $admin['username'];
$_SESSION['admin_name'] = $admin['nama'];
unset($_SESSION['csrf_token']);
header('Location: index.php');
exit;
}
$error = 'Username atau password salah.';
}
}
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login Admin - WebGIS Peduli</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="assets/css/login.css">
</head>
<body>
<main class="login-page">
<section class="login-panel">
<div class="brand-mark">
<i class="fas fa-map-location-dot"></i>
</div>
<p class="eyebrow">Admin Area</p>
<h1>Masuk ke WebGIS Peduli</h1>
<p class="subtitle">Gunakan akun admin untuk mengelola data peta, warga, dan rumah ibadah.</p>
<?php if ($error !== ''): ?>
<div class="alert-error">
<i class="fas fa-circle-exclamation"></i>
<span><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></span>
</div>
<?php endif; ?>
<form method="POST" class="login-form" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars((string)$_SESSION['csrf_token'], ENT_QUOTES, 'UTF-8') ?>">
<label class="input-group">
<span>Username</span>
<div class="input-shell">
<i class="fas fa-user"></i>
<input type="text" name="username" placeholder="admin" required autofocus>
</div>
</label>
<label class="input-group">
<span>Password</span>
<div class="input-shell">
<i class="fas fa-lock"></i>
<input type="password" name="password" placeholder="Masukkan password" required>
</div>
</label>
<button type="submit" class="btn-login">
<span>Masuk</span>
<i class="fas fa-arrow-right"></i>
</button>
</form>
<p class="login-hint">Akun awal: <strong>admin</strong> / <strong>admin123</strong></p>
</section>
</main>
</body>
</html>
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/api/auth.php';
start_admin_session();
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(
session_name(),
'',
time() - 42000,
$params['path'],
$params['domain'],
(bool)$params['secure'],
(bool)$params['httponly']
);
}
session_destroy();
header('Location: login.php');
exit;
+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/api/auth.php';
require_admin_login();
function e(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
}
$baseUrl = 'http://localhost/Leaflet2/';
$projectUrl = '[ISI URL PROJECT KELAS DI SINI]';
$giteaUrl = '[https://git.ifuntanhub.dev/Arif1031/WebGIS-PovertyMap.git]';
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Panduan Penggunaan WebGIS Poverty Map</title>
<link rel="stylesheet" href="assets/css/guide.css?v=<?= filemtime(__DIR__ . '/assets/css/guide.css') ?>">
</head>
<body>
<main class="guide-page">
<header class="guide-hero">
<div>
<p class="eyebrow">Panduan Penggunaan</p>
<h1>WebGIS Poverty Map</h1>
<p class="lead">Panduan singkat untuk mengakses, mengelola data peta, mencatat bantuan, dan mencetak laporan prioritas masyarakat miskin/kurang mampu.</p>
</div>
<button class="print-btn" onclick="window.print()">Cetak / Simpan PDF</button>
</header>
<section class="access-panel">
<h2>Informasi Akses Project</h2>
<div class="info-grid">
<div>
<span>URL Aplikasi Lokal</span>
<strong><?= e($baseUrl) ?></strong>
</div>
<div>
<span>URL Project Kelas</span>
<strong><?= e($projectUrl) ?></strong>
</div>
<div>
<span>Link Repo Gitea</span>
<strong><?= e($giteaUrl) ?></strong>
</div>
</div>
</section>
<section>
<h2>Akun dan Role</h2>
<table>
<thead>
<tr>
<th>Role</th>
<th>Username</th>
<th>Password</th>
<th>Hak Akses</th>
</tr>
</thead>
<tbody>
<tr>
<td>Admin</td>
<td><code>admin</code></td>
<td><code>admin123</code></td>
<td>Mengelola dashboard, peta, rumah ibadah, penduduk miskin, bantuan, bukti, dan laporan.</td>
</tr>
</tbody>
</table>
<p class="note">Versi sistem saat ini hanya memiliki satu role aktif, yaitu Admin. Jika role baru ditambahkan, akun dapat ditulis pada tabel ini.</p>
</section>
<section class="steps">
<h2>Alur Penggunaan Utama</h2>
<ol>
<li>Buka URL aplikasi, lalu login menggunakan akun admin.</li>
<li>Periksa ringkasan data pada Dashboard untuk melihat jumlah rumah ibadah, warga, status bantuan, prioritas, dan warga luar radius.</li>
<li>Buka menu Peta untuk melihat marker rumah ibadah, radius layanan, dan marker warga.</li>
<li>Gunakan pencarian dan filter untuk menemukan warga, rumah ibadah, status bantuan, jenis rumah ibadah, atau kategori kebutuhan.</li>
<li>Tambah atau edit rumah ibadah melalui menu atau tombol tambah, lalu pilih titik koordinat di peta.</li>
<li>Tambah atau edit penduduk miskin, lengkapi kebutuhan, kondisi rumah, penghasilan, status survei, anggota keluarga, dan bukti jika ada.</li>
<li>Gunakan tombol Bantuan pada popup warga untuk mencatat riwayat bantuan tanpa popup browser.</li>
<li>Buka Laporan untuk mencetak atau menyimpan daftar prioritas bantuan sebagai PDF.</li>
</ol>
</section>
<section>
<h2>Keterangan Warna Peta</h2>
<div class="legend-grid">
<div><span class="dot worship"></span><strong>Rumah Ibadah</strong><p>Pusat layanan atau rujukan bantuan sosial.</p></div>
<div><span class="dot reached"></span><strong>Warga Dalam Radius</strong><p>Warga berada dalam radius layanan rumah ibadah.</p></div>
<div><span class="dot outside"></span><strong>Warga Luar Radius</strong><p>Warga berada di luar semua radius layanan.</p></div>
</div>
</section>
<section>
<h2>Catatan Pengisian Data</h2>
<ul>
<li>Radius default rumah ibadah adalah 500 meter dan dapat diubah oleh admin.</li>
<li>Status dalam/luar radius dihitung menggunakan jarak lurus pada peta.</li>
<li>Jarak rute jalan ditampilkan sebagai informasi tambahan jika layanan OSRM berhasil diakses.</li>
<li>Penghasilan tidak boleh bernilai negatif.</li>
<li>Bukti bantuan atau survei dapat berupa JPG, PNG, WEBP, atau PDF dengan ukuran maksimal 3 MB.</li>
</ul>
</section>
</main>
</body>
</html>
+76
View File
@@ -0,0 +1,76 @@
DROP DATABASE IF EXISTS webgis_miskin;
CREATE DATABASE webgis_miskin CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE webgis_miskin;
CREATE TABLE admins (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
nama VARCHAR(100) NOT NULL DEFAULT 'Administrator',
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
INSERT INTO admins (username, password_hash, nama) VALUES
('admin', '$2y$10$7O061pVz/aZ5YNK/fQyYIePIPnzDb.uZn38pC/Te3s7gRyko20dIK', 'Administrator');
CREATE TABLE rumah_ibadah (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(150) NOT NULL,
jenis ENUM('Masjid', 'Gereja', 'Pura', 'Vihara', 'Klenteng', 'Lainnya') NOT NULL,
kontak VARCHAR(50),
alamat TEXT,
lat DECIMAL(10, 8) NOT NULL,
lng DECIMAL(11, 8) NOT NULL,
radius INT NOT NULL DEFAULT 500,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
CREATE TABLE penduduk_miskin (
id INT AUTO_INCREMENT PRIMARY KEY,
kk_nama VARCHAR(150) NOT NULL,
kk_nik VARCHAR(20),
alamat TEXT,
lat DECIMAL(10, 8) NOT NULL,
lng DECIMAL(11, 8) NOT NULL,
ibadah_id INT NULL,
jarak_m DECIMAL(10, 2) NULL,
jarak_rute_m DECIMAL(10, 2) NULL,
is_blank_spot TINYINT(1) NOT NULL DEFAULT 1,
status_bantuan ENUM('Sudah dibantu', 'Belum dibantu', 'Menunggu verifikasi') NOT NULL DEFAULT 'Belum dibantu',
kategori_kebutuhan VARCHAR(100),
deskripsi_kebutuhan TEXT,
tindak_lanjut VARCHAR(60) NOT NULL DEFAULT 'Belum disurvei',
kondisi_rumah VARCHAR(80),
penghasilan INT,
catatan_survei TEXT,
bukti_file VARCHAR(255),
skor_prioritas INT NOT NULL DEFAULT 0,
jenis_bantuan VARCHAR(100),
tanggal_bantuan DATE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (ibadah_id) REFERENCES rumah_ibadah(id) ON DELETE SET NULL
) ENGINE=InnoDB;
CREATE TABLE anggota_keluarga (
id INT AUTO_INCREMENT PRIMARY KEY,
penduduk_id INT NOT NULL,
nama VARCHAR(150) NOT NULL,
hubungan VARCHAR(50) NOT NULL,
umur INT,
pekerjaan VARCHAR(100),
FOREIGN KEY (penduduk_id) REFERENCES penduduk_miskin(id) ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE TABLE riwayat_bantuan (
id INT AUTO_INCREMENT PRIMARY KEY,
penduduk_id INT NOT NULL,
jenis_bantuan VARCHAR(120) NOT NULL,
tanggal_bantuan DATE NOT NULL,
nilai_bantuan VARCHAR(80),
pemberi_bantuan VARCHAR(120),
catatan TEXT,
bukti_file VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (penduduk_id) REFERENCES penduduk_miskin(id) ON DELETE CASCADE
) ENGINE=InnoDB;
+116
View File
@@ -0,0 +1,116 @@
# WebGIS Portal
Folder ini adalah versi gabungan dari landing page dan dua project WebGIS.
## Struktur Folder
```text
Landingpage/
|-- index.html # Landing page utama
|-- style.css # Style landing page
|-- assets/ # Asset landing page
|-- Leaflet/ # Project 1: WebGIS SPBU, jalan, dan parsil
| |-- leaf.html
| |-- spatial.html
| |-- api/
| |-- assets/
| `-- sql/
|-- Leaflet2/ # Project 2: WebGIS Peduli
| |-- index.php
| |-- login.php
| |-- laporan.php
| |-- panduan.php
| |-- api/
| |-- assets/
| `-- sql/
|-- Dockerfile # Persiapan Docker, belum perlu dijalankan
|-- docker-compose.yml # Persiapan service app + database
|-- .env.example # Contoh konfigurasi environment
|-- .dockerignore
|-- .gitignore
`-- DOCKER_STEPS.md # Panduan belajar menjalankan Docker
```
## Menjalankan di XAMPP
Pastikan folder ini berada di:
```text
C:\xampp\htdocs\Landingpage
```
Buka landing page:
```text
http://localhost/Landingpage/
```
Shortcut project:
```text
http://localhost/Landingpage/Leaflet/leaf.html
http://localhost/Landingpage/Leaflet/spatial.html
http://localhost/Landingpage/Leaflet2/login.php
```
## Database Lokal
Import SQL berikut melalui phpMyAdmin atau MySQL:
```text
Leaflet/sql/schema.sql
Leaflet/sql/spatial_schema.sql
Leaflet2/sql/miskin_schema.sql
```
Database yang digunakan:
```text
webgis_spbu
webgis_miskin
```
## Titik Edit Tampilan
Landing page:
```text
index.html
style.css
assets/
```
Project 1:
```text
Leaflet/leaf.html
Leaflet/spatial.html
```
Project 2:
```text
Leaflet2/assets/css/style.css
Leaflet2/assets/css/login.css
Leaflet2/assets/css/guide.css
Leaflet2/assets/css/report.css
```
## Akun Default Project 2
```text
Username: admin
Password: admin123
```
## Docker
File Docker sudah disiapkan untuk dipelajari, tetapi belum dijalankan. Baca:
```text
DOCKER_STEPS.md
```
## Catatan Git
File yang tidak perlu masuk Git sudah dicatat di `.gitignore`, terutama `.env`, upload, log, `vendor`, dan `node_modules`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

+48
View File
@@ -0,0 +1,48 @@
services:
app:
build: .
container_name: webgis-portal-app
restart: unless-stopped
ports:
- "${APP_PORT:-8080}:80"
environment:
DB_HOST: ${DB_HOST:-db}
DB_DATABASE: ${DB_DATABASE:-webgis_miskin}
DB_USERNAME: ${DB_USERNAME:-webgis_user}
DB_PASSWORD: ${DB_PASSWORD:-webgis_password}
DB_CHARSET: ${DB_CHARSET:-utf8mb4}
DB_SETUP_USERNAME: ${DB_SETUP_USERNAME:-root}
DB_SETUP_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root_password}
SPBU_DB_HOST: ${SPBU_DB_HOST:-db}
SPBU_DB_DATABASE: ${SPBU_DB_DATABASE:-webgis_spbu}
SPBU_DB_USERNAME: ${SPBU_DB_USERNAME:-webgis_user}
SPBU_DB_PASSWORD: ${SPBU_DB_PASSWORD:-webgis_password}
depends_on:
db:
condition: service_healthy
db:
image: mysql:8.0
container_name: webgis-portal-db
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root_password}
MYSQL_DATABASE: ${DB_DATABASE:-webgis_miskin}
MYSQL_USER: ${DB_USERNAME:-webgis_user}
MYSQL_PASSWORD: ${DB_PASSWORD:-webgis_password}
ports:
- "${DB_PORT:-3307}:3306"
volumes:
- db_data:/var/lib/mysql
- ./Leaflet/sql/schema.sql:/docker-entrypoint-initdb.d/01-spbu-schema.sql:ro
- ./Leaflet/sql/spatial_schema.sql:/docker-entrypoint-initdb.d/02-spbu-spatial.sql:ro
- ./Leaflet2/sql/miskin_schema.sql:/docker-entrypoint-initdb.d/03-miskin-schema.sql:ro
- ./docker/mysql-init/99-grants.sql:/docker-entrypoint-initdb.d/99-grants.sql:ro
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h localhost -uroot -p$${MYSQL_ROOT_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 10
volumes:
db_data:
+3
View File
@@ -0,0 +1,3 @@
GRANT ALL PRIVILEGES ON webgis_spbu.* TO 'webgis_user'@'%';
GRANT ALL PRIVILEGES ON webgis_miskin.* TO 'webgis_user'@'%';
FLUSH PRIVILEGES;
+124
View File
@@ -0,0 +1,124 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Portal WebGIS | Leaflet & WebGIS Peduli</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css?v=8">
</head>
<body>
<header class="site-header">
<a class="brand" href="#home" aria-label="Portal WebGIS">
<span class="brand-mark" aria-hidden="true">WG</span>
<span class="brand-text">
<strong>WebGIS Portal</strong>
<small>Leaflet Project Hub</small>
</span>
</a>
<nav class="nav-links" aria-label="Navigasi utama">
<a href="#projects">Project</a>
</nav>
</header>
<main id="home">
<section class="hero-section">
<div class="hero-bg" aria-hidden="true"></div>
<div class="hero-overlay" aria-hidden="true"></div>
<div class="hero-content">
<p class="eyebrow">Portal terpadu WebGIS</p>
<h1>Semua project peta Leaflet dalam satu pintu masuk.</h1>
<p class="hero-lead">
Akses cepat ke WebGIS SPBU, manajemen jalan dan parsil, serta dashboard WebGIS Peduli untuk data rumah ibadah, warga kurang mampu, radius layanan, bantuan, dan laporan.
</p>
</div>
</section>
<section class="projects-section" id="projects">
<div class="section-heading">
<p class="eyebrow">Project aktif</p>
<h2>Pilih aplikasi yang ingin digunakan</h2>
<p>Setiap kartu langsung mengarah ke halaman utama project yang berada di folder XAMPP.</p>
</div>
<div class="project-grid">
<article class="project-card project-one">
<div class="project-visual" aria-hidden="true">
<div class="mini-map">
<span class="route route-a"></span>
<span class="route route-b"></span>
<span class="route route-c"></span>
<span class="pin pin-red"></span>
<span class="pin pin-blue"></span>
<span class="parcel parcel-one"></span>
<span class="parcel parcel-two"></span>
</div>
</div>
<div class="project-content">
<span class="project-kicker">Project 1</span>
<h3>WebGIS SPBU, Jalan, dan Parsil</h3>
<p>Menampilkan titik SPBU dan menyediakan pengelolaan geometri jalan serta bidang parsil melalui peta Leaflet interaktif.</p>
<div class="tag-list" aria-label="Fitur Project 1">
<span>SPBU</span>
<span>Jalan</span>
<span>Parsil</span>
</div>
<div class="project-links">
<a class="btn btn-primary" href="Leaflet/leaf.html">WebGIS SPBU</a>
</div>
</div>
</article>
<article class="project-card project-two">
<div class="project-visual" aria-hidden="true">
<div class="dashboard-mock">
<span class="dash-line wide"></span>
<span class="dash-line"></span>
<span class="dash-card green"></span>
<span class="dash-card amber"></span>
<span class="dash-card red"></span>
<span class="radius-ring"></span>
<span class="help-pin"></span>
</div>
</div>
<div class="project-content">
<span class="project-kicker">Project 2</span>
<h3>WebGIS Peduli</h3>
<p>Dashboard untuk memetakan rumah ibadah, warga miskin/kurang mampu, radius layanan, prioritas bantuan, riwayat, dan laporan.</p>
<div class="tag-list" aria-label="Fitur Project 2">
<span>Dashboard</span>
<span>Bantuan</span>
<span>Laporan</span>
</div>
<div class="project-links">
<a class="btn btn-primary" href="Leaflet2/login.php">Masuk Admin</a>
</div>
</div>
</article>
</div>
</section>
</main>
<script>
const header = document.querySelector(".site-header");
function updateHeaderState() {
header.classList.toggle("is-scrolled", window.scrollY > 80);
}
updateHeaderState();
requestAnimationFrame(updateHeaderState);
window.addEventListener("load", updateHeaderState);
window.addEventListener("scroll", updateHeaderState, { passive: true });
window.addEventListener("hashchange", () => {
requestAnimationFrame(updateHeaderState);
setTimeout(updateHeaderState, 120);
});
</script>
</body>
</html>
+621
View File
@@ -0,0 +1,621 @@
:root {
--bg: #f5f7fb;
--surface: #ffffff;
--ink: #162033;
--muted: #647186;
--line: #dce4ef;
--teal: #0f766e;
--teal-dark: #115e59;
--blue: #2563eb;
--coral: #e85d4f;
--amber: #d99a28;
--green: #16a34a;
--shadow: 0 24px 70px rgba(22, 32, 51, 0.14);
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
min-height: 100vh;
color: var(--ink);
background: var(--bg);
font-family: "Inter", Arial, sans-serif;
overflow-x: hidden;
}
a {
color: inherit;
text-decoration: none;
}
h1,
h2,
h3,
p {
margin-top: 0;
}
.site-header {
position: fixed;
top: 0;
left: 50%;
z-index: 30;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
width: min(1180px, calc(100% - 36px));
padding: 18px 0;
transform: translateX(-50%);
transition: top 0.2s ease, padding 0.2s ease, background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
}
.brand {
display: inline-flex;
align-items: center;
gap: 12px;
min-width: 0;
color: #ffffff;
transition: color 0.2s ease;
}
.brand-mark {
display: grid;
width: 44px;
height: 44px;
place-items: center;
flex: 0 0 auto;
border: 1px solid rgba(255, 255, 255, 0.34);
border-radius: 8px;
background: rgba(255, 255, 255, 0.16);
font-size: 0.86rem;
font-weight: 900;
backdrop-filter: blur(16px);
transition: color 0.2s ease, background 0.2s ease, border-color 0.2s ease;
}
.brand strong,
.brand small {
display: block;
}
.brand strong {
font-size: 0.98rem;
line-height: 1.1;
}
.brand small {
margin-top: 3px;
color: rgba(255, 255, 255, 0.78);
font-size: 0.76rem;
transition: color 0.2s ease;
}
.nav-links {
display: flex;
align-items: center;
gap: 6px;
padding: 6px;
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 8px;
background: rgba(255, 255, 255, 0.14);
backdrop-filter: blur(16px);
transition: background 0.2s ease, border-color 0.2s ease;
}
.nav-links a {
min-width: 0;
padding: 9px 16px;
border-radius: 6px;
color: rgba(255, 255, 255, 0.86);
font-size: 0.9rem;
font-weight: 800;
transition: color 0.2s ease, background 0.2s ease;
}
.nav-links a:hover {
color: #ffffff;
background: rgba(255, 255, 255, 0.14);
}
.site-header.is-scrolled {
top: 14px;
padding: 10px 12px;
border: 1px solid rgba(220, 228, 239, 0.94);
border-radius: 8px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 16px 42px rgba(22, 32, 51, 0.12);
backdrop-filter: blur(18px);
}
.site-header.is-scrolled .brand {
color: var(--ink);
}
.site-header.is-scrolled .brand-mark {
color: #ffffff;
border-color: transparent;
background: linear-gradient(145deg, var(--teal), var(--blue));
}
.site-header.is-scrolled .brand small {
color: var(--muted);
}
.site-header.is-scrolled .nav-links {
border-color: var(--line);
background: #f3f6fa;
}
.site-header.is-scrolled .nav-links a {
color: #526175;
}
.site-header.is-scrolled .nav-links a:hover {
color: var(--ink);
background: #e7edf5;
}
main {
overflow: hidden;
}
.hero-section {
position: relative;
display: flex;
align-items: center;
min-height: 88vh;
padding: 112px max(18px, calc((100vw - 1180px) / 2)) 92px;
color: #ffffff;
}
.hero-bg,
.hero-overlay {
position: absolute;
inset: 0;
}
.hero-bg {
background-image: url("assets/webgis-hero.png");
background-position: center;
background-size: cover;
}
.hero-overlay {
background:
linear-gradient(90deg, rgba(8, 16, 32, 0.88) 0%, rgba(8, 16, 32, 0.68) 44%, rgba(8, 16, 32, 0.2) 100%),
linear-gradient(180deg, rgba(8, 16, 32, 0.24), rgba(8, 16, 32, 0.54));
}
.hero-content {
position: relative;
z-index: 2;
width: min(760px, 100%);
}
.eyebrow {
margin: 0 0 14px;
color: #35d0ba;
font-size: 0.76rem;
font-weight: 900;
letter-spacing: 0;
text-transform: uppercase;
}
h1 {
max-width: 820px;
margin-bottom: 22px;
font-size: clamp(2.75rem, 6vw, 5.7rem);
line-height: 0.98;
letter-spacing: 0;
}
.hero-lead {
max-width: 680px;
margin-bottom: 30px;
color: rgba(255, 255, 255, 0.82);
font-size: 1.08rem;
line-height: 1.75;
}
.project-links {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 46px;
padding: 12px 18px;
border: 1px solid transparent;
border-radius: 8px;
font-size: 0.93rem;
font-weight: 900;
transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
}
.btn:hover {
transform: translateY(-2px);
}
.btn-primary {
color: #ffffff;
background: var(--teal);
box-shadow: 0 16px 34px rgba(15, 118, 110, 0.28);
}
.btn-primary:hover {
background: var(--teal-dark);
}
.btn-secondary {
color: #ffffff;
border-color: rgba(255, 255, 255, 0.34);
background: rgba(255, 255, 255, 0.14);
backdrop-filter: blur(12px);
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.22);
}
.btn-ghost {
color: var(--teal-dark);
border-color: rgba(15, 118, 110, 0.22);
background: rgba(15, 118, 110, 0.08);
}
.btn-ghost:hover {
box-shadow: 0 12px 24px rgba(22, 32, 51, 0.09);
}
.projects-section {
width: min(1120px, calc(100% - 36px));
margin: 0 auto;
padding: 132px 0 70px;
scroll-margin-top: 0;
}
.section-heading {
max-width: 620px;
margin-bottom: 24px;
}
.section-heading .eyebrow {
margin-bottom: 10px;
color: var(--teal);
}
.section-heading h2 {
margin-bottom: 12px;
font-size: clamp(1.8rem, 3vw, 2.7rem);
line-height: 1.08;
letter-spacing: 0;
}
.section-heading p,
.project-card p {
color: var(--muted);
line-height: 1.58;
}
.project-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
}
.project-card {
overflow: hidden;
border: 1px solid rgba(220, 228, 239, 0.94);
border-radius: 8px;
background: var(--surface);
box-shadow: var(--shadow);
}
.project-visual {
height: 180px;
padding: 16px;
border-bottom: 1px solid var(--line);
background:
linear-gradient(135deg, rgba(15, 118, 110, 0.12), rgba(37, 99, 235, 0.06)),
#edf4f7;
}
.project-two .project-visual {
background:
linear-gradient(135deg, rgba(232, 93, 79, 0.13), rgba(217, 154, 40, 0.11)),
#f8f3ec;
}
.mini-map,
.dashboard-mock {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.72);
border-radius: 8px;
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.42) 1px, transparent 1px),
linear-gradient(rgba(255, 255, 255, 0.42) 1px, transparent 1px),
linear-gradient(135deg, #cfe9e5, #dfeaff 58%, #f4f7fb);
background-size: 36px 36px, 36px 36px, auto;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.6);
}
.route {
position: absolute;
height: 8px;
border-radius: 999px;
background: rgba(37, 99, 235, 0.76);
transform-origin: left center;
}
.route-a {
top: 56px;
left: 22px;
width: 72%;
transform: rotate(9deg);
}
.route-b {
top: 145px;
left: 18px;
width: 86%;
background: rgba(15, 118, 110, 0.78);
transform: rotate(-11deg);
}
.route-c {
right: 34px;
bottom: 46px;
width: 50%;
background: rgba(232, 93, 79, 0.74);
transform: rotate(19deg);
}
.pin,
.help-pin {
position: absolute;
width: 24px;
height: 24px;
border: 4px solid #ffffff;
border-radius: 50% 50% 50% 0;
box-shadow: 0 10px 22px rgba(22, 32, 51, 0.22);
transform: rotate(-45deg);
}
.pin::after,
.help-pin::after {
content: "";
position: absolute;
inset: 5px;
border-radius: 50%;
background: #ffffff;
}
.pin-red {
top: 70px;
left: 34%;
background: var(--coral);
}
.pin-blue {
right: 20%;
bottom: 70px;
background: var(--blue);
}
.parcel {
position: absolute;
border: 2px solid rgba(217, 154, 40, 0.88);
background: rgba(217, 154, 40, 0.18);
transform: rotate(-8deg);
}
.parcel-one {
right: 36px;
top: 34px;
width: 96px;
height: 64px;
}
.parcel-two {
left: 44px;
bottom: 32px;
width: 130px;
height: 74px;
}
.dashboard-mock {
background: linear-gradient(135deg, #ffffff, #f3f6fa);
}
.dash-line {
position: absolute;
left: 24px;
top: 28px;
width: 160px;
height: 12px;
border-radius: 999px;
background: #d8e1ec;
}
.dash-line.wide {
top: 54px;
width: 245px;
}
.dash-card {
position: absolute;
top: 94px;
width: 28%;
height: 64px;
border-radius: 8px;
}
.dash-card.green {
left: 24px;
background: rgba(22, 163, 74, 0.2);
border: 1px solid rgba(22, 163, 74, 0.32);
}
.dash-card.amber {
left: 36%;
background: rgba(217, 154, 40, 0.22);
border: 1px solid rgba(217, 154, 40, 0.34);
}
.dash-card.red {
right: 24px;
background: rgba(232, 93, 79, 0.2);
border: 1px solid rgba(232, 93, 79, 0.3);
}
.radius-ring {
position: absolute;
right: 72px;
bottom: 28px;
width: 124px;
height: 124px;
border: 3px solid rgba(37, 99, 235, 0.55);
border-radius: 50%;
background: rgba(37, 99, 235, 0.08);
}
.help-pin {
right: 124px;
bottom: 78px;
background: var(--teal);
}
.project-content {
padding: 20px;
}
.project-kicker {
display: inline-flex;
margin-bottom: 12px;
color: var(--teal);
font-size: 0.78rem;
font-weight: 900;
text-transform: uppercase;
}
.project-two .project-kicker {
color: var(--coral);
}
.project-card h3 {
margin-bottom: 8px;
font-size: 1.06rem;
letter-spacing: 0;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 16px 0 20px;
}
.tag-list span {
display: inline-flex;
padding: 7px 10px;
border-radius: 999px;
color: var(--muted);
background: #eef3f8;
font-size: 0.78rem;
font-weight: 800;
}
@media (max-width: 980px) {
.hero-section {
min-height: 84vh;
}
.project-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.site-header {
position: absolute;
left: 14px;
right: 14px;
align-items: center;
flex-direction: row;
width: auto;
transform: none;
}
.nav-links a {
padding-right: 12px;
padding-left: 12px;
}
.hero-section {
display: block;
align-items: flex-start;
min-height: 92vh;
padding-top: 178px;
padding-bottom: 52px;
}
.hero-overlay {
background:
linear-gradient(180deg, rgba(8, 16, 32, 0.9) 0%, rgba(8, 16, 32, 0.74) 58%, rgba(8, 16, 32, 0.5) 100%);
}
h1 {
font-size: 2.45rem;
line-height: 1.04;
}
.hero-lead {
font-size: 1rem;
}
.project-links .btn {
width: 100%;
}
.projects-section {
width: min(100% - 28px, 1180px);
padding: 126px 0 58px;
}
.project-visual {
height: 220px;
}
}
@media (max-width: 460px) {
.brand-text small {
display: none;
}
.hero-section {
min-height: 94vh;
padding-top: 168px;
}
.project-visual {
height: 190px;
padding: 14px;
}
}