Initial WebGIS portal project
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
.git
|
||||||
|
.env
|
||||||
|
**/.git
|
||||||
|
**/.env
|
||||||
|
Leaflet2/uploads
|
||||||
|
*.log
|
||||||
|
node_modules
|
||||||
|
vendor
|
||||||
|
tmp
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
APP_PORT=8080
|
||||||
|
DB_PORT=3307
|
||||||
|
|
||||||
|
DB_HOST=db
|
||||||
|
DB_DATABASE=webgis_miskin
|
||||||
|
DB_USERNAME=webgis_user
|
||||||
|
DB_PASSWORD=webgis_password
|
||||||
|
DB_CHARSET=utf8mb4
|
||||||
|
|
||||||
|
SPBU_DB_HOST=db
|
||||||
|
SPBU_DB_DATABASE=webgis_spbu
|
||||||
|
SPBU_DB_USERNAME=webgis_user
|
||||||
|
SPBU_DB_PASSWORD=webgis_password
|
||||||
|
|
||||||
|
DB_SETUP_USERNAME=root
|
||||||
|
MYSQL_ROOT_PASSWORD=root_password
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
.env
|
||||||
|
**/.env
|
||||||
|
*.log
|
||||||
|
node_modules/
|
||||||
|
vendor/
|
||||||
|
Leaflet2/uploads/
|
||||||
|
tmp/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
# Langkah Belajar Docker
|
||||||
|
|
||||||
|
File Docker sudah disiapkan, tetapi belum dijalankan.
|
||||||
|
|
||||||
|
## File yang Dibuat
|
||||||
|
|
||||||
|
```text
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
.env.example
|
||||||
|
.dockerignore
|
||||||
|
docker/mysql-init/99-grants.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fungsi Setiap File
|
||||||
|
|
||||||
|
`Dockerfile`
|
||||||
|
|
||||||
|
Membuat image aplikasi PHP Apache. File ini memasang extension PHP yang dibutuhkan project, seperti `pdo_mysql`, `mysqli`, dan `curl`.
|
||||||
|
|
||||||
|
`docker-compose.yml`
|
||||||
|
|
||||||
|
Mengatur dua service:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app = Apache + PHP + semua file project
|
||||||
|
db = MySQL 8.0
|
||||||
|
```
|
||||||
|
|
||||||
|
`.env.example`
|
||||||
|
|
||||||
|
Template konfigurasi port, username, password, dan nama database. File ini boleh masuk Git karena tidak berisi password production.
|
||||||
|
|
||||||
|
`.dockerignore`
|
||||||
|
|
||||||
|
Mengatur file/folder yang tidak perlu ikut masuk image Docker, seperti `.git`, `.env`, upload, log, `vendor`, dan `node_modules`.
|
||||||
|
|
||||||
|
`docker/mysql-init/99-grants.sql`
|
||||||
|
|
||||||
|
Memberi akses user `webgis_user` ke dua database:
|
||||||
|
|
||||||
|
```text
|
||||||
|
webgis_spbu
|
||||||
|
webgis_miskin
|
||||||
|
```
|
||||||
|
|
||||||
|
## Urutan Menjalankan Nanti
|
||||||
|
|
||||||
|
Jangan jalankan dulu kalau masih belajar struktur. Saat sudah siap, urutannya:
|
||||||
|
|
||||||
|
1. Salin file environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
copy .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Buka `.env`, lalu pelajari nilai berikut:
|
||||||
|
|
||||||
|
```text
|
||||||
|
APP_PORT=8080
|
||||||
|
DB_PORT=3307
|
||||||
|
DB_USERNAME=webgis_user
|
||||||
|
DB_PASSWORD=webgis_password
|
||||||
|
MYSQL_ROOT_PASSWORD=root_password
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Build dan jalankan container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Buka aplikasi:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Cek container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Lihat log jika ada error:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
7. Matikan container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
8. Reset database Docker dari awal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down -v
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Catatan Penting
|
||||||
|
|
||||||
|
Kalau kamu mengubah `DB_USERNAME` di `.env`, sesuaikan juga isi:
|
||||||
|
|
||||||
|
```text
|
||||||
|
docker/mysql-init/99-grants.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Untuk production, ganti semua password default di `.env`.
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
FROM php:8.2-apache
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends libcurl4-openssl-dev \
|
||||||
|
&& docker-php-ext-install pdo_mysql mysqli curl \
|
||||||
|
&& a2enmod rewrite \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /var/www/html
|
||||||
|
|
||||||
|
COPY . /var/www/html/
|
||||||
|
|
||||||
|
RUN mkdir -p /var/www/html/Leaflet2/uploads/bukti \
|
||||||
|
&& chown -R www-data:www-data /var/www/html/Leaflet2/uploads
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/db.php';
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!is_array($input)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Format request tidak valid.',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$nama = trim((string) ($input['nama'] ?? ''));
|
||||||
|
$no = trim((string) ($input['no'] ?? ''));
|
||||||
|
$status24jam = trim((string) ($input['status_24jam'] ?? ''));
|
||||||
|
$latitude = (float) ($input['latitude'] ?? 0);
|
||||||
|
$longitude = (float) ($input['longitude'] ?? 0);
|
||||||
|
|
||||||
|
if ($nama === '' || $no === '' || $status24jam === '') {
|
||||||
|
http_response_code(422);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Nama, no, dan status wajib diisi.',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!in_array($status24jam, ['24jam', 'tidak'], true)) {
|
||||||
|
http_response_code(422);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Status harus 24jam atau tidak.',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $connection->prepare('INSERT INTO spbu_points (nama, no, status_24jam, latitude, longitude) VALUES (?, ?, ?, ?, ?)');
|
||||||
|
|
||||||
|
if (!$stmt) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Gagal menyiapkan query: ' . $connection->error,
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt->bind_param('sssdd', $nama, $no, $status24jam, $latitude, $longitude);
|
||||||
|
|
||||||
|
if (!$stmt->execute()) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Gagal menyimpan data: ' . $stmt->error,
|
||||||
|
]);
|
||||||
|
$stmt->close();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Data berhasil disimpan.',
|
||||||
|
'id' => $connection->insert_id,
|
||||||
|
]);
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
mysqli_report(MYSQLI_REPORT_OFF);
|
||||||
|
|
||||||
|
$host = getenv('SPBU_DB_HOST') ?: getenv('DB_HOST') ?: '127.0.0.1';
|
||||||
|
$username = getenv('SPBU_DB_USERNAME') ?: getenv('DB_USERNAME') ?: 'root';
|
||||||
|
$password = getenv('SPBU_DB_PASSWORD') ?: getenv('DB_PASSWORD') ?: '';
|
||||||
|
$database = getenv('SPBU_DB_DATABASE') ?: 'webgis_spbu';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$connection = new mysqli($host, $username, $password, $database);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
http_response_code(500);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Koneksi database gagal. Pastikan MySQL XAMPP sedang berjalan.',
|
||||||
|
'detail' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$connection || $connection->connect_errno) {
|
||||||
|
http_response_code(500);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Koneksi database gagal. Pastikan MySQL XAMPP sedang berjalan.',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$connection->set_charset('utf8mb4');
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$host = getenv('SPBU_DB_HOST') ?: getenv('DB_HOST') ?: '127.0.0.1';
|
||||||
|
$database = getenv('SPBU_DB_DATABASE') ?: 'webgis_spbu';
|
||||||
|
$username = getenv('SPBU_DB_USERNAME') ?: getenv('DB_USERNAME') ?: 'root';
|
||||||
|
$password = getenv('SPBU_DB_PASSWORD') ?: getenv('DB_PASSWORD') ?: '';
|
||||||
|
$charset = getenv('DB_CHARSET') ?: 'utf8mb4';
|
||||||
|
|
||||||
|
set_exception_handler(static function (Throwable $exception): void {
|
||||||
|
if (!headers_sent()) {
|
||||||
|
http_response_code(500);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Terjadi kesalahan server.',
|
||||||
|
'detail' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
});
|
||||||
|
|
||||||
|
$dsn = "mysql:host={$host};dbname={$database};charset={$charset}";
|
||||||
|
|
||||||
|
$options = [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = new PDO($dsn, $username, $password, $options);
|
||||||
|
} catch (PDOException $exception) {
|
||||||
|
http_response_code(500);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Koneksi database gagal: ' . $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/db.php';
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!is_array($input)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Format request tidak valid.',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = (int) ($input['id'] ?? 0);
|
||||||
|
|
||||||
|
if ($id <= 0) {
|
||||||
|
http_response_code(422);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'ID titik tidak valid.',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $connection->prepare('DELETE FROM spbu_points WHERE id = ?');
|
||||||
|
|
||||||
|
if (!$stmt) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Gagal menyiapkan query: ' . $connection->error,
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt->bind_param('i', $id);
|
||||||
|
|
||||||
|
if (!$stmt->execute()) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Gagal menghapus data: ' . $stmt->error,
|
||||||
|
]);
|
||||||
|
$stmt->close();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($stmt->affected_rows < 1) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Data dengan ID tersebut tidak ditemukan.',
|
||||||
|
]);
|
||||||
|
$stmt->close();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Data berhasil dihapus.',
|
||||||
|
]);
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/db_pdo.php';
|
||||||
|
require_once __DIR__ . '/spatial_helpers.php';
|
||||||
|
|
||||||
|
$input = get_json_input();
|
||||||
|
|
||||||
|
$namaPemilik = trim((string) ($input['nama_pemilik'] ?? ''));
|
||||||
|
$statusKepemilikan = trim((string) ($input['status_kepemilikan'] ?? ''));
|
||||||
|
$luasM2 = (float) ($input['luas_m2'] ?? 0);
|
||||||
|
$wkt = sanitize_wkt((string) ($input['wkt'] ?? ''), 'POLYGON');
|
||||||
|
|
||||||
|
$allowedStatus = ['SHM', 'HGB', 'HGU', 'HP'];
|
||||||
|
|
||||||
|
if ($namaPemilik === '' || !in_array($statusKepemilikan, $allowedStatus, true)) {
|
||||||
|
json_response(422, [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Nama pemilik wajib diisi dan status harus SHM/HGB/HGU/HP.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($luasM2 <= 0) {
|
||||||
|
json_response(422, [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Luas tanah harus lebih dari 0 m2.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = 'INSERT INTO parsil_tanah (nama_pemilik, status_kepemilikan, luas_m2, geom) VALUES (:nama_pemilik, :status_kepemilikan, :luas_m2, ST_GeomFromText(:wkt))';
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute([
|
||||||
|
':nama_pemilik' => $namaPemilik,
|
||||||
|
':status_kepemilikan' => $statusKepemilikan,
|
||||||
|
':luas_m2' => $luasM2,
|
||||||
|
':wkt' => $wkt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
json_response(201, [
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Data parsil tanah berhasil disimpan.',
|
||||||
|
'id' => (int) $pdo->lastInsertId(),
|
||||||
|
]);
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/db_pdo.php';
|
||||||
|
require_once __DIR__ . '/spatial_helpers.php';
|
||||||
|
|
||||||
|
$input = get_json_input();
|
||||||
|
$id = (int) ($input['id'] ?? 0);
|
||||||
|
|
||||||
|
if ($id <= 0) {
|
||||||
|
json_response(422, [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'ID parsil tidak valid.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM parsil_tanah WHERE id = :id');
|
||||||
|
$stmt->execute([':id' => $id]);
|
||||||
|
|
||||||
|
if ($stmt->rowCount() < 1) {
|
||||||
|
json_response(404, [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Data parsil tidak ditemukan.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
json_response(200, [
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Data parsil tanah berhasil dihapus.',
|
||||||
|
]);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/db_pdo.php';
|
||||||
|
require_once __DIR__ . '/spatial_helpers.php';
|
||||||
|
|
||||||
|
$sql = 'SELECT id, nama_pemilik, status_kepemilikan, luas_m2, ST_AsGeoJSON(geom) AS geometry_geojson, created_at, updated_at FROM parsil_tanah ORDER BY id DESC';
|
||||||
|
$stmt = $pdo->query($sql);
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
|
||||||
|
$data = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$row['id'] = (int) $row['id'];
|
||||||
|
$row['luas_m2'] = (float) $row['luas_m2'];
|
||||||
|
$row['geometry_geojson'] = json_decode((string) $row['geometry_geojson'], true);
|
||||||
|
$data[] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
json_response(200, [
|
||||||
|
'success' => true,
|
||||||
|
'data' => $data,
|
||||||
|
]);
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/db_pdo.php';
|
||||||
|
require_once __DIR__ . '/spatial_helpers.php';
|
||||||
|
|
||||||
|
$input = get_json_input();
|
||||||
|
|
||||||
|
$id = (int) ($input['id'] ?? 0);
|
||||||
|
$namaPemilik = trim((string) ($input['nama_pemilik'] ?? ''));
|
||||||
|
$statusKepemilikan = trim((string) ($input['status_kepemilikan'] ?? ''));
|
||||||
|
$luasM2 = (float) ($input['luas_m2'] ?? 0);
|
||||||
|
$wkt = sanitize_wkt((string) ($input['wkt'] ?? ''), 'POLYGON');
|
||||||
|
|
||||||
|
$allowedStatus = ['SHM', 'HGB', 'HGU', 'HP'];
|
||||||
|
|
||||||
|
if ($id <= 0) {
|
||||||
|
json_response(422, [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'ID parsil tidak valid.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($namaPemilik === '' || !in_array($statusKepemilikan, $allowedStatus, true)) {
|
||||||
|
json_response(422, [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Nama pemilik wajib diisi dan status harus SHM/HGB/HGU/HP.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($luasM2 <= 0) {
|
||||||
|
json_response(422, [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Luas tanah harus lebih dari 0 m2.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = 'UPDATE parsil_tanah SET nama_pemilik = :nama_pemilik, status_kepemilikan = :status_kepemilikan, luas_m2 = :luas_m2, geom = ST_GeomFromText(:wkt), updated_at = CURRENT_TIMESTAMP WHERE id = :id';
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute([
|
||||||
|
':id' => $id,
|
||||||
|
':nama_pemilik' => $namaPemilik,
|
||||||
|
':status_kepemilikan' => $statusKepemilikan,
|
||||||
|
':luas_m2' => $luasM2,
|
||||||
|
':wkt' => $wkt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($stmt->rowCount() < 1) {
|
||||||
|
$checkStmt = $pdo->prepare('SELECT id FROM parsil_tanah WHERE id = :id LIMIT 1');
|
||||||
|
$checkStmt->execute([':id' => $id]);
|
||||||
|
if (!$checkStmt->fetch()) {
|
||||||
|
json_response(404, [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Data parsil tidak ditemukan.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
json_response(200, [
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Data parsil tanah berhasil diperbarui.',
|
||||||
|
]);
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/db.php';
|
||||||
|
|
||||||
|
$sql = 'SELECT id, nama, no, status_24jam, latitude, longitude, created_at FROM spbu_points ORDER BY id DESC';
|
||||||
|
$result = $connection->query($sql);
|
||||||
|
|
||||||
|
if (!$result) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Gagal mengambil data: ' . $connection->error,
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$points = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$points[] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'data' => $points,
|
||||||
|
]);
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/db_pdo.php';
|
||||||
|
require_once __DIR__ . '/spatial_helpers.php';
|
||||||
|
|
||||||
|
$input = get_json_input();
|
||||||
|
|
||||||
|
$namaJalan = trim((string) ($input['nama_jalan'] ?? ''));
|
||||||
|
$status = trim((string) ($input['status'] ?? ''));
|
||||||
|
$panjangMeter = (float) ($input['panjang_meter'] ?? 0);
|
||||||
|
$wkt = sanitize_wkt((string) ($input['wkt'] ?? ''), 'LINESTRING');
|
||||||
|
|
||||||
|
$allowedStatus = ['nasional', 'provinsi', 'kabupaten'];
|
||||||
|
|
||||||
|
if ($namaJalan === '' || !in_array($status, $allowedStatus, true)) {
|
||||||
|
json_response(422, [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Nama jalan wajib diisi dan status harus nasional/provinsi/kabupaten.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($panjangMeter <= 0) {
|
||||||
|
json_response(422, [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Panjang jalan harus lebih dari 0 meter.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = 'INSERT INTO jalan (nama_jalan, status, panjang_meter, geom) VALUES (:nama_jalan, :status, :panjang_meter, ST_GeomFromText(:wkt))';
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute([
|
||||||
|
':nama_jalan' => $namaJalan,
|
||||||
|
':status' => $status,
|
||||||
|
':panjang_meter' => $panjangMeter,
|
||||||
|
':wkt' => $wkt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
json_response(201, [
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Data jalan berhasil disimpan.',
|
||||||
|
'id' => (int) $pdo->lastInsertId(),
|
||||||
|
]);
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/db_pdo.php';
|
||||||
|
require_once __DIR__ . '/spatial_helpers.php';
|
||||||
|
|
||||||
|
$input = get_json_input();
|
||||||
|
$id = (int) ($input['id'] ?? 0);
|
||||||
|
|
||||||
|
if ($id <= 0) {
|
||||||
|
json_response(422, [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'ID jalan tidak valid.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM jalan WHERE id = :id');
|
||||||
|
$stmt->execute([':id' => $id]);
|
||||||
|
|
||||||
|
if ($stmt->rowCount() < 1) {
|
||||||
|
json_response(404, [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Data jalan tidak ditemukan.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
json_response(200, [
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Data jalan berhasil dihapus.',
|
||||||
|
]);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/db_pdo.php';
|
||||||
|
require_once __DIR__ . '/spatial_helpers.php';
|
||||||
|
|
||||||
|
$sql = 'SELECT id, nama_jalan, status, panjang_meter, ST_AsGeoJSON(geom) AS geometry_geojson, created_at, updated_at FROM jalan ORDER BY id DESC';
|
||||||
|
$stmt = $pdo->query($sql);
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
|
||||||
|
$data = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$row['id'] = (int) $row['id'];
|
||||||
|
$row['panjang_meter'] = (float) $row['panjang_meter'];
|
||||||
|
$row['geometry_geojson'] = json_decode((string) $row['geometry_geojson'], true);
|
||||||
|
$data[] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
json_response(200, [
|
||||||
|
'success' => true,
|
||||||
|
'data' => $data,
|
||||||
|
]);
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/db_pdo.php';
|
||||||
|
require_once __DIR__ . '/spatial_helpers.php';
|
||||||
|
|
||||||
|
$input = get_json_input();
|
||||||
|
|
||||||
|
$id = (int) ($input['id'] ?? 0);
|
||||||
|
$namaJalan = trim((string) ($input['nama_jalan'] ?? ''));
|
||||||
|
$status = trim((string) ($input['status'] ?? ''));
|
||||||
|
$panjangMeter = (float) ($input['panjang_meter'] ?? 0);
|
||||||
|
$wkt = sanitize_wkt((string) ($input['wkt'] ?? ''), 'LINESTRING');
|
||||||
|
|
||||||
|
$allowedStatus = ['nasional', 'provinsi', 'kabupaten'];
|
||||||
|
|
||||||
|
if ($id <= 0) {
|
||||||
|
json_response(422, [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'ID jalan tidak valid.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($namaJalan === '' || !in_array($status, $allowedStatus, true)) {
|
||||||
|
json_response(422, [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Nama jalan wajib diisi dan status harus nasional/provinsi/kabupaten.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($panjangMeter <= 0) {
|
||||||
|
json_response(422, [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Panjang jalan harus lebih dari 0 meter.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = 'UPDATE jalan SET nama_jalan = :nama_jalan, status = :status, panjang_meter = :panjang_meter, geom = ST_GeomFromText(:wkt), updated_at = CURRENT_TIMESTAMP WHERE id = :id';
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute([
|
||||||
|
':id' => $id,
|
||||||
|
':nama_jalan' => $namaJalan,
|
||||||
|
':status' => $status,
|
||||||
|
':panjang_meter' => $panjangMeter,
|
||||||
|
':wkt' => $wkt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($stmt->rowCount() < 1) {
|
||||||
|
$checkStmt = $pdo->prepare('SELECT id FROM jalan WHERE id = :id LIMIT 1');
|
||||||
|
$checkStmt->execute([':id' => $id]);
|
||||||
|
if (!$checkStmt->fetch()) {
|
||||||
|
json_response(404, [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Data jalan tidak ditemukan.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
json_response(200, [
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Data jalan berhasil diperbarui.',
|
||||||
|
]);
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
function json_response(int $statusCode, array $payload): void
|
||||||
|
{
|
||||||
|
http_response_code($statusCode);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode($payload);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function get_json_input(): array
|
||||||
|
{
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!is_array($input)) {
|
||||||
|
json_response(400, [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Format request tidak valid.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $input;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitize_wkt(string $wkt, string $expectedType): string
|
||||||
|
{
|
||||||
|
$trimmed = trim($wkt);
|
||||||
|
|
||||||
|
if ($trimmed === '') {
|
||||||
|
json_response(422, [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Geometri WKT wajib diisi.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = strtoupper($trimmed);
|
||||||
|
|
||||||
|
if (strpos($normalized, $expectedType . '(') !== 0 && strpos($normalized, $expectedType . ' (') !== 0) {
|
||||||
|
json_response(422, [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Tipe geometri tidak sesuai. Diharapkan ' . $expectedType . '.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $trimmed;
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/db.php';
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!is_array($input)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Format request tidak valid.',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = (int) ($input['id'] ?? 0);
|
||||||
|
$latitude = (float) ($input['latitude'] ?? 0);
|
||||||
|
$longitude = (float) ($input['longitude'] ?? 0);
|
||||||
|
|
||||||
|
if ($id <= 0) {
|
||||||
|
http_response_code(422);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'ID titik tidak valid.',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_finite($latitude) || !is_finite($longitude)) {
|
||||||
|
http_response_code(422);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Koordinat latitude atau longitude tidak valid.',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $connection->prepare('UPDATE spbu_points SET latitude = ?, longitude = ? WHERE id = ?');
|
||||||
|
|
||||||
|
if (!$stmt) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Gagal menyiapkan query update: ' . $connection->error,
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt->bind_param('ddi', $latitude, $longitude, $id);
|
||||||
|
|
||||||
|
if (!$stmt->execute()) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Gagal memperbarui posisi: ' . $stmt->error,
|
||||||
|
]);
|
||||||
|
$stmt->close();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($stmt->affected_rows < 1) {
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
$checkStmt = $connection->prepare('SELECT id FROM spbu_points WHERE id = ? LIMIT 1');
|
||||||
|
|
||||||
|
if (!$checkStmt) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Gagal memverifikasi data titik: ' . $connection->error,
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$checkStmt->bind_param('i', $id);
|
||||||
|
|
||||||
|
if (!$checkStmt->execute()) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Gagal memverifikasi data titik: ' . $checkStmt->error,
|
||||||
|
]);
|
||||||
|
$checkStmt->close();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$checkResult = $checkStmt->get_result();
|
||||||
|
|
||||||
|
if ($checkResult->num_rows < 1) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Data dengan ID tersebut tidak ditemukan.',
|
||||||
|
]);
|
||||||
|
$checkStmt->close();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$checkStmt->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Posisi titik berhasil diperbarui.',
|
||||||
|
]);
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,155 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const DEFAULT_SOURCE = 'assets/data/Kecamatan.json';
|
||||||
|
const DEFAULT_ANCHOR = {
|
||||||
|
lat: -0.06054796552220232,
|
||||||
|
lng: 109.34490231930592
|
||||||
|
};
|
||||||
|
const DEFAULT_GEO_BOUNDS = {
|
||||||
|
south: -0.0981948,
|
||||||
|
west: 109.2741676,
|
||||||
|
north: 0.0381168,
|
||||||
|
east: 109.3853475
|
||||||
|
};
|
||||||
|
const DEFAULT_CALIBRATION = {
|
||||||
|
scaleX: 1,
|
||||||
|
scaleY: 1,
|
||||||
|
offsetLat: 0,
|
||||||
|
offsetLng: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
function walkCoordinates(coordinates, callback) {
|
||||||
|
if (!Array.isArray(coordinates)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coordinates.length >= 2 && typeof coordinates[0] === 'number' && typeof coordinates[1] === 'number') {
|
||||||
|
callback(coordinates[0], coordinates[1]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
coordinates.forEach((child) => walkCoordinates(child, callback));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCoordinateBounds(data) {
|
||||||
|
const bounds = {
|
||||||
|
minX: Infinity,
|
||||||
|
minY: Infinity,
|
||||||
|
maxX: -Infinity,
|
||||||
|
maxY: -Infinity
|
||||||
|
};
|
||||||
|
|
||||||
|
function addGeometry(geometry) {
|
||||||
|
if (!geometry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (geometry.type === 'GeometryCollection') {
|
||||||
|
(geometry.geometries || []).forEach(addGeometry);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
walkCoordinates(geometry.coordinates, (x, y) => {
|
||||||
|
bounds.minX = Math.min(bounds.minX, x);
|
||||||
|
bounds.minY = Math.min(bounds.minY, y);
|
||||||
|
bounds.maxX = Math.max(bounds.maxX, x);
|
||||||
|
bounds.maxY = Math.max(bounds.maxY, y);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === 'FeatureCollection') {
|
||||||
|
(data.features || []).forEach((feature) => addGeometry(feature.geometry));
|
||||||
|
} else if (data.type === 'Feature') {
|
||||||
|
addGeometry(data.geometry);
|
||||||
|
} else {
|
||||||
|
addGeometry(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(bounds.minX) || !Number.isFinite(bounds.minY)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBoundingBoxTransform(sourceBounds, targetBounds, calibration) {
|
||||||
|
const sourceWidth = sourceBounds.maxX - sourceBounds.minX;
|
||||||
|
const sourceHeight = sourceBounds.maxY - sourceBounds.minY;
|
||||||
|
const targetCenterLng = (targetBounds.west + targetBounds.east) / 2 + calibration.offsetLng;
|
||||||
|
const targetCenterLat = (targetBounds.south + targetBounds.north) / 2 + calibration.offsetLat;
|
||||||
|
const targetWidth = (targetBounds.east - targetBounds.west) * calibration.scaleX;
|
||||||
|
const targetHeight = (targetBounds.north - targetBounds.south) * calibration.scaleY;
|
||||||
|
|
||||||
|
return function coordsToLatLng(coordinates) {
|
||||||
|
const xRatio = ((coordinates[0] - sourceBounds.minX) / sourceWidth) - 0.5;
|
||||||
|
const yRatio = ((coordinates[1] - sourceBounds.minY) / sourceHeight) - 0.5;
|
||||||
|
const lng = targetCenterLng + (xRatio * targetWidth);
|
||||||
|
const lat = targetCenterLat + (yRatio * targetHeight);
|
||||||
|
return L.latLng(lat, lng);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addKecamatanLayer = async function addKecamatanLayer(map, options) {
|
||||||
|
const settings = Object.assign({
|
||||||
|
source: DEFAULT_SOURCE,
|
||||||
|
anchor: DEFAULT_ANCHOR,
|
||||||
|
targetBounds: DEFAULT_GEO_BOUNDS,
|
||||||
|
calibration: DEFAULT_CALIBRATION,
|
||||||
|
fitBounds: false
|
||||||
|
}, options || {});
|
||||||
|
|
||||||
|
if (!map || typeof L === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.getPane('kecamatanPane')) {
|
||||||
|
map.createPane('kecamatanPane');
|
||||||
|
map.getPane('kecamatanPane').style.zIndex = 350;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(settings.source, {
|
||||||
|
headers: { 'Accept': 'application/json' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Gagal memuat data batas kecamatan.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const coordinateBounds = getCoordinateBounds(data);
|
||||||
|
|
||||||
|
if (!coordinateBounds) {
|
||||||
|
throw new Error('Data batas kecamatan tidak memiliki koordinat valid.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const layer = L.geoJSON(data, {
|
||||||
|
coordsToLatLng: createBoundingBoxTransform(coordinateBounds, settings.targetBounds, settings.calibration),
|
||||||
|
pane: 'kecamatanPane',
|
||||||
|
style: {
|
||||||
|
color: '#0f766e',
|
||||||
|
weight: 2,
|
||||||
|
opacity: 0.9,
|
||||||
|
fillColor: '#14b8a6',
|
||||||
|
fillOpacity: 0.08,
|
||||||
|
dashArray: '6 4'
|
||||||
|
},
|
||||||
|
onEachFeature: function (feature, featureLayer) {
|
||||||
|
const name = feature && feature.properties && (
|
||||||
|
feature.properties.nama ||
|
||||||
|
feature.properties.NAMA ||
|
||||||
|
feature.properties.KECAMATAN ||
|
||||||
|
feature.properties.kecamatan
|
||||||
|
);
|
||||||
|
|
||||||
|
featureLayer.bindPopup(name ? `Kecamatan: ${name}` : 'Batas Kecamatan');
|
||||||
|
}
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
if (settings.fitBounds && layer.getBounds().isValid()) {
|
||||||
|
map.fitBounds(layer.getBounds().pad(0.12), { maxZoom: 15 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return layer;
|
||||||
|
};
|
||||||
|
}());
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,622 @@
|
|||||||
|
function setStatus(element, message, type) {
|
||||||
|
element.textContent = message;
|
||||||
|
element.classList.remove('error', 'success');
|
||||||
|
if (type) {
|
||||||
|
element.classList.add(type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function roadColorByStatus(status) {
|
||||||
|
if (status === 'nasional') {
|
||||||
|
return '#dc2626';
|
||||||
|
}
|
||||||
|
if (status === 'provinsi') {
|
||||||
|
return '#2563eb';
|
||||||
|
}
|
||||||
|
return '#16a34a';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parcelColorByStatus(status) {
|
||||||
|
if (status === 'SHM') {
|
||||||
|
return '#ea580c';
|
||||||
|
}
|
||||||
|
if (status === 'HGB') {
|
||||||
|
return '#0ea5e9';
|
||||||
|
}
|
||||||
|
if (status === 'HGU') {
|
||||||
|
return '#22c55e';
|
||||||
|
}
|
||||||
|
return '#a855f7';
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculatePolylineLengthMeters(latlngs) {
|
||||||
|
let total = 0;
|
||||||
|
for (let index = 1; index < latlngs.length; index += 1) {
|
||||||
|
total += latlngs[index - 1].distanceTo(latlngs[index]);
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculatePolygonAreaMeters(latlngs) {
|
||||||
|
if (window.L && L.GeometryUtil && typeof L.GeometryUtil.geodesicArea === 'function') {
|
||||||
|
return L.GeometryUtil.geodesicArea(latlngs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const earthRadius = 6378137;
|
||||||
|
const toRad = (degree) => degree * Math.PI / 180;
|
||||||
|
|
||||||
|
let area = 0;
|
||||||
|
for (let i = 0; i < latlngs.length; i += 1) {
|
||||||
|
const p1 = latlngs[i];
|
||||||
|
const p2 = latlngs[(i + 1) % latlngs.length];
|
||||||
|
area += toRad(p2.lng - p1.lng) * (2 + Math.sin(toRad(p1.lat)) + Math.sin(toRad(p2.lat)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.abs(area * earthRadius * earthRadius / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function polylineToWkt(latlngs) {
|
||||||
|
const coordinates = latlngs.map((point) => `${point.lng} ${point.lat}`).join(', ');
|
||||||
|
return `LINESTRING(${coordinates})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function polygonToWkt(latlngs) {
|
||||||
|
const ring = latlngs.map((point) => `${point.lng} ${point.lat}`);
|
||||||
|
if (ring[0] !== ring[ring.length - 1]) {
|
||||||
|
ring.push(ring[0]);
|
||||||
|
}
|
||||||
|
return `POLYGON((${ring.join(', ')}))`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseJsonResponse(response) {
|
||||||
|
const text = await response.text();
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('Response server bukan JSON valid.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
const map = L.map('map').setView([-0.0605, 109.3449], 13);
|
||||||
|
|
||||||
|
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
maxZoom: 19,
|
||||||
|
attribution: '© OpenStreetMap contributors'
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
if (typeof window.addKecamatanLayer === 'function') {
|
||||||
|
window.addKecamatanLayer(map, { fitBounds: true }).catch((error) => {
|
||||||
|
console.warn('Data kecamatan belum termuat:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const editableLayer = new L.FeatureGroup();
|
||||||
|
map.addLayer(editableLayer);
|
||||||
|
|
||||||
|
const modalBackdrop = document.getElementById('modal-backdrop');
|
||||||
|
const modalTitle = document.getElementById('modal-title');
|
||||||
|
const featureForm = document.getElementById('feature-form');
|
||||||
|
const roadFields = document.getElementById('road-fields');
|
||||||
|
const parcelFields = document.getElementById('parcel-fields');
|
||||||
|
const formStatus = document.getElementById('form-status');
|
||||||
|
const cancelButton = document.getElementById('btn-cancel');
|
||||||
|
|
||||||
|
let pendingLayer = null;
|
||||||
|
let pendingType = null;
|
||||||
|
let pendingMode = 'create';
|
||||||
|
|
||||||
|
function closeModal(shouldRemovePendingLayer) {
|
||||||
|
modalBackdrop.classList.remove('open');
|
||||||
|
modalBackdrop.setAttribute('aria-hidden', 'true');
|
||||||
|
setStatus(formStatus, '', '');
|
||||||
|
|
||||||
|
if (shouldRemovePendingLayer && pendingMode === 'create' && pendingLayer) {
|
||||||
|
editableLayer.removeLayer(pendingLayer);
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingLayer = null;
|
||||||
|
pendingType = null;
|
||||||
|
pendingMode = 'create';
|
||||||
|
featureForm.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRoadModal(layer, mode, initialData) {
|
||||||
|
pendingLayer = layer;
|
||||||
|
pendingType = 'road';
|
||||||
|
pendingMode = mode;
|
||||||
|
|
||||||
|
modalTitle.textContent = mode === 'create' ? 'Tambah Data Jalan' : 'Update Data Jalan';
|
||||||
|
roadFields.hidden = false;
|
||||||
|
parcelFields.hidden = true;
|
||||||
|
|
||||||
|
const latlngs = layer.getLatLngs();
|
||||||
|
const lengthMeters = calculatePolylineLengthMeters(latlngs);
|
||||||
|
|
||||||
|
document.getElementById('nama_jalan').value = initialData && initialData.nama_jalan ? initialData.nama_jalan : '';
|
||||||
|
document.getElementById('status_jalan').value = initialData && initialData.status ? initialData.status : 'nasional';
|
||||||
|
document.getElementById('panjang_meter').value = lengthMeters.toFixed(2);
|
||||||
|
|
||||||
|
modalBackdrop.classList.add('open');
|
||||||
|
modalBackdrop.setAttribute('aria-hidden', 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openParcelModal(layer, mode, initialData) {
|
||||||
|
pendingLayer = layer;
|
||||||
|
pendingType = 'parcel';
|
||||||
|
pendingMode = mode;
|
||||||
|
|
||||||
|
modalTitle.textContent = mode === 'create' ? 'Tambah Data Parsil Tanah' : 'Update Data Parsil Tanah';
|
||||||
|
roadFields.hidden = true;
|
||||||
|
parcelFields.hidden = false;
|
||||||
|
|
||||||
|
const latlngs = layer.getLatLngs()[0];
|
||||||
|
const areaM2 = calculatePolygonAreaMeters(latlngs);
|
||||||
|
|
||||||
|
document.getElementById('nama_pemilik').value = initialData && initialData.nama_pemilik ? initialData.nama_pemilik : '';
|
||||||
|
document.getElementById('status_kepemilikan').value = initialData && initialData.status_kepemilikan ? initialData.status_kepemilikan : 'SHM';
|
||||||
|
document.getElementById('luas_m2').value = areaM2.toFixed(2);
|
||||||
|
|
||||||
|
modalBackdrop.classList.add('open');
|
||||||
|
modalBackdrop.setAttribute('aria-hidden', 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachRoadPopup(layer) {
|
||||||
|
const props = layer.featureProps;
|
||||||
|
layer.setStyle({
|
||||||
|
color: roadColorByStatus(props.status),
|
||||||
|
weight: 5
|
||||||
|
});
|
||||||
|
|
||||||
|
layer.bindPopup(`
|
||||||
|
<b>Jalan:</b> ${props.nama_jalan}<br>
|
||||||
|
<b>Status:</b> ${props.status}<br>
|
||||||
|
<b>Panjang:</b> ${Number(props.panjang_meter).toFixed(2)} m
|
||||||
|
<div class="popup-actions">
|
||||||
|
<button type="button" data-action="edit">Edit</button>
|
||||||
|
<button type="button" class="btn-danger" data-action="delete">Hapus</button>
|
||||||
|
</div>
|
||||||
|
<div class="status" data-role="popup-status"></div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
layer.on('popupopen', (event) => {
|
||||||
|
const popupElement = event.popup.getElement();
|
||||||
|
const editButton = popupElement.querySelector('[data-action="edit"]');
|
||||||
|
const deleteButton = popupElement.querySelector('[data-action="delete"]');
|
||||||
|
const popupStatus = popupElement.querySelector('[data-role="popup-status"]');
|
||||||
|
|
||||||
|
editButton.addEventListener('click', () => {
|
||||||
|
map.closePopup();
|
||||||
|
openRoadModal(layer, 'update', layer.featureProps);
|
||||||
|
});
|
||||||
|
|
||||||
|
deleteButton.addEventListener('click', async () => {
|
||||||
|
if (!window.confirm('Hapus data jalan ini?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(popupStatus, 'Menghapus data...', '');
|
||||||
|
deleteButton.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('api/road_delete.php', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ id: layer.featureId })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await parseJsonResponse(response);
|
||||||
|
if (!response.ok || !result.success) {
|
||||||
|
throw new Error(result.message || 'Gagal menghapus data jalan.');
|
||||||
|
}
|
||||||
|
|
||||||
|
editableLayer.removeLayer(layer);
|
||||||
|
map.closePopup();
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(popupStatus, error.message || 'Gagal menghapus data.', 'error');
|
||||||
|
deleteButton.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachParcelPopup(layer) {
|
||||||
|
const props = layer.featureProps;
|
||||||
|
layer.setStyle({
|
||||||
|
color: parcelColorByStatus(props.status_kepemilikan),
|
||||||
|
fillColor: parcelColorByStatus(props.status_kepemilikan),
|
||||||
|
fillOpacity: 0.35,
|
||||||
|
weight: 3
|
||||||
|
});
|
||||||
|
|
||||||
|
layer.bindPopup(`
|
||||||
|
<b>Pemilik:</b> ${props.nama_pemilik}<br>
|
||||||
|
<b>Status:</b> ${props.status_kepemilikan}<br>
|
||||||
|
<b>Luas:</b> ${Number(props.luas_m2).toFixed(2)} m²
|
||||||
|
<div class="popup-actions">
|
||||||
|
<button type="button" data-action="edit">Edit</button>
|
||||||
|
<button type="button" class="btn-danger" data-action="delete">Hapus</button>
|
||||||
|
</div>
|
||||||
|
<div class="status" data-role="popup-status"></div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
layer.on('popupopen', (event) => {
|
||||||
|
const popupElement = event.popup.getElement();
|
||||||
|
const editButton = popupElement.querySelector('[data-action="edit"]');
|
||||||
|
const deleteButton = popupElement.querySelector('[data-action="delete"]');
|
||||||
|
const popupStatus = popupElement.querySelector('[data-role="popup-status"]');
|
||||||
|
|
||||||
|
editButton.addEventListener('click', () => {
|
||||||
|
map.closePopup();
|
||||||
|
openParcelModal(layer, 'update', layer.featureProps);
|
||||||
|
});
|
||||||
|
|
||||||
|
deleteButton.addEventListener('click', async () => {
|
||||||
|
if (!window.confirm('Hapus data parsil ini?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(popupStatus, 'Menghapus data...', '');
|
||||||
|
deleteButton.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('api/parcel_delete.php', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ id: layer.featureId })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await parseJsonResponse(response);
|
||||||
|
if (!response.ok || !result.success) {
|
||||||
|
throw new Error(result.message || 'Gagal menghapus data parsil.');
|
||||||
|
}
|
||||||
|
|
||||||
|
editableLayer.removeLayer(layer);
|
||||||
|
map.closePopup();
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(popupStatus, error.message || 'Gagal menghapus data.', 'error');
|
||||||
|
deleteButton.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRoads() {
|
||||||
|
const response = await fetch('api/road_read.php', {
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await parseJsonResponse(response);
|
||||||
|
if (!response.ok || !result.success) {
|
||||||
|
throw new Error(result.message || 'Gagal memuat data jalan.');
|
||||||
|
}
|
||||||
|
|
||||||
|
result.data.forEach((item) => {
|
||||||
|
if (!item.geometry_geojson) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const layer = L.geoJSON(item.geometry_geojson).getLayers()[0];
|
||||||
|
if (!layer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
layer.featureType = 'road';
|
||||||
|
layer.featureId = item.id;
|
||||||
|
layer.featureProps = {
|
||||||
|
nama_jalan: item.nama_jalan,
|
||||||
|
status: item.status,
|
||||||
|
panjang_meter: item.panjang_meter
|
||||||
|
};
|
||||||
|
|
||||||
|
attachRoadPopup(layer);
|
||||||
|
editableLayer.addLayer(layer);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadParcels() {
|
||||||
|
const response = await fetch('api/parcel_read.php', {
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await parseJsonResponse(response);
|
||||||
|
if (!response.ok || !result.success) {
|
||||||
|
throw new Error(result.message || 'Gagal memuat data parsil.');
|
||||||
|
}
|
||||||
|
|
||||||
|
result.data.forEach((item) => {
|
||||||
|
if (!item.geometry_geojson) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const layer = L.geoJSON(item.geometry_geojson).getLayers()[0];
|
||||||
|
if (!layer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
layer.featureType = 'parcel';
|
||||||
|
layer.featureId = item.id;
|
||||||
|
layer.featureProps = {
|
||||||
|
nama_pemilik: item.nama_pemilik,
|
||||||
|
status_kepemilikan: item.status_kepemilikan,
|
||||||
|
luas_m2: item.luas_m2
|
||||||
|
};
|
||||||
|
|
||||||
|
attachParcelPopup(layer);
|
||||||
|
editableLayer.addLayer(layer);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawControl = new L.Control.Draw({
|
||||||
|
position: 'topleft',
|
||||||
|
draw: {
|
||||||
|
polyline: {
|
||||||
|
shapeOptions: {
|
||||||
|
color: '#dc2626',
|
||||||
|
weight: 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
polygon: {
|
||||||
|
allowIntersection: false,
|
||||||
|
showArea: true,
|
||||||
|
shapeOptions: {
|
||||||
|
color: '#ea580c',
|
||||||
|
fillOpacity: 0.35,
|
||||||
|
weight: 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
rectangle: false,
|
||||||
|
circle: false,
|
||||||
|
marker: false,
|
||||||
|
circlemarker: false
|
||||||
|
},
|
||||||
|
edit: {
|
||||||
|
featureGroup: editableLayer,
|
||||||
|
remove: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addControl(drawControl);
|
||||||
|
|
||||||
|
map.on(L.Draw.Event.CREATED, (event) => {
|
||||||
|
const layer = event.layer;
|
||||||
|
const geometryType = event.layerType;
|
||||||
|
|
||||||
|
editableLayer.addLayer(layer);
|
||||||
|
|
||||||
|
if (geometryType === 'polyline') {
|
||||||
|
openRoadModal(layer, 'create', null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (geometryType === 'polygon') {
|
||||||
|
openParcelModal(layer, 'create', null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
editableLayer.removeLayer(layer);
|
||||||
|
});
|
||||||
|
|
||||||
|
map.on(L.Draw.Event.EDITED, async (event) => {
|
||||||
|
const layers = event.layers.getLayers();
|
||||||
|
|
||||||
|
for (const layer of layers) {
|
||||||
|
try {
|
||||||
|
if (layer.featureType === 'road') {
|
||||||
|
const lengthMeters = calculatePolylineLengthMeters(layer.getLatLngs());
|
||||||
|
const payload = {
|
||||||
|
id: layer.featureId,
|
||||||
|
nama_jalan: layer.featureProps.nama_jalan,
|
||||||
|
status: layer.featureProps.status,
|
||||||
|
panjang_meter: Number(lengthMeters.toFixed(2)),
|
||||||
|
wkt: polylineToWkt(layer.getLatLngs())
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch('api/road_update.php', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await parseJsonResponse(response);
|
||||||
|
if (!response.ok || !result.success) {
|
||||||
|
throw new Error(result.message || 'Gagal update geometri jalan.');
|
||||||
|
}
|
||||||
|
|
||||||
|
layer.featureProps.panjang_meter = payload.panjang_meter;
|
||||||
|
attachRoadPopup(layer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layer.featureType === 'parcel') {
|
||||||
|
const areaM2 = calculatePolygonAreaMeters(layer.getLatLngs()[0]);
|
||||||
|
const payload = {
|
||||||
|
id: layer.featureId,
|
||||||
|
nama_pemilik: layer.featureProps.nama_pemilik,
|
||||||
|
status_kepemilikan: layer.featureProps.status_kepemilikan,
|
||||||
|
luas_m2: Number(areaM2.toFixed(2)),
|
||||||
|
wkt: polygonToWkt(layer.getLatLngs()[0])
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch('api/parcel_update.php', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await parseJsonResponse(response);
|
||||||
|
if (!response.ok || !result.success) {
|
||||||
|
throw new Error(result.message || 'Gagal update geometri parsil.');
|
||||||
|
}
|
||||||
|
|
||||||
|
layer.featureProps.luas_m2 = payload.luas_m2;
|
||||||
|
attachParcelPopup(layer);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
window.alert(error.message || 'Gagal menyimpan update geometri.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
map.on(L.Draw.Event.DELETED, async (event) => {
|
||||||
|
const layers = event.layers.getLayers();
|
||||||
|
|
||||||
|
for (const layer of layers) {
|
||||||
|
if (!layer.featureType || !layer.featureId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const endpoint = layer.featureType === 'road' ? 'api/road_delete.php' : 'api/parcel_delete.php';
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ id: layer.featureId })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await parseJsonResponse(response);
|
||||||
|
if (!response.ok || !result.success) {
|
||||||
|
throw new Error(result.message || 'Gagal menghapus data.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
window.alert(error.message || 'Gagal sinkron delete ke database.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
featureForm.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!pendingLayer || !pendingType) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(formStatus, 'Menyimpan data...', '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (pendingType === 'road') {
|
||||||
|
const namaJalan = document.getElementById('nama_jalan').value.trim();
|
||||||
|
const statusJalan = document.getElementById('status_jalan').value;
|
||||||
|
const panjangMeter = Number(document.getElementById('panjang_meter').value);
|
||||||
|
const wkt = polylineToWkt(pendingLayer.getLatLngs());
|
||||||
|
|
||||||
|
const endpoint = pendingMode === 'create' ? 'api/road_create.php' : 'api/road_update.php';
|
||||||
|
const payload = {
|
||||||
|
nama_jalan: namaJalan,
|
||||||
|
status: statusJalan,
|
||||||
|
panjang_meter: panjangMeter,
|
||||||
|
wkt: wkt
|
||||||
|
};
|
||||||
|
|
||||||
|
if (pendingMode === 'update') {
|
||||||
|
payload.id = pendingLayer.featureId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await parseJsonResponse(response);
|
||||||
|
if (!response.ok || !result.success) {
|
||||||
|
throw new Error(result.message || 'Gagal menyimpan data jalan.');
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingLayer.featureType = 'road';
|
||||||
|
pendingLayer.featureId = pendingMode === 'create' ? result.id : pendingLayer.featureId;
|
||||||
|
pendingLayer.featureProps = {
|
||||||
|
nama_jalan: namaJalan,
|
||||||
|
status: statusJalan,
|
||||||
|
panjang_meter: panjangMeter
|
||||||
|
};
|
||||||
|
attachRoadPopup(pendingLayer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingType === 'parcel') {
|
||||||
|
const namaPemilik = document.getElementById('nama_pemilik').value.trim();
|
||||||
|
const statusKepemilikan = document.getElementById('status_kepemilikan').value;
|
||||||
|
const luasM2 = Number(document.getElementById('luas_m2').value);
|
||||||
|
const wkt = polygonToWkt(pendingLayer.getLatLngs()[0]);
|
||||||
|
|
||||||
|
const endpoint = pendingMode === 'create' ? 'api/parcel_create.php' : 'api/parcel_update.php';
|
||||||
|
const payload = {
|
||||||
|
nama_pemilik: namaPemilik,
|
||||||
|
status_kepemilikan: statusKepemilikan,
|
||||||
|
luas_m2: luasM2,
|
||||||
|
wkt: wkt
|
||||||
|
};
|
||||||
|
|
||||||
|
if (pendingMode === 'update') {
|
||||||
|
payload.id = pendingLayer.featureId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await parseJsonResponse(response);
|
||||||
|
if (!response.ok || !result.success) {
|
||||||
|
throw new Error(result.message || 'Gagal menyimpan data parsil.');
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingLayer.featureType = 'parcel';
|
||||||
|
pendingLayer.featureId = pendingMode === 'create' ? result.id : pendingLayer.featureId;
|
||||||
|
pendingLayer.featureProps = {
|
||||||
|
nama_pemilik: namaPemilik,
|
||||||
|
status_kepemilikan: statusKepemilikan,
|
||||||
|
luas_m2: luasM2
|
||||||
|
};
|
||||||
|
attachParcelPopup(pendingLayer);
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(formStatus, 'Data berhasil disimpan.', 'success');
|
||||||
|
setTimeout(() => closeModal(false), 350);
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(formStatus, error.message || 'Gagal menyimpan data.', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cancelButton.addEventListener('click', () => closeModal(true));
|
||||||
|
modalBackdrop.addEventListener('click', (event) => {
|
||||||
|
if (event.target === modalBackdrop) {
|
||||||
|
closeModal(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadRoads();
|
||||||
|
await loadParcels();
|
||||||
|
|
||||||
|
if (editableLayer.getLayers().length > 0) {
|
||||||
|
map.fitBounds(editableLayer.getBounds().pad(0.2));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
window.alert(error.message || 'Gagal memuat data spasial.');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>WebGIS SPBU</title>
|
||||||
|
<link rel="stylesheet" href="assets/vendor/leaflet/leaflet.css">
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.css">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-top: #f0f9ff;
|
||||||
|
--bg-bottom: #e2e8f0;
|
||||||
|
--panel: rgba(255, 255, 255, 0.92);
|
||||||
|
--line: #cbd5e1;
|
||||||
|
--text: #0f172a;
|
||||||
|
--muted: #334155;
|
||||||
|
--accent: #16a34a;
|
||||||
|
--accent-dark: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
background: linear-gradient(180deg, var(--bg-top) 0%, var(--bg-bottom) 100%);
|
||||||
|
color: var(--text);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
height: 100dvh;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #223055;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-wrap {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
#map {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
background: #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-popup-content-wrapper,
|
||||||
|
.leaflet-popup-tip {
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-popup-content {
|
||||||
|
margin: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-popup {
|
||||||
|
min-width: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-popup h3 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input,
|
||||||
|
.field select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:focus,
|
||||||
|
.field select:focus {
|
||||||
|
outline: 2px solid rgba(22, 163, 74, 0.25);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #f0fdf4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save:hover {
|
||||||
|
background: var(--accent-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete {
|
||||||
|
background: #dc2626;
|
||||||
|
color: #ffffff;
|
||||||
|
margin-top: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete:hover {
|
||||||
|
background: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.point-popup .meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #475569;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.error {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.success {
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Perkecil vertex saat menggambar line/polygon */
|
||||||
|
.leaflet-div-icon.leaflet-editing-icon {
|
||||||
|
width: 8px !important;
|
||||||
|
height: 8px !important;
|
||||||
|
margin-left: -4px !important;
|
||||||
|
margin-top: -4px !important;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(11, 19, 43, 0.38);
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
z-index: 2000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop.open {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
width: min(460px, 100%);
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
box-shadow: 0 20px 55px rgba(17, 24, 39, 0.28);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal h3 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input[readonly] {
|
||||||
|
background: #eef2ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-actions button {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-popup-edit {
|
||||||
|
background: #16a34a;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-popup-refine {
|
||||||
|
background: #f59e0b;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-popup-save {
|
||||||
|
background: #0f766e;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-popup-cancel {
|
||||||
|
background: #64748b;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-popup-delete {
|
||||||
|
background: #dc2626;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.header h1 {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header p {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app">
|
||||||
|
<header class="header">
|
||||||
|
<h1>WebGIS SPBU</h1>
|
||||||
|
<p>Klik peta untuk titik SPBU. Gunakan toolbar kiri peta untuk menggambar jalan dan parsil.</p>
|
||||||
|
<div class="legend">
|
||||||
|
<span><i class="dot" style="background:#dc2626"></i>Jalan Nasional</span>
|
||||||
|
<span><i class="dot" style="background:#2563eb"></i>Jalan Provinsi</span>
|
||||||
|
<span><i class="dot" style="background:#16a34a"></i>Jalan Kabupaten</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="map-wrap">
|
||||||
|
<div id="map"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="modal-backdrop" class="modal-backdrop" aria-hidden="true">
|
||||||
|
<div class="modal">
|
||||||
|
<h3 id="modal-title">Form Data</h3>
|
||||||
|
<form id="feature-form">
|
||||||
|
<div id="road-fields" hidden>
|
||||||
|
<div class="field">
|
||||||
|
<label for="nama_jalan">Nama Jalan</label>
|
||||||
|
<input id="nama_jalan" name="nama_jalan" type="text" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="status_jalan">Status Jalan</label>
|
||||||
|
<select id="status_jalan" name="status_jalan" required>
|
||||||
|
<option value="nasional">Jalan Nasional</option>
|
||||||
|
<option value="provinsi">Jalan Provinsi</option>
|
||||||
|
<option value="kabupaten">Jalan Kabupaten</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="panjang_meter">Panjang Jalan (meter)</label>
|
||||||
|
<input id="panjang_meter" name="panjang_meter" type="text" readonly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="parcel-fields" hidden>
|
||||||
|
<div class="field">
|
||||||
|
<label for="nama_pemilik">Nama Pemilik</label>
|
||||||
|
<input id="nama_pemilik" name="nama_pemilik" type="text" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="status_kepemilikan">Status Kepemilikan</label>
|
||||||
|
<select id="status_kepemilikan" name="status_kepemilikan" required>
|
||||||
|
<option value="SHM">Sertifikat Hak Milik (SHM)</option>
|
||||||
|
<option value="HGB">Sertifikat Hak Guna Bangunan (HGB)</option>
|
||||||
|
<option value="HGU">Sertifikat Hak Guna Usaha (HGU)</option>
|
||||||
|
<option value="HP">Sertifikat Hak Pakai (HP)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="luas_m2">Luas Tanah (meter persegi)</label>
|
||||||
|
<input id="luas_m2" name="luas_m2" type="text" readonly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" class="btn-save">Simpan</button>
|
||||||
|
<button type="button" id="btn-cancel" class="btn-cancel">Batal</button>
|
||||||
|
</div>
|
||||||
|
<div id="form-status" class="status"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
||||||
|
<script src="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.js"></script>
|
||||||
|
<script src="assets/js/kecamatan-layer.js"></script>
|
||||||
|
<script src="assets/js/map.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>WebGIS Spasial Jalan dan Parsil</title>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="assets/vendor/leaflet/leaflet.css">
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.css">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--ink: #0b132b;
|
||||||
|
--sky: #f5f7ff;
|
||||||
|
--line: #d3d9eb;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--accent: #0f766e;
|
||||||
|
--accent-dark: #115e59;
|
||||||
|
--danger: #b91c1c;
|
||||||
|
--muted: #3f4d73;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
height: 100%;
|
||||||
|
font-family: "Trebuchet MS", "Segoe UI", sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background: radial-gradient(circle at top left, #e0f2fe 0%, #f8fafc 45%, #f1f5f9 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
height: 100dvh;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #223055;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-wrap {
|
||||||
|
position: relative;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#map {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(11, 19, 43, 0.38);
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
z-index: 2000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop.open {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
width: min(460px, 100%);
|
||||||
|
background: var(--panel);
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
box-shadow: 0 20px 55px rgba(17, 24, 39, 0.28);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal h3 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input,
|
||||||
|
.field select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input[readonly] {
|
||||||
|
background: #eef2ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #f0fdfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--accent-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--danger);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #334155;
|
||||||
|
min-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.error {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.success {
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-actions button {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app">
|
||||||
|
<header class="header">
|
||||||
|
<div>
|
||||||
|
<h1>WebGIS Manajemen Jalan & Parsil</h1>
|
||||||
|
<p>Gunakan toolbar kiri peta untuk gambar polyline/polygon, edit, atau hapus geometri.</p>
|
||||||
|
</div>
|
||||||
|
<div class="legend">
|
||||||
|
<span><i class="dot" style="background:#dc2626"></i>Jalan Nasional</span>
|
||||||
|
<span><i class="dot" style="background:#2563eb"></i>Jalan Provinsi</span>
|
||||||
|
<span><i class="dot" style="background:#16a34a"></i>Jalan Kabupaten</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="map-wrap">
|
||||||
|
<div id="map"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="modal-backdrop" class="modal-backdrop" aria-hidden="true">
|
||||||
|
<div class="modal">
|
||||||
|
<h3 id="modal-title">Form Data</h3>
|
||||||
|
<form id="feature-form">
|
||||||
|
<div id="road-fields" hidden>
|
||||||
|
<div class="field">
|
||||||
|
<label for="nama_jalan">Nama Jalan</label>
|
||||||
|
<input id="nama_jalan" name="nama_jalan" type="text" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="status_jalan">Status Jalan</label>
|
||||||
|
<select id="status_jalan" name="status_jalan" required>
|
||||||
|
<option value="nasional">Jalan Nasional</option>
|
||||||
|
<option value="provinsi">Jalan Provinsi</option>
|
||||||
|
<option value="kabupaten">Jalan Kabupaten</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="panjang_meter">Panjang Jalan (meter)</label>
|
||||||
|
<input id="panjang_meter" name="panjang_meter" type="text" readonly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="parcel-fields" hidden>
|
||||||
|
<div class="field">
|
||||||
|
<label for="nama_pemilik">Nama Pemilik</label>
|
||||||
|
<input id="nama_pemilik" name="nama_pemilik" type="text" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="status_kepemilikan">Status Kepemilikan</label>
|
||||||
|
<select id="status_kepemilikan" name="status_kepemilikan" required>
|
||||||
|
<option value="SHM">Sertifikat Hak Milik (SHM)</option>
|
||||||
|
<option value="HGB">Sertifikat Hak Guna Bangunan (HGB)</option>
|
||||||
|
<option value="HGU">Sertifikat Hak Guna Usaha (HGU)</option>
|
||||||
|
<option value="HP">Sertifikat Hak Pakai (HP)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="luas_m2">Luas Tanah (meter persegi)</label>
|
||||||
|
<input id="luas_m2" name="luas_m2" type="text" readonly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" class="btn-primary">Simpan</button>
|
||||||
|
<button type="button" id="btn-cancel" class="btn-secondary">Batal</button>
|
||||||
|
</div>
|
||||||
|
<div id="form-status" class="status"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
||||||
|
<script src="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.js"></script>
|
||||||
|
<script src="assets/js/kecamatan-layer.js"></script>
|
||||||
|
<script src="assets/js/spatial.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
CREATE DATABASE IF NOT EXISTS webgis_spbu;
|
||||||
|
USE webgis_spbu;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS spbu_points (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
nama VARCHAR(150) NOT NULL,
|
||||||
|
no VARCHAR(50) NOT NULL,
|
||||||
|
status_24jam ENUM('24jam', 'tidak') NOT NULL,
|
||||||
|
latitude DECIMAL(10, 7) NOT NULL,
|
||||||
|
longitude DECIMAL(10, 7) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
CREATE DATABASE IF NOT EXISTS webgis_spbu;
|
||||||
|
USE webgis_spbu;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS jalan (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
nama_jalan VARCHAR(150) NOT NULL,
|
||||||
|
status ENUM('nasional', 'provinsi', 'kabupaten') NOT NULL,
|
||||||
|
panjang_meter DOUBLE NOT NULL,
|
||||||
|
geom LINESTRING NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
SPATIAL INDEX idx_jalan_geom (geom)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS parsil_tanah (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
nama_pemilik VARCHAR(150) NOT NULL,
|
||||||
|
status_kepemilikan ENUM('SHM', 'HGB', 'HGU', 'HP') NOT NULL,
|
||||||
|
luas_m2 DOUBLE NOT NULL,
|
||||||
|
geom POLYGON NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
SPATIAL INDEX idx_parsil_geom (geom)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
function start_admin_session(): void
|
||||||
|
{
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function is_admin_logged_in(): bool
|
||||||
|
{
|
||||||
|
start_admin_session();
|
||||||
|
return !empty($_SESSION['admin_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
function require_admin_login(bool $jsonResponse = false): void
|
||||||
|
{
|
||||||
|
if (is_admin_logged_in()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($jsonResponse) {
|
||||||
|
http_response_code(401);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Sesi login tidak valid. Silakan login ulang.',
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
header('Location: login.php');
|
||||||
|
}
|
||||||
|
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function current_admin_name(): string
|
||||||
|
{
|
||||||
|
start_admin_session();
|
||||||
|
return (string)($_SESSION['admin_name'] ?? $_SESSION['admin_username'] ?? 'Admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensure_admin_table(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$pdo->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS admins (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
username VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
nama VARCHAR(100) NOT NULL DEFAULT 'Administrator',
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
");
|
||||||
|
|
||||||
|
$count = (int)$pdo->query("SELECT COUNT(*) FROM admins")->fetchColumn();
|
||||||
|
if ($count === 0) {
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO admins (username, password_hash, nama) VALUES (?, ?, ?)");
|
||||||
|
$stmt->execute([
|
||||||
|
'admin',
|
||||||
|
password_hash('admin123', PASSWORD_DEFAULT),
|
||||||
|
'Administrator',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
require_once 'db_pdo.php';
|
||||||
|
require_once 'auth.php';
|
||||||
|
require_once 'penduduk_feature_helper.php';
|
||||||
|
require_admin_login(true);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
$input = $_POST ?: (json_decode(file_get_contents('php://input'), true) ?: []);
|
||||||
|
|
||||||
|
if (empty($input['penduduk_id']) || empty($input['jenis_bantuan']) || empty($input['tanggal_bantuan'])) {
|
||||||
|
echo json_encode(['success' => false, 'message' => 'Penduduk, jenis bantuan, dan tanggal wajib diisi.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensurePendudukFeatureSchema($pdo);
|
||||||
|
$buktiFile = saveUploadedEvidence('bukti_file');
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
INSERT INTO riwayat_bantuan (penduduk_id, jenis_bantuan, tanggal_bantuan, nilai_bantuan, pemberi_bantuan, catatan, bukti_file)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
");
|
||||||
|
$stmt->execute([
|
||||||
|
$input['penduduk_id'],
|
||||||
|
$input['jenis_bantuan'],
|
||||||
|
normalizeDateOrNull($input['tanggal_bantuan']) ?? date('Y-m-d'),
|
||||||
|
$input['nilai_bantuan'] ?? null,
|
||||||
|
$input['pemberi_bantuan'] ?? 'Admin',
|
||||||
|
$input['catatan'] ?? null,
|
||||||
|
$buktiFile,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$update = $pdo->prepare("
|
||||||
|
UPDATE penduduk_miskin
|
||||||
|
SET status_bantuan = 'Sudah dibantu', jenis_bantuan = ?, tanggal_bantuan = ?
|
||||||
|
WHERE id = ?
|
||||||
|
");
|
||||||
|
$update->execute([
|
||||||
|
$input['jenis_bantuan'],
|
||||||
|
normalizeDateOrNull($input['tanggal_bantuan']) ?? date('Y-m-d'),
|
||||||
|
$input['penduduk_id'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
updatePendudukPriority($pdo, (int)$input['penduduk_id']);
|
||||||
|
|
||||||
|
echo json_encode(['success' => true, 'id' => $pdo->lastInsertId()]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
require_once 'db_pdo.php';
|
||||||
|
require_once 'auth.php';
|
||||||
|
require_once 'penduduk_feature_helper.php';
|
||||||
|
require_admin_login(true);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensurePendudukFeatureSchema($pdo);
|
||||||
|
refreshAllPriorityScores($pdo);
|
||||||
|
|
||||||
|
$stats = [];
|
||||||
|
|
||||||
|
$ri = $pdo->query("SELECT COUNT(*) as total FROM rumah_ibadah")->fetch();
|
||||||
|
$stats['total_rumah_ibadah'] = $ri['total'];
|
||||||
|
|
||||||
|
$pm = $pdo->query("SELECT COUNT(*) as total FROM penduduk_miskin")->fetch();
|
||||||
|
$stats['total_penduduk'] = $pm['total'];
|
||||||
|
|
||||||
|
$pm_sudah = $pdo->query("SELECT COUNT(*) as total FROM penduduk_miskin WHERE status_bantuan = 'Sudah dibantu'")->fetch();
|
||||||
|
$stats['sudah_dibantu'] = $pm_sudah['total'];
|
||||||
|
|
||||||
|
$pm_belum = $pdo->query("SELECT COUNT(*) as total FROM penduduk_miskin WHERE status_bantuan = 'Belum dibantu'")->fetch();
|
||||||
|
$stats['belum_dibantu'] = $pm_belum['total'];
|
||||||
|
|
||||||
|
$pm_tunggu = $pdo->query("SELECT COUNT(*) as total FROM penduduk_miskin WHERE status_bantuan = 'Menunggu verifikasi'")->fetch();
|
||||||
|
$stats['menunggu_verifikasi'] = $pm_tunggu['total'];
|
||||||
|
|
||||||
|
$blank = $pdo->query("SELECT COUNT(*) as total FROM penduduk_miskin WHERE is_blank_spot = 1")->fetch();
|
||||||
|
$stats['luar_radius'] = $blank['total'];
|
||||||
|
|
||||||
|
$prioritas = $pdo->query("SELECT COUNT(*) as total FROM penduduk_miskin WHERE skor_prioritas >= 70")->fetch();
|
||||||
|
$stats['prioritas_tinggi'] = $prioritas['total'];
|
||||||
|
|
||||||
|
$survei = $pdo->query("SELECT COUNT(*) as total FROM penduduk_miskin WHERE tindak_lanjut IN ('Sudah disurvei', 'Sudah diverifikasi')")->fetch();
|
||||||
|
$stats['sudah_survei'] = $survei['total'];
|
||||||
|
|
||||||
|
$stats['top_prioritas'] = $pdo->query("
|
||||||
|
SELECT id, kk_nama, kategori_kebutuhan, tindak_lanjut, skor_prioritas
|
||||||
|
FROM penduduk_miskin
|
||||||
|
ORDER BY skor_prioritas DESC, id DESC
|
||||||
|
LIMIT 5
|
||||||
|
")->fetchAll();
|
||||||
|
|
||||||
|
$stats['kebutuhan_terbanyak'] = $pdo->query("
|
||||||
|
SELECT COALESCE(NULLIF(kategori_kebutuhan, ''), 'Belum diisi') AS kategori, COUNT(*) AS total
|
||||||
|
FROM penduduk_miskin
|
||||||
|
GROUP BY COALESCE(NULLIF(kategori_kebutuhan, ''), 'Belum diisi')
|
||||||
|
ORDER BY total DESC
|
||||||
|
LIMIT 5
|
||||||
|
")->fetchAll();
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'data' => $stats
|
||||||
|
]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$host = getenv('DB_HOST') ?: '127.0.0.1';
|
||||||
|
$database = getenv('DB_DATABASE') ?: 'webgis_miskin';
|
||||||
|
$username = getenv('DB_USERNAME') ?: 'root';
|
||||||
|
$password = getenv('DB_PASSWORD') ?: '';
|
||||||
|
$charset = getenv('DB_CHARSET') ?: 'utf8mb4';
|
||||||
|
|
||||||
|
set_exception_handler(static function (Throwable $exception): void {
|
||||||
|
if (!headers_sent()) {
|
||||||
|
http_response_code(500);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Terjadi kesalahan server.',
|
||||||
|
'detail' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
});
|
||||||
|
|
||||||
|
$dsn = "mysql:host={$host};dbname={$database};charset={$charset}";
|
||||||
|
|
||||||
|
$options = [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = new PDO($dsn, $username, $password, $options);
|
||||||
|
} catch (PDOException $exception) {
|
||||||
|
http_response_code(500);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Koneksi database gagal: ' . $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Menghitung jarak antara dua koordinat menggunakan formula Haversine.
|
||||||
|
* @param float $lat1 Latitude titik pertama
|
||||||
|
* @param float $lon1 Longitude titik pertama
|
||||||
|
* @param float $lat2 Latitude titik kedua
|
||||||
|
* @param float $lon2 Longitude titik kedua
|
||||||
|
* @return float Jarak dalam satuan meter
|
||||||
|
*/
|
||||||
|
function calculateHaversineDistance($lat1, $lon1, $lat2, $lon2) {
|
||||||
|
$earthRadius = 6371000; // Radius bumi dalam meter
|
||||||
|
|
||||||
|
$latFrom = deg2rad($lat1);
|
||||||
|
$lonFrom = deg2rad($lon1);
|
||||||
|
$latTo = deg2rad($lat2);
|
||||||
|
$lonTo = deg2rad($lon2);
|
||||||
|
|
||||||
|
$latDelta = $latTo - $latFrom;
|
||||||
|
$lonDelta = $lonTo - $lonFrom;
|
||||||
|
|
||||||
|
$angle = 2 * asin(sqrt(pow(sin($latDelta / 2), 2) +
|
||||||
|
cos($latFrom) * cos($latTo) * pow(sin($lonDelta / 2), 2)));
|
||||||
|
|
||||||
|
return $angle * $earthRadius;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateOsrmRouteDistance($lat1, $lon1, $lat2, $lon2): ?float
|
||||||
|
{
|
||||||
|
$coordinates = sprintf(
|
||||||
|
'%.8F,%.8F;%.8F,%.8F',
|
||||||
|
$lon1,
|
||||||
|
$lat1,
|
||||||
|
$lon2,
|
||||||
|
$lat2
|
||||||
|
);
|
||||||
|
$url = 'https://router.project-osrm.org/route/v1/driving/' . $coordinates . '?overview=false&alternatives=false&steps=false';
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
if ($ch === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 4,
|
||||||
|
CURLOPT_TIMEOUT => 8,
|
||||||
|
CURLOPT_USERAGENT => 'WebGIS-Peduli/1.0',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($response === false || $httpCode !== 200) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = json_decode($response, true);
|
||||||
|
if (!is_array($json) || ($json['code'] ?? '') !== 'Ok') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$distance = $json['routes'][0]['distance'] ?? null;
|
||||||
|
|
||||||
|
return is_numeric($distance) ? (float)$distance : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateRoadDistance($lat1, $lon1, $lat2, $lon2): ?float
|
||||||
|
{
|
||||||
|
return calculateOsrmRouteDistance((float)$lat1, (float)$lon1, (float)$lat2, (float)$lon2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePendudukDistanceColumns(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->query("SHOW COLUMNS FROM penduduk_miskin LIKE 'jarak_rute_m'");
|
||||||
|
if ($stmt->fetch() === false) {
|
||||||
|
$pdo->exec("ALTER TABLE penduduk_miskin ADD COLUMN jarak_rute_m DECIMAL(10, 2) NULL AFTER jarak_m");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculatePendudukCoverage(PDO $pdo, float $lat, float $lng): array
|
||||||
|
{
|
||||||
|
$stmtRi = $pdo->query("SELECT id, lat, lng, radius FROM rumah_ibadah");
|
||||||
|
$rumahIbadah = $stmtRi->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$nearest = null;
|
||||||
|
$nearestCovered = null;
|
||||||
|
|
||||||
|
foreach ($rumahIbadah as $ri) {
|
||||||
|
$distance = calculateHaversineDistance($lat, $lng, (float)$ri['lat'], (float)$ri['lng']);
|
||||||
|
$result = [
|
||||||
|
'ibadah_id' => (int)$ri['id'],
|
||||||
|
'ri_lat' => (float)$ri['lat'],
|
||||||
|
'ri_lng' => (float)$ri['lng'],
|
||||||
|
'jarak_m' => round($distance, 2),
|
||||||
|
'distance' => $distance,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($nearest === null || $distance < $nearest['distance']) {
|
||||||
|
$nearest = $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($distance <= (float)$ri['radius'] && ($nearestCovered === null || $distance < $nearestCovered['distance'])) {
|
||||||
|
$nearestCovered = $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$selected = $nearestCovered ?? $nearest;
|
||||||
|
$routeDistance = null;
|
||||||
|
|
||||||
|
if ($selected !== null) {
|
||||||
|
$routeDistance = calculateRoadDistance($lat, $lng, $selected['ri_lat'], $selected['ri_lng']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ibadah_id' => $selected['ibadah_id'] ?? null,
|
||||||
|
'jarak_m' => $selected['jarak_m'] ?? null,
|
||||||
|
'jarak_rute_m' => $routeDistance !== null ? round($routeDistance, 2) : null,
|
||||||
|
'is_blank_spot' => $nearestCovered === null ? 1 : 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function recalculateAllPendudukCoverage(PDO $pdo): void
|
||||||
|
{
|
||||||
|
ensurePendudukDistanceColumns($pdo);
|
||||||
|
|
||||||
|
$stmt = $pdo->query("SELECT id, lat, lng FROM penduduk_miskin");
|
||||||
|
$penduduk = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
$update = $pdo->prepare("
|
||||||
|
UPDATE penduduk_miskin
|
||||||
|
SET ibadah_id = ?, jarak_m = ?, jarak_rute_m = ?, is_blank_spot = ?
|
||||||
|
WHERE id = ?
|
||||||
|
");
|
||||||
|
|
||||||
|
foreach ($penduduk as $row) {
|
||||||
|
$coverage = calculatePendudukCoverage($pdo, (float)$row['lat'], (float)$row['lng']);
|
||||||
|
$update->execute([
|
||||||
|
$coverage['ibadah_id'],
|
||||||
|
$coverage['jarak_m'],
|
||||||
|
$coverage['jarak_rute_m'],
|
||||||
|
$coverage['is_blank_spot'],
|
||||||
|
$row['id'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
require_once 'db_pdo.php';
|
||||||
|
require_once 'haversine_helper.php';
|
||||||
|
require_once 'penduduk_feature_helper.php';
|
||||||
|
require_once 'auth.php';
|
||||||
|
require_admin_login(true);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
$input = readPendudukInput();
|
||||||
|
|
||||||
|
if (!$input) {
|
||||||
|
echo json_encode(['success' => false, 'message' => 'Input tidak valid']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensurePendudukDistanceColumns($pdo);
|
||||||
|
ensurePendudukFeatureSchema($pdo);
|
||||||
|
|
||||||
|
$input_lat = floatval($input['lat']);
|
||||||
|
$input_lng = floatval($input['lng']);
|
||||||
|
$coverage = calculatePendudukCoverage($pdo, $input_lat, $input_lng);
|
||||||
|
$buktiFile = saveUploadedEvidence('bukti_file');
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
INSERT INTO penduduk_miskin
|
||||||
|
(kk_nama, kk_nik, alamat, lat, lng, ibadah_id, jarak_m, jarak_rute_m, is_blank_spot, status_bantuan, kategori_kebutuhan, deskripsi_kebutuhan, tindak_lanjut, kondisi_rumah, penghasilan, catatan_survei, bukti_file, jenis_bantuan, tanggal_bantuan)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
");
|
||||||
|
$stmt->execute([
|
||||||
|
$input['kk_nama'],
|
||||||
|
$input['kk_nik'] ?? null,
|
||||||
|
$input['alamat'],
|
||||||
|
$input['lat'],
|
||||||
|
$input['lng'],
|
||||||
|
$coverage['ibadah_id'],
|
||||||
|
$coverage['jarak_m'],
|
||||||
|
$coverage['jarak_rute_m'],
|
||||||
|
$coverage['is_blank_spot'],
|
||||||
|
$input['status_bantuan'] ?? 'Belum dibantu',
|
||||||
|
$input['kategori_kebutuhan'] ?? null,
|
||||||
|
$input['deskripsi_kebutuhan'] ?? null,
|
||||||
|
$input['tindak_lanjut'] ?? 'Belum disurvei',
|
||||||
|
$input['kondisi_rumah'] ?? null,
|
||||||
|
normalizeNonNegativeIntOrNull($input['penghasilan'] ?? '', 'Penghasilan'),
|
||||||
|
$input['catatan_survei'] ?? null,
|
||||||
|
$buktiFile,
|
||||||
|
$input['jenis_bantuan'] ?? null,
|
||||||
|
normalizeDateOrNull($input['tanggal_bantuan'] ?? null)
|
||||||
|
]);
|
||||||
|
|
||||||
|
$penduduk_id = $pdo->lastInsertId();
|
||||||
|
|
||||||
|
// 4. Insert anggota keluarga if exist
|
||||||
|
if (!empty($input['anggota']) && is_array($input['anggota'])) {
|
||||||
|
$stmtAnggota = $pdo->prepare("INSERT INTO anggota_keluarga (penduduk_id, nama, hubungan, umur, pekerjaan) VALUES (?, ?, ?, ?, ?)");
|
||||||
|
foreach ($input['anggota'] as $anggota) {
|
||||||
|
$stmtAnggota->execute([
|
||||||
|
$penduduk_id,
|
||||||
|
$anggota['nama'],
|
||||||
|
$anggota['hubungan'],
|
||||||
|
$anggota['umur'] ?? null,
|
||||||
|
$anggota['pekerjaan'] ?? null
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($input['status_bantuan'] ?? '') === 'Sudah dibantu' && !empty($input['jenis_bantuan'])) {
|
||||||
|
$stmtRiwayat = $pdo->prepare("
|
||||||
|
INSERT INTO riwayat_bantuan (penduduk_id, jenis_bantuan, tanggal_bantuan, pemberi_bantuan, catatan, bukti_file)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
");
|
||||||
|
$stmtRiwayat->execute([
|
||||||
|
$penduduk_id,
|
||||||
|
$input['jenis_bantuan'],
|
||||||
|
normalizeDateOrNull($input['tanggal_bantuan'] ?? null) ?? date('Y-m-d'),
|
||||||
|
'Admin',
|
||||||
|
'Dicatat dari form penduduk.',
|
||||||
|
$buktiFile,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePendudukPriority($pdo, (int)$penduduk_id);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'id' => $penduduk_id,
|
||||||
|
'ibadah_id' => $coverage['ibadah_id'],
|
||||||
|
'jarak_m' => $coverage['jarak_m'],
|
||||||
|
'jarak_rute_m' => $coverage['jarak_rute_m'],
|
||||||
|
'is_blank_spot' => $coverage['is_blank_spot']
|
||||||
|
]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
require_once 'db_pdo.php';
|
||||||
|
require_once 'auth.php';
|
||||||
|
require_admin_login(true);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!$input || empty($input['id'])) {
|
||||||
|
echo json_encode(['success' => false, 'message' => 'Input tidak valid']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->prepare("DELETE FROM penduduk_miskin WHERE id=?");
|
||||||
|
$stmt->execute([$input['id']]);
|
||||||
|
// Note: anggota_keluarga will be deleted automatically due to ON DELETE CASCADE
|
||||||
|
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
function ensurePendudukFeatureSchema(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$columns = [
|
||||||
|
'kategori_kebutuhan' => "ALTER TABLE penduduk_miskin ADD COLUMN kategori_kebutuhan VARCHAR(100) NULL AFTER status_bantuan",
|
||||||
|
'deskripsi_kebutuhan' => "ALTER TABLE penduduk_miskin ADD COLUMN deskripsi_kebutuhan TEXT NULL AFTER kategori_kebutuhan",
|
||||||
|
'tindak_lanjut' => "ALTER TABLE penduduk_miskin ADD COLUMN tindak_lanjut VARCHAR(60) NOT NULL DEFAULT 'Belum disurvei' AFTER deskripsi_kebutuhan",
|
||||||
|
'kondisi_rumah' => "ALTER TABLE penduduk_miskin ADD COLUMN kondisi_rumah VARCHAR(80) NULL AFTER tindak_lanjut",
|
||||||
|
'penghasilan' => "ALTER TABLE penduduk_miskin ADD COLUMN penghasilan INT NULL AFTER kondisi_rumah",
|
||||||
|
'catatan_survei' => "ALTER TABLE penduduk_miskin ADD COLUMN catatan_survei TEXT NULL AFTER penghasilan",
|
||||||
|
'bukti_file' => "ALTER TABLE penduduk_miskin ADD COLUMN bukti_file VARCHAR(255) NULL AFTER catatan_survei",
|
||||||
|
'skor_prioritas' => "ALTER TABLE penduduk_miskin ADD COLUMN skor_prioritas INT NOT NULL DEFAULT 0 AFTER bukti_file",
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($columns as $column => $sql) {
|
||||||
|
$stmt = $pdo->query("SHOW COLUMNS FROM penduduk_miskin LIKE " . $pdo->quote($column));
|
||||||
|
if ($stmt->fetch() === false) {
|
||||||
|
$pdo->exec($sql);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS riwayat_bantuan (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
penduduk_id INT NOT NULL,
|
||||||
|
jenis_bantuan VARCHAR(120) NOT NULL,
|
||||||
|
tanggal_bantuan DATE NOT NULL,
|
||||||
|
nilai_bantuan VARCHAR(80) NULL,
|
||||||
|
pemberi_bantuan VARCHAR(120) NULL,
|
||||||
|
catatan TEXT NULL,
|
||||||
|
bukti_file VARCHAR(255) NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (penduduk_id) REFERENCES penduduk_miskin(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDateOrNull(?string $value): ?string
|
||||||
|
{
|
||||||
|
$value = trim((string)$value);
|
||||||
|
return $value === '' || $value === '0000-00-00' ? null : $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNonNegativeIntOrNull(mixed $value, string $label): ?int
|
||||||
|
{
|
||||||
|
$value = trim((string)$value);
|
||||||
|
if ($value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_numeric($value) || (float)$value < 0) {
|
||||||
|
throw new InvalidArgumentException($label . ' tidak boleh bernilai negatif.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int)$value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculatePriorityScore(array $penduduk, array $anggota = []): int
|
||||||
|
{
|
||||||
|
$score = 0;
|
||||||
|
$anggotaCount = count($anggota);
|
||||||
|
$penghasilan = isset($penduduk['penghasilan']) ? (int)$penduduk['penghasilan'] : 0;
|
||||||
|
|
||||||
|
if (($penduduk['status_bantuan'] ?? '') === 'Belum dibantu') $score += 30;
|
||||||
|
if (($penduduk['status_bantuan'] ?? '') === 'Menunggu verifikasi') $score += 15;
|
||||||
|
if ((int)($penduduk['is_blank_spot'] ?? 0) === 1) $score += 15;
|
||||||
|
|
||||||
|
$score += min(20, $anggotaCount * 4);
|
||||||
|
|
||||||
|
foreach ($anggota as $a) {
|
||||||
|
$umur = isset($a['umur']) ? (int)$a['umur'] : null;
|
||||||
|
if ($umur !== null && ($umur < 6 || $umur >= 60)) {
|
||||||
|
$score += 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($penghasilan > 0 && $penghasilan <= 1000000) $score += 15;
|
||||||
|
elseif ($penghasilan > 1000000 && $penghasilan <= 2000000) $score += 8;
|
||||||
|
|
||||||
|
$kondisi = strtolower((string)($penduduk['kondisi_rumah'] ?? ''));
|
||||||
|
if (str_contains($kondisi, 'tidak layak')) $score += 15;
|
||||||
|
elseif (str_contains($kondisi, 'rentan')) $score += 8;
|
||||||
|
|
||||||
|
$kebutuhan = strtolower((string)($penduduk['kategori_kebutuhan'] ?? ''));
|
||||||
|
if (in_array($kebutuhan, ['kesehatan', 'rumah tidak layak', 'bantuan darurat'], true)) $score += 10;
|
||||||
|
|
||||||
|
return min(100, max(0, $score));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPriorityLabel(int $score): string
|
||||||
|
{
|
||||||
|
if ($score >= 70) return 'Prioritas Tinggi';
|
||||||
|
if ($score >= 40) return 'Prioritas Sedang';
|
||||||
|
return 'Prioritas Rendah';
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveUploadedEvidence(string $fieldName): ?string
|
||||||
|
{
|
||||||
|
if (empty($_FILES[$fieldName]) || !is_array($_FILES[$fieldName])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = $_FILES[$fieldName];
|
||||||
|
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($file['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) {
|
||||||
|
throw new RuntimeException('Upload bukti gagal.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($file['size'] ?? 0) > 3 * 1024 * 1024) {
|
||||||
|
throw new RuntimeException('Ukuran bukti maksimal 3 MB.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowed = [
|
||||||
|
'image/jpeg' => 'jpg',
|
||||||
|
'image/png' => 'png',
|
||||||
|
'image/webp' => 'webp',
|
||||||
|
'application/pdf' => 'pdf',
|
||||||
|
];
|
||||||
|
$mime = mime_content_type($file['tmp_name']);
|
||||||
|
|
||||||
|
if (!isset($allowed[$mime])) {
|
||||||
|
throw new RuntimeException('Format bukti harus JPG, PNG, WEBP, atau PDF.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$dir = __DIR__ . '/../uploads/bukti';
|
||||||
|
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
|
||||||
|
throw new RuntimeException('Folder upload tidak bisa dibuat.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$filename = date('YmdHis') . '-' . bin2hex(random_bytes(6)) . '.' . $allowed[$mime];
|
||||||
|
$target = $dir . '/' . $filename;
|
||||||
|
|
||||||
|
if (!move_uploaded_file($file['tmp_name'], $target)) {
|
||||||
|
throw new RuntimeException('Bukti tidak bisa disimpan.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'uploads/bukti/' . $filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPendudukInput(): array
|
||||||
|
{
|
||||||
|
if (str_starts_with((string)($_SERVER['CONTENT_TYPE'] ?? ''), 'multipart/form-data')) {
|
||||||
|
$input = $_POST;
|
||||||
|
$input['anggota'] = json_decode((string)($_POST['anggota'] ?? '[]'), true) ?: [];
|
||||||
|
return $input;
|
||||||
|
}
|
||||||
|
|
||||||
|
return json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePendudukPriority(PDO $pdo, int $pendudukId): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare("SELECT * FROM penduduk_miskin WHERE id = ?");
|
||||||
|
$stmt->execute([$pendudukId]);
|
||||||
|
$penduduk = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
if (!$penduduk) return;
|
||||||
|
|
||||||
|
$stmtAnggota = $pdo->prepare("SELECT * FROM anggota_keluarga WHERE penduduk_id = ?");
|
||||||
|
$stmtAnggota->execute([$pendudukId]);
|
||||||
|
$anggota = $stmtAnggota->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
$score = calculatePriorityScore($penduduk, $anggota);
|
||||||
|
|
||||||
|
$update = $pdo->prepare("UPDATE penduduk_miskin SET skor_prioritas = ? WHERE id = ?");
|
||||||
|
$update->execute([$score, $pendudukId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshAllPriorityScores(PDO $pdo): void
|
||||||
|
{
|
||||||
|
ensurePendudukFeatureSchema($pdo);
|
||||||
|
$ids = $pdo->query("SELECT id FROM penduduk_miskin")->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
updatePendudukPriority($pdo, (int)$id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
require_once 'db_pdo.php';
|
||||||
|
require_once 'auth.php';
|
||||||
|
require_once 'penduduk_feature_helper.php';
|
||||||
|
require_admin_login(true);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensurePendudukFeatureSchema($pdo);
|
||||||
|
refreshAllPriorityScores($pdo);
|
||||||
|
|
||||||
|
// Join with rumah_ibadah to get name
|
||||||
|
$stmt = $pdo->query("
|
||||||
|
SELECT p.*, r.nama as rumah_ibadah_nama
|
||||||
|
FROM penduduk_miskin p
|
||||||
|
LEFT JOIN rumah_ibadah r ON p.ibadah_id = r.id
|
||||||
|
ORDER BY p.skor_prioritas DESC, p.id DESC
|
||||||
|
");
|
||||||
|
$data = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Get all anggota
|
||||||
|
$stmtAnggota = $pdo->query("SELECT * FROM anggota_keluarga");
|
||||||
|
$anggota = $stmtAnggota->fetchAll();
|
||||||
|
|
||||||
|
// Map anggota to penduduk
|
||||||
|
$anggotaByPenduduk = [];
|
||||||
|
foreach ($anggota as $a) {
|
||||||
|
$anggotaByPenduduk[$a['penduduk_id']][] = $a;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmtRiwayat = $pdo->query("SELECT * FROM riwayat_bantuan ORDER BY tanggal_bantuan DESC, id DESC");
|
||||||
|
$riwayat = $stmtRiwayat->fetchAll();
|
||||||
|
$riwayatByPenduduk = [];
|
||||||
|
foreach ($riwayat as $r) {
|
||||||
|
$riwayatByPenduduk[$r['penduduk_id']][] = $r;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($data as &$d) {
|
||||||
|
$d['anggota'] = $anggotaByPenduduk[$d['id']] ?? [];
|
||||||
|
$d['riwayat_bantuan'] = $riwayatByPenduduk[$d['id']] ?? [];
|
||||||
|
$d['label_prioritas'] = getPriorityLabel((int)($d['skor_prioritas'] ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'data' => $data
|
||||||
|
]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
require_once 'db_pdo.php';
|
||||||
|
require_once 'haversine_helper.php';
|
||||||
|
require_once 'penduduk_feature_helper.php';
|
||||||
|
require_once 'auth.php';
|
||||||
|
require_admin_login(true);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
$input = readPendudukInput();
|
||||||
|
|
||||||
|
if (!$input || empty($input['id'])) {
|
||||||
|
echo json_encode(['success' => false, 'message' => 'Input tidak valid']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensurePendudukDistanceColumns($pdo);
|
||||||
|
ensurePendudukFeatureSchema($pdo);
|
||||||
|
|
||||||
|
$input_lat = floatval($input['lat']);
|
||||||
|
$input_lng = floatval($input['lng']);
|
||||||
|
$coverage = calculatePendudukCoverage($pdo, $input_lat, $input_lng);
|
||||||
|
$buktiFile = saveUploadedEvidence('bukti_file');
|
||||||
|
|
||||||
|
if ($buktiFile === null && !empty($input['existing_bukti_file'])) {
|
||||||
|
$buktiFile = $input['existing_bukti_file'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
UPDATE penduduk_miskin
|
||||||
|
SET kk_nama=?, kk_nik=?, alamat=?, lat=?, lng=?, status_bantuan=?, kategori_kebutuhan=?, deskripsi_kebutuhan=?, tindak_lanjut=?, kondisi_rumah=?, penghasilan=?, catatan_survei=?, bukti_file=?, jenis_bantuan=?, tanggal_bantuan=?, ibadah_id=?, jarak_m=?, jarak_rute_m=?, is_blank_spot=?
|
||||||
|
WHERE id=?
|
||||||
|
");
|
||||||
|
$stmt->execute([
|
||||||
|
$input['kk_nama'],
|
||||||
|
$input['kk_nik'] ?? null,
|
||||||
|
$input['alamat'],
|
||||||
|
$input['lat'],
|
||||||
|
$input['lng'],
|
||||||
|
$input['status_bantuan'] ?? 'Belum dibantu',
|
||||||
|
$input['kategori_kebutuhan'] ?? null,
|
||||||
|
$input['deskripsi_kebutuhan'] ?? null,
|
||||||
|
$input['tindak_lanjut'] ?? 'Belum disurvei',
|
||||||
|
$input['kondisi_rumah'] ?? null,
|
||||||
|
normalizeNonNegativeIntOrNull($input['penghasilan'] ?? '', 'Penghasilan'),
|
||||||
|
$input['catatan_survei'] ?? null,
|
||||||
|
$buktiFile,
|
||||||
|
$input['jenis_bantuan'] ?? null,
|
||||||
|
normalizeDateOrNull($input['tanggal_bantuan'] ?? null),
|
||||||
|
$coverage['ibadah_id'],
|
||||||
|
$coverage['jarak_m'],
|
||||||
|
$coverage['jarak_rute_m'],
|
||||||
|
$coverage['is_blank_spot'],
|
||||||
|
$input['id']
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 4. Delete old anggota and insert new ones
|
||||||
|
$pdo->prepare("DELETE FROM anggota_keluarga WHERE penduduk_id=?")->execute([$input['id']]);
|
||||||
|
|
||||||
|
if (!empty($input['anggota']) && is_array($input['anggota'])) {
|
||||||
|
$stmtAnggota = $pdo->prepare("INSERT INTO anggota_keluarga (penduduk_id, nama, hubungan, umur, pekerjaan) VALUES (?, ?, ?, ?, ?)");
|
||||||
|
foreach ($input['anggota'] as $anggota) {
|
||||||
|
$stmtAnggota->execute([
|
||||||
|
$input['id'],
|
||||||
|
$anggota['nama'],
|
||||||
|
$anggota['hubungan'],
|
||||||
|
$anggota['umur'] ?? null,
|
||||||
|
$anggota['pekerjaan'] ?? null
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePendudukPriority($pdo, (int)$input['id']);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'ibadah_id' => $coverage['ibadah_id'],
|
||||||
|
'jarak_m' => $coverage['jarak_m'],
|
||||||
|
'jarak_rute_m' => $coverage['jarak_rute_m'],
|
||||||
|
'is_blank_spot' => $coverage['is_blank_spot']
|
||||||
|
]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
require_once 'db_pdo.php';
|
||||||
|
require_once 'auth.php';
|
||||||
|
require_once 'haversine_helper.php';
|
||||||
|
require_once 'penduduk_feature_helper.php';
|
||||||
|
require_once 'rumah_ibadah_helper.php';
|
||||||
|
require_admin_login(true);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!$input) {
|
||||||
|
echo json_encode(['success' => false, 'message' => 'Input tidak valid']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
assertRumahIbadahNotDuplicate($pdo, $input);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO rumah_ibadah (nama, jenis, kontak, alamat, lat, lng, radius) VALUES (?, ?, ?, ?, ?, ?, ?)");
|
||||||
|
$stmt->execute([
|
||||||
|
$input['nama'],
|
||||||
|
$input['jenis'],
|
||||||
|
$input['kontak'],
|
||||||
|
$input['alamat'],
|
||||||
|
$input['lat'],
|
||||||
|
$input['lng'],
|
||||||
|
$input['radius'] ?? 500
|
||||||
|
]);
|
||||||
|
|
||||||
|
$id = $pdo->lastInsertId();
|
||||||
|
recalculateAllPendudukCoverage($pdo);
|
||||||
|
refreshAllPriorityScores($pdo);
|
||||||
|
|
||||||
|
echo json_encode(['success' => true, 'id' => $id]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
require_once 'db_pdo.php';
|
||||||
|
require_once 'auth.php';
|
||||||
|
require_once 'haversine_helper.php';
|
||||||
|
require_once 'penduduk_feature_helper.php';
|
||||||
|
require_admin_login(true);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!$input || empty($input['id'])) {
|
||||||
|
echo json_encode(['success' => false, 'message' => 'Input tidak valid']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->prepare("DELETE FROM rumah_ibadah WHERE id=?");
|
||||||
|
$stmt->execute([$input['id']]);
|
||||||
|
|
||||||
|
recalculateAllPendudukCoverage($pdo);
|
||||||
|
refreshAllPriorityScores($pdo);
|
||||||
|
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
function assertRumahIbadahNotDuplicate(PDO $pdo, array $input, ?int $ignoreId = null): void
|
||||||
|
{
|
||||||
|
$nama = trim((string)($input['nama'] ?? ''));
|
||||||
|
$jenis = trim((string)($input['jenis'] ?? ''));
|
||||||
|
$lat = round((float)($input['lat'] ?? 0), 6);
|
||||||
|
$lng = round((float)($input['lng'] ?? 0), 6);
|
||||||
|
$allowedJenis = ['Masjid', 'Gereja', 'Pura', 'Vihara', 'Klenteng', 'Lainnya'];
|
||||||
|
|
||||||
|
if ($nama === '' || $jenis === '' || !isset($input['lat'], $input['lng'])) {
|
||||||
|
throw new InvalidArgumentException('Nama, jenis, dan lokasi rumah ibadah wajib diisi.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!in_array($jenis, $allowedJenis, true)) {
|
||||||
|
throw new InvalidArgumentException('Jenis rumah ibadah tidak valid.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "
|
||||||
|
SELECT id
|
||||||
|
FROM rumah_ibadah
|
||||||
|
WHERE LOWER(TRIM(nama)) = LOWER(TRIM(?))
|
||||||
|
AND jenis = ?
|
||||||
|
AND ROUND(lat, 6) = ?
|
||||||
|
AND ROUND(lng, 6) = ?
|
||||||
|
";
|
||||||
|
$params = [$nama, $jenis, $lat, $lng];
|
||||||
|
|
||||||
|
if ($ignoreId !== null) {
|
||||||
|
$sql .= " AND id <> ?";
|
||||||
|
$params[] = $ignoreId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
|
||||||
|
if ($stmt->fetch()) {
|
||||||
|
throw new InvalidArgumentException('Rumah ibadah dengan nama, jenis, dan lokasi yang sama sudah ada.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
require_once 'db_pdo.php';
|
||||||
|
require_once 'auth.php';
|
||||||
|
require_admin_login(true);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->query("SELECT * FROM rumah_ibadah ORDER BY nama ASC");
|
||||||
|
$data = $stmt->fetchAll();
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'data' => $data
|
||||||
|
]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
require_once 'db_pdo.php';
|
||||||
|
require_once 'auth.php';
|
||||||
|
require_once 'haversine_helper.php';
|
||||||
|
require_once 'penduduk_feature_helper.php';
|
||||||
|
require_once 'rumah_ibadah_helper.php';
|
||||||
|
require_admin_login(true);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!$input || empty($input['id'])) {
|
||||||
|
echo json_encode(['success' => false, 'message' => 'Input tidak valid']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
assertRumahIbadahNotDuplicate($pdo, $input, (int)$input['id']);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("UPDATE rumah_ibadah SET nama=?, jenis=?, kontak=?, alamat=?, lat=?, lng=?, radius=? WHERE id=?");
|
||||||
|
$stmt->execute([
|
||||||
|
$input['nama'],
|
||||||
|
$input['jenis'],
|
||||||
|
$input['kontak'],
|
||||||
|
$input['alamat'],
|
||||||
|
$input['lat'],
|
||||||
|
$input['lng'],
|
||||||
|
$input['radius'],
|
||||||
|
$input['id']
|
||||||
|
]);
|
||||||
|
|
||||||
|
recalculateAllPendudukCoverage($pdo);
|
||||||
|
refreshAllPriorityScores($pdo);
|
||||||
|
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/auth.php';
|
||||||
|
require_admin_login(true);
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
$host = getenv('DB_HOST') ?: '127.0.0.1';
|
||||||
|
$username = getenv('DB_SETUP_USERNAME') ?: getenv('DB_USERNAME') ?: 'root';
|
||||||
|
$password = getenv('DB_SETUP_PASSWORD') ?: getenv('DB_PASSWORD') ?: '';
|
||||||
|
$charset = getenv('DB_CHARSET') ?: 'utf8mb4';
|
||||||
|
$schemaPath = __DIR__ . '/../sql/miskin_schema.sql';
|
||||||
|
|
||||||
|
$sql = file_get_contents($schemaPath);
|
||||||
|
|
||||||
|
if ($sql === false) {
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'File skema database tidak ditemukan.',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$dsn = "mysql:host={$host};charset={$charset}";
|
||||||
|
$pdo = new PDO($dsn, $username, $password, [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$pdo->exec($sql);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Database webgis_miskin berhasil di-reset.',
|
||||||
|
]);
|
||||||
|
} catch (PDOException $exception) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Gagal reset database.',
|
||||||
|
'error' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
color: #172033;
|
||||||
|
background: #eef2f7;
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-page {
|
||||||
|
width: min(1040px, calc(100% - 32px));
|
||||||
|
margin: 28px auto;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.13);
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-hero {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
border-bottom: 2px solid #1f2937;
|
||||||
|
padding-bottom: 22px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: #4f46e5;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
margin-top: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
max-width: 720px;
|
||||||
|
color: #475467;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-btn {
|
||||||
|
align-self: flex-start;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #4f46e5;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 800;
|
||||||
|
padding: 11px 15px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-panel {
|
||||||
|
border: 1px solid #d8dee9;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-grid div,
|
||||||
|
.legend-grid div {
|
||||||
|
border: 1px solid #d8dee9;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-grid span {
|
||||||
|
display: block;
|
||||||
|
color: #667085;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-grid strong {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
color: #667085;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 12px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
border: 1px solid #d8dee9;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: #eef2ff;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #3730a3;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps ol {
|
||||||
|
padding-left: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps li,
|
||||||
|
ul li {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-grid strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-grid p {
|
||||||
|
color: #667085;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.worship {
|
||||||
|
background: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.reached {
|
||||||
|
background: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.outside {
|
||||||
|
background: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.guide-hero,
|
||||||
|
.info-grid,
|
||||||
|
.legend-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-page {
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
body {
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-page {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
:root {
|
||||||
|
--primary: #4f46e5;
|
||||||
|
--primary-hover: #4338ca;
|
||||||
|
--dark: #1e293b;
|
||||||
|
--gray: #64748b;
|
||||||
|
--gray-light: #e2e8f0;
|
||||||
|
--light: #f8fafc;
|
||||||
|
--white: #ffffff;
|
||||||
|
--danger: #ef4444;
|
||||||
|
--shadow-lg: 0 18px 40px rgba(15, 23, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
color: var(--dark);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(79, 70, 229, 0.16), transparent 34%),
|
||||||
|
linear-gradient(135deg, #f8fafc 0%, #eef2ff 52%, #f8fafc 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-panel {
|
||||||
|
width: min(100%, 430px);
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
border: 1px solid rgba(226, 232, 240, 0.9);
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
padding: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 16px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--white);
|
||||||
|
background: var(--primary);
|
||||||
|
font-size: 1.45rem;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: var(--gray);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-error {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
color: #991b1b;
|
||||||
|
background-color: #fee2e2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group span {
|
||||||
|
color: var(--dark);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-shell {
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
border: 1px solid var(--gray-light);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--light);
|
||||||
|
padding: 0 14px;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-shell:focus-within {
|
||||||
|
background: var(--white);
|
||||||
|
border-color: rgba(79, 70, 229, 0.45);
|
||||||
|
box-shadow: 0 0 0 4px rgba(79, 70, 229, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-shell i {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-shell input {
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--dark);
|
||||||
|
font-size: 0.96rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login {
|
||||||
|
height: 48px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--white);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.98rem;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-top: 4px;
|
||||||
|
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-hint {
|
||||||
|
color: var(--gray);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
margin-top: 18px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.login-page {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-panel {
|
||||||
|
padding: 26px;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
color: #172033;
|
||||||
|
background: #eef2f7;
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-page {
|
||||||
|
width: min(1180px, calc(100% - 32px));
|
||||||
|
margin: 24px auto;
|
||||||
|
background: #fff;
|
||||||
|
padding: 28px;
|
||||||
|
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
border-bottom: 2px solid #1f2937;
|
||||||
|
padding-bottom: 18px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
color: #4f46e5;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-btn {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #4f46e5;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid div {
|
||||||
|
border: 1px solid #d8dee9;
|
||||||
|
border-left: 4px solid #4f46e5;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid span {
|
||||||
|
display: block;
|
||||||
|
color: #667085;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid strong {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-note {
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #d8dee9;
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
border: 1px solid #d8dee9;
|
||||||
|
padding: 8px;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: #f1f5f9;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
color: #667085;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
color: #667085;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
body {
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-page {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
page-break-inside: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr {
|
||||||
|
page-break-inside: avoid;
|
||||||
|
page-break-after: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,793 @@
|
|||||||
|
// Globals
|
||||||
|
let map;
|
||||||
|
let layerRI, layerPM, layerRadius;
|
||||||
|
let riData = [];
|
||||||
|
let pmData = [];
|
||||||
|
let pickingMode = null; // 'ri' or 'pm'
|
||||||
|
let tempMarker = null;
|
||||||
|
let currentView = 'dashboard';
|
||||||
|
let searchTerm = '';
|
||||||
|
|
||||||
|
const rumahIbadahIconConfig = {
|
||||||
|
Masjid: { icon: 'fa-mosque', className: 'mosque' },
|
||||||
|
Gereja: { icon: 'fa-church', className: 'church' },
|
||||||
|
Pura: { icon: 'fa-gopuram', className: 'temple' },
|
||||||
|
Vihara: { icon: 'fa-vihara', className: 'vihara' },
|
||||||
|
Klenteng: { icon: 'fa-torii-gate', className: 'klenteng' },
|
||||||
|
Lainnya: { icon: 'fa-place-of-worship', className: 'other' }
|
||||||
|
};
|
||||||
|
|
||||||
|
function getRumahIbadahIcon(jenis) {
|
||||||
|
const config = rumahIbadahIconConfig[jenis] || rumahIbadahIconConfig.Lainnya;
|
||||||
|
|
||||||
|
return L.divIcon({
|
||||||
|
className: 'ri-div-icon',
|
||||||
|
html: `<div class="ri-marker ri-${config.className}" title="${jenis || 'Rumah Ibadah'}"><i class="fas ${config.icon}"></i></div>`,
|
||||||
|
iconSize: [36, 46],
|
||||||
|
iconAnchor: [18, 43],
|
||||||
|
popupAnchor: [0, -43]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// PM Icons are now circleMarkers generated inline in renderPM
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
await initMap();
|
||||||
|
initUI();
|
||||||
|
loadData();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function initMap() {
|
||||||
|
map = L.map('map', {
|
||||||
|
zoomControl: false,
|
||||||
|
preferCanvas: true
|
||||||
|
}).setView([-0.060547, 109.344902], 13);
|
||||||
|
window.map = map;
|
||||||
|
|
||||||
|
L.control.zoom({ position: 'bottomright' }).addTo(map);
|
||||||
|
|
||||||
|
const tileOptions = {
|
||||||
|
keepBuffer: 6,
|
||||||
|
updateWhenIdle: false,
|
||||||
|
updateWhenZooming: false,
|
||||||
|
updateInterval: 100
|
||||||
|
};
|
||||||
|
|
||||||
|
const osm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
...tileOptions,
|
||||||
|
attribution: '© OpenStreetMap contributors'
|
||||||
|
});
|
||||||
|
|
||||||
|
const satelit = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
|
||||||
|
...tileOptions,
|
||||||
|
attribution: 'Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community'
|
||||||
|
});
|
||||||
|
|
||||||
|
const darkMap = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||||
|
...tileOptions,
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
||||||
|
subdomains: 'abcd',
|
||||||
|
maxZoom: 20
|
||||||
|
});
|
||||||
|
|
||||||
|
osm.addTo(map);
|
||||||
|
|
||||||
|
const baseMaps = {
|
||||||
|
"OSM Standard": osm,
|
||||||
|
"Citra Satelit": satelit,
|
||||||
|
"Dark Mode": darkMap
|
||||||
|
};
|
||||||
|
|
||||||
|
layerRadius = L.featureGroup().addTo(map);
|
||||||
|
layerRI = L.featureGroup().addTo(map);
|
||||||
|
layerPM = L.featureGroup().addTo(map);
|
||||||
|
|
||||||
|
const overlays = {
|
||||||
|
"Radius Layanan": layerRadius,
|
||||||
|
"Rumah Ibadah": layerRI,
|
||||||
|
"Penduduk Miskin": layerPM
|
||||||
|
};
|
||||||
|
|
||||||
|
L.control.layers(baseMaps, overlays, { position: 'topright' }).addTo(map);
|
||||||
|
|
||||||
|
// Map Click for Picking Location
|
||||||
|
map.on('click', async (e) => {
|
||||||
|
if (!pickingMode) return;
|
||||||
|
|
||||||
|
const lat = e.latlng.lat;
|
||||||
|
const lng = e.latlng.lng;
|
||||||
|
|
||||||
|
if (tempMarker) map.removeLayer(tempMarker);
|
||||||
|
tempMarker = L.marker([lat, lng]).addTo(map);
|
||||||
|
|
||||||
|
if (pickingMode === 'ri') {
|
||||||
|
document.getElementById('ri-lat').value = lat.toFixed(6);
|
||||||
|
document.getElementById('ri-lng').value = lng.toFixed(6);
|
||||||
|
document.getElementById('ri-alamat').value = 'Mencari alamat...';
|
||||||
|
document.getElementById('ri-alamat').value = await reverseGeocode(lat, lng);
|
||||||
|
document.getElementById('modal-ri').classList.add('show');
|
||||||
|
} else if (pickingMode === 'pm') {
|
||||||
|
document.getElementById('pm-lat').value = lat.toFixed(6);
|
||||||
|
document.getElementById('pm-lng').value = lng.toFixed(6);
|
||||||
|
document.getElementById('pm-alamat').value = 'Mencari alamat...';
|
||||||
|
document.getElementById('pm-alamat').value = await reverseGeocode(lat, lng);
|
||||||
|
document.getElementById('modal-pm').classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
pickingMode = null;
|
||||||
|
if(tempMarker) map.removeLayer(tempMarker);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initUI() {
|
||||||
|
// Sidebar Toggle
|
||||||
|
document.getElementById('toggle-sidebar').addEventListener('click', () => {
|
||||||
|
const sidebar = document.getElementById('sidebar');
|
||||||
|
if (window.innerWidth <= 768) {
|
||||||
|
sidebar.classList.toggle('open');
|
||||||
|
} else {
|
||||||
|
sidebar.classList.toggle('collapsed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.menu-item[data-view]').forEach(item => {
|
||||||
|
item.addEventListener('click', () => switchView(item.dataset.view));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Menus
|
||||||
|
const openAddRI = () => {
|
||||||
|
switchView('map');
|
||||||
|
document.getElementById('form-ri').reset();
|
||||||
|
document.getElementById('ri-id').value = '';
|
||||||
|
document.getElementById('modal-ri-title').innerText = 'Tambah Rumah Ibadah';
|
||||||
|
document.getElementById('modal-ri').classList.add('show');
|
||||||
|
};
|
||||||
|
document.getElementById('menu-add-ri').addEventListener('click', openAddRI);
|
||||||
|
document.getElementById('fab-add-ri')?.addEventListener('click', openAddRI);
|
||||||
|
|
||||||
|
const openAddPM = () => {
|
||||||
|
switchView('map');
|
||||||
|
document.getElementById('form-pm').reset();
|
||||||
|
document.getElementById('pm-id').value = '';
|
||||||
|
document.getElementById('anggota-container').innerHTML = '';
|
||||||
|
document.getElementById('bantuan-fields').style.display = 'none';
|
||||||
|
document.getElementById('modal-pm-title').innerText = 'Tambah Penduduk Miskin';
|
||||||
|
document.getElementById('modal-pm').classList.add('show');
|
||||||
|
};
|
||||||
|
document.getElementById('menu-add-pm').addEventListener('click', openAddPM);
|
||||||
|
document.getElementById('fab-add-pm')?.addEventListener('click', openAddPM);
|
||||||
|
document.getElementById('dashboard-add-ri')?.addEventListener('click', openAddRI);
|
||||||
|
document.getElementById('dashboard-add-pm')?.addEventListener('click', openAddPM);
|
||||||
|
document.getElementById('dashboard-open-map')?.addEventListener('click', () => switchView('map'));
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
document.getElementById('filter-status').addEventListener('change', renderMapLayers);
|
||||||
|
document.getElementById('filter-jenis').addEventListener('change', renderMapLayers);
|
||||||
|
document.getElementById('filter-kebutuhan').addEventListener('change', renderPM);
|
||||||
|
|
||||||
|
// Search
|
||||||
|
document.getElementById('search-input').addEventListener('input', () => {
|
||||||
|
searchTerm = getSearchTerm();
|
||||||
|
renderMapLayers();
|
||||||
|
});
|
||||||
|
document.getElementById('search-input').addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
applyMapSearch(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('search-button').addEventListener('click', () => applyMapSearch(true));
|
||||||
|
|
||||||
|
// Location
|
||||||
|
document.getElementById('btn-my-location').addEventListener('click', () => {
|
||||||
|
if (navigator.geolocation) {
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(position) => {
|
||||||
|
const lat = position.coords.latitude;
|
||||||
|
const lng = position.coords.longitude;
|
||||||
|
map.flyTo([lat, lng], 16);
|
||||||
|
L.marker([lat, lng]).addTo(map).bindPopup('Lokasi Anda').openPopup();
|
||||||
|
},
|
||||||
|
(err) => alert('Gagal mendapatkan lokasi: ' + err.message)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
alert('Geolocation tidak didukung browser ini.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
switchView(currentView);
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchView(view) {
|
||||||
|
currentView = view;
|
||||||
|
const mainContent = document.querySelector('.main-content');
|
||||||
|
|
||||||
|
document.querySelectorAll('.view-panel').forEach(panel => {
|
||||||
|
panel.classList.toggle('active', panel.id === `${view}-view`);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.menu-item[data-view]').forEach(item => {
|
||||||
|
item.classList.toggle('active', item.dataset.view === view);
|
||||||
|
});
|
||||||
|
|
||||||
|
mainContent?.classList.toggle('map-mode', view === 'map');
|
||||||
|
mainContent?.classList.toggle('dashboard-mode', view !== 'map');
|
||||||
|
document.querySelector('.search-bar')?.classList.toggle('hidden', view !== 'map');
|
||||||
|
document.querySelector('.map-header-controls')?.classList.toggle('hidden', view !== 'map');
|
||||||
|
|
||||||
|
if (window.innerWidth <= 768) {
|
||||||
|
document.getElementById('sidebar').classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view === 'map' && map) {
|
||||||
|
refreshMapSize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshMapSize() {
|
||||||
|
[0, 100, 300].forEach(delay => {
|
||||||
|
setTimeout(() => map.invalidateSize({ pan: false }), delay);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSearchTerm() {
|
||||||
|
return document.getElementById('search-input').value.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function textIncludes(value, term) {
|
||||||
|
return String(value || '').toLowerCase().includes(term);
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesRI(ri) {
|
||||||
|
if (!searchTerm) return true;
|
||||||
|
|
||||||
|
return [
|
||||||
|
ri.nama,
|
||||||
|
ri.jenis,
|
||||||
|
ri.kontak,
|
||||||
|
ri.alamat
|
||||||
|
].some(value => textIncludes(value, searchTerm));
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesPM(pm) {
|
||||||
|
if (!searchTerm) return true;
|
||||||
|
|
||||||
|
const anggotaMatch = Array.isArray(pm.anggota) && pm.anggota.some(anggota => [
|
||||||
|
anggota.nama,
|
||||||
|
anggota.hubungan,
|
||||||
|
anggota.pekerjaan
|
||||||
|
].some(value => textIncludes(value, searchTerm)));
|
||||||
|
|
||||||
|
return anggotaMatch || [
|
||||||
|
pm.kk_nama,
|
||||||
|
pm.kk_nik,
|
||||||
|
pm.alamat,
|
||||||
|
pm.status_bantuan,
|
||||||
|
pm.jenis_bantuan,
|
||||||
|
pm.kategori_kebutuhan,
|
||||||
|
pm.deskripsi_kebutuhan,
|
||||||
|
pm.tindak_lanjut,
|
||||||
|
pm.kondisi_rumah,
|
||||||
|
pm.rumah_ibadah_nama
|
||||||
|
].some(value => textIncludes(value, searchTerm));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFilteredRI() {
|
||||||
|
const filterJenis = document.getElementById('filter-jenis').value;
|
||||||
|
|
||||||
|
return riData.filter(ri => {
|
||||||
|
if (filterJenis !== 'all' && ri.jenis !== filterJenis) return false;
|
||||||
|
return matchesRI(ri);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFilteredPM() {
|
||||||
|
const filterStatus = document.getElementById('filter-status').value;
|
||||||
|
const filterKebutuhan = document.getElementById('filter-kebutuhan').value;
|
||||||
|
|
||||||
|
return pmData.filter(pm => {
|
||||||
|
if (filterStatus !== 'all' && pm.status_bantuan !== filterStatus) return false;
|
||||||
|
if (filterKebutuhan !== 'all' && pm.kategori_kebutuhan !== filterKebutuhan) return false;
|
||||||
|
return matchesPM(pm);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMapLayers() {
|
||||||
|
renderRI();
|
||||||
|
renderPM();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMapSearch(focusResult = false) {
|
||||||
|
searchTerm = getSearchTerm();
|
||||||
|
renderMapLayers();
|
||||||
|
|
||||||
|
if (focusResult) {
|
||||||
|
switchView('map');
|
||||||
|
focusSearchResults();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusSearchResults() {
|
||||||
|
const results = [
|
||||||
|
...getFilteredRI().map(item => [parseFloat(item.lat), parseFloat(item.lng)]),
|
||||||
|
...getFilteredPM().map(item => [parseFloat(item.lat), parseFloat(item.lng)])
|
||||||
|
].filter(([lat, lng]) => Number.isFinite(lat) && Number.isFinite(lng));
|
||||||
|
|
||||||
|
if (!searchTerm || results.length === 0) {
|
||||||
|
alert(searchTerm ? 'Data tidak ditemukan di peta.' : 'Masukkan kata kunci pencarian.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.length === 1) {
|
||||||
|
map.flyTo(results[0], 16);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
map.fitBounds(L.latLngBounds(results), { padding: [40, 40], maxZoom: 16 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickLocation(mode) {
|
||||||
|
switchView('map');
|
||||||
|
pickingMode = mode;
|
||||||
|
closeModal('modal-' + mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal(id) {
|
||||||
|
document.getElementById(id).classList.remove('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reverseGeocode(lat, lng) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}`);
|
||||||
|
const data = await res.json();
|
||||||
|
return data.display_name || 'Alamat tidak ditemukan';
|
||||||
|
} catch (e) {
|
||||||
|
return 'Gagal memuat alamat';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBantuanFields() {
|
||||||
|
const status = document.getElementById('pm-status').value;
|
||||||
|
document.getElementById('bantuan-fields').style.display = status === 'Sudah dibantu' ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAnggotaField(data = null) {
|
||||||
|
const container = document.getElementById('anggota-container');
|
||||||
|
const id = Date.now() + Math.floor(Math.random() * 100);
|
||||||
|
const html = `
|
||||||
|
<div class="dynamic-item" id="anggota-${id}">
|
||||||
|
<button type="button" class="btn-remove" onclick="document.getElementById('anggota-${id}').remove()">X</button>
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Nama</label>
|
||||||
|
<input type="text" class="form-control a-nama" value="${data ? data.nama : ''}" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Hubungan</label>
|
||||||
|
<input type="text" class="form-control a-hubungan" value="${data ? data.hubungan : ''}" placeholder="Anak/Istri/dll" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Umur</label>
|
||||||
|
<input type="number" class="form-control a-umur" value="${data ? data.umur : ''}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Pekerjaan</label>
|
||||||
|
<input type="text" class="form-control a-pekerjaan" value="${data && data.pekerjaan ? data.pekerjaan : ''}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.insertAdjacentHTML('beforeend', html);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data Fetching and Rendering
|
||||||
|
async function loadData() {
|
||||||
|
await loadStats();
|
||||||
|
await fetchRI();
|
||||||
|
await fetchPM();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('api/dashboard_stats.php');
|
||||||
|
const json = await res.json();
|
||||||
|
if(json.success) {
|
||||||
|
document.getElementById('stat-ri').innerText = json.data.total_rumah_ibadah;
|
||||||
|
document.getElementById('stat-pm').innerText = json.data.total_penduduk;
|
||||||
|
document.getElementById('stat-sudah').innerText = json.data.sudah_dibantu;
|
||||||
|
document.getElementById('stat-belum').innerText = json.data.belum_dibantu;
|
||||||
|
document.getElementById('stat-menunggu').innerText = json.data.menunggu_verifikasi || 0;
|
||||||
|
document.getElementById('stat-prioritas').innerText = json.data.prioritas_tinggi || 0;
|
||||||
|
document.getElementById('stat-luar-radius').innerText = json.data.luar_radius || 0;
|
||||||
|
document.getElementById('summary-sudah').innerText = json.data.sudah_dibantu;
|
||||||
|
document.getElementById('summary-belum').innerText = json.data.belum_dibantu;
|
||||||
|
document.getElementById('summary-menunggu').innerText = json.data.menunggu_verifikasi || 0;
|
||||||
|
renderDashboardLists(json.data);
|
||||||
|
}
|
||||||
|
} catch(e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDashboardLists(stats) {
|
||||||
|
const priorityList = document.getElementById('priority-list');
|
||||||
|
const needList = document.getElementById('need-list');
|
||||||
|
|
||||||
|
if (priorityList) {
|
||||||
|
const items = stats.top_prioritas || [];
|
||||||
|
priorityList.innerHTML = items.length ? items.map(item => `
|
||||||
|
<div class="priority-item">
|
||||||
|
<div class="priority-score">${item.skor_prioritas || 0}</div>
|
||||||
|
<div>
|
||||||
|
<strong>${item.kk_nama}</strong>
|
||||||
|
<p>${item.kategori_kebutuhan || 'Kebutuhan belum diisi'} - ${item.tindak_lanjut || 'Belum disurvei'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('') : '<div class="need-item"><p>Belum ada data prioritas.</p></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needList) {
|
||||||
|
const items = stats.kebutuhan_terbanyak || [];
|
||||||
|
needList.innerHTML = items.length ? items.map(item => `
|
||||||
|
<div class="need-item">
|
||||||
|
<strong>${item.kategori}</strong>
|
||||||
|
<p>${item.total} keluarga membutuhkan kategori ini</p>
|
||||||
|
</div>
|
||||||
|
`).join('') : '<div class="need-item"><p>Belum ada data kebutuhan.</p></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRI() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('api/rumah_ibadah_read.php');
|
||||||
|
const json = await res.json();
|
||||||
|
if(json.success) {
|
||||||
|
riData = json.data;
|
||||||
|
renderRI();
|
||||||
|
}
|
||||||
|
} catch(e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPM() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('api/penduduk_read.php');
|
||||||
|
const json = await res.json();
|
||||||
|
if(json.success) {
|
||||||
|
pmData = json.data;
|
||||||
|
renderPM();
|
||||||
|
}
|
||||||
|
} catch(e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRI() {
|
||||||
|
layerRI.clearLayers();
|
||||||
|
layerRadius.clearLayers();
|
||||||
|
|
||||||
|
getFilteredRI().forEach(ri => {
|
||||||
|
const lat = parseFloat(ri.lat);
|
||||||
|
const lng = parseFloat(ri.lng);
|
||||||
|
const rad = parseInt(ri.radius) || 500;
|
||||||
|
|
||||||
|
// Circle
|
||||||
|
L.circle([lat, lng], {
|
||||||
|
color: '#4f46e5',
|
||||||
|
fillColor: '#4f46e5',
|
||||||
|
fillOpacity: 0.1,
|
||||||
|
radius: rad,
|
||||||
|
interactive: false
|
||||||
|
}).addTo(layerRadius);
|
||||||
|
|
||||||
|
// Marker
|
||||||
|
const marker = L.marker([lat, lng], { icon: getRumahIbadahIcon(ri.jenis) }).addTo(layerRI);
|
||||||
|
marker.feature = ri;
|
||||||
|
|
||||||
|
let popup = `
|
||||||
|
<div class="popup-header">
|
||||||
|
<h3>${ri.nama}</h3>
|
||||||
|
<p>${ri.jenis}</p>
|
||||||
|
</div>
|
||||||
|
<div class="popup-body">
|
||||||
|
<div class="popup-row"><div class="label">Kontak</div><div class="value">${ri.kontak || '-'}</div></div>
|
||||||
|
<div class="popup-row"><div class="label">Radius</div><div class="value">${rad} m</div></div>
|
||||||
|
<div class="popup-row" style="flex-direction: column;"><div class="label" style="width:100%">Alamat</div><div class="value" style="font-size:0.8rem; font-weight:400">${ri.alamat}</div></div>
|
||||||
|
<div class="popup-actions">
|
||||||
|
<button class="btn-edit" onclick='editRI(${JSON.stringify(ri)})'>Edit</button>
|
||||||
|
<button class="btn-delete" onclick='deleteRI(${ri.id})'>Hapus</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
marker.bindPopup(popup);
|
||||||
|
});
|
||||||
|
|
||||||
|
layerRadius.eachLayer(circle => circle.bringToBack());
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPM() {
|
||||||
|
layerPM.clearLayers();
|
||||||
|
|
||||||
|
getFilteredPM().forEach(pm => {
|
||||||
|
const lat = parseFloat(pm.lat);
|
||||||
|
const lng = parseFloat(pm.lng);
|
||||||
|
const anggotaCount = pm.anggota ? pm.anggota.length : 0;
|
||||||
|
|
||||||
|
let badgeClass = 'badge-danger';
|
||||||
|
if(pm.status_bantuan === 'Sudah dibantu') badgeClass = 'badge-success';
|
||||||
|
else if(pm.status_bantuan === 'Menunggu verifikasi') badgeClass = 'badge-warning';
|
||||||
|
|
||||||
|
const isBlankSpot = pm.is_blank_spot == 1;
|
||||||
|
const pmColor = isBlankSpot ? '#ef4444' : '#10b981'; // Merah (Blank Spot), Hijau (Terjangkau)
|
||||||
|
const riwayatHtml = Array.isArray(pm.riwayat_bantuan) && pm.riwayat_bantuan.length
|
||||||
|
? pm.riwayat_bantuan.slice(0, 3).map(r => `${r.tanggal_bantuan}: ${r.jenis_bantuan}`).join('<br>')
|
||||||
|
: '-';
|
||||||
|
const buktiHtml = pm.bukti_file
|
||||||
|
? `<a href="${pm.bukti_file}" target="_blank">Lihat bukti</a>`
|
||||||
|
: '-';
|
||||||
|
|
||||||
|
const marker = L.circleMarker([lat, lng], {
|
||||||
|
radius: 7,
|
||||||
|
fillColor: pmColor,
|
||||||
|
color: '#ffffff',
|
||||||
|
weight: 1.5,
|
||||||
|
opacity: 1,
|
||||||
|
fillOpacity: 0.9
|
||||||
|
}).addTo(layerPM);
|
||||||
|
marker.feature = pm;
|
||||||
|
|
||||||
|
let popup = `
|
||||||
|
<div class="popup-header ${pm.status_bantuan === 'Sudah dibantu' ? 'success' : pm.status_bantuan === 'Menunggu verifikasi' ? 'warning' : 'danger'}">
|
||||||
|
<h3>Keluarga ${pm.kk_nama}</h3>
|
||||||
|
<span class="badge ${badgeClass}">${pm.status_bantuan}</span>
|
||||||
|
</div>
|
||||||
|
<div class="popup-body">
|
||||||
|
<div class="popup-row"><div class="label">Jumlah Anggota</div><div class="value">${anggotaCount} orang</div></div>
|
||||||
|
<div class="popup-row"><div class="label">Rumah Ibadah Terdekat</div><div class="value">${pm.rumah_ibadah_nama || '-'}</div></div>
|
||||||
|
<div class="popup-row"><div class="label">Prioritas</div><div class="value">${pm.skor_prioritas || 0} (${pm.label_prioritas || '-'})</div></div>
|
||||||
|
<div class="popup-row"><div class="label">Kebutuhan</div><div class="value">${pm.kategori_kebutuhan || '-'}</div></div>
|
||||||
|
<div class="popup-row"><div class="label">Tindak Lanjut</div><div class="value">${pm.tindak_lanjut || 'Belum disurvei'}</div></div>
|
||||||
|
<div class="popup-row"><div class="label">Jarak Lurus</div><div class="value">${pm.jarak_m ? pm.jarak_m + ' meter' : '-'} ${isBlankSpot ? '<i style="color:red">(Di luar radius)</i>' : '<i style="color:green">(Terjangkau)</i>'}</div></div>
|
||||||
|
<div class="popup-row"><div class="label">Jarak Rute</div><div class="value">${pm.jarak_rute_m ? pm.jarak_rute_m + ' meter' : '-'}</div></div>
|
||||||
|
${pm.status_bantuan === 'Sudah dibantu' ? `<div class="popup-row"><div class="label">Bantuan</div><div class="value">${pm.jenis_bantuan} (${pm.tanggal_bantuan})</div></div>` : ''}
|
||||||
|
<div class="popup-row"><div class="label">Riwayat</div><div class="value">${riwayatHtml}</div></div>
|
||||||
|
<div class="popup-row"><div class="label">Bukti</div><div class="value">${buktiHtml}</div></div>
|
||||||
|
<div class="popup-row" style="flex-direction: column;"><div class="label" style="width:100%">Alamat</div><div class="value" style="font-size:0.8rem; font-weight:400">${pm.alamat}</div></div>
|
||||||
|
<div class="popup-actions">
|
||||||
|
<button class="btn-edit" onclick='editPM(${JSON.stringify(pm).replace(/'/g, "\\'")})'>Edit</button>
|
||||||
|
<button class="btn-edit" onclick='openBantuanModal(${pm.id})'>Bantuan</button>
|
||||||
|
<button class="btn-delete" onclick='deletePM(${pm.id})'>Hapus</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
marker.bindPopup(popup);
|
||||||
|
});
|
||||||
|
|
||||||
|
layerPM.bringToFront();
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRUD Operations
|
||||||
|
async function saveRI() {
|
||||||
|
const id = document.getElementById('ri-id').value;
|
||||||
|
const data = {
|
||||||
|
nama: document.getElementById('ri-nama').value,
|
||||||
|
jenis: document.getElementById('ri-jenis').value,
|
||||||
|
kontak: document.getElementById('ri-kontak').value,
|
||||||
|
lat: document.getElementById('ri-lat').value,
|
||||||
|
lng: document.getElementById('ri-lng').value,
|
||||||
|
alamat: document.getElementById('ri-alamat').value,
|
||||||
|
radius: document.getElementById('ri-radius').value
|
||||||
|
};
|
||||||
|
|
||||||
|
if(!data.nama || !data.lat) { alert("Isi form dengan lengkap"); return; }
|
||||||
|
|
||||||
|
const url = id ? 'api/rumah_ibadah_update.php' : 'api/rumah_ibadah_create.php';
|
||||||
|
if(id) data.id = id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if(json.success) {
|
||||||
|
closeModal('modal-ri');
|
||||||
|
await fetchRI();
|
||||||
|
await fetchPM();
|
||||||
|
await loadStats();
|
||||||
|
} else {
|
||||||
|
alert(json.message);
|
||||||
|
}
|
||||||
|
} catch(e) { alert('Error saving data'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePM() {
|
||||||
|
const id = document.getElementById('pm-id').value;
|
||||||
|
const data = new FormData();
|
||||||
|
const anggota = [];
|
||||||
|
|
||||||
|
data.append('kk_nama', document.getElementById('pm-nama').value);
|
||||||
|
data.append('kk_nik', document.getElementById('pm-nik').value);
|
||||||
|
data.append('lat', document.getElementById('pm-lat').value);
|
||||||
|
data.append('lng', document.getElementById('pm-lng').value);
|
||||||
|
data.append('alamat', document.getElementById('pm-alamat').value);
|
||||||
|
data.append('kategori_kebutuhan', document.getElementById('pm-kategori-kebutuhan').value);
|
||||||
|
data.append('deskripsi_kebutuhan', document.getElementById('pm-deskripsi-kebutuhan').value);
|
||||||
|
data.append('kondisi_rumah', document.getElementById('pm-kondisi-rumah').value);
|
||||||
|
data.append('penghasilan', document.getElementById('pm-penghasilan').value);
|
||||||
|
data.append('status_bantuan', document.getElementById('pm-status').value);
|
||||||
|
data.append('jenis_bantuan', document.getElementById('pm-jenis-bantuan').value);
|
||||||
|
data.append('tanggal_bantuan', document.getElementById('pm-tanggal-bantuan').value);
|
||||||
|
data.append('tindak_lanjut', document.getElementById('pm-tindak-lanjut').value);
|
||||||
|
data.append('catatan_survei', document.getElementById('pm-catatan-survei').value);
|
||||||
|
data.append('existing_bukti_file', document.getElementById('pm-existing-bukti-file').value);
|
||||||
|
|
||||||
|
const buktiFile = document.getElementById('pm-bukti-file').files[0];
|
||||||
|
if (buktiFile) data.append('bukti_file', buktiFile);
|
||||||
|
|
||||||
|
if(!data.get('kk_nama') || !data.get('lat')) { alert("Isi form dengan lengkap"); return; }
|
||||||
|
if (data.get('penghasilan') !== '' && Number(data.get('penghasilan')) < 0) {
|
||||||
|
alert('Penghasilan tidak boleh bernilai negatif.');
|
||||||
|
document.getElementById('pm-penghasilan').focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = document.querySelectorAll('.dynamic-item');
|
||||||
|
items.forEach(el => {
|
||||||
|
anggota.push({
|
||||||
|
nama: el.querySelector('.a-nama').value,
|
||||||
|
hubungan: el.querySelector('.a-hubungan').value,
|
||||||
|
umur: el.querySelector('.a-umur').value,
|
||||||
|
pekerjaan: el.querySelector('.a-pekerjaan').value
|
||||||
|
});
|
||||||
|
});
|
||||||
|
data.append('anggota', JSON.stringify(anggota));
|
||||||
|
|
||||||
|
const url = id ? 'api/penduduk_update.php' : 'api/penduduk_create.php';
|
||||||
|
if(id) data.append('id', id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: data
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if(json.success) {
|
||||||
|
closeModal('modal-pm');
|
||||||
|
await fetchPM();
|
||||||
|
await loadStats();
|
||||||
|
if(json.ibadah_id) {
|
||||||
|
const ri = riData.find(r => r.id == json.ibadah_id);
|
||||||
|
if(ri) alert(`Warga otomatis dimasukkan ke binaan: ${ri.nama}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert(json.message);
|
||||||
|
}
|
||||||
|
} catch(e) { alert('Error saving data'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function editRI(data) {
|
||||||
|
document.getElementById('ri-id').value = data.id;
|
||||||
|
document.getElementById('ri-nama').value = data.nama;
|
||||||
|
document.getElementById('ri-jenis').value = data.jenis;
|
||||||
|
document.getElementById('ri-kontak').value = data.kontak;
|
||||||
|
document.getElementById('ri-lat').value = data.lat;
|
||||||
|
document.getElementById('ri-lng').value = data.lng;
|
||||||
|
document.getElementById('ri-alamat').value = data.alamat;
|
||||||
|
document.getElementById('ri-radius').value = data.radius;
|
||||||
|
document.getElementById('modal-ri-title').innerText = 'Edit Rumah Ibadah';
|
||||||
|
document.getElementById('modal-ri').classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
function editPM(data) {
|
||||||
|
document.getElementById('pm-id').value = data.id;
|
||||||
|
document.getElementById('pm-nama').value = data.kk_nama;
|
||||||
|
document.getElementById('pm-nik').value = data.kk_nik;
|
||||||
|
document.getElementById('pm-lat').value = data.lat;
|
||||||
|
document.getElementById('pm-lng').value = data.lng;
|
||||||
|
document.getElementById('pm-alamat').value = data.alamat;
|
||||||
|
document.getElementById('pm-kategori-kebutuhan').value = data.kategori_kebutuhan || '';
|
||||||
|
document.getElementById('pm-deskripsi-kebutuhan').value = data.deskripsi_kebutuhan || '';
|
||||||
|
document.getElementById('pm-kondisi-rumah').value = data.kondisi_rumah || '';
|
||||||
|
document.getElementById('pm-penghasilan').value = data.penghasilan || '';
|
||||||
|
document.getElementById('pm-status').value = data.status_bantuan;
|
||||||
|
document.getElementById('pm-jenis-bantuan').value = data.jenis_bantuan;
|
||||||
|
document.getElementById('pm-tanggal-bantuan').value = data.tanggal_bantuan;
|
||||||
|
document.getElementById('pm-tindak-lanjut').value = data.tindak_lanjut || 'Belum disurvei';
|
||||||
|
document.getElementById('pm-catatan-survei').value = data.catatan_survei || '';
|
||||||
|
document.getElementById('pm-existing-bukti-file').value = data.bukti_file || '';
|
||||||
|
document.getElementById('pm-bukti-file').value = '';
|
||||||
|
|
||||||
|
toggleBantuanFields();
|
||||||
|
|
||||||
|
document.getElementById('anggota-container').innerHTML = '';
|
||||||
|
if(data.anggota && data.anggota.length > 0) {
|
||||||
|
data.anggota.forEach(a => addAnggotaField(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('modal-pm-title').innerText = 'Edit Penduduk Miskin';
|
||||||
|
document.getElementById('modal-pm').classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBantuanAlert(message = '', type = 'error') {
|
||||||
|
const alertBox = document.getElementById('bantuan-alert');
|
||||||
|
if (!alertBox) return;
|
||||||
|
|
||||||
|
alertBox.textContent = message;
|
||||||
|
alertBox.hidden = !message;
|
||||||
|
alertBox.classList.toggle('success', type === 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBantuanModal(id) {
|
||||||
|
const penerima = pmData.find(pm => String(pm.id) === String(id));
|
||||||
|
const form = document.getElementById('form-bantuan');
|
||||||
|
|
||||||
|
form.reset();
|
||||||
|
setBantuanAlert();
|
||||||
|
document.getElementById('bantuan-penduduk-id').value = id;
|
||||||
|
document.getElementById('bantuan-penerima').value = penerima ? `Keluarga ${penerima.kk_nama}` : `ID ${id}`;
|
||||||
|
document.getElementById('bantuan-jenis').value = penerima?.kategori_kebutuhan || '';
|
||||||
|
document.getElementById('bantuan-tanggal').value = new Date().toISOString().slice(0, 10);
|
||||||
|
document.getElementById('bantuan-pemberi').value = 'Admin';
|
||||||
|
document.getElementById('bantuan-bukti-file').value = '';
|
||||||
|
document.getElementById('btn-save-bantuan').disabled = false;
|
||||||
|
document.getElementById('modal-bantuan').classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBantuan() {
|
||||||
|
const id = document.getElementById('bantuan-penduduk-id').value;
|
||||||
|
const jenis = document.getElementById('bantuan-jenis').value.trim();
|
||||||
|
const tanggal = document.getElementById('bantuan-tanggal').value;
|
||||||
|
const nilai = document.getElementById('bantuan-nilai').value;
|
||||||
|
const pemberi = document.getElementById('bantuan-pemberi').value.trim() || 'Admin';
|
||||||
|
const catatan = document.getElementById('bantuan-catatan').value.trim();
|
||||||
|
const buktiFile = document.getElementById('bantuan-bukti-file').files[0];
|
||||||
|
const saveButton = document.getElementById('btn-save-bantuan');
|
||||||
|
|
||||||
|
if (!id || !jenis || !tanggal) {
|
||||||
|
setBantuanAlert('Keluarga penerima, jenis bantuan, dan tanggal wajib diisi.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('penduduk_id', id);
|
||||||
|
formData.append('jenis_bantuan', jenis);
|
||||||
|
formData.append('tanggal_bantuan', tanggal);
|
||||||
|
formData.append('nilai_bantuan', nilai || '');
|
||||||
|
formData.append('pemberi_bantuan', pemberi);
|
||||||
|
formData.append('catatan', catatan || '');
|
||||||
|
if (buktiFile) formData.append('bukti_file', buktiFile);
|
||||||
|
|
||||||
|
try {
|
||||||
|
saveButton.disabled = true;
|
||||||
|
saveButton.innerText = 'Menyimpan...';
|
||||||
|
const res = await fetch('api/bantuan_create.php', { method: 'POST', body: formData });
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.success) {
|
||||||
|
closeModal('modal-bantuan');
|
||||||
|
await fetchPM();
|
||||||
|
await loadStats();
|
||||||
|
renderMapLayers();
|
||||||
|
} else {
|
||||||
|
setBantuanAlert(json.message || 'Gagal menambah bantuan.');
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
setBantuanAlert('Terjadi kesalahan saat menyimpan bantuan.');
|
||||||
|
} finally {
|
||||||
|
saveButton.disabled = false;
|
||||||
|
saveButton.innerText = 'Simpan Bantuan';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRI(id) {
|
||||||
|
if(!confirm('Hapus rumah ibadah ini?')) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('api/rumah_ibadah_delete.php', { method:'POST', body:JSON.stringify({id}) });
|
||||||
|
const json = await res.json();
|
||||||
|
if(json.success) { fetchRI(); fetchPM(); loadStats(); }
|
||||||
|
} catch(e) { alert('Error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePM(id) {
|
||||||
|
if(!confirm('Hapus penduduk miskin ini?')) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('api/penduduk_delete.php', { method:'POST', body:JSON.stringify({id}) });
|
||||||
|
const json = await res.json();
|
||||||
|
if(json.success) { fetchPM(); loadStats(); }
|
||||||
|
} catch(e) { alert('Error'); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="refresh" content="0;url=index.php">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>WebGIS Peduli</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script>window.location.replace('index.php');</script>
|
||||||
|
<p>Mengalihkan ke halaman sistem...</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/api/auth.php';
|
||||||
|
require_admin_login();
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>WebGIS Sebaran Penduduk Miskin</title>
|
||||||
|
<!-- Fonts -->
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<!-- Font Awesome -->
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
<!-- Leaflet CSS -->
|
||||||
|
<link rel="stylesheet" href="assets/vendor/leaflet/leaflet.css" />
|
||||||
|
<!-- Custom CSS -->
|
||||||
|
<link rel="stylesheet" href="assets/css/style.css?v=<?= filemtime(__DIR__ . '/assets/css/style.css') ?>">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="dashboard-container">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="sidebar" id="sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<i class="fas fa-map-marked-alt fa-2x" style="color: var(--primary);"></i>
|
||||||
|
<h2>WebGIS Peduli</h2>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-menu">
|
||||||
|
<div class="menu-item active" id="menu-dashboard" data-view="dashboard">
|
||||||
|
<i class="fas fa-home"></i> Dashboard
|
||||||
|
</div>
|
||||||
|
<div class="menu-item" id="menu-map" data-view="map">
|
||||||
|
<i class="fas fa-map"></i> Peta
|
||||||
|
</div>
|
||||||
|
<div class="menu-item" id="menu-add-ri">
|
||||||
|
<i class="fas fa-plus-circle"></i> Tambah Rumah Ibadah
|
||||||
|
</div>
|
||||||
|
<div class="menu-item" id="menu-add-pm">
|
||||||
|
<i class="fas fa-user-plus"></i> Tambah Penduduk
|
||||||
|
</div>
|
||||||
|
<a class="menu-item" href="laporan.php">
|
||||||
|
<i class="fas fa-file-lines"></i> Laporan
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="main-content dashboard-mode">
|
||||||
|
<!-- Topbar -->
|
||||||
|
<header class="topbar">
|
||||||
|
<button id="toggle-sidebar" class="btn-icon"><i class="fas fa-bars"></i></button>
|
||||||
|
<div class="search-bar hidden">
|
||||||
|
<input type="text" id="search-input" placeholder="Cari nama warga / rumah ibadah...">
|
||||||
|
<button id="search-button" type="button"><i class="fas fa-search"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="map-header-controls hidden">
|
||||||
|
<div class="header-filter">
|
||||||
|
<i class="fas fa-circle-check"></i>
|
||||||
|
<label>Status</label>
|
||||||
|
<select id="filter-status">
|
||||||
|
<option value="all">Semua Status</option>
|
||||||
|
<option value="Sudah dibantu">Sudah Dibantu</option>
|
||||||
|
<option value="Belum dibantu">Belum Dibantu</option>
|
||||||
|
<option value="Menunggu verifikasi">Menunggu Verifikasi</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="header-filter">
|
||||||
|
<i class="fas fa-place-of-worship"></i>
|
||||||
|
<label>Jenis</label>
|
||||||
|
<select id="filter-jenis">
|
||||||
|
<option value="all">Semua Jenis</option>
|
||||||
|
<option value="Masjid">Masjid</option>
|
||||||
|
<option value="Gereja">Gereja</option>
|
||||||
|
<option value="Pura">Pura</option>
|
||||||
|
<option value="Vihara">Vihara</option>
|
||||||
|
<option value="Klenteng">Klenteng</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="header-filter">
|
||||||
|
<i class="fas fa-hand-holding-heart"></i>
|
||||||
|
<label>Kebutuhan</label>
|
||||||
|
<select id="filter-kebutuhan">
|
||||||
|
<option value="all">Semua Kebutuhan</option>
|
||||||
|
<option value="Sembako">Sembako</option>
|
||||||
|
<option value="Pendidikan">Pendidikan</option>
|
||||||
|
<option value="Kesehatan">Kesehatan</option>
|
||||||
|
<option value="Modal Usaha">Modal Usaha</option>
|
||||||
|
<option value="Rumah Tidak Layak">Rumah Tidak Layak</option>
|
||||||
|
<option value="Bantuan Darurat">Bantuan Darurat</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="user-profile">
|
||||||
|
<span><?= htmlspecialchars(current_admin_name(), ENT_QUOTES, 'UTF-8') ?></span>
|
||||||
|
<div class="avatar"><i class="fas fa-user-shield"></i></div>
|
||||||
|
<a class="logout-link" href="logout.php" title="Logout"><i class="fas fa-right-from-bracket"></i></a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Dashboard -->
|
||||||
|
<section id="dashboard-view" class="view-panel dashboard-view active">
|
||||||
|
<div class="dashboard-page-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Ringkasan Data</p>
|
||||||
|
<h1>Dashboard WebGIS Peduli</h1>
|
||||||
|
<p>Pantau sebaran warga, status bantuan, dan cakupan layanan rumah ibadah dari satu halaman.</p>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-header-actions">
|
||||||
|
<button class="btn btn-primary" id="dashboard-open-map"><i class="fas fa-map-location-dot"></i> Buka Peta</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-container" id="stats-container">
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>Rumah Ibadah</h3>
|
||||||
|
<div class="value" id="stat-ri">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>Total Warga</h3>
|
||||||
|
<div class="value" id="stat-pm">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card success">
|
||||||
|
<h3>Sudah Dibantu</h3>
|
||||||
|
<div class="value" id="stat-sudah">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card warning">
|
||||||
|
<h3>Menunggu</h3>
|
||||||
|
<div class="value" id="stat-menunggu">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card danger">
|
||||||
|
<h3>Belum Dibantu</h3>
|
||||||
|
<div class="value" id="stat-belum">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card warning">
|
||||||
|
<h3>Prioritas Tinggi</h3>
|
||||||
|
<div class="value" id="stat-prioritas">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card danger">
|
||||||
|
<h3>Luar Radius</h3>
|
||||||
|
<div class="value" id="stat-luar-radius">0</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-grid">
|
||||||
|
<div class="dashboard-card status-card">
|
||||||
|
<div class="card-title">
|
||||||
|
<h2>Status Bantuan</h2>
|
||||||
|
<span>Data keluarga miskin</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-list">
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="status-dot success"></span>
|
||||||
|
<div>
|
||||||
|
<strong id="summary-sudah">0</strong>
|
||||||
|
<p>Sudah menerima bantuan</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="status-dot warning"></span>
|
||||||
|
<div>
|
||||||
|
<strong id="summary-menunggu">0</strong>
|
||||||
|
<p>Menunggu verifikasi</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="status-dot danger"></span>
|
||||||
|
<div>
|
||||||
|
<strong id="summary-belum">0</strong>
|
||||||
|
<p>Belum menerima bantuan</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-card">
|
||||||
|
<div class="card-title">
|
||||||
|
<h2>Aksi Cepat</h2>
|
||||||
|
<span>Input dan pemetaan data</span>
|
||||||
|
</div>
|
||||||
|
<div class="quick-actions">
|
||||||
|
<button class="quick-action" id="dashboard-add-ri">
|
||||||
|
<i class="fas fa-place-of-worship"></i>
|
||||||
|
<span>Tambah Rumah Ibadah</span>
|
||||||
|
</button>
|
||||||
|
<button class="quick-action" id="dashboard-add-pm">
|
||||||
|
<i class="fas fa-user-plus"></i>
|
||||||
|
<span>Tambah Penduduk</span>
|
||||||
|
</button>
|
||||||
|
<a class="quick-action" href="laporan.php?prioritas=tinggi">
|
||||||
|
<i class="fas fa-file-export"></i>
|
||||||
|
<span>Cetak Laporan Prioritas</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-card map-summary-card">
|
||||||
|
<div class="card-title">
|
||||||
|
<h2>Legenda Peta</h2>
|
||||||
|
<span>Indikator cakupan layanan</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-list">
|
||||||
|
<div><span class="legend-marker ri"></span> Rumah ibadah dan radius layanan</div>
|
||||||
|
<div><span class="legend-marker reached"></span> Warga dalam radius layanan</div>
|
||||||
|
<div><span class="legend-marker blank"></span> Warga di luar radius layanan</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-card">
|
||||||
|
<div class="card-title">
|
||||||
|
<h2>Prioritas Bantuan</h2>
|
||||||
|
<span>Urutan paling mendesak</span>
|
||||||
|
</div>
|
||||||
|
<div class="priority-list" id="priority-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-card">
|
||||||
|
<div class="card-title">
|
||||||
|
<h2>Kebutuhan Terbanyak</h2>
|
||||||
|
<span>Basis penyaluran bantuan</span>
|
||||||
|
</div>
|
||||||
|
<div class="need-list" id="need-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Peta -->
|
||||||
|
<section id="map-view" class="view-panel map-view">
|
||||||
|
<div id="map" class="map-fullscreen"></div>
|
||||||
|
|
||||||
|
<button id="btn-my-location" class="fab-location" title="Lokasi Saya"><i class="fas fa-crosshairs"></i></button>
|
||||||
|
<div class="fab-container">
|
||||||
|
<button class="fab-main" title="Tambah Data"><i class="fas fa-plus"></i></button>
|
||||||
|
<div class="fab-menu">
|
||||||
|
<button class="fab-item" id="fab-add-ri" title="Tambah Rumah Ibadah"><i class="fas fa-place-of-worship"></i></button>
|
||||||
|
<button class="fab-item" id="fab-add-pm" title="Tambah Penduduk Miskin"><i class="fas fa-user-plus"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Rumah Ibadah -->
|
||||||
|
<div class="modal-backdrop" id="modal-ri">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="modal-ri-title">Tambah Rumah Ibadah</h2>
|
||||||
|
<button class="btn-close" onclick="closeModal('modal-ri')"><i class="fas fa-times"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="form-ri">
|
||||||
|
<input type="hidden" id="ri-id">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Nama Rumah Ibadah</label>
|
||||||
|
<input type="text" id="ri-nama" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Jenis</label>
|
||||||
|
<select id="ri-jenis" class="form-control" required>
|
||||||
|
<option value="Masjid">Masjid</option>
|
||||||
|
<option value="Gereja">Gereja</option>
|
||||||
|
<option value="Pura">Pura</option>
|
||||||
|
<option value="Vihara">Vihara</option>
|
||||||
|
<option value="Klenteng">Klenteng</option>
|
||||||
|
<option value="Lainnya">Lainnya</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Kontak</label>
|
||||||
|
<input type="text" id="ri-kontak" class="form-control">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="display: flex; gap: 10px;">
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<label>Latitude</label>
|
||||||
|
<input type="number" step="any" id="ri-lat" class="form-control" required readonly>
|
||||||
|
</div>
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<label>Longitude</label>
|
||||||
|
<input type="number" step="any" id="ri-lng" class="form-control" required readonly>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: flex-end;">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="pickLocation('ri')"><i class="fas fa-map-marker-alt"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Alamat (Otomatis)</label>
|
||||||
|
<textarea id="ri-alamat" class="form-control" rows="2" readonly></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Radius Pelayanan (meter)</label>
|
||||||
|
<input type="number" id="ri-radius" class="form-control" value="500" required>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="closeModal('modal-ri')">Batal</button>
|
||||||
|
<button class="btn btn-primary" onclick="saveRI()">Simpan</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Penduduk Miskin -->
|
||||||
|
<div class="modal-backdrop" id="modal-pm">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="modal-pm-title">Tambah Penduduk Miskin</h2>
|
||||||
|
<button class="btn-close" onclick="closeModal('modal-pm')"><i class="fas fa-times"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="form-pm">
|
||||||
|
<input type="hidden" id="pm-id">
|
||||||
|
|
||||||
|
<h3 style="margin-bottom: 12px; border-bottom: 1px solid #eee; padding-bottom: 8px;">Data Kepala Keluarga</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Nama Kepala Keluarga</label>
|
||||||
|
<input type="text" id="pm-nama" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>NIK</label>
|
||||||
|
<input type="text" id="pm-nik" class="form-control">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="display: flex; gap: 10px;">
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<label>Latitude</label>
|
||||||
|
<input type="number" step="any" id="pm-lat" class="form-control" required readonly>
|
||||||
|
</div>
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<label>Longitude</label>
|
||||||
|
<input type="number" step="any" id="pm-lng" class="form-control" required readonly>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: flex-end;">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="pickLocation('pm')"><i class="fas fa-map-marker-alt"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Alamat</label>
|
||||||
|
<textarea id="pm-alamat" class="form-control" rows="2" readonly></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style="margin: 20px 0 12px; border-bottom: 1px solid #eee; padding-bottom: 8px;">Kebutuhan & Kondisi</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Kategori Kebutuhan</label>
|
||||||
|
<select id="pm-kategori-kebutuhan" class="form-control">
|
||||||
|
<option value="">Pilih Kebutuhan</option>
|
||||||
|
<option value="Sembako">Sembako</option>
|
||||||
|
<option value="Pendidikan">Pendidikan</option>
|
||||||
|
<option value="Kesehatan">Kesehatan</option>
|
||||||
|
<option value="Modal Usaha">Modal Usaha</option>
|
||||||
|
<option value="Rumah Tidak Layak">Rumah Tidak Layak</option>
|
||||||
|
<option value="Bantuan Darurat">Bantuan Darurat</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Deskripsi Kebutuhan</label>
|
||||||
|
<textarea id="pm-deskripsi-kebutuhan" class="form-control" rows="2" placeholder="Contoh: butuh sembako bulanan, biaya obat, seragam sekolah"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
|
||||||
|
<div>
|
||||||
|
<label>Kondisi Rumah</label>
|
||||||
|
<select id="pm-kondisi-rumah" class="form-control">
|
||||||
|
<option value="">Pilih Kondisi</option>
|
||||||
|
<option value="Layak">Layak</option>
|
||||||
|
<option value="Rentan">Rentan</option>
|
||||||
|
<option value="Tidak Layak">Tidak Layak</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Penghasilan / Bulan</label>
|
||||||
|
<input type="number" id="pm-penghasilan" class="form-control" min="0" inputmode="numeric" placeholder="0">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style="margin: 20px 0 12px; border-bottom: 1px solid #eee; padding-bottom: 8px;">Status Bantuan</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Status</label>
|
||||||
|
<select id="pm-status" class="form-control" onchange="toggleBantuanFields()" required>
|
||||||
|
<option value="Belum dibantu">Belum Dibantu</option>
|
||||||
|
<option value="Menunggu verifikasi">Menunggu Verifikasi</option>
|
||||||
|
<option value="Sudah dibantu">Sudah Dibantu</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="bantuan-fields" style="display: none;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Jenis Bantuan</label>
|
||||||
|
<input type="text" id="pm-jenis-bantuan" class="form-control">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Tanggal Bantuan</label>
|
||||||
|
<input type="date" id="pm-tanggal-bantuan" class="form-control">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style="margin: 20px 0 12px; border-bottom: 1px solid #eee; padding-bottom: 8px;">Survey Lapangan</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Status Tindak Lanjut</label>
|
||||||
|
<select id="pm-tindak-lanjut" class="form-control">
|
||||||
|
<option value="Belum disurvei">Belum Disurvei</option>
|
||||||
|
<option value="Perlu survei">Perlu Survei</option>
|
||||||
|
<option value="Sudah disurvei">Sudah Disurvei</option>
|
||||||
|
<option value="Sudah diverifikasi">Sudah Diverifikasi</option>
|
||||||
|
<option value="Sedang diproses">Sedang Diproses</option>
|
||||||
|
<option value="Sudah dibantu">Sudah Dibantu</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Catatan Survey</label>
|
||||||
|
<textarea id="pm-catatan-survei" class="form-control" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Bukti / Foto / Dokumen</label>
|
||||||
|
<input type="file" id="pm-bukti-file" class="form-control" accept="image/*,.pdf">
|
||||||
|
<input type="hidden" id="pm-existing-bukti-file">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style="margin: 20px 0 12px; border-bottom: 1px solid #eee; padding-bottom: 8px; display: flex; justify-content: space-between;">
|
||||||
|
Anggota Keluarga
|
||||||
|
<button type="button" class="btn btn-secondary" style="padding: 4px 8px; font-size: 0.8rem;" onclick="addAnggotaField()"><i class="fas fa-plus"></i> Tambah</button>
|
||||||
|
</h3>
|
||||||
|
<div id="anggota-container" class="dynamic-list">
|
||||||
|
<!-- Dynamic fields here -->
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="closeModal('modal-pm')">Batal</button>
|
||||||
|
<button class="btn btn-primary" onclick="savePM()">Simpan</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Riwayat Bantuan -->
|
||||||
|
<div class="modal-backdrop" id="modal-bantuan">
|
||||||
|
<div class="modal-content modal-content-sm">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div>
|
||||||
|
<p class="modal-kicker">Penyaluran Bantuan</p>
|
||||||
|
<h2 id="modal-bantuan-title">Tambah Riwayat Bantuan</h2>
|
||||||
|
</div>
|
||||||
|
<button class="btn-close" onclick="closeModal('modal-bantuan')"><i class="fas fa-times"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="form-bantuan">
|
||||||
|
<input type="hidden" id="bantuan-penduduk-id">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Keluarga Penerima</label>
|
||||||
|
<input type="text" id="bantuan-penerima" class="form-control" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Jenis Bantuan</label>
|
||||||
|
<input type="text" id="bantuan-jenis" class="form-control" list="bantuan-jenis-list" placeholder="Contoh: Sembako bulanan" required>
|
||||||
|
<datalist id="bantuan-jenis-list">
|
||||||
|
<option value="Sembako">
|
||||||
|
<option value="Pendidikan">
|
||||||
|
<option value="Kesehatan">
|
||||||
|
<option value="Modal Usaha">
|
||||||
|
<option value="Renovasi Rumah">
|
||||||
|
<option value="Bantuan Darurat">
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid two-cols">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Tanggal Bantuan</label>
|
||||||
|
<input type="date" id="bantuan-tanggal" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Nilai Bantuan</label>
|
||||||
|
<input type="number" id="bantuan-nilai" class="form-control" min="0" placeholder="Opsional">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Pemberi Bantuan</label>
|
||||||
|
<input type="text" id="bantuan-pemberi" class="form-control" value="Admin">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Catatan</label>
|
||||||
|
<textarea id="bantuan-catatan" class="form-control" rows="3" placeholder="Contoh: bantuan diserahkan langsung ke rumah penerima"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Bukti Bantuan</label>
|
||||||
|
<input type="file" id="bantuan-bukti-file" class="form-control" accept="image/*,.pdf">
|
||||||
|
<p class="form-hint">Opsional, bisa berupa foto penyerahan atau dokumen PDF.</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-alert" id="bantuan-alert" hidden></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="closeModal('modal-bantuan')">Batal</button>
|
||||||
|
<button class="btn btn-primary" id="btn-save-bantuan" onclick="saveBantuan()">Simpan Bantuan</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scripts -->
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
||||||
|
<script src="assets/js/app.js?v=<?= filemtime(__DIR__ . '/assets/js/app.js') ?>"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/api/db_pdo.php';
|
||||||
|
require_once __DIR__ . '/api/auth.php';
|
||||||
|
require_once __DIR__ . '/api/penduduk_feature_helper.php';
|
||||||
|
require_admin_login();
|
||||||
|
|
||||||
|
ensurePendudukFeatureSchema($pdo);
|
||||||
|
refreshAllPriorityScores($pdo);
|
||||||
|
|
||||||
|
$status = trim((string)($_GET['status'] ?? ''));
|
||||||
|
$priority = trim((string)($_GET['prioritas'] ?? ''));
|
||||||
|
$where = [];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($status !== '') {
|
||||||
|
$where[] = 'p.status_bantuan = ?';
|
||||||
|
$params[] = $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($priority === 'tinggi') {
|
||||||
|
$where[] = 'p.skor_prioritas >= 70';
|
||||||
|
} elseif ($priority === 'sedang') {
|
||||||
|
$where[] = 'p.skor_prioritas BETWEEN 40 AND 69';
|
||||||
|
} elseif ($priority === 'rendah') {
|
||||||
|
$where[] = 'p.skor_prioritas < 40';
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "
|
||||||
|
SELECT p.*, r.nama AS rumah_ibadah_nama
|
||||||
|
FROM penduduk_miskin p
|
||||||
|
LEFT JOIN rumah_ibadah r ON p.ibadah_id = r.id
|
||||||
|
";
|
||||||
|
|
||||||
|
if ($where) {
|
||||||
|
$sql .= ' WHERE ' . implode(' AND ', $where);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= ' ORDER BY p.skor_prioritas DESC, p.id DESC';
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$summary = [
|
||||||
|
'total' => count($rows),
|
||||||
|
'tinggi' => count(array_filter($rows, fn($r) => (int)$r['skor_prioritas'] >= 70)),
|
||||||
|
'belum' => count(array_filter($rows, fn($r) => $r['status_bantuan'] === 'Belum dibantu')),
|
||||||
|
'luar' => count(array_filter($rows, fn($r) => (int)$r['is_blank_spot'] === 1)),
|
||||||
|
];
|
||||||
|
|
||||||
|
function e(?string $value): string
|
||||||
|
{
|
||||||
|
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Laporan Prioritas Bantuan</title>
|
||||||
|
<link rel="stylesheet" href="assets/css/report.css?v=<?= filemtime(__DIR__ . '/assets/css/report.css') ?>">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="report-page">
|
||||||
|
<header class="report-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">WebGIS Peduli</p>
|
||||||
|
<h1>Laporan Prioritas Bantuan</h1>
|
||||||
|
<p>Dicetak pada <?= date('d/m/Y H:i') ?> oleh <?= e(current_admin_name()) ?></p>
|
||||||
|
</div>
|
||||||
|
<button onclick="window.print()" class="print-btn">Cetak / Simpan PDF</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="summary-grid">
|
||||||
|
<div><span>Total Data</span><strong><?= $summary['total'] ?></strong></div>
|
||||||
|
<div><span>Prioritas Tinggi</span><strong><?= $summary['tinggi'] ?></strong></div>
|
||||||
|
<div><span>Belum Dibantu</span><strong><?= $summary['belum'] ?></strong></div>
|
||||||
|
<div><span>Luar Radius</span><strong><?= $summary['luar'] ?></strong></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="report-note">
|
||||||
|
<strong>Keterangan:</strong> Skor prioritas dihitung dari status bantuan, jumlah tanggungan, lansia/balita,
|
||||||
|
penghasilan, kondisi rumah, kebutuhan mendesak, dan posisi terhadap radius layanan.
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>No</th>
|
||||||
|
<th>Kepala Keluarga</th>
|
||||||
|
<th>Prioritas</th>
|
||||||
|
<th>Kebutuhan</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Tindak Lanjut</th>
|
||||||
|
<th>Rumah Ibadah</th>
|
||||||
|
<th>Jarak</th>
|
||||||
|
<th>Catatan</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($rows as $i => $row): ?>
|
||||||
|
<tr>
|
||||||
|
<td><?= $i + 1 ?></td>
|
||||||
|
<td>
|
||||||
|
<strong><?= e($row['kk_nama']) ?></strong><br>
|
||||||
|
<small><?= e($row['alamat']) ?></small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<strong><?= (int)$row['skor_prioritas'] ?></strong><br>
|
||||||
|
<small><?= e(getPriorityLabel((int)$row['skor_prioritas'])) ?></small>
|
||||||
|
</td>
|
||||||
|
<td><?= e($row['kategori_kebutuhan'] ?: 'Belum diisi') ?></td>
|
||||||
|
<td><?= e($row['status_bantuan']) ?></td>
|
||||||
|
<td><?= e($row['tindak_lanjut'] ?: 'Belum disurvei') ?></td>
|
||||||
|
<td><?= e($row['rumah_ibadah_nama'] ?: '-') ?></td>
|
||||||
|
<td>
|
||||||
|
Lurus: <?= e((string)$row['jarak_m']) ?> m<br>
|
||||||
|
<small>Rute: <?= e((string)($row['jarak_rute_m'] ?? '-')) ?> m</small>
|
||||||
|
</td>
|
||||||
|
<td><?= e($row['catatan_survei'] ?: $row['deskripsi_kebutuhan'] ?: '-') ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if (!$rows): ?>
|
||||||
|
<tr><td colspan="9" class="empty">Tidak ada data sesuai filter.</td></tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/api/db_pdo.php';
|
||||||
|
require_once __DIR__ . '/api/auth.php';
|
||||||
|
|
||||||
|
start_admin_session();
|
||||||
|
ensure_admin_table($pdo);
|
||||||
|
|
||||||
|
if (is_admin_logged_in()) {
|
||||||
|
header('Location: index.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($_SESSION['csrf_token'])) {
|
||||||
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
|
}
|
||||||
|
|
||||||
|
$error = '';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$token = (string)($_POST['csrf_token'] ?? '');
|
||||||
|
$username = trim((string)($_POST['username'] ?? ''));
|
||||||
|
$password = (string)($_POST['password'] ?? '');
|
||||||
|
|
||||||
|
if (!hash_equals((string)$_SESSION['csrf_token'], $token)) {
|
||||||
|
$error = 'Sesi form tidak valid. Muat ulang halaman lalu coba lagi.';
|
||||||
|
} elseif ($username === '' || $password === '') {
|
||||||
|
$error = 'Username dan password wajib diisi.';
|
||||||
|
} else {
|
||||||
|
$stmt = $pdo->prepare("SELECT * FROM admins WHERE username = ? AND is_active = 1 LIMIT 1");
|
||||||
|
$stmt->execute([$username]);
|
||||||
|
$admin = $stmt->fetch();
|
||||||
|
|
||||||
|
if ($admin && password_verify($password, $admin['password_hash'])) {
|
||||||
|
session_regenerate_id(true);
|
||||||
|
$_SESSION['admin_id'] = (int)$admin['id'];
|
||||||
|
$_SESSION['admin_username'] = $admin['username'];
|
||||||
|
$_SESSION['admin_name'] = $admin['nama'];
|
||||||
|
unset($_SESSION['csrf_token']);
|
||||||
|
|
||||||
|
header('Location: index.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$error = 'Username atau password salah.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Login Admin - WebGIS Peduli</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
<link rel="stylesheet" href="assets/css/login.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="login-page">
|
||||||
|
<section class="login-panel">
|
||||||
|
<div class="brand-mark">
|
||||||
|
<i class="fas fa-map-location-dot"></i>
|
||||||
|
</div>
|
||||||
|
<p class="eyebrow">Admin Area</p>
|
||||||
|
<h1>Masuk ke WebGIS Peduli</h1>
|
||||||
|
<p class="subtitle">Gunakan akun admin untuk mengelola data peta, warga, dan rumah ibadah.</p>
|
||||||
|
|
||||||
|
<?php if ($error !== ''): ?>
|
||||||
|
<div class="alert-error">
|
||||||
|
<i class="fas fa-circle-exclamation"></i>
|
||||||
|
<span><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form method="POST" class="login-form" autocomplete="off">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars((string)$_SESSION['csrf_token'], ENT_QUOTES, 'UTF-8') ?>">
|
||||||
|
|
||||||
|
<label class="input-group">
|
||||||
|
<span>Username</span>
|
||||||
|
<div class="input-shell">
|
||||||
|
<i class="fas fa-user"></i>
|
||||||
|
<input type="text" name="username" placeholder="admin" required autofocus>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="input-group">
|
||||||
|
<span>Password</span>
|
||||||
|
<div class="input-shell">
|
||||||
|
<i class="fas fa-lock"></i>
|
||||||
|
<input type="password" name="password" placeholder="Masukkan password" required>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit" class="btn-login">
|
||||||
|
<span>Masuk</span>
|
||||||
|
<i class="fas fa-arrow-right"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="login-hint">Akun awal: <strong>admin</strong> / <strong>admin123</strong></p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/api/auth.php';
|
||||||
|
|
||||||
|
start_admin_session();
|
||||||
|
$_SESSION = [];
|
||||||
|
|
||||||
|
if (ini_get('session.use_cookies')) {
|
||||||
|
$params = session_get_cookie_params();
|
||||||
|
setcookie(
|
||||||
|
session_name(),
|
||||||
|
'',
|
||||||
|
time() - 42000,
|
||||||
|
$params['path'],
|
||||||
|
$params['domain'],
|
||||||
|
(bool)$params['secure'],
|
||||||
|
(bool)$params['httponly']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
session_destroy();
|
||||||
|
header('Location: login.php');
|
||||||
|
exit;
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/api/auth.php';
|
||||||
|
require_admin_login();
|
||||||
|
|
||||||
|
function e(string $value): string
|
||||||
|
{
|
||||||
|
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
$baseUrl = 'http://localhost/Leaflet2/';
|
||||||
|
$projectUrl = '[ISI URL PROJECT KELAS DI SINI]';
|
||||||
|
$giteaUrl = '[https://git.ifuntanhub.dev/Arif1031/WebGIS-PovertyMap.git]';
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Panduan Penggunaan WebGIS Poverty Map</title>
|
||||||
|
<link rel="stylesheet" href="assets/css/guide.css?v=<?= filemtime(__DIR__ . '/assets/css/guide.css') ?>">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="guide-page">
|
||||||
|
<header class="guide-hero">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Panduan Penggunaan</p>
|
||||||
|
<h1>WebGIS Poverty Map</h1>
|
||||||
|
<p class="lead">Panduan singkat untuk mengakses, mengelola data peta, mencatat bantuan, dan mencetak laporan prioritas masyarakat miskin/kurang mampu.</p>
|
||||||
|
</div>
|
||||||
|
<button class="print-btn" onclick="window.print()">Cetak / Simpan PDF</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="access-panel">
|
||||||
|
<h2>Informasi Akses Project</h2>
|
||||||
|
<div class="info-grid">
|
||||||
|
<div>
|
||||||
|
<span>URL Aplikasi Lokal</span>
|
||||||
|
<strong><?= e($baseUrl) ?></strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>URL Project Kelas</span>
|
||||||
|
<strong><?= e($projectUrl) ?></strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Link Repo Gitea</span>
|
||||||
|
<strong><?= e($giteaUrl) ?></strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Akun dan Role</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Password</th>
|
||||||
|
<th>Hak Akses</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Admin</td>
|
||||||
|
<td><code>admin</code></td>
|
||||||
|
<td><code>admin123</code></td>
|
||||||
|
<td>Mengelola dashboard, peta, rumah ibadah, penduduk miskin, bantuan, bukti, dan laporan.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="note">Versi sistem saat ini hanya memiliki satu role aktif, yaitu Admin. Jika role baru ditambahkan, akun dapat ditulis pada tabel ini.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="steps">
|
||||||
|
<h2>Alur Penggunaan Utama</h2>
|
||||||
|
<ol>
|
||||||
|
<li>Buka URL aplikasi, lalu login menggunakan akun admin.</li>
|
||||||
|
<li>Periksa ringkasan data pada Dashboard untuk melihat jumlah rumah ibadah, warga, status bantuan, prioritas, dan warga luar radius.</li>
|
||||||
|
<li>Buka menu Peta untuk melihat marker rumah ibadah, radius layanan, dan marker warga.</li>
|
||||||
|
<li>Gunakan pencarian dan filter untuk menemukan warga, rumah ibadah, status bantuan, jenis rumah ibadah, atau kategori kebutuhan.</li>
|
||||||
|
<li>Tambah atau edit rumah ibadah melalui menu atau tombol tambah, lalu pilih titik koordinat di peta.</li>
|
||||||
|
<li>Tambah atau edit penduduk miskin, lengkapi kebutuhan, kondisi rumah, penghasilan, status survei, anggota keluarga, dan bukti jika ada.</li>
|
||||||
|
<li>Gunakan tombol Bantuan pada popup warga untuk mencatat riwayat bantuan tanpa popup browser.</li>
|
||||||
|
<li>Buka Laporan untuk mencetak atau menyimpan daftar prioritas bantuan sebagai PDF.</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Keterangan Warna Peta</h2>
|
||||||
|
<div class="legend-grid">
|
||||||
|
<div><span class="dot worship"></span><strong>Rumah Ibadah</strong><p>Pusat layanan atau rujukan bantuan sosial.</p></div>
|
||||||
|
<div><span class="dot reached"></span><strong>Warga Dalam Radius</strong><p>Warga berada dalam radius layanan rumah ibadah.</p></div>
|
||||||
|
<div><span class="dot outside"></span><strong>Warga Luar Radius</strong><p>Warga berada di luar semua radius layanan.</p></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Catatan Pengisian Data</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Radius default rumah ibadah adalah 500 meter dan dapat diubah oleh admin.</li>
|
||||||
|
<li>Status dalam/luar radius dihitung menggunakan jarak lurus pada peta.</li>
|
||||||
|
<li>Jarak rute jalan ditampilkan sebagai informasi tambahan jika layanan OSRM berhasil diakses.</li>
|
||||||
|
<li>Penghasilan tidak boleh bernilai negatif.</li>
|
||||||
|
<li>Bukti bantuan atau survei dapat berupa JPG, PNG, WEBP, atau PDF dengan ukuran maksimal 3 MB.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
DROP DATABASE IF EXISTS webgis_miskin;
|
||||||
|
CREATE DATABASE webgis_miskin CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
USE webgis_miskin;
|
||||||
|
|
||||||
|
CREATE TABLE admins (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
username VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
nama VARCHAR(100) NOT NULL DEFAULT 'Administrator',
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
INSERT INTO admins (username, password_hash, nama) VALUES
|
||||||
|
('admin', '$2y$10$7O061pVz/aZ5YNK/fQyYIePIPnzDb.uZn38pC/Te3s7gRyko20dIK', 'Administrator');
|
||||||
|
|
||||||
|
CREATE TABLE rumah_ibadah (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
nama VARCHAR(150) NOT NULL,
|
||||||
|
jenis ENUM('Masjid', 'Gereja', 'Pura', 'Vihara', 'Klenteng', 'Lainnya') NOT NULL,
|
||||||
|
kontak VARCHAR(50),
|
||||||
|
alamat TEXT,
|
||||||
|
lat DECIMAL(10, 8) NOT NULL,
|
||||||
|
lng DECIMAL(11, 8) NOT NULL,
|
||||||
|
radius INT NOT NULL DEFAULT 500,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
CREATE TABLE penduduk_miskin (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
kk_nama VARCHAR(150) NOT NULL,
|
||||||
|
kk_nik VARCHAR(20),
|
||||||
|
alamat TEXT,
|
||||||
|
lat DECIMAL(10, 8) NOT NULL,
|
||||||
|
lng DECIMAL(11, 8) NOT NULL,
|
||||||
|
ibadah_id INT NULL,
|
||||||
|
jarak_m DECIMAL(10, 2) NULL,
|
||||||
|
jarak_rute_m DECIMAL(10, 2) NULL,
|
||||||
|
is_blank_spot TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
status_bantuan ENUM('Sudah dibantu', 'Belum dibantu', 'Menunggu verifikasi') NOT NULL DEFAULT 'Belum dibantu',
|
||||||
|
kategori_kebutuhan VARCHAR(100),
|
||||||
|
deskripsi_kebutuhan TEXT,
|
||||||
|
tindak_lanjut VARCHAR(60) NOT NULL DEFAULT 'Belum disurvei',
|
||||||
|
kondisi_rumah VARCHAR(80),
|
||||||
|
penghasilan INT,
|
||||||
|
catatan_survei TEXT,
|
||||||
|
bukti_file VARCHAR(255),
|
||||||
|
skor_prioritas INT NOT NULL DEFAULT 0,
|
||||||
|
jenis_bantuan VARCHAR(100),
|
||||||
|
tanggal_bantuan DATE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (ibadah_id) REFERENCES rumah_ibadah(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
CREATE TABLE anggota_keluarga (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
penduduk_id INT NOT NULL,
|
||||||
|
nama VARCHAR(150) NOT NULL,
|
||||||
|
hubungan VARCHAR(50) NOT NULL,
|
||||||
|
umur INT,
|
||||||
|
pekerjaan VARCHAR(100),
|
||||||
|
FOREIGN KEY (penduduk_id) REFERENCES penduduk_miskin(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
CREATE TABLE riwayat_bantuan (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
penduduk_id INT NOT NULL,
|
||||||
|
jenis_bantuan VARCHAR(120) NOT NULL,
|
||||||
|
tanggal_bantuan DATE NOT NULL,
|
||||||
|
nilai_bantuan VARCHAR(80),
|
||||||
|
pemberi_bantuan VARCHAR(120),
|
||||||
|
catatan TEXT,
|
||||||
|
bukti_file VARCHAR(255),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (penduduk_id) REFERENCES penduduk_miskin(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB;
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# WebGIS Portal
|
||||||
|
|
||||||
|
Folder ini adalah versi gabungan dari landing page dan dua project WebGIS.
|
||||||
|
|
||||||
|
## Struktur Folder
|
||||||
|
|
||||||
|
```text
|
||||||
|
Landingpage/
|
||||||
|
|-- index.html # Landing page utama
|
||||||
|
|-- style.css # Style landing page
|
||||||
|
|-- assets/ # Asset landing page
|
||||||
|
|-- Leaflet/ # Project 1: WebGIS SPBU, jalan, dan parsil
|
||||||
|
| |-- leaf.html
|
||||||
|
| |-- spatial.html
|
||||||
|
| |-- api/
|
||||||
|
| |-- assets/
|
||||||
|
| `-- sql/
|
||||||
|
|-- Leaflet2/ # Project 2: WebGIS Peduli
|
||||||
|
| |-- index.php
|
||||||
|
| |-- login.php
|
||||||
|
| |-- laporan.php
|
||||||
|
| |-- panduan.php
|
||||||
|
| |-- api/
|
||||||
|
| |-- assets/
|
||||||
|
| `-- sql/
|
||||||
|
|-- Dockerfile # Persiapan Docker, belum perlu dijalankan
|
||||||
|
|-- docker-compose.yml # Persiapan service app + database
|
||||||
|
|-- .env.example # Contoh konfigurasi environment
|
||||||
|
|-- .dockerignore
|
||||||
|
|-- .gitignore
|
||||||
|
`-- DOCKER_STEPS.md # Panduan belajar menjalankan Docker
|
||||||
|
```
|
||||||
|
|
||||||
|
## Menjalankan di XAMPP
|
||||||
|
|
||||||
|
Pastikan folder ini berada di:
|
||||||
|
|
||||||
|
```text
|
||||||
|
C:\xampp\htdocs\Landingpage
|
||||||
|
```
|
||||||
|
|
||||||
|
Buka landing page:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost/Landingpage/
|
||||||
|
```
|
||||||
|
|
||||||
|
Shortcut project:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost/Landingpage/Leaflet/leaf.html
|
||||||
|
http://localhost/Landingpage/Leaflet/spatial.html
|
||||||
|
http://localhost/Landingpage/Leaflet2/login.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Lokal
|
||||||
|
|
||||||
|
Import SQL berikut melalui phpMyAdmin atau MySQL:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Leaflet/sql/schema.sql
|
||||||
|
Leaflet/sql/spatial_schema.sql
|
||||||
|
Leaflet2/sql/miskin_schema.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Database yang digunakan:
|
||||||
|
|
||||||
|
```text
|
||||||
|
webgis_spbu
|
||||||
|
webgis_miskin
|
||||||
|
```
|
||||||
|
|
||||||
|
## Titik Edit Tampilan
|
||||||
|
|
||||||
|
Landing page:
|
||||||
|
|
||||||
|
```text
|
||||||
|
index.html
|
||||||
|
style.css
|
||||||
|
assets/
|
||||||
|
```
|
||||||
|
|
||||||
|
Project 1:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Leaflet/leaf.html
|
||||||
|
Leaflet/spatial.html
|
||||||
|
```
|
||||||
|
|
||||||
|
Project 2:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Leaflet2/assets/css/style.css
|
||||||
|
Leaflet2/assets/css/login.css
|
||||||
|
Leaflet2/assets/css/guide.css
|
||||||
|
Leaflet2/assets/css/report.css
|
||||||
|
```
|
||||||
|
|
||||||
|
## Akun Default Project 2
|
||||||
|
|
||||||
|
```text
|
||||||
|
Username: admin
|
||||||
|
Password: admin123
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
File Docker sudah disiapkan untuk dipelajari, tetapi belum dijalankan. Baca:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DOCKER_STEPS.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Catatan Git
|
||||||
|
|
||||||
|
File yang tidak perlu masuk Git sudah dicatat di `.gitignore`, terutama `.env`, upload, log, `vendor`, dan `node_modules`.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
@@ -0,0 +1,48 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
container_name: webgis-portal-app
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${APP_PORT:-8080}:80"
|
||||||
|
environment:
|
||||||
|
DB_HOST: ${DB_HOST:-db}
|
||||||
|
DB_DATABASE: ${DB_DATABASE:-webgis_miskin}
|
||||||
|
DB_USERNAME: ${DB_USERNAME:-webgis_user}
|
||||||
|
DB_PASSWORD: ${DB_PASSWORD:-webgis_password}
|
||||||
|
DB_CHARSET: ${DB_CHARSET:-utf8mb4}
|
||||||
|
DB_SETUP_USERNAME: ${DB_SETUP_USERNAME:-root}
|
||||||
|
DB_SETUP_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root_password}
|
||||||
|
SPBU_DB_HOST: ${SPBU_DB_HOST:-db}
|
||||||
|
SPBU_DB_DATABASE: ${SPBU_DB_DATABASE:-webgis_spbu}
|
||||||
|
SPBU_DB_USERNAME: ${SPBU_DB_USERNAME:-webgis_user}
|
||||||
|
SPBU_DB_PASSWORD: ${SPBU_DB_PASSWORD:-webgis_password}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: mysql:8.0
|
||||||
|
container_name: webgis-portal-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root_password}
|
||||||
|
MYSQL_DATABASE: ${DB_DATABASE:-webgis_miskin}
|
||||||
|
MYSQL_USER: ${DB_USERNAME:-webgis_user}
|
||||||
|
MYSQL_PASSWORD: ${DB_PASSWORD:-webgis_password}
|
||||||
|
ports:
|
||||||
|
- "${DB_PORT:-3307}:3306"
|
||||||
|
volumes:
|
||||||
|
- db_data:/var/lib/mysql
|
||||||
|
- ./Leaflet/sql/schema.sql:/docker-entrypoint-initdb.d/01-spbu-schema.sql:ro
|
||||||
|
- ./Leaflet/sql/spatial_schema.sql:/docker-entrypoint-initdb.d/02-spbu-spatial.sql:ro
|
||||||
|
- ./Leaflet2/sql/miskin_schema.sql:/docker-entrypoint-initdb.d/03-miskin-schema.sql:ro
|
||||||
|
- ./docker/mysql-init/99-grants.sql:/docker-entrypoint-initdb.d/99-grants.sql:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "mysqladmin ping -h localhost -uroot -p$${MYSQL_ROOT_PASSWORD}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db_data:
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
GRANT ALL PRIVILEGES ON webgis_spbu.* TO 'webgis_user'@'%';
|
||||||
|
GRANT ALL PRIVILEGES ON webgis_miskin.* TO 'webgis_user'@'%';
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Portal WebGIS | Leaflet & WebGIS Peduli</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="style.css?v=8">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="site-header">
|
||||||
|
<a class="brand" href="#home" aria-label="Portal WebGIS">
|
||||||
|
<span class="brand-mark" aria-hidden="true">WG</span>
|
||||||
|
<span class="brand-text">
|
||||||
|
<strong>WebGIS Portal</strong>
|
||||||
|
<small>Leaflet Project Hub</small>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<nav class="nav-links" aria-label="Navigasi utama">
|
||||||
|
<a href="#projects">Project</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="home">
|
||||||
|
<section class="hero-section">
|
||||||
|
<div class="hero-bg" aria-hidden="true"></div>
|
||||||
|
<div class="hero-overlay" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<div class="hero-content">
|
||||||
|
<p class="eyebrow">Portal terpadu WebGIS</p>
|
||||||
|
<h1>Semua project peta Leaflet dalam satu pintu masuk.</h1>
|
||||||
|
<p class="hero-lead">
|
||||||
|
Akses cepat ke WebGIS SPBU, manajemen jalan dan parsil, serta dashboard WebGIS Peduli untuk data rumah ibadah, warga kurang mampu, radius layanan, bantuan, dan laporan.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="projects-section" id="projects">
|
||||||
|
<div class="section-heading">
|
||||||
|
<p class="eyebrow">Project aktif</p>
|
||||||
|
<h2>Pilih aplikasi yang ingin digunakan</h2>
|
||||||
|
<p>Setiap kartu langsung mengarah ke halaman utama project yang berada di folder XAMPP.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="project-grid">
|
||||||
|
<article class="project-card project-one">
|
||||||
|
<div class="project-visual" aria-hidden="true">
|
||||||
|
<div class="mini-map">
|
||||||
|
<span class="route route-a"></span>
|
||||||
|
<span class="route route-b"></span>
|
||||||
|
<span class="route route-c"></span>
|
||||||
|
<span class="pin pin-red"></span>
|
||||||
|
<span class="pin pin-blue"></span>
|
||||||
|
<span class="parcel parcel-one"></span>
|
||||||
|
<span class="parcel parcel-two"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="project-content">
|
||||||
|
<span class="project-kicker">Project 1</span>
|
||||||
|
<h3>WebGIS SPBU, Jalan, dan Parsil</h3>
|
||||||
|
<p>Menampilkan titik SPBU dan menyediakan pengelolaan geometri jalan serta bidang parsil melalui peta Leaflet interaktif.</p>
|
||||||
|
<div class="tag-list" aria-label="Fitur Project 1">
|
||||||
|
<span>SPBU</span>
|
||||||
|
<span>Jalan</span>
|
||||||
|
<span>Parsil</span>
|
||||||
|
</div>
|
||||||
|
<div class="project-links">
|
||||||
|
<a class="btn btn-primary" href="Leaflet/leaf.html">WebGIS SPBU</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="project-card project-two">
|
||||||
|
<div class="project-visual" aria-hidden="true">
|
||||||
|
<div class="dashboard-mock">
|
||||||
|
<span class="dash-line wide"></span>
|
||||||
|
<span class="dash-line"></span>
|
||||||
|
<span class="dash-card green"></span>
|
||||||
|
<span class="dash-card amber"></span>
|
||||||
|
<span class="dash-card red"></span>
|
||||||
|
<span class="radius-ring"></span>
|
||||||
|
<span class="help-pin"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="project-content">
|
||||||
|
<span class="project-kicker">Project 2</span>
|
||||||
|
<h3>WebGIS Peduli</h3>
|
||||||
|
<p>Dashboard untuk memetakan rumah ibadah, warga miskin/kurang mampu, radius layanan, prioritas bantuan, riwayat, dan laporan.</p>
|
||||||
|
<div class="tag-list" aria-label="Fitur Project 2">
|
||||||
|
<span>Dashboard</span>
|
||||||
|
<span>Bantuan</span>
|
||||||
|
<span>Laporan</span>
|
||||||
|
</div>
|
||||||
|
<div class="project-links">
|
||||||
|
<a class="btn btn-primary" href="Leaflet2/login.php">Masuk Admin</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const header = document.querySelector(".site-header");
|
||||||
|
|
||||||
|
function updateHeaderState() {
|
||||||
|
header.classList.toggle("is-scrolled", window.scrollY > 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateHeaderState();
|
||||||
|
requestAnimationFrame(updateHeaderState);
|
||||||
|
window.addEventListener("load", updateHeaderState);
|
||||||
|
window.addEventListener("scroll", updateHeaderState, { passive: true });
|
||||||
|
window.addEventListener("hashchange", () => {
|
||||||
|
requestAnimationFrame(updateHeaderState);
|
||||||
|
setTimeout(updateHeaderState, 120);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,621 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #f5f7fb;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--ink: #162033;
|
||||||
|
--muted: #647186;
|
||||||
|
--line: #dce4ef;
|
||||||
|
--teal: #0f766e;
|
||||||
|
--teal-dark: #115e59;
|
||||||
|
--blue: #2563eb;
|
||||||
|
--coral: #e85d4f;
|
||||||
|
--amber: #d99a28;
|
||||||
|
--green: #16a34a;
|
||||||
|
--shadow: 0 24px 70px rgba(22, 32, 51, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--bg);
|
||||||
|
font-family: "Inter", Arial, sans-serif;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
p {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 50%;
|
||||||
|
z-index: 30;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
width: min(1180px, calc(100% - 36px));
|
||||||
|
padding: 18px 0;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
transition: top 0.2s ease, padding 0.2s ease, background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
color: #ffffff;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
display: grid;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
place-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.34);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.16);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
font-weight: 900;
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
transition: color 0.2s ease, background 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand strong,
|
||||||
|
.brand small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand strong {
|
||||||
|
font-size: 0.98rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand small {
|
||||||
|
margin-top: 3px;
|
||||||
|
color: rgba(255, 255, 255, 0.78);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
transition: background 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 9px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: rgba(255, 255, 255, 0.86);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 800;
|
||||||
|
transition: color 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a:hover {
|
||||||
|
color: #ffffff;
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header.is-scrolled {
|
||||||
|
top: 14px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid rgba(220, 228, 239, 0.94);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
box-shadow: 0 16px 42px rgba(22, 32, 51, 0.12);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header.is-scrolled .brand {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header.is-scrolled .brand-mark {
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: transparent;
|
||||||
|
background: linear-gradient(145deg, var(--teal), var(--blue));
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header.is-scrolled .brand small {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header.is-scrolled .nav-links {
|
||||||
|
border-color: var(--line);
|
||||||
|
background: #f3f6fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header.is-scrolled .nav-links a {
|
||||||
|
color: #526175;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header.is-scrolled .nav-links a:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
background: #e7edf5;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 88vh;
|
||||||
|
padding: 112px max(18px, calc((100vw - 1180px) / 2)) 92px;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-bg,
|
||||||
|
.hero-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-bg {
|
||||||
|
background-image: url("assets/webgis-hero.png");
|
||||||
|
background-position: center;
|
||||||
|
background-size: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-overlay {
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(8, 16, 32, 0.88) 0%, rgba(8, 16, 32, 0.68) 44%, rgba(8, 16, 32, 0.2) 100%),
|
||||||
|
linear-gradient(180deg, rgba(8, 16, 32, 0.24), rgba(8, 16, 32, 0.54));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
width: min(760px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
color: #35d0ba;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
max-width: 820px;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
font-size: clamp(2.75rem, 6vw, 5.7rem);
|
||||||
|
line-height: 0.98;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-lead {
|
||||||
|
max-width: 680px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
color: rgba(255, 255, 255, 0.82);
|
||||||
|
font-size: 1.08rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-links {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 46px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.93rem;
|
||||||
|
font-weight: 900;
|
||||||
|
transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
color: #ffffff;
|
||||||
|
background: var(--teal);
|
||||||
|
box-shadow: 0 16px 34px rgba(15, 118, 110, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--teal-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: rgba(255, 255, 255, 0.34);
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
color: var(--teal-dark);
|
||||||
|
border-color: rgba(15, 118, 110, 0.22);
|
||||||
|
background: rgba(15, 118, 110, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover {
|
||||||
|
box-shadow: 0 12px 24px rgba(22, 32, 51, 0.09);
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-section {
|
||||||
|
width: min(1120px, calc(100% - 36px));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 132px 0 70px;
|
||||||
|
scroll-margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading {
|
||||||
|
max-width: 620px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading .eyebrow {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: var(--teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading h2 {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: clamp(1.8rem, 3vw, 2.7rem);
|
||||||
|
line-height: 1.08;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading p,
|
||||||
|
.project-card p {
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.58;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card {
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(220, 228, 239, 0.94);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-visual {
|
||||||
|
height: 180px;
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(15, 118, 110, 0.12), rgba(37, 99, 235, 0.06)),
|
||||||
|
#edf4f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-two .project-visual {
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(232, 93, 79, 0.13), rgba(217, 154, 40, 0.11)),
|
||||||
|
#f8f3ec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-map,
|
||||||
|
.dashboard-mock {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.72);
|
||||||
|
border-radius: 8px;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(255, 255, 255, 0.42) 1px, transparent 1px),
|
||||||
|
linear-gradient(rgba(255, 255, 255, 0.42) 1px, transparent 1px),
|
||||||
|
linear-gradient(135deg, #cfe9e5, #dfeaff 58%, #f4f7fb);
|
||||||
|
background-size: 36px 36px, 36px 36px, auto;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.route {
|
||||||
|
position: absolute;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(37, 99, 235, 0.76);
|
||||||
|
transform-origin: left center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-a {
|
||||||
|
top: 56px;
|
||||||
|
left: 22px;
|
||||||
|
width: 72%;
|
||||||
|
transform: rotate(9deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-b {
|
||||||
|
top: 145px;
|
||||||
|
left: 18px;
|
||||||
|
width: 86%;
|
||||||
|
background: rgba(15, 118, 110, 0.78);
|
||||||
|
transform: rotate(-11deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-c {
|
||||||
|
right: 34px;
|
||||||
|
bottom: 46px;
|
||||||
|
width: 50%;
|
||||||
|
background: rgba(232, 93, 79, 0.74);
|
||||||
|
transform: rotate(19deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin,
|
||||||
|
.help-pin {
|
||||||
|
position: absolute;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 4px solid #ffffff;
|
||||||
|
border-radius: 50% 50% 50% 0;
|
||||||
|
box-shadow: 0 10px 22px rgba(22, 32, 51, 0.22);
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin::after,
|
||||||
|
.help-pin::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-red {
|
||||||
|
top: 70px;
|
||||||
|
left: 34%;
|
||||||
|
background: var(--coral);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-blue {
|
||||||
|
right: 20%;
|
||||||
|
bottom: 70px;
|
||||||
|
background: var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.parcel {
|
||||||
|
position: absolute;
|
||||||
|
border: 2px solid rgba(217, 154, 40, 0.88);
|
||||||
|
background: rgba(217, 154, 40, 0.18);
|
||||||
|
transform: rotate(-8deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.parcel-one {
|
||||||
|
right: 36px;
|
||||||
|
top: 34px;
|
||||||
|
width: 96px;
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parcel-two {
|
||||||
|
left: 44px;
|
||||||
|
bottom: 32px;
|
||||||
|
width: 130px;
|
||||||
|
height: 74px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-mock {
|
||||||
|
background: linear-gradient(135deg, #ffffff, #f3f6fa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-line {
|
||||||
|
position: absolute;
|
||||||
|
left: 24px;
|
||||||
|
top: 28px;
|
||||||
|
width: 160px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #d8e1ec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-line.wide {
|
||||||
|
top: 54px;
|
||||||
|
width: 245px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-card {
|
||||||
|
position: absolute;
|
||||||
|
top: 94px;
|
||||||
|
width: 28%;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-card.green {
|
||||||
|
left: 24px;
|
||||||
|
background: rgba(22, 163, 74, 0.2);
|
||||||
|
border: 1px solid rgba(22, 163, 74, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-card.amber {
|
||||||
|
left: 36%;
|
||||||
|
background: rgba(217, 154, 40, 0.22);
|
||||||
|
border: 1px solid rgba(217, 154, 40, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-card.red {
|
||||||
|
right: 24px;
|
||||||
|
background: rgba(232, 93, 79, 0.2);
|
||||||
|
border: 1px solid rgba(232, 93, 79, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radius-ring {
|
||||||
|
position: absolute;
|
||||||
|
right: 72px;
|
||||||
|
bottom: 28px;
|
||||||
|
width: 124px;
|
||||||
|
height: 124px;
|
||||||
|
border: 3px solid rgba(37, 99, 235, 0.55);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(37, 99, 235, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-pin {
|
||||||
|
right: 124px;
|
||||||
|
bottom: 78px;
|
||||||
|
background: var(--teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-kicker {
|
||||||
|
display: inline-flex;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: var(--teal);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 900;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-two .project-kicker {
|
||||||
|
color: var(--coral);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card h3 {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 1.06rem;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 16px 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-list span {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--muted);
|
||||||
|
background: #eef3f8;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.hero-section {
|
||||||
|
min-height: 84vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.site-header {
|
||||||
|
position: absolute;
|
||||||
|
left: 14px;
|
||||||
|
right: 14px;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: row;
|
||||||
|
width: auto;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
padding-right: 12px;
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section {
|
||||||
|
display: block;
|
||||||
|
align-items: flex-start;
|
||||||
|
min-height: 92vh;
|
||||||
|
padding-top: 178px;
|
||||||
|
padding-bottom: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-overlay {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(8, 16, 32, 0.9) 0%, rgba(8, 16, 32, 0.74) 58%, rgba(8, 16, 32, 0.5) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2.45rem;
|
||||||
|
line-height: 1.04;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-lead {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-links .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-section {
|
||||||
|
width: min(100% - 28px, 1180px);
|
||||||
|
padding: 126px 0 58px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-visual {
|
||||||
|
height: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 460px) {
|
||||||
|
.brand-text small {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section {
|
||||||
|
min-height: 94vh;
|
||||||
|
padding-top: 168px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-visual {
|
||||||
|
height: 190px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user