From cd376094df08e5771817190d2921e396631ead1e Mon Sep 17 00:00:00 2001
From: Tazyeuu <126654209+Tazyeuu@users.noreply.github.com>
Date: Wed, 10 Jun 2026 22:07:47 +0700
Subject: [PATCH] pertama dan terakhir
---
.dockerignore | 22 +
.env.example | 9 +
.gitignore | 53 ++
01/backend/api/jalan.php | 75 ++
01/backend/api/kavling.php | 75 ++
01/backend/api/spbu.php | 59 ++
01/backend/config/db.php | 32 +
01/backend/utils/response_helper.php | 51 ++
01/frontend/css/base.css | 61 ++
01/frontend/css/form.css | 92 +++
01/frontend/css/main.css | 205 +++++
01/frontend/css/map.css | 73 ++
01/frontend/css/sidebar.css | 78 ++
01/frontend/index.html | 56 ++
01/frontend/js/app.js | 92 +++
01/frontend/js/config.js | 15 +
01/frontend/js/modules/draw.js | 31 +
01/frontend/js/modules/form.js | 50 ++
01/frontend/js/modules/map.js | 25 +
01/frontend/js/services/api.service.js | 41 +
01/frontend/js/services/jalan.service.js | 28 +
01/frontend/js/services/kavling.service.js | 28 +
01/frontend/js/services/spbu.service.js | 28 +
01/frontend/js/utils/style.util.js | 34 +
01/frontend/js/utils/ui.util.js | 32 +
02/backend/api/jalan.php | 75 ++
02/backend/api/kavling.php | 75 ++
02/backend/api/rumah_ibadah.php | 89 ++
02/backend/api/spbu.php | 59 ++
02/backend/api/warga_miskin.php | 67 ++
02/backend/config/db.php | 32 +
02/backend/utils/geo_helper.php | 66 ++
02/backend/utils/response_helper.php | 29 +
02/frontend/css/base.css | 61 ++
02/frontend/css/form.css | 92 +++
02/frontend/css/main.css | 205 +++++
02/frontend/css/map.css | 68 ++
02/frontend/css/sidebar.css | 78 ++
02/frontend/index.html | 50 ++
02/frontend/js/app.js | 124 +++
02/frontend/js/config.js | 4 +
02/frontend/js/modules/draw.js | 31 +
02/frontend/js/modules/form.js | 50 ++
02/frontend/js/modules/map.js | 25 +
02/frontend/js/services/api.service.js | 41 +
.../js/services/rumah_ibadah.service.js | 22 +
.../js/services/warga_miskin.service.js | 18 +
02/frontend/js/utils/ui.util.js | 50 ++
03/backend/api/jalan.php | 75 ++
03/backend/api/kavling.php | 75 ++
03/backend/api/rumah_ibadah.php | 89 ++
03/backend/api/spbu.php | 59 ++
03/backend/api/statistik.php | 71 ++
03/backend/api/warga_miskin.php | 67 ++
03/backend/config/db.php | 32 +
03/backend/utils/response_helper.php | 29 +
03/frontend/base.css | 61 ++
03/frontend/css/main.css | 205 +++++
03/frontend/form.css | 92 +++
03/frontend/index.html | 53 ++
03/frontend/js/app.js | 110 +++
03/frontend/js/modules/draw.js | 31 +
03/frontend/js/modules/form.js | 50 ++
03/frontend/js/modules/map.js | 25 +
03/frontend/js/services/api.service.js | 41 +
.../js/services/rumah_ibadah.service.js | 22 +
03/frontend/js/services/statistik.service.js | 10 +
.../js/services/warga_miskin.service.js | 18 +
03/frontend/map.css | 68 ++
03/frontend/sidebar.css | 78 ++
Dockerfile | 15 +
README.md | 66 ++
database/create_tables.sql | 116 +++
database/seed_pontianak.sql | 172 ++++
database/seed_users.sql | 26 +
database/webgis_db.sql | 282 +++++++
final/backend/api/jalan.php | 75 ++
final/backend/api/kavling.php | 75 ++
final/backend/api/rumah_ibadah.php | 89 ++
final/backend/api/spbu.php | 59 ++
final/backend/api/statistik.php | 71 ++
final/backend/api/warga_miskin.php | 67 ++
final/backend/config/db.php | 32 +
final/backend/utils/response_helper.php | 29 +
final/frontend/base.css | 61 ++
final/frontend/css/main.css | 205 +++++
final/frontend/form.css | 92 +++
final/frontend/index.html | 53 ++
final/frontend/js/app.js | 110 +++
final/frontend/js/modules/draw.js | 31 +
final/frontend/js/modules/form.js | 50 ++
final/frontend/js/modules/map.js | 25 +
final/frontend/js/services/api.service.js | 41 +
.../js/services/rumah_ibadah.service.js | 22 +
.../frontend/js/services/statistik.service.js | 10 +
.../js/services/warga_miskin.service.js | 18 +
final/frontend/map.css | 68 ++
final/frontend/sidebar.css | 78 ++
index.html | 320 ++++++++
project_final/admin/data_jalan.php | 79 ++
project_final/admin/data_kavling.php | 82 ++
project_final/admin/data_kawasan.php | 57 ++
project_final/admin/data_laporan.php | 191 +++++
project_final/admin/data_rumah.php | 140 ++++
project_final/admin/data_spbu.php | 164 ++++
project_final/admin/data_warga.php | 101 +++
project_final/admin/index.php | 172 ++++
project_final/admin/partials/footer.php | 119 +++
project_final/admin/partials/header.php | 110 +++
project_final/admin/peta.php | 173 ++++
project_final/admin/users.php | 179 ++++
project_final/api/blank_spot.php | 66 ++
project_final/api/helpers.php | 52 ++
project_final/api/jalan.php | 22 +
project_final/api/kavling.php | 26 +
project_final/api/kawasan_kumuh.php | 22 +
project_final/api/laporan.php | 104 +++
project_final/api/rumah_ibadah.php | 102 +++
project_final/api/spbu.php | 57 ++
project_final/api/spbu_status.php | 29 +
project_final/api/spbu_terdekat.php | 13 +
project_final/api/statistik.php | 53 ++
project_final/api/ulasan.php | 50 ++
project_final/api/users.php | 31 +
project_final/api/warga_miskin.php | 26 +
project_final/assets/css/admin.css | 364 +++++++++
project_final/assets/css/auth.css | 215 +++++
project_final/assets/css/main.css | 248 ++++++
project_final/assets/css/map.css | 765 ++++++++++++++++++
project_final/assets/js/admin-map.js | 491 +++++++++++
project_final/assets/js/user-map.js | 358 ++++++++
project_final/config/app.php | 25 +
project_final/config/auth_check.php | 45 ++
project_final/config/db.php | 34 +
project_final/config/session.php | 24 +
project_final/index.php | 13 +
project_final/login.php | 135 ++++
project_final/logout.php | 6 +
project_final/user/index.php | 116 +++
project_final/user/laporan.php | 191 +++++
project_final/user/partials/footer.php | 119 +++
project_final/user/partials/header.php | 85 ++
project_final/user/peta.php | 161 ++++
143 files changed, 12090 insertions(+)
create mode 100644 .dockerignore
create mode 100644 .env.example
create mode 100644 .gitignore
create mode 100644 01/backend/api/jalan.php
create mode 100644 01/backend/api/kavling.php
create mode 100644 01/backend/api/spbu.php
create mode 100644 01/backend/config/db.php
create mode 100644 01/backend/utils/response_helper.php
create mode 100644 01/frontend/css/base.css
create mode 100644 01/frontend/css/form.css
create mode 100644 01/frontend/css/main.css
create mode 100644 01/frontend/css/map.css
create mode 100644 01/frontend/css/sidebar.css
create mode 100644 01/frontend/index.html
create mode 100644 01/frontend/js/app.js
create mode 100644 01/frontend/js/config.js
create mode 100644 01/frontend/js/modules/draw.js
create mode 100644 01/frontend/js/modules/form.js
create mode 100644 01/frontend/js/modules/map.js
create mode 100644 01/frontend/js/services/api.service.js
create mode 100644 01/frontend/js/services/jalan.service.js
create mode 100644 01/frontend/js/services/kavling.service.js
create mode 100644 01/frontend/js/services/spbu.service.js
create mode 100644 01/frontend/js/utils/style.util.js
create mode 100644 01/frontend/js/utils/ui.util.js
create mode 100644 02/backend/api/jalan.php
create mode 100644 02/backend/api/kavling.php
create mode 100644 02/backend/api/rumah_ibadah.php
create mode 100644 02/backend/api/spbu.php
create mode 100644 02/backend/api/warga_miskin.php
create mode 100644 02/backend/config/db.php
create mode 100644 02/backend/utils/geo_helper.php
create mode 100644 02/backend/utils/response_helper.php
create mode 100644 02/frontend/css/base.css
create mode 100644 02/frontend/css/form.css
create mode 100644 02/frontend/css/main.css
create mode 100644 02/frontend/css/map.css
create mode 100644 02/frontend/css/sidebar.css
create mode 100644 02/frontend/index.html
create mode 100644 02/frontend/js/app.js
create mode 100644 02/frontend/js/config.js
create mode 100644 02/frontend/js/modules/draw.js
create mode 100644 02/frontend/js/modules/form.js
create mode 100644 02/frontend/js/modules/map.js
create mode 100644 02/frontend/js/services/api.service.js
create mode 100644 02/frontend/js/services/rumah_ibadah.service.js
create mode 100644 02/frontend/js/services/warga_miskin.service.js
create mode 100644 02/frontend/js/utils/ui.util.js
create mode 100644 03/backend/api/jalan.php
create mode 100644 03/backend/api/kavling.php
create mode 100644 03/backend/api/rumah_ibadah.php
create mode 100644 03/backend/api/spbu.php
create mode 100644 03/backend/api/statistik.php
create mode 100644 03/backend/api/warga_miskin.php
create mode 100644 03/backend/config/db.php
create mode 100644 03/backend/utils/response_helper.php
create mode 100644 03/frontend/base.css
create mode 100644 03/frontend/css/main.css
create mode 100644 03/frontend/form.css
create mode 100644 03/frontend/index.html
create mode 100644 03/frontend/js/app.js
create mode 100644 03/frontend/js/modules/draw.js
create mode 100644 03/frontend/js/modules/form.js
create mode 100644 03/frontend/js/modules/map.js
create mode 100644 03/frontend/js/services/api.service.js
create mode 100644 03/frontend/js/services/rumah_ibadah.service.js
create mode 100644 03/frontend/js/services/statistik.service.js
create mode 100644 03/frontend/js/services/warga_miskin.service.js
create mode 100644 03/frontend/map.css
create mode 100644 03/frontend/sidebar.css
create mode 100644 Dockerfile
create mode 100644 README.md
create mode 100644 database/create_tables.sql
create mode 100644 database/seed_pontianak.sql
create mode 100644 database/seed_users.sql
create mode 100644 database/webgis_db.sql
create mode 100644 final/backend/api/jalan.php
create mode 100644 final/backend/api/kavling.php
create mode 100644 final/backend/api/rumah_ibadah.php
create mode 100644 final/backend/api/spbu.php
create mode 100644 final/backend/api/statistik.php
create mode 100644 final/backend/api/warga_miskin.php
create mode 100644 final/backend/config/db.php
create mode 100644 final/backend/utils/response_helper.php
create mode 100644 final/frontend/base.css
create mode 100644 final/frontend/css/main.css
create mode 100644 final/frontend/form.css
create mode 100644 final/frontend/index.html
create mode 100644 final/frontend/js/app.js
create mode 100644 final/frontend/js/modules/draw.js
create mode 100644 final/frontend/js/modules/form.js
create mode 100644 final/frontend/js/modules/map.js
create mode 100644 final/frontend/js/services/api.service.js
create mode 100644 final/frontend/js/services/rumah_ibadah.service.js
create mode 100644 final/frontend/js/services/statistik.service.js
create mode 100644 final/frontend/js/services/warga_miskin.service.js
create mode 100644 final/frontend/map.css
create mode 100644 final/frontend/sidebar.css
create mode 100644 index.html
create mode 100644 project_final/admin/data_jalan.php
create mode 100644 project_final/admin/data_kavling.php
create mode 100644 project_final/admin/data_kawasan.php
create mode 100644 project_final/admin/data_laporan.php
create mode 100644 project_final/admin/data_rumah.php
create mode 100644 project_final/admin/data_spbu.php
create mode 100644 project_final/admin/data_warga.php
create mode 100644 project_final/admin/index.php
create mode 100644 project_final/admin/partials/footer.php
create mode 100644 project_final/admin/partials/header.php
create mode 100644 project_final/admin/peta.php
create mode 100644 project_final/admin/users.php
create mode 100644 project_final/api/blank_spot.php
create mode 100644 project_final/api/helpers.php
create mode 100644 project_final/api/jalan.php
create mode 100644 project_final/api/kavling.php
create mode 100644 project_final/api/kawasan_kumuh.php
create mode 100644 project_final/api/laporan.php
create mode 100644 project_final/api/rumah_ibadah.php
create mode 100644 project_final/api/spbu.php
create mode 100644 project_final/api/spbu_status.php
create mode 100644 project_final/api/spbu_terdekat.php
create mode 100644 project_final/api/statistik.php
create mode 100644 project_final/api/ulasan.php
create mode 100644 project_final/api/users.php
create mode 100644 project_final/api/warga_miskin.php
create mode 100644 project_final/assets/css/admin.css
create mode 100644 project_final/assets/css/auth.css
create mode 100644 project_final/assets/css/main.css
create mode 100644 project_final/assets/css/map.css
create mode 100644 project_final/assets/js/admin-map.js
create mode 100644 project_final/assets/js/user-map.js
create mode 100644 project_final/config/app.php
create mode 100644 project_final/config/auth_check.php
create mode 100644 project_final/config/db.php
create mode 100644 project_final/config/session.php
create mode 100644 project_final/index.php
create mode 100644 project_final/login.php
create mode 100644 project_final/logout.php
create mode 100644 project_final/user/index.php
create mode 100644 project_final/user/laporan.php
create mode 100644 project_final/user/partials/footer.php
create mode 100644 project_final/user/partials/header.php
create mode 100644 project_final/user/peta.php
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..fe45183
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,22 @@
+.git/
+.gitignore
+.env
+.env.*
+!.env.example
+
+MANUAL_BOOK.docx
+PRESENTASI.pptx
+NEXT_WORK_SUMMARY.md
+PROJECT_PROGRESS_SUMMARY.md
+
+node_modules/
+vendor/
+uploads/
+project_final/uploads/
+*.log
+*.tmp
+database/backups/
+database/*backup*.sql
+database/*_backup.sql
+*.sql.gz
+*.dump
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..50448fa
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,9 @@
+APP_BASE_PATH=/project_final
+SESSION_SECURE=true
+CORS_ALLOWED_ORIGIN=
+
+DB_HOST=mysql
+DB_PORT=3306
+DB_DATABASE=webgis_db
+DB_USERNAME=webgis_user
+DB_PASSWORD=change_me
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4896d54
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,53 @@
+# OS / editor
+.DS_Store
+Thumbs.db
+desktop.ini
+.vscode/
+.idea/
+
+# Local deliverables / work notes
+MANUAL_BOOK.docx
+PRESENTASI.pptx
+NEXT_WORK_SUMMARY.md
+PROJECT_PROGRESS_SUMMARY.md
+
+# Environment / local config
+.env
+.env.*
+!.env.example
+project_final/config/db.local.php
+project_final/config/*.local.php
+
+# PHP / Composer
+vendor/
+
+# Node / frontend packages
+node_modules/
+
+# Logs / cache / temp
+*.log
+logs/
+cache/
+tmp/
+temp/
+*.tmp
+*.bak
+*.swp
+
+# Runtime uploads
+uploads/*
+project_final/uploads/*
+!uploads/.gitkeep
+!project_final/uploads/.gitkeep
+
+# Generated backups / exports
+database/backups/
+database/*backup*.sql
+database/*_backup.sql
+*.sql.gz
+*.dump
+
+# Test / build output
+coverage/
+dist/
+build/
diff --git a/01/backend/api/jalan.php b/01/backend/api/jalan.php
new file mode 100644
index 0000000..7264a11
--- /dev/null
+++ b/01/backend/api/jalan.php
@@ -0,0 +1,75 @@
+query("SELECT id, nama, jenis_jalan, created_at, ST_AsGeoJSON(geom) as geojson FROM jalan");
+ $features = [];
+ while ($row = $stmt->fetch()) {
+ $features[] = [
+ 'type' => 'Feature',
+ 'geometry' => json_decode($row['geojson']),
+ 'properties' => [
+ 'id' => $row['id'],
+ 'nama' => $row['nama'],
+ 'jenis_jalan' => $row['jenis_jalan'],
+ 'created_at' => $row['created_at']
+ ]
+ ];
+ }
+ sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Jalan berhasil diambil');
+ } catch (PDOException $e) {
+ sendError('Gagal mengambil data Jalan: ' . $e->getMessage(), 500);
+ }
+ break;
+
+ case 'POST':
+ $input = json_decode(file_get_contents('php://input'), true);
+ if (!isset($input['nama']) || !isset($input['geometry'])) {
+ sendError('Nama dan geometri wajib diisi');
+ }
+
+ try {
+ $sql = "INSERT INTO jalan (nama, jenis_jalan, geom) VALUES (:nama, :jenis_jalan, ST_GeomFromGeoJSON(:geometry))";
+ $stmt = $pdo->prepare($sql);
+ $stmt->execute([
+ ':nama' => $input['nama'],
+ ':jenis_jalan' => $input['jenis_jalan'] ?? 'Lokal',
+ ':geometry' => json_encode($input['geometry'])
+ ]);
+ sendSuccess(['id' => $pdo->lastInsertId()], 'Data Jalan berhasil disimpan', 201);
+ } catch (PDOException $e) {
+ sendError('Gagal menyimpan Jalan: ' . $e->getMessage(), 500);
+ }
+ break;
+
+ case 'DELETE':
+ $id = $_GET['id'] ?? null;
+ if (!$id) {
+ sendError('ID Jalan wajib disertakan');
+ }
+
+ try {
+ $stmt = $pdo->prepare("DELETE FROM jalan WHERE id = :id");
+ $stmt->execute([':id' => $id]);
+ sendSuccess(null, 'Data Jalan berhasil dihapus');
+ } catch (PDOException $e) {
+ sendError('Gagal menghapus Jalan: ' . $e->getMessage(), 500);
+ }
+ break;
+
+ default:
+ sendError('Method not allowed', 405);
+ break;
+}
+?>
diff --git a/01/backend/api/kavling.php b/01/backend/api/kavling.php
new file mode 100644
index 0000000..ed862a5
--- /dev/null
+++ b/01/backend/api/kavling.php
@@ -0,0 +1,75 @@
+query("SELECT id, nama_pemilik, luas, created_at, ST_AsGeoJSON(geom) as geojson FROM kavling");
+ $features = [];
+ while ($row = $stmt->fetch()) {
+ $features[] = [
+ 'type' => 'Feature',
+ 'geometry' => json_decode($row['geojson']),
+ 'properties' => [
+ 'id' => $row['id'],
+ 'nama_pemilik' => $row['nama_pemilik'],
+ 'luas' => $row['luas'],
+ 'created_at' => $row['created_at']
+ ]
+ ];
+ }
+ sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Kavling berhasil diambil');
+ } catch (PDOException $e) {
+ sendError('Gagal mengambil data Kavling: ' . $e->getMessage(), 500);
+ }
+ break;
+
+ case 'POST':
+ $input = json_decode(file_get_contents('php://input'), true);
+ if (!isset($input['nama_pemilik']) || !isset($input['geometry'])) {
+ sendError('Nama pemilik dan geometri wajib diisi');
+ }
+
+ try {
+ $sql = "INSERT INTO kavling (nama_pemilik, luas, geom) VALUES (:nama_pemilik, :luas, ST_GeomFromGeoJSON(:geometry))";
+ $stmt = $pdo->prepare($sql);
+ $stmt->execute([
+ ':nama_pemilik' => $input['nama_pemilik'],
+ ':luas' => $input['luas'] ?? 0,
+ ':geometry' => json_encode($input['geometry'])
+ ]);
+ sendSuccess(['id' => $pdo->lastInsertId()], 'Data Kavling berhasil disimpan', 201);
+ } catch (PDOException $e) {
+ sendError('Gagal menyimpan Kavling: ' . $e->getMessage(), 500);
+ }
+ break;
+
+ case 'DELETE':
+ $id = $_GET['id'] ?? null;
+ if (!$id) {
+ sendError('ID Kavling wajib disertakan');
+ }
+
+ try {
+ $stmt = $pdo->prepare("DELETE FROM kavling WHERE id = :id");
+ $stmt->execute([':id' => $id]);
+ sendSuccess(null, 'Data Kavling berhasil dihapus');
+ } catch (PDOException $e) {
+ sendError('Gagal menghapus Kavling: ' . $e->getMessage(), 500);
+ }
+ break;
+
+ default:
+ sendError('Method not allowed', 405);
+ break;
+}
+?>
diff --git a/01/backend/api/spbu.php b/01/backend/api/spbu.php
new file mode 100644
index 0000000..7529ba1
--- /dev/null
+++ b/01/backend/api/spbu.php
@@ -0,0 +1,59 @@
+query("SELECT id, nama, deskripsi, buka_24_jam, created_at, ST_AsGeoJSON(geom) as geojson FROM spbu");
+ $features = [];
+ while ($row = $stmt->fetch()) {
+ $features[] = [
+ 'type' => 'Feature',
+ 'geometry' => json_decode($row['geojson']),
+ 'properties' => [
+ 'id' => $row['id'],
+ 'nama' => $row['nama'],
+ 'deskripsi' => $row['deskripsi'],
+ 'buka_24_jam' => (bool)$row['buka_24_jam'],
+ 'created_at' => $row['created_at']
+ ]
+ ];
+ }
+ sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data SPBU berhasil diambil');
+ } catch (PDOException $e) { sendError('Gagal: ' . $e->getMessage(), 500); }
+ break;
+
+ case 'POST':
+ $input = json_decode(file_get_contents('php://input'), true);
+ if (!isset($input['nama']) || !isset($input['geometry'])) sendError('Validasi gagal');
+
+ try {
+ $sql = "INSERT INTO spbu (nama, deskripsi, buka_24_jam, geom) VALUES (:nama, :deskripsi, :buka_24_jam, ST_GeomFromGeoJSON(:geometry))";
+ $stmt = $pdo->prepare($sql);
+ $stmt->execute([
+ ':nama' => $input['nama'],
+ ':deskripsi' => $input['deskripsi'] ?? '',
+ ':buka_24_jam' => isset($input['buka_24_jam']) && $input['buka_24_jam'] ? 1 : 0,
+ ':geometry' => json_encode($input['geometry'])
+ ]);
+ sendSuccess(['id' => $pdo->lastInsertId()], 'Disimpan', 201);
+ } catch (PDOException $e) { sendError('Gagal: ' . $e->getMessage(), 500); }
+ break;
+
+ case 'DELETE':
+ $id = $_GET['id'] ?? null;
+ if (!$id) sendError('ID dibutuhkan');
+ $stmt = $pdo->prepare("DELETE FROM spbu WHERE id = ?");
+ $stmt->execute([$id]);
+ sendSuccess(null, 'Dihapus');
+ break;
+
+ default:
+ sendError('Method not allowed', 405);
+ break;
+}
+?>
diff --git a/01/backend/config/db.php b/01/backend/config/db.php
new file mode 100644
index 0000000..287e5da
--- /dev/null
+++ b/01/backend/config/db.php
@@ -0,0 +1,32 @@
+setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+ self::$conn->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
+ } catch (PDOException $exception) {
+ echo "Connection error: " . $exception->getMessage();
+ }
+ }
+ return self::$conn;
+ }
+}
diff --git a/01/backend/utils/response_helper.php b/01/backend/utils/response_helper.php
new file mode 100644
index 0000000..01e7070
--- /dev/null
+++ b/01/backend/utils/response_helper.php
@@ -0,0 +1,51 @@
+ 'success',
+ 'message' => $message,
+ 'data' => $data
+ ]);
+ exit();
+}
+
+/**
+ * Mengirim response error
+ * @param string $message Pesan error
+ * @param int $statusCode HTTP Status Code (default: 400)
+ */
+function sendError($message = 'Error', $statusCode = 400) {
+ http_response_code($statusCode);
+ echo json_encode([
+ 'status' => 'error',
+ 'message' => $message
+ ]);
+ exit();
+}
+?>
diff --git a/01/frontend/css/base.css b/01/frontend/css/base.css
new file mode 100644
index 0000000..7e7c44c
--- /dev/null
+++ b/01/frontend/css/base.css
@@ -0,0 +1,61 @@
+/* base.css
+ * Tanggung Jawab: Reset CSS, definisi variabel warna premium, dan tipografi dasar.
+ */
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
+
+:root {
+ /* Color Palette - Minimalist & Elegant */
+ --primary: #4F46E5;
+ --primary-hover: #4338CA;
+ --secondary: #10B981;
+ --background: #F3F4F6;
+ --surface: #FFFFFF;
+ --surface-glass: rgba(255, 255, 255, 0.85);
+ --text-main: #111827;
+ --text-muted: #6B7280;
+ --border: #E5E7EB;
+ --danger: #EF4444;
+
+ /* Shadows & Effects */
+ --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
+ --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
+ --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
+ --radius-md: 12px;
+ --radius-lg: 16px;
+ --transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: 'Inter', sans-serif;
+ background-color: var(--background);
+ color: var(--text-main);
+ display: flex;
+ height: 100vh;
+ overflow: hidden;
+}
+
+h1, h2, h3, h4 {
+ font-weight: 600;
+ color: var(--text-main);
+}
+
+/* Scrollbar minimalis */
+::-webkit-scrollbar {
+ width: 6px;
+}
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+::-webkit-scrollbar-thumb {
+ background: #CBD5E1;
+ border-radius: 10px;
+}
+::-webkit-scrollbar-thumb:hover {
+ background: #94A3B8;
+}
diff --git a/01/frontend/css/form.css b/01/frontend/css/form.css
new file mode 100644
index 0000000..be53320
--- /dev/null
+++ b/01/frontend/css/form.css
@@ -0,0 +1,92 @@
+/* form.css
+ * Tanggung Jawab: Styling komponen form (input, button, select).
+ */
+
+.form-group {
+ margin-bottom: 16px;
+}
+
+.form-label {
+ display: block;
+ font-size: 0.85rem;
+ font-weight: 500;
+ margin-bottom: 6px;
+ color: var(--text-main);
+}
+
+.form-control {
+ width: 100%;
+ padding: 10px 14px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ font-family: inherit;
+ font-size: 0.95rem;
+ transition: var(--transition);
+ background: var(--surface);
+}
+
+.form-control:focus {
+ outline: none;
+ border-color: var(--primary);
+ box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
+}
+
+/* Buttons */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 10px 20px;
+ font-family: inherit;
+ font-weight: 600;
+ font-size: 0.9rem;
+ border-radius: var(--radius-md);
+ border: none;
+ cursor: pointer;
+ transition: var(--transition);
+ gap: 8px;
+ width: 100%;
+}
+
+.btn-primary {
+ background: var(--primary);
+ color: white;
+}
+
+.btn-primary:hover {
+ background: var(--primary-hover);
+ transform: translateY(-1px);
+ box-shadow: var(--shadow-md);
+}
+
+.btn-danger {
+ background: var(--danger);
+ color: white;
+ width: auto;
+ padding: 6px 12px;
+ font-size: 0.8rem;
+}
+
+.btn-danger:hover {
+ background: #DC2626;
+}
+
+/* Micro-animation for active state */
+.btn:active {
+ transform: translateY(1px);
+}
+
+/* Form Container Animation */
+#form-container {
+ background: var(--surface);
+ padding: 20px;
+ border-radius: var(--radius-lg);
+ border: 1px solid var(--border);
+ margin-bottom: 20px;
+ animation: fadeIn 0.4s ease-out;
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; transform: scale(0.98); }
+ to { opacity: 1; transform: scale(1); }
+}
diff --git a/01/frontend/css/main.css b/01/frontend/css/main.css
new file mode 100644
index 0000000..14cadd4
--- /dev/null
+++ b/01/frontend/css/main.css
@@ -0,0 +1,205 @@
+:root {
+ --primary: #6366F1;
+ --primary-hover: #4F46E5;
+ --bg-dark: #0F172A;
+ --bg-glass: rgba(15, 23, 42, 0.75);
+ --border-glass: rgba(255, 255, 255, 0.1);
+ --text-light: #F8FAFC;
+ --text-muted: #94A3B8;
+ --radius: 20px;
+ --shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
+ --font: 'Inter', sans-serif;
+}
+
+* { box-sizing: border-box; }
+
+body {
+ margin: 0;
+ font-family: var(--font);
+ background: var(--bg-dark);
+ color: var(--text-light);
+ overflow: hidden;
+}
+
+#map {
+ width: 100vw;
+ height: 100vh;
+ z-index: 1;
+}
+
+/* Floating Dock */
+.floating-dock {
+ position: absolute;
+ top: 24px;
+ left: 24px;
+ z-index: 1000;
+ background: var(--bg-glass);
+ backdrop-filter: blur(24px);
+ -webkit-backdrop-filter: blur(24px);
+ border: 1px solid var(--border-glass);
+ border-radius: var(--radius);
+ padding: 24px;
+ width: 340px;
+ box-shadow: var(--shadow);
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+ max-height: calc(100vh - 48px);
+ overflow-y: auto;
+}
+
+/* Custom Scrollbar for Dock */
+.floating-dock::-webkit-scrollbar { width: 6px; }
+.floating-dock::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 10px; }
+
+.header h2 { margin: 0 0 8px 0; font-size: 1.4rem; font-weight: 700; background: linear-gradient(135deg, #818CF8, #C084FC); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
+.header p { margin: 0; font-size: 0.85rem; color: var(--text-muted); line-height: 1.5; }
+
+/* Buttons */
+.menu-group { display: flex; flex-direction: column; gap: 10px; }
+.menu-title { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1px; color: var(--text-muted); font-weight: 600; margin-bottom: 4px; }
+
+.btn-action {
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid var(--border-glass);
+ color: var(--text-light);
+ padding: 14px 16px;
+ border-radius: 12px;
+ cursor: pointer;
+ font-weight: 500;
+ font-family: var(--font);
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ font-size: 0.9rem;
+ text-align: left;
+}
+
+.btn-action svg { width: 20px; height: 20px; flex-shrink: 0; }
+
+.btn-action:hover {
+ background: rgba(255, 255, 255, 0.1);
+ transform: translateX(4px);
+}
+
+.btn-action.active {
+ background: var(--primary);
+ border-color: var(--primary);
+ box-shadow: 0 10px 15px -3px rgba(99, 102, 241, 0.4);
+}
+
+/* Button Variants */
+.btn-spbu svg { color: #10B981; }
+.btn-jalan svg { color: #F59E0B; }
+.btn-kavling svg { color: #3B82F6; }
+.btn-rumah svg { color: #8B5CF6; }
+.btn-warga svg { color: #EF4444; }
+
+/* Form Panel */
+.form-panel {
+ background: rgba(0, 0, 0, 0.4);
+ border-radius: 12px;
+ padding: 16px;
+ border: 1px solid var(--border-glass);
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.form-group { display: flex; flex-direction: column; gap: 6px; }
+.form-group label { font-size: 0.8rem; color: var(--text-muted); }
+.form-control {
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid var(--border-glass);
+ padding: 10px 12px;
+ border-radius: 8px;
+ color: white;
+ font-family: var(--font);
+ font-size: 0.9rem;
+ outline: none;
+ transition: border 0.2s;
+}
+.form-control:focus { border-color: var(--primary); }
+
+.checkbox-group {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ cursor: pointer;
+}
+.checkbox-group input { width: 16px; height: 16px; accent-color: var(--primary); cursor: pointer; }
+.checkbox-group span { font-size: 0.85rem; color: var(--text-light); }
+
+.btn-submit {
+ background: var(--primary);
+ color: white;
+ border: none;
+ padding: 12px;
+ border-radius: 8px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.2s;
+}
+.btn-submit:hover { background: var(--primary-hover); }
+
+/* Leaflet Customizations */
+.leaflet-draw-toolbar { display: none !important; }
+.leaflet-control-zoom { border: none !important; box-shadow: var(--shadow) !important; }
+.leaflet-control-zoom a {
+ background: var(--bg-glass) !important;
+ color: var(--text-light) !important;
+ border-color: var(--border-glass) !important;
+ backdrop-filter: blur(10px);
+}
+.leaflet-control-zoom a:hover { background: var(--primary) !important; }
+
+/* Leaflet Popup Premium */
+.leaflet-popup-content-wrapper {
+ background: var(--bg-glass) !important;
+ backdrop-filter: blur(16px) !important;
+ border: 1px solid var(--border-glass) !important;
+ color: var(--text-light) !important;
+ border-radius: 16px !important;
+ box-shadow: var(--shadow) !important;
+}
+.leaflet-popup-tip { background: var(--bg-glass) !important; border: 1px solid var(--border-glass) !important; }
+.leaflet-popup-content { margin: 16px !important; font-family: var(--font); }
+.leaflet-popup-content h3 { margin: 0 0 8px 0; font-size: 1.1rem; border-bottom: 1px solid var(--border-glass); padding-bottom: 8px; }
+.leaflet-popup-content p { margin: 4px 0; font-size: 0.9rem; color: var(--text-muted); }
+.leaflet-popup-content .btn-delete {
+ background: rgba(239, 68, 68, 0.1);
+ color: #EF4444;
+ border: 1px solid rgba(239, 68, 68, 0.3);
+ padding: 6px 12px;
+ border-radius: 6px;
+ cursor: pointer;
+ margin-top: 12px;
+ width: 100%;
+ transition: all 0.2s;
+}
+.leaflet-popup-content .btn-delete:hover { background: #EF4444; color: white; }
+
+/* Custom Map Marker Icon Animation */
+.custom-icon div {
+ transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+}
+.custom-icon:hover div {
+ transform: scale(1.1) translateY(-4px);
+}
+
+/* Toast Notification */
+.toast-container {
+ position: fixed; bottom: 24px; right: 24px; z-index: 9999;
+ display: flex; flex-direction: column; gap: 10px;
+}
+.toast {
+ background: var(--bg-glass); backdrop-filter: blur(16px);
+ border: 1px solid var(--border-glass); border-left: 4px solid var(--primary);
+ padding: 16px 20px; border-radius: 12px; color: white; font-size: 0.9rem;
+ box-shadow: var(--shadow); animation: slideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+}
+.toast.error { border-left-color: #EF4444; }
+.toast.success { border-left-color: #10B981; }
+
+@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
diff --git a/01/frontend/css/map.css b/01/frontend/css/map.css
new file mode 100644
index 0000000..5cbc999
--- /dev/null
+++ b/01/frontend/css/map.css
@@ -0,0 +1,73 @@
+/* map.css
+ * Tanggung Jawab: Styling container peta, kontrol Leaflet, dan pop-up modern.
+ */
+
+.map-container {
+ flex: 1;
+ position: relative;
+ background: #E2E8F0;
+}
+
+#map {
+ width: 100%;
+ height: 100%;
+ z-index: 1;
+}
+
+/* Customizing Leaflet Controls */
+.leaflet-control-zoom {
+ border: none !important;
+ box-shadow: var(--shadow-md) !important;
+ border-radius: var(--radius-md) !important;
+ overflow: hidden;
+}
+
+.leaflet-control-zoom a {
+ color: var(--text-main) !important;
+ background: var(--surface-glass) !important;
+ backdrop-filter: blur(8px);
+ transition: var(--transition);
+}
+
+.leaflet-control-zoom a:hover {
+ background: var(--surface) !important;
+ color: var(--primary) !important;
+}
+
+/* Sembunyikan Toolbar Default Leaflet Draw karena kita pakai Menu Kiri */
+.leaflet-draw-toolbar {
+ display: none !important;
+}
+
+/* Custom Popup Modern */
+.leaflet-popup-content-wrapper {
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-lg);
+ padding: 4px;
+}
+
+.leaflet-popup-content {
+ margin: 14px;
+ font-family: 'Inter', sans-serif;
+}
+
+.popup-title {
+ font-size: 1.1rem;
+ font-weight: 600;
+ color: var(--text-main);
+ margin-bottom: 6px;
+ border-bottom: 2px solid var(--primary);
+ padding-bottom: 4px;
+ display: inline-block;
+}
+
+.popup-desc {
+ font-size: 0.9rem;
+ color: var(--text-muted);
+ margin-bottom: 12px;
+}
+
+.popup-action {
+ display: flex;
+ justify-content: flex-end;
+}
diff --git a/01/frontend/css/sidebar.css b/01/frontend/css/sidebar.css
new file mode 100644
index 0000000..5ce4b23
--- /dev/null
+++ b/01/frontend/css/sidebar.css
@@ -0,0 +1,78 @@
+/* sidebar.css
+ * Tanggung Jawab: Styling layout sidebar, navigasi, dan list item.
+ */
+
+.sidebar {
+ width: 350px;
+ background: var(--surface-glass);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ border-right: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ z-index: 1000;
+ box-shadow: var(--shadow-lg);
+ transition: var(--transition);
+}
+
+.sidebar-header {
+ padding: 24px;
+ border-bottom: 1px solid var(--border);
+}
+
+.sidebar-title {
+ font-size: 1.25rem;
+ font-weight: 700;
+ background: linear-gradient(135deg, var(--primary), var(--secondary));
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ letter-spacing: -0.5px;
+}
+
+.sidebar-subtitle {
+ font-size: 0.85rem;
+ color: var(--text-muted);
+ margin-top: 4px;
+}
+
+.sidebar-content {
+ flex: 1;
+ overflow-y: auto;
+ padding: 24px;
+}
+
+/* Toast Notification */
+.toast-container {
+ position: fixed;
+ bottom: 24px;
+ right: 24px;
+ z-index: 9999;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.toast {
+ background: var(--surface);
+ color: var(--text-main);
+ padding: 12px 20px;
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-md);
+ font-size: 0.9rem;
+ font-weight: 500;
+ transform: translateY(20px);
+ opacity: 0;
+ animation: slideIn 0.3s forwards ease-out;
+ border-left: 4px solid var(--primary);
+}
+
+.toast.error {
+ border-left-color: var(--danger);
+}
+
+@keyframes slideIn {
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
+}
diff --git a/01/frontend/index.html b/01/frontend/index.html
new file mode 100644
index 0000000..a851e3b
--- /dev/null
+++ b/01/frontend/index.html
@@ -0,0 +1,56 @@
+
+
+
+
+
+ WebGIS - Pertemuan 01
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/01/frontend/js/app.js b/01/frontend/js/app.js
new file mode 100644
index 0000000..073b818
--- /dev/null
+++ b/01/frontend/js/app.js
@@ -0,0 +1,92 @@
+import { initMap } from './modules/map.js';
+import { setupDrawControls } from './modules/draw.js';
+import { renderForm } from './modules/form.js';
+import { spbuService, jalanService, kavlingService } from './services/api.service.js';
+
+let appMap;
+let drawControl;
+
+// Custom SVG Icons Generator
+export const createIcon = (svgPath, color) => {
+ return L.divIcon({
+ className: 'custom-icon',
+ html: `
+ ${svgPath}
+
`,
+ iconSize: [34, 34],
+ iconAnchor: [17, 34],
+ popupAnchor: [0, -34]
+ });
+};
+
+export const iconSPBU = (is24h) => createIcon(' ', is24h ? '#10B981' : '#EF4444');
+
+window.showToast = (msg, type='success') => {
+ const t = document.createElement('div');
+ t.className = `toast ${type}`;
+ t.innerHTML = msg;
+ document.getElementById('toast-container').appendChild(t);
+ setTimeout(() => { t.style.transform='translateX(100%)'; t.style.opacity=0; setTimeout(()=>t.remove(),300); }, 3000);
+};
+
+document.addEventListener('DOMContentLoaded', async () => {
+ appMap = initMap('map');
+ drawControl = setupDrawControls(appMap, handleGeometryCreated);
+ await loadAllData();
+});
+
+const loadAllData = async () => {
+ try {
+ const spbu = await spbuService.getAll();
+ L.geoJSON(spbu, {
+ pointToLayer: (f, latlng) => L.marker(latlng, { icon: iconSPBU(f.properties.buka_24_jam) }),
+ onEachFeature: (f, l) => bindPopup(l, 'spbu', f.properties)
+ }).addTo(appMap);
+
+ const jalan = await jalanService.getAll();
+ L.geoJSON(jalan, { style: { color: '#F59E0B', weight: 4 }, onEachFeature: (f, l) => bindPopup(l, 'jalan', f.properties) }).addTo(appMap);
+
+ const kavling = await kavlingService.getAll();
+ L.geoJSON(kavling, { style: { color: '#3B82F6', weight: 2, fillColor: '#3B82F6', fillOpacity: 0.3 }, onEachFeature: (f, l) => bindPopup(l, 'kavling', f.properties) }).addTo(appMap);
+ } catch (e) {
+ window.showToast("Gagal meload data: "+e.message, 'error');
+ }
+};
+
+const bindPopup = (layer, type, props) => {
+ const ext = props.buka_24_jam !== undefined ? `Buka 24 Jam: ${props.buka_24_jam ? 'Ya' : 'Tidak'}
` : '';
+ layer.bindPopup(`
+
+ `);
+};
+
+window.deleteData = async (type, id) => {
+ if(!confirm('Yakin ingin menghapus?')) return;
+ try {
+ if(type==='spbu') await spbuService.delete(id);
+ if(type==='jalan') await jalanService.delete(id);
+ if(type==='kavling') await kavlingService.delete(id);
+ window.showToast('Data dihapus');
+ setTimeout(()=>location.reload(), 800);
+ } catch(e) { window.showToast('Gagal hapus', 'error'); }
+};
+
+const handleGeometryCreated = (type, geometry, layer) => {
+ const tempLayer = L.geoJSON(geometry).addTo(appMap);
+ renderForm(type, geometry, async (payload) => {
+ try {
+ if(type==='spbu') await spbuService.create(payload);
+ if(type==='jalan') await jalanService.create(payload);
+ if(type==='kavling') await kavlingService.create(payload);
+ window.showToast('Berhasil disimpan');
+ setTimeout(()=>location.reload(), 800);
+ } catch(e) { window.showToast('Gagal simpan', 'error'); }
+ }, () => {
+ appMap.removeLayer(tempLayer);
+ });
+};
diff --git a/01/frontend/js/config.js b/01/frontend/js/config.js
new file mode 100644
index 0000000..452904b
--- /dev/null
+++ b/01/frontend/js/config.js
@@ -0,0 +1,15 @@
+/**
+ * config.js
+ * Tanggung Jawab: Menyimpan konstanta dan konfigurasi global aplikasi.
+ */
+
+export const CONFIG = {
+ BASE_URL: '/01/backend/api',
+
+ // Konstanta tipe fitur
+ FEATURE_TYPES: {
+ SPBU: 'Point',
+ JALAN: 'LineString',
+ KAVLING: 'Polygon'
+ }
+};
diff --git a/01/frontend/js/modules/draw.js b/01/frontend/js/modules/draw.js
new file mode 100644
index 0000000..40628f4
--- /dev/null
+++ b/01/frontend/js/modules/draw.js
@@ -0,0 +1,31 @@
+export const setupDrawControls = (map, onGeometryCreated) => {
+ const drawControl = new L.Control.Draw({
+ draw: { marker: true, polyline: true, polygon: true, circle: false, circlemarker: false, rectangle: false }
+ });
+ map.addControl(drawControl);
+
+ map.on(L.Draw.Event.CREATED, function (event) {
+ const layer = event.layer;
+ const type = event.layerType;
+
+ // Peta draw default tipe: marker -> spbu, polyline -> jalan, polygon -> kavling
+ let customType = type;
+ if(type === 'marker') customType = 'spbu';
+ if(type === 'polyline') customType = 'jalan';
+ if(type === 'polygon') customType = 'kavling';
+ if(window.currentDrawType) customType = window.currentDrawType; // Untuk P2/P3
+
+ onGeometryCreated(customType, layer.toGeoJSON().geometry, layer);
+ });
+
+ // Custom Menu Handlers
+ const markerDrawer = new L.Draw.Marker(map, drawControl.options.draw.marker);
+ const polylineDrawer = new L.Draw.Polyline(map, drawControl.options.draw.polyline);
+ const polygonDrawer = new L.Draw.Polygon(map, drawControl.options.draw.polygon);
+
+ document.getElementById('btn-draw-spbu')?.addEventListener('click', () => { window.currentDrawType='spbu'; markerDrawer.enable(); });
+ document.getElementById('btn-draw-jalan')?.addEventListener('click', () => { window.currentDrawType='jalan'; polylineDrawer.enable(); });
+ document.getElementById('btn-draw-kavling')?.addEventListener('click', () => { window.currentDrawType='kavling'; polygonDrawer.enable(); });
+
+ return drawControl;
+};
diff --git a/01/frontend/js/modules/form.js b/01/frontend/js/modules/form.js
new file mode 100644
index 0000000..3959e52
--- /dev/null
+++ b/01/frontend/js/modules/form.js
@@ -0,0 +1,50 @@
+export const renderForm = (type, geometry, onSubmit, onCancel) => {
+ const container = document.getElementById('form-container');
+ container.style.display = 'flex';
+
+ const extraFields = type === 'spbu' ? `
+
+
+
+ Buka 24 Jam
+
+
+ ` : '';
+
+ container.innerHTML = `
+
+ `;
+
+ document.getElementById('btn-save').addEventListener('click', () => {
+ const payload = {
+ type,
+ geometry,
+ nama: document.getElementById('input-nama').value,
+ deskripsi: document.getElementById('input-deskripsi').value,
+ };
+ if (type === 'spbu') payload.buka_24_jam = document.getElementById('input-24jam').checked;
+
+ onSubmit(payload);
+ container.style.display = 'none';
+ container.innerHTML = '';
+ });
+
+ document.getElementById('btn-cancel').addEventListener('click', () => {
+ if(onCancel) onCancel();
+ container.style.display = 'none';
+ container.innerHTML = '';
+ });
+};
diff --git a/01/frontend/js/modules/map.js b/01/frontend/js/modules/map.js
new file mode 100644
index 0000000..fdecef9
--- /dev/null
+++ b/01/frontend/js/modules/map.js
@@ -0,0 +1,25 @@
+/**
+ * map.js
+ * Tanggung Jawab: Inisialisasi peta Leaflet dasar dan base layer.
+ */
+
+export const initMap = (containerId) => {
+ // Pusat peta default (Universitas Tanjungpura, Pontianak)
+ const map = L.map(containerId, {
+ zoomControl: false
+ }).setView([-0.0583, 109.3448], 15);
+
+ // Pindahkan zoom control ke kanan bawah
+ L.control.zoom({
+ position: 'bottomright'
+ }).addTo(map);
+
+ // Tile Layer Premium (CartoDB Dark Matter untuk Glass UI)
+ L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
+ attribution: '© OpenStreetMap contributors © CARTO ',
+ subdomains: 'abcd',
+ maxZoom: 20
+ }).addTo(map);
+
+ return map;
+};
diff --git a/01/frontend/js/services/api.service.js b/01/frontend/js/services/api.service.js
new file mode 100644
index 0000000..36e7e5f
--- /dev/null
+++ b/01/frontend/js/services/api.service.js
@@ -0,0 +1,41 @@
+export const BASE_URL = '';
+
+const createService = (endpoint) => ({
+ getAll: async () => {
+ const res = await fetch(`${BASE_URL}/${endpoint}`);
+ const json = await res.json();
+ return json.data;
+ },
+ create: async (data) => {
+ const res = await fetch(`${BASE_URL}/${endpoint}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data)
+ });
+ const json = await res.json();
+ return json.data;
+ },
+ delete: async (id) => {
+ const res = await fetch(`${BASE_URL}/${endpoint}?id=${id}`, {
+ method: 'DELETE'
+ });
+ const json = await res.json();
+ return json.data;
+ }
+});
+
+// P01
+export const spbuService = createService('01/backend/api/spbu.php');
+export const jalanService = createService('01/backend/api/jalan.php');
+export const kavlingService = createService('01/backend/api/kavling.php');
+
+// P02
+export const rumahIbadahService = createService('02/backend/api/rumah_ibadah.php');
+export const wargaMiskinService = createService('02/backend/api/warga_miskin.php');
+export const haversineService = {
+ getDalamRadius: async (id, radius) => {
+ const res = await fetch(`${BASE_URL}/02/backend/api/haversine.php?rumah_ibadah_id=${id}&radius_km=${radius}`);
+ const json = await res.json();
+ return json.data;
+ }
+};
diff --git a/01/frontend/js/services/jalan.service.js b/01/frontend/js/services/jalan.service.js
new file mode 100644
index 0000000..b83d1d4
--- /dev/null
+++ b/01/frontend/js/services/jalan.service.js
@@ -0,0 +1,28 @@
+/**
+ * jalan.service.js
+ * Tanggung Jawab: Komunikasi HTTP ke endpoint API jalan.php
+ */
+import { CONFIG } from '../config.js';
+
+export const jalanService = {
+ getAll: async () => {
+ const response = await fetch(`${CONFIG.BASE_URL}/jalan.php`);
+ return await response.json();
+ },
+
+ save: async (data) => {
+ const response = await fetch(`${CONFIG.BASE_URL}/jalan.php`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data)
+ });
+ return await response.json();
+ },
+
+ delete: async (id) => {
+ const response = await fetch(`${CONFIG.BASE_URL}/jalan.php?id=${id}`, {
+ method: 'DELETE'
+ });
+ return await response.json();
+ }
+};
diff --git a/01/frontend/js/services/kavling.service.js b/01/frontend/js/services/kavling.service.js
new file mode 100644
index 0000000..eb25543
--- /dev/null
+++ b/01/frontend/js/services/kavling.service.js
@@ -0,0 +1,28 @@
+/**
+ * kavling.service.js
+ * Tanggung Jawab: Komunikasi HTTP ke endpoint API kavling.php
+ */
+import { CONFIG } from '../config.js';
+
+export const kavlingService = {
+ getAll: async () => {
+ const response = await fetch(`${CONFIG.BASE_URL}/kavling.php`);
+ return await response.json();
+ },
+
+ save: async (data) => {
+ const response = await fetch(`${CONFIG.BASE_URL}/kavling.php`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data)
+ });
+ return await response.json();
+ },
+
+ delete: async (id) => {
+ const response = await fetch(`${CONFIG.BASE_URL}/kavling.php?id=${id}`, {
+ method: 'DELETE'
+ });
+ return await response.json();
+ }
+};
diff --git a/01/frontend/js/services/spbu.service.js b/01/frontend/js/services/spbu.service.js
new file mode 100644
index 0000000..31c6b8d
--- /dev/null
+++ b/01/frontend/js/services/spbu.service.js
@@ -0,0 +1,28 @@
+/**
+ * spbu.service.js
+ * Tanggung Jawab: Komunikasi HTTP ke endpoint API spbu.php
+ */
+import { CONFIG } from '../config.js';
+
+export const spbuService = {
+ getAll: async () => {
+ const response = await fetch(`${CONFIG.BASE_URL}/spbu.php`);
+ return await response.json();
+ },
+
+ save: async (data) => {
+ const response = await fetch(`${CONFIG.BASE_URL}/spbu.php`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data)
+ });
+ return await response.json();
+ },
+
+ delete: async (id) => {
+ const response = await fetch(`${CONFIG.BASE_URL}/spbu.php?id=${id}`, {
+ method: 'DELETE'
+ });
+ return await response.json();
+ }
+};
diff --git a/01/frontend/js/utils/style.util.js b/01/frontend/js/utils/style.util.js
new file mode 100644
index 0000000..565c238
--- /dev/null
+++ b/01/frontend/js/utils/style.util.js
@@ -0,0 +1,34 @@
+/**
+ * style.util.js
+ * Tanggung Jawab: Mendefinisikan style untuk GeoJSON berdasarkan properti.
+ */
+
+export const getGeoJsonStyle = (feature) => {
+ // Style default untuk Kavling (Polygon)
+ if (feature.geometry.type === 'Polygon') {
+ return {
+ color: '#10B981', // Secondary color
+ weight: 2,
+ opacity: 0.8,
+ fillColor: '#34D399',
+ fillOpacity: 0.4
+ };
+ }
+
+ // Style default untuk Jalan (LineString)
+ if (feature.geometry.type === 'LineString') {
+ const type = feature.properties.jenis_jalan;
+ let color = '#4F46E5'; // Primary color
+
+ if (type === 'Arteri') color = '#EF4444'; // Danger color
+ else if (type === 'Kolektor') color = '#F59E0B'; // Warning color
+
+ return {
+ color: color,
+ weight: 4,
+ opacity: 0.9
+ };
+ }
+
+ return {};
+};
diff --git a/01/frontend/js/utils/ui.util.js b/01/frontend/js/utils/ui.util.js
new file mode 100644
index 0000000..a462ff5
--- /dev/null
+++ b/01/frontend/js/utils/ui.util.js
@@ -0,0 +1,32 @@
+/**
+ * ui.util.js
+ * Tanggung Jawab: Manipulasi DOM umum seperti Toast notification.
+ */
+
+export const showToast = (message, type = 'success') => {
+ const container = document.getElementById('toast-container');
+ if (!container) return;
+
+ const toast = document.createElement('div');
+ toast.className = `toast ${type}`;
+ toast.textContent = message;
+
+ container.appendChild(toast);
+
+ // Hapus toast setelah 3 detik
+ setTimeout(() => {
+ toast.style.opacity = '0';
+ toast.style.transform = 'translateY(20px)';
+ toast.style.transition = 'all 0.3s ease';
+
+ setTimeout(() => toast.remove(), 300);
+ }, 3000);
+};
+
+export const clearFormContainer = () => {
+ const container = document.getElementById('form-container');
+ if (container) {
+ container.innerHTML = '';
+ container.style.display = 'none';
+ }
+};
diff --git a/02/backend/api/jalan.php b/02/backend/api/jalan.php
new file mode 100644
index 0000000..7264a11
--- /dev/null
+++ b/02/backend/api/jalan.php
@@ -0,0 +1,75 @@
+query("SELECT id, nama, jenis_jalan, created_at, ST_AsGeoJSON(geom) as geojson FROM jalan");
+ $features = [];
+ while ($row = $stmt->fetch()) {
+ $features[] = [
+ 'type' => 'Feature',
+ 'geometry' => json_decode($row['geojson']),
+ 'properties' => [
+ 'id' => $row['id'],
+ 'nama' => $row['nama'],
+ 'jenis_jalan' => $row['jenis_jalan'],
+ 'created_at' => $row['created_at']
+ ]
+ ];
+ }
+ sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Jalan berhasil diambil');
+ } catch (PDOException $e) {
+ sendError('Gagal mengambil data Jalan: ' . $e->getMessage(), 500);
+ }
+ break;
+
+ case 'POST':
+ $input = json_decode(file_get_contents('php://input'), true);
+ if (!isset($input['nama']) || !isset($input['geometry'])) {
+ sendError('Nama dan geometri wajib diisi');
+ }
+
+ try {
+ $sql = "INSERT INTO jalan (nama, jenis_jalan, geom) VALUES (:nama, :jenis_jalan, ST_GeomFromGeoJSON(:geometry))";
+ $stmt = $pdo->prepare($sql);
+ $stmt->execute([
+ ':nama' => $input['nama'],
+ ':jenis_jalan' => $input['jenis_jalan'] ?? 'Lokal',
+ ':geometry' => json_encode($input['geometry'])
+ ]);
+ sendSuccess(['id' => $pdo->lastInsertId()], 'Data Jalan berhasil disimpan', 201);
+ } catch (PDOException $e) {
+ sendError('Gagal menyimpan Jalan: ' . $e->getMessage(), 500);
+ }
+ break;
+
+ case 'DELETE':
+ $id = $_GET['id'] ?? null;
+ if (!$id) {
+ sendError('ID Jalan wajib disertakan');
+ }
+
+ try {
+ $stmt = $pdo->prepare("DELETE FROM jalan WHERE id = :id");
+ $stmt->execute([':id' => $id]);
+ sendSuccess(null, 'Data Jalan berhasil dihapus');
+ } catch (PDOException $e) {
+ sendError('Gagal menghapus Jalan: ' . $e->getMessage(), 500);
+ }
+ break;
+
+ default:
+ sendError('Method not allowed', 405);
+ break;
+}
+?>
diff --git a/02/backend/api/kavling.php b/02/backend/api/kavling.php
new file mode 100644
index 0000000..ed862a5
--- /dev/null
+++ b/02/backend/api/kavling.php
@@ -0,0 +1,75 @@
+query("SELECT id, nama_pemilik, luas, created_at, ST_AsGeoJSON(geom) as geojson FROM kavling");
+ $features = [];
+ while ($row = $stmt->fetch()) {
+ $features[] = [
+ 'type' => 'Feature',
+ 'geometry' => json_decode($row['geojson']),
+ 'properties' => [
+ 'id' => $row['id'],
+ 'nama_pemilik' => $row['nama_pemilik'],
+ 'luas' => $row['luas'],
+ 'created_at' => $row['created_at']
+ ]
+ ];
+ }
+ sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Kavling berhasil diambil');
+ } catch (PDOException $e) {
+ sendError('Gagal mengambil data Kavling: ' . $e->getMessage(), 500);
+ }
+ break;
+
+ case 'POST':
+ $input = json_decode(file_get_contents('php://input'), true);
+ if (!isset($input['nama_pemilik']) || !isset($input['geometry'])) {
+ sendError('Nama pemilik dan geometri wajib diisi');
+ }
+
+ try {
+ $sql = "INSERT INTO kavling (nama_pemilik, luas, geom) VALUES (:nama_pemilik, :luas, ST_GeomFromGeoJSON(:geometry))";
+ $stmt = $pdo->prepare($sql);
+ $stmt->execute([
+ ':nama_pemilik' => $input['nama_pemilik'],
+ ':luas' => $input['luas'] ?? 0,
+ ':geometry' => json_encode($input['geometry'])
+ ]);
+ sendSuccess(['id' => $pdo->lastInsertId()], 'Data Kavling berhasil disimpan', 201);
+ } catch (PDOException $e) {
+ sendError('Gagal menyimpan Kavling: ' . $e->getMessage(), 500);
+ }
+ break;
+
+ case 'DELETE':
+ $id = $_GET['id'] ?? null;
+ if (!$id) {
+ sendError('ID Kavling wajib disertakan');
+ }
+
+ try {
+ $stmt = $pdo->prepare("DELETE FROM kavling WHERE id = :id");
+ $stmt->execute([':id' => $id]);
+ sendSuccess(null, 'Data Kavling berhasil dihapus');
+ } catch (PDOException $e) {
+ sendError('Gagal menghapus Kavling: ' . $e->getMessage(), 500);
+ }
+ break;
+
+ default:
+ sendError('Method not allowed', 405);
+ break;
+}
+?>
diff --git a/02/backend/api/rumah_ibadah.php b/02/backend/api/rumah_ibadah.php
new file mode 100644
index 0000000..1de6d5d
--- /dev/null
+++ b/02/backend/api/rumah_ibadah.php
@@ -0,0 +1,89 @@
+prepare("SELECT ST_AsGeoJSON(geom) as geojson FROM rumah_ibadah WHERE id = ?");
+ $stmt->execute([$id]);
+ $rumah_ibadah = $stmt->fetch();
+
+ if (!$rumah_ibadah) sendError('Rumah ibadah tidak ditemukan', 404);
+
+ $geom = json_decode($rumah_ibadah['geojson'], true);
+ $pusatLon = $geom['coordinates'][0];
+ $pusatLat = $geom['coordinates'][1];
+
+ // Panggil geo_helper untuk mendapatkan warga miskin
+ $warga = GeoHelper::getWargaDalamRadius($pdo, $pusatLat, $pusatLon, $radius);
+ sendSuccess($warga, 'Data jangkauan berhasil dihitung');
+
+ } else {
+ // GET Semua Rumah Ibadah (GeoJSON)
+ try {
+ $stmt = $pdo->query("SELECT id, nama, agama, ST_AsGeoJSON(geom) as geojson FROM rumah_ibadah");
+ $features = [];
+ while ($row = $stmt->fetch()) {
+ $features[] = [
+ 'type' => 'Feature',
+ 'geometry' => json_decode($row['geojson']),
+ 'properties' => [
+ 'id' => $row['id'],
+ 'nama' => $row['nama'],
+ 'agama' => $row['agama']
+ ]
+ ];
+ }
+ sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Rumah Ibadah');
+ } catch (PDOException $e) {
+ sendError('Gagal: ' . $e->getMessage(), 500);
+ }
+ }
+ break;
+
+ case 'POST':
+ $input = json_decode(file_get_contents('php://input'), true);
+ if (!isset($input['nama']) || !isset($input['geometry'])) sendError('Validasi gagal');
+
+ try {
+ $sql = "INSERT INTO rumah_ibadah (nama, agama, geom) VALUES (:nama, :agama, ST_GeomFromGeoJSON(:geometry))";
+ $stmt = $pdo->prepare($sql);
+ $stmt->execute([
+ ':nama' => $input['nama'],
+ ':agama' => $input['agama'] ?? 'Islam',
+ ':geometry' => json_encode($input['geometry'])
+ ]);
+ sendSuccess(['id' => $pdo->lastInsertId()], 'Disimpan', 201);
+ } catch (PDOException $e) {
+ sendError('Gagal: ' . $e->getMessage(), 500);
+ }
+ break;
+
+ case 'DELETE':
+ $id = $_GET['id'] ?? null;
+ if (!$id) sendError('ID dibutuhkan');
+ $stmt = $pdo->prepare("DELETE FROM rumah_ibadah WHERE id = ?");
+ $stmt->execute([$id]);
+ sendSuccess(null, 'Dihapus');
+ break;
+
+ default:
+ sendError('Method not allowed', 405);
+ break;
+}
+?>
diff --git a/02/backend/api/spbu.php b/02/backend/api/spbu.php
new file mode 100644
index 0000000..7529ba1
--- /dev/null
+++ b/02/backend/api/spbu.php
@@ -0,0 +1,59 @@
+query("SELECT id, nama, deskripsi, buka_24_jam, created_at, ST_AsGeoJSON(geom) as geojson FROM spbu");
+ $features = [];
+ while ($row = $stmt->fetch()) {
+ $features[] = [
+ 'type' => 'Feature',
+ 'geometry' => json_decode($row['geojson']),
+ 'properties' => [
+ 'id' => $row['id'],
+ 'nama' => $row['nama'],
+ 'deskripsi' => $row['deskripsi'],
+ 'buka_24_jam' => (bool)$row['buka_24_jam'],
+ 'created_at' => $row['created_at']
+ ]
+ ];
+ }
+ sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data SPBU berhasil diambil');
+ } catch (PDOException $e) { sendError('Gagal: ' . $e->getMessage(), 500); }
+ break;
+
+ case 'POST':
+ $input = json_decode(file_get_contents('php://input'), true);
+ if (!isset($input['nama']) || !isset($input['geometry'])) sendError('Validasi gagal');
+
+ try {
+ $sql = "INSERT INTO spbu (nama, deskripsi, buka_24_jam, geom) VALUES (:nama, :deskripsi, :buka_24_jam, ST_GeomFromGeoJSON(:geometry))";
+ $stmt = $pdo->prepare($sql);
+ $stmt->execute([
+ ':nama' => $input['nama'],
+ ':deskripsi' => $input['deskripsi'] ?? '',
+ ':buka_24_jam' => isset($input['buka_24_jam']) && $input['buka_24_jam'] ? 1 : 0,
+ ':geometry' => json_encode($input['geometry'])
+ ]);
+ sendSuccess(['id' => $pdo->lastInsertId()], 'Disimpan', 201);
+ } catch (PDOException $e) { sendError('Gagal: ' . $e->getMessage(), 500); }
+ break;
+
+ case 'DELETE':
+ $id = $_GET['id'] ?? null;
+ if (!$id) sendError('ID dibutuhkan');
+ $stmt = $pdo->prepare("DELETE FROM spbu WHERE id = ?");
+ $stmt->execute([$id]);
+ sendSuccess(null, 'Dihapus');
+ break;
+
+ default:
+ sendError('Method not allowed', 405);
+ break;
+}
+?>
diff --git a/02/backend/api/warga_miskin.php b/02/backend/api/warga_miskin.php
new file mode 100644
index 0000000..dca0aff
--- /dev/null
+++ b/02/backend/api/warga_miskin.php
@@ -0,0 +1,67 @@
+query("SELECT id, nama_kk, penghasilan, jumlah_tanggungan, ST_AsGeoJSON(geom) as geojson FROM warga_miskin");
+ $features = [];
+ while ($row = $stmt->fetch()) {
+ $features[] = [
+ 'type' => 'Feature',
+ 'geometry' => json_decode($row['geojson']),
+ 'properties' => [
+ 'id' => $row['id'],
+ 'nama_kk' => $row['nama_kk'],
+ 'penghasilan' => $row['penghasilan'],
+ 'jumlah_tanggungan' => $row['jumlah_tanggungan']
+ ]
+ ];
+ }
+ sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Warga Miskin');
+ } catch (PDOException $e) {
+ sendError('Gagal: ' . $e->getMessage(), 500);
+ }
+ break;
+
+ case 'POST':
+ $input = json_decode(file_get_contents('php://input'), true);
+ if (!isset($input['nama_kk']) || !isset($input['geometry'])) sendError('Validasi gagal');
+
+ try {
+ $sql = "INSERT INTO warga_miskin (nama_kk, penghasilan, jumlah_tanggungan, geom) VALUES (:nama_kk, :penghasilan, :jumlah_tanggungan, ST_GeomFromGeoJSON(:geometry))";
+ $stmt = $pdo->prepare($sql);
+ $stmt->execute([
+ ':nama_kk' => $input['nama_kk'],
+ ':penghasilan' => $input['penghasilan'] ?? 0,
+ ':jumlah_tanggungan' => $input['jumlah_tanggungan'] ?? 0,
+ ':geometry' => json_encode($input['geometry'])
+ ]);
+ sendSuccess(['id' => $pdo->lastInsertId()], 'Disimpan', 201);
+ } catch (PDOException $e) {
+ sendError('Gagal: ' . $e->getMessage(), 500);
+ }
+ break;
+
+ case 'DELETE':
+ $id = $_GET['id'] ?? null;
+ if (!$id) sendError('ID dibutuhkan');
+ $stmt = $pdo->prepare("DELETE FROM warga_miskin WHERE id = ?");
+ $stmt->execute([$id]);
+ sendSuccess(null, 'Dihapus');
+ break;
+
+ default:
+ sendError('Method not allowed', 405);
+ break;
+}
+?>
diff --git a/02/backend/config/db.php b/02/backend/config/db.php
new file mode 100644
index 0000000..287e5da
--- /dev/null
+++ b/02/backend/config/db.php
@@ -0,0 +1,32 @@
+setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+ self::$conn->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
+ } catch (PDOException $exception) {
+ echo "Connection error: " . $exception->getMessage();
+ }
+ }
+ return self::$conn;
+ }
+}
diff --git a/02/backend/utils/geo_helper.php b/02/backend/utils/geo_helper.php
new file mode 100644
index 0000000..4d0a44f
--- /dev/null
+++ b/02/backend/utils/geo_helper.php
@@ -0,0 +1,66 @@
+query("SELECT id, nama_kk, penghasilan, jumlah_tanggungan, ST_AsGeoJSON(geom) as geojson FROM warga_miskin");
+
+ $wargaDalamRadius = [];
+
+ while ($row = $stmt->fetch()) {
+ $geom = json_decode($row['geojson'], true);
+ // GeoJSON Point format: [Longitude, Latitude]
+ $wargaLon = $geom['coordinates'][0];
+ $wargaLat = $geom['coordinates'][1];
+
+ // Hitung jarak dengan Haversine
+ $jarak = self::haversineDistance($pusatLat, $pusatLon, $wargaLat, $wargaLon);
+
+ if ($jarak <= $radiusKm) {
+ $wargaDalamRadius[] = [
+ 'id' => $row['id'],
+ 'nama_kk' => $row['nama_kk'],
+ 'penghasilan' => $row['penghasilan'],
+ 'jumlah_tanggungan' => $row['jumlah_tanggungan'],
+ 'jarak_km' => round($jarak, 2),
+ 'geometry' => $geom
+ ];
+ }
+ }
+
+ // Urutkan berdasarkan jarak terdekat
+ usort($wargaDalamRadius, function($a, $b) {
+ return $a['jarak_km'] <=> $b['jarak_km'];
+ });
+
+ return $wargaDalamRadius;
+ }
+}
+?>
diff --git a/02/backend/utils/response_helper.php b/02/backend/utils/response_helper.php
new file mode 100644
index 0000000..cd7968c
--- /dev/null
+++ b/02/backend/utils/response_helper.php
@@ -0,0 +1,29 @@
+ 'success', 'message' => $message, 'data' => $data]);
+ exit();
+}
+
+function sendError($message = 'Error', $statusCode = 400) {
+ http_response_code($statusCode);
+ echo json_encode(['status' => 'error', 'message' => $message]);
+ exit();
+}
+?>
diff --git a/02/frontend/css/base.css b/02/frontend/css/base.css
new file mode 100644
index 0000000..7e7c44c
--- /dev/null
+++ b/02/frontend/css/base.css
@@ -0,0 +1,61 @@
+/* base.css
+ * Tanggung Jawab: Reset CSS, definisi variabel warna premium, dan tipografi dasar.
+ */
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
+
+:root {
+ /* Color Palette - Minimalist & Elegant */
+ --primary: #4F46E5;
+ --primary-hover: #4338CA;
+ --secondary: #10B981;
+ --background: #F3F4F6;
+ --surface: #FFFFFF;
+ --surface-glass: rgba(255, 255, 255, 0.85);
+ --text-main: #111827;
+ --text-muted: #6B7280;
+ --border: #E5E7EB;
+ --danger: #EF4444;
+
+ /* Shadows & Effects */
+ --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
+ --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
+ --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
+ --radius-md: 12px;
+ --radius-lg: 16px;
+ --transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: 'Inter', sans-serif;
+ background-color: var(--background);
+ color: var(--text-main);
+ display: flex;
+ height: 100vh;
+ overflow: hidden;
+}
+
+h1, h2, h3, h4 {
+ font-weight: 600;
+ color: var(--text-main);
+}
+
+/* Scrollbar minimalis */
+::-webkit-scrollbar {
+ width: 6px;
+}
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+::-webkit-scrollbar-thumb {
+ background: #CBD5E1;
+ border-radius: 10px;
+}
+::-webkit-scrollbar-thumb:hover {
+ background: #94A3B8;
+}
diff --git a/02/frontend/css/form.css b/02/frontend/css/form.css
new file mode 100644
index 0000000..be53320
--- /dev/null
+++ b/02/frontend/css/form.css
@@ -0,0 +1,92 @@
+/* form.css
+ * Tanggung Jawab: Styling komponen form (input, button, select).
+ */
+
+.form-group {
+ margin-bottom: 16px;
+}
+
+.form-label {
+ display: block;
+ font-size: 0.85rem;
+ font-weight: 500;
+ margin-bottom: 6px;
+ color: var(--text-main);
+}
+
+.form-control {
+ width: 100%;
+ padding: 10px 14px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ font-family: inherit;
+ font-size: 0.95rem;
+ transition: var(--transition);
+ background: var(--surface);
+}
+
+.form-control:focus {
+ outline: none;
+ border-color: var(--primary);
+ box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
+}
+
+/* Buttons */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 10px 20px;
+ font-family: inherit;
+ font-weight: 600;
+ font-size: 0.9rem;
+ border-radius: var(--radius-md);
+ border: none;
+ cursor: pointer;
+ transition: var(--transition);
+ gap: 8px;
+ width: 100%;
+}
+
+.btn-primary {
+ background: var(--primary);
+ color: white;
+}
+
+.btn-primary:hover {
+ background: var(--primary-hover);
+ transform: translateY(-1px);
+ box-shadow: var(--shadow-md);
+}
+
+.btn-danger {
+ background: var(--danger);
+ color: white;
+ width: auto;
+ padding: 6px 12px;
+ font-size: 0.8rem;
+}
+
+.btn-danger:hover {
+ background: #DC2626;
+}
+
+/* Micro-animation for active state */
+.btn:active {
+ transform: translateY(1px);
+}
+
+/* Form Container Animation */
+#form-container {
+ background: var(--surface);
+ padding: 20px;
+ border-radius: var(--radius-lg);
+ border: 1px solid var(--border);
+ margin-bottom: 20px;
+ animation: fadeIn 0.4s ease-out;
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; transform: scale(0.98); }
+ to { opacity: 1; transform: scale(1); }
+}
diff --git a/02/frontend/css/main.css b/02/frontend/css/main.css
new file mode 100644
index 0000000..14cadd4
--- /dev/null
+++ b/02/frontend/css/main.css
@@ -0,0 +1,205 @@
+:root {
+ --primary: #6366F1;
+ --primary-hover: #4F46E5;
+ --bg-dark: #0F172A;
+ --bg-glass: rgba(15, 23, 42, 0.75);
+ --border-glass: rgba(255, 255, 255, 0.1);
+ --text-light: #F8FAFC;
+ --text-muted: #94A3B8;
+ --radius: 20px;
+ --shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
+ --font: 'Inter', sans-serif;
+}
+
+* { box-sizing: border-box; }
+
+body {
+ margin: 0;
+ font-family: var(--font);
+ background: var(--bg-dark);
+ color: var(--text-light);
+ overflow: hidden;
+}
+
+#map {
+ width: 100vw;
+ height: 100vh;
+ z-index: 1;
+}
+
+/* Floating Dock */
+.floating-dock {
+ position: absolute;
+ top: 24px;
+ left: 24px;
+ z-index: 1000;
+ background: var(--bg-glass);
+ backdrop-filter: blur(24px);
+ -webkit-backdrop-filter: blur(24px);
+ border: 1px solid var(--border-glass);
+ border-radius: var(--radius);
+ padding: 24px;
+ width: 340px;
+ box-shadow: var(--shadow);
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+ max-height: calc(100vh - 48px);
+ overflow-y: auto;
+}
+
+/* Custom Scrollbar for Dock */
+.floating-dock::-webkit-scrollbar { width: 6px; }
+.floating-dock::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 10px; }
+
+.header h2 { margin: 0 0 8px 0; font-size: 1.4rem; font-weight: 700; background: linear-gradient(135deg, #818CF8, #C084FC); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
+.header p { margin: 0; font-size: 0.85rem; color: var(--text-muted); line-height: 1.5; }
+
+/* Buttons */
+.menu-group { display: flex; flex-direction: column; gap: 10px; }
+.menu-title { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1px; color: var(--text-muted); font-weight: 600; margin-bottom: 4px; }
+
+.btn-action {
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid var(--border-glass);
+ color: var(--text-light);
+ padding: 14px 16px;
+ border-radius: 12px;
+ cursor: pointer;
+ font-weight: 500;
+ font-family: var(--font);
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ font-size: 0.9rem;
+ text-align: left;
+}
+
+.btn-action svg { width: 20px; height: 20px; flex-shrink: 0; }
+
+.btn-action:hover {
+ background: rgba(255, 255, 255, 0.1);
+ transform: translateX(4px);
+}
+
+.btn-action.active {
+ background: var(--primary);
+ border-color: var(--primary);
+ box-shadow: 0 10px 15px -3px rgba(99, 102, 241, 0.4);
+}
+
+/* Button Variants */
+.btn-spbu svg { color: #10B981; }
+.btn-jalan svg { color: #F59E0B; }
+.btn-kavling svg { color: #3B82F6; }
+.btn-rumah svg { color: #8B5CF6; }
+.btn-warga svg { color: #EF4444; }
+
+/* Form Panel */
+.form-panel {
+ background: rgba(0, 0, 0, 0.4);
+ border-radius: 12px;
+ padding: 16px;
+ border: 1px solid var(--border-glass);
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.form-group { display: flex; flex-direction: column; gap: 6px; }
+.form-group label { font-size: 0.8rem; color: var(--text-muted); }
+.form-control {
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid var(--border-glass);
+ padding: 10px 12px;
+ border-radius: 8px;
+ color: white;
+ font-family: var(--font);
+ font-size: 0.9rem;
+ outline: none;
+ transition: border 0.2s;
+}
+.form-control:focus { border-color: var(--primary); }
+
+.checkbox-group {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ cursor: pointer;
+}
+.checkbox-group input { width: 16px; height: 16px; accent-color: var(--primary); cursor: pointer; }
+.checkbox-group span { font-size: 0.85rem; color: var(--text-light); }
+
+.btn-submit {
+ background: var(--primary);
+ color: white;
+ border: none;
+ padding: 12px;
+ border-radius: 8px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.2s;
+}
+.btn-submit:hover { background: var(--primary-hover); }
+
+/* Leaflet Customizations */
+.leaflet-draw-toolbar { display: none !important; }
+.leaflet-control-zoom { border: none !important; box-shadow: var(--shadow) !important; }
+.leaflet-control-zoom a {
+ background: var(--bg-glass) !important;
+ color: var(--text-light) !important;
+ border-color: var(--border-glass) !important;
+ backdrop-filter: blur(10px);
+}
+.leaflet-control-zoom a:hover { background: var(--primary) !important; }
+
+/* Leaflet Popup Premium */
+.leaflet-popup-content-wrapper {
+ background: var(--bg-glass) !important;
+ backdrop-filter: blur(16px) !important;
+ border: 1px solid var(--border-glass) !important;
+ color: var(--text-light) !important;
+ border-radius: 16px !important;
+ box-shadow: var(--shadow) !important;
+}
+.leaflet-popup-tip { background: var(--bg-glass) !important; border: 1px solid var(--border-glass) !important; }
+.leaflet-popup-content { margin: 16px !important; font-family: var(--font); }
+.leaflet-popup-content h3 { margin: 0 0 8px 0; font-size: 1.1rem; border-bottom: 1px solid var(--border-glass); padding-bottom: 8px; }
+.leaflet-popup-content p { margin: 4px 0; font-size: 0.9rem; color: var(--text-muted); }
+.leaflet-popup-content .btn-delete {
+ background: rgba(239, 68, 68, 0.1);
+ color: #EF4444;
+ border: 1px solid rgba(239, 68, 68, 0.3);
+ padding: 6px 12px;
+ border-radius: 6px;
+ cursor: pointer;
+ margin-top: 12px;
+ width: 100%;
+ transition: all 0.2s;
+}
+.leaflet-popup-content .btn-delete:hover { background: #EF4444; color: white; }
+
+/* Custom Map Marker Icon Animation */
+.custom-icon div {
+ transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+}
+.custom-icon:hover div {
+ transform: scale(1.1) translateY(-4px);
+}
+
+/* Toast Notification */
+.toast-container {
+ position: fixed; bottom: 24px; right: 24px; z-index: 9999;
+ display: flex; flex-direction: column; gap: 10px;
+}
+.toast {
+ background: var(--bg-glass); backdrop-filter: blur(16px);
+ border: 1px solid var(--border-glass); border-left: 4px solid var(--primary);
+ padding: 16px 20px; border-radius: 12px; color: white; font-size: 0.9rem;
+ box-shadow: var(--shadow); animation: slideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+}
+.toast.error { border-left-color: #EF4444; }
+.toast.success { border-left-color: #10B981; }
+
+@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
diff --git a/02/frontend/css/map.css b/02/frontend/css/map.css
new file mode 100644
index 0000000..0656663
--- /dev/null
+++ b/02/frontend/css/map.css
@@ -0,0 +1,68 @@
+/* map.css
+ * Tanggung Jawab: Styling container peta, kontrol Leaflet, dan pop-up modern.
+ */
+
+.map-container {
+ flex: 1;
+ position: relative;
+ background: #E2E8F0;
+}
+
+#map {
+ width: 100%;
+ height: 100%;
+ z-index: 1;
+}
+
+/* Customizing Leaflet Controls */
+.leaflet-control-zoom {
+ border: none !important;
+ box-shadow: var(--shadow-md) !important;
+ border-radius: var(--radius-md) !important;
+ overflow: hidden;
+}
+
+.leaflet-control-zoom a {
+ color: var(--text-main) !important;
+ background: var(--surface-glass) !important;
+ backdrop-filter: blur(8px);
+ transition: var(--transition);
+}
+
+.leaflet-control-zoom a:hover {
+ background: var(--surface) !important;
+ color: var(--primary) !important;
+}
+
+/* Custom Popup Modern */
+.leaflet-popup-content-wrapper {
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-lg);
+ padding: 4px;
+}
+
+.leaflet-popup-content {
+ margin: 14px;
+ font-family: 'Inter', sans-serif;
+}
+
+.popup-title {
+ font-size: 1.1rem;
+ font-weight: 600;
+ color: var(--text-main);
+ margin-bottom: 6px;
+ border-bottom: 2px solid var(--primary);
+ padding-bottom: 4px;
+ display: inline-block;
+}
+
+.popup-desc {
+ font-size: 0.9rem;
+ color: var(--text-muted);
+ margin-bottom: 12px;
+}
+
+.popup-action {
+ display: flex;
+ justify-content: flex-end;
+}
diff --git a/02/frontend/css/sidebar.css b/02/frontend/css/sidebar.css
new file mode 100644
index 0000000..5ce4b23
--- /dev/null
+++ b/02/frontend/css/sidebar.css
@@ -0,0 +1,78 @@
+/* sidebar.css
+ * Tanggung Jawab: Styling layout sidebar, navigasi, dan list item.
+ */
+
+.sidebar {
+ width: 350px;
+ background: var(--surface-glass);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ border-right: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ z-index: 1000;
+ box-shadow: var(--shadow-lg);
+ transition: var(--transition);
+}
+
+.sidebar-header {
+ padding: 24px;
+ border-bottom: 1px solid var(--border);
+}
+
+.sidebar-title {
+ font-size: 1.25rem;
+ font-weight: 700;
+ background: linear-gradient(135deg, var(--primary), var(--secondary));
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ letter-spacing: -0.5px;
+}
+
+.sidebar-subtitle {
+ font-size: 0.85rem;
+ color: var(--text-muted);
+ margin-top: 4px;
+}
+
+.sidebar-content {
+ flex: 1;
+ overflow-y: auto;
+ padding: 24px;
+}
+
+/* Toast Notification */
+.toast-container {
+ position: fixed;
+ bottom: 24px;
+ right: 24px;
+ z-index: 9999;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.toast {
+ background: var(--surface);
+ color: var(--text-main);
+ padding: 12px 20px;
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-md);
+ font-size: 0.9rem;
+ font-weight: 500;
+ transform: translateY(20px);
+ opacity: 0;
+ animation: slideIn 0.3s forwards ease-out;
+ border-left: 4px solid var(--primary);
+}
+
+.toast.error {
+ border-left-color: var(--danger);
+}
+
+@keyframes slideIn {
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
+}
diff --git a/02/frontend/index.html b/02/frontend/index.html
new file mode 100644
index 0000000..359e23e
--- /dev/null
+++ b/02/frontend/index.html
@@ -0,0 +1,50 @@
+
+
+
+
+
+ WebGIS - Pertemuan 02
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/02/frontend/js/app.js b/02/frontend/js/app.js
new file mode 100644
index 0000000..3485d03
--- /dev/null
+++ b/02/frontend/js/app.js
@@ -0,0 +1,124 @@
+import { initMap } from './modules/map.js';
+import { setupDrawControls } from './modules/draw.js';
+import { renderForm } from './modules/form.js';
+import { spbuService, jalanService, kavlingService, rumahIbadahService, wargaMiskinService, haversineService } from './services/api.service.js';
+
+let appMap;
+let drawControl;
+let currentRadiusCircle = null;
+let currentHighlightLayer = null;
+
+export const createIcon = (svgPath, color) => {
+ return L.divIcon({
+ className: 'custom-icon',
+ html: `
+ ${svgPath}
+
`,
+ iconSize: [34, 34], iconAnchor: [17, 34], popupAnchor: [0, -34]
+ });
+};
+
+const iconSPBU = (is24h) => createIcon(' ', is24h ? '#10B981' : '#EF4444');
+const iconRumah = createIcon(' ', '#8B5CF6');
+const iconWarga = createIcon(' ', '#EF4444');
+
+window.showToast = (msg, type='success') => {
+ const t = document.createElement('div');
+ t.className = `toast ${type}`; t.innerHTML = msg;
+ document.getElementById('toast-container').appendChild(t);
+ setTimeout(() => { t.style.transform='translateX(100%)'; t.style.opacity=0; setTimeout(()=>t.remove(),300); }, 3000);
+};
+
+document.addEventListener('DOMContentLoaded', async () => {
+ appMap = initMap('map');
+
+ // Bind extra draw events for 02
+ document.getElementById('btn-draw-rumah')?.addEventListener('click', () => { window.currentDrawType='rumah_ibadah'; new L.Draw.Marker(appMap).enable(); });
+ document.getElementById('btn-draw-warga')?.addEventListener('click', () => { window.currentDrawType='warga_miskin'; new L.Draw.Marker(appMap).enable(); });
+
+ drawControl = setupDrawControls(appMap, handleGeometryCreated);
+ await loadAllData();
+});
+
+const loadAllData = async () => {
+ try {
+ const [spbu, jalan, kavling, rumah, warga] = await Promise.all([
+ spbuService.getAll(), jalanService.getAll(), kavlingService.getAll(),
+ rumahIbadahService.getAll(), wargaMiskinService.getAll()
+ ]);
+
+ L.geoJSON(spbu, { pointToLayer: (f, ll) => L.marker(ll, { icon: iconSPBU(f.properties.buka_24_jam) }), onEachFeature: (f, l) => bindPopup(l, 'spbu', f.properties) }).addTo(appMap);
+ L.geoJSON(jalan, { style: { color: '#F59E0B', weight: 4 }, onEachFeature: (f, l) => bindPopup(l, 'jalan', f.properties) }).addTo(appMap);
+ L.geoJSON(kavling, { style: { color: '#3B82F6', weight: 2, fillColor: '#3B82F6', fillOpacity: 0.3 }, onEachFeature: (f, l) => bindPopup(l, 'kavling', f.properties) }).addTo(appMap);
+ L.geoJSON(rumah, { pointToLayer: (f, ll) => L.marker(ll, { icon: iconRumah }), onEachFeature: (f, l) => bindPopup(l, 'rumah_ibadah', f.properties, f) }).addTo(appMap);
+ L.geoJSON(warga, { pointToLayer: (f, ll) => L.marker(ll, { icon: iconWarga }), onEachFeature: (f, l) => bindPopup(l, 'warga_miskin', f.properties) }).addTo(appMap);
+ } catch (e) {
+ window.showToast("Gagal meload data: "+e.message, 'error');
+ }
+};
+
+const bindPopup = (layer, type, props, feature=null) => {
+ let ext = '';
+ if(type==='spbu') ext = `24 Jam: ${props.buka_24_jam?'Ya':'Tidak'}
`;
+ if(type==='rumah_ibadah' && feature) ext = `Cek Radius 1km `;
+
+ layer.bindPopup(`
+
+ `);
+};
+
+window.checkRadius = async (rumahId, lat, lng) => {
+ if (currentRadiusCircle) appMap.removeLayer(currentRadiusCircle);
+ if (currentHighlightLayer) appMap.removeLayer(currentHighlightLayer);
+
+ currentRadiusCircle = L.circle([lat, lng], { radius: 1000, color: '#8B5CF6', fillColor: '#8B5CF6', fillOpacity: 0.15, weight: 2 }).addTo(appMap);
+ appMap.flyTo([lat, lng], 15);
+ window.showToast('Menghitung jarak...');
+
+ try {
+ const res = await haversineService.getDalamRadius(rumahId, 1);
+ const container = document.getElementById('radius-results');
+ container.innerHTML = res.features.length ? '' : 'Tidak ada warga miskin.
';
+
+ res.features.forEach(f => {
+ const d = parseFloat(f.properties.jarak_km).toFixed(2);
+ container.innerHTML += `${f.properties.nama} ${d} km
`;
+ });
+
+ currentHighlightLayer = L.geoJSON(res, {
+ pointToLayer: (f, ll) => L.circleMarker(ll, { radius:8, fillColor:'#EF4444', color:'#fff', weight:2, fillOpacity:1 })
+ }).addTo(appMap);
+
+ } catch(e) { window.showToast('Gagal kalkulasi', 'error'); }
+};
+
+window.deleteData = async (type, id) => {
+ if(!confirm('Yakin menghapus?')) return;
+ try {
+ if(type==='spbu') await spbuService.delete(id);
+ if(type==='jalan') await jalanService.delete(id);
+ if(type==='kavling') await kavlingService.delete(id);
+ if(type==='rumah_ibadah') await rumahIbadahService.delete(id);
+ if(type==='warga_miskin') await wargaMiskinService.delete(id);
+ window.showToast('Data dihapus'); setTimeout(()=>location.reload(), 800);
+ } catch(e) { window.showToast('Gagal hapus', 'error'); }
+};
+
+const handleGeometryCreated = (type, geometry, layer) => {
+ const tempLayer = L.geoJSON(geometry).addTo(appMap);
+ renderForm(type, geometry, async (payload) => {
+ try {
+ if(type==='spbu') await spbuService.create(payload);
+ if(type==='jalan') await jalanService.create(payload);
+ if(type==='kavling') await kavlingService.create(payload);
+ if(type==='rumah_ibadah') await rumahIbadahService.create(payload);
+ if(type==='warga_miskin') await wargaMiskinService.create(payload);
+ window.showToast('Tersimpan'); setTimeout(()=>location.reload(), 800);
+ } catch(e) { window.showToast('Gagal simpan', 'error'); }
+ }, () => appMap.removeLayer(tempLayer));
+};
diff --git a/02/frontend/js/config.js b/02/frontend/js/config.js
new file mode 100644
index 0000000..8988fae
--- /dev/null
+++ b/02/frontend/js/config.js
@@ -0,0 +1,4 @@
+export const CONFIG = {
+ BASE_URL: '/02/backend/api',
+ RADIUS_KM: 1.0 // Radius default 1 km
+};
diff --git a/02/frontend/js/modules/draw.js b/02/frontend/js/modules/draw.js
new file mode 100644
index 0000000..40628f4
--- /dev/null
+++ b/02/frontend/js/modules/draw.js
@@ -0,0 +1,31 @@
+export const setupDrawControls = (map, onGeometryCreated) => {
+ const drawControl = new L.Control.Draw({
+ draw: { marker: true, polyline: true, polygon: true, circle: false, circlemarker: false, rectangle: false }
+ });
+ map.addControl(drawControl);
+
+ map.on(L.Draw.Event.CREATED, function (event) {
+ const layer = event.layer;
+ const type = event.layerType;
+
+ // Peta draw default tipe: marker -> spbu, polyline -> jalan, polygon -> kavling
+ let customType = type;
+ if(type === 'marker') customType = 'spbu';
+ if(type === 'polyline') customType = 'jalan';
+ if(type === 'polygon') customType = 'kavling';
+ if(window.currentDrawType) customType = window.currentDrawType; // Untuk P2/P3
+
+ onGeometryCreated(customType, layer.toGeoJSON().geometry, layer);
+ });
+
+ // Custom Menu Handlers
+ const markerDrawer = new L.Draw.Marker(map, drawControl.options.draw.marker);
+ const polylineDrawer = new L.Draw.Polyline(map, drawControl.options.draw.polyline);
+ const polygonDrawer = new L.Draw.Polygon(map, drawControl.options.draw.polygon);
+
+ document.getElementById('btn-draw-spbu')?.addEventListener('click', () => { window.currentDrawType='spbu'; markerDrawer.enable(); });
+ document.getElementById('btn-draw-jalan')?.addEventListener('click', () => { window.currentDrawType='jalan'; polylineDrawer.enable(); });
+ document.getElementById('btn-draw-kavling')?.addEventListener('click', () => { window.currentDrawType='kavling'; polygonDrawer.enable(); });
+
+ return drawControl;
+};
diff --git a/02/frontend/js/modules/form.js b/02/frontend/js/modules/form.js
new file mode 100644
index 0000000..3959e52
--- /dev/null
+++ b/02/frontend/js/modules/form.js
@@ -0,0 +1,50 @@
+export const renderForm = (type, geometry, onSubmit, onCancel) => {
+ const container = document.getElementById('form-container');
+ container.style.display = 'flex';
+
+ const extraFields = type === 'spbu' ? `
+
+
+
+ Buka 24 Jam
+
+
+ ` : '';
+
+ container.innerHTML = `
+
+ `;
+
+ document.getElementById('btn-save').addEventListener('click', () => {
+ const payload = {
+ type,
+ geometry,
+ nama: document.getElementById('input-nama').value,
+ deskripsi: document.getElementById('input-deskripsi').value,
+ };
+ if (type === 'spbu') payload.buka_24_jam = document.getElementById('input-24jam').checked;
+
+ onSubmit(payload);
+ container.style.display = 'none';
+ container.innerHTML = '';
+ });
+
+ document.getElementById('btn-cancel').addEventListener('click', () => {
+ if(onCancel) onCancel();
+ container.style.display = 'none';
+ container.innerHTML = '';
+ });
+};
diff --git a/02/frontend/js/modules/map.js b/02/frontend/js/modules/map.js
new file mode 100644
index 0000000..fdecef9
--- /dev/null
+++ b/02/frontend/js/modules/map.js
@@ -0,0 +1,25 @@
+/**
+ * map.js
+ * Tanggung Jawab: Inisialisasi peta Leaflet dasar dan base layer.
+ */
+
+export const initMap = (containerId) => {
+ // Pusat peta default (Universitas Tanjungpura, Pontianak)
+ const map = L.map(containerId, {
+ zoomControl: false
+ }).setView([-0.0583, 109.3448], 15);
+
+ // Pindahkan zoom control ke kanan bawah
+ L.control.zoom({
+ position: 'bottomright'
+ }).addTo(map);
+
+ // Tile Layer Premium (CartoDB Dark Matter untuk Glass UI)
+ L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
+ attribution: '© OpenStreetMap contributors © CARTO ',
+ subdomains: 'abcd',
+ maxZoom: 20
+ }).addTo(map);
+
+ return map;
+};
diff --git a/02/frontend/js/services/api.service.js b/02/frontend/js/services/api.service.js
new file mode 100644
index 0000000..36e7e5f
--- /dev/null
+++ b/02/frontend/js/services/api.service.js
@@ -0,0 +1,41 @@
+export const BASE_URL = '';
+
+const createService = (endpoint) => ({
+ getAll: async () => {
+ const res = await fetch(`${BASE_URL}/${endpoint}`);
+ const json = await res.json();
+ return json.data;
+ },
+ create: async (data) => {
+ const res = await fetch(`${BASE_URL}/${endpoint}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data)
+ });
+ const json = await res.json();
+ return json.data;
+ },
+ delete: async (id) => {
+ const res = await fetch(`${BASE_URL}/${endpoint}?id=${id}`, {
+ method: 'DELETE'
+ });
+ const json = await res.json();
+ return json.data;
+ }
+});
+
+// P01
+export const spbuService = createService('01/backend/api/spbu.php');
+export const jalanService = createService('01/backend/api/jalan.php');
+export const kavlingService = createService('01/backend/api/kavling.php');
+
+// P02
+export const rumahIbadahService = createService('02/backend/api/rumah_ibadah.php');
+export const wargaMiskinService = createService('02/backend/api/warga_miskin.php');
+export const haversineService = {
+ getDalamRadius: async (id, radius) => {
+ const res = await fetch(`${BASE_URL}/02/backend/api/haversine.php?rumah_ibadah_id=${id}&radius_km=${radius}`);
+ const json = await res.json();
+ return json.data;
+ }
+};
diff --git a/02/frontend/js/services/rumah_ibadah.service.js b/02/frontend/js/services/rumah_ibadah.service.js
new file mode 100644
index 0000000..dc20231
--- /dev/null
+++ b/02/frontend/js/services/rumah_ibadah.service.js
@@ -0,0 +1,22 @@
+import { CONFIG } from '../config.js';
+
+export const rumahIbadahService = {
+ getAll: async () => {
+ const res = await fetch(`${CONFIG.BASE_URL}/rumah_ibadah.php`);
+ return await res.json();
+ },
+ save: async (data) => {
+ const res = await fetch(`${CONFIG.BASE_URL}/rumah_ibadah.php`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data)
+ });
+ return await res.json();
+ },
+ delete: async (id) => {
+ const res = await fetch(`${CONFIG.BASE_URL}/rumah_ibadah.php?id=${id}`, { method: 'DELETE' });
+ return await res.json();
+ },
+ getJangkauan: async (id, radius = 1) => {
+ const res = await fetch(`${CONFIG.BASE_URL}/rumah_ibadah.php?action=jangkauan&id=${id}&radius=${radius}`);
+ return await res.json();
+ }
+};
diff --git a/02/frontend/js/services/warga_miskin.service.js b/02/frontend/js/services/warga_miskin.service.js
new file mode 100644
index 0000000..d9cee43
--- /dev/null
+++ b/02/frontend/js/services/warga_miskin.service.js
@@ -0,0 +1,18 @@
+import { CONFIG } from '../config.js';
+
+export const wargaMiskinService = {
+ getAll: async () => {
+ const res = await fetch(`${CONFIG.BASE_URL}/warga_miskin.php`);
+ return await res.json();
+ },
+ save: async (data) => {
+ const res = await fetch(`${CONFIG.BASE_URL}/warga_miskin.php`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data)
+ });
+ return await res.json();
+ },
+ delete: async (id) => {
+ const res = await fetch(`${CONFIG.BASE_URL}/warga_miskin.php?id=${id}`, { method: 'DELETE' });
+ return await res.json();
+ }
+};
diff --git a/02/frontend/js/utils/ui.util.js b/02/frontend/js/utils/ui.util.js
new file mode 100644
index 0000000..6ed9116
--- /dev/null
+++ b/02/frontend/js/utils/ui.util.js
@@ -0,0 +1,50 @@
+export const showToast = (message, type = 'success') => {
+ const container = document.getElementById('toast-container');
+ if (!container) return;
+ const toast = document.createElement('div');
+ toast.className = `toast ${type}`;
+ toast.textContent = message;
+ container.appendChild(toast);
+ setTimeout(() => {
+ toast.style.opacity = '0';
+ toast.style.transform = 'translateY(20px)';
+ toast.style.transition = 'all 0.3s ease';
+ setTimeout(() => toast.remove(), 300);
+ }, 3000);
+};
+
+export const clearFormContainer = () => {
+ const container = document.getElementById('form-container');
+ if (container) {
+ container.innerHTML = '';
+ container.style.display = 'none';
+ }
+};
+
+export const renderResultPanel = (wargaList, radiusKm) => {
+ const panel = document.getElementById('result-panel');
+ const list = document.getElementById('result-list');
+ const title = document.getElementById('result-title');
+
+ panel.style.display = 'block';
+ title.innerHTML = `Warga Miskin dalam radius ${radiusKm} km `;
+
+ if (wargaList.length === 0) {
+ list.innerHTML = `Tidak ada data dalam radius ini.
`;
+ return;
+ }
+
+ list.innerHTML = wargaList.map(w => `
+
+
+ ${w.nama_kk}
+ ${w.jarak_km} km
+
+
Rp ${w.penghasilan.toLocaleString('id-ID')} | ${w.jumlah_tanggungan} orang
+
+ `).join('');
+};
+
+export const hideResultPanel = () => {
+ document.getElementById('result-panel').style.display = 'none';
+};
diff --git a/03/backend/api/jalan.php b/03/backend/api/jalan.php
new file mode 100644
index 0000000..7264a11
--- /dev/null
+++ b/03/backend/api/jalan.php
@@ -0,0 +1,75 @@
+query("SELECT id, nama, jenis_jalan, created_at, ST_AsGeoJSON(geom) as geojson FROM jalan");
+ $features = [];
+ while ($row = $stmt->fetch()) {
+ $features[] = [
+ 'type' => 'Feature',
+ 'geometry' => json_decode($row['geojson']),
+ 'properties' => [
+ 'id' => $row['id'],
+ 'nama' => $row['nama'],
+ 'jenis_jalan' => $row['jenis_jalan'],
+ 'created_at' => $row['created_at']
+ ]
+ ];
+ }
+ sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Jalan berhasil diambil');
+ } catch (PDOException $e) {
+ sendError('Gagal mengambil data Jalan: ' . $e->getMessage(), 500);
+ }
+ break;
+
+ case 'POST':
+ $input = json_decode(file_get_contents('php://input'), true);
+ if (!isset($input['nama']) || !isset($input['geometry'])) {
+ sendError('Nama dan geometri wajib diisi');
+ }
+
+ try {
+ $sql = "INSERT INTO jalan (nama, jenis_jalan, geom) VALUES (:nama, :jenis_jalan, ST_GeomFromGeoJSON(:geometry))";
+ $stmt = $pdo->prepare($sql);
+ $stmt->execute([
+ ':nama' => $input['nama'],
+ ':jenis_jalan' => $input['jenis_jalan'] ?? 'Lokal',
+ ':geometry' => json_encode($input['geometry'])
+ ]);
+ sendSuccess(['id' => $pdo->lastInsertId()], 'Data Jalan berhasil disimpan', 201);
+ } catch (PDOException $e) {
+ sendError('Gagal menyimpan Jalan: ' . $e->getMessage(), 500);
+ }
+ break;
+
+ case 'DELETE':
+ $id = $_GET['id'] ?? null;
+ if (!$id) {
+ sendError('ID Jalan wajib disertakan');
+ }
+
+ try {
+ $stmt = $pdo->prepare("DELETE FROM jalan WHERE id = :id");
+ $stmt->execute([':id' => $id]);
+ sendSuccess(null, 'Data Jalan berhasil dihapus');
+ } catch (PDOException $e) {
+ sendError('Gagal menghapus Jalan: ' . $e->getMessage(), 500);
+ }
+ break;
+
+ default:
+ sendError('Method not allowed', 405);
+ break;
+}
+?>
diff --git a/03/backend/api/kavling.php b/03/backend/api/kavling.php
new file mode 100644
index 0000000..ed862a5
--- /dev/null
+++ b/03/backend/api/kavling.php
@@ -0,0 +1,75 @@
+query("SELECT id, nama_pemilik, luas, created_at, ST_AsGeoJSON(geom) as geojson FROM kavling");
+ $features = [];
+ while ($row = $stmt->fetch()) {
+ $features[] = [
+ 'type' => 'Feature',
+ 'geometry' => json_decode($row['geojson']),
+ 'properties' => [
+ 'id' => $row['id'],
+ 'nama_pemilik' => $row['nama_pemilik'],
+ 'luas' => $row['luas'],
+ 'created_at' => $row['created_at']
+ ]
+ ];
+ }
+ sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Kavling berhasil diambil');
+ } catch (PDOException $e) {
+ sendError('Gagal mengambil data Kavling: ' . $e->getMessage(), 500);
+ }
+ break;
+
+ case 'POST':
+ $input = json_decode(file_get_contents('php://input'), true);
+ if (!isset($input['nama_pemilik']) || !isset($input['geometry'])) {
+ sendError('Nama pemilik dan geometri wajib diisi');
+ }
+
+ try {
+ $sql = "INSERT INTO kavling (nama_pemilik, luas, geom) VALUES (:nama_pemilik, :luas, ST_GeomFromGeoJSON(:geometry))";
+ $stmt = $pdo->prepare($sql);
+ $stmt->execute([
+ ':nama_pemilik' => $input['nama_pemilik'],
+ ':luas' => $input['luas'] ?? 0,
+ ':geometry' => json_encode($input['geometry'])
+ ]);
+ sendSuccess(['id' => $pdo->lastInsertId()], 'Data Kavling berhasil disimpan', 201);
+ } catch (PDOException $e) {
+ sendError('Gagal menyimpan Kavling: ' . $e->getMessage(), 500);
+ }
+ break;
+
+ case 'DELETE':
+ $id = $_GET['id'] ?? null;
+ if (!$id) {
+ sendError('ID Kavling wajib disertakan');
+ }
+
+ try {
+ $stmt = $pdo->prepare("DELETE FROM kavling WHERE id = :id");
+ $stmt->execute([':id' => $id]);
+ sendSuccess(null, 'Data Kavling berhasil dihapus');
+ } catch (PDOException $e) {
+ sendError('Gagal menghapus Kavling: ' . $e->getMessage(), 500);
+ }
+ break;
+
+ default:
+ sendError('Method not allowed', 405);
+ break;
+}
+?>
diff --git a/03/backend/api/rumah_ibadah.php b/03/backend/api/rumah_ibadah.php
new file mode 100644
index 0000000..1de6d5d
--- /dev/null
+++ b/03/backend/api/rumah_ibadah.php
@@ -0,0 +1,89 @@
+prepare("SELECT ST_AsGeoJSON(geom) as geojson FROM rumah_ibadah WHERE id = ?");
+ $stmt->execute([$id]);
+ $rumah_ibadah = $stmt->fetch();
+
+ if (!$rumah_ibadah) sendError('Rumah ibadah tidak ditemukan', 404);
+
+ $geom = json_decode($rumah_ibadah['geojson'], true);
+ $pusatLon = $geom['coordinates'][0];
+ $pusatLat = $geom['coordinates'][1];
+
+ // Panggil geo_helper untuk mendapatkan warga miskin
+ $warga = GeoHelper::getWargaDalamRadius($pdo, $pusatLat, $pusatLon, $radius);
+ sendSuccess($warga, 'Data jangkauan berhasil dihitung');
+
+ } else {
+ // GET Semua Rumah Ibadah (GeoJSON)
+ try {
+ $stmt = $pdo->query("SELECT id, nama, agama, ST_AsGeoJSON(geom) as geojson FROM rumah_ibadah");
+ $features = [];
+ while ($row = $stmt->fetch()) {
+ $features[] = [
+ 'type' => 'Feature',
+ 'geometry' => json_decode($row['geojson']),
+ 'properties' => [
+ 'id' => $row['id'],
+ 'nama' => $row['nama'],
+ 'agama' => $row['agama']
+ ]
+ ];
+ }
+ sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Rumah Ibadah');
+ } catch (PDOException $e) {
+ sendError('Gagal: ' . $e->getMessage(), 500);
+ }
+ }
+ break;
+
+ case 'POST':
+ $input = json_decode(file_get_contents('php://input'), true);
+ if (!isset($input['nama']) || !isset($input['geometry'])) sendError('Validasi gagal');
+
+ try {
+ $sql = "INSERT INTO rumah_ibadah (nama, agama, geom) VALUES (:nama, :agama, ST_GeomFromGeoJSON(:geometry))";
+ $stmt = $pdo->prepare($sql);
+ $stmt->execute([
+ ':nama' => $input['nama'],
+ ':agama' => $input['agama'] ?? 'Islam',
+ ':geometry' => json_encode($input['geometry'])
+ ]);
+ sendSuccess(['id' => $pdo->lastInsertId()], 'Disimpan', 201);
+ } catch (PDOException $e) {
+ sendError('Gagal: ' . $e->getMessage(), 500);
+ }
+ break;
+
+ case 'DELETE':
+ $id = $_GET['id'] ?? null;
+ if (!$id) sendError('ID dibutuhkan');
+ $stmt = $pdo->prepare("DELETE FROM rumah_ibadah WHERE id = ?");
+ $stmt->execute([$id]);
+ sendSuccess(null, 'Dihapus');
+ break;
+
+ default:
+ sendError('Method not allowed', 405);
+ break;
+}
+?>
diff --git a/03/backend/api/spbu.php b/03/backend/api/spbu.php
new file mode 100644
index 0000000..7529ba1
--- /dev/null
+++ b/03/backend/api/spbu.php
@@ -0,0 +1,59 @@
+query("SELECT id, nama, deskripsi, buka_24_jam, created_at, ST_AsGeoJSON(geom) as geojson FROM spbu");
+ $features = [];
+ while ($row = $stmt->fetch()) {
+ $features[] = [
+ 'type' => 'Feature',
+ 'geometry' => json_decode($row['geojson']),
+ 'properties' => [
+ 'id' => $row['id'],
+ 'nama' => $row['nama'],
+ 'deskripsi' => $row['deskripsi'],
+ 'buka_24_jam' => (bool)$row['buka_24_jam'],
+ 'created_at' => $row['created_at']
+ ]
+ ];
+ }
+ sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data SPBU berhasil diambil');
+ } catch (PDOException $e) { sendError('Gagal: ' . $e->getMessage(), 500); }
+ break;
+
+ case 'POST':
+ $input = json_decode(file_get_contents('php://input'), true);
+ if (!isset($input['nama']) || !isset($input['geometry'])) sendError('Validasi gagal');
+
+ try {
+ $sql = "INSERT INTO spbu (nama, deskripsi, buka_24_jam, geom) VALUES (:nama, :deskripsi, :buka_24_jam, ST_GeomFromGeoJSON(:geometry))";
+ $stmt = $pdo->prepare($sql);
+ $stmt->execute([
+ ':nama' => $input['nama'],
+ ':deskripsi' => $input['deskripsi'] ?? '',
+ ':buka_24_jam' => isset($input['buka_24_jam']) && $input['buka_24_jam'] ? 1 : 0,
+ ':geometry' => json_encode($input['geometry'])
+ ]);
+ sendSuccess(['id' => $pdo->lastInsertId()], 'Disimpan', 201);
+ } catch (PDOException $e) { sendError('Gagal: ' . $e->getMessage(), 500); }
+ break;
+
+ case 'DELETE':
+ $id = $_GET['id'] ?? null;
+ if (!$id) sendError('ID dibutuhkan');
+ $stmt = $pdo->prepare("DELETE FROM spbu WHERE id = ?");
+ $stmt->execute([$id]);
+ sendSuccess(null, 'Dihapus');
+ break;
+
+ default:
+ sendError('Method not allowed', 405);
+ break;
+}
+?>
diff --git a/03/backend/api/statistik.php b/03/backend/api/statistik.php
new file mode 100644
index 0000000..b5712c6
--- /dev/null
+++ b/03/backend/api/statistik.php
@@ -0,0 +1,71 @@
+query($sql);
+
+ $features = [];
+ $total_warga = 0;
+
+ while ($row = $stmt->fetch()) {
+ $jumlah = (int) $row['jumlah_warga'];
+ $total_warga += $jumlah;
+
+ $features[] = [
+ 'type' => 'Feature',
+ 'geometry' => json_decode($row['geojson']),
+ 'properties' => [
+ 'id' => $row['id'],
+ 'nama_pemilik' => $row['nama_pemilik'],
+ 'luas' => $row['luas'],
+ 'jumlah_warga' => $jumlah,
+ 'total_tanggungan' => (int) $row['total_tanggungan']
+ ]
+ ];
+ }
+
+ $response = [
+ 'statistik' => [
+ 'total_kavling' => count($features),
+ 'total_warga_terpetakan' => $total_warga
+ ],
+ 'geojson' => [
+ 'type' => 'FeatureCollection',
+ 'features' => $features
+ ]
+ ];
+
+ sendSuccess($response, 'Data statistik spasial berhasil dihitung');
+ } catch (PDOException $e) {
+ sendError('Gagal menghitung statistik spasial: ' . $e->getMessage(), 500);
+ }
+} else {
+ sendError('Method not allowed', 405);
+}
+?>
diff --git a/03/backend/api/warga_miskin.php b/03/backend/api/warga_miskin.php
new file mode 100644
index 0000000..dca0aff
--- /dev/null
+++ b/03/backend/api/warga_miskin.php
@@ -0,0 +1,67 @@
+query("SELECT id, nama_kk, penghasilan, jumlah_tanggungan, ST_AsGeoJSON(geom) as geojson FROM warga_miskin");
+ $features = [];
+ while ($row = $stmt->fetch()) {
+ $features[] = [
+ 'type' => 'Feature',
+ 'geometry' => json_decode($row['geojson']),
+ 'properties' => [
+ 'id' => $row['id'],
+ 'nama_kk' => $row['nama_kk'],
+ 'penghasilan' => $row['penghasilan'],
+ 'jumlah_tanggungan' => $row['jumlah_tanggungan']
+ ]
+ ];
+ }
+ sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Warga Miskin');
+ } catch (PDOException $e) {
+ sendError('Gagal: ' . $e->getMessage(), 500);
+ }
+ break;
+
+ case 'POST':
+ $input = json_decode(file_get_contents('php://input'), true);
+ if (!isset($input['nama_kk']) || !isset($input['geometry'])) sendError('Validasi gagal');
+
+ try {
+ $sql = "INSERT INTO warga_miskin (nama_kk, penghasilan, jumlah_tanggungan, geom) VALUES (:nama_kk, :penghasilan, :jumlah_tanggungan, ST_GeomFromGeoJSON(:geometry))";
+ $stmt = $pdo->prepare($sql);
+ $stmt->execute([
+ ':nama_kk' => $input['nama_kk'],
+ ':penghasilan' => $input['penghasilan'] ?? 0,
+ ':jumlah_tanggungan' => $input['jumlah_tanggungan'] ?? 0,
+ ':geometry' => json_encode($input['geometry'])
+ ]);
+ sendSuccess(['id' => $pdo->lastInsertId()], 'Disimpan', 201);
+ } catch (PDOException $e) {
+ sendError('Gagal: ' . $e->getMessage(), 500);
+ }
+ break;
+
+ case 'DELETE':
+ $id = $_GET['id'] ?? null;
+ if (!$id) sendError('ID dibutuhkan');
+ $stmt = $pdo->prepare("DELETE FROM warga_miskin WHERE id = ?");
+ $stmt->execute([$id]);
+ sendSuccess(null, 'Dihapus');
+ break;
+
+ default:
+ sendError('Method not allowed', 405);
+ break;
+}
+?>
diff --git a/03/backend/config/db.php b/03/backend/config/db.php
new file mode 100644
index 0000000..287e5da
--- /dev/null
+++ b/03/backend/config/db.php
@@ -0,0 +1,32 @@
+setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+ self::$conn->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
+ } catch (PDOException $exception) {
+ echo "Connection error: " . $exception->getMessage();
+ }
+ }
+ return self::$conn;
+ }
+}
diff --git a/03/backend/utils/response_helper.php b/03/backend/utils/response_helper.php
new file mode 100644
index 0000000..cd7968c
--- /dev/null
+++ b/03/backend/utils/response_helper.php
@@ -0,0 +1,29 @@
+ 'success', 'message' => $message, 'data' => $data]);
+ exit();
+}
+
+function sendError($message = 'Error', $statusCode = 400) {
+ http_response_code($statusCode);
+ echo json_encode(['status' => 'error', 'message' => $message]);
+ exit();
+}
+?>
diff --git a/03/frontend/base.css b/03/frontend/base.css
new file mode 100644
index 0000000..7e7c44c
--- /dev/null
+++ b/03/frontend/base.css
@@ -0,0 +1,61 @@
+/* base.css
+ * Tanggung Jawab: Reset CSS, definisi variabel warna premium, dan tipografi dasar.
+ */
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
+
+:root {
+ /* Color Palette - Minimalist & Elegant */
+ --primary: #4F46E5;
+ --primary-hover: #4338CA;
+ --secondary: #10B981;
+ --background: #F3F4F6;
+ --surface: #FFFFFF;
+ --surface-glass: rgba(255, 255, 255, 0.85);
+ --text-main: #111827;
+ --text-muted: #6B7280;
+ --border: #E5E7EB;
+ --danger: #EF4444;
+
+ /* Shadows & Effects */
+ --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
+ --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
+ --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
+ --radius-md: 12px;
+ --radius-lg: 16px;
+ --transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: 'Inter', sans-serif;
+ background-color: var(--background);
+ color: var(--text-main);
+ display: flex;
+ height: 100vh;
+ overflow: hidden;
+}
+
+h1, h2, h3, h4 {
+ font-weight: 600;
+ color: var(--text-main);
+}
+
+/* Scrollbar minimalis */
+::-webkit-scrollbar {
+ width: 6px;
+}
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+::-webkit-scrollbar-thumb {
+ background: #CBD5E1;
+ border-radius: 10px;
+}
+::-webkit-scrollbar-thumb:hover {
+ background: #94A3B8;
+}
diff --git a/03/frontend/css/main.css b/03/frontend/css/main.css
new file mode 100644
index 0000000..14cadd4
--- /dev/null
+++ b/03/frontend/css/main.css
@@ -0,0 +1,205 @@
+:root {
+ --primary: #6366F1;
+ --primary-hover: #4F46E5;
+ --bg-dark: #0F172A;
+ --bg-glass: rgba(15, 23, 42, 0.75);
+ --border-glass: rgba(255, 255, 255, 0.1);
+ --text-light: #F8FAFC;
+ --text-muted: #94A3B8;
+ --radius: 20px;
+ --shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
+ --font: 'Inter', sans-serif;
+}
+
+* { box-sizing: border-box; }
+
+body {
+ margin: 0;
+ font-family: var(--font);
+ background: var(--bg-dark);
+ color: var(--text-light);
+ overflow: hidden;
+}
+
+#map {
+ width: 100vw;
+ height: 100vh;
+ z-index: 1;
+}
+
+/* Floating Dock */
+.floating-dock {
+ position: absolute;
+ top: 24px;
+ left: 24px;
+ z-index: 1000;
+ background: var(--bg-glass);
+ backdrop-filter: blur(24px);
+ -webkit-backdrop-filter: blur(24px);
+ border: 1px solid var(--border-glass);
+ border-radius: var(--radius);
+ padding: 24px;
+ width: 340px;
+ box-shadow: var(--shadow);
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+ max-height: calc(100vh - 48px);
+ overflow-y: auto;
+}
+
+/* Custom Scrollbar for Dock */
+.floating-dock::-webkit-scrollbar { width: 6px; }
+.floating-dock::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 10px; }
+
+.header h2 { margin: 0 0 8px 0; font-size: 1.4rem; font-weight: 700; background: linear-gradient(135deg, #818CF8, #C084FC); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
+.header p { margin: 0; font-size: 0.85rem; color: var(--text-muted); line-height: 1.5; }
+
+/* Buttons */
+.menu-group { display: flex; flex-direction: column; gap: 10px; }
+.menu-title { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1px; color: var(--text-muted); font-weight: 600; margin-bottom: 4px; }
+
+.btn-action {
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid var(--border-glass);
+ color: var(--text-light);
+ padding: 14px 16px;
+ border-radius: 12px;
+ cursor: pointer;
+ font-weight: 500;
+ font-family: var(--font);
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ font-size: 0.9rem;
+ text-align: left;
+}
+
+.btn-action svg { width: 20px; height: 20px; flex-shrink: 0; }
+
+.btn-action:hover {
+ background: rgba(255, 255, 255, 0.1);
+ transform: translateX(4px);
+}
+
+.btn-action.active {
+ background: var(--primary);
+ border-color: var(--primary);
+ box-shadow: 0 10px 15px -3px rgba(99, 102, 241, 0.4);
+}
+
+/* Button Variants */
+.btn-spbu svg { color: #10B981; }
+.btn-jalan svg { color: #F59E0B; }
+.btn-kavling svg { color: #3B82F6; }
+.btn-rumah svg { color: #8B5CF6; }
+.btn-warga svg { color: #EF4444; }
+
+/* Form Panel */
+.form-panel {
+ background: rgba(0, 0, 0, 0.4);
+ border-radius: 12px;
+ padding: 16px;
+ border: 1px solid var(--border-glass);
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.form-group { display: flex; flex-direction: column; gap: 6px; }
+.form-group label { font-size: 0.8rem; color: var(--text-muted); }
+.form-control {
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid var(--border-glass);
+ padding: 10px 12px;
+ border-radius: 8px;
+ color: white;
+ font-family: var(--font);
+ font-size: 0.9rem;
+ outline: none;
+ transition: border 0.2s;
+}
+.form-control:focus { border-color: var(--primary); }
+
+.checkbox-group {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ cursor: pointer;
+}
+.checkbox-group input { width: 16px; height: 16px; accent-color: var(--primary); cursor: pointer; }
+.checkbox-group span { font-size: 0.85rem; color: var(--text-light); }
+
+.btn-submit {
+ background: var(--primary);
+ color: white;
+ border: none;
+ padding: 12px;
+ border-radius: 8px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.2s;
+}
+.btn-submit:hover { background: var(--primary-hover); }
+
+/* Leaflet Customizations */
+.leaflet-draw-toolbar { display: none !important; }
+.leaflet-control-zoom { border: none !important; box-shadow: var(--shadow) !important; }
+.leaflet-control-zoom a {
+ background: var(--bg-glass) !important;
+ color: var(--text-light) !important;
+ border-color: var(--border-glass) !important;
+ backdrop-filter: blur(10px);
+}
+.leaflet-control-zoom a:hover { background: var(--primary) !important; }
+
+/* Leaflet Popup Premium */
+.leaflet-popup-content-wrapper {
+ background: var(--bg-glass) !important;
+ backdrop-filter: blur(16px) !important;
+ border: 1px solid var(--border-glass) !important;
+ color: var(--text-light) !important;
+ border-radius: 16px !important;
+ box-shadow: var(--shadow) !important;
+}
+.leaflet-popup-tip { background: var(--bg-glass) !important; border: 1px solid var(--border-glass) !important; }
+.leaflet-popup-content { margin: 16px !important; font-family: var(--font); }
+.leaflet-popup-content h3 { margin: 0 0 8px 0; font-size: 1.1rem; border-bottom: 1px solid var(--border-glass); padding-bottom: 8px; }
+.leaflet-popup-content p { margin: 4px 0; font-size: 0.9rem; color: var(--text-muted); }
+.leaflet-popup-content .btn-delete {
+ background: rgba(239, 68, 68, 0.1);
+ color: #EF4444;
+ border: 1px solid rgba(239, 68, 68, 0.3);
+ padding: 6px 12px;
+ border-radius: 6px;
+ cursor: pointer;
+ margin-top: 12px;
+ width: 100%;
+ transition: all 0.2s;
+}
+.leaflet-popup-content .btn-delete:hover { background: #EF4444; color: white; }
+
+/* Custom Map Marker Icon Animation */
+.custom-icon div {
+ transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+}
+.custom-icon:hover div {
+ transform: scale(1.1) translateY(-4px);
+}
+
+/* Toast Notification */
+.toast-container {
+ position: fixed; bottom: 24px; right: 24px; z-index: 9999;
+ display: flex; flex-direction: column; gap: 10px;
+}
+.toast {
+ background: var(--bg-glass); backdrop-filter: blur(16px);
+ border: 1px solid var(--border-glass); border-left: 4px solid var(--primary);
+ padding: 16px 20px; border-radius: 12px; color: white; font-size: 0.9rem;
+ box-shadow: var(--shadow); animation: slideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+}
+.toast.error { border-left-color: #EF4444; }
+.toast.success { border-left-color: #10B981; }
+
+@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
diff --git a/03/frontend/form.css b/03/frontend/form.css
new file mode 100644
index 0000000..be53320
--- /dev/null
+++ b/03/frontend/form.css
@@ -0,0 +1,92 @@
+/* form.css
+ * Tanggung Jawab: Styling komponen form (input, button, select).
+ */
+
+.form-group {
+ margin-bottom: 16px;
+}
+
+.form-label {
+ display: block;
+ font-size: 0.85rem;
+ font-weight: 500;
+ margin-bottom: 6px;
+ color: var(--text-main);
+}
+
+.form-control {
+ width: 100%;
+ padding: 10px 14px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ font-family: inherit;
+ font-size: 0.95rem;
+ transition: var(--transition);
+ background: var(--surface);
+}
+
+.form-control:focus {
+ outline: none;
+ border-color: var(--primary);
+ box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
+}
+
+/* Buttons */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 10px 20px;
+ font-family: inherit;
+ font-weight: 600;
+ font-size: 0.9rem;
+ border-radius: var(--radius-md);
+ border: none;
+ cursor: pointer;
+ transition: var(--transition);
+ gap: 8px;
+ width: 100%;
+}
+
+.btn-primary {
+ background: var(--primary);
+ color: white;
+}
+
+.btn-primary:hover {
+ background: var(--primary-hover);
+ transform: translateY(-1px);
+ box-shadow: var(--shadow-md);
+}
+
+.btn-danger {
+ background: var(--danger);
+ color: white;
+ width: auto;
+ padding: 6px 12px;
+ font-size: 0.8rem;
+}
+
+.btn-danger:hover {
+ background: #DC2626;
+}
+
+/* Micro-animation for active state */
+.btn:active {
+ transform: translateY(1px);
+}
+
+/* Form Container Animation */
+#form-container {
+ background: var(--surface);
+ padding: 20px;
+ border-radius: var(--radius-lg);
+ border: 1px solid var(--border);
+ margin-bottom: 20px;
+ animation: fadeIn 0.4s ease-out;
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; transform: scale(0.98); }
+ to { opacity: 1; transform: scale(1); }
+}
diff --git a/03/frontend/index.html b/03/frontend/index.html
new file mode 100644
index 0000000..968c82c
--- /dev/null
+++ b/03/frontend/index.html
@@ -0,0 +1,53 @@
+
+
+
+
+
+ WebGIS - Pertemuan 03
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/03/frontend/js/app.js b/03/frontend/js/app.js
new file mode 100644
index 0000000..3941765
--- /dev/null
+++ b/03/frontend/js/app.js
@@ -0,0 +1,110 @@
+import { initMap } from './modules/map.js';
+import { setupDrawControls } from './modules/draw.js';
+import { renderForm } from './modules/form.js';
+import { spbuService, jalanService, kavlingService, rumahIbadahService, wargaMiskinService, BASE_URL } from './services/api.service.js';
+
+let appMap;
+let drawControl;
+
+const statistikService = {
+ getChoropleth: async () => {
+ const res = await fetch(`${BASE_URL}/03/backend/api/statistik.php`);
+ const json = await res.json();
+ return json.data;
+ }
+};
+
+export const createIcon = (svgPath, color) => L.divIcon({
+ className: 'custom-icon',
+ html: `${svgPath}
`,
+ iconSize: [34, 34], iconAnchor: [17, 34], popupAnchor: [0, -34]
+});
+
+const iconSPBU = (is24h) => createIcon(' ', is24h ? '#10B981' : '#EF4444');
+const iconRumah = createIcon(' ', '#8B5CF6');
+const iconWarga = createIcon(' ', '#EF4444');
+
+window.showToast = (msg, type='success') => {
+ const t = document.createElement('div'); t.className = `toast ${type}`; t.innerHTML = msg;
+ document.getElementById('toast-container').appendChild(t);
+ setTimeout(() => { t.style.opacity=0; setTimeout(()=>t.remove(),300); }, 3000);
+};
+
+document.addEventListener('DOMContentLoaded', async () => {
+ appMap = initMap('map');
+ document.getElementById('btn-draw-rumah')?.addEventListener('click', () => { window.currentDrawType='rumah_ibadah'; new L.Draw.Marker(appMap).enable(); });
+ document.getElementById('btn-draw-warga')?.addEventListener('click', () => { window.currentDrawType='warga_miskin'; new L.Draw.Marker(appMap).enable(); });
+ drawControl = setupDrawControls(appMap, handleGeometryCreated);
+ await loadAllData();
+});
+
+const loadAllData = async () => {
+ try {
+ const [spbu, jalan, choro, rumah, warga] = await Promise.all([
+ spbuService.getAll(), jalanService.getAll(), statistikService.getChoropleth(),
+ rumahIbadahService.getAll(), wargaMiskinService.getAll()
+ ]);
+
+ L.geoJSON(spbu, { pointToLayer: (f, ll) => L.marker(ll, { icon: iconSPBU(f.properties.buka_24_jam) }), onEachFeature: (f, l) => bindPopup(l, 'spbu', f.properties) }).addTo(appMap);
+ L.geoJSON(jalan, { style: { color: '#F59E0B', weight: 4 }, onEachFeature: (f, l) => bindPopup(l, 'jalan', f.properties) }).addTo(appMap);
+ L.geoJSON(rumah, { pointToLayer: (f, ll) => L.marker(ll, { icon: iconRumah }), onEachFeature: (f, l) => bindPopup(l, 'rumah_ibadah', f.properties) }).addTo(appMap);
+ L.geoJSON(warga, { pointToLayer: (f, ll) => L.marker(ll, { icon: iconWarga }), onEachFeature: (f, l) => bindPopup(l, 'warga_miskin', f.properties) }).addTo(appMap);
+
+ let merahCount = 0;
+ L.geoJSON(choro, {
+ style: (f) => {
+ const count = f.properties.jumlah_warga_miskin;
+ let c = '#10B981'; // Hijau (0)
+ if(count > 0) c = '#F59E0B'; // Kuning (1-2)
+ if(count > 2) { c = '#EF4444'; merahCount++; } // Merah (>2)
+ return { color: c, fillColor: c, fillOpacity: 0.4, weight: 2 };
+ },
+ onEachFeature: (f, l) => bindPopup(l, 'kavling', f.properties)
+ }).addTo(appMap);
+
+ document.getElementById('stat-total-warga').innerText = warga.features.length;
+ document.getElementById('stat-kavling-merah').innerText = merahCount;
+
+ } catch (e) { window.showToast("Gagal meload data: "+e.message, 'error'); }
+};
+
+const bindPopup = (layer, type, props) => {
+ let ext = '';
+ if(type==='spbu') ext = `24 Jam: ${props.buka_24_jam?'Ya':'Tidak'}
`;
+ if(type==='kavling') ext = `Jml Warga Miskin: ${props.jumlah_warga_miskin||0} Jiwa
`;
+
+ layer.bindPopup(`
+
+ `);
+};
+
+window.deleteData = async (type, id) => {
+ if(!confirm('Yakin menghapus?')) return;
+ try {
+ if(type==='spbu') await spbuService.delete(id);
+ if(type==='jalan') await jalanService.delete(id);
+ if(type==='kavling') await kavlingService.delete(id);
+ if(type==='rumah_ibadah') await rumahIbadahService.delete(id);
+ if(type==='warga_miskin') await wargaMiskinService.delete(id);
+ window.showToast('Data dihapus'); setTimeout(()=>location.reload(), 800);
+ } catch(e) { window.showToast('Gagal hapus', 'error'); }
+};
+
+const handleGeometryCreated = (type, geometry, layer) => {
+ const tempLayer = L.geoJSON(geometry).addTo(appMap);
+ renderForm(type, geometry, async (payload) => {
+ try {
+ if(type==='spbu') await spbuService.create(payload);
+ if(type==='jalan') await jalanService.create(payload);
+ if(type==='kavling') await kavlingService.create(payload);
+ if(type==='rumah_ibadah') await rumahIbadahService.create(payload);
+ if(type==='warga_miskin') await wargaMiskinService.create(payload);
+ window.showToast('Tersimpan'); setTimeout(()=>location.reload(), 800);
+ } catch(e) { window.showToast('Gagal simpan', 'error'); }
+ }, () => appMap.removeLayer(tempLayer));
+};
diff --git a/03/frontend/js/modules/draw.js b/03/frontend/js/modules/draw.js
new file mode 100644
index 0000000..40628f4
--- /dev/null
+++ b/03/frontend/js/modules/draw.js
@@ -0,0 +1,31 @@
+export const setupDrawControls = (map, onGeometryCreated) => {
+ const drawControl = new L.Control.Draw({
+ draw: { marker: true, polyline: true, polygon: true, circle: false, circlemarker: false, rectangle: false }
+ });
+ map.addControl(drawControl);
+
+ map.on(L.Draw.Event.CREATED, function (event) {
+ const layer = event.layer;
+ const type = event.layerType;
+
+ // Peta draw default tipe: marker -> spbu, polyline -> jalan, polygon -> kavling
+ let customType = type;
+ if(type === 'marker') customType = 'spbu';
+ if(type === 'polyline') customType = 'jalan';
+ if(type === 'polygon') customType = 'kavling';
+ if(window.currentDrawType) customType = window.currentDrawType; // Untuk P2/P3
+
+ onGeometryCreated(customType, layer.toGeoJSON().geometry, layer);
+ });
+
+ // Custom Menu Handlers
+ const markerDrawer = new L.Draw.Marker(map, drawControl.options.draw.marker);
+ const polylineDrawer = new L.Draw.Polyline(map, drawControl.options.draw.polyline);
+ const polygonDrawer = new L.Draw.Polygon(map, drawControl.options.draw.polygon);
+
+ document.getElementById('btn-draw-spbu')?.addEventListener('click', () => { window.currentDrawType='spbu'; markerDrawer.enable(); });
+ document.getElementById('btn-draw-jalan')?.addEventListener('click', () => { window.currentDrawType='jalan'; polylineDrawer.enable(); });
+ document.getElementById('btn-draw-kavling')?.addEventListener('click', () => { window.currentDrawType='kavling'; polygonDrawer.enable(); });
+
+ return drawControl;
+};
diff --git a/03/frontend/js/modules/form.js b/03/frontend/js/modules/form.js
new file mode 100644
index 0000000..3959e52
--- /dev/null
+++ b/03/frontend/js/modules/form.js
@@ -0,0 +1,50 @@
+export const renderForm = (type, geometry, onSubmit, onCancel) => {
+ const container = document.getElementById('form-container');
+ container.style.display = 'flex';
+
+ const extraFields = type === 'spbu' ? `
+
+
+
+ Buka 24 Jam
+
+
+ ` : '';
+
+ container.innerHTML = `
+
+ `;
+
+ document.getElementById('btn-save').addEventListener('click', () => {
+ const payload = {
+ type,
+ geometry,
+ nama: document.getElementById('input-nama').value,
+ deskripsi: document.getElementById('input-deskripsi').value,
+ };
+ if (type === 'spbu') payload.buka_24_jam = document.getElementById('input-24jam').checked;
+
+ onSubmit(payload);
+ container.style.display = 'none';
+ container.innerHTML = '';
+ });
+
+ document.getElementById('btn-cancel').addEventListener('click', () => {
+ if(onCancel) onCancel();
+ container.style.display = 'none';
+ container.innerHTML = '';
+ });
+};
diff --git a/03/frontend/js/modules/map.js b/03/frontend/js/modules/map.js
new file mode 100644
index 0000000..fdecef9
--- /dev/null
+++ b/03/frontend/js/modules/map.js
@@ -0,0 +1,25 @@
+/**
+ * map.js
+ * Tanggung Jawab: Inisialisasi peta Leaflet dasar dan base layer.
+ */
+
+export const initMap = (containerId) => {
+ // Pusat peta default (Universitas Tanjungpura, Pontianak)
+ const map = L.map(containerId, {
+ zoomControl: false
+ }).setView([-0.0583, 109.3448], 15);
+
+ // Pindahkan zoom control ke kanan bawah
+ L.control.zoom({
+ position: 'bottomright'
+ }).addTo(map);
+
+ // Tile Layer Premium (CartoDB Dark Matter untuk Glass UI)
+ L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
+ attribution: '© OpenStreetMap contributors © CARTO ',
+ subdomains: 'abcd',
+ maxZoom: 20
+ }).addTo(map);
+
+ return map;
+};
diff --git a/03/frontend/js/services/api.service.js b/03/frontend/js/services/api.service.js
new file mode 100644
index 0000000..36e7e5f
--- /dev/null
+++ b/03/frontend/js/services/api.service.js
@@ -0,0 +1,41 @@
+export const BASE_URL = '';
+
+const createService = (endpoint) => ({
+ getAll: async () => {
+ const res = await fetch(`${BASE_URL}/${endpoint}`);
+ const json = await res.json();
+ return json.data;
+ },
+ create: async (data) => {
+ const res = await fetch(`${BASE_URL}/${endpoint}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data)
+ });
+ const json = await res.json();
+ return json.data;
+ },
+ delete: async (id) => {
+ const res = await fetch(`${BASE_URL}/${endpoint}?id=${id}`, {
+ method: 'DELETE'
+ });
+ const json = await res.json();
+ return json.data;
+ }
+});
+
+// P01
+export const spbuService = createService('01/backend/api/spbu.php');
+export const jalanService = createService('01/backend/api/jalan.php');
+export const kavlingService = createService('01/backend/api/kavling.php');
+
+// P02
+export const rumahIbadahService = createService('02/backend/api/rumah_ibadah.php');
+export const wargaMiskinService = createService('02/backend/api/warga_miskin.php');
+export const haversineService = {
+ getDalamRadius: async (id, radius) => {
+ const res = await fetch(`${BASE_URL}/02/backend/api/haversine.php?rumah_ibadah_id=${id}&radius_km=${radius}`);
+ const json = await res.json();
+ return json.data;
+ }
+};
diff --git a/03/frontend/js/services/rumah_ibadah.service.js b/03/frontend/js/services/rumah_ibadah.service.js
new file mode 100644
index 0000000..dc20231
--- /dev/null
+++ b/03/frontend/js/services/rumah_ibadah.service.js
@@ -0,0 +1,22 @@
+import { CONFIG } from '../config.js';
+
+export const rumahIbadahService = {
+ getAll: async () => {
+ const res = await fetch(`${CONFIG.BASE_URL}/rumah_ibadah.php`);
+ return await res.json();
+ },
+ save: async (data) => {
+ const res = await fetch(`${CONFIG.BASE_URL}/rumah_ibadah.php`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data)
+ });
+ return await res.json();
+ },
+ delete: async (id) => {
+ const res = await fetch(`${CONFIG.BASE_URL}/rumah_ibadah.php?id=${id}`, { method: 'DELETE' });
+ return await res.json();
+ },
+ getJangkauan: async (id, radius = 1) => {
+ const res = await fetch(`${CONFIG.BASE_URL}/rumah_ibadah.php?action=jangkauan&id=${id}&radius=${radius}`);
+ return await res.json();
+ }
+};
diff --git a/03/frontend/js/services/statistik.service.js b/03/frontend/js/services/statistik.service.js
new file mode 100644
index 0000000..d0afd0d
--- /dev/null
+++ b/03/frontend/js/services/statistik.service.js
@@ -0,0 +1,10 @@
+export const CONFIG = {
+ BASE_URL: '/03/backend/api'
+};
+
+export const statistikService = {
+ getKepadatan: async () => {
+ const res = await fetch(`${CONFIG.BASE_URL}/statistik.php`);
+ return await res.json();
+ }
+};
diff --git a/03/frontend/js/services/warga_miskin.service.js b/03/frontend/js/services/warga_miskin.service.js
new file mode 100644
index 0000000..d9cee43
--- /dev/null
+++ b/03/frontend/js/services/warga_miskin.service.js
@@ -0,0 +1,18 @@
+import { CONFIG } from '../config.js';
+
+export const wargaMiskinService = {
+ getAll: async () => {
+ const res = await fetch(`${CONFIG.BASE_URL}/warga_miskin.php`);
+ return await res.json();
+ },
+ save: async (data) => {
+ const res = await fetch(`${CONFIG.BASE_URL}/warga_miskin.php`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data)
+ });
+ return await res.json();
+ },
+ delete: async (id) => {
+ const res = await fetch(`${CONFIG.BASE_URL}/warga_miskin.php?id=${id}`, { method: 'DELETE' });
+ return await res.json();
+ }
+};
diff --git a/03/frontend/map.css b/03/frontend/map.css
new file mode 100644
index 0000000..0656663
--- /dev/null
+++ b/03/frontend/map.css
@@ -0,0 +1,68 @@
+/* map.css
+ * Tanggung Jawab: Styling container peta, kontrol Leaflet, dan pop-up modern.
+ */
+
+.map-container {
+ flex: 1;
+ position: relative;
+ background: #E2E8F0;
+}
+
+#map {
+ width: 100%;
+ height: 100%;
+ z-index: 1;
+}
+
+/* Customizing Leaflet Controls */
+.leaflet-control-zoom {
+ border: none !important;
+ box-shadow: var(--shadow-md) !important;
+ border-radius: var(--radius-md) !important;
+ overflow: hidden;
+}
+
+.leaflet-control-zoom a {
+ color: var(--text-main) !important;
+ background: var(--surface-glass) !important;
+ backdrop-filter: blur(8px);
+ transition: var(--transition);
+}
+
+.leaflet-control-zoom a:hover {
+ background: var(--surface) !important;
+ color: var(--primary) !important;
+}
+
+/* Custom Popup Modern */
+.leaflet-popup-content-wrapper {
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-lg);
+ padding: 4px;
+}
+
+.leaflet-popup-content {
+ margin: 14px;
+ font-family: 'Inter', sans-serif;
+}
+
+.popup-title {
+ font-size: 1.1rem;
+ font-weight: 600;
+ color: var(--text-main);
+ margin-bottom: 6px;
+ border-bottom: 2px solid var(--primary);
+ padding-bottom: 4px;
+ display: inline-block;
+}
+
+.popup-desc {
+ font-size: 0.9rem;
+ color: var(--text-muted);
+ margin-bottom: 12px;
+}
+
+.popup-action {
+ display: flex;
+ justify-content: flex-end;
+}
diff --git a/03/frontend/sidebar.css b/03/frontend/sidebar.css
new file mode 100644
index 0000000..5ce4b23
--- /dev/null
+++ b/03/frontend/sidebar.css
@@ -0,0 +1,78 @@
+/* sidebar.css
+ * Tanggung Jawab: Styling layout sidebar, navigasi, dan list item.
+ */
+
+.sidebar {
+ width: 350px;
+ background: var(--surface-glass);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ border-right: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ z-index: 1000;
+ box-shadow: var(--shadow-lg);
+ transition: var(--transition);
+}
+
+.sidebar-header {
+ padding: 24px;
+ border-bottom: 1px solid var(--border);
+}
+
+.sidebar-title {
+ font-size: 1.25rem;
+ font-weight: 700;
+ background: linear-gradient(135deg, var(--primary), var(--secondary));
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ letter-spacing: -0.5px;
+}
+
+.sidebar-subtitle {
+ font-size: 0.85rem;
+ color: var(--text-muted);
+ margin-top: 4px;
+}
+
+.sidebar-content {
+ flex: 1;
+ overflow-y: auto;
+ padding: 24px;
+}
+
+/* Toast Notification */
+.toast-container {
+ position: fixed;
+ bottom: 24px;
+ right: 24px;
+ z-index: 9999;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.toast {
+ background: var(--surface);
+ color: var(--text-main);
+ padding: 12px 20px;
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-md);
+ font-size: 0.9rem;
+ font-weight: 500;
+ transform: translateY(20px);
+ opacity: 0;
+ animation: slideIn 0.3s forwards ease-out;
+ border-left: 4px solid var(--primary);
+}
+
+.toast.error {
+ border-left-color: var(--danger);
+}
+
+@keyframes slideIn {
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
+}
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..1f07a0b
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,15 @@
+FROM php:8.2-apache
+
+RUN docker-php-ext-install pdo_mysql mysqli \
+ && a2enmod rewrite headers
+
+COPY index.html /var/www/html/index.html
+COPY 01/ /var/www/html/01/
+COPY 02/ /var/www/html/02/
+COPY 03/ /var/www/html/03/
+COPY final/ /var/www/html/final/
+COPY project_final/ /var/www/html/project_final/
+
+RUN chown -R www-data:www-data /var/www/html
+
+EXPOSE 80
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0f22db8
--- /dev/null
+++ b/README.md
@@ -0,0 +1,66 @@
+# WebGIS Poverty Map
+
+Aplikasi WebGIS PHP + MySQL untuk pemetaan data kemiskinan, fasilitas, laporan warga, dan analisis blank spot bantuan.
+
+## Stack
+
+- PHP 8.2 Apache
+- MySQL/MariaDB
+- Leaflet
+- PDO MySQL
+
+## Deploy Coolify
+
+1. Buat database MySQL/MariaDB di Coolify.
+2. Import `database/webgis_db.sql` ke database tersebut.
+3. Buat aplikasi baru dari repo ini.
+4. Pilih build menggunakan `Dockerfile` di root repository.
+5. Set environment variable:
+
+```env
+APP_BASE_PATH=
+SESSION_SECURE=true
+CORS_ALLOWED_ORIGIN=
+DB_HOST=nama-service-database
+DB_PORT=3306
+DB_DATABASE=webgis_db
+DB_USERNAME=user_database
+DB_PASSWORD=password_database
+```
+
+Root aplikasi menampilkan halaman pemilih progres dari `index.html`. Versi final berjalan di `/project_final`, jadi gunakan `APP_BASE_PATH=/project_final` untuk deploy Coolify ini.
+
+`CORS_ALLOWED_ORIGIN` boleh dikosongkan untuk same-origin deployment. Isi dengan domain aplikasi jika API memang perlu diakses dari origin lain.
+
+## Akun Awal
+
+Setelah import SQL:
+
+- Admin: `admin / admin123`
+- Pengguna: `pengguna / user123`
+
+Ganti password akun demo setelah deploy jika aplikasi dibuka publik.
+
+## Struktur Penting
+
+- `index.html` - halaman awal untuk memilih progres.
+- `01/`, `02/`, `03/`, `final/` - progres pertemuan.
+- `project_final/` - aplikasi final yang berjalan di `/project_final`.
+- `database/webgis_db.sql` - struktur dan seed database lengkap.
+- `Dockerfile` - image PHP Apache untuk Coolify.
+- `.env.example` - contoh environment variable deploy.
+
+## Local XAMPP
+
+Untuk menjalankan dari XAMPP seperti struktur lama, set base path:
+
+```env
+APP_BASE_PATH=/project/project_final
+DB_HOST=127.0.0.1
+DB_PORT=3306
+DB_DATABASE=webgis_db
+DB_USERNAME=root
+DB_PASSWORD=
+```
+
+Import `database/webgis_db.sql`, lalu buka `/project/project_final/login.php`.
diff --git a/database/create_tables.sql b/database/create_tables.sql
new file mode 100644
index 0000000..f7dfc13
--- /dev/null
+++ b/database/create_tables.sql
@@ -0,0 +1,116 @@
+-- ============================================================
+-- WebGIS Pontianak - Struktur Database
+-- ============================================================
+-- Jalankan file ini terlebih dahulu sebelum menjalankan seed data.
+
+CREATE DATABASE IF NOT EXISTS webgis_db
+ CHARACTER SET utf8mb4
+ COLLATE utf8mb4_unicode_ci;
+
+USE webgis_db;
+
+-- ============================================================
+-- 1. Users
+-- ============================================================
+CREATE TABLE IF NOT EXISTS users (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ username VARCHAR(50) NOT NULL UNIQUE,
+ password VARCHAR(255) NOT NULL,
+ role ENUM('admin', 'user') NOT NULL DEFAULT 'user',
+ nama_lengkap VARCHAR(100) DEFAULT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- ============================================================
+-- 2. Data Spasial
+-- ============================================================
+CREATE TABLE IF NOT EXISTS spbu (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ nama VARCHAR(100) NOT NULL,
+ deskripsi TEXT,
+ buka_24_jam TINYINT(1) NOT NULL DEFAULT 0,
+ geom GEOMETRY NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ SPATIAL INDEX idx_spbu_geom (geom)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS rumah_ibadah (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ nama VARCHAR(100) NOT NULL,
+ agama VARCHAR(50) NOT NULL,
+ radius_bantuan_meter INT NOT NULL DEFAULT 1000,
+ geom GEOMETRY NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ SPATIAL INDEX idx_rumah_ibadah_geom (geom)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS jalan (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ nama VARCHAR(100) NOT NULL,
+ jenis_jalan VARCHAR(50) DEFAULT NULL,
+ geom GEOMETRY NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ SPATIAL INDEX idx_jalan_geom (geom)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS kavling (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ nama_pemilik VARCHAR(100) NOT NULL,
+ status_kepemilikan VARCHAR(50) NOT NULL,
+ luas DECIMAL(10,2) DEFAULT NULL,
+ geom GEOMETRY NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ SPATIAL INDEX idx_kavling_geom (geom)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS kawasan_kumuh (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ nama_kawasan VARCHAR(100) NOT NULL,
+ geom GEOMETRY NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ SPATIAL INDEX idx_kawasan_kumuh_geom (geom)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS warga_miskin (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ nama_kk VARCHAR(100) NOT NULL,
+ penghasilan DECIMAL(15,2) NOT NULL,
+ jumlah_tanggungan INT NOT NULL,
+ geom GEOMETRY NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ SPATIAL INDEX idx_warga_miskin_geom (geom)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- ============================================================
+-- 3. Data Interaksi Pengguna
+-- ============================================================
+CREATE TABLE IF NOT EXISTS laporan_warga (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ user_id INT NOT NULL,
+ kategori VARCHAR(50) NOT NULL,
+ deskripsi TEXT NOT NULL,
+ geometry POINT NOT NULL,
+ status ENUM('menunggu', 'diproses', 'selesai', 'ditolak') NOT NULL DEFAULT 'menunggu',
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ INDEX idx_laporan_user_id (user_id),
+ SPATIAL INDEX idx_laporan_geometry (geometry),
+ CONSTRAINT fk_laporan_user
+ FOREIGN KEY (user_id) REFERENCES users(id)
+ ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS ulasan_fasilitas (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ user_id INT NOT NULL,
+ fasilitas_tipe ENUM('spbu', 'rumah_ibadah') NOT NULL,
+ fasilitas_id INT NOT NULL,
+ rating TINYINT NOT NULL,
+ komentar TEXT,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ INDEX idx_ulasan_user_id (user_id),
+ INDEX idx_ulasan_fasilitas (fasilitas_tipe, fasilitas_id),
+ CONSTRAINT fk_ulasan_user
+ FOREIGN KEY (user_id) REFERENCES users(id)
+ ON DELETE CASCADE,
+ CONSTRAINT chk_ulasan_rating CHECK (rating BETWEEN 1 AND 5)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/database/seed_pontianak.sql b/database/seed_pontianak.sql
new file mode 100644
index 0000000..d0d3b70
--- /dev/null
+++ b/database/seed_pontianak.sql
@@ -0,0 +1,172 @@
+-- ============================================================
+-- WebGIS Pontianak - Seed Data Spasial
+-- ============================================================
+-- Koordinat menggunakan urutan longitude, latitude.
+
+USE webgis_db;
+
+SET FOREIGN_KEY_CHECKS = 0;
+
+TRUNCATE TABLE spbu;
+TRUNCATE TABLE rumah_ibadah;
+TRUNCATE TABLE jalan;
+TRUNCATE TABLE kavling;
+TRUNCATE TABLE kawasan_kumuh;
+TRUNCATE TABLE warga_miskin;
+
+SET FOREIGN_KEY_CHECKS = 1;
+
+-- ============================================================
+-- 1. SPBU
+-- ============================================================
+INSERT INTO spbu (nama, deskripsi, buka_24_jam, geom) VALUES
+ (
+ 'SPBU Bundaran Digulis Untan',
+ 'SPBU terdekat dari kampus',
+ 1,
+ ST_GeomFromText('POINT(109.3440 -0.0565)')
+ ),
+ (
+ 'SPBU Ahmad Yani',
+ 'Dekat Mega Mall (24 Jam)',
+ 1,
+ ST_GeomFromText('POINT(109.3360 -0.0450)')
+ ),
+ (
+ 'SPBU Imam Bonjol',
+ 'SPBU pinggir kota',
+ 0,
+ ST_GeomFromText('POINT(109.3410 -0.0380)')
+ ),
+ (
+ 'SPBU Kota Baru',
+ 'Jl. Sultan Abdurrahman',
+ 0,
+ ST_GeomFromText('POINT(109.3245 -0.0487)')
+ );
+
+-- ============================================================
+-- 2. Rumah Ibadah
+-- ============================================================
+INSERT INTO rumah_ibadah (nama, agama, radius_bantuan_meter, geom) VALUES
+ (
+ 'Masjid Raya Mujahidin',
+ 'Islam',
+ 1000,
+ ST_GeomFromText('POINT(109.3356 -0.0454)')
+ ),
+ (
+ 'Gereja Katedral St. Yosef',
+ 'Katolik',
+ 1000,
+ ST_GeomFromText('POINT(109.3368 -0.0298)')
+ ),
+ (
+ 'Vihara Bodhisatva Karaniya Metta',
+ 'Buddha',
+ 1000,
+ ST_GeomFromText('POINT(109.3468 -0.0233)')
+ ),
+ (
+ 'Masjid Jami Keraton Kadriyah',
+ 'Islam',
+ 1000,
+ ST_GeomFromText('POINT(109.3523 -0.0229)')
+ );
+
+-- ============================================================
+-- 3. Jalan
+-- ============================================================
+INSERT INTO jalan (nama, jenis_jalan, geom) VALUES
+ (
+ 'Jalan Ahmad Yani',
+ 'Arteri Primer',
+ ST_GeomFromText('LINESTRING(109.3448 -0.0583, 109.3360 -0.0450, 109.3310 -0.0320)')
+ ),
+ (
+ 'Jalan Gajah Mada',
+ 'Kolektor',
+ ST_GeomFromText('LINESTRING(109.3310 -0.0320, 109.3410 -0.0290, 109.3460 -0.0250)')
+ ),
+ (
+ 'Jalan Imam Bonjol',
+ 'Lokal',
+ ST_GeomFromText('LINESTRING(109.3360 -0.0450, 109.3420 -0.0400, 109.3500 -0.0350)')
+ );
+
+-- ============================================================
+-- 4. Kavling
+-- ============================================================
+INSERT INTO kavling (nama_pemilik, status_kepemilikan, luas, geom) VALUES
+ (
+ 'PT. Mega Karya',
+ 'HGB',
+ 25000.00,
+ ST_GeomFromText('POLYGON((109.333 -0.048, 109.338 -0.048, 109.338 -0.053, 109.333 -0.053, 109.333 -0.048))')
+ ),
+ (
+ 'Bapak Sudirman',
+ 'SHM',
+ 1500.00,
+ ST_GeomFromText('POLYGON((109.340 -0.035, 109.341 -0.035, 109.341 -0.036, 109.340 -0.036, 109.340 -0.035))')
+ );
+
+-- ============================================================
+-- 5. Kawasan Kumuh
+-- ============================================================
+INSERT INTO kawasan_kumuh (nama_kawasan, geom) VALUES
+ (
+ 'Kawasan Rawan Bantaran Sungai',
+ ST_GeomFromText('POLYGON((109.360 -0.050, 109.365 -0.050, 109.365 -0.055, 109.360 -0.055, 109.360 -0.050))')
+ ),
+ (
+ 'Kawasan Padat Parit Tokaya',
+ ST_GeomFromText('POLYGON((109.342 -0.038, 109.346 -0.038, 109.346 -0.042, 109.342 -0.042, 109.342 -0.038))')
+ );
+
+-- ============================================================
+-- 6. Warga Miskin
+-- ============================================================
+INSERT INTO warga_miskin (nama_kk, penghasilan, jumlah_tanggungan, geom) VALUES
+ (
+ 'Bpk. Budi (Terisolasi)',
+ 800000.00,
+ 4,
+ ST_GeomFromText('POINT(109.361 -0.051)')
+ ),
+ (
+ 'Ibu Siti (Terisolasi)',
+ 600000.00,
+ 2,
+ ST_GeomFromText('POINT(109.362 -0.052)')
+ ),
+ (
+ 'Bpk. Joko (Terisolasi)',
+ 1000000.00,
+ 5,
+ ST_GeomFromText('POINT(109.363 -0.053)')
+ ),
+ (
+ 'Ibu Ani (Terisolasi)',
+ 700000.00,
+ 3,
+ ST_GeomFromText('POINT(109.364 -0.054)')
+ ),
+ (
+ 'Bpk. Hasan',
+ 1200000.00,
+ 3,
+ ST_GeomFromText('POINT(109.334 -0.050)')
+ ),
+ (
+ 'Mbah Warno',
+ 400000.00,
+ 1,
+ ST_GeomFromText('POINT(109.336 -0.046)')
+ ),
+ (
+ 'Pak Junaidi',
+ 900000.00,
+ 4,
+ ST_GeomFromText('POINT(109.344 -0.040)')
+ );
diff --git a/database/seed_users.sql b/database/seed_users.sql
new file mode 100644
index 0000000..32d5b0a
--- /dev/null
+++ b/database/seed_users.sql
@@ -0,0 +1,26 @@
+-- ============================================================
+-- WebGIS Pontianak - Seed Users
+-- ============================================================
+-- Akun demo:
+-- admin / admin123
+-- pengguna / user123
+
+USE webgis_db;
+
+INSERT INTO users (username, password, role, nama_lengkap) VALUES
+ (
+ 'admin',
+ '$2y$12$oRvX1EtLHM/y8XtwlOy./Oi2UVpnHp98GvXqEuLCFimhr0doKus62',
+ 'admin',
+ 'Administrator WebGIS'
+ ),
+ (
+ 'pengguna',
+ '$2y$12$djJkmhJBRCSCqqpAICIvGuLoVpICrDUS2IVECbZUodgR89VRTqdLW',
+ 'user',
+ 'Pengguna Publik'
+ )
+ON DUPLICATE KEY UPDATE
+ password = VALUES(password),
+ role = VALUES(role),
+ nama_lengkap = VALUES(nama_lengkap);
diff --git a/database/webgis_db.sql b/database/webgis_db.sql
new file mode 100644
index 0000000..f05a77e
--- /dev/null
+++ b/database/webgis_db.sql
@@ -0,0 +1,282 @@
+-- ============================================================
+-- WebGIS Pontianak - Database Lengkap
+-- ============================================================
+-- Import file ini jika ingin membuat ulang database dari awal.
+-- Perhatian: tabel lama dengan nama yang sama akan dihapus.
+
+CREATE DATABASE IF NOT EXISTS webgis_db
+ CHARACTER SET utf8mb4
+ COLLATE utf8mb4_unicode_ci;
+
+USE webgis_db;
+
+SET FOREIGN_KEY_CHECKS = 0;
+
+DROP TABLE IF EXISTS ulasan_fasilitas;
+DROP TABLE IF EXISTS laporan_warga;
+DROP TABLE IF EXISTS warga_miskin;
+DROP TABLE IF EXISTS kawasan_kumuh;
+DROP TABLE IF EXISTS kavling;
+DROP TABLE IF EXISTS jalan;
+DROP TABLE IF EXISTS rumah_ibadah;
+DROP TABLE IF EXISTS spbu;
+DROP TABLE IF EXISTS users;
+
+SET FOREIGN_KEY_CHECKS = 1;
+
+-- ============================================================
+-- 1. Users
+-- ============================================================
+CREATE TABLE users (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ username VARCHAR(50) NOT NULL UNIQUE,
+ password VARCHAR(255) NOT NULL,
+ role ENUM('admin', 'user') NOT NULL DEFAULT 'user',
+ nama_lengkap VARCHAR(100) DEFAULT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+INSERT INTO users (username, password, role, nama_lengkap) VALUES
+ (
+ 'admin',
+ '$2y$12$oRvX1EtLHM/y8XtwlOy./Oi2UVpnHp98GvXqEuLCFimhr0doKus62',
+ 'admin',
+ 'Administrator WebGIS'
+ ),
+ (
+ 'pengguna',
+ '$2y$12$djJkmhJBRCSCqqpAICIvGuLoVpICrDUS2IVECbZUodgR89VRTqdLW',
+ 'user',
+ 'Pengguna Publik'
+ );
+
+-- ============================================================
+-- 2. Data Spasial
+-- ============================================================
+CREATE TABLE spbu (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ nama VARCHAR(100) NOT NULL,
+ deskripsi TEXT,
+ buka_24_jam TINYINT(1) NOT NULL DEFAULT 0,
+ geom GEOMETRY NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ SPATIAL INDEX idx_spbu_geom (geom)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+INSERT INTO spbu (nama, deskripsi, buka_24_jam, geom) VALUES
+ (
+ 'SPBU Bundaran Digulis Untan',
+ 'SPBU terdekat dari kampus',
+ 1,
+ ST_GeomFromText('POINT(109.3440 -0.0565)')
+ ),
+ (
+ 'SPBU Ahmad Yani',
+ 'Dekat Mega Mall (24 Jam)',
+ 1,
+ ST_GeomFromText('POINT(109.3360 -0.0450)')
+ ),
+ (
+ 'SPBU Imam Bonjol',
+ 'SPBU pinggir kota',
+ 0,
+ ST_GeomFromText('POINT(109.3410 -0.0380)')
+ ),
+ (
+ 'SPBU Kota Baru',
+ 'Jl. Sultan Abdurrahman',
+ 0,
+ ST_GeomFromText('POINT(109.3245 -0.0487)')
+ );
+
+CREATE TABLE rumah_ibadah (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ nama VARCHAR(100) NOT NULL,
+ agama VARCHAR(50) NOT NULL,
+ radius_bantuan_meter INT NOT NULL DEFAULT 1000,
+ geom GEOMETRY NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ SPATIAL INDEX idx_rumah_ibadah_geom (geom)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+INSERT INTO rumah_ibadah (nama, agama, radius_bantuan_meter, geom) VALUES
+ (
+ 'Masjid Raya Mujahidin',
+ 'Islam',
+ 1000,
+ ST_GeomFromText('POINT(109.3356 -0.0454)')
+ ),
+ (
+ 'Gereja Katedral St. Yosef',
+ 'Katolik',
+ 1000,
+ ST_GeomFromText('POINT(109.3368 -0.0298)')
+ ),
+ (
+ 'Vihara Bodhisatva Karaniya Metta',
+ 'Buddha',
+ 1000,
+ ST_GeomFromText('POINT(109.3468 -0.0233)')
+ ),
+ (
+ 'Masjid Jami Keraton Kadriyah',
+ 'Islam',
+ 1000,
+ ST_GeomFromText('POINT(109.3523 -0.0229)')
+ );
+
+CREATE TABLE jalan (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ nama VARCHAR(100) NOT NULL,
+ jenis_jalan VARCHAR(50) DEFAULT NULL,
+ geom GEOMETRY NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ SPATIAL INDEX idx_jalan_geom (geom)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+INSERT INTO jalan (nama, jenis_jalan, geom) VALUES
+ (
+ 'Jalan Ahmad Yani',
+ 'Arteri Primer',
+ ST_GeomFromText('LINESTRING(109.3448 -0.0583, 109.3360 -0.0450, 109.3310 -0.0320)')
+ ),
+ (
+ 'Jalan Gajah Mada',
+ 'Kolektor',
+ ST_GeomFromText('LINESTRING(109.3310 -0.0320, 109.3410 -0.0290, 109.3460 -0.0250)')
+ ),
+ (
+ 'Jalan Imam Bonjol',
+ 'Lokal',
+ ST_GeomFromText('LINESTRING(109.3360 -0.0450, 109.3420 -0.0400, 109.3500 -0.0350)')
+ );
+
+CREATE TABLE kavling (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ nama_pemilik VARCHAR(100) NOT NULL,
+ status_kepemilikan VARCHAR(50) NOT NULL,
+ luas DECIMAL(10,2) DEFAULT NULL,
+ geom GEOMETRY NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ SPATIAL INDEX idx_kavling_geom (geom)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+INSERT INTO kavling (nama_pemilik, status_kepemilikan, luas, geom) VALUES
+ (
+ 'PT. Mega Karya',
+ 'HGB',
+ 25000.00,
+ ST_GeomFromText('POLYGON((109.333 -0.048, 109.338 -0.048, 109.338 -0.053, 109.333 -0.053, 109.333 -0.048))')
+ ),
+ (
+ 'Bapak Sudirman',
+ 'SHM',
+ 1500.00,
+ ST_GeomFromText('POLYGON((109.340 -0.035, 109.341 -0.035, 109.341 -0.036, 109.340 -0.036, 109.340 -0.035))')
+ );
+
+CREATE TABLE kawasan_kumuh (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ nama_kawasan VARCHAR(100) NOT NULL,
+ geom GEOMETRY NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ SPATIAL INDEX idx_kawasan_kumuh_geom (geom)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+INSERT INTO kawasan_kumuh (nama_kawasan, geom) VALUES
+ (
+ 'Kawasan Rawan Bantaran Sungai',
+ ST_GeomFromText('POLYGON((109.360 -0.050, 109.365 -0.050, 109.365 -0.055, 109.360 -0.055, 109.360 -0.050))')
+ ),
+ (
+ 'Kawasan Padat Parit Tokaya',
+ ST_GeomFromText('POLYGON((109.342 -0.038, 109.346 -0.038, 109.346 -0.042, 109.342 -0.042, 109.342 -0.038))')
+ );
+
+CREATE TABLE warga_miskin (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ nama_kk VARCHAR(100) NOT NULL,
+ penghasilan DECIMAL(15,2) NOT NULL,
+ jumlah_tanggungan INT NOT NULL,
+ geom GEOMETRY NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ SPATIAL INDEX idx_warga_miskin_geom (geom)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+INSERT INTO warga_miskin (nama_kk, penghasilan, jumlah_tanggungan, geom) VALUES
+ (
+ 'Bpk. Budi (Terisolasi)',
+ 800000.00,
+ 4,
+ ST_GeomFromText('POINT(109.361 -0.051)')
+ ),
+ (
+ 'Ibu Siti (Terisolasi)',
+ 600000.00,
+ 2,
+ ST_GeomFromText('POINT(109.362 -0.052)')
+ ),
+ (
+ 'Bpk. Joko (Terisolasi)',
+ 1000000.00,
+ 5,
+ ST_GeomFromText('POINT(109.363 -0.053)')
+ ),
+ (
+ 'Ibu Ani (Terisolasi)',
+ 700000.00,
+ 3,
+ ST_GeomFromText('POINT(109.364 -0.054)')
+ ),
+ (
+ 'Bpk. Hasan',
+ 1200000.00,
+ 3,
+ ST_GeomFromText('POINT(109.334 -0.050)')
+ ),
+ (
+ 'Mbah Warno',
+ 400000.00,
+ 1,
+ ST_GeomFromText('POINT(109.336 -0.046)')
+ ),
+ (
+ 'Pak Junaidi',
+ 900000.00,
+ 4,
+ ST_GeomFromText('POINT(109.344 -0.040)')
+ );
+
+-- ============================================================
+-- 3. Data Interaksi Pengguna
+-- ============================================================
+CREATE TABLE laporan_warga (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ user_id INT NOT NULL,
+ kategori VARCHAR(50) NOT NULL,
+ deskripsi TEXT NOT NULL,
+ geometry POINT NOT NULL,
+ status ENUM('menunggu', 'diproses', 'selesai', 'ditolak') NOT NULL DEFAULT 'menunggu',
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ INDEX idx_laporan_user_id (user_id),
+ SPATIAL INDEX idx_laporan_geometry (geometry),
+ CONSTRAINT fk_laporan_user
+ FOREIGN KEY (user_id) REFERENCES users(id)
+ ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE ulasan_fasilitas (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ user_id INT NOT NULL,
+ fasilitas_tipe ENUM('spbu', 'rumah_ibadah') NOT NULL,
+ fasilitas_id INT NOT NULL,
+ rating TINYINT NOT NULL,
+ komentar TEXT,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ INDEX idx_ulasan_user_id (user_id),
+ INDEX idx_ulasan_fasilitas (fasilitas_tipe, fasilitas_id),
+ CONSTRAINT fk_ulasan_user
+ FOREIGN KEY (user_id) REFERENCES users(id)
+ ON DELETE CASCADE,
+ CONSTRAINT chk_ulasan_rating CHECK (rating BETWEEN 1 AND 5)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/final/backend/api/jalan.php b/final/backend/api/jalan.php
new file mode 100644
index 0000000..7264a11
--- /dev/null
+++ b/final/backend/api/jalan.php
@@ -0,0 +1,75 @@
+query("SELECT id, nama, jenis_jalan, created_at, ST_AsGeoJSON(geom) as geojson FROM jalan");
+ $features = [];
+ while ($row = $stmt->fetch()) {
+ $features[] = [
+ 'type' => 'Feature',
+ 'geometry' => json_decode($row['geojson']),
+ 'properties' => [
+ 'id' => $row['id'],
+ 'nama' => $row['nama'],
+ 'jenis_jalan' => $row['jenis_jalan'],
+ 'created_at' => $row['created_at']
+ ]
+ ];
+ }
+ sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Jalan berhasil diambil');
+ } catch (PDOException $e) {
+ sendError('Gagal mengambil data Jalan: ' . $e->getMessage(), 500);
+ }
+ break;
+
+ case 'POST':
+ $input = json_decode(file_get_contents('php://input'), true);
+ if (!isset($input['nama']) || !isset($input['geometry'])) {
+ sendError('Nama dan geometri wajib diisi');
+ }
+
+ try {
+ $sql = "INSERT INTO jalan (nama, jenis_jalan, geom) VALUES (:nama, :jenis_jalan, ST_GeomFromGeoJSON(:geometry))";
+ $stmt = $pdo->prepare($sql);
+ $stmt->execute([
+ ':nama' => $input['nama'],
+ ':jenis_jalan' => $input['jenis_jalan'] ?? 'Lokal',
+ ':geometry' => json_encode($input['geometry'])
+ ]);
+ sendSuccess(['id' => $pdo->lastInsertId()], 'Data Jalan berhasil disimpan', 201);
+ } catch (PDOException $e) {
+ sendError('Gagal menyimpan Jalan: ' . $e->getMessage(), 500);
+ }
+ break;
+
+ case 'DELETE':
+ $id = $_GET['id'] ?? null;
+ if (!$id) {
+ sendError('ID Jalan wajib disertakan');
+ }
+
+ try {
+ $stmt = $pdo->prepare("DELETE FROM jalan WHERE id = :id");
+ $stmt->execute([':id' => $id]);
+ sendSuccess(null, 'Data Jalan berhasil dihapus');
+ } catch (PDOException $e) {
+ sendError('Gagal menghapus Jalan: ' . $e->getMessage(), 500);
+ }
+ break;
+
+ default:
+ sendError('Method not allowed', 405);
+ break;
+}
+?>
diff --git a/final/backend/api/kavling.php b/final/backend/api/kavling.php
new file mode 100644
index 0000000..ed862a5
--- /dev/null
+++ b/final/backend/api/kavling.php
@@ -0,0 +1,75 @@
+query("SELECT id, nama_pemilik, luas, created_at, ST_AsGeoJSON(geom) as geojson FROM kavling");
+ $features = [];
+ while ($row = $stmt->fetch()) {
+ $features[] = [
+ 'type' => 'Feature',
+ 'geometry' => json_decode($row['geojson']),
+ 'properties' => [
+ 'id' => $row['id'],
+ 'nama_pemilik' => $row['nama_pemilik'],
+ 'luas' => $row['luas'],
+ 'created_at' => $row['created_at']
+ ]
+ ];
+ }
+ sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Kavling berhasil diambil');
+ } catch (PDOException $e) {
+ sendError('Gagal mengambil data Kavling: ' . $e->getMessage(), 500);
+ }
+ break;
+
+ case 'POST':
+ $input = json_decode(file_get_contents('php://input'), true);
+ if (!isset($input['nama_pemilik']) || !isset($input['geometry'])) {
+ sendError('Nama pemilik dan geometri wajib diisi');
+ }
+
+ try {
+ $sql = "INSERT INTO kavling (nama_pemilik, luas, geom) VALUES (:nama_pemilik, :luas, ST_GeomFromGeoJSON(:geometry))";
+ $stmt = $pdo->prepare($sql);
+ $stmt->execute([
+ ':nama_pemilik' => $input['nama_pemilik'],
+ ':luas' => $input['luas'] ?? 0,
+ ':geometry' => json_encode($input['geometry'])
+ ]);
+ sendSuccess(['id' => $pdo->lastInsertId()], 'Data Kavling berhasil disimpan', 201);
+ } catch (PDOException $e) {
+ sendError('Gagal menyimpan Kavling: ' . $e->getMessage(), 500);
+ }
+ break;
+
+ case 'DELETE':
+ $id = $_GET['id'] ?? null;
+ if (!$id) {
+ sendError('ID Kavling wajib disertakan');
+ }
+
+ try {
+ $stmt = $pdo->prepare("DELETE FROM kavling WHERE id = :id");
+ $stmt->execute([':id' => $id]);
+ sendSuccess(null, 'Data Kavling berhasil dihapus');
+ } catch (PDOException $e) {
+ sendError('Gagal menghapus Kavling: ' . $e->getMessage(), 500);
+ }
+ break;
+
+ default:
+ sendError('Method not allowed', 405);
+ break;
+}
+?>
diff --git a/final/backend/api/rumah_ibadah.php b/final/backend/api/rumah_ibadah.php
new file mode 100644
index 0000000..1de6d5d
--- /dev/null
+++ b/final/backend/api/rumah_ibadah.php
@@ -0,0 +1,89 @@
+prepare("SELECT ST_AsGeoJSON(geom) as geojson FROM rumah_ibadah WHERE id = ?");
+ $stmt->execute([$id]);
+ $rumah_ibadah = $stmt->fetch();
+
+ if (!$rumah_ibadah) sendError('Rumah ibadah tidak ditemukan', 404);
+
+ $geom = json_decode($rumah_ibadah['geojson'], true);
+ $pusatLon = $geom['coordinates'][0];
+ $pusatLat = $geom['coordinates'][1];
+
+ // Panggil geo_helper untuk mendapatkan warga miskin
+ $warga = GeoHelper::getWargaDalamRadius($pdo, $pusatLat, $pusatLon, $radius);
+ sendSuccess($warga, 'Data jangkauan berhasil dihitung');
+
+ } else {
+ // GET Semua Rumah Ibadah (GeoJSON)
+ try {
+ $stmt = $pdo->query("SELECT id, nama, agama, ST_AsGeoJSON(geom) as geojson FROM rumah_ibadah");
+ $features = [];
+ while ($row = $stmt->fetch()) {
+ $features[] = [
+ 'type' => 'Feature',
+ 'geometry' => json_decode($row['geojson']),
+ 'properties' => [
+ 'id' => $row['id'],
+ 'nama' => $row['nama'],
+ 'agama' => $row['agama']
+ ]
+ ];
+ }
+ sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Rumah Ibadah');
+ } catch (PDOException $e) {
+ sendError('Gagal: ' . $e->getMessage(), 500);
+ }
+ }
+ break;
+
+ case 'POST':
+ $input = json_decode(file_get_contents('php://input'), true);
+ if (!isset($input['nama']) || !isset($input['geometry'])) sendError('Validasi gagal');
+
+ try {
+ $sql = "INSERT INTO rumah_ibadah (nama, agama, geom) VALUES (:nama, :agama, ST_GeomFromGeoJSON(:geometry))";
+ $stmt = $pdo->prepare($sql);
+ $stmt->execute([
+ ':nama' => $input['nama'],
+ ':agama' => $input['agama'] ?? 'Islam',
+ ':geometry' => json_encode($input['geometry'])
+ ]);
+ sendSuccess(['id' => $pdo->lastInsertId()], 'Disimpan', 201);
+ } catch (PDOException $e) {
+ sendError('Gagal: ' . $e->getMessage(), 500);
+ }
+ break;
+
+ case 'DELETE':
+ $id = $_GET['id'] ?? null;
+ if (!$id) sendError('ID dibutuhkan');
+ $stmt = $pdo->prepare("DELETE FROM rumah_ibadah WHERE id = ?");
+ $stmt->execute([$id]);
+ sendSuccess(null, 'Dihapus');
+ break;
+
+ default:
+ sendError('Method not allowed', 405);
+ break;
+}
+?>
diff --git a/final/backend/api/spbu.php b/final/backend/api/spbu.php
new file mode 100644
index 0000000..7529ba1
--- /dev/null
+++ b/final/backend/api/spbu.php
@@ -0,0 +1,59 @@
+query("SELECT id, nama, deskripsi, buka_24_jam, created_at, ST_AsGeoJSON(geom) as geojson FROM spbu");
+ $features = [];
+ while ($row = $stmt->fetch()) {
+ $features[] = [
+ 'type' => 'Feature',
+ 'geometry' => json_decode($row['geojson']),
+ 'properties' => [
+ 'id' => $row['id'],
+ 'nama' => $row['nama'],
+ 'deskripsi' => $row['deskripsi'],
+ 'buka_24_jam' => (bool)$row['buka_24_jam'],
+ 'created_at' => $row['created_at']
+ ]
+ ];
+ }
+ sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data SPBU berhasil diambil');
+ } catch (PDOException $e) { sendError('Gagal: ' . $e->getMessage(), 500); }
+ break;
+
+ case 'POST':
+ $input = json_decode(file_get_contents('php://input'), true);
+ if (!isset($input['nama']) || !isset($input['geometry'])) sendError('Validasi gagal');
+
+ try {
+ $sql = "INSERT INTO spbu (nama, deskripsi, buka_24_jam, geom) VALUES (:nama, :deskripsi, :buka_24_jam, ST_GeomFromGeoJSON(:geometry))";
+ $stmt = $pdo->prepare($sql);
+ $stmt->execute([
+ ':nama' => $input['nama'],
+ ':deskripsi' => $input['deskripsi'] ?? '',
+ ':buka_24_jam' => isset($input['buka_24_jam']) && $input['buka_24_jam'] ? 1 : 0,
+ ':geometry' => json_encode($input['geometry'])
+ ]);
+ sendSuccess(['id' => $pdo->lastInsertId()], 'Disimpan', 201);
+ } catch (PDOException $e) { sendError('Gagal: ' . $e->getMessage(), 500); }
+ break;
+
+ case 'DELETE':
+ $id = $_GET['id'] ?? null;
+ if (!$id) sendError('ID dibutuhkan');
+ $stmt = $pdo->prepare("DELETE FROM spbu WHERE id = ?");
+ $stmt->execute([$id]);
+ sendSuccess(null, 'Dihapus');
+ break;
+
+ default:
+ sendError('Method not allowed', 405);
+ break;
+}
+?>
diff --git a/final/backend/api/statistik.php b/final/backend/api/statistik.php
new file mode 100644
index 0000000..b5712c6
--- /dev/null
+++ b/final/backend/api/statistik.php
@@ -0,0 +1,71 @@
+query($sql);
+
+ $features = [];
+ $total_warga = 0;
+
+ while ($row = $stmt->fetch()) {
+ $jumlah = (int) $row['jumlah_warga'];
+ $total_warga += $jumlah;
+
+ $features[] = [
+ 'type' => 'Feature',
+ 'geometry' => json_decode($row['geojson']),
+ 'properties' => [
+ 'id' => $row['id'],
+ 'nama_pemilik' => $row['nama_pemilik'],
+ 'luas' => $row['luas'],
+ 'jumlah_warga' => $jumlah,
+ 'total_tanggungan' => (int) $row['total_tanggungan']
+ ]
+ ];
+ }
+
+ $response = [
+ 'statistik' => [
+ 'total_kavling' => count($features),
+ 'total_warga_terpetakan' => $total_warga
+ ],
+ 'geojson' => [
+ 'type' => 'FeatureCollection',
+ 'features' => $features
+ ]
+ ];
+
+ sendSuccess($response, 'Data statistik spasial berhasil dihitung');
+ } catch (PDOException $e) {
+ sendError('Gagal menghitung statistik spasial: ' . $e->getMessage(), 500);
+ }
+} else {
+ sendError('Method not allowed', 405);
+}
+?>
diff --git a/final/backend/api/warga_miskin.php b/final/backend/api/warga_miskin.php
new file mode 100644
index 0000000..dca0aff
--- /dev/null
+++ b/final/backend/api/warga_miskin.php
@@ -0,0 +1,67 @@
+query("SELECT id, nama_kk, penghasilan, jumlah_tanggungan, ST_AsGeoJSON(geom) as geojson FROM warga_miskin");
+ $features = [];
+ while ($row = $stmt->fetch()) {
+ $features[] = [
+ 'type' => 'Feature',
+ 'geometry' => json_decode($row['geojson']),
+ 'properties' => [
+ 'id' => $row['id'],
+ 'nama_kk' => $row['nama_kk'],
+ 'penghasilan' => $row['penghasilan'],
+ 'jumlah_tanggungan' => $row['jumlah_tanggungan']
+ ]
+ ];
+ }
+ sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Warga Miskin');
+ } catch (PDOException $e) {
+ sendError('Gagal: ' . $e->getMessage(), 500);
+ }
+ break;
+
+ case 'POST':
+ $input = json_decode(file_get_contents('php://input'), true);
+ if (!isset($input['nama_kk']) || !isset($input['geometry'])) sendError('Validasi gagal');
+
+ try {
+ $sql = "INSERT INTO warga_miskin (nama_kk, penghasilan, jumlah_tanggungan, geom) VALUES (:nama_kk, :penghasilan, :jumlah_tanggungan, ST_GeomFromGeoJSON(:geometry))";
+ $stmt = $pdo->prepare($sql);
+ $stmt->execute([
+ ':nama_kk' => $input['nama_kk'],
+ ':penghasilan' => $input['penghasilan'] ?? 0,
+ ':jumlah_tanggungan' => $input['jumlah_tanggungan'] ?? 0,
+ ':geometry' => json_encode($input['geometry'])
+ ]);
+ sendSuccess(['id' => $pdo->lastInsertId()], 'Disimpan', 201);
+ } catch (PDOException $e) {
+ sendError('Gagal: ' . $e->getMessage(), 500);
+ }
+ break;
+
+ case 'DELETE':
+ $id = $_GET['id'] ?? null;
+ if (!$id) sendError('ID dibutuhkan');
+ $stmt = $pdo->prepare("DELETE FROM warga_miskin WHERE id = ?");
+ $stmt->execute([$id]);
+ sendSuccess(null, 'Dihapus');
+ break;
+
+ default:
+ sendError('Method not allowed', 405);
+ break;
+}
+?>
diff --git a/final/backend/config/db.php b/final/backend/config/db.php
new file mode 100644
index 0000000..287e5da
--- /dev/null
+++ b/final/backend/config/db.php
@@ -0,0 +1,32 @@
+setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+ self::$conn->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
+ } catch (PDOException $exception) {
+ echo "Connection error: " . $exception->getMessage();
+ }
+ }
+ return self::$conn;
+ }
+}
diff --git a/final/backend/utils/response_helper.php b/final/backend/utils/response_helper.php
new file mode 100644
index 0000000..cd7968c
--- /dev/null
+++ b/final/backend/utils/response_helper.php
@@ -0,0 +1,29 @@
+ 'success', 'message' => $message, 'data' => $data]);
+ exit();
+}
+
+function sendError($message = 'Error', $statusCode = 400) {
+ http_response_code($statusCode);
+ echo json_encode(['status' => 'error', 'message' => $message]);
+ exit();
+}
+?>
diff --git a/final/frontend/base.css b/final/frontend/base.css
new file mode 100644
index 0000000..7e7c44c
--- /dev/null
+++ b/final/frontend/base.css
@@ -0,0 +1,61 @@
+/* base.css
+ * Tanggung Jawab: Reset CSS, definisi variabel warna premium, dan tipografi dasar.
+ */
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
+
+:root {
+ /* Color Palette - Minimalist & Elegant */
+ --primary: #4F46E5;
+ --primary-hover: #4338CA;
+ --secondary: #10B981;
+ --background: #F3F4F6;
+ --surface: #FFFFFF;
+ --surface-glass: rgba(255, 255, 255, 0.85);
+ --text-main: #111827;
+ --text-muted: #6B7280;
+ --border: #E5E7EB;
+ --danger: #EF4444;
+
+ /* Shadows & Effects */
+ --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
+ --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
+ --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
+ --radius-md: 12px;
+ --radius-lg: 16px;
+ --transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: 'Inter', sans-serif;
+ background-color: var(--background);
+ color: var(--text-main);
+ display: flex;
+ height: 100vh;
+ overflow: hidden;
+}
+
+h1, h2, h3, h4 {
+ font-weight: 600;
+ color: var(--text-main);
+}
+
+/* Scrollbar minimalis */
+::-webkit-scrollbar {
+ width: 6px;
+}
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+::-webkit-scrollbar-thumb {
+ background: #CBD5E1;
+ border-radius: 10px;
+}
+::-webkit-scrollbar-thumb:hover {
+ background: #94A3B8;
+}
diff --git a/final/frontend/css/main.css b/final/frontend/css/main.css
new file mode 100644
index 0000000..14cadd4
--- /dev/null
+++ b/final/frontend/css/main.css
@@ -0,0 +1,205 @@
+:root {
+ --primary: #6366F1;
+ --primary-hover: #4F46E5;
+ --bg-dark: #0F172A;
+ --bg-glass: rgba(15, 23, 42, 0.75);
+ --border-glass: rgba(255, 255, 255, 0.1);
+ --text-light: #F8FAFC;
+ --text-muted: #94A3B8;
+ --radius: 20px;
+ --shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
+ --font: 'Inter', sans-serif;
+}
+
+* { box-sizing: border-box; }
+
+body {
+ margin: 0;
+ font-family: var(--font);
+ background: var(--bg-dark);
+ color: var(--text-light);
+ overflow: hidden;
+}
+
+#map {
+ width: 100vw;
+ height: 100vh;
+ z-index: 1;
+}
+
+/* Floating Dock */
+.floating-dock {
+ position: absolute;
+ top: 24px;
+ left: 24px;
+ z-index: 1000;
+ background: var(--bg-glass);
+ backdrop-filter: blur(24px);
+ -webkit-backdrop-filter: blur(24px);
+ border: 1px solid var(--border-glass);
+ border-radius: var(--radius);
+ padding: 24px;
+ width: 340px;
+ box-shadow: var(--shadow);
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+ max-height: calc(100vh - 48px);
+ overflow-y: auto;
+}
+
+/* Custom Scrollbar for Dock */
+.floating-dock::-webkit-scrollbar { width: 6px; }
+.floating-dock::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 10px; }
+
+.header h2 { margin: 0 0 8px 0; font-size: 1.4rem; font-weight: 700; background: linear-gradient(135deg, #818CF8, #C084FC); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
+.header p { margin: 0; font-size: 0.85rem; color: var(--text-muted); line-height: 1.5; }
+
+/* Buttons */
+.menu-group { display: flex; flex-direction: column; gap: 10px; }
+.menu-title { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1px; color: var(--text-muted); font-weight: 600; margin-bottom: 4px; }
+
+.btn-action {
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid var(--border-glass);
+ color: var(--text-light);
+ padding: 14px 16px;
+ border-radius: 12px;
+ cursor: pointer;
+ font-weight: 500;
+ font-family: var(--font);
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ font-size: 0.9rem;
+ text-align: left;
+}
+
+.btn-action svg { width: 20px; height: 20px; flex-shrink: 0; }
+
+.btn-action:hover {
+ background: rgba(255, 255, 255, 0.1);
+ transform: translateX(4px);
+}
+
+.btn-action.active {
+ background: var(--primary);
+ border-color: var(--primary);
+ box-shadow: 0 10px 15px -3px rgba(99, 102, 241, 0.4);
+}
+
+/* Button Variants */
+.btn-spbu svg { color: #10B981; }
+.btn-jalan svg { color: #F59E0B; }
+.btn-kavling svg { color: #3B82F6; }
+.btn-rumah svg { color: #8B5CF6; }
+.btn-warga svg { color: #EF4444; }
+
+/* Form Panel */
+.form-panel {
+ background: rgba(0, 0, 0, 0.4);
+ border-radius: 12px;
+ padding: 16px;
+ border: 1px solid var(--border-glass);
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.form-group { display: flex; flex-direction: column; gap: 6px; }
+.form-group label { font-size: 0.8rem; color: var(--text-muted); }
+.form-control {
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid var(--border-glass);
+ padding: 10px 12px;
+ border-radius: 8px;
+ color: white;
+ font-family: var(--font);
+ font-size: 0.9rem;
+ outline: none;
+ transition: border 0.2s;
+}
+.form-control:focus { border-color: var(--primary); }
+
+.checkbox-group {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ cursor: pointer;
+}
+.checkbox-group input { width: 16px; height: 16px; accent-color: var(--primary); cursor: pointer; }
+.checkbox-group span { font-size: 0.85rem; color: var(--text-light); }
+
+.btn-submit {
+ background: var(--primary);
+ color: white;
+ border: none;
+ padding: 12px;
+ border-radius: 8px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.2s;
+}
+.btn-submit:hover { background: var(--primary-hover); }
+
+/* Leaflet Customizations */
+.leaflet-draw-toolbar { display: none !important; }
+.leaflet-control-zoom { border: none !important; box-shadow: var(--shadow) !important; }
+.leaflet-control-zoom a {
+ background: var(--bg-glass) !important;
+ color: var(--text-light) !important;
+ border-color: var(--border-glass) !important;
+ backdrop-filter: blur(10px);
+}
+.leaflet-control-zoom a:hover { background: var(--primary) !important; }
+
+/* Leaflet Popup Premium */
+.leaflet-popup-content-wrapper {
+ background: var(--bg-glass) !important;
+ backdrop-filter: blur(16px) !important;
+ border: 1px solid var(--border-glass) !important;
+ color: var(--text-light) !important;
+ border-radius: 16px !important;
+ box-shadow: var(--shadow) !important;
+}
+.leaflet-popup-tip { background: var(--bg-glass) !important; border: 1px solid var(--border-glass) !important; }
+.leaflet-popup-content { margin: 16px !important; font-family: var(--font); }
+.leaflet-popup-content h3 { margin: 0 0 8px 0; font-size: 1.1rem; border-bottom: 1px solid var(--border-glass); padding-bottom: 8px; }
+.leaflet-popup-content p { margin: 4px 0; font-size: 0.9rem; color: var(--text-muted); }
+.leaflet-popup-content .btn-delete {
+ background: rgba(239, 68, 68, 0.1);
+ color: #EF4444;
+ border: 1px solid rgba(239, 68, 68, 0.3);
+ padding: 6px 12px;
+ border-radius: 6px;
+ cursor: pointer;
+ margin-top: 12px;
+ width: 100%;
+ transition: all 0.2s;
+}
+.leaflet-popup-content .btn-delete:hover { background: #EF4444; color: white; }
+
+/* Custom Map Marker Icon Animation */
+.custom-icon div {
+ transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+}
+.custom-icon:hover div {
+ transform: scale(1.1) translateY(-4px);
+}
+
+/* Toast Notification */
+.toast-container {
+ position: fixed; bottom: 24px; right: 24px; z-index: 9999;
+ display: flex; flex-direction: column; gap: 10px;
+}
+.toast {
+ background: var(--bg-glass); backdrop-filter: blur(16px);
+ border: 1px solid var(--border-glass); border-left: 4px solid var(--primary);
+ padding: 16px 20px; border-radius: 12px; color: white; font-size: 0.9rem;
+ box-shadow: var(--shadow); animation: slideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+}
+.toast.error { border-left-color: #EF4444; }
+.toast.success { border-left-color: #10B981; }
+
+@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
diff --git a/final/frontend/form.css b/final/frontend/form.css
new file mode 100644
index 0000000..be53320
--- /dev/null
+++ b/final/frontend/form.css
@@ -0,0 +1,92 @@
+/* form.css
+ * Tanggung Jawab: Styling komponen form (input, button, select).
+ */
+
+.form-group {
+ margin-bottom: 16px;
+}
+
+.form-label {
+ display: block;
+ font-size: 0.85rem;
+ font-weight: 500;
+ margin-bottom: 6px;
+ color: var(--text-main);
+}
+
+.form-control {
+ width: 100%;
+ padding: 10px 14px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ font-family: inherit;
+ font-size: 0.95rem;
+ transition: var(--transition);
+ background: var(--surface);
+}
+
+.form-control:focus {
+ outline: none;
+ border-color: var(--primary);
+ box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
+}
+
+/* Buttons */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 10px 20px;
+ font-family: inherit;
+ font-weight: 600;
+ font-size: 0.9rem;
+ border-radius: var(--radius-md);
+ border: none;
+ cursor: pointer;
+ transition: var(--transition);
+ gap: 8px;
+ width: 100%;
+}
+
+.btn-primary {
+ background: var(--primary);
+ color: white;
+}
+
+.btn-primary:hover {
+ background: var(--primary-hover);
+ transform: translateY(-1px);
+ box-shadow: var(--shadow-md);
+}
+
+.btn-danger {
+ background: var(--danger);
+ color: white;
+ width: auto;
+ padding: 6px 12px;
+ font-size: 0.8rem;
+}
+
+.btn-danger:hover {
+ background: #DC2626;
+}
+
+/* Micro-animation for active state */
+.btn:active {
+ transform: translateY(1px);
+}
+
+/* Form Container Animation */
+#form-container {
+ background: var(--surface);
+ padding: 20px;
+ border-radius: var(--radius-lg);
+ border: 1px solid var(--border);
+ margin-bottom: 20px;
+ animation: fadeIn 0.4s ease-out;
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; transform: scale(0.98); }
+ to { opacity: 1; transform: scale(1); }
+}
diff --git a/final/frontend/index.html b/final/frontend/index.html
new file mode 100644
index 0000000..968c82c
--- /dev/null
+++ b/final/frontend/index.html
@@ -0,0 +1,53 @@
+
+
+
+
+
+ WebGIS - Pertemuan 03
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/final/frontend/js/app.js b/final/frontend/js/app.js
new file mode 100644
index 0000000..3941765
--- /dev/null
+++ b/final/frontend/js/app.js
@@ -0,0 +1,110 @@
+import { initMap } from './modules/map.js';
+import { setupDrawControls } from './modules/draw.js';
+import { renderForm } from './modules/form.js';
+import { spbuService, jalanService, kavlingService, rumahIbadahService, wargaMiskinService, BASE_URL } from './services/api.service.js';
+
+let appMap;
+let drawControl;
+
+const statistikService = {
+ getChoropleth: async () => {
+ const res = await fetch(`${BASE_URL}/03/backend/api/statistik.php`);
+ const json = await res.json();
+ return json.data;
+ }
+};
+
+export const createIcon = (svgPath, color) => L.divIcon({
+ className: 'custom-icon',
+ html: `${svgPath}
`,
+ iconSize: [34, 34], iconAnchor: [17, 34], popupAnchor: [0, -34]
+});
+
+const iconSPBU = (is24h) => createIcon(' ', is24h ? '#10B981' : '#EF4444');
+const iconRumah = createIcon(' ', '#8B5CF6');
+const iconWarga = createIcon(' ', '#EF4444');
+
+window.showToast = (msg, type='success') => {
+ const t = document.createElement('div'); t.className = `toast ${type}`; t.innerHTML = msg;
+ document.getElementById('toast-container').appendChild(t);
+ setTimeout(() => { t.style.opacity=0; setTimeout(()=>t.remove(),300); }, 3000);
+};
+
+document.addEventListener('DOMContentLoaded', async () => {
+ appMap = initMap('map');
+ document.getElementById('btn-draw-rumah')?.addEventListener('click', () => { window.currentDrawType='rumah_ibadah'; new L.Draw.Marker(appMap).enable(); });
+ document.getElementById('btn-draw-warga')?.addEventListener('click', () => { window.currentDrawType='warga_miskin'; new L.Draw.Marker(appMap).enable(); });
+ drawControl = setupDrawControls(appMap, handleGeometryCreated);
+ await loadAllData();
+});
+
+const loadAllData = async () => {
+ try {
+ const [spbu, jalan, choro, rumah, warga] = await Promise.all([
+ spbuService.getAll(), jalanService.getAll(), statistikService.getChoropleth(),
+ rumahIbadahService.getAll(), wargaMiskinService.getAll()
+ ]);
+
+ L.geoJSON(spbu, { pointToLayer: (f, ll) => L.marker(ll, { icon: iconSPBU(f.properties.buka_24_jam) }), onEachFeature: (f, l) => bindPopup(l, 'spbu', f.properties) }).addTo(appMap);
+ L.geoJSON(jalan, { style: { color: '#F59E0B', weight: 4 }, onEachFeature: (f, l) => bindPopup(l, 'jalan', f.properties) }).addTo(appMap);
+ L.geoJSON(rumah, { pointToLayer: (f, ll) => L.marker(ll, { icon: iconRumah }), onEachFeature: (f, l) => bindPopup(l, 'rumah_ibadah', f.properties) }).addTo(appMap);
+ L.geoJSON(warga, { pointToLayer: (f, ll) => L.marker(ll, { icon: iconWarga }), onEachFeature: (f, l) => bindPopup(l, 'warga_miskin', f.properties) }).addTo(appMap);
+
+ let merahCount = 0;
+ L.geoJSON(choro, {
+ style: (f) => {
+ const count = f.properties.jumlah_warga_miskin;
+ let c = '#10B981'; // Hijau (0)
+ if(count > 0) c = '#F59E0B'; // Kuning (1-2)
+ if(count > 2) { c = '#EF4444'; merahCount++; } // Merah (>2)
+ return { color: c, fillColor: c, fillOpacity: 0.4, weight: 2 };
+ },
+ onEachFeature: (f, l) => bindPopup(l, 'kavling', f.properties)
+ }).addTo(appMap);
+
+ document.getElementById('stat-total-warga').innerText = warga.features.length;
+ document.getElementById('stat-kavling-merah').innerText = merahCount;
+
+ } catch (e) { window.showToast("Gagal meload data: "+e.message, 'error'); }
+};
+
+const bindPopup = (layer, type, props) => {
+ let ext = '';
+ if(type==='spbu') ext = `24 Jam: ${props.buka_24_jam?'Ya':'Tidak'}
`;
+ if(type==='kavling') ext = `Jml Warga Miskin: ${props.jumlah_warga_miskin||0} Jiwa
`;
+
+ layer.bindPopup(`
+
+ `);
+};
+
+window.deleteData = async (type, id) => {
+ if(!confirm('Yakin menghapus?')) return;
+ try {
+ if(type==='spbu') await spbuService.delete(id);
+ if(type==='jalan') await jalanService.delete(id);
+ if(type==='kavling') await kavlingService.delete(id);
+ if(type==='rumah_ibadah') await rumahIbadahService.delete(id);
+ if(type==='warga_miskin') await wargaMiskinService.delete(id);
+ window.showToast('Data dihapus'); setTimeout(()=>location.reload(), 800);
+ } catch(e) { window.showToast('Gagal hapus', 'error'); }
+};
+
+const handleGeometryCreated = (type, geometry, layer) => {
+ const tempLayer = L.geoJSON(geometry).addTo(appMap);
+ renderForm(type, geometry, async (payload) => {
+ try {
+ if(type==='spbu') await spbuService.create(payload);
+ if(type==='jalan') await jalanService.create(payload);
+ if(type==='kavling') await kavlingService.create(payload);
+ if(type==='rumah_ibadah') await rumahIbadahService.create(payload);
+ if(type==='warga_miskin') await wargaMiskinService.create(payload);
+ window.showToast('Tersimpan'); setTimeout(()=>location.reload(), 800);
+ } catch(e) { window.showToast('Gagal simpan', 'error'); }
+ }, () => appMap.removeLayer(tempLayer));
+};
diff --git a/final/frontend/js/modules/draw.js b/final/frontend/js/modules/draw.js
new file mode 100644
index 0000000..40628f4
--- /dev/null
+++ b/final/frontend/js/modules/draw.js
@@ -0,0 +1,31 @@
+export const setupDrawControls = (map, onGeometryCreated) => {
+ const drawControl = new L.Control.Draw({
+ draw: { marker: true, polyline: true, polygon: true, circle: false, circlemarker: false, rectangle: false }
+ });
+ map.addControl(drawControl);
+
+ map.on(L.Draw.Event.CREATED, function (event) {
+ const layer = event.layer;
+ const type = event.layerType;
+
+ // Peta draw default tipe: marker -> spbu, polyline -> jalan, polygon -> kavling
+ let customType = type;
+ if(type === 'marker') customType = 'spbu';
+ if(type === 'polyline') customType = 'jalan';
+ if(type === 'polygon') customType = 'kavling';
+ if(window.currentDrawType) customType = window.currentDrawType; // Untuk P2/P3
+
+ onGeometryCreated(customType, layer.toGeoJSON().geometry, layer);
+ });
+
+ // Custom Menu Handlers
+ const markerDrawer = new L.Draw.Marker(map, drawControl.options.draw.marker);
+ const polylineDrawer = new L.Draw.Polyline(map, drawControl.options.draw.polyline);
+ const polygonDrawer = new L.Draw.Polygon(map, drawControl.options.draw.polygon);
+
+ document.getElementById('btn-draw-spbu')?.addEventListener('click', () => { window.currentDrawType='spbu'; markerDrawer.enable(); });
+ document.getElementById('btn-draw-jalan')?.addEventListener('click', () => { window.currentDrawType='jalan'; polylineDrawer.enable(); });
+ document.getElementById('btn-draw-kavling')?.addEventListener('click', () => { window.currentDrawType='kavling'; polygonDrawer.enable(); });
+
+ return drawControl;
+};
diff --git a/final/frontend/js/modules/form.js b/final/frontend/js/modules/form.js
new file mode 100644
index 0000000..3959e52
--- /dev/null
+++ b/final/frontend/js/modules/form.js
@@ -0,0 +1,50 @@
+export const renderForm = (type, geometry, onSubmit, onCancel) => {
+ const container = document.getElementById('form-container');
+ container.style.display = 'flex';
+
+ const extraFields = type === 'spbu' ? `
+
+
+
+ Buka 24 Jam
+
+
+ ` : '';
+
+ container.innerHTML = `
+
+ `;
+
+ document.getElementById('btn-save').addEventListener('click', () => {
+ const payload = {
+ type,
+ geometry,
+ nama: document.getElementById('input-nama').value,
+ deskripsi: document.getElementById('input-deskripsi').value,
+ };
+ if (type === 'spbu') payload.buka_24_jam = document.getElementById('input-24jam').checked;
+
+ onSubmit(payload);
+ container.style.display = 'none';
+ container.innerHTML = '';
+ });
+
+ document.getElementById('btn-cancel').addEventListener('click', () => {
+ if(onCancel) onCancel();
+ container.style.display = 'none';
+ container.innerHTML = '';
+ });
+};
diff --git a/final/frontend/js/modules/map.js b/final/frontend/js/modules/map.js
new file mode 100644
index 0000000..fdecef9
--- /dev/null
+++ b/final/frontend/js/modules/map.js
@@ -0,0 +1,25 @@
+/**
+ * map.js
+ * Tanggung Jawab: Inisialisasi peta Leaflet dasar dan base layer.
+ */
+
+export const initMap = (containerId) => {
+ // Pusat peta default (Universitas Tanjungpura, Pontianak)
+ const map = L.map(containerId, {
+ zoomControl: false
+ }).setView([-0.0583, 109.3448], 15);
+
+ // Pindahkan zoom control ke kanan bawah
+ L.control.zoom({
+ position: 'bottomright'
+ }).addTo(map);
+
+ // Tile Layer Premium (CartoDB Dark Matter untuk Glass UI)
+ L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
+ attribution: '© OpenStreetMap contributors © CARTO ',
+ subdomains: 'abcd',
+ maxZoom: 20
+ }).addTo(map);
+
+ return map;
+};
diff --git a/final/frontend/js/services/api.service.js b/final/frontend/js/services/api.service.js
new file mode 100644
index 0000000..36e7e5f
--- /dev/null
+++ b/final/frontend/js/services/api.service.js
@@ -0,0 +1,41 @@
+export const BASE_URL = '';
+
+const createService = (endpoint) => ({
+ getAll: async () => {
+ const res = await fetch(`${BASE_URL}/${endpoint}`);
+ const json = await res.json();
+ return json.data;
+ },
+ create: async (data) => {
+ const res = await fetch(`${BASE_URL}/${endpoint}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data)
+ });
+ const json = await res.json();
+ return json.data;
+ },
+ delete: async (id) => {
+ const res = await fetch(`${BASE_URL}/${endpoint}?id=${id}`, {
+ method: 'DELETE'
+ });
+ const json = await res.json();
+ return json.data;
+ }
+});
+
+// P01
+export const spbuService = createService('01/backend/api/spbu.php');
+export const jalanService = createService('01/backend/api/jalan.php');
+export const kavlingService = createService('01/backend/api/kavling.php');
+
+// P02
+export const rumahIbadahService = createService('02/backend/api/rumah_ibadah.php');
+export const wargaMiskinService = createService('02/backend/api/warga_miskin.php');
+export const haversineService = {
+ getDalamRadius: async (id, radius) => {
+ const res = await fetch(`${BASE_URL}/02/backend/api/haversine.php?rumah_ibadah_id=${id}&radius_km=${radius}`);
+ const json = await res.json();
+ return json.data;
+ }
+};
diff --git a/final/frontend/js/services/rumah_ibadah.service.js b/final/frontend/js/services/rumah_ibadah.service.js
new file mode 100644
index 0000000..dc20231
--- /dev/null
+++ b/final/frontend/js/services/rumah_ibadah.service.js
@@ -0,0 +1,22 @@
+import { CONFIG } from '../config.js';
+
+export const rumahIbadahService = {
+ getAll: async () => {
+ const res = await fetch(`${CONFIG.BASE_URL}/rumah_ibadah.php`);
+ return await res.json();
+ },
+ save: async (data) => {
+ const res = await fetch(`${CONFIG.BASE_URL}/rumah_ibadah.php`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data)
+ });
+ return await res.json();
+ },
+ delete: async (id) => {
+ const res = await fetch(`${CONFIG.BASE_URL}/rumah_ibadah.php?id=${id}`, { method: 'DELETE' });
+ return await res.json();
+ },
+ getJangkauan: async (id, radius = 1) => {
+ const res = await fetch(`${CONFIG.BASE_URL}/rumah_ibadah.php?action=jangkauan&id=${id}&radius=${radius}`);
+ return await res.json();
+ }
+};
diff --git a/final/frontend/js/services/statistik.service.js b/final/frontend/js/services/statistik.service.js
new file mode 100644
index 0000000..c41a736
--- /dev/null
+++ b/final/frontend/js/services/statistik.service.js
@@ -0,0 +1,10 @@
+export const CONFIG = {
+ BASE_URL: '/final/backend/api'
+};
+
+export const statistikService = {
+ getKepadatan: async () => {
+ const res = await fetch(`${CONFIG.BASE_URL}/statistik.php`);
+ return await res.json();
+ }
+};
diff --git a/final/frontend/js/services/warga_miskin.service.js b/final/frontend/js/services/warga_miskin.service.js
new file mode 100644
index 0000000..d9cee43
--- /dev/null
+++ b/final/frontend/js/services/warga_miskin.service.js
@@ -0,0 +1,18 @@
+import { CONFIG } from '../config.js';
+
+export const wargaMiskinService = {
+ getAll: async () => {
+ const res = await fetch(`${CONFIG.BASE_URL}/warga_miskin.php`);
+ return await res.json();
+ },
+ save: async (data) => {
+ const res = await fetch(`${CONFIG.BASE_URL}/warga_miskin.php`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data)
+ });
+ return await res.json();
+ },
+ delete: async (id) => {
+ const res = await fetch(`${CONFIG.BASE_URL}/warga_miskin.php?id=${id}`, { method: 'DELETE' });
+ return await res.json();
+ }
+};
diff --git a/final/frontend/map.css b/final/frontend/map.css
new file mode 100644
index 0000000..0656663
--- /dev/null
+++ b/final/frontend/map.css
@@ -0,0 +1,68 @@
+/* map.css
+ * Tanggung Jawab: Styling container peta, kontrol Leaflet, dan pop-up modern.
+ */
+
+.map-container {
+ flex: 1;
+ position: relative;
+ background: #E2E8F0;
+}
+
+#map {
+ width: 100%;
+ height: 100%;
+ z-index: 1;
+}
+
+/* Customizing Leaflet Controls */
+.leaflet-control-zoom {
+ border: none !important;
+ box-shadow: var(--shadow-md) !important;
+ border-radius: var(--radius-md) !important;
+ overflow: hidden;
+}
+
+.leaflet-control-zoom a {
+ color: var(--text-main) !important;
+ background: var(--surface-glass) !important;
+ backdrop-filter: blur(8px);
+ transition: var(--transition);
+}
+
+.leaflet-control-zoom a:hover {
+ background: var(--surface) !important;
+ color: var(--primary) !important;
+}
+
+/* Custom Popup Modern */
+.leaflet-popup-content-wrapper {
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-lg);
+ padding: 4px;
+}
+
+.leaflet-popup-content {
+ margin: 14px;
+ font-family: 'Inter', sans-serif;
+}
+
+.popup-title {
+ font-size: 1.1rem;
+ font-weight: 600;
+ color: var(--text-main);
+ margin-bottom: 6px;
+ border-bottom: 2px solid var(--primary);
+ padding-bottom: 4px;
+ display: inline-block;
+}
+
+.popup-desc {
+ font-size: 0.9rem;
+ color: var(--text-muted);
+ margin-bottom: 12px;
+}
+
+.popup-action {
+ display: flex;
+ justify-content: flex-end;
+}
diff --git a/final/frontend/sidebar.css b/final/frontend/sidebar.css
new file mode 100644
index 0000000..5ce4b23
--- /dev/null
+++ b/final/frontend/sidebar.css
@@ -0,0 +1,78 @@
+/* sidebar.css
+ * Tanggung Jawab: Styling layout sidebar, navigasi, dan list item.
+ */
+
+.sidebar {
+ width: 350px;
+ background: var(--surface-glass);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ border-right: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ z-index: 1000;
+ box-shadow: var(--shadow-lg);
+ transition: var(--transition);
+}
+
+.sidebar-header {
+ padding: 24px;
+ border-bottom: 1px solid var(--border);
+}
+
+.sidebar-title {
+ font-size: 1.25rem;
+ font-weight: 700;
+ background: linear-gradient(135deg, var(--primary), var(--secondary));
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ letter-spacing: -0.5px;
+}
+
+.sidebar-subtitle {
+ font-size: 0.85rem;
+ color: var(--text-muted);
+ margin-top: 4px;
+}
+
+.sidebar-content {
+ flex: 1;
+ overflow-y: auto;
+ padding: 24px;
+}
+
+/* Toast Notification */
+.toast-container {
+ position: fixed;
+ bottom: 24px;
+ right: 24px;
+ z-index: 9999;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.toast {
+ background: var(--surface);
+ color: var(--text-main);
+ padding: 12px 20px;
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-md);
+ font-size: 0.9rem;
+ font-weight: 500;
+ transform: translateY(20px);
+ opacity: 0;
+ animation: slideIn 0.3s forwards ease-out;
+ border-left: 4px solid var(--primary);
+}
+
+.toast.error {
+ border-left-color: var(--danger);
+}
+
+@keyframes slideIn {
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
+}
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..1ba1c19
--- /dev/null
+++ b/index.html
@@ -0,0 +1,320 @@
+
+
+
+
+
+ Portal WebGIS - Sistem Informasi Geografis
+
+
+
+
+
+
+
+
+
+ WebGIS Portal
+
+
+
+
+
+
+
+
+
diff --git a/project_final/admin/data_jalan.php b/project_final/admin/data_jalan.php
new file mode 100644
index 0000000..ae32088
--- /dev/null
+++ b/project_final/admin/data_jalan.php
@@ -0,0 +1,79 @@
+query("SELECT id,nama,jenis_jalan,created_at FROM jalan ORDER BY id DESC")->fetchAll();
+$pageTitle='Data Jalan'; $activeNav='jalan';
+require_once __DIR__ . '/partials/header.php';
+?>
+
+
+
+
+ # Nama Jalan Jenis Ditambahkan Aksi
+
+ $r): ?>
+
+ =$i+1?>
+ =htmlspecialchars($r['nama'])?>
+ =htmlspecialchars($r['jenis_jalan']??'-')?>
+ =date('d M Y',strtotime($r['created_at']))?>
+
+
+
+
+
+
+ Belum ada data jalan.
+
+
+
+
+
+
+
+
+
Nama Jalan *
+
Jenis Jalan
+
+ Jalan Arteri Jalan Kolektor
+ Jalan Lokal Jalan Lingkungan Jalan Tol
+
+
+
+
+
+
+const API=APP_BASE + '/api/jalan.php';
+function editRow(r){ document.getElementById('editId').value=r.id; document.getElementById('editNama').value=r.nama; document.getElementById('editJenis').value=r.jenis_jalan||'Jalan Lokal'; openModal('modalEdit'); }
+async function simpan(){ const f=document.getElementById('formTambah'); const res=await fetch(API,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({nama:f.nama.value,jenis_jalan:f.jenis_jalan.value,geometry:{type:'LineString',coordinates:[]}})}); const d=await res.json(); if(d.status==='success'){showToast('Jalan disimpan!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); }
+async function update(){ const id=document.getElementById('editId').value; const res=await fetch(API+'?id='+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({nama:document.getElementById('editNama').value,jenis_jalan:document.getElementById('editJenis').value})}); const d=await res.json(); if(d.status==='success'){showToast('Jalan diperbarui!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); }
+async function hapus(id,nama){ if(!confirm(`Hapus jalan "${nama}"?`))return; const res=await fetch(API+'?id='+id,{method:'DELETE'}); const d=await res.json(); if(d.status==='success'){showToast('Jalan dihapus!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); }
+
+JS; ?>
+
diff --git a/project_final/admin/data_kavling.php b/project_final/admin/data_kavling.php
new file mode 100644
index 0000000..0de57bd
--- /dev/null
+++ b/project_final/admin/data_kavling.php
@@ -0,0 +1,82 @@
+query("SELECT id,nama_pemilik,status_kepemilikan,luas,created_at FROM kavling ORDER BY id DESC")->fetchAll();
+$pageTitle='Data Kavling'; $activeNav='kavling';
+require_once __DIR__ . '/partials/header.php';
+$statusColors = ['SHM'=>'badge-success','HGB'=>'badge-info','HGU'=>'badge-warning','HP'=>'badge-primary'];
+$statusFull = ['SHM'=>'Sertifikat Hak Milik','HGB'=>'Hak Guna Bangunan','HGU'=>'Hak Guna Usaha','HP'=>'Hak Pakai'];
+?>
+
+
+
+
+ # Nama Pemilik Status Kepemilikan Luas (m²) Ditambahkan Aksi
+
+ $r): $sk=$r['status_kepemilikan']; ?>
+
+ =$i+1?>
+ =htmlspecialchars($r['nama_pemilik'])?>
+ =$sk?>
+ =$r['luas'] ? number_format($r['luas'],0,',','.') : '-'?>
+ =date('d M Y',strtotime($r['created_at']))?>
+
+
+
+
+
+
+ Belum ada data kavling.
+
+
+
+
+
+
+
+
+
Nama Pemilik *
+
Status Kepemilikan
+
+ SHM HGB HGU HP
+
+
+
Luas (m²)
+
+
+
+
+const API=APP_BASE + '/api/kavling.php';
+function editRow(r){ document.getElementById('editId').value=r.id; document.getElementById('editNama').value=r.nama_pemilik; document.getElementById('editStatus').value=r.status_kepemilikan; document.getElementById('editLuas').value=r.luas||0; openModal('modalEdit'); }
+async function simpan(){ const f=document.getElementById('formTambah'); if(!f.nama_pemilik.value){showToast('Nama pemilik wajib diisi!','error');return;} const res=await fetch(API,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({nama_pemilik:f.nama_pemilik.value,status_kepemilikan:f.status_kepemilikan.value,luas:parseFloat(f.luas.value)||0,geometry:{type:'Polygon',coordinates:[[]]}})}); const d=await res.json(); if(d.status==='success'){showToast('Kavling disimpan!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); }
+async function update(){ const id=document.getElementById('editId').value; const res=await fetch(API+'?id='+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({nama_pemilik:document.getElementById('editNama').value,status_kepemilikan:document.getElementById('editStatus').value,luas:parseFloat(document.getElementById('editLuas').value)||0})}); const d=await res.json(); if(d.status==='success'){showToast('Kavling diperbarui!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); }
+async function hapus(id,nama){ if(!confirm(`Hapus kavling milik "${nama}"?`))return; const res=await fetch(API+'?id='+id,{method:'DELETE'}); const d=await res.json(); if(d.status==='success'){showToast('Kavling dihapus!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); }
+
+JS; ?>
+
diff --git a/project_final/admin/data_kawasan.php b/project_final/admin/data_kawasan.php
new file mode 100644
index 0000000..c044ed9
--- /dev/null
+++ b/project_final/admin/data_kawasan.php
@@ -0,0 +1,57 @@
+query("SELECT k.id, k.nama_kawasan, k.created_at, COUNT(w.id) as jumlah_warga
+ FROM kawasan_kumuh k LEFT JOIN warga_miskin w ON ST_Contains(k.geom, w.geom)
+ GROUP BY k.id ORDER BY k.id DESC")->fetchAll();
+$pageTitle='Kawasan Kumuh'; $activeNav='kawasan';
+require_once __DIR__ . '/partials/header.php';
+?>
+
+
+
+
+ # Nama Kawasan Warga Terdampak Status Ditambahkan Aksi
+
+ $r): $cnt=(int)$r['jumlah_warga']; $status=$cnt>3?'Rawan Kumuh':($cnt>0?'Perlu Perhatian':'Aman'); $badge=$cnt>3?'badge-danger':($cnt>0?'badge-warning':'badge-success'); ?>
+
+ =$i+1?>
+ =htmlspecialchars($r['nama_kawasan'])?>
+ =$cnt?>
+ =$status?>
+ =date('d M Y',strtotime($r['created_at']))?>
+
+
+
+
+
+
+ Belum ada data kawasan. Gambar polygon di peta untuk menambahkan.
+
+
+
+
+
+
+const API=APP_BASE + '/api/kawasan_kumuh.php';
+function editRow(r){ document.getElementById('editId').value=r.id; document.getElementById('editNama').value=r.nama_kawasan; openModal('modalEdit'); }
+async function update(){ const id=document.getElementById('editId').value; const res=await fetch(API+'?id='+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({nama_kawasan:document.getElementById('editNama').value})}); const d=await res.json(); if(d.status==='success'){showToast('Kawasan diperbarui!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); }
+async function hapus(id,nama){ if(!confirm(`Hapus kawasan "${nama}"?`))return; const res=await fetch(API+'?id='+id,{method:'DELETE'}); const d=await res.json(); if(d.status==='success'){showToast('Kawasan dihapus!');setTimeout(()=>location.reload(),900);}else showToast(d.message,'error'); }
+
+JS; ?>
+
diff --git a/project_final/admin/data_laporan.php b/project_final/admin/data_laporan.php
new file mode 100644
index 0000000..47582fc
--- /dev/null
+++ b/project_final/admin/data_laporan.php
@@ -0,0 +1,191 @@
+query($sql)->fetchAll();
+
+$pageTitle = 'Laporan Warga';
+$activeNav = 'laporan';
+$extraHead = ' ';
+require_once __DIR__ . '/partials/header.php';
+
+function badgeStatus($status) {
+ if($status === 'menunggu') return 'badge-warning';
+ if($status === 'diproses') return 'badge-info';
+ if($status === 'selesai') return 'badge-success';
+ if($status === 'ditolak') return 'badge-danger';
+ return 'badge-primary';
+}
+?>
+
+
+
+
+
+
+
+
+
+ #
+ Pelapor
+ Kategori
+ Deskripsi
+ Status
+ Tanggal
+ Aksi
+
+
+
+ $r): ?>
+
+ = $i+1 ?>
+ = htmlspecialchars($r['nama_lengkap'] ?: $r['username']) ?>
+ = htmlspecialchars($r['kategori']) ?>
+
+ = htmlspecialchars($r['deskripsi']) ?>
+
+ = ucfirst($r['status']) ?>
+ = date('d M Y', strtotime($r['created_at'])) ?>
+
+
+
+
+
+
+
+
+
+
+ Belum ada laporan dari warga.
+
+
+
+
+
+
+
+
+
+
+
+ Ubah Status
+
+ Menunggu
+ Diproses
+ Selesai
+ Ditolak
+
+
+
+
+
+
+
+
+
+
+
+JS; ?>
+
diff --git a/project_final/admin/data_rumah.php b/project_final/admin/data_rumah.php
new file mode 100644
index 0000000..ec8dcc0
--- /dev/null
+++ b/project_final/admin/data_rumah.php
@@ -0,0 +1,140 @@
+query("SELECT id,nama,agama,radius_bantuan_meter,created_at FROM rumah_ibadah ORDER BY id DESC")->fetchAll();
+$pageTitle='Rumah Ibadah'; $activeNav='rumah';
+$extraHead = ' ';
+require_once __DIR__ . '/partials/header.php';
+$agamaIcon = ['Islam'=>'🕌','Kristen'=>'⛪','Katolik'=>'⛪','Hindu'=>'🛕','Buddha'=>'☸️','Konghucu'=>'🏯'];
+$agamaBadge = ['Islam'=>'badge-success','Kristen'=>'badge-info','Katolik'=>'badge-primary','Hindu'=>'badge-warning','Buddha'=>'badge-danger','Konghucu'=>'badge-warning'];
+
+$grouped = [];
+foreach($agamaIcon as $ag => $ic) { $grouped[$ag] = []; }
+foreach($rows as $r) {
+ if(!isset($grouped[$r['agama']])) $grouped[$r['agama']] = [];
+ $grouped[$r['agama']][] = $r;
+}
+?>
+
+
+ $list): ?>
+
+ = $agamaIcon[$agama] ?? '🏛' ?> = $agama ?> = count($list) ?>
+
+
+
+ # Nama Radius Bantuan Ditambahkan Aksi
+
+ $r): ?>
+
+ =$i+1?>
+ =htmlspecialchars($r['nama'])?>
+ = number_format((int)$r['radius_bantuan_meter'], 0, ',', '.') ?> m
+ =date('d M Y',strtotime($r['created_at']))?>
+
+
+
+
+
+
+ Belum ada data =$agama?>.
+
+
+
+
+
+
+
+
+
+
Nama *
+
Agama
+
+ 🕌 Islam ⛪ Kristen
+ ⛪ Katolik 🛕 Hindu
+ ☸️ Buddha 🏯 Konghucu
+
+
+
Radius Bantuan (meter) *
+
+
+
+
+
+JS; ?>
+
diff --git a/project_final/admin/data_spbu.php b/project_final/admin/data_spbu.php
new file mode 100644
index 0000000..a9cb598
--- /dev/null
+++ b/project_final/admin/data_spbu.php
@@ -0,0 +1,164 @@
+query("SELECT id, nama, deskripsi, buka_24_jam, created_at FROM spbu ORDER BY id DESC")->fetchAll();
+$pageTitle = 'Data SPBU'; $activeNav = 'spbu';
+$extraHead = ' ';
+require_once __DIR__ . '/partials/header.php';
+?>
+
+
+
+
+
+
+
+ Tambah SPBU
+
+
+
+
+
+ # Nama SPBU Deskripsi Status Ditambahkan Aksi
+
+
+ $r): ?>
+
+ = $i+1 ?>
+ = htmlspecialchars($r['nama']) ?>
+ = htmlspecialchars($r['deskripsi'] ?: '-') ?>
+ = $r['buka_24_jam'] ? '✅ 24 Jam' : '🕐 Terbatas' ?>
+ = date('d M Y, H:i', strtotime($r['created_at'])) ?>
+
+
+
+
+
+
+ Belum ada data SPBU. Tambah sekarang
+
+
+
+
+
+
+
+
+
+
+
+
+JS; ?>
+
diff --git a/project_final/admin/data_warga.php b/project_final/admin/data_warga.php
new file mode 100644
index 0000000..4877948
--- /dev/null
+++ b/project_final/admin/data_warga.php
@@ -0,0 +1,101 @@
+query("SELECT id,nama_kk,penghasilan,jumlah_tanggungan,created_at FROM warga_miskin ORDER BY id DESC")->fetchAll();
+$pageTitle='Warga Miskin'; $activeNav='warga';
+$extraHead = ' ';
+require_once __DIR__ . '/partials/header.php';
+?>
+
+
+
+
+ # Nama KK Penghasilan/Bln Tanggungan Ditambahkan Aksi
+
+ $r): ?>
+
+ =$i+1?>
+ =htmlspecialchars($r['nama_kk'])?>
+ Rp =number_format($r['penghasilan'],0,',','.')?>
+ =$r['jumlah_tanggungan']?> orang
+ =date('d M Y',strtotime($r['created_at']))?>
+
+
+
+
+
+
+ Belum ada data.
+
+
+
+
+
+
+
+
+
+JS; ?>
+
diff --git a/project_final/admin/index.php b/project_final/admin/index.php
new file mode 100644
index 0000000..6f6dc51
--- /dev/null
+++ b/project_final/admin/index.php
@@ -0,0 +1,172 @@
+query("SELECT
+ (SELECT COUNT(*) FROM spbu) as spbu,
+ (SELECT COUNT(*) FROM jalan) as jalan,
+ (SELECT COUNT(*) FROM kavling) as kavling,
+ (SELECT COUNT(*) FROM rumah_ibadah) as rumah_ibadah,
+ (SELECT COUNT(*) FROM warga_miskin) as warga_miskin,
+ (SELECT COUNT(*) FROM kawasan_kumuh) as kawasan_kumuh,
+ (SELECT COUNT(*) FROM spbu WHERE buka_24_jam=1) as spbu_24jam,
+ (SELECT COUNT(*) FROM users) as total_users
+")->fetch();
+
+// Kawasan merah
+$merahResult = $pdo->query("SELECT COUNT(*) as cnt FROM (
+ SELECT k.id FROM kawasan_kumuh k
+ LEFT JOIN warga_miskin w ON ST_Contains(k.geom, w.geom)
+ GROUP BY k.id HAVING COUNT(w.id) > 3
+) sub")->fetch();
+$kawasanMerah = (int)($merahResult['cnt'] ?? 0);
+
+// Warga miskin di luar semua radius bantuan rumah ibadah
+$blankCount = $pdo->query("
+ SELECT COUNT(*) as cnt
+ FROM warga_miskin w
+ WHERE NOT EXISTS (
+ SELECT 1
+ FROM rumah_ibadah ri
+ WHERE ST_Distance_Sphere(w.geom, ri.geom) <= ri.radius_bantuan_meter
+ )
+")->fetch();
+$blankSpot = (int)($blankCount['cnt'] ?? 0);
+
+// Recent data
+$recentSpbu = $pdo->query("SELECT nama, buka_24_jam, created_at FROM spbu ORDER BY created_at DESC LIMIT 5")->fetchAll();
+$recentWarga = $pdo->query("SELECT nama_kk, penghasilan, jumlah_tanggungan, created_at FROM warga_miskin ORDER BY created_at DESC LIMIT 5")->fetchAll();
+
+$pageTitle = 'Dashboard';
+$activeNav = 'dashboard';
+require_once __DIR__ . '/partials/header.php';
+?>
+
+
+
+
+
+
🗺️ Buka Peta Interaktif
+
+ Tambahkan, edit, dan analisis data spasial langsung di peta. Mendukung drawing tool untuk semua layer data.
+
+
+
+
+
+
+
+
+
+
+
+
Total SPBU
+
= $stats['spbu'] ?>
+
= $stats['spbu_24jam'] ?> buka 24 jam
+
+
+
+
+
+
Ruas Jalan
+
= $stats['jalan'] ?>
+
Segmen terpetakan
+
+
+
+
+
+
Rumah Ibadah
+
= $stats['rumah_ibadah'] ?>
+
5 agama tercakup
+
+
+
+
+
+
Warga Miskin
+
= $stats['warga_miskin'] ?>
+
= $blankSpot ?> blank spot
+
+
+
+
+
+
Kawasan Rawan
+
= $kawasanMerah ?>
+
dari = $stats['kawasan_kumuh'] ?> kawasan
+
+
+
+
+
+
Kavling / Parsil
+
= $stats['kavling'] ?>
+
Bidang tanah tercatat
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
= htmlspecialchars($s['nama']) ?>
+
= date('d M Y', strtotime($s['created_at'])) ?>
+
+
+ = $s['buka_24_jam'] ? '24 Jam' : 'Terbatas' ?>
+
+
+
+
+
+
Belum ada data SPBU
+
+
+
+
+
+
+
+
+
+
+
+
= htmlspecialchars($w['nama_kk']) ?>
+
Rp = number_format($w['penghasilan'],0,',','.') ?>/bln · = $w['jumlah_tanggungan'] ?> tanggungan
+
+
= $w['jumlah_tanggungan'] ?> org
+
+
+
+
+
Belum ada data warga
+
+
+
+
+
+
+
diff --git a/project_final/admin/partials/footer.php b/project_final/admin/partials/footer.php
new file mode 100644
index 0000000..a2474e0
--- /dev/null
+++ b/project_final/admin/partials/footer.php
@@ -0,0 +1,119 @@
+
+
+
+
+
+
+
+= $extraScript ?? '' ?>
+