From 522c4f7200907334749f16f0fc023941b9e53e23 Mon Sep 17 00:00:00 2001 From: "Mr.Haruna" Date: Sat, 13 Jun 2026 11:24:58 +0700 Subject: [PATCH] First commit / commit pertama --- .claude/settings.local.json | 10 + .dockerignore | 22 + .gitignore | 55 ++ 01/backend/api/jalan.php | 75 ++ 01/backend/api/kavling.php | 75 ++ 01/backend/api/spbu.php | 59 ++ 01/backend/config/db.php | 57 ++ 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 | 57 ++ 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 | 57 ++ 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 ++ CHANGELOG_SKPL.md | 96 +++ Dockerfile | 16 + README.md | 88 ++ RENCANA_LANJUTAN.md | 93 +++ database/create_tables.sql | 116 +++ database/migrations/001_fase_a_roles.sql | 51 ++ database/migrations/002_wilayah.sql | 29 + database/migrations/003_fase_c_data.sql | 82 ++ database/seed_pontianak.sql | 172 ++++ database/seed_users.sql | 26 + database/webgis_db.sql | 210 +++++ docker-compose.yml | 46 ++ 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 ++ import_db.php | 24 + index.html | 320 ++++++++ project_final/admin/analisis.php | 11 + project_final/admin/data_laporan.php | 191 +++++ project_final/admin/data_warga.php | 324 ++++++++ project_final/admin/index.php | 171 ++++ project_final/admin/log.php | 59 ++ project_final/admin/partials/footer.php | 54 ++ project_final/admin/partials/header.php | 106 +++ project_final/admin/pelaporan.php | 10 + project_final/admin/peta.php | 31 + project_final/admin/users.php | 235 ++++++ project_final/admin/wilayah.php | 160 ++++ project_final/api/bantuan.php | 63 ++ project_final/api/cakupan_bantuan.php | 53 ++ project_final/api/fasilitas.php | 21 + project_final/api/filter.php | 65 ++ project_final/api/foto.php | 110 +++ project_final/api/heatmap.php | 31 + project_final/api/helpers.php | 67 ++ project_final/api/komparatif.php | 40 + project_final/api/laporan.php | 104 +++ project_final/api/laporan_bantuan.php | 67 ++ project_final/api/laporan_wilayah.php | 92 +++ project_final/api/proximity.php | 51 ++ project_final/api/tren.php | 45 ++ project_final/api/users.php | 94 +++ project_final/api/warga_export.php | 93 +++ project_final/api/warga_import.php | 83 ++ project_final/api/warga_miskin.php | 160 ++++ project_final/api/wilayah.php | 89 ++ project_final/api/zona_prioritas.php | 54 ++ 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/public-map.js | 141 ++++ project_final/assets/js/skpl-layers.js | 127 +++ project_final/assets/js/skpl-map.js | 75 ++ project_final/config/activity.php | 38 + project_final/config/app.php | 46 ++ project_final/config/auth_check.php | 95 +++ project_final/config/db.php | 57 ++ project_final/config/session.php | 24 + project_final/enumerator/data.php | 161 ++++ project_final/enumerator/index.php | 91 +++ project_final/enumerator/input.php | 190 +++++ project_final/import.php | 28 + project_final/index.php | 10 + project_final/login.php | 151 ++++ project_final/logout.php | 11 + project_final/partials/analisis_body.php | 209 +++++ project_final/partials/laporan_body.php | 64 ++ project_final/peta_publik.php | 63 ++ project_final/pimpinan/analisis.php | 11 + project_final/pimpinan/index.php | 108 +++ project_final/pimpinan/laporan.php | 191 +++++ project_final/pimpinan/partials/footer.php | 54 ++ project_final/pimpinan/partials/header.php | 93 +++ project_final/pimpinan/pelaporan.php | 10 + project_final/pimpinan/peta.php | 27 + project_final/uploads/.gitkeep | 0 166 files changed, 13326 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .dockerignore 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 CHANGELOG_SKPL.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 RENCANA_LANJUTAN.md create mode 100644 database/create_tables.sql create mode 100644 database/migrations/001_fase_a_roles.sql create mode 100644 database/migrations/002_wilayah.sql create mode 100644 database/migrations/003_fase_c_data.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 docker-compose.yml 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 import_db.php create mode 100644 index.html create mode 100644 project_final/admin/analisis.php create mode 100644 project_final/admin/data_laporan.php create mode 100644 project_final/admin/data_warga.php create mode 100644 project_final/admin/index.php create mode 100644 project_final/admin/log.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/pelaporan.php create mode 100644 project_final/admin/peta.php create mode 100644 project_final/admin/users.php create mode 100644 project_final/admin/wilayah.php create mode 100644 project_final/api/bantuan.php create mode 100644 project_final/api/cakupan_bantuan.php create mode 100644 project_final/api/fasilitas.php create mode 100644 project_final/api/filter.php create mode 100644 project_final/api/foto.php create mode 100644 project_final/api/heatmap.php create mode 100644 project_final/api/helpers.php create mode 100644 project_final/api/komparatif.php create mode 100644 project_final/api/laporan.php create mode 100644 project_final/api/laporan_bantuan.php create mode 100644 project_final/api/laporan_wilayah.php create mode 100644 project_final/api/proximity.php create mode 100644 project_final/api/tren.php create mode 100644 project_final/api/users.php create mode 100644 project_final/api/warga_export.php create mode 100644 project_final/api/warga_import.php create mode 100644 project_final/api/warga_miskin.php create mode 100644 project_final/api/wilayah.php create mode 100644 project_final/api/zona_prioritas.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/public-map.js create mode 100644 project_final/assets/js/skpl-layers.js create mode 100644 project_final/assets/js/skpl-map.js create mode 100644 project_final/config/activity.php 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/enumerator/data.php create mode 100644 project_final/enumerator/index.php create mode 100644 project_final/enumerator/input.php create mode 100644 project_final/import.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/partials/analisis_body.php create mode 100644 project_final/partials/laporan_body.php create mode 100644 project_final/peta_publik.php create mode 100644 project_final/pimpinan/analisis.php create mode 100644 project_final/pimpinan/index.php create mode 100644 project_final/pimpinan/laporan.php create mode 100644 project_final/pimpinan/partials/footer.php create mode 100644 project_final/pimpinan/partials/header.php create mode 100644 project_final/pimpinan/pelaporan.php create mode 100644 project_final/pimpinan/peta.php create mode 100644 project_final/uploads/.gitkeep diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..864b2f5 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" \"http://localhost/Final_Project/project_final/login.php\")", + "Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" \"http://localhost/Final_Project/project_final/peta_publik.php\")", + "Bash(curl -s -o /dev/null -w \"%{http_code} -> %{redirect_url}\\\\n\" \"http://localhost/Final_Project/project_final/admin/index.php\")", + "Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" \"http://localhost/Final_Project/project_final/enumerator/index.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/.gitignore b/.gitignore new file mode 100644 index 0000000..cf2d2a2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,55 @@ +# 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/ + +akun_testing.txt 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..67677f8 --- /dev/null +++ b/01/backend/config/db.php @@ -0,0 +1,57 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + self::$conn->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + + // Disable strict mode for GROUP BY to prevent 1055 errors in Coolify's MariaDB + self::$conn->exec("SET SESSION sql_mode=(SELECT REPLACE(@@sql_mode,'ONLY_FULL_GROUP_BY',''))"); + + // Auto-initialize database if empty + $stmt = self::$conn->query("SHOW TABLES LIKE 'users'"); + if ($stmt->rowCount() == 0) { + $sqlFile = __DIR__ . '/../../../database/webgis_db.sql'; + if (file_exists($sqlFile)) { + $sql = file_get_contents($sqlFile); + self::$conn->exec($sql); + } + } + } + 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 + + + + + + + + +
+ + +
+
+

P01: Geometri

+

Sistem Informasi Geografis dengan arsitektur modern.

+
+ + + + + + +
+ + +
+ + + + + + + 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' ? ` +
+ +
+ ` : ''; + + container.innerHTML = ` +
+

Simpan Data (${type.toUpperCase()})

+
+ +
+
+ +
+ ${extraFields} +
+ + +
+
+ `; + + 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..67677f8 --- /dev/null +++ b/02/backend/config/db.php @@ -0,0 +1,57 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + self::$conn->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + + // Disable strict mode for GROUP BY to prevent 1055 errors in Coolify's MariaDB + self::$conn->exec("SET SESSION sql_mode=(SELECT REPLACE(@@sql_mode,'ONLY_FULL_GROUP_BY',''))"); + + // Auto-initialize database if empty + $stmt = self::$conn->query("SHOW TABLES LIKE 'users'"); + if ($stmt->rowCount() == 0) { + $sqlFile = __DIR__ . '/../../../database/webgis_db.sql'; + if (file_exists($sqlFile)) { + $sql = file_get_contents($sqlFile); + self::$conn->exec($sql); + } + } + } + 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 + + + + + + + +
+
+
+

P02: Tematik & Jarak

+

Kumulatif P01 + Analisis radius jarak Haversine.

+
+ + + + + +
+

Analisis Jarak

+

Klik [Cek Radius] di popup Rumah Ibadah mana pun.

+
+
+ + +
+
+ + + + + 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 = ``; + + 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' ? ` +
+ +
+ ` : ''; + + container.innerHTML = ` +
+

Simpan Data (${type.toUpperCase()})

+
+ +
+
+ +
+ ${extraFields} +
+ + +
+
+ `; + + 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..67677f8 --- /dev/null +++ b/03/backend/config/db.php @@ -0,0 +1,57 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + self::$conn->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + + // Disable strict mode for GROUP BY to prevent 1055 errors in Coolify's MariaDB + self::$conn->exec("SET SESSION sql_mode=(SELECT REPLACE(@@sql_mode,'ONLY_FULL_GROUP_BY',''))"); + + // Auto-initialize database if empty + $stmt = self::$conn->query("SHOW TABLES LIKE 'users'"); + if ($stmt->rowCount() == 0) { + $sqlFile = __DIR__ . '/../../../database/webgis_db.sql'; + if (file_exists($sqlFile)) { + $sql = file_get_contents($sqlFile); + self::$conn->exec($sql); + } + } + } + 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 + + + + + + + +
+
+
+

P03: Analisis Spasial

+

Kumulatif P01 & P02 + Choropleth Map (Point in Polygon).

+
+ + + +
+

Statistik Kavling (Choropleth)

+

Pewarnaan otomatis area kavling berdasarkan jumlah warga miskin yang bermukim di atasnya.

+
+ Total Penduduk Miskin: + 0 +
+
+ Kavling Padat (>2 warga): + 0 +
+
+ + +
+
+ + + + + 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' ? ` +
+ +
+ ` : ''; + + container.innerHTML = ` +
+

Simpan Data (${type.toUpperCase()})

+
+ +
+
+ +
+ ${extraFields} +
+ + +
+
+ `; + + 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/CHANGELOG_SKPL.md b/CHANGELOG_SKPL.md new file mode 100644 index 0000000..210c749 --- /dev/null +++ b/CHANGELOG_SKPL.md @@ -0,0 +1,96 @@ +# Changelog β€” Transformasi WebGIS sesuai SKPL + +Dokumen ini merangkum perubahan aplikasi dari **"WebGIS Smart City Pontianak"** menjadi **"WebGIS Pemetaan Kemiskinan Terpadu"** sesuai dokumen SKPL. + +Status saat ini: **Fase A (Fondasi)** dan **Fase B (Visualisasi Peta)** selesai & teruji. + +--- + +## 1. Sistem Role & Autentikasi + +| Sebelum | Sesudah | +|---|---| +| 2 role: `admin`, `user` | 4 role SKPL: `publik`, `enumerator`, `operator`, `pimpinan` | +| Login dasar | + Lockout 5Γ— percobaan gagal (KF-01) | +| β€” | Cek akun nonaktif (`is_active`) saat login | +| β€” | Catat `last_login` | +| Routing role hardcode | Routing dinamis per role via `roleHome()` | +| `requireRole('admin')` (exact) | RBAC fleksibel `requireAnyRole([...])` | + +File: `config/auth_check.php`, `login.php`, `logout.php`, `index.php` + +## 2. Struktur Folder & Halaman + +- `user/` β†’ di-rename menjadi `pimpinan/` (dashboard, peta, laporan) +- **Baru:** `enumerator/` β€” area petugas lapangan (mobile-friendly) +- **Baru:** `peta_publik.php` β€” peta tanpa login untuk masyarakat +- **Baru:** `admin/log.php` β€” halaman log aktivitas + +## 3. Database + +| Perubahan | Detail | +|---|---| +| `users` diperluas | + `email`, `is_active`, `wilayah_tugas`, `last_login`; ENUM role jadi 4 | +| Tabel baru `activity_log` | Audit trail aktivitas pengguna (KF-04) | +| Tabel baru `wilayah` | Polygon kelurahan + `jumlah_penduduk` untuk choropleth (KF-06) | + +File migrasi: +- `database/migrations/001_fase_a_roles.sql` +- `database/migrations/002_wilayah.sql` +- `database/webgis_db.sql` (diperbarui untuk install baru) + +## 4. Manajemen User (KF-02 / KF-03) + +`admin/users.php` + `api/users.php` dirombak: +- Kelola 4 role +- Status aktif/nonaktif +- Email & wilayah tugas (enumerator) +- Proteksi akun utama (ID 1) & akun sendiri dari penghapusan + +## 5. Log Aktivitas (KF-04) + +- Helper `logActivity()` di `config/activity.php` +- Dicatat saat: login, logout, dan CRUD data (warga, user) +- Ditampilkan di `admin/log.php` (300 entri terbaru) + +## 6. Visualisasi Peta (Fase B) + +| Fitur SKPL | Implementasi | +|---|---| +| Choropleth (KF-06) | `api/wilayah.php` β€” gradasi warna % kemiskinan + legenda | +| Heatmap (KF-08) | `api/heatmap.php` β€” data agregat tanpa info pribadi | +| Layer switcher (KF-09) | Toggle choropleth & heatmap | +| Pencarian lokasi (KF-11) | Cari kelurahan, peta auto-`flyTo` | + +Dipasang di: peta publik, peta operator, peta pimpinan. +File: `assets/js/public-map.js`, `assets/js/skpl-layers.js` + +## 7. Branding & Keamanan API + +- Branding "Smart City" β†’ "WebGIS Kemiskinan / Pemetaan Kemiskinan" +- Guard mutasi API: role `admin` β†’ `operator` (`requireOperatorForMutation` di `api/helpers.php`) + +## 8. Perbaikan Bug + +- Fungsi MySQL `ST_SRID()` (sintaks PostGIS) yang menyebabkan error diperbaiki di 5 file +- Pemuatan `.env` otomatis + perbaikan path cookie sesi +- Bug `window.APP_BASE` (binding `const` bukan properti window) di `skpl-layers.js` β€” diperbaiki + +--- + +## Akun Testing (DB hasil migrasi) + +| Role | Username | Password | +|---|---|---| +| Operator | `admin` | `admin123` | +| Pimpinan | `pengguna` | `user123` | +| Enumerator | `enumerator` | `user123` | +| Publik | (tanpa login) | β€” | + +--- + +## Belum Dikerjakan (warisan lama / fase berikutnya) + +- Data spasial smart-city (SPBU, jalan, kavling, rumah ibadah, kawasan kumuh) **masih ada** β€” rencana di-repurpose/dibuang pada modul Manajemen Data. +- Form input enumerator (`enumerator/input.php`) masih placeholder "Segera". +- Modul Manajemen Data, Analisis Spasial, Geotagging, dan Pelaporan PDF belum dibuat (lihat rencana di bawah / dokumen perencanaan). diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9f3f94e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +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/ +COPY database/ /var/www/html/database/ + +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..dd9bbfa --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# 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 (4 Role SKPL) + +Sistem memakai 4 peran sesuai SKPL: **Publik, Enumerator, Operator, Pimpinan**. + +Database lama yang sudah dimigrasi (`database/migrations/001_fase_a_roles.sql`): + +- Operator: `admin / admin123` (akun lama `admin`, kini role operator) +- Pimpinan: `pengguna / user123` (akun lama `user`, kini role pimpinan) +- Enumerator: `enumerator / user123` + +Install baru (`database/webgis_db.sql`) memakai username yang lebih jelas: + +- Operator: `operator / admin123` +- Pimpinan: `pimpinan / user123` +- Enumerator: `enumerator / user123` + +Publik tidak perlu login β€” akses langsung `peta_publik.php`. + +Ganti password akun demo setelah deploy jika aplikasi dibuka publik. + +## Migrasi Database + +Untuk DB existing, jalankan berurutan: + +``` +mysql -u root webgis_db < database/migrations/001_fase_a_roles.sql +mysql -u root webgis_db < database/migrations/002_wilayah.sql +``` + +Install baru cukup import `database/webgis_db.sql` (sudah mencakup semuanya). + +## 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/RENCANA_LANJUTAN.md b/RENCANA_LANJUTAN.md new file mode 100644 index 0000000..f95a738 --- /dev/null +++ b/RENCANA_LANJUTAN.md @@ -0,0 +1,93 @@ +# Rencana Tahap Lanjutan β€” WebGIS Pemetaan Kemiskinan SKPL + +Lanjutan setelah Fase A (Fondasi) & Fase B (Visualisasi Peta) selesai. +Prinsip tetap: **fokus penuh ke SKPL** + **perluas bertahap**. + +Modul SKPL yang tersisa: 3.3 Manajemen Data, 3.4 Analisis Spasial, +3.5 Geotagging & Survei, 3.6 Dashboard & Pelaporan. + +Urutan disusun berdasarkan ketergantungan: **C β†’ D β†’ E β†’ F** +(data kaya dulu, baru analisis & laporan). + +--- + +## FASE C β€” Manajemen Data Kemiskinan (KF-12 s/d 18) + +Fondasi data untuk semua modul berikutnya. Memperkaya `warga_miskin`. + +| ID | Pekerjaan | KF | +|---|---|---| +| C1 | Perluas kolom `warga_miskin`: `nik_kk`, `alamat`, `wilayah_id`, `rt`, `rw`, `jumlah_jiwa`, `pekerjaan_kk`, `sumber_air`, `status_listrik`, `material_dinding`, `status_kepemilikan`, `status_verifikasi` | KF-12 | +| C2 | Tabel `jenis_bantuan` (PKH, BPNT, PBI-JK, BLT) + `bantuan_sosial` (status per RT, tgl mulai/berakhir) | KF-17 | +| C3 | Upload foto kondisi rumah (folder `uploads/`, kompres GD, 1–5 foto) | KF-13 | +| C4 | Form input/edit lengkap di operator + popup detail kaya di peta | KF-12/14 | +| C5 | Import CSV/Excel sesuai template Lampiran A + validasi & laporan error | KF-15 | +| C6 | Export CSV / Excel / PDF (data penuh & hasil filter) | KF-16 | +| C7 | Manajemen status bantuan per rumah tangga | KF-17 | +| C8 | Verifikasi data (status + bulk verification + penanda di peta) | KF-18 | + +**Catatan:** sekaligus repurpose `rumah_ibadah`/`spbu` β†’ tabel `fasilitas_publik` +(sekolah/puskesmas/pasar) untuk proximity, dan pensiunkan layer smart-city lama. + +--- + +## FASE D β€” Geotagging & Form Enumerator (KF-24, 25, 26) + +Membuat role Enumerator benar-benar fungsional. + +| ID | Pekerjaan | KF | +|---|---|---| +| D1 | `enumerator/input.php` β€” form multi-langkah mobile + auto-GPS (`navigator.geolocation`) + koreksi manual di peta mini | KF-24/25 | +| D2 | Upload foto langsung dari kamera HP | KF-13 | +| D3 | Penugasan wilayah: enumerator hanya akses/input data di wilayah tugasnya | KF-26 | +| D4 | (Opsional, prioritas rendah) Notifikasi in-app tugas baru | KF-27 | + +--- + +## FASE E β€” Analisis Spasial (KF-19 s/d 23) + +Butuh data kaya dari Fase C + `fasilitas_publik`. + +| ID | Pekerjaan | KF | +|---|---|---| +| E1 | Filter multi-kriteria (kecamatan, pendidikan, pekerjaan, status bantuan, sumber air, material, kepemilikan) β€” hasil langsung di peta + jumlah | KF-19 | +| E2 | Identifikasi zona prioritas (skor per kelurahan: % miskin + kepadatan + cakupan bantuan rendah) | KF-20 | +| E3 | Proximity analysis ke fasilitas publik + identifikasi warga "terisolasi" (radius dapat diatur) | KF-21 | +| E4 | Analisis cakupan bantuan (pie chart + choropleth per jenis bantuan) | KF-22 | +| E5 | Analisis tren waktu (line chart jumlah warga & cakupan) | KF-23 | + +--- + +## FASE F β€” Dashboard & Pelaporan (KF-28 s/d 31) + +| ID | Pekerjaan | KF | +|---|---|---| +| F1 | Dashboard eksekutif: kartu KPI + grafik (Chart.js) + peta ringkasan zona prioritas | KF-28 | +| F2 | Laporan per wilayah (tabel + grafik + peta) β€” cetak/unduh PDF | KF-29 | +| F3 | Laporan rekapitulasi penerima bantuan per jenis (PDF) | KF-30 | +| F4 | Grafik komparatif antar kecamatan (bar + radar) | KF-31 | + +--- + +## Kebutuhan Non-Fungsional (NFR) + +Sebagian implementasi, sebagian cukup didokumentasikan untuk final project: + +- **Implementasi:** RBAC server-side (sudah), proteksi SQL Injection (PDO prepared β€” sudah), bcrypt cost 12 (sudah), validasi input, pesan error informatif, responsif mobile. +- **Dokumentasi saja (di luar lingkup realistis final project):** JWT HS256, 100 user konkuren, enkripsi NIK, backup harian otomatis, uptime 99%, integrasi DTKS. + +--- + +## Library Tambahan yang Diperlukan + +- **Chart.js 4.x** β€” grafik dashboard & laporan (Fase E/F) +- **PhpSpreadsheet** atau parser CSV manual β€” import/export Excel (Fase C) +- **Dompdf** atau **mPDF** β€” generate PDF laporan (Fase F) +- (Leaflet & leaflet.heat sudah dipakai) + +--- + +## Rekomendasi Mulai + +**Mulai dari Fase C (Manajemen Data)** karena membuka jalan untuk D, E, dan F +sekaligus memperkaya popup detail peta (KF-10) yang sekarang masih minim. 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/migrations/001_fase_a_roles.sql b/database/migrations/001_fase_a_roles.sql new file mode 100644 index 0000000..0ac9519 --- /dev/null +++ b/database/migrations/001_fase_a_roles.sql @@ -0,0 +1,51 @@ +-- ============================================================ +-- Migrasi Fase A β€” Sistem 4 Role SKPL + Log Aktivitas +-- ============================================================ +-- Jalankan SEKALI pada database existing (webgis_db). +-- Aman untuk data yang sudah ada: role lama dipetakan, bukan dihapus. +-- admin -> operator +-- user -> pimpinan +-- ============================================================ + +-- 1. Migrasi nilai role lama -> SKPL (widen -> update -> narrow) +ALTER TABLE users MODIFY COLUMN role VARCHAR(20) NOT NULL DEFAULT 'enumerator'; + +UPDATE users SET role = 'operator' WHERE role = 'admin'; +UPDATE users SET role = 'pimpinan' WHERE role = 'user'; + +ALTER TABLE users + MODIFY COLUMN role ENUM('publik','enumerator','operator','pimpinan') + NOT NULL DEFAULT 'enumerator'; + +-- 2. Kolom tambahan users (KF-02 / KF-03 / KF-26) +ALTER TABLE users + ADD COLUMN email VARCHAR(120) DEFAULT NULL AFTER username, + ADD COLUMN is_active TINYINT(1) NOT NULL DEFAULT 1 AFTER role, + ADD COLUMN wilayah_tugas VARCHAR(100) DEFAULT NULL AFTER is_active, + ADD COLUMN last_login TIMESTAMP NULL DEFAULT NULL AFTER wilayah_tugas; + +-- 3. Akun demo Enumerator (password: user123) +-- operator & pimpinan berasal dari migrasi akun lama (admin / pengguna) +INSERT IGNORE INTO users (username, email, password, role, nama_lengkap) +VALUES ( + 'enumerator', + 'enumerator@webgis.id', + '$2y$12$djJkmhJBRCSCqqpAICIvGuLoVpICrDUS2IVECbZUodgR89VRTqdLW', + 'enumerator', + 'Petugas Lapangan' +); + +-- 4. Log aktivitas (KF-04) β€” audit trail +CREATE TABLE IF NOT EXISTS activity_log ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NULL, + username VARCHAR(50) DEFAULT NULL, + aksi VARCHAR(50) NOT NULL, + tabel_target VARCHAR(50) DEFAULT NULL, + record_id INT DEFAULT NULL, + keterangan TEXT DEFAULT NULL, + ip_address VARCHAR(45) DEFAULT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_activity_user (user_id), + INDEX idx_activity_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/database/migrations/002_wilayah.sql b/database/migrations/002_wilayah.sql new file mode 100644 index 0000000..105fdb2 --- /dev/null +++ b/database/migrations/002_wilayah.sql @@ -0,0 +1,29 @@ +-- ============================================================ +-- Migrasi Fase B β€” Tabel Wilayah (choropleth) + seed sampel +-- ============================================================ +-- Polygon kelurahan SAMPEL (disederhanakan) di sekitar data warga. +-- Geometri SRID 0 agar konsisten dengan data spasial lama. +-- jumlah_penduduk = jumlah KK (dummy) untuk hitung persentase miskin. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS wilayah ( + id INT AUTO_INCREMENT PRIMARY KEY, + kode_wilayah VARCHAR(20) DEFAULT NULL, + nama VARCHAR(100) NOT NULL, + jenis ENUM('kelurahan','kecamatan') NOT NULL DEFAULT 'kelurahan', + parent_id INT DEFAULT NULL, + jumlah_penduduk INT NOT NULL DEFAULT 0, + geom GEOMETRY NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + SPATIAL INDEX idx_wilayah_geom (geom) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO wilayah (kode_wilayah, nama, jenis, jumlah_penduduk, geom) VALUES + ('6171010001', 'Kel. Tambelan Sampit', 'kelurahan', 45, + ST_GeomFromText('POLYGON((109.357 -0.047, 109.368 -0.047, 109.368 -0.058, 109.357 -0.058, 109.357 -0.047))')), + ('6171010002', 'Kel. Sungai Bangkong', 'kelurahan', 60, + ST_GeomFromText('POLYGON((109.328 -0.043, 109.340 -0.043, 109.340 -0.055, 109.328 -0.055, 109.328 -0.043))')), + ('6171010003', 'Kel. Akcaya', 'kelurahan', 80, + ST_GeomFromText('POLYGON((109.339 -0.035, 109.350 -0.035, 109.350 -0.045, 109.339 -0.045, 109.339 -0.035))')), + ('6171010004', 'Kel. Benua Melayu Darat', 'kelurahan', 55, + ST_GeomFromText('POLYGON((109.318 -0.044, 109.329 -0.044, 109.329 -0.056, 109.318 -0.056, 109.318 -0.044))')); diff --git a/database/migrations/003_fase_c_data.sql b/database/migrations/003_fase_c_data.sql new file mode 100644 index 0000000..268cc31 --- /dev/null +++ b/database/migrations/003_fase_c_data.sql @@ -0,0 +1,82 @@ +-- ============================================================ +-- Migrasi Fase C β€” Manajemen Data Kemiskinan (KF-12 s/d 18) +-- ============================================================ +-- Memperkaya warga_miskin + bantuan sosial + foto + fasilitas publik. +-- Aman dijalankan pada DB existing (kolom & tabel baru saja). +-- ============================================================ + +-- 1. Perluas kolom warga_miskin (KF-12 / KF-18) +ALTER TABLE warga_miskin + ADD COLUMN nik_kk VARCHAR(16) DEFAULT NULL AFTER nama_kk, + ADD COLUMN alamat VARCHAR(255) DEFAULT NULL AFTER nik_kk, + ADD COLUMN wilayah_id INT DEFAULT NULL AFTER alamat, + ADD COLUMN rt VARCHAR(5) DEFAULT NULL AFTER wilayah_id, + ADD COLUMN rw VARCHAR(5) DEFAULT NULL AFTER rt, + ADD COLUMN jumlah_jiwa INT DEFAULT NULL AFTER rw, + ADD COLUMN pekerjaan_kk VARCHAR(100) DEFAULT NULL AFTER jumlah_jiwa, + ADD COLUMN sumber_air ENUM('SUMUR_BOR','PDAM','SUNGAI','HUJAN','TIDAK_ADA') DEFAULT NULL, + ADD COLUMN status_listrik ENUM('PLN','NON_PLN','TIDAK_ADA') DEFAULT NULL, + ADD COLUMN material_dinding ENUM('PERMANEN','SEMI_PERMANEN','TIDAK_PERMANEN') DEFAULT NULL, + ADD COLUMN status_kepemilikan ENUM('MILIK','SEWA','NUMPANG','LAINNYA') DEFAULT NULL, + ADD COLUMN status_verifikasi ENUM('belum','terverifikasi','perlu_tinjauan') NOT NULL DEFAULT 'belum', + ADD COLUMN created_by INT DEFAULT NULL; + +-- 2. Master jenis bantuan (KF-17) + seed +CREATE TABLE IF NOT EXISTS jenis_bantuan ( + id INT AUTO_INCREMENT PRIMARY KEY, + kode VARCHAR(20) NOT NULL UNIQUE, + nama VARCHAR(100) NOT NULL, + deskripsi VARCHAR(255) DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT IGNORE INTO jenis_bantuan (kode, nama, deskripsi) VALUES + ('PKH', 'Program Keluarga Harapan', 'Bantuan tunai bersyarat keluarga miskin'), + ('BPNT', 'Bantuan Pangan Non Tunai', 'Bantuan sembako via KKS'), + ('PBI-JK', 'Penerima Bantuan Iuran JK', 'Subsidi iuran BPJS Kesehatan'), + ('BLT-DD', 'Bantuan Langsung Tunai Dana Desa','Bantuan tunai dari Dana Desa'), + ('RUTILAHU','Rumah Tidak Layak Huni', 'Rehabilitasi rumah tidak layak huni'), + ('KIP', 'Kartu Indonesia Pintar', 'Bantuan biaya pendidikan'); + +-- 3. Riwayat penerimaan bantuan per rumah tangga (KF-17) +CREATE TABLE IF NOT EXISTS bantuan_sosial ( + id INT AUTO_INCREMENT PRIMARY KEY, + warga_id INT NOT NULL, + jenis_bantuan_id INT NOT NULL, + tgl_mulai DATE DEFAULT NULL, + tgl_berakhir DATE DEFAULT NULL, + is_aktif TINYINT(1) NOT NULL DEFAULT 1, + keterangan VARCHAR(255) DEFAULT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_bansos_warga (warga_id), + INDEX idx_bansos_jenis (jenis_bantuan_id), + CONSTRAINT fk_bansos_warga FOREIGN KEY (warga_id) REFERENCES warga_miskin(id) ON DELETE CASCADE, + CONSTRAINT fk_bansos_jenis FOREIGN KEY (jenis_bantuan_id) REFERENCES jenis_bantuan(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 4. Foto kondisi rumah (KF-13) +CREATE TABLE IF NOT EXISTS foto_rumah ( + id INT AUTO_INCREMENT PRIMARY KEY, + warga_id INT NOT NULL, + file_path VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_foto_warga (warga_id), + CONSTRAINT fk_foto_warga FOREIGN KEY (warga_id) REFERENCES warga_miskin(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 5. Fasilitas publik untuk proximity (KF-21) + seed sampel +CREATE TABLE IF NOT EXISTS fasilitas_publik ( + id INT AUTO_INCREMENT PRIMARY KEY, + nama VARCHAR(120) NOT NULL, + jenis ENUM('sekolah','puskesmas','pasar','kantor_kelurahan','rumah_ibadah','lainnya') NOT NULL DEFAULT 'lainnya', + wilayah_id INT DEFAULT NULL, + geom GEOMETRY NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + SPATIAL INDEX idx_fasilitas_geom (geom) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO fasilitas_publik (nama, jenis, geom) VALUES + ('SDN 01 Pontianak Kota', 'sekolah', ST_GeomFromText('POINT(109.3380 -0.0420)')), + ('Puskesmas Alianyang', 'puskesmas', ST_GeomFromText('POINT(109.3330 -0.0480)')), + ('Pasar Flamboyan', 'pasar', ST_GeomFromText('POINT(109.3470 -0.0250)')), + ('Kantor Kel. Akcaya', 'kantor_kelurahan', ST_GeomFromText('POINT(109.3445 -0.0400)')), + ('Puskesmas Kampung Bali','puskesmas', ST_GeomFromText('POINT(109.3360 -0.0440)')); 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..643a92b --- /dev/null +++ b/database/webgis_db.sql @@ -0,0 +1,210 @@ +-- ============================================================ +-- WebGIS Pemetaan Kemiskinan Terpadu β€” Database Lengkap (SKPL) +-- ============================================================ +-- Import file ini untuk membuat ulang database dari awal. +-- Perhatian: tabel lama dengan nama yang sama akan dihapus. + +SET FOREIGN_KEY_CHECKS = 0; + +DROP TABLE IF EXISTS activity_log; +DROP TABLE IF EXISTS foto_rumah; +DROP TABLE IF EXISTS bantuan_sosial; +DROP TABLE IF EXISTS jenis_bantuan; +DROP TABLE IF EXISTS fasilitas_publik; +DROP TABLE IF EXISTS wilayah; +DROP TABLE IF EXISTS laporan_warga; +DROP TABLE IF EXISTS warga_miskin; +DROP TABLE IF EXISTS users; + +SET FOREIGN_KEY_CHECKS = 1; + +-- ============================================================ +-- 1. Users (4 role SKPL: publik / enumerator / operator / pimpinan) +-- ============================================================ +CREATE TABLE users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) NOT NULL UNIQUE, + email VARCHAR(120) DEFAULT NULL, + password VARCHAR(255) NOT NULL, + role ENUM('publik','enumerator','operator','pimpinan') NOT NULL DEFAULT 'enumerator', + is_active TINYINT(1) NOT NULL DEFAULT 1, + wilayah_tugas VARCHAR(100) DEFAULT NULL, + last_login TIMESTAMP NULL DEFAULT NULL, + nama_lengkap VARCHAR(100) DEFAULT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Akun demo. Password: operator=admin123, pimpinan & enumerator=user123 +INSERT INTO users (username, email, password, role, nama_lengkap) VALUES + ('operator', 'operator@webgis.id', '$2y$12$oRvX1EtLHM/y8XtwlOy./Oi2UVpnHp98GvXqEuLCFimhr0doKus62', 'operator', 'Operator Dinas Sosial'), + ('pimpinan', 'pimpinan@webgis.id', '$2y$12$djJkmhJBRCSCqqpAICIvGuLoVpICrDUS2IVECbZUodgR89VRTqdLW', 'pimpinan', 'Kepala Dinas Sosial'), + ('enumerator', 'enumerator@webgis.id', '$2y$12$djJkmhJBRCSCqqpAICIvGuLoVpICrDUS2IVECbZUodgR89VRTqdLW', 'enumerator', 'Petugas Lapangan'); + +-- ============================================================ +-- 2. Rumah Tangga Miskin +-- ============================================================ +CREATE TABLE warga_miskin ( + id INT AUTO_INCREMENT PRIMARY KEY, + nama_kk VARCHAR(100) NOT NULL, + nik_kk VARCHAR(16) DEFAULT NULL, + alamat VARCHAR(255) DEFAULT NULL, + wilayah_id INT DEFAULT NULL, + rt VARCHAR(5) DEFAULT NULL, + rw VARCHAR(5) DEFAULT NULL, + jumlah_jiwa INT DEFAULT NULL, + pekerjaan_kk VARCHAR(100) DEFAULT NULL, + penghasilan DECIMAL(15,2) NOT NULL, + jumlah_tanggungan INT NOT NULL, + sumber_air ENUM('SUMUR_BOR','PDAM','SUNGAI','HUJAN','TIDAK_ADA') DEFAULT NULL, + status_listrik ENUM('PLN','NON_PLN','TIDAK_ADA') DEFAULT NULL, + material_dinding ENUM('PERMANEN','SEMI_PERMANEN','TIDAK_PERMANEN') DEFAULT NULL, + status_kepemilikan ENUM('MILIK','SEWA','NUMPANG','LAINNYA') DEFAULT NULL, + status_verifikasi ENUM('belum','terverifikasi','perlu_tinjauan') NOT NULL DEFAULT 'belum', + created_by INT DEFAULT 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. Laporan Warga (partisipasi/transparansi) +-- ============================================================ +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; + +-- ============================================================ +-- 4. Log Aktivitas (KF-04) +-- ============================================================ +CREATE TABLE activity_log ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NULL, + username VARCHAR(50) DEFAULT NULL, + aksi VARCHAR(50) NOT NULL, + tabel_target VARCHAR(50) DEFAULT NULL, + record_id INT DEFAULT NULL, + keterangan TEXT DEFAULT NULL, + ip_address VARCHAR(45) DEFAULT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_activity_user (user_id), + INDEX idx_activity_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================================ +-- 5. Wilayah (KF-06) β€” batas kelurahan untuk choropleth +-- ============================================================ +CREATE TABLE wilayah ( + id INT AUTO_INCREMENT PRIMARY KEY, + kode_wilayah VARCHAR(20) DEFAULT NULL, + nama VARCHAR(100) NOT NULL, + jenis ENUM('kelurahan','kecamatan') NOT NULL DEFAULT 'kelurahan', + parent_id INT DEFAULT NULL, + jumlah_penduduk INT NOT NULL DEFAULT 0, + geom GEOMETRY NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + SPATIAL INDEX idx_wilayah_geom (geom) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO wilayah (kode_wilayah, nama, jenis, jumlah_penduduk, geom) VALUES + ('6171010001', 'Kel. Tambelan Sampit', 'kelurahan', 45, + ST_GeomFromText('POLYGON((109.357 -0.047, 109.368 -0.047, 109.368 -0.058, 109.357 -0.058, 109.357 -0.047))')), + ('6171010002', 'Kel. Sungai Bangkong', 'kelurahan', 60, + ST_GeomFromText('POLYGON((109.328 -0.043, 109.340 -0.043, 109.340 -0.055, 109.328 -0.055, 109.328 -0.043))')), + ('6171010003', 'Kel. Akcaya', 'kelurahan', 80, + ST_GeomFromText('POLYGON((109.339 -0.035, 109.350 -0.035, 109.350 -0.045, 109.339 -0.045, 109.339 -0.035))')), + ('6171010004', 'Kel. Benua Melayu Darat', 'kelurahan', 55, + ST_GeomFromText('POLYGON((109.318 -0.044, 109.329 -0.044, 109.329 -0.056, 109.318 -0.056, 109.318 -0.044))')); + +-- ============================================================ +-- 6. Bantuan Sosial (KF-17) β€” master jenis + riwayat per warga +-- ============================================================ +CREATE TABLE jenis_bantuan ( + id INT AUTO_INCREMENT PRIMARY KEY, + kode VARCHAR(20) NOT NULL UNIQUE, + nama VARCHAR(100) NOT NULL, + deskripsi VARCHAR(255) DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO jenis_bantuan (kode, nama, deskripsi) VALUES + ('PKH', 'Program Keluarga Harapan', 'Bantuan tunai bersyarat keluarga miskin'), + ('BPNT', 'Bantuan Pangan Non Tunai', 'Bantuan sembako via KKS'), + ('PBI-JK', 'Penerima Bantuan Iuran JK', 'Subsidi iuran BPJS Kesehatan'), + ('BLT-DD', 'Bantuan Langsung Tunai Dana Desa', 'Bantuan tunai dari Dana Desa'), + ('RUTILAHU','Rumah Tidak Layak Huni', 'Rehabilitasi rumah tidak layak huni'), + ('KIP', 'Kartu Indonesia Pintar', 'Bantuan biaya pendidikan'); + +CREATE TABLE bantuan_sosial ( + id INT AUTO_INCREMENT PRIMARY KEY, + warga_id INT NOT NULL, + jenis_bantuan_id INT NOT NULL, + tgl_mulai DATE DEFAULT NULL, + tgl_berakhir DATE DEFAULT NULL, + is_aktif TINYINT(1) NOT NULL DEFAULT 1, + keterangan VARCHAR(255) DEFAULT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_bansos_warga (warga_id), + INDEX idx_bansos_jenis (jenis_bantuan_id), + CONSTRAINT fk_bansos_warga FOREIGN KEY (warga_id) REFERENCES warga_miskin(id) ON DELETE CASCADE, + CONSTRAINT fk_bansos_jenis FOREIGN KEY (jenis_bantuan_id) REFERENCES jenis_bantuan(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================================ +-- 7. Foto Kondisi Rumah (KF-13) +-- ============================================================ +CREATE TABLE foto_rumah ( + id INT AUTO_INCREMENT PRIMARY KEY, + warga_id INT NOT NULL, + file_path VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_foto_warga (warga_id), + CONSTRAINT fk_foto_warga FOREIGN KEY (warga_id) REFERENCES warga_miskin(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================================ +-- 8. Fasilitas Publik (KF-21) β€” untuk proximity analysis +-- ============================================================ +CREATE TABLE fasilitas_publik ( + id INT AUTO_INCREMENT PRIMARY KEY, + nama VARCHAR(120) NOT NULL, + jenis ENUM('sekolah','puskesmas','pasar','kantor_kelurahan','rumah_ibadah','lainnya') NOT NULL DEFAULT 'lainnya', + wilayah_id INT DEFAULT NULL, + geom GEOMETRY NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + SPATIAL INDEX idx_fasilitas_geom (geom) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO fasilitas_publik (nama, jenis, geom) VALUES + ('SDN 01 Pontianak Kota', 'sekolah', ST_GeomFromText('POINT(109.3380 -0.0420)')), + ('Puskesmas Alianyang', 'puskesmas', ST_GeomFromText('POINT(109.3330 -0.0480)')), + ('Pasar Flamboyan', 'pasar', ST_GeomFromText('POINT(109.3470 -0.0250)')), + ('Kantor Kel. Akcaya', 'kantor_kelurahan', ST_GeomFromText('POINT(109.3445 -0.0400)')), + ('Puskesmas Kampung Bali', 'puskesmas', ST_GeomFromText('POINT(109.3360 -0.0440)')); + +-- ============================================================ +-- 9. Seed demo kondisi rumah & bantuan (untuk analisis Fase E/F) +-- ============================================================ +UPDATE warga_miskin SET sumber_air='SUNGAI', status_listrik='NON_PLN', material_dinding='TIDAK_PERMANEN', status_kepemilikan='NUMPANG' WHERE nama_kk LIKE '%Terisolasi%'; +UPDATE warga_miskin SET sumber_air='PDAM', status_listrik='PLN', material_dinding='SEMI_PERMANEN', status_kepemilikan='MILIK' WHERE nama_kk IN ('Bpk. Hasan','Mbah Warno','Pak Junaidi'); + +INSERT INTO bantuan_sosial (warga_id, jenis_bantuan_id, tgl_mulai, is_aktif) +SELECT id, 1, '2026-01-01', 1 FROM warga_miskin WHERE nama_kk IN ('Bpk. Hasan','Pak Junaidi'); +INSERT INTO bantuan_sosial (warga_id, jenis_bantuan_id, tgl_mulai, is_aktif) +SELECT id, 2, '2026-02-01', 1 FROM warga_miskin WHERE nama_kk='Mbah Warno'; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cb7e7f9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,46 @@ +services: + web: + build: . + container_name: webgis_web + ports: + - "80:80" + environment: + - APP_BASE_PATH=/project_final + - SESSION_SECURE=false + - CORS_ALLOWED_ORIGIN= + - DB_HOST=mysql + - DB_PORT=3306 + - DB_DATABASE=webgis_db + - DB_USERNAME=root + - DB_PASSWORD=rootpassword + volumes: + - .:/var/www/html + depends_on: + - mysql + networks: + - webgis_network + + mysql: + image: mysql:8.0 + container_name: webgis_mysql + environment: + - MYSQL_ROOT_PASSWORD=rootpassword + - MYSQL_DATABASE=webgis_db + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + - ./database/webgis_db.sql:/docker-entrypoint-initdb.d/webgis_db.sql + networks: + - webgis_network + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + timeout: 20s + retries: 10 + +volumes: + mysql_data: + +networks: + webgis_network: + driver: bridge 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 + + + + + + + +
+
+
+

P03: Analisis Spasial

+

Kumulatif P01 & P02 + Choropleth Map (Point in Polygon).

+
+ + + +
+

Statistik Kavling (Choropleth)

+

Pewarnaan otomatis area kavling berdasarkan jumlah warga miskin yang bermukim di atasnya.

+
+ Total Penduduk Miskin: + 0 +
+
+ Kavling Padat (>2 warga): + 0 +
+
+ + +
+
+ + + + + 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' ? ` +
+ +
+ ` : ''; + + container.innerHTML = ` +
+

Simpan Data (${type.toUpperCase()})

+
+ +
+
+ +
+ ${extraFields} +
+ + +
+
+ `; + + 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/import_db.php b/import_db.php new file mode 100644 index 0000000..5ebe69e --- /dev/null +++ b/import_db.php @@ -0,0 +1,24 @@ + + + + + + Portal WebGIS - Sistem Informasi Geografis + + + + + + + + +
+
+ +
+

Sistem Informasi Geografis

+

Direktori tugas dan proyek akhir mata kuliah Sistem Informasi Geografis. Pilih modul pembelajaran di bawah ini untuk memulai.

+
+ +
+ + + +
+ +
+
Pertemuan 01
+

Geometri Dasar

+

Fitur menggambar peta dasar dan CRUD spasial.

+ +
+ + + +
+ +
+
Pertemuan 02
+

Haversine & Tematik

+

Pemetaan sosial dan query pencarian radius jarak.

+ +
+ + + +
+ +
+
Pertemuan 03
+

Analisis Spasial

+

Visualisasi Peta Choropleth dinamis (Point-in-Polygon).

+ +
+ + + +
+ +
+
Proyek Akhir
+

Final UAS

+

Sistem komprehensif untuk evaluasi akhir semester.

+ +
+ +
+ + + diff --git a/project_final/admin/analisis.php b/project_final/admin/analisis.php new file mode 100644 index 0000000..f46201e --- /dev/null +++ b/project_final/admin/analisis.php @@ -0,0 +1,11 @@ +'; +require_once __DIR__ . '/partials/header.php'; +require_once __DIR__ . '/../partials/analisis_body.php'; +require_once __DIR__ . '/partials/footer.php'; diff --git a/project_final/admin/data_laporan.php b/project_final/admin/data_laporan.php new file mode 100644 index 0000000..54326d4 --- /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'; +} +?> + + + +
+ +
+ +
+ + + + + + + + + + + + + + $r): ?> + + + + + + + + + + + + + + +
#PelaporKategoriDeskripsiStatusTanggalAksi
+ + +
+ + + +
+
Belum ada laporan dari warga.
+
+ + + + + + + + + +JS; ?> + diff --git a/project_final/admin/data_warga.php b/project_final/admin/data_warga.php new file mode 100644 index 0000000..a44bfe4 --- /dev/null +++ b/project_final/admin/data_warga.php @@ -0,0 +1,324 @@ +query(" + SELECT w.*, wl.nama AS wilayah_nama, + (SELECT GROUP_CONCAT(jb.kode SEPARATOR ',') + FROM bantuan_sosial bs JOIN jenis_bantuan jb ON jb.id=bs.jenis_bantuan_id + WHERE bs.warga_id=w.id AND bs.is_aktif=1) AS bantuan_aktif, + (SELECT COUNT(*) FROM foto_rumah f WHERE f.warga_id=w.id) AS jumlah_foto + FROM warga_miskin w + LEFT JOIN wilayah wl ON wl.id=w.wilayah_id + ORDER BY w.id DESC +")->fetchAll(); + +$wilayahList = $pdo->query("SELECT id, nama FROM wilayah ORDER BY nama")->fetchAll(); +$jenisList = $pdo->query("SELECT id, kode, nama FROM jenis_bantuan ORDER BY id")->fetchAll(); + +$pageTitle = 'Data Rumah Tangga Miskin'; $activeNav = 'warga'; +$extraHead = ''; +require_once __DIR__ . '/partials/header.php'; + +function verifBadge($s) { + return match ($s) { + 'terverifikasi' => 'βœ” Terverifikasi', + 'perlu_tinjauan' => '⚠ Perlu Tinjauan', + default => 'β—‹ Belum', + }; +} +?> + + +
+ + + + Export CSV + Cetak/PDF + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
Nama KKNIKKelurahanPenghasilanJiwaBantuanFotoVerifikasiAksi
Rp '.str_replace(',',' ',htmlspecialchars($r['bantuan_aktif'])).'' : 'β€”' ?> 0 ? 'πŸ“· '.$r['jumlah_foto'] : 'β€”' ?>
+ + + +
Belum ada data.
+
+ + + + + + + + + + + + +JS; ?> + diff --git a/project_final/admin/index.php b/project_final/admin/index.php new file mode 100644 index 0000000..2348d5f --- /dev/null +++ b/project_final/admin/index.php @@ -0,0 +1,171 @@ +query("SELECT + (SELECT COUNT(*) FROM warga_miskin) AS total_warga, + (SELECT COUNT(*) FROM warga_miskin WHERE status_verifikasi='terverifikasi') AS terverifikasi, + (SELECT COUNT(*) FROM warga_miskin WHERE status_verifikasi='belum') AS belum, + (SELECT COUNT(*) FROM warga_miskin WHERE status_verifikasi='perlu_tinjauan') AS perlu_tinjauan, + (SELECT COUNT(*) FROM wilayah) AS total_wilayah, + (SELECT COUNT(DISTINCT warga_id) FROM bantuan_sosial WHERE is_aktif=1) AS warga_dibantu, + (SELECT COUNT(*) FROM users) AS total_users +")->fetch(); + +$total = (int)$stats['total_warga']; +$dibantu = (int)$stats['warga_dibantu']; +$cakupan = $total > 0 ? round($dibantu / $total * 100, 1) : 0; +$belumDibantu = $total - $dibantu; + +// Zona prioritas: kelurahan dengan % miskin > 5% +$zona = $pdo->query(" + SELECT COUNT(*) AS cnt FROM ( + SELECT w.id, (COUNT(wm.id) / NULLIF(w.jumlah_penduduk,0) * 100) AS persen + FROM wilayah w LEFT JOIN warga_miskin wm ON ST_Contains(w.geom, wm.geom) + GROUP BY w.id HAVING persen > 5 + ) sub +")->fetch(); +$zonaPrioritas = (int)($zona['cnt'] ?? 0); + +// Data terbaru +$recentWarga = $pdo->query(" + SELECT w.nama_kk, w.penghasilan, w.status_verifikasi, wl.nama AS kelurahan, w.created_at + FROM warga_miskin w LEFT JOIN wilayah wl ON wl.id=w.wilayah_id + ORDER BY w.id DESC LIMIT 6 +")->fetchAll(); + +// Rekap per kelurahan +$rekapWilayah = $pdo->query(" + SELECT w.nama, w.jumlah_penduduk, COUNT(wm.id) AS miskin + FROM wilayah w LEFT JOIN warga_miskin wm ON ST_Contains(w.geom, wm.geom) + GROUP BY w.id ORDER BY miskin DESC +")->fetchAll(); + +$pageTitle = 'Dashboard'; +$activeNav = 'dashboard'; +require_once __DIR__ . '/partials/header.php'; + +function vBadge($s) { + return match ($s) { + 'terverifikasi' => 'Terverifikasi', + 'perlu_tinjauan' => 'Perlu Tinjauan', + default => 'Belum', + }; +} +?> + + +
+
+
+

πŸ—ΊοΈ Peta Pemetaan Kemiskinan

+

+ Visualisasikan sebaran kemiskinan (choropleth & heatmap), kelola data rumah tangga, dan analisis zona prioritas. +

+
+ Buka Peta +
+
+ + +
+
+
+
+
Total Rumah Tangga Miskin
+
+
kelurahan
+
+
+
+
+
+
Terverifikasi
+
+
belum diverifikasi
+
+
+
+
+
+
Cakupan Bantuan
+
%
+
dari RT
+
+
+
+
+
+
Belum Terima Bantuan
+
+
kandidat prioritas
+
+
+
+
+
+
Zona Prioritas
+
+
kelurahan > 5% miskin
+
+
+
+
+
+
Perlu Tinjauan
+
+
data menunggu review
+
+
+
+ + +
+ +
+
+

πŸ“Š Rekap per Kelurahan

+
+ 0 ? round($w['miskin']/$w['jumlah_penduduk']*100,1) : 0; ?> +
+
+ + RT (%) +
+
+
+
+
+ +

Belum ada data wilayah.

+
+ + +
+
+

πŸ‘₯ Rumah Tangga Terbaru

+ Lihat Semua +
+ +
+ +
+
+
+
Β· Rp
+
+ +
+ +
+ +

Belum ada data warga

+ +
+
+ + diff --git a/project_final/admin/log.php b/project_final/admin/log.php new file mode 100644 index 0000000..705d5e4 --- /dev/null +++ b/project_final/admin/log.php @@ -0,0 +1,59 @@ +query( + "SELECT id, username, aksi, tabel_target, record_id, keterangan, ip_address, created_at + FROM activity_log ORDER BY id DESC LIMIT 300" +)->fetchAll(); + +$pageTitle = 'Log Aktivitas'; +$activeNav = 'log'; +require_once __DIR__ . '/partials/header.php'; + +function aksiBadge(string $a): string { + return match ($a) { + 'login' => 'login', + 'logout' => 'logout', + 'create' => 'create', + 'update' => 'update', + 'delete' => 'delete', + default => '' . htmlspecialchars($a) . '', + }; +} +?> + +
+ +
+
+ + + + + + + + + + + + + + + + + + +
WaktuUserAksiTabelRecordKeteranganIP
Belum ada aktivitas tercatat.
+
+ diff --git a/project_final/admin/partials/footer.php b/project_final/admin/partials/footer.php new file mode 100644 index 0000000..5226ba2 --- /dev/null +++ b/project_final/admin/partials/footer.php @@ -0,0 +1,54 @@ + + + + +
+ + + + + diff --git a/project_final/admin/partials/header.php b/project_final/admin/partials/header.php new file mode 100644 index 0000000..d2b4238 --- /dev/null +++ b/project_final/admin/partials/header.php @@ -0,0 +1,106 @@ + + + + + + + <?= htmlspecialchars($pageTitle ?? 'Panel Operator') ?> β€” WebGIS Kemiskinan + + + + + + + + + + + + + +
+
+ +
+
+ +
+ + Buka Peta + +
+
+
diff --git a/project_final/admin/pelaporan.php b/project_final/admin/pelaporan.php new file mode 100644 index 0000000..58f433c --- /dev/null +++ b/project_final/admin/pelaporan.php @@ -0,0 +1,10 @@ + +'; +require_once __DIR__ . '/partials/header.php'; +?> + +
+
+
+ + + + + + + + diff --git a/project_final/admin/users.php b/project_final/admin/users.php new file mode 100644 index 0000000..faefe2b --- /dev/null +++ b/project_final/admin/users.php @@ -0,0 +1,235 @@ +query( + "SELECT id, username, email, role, is_active, wilayah_tugas, nama_lengkap, last_login, created_at + FROM users ORDER BY id ASC" +)->fetchAll(); +$wilayahList = $pdo->query("SELECT nama FROM wilayah ORDER BY nama")->fetchAll(); +$pageTitle = 'Kelola Users'; +$activeNav = 'users'; +require_once __DIR__ . '/partials/header.php'; + +// Helper badge per role +function roleBadge(string $role): string { + return match ($role) { + 'operator' => 'πŸ› οΈ Operator', + 'pimpinan' => 'πŸ“Š Pimpinan', + 'enumerator' => 'πŸ“‹ Enumerator', + 'publik' => '🌐 Publik', + default => htmlspecialchars($role), + }; +} +?> + +
+ + +
+
+ + + + + + + $r): ?> + + + + + + + + + + + + + +
#UsernameNama LengkapEmailRoleWilayahStatusLogin TerakhirAksi
+ + Aktif + + Nonaktif + + + +
+ + +
+
+ + + + + + + + +const API = APP_BASE + '/api/users.php'; + +// Wilayah tugas hanya relevan untuk enumerator +function toggleWilayah(prefix) { + const role = document.getElementById(prefix + 'Role').value; + const group = document.getElementById(prefix + 'WilayahGroup'); + group.style.display = (role === 'enumerator') ? 'block' : 'none'; +} + +function editRow(r) { + document.getElementById('editId').value = r.id; + document.getElementById('editUsername').value = r.username; + document.getElementById('editNama').value = r.nama_lengkap || ''; + document.getElementById('editEmail').value = r.email || ''; + document.getElementById('editRole').value = r.role; + document.getElementById('editWilayah').value = r.wilayah_tugas || ''; + document.getElementById('editActive').value = String(r.is_active); + document.getElementById('editPassword').value = ''; + toggleWilayah('edit'); + openModal('modalEdit'); +} + +async function simpan() { + const u = document.getElementById('tUsername').value.trim(); + const p = document.getElementById('tPassword').value; + if (!u) return showToast('Username wajib!', 'error'); + if (p.length < 6) return showToast('Password minimal 6 karakter!', 'error'); + + const res = await fetch(API, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: u, + nama_lengkap: document.getElementById('tNama').value, + email: document.getElementById('tEmail').value, + role: document.getElementById('tRole').value, + wilayah_tugas: document.getElementById('tWilayah').value, + password: p + }) + }); + const d = await res.json(); + if (d.status === 'success') { showToast('User berhasil ditambahkan!'); setTimeout(() => location.reload(), 900); } + else showToast(d.message || 'Gagal', 'error'); +} + +async function update() { + const id = document.getElementById('editId').value; + const u = document.getElementById('editUsername').value.trim(); + const p = document.getElementById('editPassword').value; + if (!u) return showToast('Username wajib!', 'error'); + if (p && p.length < 6) return showToast('Password minimal 6 karakter!', 'error'); + + const body = { + username: u, + nama_lengkap: document.getElementById('editNama').value, + email: document.getElementById('editEmail').value, + role: document.getElementById('editRole').value, + wilayah_tugas: document.getElementById('editWilayah').value, + is_active: document.getElementById('editActive').value + }; + if (p) body.password = p; + + const res = await fetch(API + '?id=' + id, { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + const d = await res.json(); + if (d.status === 'success') { showToast('User diperbarui!'); setTimeout(() => location.reload(), 900); } + else showToast(d.message || 'Gagal', 'error'); +} + +async function hapus(id, username) { + if (id == 1) return showToast('Akun utama (ID=1) tidak dapat dihapus!', 'error'); + if (!confirm(`Hapus user "${username}"?`)) return; + const res = await fetch(API + '?id=' + id, { method: 'DELETE' }); + const d = await res.json(); + if (d.status === 'success') { showToast('User dihapus!'); setTimeout(() => location.reload(), 900); } + else showToast(d.message || 'Gagal', 'error'); +} + +toggleWilayah('t'); + +JS; ?> + diff --git a/project_final/admin/wilayah.php b/project_final/admin/wilayah.php new file mode 100644 index 0000000..439aa6f --- /dev/null +++ b/project_final/admin/wilayah.php @@ -0,0 +1,160 @@ + + +'; +require_once __DIR__ . '/partials/header.php'; +?> + + +
+
+
+
+ Cara pakai: klik ikon β¬  (kiri atas peta) untuk gambar polygon baru. Ikon ✏️ untuk geser titik/ubah ukuran, πŸ—‘οΈ untuk hapus. Klik polygon untuk edit atribut. +
+
+ +
+

Atribut Wilayah

+ +
+
+
+
+
+
Belum ada polygon. Gambar di peta dulu untuk wilayah baru.
+ + + +

Daftar Wilayah

+
+
+
+ + + + + + diff --git a/project_final/api/bantuan.php b/project_final/api/bantuan.php new file mode 100644 index 0000000..d8be02c --- /dev/null +++ b/project_final/api/bantuan.php @@ -0,0 +1,63 @@ +query("SELECT id, kode, nama, deskripsi FROM jenis_bantuan ORDER BY id")->fetchAll(), 'Jenis Bantuan'); + } + $wargaId = $_GET['warga_id'] ?? null; + if (!$wargaId) sendError('warga_id wajib'); + $stmt = $pdo->prepare(" + SELECT bs.id, bs.jenis_bantuan_id, jb.kode, jb.nama, + bs.tgl_mulai, bs.tgl_berakhir, bs.is_aktif, bs.keterangan + FROM bantuan_sosial bs + JOIN jenis_bantuan jb ON jb.id = bs.jenis_bantuan_id + WHERE bs.warga_id = ? + ORDER BY bs.id DESC + "); + $stmt->execute([$wargaId]); + sendSuccess($stmt->fetchAll(), 'Bantuan warga'); + break; + + case 'POST': + $d = getInput(); + if (empty($d['warga_id']) || empty($d['jenis_bantuan_id'])) sendError('warga_id & jenis_bantuan_id wajib'); + $pdo->prepare(" + INSERT INTO bantuan_sosial (warga_id, jenis_bantuan_id, tgl_mulai, tgl_berakhir, is_aktif, keterangan) + VALUES (?,?,?,?,?,?) + ")->execute([ + $d['warga_id'], $d['jenis_bantuan_id'], + ($d['tgl_mulai'] ?? '') ?: null, ($d['tgl_berakhir'] ?? '') ?: null, + isset($d['is_aktif']) ? (int)$d['is_aktif'] : 1, ($d['keterangan'] ?? '') ?: null, + ]); + $id = (int)$pdo->lastInsertId(); + logActivity($pdo, 'create', 'bantuan_sosial', $id, "Tambah bantuan warga #{$d['warga_id']}"); + sendSuccess(['id' => $id], 'Bantuan ditambahkan', 201); + break; + + case 'DELETE': + $id = $_GET['id'] ?? null; + if (!$id) sendError('ID wajib'); + $pdo->prepare("DELETE FROM bantuan_sosial WHERE id=?")->execute([$id]); + logActivity($pdo, 'delete', 'bantuan_sosial', (int)$id, 'Hapus bantuan'); + sendSuccess(null, 'Bantuan dihapus'); + break; + + default: + sendError('Method not allowed', 405); +} diff --git a/project_final/api/cakupan_bantuan.php b/project_final/api/cakupan_bantuan.php new file mode 100644 index 0000000..4c12495 --- /dev/null +++ b/project_final/api/cakupan_bantuan.php @@ -0,0 +1,53 @@ +query("SELECT COUNT(*) FROM warga_miskin")->fetchColumn(); +$dibantu = (int)$pdo->query("SELECT COUNT(DISTINCT warga_id) FROM bantuan_sosial WHERE is_aktif=1")->fetchColumn(); + +// Per jenis bantuan +$perJenis = $pdo->query(" + SELECT jb.kode, jb.nama, + COUNT(DISTINCT CASE WHEN bs.is_aktif=1 THEN bs.warga_id END) AS penerima + FROM jenis_bantuan jb + LEFT JOIN bantuan_sosial bs ON bs.jenis_bantuan_id = jb.id + GROUP BY jb.id ORDER BY jb.id +")->fetchAll(); +foreach ($perJenis as &$j) { + $j['penerima'] = (int)$j['penerima']; + $j['persen'] = $total > 0 ? round($j['penerima'] / $total * 100, 1) : 0; +} +unset($j); + +// Per kelurahan +$perWilayah = $pdo->query(" + SELECT w.nama, + COUNT(DISTINCT wm.id) AS miskin, + COUNT(DISTINCT CASE WHEN bs.id IS NOT NULL THEN wm.id END) AS dibantu + FROM wilayah w + LEFT JOIN warga_miskin wm ON ST_Contains(w.geom, wm.geom) + LEFT JOIN bantuan_sosial bs ON bs.warga_id = wm.id AND bs.is_aktif=1 + GROUP BY w.id ORDER BY w.nama +")->fetchAll(); +foreach ($perWilayah as &$w) { + $w['miskin'] = (int)$w['miskin']; $w['dibantu'] = (int)$w['dibantu']; + $w['cakupan'] = $w['miskin'] > 0 ? round($w['dibantu'] / $w['miskin'] * 100, 1) : 0; +} +unset($w); + +sendSuccess([ + 'total_warga' => $total, + 'dibantu' => $dibantu, + 'belum_dibantu' => $total - $dibantu, + 'cakupan_persen' => $total > 0 ? round($dibantu / $total * 100, 1) : 0, + 'per_jenis' => $perJenis, + 'per_wilayah' => $perWilayah, +], 'Cakupan Bantuan'); diff --git a/project_final/api/fasilitas.php b/project_final/api/fasilitas.php new file mode 100644 index 0000000..12d12c8 --- /dev/null +++ b/project_final/api/fasilitas.php @@ -0,0 +1,21 @@ +query("SELECT id, nama, jenis, ST_AsGeoJSON(geom) AS geojson FROM fasilitas_publik ORDER BY jenis, nama"); +$features = []; +while ($r = $stmt->fetch()) { + $features[] = [ + 'type' => 'Feature', + 'geometry' => json_decode($r['geojson']), + 'properties' => ['id' => (int)$r['id'], 'nama' => $r['nama'], 'jenis' => $r['jenis']], + ]; +} +sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Fasilitas Publik'); diff --git a/project_final/api/filter.php b/project_final/api/filter.php new file mode 100644 index 0000000..20555fb --- /dev/null +++ b/project_final/api/filter.php @@ -0,0 +1,65 @@ +prepare($sql); +$stmt->execute($params); + +$features = []; +while ($r = $stmt->fetch()) { + $features[] = [ + 'type' => 'Feature', + 'geometry' => json_decode($r['geojson']), + 'properties' => [ + 'id' => (int)$r['id'], 'nama_kk' => $r['nama_kk'], 'nik_kk' => $r['nik_kk'], + 'penghasilan' => $r['penghasilan'], 'jumlah_jiwa' => $r['jumlah_jiwa'], + 'sumber_air' => $r['sumber_air'], 'material_dinding' => $r['material_dinding'], + 'status_verifikasi' => $r['status_verifikasi'], 'pekerjaan_kk' => $r['pekerjaan_kk'], + 'kelurahan' => $r['kelurahan'], 'bantuan_aktif' => $r['bantuan_aktif'], + ], + ]; +} +sendSuccess(['type' => 'FeatureCollection', 'features' => $features, 'jumlah' => count($features)], 'Hasil Filter'); diff --git a/project_final/api/foto.php b/project_final/api/foto.php new file mode 100644 index 0000000..2e5df11 --- /dev/null +++ b/project_final/api/foto.php @@ -0,0 +1,110 @@ + $maxW ? $maxW : $w; + $nh = (int)round($h * ($nw / $w)); + $canvas = imagecreatetruecolor($nw, $nh); + // latar putih untuk PNG transparan + imagefill($canvas, 0, 0, imagecolorallocate($canvas, 255, 255, 255)); + imagecopyresampled($canvas, $img, 0, 0, 0, 0, $nw, $nh, $w, $h); + $ok = imagejpeg($canvas, $dst, $quality); + imagedestroy($img); + imagedestroy($canvas); + return $ok; +} + +switch ($method) { + case 'GET': + $wargaId = $_GET['warga_id'] ?? null; + if (!$wargaId) sendError('warga_id wajib'); + $stmt = $pdo->prepare("SELECT id, file_path FROM foto_rumah WHERE warga_id=? ORDER BY id"); + $stmt->execute([$wargaId]); + $out = []; + while ($r = $stmt->fetch()) { + $out[] = ['id' => (int)$r['id'], 'url' => app_url($r['file_path'])]; + } + sendSuccess($out, 'Foto rumah'); + break; + + case 'POST': + $wargaId = $_POST['warga_id'] ?? null; + if (!$wargaId) sendError('warga_id wajib'); + if (empty($_FILES['foto'])) sendError('Tidak ada file diunggah'); + + $existing = (int)$pdo->query("SELECT COUNT(*) FROM foto_rumah WHERE warga_id=" . (int)$wargaId)->fetchColumn(); + + // Normalisasi $_FILES['foto'] ke array + $files = $_FILES['foto']; + $names = is_array($files['name']) ? $files['name'] : [$files['name']]; + $tmps = is_array($files['tmp_name']) ? $files['tmp_name'] : [$files['tmp_name']]; + + if (!is_dir(UPLOAD_DIR)) mkdir(UPLOAD_DIR, 0775, true); + + $saved = 0; + foreach ($tmps as $i => $tmp) { + if ($existing + $saved >= MAX_FOTO) break; + if (!is_uploaded_file($tmp)) continue; + $fname = 'w' . (int)$wargaId . '_' . uniqid() . '.jpg'; + $dst = UPLOAD_DIR . $fname; + if (compressImage($tmp, $dst)) { + $rel = 'uploads/rumah/' . $fname; + $pdo->prepare("INSERT INTO foto_rumah (warga_id, file_path) VALUES (?,?)") + ->execute([$wargaId, $rel]); + $saved++; + } + } + if ($saved === 0) sendError('Gagal memproses foto (format harus JPG/PNG/WEBP)'); + logActivity($pdo, 'create', 'foto_rumah', (int)$wargaId, "Upload {$saved} foto"); + sendSuccess(['uploaded' => $saved], "{$saved} foto diunggah", 201); + break; + + case 'DELETE': + $id = $_GET['id'] ?? null; + if (!$id) sendError('ID wajib'); + $row = $pdo->prepare("SELECT file_path FROM foto_rumah WHERE id=?"); + $row->execute([$id]); + $fp = $row->fetchColumn(); + if ($fp) { + $abs = __DIR__ . '/../' . $fp; + if (is_file($abs)) @unlink($abs); + $pdo->prepare("DELETE FROM foto_rumah WHERE id=?")->execute([$id]); + logActivity($pdo, 'delete', 'foto_rumah', (int)$id, 'Hapus foto'); + } + sendSuccess(null, 'Foto dihapus'); + break; + + default: + sendError('Method not allowed', 405); +} diff --git a/project_final/api/heatmap.php b/project_final/api/heatmap.php new file mode 100644 index 0000000..02701ca --- /dev/null +++ b/project_final/api/heatmap.php @@ -0,0 +1,31 @@ +query( + "SELECT ST_Y(geom) AS lat, ST_X(geom) AS lng, jumlah_tanggungan + FROM warga_miskin" +); + +$points = []; +while ($r = $stmt->fetch()) { + $points[] = [ + (float)$r['lat'], + (float)$r['lng'], + (int)$r['jumlah_tanggungan'] + 1, + ]; +} + +sendSuccess($points, 'Heatmap Kepadatan'); diff --git a/project_final/api/helpers.php b/project_final/api/helpers.php new file mode 100644 index 0000000..24ad712 --- /dev/null +++ b/project_final/api/helpers.php @@ -0,0 +1,67 @@ + 'success', 'message' => $message, 'data' => $data]); + exit; +} + +function sendError(string $message = 'Bad Request', int $code = 400): void { + http_response_code($code); + echo json_encode(['status' => 'error', 'message' => $message, 'data' => null]); + exit; +} + +function getInput(): array { + return json_decode(file_get_contents('php://input'), true) ?? []; +} + +function requireApiRole(string $role): void { + requireApiAnyRole([$role]); +} + +function requireApiAnyRole(array $roles): void { + if (session_status() === PHP_SESSION_NONE) { + startAppSession(); + } + + if (!isset($_SESSION['user_id'], $_SESSION['role'])) { + sendError('Unauthorized', 401); + } + + if (!in_array($_SESSION['role'], $roles, true)) { + sendError('Forbidden', 403); + } +} + +/** + * Mutasi data (POST/PUT/DELETE) hanya untuk Operator. + * GET tetap terbuka sesuai kebutuhan endpoint. + */ +function requireOperatorForMutation(): void { + if ($_SERVER['REQUEST_METHOD'] !== 'GET') { + requireApiAnyRole(['operator']); + } +} + +/** + * Alias kompatibilitas lama. + */ +function requireAdminForMutation(): void { + requireOperatorForMutation(); +} diff --git a/project_final/api/komparatif.php b/project_final/api/komparatif.php new file mode 100644 index 0000000..e3ecfdc --- /dev/null +++ b/project_final/api/komparatif.php @@ -0,0 +1,40 @@ +query(" + SELECT w.nama, w.jumlah_penduduk, + COUNT(DISTINCT wm.id) AS miskin, + COUNT(DISTINCT CASE WHEN bs.id IS NOT NULL THEN wm.id END) AS dibantu, + COUNT(DISTINCT CASE WHEN wm.status_verifikasi='terverifikasi' THEN wm.id END) AS terverif + FROM wilayah w + LEFT JOIN warga_miskin wm ON ST_Contains(w.geom, wm.geom) + LEFT JOIN bantuan_sosial bs ON bs.warga_id = wm.id AND bs.is_aktif=1 + GROUP BY w.id ORDER BY w.nama +")->fetchAll(); + +$labels = []; $persenMiskin = []; $cakupan = []; $jumlahMiskin = []; $persenVerif = []; +foreach ($rows as $r) { + $miskin = (int)$r['miskin']; $penduduk = (int)$r['jumlah_penduduk']; + $labels[] = $r['nama']; + $persenMiskin[] = $penduduk > 0 ? round($miskin / $penduduk * 100, 2) : 0; + $cakupan[] = $miskin > 0 ? round($r['dibantu'] / $miskin * 100, 1) : 0; + $jumlahMiskin[] = $miskin; + $persenVerif[] = $miskin > 0 ? round($r['terverif'] / $miskin * 100, 1) : 0; +} + +sendSuccess([ + 'labels' => $labels, + 'persentase_miskin' => $persenMiskin, + 'cakupan_bantuan' => $cakupan, + 'jumlah_miskin' => $jumlahMiskin, + 'persen_verifikasi' => $persenVerif, +], 'Komparatif Wilayah'); diff --git a/project_final/api/laporan.php b/project_final/api/laporan.php new file mode 100644 index 0000000..07212a6 --- /dev/null +++ b/project_final/api/laporan.php @@ -0,0 +1,104 @@ + 'error', 'message' => 'Unauthorized']); + exit; +} + +$pdo = Database::getConnection(); +$method = $_SERVER['REQUEST_METHOD']; + +try { + if ($method === 'GET') { + // Jika parameter my_reports=1, hanya laporan milik user yang login + // Jika tidak dan role=admin, ambil semua. + $my_reports = $_GET['my_reports'] ?? 0; + + $sql = "SELECT l.*, ST_X(l.geometry) as lng, ST_Y(l.geometry) as lat, u.nama_lengkap, u.username + FROM laporan_warga l + JOIN users u ON l.user_id = u.id "; + + if ($my_reports || $_SESSION['role'] !== 'admin') { + // User biasa hanya boleh melihat laporannya sendiri di tabel dashboard, + // tapi mungkin butuh lihat semua laporan di peta publik? + // Untuk peta public (semua laporan disetujui atau diproses), tambahkan parameter public=1 + if (isset($_GET['public']) && $_GET['public'] == 1) { + $sql .= " WHERE l.status IN ('diproses', 'selesai') "; + } else { + $sql .= " WHERE l.user_id = " . (int)$_SESSION['user_id']; + } + } + $sql .= " ORDER BY l.id DESC"; + + $rows = $pdo->query($sql)->fetchAll(); + + $features = []; + foreach ($rows as $row) { + $features[] = [ + 'type' => 'Feature', + 'properties' => [ + 'id' => $row['id'], + 'user_id' => $row['user_id'], + 'pelapor' => $row['nama_lengkap'] ?: $row['username'], + 'kategori' => $row['kategori'], + 'deskripsi' => $row['deskripsi'], + 'status' => $row['status'], + 'created_at' => $row['created_at'] + ], + 'geometry' => [ + 'type' => 'Point', + 'coordinates' => [(float)$row['lng'], (float)$row['lat']] + ] + ]; + } + echo json_encode([ + 'type' => 'FeatureCollection', + 'features' => $features + ]); + } + elseif ($method === 'POST') { + $input = json_decode(file_get_contents('php://input'), true); + if (!isset($input['kategori'], $input['deskripsi'], $input['geometry'])) { + throw new Exception("Data tidak lengkap."); + } + $coords = $input['geometry']['coordinates']; + $stmt = $pdo->prepare("INSERT INTO laporan_warga (user_id, kategori, deskripsi, geometry) VALUES (?, ?, ?, ST_GeomFromText(?))"); + $stmt->execute([ + $_SESSION['user_id'], + $input['kategori'], + $input['deskripsi'], + "POINT({$coords[0]} {$coords[1]})" + ]); + echo json_encode(['status' => 'success', 'message' => 'Laporan berhasil dibuat.']); + } + elseif ($method === 'PUT') { + if ($_SESSION['role'] !== 'admin') throw new Exception("Unauthorized"); + $id = $_GET['id'] ?? 0; + $input = json_decode(file_get_contents('php://input'), true); + $status = $input['status'] ?? 'menunggu'; + $stmt = $pdo->prepare("UPDATE laporan_warga SET status = ? WHERE id = ?"); + $stmt->execute([$status, $id]); + echo json_encode(['status' => 'success', 'message' => 'Status laporan diperbarui.']); + } + elseif ($method === 'DELETE') { + $id = $_GET['id'] ?? 0; + // User bisa hapus kalau status menunggu. Admin bisa hapus semua. + if ($_SESSION['role'] === 'admin') { + $stmt = $pdo->prepare("DELETE FROM laporan_warga WHERE id = ?"); + $stmt->execute([$id]); + } else { + $stmt = $pdo->prepare("DELETE FROM laporan_warga WHERE id = ? AND user_id = ? AND status = 'menunggu'"); + $stmt->execute([$id, $_SESSION['user_id']]); + } + echo json_encode(['status' => 'success', 'message' => 'Laporan dihapus.']); + } +} catch (Exception $e) { + http_response_code(400); + echo json_encode(['status' => 'error', 'message' => $e->getMessage()]); +} diff --git a/project_final/api/laporan_bantuan.php b/project_final/api/laporan_bantuan.php new file mode 100644 index 0000000..5ae8877 --- /dev/null +++ b/project_final/api/laporan_bantuan.php @@ -0,0 +1,67 @@ +query(" + SELECT jb.kode, jb.nama AS jenis_nama, w.nama_kk, w.nik_kk, + bs.tgl_mulai, bs.tgl_berakhir, w.status_verifikasi, + (SELECT wl.nama FROM wilayah wl WHERE ST_Contains(wl.geom, w.geom) LIMIT 1) AS kelurahan + FROM bantuan_sosial bs + JOIN warga_miskin w ON w.id = bs.warga_id + JOIN jenis_bantuan jb ON jb.id = bs.jenis_bantuan_id + WHERE bs.is_aktif = 1 + ORDER BY jb.id, w.nama_kk +")->fetchAll(); + +// kelompokkan per jenis +$grup = []; +foreach ($rows as $r) $grup[$r['kode'] . ' β€” ' . $r['jenis_nama']][] = $r; + +if ($format === 'json') { + header('Content-Type: application/json'); + echo json_encode(['status' => 'success', 'data' => ['total' => count($rows), 'jenis' => array_map('count', $grup)]]); + exit; +} +?> + +Rekap Penerima Bantuan + + +

Rekapitulasi Penerima Bantuan Sosial

+
WebGIS Pemetaan Kemiskinan Β· dicetak Β· total penerima aktif
+

Belum ada penerima bantuan aktif.

+ $list): ?> +

()

+ + + + $r): ?> + + + + + + + + + + + +
#Nama KKNIKKelurahanMulaiBerakhirVerifikasi
+ + diff --git a/project_final/api/laporan_wilayah.php b/project_final/api/laporan_wilayah.php new file mode 100644 index 0000000..12cf25a --- /dev/null +++ b/project_final/api/laporan_wilayah.php @@ -0,0 +1,92 @@ +prepare("SELECT id, nama, jumlah_penduduk FROM wilayah WHERE id=?"); +$wil->execute([$wid]); +$w = $wil->fetch(); +if (!$w) { http_response_code(404); echo 'Wilayah tidak ditemukan'; exit; } + +$stmt = $pdo->prepare(" + SELECT wm.id, wm.nik_kk, wm.nama_kk, wm.penghasilan, wm.jumlah_jiwa, + wm.sumber_air, wm.material_dinding, wm.status_verifikasi, + (SELECT GROUP_CONCAT(jb.kode SEPARATOR ', ') FROM bantuan_sosial bs + JOIN jenis_bantuan jb ON jb.id=bs.jenis_bantuan_id + WHERE bs.warga_id=wm.id AND bs.is_aktif=1) AS bantuan + FROM warga_miskin wm + JOIN wilayah w ON w.id = ? + WHERE ST_Contains(w.geom, wm.geom) + ORDER BY wm.nama_kk +"); +$stmt->execute([$wid]); +$rows = $stmt->fetchAll(); + +$miskin = count($rows); +$dibantu = count(array_filter($rows, fn($r) => !empty($r['bantuan']))); +$terverif = count(array_filter($rows, fn($r) => $r['status_verifikasi'] === 'terverifikasi')); +$persen = $w['jumlah_penduduk'] > 0 ? round($miskin / $w['jumlah_penduduk'] * 100, 2) : 0; +$cakupan = $miskin > 0 ? round($dibantu / $miskin * 100, 1) : 0; + +if ($format === 'json') { + header('Content-Type: application/json'); + echo json_encode(['status' => 'success', 'data' => [ + 'nama' => $w['nama'], 'penduduk' => (int)$w['jumlah_penduduk'], + 'miskin' => $miskin, 'dibantu' => $dibantu, 'terverif' => $terverif, + 'persen' => $persen, 'cakupan' => $cakupan, + ]]); + exit; +} +?> + +Laporan <?= htmlspecialchars($w['nama']) ?> + + +

Laporan Kemiskinan β€”

+
WebGIS Pemetaan Kemiskinan Β· dicetak
+
+
Rumah Tangga Miskin
+
%
dari KK
+
%
Cakupan Bantuan
+
Terverifikasi
+
+ + + + $r): ?> + + + + + + + + + + + + + + +
#NIKNama KKJiwaPenghasilanSumber AirDindingBantuanVerifikasi
Rp
Tidak ada data di wilayah ini.
+ diff --git a/project_final/api/proximity.php b/project_final/api/proximity.php new file mode 100644 index 0000000..4df6867 --- /dev/null +++ b/project_final/api/proximity.php @@ -0,0 +1,51 @@ +quote($jenis) : ""; + +$sql = " + SELECT w.id, w.nama_kk, ST_AsGeoJSON(w.geom) AS geojson, + (SELECT ST_Distance_Sphere(w.geom, fp.geom) FROM fasilitas_publik fp $jenisFilter + ORDER BY ST_Distance_Sphere(w.geom, fp.geom) LIMIT 1) AS jarak_m, + (SELECT fp.nama FROM fasilitas_publik fp $jenisFilter + ORDER BY ST_Distance_Sphere(w.geom, fp.geom) LIMIT 1) AS fasilitas, + (SELECT fp.jenis FROM fasilitas_publik fp $jenisFilter + ORDER BY ST_Distance_Sphere(w.geom, fp.geom) LIMIT 1) AS fasilitas_jenis + FROM warga_miskin w + ORDER BY jarak_m DESC +"; +$stmt = $pdo->query($sql); + +$features = []; +$terisolasi = 0; +while ($r = $stmt->fetch()) { + $jarak = $r['jarak_m'] !== null ? round((float)$r['jarak_m']) : null; + $iso = ($jarak !== null && $jarak > $radius); + if ($iso) $terisolasi++; + $features[] = [ + 'type' => 'Feature', + 'geometry' => json_decode($r['geojson']), + 'properties' => [ + 'id' => (int)$r['id'], 'nama_kk' => $r['nama_kk'], + 'jarak_m' => $jarak, 'jarak_km' => $jarak !== null ? round($jarak / 1000, 2) : null, + 'fasilitas' => $r['fasilitas'], 'fasilitas_jenis' => $r['fasilitas_jenis'], + 'terisolasi' => $iso, + ], + ]; +} +sendSuccess([ + 'type' => 'FeatureCollection', 'features' => $features, + 'radius_m' => $radius, 'jumlah_terisolasi' => $terisolasi, 'total' => count($features), +], 'Proximity Analysis'); diff --git a/project_final/api/tren.php b/project_final/api/tren.php new file mode 100644 index 0000000..8430e90 --- /dev/null +++ b/project_final/api/tren.php @@ -0,0 +1,45 @@ += 0; $i--) { + $months[date('Y-m', strtotime("-$i month"))] = ['warga' => 0, 'bantuan' => 0]; +} + +foreach ($pdo->query("SELECT DATE_FORMAT(created_at,'%Y-%m') ym, COUNT(*) c FROM warga_miskin GROUP BY ym") as $r) { + if (isset($months[$r['ym']])) $months[$r['ym']]['warga'] = (int)$r['c']; +} +foreach ($pdo->query("SELECT DATE_FORMAT(created_at,'%Y-%m') ym, COUNT(*) c FROM bantuan_sosial GROUP BY ym") as $r) { + if (isset($months[$r['ym']])) $months[$r['ym']]['bantuan'] = (int)$r['c']; +} + +// warga sebelum jendela 12 bulan (untuk basis kumulatif) +$firstMonth = array_key_first($months); +$base = (int)$pdo->query("SELECT COUNT(*) FROM warga_miskin WHERE DATE_FORMAT(created_at,'%Y-%m') < '$firstMonth'")->fetchColumn(); + +$labels = []; $wargaBaru = []; $kumulatif = []; $bantuanBaru = []; +$run = $base; +foreach ($months as $ym => $v) { + $labels[] = $ym; + $wargaBaru[] = $v['warga']; + $run += $v['warga']; + $kumulatif[] = $run; + $bantuanBaru[] = $v['bantuan']; +} + +sendSuccess([ + 'labels' => $labels, + 'warga_baru' => $wargaBaru, + 'kumulatif' => $kumulatif, + 'bantuan_baru' => $bantuanBaru, +], 'Tren Waktu'); diff --git a/project_final/api/users.php b/project_final/api/users.php new file mode 100644 index 0000000..94979be --- /dev/null +++ b/project_final/api/users.php @@ -0,0 +1,94 @@ +query( + "SELECT id, username, email, role, is_active, wilayah_tugas, nama_lengkap, last_login, created_at + FROM users ORDER BY id ASC" + ); + sendSuccess($stmt->fetchAll(), 'Data Users'); + break; + + case 'POST': + $d = getInput(); + if (empty($d['username']) || empty($d['password']) || empty($d['role'])) { + sendError('Data tidak lengkap'); + } + if (!in_array($d['role'], VALID_ROLES, true)) sendError('Role tidak valid'); + + $hash = password_hash($d['password'], PASSWORD_BCRYPT, ['cost' => 12]); + try { + $pdo->prepare( + "INSERT INTO users (username, email, password, role, is_active, wilayah_tugas, nama_lengkap) + VALUES (?,?,?,?,?,?,?)" + )->execute([ + $d['username'], + $d['email'] ?? null, + $hash, + $d['role'], + isset($d['is_active']) ? (int)$d['is_active'] : 1, + $d['wilayah_tugas'] ?? null, + $d['nama_lengkap'] ?? '', + ]); + $id = (int)$pdo->lastInsertId(); + logActivity($pdo, 'create', 'users', $id, "Tambah user {$d['username']} ({$d['role']})"); + sendSuccess(['id' => $id], 'User disimpan', 201); + } catch (Exception $e) { + sendError('Username sudah digunakan'); + } + break; + + case 'PUT': + $d = getInput(); + $id = $_GET['id'] ?? null; + if (!$id) sendError('ID wajib'); + if (!empty($d['role']) && !in_array($d['role'], VALID_ROLES, true)) sendError('Role tidak valid'); + + if (!empty($d['password'])) { + $hash = password_hash($d['password'], PASSWORD_BCRYPT, ['cost' => 12]); + $pdo->prepare( + "UPDATE users SET username=?, email=?, password=?, role=?, is_active=?, wilayah_tugas=?, nama_lengkap=? + WHERE id=?" + )->execute([ + $d['username'], $d['email'] ?? null, $hash, $d['role'], + isset($d['is_active']) ? (int)$d['is_active'] : 1, + $d['wilayah_tugas'] ?? null, $d['nama_lengkap'] ?? '', $id, + ]); + } else { + $pdo->prepare( + "UPDATE users SET username=?, email=?, role=?, is_active=?, wilayah_tugas=?, nama_lengkap=? + WHERE id=?" + )->execute([ + $d['username'], $d['email'] ?? null, $d['role'], + isset($d['is_active']) ? (int)$d['is_active'] : 1, + $d['wilayah_tugas'] ?? null, $d['nama_lengkap'] ?? '', $id, + ]); + } + logActivity($pdo, 'update', 'users', (int)$id, "Edit user {$d['username']}"); + sendSuccess(null, 'User diperbarui'); + break; + + case 'DELETE': + $id = $_GET['id'] ?? null; + if (!$id) sendError('ID wajib'); + if ($id == 1) sendError('Akun utama tidak dapat dihapus'); + if ($id == ($_SESSION['user_id'] ?? null)) sendError('Tidak dapat menghapus akun sendiri'); + $pdo->prepare("DELETE FROM users WHERE id=?")->execute([$id]); + logActivity($pdo, 'delete', 'users', (int)$id, 'Hapus user'); + sendSuccess(null, 'User dihapus'); + break; + + default: + sendError('Method not allowed', 405); +} diff --git a/project_final/api/warga_export.php b/project_final/api/warga_export.php new file mode 100644 index 0000000..60b947a --- /dev/null +++ b/project_final/api/warga_export.php @@ -0,0 +1,93 @@ +query(" + SELECT w.id, w.nik_kk, w.nama_kk, wl.nama AS kelurahan, w.alamat, w.rt, w.rw, + w.jumlah_jiwa, w.jumlah_tanggungan, w.pekerjaan_kk, w.penghasilan, + w.sumber_air, w.status_listrik, w.material_dinding, w.status_kepemilikan, w.status_verifikasi, + ST_Y(w.geom) AS lat, ST_X(w.geom) AS lng, + (SELECT GROUP_CONCAT(jb.kode SEPARATOR '; ') + FROM bantuan_sosial bs JOIN jenis_bantuan jb ON jb.id=bs.jenis_bantuan_id + WHERE bs.warga_id=w.id AND bs.is_aktif=1) AS bantuan + FROM warga_miskin w LEFT JOIN wilayah wl ON wl.id=w.wilayah_id + ORDER BY w.id +")->fetchAll(); + +if ($format === 'csv') { + header('Content-Type: text/csv; charset=utf-8'); + header('Content-Disposition: attachment; filename="data_warga_' . date('Ymd') . '.csv"'); + $out = fopen('php://output', 'w'); + fputcsv($out, ['ID','NIK KK','Nama KK','Kelurahan','Alamat','RT','RW','Jiwa','Tanggungan','Pekerjaan','Penghasilan','Sumber Air','Listrik','Dinding','Kepemilikan','Verifikasi','Bantuan Aktif','Lat','Lng']); + foreach ($rows as $r) { + fputcsv($out, [ + $r['id'], $r['nik_kk'], $r['nama_kk'], $r['kelurahan'], $r['alamat'], $r['rt'], $r['rw'], + $r['jumlah_jiwa'], $r['jumlah_tanggungan'], $r['pekerjaan_kk'], $r['penghasilan'], + $r['sumber_air'], $r['status_listrik'], $r['material_dinding'], $r['status_kepemilikan'], + $r['status_verifikasi'], $r['bantuan'], $r['lat'], $r['lng'], + ]); + } + fclose($out); + exit; +} + +// format=print β†’ HTML untuk dicetak/disimpan PDF +?> + + + +Laporan Data Warga Miskin + + +
+

Laporan Data Rumah Tangga Miskin

+
WebGIS Pemetaan Kemiskinan β€” dicetak β€” total rumah tangga
+ + + + $r): ?> + + + + + + + + + + + + + + +
#NIKNama KKKelurahanJiwaPenghasilanSumber AirDindingBantuanVerifikasi
Rp
+ diff --git a/project_final/api/warga_import.php b/project_final/api/warga_import.php new file mode 100644 index 0000000..8942f16 --- /dev/null +++ b/project_final/api/warga_import.php @@ -0,0 +1,83 @@ + id +$wmap = []; +foreach ($pdo->query("SELECT id, kode_wilayah FROM wilayah") as $w) { + if ($w['kode_wilayah']) $wmap[trim($w['kode_wilayah'])] = $w['id']; +} + +$header = fgetcsv($fh); +if (!$header) sendError('CSV kosong'); +$header = array_map(fn($h) => strtolower(trim($h)), $header); +$idx = array_flip($header); + +$need = ['nama_kk', 'latitude', 'longitude']; +foreach ($need as $col) { + if (!isset($idx[$col])) sendError("Kolom wajib '$col' tidak ditemukan di CSV"); +} + +$enumAir = ['SUMUR_BOR','PDAM','SUNGAI','HUJAN','TIDAK_ADA']; +$enumListrik = ['PLN','NON_PLN','TIDAK_ADA']; +$enumDinding = ['PERMANEN','SEMI_PERMANEN','TIDAK_PERMANEN']; +$val = fn($row, $key) => isset($idx[$key]) && isset($row[$idx[$key]]) ? trim($row[$idx[$key]]) : null; +$enumOrNull = fn($v, $allowed) => ($v && in_array(strtoupper($v), $allowed, true)) ? strtoupper($v) : null; + +$ins = $pdo->prepare(" + INSERT INTO warga_miskin + (nama_kk, nik_kk, wilayah_id, jumlah_jiwa, pekerjaan_kk, penghasilan, jumlah_tanggungan, + sumber_air, status_listrik, material_dinding, status_verifikasi, created_by, geom) + VALUES (?,?,?,?,?,?,?,?,?,?, 'belum', ?, ST_GeomFromText(?)) +"); + +$ok = 0; $fail = 0; $errors = []; $line = 1; +while (($row = fgetcsv($fh)) !== false) { + $line++; + if (count(array_filter($row, fn($x) => $x !== '' && $x !== null)) === 0) continue; // baris kosong + $nama = $val($row, 'nama_kk'); + $lat = $val($row, 'latitude'); + $lng = $val($row, 'longitude'); + if (!$nama) { $fail++; $errors[] = "Baris $line: nama_kk kosong"; continue; } + if (!is_numeric($lat) || !is_numeric($lng)) { $fail++; $errors[] = "Baris $line: koordinat tidak valid"; continue; } + + $kode = $val($row, 'kode_kelurahan'); + $wid = ($kode && isset($wmap[$kode])) ? $wmap[$kode] : null; + try { + $ins->execute([ + $nama, $val($row, 'nik_kk'), $wid, + ($val($row, 'jumlah_jiwa') !== null && $val($row, 'jumlah_jiwa') !== '') ? (int)$val($row, 'jumlah_jiwa') : null, + $val($row, 'pekerjaan_kk'), + (float)($val($row, 'penghasilan') ?? 0), + (int)($val($row, 'jumlah_tanggungan') ?? 0), + $enumOrNull($val($row, 'sumber_air'), $enumAir), + $enumOrNull($val($row, 'status_listrik'), $enumListrik), + $enumOrNull($val($row, 'material_dinding'), $enumDinding), + $_SESSION['user_id'] ?? null, + sprintf('POINT(%F %F)', (float)$lng, (float)$lat), + ]); + $ok++; + } catch (Exception $e) { + $fail++; $errors[] = "Baris $line: " . $e->getMessage(); + } +} +fclose($fh); + +logActivity($pdo, 'create', 'warga_miskin', null, "Import CSV: $ok berhasil, $fail gagal"); +sendSuccess(['berhasil' => $ok, 'gagal' => $fail, 'errors' => array_slice($errors, 0, 15)], 'Import selesai'); diff --git a/project_final/api/warga_miskin.php b/project_final/api/warga_miskin.php new file mode 100644 index 0000000..93b2f29 --- /dev/null +++ b/project_final/api/warga_miskin.php @@ -0,0 +1,160 @@ +prepare("SELECT id FROM wilayah WHERE nama = ? LIMIT 1"); + $st->execute([$tugas]); + $id = $st->fetchColumn(); + return $id ? (int)$id : null; +} + +/** Pastikan record milik enumerator yang sedang login. */ +function assertOwn(PDO $pdo, $id, string $role, $uid): void { + if ($role !== 'enumerator') return; // operator bebas + $st = $pdo->prepare("SELECT created_by FROM warga_miskin WHERE id=?"); + $st->execute([$id]); + $owner = $st->fetchColumn(); + if ((int)$owner !== (int)$uid) sendError('Anda hanya dapat mengubah data yang Anda input', 403); +} + +switch ($method) { + case 'GET': + // Enumerator hanya melihat data yang ia input (KF-26) + $where = ($role === 'enumerator') ? "WHERE w.created_by = " . (int)$uid : ""; + $stmt = $pdo->query(" + SELECT w.id, w.nama_kk, w.nik_kk, w.alamat, w.wilayah_id, w.rt, w.rw, + w.jumlah_jiwa, w.pekerjaan_kk, w.penghasilan, w.jumlah_tanggungan, + w.sumber_air, w.status_listrik, w.material_dinding, w.status_kepemilikan, + w.status_verifikasi, w.created_at, + ST_AsGeoJSON(w.geom) AS geojson, + (SELECT GROUP_CONCAT(jb.kode SEPARATOR ',') + FROM bantuan_sosial bs JOIN jenis_bantuan jb ON jb.id = bs.jenis_bantuan_id + WHERE bs.warga_id = w.id AND bs.is_aktif = 1) AS bantuan_aktif, + (SELECT COUNT(*) FROM foto_rumah f WHERE f.warga_id = w.id) AS jumlah_foto + FROM warga_miskin w + $where + ORDER BY w.id DESC + "); + $features = []; + while ($r = $stmt->fetch()) { + $features[] = [ + 'type' => 'Feature', + 'geometry' => json_decode($r['geojson']), + 'properties' => [ + 'id' => (int)$r['id'], 'nama_kk' => $r['nama_kk'], 'nik_kk' => $r['nik_kk'], + 'alamat' => $r['alamat'], 'wilayah_id' => $r['wilayah_id'], + 'rt' => $r['rt'], 'rw' => $r['rw'], 'jumlah_jiwa' => $r['jumlah_jiwa'], + 'pekerjaan_kk' => $r['pekerjaan_kk'], 'penghasilan' => $r['penghasilan'], + 'jumlah_tanggungan' => $r['jumlah_tanggungan'], 'sumber_air' => $r['sumber_air'], + 'status_listrik' => $r['status_listrik'], 'material_dinding' => $r['material_dinding'], + 'status_kepemilikan' => $r['status_kepemilikan'], 'status_verifikasi' => $r['status_verifikasi'], + 'bantuan_aktif' => $r['bantuan_aktif'], 'jumlah_foto' => (int)$r['jumlah_foto'], + 'created_at' => $r['created_at'], + ], + ]; + } + sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Warga Miskin'); + break; + + case 'POST': + $d = getInput(); + if (empty($d['nama_kk']) || empty($d['geometry'])) sendError('Nama KK & lokasi wajib diisi'); + // Enumerator: wilayah dipaksa ke wilayah penugasannya + $wilayahId = ($role === 'enumerator') ? enumeratorWilayahId($pdo) : nn($d['wilayah_id'] ?? null); + // Enumerator: data baru selalu berstatus "belum" untuk diverifikasi operator + $verif = ($role === 'enumerator') ? 'belum' : ($d['status_verifikasi'] ?? 'belum'); + $pdo->prepare(" + INSERT INTO warga_miskin + (nama_kk, nik_kk, alamat, wilayah_id, rt, rw, jumlah_jiwa, pekerjaan_kk, + penghasilan, jumlah_tanggungan, sumber_air, status_listrik, material_dinding, + status_kepemilikan, status_verifikasi, created_by, geom) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, ST_GeomFromGeoJSON(?)) + ")->execute([ + $d['nama_kk'], nn($d['nik_kk'] ?? null), nn($d['alamat'] ?? null), $wilayahId, + nn($d['rt'] ?? null), nn($d['rw'] ?? null), nn($d['jumlah_jiwa'] ?? null), nn($d['pekerjaan_kk'] ?? null), + $d['penghasilan'] ?? 0, $d['jumlah_tanggungan'] ?? 0, + nn($d['sumber_air'] ?? null), nn($d['status_listrik'] ?? null), nn($d['material_dinding'] ?? null), + nn($d['status_kepemilikan'] ?? null), $verif, $uid, json_encode($d['geometry']), + ]); + $newId = (int)$pdo->lastInsertId(); + logActivity($pdo, 'create', 'warga_miskin', $newId, "Tambah warga {$d['nama_kk']}"); + sendSuccess(['id' => $newId], 'Warga disimpan', 201); + break; + + case 'PUT': + $d = getInput(); + $id = $_GET['id'] ?? null; + if (!$id) sendError('ID wajib'); + + // Mode verifikasi β†’ hanya operator + if (isset($d['status_verifikasi']) && count($d) === 1) { + if ($role !== 'operator') sendError('Hanya operator yang dapat memverifikasi', 403); + $pdo->prepare("UPDATE warga_miskin SET status_verifikasi=? WHERE id=?")->execute([$d['status_verifikasi'], $id]); + logActivity($pdo, 'update', 'warga_miskin', (int)$id, "Verifikasi: {$d['status_verifikasi']}"); + sendSuccess(null, 'Status verifikasi diperbarui'); + } + + assertOwn($pdo, $id, $role, $uid); + + // Mode lokasi saja + if (!empty($d['geometry']) && empty($d['nama_kk'])) { + $pdo->prepare("UPDATE warga_miskin SET geom=ST_GeomFromGeoJSON(?) WHERE id=?")->execute([json_encode($d['geometry']), $id]); + logActivity($pdo, 'update', 'warga_miskin', (int)$id, 'Ubah lokasi warga'); + sendSuccess(null, 'Lokasi diperbarui'); + } + + // Mode data lengkap. Enumerator tidak boleh mengubah status verifikasi. + $verifSet = ($role === 'operator') ? ($d['status_verifikasi'] ?? 'belum') : null; + if ($verifSet !== null) { + $sql = "UPDATE warga_miskin SET nama_kk=?, nik_kk=?, alamat=?, rt=?, rw=?, jumlah_jiwa=?, pekerjaan_kk=?, + penghasilan=?, jumlah_tanggungan=?, sumber_air=?, status_listrik=?, material_dinding=?, + status_kepemilikan=?, status_verifikasi=? WHERE id=?"; + $params = [$d['nama_kk'], nn($d['nik_kk'] ?? null), nn($d['alamat'] ?? null), nn($d['rt'] ?? null), nn($d['rw'] ?? null), + nn($d['jumlah_jiwa'] ?? null), nn($d['pekerjaan_kk'] ?? null), $d['penghasilan'] ?? 0, $d['jumlah_tanggungan'] ?? 0, + nn($d['sumber_air'] ?? null), nn($d['status_listrik'] ?? null), nn($d['material_dinding'] ?? null), + nn($d['status_kepemilikan'] ?? null), $verifSet, $id]; + } else { + $sql = "UPDATE warga_miskin SET nama_kk=?, nik_kk=?, alamat=?, rt=?, rw=?, jumlah_jiwa=?, pekerjaan_kk=?, + penghasilan=?, jumlah_tanggungan=?, sumber_air=?, status_listrik=?, material_dinding=?, + status_kepemilikan=? WHERE id=?"; + $params = [$d['nama_kk'], nn($d['nik_kk'] ?? null), nn($d['alamat'] ?? null), nn($d['rt'] ?? null), nn($d['rw'] ?? null), + nn($d['jumlah_jiwa'] ?? null), nn($d['pekerjaan_kk'] ?? null), $d['penghasilan'] ?? 0, $d['jumlah_tanggungan'] ?? 0, + nn($d['sumber_air'] ?? null), nn($d['status_listrik'] ?? null), nn($d['material_dinding'] ?? null), + nn($d['status_kepemilikan'] ?? null), $id]; + } + $pdo->prepare($sql)->execute($params); + logActivity($pdo, 'update', 'warga_miskin', (int)$id, 'Edit data warga'); + sendSuccess(null, 'Warga diperbarui'); + break; + + case 'DELETE': + if ($role !== 'operator') sendError('Hanya operator yang dapat menghapus data', 403); + $id = $_GET['id'] ?? null; + if (!$id) sendError('ID wajib'); + $pdo->prepare("DELETE FROM warga_miskin WHERE id=?")->execute([$id]); + logActivity($pdo, 'delete', 'warga_miskin', (int)$id, 'Hapus data warga'); + sendSuccess(null, 'Warga dihapus'); + break; + + default: + sendError('Method not allowed', 405); +} diff --git a/project_final/api/wilayah.php b/project_final/api/wilayah.php new file mode 100644 index 0000000..2af3529 --- /dev/null +++ b/project_final/api/wilayah.php @@ -0,0 +1,89 @@ +query($sql); + $features = []; + while ($r = $stmt->fetch()) { + $penduduk = (int)$r['jumlah_penduduk']; + $miskin = (int)$r['jumlah_miskin']; + $persen = $penduduk > 0 ? round($miskin / $penduduk * 100, 2) : 0; + $features[] = [ + 'type' => 'Feature', + 'geometry' => json_decode($r['geojson']), + 'properties' => [ + 'id' => (int)$r['id'], 'nama' => $r['nama'], 'jenis' => $r['jenis'], + 'kode_wilayah' => $r['kode_wilayah'], 'jumlah_penduduk' => $penduduk, + 'jumlah_miskin' => $miskin, 'persentase_miskin' => $persen, + ], + ]; + } + sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Data Wilayah'); + break; + + case 'POST': + $d = getInput(); + if (empty($d['nama']) || empty($d['geometry'])) sendError('Nama & geometri wajib diisi'); + $pdo->prepare("INSERT INTO wilayah (nama, kode_wilayah, jenis, jumlah_penduduk, geom) + VALUES (?,?,?,?, ST_GeomFromGeoJSON(?))") + ->execute([ + $d['nama'], nnw($d['kode_wilayah'] ?? null), $d['jenis'] ?? 'kelurahan', + (int)($d['jumlah_penduduk'] ?? 0), json_encode($d['geometry']), + ]); + $id = (int)$pdo->lastInsertId(); + logActivity($pdo, 'create', 'wilayah', $id, "Tambah wilayah {$d['nama']}"); + sendSuccess(['id' => $id], 'Wilayah disimpan', 201); + break; + + case 'PUT': + $d = getInput(); + $id = $_GET['id'] ?? null; + if (!$id) sendError('ID wajib'); + if (!empty($d['geometry'])) { + $pdo->prepare("UPDATE wilayah SET nama=?, kode_wilayah=?, jenis=?, jumlah_penduduk=?, geom=ST_GeomFromGeoJSON(?) WHERE id=?") + ->execute([$d['nama'], nnw($d['kode_wilayah'] ?? null), $d['jenis'] ?? 'kelurahan', + (int)($d['jumlah_penduduk'] ?? 0), json_encode($d['geometry']), $id]); + } else { + $pdo->prepare("UPDATE wilayah SET nama=?, kode_wilayah=?, jenis=?, jumlah_penduduk=? WHERE id=?") + ->execute([$d['nama'], nnw($d['kode_wilayah'] ?? null), $d['jenis'] ?? 'kelurahan', + (int)($d['jumlah_penduduk'] ?? 0), $id]); + } + logActivity($pdo, 'update', 'wilayah', (int)$id, 'Ubah wilayah'); + sendSuccess(null, 'Wilayah diperbarui'); + break; + + case 'DELETE': + $id = $_GET['id'] ?? null; + if (!$id) sendError('ID wajib'); + $pdo->prepare("DELETE FROM wilayah WHERE id=?")->execute([$id]); + logActivity($pdo, 'delete', 'wilayah', (int)$id, 'Hapus wilayah'); + sendSuccess(null, 'Wilayah dihapus'); + break; + + default: + sendError('Method not allowed', 405); +} diff --git a/project_final/api/zona_prioritas.php b/project_final/api/zona_prioritas.php new file mode 100644 index 0000000..6f82327 --- /dev/null +++ b/project_final/api/zona_prioritas.php @@ -0,0 +1,54 @@ +35 Tinggi, >20 Sedang, selain itu Rendah. + * GET β†’ GeoJSON wilayah + properti skor/level/komponen. + */ +require_once __DIR__ . '/../config/db.php'; +require_once __DIR__ . '/helpers.php'; + +$pdo = Database::getConnection(); +requireApiAnyRole(['operator', 'pimpinan']); +if ($_SERVER['REQUEST_METHOD'] !== 'GET') sendError('Method not allowed', 405); + +$sql = " + SELECT w.id, w.nama, w.jumlah_penduduk, ST_AsGeoJSON(w.geom) AS geojson, + COUNT(DISTINCT wm.id) AS miskin, + COUNT(DISTINCT CASE WHEN bs.id IS NOT NULL THEN wm.id END) AS dibantu + FROM wilayah w + LEFT JOIN warga_miskin wm ON ST_Contains(w.geom, wm.geom) + LEFT JOIN bantuan_sosial bs ON bs.warga_id = wm.id AND bs.is_aktif = 1 + GROUP BY w.id + ORDER BY w.nama +"; +$stmt = $pdo->query($sql); + +$features = []; +while ($r = $stmt->fetch()) { + $penduduk = (int)$r['jumlah_penduduk']; + $miskin = (int)$r['miskin']; + $dibantu = (int)$r['dibantu']; + $persen = $penduduk > 0 ? round($miskin / $penduduk * 100, 2) : 0; + $cakupan = $miskin > 0 ? round($dibantu / $miskin * 100, 1) : 0; + $gap = $miskin > 0 ? (100 - $cakupan) : 0; + $skor = round($persen * 2 + $gap * 0.2 + $miskin * 1, 1); + $level = $skor > 35 ? 'Tinggi' : ($skor > 20 ? 'Sedang' : 'Rendah'); + + $features[] = [ + 'type' => 'Feature', + 'geometry' => json_decode($r['geojson']), + 'properties' => [ + 'id' => (int)$r['id'], 'nama' => $r['nama'], + 'jumlah_penduduk' => $penduduk, 'jumlah_miskin' => $miskin, + 'dibantu' => $dibantu, 'persentase_miskin' => $persen, + 'cakupan_bantuan' => $cakupan, 'skor' => $skor, 'level' => $level, + ], + ]; +} +// urΡƒΡ‚kan ranking by skor desc +usort($features, fn($a, $b) => $b['properties']['skor'] <=> $a['properties']['skor']); +foreach ($features as $i => &$f) $f['properties']['rank'] = $i + 1; + +sendSuccess(['type' => 'FeatureCollection', 'features' => $features], 'Zona Prioritas'); diff --git a/project_final/assets/css/admin.css b/project_final/assets/css/admin.css new file mode 100644 index 0000000..aeec233 --- /dev/null +++ b/project_final/assets/css/admin.css @@ -0,0 +1,364 @@ +/* ========================================================= + ADMIN LAYOUT STYLES β€” sidebar, topbar, main content + ========================================================= */ + +body.admin-page { + display: flex; + min-height: 100vh; + background: var(--bg-base); +} + +/* ══════════════════════════════ + SIDEBAR + ══════════════════════════════ */ +.sidebar { + width: var(--sidebar-w); + flex-shrink: 0; + background: var(--sidebar-bg); + border-right: 1px solid var(--sidebar-border); + display: flex; + flex-direction: column; + height: 100vh; + position: fixed; + top: 0; left: 0; + z-index: 100; + overflow-y: auto; + transition: var(--transition); +} + +.sidebar-brand { + padding: 24px 20px; + border-bottom: 1px solid var(--sidebar-border); + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; +} + +.brand-icon { + width: 40px; height: 40px; + background: var(--primary); + border-radius: 10px; + display: flex; align-items: center; justify-content: center; + font-size: 1.1rem; + color: white; + flex-shrink: 0; + box-shadow: var(--shadow-sm); +} + +.brand-text { + display: flex; + flex-direction: column; + line-height: 1.3; +} + +.brand-name { + font-size: 0.95rem; + font-weight: 700; + color: var(--text-primary); + letter-spacing: -0.01em; +} + +.brand-sub { + font-size: 0.75rem; + color: var(--text-muted); + font-weight: 500; +} + +.sidebar-nav { + padding: 16px 0; + flex: 1; +} + +.nav-section-title { + padding: 16px 20px 8px; + font-size: 0.7rem; + font-weight: 700; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.nav-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 20px; + color: var(--text-secondary); + font-size: 0.9rem; + font-weight: 500; + text-decoration: none; + border-radius: 0; + transition: var(--transition); + position: relative; + cursor: pointer; + border: none; + background: none; + width: 100%; + text-align: left; +} + +.nav-item:hover { + background: #F3F4F6; + color: var(--text-primary); +} + +.nav-item.active { + background: #EFF6FF; + color: var(--primary-dark); +} + +.nav-item.active::before { + content: ''; + position: absolute; + left: 0; top: 0; bottom: 0; + width: 4px; + background: var(--primary); + border-radius: 0 2px 2px 0; +} + +.nav-item .nav-icon { + width: 20px; + text-align: center; + font-size: 0.95rem; + flex-shrink: 0; + color: inherit; +} + +.nav-item .nav-badge { + margin-left: auto; + background: var(--danger); + color: #fff; + font-size: 0.7rem; + font-weight: 700; + padding: 2px 8px; + border-radius: 20px; +} + +.sidebar-footer { + border-top: 1px solid var(--sidebar-border); + padding: 16px; + flex-shrink: 0; + background: #FAFAFA; +} + +.user-info { + display: flex; + align-items: center; + gap: 12px; + padding: 10px; + border-radius: var(--radius-md); + background: #FFFFFF; + border: 1px solid var(--sidebar-border); + box-shadow: var(--shadow-sm); +} + +.user-avatar { + width: 36px; height: 36px; + border-radius: 50%; + background: #E0E7FF; + display: flex; align-items: center; justify-content: center; + font-size: 0.9rem; + font-weight: 700; + color: var(--primary-dark); + flex-shrink: 0; +} + +.user-details { flex: 1; overflow: hidden; } +.user-name { font-size: 0.85rem; font-weight: 600; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.user-role { font-size: 0.75rem; color: var(--text-muted); text-transform: capitalize; } + +.user-logout { + background: none; border: none; color: var(--text-muted); + cursor: pointer; font-size: 1.1rem; padding: 6px; + transition: all 0.2s; + border-radius: var(--radius-sm); +} +.user-logout:hover { color: var(--danger); background: #FEF2F2; } + +/* ══════════════════════════════ + MAIN CONTENT AREA + ══════════════════════════════ */ +.admin-main { + margin-left: var(--sidebar-w); + flex: 1; + display: flex; + flex-direction: column; + min-height: 100vh; + min-width: 0; +} + +/* ── Topbar ── */ +.topbar { + height: 64px; + background: var(--bg-surface); + border-bottom: 1px solid var(--border-light); + display: flex; + align-items: center; + padding: 0 32px; + gap: 16px; + position: sticky; + top: 0; + z-index: 50; +} + +.topbar-title { + font-size: 1.05rem; + font-weight: 600; + color: var(--text-primary); + flex: 1; +} + +.topbar-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.topbar-time { + font-size: 0.82rem; + font-weight: 500; + color: var(--text-secondary); + display: flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + background: #F9FAFB; + border: 1px solid var(--border-light); + border-radius: var(--radius-md); +} + +/* ── Content ── */ +.admin-content { + flex: 1; + padding: 32px; + overflow-y: auto; +} + +/* ══════════════════════════════ + DASHBOARD STATS CARDS + ══════════════════════════════ */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 20px; + margin-bottom: 32px; +} + +.stat-card { + background: var(--bg-surface); + border: 1px solid var(--border-light); + border-radius: var(--radius-lg); + padding: 24px; + display: flex; + align-items: flex-start; + gap: 16px; + transition: var(--transition); + box-shadow: var(--shadow-sm); +} + +.stat-card:hover { transform: translateY(-3px); box-shadow: var(--shadow-md); border-color: var(--border-hover); } + +.stat-icon { + width: 48px; height: 48px; + border-radius: var(--radius-lg); + display: flex; align-items: center; justify-content: center; + font-size: 1.3rem; + flex-shrink: 0; +} + +.stat-body { flex: 1; min-width: 0; } +.stat-label { font-size: 0.8rem; color: var(--text-muted); font-weight: 600; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.05em; } +.stat-value { font-size: 1.9rem; font-weight: 800; color: var(--text-primary); line-height: 1; letter-spacing: -0.02em; } +.stat-change { font-size: 0.75rem; color: var(--text-muted); margin-top: 8px; font-weight: 500; } + +/* Card color variants (Flat UI style) */ +.stat-card.primary .stat-icon { background: #EFF6FF; color: #2563EB; } +.stat-card.success .stat-icon { background: #ECFDF5; color: #059669; } +.stat-card.danger .stat-icon { background: #FEF2F2; color: #DC2626; } +.stat-card.warning .stat-icon { background: #FFFBEB; color: #D97706; } +.stat-card.info .stat-icon { background: #F0F9FF; color: #0284C7; } +.stat-card.violet .stat-icon { background: #F5F3FF; color: #7C3AED; } + +/* ══════════════════════════════ + DATA TABLE PAGE LAYOUT + ══════════════════════════════ */ +.page-toolbar { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 24px; + flex-wrap: wrap; +} + +.page-toolbar .search-box { + flex: 1; + min-width: 250px; + max-width: 360px; + position: relative; +} + +.search-box .search-icon { + position: absolute; + left: 14px; top: 50%; + transform: translateY(-50%); + color: var(--text-muted); + font-size: 0.9rem; +} + +.search-box input { + width: 100%; + padding: 10px 14px 10px 38px; + background: #FFFFFF; + border: 1px solid var(--border-light); + border-radius: var(--radius-md); + color: var(--text-primary); + font-size: 0.9rem; + outline: none; + transition: var(--transition); + font-family: 'Inter', sans-serif; + box-shadow: var(--shadow-sm); +} + +.search-box input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); } +.search-box input::placeholder { color: #9CA3AF; } + +/* ══════════════════════════════ + MAP PAGE IN ADMIN + ══════════════════════════════ */ +.admin-map-page .admin-content { + padding: 0; + display: flex; + flex-direction: column; +} + +.admin-map-page .topbar { + z-index: 500; +} + +#admin-map { + flex: 1; + min-height: calc(100vh - 64px); +} + +.map-sidebar-floating { + position: absolute; + top: 16px; left: 16px; + z-index: 400; + width: 280px; + background: #FFFFFF; + border: 1px solid var(--border-light); + border-radius: var(--radius-lg); + padding: 16px; + box-shadow: var(--shadow-md); +} + +/* ══════════════════════════════ + RESPONSIVE + ══════════════════════════════ */ +@media (max-width: 768px) { + .sidebar { transform: translateX(-100%); } + .sidebar.open { transform: translateX(0); box-shadow: var(--shadow-lg); } + .admin-main { margin-left: 0; } + .stats-grid { grid-template-columns: 1fr; } +} diff --git a/project_final/assets/css/auth.css b/project_final/assets/css/auth.css new file mode 100644 index 0000000..5b92140 --- /dev/null +++ b/project_final/assets/css/auth.css @@ -0,0 +1,215 @@ +/* ========================================================= + AUTH PAGE STYLES β€” login.php + ========================================================= */ + +body.auth-page { + background: #F9FAFB; + min-height: 100vh; + padding: 20px; + overflow-y: auto; +} + +/* ── Center Panel (Form) ── */ +.auth-center { + width: 100%; + max-width: 440px; + margin: 40px auto; + display: flex; + align-items: center; + justify-content: center; + padding: 40px; + background: #FFFFFF; + border-radius: 16px; + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.05), 0 8px 10px -6px rgba(0, 0, 0, 0.01); + border: 1px solid #E5E7EB; +} + +.auth-form-wrap { + width: 100%; +} + +.auth-form-title { + font-size: 1.8rem; + font-weight: 800; + color: #111827; + margin-bottom: 8px; + letter-spacing: -0.03em; +} + +.auth-form-subtitle { + color: #6B7280; + font-size: 0.95rem; + margin-bottom: 32px; +} + +/* Role selector */ +.role-selector { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-bottom: 32px; +} + +.role-card { + position: relative; + cursor: pointer; +} + +.role-card input[type="radio"] { + position: absolute; + opacity: 0; + width: 0; height: 0; +} + +.role-card-label { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 16px 12px; + border-radius: 12px; + border: 2px solid #E5E7EB; + background: #FFFFFF; + cursor: pointer; + transition: all 0.2s ease; + text-align: center; +} + +.role-card input:checked + .role-card-label { + border-color: #2563EB; + background: #EFF6FF; +} + +.role-card-label:hover { + border-color: #D1D5DB; + background: #F9FAFB; +} + +.role-card input:checked + .role-card-label:hover { + border-color: #2563EB; +} + +.role-card-icon { + font-size: 1.5rem; + line-height: 1; +} + +.role-card-name { + font-size: 0.9rem; + font-weight: 600; + color: #111827; +} + +.role-card input:checked + .role-card-label .role-card-name { + color: #1D4ED8; +} + +.role-card-desc { + font-size: 0.75rem; + color: #6B7280; +} + +/* Login form */ +.auth-form .form-group { margin-bottom: 20px; } + +.auth-form .input-with-icon { + position: relative; +} + +.auth-form .input-with-icon .input-icon { + position: absolute; + left: 16px; + top: 50%; + transform: translateY(-50%); + color: #9CA3AF; + font-size: 1rem; + pointer-events: none; +} + +.auth-form .input-with-icon .form-control { + padding-left: 44px; + height: 46px; + border-radius: 8px; +} + +.auth-form .btn-login { + width: 100%; + padding: 14px; + font-size: 1rem; + justify-content: center; + background: #2563EB; + color: #FFFFFF; + border-radius: 8px; + margin-top: 12px; + font-weight: 600; + box-shadow: 0 4px 6px -1px rgba(37, 99, 235, 0.2); +} + +.auth-form .btn-login:hover { + background: #1D4ED8; + box-shadow: 0 6px 8px -1px rgba(37, 99, 235, 0.3); +} + +.auth-error { + background: #FEF2F2; + border: 1px solid #FECACA; + border-radius: 8px; + padding: 12px 16px; + color: #B91C1C; + font-size: 0.9rem; + margin-bottom: 24px; + display: flex; + align-items: center; + gap: 10px; +} + +.auth-hint { + margin-top: 36px; + padding-top: 24px; + border-top: 1px solid #E5E7EB; +} + +.auth-hint-title { + font-size: 0.8rem; + font-weight: 700; + color: #6B7280; + text-transform: uppercase; + letter-spacing: 0.08em; + margin-bottom: 12px; +} + +.auth-hint-creds { + display: flex; + flex-direction: column; + gap: 10px; +} + +.auth-hint-item { + display: flex; + justify-content: space-between; + align-items: center; + background: #F9FAFB; + border: 1px solid #E5E7EB; + border-radius: 8px; + padding: 10px 14px; + font-size: 0.85rem; +} + +.auth-hint-role { + font-weight: 600; + color: #374151; +} + +.auth-hint-pass { + font-family: monospace; + color: #4B5563; + background: #E5E7EB; + padding: 4px 10px; + border-radius: 6px; + font-weight: 500; +} + +/* Responsive */ +@media (max-width: 900px) { + .auth-center { width: 100%; border-left: none; } +} diff --git a/project_final/assets/css/main.css b/project_final/assets/css/main.css new file mode 100644 index 0000000..df0c78a --- /dev/null +++ b/project_final/assets/css/main.css @@ -0,0 +1,248 @@ +/* ========================================================= + WEBGIS PROJECT FINAL β€” Design System (main.css) + ========================================================= */ + +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap'); + +:root { + /* Colors - Clean Light Theme */ + --primary: #2563EB; /* Professional Blue */ + --primary-light: #3B82F6; + --primary-dark: #1D4ED8; + --accent: #10B981; + --danger: #EF4444; + --warning: #F59E0B; + --info: #0EA5E9; + + /* Backgrounds */ + --bg-base: #F9FAFB; /* Very light gray */ + --bg-surface: #FFFFFF; /* Pure white */ + --bg-elevated: #FFFFFF; + --bg-card: #FFFFFF; + + /* Borders */ + --border-light: #E5E7EB; + --border-hover: #D1D5DB; + + /* Text */ + --text-primary: #111827; /* Near black */ + --text-secondary: #4B5563; /* Dark gray */ + --text-muted: #6B7280; /* Medium gray */ + + /* Sidebar */ + --sidebar-w: 260px; + --sidebar-bg: #FFFFFF; + --sidebar-border: #E5E7EB; + + /* Misc */ + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + --transition: all 0.2s ease-in-out; +} + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html { scroll-behavior: smooth; } + +body { + font-family: 'Inter', sans-serif; + background: var(--bg-base); + color: var(--text-primary); + font-size: 14px; + line-height: 1.6; + min-height: 100vh; + -webkit-font-smoothing: antialiased; +} + +a { color: var(--primary); text-decoration: none; transition: var(--transition); } +a:hover { color: var(--primary-dark); } + +/* ── Scrollbar ── */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: #CBD5E1; border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: #94A3B8; } + +/* ── Buttons ── */ +.btn { + display: inline-flex; align-items: center; gap: 8px; + padding: 8px 16px; border-radius: var(--radius-md); border: 1px solid transparent; + font-size: 0.85rem; font-weight: 500; font-family: 'Inter', sans-serif; + cursor: pointer; transition: var(--transition); white-space: nowrap; + outline: none; +} +.btn-primary { background: var(--primary); color: #fff; box-shadow: var(--shadow-sm); } +.btn-primary:hover { background: var(--primary-dark); } +.btn-success { background: var(--accent); color: #fff; box-shadow: var(--shadow-sm); } +.btn-success:hover { filter: brightness(0.95); } +.btn-danger { background: var(--danger); color: #fff; } +.btn-danger:hover { filter: brightness(0.95); } +.btn-ghost { background: transparent; border: 1px solid var(--border-light); color: var(--text-secondary); } +.btn-ghost:hover { background: #F3F4F6; color: var(--text-primary); border-color: var(--border-hover); } +.btn-sm { padding: 6px 12px; font-size: 0.78rem; } +.btn-icon { padding: 8px; border-radius: var(--radius-sm); } + +/* ── Cards ── */ +.card { + background: var(--bg-surface); + border: 1px solid var(--border-light); + border-radius: var(--radius-lg); + padding: 24px; + box-shadow: var(--shadow-sm); +} + +/* ── Form Elements ── */ +.form-group { margin-bottom: 16px; } +.form-label { display: block; font-size: 0.82rem; font-weight: 600; color: var(--text-primary); margin-bottom: 6px; } +.form-control { + width: 100%; padding: 10px 14px; border-radius: var(--radius-md); + background: #FFFFFF; border: 1px solid var(--border-light); + color: var(--text-primary); font-size: 0.9rem; font-family: 'Inter', sans-serif; + outline: none; transition: var(--transition); box-shadow: var(--shadow-sm); +} +.form-control:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); } +.form-control::placeholder { color: #9CA3AF; } +select.form-control { + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + padding-right: 36px; +} +textarea.form-control { resize: vertical; min-height: 80px; } + +/* ── Badges ── */ +.badge { display: inline-flex; align-items: center; padding: 4px 10px; border-radius: var(--radius-xl); font-size: 0.75rem; font-weight: 600; } +.badge-primary { background: #EFF6FF; color: #1D4ED8; border: 1px solid #BFDBFE; } +.badge-success { background: #ECFDF5; color: #047857; border: 1px solid #A7F3D0; } +.badge-danger { background: #FEF2F2; color: #B91C1C; border: 1px solid #FECACA; } +.badge-warning { background: #FFFBEB; color: #B45309; border: 1px solid #FDE68A; } +.badge-info { background: #F0F9FF; color: #0369A1; border: 1px solid #BAE6FD; } + +/* ── Tables ── */ +.table-wrap { overflow-x: auto; border-radius: var(--radius-lg); border: 1px solid var(--border-light); background: var(--bg-surface); box-shadow: var(--shadow-sm); } +.data-table { width: 100%; border-collapse: collapse; font-size: 0.88rem; } +.data-table thead th { background: #F9FAFB; padding: 14px 16px; text-align: left; font-size: 0.75rem; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; white-space: nowrap; border-bottom: 1px solid var(--border-light); } +.data-table tbody tr { border-bottom: 1px solid var(--border-light); transition: background 0.15s; } +.data-table tbody tr:last-child { border-bottom: none; } +.data-table tbody tr:hover { background: #F3F4F6; } +.data-table td { padding: 14px 16px; color: var(--text-primary); vertical-align: middle; } +.data-table .actions { display: flex; gap: 6px; } + +/* ── Modal ── */ +.modal-overlay { + display: none; position: fixed; inset: 0; z-index: 9999; + background: rgba(17, 24, 39, 0.4); backdrop-filter: blur(2px); + align-items: center; justify-content: center; +} +.modal-overlay.active { display: flex; } +.modal { + background: var(--bg-surface); border: 1px solid var(--border-light); + border-radius: var(--radius-lg); padding: 32px; + width: 90%; max-width: 500px; max-height: 90vh; overflow-y: auto; + box-shadow: var(--shadow-lg); + animation: modalIn 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} +@keyframes modalIn { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } } +.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; } +.modal-title { font-size: 1.15rem; font-weight: 700; color: var(--text-primary); } +.modal-close { background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 1.4rem; padding: 4px; border-radius: var(--radius-sm); transition: all 0.15s; } +.modal-close:hover { background: #F3F4F6; color: var(--text-primary); } +.modal-footer { display: flex; gap: 12px; justify-content: flex-end; margin-top: 28px; padding-top: 20px; border-top: 1px solid var(--border-light); } + +/* ── Toast ── */ +.toast-container { position: fixed; bottom: 24px; right: 24px; z-index: 99999; display: flex; flex-direction: column; gap: 10px; } +.toast { padding: 14px 20px; border-radius: var(--radius-md); font-size: 0.9rem; font-weight: 500; color: #fff; box-shadow: var(--shadow-md); animation: toastIn 0.3s ease; transition: opacity 0.3s; } +.toast.success { background: #10B981; } +.toast.error { background: #EF4444; } +.toast.info { background: #3B82F6; } +@keyframes toastIn { from { opacity: 0; transform: translateX(30px); } to { opacity: 1; transform: translateX(0); } } + +/* SPBU realtime snackbar */ +.spbu-snackbar { + position: fixed; + right: 24px; + bottom: 24px; + z-index: 99998; + display: grid; + grid-template-columns: 38px minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + width: min(360px, calc(100vw - 32px)); + padding: 13px 14px; + color: var(--text-primary); + background: #FFFFFF; + border: 1px solid #A7F3D0; + border-left: 4px solid var(--accent); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + opacity: 0; + pointer-events: none; + transform: translateY(18px); + transition: opacity 0.25s ease, transform 0.25s ease; +} +.spbu-snackbar.show { + opacity: 1; + pointer-events: auto; + transform: translateY(0); +} +.spbu-snackbar-icon { + width: 38px; + height: 38px; + display: grid; + place-items: center; + color: #047857; + background: #ECFDF5; + border: 1px solid #A7F3D0; + border-radius: var(--radius-md); +} +.spbu-snackbar-title { + font-size: 0.84rem; + font-weight: 800; + line-height: 1.2; +} +.spbu-snackbar-meta { + margin-top: 3px; + color: var(--text-muted); + font-size: 0.72rem; + line-height: 1.3; +} +.spbu-snackbar-count { + min-width: 44px; + padding: 5px 8px; + color: #047857; + background: #ECFDF5; + border: 1px solid #A7F3D0; + border-radius: var(--radius-md); + text-align: center; + font-size: 1.05rem; + font-weight: 800; +} + +/* ── Page header ── */ +.page-header { margin-bottom: 28px; } +.page-header h1 { font-size: 1.6rem; font-weight: 700; color: var(--text-primary); letter-spacing: -0.02em; } +.page-header p { color: var(--text-secondary); margin-top: 6px; font-size: 0.9rem; } +.page-header .breadcrumb { display: flex; gap: 8px; align-items: center; font-size: 0.82rem; color: var(--text-muted); margin-bottom: 12px; } +.page-header .breadcrumb .sep { color: #D1D5DB; } + +/* ── Utilities ── */ +.text-muted { color: var(--text-muted); } +.text-secondary { color: var(--text-secondary); } +.text-success { color: var(--accent); } +.text-danger { color: var(--danger); } +.text-warning { color: var(--warning); } +.flex { display: flex; } +.flex-center { display: flex; align-items: center; justify-content: center; } +.gap-2 { gap: 8px; } +.gap-3 { gap: 12px; } +.mt-1 { margin-top: 8px; } +.mt-2 { margin-top: 16px; } +.mb-1 { margin-bottom: 8px; } +.mb-2 { margin-bottom: 16px; } +.w-full { width: 100%; } diff --git a/project_final/assets/css/map.css b/project_final/assets/css/map.css new file mode 100644 index 0000000..af8f700 --- /dev/null +++ b/project_final/assets/css/map.css @@ -0,0 +1,765 @@ +/* ========================================================= + MAP STYLES - interactive map panels and Leaflet overrides + ========================================================= */ + +.map-page { + height: 100vh; + overflow: hidden; + background: var(--bg-base); +} + +.map-wrap { + position: relative; + width: 100%; + height: calc(100vh - 64px); + background: #E5E7EB; +} + +#admin-map, +#user-map { + width: 100%; + height: 100%; + background: #E5E7EB; +} + +.leaflet-container { + font-family: 'Inter', sans-serif; + background: #E5E7EB; +} + +.leaflet-control-attribution { + background: rgba(255, 255, 255, 0.92) !important; + color: var(--text-muted) !important; + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-sm); + padding: 3px 8px; + font-size: 0.68rem; +} + +.leaflet-control-attribution a { + color: var(--primary-dark) !important; +} + +.leaflet-bar { + border: 1px solid var(--border-light) !important; + border-radius: var(--radius-md) !important; + box-shadow: var(--shadow-md) !important; + overflow: hidden; +} + +.leaflet-control-zoom a, +.leaflet-draw-toolbar a { + width: 34px !important; + height: 34px !important; + line-height: 34px !important; + background: #FFFFFF !important; + color: var(--text-primary) !important; + border: 0 !important; + border-bottom: 1px solid var(--border-light) !important; +} + +.leaflet-control-zoom a:last-child, +.leaflet-draw-toolbar a:last-child { + border-bottom: 0 !important; +} + +.leaflet-control-zoom a:hover, +.leaflet-draw-toolbar a:hover { + background: #F3F4F6 !important; + color: var(--primary-dark) !important; +} + +/* Layer panel */ +.map-sidebar { + position: absolute; + top: 16px; + left: 16px; + z-index: 500; + width: 316px; + max-height: calc(100vh - 112px); + overflow-y: auto; + background: rgba(255, 255, 255, 0.96); + border: 1px solid var(--border-light); + border-radius: var(--radius-lg); + padding: 16px; + box-shadow: var(--shadow-lg); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} + +.map-panel-header { + display: flex; + align-items: center; + gap: 12px; + padding-bottom: 14px; + margin-bottom: 14px; + border-bottom: 1px solid var(--border-light); +} + +.map-panel-icon { + width: 40px; + height: 40px; + display: grid; + place-items: center; + flex-shrink: 0; + border-radius: var(--radius-md); + background: #EFF6FF; + color: var(--primary-dark); + border: 1px solid #BFDBFE; +} + +.map-panel-header h3 { + margin: 0; + color: var(--text-primary); + font-size: 0.98rem; + line-height: 1.2; + font-weight: 700; +} + +.map-panel-header p { + margin: 3px 0 0; + color: var(--text-muted); + font-size: 0.76rem; + line-height: 1.35; +} + +.layer-section { + margin-bottom: 16px; +} + +.layer-section:last-of-type { + margin-bottom: 12px; +} + +.layer-section-title { + margin: 0 0 8px; + color: var(--text-muted); + font-size: 0.68rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.layer-item { + display: grid; + grid-template-columns: 18px 30px minmax(0, 1fr); + align-items: center; + gap: 10px; + min-height: 48px; + padding: 8px 10px; + border: 1px solid transparent; + border-radius: var(--radius-md); + color: var(--text-secondary); + cursor: pointer; + user-select: none; + transition: var(--transition); +} + +.layer-item + .layer-item { + margin-top: 4px; +} + +.layer-item:hover { + background: #F9FAFB; + border-color: var(--border-light); + color: var(--text-primary); +} + +.layer-item input[type="checkbox"] { + width: 16px; + height: 16px; + margin: 0; + accent-color: var(--primary); + cursor: pointer; +} + +.layer-color { + --layer-color: var(--primary); + width: 30px; + height: 30px; + display: grid; + place-items: center; + flex-shrink: 0; + color: #FFFFFF; + background: var(--layer-color); + border: 1px solid color-mix(in srgb, var(--layer-color) 72%, #000 28%); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.28); +} + +.layer-color i { + font-size: 0.76rem; +} + +.layer-point { + border-radius: 50%; +} + +.layer-line { + height: 4px; + border-radius: 999px; + align-self: center; + box-shadow: none; +} + +.layer-area { + border-radius: 6px; + background: color-mix(in srgb, var(--layer-color) 22%, #FFFFFF 78%); + border-color: var(--layer-color); +} + +.layer-hatch { + background: + repeating-linear-gradient(45deg, color-mix(in srgb, var(--layer-color) 78%, #FFFFFF 22%) 0 4px, transparent 4px 8px), + color-mix(in srgb, var(--layer-color) 12%, #FFFFFF 88%); +} + +.layer-gradient { + border-radius: 6px; + background: linear-gradient(90deg, #10B981 0%, #F59E0B 50%, #EF4444 100%); + border-color: #D1D5DB; +} + +.layer-text { + display: flex; + flex-direction: column; + min-width: 0; + line-height: 1.25; +} + +.layer-name { + color: var(--text-primary); + font-size: 0.86rem; + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.layer-meta { + margin-top: 2px; + color: var(--text-muted); + font-size: 0.7rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.map-quickstats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + padding-top: 14px; + border-top: 1px solid var(--border-light); +} + +.qs-item { + min-width: 0; + padding: 9px 6px; + text-align: center; + background: #F9FAFB; + border: 1px solid var(--border-light); + border-radius: var(--radius-md); +} + +.qs-value { + font-size: 1.08rem; + line-height: 1; + font-weight: 800; +} + +.qs-label { + margin-top: 3px; + color: var(--text-muted); + font-size: 0.62rem; + font-weight: 700; +} + +/* Toolbar */ +.map-analysis { + position: absolute; + top: 16px; + left: 50%; + z-index: 500; + display: flex; + align-items: center; + gap: 6px; + max-width: calc(100% - 380px); + padding: 8px; + background: rgba(255, 255, 255, 0.96); + border: 1px solid var(--border-light); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); + transform: translateX(-50%); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} + +.map-analysis button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + min-height: 34px; + padding: 7px 12px; + color: var(--text-secondary); + background: #FFFFFF; + border: 1px solid var(--border-light); + border-radius: var(--radius-md); + font-family: 'Inter', sans-serif; + font-size: 0.78rem; + font-weight: 700; + cursor: pointer; + white-space: nowrap; + box-shadow: var(--shadow-sm); + transition: var(--transition); +} + +.map-analysis button:hover { + background: #F3F4F6; + color: var(--text-primary); + border-color: var(--border-hover); +} + +.map-analysis button.active { + color: #FFFFFF; + background: var(--primary); + border-color: var(--primary); +} + +.map-analysis .map-tool-accent { + color: #047857; + background: #ECFDF5; + border-color: #A7F3D0; +} + +.map-analysis .map-tool-accent:hover { + color: #065F46; + background: #D1FAE5; + border-color: #6EE7B7; +} + +/* Nearest SPBU panel */ +.spbu-panel { + position: absolute; + top: 16px; + right: 16px; + z-index: 500; + width: 310px; + padding: 16px; + background: rgba(255, 255, 255, 0.96); + border: 1px solid var(--border-light); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} + +.spbu-panel-title { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + color: var(--text-primary); + font-size: 0.94rem; + font-weight: 800; +} + +.spbu-result { + margin-top: 10px; + padding: 12px; + background: #F9FAFB; + border: 1px solid var(--border-light); + border-radius: var(--radius-md); +} + +.spbu-result-name { + color: var(--text-primary); + font-size: 0.9rem; + font-weight: 800; +} + +.spbu-result-meta { + margin-top: 4px; + color: var(--text-muted); + font-size: 0.75rem; +} + +.spbu-result-distance { + margin-top: 8px; + color: var(--primary-dark); + font-size: 1.35rem; + line-height: 1; + font-weight: 800; +} + +/* Markers */ +.marker-pin { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + color: #FFFFFF; + background: var(--primary); + border: 2px solid #FFFFFF; + border-radius: 50% 50% 50% 0; + box-shadow: 0 6px 12px rgba(17, 24, 39, 0.28); + font-size: 0.9rem; + transform: rotate(-45deg); +} + +.marker-pin > * { + transform: rotate(45deg); +} + +.div-icon-wrap { + background: transparent; + border: none; +} + +/* Popups */ +.leaflet-popup-content-wrapper { + color: var(--text-primary) !important; + background: #FFFFFF !important; + border: 1px solid var(--border-light); + border-radius: var(--radius-lg) !important; + box-shadow: var(--shadow-lg); +} + +.leaflet-popup-tip { + background: #FFFFFF !important; +} + +.leaflet-popup-content { + margin: 13px 16px !important; + font-family: 'Inter', sans-serif; + font-size: 0.85rem; + line-height: 1.5; +} + +.popup-title { + margin-bottom: 6px; + color: var(--text-primary); + font-size: 0.98rem; + font-weight: 800; +} + +.popup-row { + display: flex; + justify-content: space-between; + gap: 12px; + margin-top: 5px; + color: var(--text-secondary); +} + +.popup-row strong { + color: var(--text-primary); +} + +.popup-actions { + display: flex; + gap: 8px; + margin-top: 12px; +} + +.popup-actions .btn { + padding: 5px 10px; + font-size: 0.75rem; +} + +/* Legend */ +.map-legend { + position: absolute; + right: 16px; + bottom: 16px; + z-index: 500; + min-width: 230px; + padding: 14px; + color: var(--text-secondary); + background: rgba(255, 255, 255, 0.96); + border: 1px solid var(--border-light); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} + +.map-legend-title { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; + color: var(--text-primary); + font-size: 0.82rem; + font-weight: 800; +} + +.legend-grid { + display: grid; + gap: 7px; +} + +.legend-item { + display: grid; + grid-template-columns: 24px minmax(0, 1fr); + align-items: center; + gap: 8px; + min-height: 22px; + color: var(--text-secondary); + font-size: 0.76rem; + font-weight: 600; +} + +.legend-symbol { + --legend-color: var(--primary); + justify-self: center; + background: var(--legend-color); +} + +.legend-dot { + width: 12px; + height: 12px; + border: 2px solid #FFFFFF; + border-radius: 50%; + box-shadow: 0 0 0 1px color-mix(in srgb, var(--legend-color) 70%, #000 30%); +} + +.legend-line { + width: 22px; + height: 4px; + border-radius: 999px; +} + +.legend-area { + width: 16px; + height: 16px; + border: 1px solid var(--legend-color); + border-radius: 4px; + background: color-mix(in srgb, var(--legend-color) 18%, #FFFFFF 82%); +} + +.legend-hatch { + width: 16px; + height: 16px; + border: 1px solid var(--legend-color); + border-radius: 4px; + background: + repeating-linear-gradient(45deg, color-mix(in srgb, var(--legend-color) 76%, #FFFFFF 24%) 0 3px, transparent 3px 6px), + color-mix(in srgb, var(--legend-color) 12%, #FFFFFF 88%); +} + +/* Modal */ +.map-modal { + position: fixed; + inset: 0; + z-index: 9999; + display: none; + align-items: center; + justify-content: center; + background: rgba(17, 24, 39, 0.45); + backdrop-filter: blur(2px); +} + +.map-modal.active { + display: flex; +} + +.map-modal-content { + width: 90%; + max-width: 500px; + max-height: 90vh; + overflow-y: auto; + padding: 28px 32px; + background: var(--bg-surface); + border: 1px solid var(--border-light); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + animation: modalIn 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +@keyframes modalIn { + from { + opacity: 0; + transform: scale(0.95) translateY(10px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +/* Loading and toast */ +.map-loading { + position: absolute; + inset: 0; + z-index: 600; + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + color: var(--text-primary); + background: rgba(249, 250, 251, 0.72); + backdrop-filter: blur(3px); + font-size: 0.95rem; + font-weight: 700; +} + +.spinner { + width: 28px; + height: 28px; + border: 3px solid rgba(37, 99, 235, 0.16); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.toast-container { + position: fixed; + right: 24px; + bottom: 24px; + z-index: 99999; + display: flex; + flex-direction: column; + gap: 10px; +} + +.toast { + max-width: 320px; + padding: 12px 20px; + color: #FFFFFF; + border-radius: var(--radius-md); + box-shadow: var(--shadow-md); + font-size: 0.88rem; + font-weight: 600; + animation: toastIn 0.3s ease; + transition: opacity 0.3s; +} + +.toast.success { background: #10B981; } +.toast.error { background: #EF4444; } +.toast.info { background: #3B82F6; } + +@keyframes toastIn { + from { + opacity: 0; + transform: translateX(30px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.read-only-badge { + position: absolute; + top: 16px; + right: 16px; + z-index: 500; + display: flex; + align-items: center; + gap: 7px; + padding: 8px 13px; + color: #047857; + background: #ECFDF5; + border: 1px solid #A7F3D0; + border-radius: var(--radius-md); + box-shadow: var(--shadow-md); + font-size: 0.78rem; + font-weight: 800; +} + +/* Responsive */ +@media (max-width: 1180px) { + .map-analysis { + left: auto; + right: 16px; + max-width: calc(100% - 364px); + overflow-x: auto; + transform: none; + } +} + +@media (max-width: 900px) { + .map-sidebar { + width: 300px; + } + + .map-analysis { + top: auto; + right: 16px; + bottom: 16px; + left: 16px; + max-width: none; + justify-content: flex-start; + } + + .map-legend { + right: 16px; + bottom: 78px; + } +} + +@media (max-width: 768px) { + .map-wrap { + height: calc(100vh - 64px); + } + + .map-sidebar { + top: 8px; + left: 8px; + width: calc(100% - 16px); + max-height: 46vh; + padding: 12px; + } + + .map-panel-header { + margin-bottom: 10px; + padding-bottom: 10px; + } + + .layer-item { + min-height: 44px; + } + + .map-quickstats { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .map-analysis { + right: 8px; + bottom: 8px; + left: 8px; + padding: 6px; + overflow-x: auto; + } + + .map-analysis button { + min-height: 32px; + padding: 6px 10px; + font-size: 0.74rem; + } + + .spbu-panel { + display: none; + } + + .map-legend { + right: 8px; + bottom: 66px; + min-width: 210px; + max-width: calc(100% - 16px); + } + + .read-only-badge { + top: auto; + right: 8px; + bottom: 66px; + } +} diff --git a/project_final/assets/js/public-map.js b/project_final/assets/js/public-map.js new file mode 100644 index 0000000..c522cb7 --- /dev/null +++ b/project_final/assets/js/public-map.js @@ -0,0 +1,141 @@ +/* ========================================================= + public-map.js β€” Peta Publik SKPL + Choropleth (KF-06) + Heatmap (KF-08) + Layer switcher (KF-09) + + Legenda + Pencarian wilayah (KF-11). + `map` & `APP_BASE` disediakan global oleh peta_publik.php. + ========================================================= */ +(function () { + const API = APP_BASE + '/api'; + + let choroplethLayer = null; + let heatLayer = null; + let wilayahFeatures = []; + + // ---- Skala warna persentase kemiskinan ---- + function colorFor(persen) { + return persen >= 10 ? '#EF4444' // merah β€” tinggi + : persen >= 5 ? '#FB923C' // oranye β€” sedang-tinggi + : persen >= 2 ? '#FBBF24' // kuning β€” sedang + : persen > 0 ? '#A3E635' // hijau muda β€” rendah + : '#2ECC71'; // hijau β€” tanpa kasus + } + + function styleWilayah(feature) { + const p = feature.properties.persentase_miskin || 0; + return { + fillColor: colorFor(p), + fillOpacity: 0.55, + color: '#374151', + weight: 1.5, + }; + } + + function popupWilayah(props) { + return `
${props.nama}
+
+ Penduduk: ${props.jumlah_penduduk}
+ Warga miskin: ${props.jumlah_miskin}
+ Persentase: ${props.persentase_miskin}% +
`; + } + + // ---- Choropleth (KF-06) ---- + async function loadChoropleth() { + const r = await fetch(`${API}/wilayah.php`); + const j = await r.json(); + const data = j.status === 'success' ? j.data : { type: 'FeatureCollection', features: [] }; + wilayahFeatures = data.features || []; + + choroplethLayer = L.geoJSON(data, { + style: styleWilayah, + onEachFeature: (f, layer) => { + layer.bindPopup(popupWilayah(f.properties)); + layer.bindTooltip(f.properties.nama, { sticky: true }); + }, + }).addTo(map); + + if (wilayahFeatures.length) { + map.fitBounds(choroplethLayer.getBounds(), { padding: [40, 40] }); + } + return choroplethLayer; + } + + // ---- Heatmap (KF-08) ---- + async function loadHeatmap() { + const r = await fetch(`${API}/heatmap.php`); + const j = await r.json(); + const points = j.status === 'success' ? j.data : []; + heatLayer = L.heatLayer(points, { + radius: 30, blur: 22, maxZoom: 17, + gradient: { 0.2: '#3B82F6', 0.5: '#FBBF24', 0.9: '#EF4444' }, + }); + return heatLayer; + } + + // ---- Legenda (KF-06) ---- + function addLegend() { + const legend = L.control({ position: 'bottomleft' }); + legend.onAdd = function () { + const div = L.DomUtil.create('div', 'legend'); + const grades = [ + { c: '#2ECC71', t: 'Tidak ada kasus' }, + { c: '#A3E635', t: '< 2% miskin' }, + { c: '#FBBF24', t: '2 – 5%' }, + { c: '#FB923C', t: '5 – 10%' }, + { c: '#EF4444', t: '> 10% (prioritas)' }, + ]; + div.innerHTML = '

Tingkat Kemiskinan

' + + grades.map(g => `
${g.t}
`).join(''); + return div; + }; + legend.addTo(map); + } + + // ---- Pencarian wilayah (KF-11) ---- + function addSearch() { + const ctrl = L.control({ position: 'topright' }); + ctrl.onAdd = function () { + const div = L.DomUtil.create('div', 'map-search'); + div.innerHTML = ` +
`; + L.DomEvent.disableClickPropagation(div); + return div; + }; + ctrl.addTo(map); + + const input = document.getElementById('searchWilayah'); + const results = document.getElementById('searchResults'); + + input.addEventListener('input', function () { + const q = this.value.toLowerCase().trim(); + results.innerHTML = ''; + if (!q) return; + wilayahFeatures + .filter(f => f.properties.nama.toLowerCase().includes(q)) + .slice(0, 6) + .forEach(f => { + const item = document.createElement('div'); + item.className = 'search-item'; + item.textContent = f.properties.nama; + item.onclick = () => { + const layer = L.geoJSON(f); + map.fitBounds(layer.getBounds(), { padding: [60, 60] }); + results.innerHTML = ''; + input.value = f.properties.nama; + }; + results.appendChild(item); + }); + }); + } + + // ---- Inisialisasi ---- + Promise.all([loadChoropleth(), loadHeatmap()]).then(([choro, heat]) => { + // Layer switcher (KF-09) + L.control.layers(null, { + 'Batas Kelurahan (Choropleth)': choro, + 'Heatmap Kepadatan': heat, + }, { collapsed: false, position: 'topright' }).addTo(map); + addLegend(); + addSearch(); + }); +})(); diff --git a/project_final/assets/js/skpl-layers.js b/project_final/assets/js/skpl-layers.js new file mode 100644 index 0000000..79b5ad9 --- /dev/null +++ b/project_final/assets/js/skpl-layers.js @@ -0,0 +1,127 @@ +/* ========================================================= + skpl-layers.js β€” Layer SKPL reusable untuk peta login. + Choropleth kelurahan (KF-06) + Heatmap (KF-08) + legenda + + layer switcher overlay (KF-09) + pencarian wilayah (KF-11). + + Pakai: panggil attachSkplLayers(map) setelah map dibuat. + Butuh leaflet.heat sudah dimuat di halaman. + ========================================================= */ +(function () { + function injectStyle() { + if (document.getElementById('skpl-layer-style')) return; + const css = ` + .skpl-legend{background:#fff;padding:10px 12px;border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,.15);font-size:.76rem;line-height:1.6} + .skpl-legend h4{font-size:.78rem;margin:0 0 6px} + .skpl-legend i{display:inline-block;width:13px;height:13px;border-radius:3px;margin-right:6px;vertical-align:middle} + .skpl-search{background:#fff;border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,.15);padding:6px;width:220px} + .skpl-search input{width:100%;border:1px solid #E5E7EB;border-radius:7px;padding:7px 9px;font-size:.83rem;outline:none} + .skpl-search input:focus{border-color:#2563EB} + .skpl-results .item{padding:7px 9px;font-size:.82rem;cursor:pointer;border-radius:6px} + .skpl-results .item:hover{background:#EFF6FF;color:#1D4ED8}`; + const s = document.createElement('style'); + s.id = 'skpl-layer-style'; + s.textContent = css; + document.head.appendChild(s); + } + + function colorFor(p) { + return p >= 10 ? '#EF4444' : p >= 5 ? '#FB923C' : p >= 2 ? '#FBBF24' : p > 0 ? '#A3E635' : '#2ECC71'; + } + + window.attachSkplLayers = async function (map) { + if (!map || typeof L === 'undefined') return; + injectStyle(); + const API = (typeof APP_BASE !== 'undefined' ? APP_BASE : '') + '/api'; + let features = []; + + // Choropleth + let choro = null; + try { + const j = await (await fetch(`${API}/wilayah.php`)).json(); + const data = j.status === 'success' ? j.data : { type: 'FeatureCollection', features: [] }; + features = data.features || []; + choro = L.geoJSON(data, { + style: f => ({ + fillColor: colorFor(f.properties.persentase_miskin || 0), + fillOpacity: 0.5, color: '#374151', weight: 1.5, + }), + onEachFeature: (f, layer) => { + const p = f.properties; + layer.bindPopup(`
${p.nama}
+
+ Penduduk: ${p.jumlah_penduduk}
+ Warga miskin: ${p.jumlah_miskin}
+ Persentase: ${p.persentase_miskin}%
`); + layer.bindTooltip(p.nama, { sticky: true }); + }, + }); + } catch (e) { /* abaikan */ } + + // Heatmap + let heat = null; + try { + const j = await (await fetch(`${API}/heatmap.php`)).json(); + const pts = j.status === 'success' ? j.data : []; + if (L.heatLayer) { + heat = L.heatLayer(pts, { + radius: 30, blur: 22, maxZoom: 17, + gradient: { 0.2: '#3B82F6', 0.5: '#FBBF24', 0.9: '#EF4444' }, + }); + } + } catch (e) { /* abaikan */ } + + // Layer switcher overlay + const overlays = {}; + if (choro) overlays['πŸ—ΊοΈ Choropleth Kelurahan'] = choro; + if (heat) overlays['πŸ”₯ Heatmap Kepadatan'] = heat; + if (Object.keys(overlays).length) { + L.control.layers(null, overlays, { collapsed: true, position: 'topright' }).addTo(map); + } + + // Legenda + const legend = L.control({ position: 'bottomleft' }); + legend.onAdd = function () { + const div = L.DomUtil.create('div', 'skpl-legend'); + const g = [ + ['#2ECC71', 'Tidak ada kasus'], ['#A3E635', '< 2%'], + ['#FBBF24', '2 – 5%'], ['#FB923C', '5 – 10%'], ['#EF4444', '> 10% (prioritas)'], + ]; + div.innerHTML = '

Tingkat Kemiskinan

' + + g.map(x => `
${x[1]}
`).join(''); + return div; + }; + legend.addTo(map); + + // Pencarian wilayah (KF-11) + const search = L.control({ position: 'topright' }); + search.onAdd = function () { + const div = L.DomUtil.create('div', 'skpl-search'); + div.innerHTML = ` +
`; + L.DomEvent.disableClickPropagation(div); + return div; + }; + search.addTo(map); + const root = document.querySelector('.skpl-search'); + if (root) { + const input = root.querySelector('input'); + const res = root.querySelector('.skpl-results'); + input.addEventListener('input', function () { + const q = this.value.toLowerCase().trim(); + res.innerHTML = ''; + if (!q) return; + features.filter(f => f.properties.nama.toLowerCase().includes(q)).slice(0, 6).forEach(f => { + const it = document.createElement('div'); + it.className = 'item'; + it.textContent = f.properties.nama; + it.onclick = () => { + map.fitBounds(L.geoJSON(f).getBounds(), { padding: [60, 60] }); + res.innerHTML = ''; + input.value = f.properties.nama; + }; + res.appendChild(it); + }); + }); + } + }; +})(); diff --git a/project_final/assets/js/skpl-map.js b/project_final/assets/js/skpl-map.js new file mode 100644 index 0000000..5d23435 --- /dev/null +++ b/project_final/assets/js/skpl-map.js @@ -0,0 +1,75 @@ +/* ========================================================= + skpl-map.js β€” Peta SKPL (operator & pimpinan) + Warga miskin (POI) + fasilitas publik + choropleth + heatmap + (choropleth/heatmap/legenda/pencarian via attachSkplLayers). + Role ditentukan global SKPL_ROLE ('operator' | 'pimpinan'). + ========================================================= */ +(function () { + const API = APP_BASE + '/api'; + const ROLE = (typeof SKPL_ROLE !== 'undefined') ? SKPL_ROLE : 'pimpinan'; + let map, wargaLayer, fasilitasLayer; + + const FAS_ICON = { sekolah: '🏫', puskesmas: 'πŸ₯', pasar: 'πŸͺ', kantor_kelurahan: 'πŸ›οΈ', rumah_ibadah: 'πŸ•Œ', lainnya: 'πŸ“' }; + + function divIcon(emoji, bg, size = 30) { + return L.divIcon({ + className: 'div-icon-wrap', + html: `
${emoji}
`, + iconSize: [size, size], iconAnchor: [size / 2, size], popupAnchor: [0, -size], + }); + } + const wargaIcon = divIcon('πŸ‘€', '#EF4444'); + + function wargaPopup(p) { + const vb = { terverifikasi: '#10B981', perlu_tinjauan: '#F59E0B', belum: '#9CA3AF' }[p.status_verifikasi] || '#9CA3AF'; + let h = `
${p.nama_kk}
`; + if (p.nik_kk) h += `
NIK: ${p.nik_kk}
`; + if (p.alamat) h += `
${p.alamat}
`; + h += `
Penghasilan: Rp ${Number(p.penghasilan||0).toLocaleString('id-ID')}
`; + h += `
Jiwa: ${p.jumlah_jiwa||'-'} Β· Tanggungan: ${p.jumlah_tanggungan||0}
`; + h += `
Air: ${p.sumber_air||'-'} Β· Dinding: ${p.material_dinding||'-'}
`; + h += `
Bantuan: ${p.bantuan_aktif||'Belum ada'}
`; + h += `
Status: ${p.status_verifikasi||'belum'}${p.jumlah_foto?' Β· πŸ“· '+p.jumlah_foto:''}
`; + if (ROLE === 'operator') { + h += `
+ + +
`; + } + return h + `
`; + } + + async function loadWarga() { + const j = await (await fetch(`${API}/warga_miskin.php`)).json(); + const data = j.status === 'success' ? j.data : { features: [] }; + wargaLayer = L.geoJSON(data, { + pointToLayer: (f, ll) => L.marker(ll, { icon: wargaIcon }), + onEachFeature: (f, l) => l.bindPopup(wargaPopup(f.properties)), + }).addTo(map); + } + async function loadFasilitas() { + const j = await (await fetch(`${API}/fasilitas.php`)).json(); + const data = j.status === 'success' ? j.data : { features: [] }; + fasilitasLayer = L.geoJSON(data, { + pointToLayer: (f, ll) => L.marker(ll, { icon: divIcon(FAS_ICON[f.properties.jenis] || 'πŸ“', '#2563EB', 26) }), + onEachFeature: (f, l) => l.bindPopup(`${f.properties.nama}
${f.properties.jenis.replace('_',' ')}`), + }); + } + + window.SKPLMAP_del = async function (id) { + if (!confirm('Hapus data warga ini?')) return; + const r = await (await fetch(`${API}/warga_miskin.php?id=${id}`, { method: 'DELETE' })).json(); + if (r.status === 'success') { map.removeLayer(wargaLayer); loadWarga(); } + else alert(r.message || 'Gagal'); + }; + + document.addEventListener('DOMContentLoaded', async () => { + map = L.map('skpl-map').setView([-0.0500, 109.3450], 13); + L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { subdomains: 'abcd', maxZoom: 19, attribution: 'Β© OpenStreetMap, Β© CARTO' }).addTo(map); + await loadWarga(); + await loadFasilitas(); + if (window.attachSkplLayers) await window.attachSkplLayers(map); + // tambahkan toggle warga & fasilitas + L.control.layers(null, { 'πŸ‘€ Warga Miskin': wargaLayer, '🏒 Fasilitas Publik': fasilitasLayer }, { collapsed: true, position: 'topright' }).addTo(map); + }); +})(); diff --git a/project_final/config/activity.php b/project_final/config/activity.php new file mode 100644 index 0000000..09b310c --- /dev/null +++ b/project_final/config/activity.php @@ -0,0 +1,38 @@ +prepare( + "INSERT INTO activity_log + (user_id, username, aksi, tabel_target, record_id, keterangan, ip_address) + VALUES (?, ?, ?, ?, ?, ?, ?)" + ); + $stmt->execute([ + $_SESSION['user_id'] ?? null, + $_SESSION['username'] ?? null, + $aksi, + $tabel, + $recordId, + $keterangan, + $_SERVER['REMOTE_ADDR'] ?? null, + ]); + } catch (Throwable $e) { + // Abaikan β€” logging tidak boleh menggagalkan request. + } +} diff --git a/project_final/config/app.php b/project_final/config/app.php new file mode 100644 index 0000000..52d509f --- /dev/null +++ b/project_final/config/app.php @@ -0,0 +1,46 @@ + 'admin/index.php', + 'pimpinan' => 'pimpinan/index.php', + 'enumerator' => 'enumerator/index.php', + default => 'peta_publik.php', + }); +} + +function requireLogin(?string $redirect = null): void { + if (!isLoggedIn()) { + $redirect = $redirect ?? app_url('login.php'); + header("Location: $redirect"); + exit; + } +} + +/** + * Izinkan akses hanya jika role pengguna ada di daftar $roles. + * Jika tidak, dialihkan ke beranda role-nya sendiri. + */ +function requireAnyRole(array $roles, ?string $redirect = null): void { + requireLogin($redirect); + if (!in_array(currentRole(), $roles, true)) { + header('Location: ' . roleHome(currentRole())); + exit; + } +} + +/** + * Kompatibilitas dengan kode lama: requireRole('operator'). + */ +function requireRole(string $role, ?string $redirect = null): void { + requireAnyRole([$role], $redirect); +} + +/** + * Cek role tanpa redirect (untuk tampilan kondisional menu). + */ +function hasAnyRole(array $roles): bool { + return isLoggedIn() && in_array(currentRole(), $roles, true); +} + +function currentUser(): array { + return [ + 'id' => $_SESSION['user_id'] ?? null, + 'username' => $_SESSION['username'] ?? '', + 'email' => $_SESSION['email'] ?? '', + 'role' => $_SESSION['role'] ?? '', + 'wilayah_tugas'=> $_SESSION['wilayah_tugas'] ?? '', + 'nama_lengkap' => $_SESSION['nama_lengkap'] ?? '', + ]; +} + +/** + * Label role yang ramah ditampilkan. + */ +function roleLabel(string $role): string { + return match ($role) { + 'operator' => 'Operator', + 'pimpinan' => 'Pimpinan', + 'enumerator' => 'Enumerator', + 'publik' => 'Publik', + default => ucfirst($role), + }; +} diff --git a/project_final/config/db.php b/project_final/config/db.php new file mode 100644 index 0000000..e90bd12 --- /dev/null +++ b/project_final/config/db.php @@ -0,0 +1,57 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + self::$conn->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + + // Disable strict mode for GROUP BY to prevent 1055 errors in Coolify's MariaDB + self::$conn->exec("SET SESSION sql_mode=(SELECT REPLACE(@@sql_mode,'ONLY_FULL_GROUP_BY',''))"); + + // Auto-initialize database if empty + $stmt = self::$conn->query("SHOW TABLES LIKE 'users'"); + if ($stmt->rowCount() == 0) { + $sqlFile = __DIR__ . '/../../database/webgis_db.sql'; + if (file_exists($sqlFile)) { + $sql = file_get_contents($sqlFile); + self::$conn->exec($sql); + } + } + } + return self::$conn; + } +} diff --git a/project_final/config/session.php b/project_final/config/session.php new file mode 100644 index 0000000..14a6858 --- /dev/null +++ b/project_final/config/session.php @@ -0,0 +1,24 @@ + 0, + 'path' => app_base_path() ?: '/', + 'secure' => $isHttps, + 'httponly' => true, + 'samesite' => 'Lax', + ]); + + session_start(); +} diff --git a/project_final/enumerator/data.php b/project_final/enumerator/data.php new file mode 100644 index 0000000..5b17538 --- /dev/null +++ b/project_final/enumerator/data.php @@ -0,0 +1,161 @@ +prepare(" + SELECT w.*, wl.nama AS wilayah_nama, + (SELECT COUNT(*) FROM foto_rumah f WHERE f.warga_id=w.id) AS jumlah_foto + FROM warga_miskin w LEFT JOIN wilayah wl ON wl.id=w.wilayah_id + WHERE w.created_by=? ORDER BY w.id DESC +"); +$stmt->execute([$uid]); +$rows = $stmt->fetchAll(); +?> + + + + + + Data Saya β€” Enumerator + + + + + +
+ +

Data yang Saya Input ()

+
+
+
Belum ada data.
Tambahkan via "Input Data Warga Baru".
+ +
+
+
+
+
Β· Rp Β· πŸ“·
+
+ +
+
+ + +
+
+ +
+ + +
+

✏️ Edit Data

+ + + + +
+ + + + + + +
+ + +
+

πŸ“· Foto Kondisi Rumah

+
+ + + +
+ +
+ + + + diff --git a/project_final/enumerator/index.php b/project_final/enumerator/index.php new file mode 100644 index 0000000..0ad4a1d --- /dev/null +++ b/project_final/enumerator/index.php @@ -0,0 +1,91 @@ +query("SELECT COUNT(*) FROM warga_miskin WHERE created_by=" . (int)$uid)->fetchColumn(); +$verif = (int)$pdo->query("SELECT COUNT(*) FROM warga_miskin WHERE created_by=" . (int)$uid . " AND status_verifikasi='terverifikasi'")->fetchColumn(); +$belum = $mine - $verif; + +$recent = $pdo->prepare("SELECT id, nama_kk, status_verifikasi, created_at FROM warga_miskin WHERE created_by=? ORDER BY id DESC LIMIT 5"); +$recent->execute([$uid]); +$recentRows = $recent->fetchAll(); +?> + + + + + + Enumerator β€” WebGIS Kemiskinan + + + + + +
+

Halo,

+

Petugas Lapangan Β· Wilayah:

+
+ +
+
+
Input Saya
+
Terverifikasi
+
Menunggu
+
+ + + Input Data Warga Baru + + + Data yang Saya Input + + + Lihat Peta Sebaran + + +
+

πŸ•˜ Input Terbaru

+ +
+
+ +
+ +

Belum ada data. Mulai dengan "Input Data Warga Baru".

+ +
+ + Keluar +
+ + diff --git a/project_final/enumerator/input.php b/project_final/enumerator/input.php new file mode 100644 index 0000000..d199043 --- /dev/null +++ b/project_final/enumerator/input.php @@ -0,0 +1,190 @@ + + + + + + + Input Data Warga β€” Enumerator + + + + + + +
+ +

Input Data Warga

Wilayah:
+
+ +
+
+

πŸ“ Lokasi (Geotagging)

+ +
+
Gunakan GPS, ketuk peta, geser pin, atau ketik koordinat manual di bawah.
+
+
+
+
+
+ +
+

πŸ‘€ Identitas

+ + + +
+
+ +
+

πŸ‘¨β€πŸ‘©β€πŸ‘§ Demografi & Ekonomi

+
+
+
+
+ + +
+ +
+

🏠 Kondisi Rumah

+ + + + + + + + +
+ +
+

πŸ“· Foto Kondisi Rumah

+ +
+
+
+ +
+ +
+ +
+ + + + + diff --git a/project_final/import.php b/project_final/import.php new file mode 100644 index 0000000..09d6687 --- /dev/null +++ b/project_final/import.php @@ -0,0 +1,28 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + // Read SQL file + $sqlFile = __DIR__ . '/../database/webgis_db.sql'; + if (!file_exists($sqlFile)) { + die("SQL file not found at: $sqlFile"); + } + + $sql = file_get_contents($sqlFile); + + // Split into statements and execute + $pdo->exec($sql); + + echo "Database imported successfully to $db at $host!"; +} catch (PDOException $e) { + echo "Connection failed: " . $e->getMessage() . "\n"; +} diff --git a/project_final/index.php b/project_final/index.php new file mode 100644 index 0000000..9e4b8f8 --- /dev/null +++ b/project_final/index.php @@ -0,0 +1,10 @@ + time(); + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + require_once __DIR__ . '/config/db.php'; + require_once __DIR__ . '/config/activity.php'; + + if ($isLocked) { + $sisa = (int)ceil(($lockUntil - time()) / 60); + $error = "Terlalu banyak percobaan gagal. Coba lagi dalam {$sisa} menit."; + } else { + $username = trim($_POST['username'] ?? ''); + $password = $_POST['password'] ?? ''; + + if ($username && $password) { + $pdo = Database::getConnection(); + $stmt = $pdo->prepare("SELECT * FROM users WHERE username = ? LIMIT 1"); + $stmt->execute([$username]); + $user = $stmt->fetch(); + + if ($user && (int)$user['is_active'] !== 1) { + $error = 'Akun ini dinonaktifkan. Hubungi operator.'; + } elseif ($user && password_verify($password, $user['password'])) { + // Sukses β€” reset counter & buat sesi + unset($_SESSION['login_attempts'], $_SESSION['login_lock_until']); + session_regenerate_id(true); + $_SESSION['user_id'] = $user['id']; + $_SESSION['username'] = $user['username']; + $_SESSION['email'] = $user['email']; + $_SESSION['role'] = $user['role']; + $_SESSION['wilayah_tugas'] = $user['wilayah_tugas']; + $_SESSION['nama_lengkap'] = $user['nama_lengkap']; + + $pdo->prepare("UPDATE users SET last_login = NOW() WHERE id = ?")->execute([$user['id']]); + logActivity($pdo, 'login', null, null, 'Login berhasil'); + + header('Location: ' . roleHome($user['role'])); + exit; + } else { + // Gagal β€” naikkan counter + $attempts++; + $_SESSION['login_attempts'] = $attempts; + if ($attempts >= MAX_LOGIN_ATTEMPTS) { + $_SESSION['login_lock_until'] = time() + LOCKOUT_SECONDS; + $error = 'Terlalu banyak percobaan gagal. Akun dikunci sementara 15 menit.'; + } else { + $sisa = MAX_LOGIN_ATTEMPTS - $attempts; + $error = "Username atau password salah. Sisa percobaan: {$sisa}."; + } + } + } else { + $error = 'Username dan password wajib diisi.'; + } + } +} +?> + + + + + + Login - WebGIS Pemetaan Kemiskinan + + + + + + + +
+
+

Selamat Datang

+

Masuk ke WebGIS Pemetaan Kemiskinan Terpadu.

+ +
+ Hak akses mengikuti akun: Operator, Pimpinan, atau Enumerator. + Masyarakat umum dapat lihat peta publik tanpa login. +
+ + +
+ + +
+ + + +
+
+ +
+ + +
+
+
+ +
+ + + +
+
+ +
+
+
+ + + + diff --git a/project_final/logout.php b/project_final/logout.php new file mode 100644 index 0000000..feaf2a0 --- /dev/null +++ b/project_final/logout.php @@ -0,0 +1,11 @@ +query("SELECT id, nama FROM wilayah ORDER BY nama")->fetchAll(); +$jenisOpt = $pdo->query("SELECT id, kode FROM jenis_bantuan ORDER BY id")->fetchAll(); +?> + + +
+ + + + + +
+ + + + + +
+
+
+

Kriteria

+ + + + + + + + + +
+ + +
+ + + + +
β€”
+
+
+
+
+ + + + + + + + + + + + + + + + + + diff --git a/project_final/partials/laporan_body.php b/project_final/partials/laporan_body.php new file mode 100644 index 0000000..e36005d --- /dev/null +++ b/project_final/partials/laporan_body.php @@ -0,0 +1,64 @@ +query("SELECT id, nama FROM wilayah ORDER BY nama")->fetchAll(); +?> + + +
+
+

πŸ“ Laporan per Kelurahan

+

Pilih kelurahan untuk laporan lengkap (statistik + daftar rumah tangga), siap dicetak/PDF.

+
+ + +
+
+
+
+

πŸ’³ Rekap Penerima Bantuan

+

Daftar seluruh penerima bantuan aktif, dikelompokkan per jenis (PKH, BPNT, dll).

+ Buka Rekap Bantuan + Export Data CSV +
+
+ +
+

πŸ“Š Grafik Komparatif Antar Kelurahan

+
+
+ + + diff --git a/project_final/peta_publik.php b/project_final/peta_publik.php new file mode 100644 index 0000000..fbce103 --- /dev/null +++ b/project_final/peta_publik.php @@ -0,0 +1,63 @@ + + + + + + + Peta Sebaran Kemiskinan β€” WebGIS Publik + + + + + + + +
+

Peta Sebaran Kemiskinan + Data agregat publik β€” Pemerintah Kota

+ Login Petugas +
+
+ + + + + + + diff --git a/project_final/pimpinan/analisis.php b/project_final/pimpinan/analisis.php new file mode 100644 index 0000000..2d696ed --- /dev/null +++ b/project_final/pimpinan/analisis.php @@ -0,0 +1,11 @@ +'; +require_once __DIR__ . '/partials/header.php'; +require_once __DIR__ . '/../partials/analisis_body.php'; +require_once __DIR__ . '/partials/footer.php'; diff --git a/project_final/pimpinan/index.php b/project_final/pimpinan/index.php new file mode 100644 index 0000000..b262bec --- /dev/null +++ b/project_final/pimpinan/index.php @@ -0,0 +1,108 @@ +query("SELECT COUNT(*) FROM warga_miskin")->fetchColumn(); +$dibantu = (int)$pdo->query("SELECT COUNT(DISTINCT warga_id) FROM bantuan_sosial WHERE is_aktif=1")->fetchColumn(); +$terverif = (int)$pdo->query("SELECT COUNT(*) FROM warga_miskin WHERE status_verifikasi='terverifikasi'")->fetchColumn(); +$cakupan = $total > 0 ? round($dibantu / $total * 100, 1) : 0; + +// Zona prioritas (skor sama dgn api/zona_prioritas) +$zrows = $pdo->query(" + SELECT w.nama, w.jumlah_penduduk, + COUNT(DISTINCT wm.id) AS miskin, + COUNT(DISTINCT CASE WHEN bs.id IS NOT NULL THEN wm.id END) AS dibantu + FROM wilayah w + LEFT JOIN warga_miskin wm ON ST_Contains(w.geom, wm.geom) + LEFT JOIN bantuan_sosial bs ON bs.warga_id=wm.id AND bs.is_aktif=1 + GROUP BY w.id +")->fetchAll(); +$zona = []; +foreach ($zrows as $r) { + $m = (int)$r['miskin']; $p = (int)$r['jumlah_penduduk']; + $persen = $p > 0 ? round($m/$p*100,2) : 0; + $cak = $m > 0 ? round($r['dibantu']/$m*100,1) : 0; + $gap = $m > 0 ? (100-$cak) : 0; + $skor = round($persen*2 + $gap*0.2 + $m*1, 1); + $level = $skor > 35 ? 'Tinggi' : ($skor > 20 ? 'Sedang' : 'Rendah'); + $zona[] = ['nama'=>$r['nama'],'skor'=>$skor,'level'=>$level,'persen'=>$persen,'cakupan'=>$cak]; +} +usort($zona, fn($a,$b)=>$b['skor']<=>$a['skor']); +$zonaTinggi = count(array_filter($zona, fn($z)=>$z['level']==='Tinggi')); + +$pageTitle = 'Dashboard Eksekutif'; +$activeNav = 'dashboard'; +$extraHead = ''; +require_once __DIR__ . '/partials/header.php'; +?> + + +
+
+
Total RT Miskin
kelurahan
+
+
Cakupan Bantuan
%
RT dibantu
+
+
Terverifikasi
dari data
+
+
Zona Prioritas Tinggi
kelurahan
+
+ +
+

Cakupan Bantuan

+

Penerima per Jenis Bantuan

+
+ +
+

Tren 12 Bulan

+

Komparatif Kelurahan (radar)

+
+ +
+
+

πŸ† Zona Prioritas Teratas

+ $z): $c=['Tinggi'=>'#EF4444','Sedang'=>'#FB923C','Rendah'=>'#10B981'][$z['level']]; ?> +
+
# +
% miskin % Β· cakupan %
+ Skor Β· +
+ +
+ +
+ + + + diff --git a/project_final/pimpinan/laporan.php b/project_final/pimpinan/laporan.php new file mode 100644 index 0000000..187f33b --- /dev/null +++ b/project_final/pimpinan/laporan.php @@ -0,0 +1,191 @@ +prepare("SELECT id, kategori, deskripsi, status, created_at FROM laporan_warga WHERE user_id = ? ORDER BY id DESC"); +$stmt->execute([$_SESSION['user_id']]); +$rows = $stmt->fetchAll(); + +$pageTitle = 'Laporan Warga'; +$activeNav = 'laporan'; +$extraHead = ''; +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'; +} +?> + + + +
+ + +
+ +
+ + + + + + + + + + + + + $r): ?> + + + + + + + + + + + + + + + +
#KategoriDeskripsi SingkatStatusTanggalAksi
+ + +
+ + + + Dikunci + +
+
+ Anda belum pernah membuat laporan. +
+
+ + + + + + +JS; ?> + diff --git a/project_final/pimpinan/partials/footer.php b/project_final/pimpinan/partials/footer.php new file mode 100644 index 0000000..5226ba2 --- /dev/null +++ b/project_final/pimpinan/partials/footer.php @@ -0,0 +1,54 @@ +
+
+ + +
+ + + + + diff --git a/project_final/pimpinan/partials/header.php b/project_final/pimpinan/partials/header.php new file mode 100644 index 0000000..d538496 --- /dev/null +++ b/project_final/pimpinan/partials/header.php @@ -0,0 +1,93 @@ + + + + + + + <?= htmlspecialchars($pageTitle ?? 'Portal Pimpinan') ?> β€” WebGIS Kemiskinan + + + + + + + + + + + + + +
+
+ +
+
+ +
+ + Mode Baca + +
+
+
diff --git a/project_final/pimpinan/pelaporan.php b/project_final/pimpinan/pelaporan.php new file mode 100644 index 0000000..b3bab67 --- /dev/null +++ b/project_final/pimpinan/pelaporan.php @@ -0,0 +1,10 @@ + +'; +require_once __DIR__ . '/partials/header.php'; +?> + +
+
+
+ + + + + + + + diff --git a/project_final/uploads/.gitkeep b/project_final/uploads/.gitkeep new file mode 100644 index 0000000..e69de29