chore: prepare docker webgis deployment
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
.git/
|
||||
.claude/
|
||||
.agents/
|
||||
.codex/
|
||||
.superpowers/
|
||||
.env
|
||||
|
||||
02/
|
||||
docs/
|
||||
submission/
|
||||
Tugas/
|
||||
DESIGN.md
|
||||
SpesifikasiKebutuhanPerangkatLunak.docx
|
||||
SpesifikasiKebutuhanPerangkatLunak.md
|
||||
|
||||
**/tests/
|
||||
**/tmp/
|
||||
**/*.log
|
||||
node_modules/
|
||||
.cache/
|
||||
__pycache__/
|
||||
@@ -0,0 +1 @@
|
||||
MARIADB_ROOT_PASSWORD=change-this-password
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# Local agent/tooling metadata
|
||||
.claude/
|
||||
.superpowers/
|
||||
.agents/
|
||||
.codex/
|
||||
|
||||
# Generated runtime/temp files
|
||||
tmp/
|
||||
.env
|
||||
*.tmp
|
||||
*.temp
|
||||
*.log
|
||||
*.bak
|
||||
*.swp
|
||||
|
||||
# OS/editor noise
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Dependency/cache folders not required by this plain PHP submission
|
||||
node_modules/
|
||||
.cache/
|
||||
__pycache__/
|
||||
|
||||
# Unrelated local design scratch file at repository root
|
||||
DESIGN.md
|
||||
|
||||
# Unrelated file
|
||||
Tugas/
|
||||
superpowers
|
||||
|
||||
# Internal development planning docs, not needed for submission
|
||||
WebgisPovertyMapping/docs/superpowers/
|
||||
WebgisPovertyMapping/docs/archive/
|
||||
WebgisPovertyMapping/docs/audit-fix-task-plan.md
|
||||
/docs
|
||||
/02
|
||||
/submission
|
||||
SpesifikasiKebutuhanPerangkatLunak.docx
|
||||
SpesifikasiKebutuhanPerangkatLunak.md
|
||||
@@ -0,0 +1,84 @@
|
||||
# Project 01 - WebGIS Dasar
|
||||
|
||||
Project ini adalah tugas kelas WebGIS dasar berbasis Leaflet.js, PHP, dan MySQL/MariaDB. Fokusnya adalah pengelolaan layer geometri dasar: point, polyline, dan polygon.
|
||||
|
||||
## Status Project
|
||||
|
||||
Project ini disertakan sebagai artifact tugas kelas. Aplikasi final Tugas Besar ada di folder `../WebgisPovertyMapping`.
|
||||
|
||||
File utama untuk demo project ini adalah:
|
||||
|
||||
```text
|
||||
index.php
|
||||
```
|
||||
|
||||
File `index.html` dan endpoint root seperti `simpan.php`, `ambil_data.php`, `hapus.php`, dan `update_posisi.php` adalah peninggalan versi awal POI. Demo utama menggunakan `index.php` dan endpoint di folder `api/`.
|
||||
|
||||
## Fitur
|
||||
|
||||
| Fitur | Deskripsi |
|
||||
| --- | --- |
|
||||
| POI/lokasi usaha | Tambah, tampilkan, hapus, dan drag marker lokasi usaha |
|
||||
| Data jalan | Gambar polyline jalan, simpan status jalan, hitung panjang |
|
||||
| Data parsil | Gambar polygon bidang tanah, simpan status kepemilikan, hitung luas |
|
||||
| CRUD API | Endpoint per layer di `api/point/`, `api/jalan/`, dan `api/parsil/` |
|
||||
| Peta interaktif | Leaflet.js dengan tile OpenStreetMap |
|
||||
|
||||
## Struktur Utama
|
||||
|
||||
```text
|
||||
01/
|
||||
├── index.php
|
||||
├── koneksi.php
|
||||
├── setup_database.sql
|
||||
├── api/
|
||||
│ ├── point/
|
||||
│ ├── jalan/
|
||||
│ └── parsil/
|
||||
└── modules/
|
||||
├── point.js
|
||||
├── jalan.js
|
||||
└── parsil.js
|
||||
```
|
||||
|
||||
## Database
|
||||
|
||||
Project ini memakai database khusus:
|
||||
|
||||
```text
|
||||
db_webgis_01
|
||||
```
|
||||
|
||||
Import schema:
|
||||
|
||||
```powershell
|
||||
mysql -u root -P 3307 < setup_database.sql
|
||||
```
|
||||
|
||||
Jika MySQL/MariaDB lokal memakai port lain, sesuaikan `DB_PORT` pada `koneksi.php`.
|
||||
|
||||
## Konfigurasi
|
||||
|
||||
Konfigurasi database ada di `koneksi.php`:
|
||||
|
||||
```php
|
||||
define('DB_HOST', 'localhost');
|
||||
define('DB_USER', 'root');
|
||||
define('DB_PASS', '');
|
||||
define('DB_NAME', 'db_webgis_01');
|
||||
define('DB_PORT', 3307);
|
||||
```
|
||||
|
||||
## Akses Lokal
|
||||
|
||||
```text
|
||||
http://localhost/webgis/01/
|
||||
```
|
||||
|
||||
## Dependensi
|
||||
|
||||
- Leaflet.js
|
||||
- OpenStreetMap tile
|
||||
- Google Fonts
|
||||
|
||||
Dependensi frontend dimuat dari CDN, sehingga demo membutuhkan koneksi internet.
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
/**
|
||||
* ambil_data.php
|
||||
* Mengambil semua data dari tabel lokasi_usaha dan mengembalikannya sebagai JSON
|
||||
* Digunakan oleh frontend untuk menampilkan marker di peta saat halaman dimuat
|
||||
*/
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
||||
header('Pragma: no-cache');
|
||||
header('Expires: 0');
|
||||
|
||||
require_once 'koneksi.php';
|
||||
|
||||
$result = $conn->query(
|
||||
"SELECT id, nama_tempat, no_wa, buka_24jam, latitude, longitude
|
||||
FROM lokasi_usaha
|
||||
ORDER BY id DESC"
|
||||
);
|
||||
|
||||
if (!$result) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Query gagal: ' . $conn->error]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$data[] = [
|
||||
'id' => (int)$row['id'],
|
||||
'nama_tempat' => $row['nama_tempat'],
|
||||
'no_wa' => $row['no_wa'],
|
||||
'buka_24jam' => (int)$row['buka_24jam'],
|
||||
'latitude' => (float)$row['latitude'],
|
||||
'longitude' => (float)$row['longitude']
|
||||
];
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'total' => count($data),
|
||||
'data' => $data
|
||||
]);
|
||||
|
||||
$conn->close();
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
require_once '../../koneksi.php';
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$result = $conn->query(
|
||||
"SELECT id, nama, deskripsi, attribute_key, palette, is_visible, created_at
|
||||
FROM choropleth_layers ORDER BY created_at ASC"
|
||||
);
|
||||
|
||||
if ($result === false) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Tabel belum ada. Jalankan setup_database.sql terlebih dahulu. (' . $conn->error . ')']);
|
||||
$conn->close(); exit;
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
while ($r = $result->fetch_assoc()) {
|
||||
$rows[] = [
|
||||
'id' => (int)$r['id'],
|
||||
'nama' => $r['nama'],
|
||||
'deskripsi' => $r['deskripsi'],
|
||||
'attribute_key' => $r['attribute_key'],
|
||||
'palette' => $r['palette'],
|
||||
'is_visible' => (bool)$r['is_visible'],
|
||||
'created_at' => $r['created_at'],
|
||||
];
|
||||
}
|
||||
$conn->close();
|
||||
|
||||
echo json_encode(['status' => 'success', 'data' => $rows, 'total' => count($rows)]);
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
require_once '../../koneksi.php';
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid.']); exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("SELECT geojson FROM choropleth_layers WHERE id = ?");
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$row = $stmt->get_result()->fetch_assoc();
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
|
||||
if (!$row) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Layer tidak ditemukan.']); exit;
|
||||
}
|
||||
|
||||
echo $row['geojson'];
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
require_once '../../koneksi.php';
|
||||
require_admin_json();
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method tidak valid.']); exit;
|
||||
}
|
||||
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid.']); exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("DELETE FROM choropleth_layers WHERE id = ?");
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$affected = $stmt->affected_rows;
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
|
||||
if ($affected === 0) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Layer tidak ditemukan.']); exit;
|
||||
}
|
||||
|
||||
echo json_encode(['status' => 'success']);
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
require_once '../../koneksi.php';
|
||||
require_admin_json();
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method tidak valid.']); exit;
|
||||
}
|
||||
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid.']); exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("UPDATE choropleth_layers SET is_visible = 1 - is_visible WHERE id = ?");
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
$stmt2 = $conn->prepare("SELECT is_visible FROM choropleth_layers WHERE id = ?");
|
||||
$stmt2->bind_param('i', $id);
|
||||
$stmt2->execute();
|
||||
$row = $stmt2->get_result()->fetch_assoc();
|
||||
$stmt2->close();
|
||||
$conn->close();
|
||||
|
||||
echo json_encode(['status' => 'success', 'is_visible' => (bool)$row['is_visible']]);
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
require_once '../../koneksi.php';
|
||||
require_admin_json();
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method tidak valid.']); exit;
|
||||
}
|
||||
|
||||
$nama = trim($_POST['nama'] ?? '');
|
||||
$deskripsi = trim($_POST['deskripsi'] ?? '');
|
||||
$attr_key = trim($_POST['attribute_key'] ?? '');
|
||||
$palette = in_array($_POST['palette'] ?? '', ['blue', 'orange', 'green', 'purple'])
|
||||
? $_POST['palette'] : 'blue';
|
||||
|
||||
if (!$nama || !$attr_key) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Nama layer dan atribut wajib diisi.']); exit;
|
||||
}
|
||||
|
||||
if (empty($_FILES['geojson']['tmp_name']) || $_FILES['geojson']['error'] !== UPLOAD_ERR_OK) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'File GeoJSON tidak ditemukan atau gagal diupload.']); exit;
|
||||
}
|
||||
|
||||
if ($_FILES['geojson']['size'] > 10 * 1024 * 1024) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'File terlalu besar (maksimum 10 MB).']); exit;
|
||||
}
|
||||
|
||||
$raw = file_get_contents($_FILES['geojson']['tmp_name']);
|
||||
if ($raw === false) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal membaca file.']); exit;
|
||||
}
|
||||
|
||||
$gj = json_decode($raw, true);
|
||||
if (!$gj || !isset($gj['type']) || $gj['type'] !== 'FeatureCollection') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'File bukan GeoJSON FeatureCollection yang valid.']); exit;
|
||||
}
|
||||
if (empty($gj['features'])) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'GeoJSON tidak memiliki fitur/data.']); exit;
|
||||
}
|
||||
|
||||
$first_props = $gj['features'][0]['properties'] ?? [];
|
||||
if (!array_key_exists($attr_key, $first_props)) {
|
||||
$available = implode(', ', array_keys($first_props));
|
||||
echo json_encode(['status' => 'error', 'message' => "Atribut '{$attr_key}' tidak ditemukan. Tersedia: {$available}"]); exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("INSERT INTO choropleth_layers (nama, deskripsi, geojson, attribute_key, palette) VALUES (?, ?, ?, ?, ?)");
|
||||
$stmt->bind_param('sssss', $nama, $deskripsi, $raw, $attr_key, $palette);
|
||||
$stmt->execute();
|
||||
$id = $conn->insert_id;
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
|
||||
echo json_encode(['status' => 'success', 'id' => $id, 'nama' => $nama]);
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
// api/jalan/ambil.php — Ambil semua data jalan
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
$result = $conn->query("SELECT id, nama_jalan, status_jalan, geojson, panjang_meter FROM data_jalan ORDER BY id DESC");
|
||||
|
||||
if (!$result) {
|
||||
echo json_encode(['status' => 'error', 'message' => $conn->error]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$data[] = $row;
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'total' => count($data),
|
||||
'data' => $data
|
||||
]);
|
||||
|
||||
$conn->close();
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
// api/jalan/hapus.php — Hapus data jalan
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
if (!$id) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("DELETE FROM data_jalan WHERE id = ?");
|
||||
$stmt->bind_param('i', $id);
|
||||
|
||||
if ($stmt->execute() && $stmt->affected_rows > 0) {
|
||||
echo json_encode(['status' => 'success', 'message' => 'Data jalan berhasil dihapus']);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data tidak ditemukan atau gagal dihapus']);
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
// api/jalan/simpan.php — Simpan data jalan baru
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$nama = trim($_POST['nama_jalan'] ?? '');
|
||||
$status_jalan = trim($_POST['status_jalan'] ?? '');
|
||||
$geojson = trim($_POST['geojson'] ?? '');
|
||||
$panjang = (float) ($_POST['panjang_meter'] ?? 0);
|
||||
|
||||
$allowed_status = ['Nasional', 'Provinsi', 'Kabupaten'];
|
||||
|
||||
if (!$nama) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Nama jalan tidak boleh kosong']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!in_array($status_jalan, $allowed_status)) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Status jalan tidak valid']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Validasi JSON
|
||||
json_decode($geojson);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data geometri tidak valid']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("INSERT INTO data_jalan (nama_jalan, status_jalan, geojson, panjang_meter) VALUES (?, ?, ?, ?)");
|
||||
$stmt->bind_param('sssd', $nama, $status_jalan, $geojson, $panjang);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$id = $conn->insert_id;
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'message' => 'Data jalan berhasil disimpan',
|
||||
'data' => [
|
||||
'id' => $id,
|
||||
'nama_jalan' => $nama,
|
||||
'status_jalan' => $status_jalan,
|
||||
'geojson' => $geojson,
|
||||
'panjang_meter' => $panjang
|
||||
]
|
||||
]);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal menyimpan: ' . $stmt->error]);
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
// api/parsil/ambil.php — Ambil semua data parsil tanah
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
$result = $conn->query("SELECT id, nama_parsil, status_kepemilikan, geojson, luas_m2 FROM data_parsil ORDER BY id DESC");
|
||||
|
||||
if (!$result) {
|
||||
echo json_encode(['status' => 'error', 'message' => $conn->error]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$data[] = $row;
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'total' => count($data),
|
||||
'data' => $data
|
||||
]);
|
||||
|
||||
$conn->close();
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
// api/parsil/hapus.php — Hapus data parsil tanah
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
if (!$id) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("DELETE FROM data_parsil WHERE id = ?");
|
||||
$stmt->bind_param('i', $id);
|
||||
|
||||
if ($stmt->execute() && $stmt->affected_rows > 0) {
|
||||
echo json_encode(['status' => 'success', 'message' => 'Data parsil berhasil dihapus']);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data tidak ditemukan atau gagal dihapus']);
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
// api/parsil/simpan.php — Simpan data parsil tanah baru
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$nama = trim($_POST['nama_parsil'] ?? '');
|
||||
$status = trim($_POST['status_kepemilikan'] ?? '');
|
||||
$geojson = trim($_POST['geojson'] ?? '');
|
||||
$luas = (float) ($_POST['luas_m2'] ?? 0);
|
||||
|
||||
$allowed_status = ['SHM', 'HGB', 'HGU', 'HP'];
|
||||
|
||||
if (!$nama) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Nama parsil tidak boleh kosong']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!in_array($status, $allowed_status)) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Status kepemilikan tidak valid']);
|
||||
exit;
|
||||
}
|
||||
|
||||
json_decode($geojson);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data geometri tidak valid']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("INSERT INTO data_parsil (nama_parsil, status_kepemilikan, geojson, luas_m2) VALUES (?, ?, ?, ?)");
|
||||
$stmt->bind_param('sssd', $nama, $status, $geojson, $luas);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$id = $conn->insert_id;
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'message' => 'Data parsil berhasil disimpan',
|
||||
'data' => [
|
||||
'id' => $id,
|
||||
'nama_parsil' => $nama,
|
||||
'status_kepemilikan' => $status,
|
||||
'geojson' => $geojson,
|
||||
'luas_m2' => $luas
|
||||
]
|
||||
]);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal menyimpan: ' . $stmt->error]);
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
// api/point/ambil.php — Ambil semua POI
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
$result = $conn->query("SELECT id, nama_tempat, no_wa, buka_24jam, latitude, longitude FROM lokasi_usaha ORDER BY id DESC");
|
||||
|
||||
if (!$result) {
|
||||
echo json_encode(['status' => 'error', 'message' => $conn->error]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$data[] = $row;
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'total' => count($data),
|
||||
'data' => $data
|
||||
]);
|
||||
|
||||
$conn->close();
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
// api/point/hapus.php — Hapus POI
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
if (!$id) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("DELETE FROM lokasi_usaha WHERE id = ?");
|
||||
$stmt->bind_param('i', $id);
|
||||
|
||||
if ($stmt->execute() && $stmt->affected_rows > 0) {
|
||||
echo json_encode(['status' => 'success', 'message' => 'Data berhasil dihapus']);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data tidak ditemukan atau gagal dihapus']);
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
// api/point/simpan.php — Simpan POI baru
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$nama = trim($_POST['nama_tempat'] ?? '');
|
||||
$wa = trim($_POST['no_wa'] ?? '');
|
||||
$buka24 = isset($_POST['buka_24jam']) ? (int) $_POST['buka_24jam'] : 0;
|
||||
$lat = (float) ($_POST['latitude'] ?? 0);
|
||||
$lng = (float) ($_POST['longitude'] ?? 0);
|
||||
|
||||
if (!$nama) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Nama tempat tidak boleh kosong']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("INSERT INTO lokasi_usaha (nama_tempat, no_wa, buka_24jam, latitude, longitude) VALUES (?, ?, ?, ?, ?)");
|
||||
$stmt->bind_param('ssidd', $nama, $wa, $buka24, $lat, $lng);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$id = $conn->insert_id;
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'message' => 'Data berhasil disimpan',
|
||||
'data' => [
|
||||
'id' => $id,
|
||||
'nama_tempat' => $nama,
|
||||
'no_wa' => $wa,
|
||||
'buka_24jam' => $buka24,
|
||||
'latitude' => $lat,
|
||||
'longitude' => $lng
|
||||
]
|
||||
]);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal menyimpan: ' . $stmt->error]);
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
// api/point/update_posisi.php — Update koordinat POI (drag marker)
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$lat = (float) ($_POST['latitude'] ?? 0);
|
||||
$lng = (float) ($_POST['longitude'] ?? 0);
|
||||
|
||||
if (!$id) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("UPDATE lokasi_usaha SET latitude = ?, longitude = ? WHERE id = ?");
|
||||
$stmt->bind_param('ddi', $lat, $lng, $id);
|
||||
|
||||
if ($stmt->execute() && $stmt->affected_rows > 0) {
|
||||
echo json_encode(['status' => 'success', 'message' => 'Posisi diperbarui']);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal memperbarui posisi']);
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
function start_session(): void {
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_set_cookie_params(['lifetime' => 0, 'httponly' => true, 'samesite' => 'Strict']);
|
||||
session_start();
|
||||
}
|
||||
}
|
||||
|
||||
function is_admin(): bool {
|
||||
start_session();
|
||||
return !empty($_SESSION['admin_id']);
|
||||
}
|
||||
|
||||
function require_admin_json(): void {
|
||||
if (!is_admin()) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Akses ditolak. Silahkan login terlebih dahulu.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once 'koneksi.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Metode tidak diizinkan.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$idRaw = $_POST['id'] ?? null;
|
||||
|
||||
if ($idRaw === null || !is_numeric($idRaw) || (int)$idRaw <= 0) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = (int)$idRaw;
|
||||
|
||||
$stmt = $conn->prepare("DELETE FROM lokasi_usaha WHERE id = ?");
|
||||
|
||||
if (!$stmt) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Persiapan query gagal: ' . $conn->error]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt->bind_param('i', $id);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
if ($stmt->affected_rows > 0) {
|
||||
echo json_encode(['status' => 'success', 'message' => 'Lokasi berhasil dihapus.']);
|
||||
} else {
|
||||
http_response_code(404);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Lokasi tidak ditemukan.']);
|
||||
}
|
||||
} else {
|
||||
http_response_code(500);
|
||||
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
+5
-5
@@ -334,7 +334,7 @@
|
||||
.radio-option input:checked+label {
|
||||
border-color: var(--c-accent);
|
||||
color: var(--c-accent-h);
|
||||
background: rgba(46, 160, 67, .1);
|
||||
background: rgba(13, 116, 144, .1);
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
@@ -591,7 +591,7 @@
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
background: rgba(248, 81, 73, .15);
|
||||
background: rgba(239, 68, 68, .15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -745,10 +745,10 @@
|
||||
<header class="app-header">
|
||||
<div class="app-logo">
|
||||
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="30" height="30" rx="7" fill="rgba(46,160,67,.15)" />
|
||||
<rect width="30" height="30" rx="7" fill="rgba(13,116,144,.15)" />
|
||||
<path
|
||||
d="M15 5C11.134 5 8 8.134 8 12C8 17.25 15 25 15 25C15 25 22 17.25 22 12C22 8.134 18.866 5 15 5ZM15 15C13.343 15 12 13.657 12 12C12 10.343 13.343 9 15 9C16.657 9 18 10.343 18 12C18 13.657 16.657 15 15 15Z"
|
||||
fill="#3fb950" />
|
||||
fill="#0d7490" />
|
||||
</svg>
|
||||
<div>
|
||||
<div class="app-logo-title">WebGIS POI</div>
|
||||
@@ -1036,7 +1036,7 @@
|
||||
<div class="form-popup">
|
||||
<div class="form-popup-header">
|
||||
<div class="form-popup-icon">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#3fb950" stroke-width="2.5">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#0d7490" stroke-width="2.5">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/>
|
||||
<line x1="8" y1="12" x2="16" y2="12"/>
|
||||
</svg>
|
||||
|
||||
+1159
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
define('DB_HOST', getenv('DB_HOST') ?: 'localhost');
|
||||
define('DB_USER', getenv('DB_USER') ?: 'root');
|
||||
define('DB_PASS', getenv('DB_PASS') ?: '');
|
||||
define('DB_NAME', getenv('DB_01_NAME') ?: (getenv('DB_NAME') ?: 'db_webgis_01'));
|
||||
define('DB_PORT', (int)(getenv('DB_PORT') ?: 3307));
|
||||
|
||||
require_once __DIR__ . '/auth/helper.php';
|
||||
start_session();
|
||||
|
||||
mysqli_report(MYSQLI_REPORT_OFF);
|
||||
$conn = @new mysqli(DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
http_response_code(500);
|
||||
die(json_encode(['status' => 'error', 'message' => 'Koneksi database gagal: ' . $conn->connect_error]));
|
||||
}
|
||||
|
||||
$conn->set_charset('utf8mb4');
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/koneksi.php';
|
||||
|
||||
if (is_admin()) { header('Location: index.php'); exit; }
|
||||
|
||||
$error = '';
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$username = trim($_POST['username'] ?? '');
|
||||
$password = $_POST['password'] ?? '';
|
||||
if ($username && $password) {
|
||||
$stmt = $conn->prepare("SELECT id, password FROM users WHERE username = ?");
|
||||
$stmt->bind_param('s', $username);
|
||||
$stmt->execute();
|
||||
$row = $stmt->get_result()->fetch_assoc();
|
||||
$stmt->close();
|
||||
if ($row && password_verify($password, $row['password'])) {
|
||||
$_SESSION['admin_id'] = $row['id'];
|
||||
$_SESSION['admin_username'] = $username;
|
||||
header('Location: index.php'); exit;
|
||||
}
|
||||
$error = 'Username atau password salah.';
|
||||
} else {
|
||||
$error = 'Username dan password wajib diisi.';
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Masuk — WebGIS Pontianak</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&display=swap" rel="stylesheet">
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--sb-bg: #1c1612;
|
||||
--body-bg: #f5f0eb;
|
||||
--card-bg: #fafaf9;
|
||||
--card-border:#ddd8d2;
|
||||
--accent: #0d7490;
|
||||
--accent-h: #0a5f7a;
|
||||
--accent-dim: rgba(13,116,144,.06);
|
||||
--text: #201515;
|
||||
--text-sec: #3d3530;
|
||||
--text-muted: #7a7067;
|
||||
--danger: #ef4444;
|
||||
--danger-dim: rgba(239,68,68,.08);
|
||||
--danger-bdr: rgba(239,68,68,.25);
|
||||
--border: #ddd8d2;
|
||||
--radius: 12px;
|
||||
--shadow: 0 4px 12px rgba(32,21,21,.08), 0 1px 2px rgba(32,21,21,.04);
|
||||
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
html, body { height: 100%; font-family: var(--font); background: var(--body-bg); color: var(--text); }
|
||||
.page { min-height: 100vh; display: grid; grid-template-columns: 1fr 1fr; }
|
||||
@media (max-width: 768px) { .page { grid-template-columns: 1fr; } .brand-side { display: none; } }
|
||||
|
||||
.brand-side {
|
||||
background: var(--sb-bg);
|
||||
display: flex; flex-direction: column; justify-content: space-between;
|
||||
padding: 48px 52px; position: relative; overflow: hidden;
|
||||
}
|
||||
.brand-side::before {
|
||||
content: ''; position: absolute; inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse 60% 50% at 30% 20%, rgba(14,165,233,.18) 0%, transparent 70%),
|
||||
radial-gradient(ellipse 50% 60% at 80% 80%, rgba(56,189,248,.12) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.brand-logo { display: flex; align-items: center; gap: 12px; position: relative; }
|
||||
.brand-logo-icon {
|
||||
width: 44px; height: 44px; border-radius: 12px; background: #0d7490;
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
}
|
||||
.brand-logo-icon .lucide { width: 22px; height: 22px; color: #fff; }
|
||||
.brand-logo-name { font-size: 15px; font-weight: 600; color: #fff; line-height: 1.3; }
|
||||
.brand-logo-sub { font-size: 11px; color: #a89f96; }
|
||||
.brand-body { position: relative; }
|
||||
.brand-headline { font-size: 28px; font-weight: 600; color: #fff; line-height: 1.35; margin-bottom: 16px; }
|
||||
.brand-headline span { color: #a89f96; }
|
||||
.brand-desc { font-size: 14px; color: #a89f96; line-height: 1.7; max-width: 340px; }
|
||||
.brand-stats { display: flex; gap: 28px; margin-top: 36px; }
|
||||
.brand-stat-num { font-size: 20px; font-weight: 600; color: #fff; letter-spacing: -0.5px; }
|
||||
.brand-stat-label { font-size: 11px; color: #a89f96; margin-top: 2px; }
|
||||
.brand-footer { font-size: 11px; color: #a89f96; position: relative; }
|
||||
|
||||
.form-side { display: flex; align-items: center; justify-content: center; padding: 40px 32px; background: var(--body-bg); }
|
||||
.form-box { width: min(420px, 100%); }
|
||||
.form-header { margin-bottom: 32px; }
|
||||
.form-welcome { font-size: 24px; font-weight: 600; color: var(--text); margin-bottom: 6px; }
|
||||
.form-welcome-sub { font-size: 14px; color: var(--text-muted); line-height: 1.5; }
|
||||
|
||||
.login-card { background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius); padding: 28px 32px 32px; box-shadow: var(--shadow); }
|
||||
|
||||
.alert { display: none; align-items: flex-start; gap: 10px; background: var(--danger-dim); border: 1px solid var(--danger-bdr); border-radius: 10px; padding: 12px 14px; margin-bottom: 20px; font-size: 13px; color: var(--danger); line-height: 1.5; }
|
||||
.alert.show { display: flex; }
|
||||
.alert .lucide { width: 16px; height: 16px; color: var(--danger); flex-shrink: 0; margin-top: 1px; }
|
||||
|
||||
.form-group { margin-bottom: 18px; }
|
||||
.form-group label { display: block; font-size: 12px; font-weight: 600; color: var(--text-sec); margin-bottom: 7px; }
|
||||
.input-wrap { position: relative; }
|
||||
.form-group input {
|
||||
width: 100%; padding: 11px 14px; background: #ede8e2;
|
||||
border: 1.5px solid var(--border); border-radius: 9px;
|
||||
color: var(--text); font-family: var(--font); font-size: 14px; outline: none;
|
||||
transition: border-color .2s, background .2s, box-shadow .2s;
|
||||
}
|
||||
.form-group input:focus { border-color: var(--accent); background: #fafaf9; box-shadow: 0 0 0 3px rgba(13,116,144,.08); }
|
||||
.form-group input::placeholder { color: var(--text-muted); }
|
||||
.input-wrap input { padding-right: 44px; }
|
||||
.pw-toggle {
|
||||
position: absolute; right: 12px; top: 50%; transform: translateY(-50%);
|
||||
background: none; border: none; cursor: pointer; color: var(--text-muted);
|
||||
padding: 4px; border-radius: 5px; transition: color .2s;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.pw-toggle:hover { color: var(--text-sec); }
|
||||
.pw-toggle .lucide { width: 17px; height: 17px; }
|
||||
|
||||
.btn-login {
|
||||
width: 100%; padding: 12px; background: var(--accent); color: #fff; border: none;
|
||||
border-radius: 9px; font-family: var(--font); font-size: 14px; font-weight: 600;
|
||||
cursor: pointer; margin-top: 6px; transition: background .2s, transform .1s;
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
}
|
||||
.btn-login:hover:not(:disabled) { background: var(--accent-h); transform: translateY(-1px); }
|
||||
.btn-login:disabled { opacity: .6; cursor: not-allowed; }
|
||||
.btn-spinner { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,.35); border-top-color: #fff; border-radius: 50%; animation: spin .7s linear infinite; display: none; }
|
||||
.btn-login.loading .btn-spinner { display: block; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.divider { display: flex; align-items: center; gap: 12px; margin: 22px 0; color: var(--text-muted); font-size: 12px; }
|
||||
.divider::before, .divider::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
||||
|
||||
.public-btn {
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
width: 100%; padding: 11px; background: transparent; color: var(--text-sec);
|
||||
border: 1.5px solid var(--border); border-radius: 9px; font-family: var(--font);
|
||||
font-size: 14px; font-weight: 600; text-decoration: none;
|
||||
transition: border-color .2s, color .2s, background .2s;
|
||||
}
|
||||
.public-btn:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-dim); }
|
||||
.public-btn .lucide { width: 16px; height: 16px; }
|
||||
.form-note { text-align: center; margin-top: 24px; font-size: 12px; color: var(--text-muted); line-height: 1.6; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<div class="brand-side">
|
||||
<div class="brand-logo">
|
||||
<div class="brand-logo-icon"><i data-lucide="map"></i></div>
|
||||
<div>
|
||||
<div class="brand-logo-name">WebGIS Pontianak</div>
|
||||
<div class="brand-logo-sub">Visualisasi Data Wilayah</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="brand-body">
|
||||
<div class="brand-headline">
|
||||
Pemetaan Wilayah<br>Berbasis <span>Data</span><br>ArcGIS
|
||||
</div>
|
||||
<div class="brand-desc">
|
||||
Platform visualisasi choropleth untuk menganalisis data spasial wilayah Pontianak
|
||||
melalui layer GeoJSON yang dapat dikonfigurasi.
|
||||
</div>
|
||||
<div class="brand-stats">
|
||||
<div><div class="brand-stat-num">GIS</div><div class="brand-stat-label">Berbasis Peta</div></div>
|
||||
<div><div class="brand-stat-num">ArcGIS</div><div class="brand-stat-label">Import Data</div></div>
|
||||
<div><div class="brand-stat-num">RT</div><div class="brand-stat-label">Real-time</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="brand-footer">© <?= date('Y') ?> WebGIS Pontianak</div>
|
||||
</div>
|
||||
|
||||
<div class="form-side">
|
||||
<div class="form-box">
|
||||
<div class="form-header">
|
||||
<div class="form-welcome">Selamat datang 👋</div>
|
||||
<div class="form-welcome-sub">Masuk untuk mengelola layer dan data peta.</div>
|
||||
</div>
|
||||
<div class="login-card">
|
||||
<?php if ($error): ?>
|
||||
<div class="alert show">
|
||||
<i data-lucide="alert-triangle"></i>
|
||||
<span><?= htmlspecialchars($error) ?></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<form method="POST" id="loginForm">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" placeholder="Masukkan username" required autofocus autocomplete="username">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<div class="input-wrap">
|
||||
<input type="password" id="password" name="password" placeholder="Masukkan password" required autocomplete="current-password">
|
||||
<button type="button" class="pw-toggle" onclick="togglePw()" aria-label="Tampilkan password">
|
||||
<i data-lucide="eye" id="pwIcon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn-login">
|
||||
<div class="btn-spinner"></div>
|
||||
<span>Masuk</span>
|
||||
</button>
|
||||
</form>
|
||||
<div class="divider">atau</div>
|
||||
<a href="index.php" class="public-btn">
|
||||
<i data-lucide="map"></i> Lihat Peta sebagai Pengunjung
|
||||
</a>
|
||||
</div>
|
||||
<div class="form-note">Lupa password? Hubungi administrator sistem.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<script>
|
||||
function togglePw() {
|
||||
const pw = document.getElementById('password');
|
||||
const ic = document.getElementById('pwIcon');
|
||||
const show = pw.type === 'password';
|
||||
pw.type = show ? 'text' : 'password';
|
||||
ic.setAttribute('data-lucide', show ? 'eye-off' : 'eye');
|
||||
lucide.createIcons();
|
||||
}
|
||||
document.getElementById('loginForm').addEventListener('submit', function() {
|
||||
const btn = this.querySelector('.btn-login');
|
||||
btn.disabled = true;
|
||||
btn.classList.add('loading');
|
||||
});
|
||||
lucide.createIcons();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/auth/helper.php';
|
||||
start_session();
|
||||
session_destroy();
|
||||
header('Location: index.php');
|
||||
exit;
|
||||
@@ -0,0 +1,311 @@
|
||||
// modules/choropleth.js — Choropleth visualizer untuk Project 01
|
||||
|
||||
(function () {
|
||||
const PALETTES = {
|
||||
blue: ['#c6dbef', '#9ecae1', '#6baed6', '#3182bd', '#08519c'],
|
||||
orange: ['#fdd0a2', '#fdae6b', '#fd8d3c', '#e6550d', '#a63603'],
|
||||
green: ['#c7e9c0', '#a1d99b', '#74c476', '#31a354', '#006d2c'],
|
||||
purple: ['#dadaeb', '#bcbddc', '#9e9ac8', '#6a51a3', '#3f007d'],
|
||||
};
|
||||
|
||||
// Qualitative palette — 10 distinct colors for categorical data
|
||||
const CAT_COLORS = [
|
||||
'#4e79a7','#f28e2b','#e15759','#76b7b2',
|
||||
'#59a14f','#edc948','#b07aa1','#ff9da7',
|
||||
'#9c755f','#bab0ac',
|
||||
];
|
||||
|
||||
let layers = []; // { meta, leafletLayer, colorMode, categoryMap, breaks, pal }
|
||||
let allMeta = []; // metadata rows from API (no geojson blob)
|
||||
|
||||
// ── Color scale helpers ──────────────────────────────────────────
|
||||
function quantileBreaks(values) {
|
||||
const sorted = values.filter(v => isFinite(v)).sort((a, b) => a - b);
|
||||
if (sorted.length === 0) return [0, 0, 0, 0, 0, 0];
|
||||
const breaks = [];
|
||||
for (let i = 0; i <= 5; i++) {
|
||||
breaks.push(sorted[Math.round(i * (sorted.length - 1) / 5)]);
|
||||
}
|
||||
return breaks;
|
||||
}
|
||||
|
||||
function getColor(val, breaks, pal) {
|
||||
for (let i = 0; i < pal.length; i++) {
|
||||
if (val <= breaks[i + 1]) return pal[i];
|
||||
}
|
||||
return pal[pal.length - 1];
|
||||
}
|
||||
|
||||
// Returns {colorMode:'categorical'|'quantile', categoryMap, breaks}
|
||||
function buildColorConfig(features, attrKey, pal) {
|
||||
const rawVals = features.map(f => f.properties[attrKey]);
|
||||
const numVals = rawVals.map(v => parseFloat(v));
|
||||
const allNumeric = numVals.every(v => isFinite(v));
|
||||
const uniqueRaw = [...new Set(rawVals.map(String))];
|
||||
|
||||
// Use categorical when: any non-numeric value, OR ≤ 10 unique values
|
||||
if (!allNumeric || uniqueRaw.length <= 10) {
|
||||
const categoryMap = {};
|
||||
uniqueRaw.sort().forEach((v, i) => { categoryMap[v] = CAT_COLORS[i % CAT_COLORS.length]; });
|
||||
return { colorMode: 'categorical', categoryMap, breaks: null };
|
||||
}
|
||||
const breaks = quantileBreaks(numVals);
|
||||
return { colorMode: 'quantile', categoryMap: null, breaks };
|
||||
}
|
||||
|
||||
function fmtVal(v) {
|
||||
const n = parseFloat(v);
|
||||
if (isFinite(n)) return n.toLocaleString('id-ID', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
||||
return String(v ?? '—');
|
||||
}
|
||||
|
||||
function isClassicDrawingActive() {
|
||||
return typeof window.currentMode !== 'undefined' && Number(window.currentMode) !== 0;
|
||||
}
|
||||
|
||||
function setLayerHitTesting(leafletLayer, enabled) {
|
||||
if (!leafletLayer || typeof leafletLayer.eachLayer !== 'function') return;
|
||||
leafletLayer.eachLayer(layer => {
|
||||
if (layer._path && layer._path.style) {
|
||||
layer._path.style.pointerEvents = enabled ? '' : 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setAllHitTesting(enabled) {
|
||||
layers.forEach(rendered => setLayerHitTesting(rendered.leafletLayer, enabled));
|
||||
}
|
||||
|
||||
// ── Render one GeoJSON layer on map ─────────────────────────────
|
||||
function renderLeafletLayer(meta, geojson) {
|
||||
const pal = PALETTES[meta.palette] || PALETTES.blue;
|
||||
const { colorMode, categoryMap, breaks } = buildColorConfig(geojson.features, meta.attribute_key, pal);
|
||||
|
||||
function featureColor(f) {
|
||||
const raw = f.properties[meta.attribute_key];
|
||||
if (colorMode === 'categorical') {
|
||||
return categoryMap[String(raw)] || '#cccccc';
|
||||
}
|
||||
const val = parseFloat(raw);
|
||||
return isFinite(val) ? getColor(val, breaks, pal) : '#cccccc';
|
||||
}
|
||||
|
||||
const lyr = L.geoJSON(geojson, {
|
||||
pane: 'choroplethPane',
|
||||
style: function (f) {
|
||||
return {
|
||||
fillColor: featureColor(f),
|
||||
weight: 1,
|
||||
color: '#ffffff',
|
||||
fillOpacity: 0.78,
|
||||
opacity: 0.9,
|
||||
};
|
||||
},
|
||||
onEachFeature: function (f, layer) {
|
||||
const props = f.properties || {};
|
||||
|
||||
function buildPopup(closeBtn) {
|
||||
const rows = Object.entries(props)
|
||||
.map(([k, v]) => `<tr><td class="pp-k">${escapeHTML(String(k))}</td><td class="pp-v">${escapeHTML(String(v ?? '—'))}</td></tr>`)
|
||||
.join('');
|
||||
return `<div class="choro-popup"><table>${rows}</table></div>`;
|
||||
}
|
||||
|
||||
layer.on('mouseover', function (e) {
|
||||
if (isClassicDrawingActive()) return;
|
||||
L.popup({ closeButton: false, className: 'choro-popup-wrap', offset: [0, -4] })
|
||||
.setLatLng(e.latlng)
|
||||
.setContent(buildPopup(false))
|
||||
.openOn(map);
|
||||
layer.setStyle({ weight: 2, color: '#1c1612' });
|
||||
});
|
||||
layer.on('mouseout', function () {
|
||||
if (isClassicDrawingActive()) return;
|
||||
map.closePopup();
|
||||
lyr.resetStyle(layer);
|
||||
});
|
||||
layer.on('click', function (e) {
|
||||
if (isClassicDrawingActive()) return;
|
||||
L.popup({ closeButton: true, className: 'choro-popup-wrap' })
|
||||
.setLatLng(e.latlng)
|
||||
.setContent(buildPopup(true))
|
||||
.openOn(map);
|
||||
L.DomEvent.stopPropagation(e);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return { meta, leafletLayer: lyr, colorMode, categoryMap, breaks, pal };
|
||||
}
|
||||
|
||||
// ── Legend ───────────────────────────────────────────────────────
|
||||
function buildLegends() {
|
||||
const container = document.getElementById('legendContainer');
|
||||
if (!container) return;
|
||||
|
||||
const visible = layers.filter(l => l.meta.is_visible);
|
||||
if (visible.length === 0) {
|
||||
container.innerHTML = '';
|
||||
container.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
container.style.display = 'block';
|
||||
container.innerHTML = visible.map(({ meta, colorMode, categoryMap, breaks, pal }) => {
|
||||
let classes;
|
||||
if (colorMode === 'categorical') {
|
||||
classes = Object.entries(categoryMap).map(([val, color]) => `
|
||||
<div class="leg-row">
|
||||
<span class="leg-swatch" style="background:${color}"></span>
|
||||
<span class="leg-range">${escapeHTML(String(val))}</span>
|
||||
</div>`).join('');
|
||||
} else {
|
||||
classes = pal.map((color, i) => `
|
||||
<div class="leg-row">
|
||||
<span class="leg-swatch" style="background:${color}"></span>
|
||||
<span class="leg-range">${fmtVal(breaks[i])} – ${fmtVal(breaks[i + 1])}</span>
|
||||
</div>`).join('');
|
||||
}
|
||||
return `<div class="leg-block">
|
||||
<div class="leg-title">${escapeHTML(meta.nama)}</div>
|
||||
<div class="leg-attr">${escapeHTML(meta.attribute_key)}</div>
|
||||
${classes}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── Layer panel ──────────────────────────────────────────────────
|
||||
function refreshPanel() {
|
||||
const list = document.getElementById('layerList');
|
||||
if (!list) return;
|
||||
|
||||
if (allMeta.length === 0) {
|
||||
list.innerHTML = '<div class="layer-empty">Belum ada layer.<br>Upload GeoJSON dari ArcGIS.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = allMeta.map(m => {
|
||||
const isVisible = m.is_visible;
|
||||
const eyeIcon = isVisible ? 'eye' : 'eye-off';
|
||||
const palDots = (PALETTES[m.palette] || PALETTES.blue)
|
||||
.map(c => `<span class="pal-dot" style="background:${c}"></span>`).join('');
|
||||
const safeNama = m.nama.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
|
||||
const delBtn = window._IS_ADMIN
|
||||
? `<button class="layer-del" onclick="window._choro.deleteLayer(${m.id},'${safeNama}')" title="Hapus"><i data-lucide="trash-2"></i></button>`
|
||||
: '';
|
||||
return `
|
||||
<div class="layer-item ${isVisible ? '' : 'is-hidden'}" id="li${m.id}">
|
||||
<div class="layer-item-row">
|
||||
<button class="layer-eye" onclick="window._choro.toggleLayer(${m.id})" title="${isVisible ? 'Sembunyikan' : 'Tampilkan'}">
|
||||
<i data-lucide="${eyeIcon}" id="eye${m.id}"></i>
|
||||
</button>
|
||||
<div class="layer-info">
|
||||
<div class="layer-name">${escapeHTML(m.nama)}</div>
|
||||
<div class="layer-attr">${escapeHTML(m.attribute_key)}</div>
|
||||
</div>
|
||||
${delBtn}
|
||||
</div>
|
||||
<div class="layer-pal">${palDots}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
}
|
||||
|
||||
// ── Load GeoJSON for one layer, add to map ───────────────────────
|
||||
function loadLayerGeojson(meta) {
|
||||
return fetch('api/choropleth/geojson.php?id=' + meta.id + '&_=' + Date.now())
|
||||
.then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
||||
.then(geojson => {
|
||||
const rendered = renderLeafletLayer(meta, geojson);
|
||||
if (meta.is_visible) rendered.leafletLayer.addTo(map);
|
||||
layers.push(rendered);
|
||||
setLayerHitTesting(rendered.leafletLayer, !isClassicDrawingActive());
|
||||
return rendered;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Initial load ─────────────────────────────────────────────────
|
||||
function loadAll() {
|
||||
layers = [];
|
||||
return fetch('api/choropleth/ambil.php?_=' + Date.now())
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status !== 'success') throw new Error(j.message || 'Gagal memuat layer.');
|
||||
allMeta = j.data;
|
||||
refreshPanel();
|
||||
return Promise.all(allMeta.map(meta => loadLayerGeojson(meta)));
|
||||
})
|
||||
.then(() => buildLegends())
|
||||
.catch(err => {
|
||||
showToast('Gagal memuat data: ' + err.message, 'error');
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────
|
||||
window._choro = {
|
||||
|
||||
toggleLayer: function (id) {
|
||||
const rendered = layers.find(l => l.meta.id === id);
|
||||
const meta = allMeta.find(m => m.id === id);
|
||||
if (!meta) return;
|
||||
|
||||
const newVisible = !meta.is_visible;
|
||||
meta.is_visible = newVisible;
|
||||
if (rendered) {
|
||||
rendered.meta.is_visible = newVisible;
|
||||
if (newVisible) rendered.leafletLayer.addTo(map);
|
||||
else map.removeLayer(rendered.leafletLayer);
|
||||
}
|
||||
|
||||
refreshPanel();
|
||||
buildLegends();
|
||||
showToast(escapeHTML(meta.nama) + (newVisible ? ' ditampilkan.' : ' disembunyikan.'));
|
||||
|
||||
if (window._IS_ADMIN) {
|
||||
const fd = new FormData();
|
||||
fd.append('id', id);
|
||||
fetch('api/choropleth/toggle.php', { method: 'POST', body: fd }).catch(() => {});
|
||||
}
|
||||
},
|
||||
|
||||
deleteLayer: function (id, nama) {
|
||||
showDeleteConfirm('Yakin ingin menghapus layer "' + nama + '"?').then(ok => {
|
||||
if (!ok) return;
|
||||
const fd = new FormData();
|
||||
fd.append('id', id);
|
||||
fetch('api/choropleth/hapus.php', { method: 'POST', body: fd })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status !== 'success') throw new Error(j.message);
|
||||
const idx = layers.findIndex(l => l.meta.id === id);
|
||||
if (idx !== -1) { map.removeLayer(layers[idx].leafletLayer); layers.splice(idx, 1); }
|
||||
allMeta = allMeta.filter(m => m.id !== id);
|
||||
refreshPanel();
|
||||
buildLegends();
|
||||
showToast('Layer "' + escapeHTML(nama) + '" berhasil dihapus.');
|
||||
})
|
||||
.catch(err => showToast(err.message || 'Gagal menghapus.', 'error'));
|
||||
});
|
||||
},
|
||||
|
||||
// Called after successful upload to add new layer without full reload
|
||||
addLayer: function (meta) {
|
||||
allMeta.push(meta);
|
||||
loadLayerGeojson(meta).then(() => {
|
||||
refreshPanel();
|
||||
buildLegends();
|
||||
// Zoom to new layer
|
||||
const rendered = layers.find(l => l.meta.id === meta.id);
|
||||
if (rendered) {
|
||||
try { map.fitBounds(rendered.leafletLayer.getBounds(), { padding: [40, 40] }); } catch (_) {}
|
||||
}
|
||||
}).catch(err => showToast('Layer tersimpan tapi gagal render: ' + err.message, 'error'));
|
||||
},
|
||||
|
||||
setHitTesting: setAllHitTesting,
|
||||
};
|
||||
|
||||
window.initChoropleth = loadAll;
|
||||
})();
|
||||
@@ -0,0 +1,432 @@
|
||||
// modules/jalan.js — Logika Pertemuan 2: Manajemen Data Jalan (Polyline)
|
||||
|
||||
(function () {
|
||||
const layerGroup = L.layerGroup();
|
||||
let allLayers = {}; // { id: polylineObject }
|
||||
let active = false;
|
||||
|
||||
// ── Warna per status jalan ────────────────────────
|
||||
const JALAN_COLORS = {
|
||||
'Nasional': '#ef4444',
|
||||
'Provinsi': '#f97316',
|
||||
'Kabupaten': '#eab308'
|
||||
};
|
||||
|
||||
function getColor(status) {
|
||||
return JALAN_COLORS[status] || '#888';
|
||||
}
|
||||
|
||||
// ── Render polyline ───────────────────────────────
|
||||
function addPolylineToMap(row) {
|
||||
let coords;
|
||||
try {
|
||||
const gj = typeof row.geojson === 'string' ? JSON.parse(row.geojson) : row.geojson;
|
||||
// GeoJSON coordinates: [lng, lat] → Leaflet: [lat, lng]
|
||||
coords = gj.coordinates.map(c => [c[1], c[0]]);
|
||||
} catch (e) {
|
||||
console.error('GeoJSON parse error (jalan id=' + row.id + ')', e);
|
||||
return;
|
||||
}
|
||||
|
||||
const poly = L.polyline(coords, {
|
||||
color: getColor(row.status_jalan),
|
||||
weight: 5,
|
||||
opacity: 0.85,
|
||||
pane: 'jalanPane'
|
||||
});
|
||||
|
||||
poly._jalanId = row.id;
|
||||
poly._jalanNama = row.nama_jalan;
|
||||
poly._jalanStatus = row.status_jalan;
|
||||
allLayers[row.id] = poly;
|
||||
|
||||
poly.bindPopup(buildInfoPopup(row), { maxWidth: 300 });
|
||||
layerGroup.addLayer(poly);
|
||||
}
|
||||
|
||||
function buildInfoPopup(row) {
|
||||
const color = getColor(row.status_jalan);
|
||||
const panjang = row.panjang_meter
|
||||
? parseFloat(row.panjang_meter) >= 1000
|
||||
? (parseFloat(row.panjang_meter) / 1000).toFixed(2) + ' km'
|
||||
: parseFloat(row.panjang_meter).toFixed(1) + ' m'
|
||||
: '—';
|
||||
|
||||
return `
|
||||
<div class="info-popup">
|
||||
<div class="info-popup-header">
|
||||
<div class="info-popup-icon" style="background:${color}22;">🛣️</div>
|
||||
<div>
|
||||
<div class="info-popup-name">${escapeHTML(row.nama_jalan)}</div>
|
||||
<div class="info-popup-id">#${row.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-row-icon">🏷️</div>
|
||||
<div>
|
||||
<div class="info-row-label">Status Jalan</div>
|
||||
<div class="info-row-value">
|
||||
<span class="status-badge-inline"
|
||||
style="background:${color}22;color:${color};border:1px solid ${color}55;">
|
||||
Jalan ${escapeHTML(row.status_jalan)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-row-icon">📏</div>
|
||||
<div>
|
||||
<div class="info-row-label">Panjang Jalan</div>
|
||||
<div class="info-row-value" style="font-family:var(--font-mono);">${panjang}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-hapus" onclick="window._jalanHapus(${row.id}, this)">🗑 Hapus Jalan</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Load data ───────────────────────────────────────────────────
|
||||
function loadData() {
|
||||
layerGroup.clearLayers();
|
||||
allLayers = {};
|
||||
|
||||
fetch('api/jalan/ambil.php?_=' + Date.now(), { cache: 'no-store' })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status !== 'success') throw new Error(j.message);
|
||||
updateCount(j.total, '🛣️');
|
||||
j.data.forEach(addPolylineToMap);
|
||||
refreshJalanList(j.data);
|
||||
})
|
||||
.catch(err => { showToast('Gagal memuat data jalan.', 'error'); console.error(err); });
|
||||
}
|
||||
|
||||
// ── Refresh panel list Data Jalan ──────────────────────────────────
|
||||
function refreshJalanList(data) {
|
||||
if (typeof window.dlRefreshList !== 'function') return;
|
||||
const COLORS = { 'Nasional': '#ef4444', 'Provinsi': '#f97316', 'Kabupaten': '#eab308' };
|
||||
const items = (data || []).map(row => ({
|
||||
id: row.id,
|
||||
name: row.nama_jalan,
|
||||
badge: row.status_jalan,
|
||||
badgeColor: COLORS[row.status_jalan] || '#888',
|
||||
dotColor: COLORS[row.status_jalan] || '#888'
|
||||
}));
|
||||
window.dlRefreshList('Jalan', items);
|
||||
}
|
||||
|
||||
// ── Hapus jalan ───────────────────────────────────
|
||||
window._jalanHapus = function (id, btnEl) {
|
||||
showDeleteConfirm('Yakin ingin menghapus data jalan ini?').then(confirmed => {
|
||||
if (!confirmed) return;
|
||||
btnEl.disabled = true;
|
||||
btnEl.textContent = '⏳ Menghapus...';
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('id', id);
|
||||
|
||||
fetch('api/jalan/hapus.php', { method: 'POST', body: fd })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status === 'success') {
|
||||
map.closePopup();
|
||||
if (allLayers[id]) { layerGroup.removeLayer(allLayers[id]); delete allLayers[id]; }
|
||||
updateCount(Object.keys(allLayers).length, '🛣️');
|
||||
const COLORS = { 'Nasional': '#ef4444', 'Provinsi': '#f97316', 'Kabupaten': '#eab308' };
|
||||
const remaining = Object.values(allLayers).map(l => ({
|
||||
id: l._jalanId, name: l._jalanNama,
|
||||
badge: l._jalanStatus,
|
||||
badgeColor: COLORS[l._jalanStatus] || '#888',
|
||||
dotColor: COLORS[l._jalanStatus] || '#888'
|
||||
}));
|
||||
if (typeof window.dlRefreshList === 'function') window.dlRefreshList('Jalan', remaining);
|
||||
showToast('Data jalan berhasil dihapus.');
|
||||
} else throw new Error(j.message);
|
||||
})
|
||||
.catch(err => {
|
||||
showToast(err.message || 'Gagal menghapus.', 'error');
|
||||
btnEl.disabled = false;
|
||||
btnEl.textContent = '🗑 Hapus Jalan';
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// ── Drawing: gambar polyline baru ────────────────
|
||||
let drawingPoints = [];
|
||||
let drawingLayer = null; // polyline permanen per point click
|
||||
let liveLineLayer = null; // polyline preview kursor ke ujung terakhir
|
||||
let liveTooltip = null; // tooltip info ukuran
|
||||
let drawingMarkers = []; // titik-titik sementara
|
||||
let isDrawing = false;
|
||||
let lastClickAt = 0;
|
||||
|
||||
function startDrawing() {
|
||||
isDrawing = true;
|
||||
drawingPoints = [];
|
||||
lastClickAt = 0;
|
||||
document.getElementById('drawHint').classList.add('visible');
|
||||
map.doubleClickZoom.disable();
|
||||
}
|
||||
|
||||
function stopDrawing(save) {
|
||||
isDrawing = false;
|
||||
document.getElementById('drawHint').classList.remove('visible');
|
||||
map.doubleClickZoom.enable();
|
||||
|
||||
// Bersihkan preview
|
||||
if (drawingLayer) { map.removeLayer(drawingLayer); drawingLayer = null; }
|
||||
if (liveLineLayer) { map.removeLayer(liveLineLayer); liveLineLayer = null; }
|
||||
if (liveTooltip) { map.removeLayer(liveTooltip); liveTooltip = null; }
|
||||
drawingMarkers.forEach(m => map.removeLayer(m));
|
||||
drawingMarkers = [];
|
||||
|
||||
if (save && drawingPoints.length >= 2) {
|
||||
showFormJalan(drawingPoints.slice());
|
||||
}
|
||||
drawingPoints = [];
|
||||
|
||||
if (typeof window._resetActiveTool === 'function') window._resetActiveTool();
|
||||
}
|
||||
|
||||
function finishDrawing() {
|
||||
if (!isDrawing) return;
|
||||
if (drawingPoints.length >= 2) {
|
||||
stopDrawing(true);
|
||||
} else {
|
||||
showToast('Minimal 2 titik untuk membuat jalan (Polyline)', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function onMapClick(e) {
|
||||
if (currentMode !== 2 || currentSubMode !== 'jalan') return;
|
||||
if (!isDrawing) return;
|
||||
|
||||
const now = Date.now();
|
||||
// Deteksi double-click: klik kedua dalam 350ms → finalisasi tanpa menambah titik
|
||||
if (now - lastClickAt < 350) {
|
||||
lastClickAt = 0;
|
||||
finishDrawing();
|
||||
return;
|
||||
}
|
||||
lastClickAt = now;
|
||||
|
||||
const latlng = e.latlng;
|
||||
drawingPoints.push(latlng);
|
||||
|
||||
// Titik visual
|
||||
const m = L.circleMarker(latlng, { radius: 5, color: '#388bfd', fillColor: '#388bfd', fillOpacity: 1, pane: 'drawPane' });
|
||||
m.addTo(map);
|
||||
drawingMarkers.push(m);
|
||||
|
||||
// Preview polyline
|
||||
if (drawingLayer) map.removeLayer(drawingLayer);
|
||||
if (drawingPoints.length >= 2) {
|
||||
drawingLayer = L.polyline(drawingPoints, { color: '#388bfd', weight: 4, opacity: .9, pane: 'drawPane' }).addTo(map);
|
||||
}
|
||||
}
|
||||
|
||||
function onMapDoubleClick(e) {
|
||||
if (currentMode !== 2 || currentSubMode !== 'jalan' || !isDrawing) return;
|
||||
if (e.originalEvent) L.DomEvent.stop(e.originalEvent);
|
||||
finishDrawing();
|
||||
}
|
||||
|
||||
function onMouseMove(e) {
|
||||
if (currentMode !== 2 || currentSubMode !== 'jalan' || !isDrawing) return;
|
||||
if (drawingPoints.length === 0) {
|
||||
if (liveTooltip) { map.removeLayer(liveTooltip); liveTooltip = null; }
|
||||
return;
|
||||
}
|
||||
|
||||
const lastPoint = drawingPoints[drawingPoints.length - 1];
|
||||
const currentPoint = e.latlng;
|
||||
|
||||
// Gambar garis tipis (preview) dari titik terakhir ke cursor
|
||||
if (liveLineLayer) map.removeLayer(liveLineLayer);
|
||||
liveLineLayer = L.polyline([lastPoint, currentPoint], {
|
||||
color: '#79c0ff', weight: 2, dashArray: '5,6', opacity: 0.6, pane: 'drawPane'
|
||||
}).addTo(map);
|
||||
|
||||
// Hitung total panjang sampai titik ini
|
||||
let totalMeter = 0;
|
||||
for (let i = 0; i < drawingPoints.length - 1; i++) {
|
||||
totalMeter += drawingPoints[i].distanceTo(drawingPoints[i + 1]);
|
||||
}
|
||||
totalMeter += lastPoint.distanceTo(currentPoint);
|
||||
|
||||
const txt = (totalMeter >= 1000)
|
||||
? (totalMeter / 1000).toFixed(2) + ' km'
|
||||
: totalMeter.toFixed(1) + ' m';
|
||||
|
||||
// Tampilkan tooltip berjalan
|
||||
if (!liveTooltip) {
|
||||
liveTooltip = L.tooltip({ permanent: true, className: 'live-tooltip', direction: 'auto', offset: [15, 15], pane: 'drawPane' })
|
||||
.setLatLng(currentPoint)
|
||||
.setContent('<b>➕ Lanjut</b> · <kbd>Enter</kbd> / 2×klik selesai<br>Panjang: ' + txt)
|
||||
.addTo(map);
|
||||
} else {
|
||||
liveTooltip.setLatLng(currentPoint);
|
||||
liveTooltip.setContent('<b>➕ Lanjut</b> · <kbd>Enter</kbd> / 2×klik selesai<br>Panjang: ' + txt);
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(e) {
|
||||
if (currentMode !== 2 || currentSubMode !== 'jalan' || !isDrawing) return;
|
||||
if (e.key === 'Enter' || e.keyCode === 13) {
|
||||
e.preventDefault();
|
||||
finishDrawing();
|
||||
}
|
||||
if (e.key === 'Escape') stopDrawing(false);
|
||||
}
|
||||
|
||||
// ── Form popup setelah gambar selesai ─────────────
|
||||
function showFormJalan(points) {
|
||||
// Hitung panjang via Leaflet
|
||||
let totalMeter = 0;
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
totalMeter += points[i].distanceTo(points[i + 1]);
|
||||
}
|
||||
totalMeter = totalMeter.toFixed(2);
|
||||
|
||||
// Build GeoJSON LineString
|
||||
const geojson = JSON.stringify({
|
||||
type: 'LineString',
|
||||
coordinates: points.map(p => [p.lng, p.lat])
|
||||
});
|
||||
|
||||
// Popup di titik tengah
|
||||
const midIdx = Math.floor(points.length / 2);
|
||||
const midPt = points[midIdx];
|
||||
|
||||
L.popup({ maxWidth: 340, closeOnClick: false, closeButton: true })
|
||||
.setLatLng(midPt)
|
||||
.setContent(`
|
||||
<div class="form-popup">
|
||||
<div class="form-popup-header">
|
||||
<div class="form-popup-icon blue">🛣️</div>
|
||||
<div>
|
||||
<div class="form-popup-title">Tambah Data Jalan</div>
|
||||
<div class="form-popup-coords">${points.length} titik · ${totalMeter} m</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Nama Jalan *</label>
|
||||
<input type="text" id="fj_nama" placeholder="cth. Jl. Ahmad Yani" maxlength="100" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Status Jalan *</label>
|
||||
<select id="fj_status">
|
||||
<option value="Nasional">Jalan Nasional</option>
|
||||
<option value="Provinsi">Jalan Provinsi</option>
|
||||
<option value="Kabupaten" selected>Jalan Kabupaten</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Panjang Jalan (otomatis)</label>
|
||||
<div class="input-readonly">${totalMeter} meter</div>
|
||||
</div>
|
||||
<input type="hidden" id="fj_geojson" value='${escapeHTML(geojson)}'>
|
||||
<input type="hidden" id="fj_panjang" value="${totalMeter}">
|
||||
<button class="btn-save" id="btnSimpanJalan" onclick="window._jalanSimpan()">💾 Simpan Jalan</button>
|
||||
<div class="form-status" id="jalanStatus"></div>
|
||||
</div>
|
||||
`)
|
||||
.openOn(map);
|
||||
|
||||
setTimeout(() => { const el = document.getElementById('fj_nama'); if (el) el.focus(); }, 200);
|
||||
|
||||
// Kalau popup ditutup tanpa simpan: hapus preview
|
||||
map.once('popupclose', () => {
|
||||
// preview sudah di-clear di stopDrawing
|
||||
});
|
||||
}
|
||||
|
||||
// ── Simpan jalan ──────────────────────────────────
|
||||
window._jalanSimpan = function () {
|
||||
const nama = document.getElementById('fj_nama').value.trim();
|
||||
const status = document.getElementById('fj_status').value;
|
||||
const geojson = document.getElementById('fj_geojson').value;
|
||||
const panjang = document.getElementById('fj_panjang').value;
|
||||
const statusEl = document.getElementById('jalanStatus');
|
||||
const btn = document.getElementById('btnSimpanJalan');
|
||||
|
||||
if (!nama) {
|
||||
statusEl.textContent = '⚠ Nama jalan wajib diisi.';
|
||||
statusEl.className = 'form-status error';
|
||||
document.getElementById('fj_nama').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ Menyimpan...';
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('nama_jalan', nama);
|
||||
fd.append('status_jalan', status);
|
||||
fd.append('geojson', geojson);
|
||||
fd.append('panjang_meter', panjang);
|
||||
|
||||
fetch('api/jalan/simpan.php', { method: 'POST', body: fd })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status === 'success') {
|
||||
map.closePopup();
|
||||
addPolylineToMap(j.data);
|
||||
updateCount(Object.keys(allLayers).length, '🛣️');
|
||||
const COLORS = { 'Nasional': '#ef4444', 'Provinsi': '#f97316', 'Kabupaten': '#eab308' };
|
||||
const allItems = Object.values(allLayers).map(l => ({
|
||||
id: l._jalanId, name: l._jalanNama,
|
||||
badge: l._jalanStatus,
|
||||
badgeColor: COLORS[l._jalanStatus] || '#888',
|
||||
dotColor: COLORS[l._jalanStatus] || '#888'
|
||||
}));
|
||||
if (typeof window.dlRefreshList === 'function') window.dlRefreshList('Jalan', allItems);
|
||||
showToast(`"${escapeHTML(nama)}" berhasil disimpan!`);
|
||||
} else throw new Error(j.message);
|
||||
})
|
||||
.catch(err => {
|
||||
statusEl.textContent = '✕ ' + err.message;
|
||||
statusEl.className = 'form-status error';
|
||||
btn.disabled = false;
|
||||
btn.textContent = '💾 Simpan Jalan';
|
||||
});
|
||||
};
|
||||
|
||||
// ── Init / cleanup ───────────────────────────────────────────
|
||||
window.initJalan = function () {
|
||||
if (!active) {
|
||||
active = true;
|
||||
map.on('click', onMapClick);
|
||||
map.on('dblclick', onMapDoubleClick);
|
||||
map.on('mousemove', onMouseMove);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
}
|
||||
|
||||
// Daftarkan fungsi focus untuk panel list
|
||||
window._dlFocusFns['Jalan'] = function(id) {
|
||||
const layer = allLayers[id];
|
||||
if (!layer) return;
|
||||
map.fitBounds(layer.getBounds(), { padding: [40, 40], animate: true });
|
||||
setTimeout(() => layer.openPopup(layer.getBounds().getCenter()), 400);
|
||||
};
|
||||
|
||||
layerGroup.addTo(map);
|
||||
loadData();
|
||||
};
|
||||
|
||||
// Ekspos ke global agar bisa dipanggil dari index.php
|
||||
window._jalanStartDraw = startDrawing;
|
||||
window._jalanFinishDraw = finishDrawing;
|
||||
window._jalanStopDraw = function() { stopDrawing(false); };
|
||||
|
||||
// Filter Toggle
|
||||
window._jalanToggleLayer = function(visible) {
|
||||
if (visible) {
|
||||
map.addLayer(layerGroup);
|
||||
} else {
|
||||
map.removeLayer(layerGroup);
|
||||
stopDrawing(false); // Batalkan mode draw jika layer disembunyikan
|
||||
}
|
||||
};
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,459 @@
|
||||
// modules/parsil.js — Logika Pertemuan 2: Manajemen Parsil Tanah (Polygon)
|
||||
|
||||
(function () {
|
||||
const layerGroup = L.layerGroup();
|
||||
let allLayers = {}; // { id: polygonObject }
|
||||
let active = false;
|
||||
|
||||
// ── Warna per status kepemilikan ──────────────────
|
||||
const PARSIL_COLORS = {
|
||||
'SHM': '#3b82f6',
|
||||
'HGB': '#8b5cf6',
|
||||
'HGU': '#ec4899',
|
||||
'HP': '#14b8a6'
|
||||
};
|
||||
|
||||
function getColor(status) {
|
||||
return PARSIL_COLORS[status] || '#888';
|
||||
}
|
||||
|
||||
// ── Hitung Luas Polygon (Spherical Area) ──────────
|
||||
function calculatePolygonArea(latLngs) {
|
||||
let radius = 6378137; // Earth's radius in meters
|
||||
let area = 0;
|
||||
let d2r = Math.PI / 180;
|
||||
|
||||
if (latLngs.length > 2) {
|
||||
for (let i = 0; i < latLngs.length; i++) {
|
||||
let p1 = latLngs[i];
|
||||
let p2 = latLngs[(i + 1) % latLngs.length];
|
||||
area += ((p2.lng - p1.lng) * d2r) * (2 + Math.sin(p1.lat * d2r) + Math.sin(p2.lat * d2r));
|
||||
}
|
||||
area = area * radius * radius / 2.0;
|
||||
}
|
||||
return Math.abs(area);
|
||||
}
|
||||
|
||||
// ── Render polygon ────────────────────────────────
|
||||
function addPolygonToMap(row) {
|
||||
let coords;
|
||||
try {
|
||||
const gj = typeof row.geojson === 'string' ? JSON.parse(row.geojson) : row.geojson;
|
||||
// GeoJSON Polygon coordinates are MultiArray: [[[lng, lat], [lng, lat]...]]
|
||||
// We just need the exterior ring [0] maps to latlng
|
||||
let extRing = gj.coordinates[0];
|
||||
coords = extRing.map(c => [c[1], c[0]]);
|
||||
} catch (e) {
|
||||
console.error('GeoJSON parse error (parsil id=' + row.id + ')', e);
|
||||
return;
|
||||
}
|
||||
|
||||
const poly = L.polygon(coords, {
|
||||
color: getColor(row.status_kepemilikan),
|
||||
fillColor: getColor(row.status_kepemilikan),
|
||||
weight: 2,
|
||||
fillOpacity: 0.4,
|
||||
pane: 'parsilPane'
|
||||
});
|
||||
|
||||
poly._parsilId = row.id;
|
||||
poly._parsilNama = row.nama_parsil;
|
||||
poly._parsilStatus = row.status_kepemilikan;
|
||||
allLayers[row.id] = poly;
|
||||
|
||||
poly.bindPopup(buildInfoPopup(row), { maxWidth: 300 });
|
||||
layerGroup.addLayer(poly);
|
||||
}
|
||||
|
||||
function buildInfoPopup(row) {
|
||||
const color = getColor(row.status_kepemilikan);
|
||||
const luas = row.luas_m2
|
||||
? parseFloat(row.luas_m2).toLocaleString('id-ID', { minimumFractionDigits: 1, maximumFractionDigits: 2 }) + ' m²'
|
||||
: '—';
|
||||
|
||||
return `
|
||||
<div class="info-popup">
|
||||
<div class="info-popup-header">
|
||||
<div class="info-popup-icon" style="background:${color}22;">🏘️</div>
|
||||
<div>
|
||||
<div class="info-popup-name">${escapeHTML(row.nama_parsil)}</div>
|
||||
<div class="info-popup-id">#${row.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-row-icon">📜</div>
|
||||
<div>
|
||||
<div class="info-row-label">Status Kepemilikan</div>
|
||||
<div class="info-row-value">
|
||||
<span class="status-badge-inline"
|
||||
style="background:${color}22;color:${color};border:1px solid ${color}55;">
|
||||
${escapeHTML(row.status_kepemilikan)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-row-icon">📐</div>
|
||||
<div>
|
||||
<div class="info-row-label">Luas Tanah</div>
|
||||
<div class="info-row-value" style="font-family:var(--font-mono);">${luas}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-hapus" onclick="window._parsilHapus(${row.id}, this)">🗑 Hapus Parsil</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Load data ───────────────────────────────────────────────────
|
||||
function loadData() {
|
||||
layerGroup.clearLayers();
|
||||
allLayers = {};
|
||||
|
||||
fetch('api/parsil/ambil.php?_=' + Date.now(), { cache: 'no-store' })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status !== 'success') throw new Error(j.message);
|
||||
updateCount(j.total, '🏘️');
|
||||
j.data.forEach(addPolygonToMap);
|
||||
refreshParsilList(j.data);
|
||||
})
|
||||
.catch(err => { showToast('Gagal memuat data parsil.', 'error'); console.error(err); });
|
||||
}
|
||||
|
||||
// ── Refresh panel list Parsil Tanah ─────────────────────────────────
|
||||
function refreshParsilList(data) {
|
||||
if (typeof window.dlRefreshList !== 'function') return;
|
||||
const items = (data || []).map(row => ({
|
||||
id: row.id,
|
||||
name: row.nama_parsil,
|
||||
badge: row.status_kepemilikan,
|
||||
badgeColor: getColor(row.status_kepemilikan),
|
||||
dotColor: getColor(row.status_kepemilikan)
|
||||
}));
|
||||
window.dlRefreshList('Parsil', items);
|
||||
}
|
||||
|
||||
// ── Hapus parsil ──────────────────────────────────
|
||||
window._parsilHapus = function (id, btnEl) {
|
||||
showDeleteConfirm('Yakin ingin menghapus data parsil tanah ini?').then(confirmed => {
|
||||
if (!confirmed) return;
|
||||
btnEl.disabled = true;
|
||||
btnEl.textContent = '⏳ Menghapus...';
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('id', id);
|
||||
|
||||
fetch('api/parsil/hapus.php', { method: 'POST', body: fd })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status === 'success') {
|
||||
map.closePopup();
|
||||
if (allLayers[id]) { layerGroup.removeLayer(allLayers[id]); delete allLayers[id]; }
|
||||
updateCount(Object.keys(allLayers).length, '🏘️');
|
||||
const remaining = Object.values(allLayers).map(l => ({
|
||||
id: l._parsilId, name: l._parsilNama,
|
||||
badge: l._parsilStatus,
|
||||
badgeColor: getColor(l._parsilStatus),
|
||||
dotColor: getColor(l._parsilStatus)
|
||||
}));
|
||||
if (typeof window.dlRefreshList === 'function') window.dlRefreshList('Parsil', remaining);
|
||||
showToast('Data parsil berhasil dihapus.');
|
||||
} else throw new Error(j.message);
|
||||
})
|
||||
.catch(err => {
|
||||
showToast(err.message || 'Gagal menghapus.', 'error');
|
||||
btnEl.disabled = false;
|
||||
btnEl.textContent = '🗑 Hapus Parsil';
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// ── Drawing: gambar polygon baru ──────────────────
|
||||
let drawingPoints = [];
|
||||
let drawingLayer = null; // polyline/polygon sementara
|
||||
let liveLineLayer = null; // polyline preview kursor
|
||||
let liveTooltip = null; // tooltip info ukuran
|
||||
let drawingMarkers = []; // titik-titik sementara
|
||||
let isDrawing = false;
|
||||
let lastClickAt = 0;
|
||||
|
||||
function startDrawing() {
|
||||
isDrawing = true;
|
||||
drawingPoints = [];
|
||||
lastClickAt = 0;
|
||||
document.getElementById('drawHint').classList.add('visible');
|
||||
map.doubleClickZoom.disable();
|
||||
}
|
||||
|
||||
function stopDrawing(save) {
|
||||
isDrawing = false;
|
||||
document.getElementById('drawHint').classList.remove('visible');
|
||||
map.doubleClickZoom.enable();
|
||||
|
||||
// Bersihkan preview
|
||||
if (drawingLayer) { map.removeLayer(drawingLayer); drawingLayer = null; }
|
||||
if (liveLineLayer) { map.removeLayer(liveLineLayer); liveLineLayer = null; }
|
||||
if (liveTooltip) { map.removeLayer(liveTooltip); liveTooltip = null; }
|
||||
drawingMarkers.forEach(m => map.removeLayer(m));
|
||||
drawingMarkers = [];
|
||||
|
||||
if (save && drawingPoints.length >= 3) {
|
||||
showFormParsil(drawingPoints.slice());
|
||||
} else if (save && drawingPoints.length < 3) {
|
||||
showToast('Minimal 3 titik untuk membuat parsil (Polygon)', 'error');
|
||||
}
|
||||
drawingPoints = [];
|
||||
|
||||
if (typeof window._resetActiveTool === 'function') window._resetActiveTool();
|
||||
}
|
||||
|
||||
function finishDrawing() {
|
||||
if (!isDrawing) return;
|
||||
if (drawingPoints.length >= 3) {
|
||||
stopDrawing(true);
|
||||
} else {
|
||||
showToast('Minimal 3 titik untuk membuat parsil (Polygon)', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function onMapClick(e) {
|
||||
if (currentMode !== 2 || currentSubMode !== 'parsil') return;
|
||||
if (!isDrawing) return;
|
||||
|
||||
const now = Date.now();
|
||||
// Deteksi double-click: klik kedua dalam 350ms → finalisasi tanpa menambah titik
|
||||
if (now - lastClickAt < 350) {
|
||||
lastClickAt = 0;
|
||||
finishDrawing();
|
||||
return;
|
||||
}
|
||||
lastClickAt = now;
|
||||
|
||||
const latlng = e.latlng;
|
||||
drawingPoints.push(latlng);
|
||||
|
||||
// Titik visual
|
||||
const m = L.circleMarker(latlng, { radius: 5, color: '#eab308', fillColor: '#eab308', fillOpacity: 1, pane: 'drawPane' });
|
||||
m.addTo(map);
|
||||
drawingMarkers.push(m);
|
||||
|
||||
// Preview polygon
|
||||
if (drawingLayer) map.removeLayer(drawingLayer);
|
||||
if (drawingPoints.length >= 3) {
|
||||
drawingLayer = L.polygon(drawingPoints, { color: '#eab308', weight: 2, fillOpacity: 0.3, pane: 'drawPane' }).addTo(map);
|
||||
} else if (drawingPoints.length === 2) {
|
||||
drawingLayer = L.polyline(drawingPoints, { color: '#eab308', weight: 2, dashArray: '6,4', opacity: 0.7, pane: 'drawPane' }).addTo(map);
|
||||
}
|
||||
}
|
||||
|
||||
function onMapDoubleClick(e) {
|
||||
if (currentMode !== 2 || currentSubMode !== 'parsil' || !isDrawing) return;
|
||||
if (e.originalEvent) L.DomEvent.stop(e.originalEvent);
|
||||
finishDrawing();
|
||||
}
|
||||
|
||||
function onMouseMove(e) {
|
||||
if (currentMode !== 2 || currentSubMode !== 'parsil' || !isDrawing) return;
|
||||
if (drawingPoints.length === 0) {
|
||||
if (liveTooltip) { map.removeLayer(liveTooltip); liveTooltip = null; }
|
||||
return;
|
||||
}
|
||||
|
||||
const lastPoint = drawingPoints[drawingPoints.length - 1];
|
||||
const currentPoint = e.latlng;
|
||||
|
||||
// Gambar garis tipis preview dari titik terakhir ke cursor (dan dari kursor ke titik awal jika >= 2 titik)
|
||||
if (liveLineLayer) map.removeLayer(liveLineLayer);
|
||||
|
||||
const previewCoords = [lastPoint, currentPoint];
|
||||
if (drawingPoints.length >= 2) {
|
||||
previewCoords.push(drawingPoints[0]); // Garis penutup bayangan
|
||||
}
|
||||
|
||||
liveLineLayer = L.polyline(previewCoords, {
|
||||
color: '#eab308', weight: 2, dashArray: '5,6', opacity: 0.6, pane: 'drawPane'
|
||||
}).addTo(map);
|
||||
|
||||
// Hitung total luas jika + titik baru ini (minimal 3 titik)
|
||||
let txt = '';
|
||||
if (drawingPoints.length >= 2) {
|
||||
const tempPoints = [...drawingPoints, currentPoint];
|
||||
let luasArea = calculatePolygonArea(tempPoints);
|
||||
txt = '<br>Luas: ' + parseFloat(luasArea.toFixed(2)).toLocaleString('id-ID', { minimumFractionDigits: 1, maximumFractionDigits: 2 }) + ' m²';
|
||||
}
|
||||
|
||||
// Tampilkan tooltip berjalan
|
||||
if (!liveTooltip) {
|
||||
liveTooltip = L.tooltip({ permanent: true, className: 'live-tooltip', direction: 'auto', offset: [15, 15], pane: 'drawPane' })
|
||||
.setLatLng(currentPoint)
|
||||
.setContent('<b>➕ Lanjut</b> · <kbd>Enter</kbd> / 2×klik selesai' + txt)
|
||||
.addTo(map);
|
||||
} else {
|
||||
liveTooltip.setLatLng(currentPoint);
|
||||
liveTooltip.setContent('<b>➕ Lanjut</b> · <kbd>Enter</kbd> / 2×klik selesai' + txt);
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(e) {
|
||||
if (currentMode !== 2 || currentSubMode !== 'parsil' || !isDrawing) return;
|
||||
if (e.key === 'Enter' || e.keyCode === 13) {
|
||||
e.preventDefault();
|
||||
finishDrawing();
|
||||
}
|
||||
if (e.key === 'Escape') stopDrawing(false);
|
||||
}
|
||||
|
||||
// ── Form popup setelah gambar selesai ─────────────
|
||||
function showFormParsil(points) {
|
||||
// Hitung luas area
|
||||
let luasArea = calculatePolygonArea(points);
|
||||
luasArea = luasArea.toFixed(2);
|
||||
|
||||
let luasStr = parseFloat(luasArea).toLocaleString('id-ID', { minimumFractionDigits: 1, maximumFractionDigits: 2 });
|
||||
|
||||
// Build GeoJSON Polygon
|
||||
let geojsonPoints = points.map(p => [p.lng, p.lat]);
|
||||
// GeoJSON First and last position must be strictly equivalent
|
||||
geojsonPoints.push([points[0].lng, points[0].lat]);
|
||||
|
||||
const geojson = JSON.stringify({
|
||||
type: 'Polygon',
|
||||
coordinates: [geojsonPoints]
|
||||
});
|
||||
|
||||
// Popup di bounds / center polygon
|
||||
const tempPoly = L.polygon(points);
|
||||
const centerPt = tempPoly.getBounds().getCenter();
|
||||
|
||||
L.popup({ maxWidth: 340, closeOnClick: false, closeButton: true })
|
||||
.setLatLng(centerPt)
|
||||
.setContent(`
|
||||
<div class="form-popup">
|
||||
<div class="form-popup-header">
|
||||
<div class="form-popup-icon amber">🏘️</div>
|
||||
<div>
|
||||
<div class="form-popup-title">Tambah Data Parsil (Kavling)</div>
|
||||
<div class="form-popup-coords">${points.length} titik · Luas : ${luasStr} m²</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Nama/Keterangan Parsil *</label>
|
||||
<input type="text" id="fp_nama" placeholder="cth. Kavling Blok C" maxlength="100" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Status Kepemilikan *</label>
|
||||
<select id="fp_status">
|
||||
<option value="SHM" selected>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">Hak Pakai (HP)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Luas Tanah (otomatis)</label>
|
||||
<div class="input-readonly">${luasStr} m²</div>
|
||||
</div>
|
||||
<input type="hidden" id="fp_geojson" value='${escapeHTML(geojson)}'>
|
||||
<input type="hidden" id="fp_luas" value="${luasArea}">
|
||||
<button class="btn-save" id="btnSimpanParsil" style="background:var(--c-warn);color:#111" onclick="window._parsilSimpan()">💾 Simpan Parsil</button>
|
||||
<div class="form-status" id="parsilStatus"></div>
|
||||
</div>
|
||||
`)
|
||||
.openOn(map);
|
||||
|
||||
setTimeout(() => { const el = document.getElementById('fp_nama'); if (el) el.focus(); }, 200);
|
||||
|
||||
// Kalau popup ditutup tanpa simpan: hapus preview
|
||||
map.once('popupclose', () => {
|
||||
// preview sudah di-clear di stopDrawing
|
||||
});
|
||||
}
|
||||
|
||||
// ── Simpan parsil ─────────────────────────────────
|
||||
window._parsilSimpan = function () {
|
||||
const nama = document.getElementById('fp_nama').value.trim();
|
||||
const status = document.getElementById('fp_status').value;
|
||||
const geojson = document.getElementById('fp_geojson').value;
|
||||
const luas = document.getElementById('fp_luas').value;
|
||||
const statusEl = document.getElementById('parsilStatus');
|
||||
const btn = document.getElementById('btnSimpanParsil');
|
||||
|
||||
if (!nama) {
|
||||
statusEl.textContent = '⚠ Nama parsil wajib diisi.';
|
||||
statusEl.className = 'form-status error';
|
||||
document.getElementById('fp_nama').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ Menyimpan...';
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('nama_parsil', nama);
|
||||
fd.append('status_kepemilikan', status);
|
||||
fd.append('geojson', geojson);
|
||||
fd.append('luas_m2', luas);
|
||||
|
||||
fetch('api/parsil/simpan.php', { method: 'POST', body: fd })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status === 'success') {
|
||||
map.closePopup();
|
||||
addPolygonToMap(j.data);
|
||||
updateCount(Object.keys(allLayers).length, '🏘️');
|
||||
const allItems = Object.values(allLayers).map(l => ({
|
||||
id: l._parsilId, name: l._parsilNama,
|
||||
badge: l._parsilStatus,
|
||||
badgeColor: getColor(l._parsilStatus),
|
||||
dotColor: getColor(l._parsilStatus)
|
||||
}));
|
||||
if (typeof window.dlRefreshList === 'function') window.dlRefreshList('Parsil', allItems);
|
||||
showToast(`"${escapeHTML(nama)}" berhasil disimpan!`);
|
||||
} else throw new Error(j.message);
|
||||
})
|
||||
.catch(err => {
|
||||
statusEl.textContent = '✕ ' + err.message;
|
||||
statusEl.className = 'form-status error';
|
||||
btn.disabled = false;
|
||||
btn.textContent = '💾 Simpan Parsil';
|
||||
});
|
||||
};
|
||||
|
||||
// ── Init / cleanup ────────────────────────────────────────────
|
||||
window.initParsil = function () {
|
||||
if (!active) {
|
||||
active = true;
|
||||
map.on('click', onMapClick);
|
||||
map.on('dblclick', onMapDoubleClick);
|
||||
map.on('mousemove', onMouseMove);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
}
|
||||
|
||||
// Daftarkan fungsi focus untuk panel list
|
||||
window._dlFocusFns['Parsil'] = function(id) {
|
||||
const layer = allLayers[id];
|
||||
if (!layer) return;
|
||||
map.fitBounds(layer.getBounds(), { padding: [60, 60], animate: true });
|
||||
setTimeout(() => layer.openPopup(layer.getBounds().getCenter()), 400);
|
||||
};
|
||||
|
||||
layerGroup.addTo(map);
|
||||
loadData();
|
||||
};
|
||||
|
||||
// Ekspos ke global agar bisa dipanggil dari index.php
|
||||
window._parsilStartDraw = startDrawing;
|
||||
window._parsilFinishDraw = finishDrawing;
|
||||
window._parsilStopDraw = function() { stopDrawing(false); };
|
||||
|
||||
// Filter Toggle
|
||||
window._parsilToggleLayer = function(visible) {
|
||||
if (visible) {
|
||||
map.addLayer(layerGroup);
|
||||
} else {
|
||||
map.removeLayer(layerGroup);
|
||||
stopDrawing(false); // Batalkan mode draw jika layer disembunyikan
|
||||
}
|
||||
};
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,315 @@
|
||||
// modules/point.js — Logika Pertemuan 1: Point of Interest (POI)
|
||||
// Dipanggil oleh index.php saat mode = 1
|
||||
|
||||
(function () {
|
||||
// ── Layer group ──────────────────────────────────
|
||||
const layerGroup = L.layerGroup();
|
||||
let allMarkers = {}; // { id: markerObject }
|
||||
let tempMarker = null;
|
||||
let active = false;
|
||||
|
||||
// ── Custom Icons ─────────────────────────────────
|
||||
function createIcon(color = '#2ea043') {
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
html: `<svg width="28" height="38" viewBox="0 0 28 38" xmlns="http://www.w3.org/2000/svg">
|
||||
<filter id="sh"><feDropShadow dx="0" dy="2" stdDeviation="2" flood-opacity=".4"/></filter>
|
||||
<path d="M14 0C6.268 0 0 6.268 0 14C0 24.5 14 38 14 38C14 38 28 24.5 28 14C28 6.268 21.732 0 14 0Z"
|
||||
fill="${color}" filter="url(#sh)"/>
|
||||
<circle cx="14" cy="14" r="6" fill="white" opacity=".9"/>
|
||||
</svg>`,
|
||||
iconSize: [28, 38],
|
||||
iconAnchor: [14, 38],
|
||||
popupAnchor: [0, -40]
|
||||
});
|
||||
}
|
||||
|
||||
const iconDefault = createIcon('#2ea043');
|
||||
const iconClosed = createIcon('#f85149');
|
||||
const iconTemp = createIcon('#e3b341');
|
||||
|
||||
// ── Render marker on map ─────────────────────────
|
||||
function addMarkerToMap(poi) {
|
||||
const isOpen = Number(poi.buka_24jam) === 1;
|
||||
const marker = L.marker(
|
||||
[poi.latitude, poi.longitude],
|
||||
{ icon: isOpen ? iconDefault : iconClosed, draggable: true, pane: 'pointPane' }
|
||||
);
|
||||
|
||||
marker._poiId = poi.id;
|
||||
marker._poiNama = poi.nama_tempat;
|
||||
marker._poiBuka = Number(poi.buka_24jam) === 1;
|
||||
allMarkers[poi.id] = marker;
|
||||
|
||||
// Drag to update position
|
||||
marker.on('dragend', function (e) {
|
||||
const { lat, lng } = e.target.getLatLng();
|
||||
const fd = new FormData();
|
||||
fd.append('id', poi.id);
|
||||
fd.append('latitude', lat.toFixed(8));
|
||||
fd.append('longitude', lng.toFixed(8));
|
||||
|
||||
fetch('api/point/update_posisi.php', { method: 'POST', body: fd })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status === 'success') showToast(`Posisi "${escapeHTML(poi.nama_tempat)}" diperbarui!`);
|
||||
else throw new Error(j.message);
|
||||
})
|
||||
.catch(err => showToast(err.message || 'Gagal update posisi.', 'error'));
|
||||
});
|
||||
|
||||
// Popup info
|
||||
const noWa = poi.no_wa ? poi.no_wa.replace(/\D/g, '') : '';
|
||||
const waBtn = noWa
|
||||
? `<a class="btn-wa" href="https://wa.me/${noWa}" target="_blank" rel="noopener"
|
||||
style="display:flex;align-items:center;justify-content:center;gap:8px;width:100%;margin-top:14px;padding:9px;background:#25d366;color:#fff;border:none;border-radius:7px;font-family:var(--font-body);font-size:13px;font-weight:700;text-decoration:none;">
|
||||
💬 Chat WhatsApp
|
||||
</a>`
|
||||
: `<div style="font-size:12px;color:var(--c-muted);text-align:center;padding:8px 0;margin-top:8px;">Tidak ada nomor WA</div>`;
|
||||
|
||||
const buka24Badge = isOpen
|
||||
? `<span class="status-badge-inline" style="background:rgba(46,160,67,.2);color:#3fb950;border:1px solid rgba(46,160,67,.35);">🟢 Buka 24 Jam</span>`
|
||||
: `<span class="status-badge-inline" style="background:rgba(248,81,73,.12);color:#f85149;border:1px solid rgba(248,81,73,.3);">🔴 Tidak 24 Jam</span>`;
|
||||
|
||||
marker.bindPopup(`
|
||||
<div class="info-popup">
|
||||
<div class="info-popup-header">
|
||||
<div class="info-popup-icon">📍</div>
|
||||
<div>
|
||||
<div class="info-popup-name">${escapeHTML(poi.nama_tempat)}</div>
|
||||
<div class="info-popup-id">#${poi.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-row-icon">📞</div>
|
||||
<div>
|
||||
<div class="info-row-label">No. WhatsApp</div>
|
||||
<div class="info-row-value">${poi.no_wa ? escapeHTML(poi.no_wa) : '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-row-icon">🕐</div>
|
||||
<div>
|
||||
<div class="info-row-label">Jam Operasional</div>
|
||||
<div class="info-row-value">${buka24Badge}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-row-icon">🌐</div>
|
||||
<div>
|
||||
<div class="info-row-label">Koordinat</div>
|
||||
<div class="info-row-value" style="font-family:var(--font-mono);font-size:11px;">
|
||||
${parseFloat(poi.latitude).toFixed(6)}, ${parseFloat(poi.longitude).toFixed(6)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${waBtn}
|
||||
<button class="btn-hapus" onclick="window._pointHapus(${poi.id}, this)">🗑 Hapus Lokasi</button>
|
||||
</div>
|
||||
`, { maxWidth: 300 });
|
||||
|
||||
layerGroup.addLayer(marker);
|
||||
}
|
||||
|
||||
// ── Load semua POI dari server ──────────────────
|
||||
function loadData() {
|
||||
layerGroup.clearLayers();
|
||||
allMarkers = {};
|
||||
|
||||
fetch('api/point/ambil.php?_=' + Date.now(), { cache: 'no-store' })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status !== 'success') throw new Error(j.message);
|
||||
updateCount(j.total, '📍');
|
||||
j.data.forEach(addMarkerToMap);
|
||||
refreshPointList(j.data);
|
||||
})
|
||||
.catch(err => {
|
||||
showToast('Gagal memuat data POI.', 'error');
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Refresh panel list Lokasi Usaha ──────────────────
|
||||
function refreshPointList(data) {
|
||||
if (typeof window.dlRefreshList !== 'function') return;
|
||||
const items = (data || []).map(poi => ({
|
||||
id: poi.id,
|
||||
name: poi.nama_tempat,
|
||||
badge: Number(poi.buka_24jam) === 1 ? '24 Jam' : 'Non-24',
|
||||
badgeColor: Number(poi.buka_24jam) === 1 ? '#2ea043' : '#f85149',
|
||||
dotColor: Number(poi.buka_24jam) === 1 ? '#2ea043' : '#f85149'
|
||||
}));
|
||||
window.dlRefreshList('Point', items);
|
||||
}
|
||||
|
||||
// ── Hapus marker ────────────────────────────────────
|
||||
window._pointHapus = function (id, btnEl) {
|
||||
showDeleteConfirm('Yakin ingin menghapus lokasi ini?').then(confirmed => {
|
||||
if (!confirmed) return;
|
||||
btnEl.disabled = true;
|
||||
btnEl.textContent = '⏳ Menghapus...';
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('id', id);
|
||||
|
||||
fetch('api/point/hapus.php', { method: 'POST', body: fd })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status === 'success') {
|
||||
map.closePopup();
|
||||
if (allMarkers[id]) { layerGroup.removeLayer(allMarkers[id]); delete allMarkers[id]; }
|
||||
updateCount(Math.max(0, Object.keys(allMarkers).length), '📍');
|
||||
// rebuild list dari allMarkers (ambil nama dari marker)
|
||||
const remaining = Object.keys(allMarkers).map(k => ({
|
||||
id: k,
|
||||
name: allMarkers[k]._poiNama || ('#' + k),
|
||||
badge: allMarkers[k]._poiBuka ? '24 Jam' : 'Non-24',
|
||||
badgeColor: allMarkers[k]._poiBuka ? '#2ea043' : '#f85149',
|
||||
dotColor: allMarkers[k]._poiBuka ? '#2ea043' : '#f85149'
|
||||
}));
|
||||
if (typeof window.dlRefreshList === 'function') window.dlRefreshList('Point', remaining);
|
||||
showToast('Lokasi berhasil dihapus.');
|
||||
} else throw new Error(j.message);
|
||||
})
|
||||
.catch(err => {
|
||||
showToast(err.message || 'Gagal menghapus.', 'error');
|
||||
btnEl.disabled = false;
|
||||
btnEl.textContent = '🗑 Hapus Lokasi';
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// ── Klik peta = form tambah POI ──────────────────
|
||||
function onMapClick(e) {
|
||||
if (currentMode !== 1) return;
|
||||
|
||||
const lat = e.latlng.lat.toFixed(8);
|
||||
const lng = e.latlng.lng.toFixed(8);
|
||||
|
||||
if (tempMarker) map.removeLayer(tempMarker);
|
||||
tempMarker = L.marker([lat, lng], { icon: iconTemp, pane: 'drawPane' }).addTo(map);
|
||||
|
||||
L.popup({ maxWidth: 320, closeOnClick: false })
|
||||
.setLatLng(e.latlng)
|
||||
.setContent(`
|
||||
<div class="form-popup">
|
||||
<div class="form-popup-header">
|
||||
<div class="form-popup-icon">➕</div>
|
||||
<div>
|
||||
<div class="form-popup-title">Tambah Lokasi Baru</div>
|
||||
<div class="form-popup-coords">${lat}, ${lng}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Nama Tempat *</label>
|
||||
<input type="text" id="f_nama" placeholder="cth. Warung Kopi Aroma" maxlength="100" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>No. WhatsApp</label>
|
||||
<input type="text" id="f_wa" placeholder="628xxxxxxxxxx" maxlength="15" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Buka 24 Jam?</label>
|
||||
<select id="f_buka24">
|
||||
<option value="0">❌ Tidak</option>
|
||||
<option value="1">✅ Ya</option>
|
||||
</select>
|
||||
</div>
|
||||
<input type="hidden" id="f_lat" value="${lat}">
|
||||
<input type="hidden" id="f_lng" value="${lng}">
|
||||
<button class="btn-save" id="btnSimpanPoi" onclick="window._pointSimpan()">💾 Simpan Lokasi</button>
|
||||
<div class="form-status" id="poiStatus"></div>
|
||||
</div>
|
||||
`)
|
||||
.openOn(map);
|
||||
|
||||
setTimeout(() => { const el = document.getElementById('f_nama'); if (el) el.focus(); }, 200);
|
||||
}
|
||||
|
||||
// ── Simpan POI ────────────────────────────────────
|
||||
window._pointSimpan = function () {
|
||||
const nama = document.getElementById('f_nama').value.trim();
|
||||
const wa = document.getElementById('f_wa').value.trim();
|
||||
const buka24 = document.getElementById('f_buka24').value;
|
||||
const lat = document.getElementById('f_lat').value;
|
||||
const lng = document.getElementById('f_lng').value;
|
||||
const status = document.getElementById('poiStatus');
|
||||
const btn = document.getElementById('btnSimpanPoi');
|
||||
|
||||
if (!nama) {
|
||||
status.textContent = '⚠ Nama tempat wajib diisi.';
|
||||
status.className = 'form-status error';
|
||||
document.getElementById('f_nama').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ Menyimpan...';
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('nama_tempat', nama);
|
||||
fd.append('no_wa', wa);
|
||||
fd.append('buka_24jam', buka24);
|
||||
fd.append('latitude', lat);
|
||||
fd.append('longitude', lng);
|
||||
|
||||
fetch('api/point/simpan.php', { method: 'POST', body: fd })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status === 'success') {
|
||||
map.closePopup();
|
||||
if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; }
|
||||
addMarkerToMap(j.data);
|
||||
updateCount(Object.keys(allMarkers).length, '📍');
|
||||
// Tambahkan item baru ke list
|
||||
const allItems = Object.values(allMarkers).map(m => ({
|
||||
id: m._poiId,
|
||||
name: m._poiNama || ('#' + m._poiId),
|
||||
badge: m._poiBuka ? '24 Jam' : 'Non-24',
|
||||
badgeColor: m._poiBuka ? '#2ea043' : '#f85149',
|
||||
dotColor: m._poiBuka ? '#2ea043' : '#f85149'
|
||||
}));
|
||||
if (typeof window.dlRefreshList === 'function') window.dlRefreshList('Point', allItems);
|
||||
showToast(`"${escapeHTML(nama)}" berhasil disimpan!`);
|
||||
} else throw new Error(j.message);
|
||||
})
|
||||
.catch(err => {
|
||||
status.textContent = '✕ ' + err.message;
|
||||
status.className = 'form-status error';
|
||||
btn.disabled = false;
|
||||
btn.textContent = '💾 Simpan Lokasi';
|
||||
});
|
||||
};
|
||||
|
||||
// ── Init / cleanup ────────────────────────────────────
|
||||
window.initPoint = function () {
|
||||
if (active) { /* already added to map, just refresh data if needed */ loadData(); return; }
|
||||
active = true;
|
||||
|
||||
layerGroup.addTo(map);
|
||||
map.on('click', onMapClick);
|
||||
|
||||
// Daftarkan fungsi focus untuk panel list
|
||||
window._dlFocusFns['Point'] = function(id) {
|
||||
const marker = allMarkers[id];
|
||||
if (!marker) return;
|
||||
map.setView(marker.getLatLng(), Math.max(map.getZoom(), 17), { animate: true });
|
||||
setTimeout(() => marker.openPopup(), 350);
|
||||
};
|
||||
|
||||
loadData();
|
||||
};
|
||||
|
||||
// Filter Toggle
|
||||
window._pointToggleLayer = function(visible) {
|
||||
if (visible) {
|
||||
map.addLayer(layerGroup);
|
||||
} else {
|
||||
map.removeLayer(layerGroup);
|
||||
// Juga tutup popup & bersihkan titik sementara jika ada
|
||||
if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; }
|
||||
}
|
||||
};
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,62 @@
|
||||
-- setup_database.sql — Project 01: WebGIS Choropleth Visualizer
|
||||
-- Jalankan: mysql -u root -p < setup_database.sql
|
||||
-- atau paste ke phpMyAdmin
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS db_webgis_01 CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
USE db_webgis_01;
|
||||
|
||||
-- ── Admin users ──────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
username VARCHAR(100) UNIQUE NOT NULL,
|
||||
password VARCHAR(255) NOT NULL
|
||||
);
|
||||
|
||||
-- Default: admin / password
|
||||
INSERT IGNORE INTO users (username, password) VALUES (
|
||||
'admin',
|
||||
'$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi'
|
||||
);
|
||||
|
||||
-- ── Choropleth layers ─────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS choropleth_layers (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
nama VARCHAR(150) NOT NULL,
|
||||
deskripsi TEXT,
|
||||
geojson LONGTEXT NOT NULL,
|
||||
attribute_key VARCHAR(100) NOT NULL,
|
||||
palette ENUM('blue','orange','green','purple') DEFAULT 'blue',
|
||||
is_visible TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- ── Point of Interest / Lokasi usaha ──────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS lokasi_usaha (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
nama_tempat VARCHAR(150) NOT NULL,
|
||||
no_wa VARCHAR(30) DEFAULT NULL,
|
||||
buka_24jam TINYINT(1) NOT NULL DEFAULT 0,
|
||||
latitude DECIMAL(10,8) NOT NULL,
|
||||
longitude DECIMAL(11,8) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ── Jalan polyline ────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS data_jalan (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
nama_jalan VARCHAR(150) NOT NULL,
|
||||
status_jalan ENUM('Nasional','Provinsi','Kabupaten') NOT NULL DEFAULT 'Kabupaten',
|
||||
geojson LONGTEXT NOT NULL,
|
||||
panjang_meter DOUBLE NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ── Parsil polygon ────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS data_parsil (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
nama_parsil VARCHAR(150) NOT NULL,
|
||||
status_kepemilikan ENUM('SHM','HGB','HGU','HP') NOT NULL DEFAULT 'SHM',
|
||||
geojson LONGTEXT NOT NULL,
|
||||
luas_m2 DOUBLE NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
/**
|
||||
* simpan.php
|
||||
* Menerima data POST dari form dan menyimpannya ke tabel lokasi_usaha
|
||||
* Mengembalikan respons dalam format JSON
|
||||
*/
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
|
||||
require_once 'koneksi.php';
|
||||
|
||||
// Hanya terima metode POST
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Metode tidak diizinkan.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Ambil dan sanitasi input
|
||||
$nama_tempat = trim($_POST['nama_tempat'] ?? '');
|
||||
$no_wa = trim($_POST['no_wa'] ?? '');
|
||||
$buka_24jam = isset($_POST['buka_24jam']) ? (int)$_POST['buka_24jam'] : 0;
|
||||
$latitude = $_POST['latitude'] ?? '';
|
||||
$longitude = $_POST['longitude'] ?? '';
|
||||
|
||||
// Validasi input wajib
|
||||
if (empty($nama_tempat) || empty($latitude) || empty($longitude)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Nama tempat, latitude, dan longitude wajib diisi.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Validasi format angka koordinat
|
||||
if (!is_numeric($latitude) || !is_numeric($longitude)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Format koordinat tidak valid.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$latitude = (float)$latitude;
|
||||
$longitude = (float)$longitude;
|
||||
$buka_24jam = ($buka_24jam === 1) ? 1 : 0;
|
||||
|
||||
// Prepared statement untuk mencegah SQL Injection
|
||||
$stmt = $conn->prepare(
|
||||
"INSERT INTO lokasi_usaha (nama_tempat, no_wa, buka_24jam, latitude, longitude)
|
||||
VALUES (?, ?, ?, ?, ?)"
|
||||
);
|
||||
|
||||
if (!$stmt) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Persiapan query gagal: ' . $conn->error]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// bind_param dengan tipe data yang benar
|
||||
$stmt->bind_param(
|
||||
'ssidd',
|
||||
$nama_tempat,
|
||||
$no_wa,
|
||||
$buka_24jam,
|
||||
$latitude,
|
||||
$longitude
|
||||
);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$new_id = $conn->insert_id;
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'message' => 'Data berhasil disimpan.',
|
||||
'data' => [
|
||||
'id' => $new_id,
|
||||
'nama_tempat' => $nama_tempat,
|
||||
'no_wa' => $no_wa,
|
||||
'buka_24jam' => $buka_24jam,
|
||||
'latitude' => $latitude,
|
||||
'longitude' => $longitude
|
||||
]
|
||||
]);
|
||||
} else {
|
||||
http_response_code(500);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal menyimpan data: ' . $stmt->error]);
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
|
||||
function read_file_or_fail(string $path): string
|
||||
{
|
||||
if (!is_file($path)) {
|
||||
fwrite(STDERR, "Missing file: {$path}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
return file_get_contents($path);
|
||||
}
|
||||
|
||||
function assert_contains(string $haystack, string $needle, string $label): void
|
||||
{
|
||||
if (strpos($haystack, $needle) === false) {
|
||||
fwrite(STDERR, "FAIL: {$label} must contain {$needle}\n");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
$index = read_file_or_fail($root . DIRECTORY_SEPARATOR . 'index.php');
|
||||
$choropleth = read_file_or_fail($root . DIRECTORY_SEPARATOR . 'modules' . DIRECTORY_SEPARATOR . 'choropleth.js');
|
||||
$point = read_file_or_fail($root . DIRECTORY_SEPARATOR . 'modules' . DIRECTORY_SEPARATOR . 'point.js');
|
||||
$jalan = read_file_or_fail($root . DIRECTORY_SEPARATOR . 'modules' . DIRECTORY_SEPARATOR . 'jalan.js');
|
||||
$parsil = read_file_or_fail($root . DIRECTORY_SEPARATOR . 'modules' . DIRECTORY_SEPARATOR . 'parsil.js');
|
||||
|
||||
foreach (['choroplethPane', 'parsilPane', 'jalanPane', 'drawPane', 'pointPane'] as $pane) {
|
||||
assert_contains($index, "'{$pane}'", "index.php");
|
||||
}
|
||||
assert_contains($index, 'map.createPane(name)', 'index.php');
|
||||
assert_contains($index, '.leaflet-pane.is-hit-disabled *', 'choropleth disabled pane CSS');
|
||||
assert_contains($index, 'function setDrawBlockingLayerHitTesting(enabled)', 'draw blocking pane helper');
|
||||
foreach (['choroplethPane', 'parsilPane', 'jalanPane', 'pointPane', 'drawPane'] as $pane) {
|
||||
assert_contains($index, "'{$pane}'", 'draw blocking pane list');
|
||||
}
|
||||
assert_contains($index, 'function setChoroplethHitTesting(enabled)', 'choropleth hit-testing helper');
|
||||
assert_contains($index, "pane.style.pointerEvents = enabled ? '' : 'none';", 'choropleth hit-testing toggle');
|
||||
assert_contains($index, "pane.classList.toggle('is-hit-disabled', !enabled);", 'choropleth disabled pane class toggle');
|
||||
assert_contains($index, 'window._choro.setHitTesting(enabled)', 'choropleth per-feature hit-testing toggle');
|
||||
assert_contains($index, 'setChoroplethHitTesting(false)', 'draw tool disables choropleth hit-testing');
|
||||
assert_contains($index, 'setChoroplethHitTesting(true)', 'draw tool restores choropleth hit-testing');
|
||||
assert_contains($index, 'function finishActiveDrawing()', 'global finish drawing helper');
|
||||
assert_contains($index, 'onclick="finishActiveDrawing()"', 'draw hint finish button');
|
||||
assert_contains($index, 'onclick="cancelActiveDrawing()"', 'draw hint cancel button');
|
||||
|
||||
assert_contains($choropleth, "pane: 'choroplethPane'", 'choropleth layer options');
|
||||
assert_contains($choropleth, 'function isClassicDrawingActive()', 'choropleth draw guard');
|
||||
assert_contains($choropleth, 'if (isClassicDrawingActive()) return;', 'choropleth draw guard usage');
|
||||
assert_contains($choropleth, 'function setLayerHitTesting(leafletLayer, enabled)', 'choropleth layer hit-testing helper');
|
||||
assert_contains($choropleth, "layer._path.style.pointerEvents = enabled ? '' : 'none';", 'choropleth path hit-testing toggle');
|
||||
assert_contains($choropleth, 'setHitTesting: setAllHitTesting', 'choropleth public hit-testing API');
|
||||
assert_contains($point, "pane: 'pointPane'", 'point marker options');
|
||||
assert_contains($point, "pane: 'drawPane'", 'temporary point marker options');
|
||||
assert_contains($jalan, "pane: 'jalanPane'", 'jalan polyline options');
|
||||
assert_contains($jalan, "pane: 'drawPane'", 'jalan drawing preview options');
|
||||
assert_contains($jalan, "map.on('dblclick', onMapDoubleClick)", 'jalan double click finish handler');
|
||||
assert_contains($jalan, 'if (e.originalEvent) L.DomEvent.stop(e.originalEvent);', 'jalan double click event stop');
|
||||
assert_contains($jalan, 'e.preventDefault()', 'jalan enter finish handler');
|
||||
assert_contains($jalan, 'window._jalanFinishDraw = finishDrawing;', 'jalan exposed finish handler');
|
||||
assert_contains($parsil, "pane: 'parsilPane'", 'parsil polygon options');
|
||||
assert_contains($parsil, "pane: 'drawPane'", 'parsil drawing preview options');
|
||||
assert_contains($parsil, "map.on('dblclick', onMapDoubleClick)", 'parsil double click finish handler');
|
||||
assert_contains($parsil, 'if (e.originalEvent) L.DomEvent.stop(e.originalEvent);', 'parsil double click event stop');
|
||||
assert_contains($parsil, 'e.preventDefault()', 'parsil enter finish handler');
|
||||
assert_contains($parsil, 'window._parsilFinishDraw = finishDrawing;', 'parsil exposed finish handler');
|
||||
|
||||
echo "PASS: layer panes are configured\n";
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once 'koneksi.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Metode tidak diizinkan.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$idRaw = $_POST['id'] ?? null;
|
||||
$latitudeRaw = $_POST['latitude'] ?? null;
|
||||
$longitudeRaw = $_POST['longitude'] ?? null;
|
||||
|
||||
if ($idRaw === null || $latitudeRaw === null || $longitudeRaw === null) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data tidak lengkap.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!is_numeric($idRaw) || !is_numeric($latitudeRaw) || !is_numeric($longitudeRaw)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Format data tidak valid.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = (int)$idRaw;
|
||||
$latitude = (float)$latitudeRaw;
|
||||
$longitude = (float)$longitudeRaw;
|
||||
|
||||
if ($id <= 0) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare(
|
||||
"UPDATE lokasi_usaha SET latitude = ?, longitude = ? WHERE id = ?"
|
||||
);
|
||||
$stmt->bind_param('ddi', $latitude, $longitude, $id);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
echo json_encode(['status' => 'success', 'message' => 'Posisi berhasil diperbarui.']);
|
||||
} else {
|
||||
http_response_code(500);
|
||||
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
FROM php:8.2-apache
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libonig-dev \
|
||||
&& docker-php-ext-install mysqli mbstring \
|
||||
&& a2enmod rewrite headers \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY docker/apache-webgis.conf /etc/apache2/conf-available/webgis.conf
|
||||
RUN a2enconf webgis
|
||||
|
||||
WORKDIR /var/www/html
|
||||
COPY . /var/www/html/
|
||||
|
||||
RUN mkdir -p /var/www/html/WebgisPovertyMapping/tmp \
|
||||
&& chown -R www-data:www-data /var/www/html/WebgisPovertyMapping/tmp
|
||||
@@ -0,0 +1,150 @@
|
||||
# Tugas Besar SIG - WebGIS Poverty Mapping
|
||||
|
||||
Repository ini berisi aplikasi utama Tugas Besar SIG dan artifact tugas kelas WebGIS dasar.
|
||||
|
||||
## Prioritas Penilaian
|
||||
|
||||
Jalankan dan nilai aplikasi utama pada folder:
|
||||
|
||||
```text
|
||||
WebgisPovertyMapping/
|
||||
```
|
||||
|
||||
Folder `01/` disertakan sebagai bukti tugas kelas/prototype pengembangan sebelum aplikasi final.
|
||||
|
||||
## Daftar Project
|
||||
|
||||
| Folder | Peran | Ringkasan |
|
||||
| --- | --- | --- |
|
||||
| `01/` | Tugas kelas WebGIS dasar | CRUD POI/lokasi usaha, digitasi jalan polyline, digitasi parsil polygon |
|
||||
| `WebgisPovertyMapping/` | Aplikasi final tugas besar | Auth, role admin/operator/viewer, dashboard, kebutuhan bantuan, import/export, keamanan, laporan |
|
||||
|
||||
## Mapping Database
|
||||
|
||||
Setiap project memakai database terpisah agar schema tidak saling bertabrakan saat di-import.
|
||||
|
||||
```text
|
||||
01 -> db_webgis_01
|
||||
WebgisPovertyMapping -> db_webgis
|
||||
```
|
||||
|
||||
## URL Lokal Dengan Docker
|
||||
|
||||
Jalankan dari root repository dengan Docker Compose lokal:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose -f docker-compose.yml -f docker-compose.local.yml up --build -d
|
||||
```
|
||||
|
||||
Buka:
|
||||
|
||||
```text
|
||||
http://localhost:8080/
|
||||
http://localhost:8080/01/
|
||||
http://localhost:8080/WebgisPovertyMapping/
|
||||
```
|
||||
|
||||
Tambahkan `APP_PORT=8081` di `.env` jika port `8080` sudah dipakai.
|
||||
|
||||
## Quick Start Aplikasi Final
|
||||
|
||||
Setelah Docker berjalan, buka aplikasi final:
|
||||
|
||||
```text
|
||||
http://localhost:8080/WebgisPovertyMapping/
|
||||
```
|
||||
|
||||
Database `db_webgis` otomatis dibuat dan diisi dari `WebgisPovertyMapping/setup_database.sql` saat volume MariaDB Docker masih kosong.
|
||||
|
||||
XAMPP tidak lagi menjadi jalur utama. Jika perlu menjalankan mode legacy tanpa Docker, lihat `WebgisPovertyMapping/docs/deployment-xampp.md`.
|
||||
|
||||
## Quick Start Docker
|
||||
|
||||
Docker setup menjalankan dua service:
|
||||
|
||||
- `app`: PHP 8.2 + Apache untuk root repo, `01/`, dan `WebgisPovertyMapping/`.
|
||||
- `db`: MariaDB 11.4 yang otomatis import `01/setup_database.sql` dan `WebgisPovertyMapping/setup_database.sql` saat volume database masih kosong.
|
||||
|
||||
Jalankan dari root repository:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose -f docker-compose.yml -f docker-compose.local.yml up --build -d
|
||||
```
|
||||
|
||||
Buka:
|
||||
|
||||
```text
|
||||
http://localhost:8080/
|
||||
http://localhost:8080/01/
|
||||
http://localhost:8080/WebgisPovertyMapping/
|
||||
```
|
||||
|
||||
Tambahkan `APP_PORT=8081` di `.env` jika port `8080` sudah dipakai. Untuk reset database Docker dari awal:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.local.yml down -v
|
||||
docker compose -f docker-compose.yml -f docker-compose.local.yml up --build -d
|
||||
```
|
||||
|
||||
## Deployment Coolify
|
||||
|
||||
Gunakan repo ini sebagai source project Coolify dengan Docker Compose deployment. File `docker-compose.yml` adalah konfigurasi production: service `app` hanya memakai `expose: 80`, sehingga tidak membuka host port publik. Domain publik diarahkan oleh reverse proxy Coolify ke container port `80`; service `db` tetap private di network internal compose.
|
||||
|
||||
1. Push root repository ini ke Git provider yang bisa diakses Coolify.
|
||||
2. Di Coolify, buat resource baru dari Git repository tersebut.
|
||||
3. Pilih build pack/deployment type `Docker Compose`.
|
||||
4. Pastikan compose file yang dipakai adalah `docker-compose.yml` di root repository.
|
||||
5. Assign domain pada service `app`, misalnya:
|
||||
|
||||
```text
|
||||
https://ifuntanhub.dev
|
||||
```
|
||||
|
||||
Karena container Apache listen di port `80`, domain tidak perlu diberi suffix port.
|
||||
|
||||
6. Set environment variable berikut di Coolify:
|
||||
|
||||
```env
|
||||
MARIADB_ROOT_PASSWORD=isi-password-kuat
|
||||
```
|
||||
|
||||
7. Deploy. Saat database volume masih kosong, MariaDB otomatis menjalankan:
|
||||
|
||||
```text
|
||||
01/setup_database.sql
|
||||
WebgisPovertyMapping/setup_database.sql
|
||||
```
|
||||
|
||||
8. Setelah deploy, buka URL publik Coolify root. Landing page akan menampilkan link ke project kelas `01/` dan aplikasi final `WebgisPovertyMapping/`.
|
||||
|
||||
Catatan penting:
|
||||
|
||||
- Jangan tambahkan `ports:` di `docker-compose.yml` production. Jika port host dipublish, service dapat terbuka di luar kontrol reverse proxy.
|
||||
- `docker-compose.local.yml` hanya untuk development lokal dan tidak perlu dipilih di Coolify.
|
||||
- Jika ingin langsung membuka aplikasi final sebagai halaman utama domain, atur domain/path di reverse proxy ke `/WebgisPovertyMapping/` atau ubah landing page root sesuai kebutuhan.
|
||||
- Jika database Docker perlu direset di Coolify, hapus persistent volume database terlebih dahulu. Tanpa reset volume, file SQL init tidak dijalankan ulang oleh MariaDB.
|
||||
|
||||
## Environment
|
||||
|
||||
- Runtime utama: Docker Compose dengan PHP 8.2 Apache dan MariaDB 11.4.
|
||||
- Di Docker, aplikasi memakai `DB_HOST=db` dan `DB_PORT=3306`.
|
||||
- Default port `3307` hanya relevan untuk mode XAMPP legacy.
|
||||
- Leaflet, Chart.js, font, tile peta, dan geocoding memakai CDN/layanan eksternal sehingga demo membutuhkan koneksi internet.
|
||||
- File root `DESIGN.md` adalah catatan desain lokal yang tidak terkait pengumpulan dan diabaikan oleh `.gitignore`.
|
||||
|
||||
## Urutan Demo Yang Disarankan
|
||||
|
||||
1. Buka `WebgisPovertyMapping/` sebagai aplikasi final.
|
||||
2. Login dengan akun demo:
|
||||
|
||||
```text
|
||||
Administrator: admin / password
|
||||
Operator : operator / password
|
||||
Viewer : viewer / password
|
||||
```
|
||||
|
||||
3. Untuk akses publik tanpa login, buka langsung halaman peta aplikasi final.
|
||||
4. Tunjukkan peta, dashboard, rumah ibadah, penduduk miskin, proximity/blank spot, kebutuhan bantuan, dan laporan.
|
||||
5. Jika diminta bukti tugas kelas, buka `01/`.
|
||||
@@ -0,0 +1,18 @@
|
||||
# WebGIS Poverty Mapping - Apache hardening
|
||||
|
||||
Options -Indexes
|
||||
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
|
||||
# Test scripts are for local CLI verification only.
|
||||
# They must not be executable from a browser.
|
||||
RewriteRule ^tests(/|$) - [F,L]
|
||||
|
||||
# Runtime/session scratch data must not be browsable.
|
||||
RewriteRule ^tmp(/|$) - [F,L]
|
||||
|
||||
# Local agent/tooling metadata is not part of the application.
|
||||
RewriteRule ^\.claude(/|$) - [F,L]
|
||||
RewriteRule ^\.superpowers(/|$) - [F,L]
|
||||
</IfModule>
|
||||
@@ -0,0 +1,170 @@
|
||||
# WebGIS Poverty Mapping
|
||||
|
||||
Aplikasi utama Tugas Besar SIG untuk pemetaan warga miskin, rumah ibadah, kebutuhan bantuan, status bantuan, dan blank spot layanan berbasis Leaflet, PHP, dan MySQL/MariaDB.
|
||||
|
||||
Status audit terakhir: 2026-06-03. Kode aktif sudah memakai validasi koordinat bersama, CSRF untuk mutasi terautentikasi, session cookie hardening, scope operator, masking data publik, verifikasi warga sebelum workflow bantuan/kebutuhan, dan kebijakan import admin langsung `Terverifikasi`.
|
||||
|
||||
Catatan submit: project final ini sekarang dijalankan lewat Docker Compose dari root repository `webgis/`. Runtime utama memakai PHP 8.2 Apache dan MariaDB 11.4; database `db_webgis` otomatis dibuat dari `setup_database.sql` saat volume MariaDB masih kosong. SQL setup menggunakan syntax MariaDB-friendly seperti `ADD COLUMN IF NOT EXISTS`.
|
||||
|
||||
## Stack
|
||||
|
||||
- PHP procedural dengan MySQLi
|
||||
- MySQL/MariaDB
|
||||
- Leaflet untuk peta interaktif
|
||||
- Chart.js untuk statistik
|
||||
- Vanilla JavaScript modules
|
||||
- Docker Compose sebagai runtime utama
|
||||
- XAMPP/Laragon/WAMP hanya sebagai opsi legacy lokal
|
||||
|
||||
## Struktur Utama
|
||||
|
||||
```text
|
||||
WebgisPovertyMapping/
|
||||
├── index.php # Shell peta utama
|
||||
├── dashboard.php # Dashboard administrator
|
||||
├── config.php # Konfigurasi app, session, dan database
|
||||
├── koneksi.php # Koneksi DB + Haversine/proximity helper
|
||||
├── setup_database.sql # Schema database dan seed admin
|
||||
├── auth/ # Login, change password, session/RBAC helper
|
||||
├── api/ # Endpoint JSON/CSV/HTML per domain
|
||||
├── pages/ # Halaman admin/operator
|
||||
├── modules/ # JavaScript map modules
|
||||
├── css/ # CSS admin
|
||||
├── tests/ # Test PHP ad-hoc
|
||||
└── docs/ # Dokumentasi audit, deployment, dan superpowers plans/specs
|
||||
```
|
||||
|
||||
## Setup Lokal Dengan Docker
|
||||
|
||||
Jalankan dari root repository `webgis/`, bukan dari folder `WebgisPovertyMapping/`:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose -f docker-compose.yml -f docker-compose.local.yml up --build -d
|
||||
```
|
||||
|
||||
Akses aplikasi:
|
||||
|
||||
```text
|
||||
http://localhost:8080/WebgisPovertyMapping/
|
||||
```
|
||||
|
||||
Tambahkan `APP_PORT=8081` di file `.env` root jika port `8080` sudah dipakai. Database akan otomatis diinisialisasi oleh container MariaDB dari `WebgisPovertyMapping/setup_database.sql`.
|
||||
|
||||
Untuk reset database Docker dari awal:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.local.yml down -v
|
||||
docker compose -f docker-compose.yml -f docker-compose.local.yml up --build -d
|
||||
```
|
||||
|
||||
XAMPP tidak lagi menjadi jalur utama. Dokumentasi XAMPP tetap ada sebagai referensi legacy di `docs/deployment-xampp.md`.
|
||||
|
||||
## Akun Default
|
||||
|
||||
Default akun demo untuk penilaian:
|
||||
|
||||
```text
|
||||
Administrator: admin / password
|
||||
Operator : operator / password
|
||||
Viewer : viewer / password
|
||||
```
|
||||
|
||||
Untuk demo penilaian, akun tersebut tidak dipaksa mengganti password saat login pertama.
|
||||
|
||||
## Role
|
||||
|
||||
- `viewer`: akses publik/read-only, data sensitif penduduk dimasking.
|
||||
- `operator`: tambah/mengelola warga dan kebutuhan dalam rumah ibadah yang ditugaskan.
|
||||
- `administrator`: kelola semua data, rumah ibadah, pengguna, import/export, verifikasi, dan recalculation.
|
||||
|
||||
Publik tanpa login tetap bisa membuka peta sebagai `viewer` read-only. Dashboard admin tidak menawarkan pembuatan akun `viewer` baru karena akses publik tidak membutuhkan akun; role tersebut dipertahankan sebagai fallback RBAC dan kompatibilitas akun lama.
|
||||
|
||||
Operator harus dikaitkan ke satu `rumah_ibadah` melalui halaman manajemen user.
|
||||
|
||||
Model ownership warga saat ini adalah `proximity-only`: `penduduk_miskin.ibadah_id` dihitung dari radius rumah ibadah terdekat dan menjadi dasar scope operator. Jika posisi/radius rumah ibadah berubah, admin dapat menjalankan hitung ulang proximity dan assignment warga bisa berubah mengikuti hasil spasial terbaru.
|
||||
|
||||
## Fitur Utama
|
||||
|
||||
- Peta interaktif Pontianak berbasis Leaflet.
|
||||
- Layer rumah ibadah dengan radius layanan.
|
||||
- Layer penduduk miskin dengan proximity dan blank spot.
|
||||
- Status bantuan dan riwayat perubahan.
|
||||
- Kebutuhan bantuan per warga dan papan kebutuhan publik.
|
||||
- Dashboard statistik dan heatmap untuk operator/admin.
|
||||
- Import CSV dan export laporan.
|
||||
- RBAC berbasis session.
|
||||
- Proteksi data publik: hanya warga `Terverifikasi` yang tampil, PII dimasking, dan koordinat rumah warga tidak dikirim untuk pengunjung publik.
|
||||
- Workflow bantuan/kebutuhan hanya aktif untuk warga `Terverifikasi`.
|
||||
|
||||
## Dokumentasi Teknis
|
||||
|
||||
- `docs/codebase-navigation.md`: navigasi struktur repo, schema, endpoint, modul frontend, proximity, dan RBAC.
|
||||
- `docs/codebase-audit-report.md`: laporan proses bisnis, wiring backend/frontend, data lifecycle, catatan logika, prioritas fix, dan saran pengembangan.
|
||||
- `docs/business-process-sop.md`: SOP proses bisnis aktif untuk admin, operator, publik, dan donatur.
|
||||
- `docs/deployment-docker.md`: panduan deployment Docker/Coolify, reverse proxy, database container, dan troubleshooting.
|
||||
- `docs/deployment-xampp.md`: referensi legacy jika harus menjalankan aplikasi tanpa Docker.
|
||||
- `docs/audit-fix-task-plan.md`: task plan audit/hardening lintas fase.
|
||||
- `docs/superpowers/README.md`: indeks dokumen superpowers aktif.
|
||||
- `docs/superpowers/plans/2026-06-03-codebase-audit-refresh.md`: ringkasan audit implementasi terbaru.
|
||||
- `Tugas/` dan `DESIGN.md` adalah artifact lama/pendukung; gunakan dokumen di atas sebagai referensi implementasi aktif.
|
||||
|
||||
## Catatan Audit 2026-06-03
|
||||
|
||||
Yang sudah terlihat terpasang di kode:
|
||||
|
||||
- Validasi koordinat terpusat di `includes/validation.php` dan bounds wilayah studi di `config.php`.
|
||||
- Import CSV admin menyimpan warga sebagai `Terverifikasi` beserta `verified_by` dan `verified_at`.
|
||||
- Duplikasi NIK dicek terhadap semua record, termasuk arsip/inactive, agar validasi PHP sejalan dengan unique index database.
|
||||
- Endpoint kebutuhan dan status bantuan menolak warga yang belum `Terverifikasi`.
|
||||
- Statistik operator sudah scoped ke rumah ibadah operator.
|
||||
- Heatmap publik disembunyikan; heatmap menggunakan dataset internal untuk operator/admin.
|
||||
- User management mencegah self-demotion administrator dan operator ke rumah ibadah yang sudah dihapus.
|
||||
|
||||
Sisa pekerjaan yang masih disarankan:
|
||||
|
||||
- Rate limit session sederhana sudah aktif untuk form kontak donatur publik; pertimbangkan CAPTCHA atau rate limit IP untuk deploy publik serius.
|
||||
- Pertimbangkan model ownership `hybrid` jika tanggung jawab operasional operator harus dipisah dari hasil proximity spasial.
|
||||
- Perluas `tests/run_all.php` jika ingin mencakup semua test DB smoke test dalam satu runner.
|
||||
- Siapkan mirror lokal untuk CDN/tile provider bila deployment berada di jaringan tertutup.
|
||||
|
||||
## Test dan Verifikasi Dengan Docker
|
||||
|
||||
Lint semua file PHP:
|
||||
|
||||
```bash
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app sh -lc 'find /var/www/html/WebgisPovertyMapping -name "*.php" -print0 | xargs -0 -n1 php -l'
|
||||
```
|
||||
|
||||
Test ad-hoc statis yang aman dijalankan dari root project:
|
||||
|
||||
```bash
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app php /var/www/html/WebgisPovertyMapping/tests/run_all.php
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app php /var/www/html/WebgisPovertyMapping/tests/test_verifikasi_binding.php
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app php /var/www/html/WebgisPovertyMapping/tests/test_update_posisi_scope.php
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app php /var/www/html/WebgisPovertyMapping/tests/test_operator_scope_endpoints.php
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app php /var/www/html/WebgisPovertyMapping/tests/test_public_verification_filters.php
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app php /var/www/html/WebgisPovertyMapping/tests/test_frontend_module_loader.php
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app php /var/www/html/WebgisPovertyMapping/tests/test_frontend_reliability_guards.php
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app php /var/www/html/WebgisPovertyMapping/tests/test_no_legacy_poi.php
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app php /var/www/html/WebgisPovertyMapping/tests/test_viewer_public_filters.php
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app php /var/www/html/WebgisPovertyMapping/tests/test_operator_scope_strict.php
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app php /var/www/html/WebgisPovertyMapping/tests/test_penduduk_binding_types.php
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app php /var/www/html/WebgisPovertyMapping/tests/test_csrf_enforcement.php
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app php /var/www/html/WebgisPovertyMapping/tests/test_session_cookie_security.php
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app php /var/www/html/WebgisPovertyMapping/tests/test_operator_ibadah_highlight.php
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app php /var/www/html/WebgisPovertyMapping/tests/test_viewer_account_ui_hidden.php
|
||||
```
|
||||
|
||||
Test yang memuat `../config.php` atau `../koneksi.php` perlu dijalankan dari folder `tests`:
|
||||
|
||||
```bash
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app sh -lc 'cd /var/www/html/WebgisPovertyMapping/tests && php test_auth_helper.php'
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app sh -lc 'cd /var/www/html/WebgisPovertyMapping/tests && php test_db.php'
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app sh -lc 'cd /var/www/html/WebgisPovertyMapping/tests && php test_ibadah.php'
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app sh -lc 'cd /var/www/html/WebgisPovertyMapping/tests && php test_kebutuhan.php'
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app sh -lc 'cd /var/www/html/WebgisPovertyMapping/tests && php test_penduduk.php'
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app sh -lc 'cd /var/www/html/WebgisPovertyMapping/tests && php test_proximity.php'
|
||||
```
|
||||
|
||||
Beberapa test membutuhkan container database yang sudah sehat. Jika menjalankan mode XAMPP legacy, sesuaikan command PHP dan port database mengikuti `docs/deployment-xampp.md`.
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
// api/auth/change_password.php — POST: ganti password akun sendiri
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
require_auth('viewer');
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$new_password = $_POST['new_password'] ?? '';
|
||||
$confirm = $_POST['confirm_password'] ?? '';
|
||||
|
||||
// Validasi policy: min 8 karakter, kombinasi huruf + angka
|
||||
if (strlen($new_password) < 8) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Password minimal 8 karakter']);
|
||||
exit;
|
||||
}
|
||||
if (!preg_match('/[a-zA-Z]/', $new_password) || !preg_match('/[0-9]/', $new_password)) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Password harus mengandung huruf dan angka']);
|
||||
exit;
|
||||
}
|
||||
if ($new_password !== $confirm) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Konfirmasi password tidak cocok']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$hash = password_hash($new_password, PASSWORD_BCRYPT);
|
||||
$user_id = get_user_id();
|
||||
|
||||
$stmt = $conn->prepare("UPDATE users SET password = ?, must_change_password = 0 WHERE id = ?");
|
||||
$stmt->bind_param('si', $hash, $user_id);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$_SESSION['must_change_password'] = 0;
|
||||
echo json_encode(['status' => 'success', 'message' => 'Password berhasil diubah']);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal menyimpan password']);
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
// api/auth/login.php — POST: validasi kredensial + buat sesi
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$username = trim($_POST['username'] ?? '');
|
||||
$password = trim($_POST['password'] ?? '');
|
||||
|
||||
if (!$username || !$password) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Username dan password wajib diisi']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Ambil user dari DB (prepared statement — SQL injection safe)
|
||||
$stmt = $conn->prepare(
|
||||
"SELECT id, nama_lengkap, password, role, ibadah_id, is_active,
|
||||
must_change_password, login_attempts, locked_until
|
||||
FROM users WHERE username = ? LIMIT 1"
|
||||
);
|
||||
$stmt->bind_param('s', $username);
|
||||
$stmt->execute();
|
||||
$user = $stmt->get_result()->fetch_assoc();
|
||||
$stmt->close();
|
||||
|
||||
if (!$user) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Username atau password salah']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!$user['is_active']) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Akun dinonaktifkan. Hubungi Administrator.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Cek lockout
|
||||
if ($user['locked_until'] && strtotime($user['locked_until']) > time()) {
|
||||
$menit = ceil((strtotime($user['locked_until']) - time()) / 60);
|
||||
echo json_encode(['status' => 'error', 'message' => "Akun terkunci. Coba lagi dalam {$menit} menit."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Verifikasi password
|
||||
if (!password_verify($password, $user['password'])) {
|
||||
$attempts = $user['login_attempts'] + 1;
|
||||
|
||||
if ($attempts >= MAX_LOGIN_ATTEMPTS) {
|
||||
$locked_until = date('Y-m-d H:i:s', time() + LOCKOUT_MINUTES * 60);
|
||||
$stmt = $conn->prepare("UPDATE users SET login_attempts = ?, locked_until = ? WHERE id = ?");
|
||||
$stmt->bind_param('isi', $attempts, $locked_until, $user['id']);
|
||||
} else {
|
||||
$stmt = $conn->prepare("UPDATE users SET login_attempts = ? WHERE id = ?");
|
||||
$stmt->bind_param('ii', $attempts, $user['id']);
|
||||
}
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
$sisa = MAX_LOGIN_ATTEMPTS - $attempts;
|
||||
$msg = $sisa > 0
|
||||
? "Username atau password salah. Sisa percobaan: {$sisa}"
|
||||
: "Akun terkunci " . LOCKOUT_MINUTES . " menit karena terlalu banyak percobaan gagal.";
|
||||
echo json_encode(['status' => 'error', 'message' => $msg]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Login berhasil — reset attempts
|
||||
$stmt = $conn->prepare("UPDATE users SET login_attempts = 0, locked_until = NULL WHERE id = ?");
|
||||
$stmt->bind_param('i', $user['id']);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
// Rotate session ID sebelum menulis data (cegah session fixation)
|
||||
session_regenerate_id(true);
|
||||
|
||||
// Buat sesi
|
||||
$_SESSION['user_id'] = $user['id'];
|
||||
$_SESSION['username'] = $username;
|
||||
$_SESSION['nama'] = $user['nama_lengkap'];
|
||||
$_SESSION['role'] = $user['role'];
|
||||
$_SESSION['ibadah_id'] = $user['ibadah_id'];
|
||||
$_SESSION['must_change_password'] = (int)$user['must_change_password'];
|
||||
$_SESSION['last_activity'] = time();
|
||||
|
||||
// Redirect target: admin → dashboard, operator/viewer → map
|
||||
$redirect = ($user['role'] === 'administrator') ? '../dashboard.php' : '../index.php';
|
||||
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'redirect' => $redirect,
|
||||
'data' => [
|
||||
'user_id' => $user['id'],
|
||||
'nama' => $user['nama_lengkap'],
|
||||
'role' => $user['role'],
|
||||
'ibadah_id' => $user['ibadah_id'],
|
||||
'must_change_password'=> (bool)$user['must_change_password'],
|
||||
]
|
||||
]);
|
||||
|
||||
$conn->close();
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
// api/auth/logout.php — Hancurkan sesi dan redirect ke login
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
|
||||
session_destroy();
|
||||
header('Location: ../../auth/login.php');
|
||||
exit;
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
/**
|
||||
* api/dashboard/trend.php
|
||||
* Hitung tren persen cakupan 6 bulan terakhir.
|
||||
* Metode: untuk tiap bulan, query penduduk yang created_at <= akhir bulan itu
|
||||
* (kumulatif), lalu hitung % jiwa terjangkau vs total jiwa.
|
||||
*/
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('administrator');
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// Build last 6 months (oldest first)
|
||||
$months = [];
|
||||
for ($i = 5; $i >= 0; $i--) {
|
||||
$year = (int) date('Y');
|
||||
$month = (int) date('n') - $i;
|
||||
while ($month <= 0) { $month += 12; $year--; }
|
||||
while ($month > 12) { $month -= 12; $year++; }
|
||||
|
||||
$last_day = date('Y-m-d', mktime(0, 0, 0, $month + 1, 0, $year)); // last day of month
|
||||
|
||||
$month_names = ['Jan','Feb','Mar','Apr','Mei','Jun','Jul','Agt','Sep','Okt','Nov','Des'];
|
||||
$months[] = [
|
||||
'label' => $month_names[$month - 1] . ' ' . $year,
|
||||
'end_dt' => $last_day,
|
||||
];
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
$stmt = $conn->prepare(
|
||||
"SELECT
|
||||
COALESCE(SUM(jumlah_jiwa), 0) AS total_jiwa,
|
||||
COALESCE(SUM(CASE WHEN is_blank_spot = 0 THEN jumlah_jiwa ELSE 0 END), 0) AS jiwa_terjangkau
|
||||
FROM penduduk_miskin
|
||||
WHERE is_active = 1
|
||||
AND deleted_at IS NULL
|
||||
AND created_at <= ?"
|
||||
);
|
||||
|
||||
foreach ($months as $m) {
|
||||
$end = $m['end_dt'];
|
||||
$stmt->bind_param('s', $end);
|
||||
$stmt->execute();
|
||||
$r = $stmt->get_result()->fetch_assoc();
|
||||
|
||||
$total = (int) ($r['total_jiwa'] ?? 0);
|
||||
$terjangkau = (int) ($r['jiwa_terjangkau'] ?? 0);
|
||||
$pct = $total > 0 ? round($terjangkau / $total * 100, 2) : 0;
|
||||
|
||||
$rows[] = [
|
||||
'bulan' => $m['label'],
|
||||
'total_jiwa' => $total,
|
||||
'jiwa_terjangkau' => $terjangkau,
|
||||
'pct_cakupan' => $pct,
|
||||
];
|
||||
}
|
||||
$stmt->close();
|
||||
|
||||
echo json_encode(['status' => 'success', 'data' => $rows]);
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('administrator');
|
||||
|
||||
$ibadah_id = (int)($_GET['ibadah_id'] ?? 0);
|
||||
if (!$ibadah_id) {
|
||||
http_response_code(400);
|
||||
die('ibadah_id diperlukan');
|
||||
}
|
||||
|
||||
$r = $conn->prepare("SELECT nama FROM rumah_ibadah WHERE id=? AND deleted_at IS NULL");
|
||||
$r->bind_param('i', $ibadah_id);
|
||||
$r->execute();
|
||||
$row = $r->get_result()->fetch_assoc();
|
||||
$r->close();
|
||||
|
||||
if (!$row) {
|
||||
http_response_code(404);
|
||||
die('Rumah ibadah tidak ditemukan');
|
||||
}
|
||||
|
||||
$nama_ibadah = preg_replace('/[^a-zA-Z0-9\-_]/', '_', $row['nama']);
|
||||
$tanggal = date('Y-m-d');
|
||||
$filename = "binaan_{$nama_ibadah}_{$tanggal}.csv";
|
||||
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
header("Content-Disposition: attachment; filename=\"$filename\"");
|
||||
|
||||
$out = fopen('php://output', 'w');
|
||||
fwrite($out, "\xEF\xBB\xBF");
|
||||
|
||||
function csv_safe_cell($value): string {
|
||||
$text = (string)($value ?? '');
|
||||
$trimmed = ltrim($text);
|
||||
if ($trimmed !== '' && in_array($trimmed[0], ['=', '+', '-', '@'], true)) {
|
||||
return "'" . $text;
|
||||
}
|
||||
return $text;
|
||||
}
|
||||
|
||||
fputcsv($out, ['nama_kk', 'jumlah_jiwa', 'kategori', 'alamat', 'status_bantuan', 'catatan']);
|
||||
|
||||
$stmt = $conn->prepare("
|
||||
SELECT nama_kk, jumlah_jiwa, kategori, alamat, status_bantuan, catatan
|
||||
FROM penduduk_miskin
|
||||
WHERE ibadah_id=? AND is_active=1 AND deleted_at IS NULL
|
||||
ORDER BY nama_kk
|
||||
");
|
||||
$stmt->bind_param('i', $ibadah_id);
|
||||
$stmt->execute();
|
||||
$res = $stmt->get_result();
|
||||
while ($w = $res->fetch_assoc()) {
|
||||
fputcsv($out, [
|
||||
csv_safe_cell($w['nama_kk']),
|
||||
csv_safe_cell($w['jumlah_jiwa']),
|
||||
csv_safe_cell($w['kategori']),
|
||||
csv_safe_cell($w['alamat'] ?? ''),
|
||||
csv_safe_cell($w['status_bantuan']),
|
||||
csv_safe_cell($w['catatan'] ?? ''),
|
||||
]);
|
||||
}
|
||||
$stmt->close();
|
||||
fclose($out);
|
||||
$conn->close();
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('administrator');
|
||||
|
||||
$ibadah_id = (int)($_GET['ibadah_id'] ?? 0);
|
||||
if (!$ibadah_id) { http_response_code(400); die('ibadah_id diperlukan'); }
|
||||
|
||||
$r = $conn->prepare("SELECT * FROM rumah_ibadah WHERE id=? AND deleted_at IS NULL");
|
||||
$r->bind_param('i', $ibadah_id);
|
||||
$r->execute();
|
||||
$ib = $r->get_result()->fetch_assoc();
|
||||
$r->close();
|
||||
if (!$ib) { http_response_code(404); die('Tidak ditemukan'); }
|
||||
|
||||
$s = $conn->prepare("
|
||||
SELECT COUNT(*) AS jml_kk,
|
||||
COALESCE(SUM(jumlah_jiwa), 0) AS jml_jiwa,
|
||||
COALESCE(SUM(CASE WHEN status_bantuan='Sudah Ditangani' THEN 1 ELSE 0 END), 0) AS ditangani
|
||||
FROM penduduk_miskin
|
||||
WHERE ibadah_id=? AND is_active=1 AND deleted_at IS NULL
|
||||
");
|
||||
$s->bind_param('i', $ibadah_id);
|
||||
$s->execute();
|
||||
$st = $s->get_result()->fetch_assoc();
|
||||
$s->close();
|
||||
|
||||
$pct = $st['jml_kk'] > 0 ? round($st['ditangani'] / $st['jml_kk'] * 100) : 0;
|
||||
|
||||
$w = $conn->prepare("
|
||||
SELECT nama_kk, jumlah_jiwa, kategori, status_bantuan
|
||||
FROM penduduk_miskin
|
||||
WHERE ibadah_id=? AND is_active=1 AND deleted_at IS NULL
|
||||
ORDER BY nama_kk
|
||||
");
|
||||
$w->bind_param('i', $ibadah_id);
|
||||
$w->execute();
|
||||
$warga_res = $w->get_result();
|
||||
$warga_list = [];
|
||||
while ($row = $warga_res->fetch_assoc()) $warga_list[] = $row;
|
||||
$w->close();
|
||||
$conn->close();
|
||||
|
||||
$tanggal = date('d F Y');
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Laporan Binaan — <?= htmlspecialchars($ib['nama']) ?></title>
|
||||
<style>
|
||||
* { box-sizing:border-box; margin:0; padding:0; }
|
||||
body { font-family: Arial, sans-serif; font-size: 12px; color: #111; padding: 20px; }
|
||||
.header { border-bottom: 2px solid #333; margin-bottom: 16px; padding-bottom: 10px; }
|
||||
.header h1 { font-size: 16px; margin-bottom: 4px; }
|
||||
.header p { font-size: 11px; color: #555; margin-top: 2px; }
|
||||
.cards { display: flex; gap: 16px; margin-bottom: 16px; }
|
||||
.card { border: 1px solid #ddd; border-radius: 6px; padding: 10px 16px; text-align: center; flex: 1; }
|
||||
.card .val { font-size: 22px; font-weight: 700; color: #1a1a1a; }
|
||||
.card .lbl { font-size: 10px; color: #777; margin-top: 2px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { border: 1px solid #ddd; padding: 6px 10px; text-align: left; font-size: 11px; }
|
||||
th { background: #f0f0f0; font-weight: 700; }
|
||||
tr:nth-child(even) td { background: #fafafa; }
|
||||
.status-done { color: #166534; font-weight: 700; }
|
||||
.status-process { color: #92400e; font-weight: 700; }
|
||||
.status-none { color: #6b7280; }
|
||||
.footer { margin-top: 20px; font-size: 10px; color: #999; text-align: right; }
|
||||
@media print { body { padding: 0; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Laporan Warga Binaan — <?= htmlspecialchars($ib['nama']) ?></h1>
|
||||
<p><?= htmlspecialchars($ib['jenis']) ?> · <?= htmlspecialchars($ib['alamat'] ?? '-') ?></p>
|
||||
<p>Digenerate: <?= $tanggal ?></p>
|
||||
</div>
|
||||
<div class="cards">
|
||||
<div class="card"><div class="val"><?= $st['jml_kk'] ?></div><div class="lbl">Total KK</div></div>
|
||||
<div class="card"><div class="val"><?= $st['jml_jiwa']?></div><div class="lbl">Total Jiwa</div></div>
|
||||
<div class="card"><div class="val"><?= $pct ?>%</div> <div class="lbl">Sudah Ditangani</div></div>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>#</th><th>Nama KK</th><th>Jiwa</th><th>Kategori</th><th>Status Bantuan</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($warga_list as $i => $warga): ?>
|
||||
<?php $cls = match($warga['status_bantuan']) {
|
||||
'Sudah Ditangani' => 'status-done',
|
||||
'Dalam Proses' => 'status-process',
|
||||
default => 'status-none',
|
||||
}; ?>
|
||||
<tr>
|
||||
<td><?= $i + 1 ?></td>
|
||||
<td><?= htmlspecialchars($warga['nama_kk']) ?></td>
|
||||
<td><?= $warga['jumlah_jiwa'] ?></td>
|
||||
<td><?= $warga['kategori'] ?></td>
|
||||
<td class="<?= $cls ?>"><?= $warga['status_bantuan'] ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="footer"><?= APP_NAME ?> · <?= $tanggal ?></div>
|
||||
<script>window.onload = () => window.print();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
// Public viewer boleh akses — tidak perlu require_auth
|
||||
|
||||
// Hanya tampilkan warga yang sudah Terverifikasi di agregat publik
|
||||
$sql = "
|
||||
SELECT ri.id, ri.nama, ri.jenis, ri.alamat, ri.lat, ri.lng,
|
||||
ri.radius_m, ri.kontak, ri.created_at, ri.updated_at,
|
||||
COUNT(pm.id) AS total_kk,
|
||||
COALESCE(SUM(pm.jumlah_jiwa), 0) AS total_jiwa,
|
||||
COALESCE(SUM(CASE WHEN pm.is_blank_spot = 0 THEN pm.jumlah_jiwa ELSE 0 END), 0) AS jiwa_terjangkau,
|
||||
COALESCE(SUM(CASE WHEN pm.is_blank_spot = 1 THEN pm.jumlah_jiwa ELSE 0 END), 0) AS jiwa_blankspot
|
||||
FROM rumah_ibadah ri
|
||||
LEFT JOIN penduduk_miskin pm
|
||||
ON pm.ibadah_id = ri.id
|
||||
AND pm.is_active = 1
|
||||
AND pm.deleted_at IS NULL
|
||||
AND pm.status_verifikasi = 'Terverifikasi'
|
||||
WHERE ri.deleted_at IS NULL
|
||||
GROUP BY ri.id
|
||||
ORDER BY ri.created_at DESC
|
||||
";
|
||||
|
||||
$result = $conn->query($sql);
|
||||
$data = [];
|
||||
while ($row = $result->fetch_assoc()) $data[] = $row;
|
||||
|
||||
echo json_encode(['status' => 'success', 'total' => count($data), 'data' => $data]);
|
||||
$conn->close();
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('administrator');
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']); exit;
|
||||
}
|
||||
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
if (!$id) { echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']); exit; }
|
||||
|
||||
$active_stmt = $conn->prepare("SELECT id FROM rumah_ibadah WHERE id = ? AND deleted_at IS NULL LIMIT 1");
|
||||
$active_stmt->bind_param('i', $id);
|
||||
$active_stmt->execute();
|
||||
$active_row = $active_stmt->get_result()->fetch_assoc();
|
||||
$active_stmt->close();
|
||||
if (!$active_row) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data tidak ditemukan atau sudah dihapus']); exit;
|
||||
}
|
||||
|
||||
// Cek operator yang terkait
|
||||
$stmt = $conn->prepare(
|
||||
"SELECT nama_lengkap FROM users WHERE ibadah_id = ? AND is_active = 1 LIMIT 5"
|
||||
);
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$ops_result = $stmt->get_result();
|
||||
$operators = [];
|
||||
while ($op = $ops_result->fetch_assoc()) $operators[] = $op['nama_lengkap'];
|
||||
$stmt->close();
|
||||
|
||||
// Jika belum konfirmasi dan ada operator terkait, kembalikan warning
|
||||
$confirmed = ($_POST['confirmed'] ?? '0') === '1';
|
||||
if (!$confirmed && count($operators) > 0) {
|
||||
echo json_encode([
|
||||
'status' => 'warning',
|
||||
'operators' => $operators,
|
||||
'message' => 'Rumah ibadah ini dikaitkan dengan akun Operator: ' . implode(', ', $operators)
|
||||
. '. Operator tidak akan bisa login sampai dikaitkan ke rumah ibadah lain.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$conn->begin_transaction();
|
||||
try {
|
||||
// Soft delete
|
||||
$stmt = $conn->prepare("UPDATE rumah_ibadah SET deleted_at = NOW() WHERE id = ? AND deleted_at IS NULL");
|
||||
if (!$stmt) {
|
||||
throw new RuntimeException('prepare delete failed: ' . $conn->error);
|
||||
}
|
||||
$stmt->bind_param('i', $id);
|
||||
|
||||
if (!$stmt->execute()) {
|
||||
throw new RuntimeException('delete failed: ' . $stmt->error);
|
||||
}
|
||||
$deleted = $stmt->affected_rows > 0;
|
||||
$stmt->close();
|
||||
|
||||
if (!$deleted) {
|
||||
$conn->rollback();
|
||||
http_response_code(404);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data tidak ditemukan atau sudah dihapus']);
|
||||
$conn->close();
|
||||
exit;
|
||||
}
|
||||
|
||||
$recalc = $conn->query("SELECT id, lat, lng FROM penduduk_miskin WHERE is_active=1 AND deleted_at IS NULL");
|
||||
if (!$recalc) {
|
||||
throw new RuntimeException('read penduduk failed: ' . $conn->error);
|
||||
}
|
||||
while ($warga = $recalc->fetch_assoc()) {
|
||||
_recalc_proximity($conn, $warga['id'], $warga['lat'], $warga['lng']);
|
||||
}
|
||||
$conn->commit();
|
||||
echo json_encode(['status' => 'success', 'message' => 'Rumah ibadah berhasil dihapus']);
|
||||
} catch (Throwable $e) {
|
||||
$conn->rollback();
|
||||
error_log('ibadah/hapus transaction failed: ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal menghapus rumah ibadah.']);
|
||||
}
|
||||
|
||||
function _recalc_proximity($conn, $penduduk_id, $lat, $lng) {
|
||||
$p = calc_proximity($conn, (float)$lat, (float)$lng);
|
||||
$s = $conn->prepare(
|
||||
"UPDATE penduduk_miskin SET ibadah_id=?, jarak_m=?, is_blank_spot=? WHERE id=?"
|
||||
);
|
||||
if (!$s) {
|
||||
throw new RuntimeException('prepare recalc failed: ' . $conn->error);
|
||||
}
|
||||
$s->bind_param('idii', $p['ibadah_id'], $p['jarak_m'], $p['is_blank_spot'], $penduduk_id);
|
||||
if (!$s->execute()) {
|
||||
throw new RuntimeException('recalc update failed: ' . $s->error);
|
||||
}
|
||||
$s->close();
|
||||
}
|
||||
$conn->close();
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('administrator');
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']); exit;
|
||||
}
|
||||
|
||||
$conn->begin_transaction();
|
||||
try {
|
||||
$warga_list = $conn->query(
|
||||
"SELECT id, lat, lng FROM penduduk_miskin WHERE is_active=1 AND deleted_at IS NULL"
|
||||
);
|
||||
if (!$warga_list) {
|
||||
throw new RuntimeException('read penduduk failed: ' . $conn->error);
|
||||
}
|
||||
|
||||
$updated = 0;
|
||||
while ($warga = $warga_list->fetch_assoc()) {
|
||||
$p = calc_proximity($conn, (float)$warga['lat'], (float)$warga['lng']);
|
||||
$s = $conn->prepare(
|
||||
"UPDATE penduduk_miskin SET ibadah_id=?, jarak_m=?, is_blank_spot=? WHERE id=?"
|
||||
);
|
||||
if (!$s) {
|
||||
throw new RuntimeException('prepare recalc failed: ' . $conn->error);
|
||||
}
|
||||
$s->bind_param('idii', $p['ibadah_id'], $p['jarak_m'], $p['is_blank_spot'], $warga['id']);
|
||||
if (!$s->execute()) {
|
||||
throw new RuntimeException('recalc update failed: ' . $s->error);
|
||||
}
|
||||
$s->close();
|
||||
$updated++;
|
||||
}
|
||||
|
||||
$conn->commit();
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'updated' => $updated,
|
||||
'message' => "$updated data penduduk berhasil diperbarui",
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
$conn->rollback();
|
||||
error_log('ibadah/recalculate transaction failed: ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal menghitung ulang proximity.']);
|
||||
}
|
||||
$conn->close();
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../includes/validation.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('administrator');
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']); exit;
|
||||
}
|
||||
|
||||
$nama = trim($_POST['nama'] ?? '');
|
||||
$jenis = trim($_POST['jenis'] ?? 'Masjid');
|
||||
$alamat = trim($_POST['alamat'] ?? '');
|
||||
$kontak = trim($_POST['kontak'] ?? '');
|
||||
$coord = validate_lat_lng($_POST['lat'] ?? null, $_POST['lng'] ?? null);
|
||||
if (!$coord['ok']) {
|
||||
echo json_encode(['status' => 'error', 'message' => $coord['message']]); exit;
|
||||
}
|
||||
$lat = $coord['lat'];
|
||||
$lng = $coord['lng'];
|
||||
$radius = (int) ($_POST['radius_m'] ?? 500);
|
||||
|
||||
if (!$nama) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Nama ibadah tidak boleh kosong']); exit;
|
||||
}
|
||||
if ($radius < 100 || $radius > 5000) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Radius minimum 100 meter, maksimum 5.000 meter']); exit;
|
||||
}
|
||||
|
||||
$valid_jenis = ['Masjid','Mushola','Gereja','Pura','Vihara','Klenteng'];
|
||||
if (!in_array($jenis, $valid_jenis)) $jenis = 'Masjid';
|
||||
|
||||
$stmt = $conn->prepare(
|
||||
"INSERT INTO rumah_ibadah (nama, jenis, alamat, lat, lng, radius_m, kontak) VALUES (?,?,?,?,?,?,?)"
|
||||
);
|
||||
$stmt->bind_param('sssddis', $nama, $jenis, $alamat, $lat, $lng, $radius, $kontak);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
echo json_encode(['status' => 'success', 'message' => 'Data berhasil disimpan',
|
||||
'data' => ['id'=>$conn->insert_id,'nama'=>$nama,'jenis'=>$jenis,
|
||||
'alamat'=>$alamat,'kontak'=>$kontak,'lat'=>$lat,'lng'=>$lng,'radius_m'=>$radius]]);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal menyimpan: '.$stmt->error]);
|
||||
}
|
||||
$stmt->close(); $conn->close();
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../includes/validation.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('administrator');
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']); exit;
|
||||
}
|
||||
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$nama = trim( $_POST['nama'] ?? '');
|
||||
$jenis = trim( $_POST['jenis'] ?? 'Masjid');
|
||||
$alamat = trim( $_POST['alamat'] ?? '');
|
||||
$kontak = trim( $_POST['kontak'] ?? '');
|
||||
$coord = validate_lat_lng($_POST['lat'] ?? null, $_POST['lng'] ?? null);
|
||||
if (!$coord['ok']) {
|
||||
echo json_encode(['status' => 'error', 'message' => $coord['message']]);
|
||||
$conn->close(); exit;
|
||||
}
|
||||
$lat = $coord['lat'];
|
||||
$lng = $coord['lng'];
|
||||
$radius = (int) ($_POST['radius_m'] ?? 500);
|
||||
|
||||
$valid_jenis = ['Masjid', 'Mushola', 'Gereja', 'Pura', 'Vihara', 'Klenteng'];
|
||||
|
||||
if (!$id) { echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']); exit; }
|
||||
if (!$nama) { echo json_encode(['status' => 'error', 'message' => 'Nama wajib diisi']); exit; }
|
||||
if (!in_array($jenis, $valid_jenis)) { echo json_encode(['status' => 'error', 'message' => 'Jenis tidak valid']); exit; }
|
||||
if ($radius < 100 || $radius > 5000) { echo json_encode(['status' => 'error', 'message' => 'Radius minimum 100 meter, maksimum 5.000 meter']); exit; }
|
||||
|
||||
$active_stmt = $conn->prepare("SELECT id FROM rumah_ibadah WHERE id = ? AND deleted_at IS NULL LIMIT 1");
|
||||
$active_stmt->bind_param('i', $id);
|
||||
$active_stmt->execute();
|
||||
$active_row = $active_stmt->get_result()->fetch_assoc();
|
||||
$active_stmt->close();
|
||||
if (!$active_row) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data tidak ditemukan atau sudah dihapus']);
|
||||
$conn->close(); exit;
|
||||
}
|
||||
|
||||
$conn->begin_transaction();
|
||||
try {
|
||||
$stmt = $conn->prepare(
|
||||
"UPDATE rumah_ibadah SET nama=?, jenis=?, alamat=?, kontak=?, lat=?, lng=?, radius_m=? WHERE id=? AND deleted_at IS NULL"
|
||||
);
|
||||
if (!$stmt) {
|
||||
throw new RuntimeException('prepare update failed: ' . $conn->error);
|
||||
}
|
||||
$stmt->bind_param('ssssddii', $nama, $jenis, $alamat, $kontak, $lat, $lng, $radius, $id);
|
||||
if (!$stmt->execute()) {
|
||||
throw new RuntimeException('update failed: ' . $stmt->error);
|
||||
}
|
||||
$changed = $stmt->affected_rows > 0;
|
||||
$stmt->close();
|
||||
|
||||
// Recalculate proximity for all active penduduk (radius may have changed)
|
||||
if ($changed) {
|
||||
$warga = $conn->query("SELECT id, lat, lng FROM penduduk_miskin WHERE is_active=1 AND deleted_at IS NULL");
|
||||
if (!$warga) {
|
||||
throw new RuntimeException('read penduduk failed: ' . $conn->error);
|
||||
}
|
||||
while ($w = $warga->fetch_assoc()) {
|
||||
if ($w['lat'] === null || $w['lng'] === null) continue;
|
||||
$p = calc_proximity($conn, (float)$w['lat'], (float)$w['lng']);
|
||||
$s = $conn->prepare("UPDATE penduduk_miskin SET ibadah_id=?, jarak_m=?, is_blank_spot=? WHERE id=?");
|
||||
if (!$s) {
|
||||
throw new RuntimeException('prepare recalc failed: ' . $conn->error);
|
||||
}
|
||||
$s->bind_param('idii', $p['ibadah_id'], $p['jarak_m'], $p['is_blank_spot'], $w['id']);
|
||||
if (!$s->execute()) {
|
||||
throw new RuntimeException('recalc update failed: ' . $s->error);
|
||||
}
|
||||
$s->close();
|
||||
}
|
||||
}
|
||||
|
||||
$conn->commit();
|
||||
echo json_encode(['status' => 'success', 'message' => 'Rumah ibadah berhasil diperbarui']);
|
||||
} catch (Throwable $e) {
|
||||
$conn->rollback();
|
||||
error_log('ibadah/update transaction failed: ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal memproses perubahan rumah ibadah.']);
|
||||
}
|
||||
$conn->close();
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
// api/ibadah/update_kontak.php — Update kontak rumah ibadah
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('administrator');
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$kontak = trim($_POST['kontak'] ?? '');
|
||||
|
||||
if (!$id) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$active_stmt = $conn->prepare("SELECT id FROM rumah_ibadah WHERE id = ? AND deleted_at IS NULL LIMIT 1");
|
||||
$active_stmt->bind_param('i', $id);
|
||||
$active_stmt->execute();
|
||||
$active_row = $active_stmt->get_result()->fetch_assoc();
|
||||
$active_stmt->close();
|
||||
if (!$active_row) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data tidak ditemukan atau sudah dihapus']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("UPDATE rumah_ibadah SET kontak = ? WHERE id = ? AND deleted_at IS NULL");
|
||||
$stmt->bind_param('si', $kontak, $id);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
echo json_encode(['status' => 'success', 'message' => 'Kontak berhasil diperbarui']);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal mengupdate kontak: ' . $stmt->error]);
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../includes/validation.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('administrator');
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$coord = validate_lat_lng($_POST['lat'] ?? null, $_POST['lng'] ?? null);
|
||||
if (!$coord['ok']) {
|
||||
echo json_encode(['status' => 'error', 'message' => $coord['message']]);
|
||||
exit;
|
||||
}
|
||||
$lat = $coord['lat'];
|
||||
$lng = $coord['lng'];
|
||||
|
||||
if (!$id) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$active_stmt = $conn->prepare("SELECT id FROM rumah_ibadah WHERE id = ? AND deleted_at IS NULL LIMIT 1");
|
||||
$active_stmt->bind_param('i', $id);
|
||||
$active_stmt->execute();
|
||||
$active_row = $active_stmt->get_result()->fetch_assoc();
|
||||
$active_stmt->close();
|
||||
if (!$active_row) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data tidak ditemukan atau sudah dihapus']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$conn->begin_transaction();
|
||||
try {
|
||||
$stmt = $conn->prepare("UPDATE rumah_ibadah SET lat = ?, lng = ? WHERE id = ? AND deleted_at IS NULL");
|
||||
if (!$stmt) {
|
||||
throw new RuntimeException('prepare update failed: ' . $conn->error);
|
||||
}
|
||||
$stmt->bind_param('ddi', $lat, $lng, $id);
|
||||
|
||||
if (!$stmt->execute()) {
|
||||
throw new RuntimeException('update failed: ' . $stmt->error);
|
||||
}
|
||||
$changed = $stmt->affected_rows > 0;
|
||||
$stmt->close();
|
||||
|
||||
// Recalculate proximity for all active penduduk
|
||||
if ($changed) {
|
||||
$warga_list = $conn->query(
|
||||
"SELECT id, lat, lng FROM penduduk_miskin WHERE is_active=1 AND deleted_at IS NULL"
|
||||
);
|
||||
if (!$warga_list) {
|
||||
throw new RuntimeException('read penduduk failed: ' . $conn->error);
|
||||
}
|
||||
while ($warga = $warga_list->fetch_assoc()) {
|
||||
$p = calc_proximity($conn, (float)$warga['lat'], (float)$warga['lng']);
|
||||
$s = $conn->prepare("UPDATE penduduk_miskin SET ibadah_id=?, jarak_m=?, is_blank_spot=? WHERE id=?");
|
||||
if (!$s) {
|
||||
throw new RuntimeException('prepare recalc failed: ' . $conn->error);
|
||||
}
|
||||
$s->bind_param('idii', $p['ibadah_id'], $p['jarak_m'], $p['is_blank_spot'], $warga['id']);
|
||||
if (!$s->execute()) {
|
||||
throw new RuntimeException('recalc update failed: ' . $s->error);
|
||||
}
|
||||
$s->close();
|
||||
}
|
||||
}
|
||||
|
||||
$conn->commit();
|
||||
echo json_encode(['status' => 'success', 'message' => 'Posisi berhasil diperbarui']);
|
||||
} catch (Throwable $e) {
|
||||
$conn->rollback();
|
||||
error_log('ibadah/update_posisi transaction failed: ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal memproses perubahan posisi.']);
|
||||
}
|
||||
$conn->close();
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('administrator');
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']); exit;
|
||||
}
|
||||
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$radius = (int) ($_POST['radius_m'] ?? 500);
|
||||
|
||||
if (!$id) { echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']); exit; }
|
||||
|
||||
if ($radius < 100 || $radius > 5000) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Radius minimum 100 meter, maksimum 5.000 meter']); exit;
|
||||
}
|
||||
|
||||
$active_stmt = $conn->prepare("SELECT id FROM rumah_ibadah WHERE id = ? AND deleted_at IS NULL LIMIT 1");
|
||||
$active_stmt->bind_param('i', $id);
|
||||
$active_stmt->execute();
|
||||
$active_row = $active_stmt->get_result()->fetch_assoc();
|
||||
$active_stmt->close();
|
||||
if (!$active_row) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data tidak ditemukan atau sudah dihapus']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$conn->begin_transaction();
|
||||
try {
|
||||
$stmt = $conn->prepare("UPDATE rumah_ibadah SET radius_m = ? WHERE id = ? AND deleted_at IS NULL");
|
||||
if (!$stmt) {
|
||||
throw new RuntimeException('prepare update failed: ' . $conn->error);
|
||||
}
|
||||
$stmt->bind_param('ii', $radius, $id);
|
||||
|
||||
if (!$stmt->execute()) {
|
||||
throw new RuntimeException('update failed: ' . $stmt->error);
|
||||
}
|
||||
$changed = $stmt->affected_rows > 0;
|
||||
$stmt->close();
|
||||
|
||||
// Recalculate proximity for all active penduduk
|
||||
if ($changed) {
|
||||
$warga_list = $conn->query(
|
||||
"SELECT id, lat, lng FROM penduduk_miskin WHERE is_active=1 AND deleted_at IS NULL"
|
||||
);
|
||||
if (!$warga_list) {
|
||||
throw new RuntimeException('read penduduk failed: ' . $conn->error);
|
||||
}
|
||||
while ($warga = $warga_list->fetch_assoc()) {
|
||||
$p = calc_proximity($conn, (float)$warga['lat'], (float)$warga['lng']);
|
||||
$s = $conn->prepare("UPDATE penduduk_miskin SET ibadah_id=?, jarak_m=?, is_blank_spot=? WHERE id=?");
|
||||
if (!$s) {
|
||||
throw new RuntimeException('prepare recalc failed: ' . $conn->error);
|
||||
}
|
||||
$s->bind_param('idii', $p['ibadah_id'], $p['jarak_m'], $p['is_blank_spot'], $warga['id']);
|
||||
if (!$s->execute()) {
|
||||
throw new RuntimeException('recalc update failed: ' . $s->error);
|
||||
}
|
||||
$s->close();
|
||||
}
|
||||
}
|
||||
|
||||
$conn->commit();
|
||||
echo json_encode(['status' => 'success', 'message' => 'Radius berhasil diperbarui', 'radius_m' => $radius]);
|
||||
} catch (Throwable $e) {
|
||||
$conn->rollback();
|
||||
error_log('ibadah/update_radius transaction failed: ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal memproses perubahan radius.']);
|
||||
}
|
||||
$conn->close();
|
||||
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../includes/validation.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('administrator');
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$mode = trim($_POST['mode'] ?? 'preview'); // 'preview' | 'import'
|
||||
$max_rows = 500;
|
||||
|
||||
$file = $_FILES['csv_file'] ?? null;
|
||||
if (!$file || empty($file['tmp_name'])) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'File CSV tidak ditemukan']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Upload CSV gagal']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$max_bytes = 2 * 1024 * 1024;
|
||||
if (($file['size'] ?? 0) <= 0 || $file['size'] > $max_bytes) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Ukuran CSV maksimal 2 MB']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$ext = strtolower(pathinfo($file['name'] ?? '', PATHINFO_EXTENSION));
|
||||
if ($ext !== 'csv') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'File harus berformat .csv']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$handle = fopen($file['tmp_name'], 'r');
|
||||
if (!$handle) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal membuka file']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Deteksi dan lewati BOM UTF-8
|
||||
$bom = fread($handle, 3);
|
||||
if ($bom !== "\xEF\xBB\xBF") rewind($handle);
|
||||
|
||||
$header = fgetcsv($handle);
|
||||
if (!$header) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'File kosong atau format salah']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$header = array_map(fn($h) => strtolower(trim($h)), $header);
|
||||
$required_cols = ['nama_kk', 'jumlah_jiwa', 'kategori', 'lat', 'lng'];
|
||||
foreach ($required_cols as $col) {
|
||||
if (!in_array($col, $header)) {
|
||||
echo json_encode([
|
||||
'status' => 'error',
|
||||
'message' => "Kolom '$col' tidak ditemukan. Gunakan template yang disediakan.",
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$col = array_flip($header);
|
||||
|
||||
$rows_ok = [];
|
||||
$rows_error = [];
|
||||
$row_num = 1;
|
||||
|
||||
while (($raw = fgetcsv($handle)) !== false) {
|
||||
$row_num++;
|
||||
if ($row_num > $max_rows + 1) {
|
||||
$rows_error[] = ['row' => $row_num, 'error' => 'Batas 500 baris terlampaui'];
|
||||
continue;
|
||||
}
|
||||
if (count($raw) < count($header)) {
|
||||
$rows_error[] = ['row' => $row_num, 'error' => 'Jumlah kolom tidak sesuai'];
|
||||
continue;
|
||||
}
|
||||
|
||||
$get = fn($name) => isset($col[$name]) ? trim($raw[$col[$name]] ?? '') : '';
|
||||
|
||||
$nama_kk = $get('nama_kk');
|
||||
$nik = $get('nik');
|
||||
$jumlah_jiwa = max(1, (int)$get('jumlah_jiwa'));
|
||||
$kategori = $get('kategori');
|
||||
$alamat = $get('alamat');
|
||||
$catatan = $get('catatan');
|
||||
|
||||
$lat_raw = $get('lat');
|
||||
$lng_raw = $get('lng');
|
||||
|
||||
$errors = [];
|
||||
if (!$nama_kk) $errors[] = 'nama_kk kosong';
|
||||
|
||||
$coord = validate_lat_lng($lat_raw, $lng_raw);
|
||||
if (!$coord['ok']) {
|
||||
$errors[] = $coord['message'];
|
||||
} else {
|
||||
$lat = $coord['lat'];
|
||||
$lng = $coord['lng'];
|
||||
}
|
||||
|
||||
$valid_kat = ['Sangat Miskin', 'Miskin', 'Hampir Miskin'];
|
||||
if (!in_array($kategori, $valid_kat)) {
|
||||
if ($kategori === '') $kategori = 'Miskin';
|
||||
else $errors[] = "kategori '$kategori' tidak valid";
|
||||
}
|
||||
|
||||
if ($nik !== '' && !preg_match('/^\d{16}$/', $nik)) {
|
||||
$errors[] = 'NIK harus 16 digit jika diisi';
|
||||
}
|
||||
|
||||
if ($errors) {
|
||||
$rows_error[] = ['row' => $row_num, 'nama_kk' => $nama_kk, 'error' => implode('; ', $errors)];
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows_ok[] = compact('nama_kk', 'nik', 'jumlah_jiwa', 'kategori', 'alamat', 'catatan', 'lat', 'lng');
|
||||
}
|
||||
fclose($handle);
|
||||
|
||||
if ($mode === 'preview') {
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'total_ok' => count($rows_ok),
|
||||
'total_error' => count($rows_error),
|
||||
'preview' => array_slice($rows_ok, 0, 10),
|
||||
'errors' => $rows_error,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Mode import: INSERT semua baris valid
|
||||
$imported = 0;
|
||||
$skipped = 0;
|
||||
$status_verif = 'Terverifikasi';
|
||||
$verified_by = get_user_id();
|
||||
$verified_at = date('Y-m-d H:i:s');
|
||||
|
||||
foreach ($rows_ok as $r) {
|
||||
// Cek duplikat NIK pada semua data, termasuk arsip/inactive.
|
||||
if ($r['nik'] !== '') {
|
||||
$chk = $conn->prepare(
|
||||
"SELECT id FROM penduduk_miskin WHERE nik=? LIMIT 1"
|
||||
);
|
||||
$chk->bind_param('s', $r['nik']);
|
||||
$chk->execute();
|
||||
if ($chk->get_result()->num_rows > 0) {
|
||||
$skipped++;
|
||||
$chk->close();
|
||||
continue;
|
||||
}
|
||||
$chk->close();
|
||||
}
|
||||
|
||||
// PRD-correct proximity (sama dengan simpan.php)
|
||||
$p = calc_proximity($conn, $r['lat'], $r['lng']);
|
||||
|
||||
$nik_val = $r['nik'] !== '' ? $r['nik'] : null;
|
||||
$stmt = $conn->prepare(
|
||||
"INSERT INTO penduduk_miskin
|
||||
(nama_kk, nik, jumlah_jiwa, kategori, alamat, catatan, lat, lng, ibadah_id, jarak_m, is_blank_spot,
|
||||
status_verifikasi, verified_by, verified_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
$stmt->bind_param(
|
||||
'ssisssddidisis',
|
||||
$r['nama_kk'], $nik_val, $r['jumlah_jiwa'], $r['kategori'],
|
||||
$r['alamat'], $r['catatan'], $r['lat'], $r['lng'],
|
||||
$p['ibadah_id'], $p['jarak_m'], $p['is_blank_spot'],
|
||||
$status_verif, $verified_by, $verified_at
|
||||
);
|
||||
if ($stmt->execute()) $imported++;
|
||||
$stmt->close();
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'imported' => $imported,
|
||||
'skipped' => $skipped,
|
||||
'message' => "$imported baris berhasil diimport sebagai Terverifikasi, $skipped dilewati (NIK pernah terdaftar/duplikat)",
|
||||
]);
|
||||
$conn->close();
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_auth('administrator');
|
||||
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="template_penduduk_miskin.csv"');
|
||||
|
||||
$out = fopen('php://output', 'w');
|
||||
fwrite($out, "\xEF\xBB\xBF"); // BOM UTF-8 agar Excel membaca dengan benar
|
||||
|
||||
fputcsv($out, ['nama_kk', 'nik', 'jumlah_jiwa', 'kategori', 'alamat', 'catatan', 'lat', 'lng']);
|
||||
fputcsv($out, ['Budi Santoso', '3271011234567890', '4', 'Miskin', 'Jl. Merdeka No. 1', 'Rumah semi permanen', '-0.0557', '109.3487']);
|
||||
fputcsv($out, ['Siti Rahayu', '', '2', 'Sangat Miskin', 'Jl. Damai No. 5', '', '-0.0560', '109.3490']);
|
||||
fclose($out);
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
require_auth('operator');
|
||||
|
||||
$penduduk_id = (int)($_GET['penduduk_id'] ?? 0);
|
||||
if ($penduduk_id <= 0) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Parameter tidak valid.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Operator hanya boleh lihat kebutuhan warga dalam ibadah-nya
|
||||
if (!has_role('administrator')) {
|
||||
$op_ibadah = get_ibadah_id();
|
||||
$stmt = $conn->prepare(
|
||||
"SELECT ibadah_id FROM penduduk_miskin
|
||||
WHERE id=? AND is_active=1 AND deleted_at IS NULL"
|
||||
);
|
||||
$stmt->bind_param('i', $penduduk_id);
|
||||
$stmt->execute();
|
||||
$r = $stmt->get_result()->fetch_assoc();
|
||||
$stmt->close();
|
||||
if (!$r || (int)$r['ibadah_id'] !== (int)$op_ibadah) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Akses ditolak.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("
|
||||
SELECT k.id, k.kategori, k.deskripsi, k.status, k.created_at,
|
||||
u.nama_lengkap AS created_by_nama
|
||||
FROM kebutuhan k
|
||||
LEFT JOIN users u ON u.id = k.created_by
|
||||
WHERE k.penduduk_id = ?
|
||||
ORDER BY k.created_at DESC
|
||||
");
|
||||
$stmt->bind_param('i', $penduduk_id);
|
||||
$stmt->execute();
|
||||
$res = $stmt->get_result();
|
||||
$rows = [];
|
||||
while ($r = $res->fetch_assoc()) $rows[] = $r;
|
||||
$stmt->close();
|
||||
|
||||
echo json_encode(['status' => 'success', 'data' => $rows]);
|
||||
$conn->close();
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
require_auth('operator');
|
||||
require_csrf();
|
||||
|
||||
$penduduk_id = (int)($_POST['penduduk_id'] ?? 0);
|
||||
$kategori = trim($_POST['kategori'] ?? '');
|
||||
$deskripsi = trim($_POST['deskripsi'] ?? '');
|
||||
|
||||
$valid_kategori = ['Sembako','Biaya Sekolah','Biaya Kesehatan','Modal Usaha',
|
||||
'Renovasi Rumah','Perlengkapan Rumah','Pakaian','Lainnya'];
|
||||
|
||||
if ($penduduk_id <= 0 || !in_array($kategori, $valid_kategori, true)) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Parameter tidak valid.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Verifikasi akses warga
|
||||
$stmt = $conn->prepare(
|
||||
"SELECT ibadah_id, status_verifikasi FROM penduduk_miskin
|
||||
WHERE id=? AND is_active=1 AND deleted_at IS NULL"
|
||||
);
|
||||
$stmt->bind_param('i', $penduduk_id);
|
||||
$stmt->execute();
|
||||
$warga = $stmt->get_result()->fetch_assoc();
|
||||
$stmt->close();
|
||||
|
||||
if (!$warga) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data warga tidak ditemukan.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!has_role('administrator')) {
|
||||
$op_ibadah = get_ibadah_id();
|
||||
if ($warga['ibadah_id'] === null || (int)$warga['ibadah_id'] !== (int)$op_ibadah) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Anda tidak memiliki akses untuk warga ini.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
if ($warga['status_verifikasi'] !== 'Terverifikasi') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data warga belum terverifikasi']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$user_id = (int)$_SESSION['user_id'];
|
||||
$deskripsi = $deskripsi !== '' ? mb_substr($deskripsi, 0, 300) : null;
|
||||
|
||||
$stmt = $conn->prepare(
|
||||
"INSERT INTO kebutuhan (penduduk_id, kategori, deskripsi, created_by) VALUES (?,?,?,?)"
|
||||
);
|
||||
$stmt->bind_param('issi', $penduduk_id, $kategori, $deskripsi, $user_id);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
echo json_encode(['status' => 'success', 'id' => $conn->insert_id]);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal menyimpan: ' . $stmt->error]);
|
||||
}
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
require_auth('operator');
|
||||
require_csrf();
|
||||
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$status = trim($_POST['status'] ?? '');
|
||||
$catatan = trim($_POST['catatan'] ?? '');
|
||||
|
||||
$valid_status = ['Belum Terpenuhi', 'Dalam Proses', 'Terpenuhi'];
|
||||
if ($id <= 0 || !in_array($status, $valid_status, true)) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Parameter tidak valid.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Ambil kebutuhan + ibadah warga untuk cek akses
|
||||
$stmt = $conn->prepare("
|
||||
SELECT k.id, k.status AS status_lama, pm.ibadah_id, pm.status_verifikasi
|
||||
FROM kebutuhan k
|
||||
JOIN penduduk_miskin pm ON pm.id = k.penduduk_id
|
||||
AND pm.is_active = 1
|
||||
AND pm.deleted_at IS NULL
|
||||
WHERE k.id = ?
|
||||
");
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$row = $stmt->get_result()->fetch_assoc();
|
||||
$stmt->close();
|
||||
|
||||
if (!$row) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data tidak ditemukan.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!has_role('administrator')) {
|
||||
$op_ibadah = get_ibadah_id();
|
||||
if ($row['ibadah_id'] === null || (int)$row['ibadah_id'] !== (int)$op_ibadah) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Akses ditolak.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
if ($row['status_verifikasi'] !== 'Terverifikasi') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data warga belum terverifikasi']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$user_id = (int)$_SESSION['user_id'];
|
||||
$old_status = $row['status_lama'];
|
||||
|
||||
$stmt = $conn->prepare(
|
||||
"UPDATE kebutuhan SET status=?, updated_by=?, updated_at=NOW() WHERE id=?"
|
||||
);
|
||||
$stmt->bind_param('sii', $status, $user_id, $id);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
$stmt = $conn->prepare(
|
||||
"INSERT INTO riwayat_kebutuhan (kebutuhan_id, operator_id, status_lama, status_baru, catatan)
|
||||
VALUES (?,?,?,?,?)"
|
||||
);
|
||||
$catatan_val = $catatan !== '' ? $catatan : null;
|
||||
$stmt->bind_param('iisss', $id, $user_id, $old_status, $status, $catatan_val);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
echo json_encode(['status' => 'success', 'status_baru' => $status]);
|
||||
$conn->close();
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
if (!is_logged_in()) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$is_admin = has_role('administrator');
|
||||
$is_op = has_role('operator');
|
||||
$items = [];
|
||||
|
||||
// ── Admin only ───────────────────────────────────────────────────────
|
||||
if ($is_admin) {
|
||||
$r = $conn->query("
|
||||
SELECT COUNT(*) AS n FROM penduduk_miskin
|
||||
WHERE is_active=1 AND deleted_at IS NULL AND status_verifikasi='Pending'
|
||||
");
|
||||
if ($r && ($n = (int)$r->fetch_assoc()['n'])) {
|
||||
$items[] = [
|
||||
'type' => 'pending_verif',
|
||||
'label' => "$n penduduk menunggu verifikasi",
|
||||
'count' => $n,
|
||||
'page' => 'pages/penduduk.php',
|
||||
'icon' => 'clock',
|
||||
];
|
||||
}
|
||||
|
||||
$de = $conn->query(
|
||||
"SELECT 1 FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='kontak_donatur' LIMIT 1"
|
||||
)->num_rows > 0;
|
||||
if ($de) {
|
||||
$r = $conn->query("SELECT COUNT(*) AS n FROM kontak_donatur WHERE is_read=0");
|
||||
if ($r && ($n = (int)$r->fetch_assoc()['n'])) {
|
||||
$items[] = [
|
||||
'type' => 'donatur',
|
||||
'label' => "$n pesan donatur belum dibaca",
|
||||
'count' => $n,
|
||||
'page' => 'pages/kebutuhan.php',
|
||||
'icon' => 'mail',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin + Operator ────────────────────────────────────────────────
|
||||
if ($is_admin || $is_op) {
|
||||
$scope = '';
|
||||
if ($is_op && !$is_admin) {
|
||||
$iid = (int)get_ibadah_id();
|
||||
$scope = $iid > 0 ? " AND pm.ibadah_id = $iid" : " AND 1=0";
|
||||
}
|
||||
|
||||
$ke = $conn->query(
|
||||
"SELECT 1 FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='kebutuhan' LIMIT 1"
|
||||
)->num_rows > 0;
|
||||
if ($ke) {
|
||||
$r = $conn->query("
|
||||
SELECT COUNT(*) AS n FROM kebutuhan k
|
||||
JOIN penduduk_miskin pm
|
||||
ON pm.id = k.penduduk_id AND pm.is_active=1 AND pm.deleted_at IS NULL
|
||||
WHERE k.status = 'Belum Terpenuhi' $scope
|
||||
");
|
||||
if ($r && ($n = (int)$r->fetch_assoc()['n'])) {
|
||||
$items[] = [
|
||||
'type' => 'kebutuhan',
|
||||
'label' => "$n kebutuhan belum terpenuhi",
|
||||
'count' => $n,
|
||||
'page' => 'pages/kebutuhan.php',
|
||||
'icon' => 'heart',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$r = $conn->query("
|
||||
SELECT COUNT(*) AS n FROM penduduk_miskin pm
|
||||
WHERE pm.is_active=1 AND pm.deleted_at IS NULL
|
||||
AND pm.status_verifikasi = 'Terverifikasi'
|
||||
AND pm.status_bantuan = 'Belum Ditangani'
|
||||
$scope
|
||||
");
|
||||
if ($r && ($n = (int)$r->fetch_assoc()['n'])) {
|
||||
$items[] = [
|
||||
'type' => 'belum_ditangani',
|
||||
'label' => "$n data belum ditangani",
|
||||
'count' => $n,
|
||||
'page' => 'pages/status-bantuan.php',
|
||||
'icon' => 'clipboard-list',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'items' => $items,
|
||||
'total' => array_sum(array_column($items, 'count')),
|
||||
]);
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
// Publik — tidak perlu auth
|
||||
// Agregasi kebutuhan Belum Terpenuhi per kategori × rumah ibadah (area)
|
||||
|
||||
$rows = $conn->query("
|
||||
SELECT
|
||||
k.kategori,
|
||||
COALESCE(ri.nama, 'Di luar radius ibadah') AS area,
|
||||
COUNT(DISTINCT k.penduduk_id) AS jumlah_kk
|
||||
FROM kebutuhan k
|
||||
JOIN penduduk_miskin pm ON pm.id = k.penduduk_id
|
||||
AND pm.is_active = 1
|
||||
AND pm.deleted_at IS NULL
|
||||
AND pm.status_verifikasi = 'Terverifikasi'
|
||||
LEFT JOIN rumah_ibadah ri ON ri.id = pm.ibadah_id AND ri.deleted_at IS NULL
|
||||
WHERE k.status = 'Belum Terpenuhi'
|
||||
GROUP BY k.kategori, pm.ibadah_id, ri.nama
|
||||
ORDER BY jumlah_kk DESC, k.kategori ASC
|
||||
");
|
||||
|
||||
$data = [];
|
||||
while ($r = $rows->fetch_assoc()) {
|
||||
$data[] = [
|
||||
'kategori' => $r['kategori'],
|
||||
'area' => $r['area'],
|
||||
'jumlah_kk' => (int)$r['jumlah_kk'],
|
||||
];
|
||||
}
|
||||
|
||||
echo json_encode(['status' => 'success', 'data' => $data]);
|
||||
$conn->close();
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
require_auth('administrator');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
require_csrf();
|
||||
|
||||
$action = trim($_POST['action'] ?? 'mark_all');
|
||||
$id = isset($_POST['id']) ? (int)$_POST['id'] : 0;
|
||||
|
||||
if ($action === 'hapus') {
|
||||
if ($id <= 0) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid.']);
|
||||
$conn->close(); exit;
|
||||
}
|
||||
$stmt = $conn->prepare("DELETE FROM kontak_donatur WHERE id=?");
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
echo json_encode(['status' => 'success']);
|
||||
$conn->close(); exit;
|
||||
}
|
||||
|
||||
if ($action === 'mark_one') {
|
||||
if ($id <= 0) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid.']);
|
||||
$conn->close(); exit;
|
||||
}
|
||||
$stmt = $conn->prepare("UPDATE kontak_donatur SET is_read=1 WHERE id=?");
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
echo json_encode(['status' => 'success']);
|
||||
$conn->close(); exit;
|
||||
}
|
||||
|
||||
// mark_all — default, also handles legacy calls without action param
|
||||
$conn->query("UPDATE kontak_donatur SET is_read=1 WHERE is_read=0");
|
||||
echo json_encode(['status' => 'success', 'marked_read' => $conn->affected_rows]);
|
||||
$conn->close();
|
||||
exit;
|
||||
}
|
||||
|
||||
$rows = $conn->query("
|
||||
SELECT id, nama, kontak, kategori_minat, pesan, is_read, created_at
|
||||
FROM kontak_donatur
|
||||
ORDER BY is_read ASC, created_at DESC
|
||||
LIMIT 100
|
||||
");
|
||||
|
||||
$data = [];
|
||||
while ($r = $rows->fetch_assoc()) {
|
||||
$data[] = $r;
|
||||
}
|
||||
|
||||
echo json_encode(['status' => 'success', 'data' => $data]);
|
||||
$conn->close();
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$rate_window = 60;
|
||||
$client_ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||
$rate_dir = dirname(__DIR__, 2) . '/tmp/donor-rate-limit';
|
||||
if (!is_dir($rate_dir)) {
|
||||
@mkdir($rate_dir, 0775, true);
|
||||
}
|
||||
$rate_file = $rate_dir . '/' . hash('sha256', $client_ip) . '.txt';
|
||||
$last_submit = max(
|
||||
(int)($_SESSION['donor_last_submit'] ?? 0),
|
||||
is_file($rate_file) ? (int)filemtime($rate_file) : 0
|
||||
);
|
||||
if ($last_submit > 0 && ($now - $last_submit) < $rate_window) {
|
||||
http_response_code(429);
|
||||
echo json_encode([
|
||||
'status' => 'error',
|
||||
'message' => 'Terlalu sering mengirim. Coba lagi dalam 1 menit.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$nama = trim($_POST['nama'] ?? '');
|
||||
$kontak = trim($_POST['kontak'] ?? '');
|
||||
$kategori_minat = trim($_POST['kategori_minat'] ?? '');
|
||||
$pesan = trim($_POST['pesan'] ?? '');
|
||||
|
||||
if (!$nama || !$kontak) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Nama dan kontak wajib diisi.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$valid_kategori = ['Sembako','Biaya Sekolah','Biaya Kesehatan','Modal Usaha',
|
||||
'Renovasi Rumah','Perlengkapan Rumah','Pakaian','Lainnya'];
|
||||
$kat = in_array($kategori_minat, $valid_kategori, true) ? $kategori_minat : null;
|
||||
$pesan_val = $pesan !== '' ? mb_substr($pesan, 0, 1000) : null;
|
||||
$nama_safe = mb_substr($nama, 0, 150);
|
||||
$kontak_safe = mb_substr($kontak, 0, 150);
|
||||
|
||||
$stmt = $conn->prepare(
|
||||
"INSERT INTO kontak_donatur (nama, kontak, kategori_minat, pesan) VALUES (?,?,?,?)"
|
||||
);
|
||||
$stmt->bind_param('ssss', $nama_safe, $kontak_safe, $kat, $pesan_val);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$_SESSION['donor_last_submit'] = time();
|
||||
@file_put_contents($rate_file, (string)$_SESSION['donor_last_submit'], LOCK_EX);
|
||||
echo json_encode(['status' => 'success']);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal menyimpan.']);
|
||||
}
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
$is_authenticated = is_logged_in();
|
||||
$is_privileged = $is_authenticated && has_role('operator');
|
||||
$role = $is_authenticated ? get_role() : 'viewer';
|
||||
$is_admin = $is_authenticated && has_role('administrator');
|
||||
$can_see_sensitive = $is_privileged;
|
||||
$can_see_coords = $is_privileged;
|
||||
$public_verif_sql = $is_privileged ? "" : " AND pm.status_verifikasi = 'Terverifikasi'";
|
||||
|
||||
// Publik: hanya data Terverifikasi
|
||||
// Operator: semua data ibadahnya sendiri (semua status)
|
||||
// Admin: semua data tanpa filter
|
||||
$operator_scope_sql = '';
|
||||
|
||||
if ($is_privileged && !$is_admin) {
|
||||
$linked_ibadah_id = get_ibadah_id();
|
||||
$operator_scope_sql = $linked_ibadah_id
|
||||
? ' AND pm.ibadah_id = ' . (int)$linked_ibadah_id
|
||||
: ' AND 1 = 0';
|
||||
// operator lihat semua status miliknya (termasuk Pending/Ditolak)
|
||||
}
|
||||
|
||||
// Cek apakah tabel kebutuhan sudah ada (migrasi F14 sudah dijalankan)
|
||||
$kebutuhan_exists = $conn->query(
|
||||
"SELECT 1 FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'kebutuhan' LIMIT 1"
|
||||
)->num_rows > 0;
|
||||
|
||||
if ($kebutuhan_exists) {
|
||||
$sql = "
|
||||
SELECT
|
||||
pm.id, pm.nama_kk, pm.nik, pm.kategori, pm.alamat, pm.catatan,
|
||||
pm.jumlah_jiwa, pm.lat, pm.lng, pm.ibadah_id, pm.jarak_m,
|
||||
pm.is_blank_spot, pm.status_bantuan, pm.status_verifikasi, pm.created_at,
|
||||
ri.nama AS nama_ibadah,
|
||||
ri.jenis AS jenis_ibadah,
|
||||
COALESCE(kstat.kebutuhan_open, 0) AS kebutuhan_open,
|
||||
COALESCE(kstat.kebutuhan_proses, 0) AS kebutuhan_proses,
|
||||
COALESCE(kstat.kebutuhan_terpenuhi,0) AS kebutuhan_terpenuhi,
|
||||
kstat.kebutuhan_kategori_open
|
||||
FROM penduduk_miskin pm
|
||||
LEFT JOIN rumah_ibadah ri ON pm.ibadah_id = ri.id AND ri.deleted_at IS NULL
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
penduduk_id,
|
||||
SUM(CASE WHEN status='Belum Terpenuhi' THEN 1 ELSE 0 END) AS kebutuhan_open,
|
||||
SUM(CASE WHEN status='Dalam Proses' THEN 1 ELSE 0 END) AS kebutuhan_proses,
|
||||
SUM(CASE WHEN status='Terpenuhi' THEN 1 ELSE 0 END) AS kebutuhan_terpenuhi,
|
||||
GROUP_CONCAT(DISTINCT CASE WHEN status='Belum Terpenuhi' THEN kategori ELSE NULL END
|
||||
ORDER BY kategori SEPARATOR ',') AS kebutuhan_kategori_open
|
||||
FROM kebutuhan
|
||||
GROUP BY penduduk_id
|
||||
) kstat ON kstat.penduduk_id = pm.id
|
||||
WHERE pm.is_active = 1 AND pm.deleted_at IS NULL {$public_verif_sql} {$operator_scope_sql}
|
||||
ORDER BY pm.created_at DESC
|
||||
";
|
||||
} else {
|
||||
$sql = "
|
||||
SELECT
|
||||
pm.id, pm.nama_kk, pm.nik, pm.kategori, pm.alamat, pm.catatan,
|
||||
pm.jumlah_jiwa, pm.lat, pm.lng, pm.ibadah_id, pm.jarak_m,
|
||||
pm.is_blank_spot, pm.status_bantuan, pm.status_verifikasi, pm.created_at,
|
||||
ri.nama AS nama_ibadah,
|
||||
ri.jenis AS jenis_ibadah,
|
||||
0 AS kebutuhan_open, 0 AS kebutuhan_proses,
|
||||
0 AS kebutuhan_terpenuhi, NULL AS kebutuhan_kategori_open
|
||||
FROM penduduk_miskin pm
|
||||
LEFT JOIN rumah_ibadah ri ON pm.ibadah_id = ri.id AND ri.deleted_at IS NULL
|
||||
WHERE pm.is_active = 1 AND pm.deleted_at IS NULL {$public_verif_sql} {$operator_scope_sql}
|
||||
ORDER BY pm.created_at DESC
|
||||
";
|
||||
}
|
||||
|
||||
$result = $conn->query($sql);
|
||||
|
||||
if (!$result) {
|
||||
error_log('penduduk/ambil query failed: ' . $conn->error);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal memuat data penduduk.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
if (!$can_see_sensitive) {
|
||||
// Publik tidak boleh lihat data pribadi
|
||||
$row['nik'] = null;
|
||||
$row['nama_kk'] = null;
|
||||
$row['alamat'] = null;
|
||||
$row['catatan'] = null;
|
||||
}
|
||||
if (!$can_see_coords) {
|
||||
// Publik tidak boleh lihat koordinat rumah warga
|
||||
$row['lat'] = null;
|
||||
$row['lng'] = null;
|
||||
}
|
||||
$data[] = $row;
|
||||
}
|
||||
|
||||
echo json_encode(['status' => 'success', 'total' => count($data), 'data' => $data]);
|
||||
$conn->close();
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
require_auth('administrator');
|
||||
|
||||
// Return inactive (is_active=0) AND soft-deleted warga for admin audit
|
||||
$stmt = $conn->prepare("
|
||||
SELECT pm.id, pm.nama_kk, pm.jumlah_jiwa, pm.kategori, pm.status_bantuan,
|
||||
pm.is_active, pm.deleted_at,
|
||||
ri.nama AS nama_ibadah,
|
||||
(SELECT COUNT(*) FROM riwayat_bantuan WHERE penduduk_id = pm.id) AS jml_riwayat
|
||||
FROM penduduk_miskin pm
|
||||
LEFT JOIN rumah_ibadah ri ON ri.id = pm.ibadah_id
|
||||
WHERE pm.is_active = 0 OR pm.deleted_at IS NOT NULL
|
||||
ORDER BY COALESCE(pm.deleted_at, pm.updated_at) DESC
|
||||
LIMIT 200
|
||||
");
|
||||
$stmt->execute();
|
||||
$res = $stmt->get_result();
|
||||
$rows = [];
|
||||
while ($r = $res->fetch_assoc()) $rows[] = $r;
|
||||
$stmt->close();
|
||||
|
||||
echo json_encode(['status' => 'success', 'data' => $rows]);
|
||||
$conn->close();
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('administrator');
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
if (!$id) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare(
|
||||
"UPDATE penduduk_miskin SET deleted_at = NOW() WHERE id = ? AND deleted_at IS NULL"
|
||||
);
|
||||
$stmt->bind_param('i', $id);
|
||||
|
||||
if ($stmt->execute() && $stmt->affected_rows > 0) {
|
||||
echo json_encode(['status' => 'success', 'message' => 'Data berhasil dihapus']);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data tidak ditemukan atau sudah dihapus']);
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('administrator');
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']); exit;
|
||||
}
|
||||
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
if (!$id) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']); exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare(
|
||||
"UPDATE penduduk_miskin SET is_active = 0 WHERE id = ? AND is_active = 1 AND deleted_at IS NULL"
|
||||
);
|
||||
$stmt->bind_param('i', $id);
|
||||
|
||||
if ($stmt->execute() && $stmt->affected_rows > 0) {
|
||||
echo json_encode(['status' => 'success', 'message' => 'Warga berhasil dinonaktifkan']);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data tidak ditemukan atau sudah tidak aktif']);
|
||||
}
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
require_auth('operator');
|
||||
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Operator hanya boleh lihat riwayat warga dalam ibadah-nya
|
||||
if (!has_role('administrator')) {
|
||||
$op_ibadah = get_ibadah_id();
|
||||
$stmt = $conn->prepare(
|
||||
"SELECT ibadah_id FROM penduduk_miskin
|
||||
WHERE id=? AND is_active=1 AND deleted_at IS NULL"
|
||||
);
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$r = $stmt->get_result()->fetch_assoc();
|
||||
$stmt->close();
|
||||
if (!$r || (int)$r['ibadah_id'] !== (int)$op_ibadah) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Akses ditolak.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("
|
||||
SELECT r.id, r.status_lama, r.status_baru, r.catatan, r.created_at,
|
||||
u.nama_lengkap AS user_nama
|
||||
FROM riwayat_bantuan r
|
||||
LEFT JOIN users u ON u.id = r.operator_id
|
||||
WHERE r.penduduk_id = ?
|
||||
ORDER BY r.created_at DESC
|
||||
LIMIT 20
|
||||
");
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$res = $stmt->get_result();
|
||||
$rows = [];
|
||||
while ($r = $res->fetch_assoc()) $rows[] = $r;
|
||||
$stmt->close();
|
||||
|
||||
echo json_encode(['status' => 'success', 'data' => $rows]);
|
||||
$conn->close();
|
||||
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../includes/validation.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('operator');
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$nama_kk = trim($_POST['nama_kk'] ?? '');
|
||||
$nik = trim($_POST['nik'] ?? '');
|
||||
$jumlah_jiwa = max(1, (int)($_POST['jumlah_jiwa'] ?? 1));
|
||||
$kategori = trim($_POST['kategori'] ?? 'Miskin');
|
||||
$alamat = trim($_POST['alamat'] ?? '');
|
||||
$catatan = trim($_POST['catatan'] ?? '');
|
||||
$coord = validate_lat_lng($_POST['lat'] ?? null, $_POST['lng'] ?? null);
|
||||
if (!$coord['ok']) {
|
||||
echo json_encode(['status' => 'error', 'message' => $coord['message']]);
|
||||
exit;
|
||||
}
|
||||
$lat = $coord['lat'];
|
||||
$lng = $coord['lng'];
|
||||
|
||||
if (!$nama_kk) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Nama Kepala Keluarga tidak boleh kosong']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$valid_kategori = ['Sangat Miskin', 'Miskin', 'Hampir Miskin'];
|
||||
if (!in_array($kategori, $valid_kategori)) $kategori = 'Miskin';
|
||||
|
||||
// Validasi & dedup NIK
|
||||
$nik_val = $nik !== '' ? $nik : null;
|
||||
if ($nik_val !== null) {
|
||||
if (!preg_match('/^\d{16}$/', $nik_val)) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'NIK harus 16 digit angka']);
|
||||
exit;
|
||||
}
|
||||
$stmt_nik = $conn->prepare(
|
||||
"SELECT id, nama_kk FROM penduduk_miskin
|
||||
WHERE nik = ? LIMIT 1"
|
||||
);
|
||||
$stmt_nik->bind_param('s', $nik_val);
|
||||
$stmt_nik->execute();
|
||||
$existing = $stmt_nik->get_result()->fetch_assoc();
|
||||
$stmt_nik->close();
|
||||
if ($existing) {
|
||||
echo json_encode([
|
||||
'status' => 'error',
|
||||
'message' => "NIK {$nik_val} pernah terdaftar atas nama {$existing['nama_kk']}. Cek arsip sebelum menambah ulang.",
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// PRD-correct proximity calculation
|
||||
$p = calc_proximity($conn, $lat, $lng);
|
||||
|
||||
if (!has_role('administrator')) {
|
||||
$op_ibadah_id = get_ibadah_id();
|
||||
if (!$op_ibadah_id) {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'status' => 'error',
|
||||
'message' => 'Operator belum dikaitkan dengan rumah ibadah'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
if (empty($p['ibadah_id']) || (int)$p['ibadah_id'] !== $op_ibadah_id) {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'status' => 'error',
|
||||
'message' => 'Lokasi warga berada di luar wilayah rumah ibadah operator'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Admin langsung Terverifikasi; operator masuk Pending untuk direview
|
||||
$status_verif = has_role('administrator') ? 'Terverifikasi' : 'Pending';
|
||||
$verified_by = has_role('administrator') ? get_user_id() : null;
|
||||
$verified_at = has_role('administrator') ? date('Y-m-d H:i:s') : null;
|
||||
|
||||
$stmt = $conn->prepare(
|
||||
"INSERT INTO penduduk_miskin
|
||||
(nama_kk, nik, jumlah_jiwa, kategori, alamat, catatan, lat, lng,
|
||||
ibadah_id, jarak_m, is_blank_spot, status_verifikasi, verified_by, verified_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
$stmt->bind_param(
|
||||
'ssisssddidisis',
|
||||
$nama_kk, $nik_val, $jumlah_jiwa, $kategori, $alamat, $catatan,
|
||||
$lat, $lng, $p['ibadah_id'], $p['jarak_m'], $p['is_blank_spot'],
|
||||
$status_verif, $verified_by, $verified_at
|
||||
);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$new_id = $conn->insert_id;
|
||||
|
||||
$nama_ibadah = null;
|
||||
$jenis_ibadah = null;
|
||||
if ($p['ibadah_id']) {
|
||||
$s2 = $conn->prepare("SELECT nama, jenis FROM rumah_ibadah WHERE id = ? AND deleted_at IS NULL");
|
||||
$s2->bind_param('i', $p['ibadah_id']);
|
||||
$s2->execute();
|
||||
$ri = $s2->get_result()->fetch_assoc();
|
||||
$s2->close();
|
||||
if ($ri) { $nama_ibadah = $ri['nama']; $jenis_ibadah = $ri['jenis']; }
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'message' => 'Data berhasil disimpan',
|
||||
'data' => [
|
||||
'id' => $new_id,
|
||||
'nama_kk' => $nama_kk,
|
||||
'nik' => $nik_val,
|
||||
'kategori' => $kategori,
|
||||
'alamat' => $alamat,
|
||||
'catatan' => $catatan,
|
||||
'jumlah_jiwa' => $jumlah_jiwa,
|
||||
'lat' => $lat,
|
||||
'lng' => $lng,
|
||||
'ibadah_id' => $p['ibadah_id'],
|
||||
'jarak_m' => $p['jarak_m'],
|
||||
'is_blank_spot' => $p['is_blank_spot'],
|
||||
'status_verifikasi' => $status_verif,
|
||||
'nama_ibadah' => $nama_ibadah,
|
||||
'jenis_ibadah' => $jenis_ibadah,
|
||||
]
|
||||
]);
|
||||
} else {
|
||||
error_log('penduduk/simpan insert failed: ' . $stmt->error);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal menyimpan data penduduk.']);
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
require_auth('operator');
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$nama_kk = trim($_POST['nama_kk'] ?? '');
|
||||
$nik = trim($_POST['nik'] ?? '');
|
||||
$jumlah_jiwa = max(1, (int)($_POST['jumlah_jiwa'] ?? 1));
|
||||
$kategori = trim($_POST['kategori'] ?? 'Miskin');
|
||||
$alamat = trim($_POST['alamat'] ?? '');
|
||||
$catatan = trim($_POST['catatan'] ?? '');
|
||||
|
||||
if ($id <= 0 || !$nama_kk) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID dan nama kepala keluarga wajib diisi.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$valid_kategori = ['Sangat Miskin', 'Miskin', 'Hampir Miskin'];
|
||||
if (!in_array($kategori, $valid_kategori)) $kategori = 'Miskin';
|
||||
|
||||
// Ambil data existing untuk scope check
|
||||
$stmt = $conn->prepare(
|
||||
"SELECT ibadah_id FROM penduduk_miskin WHERE id=? AND is_active=1 AND deleted_at IS NULL"
|
||||
);
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$warga = $stmt->get_result()->fetch_assoc();
|
||||
$stmt->close();
|
||||
|
||||
if (!$warga) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data warga tidak ditemukan.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Operator hanya boleh edit warga dalam ibadah-nya
|
||||
if (!has_role('administrator')) {
|
||||
$op_ibadah = get_ibadah_id();
|
||||
if ($warga['ibadah_id'] === null || (int)$warga['ibadah_id'] !== (int)$op_ibadah) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Anda tidak memiliki akses untuk warga ini.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Validasi & dedup NIK (exclude diri sendiri)
|
||||
$nik_val = $nik !== '' ? $nik : null;
|
||||
if ($nik_val !== null) {
|
||||
if (!preg_match('/^\d{16}$/', $nik_val)) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'NIK harus 16 digit angka.']);
|
||||
exit;
|
||||
}
|
||||
$stmt_nik = $conn->prepare(
|
||||
"SELECT id, nama_kk FROM penduduk_miskin WHERE nik=? AND id != ? LIMIT 1"
|
||||
);
|
||||
$stmt_nik->bind_param('si', $nik_val, $id);
|
||||
$stmt_nik->execute();
|
||||
$existing = $stmt_nik->get_result()->fetch_assoc();
|
||||
$stmt_nik->close();
|
||||
if ($existing) {
|
||||
echo json_encode([
|
||||
'status' => 'error',
|
||||
'message' => "NIK {$nik_val} sudah terdaftar atas nama {$existing['nama_kk']}.",
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$catatan_val = $catatan !== '' ? $catatan : null;
|
||||
|
||||
$stmt = $conn->prepare(
|
||||
"UPDATE penduduk_miskin
|
||||
SET nama_kk=?, nik=?, jumlah_jiwa=?, kategori=?, alamat=?, catatan=?, updated_at=NOW()
|
||||
WHERE id=? AND is_active=1 AND deleted_at IS NULL"
|
||||
);
|
||||
$stmt->bind_param('ssisssi', $nama_kk, $nik_val, $jumlah_jiwa, $kategori, $alamat, $catatan_val, $id);
|
||||
|
||||
if ($stmt->execute() && $stmt->affected_rows >= 0) {
|
||||
echo json_encode(['status' => 'success', 'message' => 'Data berhasil diperbarui.']);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal memperbarui data: ' . $stmt->error]);
|
||||
}
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../includes/validation.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('operator');
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$coord = validate_lat_lng($_POST['lat'] ?? null, $_POST['lng'] ?? null);
|
||||
if (!$coord['ok']) {
|
||||
echo json_encode(['status' => 'error', 'message' => $coord['message']]);
|
||||
exit;
|
||||
}
|
||||
$lat = $coord['lat'];
|
||||
$lng = $coord['lng'];
|
||||
|
||||
if (!$id) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!has_role('administrator')) {
|
||||
$linked_ibadah_id = get_ibadah_id();
|
||||
if (!$linked_ibadah_id) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Akses ditolak untuk data warga ini']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$scope = $conn->prepare(
|
||||
"SELECT id FROM penduduk_miskin
|
||||
WHERE id = ? AND ibadah_id = ? AND is_active = 1 AND deleted_at IS NULL"
|
||||
);
|
||||
$scope->bind_param('ii', $id, $linked_ibadah_id);
|
||||
$scope->execute();
|
||||
$allowed = $scope->get_result()->fetch_assoc();
|
||||
$scope->close();
|
||||
|
||||
if (!$allowed) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Akses ditolak untuk data warga ini']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// PRD-correct proximity calculation
|
||||
$p = calc_proximity($conn, $lat, $lng);
|
||||
|
||||
if (!has_role('administrator')) {
|
||||
if (empty($p['ibadah_id']) || (int)$p['ibadah_id'] !== $linked_ibadah_id) {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'status' => 'error',
|
||||
'message' => 'Posisi baru berada di luar wilayah rumah ibadah operator'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare(
|
||||
"UPDATE penduduk_miskin
|
||||
SET lat = ?, lng = ?, ibadah_id = ?, jarak_m = ?, is_blank_spot = ?
|
||||
WHERE id = ? AND deleted_at IS NULL"
|
||||
);
|
||||
$stmt->bind_param('ddidii', $lat, $lng, $p['ibadah_id'], $p['jarak_m'], $p['is_blank_spot'], $id);
|
||||
|
||||
if ($stmt->execute() && $stmt->affected_rows > 0) {
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'message' => 'Posisi berhasil diperbarui',
|
||||
'data' => [
|
||||
'ibadah_id' => $p['ibadah_id'],
|
||||
'jarak_m' => $p['jarak_m'],
|
||||
'is_blank_spot' => $p['is_blank_spot'],
|
||||
]
|
||||
]);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal update atau data tidak ditemukan']);
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
require_auth('operator');
|
||||
require_csrf();
|
||||
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$status = trim($_POST['status'] ?? '');
|
||||
$catatan = trim($_POST['catatan'] ?? '');
|
||||
|
||||
$allowed = ['Belum Ditangani', 'Dalam Proses', 'Sudah Ditangani'];
|
||||
if ($id <= 0 || !in_array($status, $allowed, true)) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Parameter tidak valid.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare(
|
||||
"SELECT id, ibadah_id, status_bantuan, status_verifikasi FROM penduduk_miskin
|
||||
WHERE id=? AND is_active=1 AND deleted_at IS NULL"
|
||||
);
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$row = $stmt->get_result()->fetch_assoc();
|
||||
$stmt->close();
|
||||
|
||||
if (!$row) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data tidak ditemukan.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Operator hanya boleh update warga dalam ibadah-nya sendiri
|
||||
if (!has_role('administrator')) {
|
||||
$op_ibadah = get_ibadah_id();
|
||||
if ($row['ibadah_id'] === null || (int)$row['ibadah_id'] !== (int)$op_ibadah) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Anda tidak memiliki akses untuk warga ini.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
if ($row['status_verifikasi'] !== 'Terverifikasi') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data warga belum terverifikasi']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$old_status = $row['status_bantuan'];
|
||||
$user_id = (int)$_SESSION['user_id'];
|
||||
|
||||
$stmt = $conn->prepare(
|
||||
"UPDATE penduduk_miskin SET status_bantuan=?, updated_at=NOW() WHERE id=?"
|
||||
);
|
||||
$stmt->bind_param('si', $status, $id);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
$stmt = $conn->prepare(
|
||||
"INSERT INTO riwayat_bantuan (penduduk_id, operator_id, status_lama, status_baru, catatan)
|
||||
VALUES (?,?,?,?,?)"
|
||||
);
|
||||
$stmt->bind_param('iisss', $id, $user_id, $old_status, $status, $catatan);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
echo json_encode(['status' => 'success', 'status_baru' => $status]);
|
||||
$conn->close();
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('administrator');
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = (int)trim($_POST['id'] ?? 0);
|
||||
$aksi = trim($_POST['aksi'] ?? ''); // 'approve' | 'reject'
|
||||
$catatan = trim($_POST['catatan'] ?? '');
|
||||
|
||||
if ($id <= 0 || !in_array($aksi, ['approve', 'reject'], true)) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Parameter tidak valid.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$status_baru = $aksi === 'approve' ? 'Terverifikasi' : 'Ditolak';
|
||||
$admin_id = get_user_id();
|
||||
$catatan_val = $catatan !== '' ? mb_substr($catatan, 0, 500) : null;
|
||||
|
||||
$stmt = $conn->prepare("
|
||||
UPDATE penduduk_miskin
|
||||
SET status_verifikasi = ?,
|
||||
verified_by = ?,
|
||||
verified_at = NOW(),
|
||||
catatan_verifikasi = ?
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
");
|
||||
$stmt->bind_param('sisi', $status_baru, $admin_id, $catatan_val, $id);
|
||||
|
||||
if ($stmt->execute() && $stmt->affected_rows > 0) {
|
||||
echo json_encode(['status' => 'success', 'status_verifikasi' => $status_baru, 'id' => $id]);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data tidak ditemukan atau tidak ada perubahan.']);
|
||||
}
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
$is_auth = is_logged_in();
|
||||
$is_privileged = $is_auth && has_role('operator');
|
||||
$is_admin = $is_auth && has_role('administrator');
|
||||
$public_verif_sql = $is_privileged ? "" : " AND status_verifikasi = 'Terverifikasi'";
|
||||
$public_verif_pm_sql = $is_privileged ? "" : " AND pm.status_verifikasi = 'Terverifikasi'";
|
||||
$operator_ibadah_id = null;
|
||||
$operator_scope_sql = '';
|
||||
$operator_scope_pm_sql = '';
|
||||
if ($is_privileged && !$is_admin) {
|
||||
$operator_ibadah_id = (int)get_ibadah_id();
|
||||
if ($operator_ibadah_id > 0) {
|
||||
$operator_scope_sql = " AND ibadah_id = {$operator_ibadah_id}";
|
||||
$operator_scope_pm_sql = " AND pm.ibadah_id = {$operator_ibadah_id}";
|
||||
} else {
|
||||
$operator_scope_sql = " AND 1 = 0";
|
||||
$operator_scope_pm_sql = " AND 1 = 0";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Statistik dasar (semua role) ────────────────────────────────────────────
|
||||
$r = $conn->query("
|
||||
SELECT
|
||||
COUNT(*) AS total_kk,
|
||||
COALESCE(SUM(jumlah_jiwa), 0) AS total_jiwa,
|
||||
COALESCE(SUM(CASE WHEN is_blank_spot=1 THEN jumlah_jiwa ELSE 0 END), 0) AS jiwa_blank_spot,
|
||||
COALESCE(SUM(CASE WHEN is_blank_spot=0 THEN jumlah_jiwa ELSE 0 END), 0) AS jiwa_terjangkau
|
||||
FROM penduduk_miskin pm
|
||||
WHERE pm.is_active = 1 AND pm.deleted_at IS NULL {$public_verif_pm_sql} {$operator_scope_pm_sql}
|
||||
");
|
||||
$base = $r->fetch_assoc();
|
||||
|
||||
$total_jiwa = (int)$base['total_jiwa'];
|
||||
$jiwa_terjangkau = (int)$base['jiwa_terjangkau'];
|
||||
$jiwa_blank_spot = (int)$base['jiwa_blank_spot'];
|
||||
$pct_cakupan = $total_jiwa > 0 ? round($jiwa_terjangkau / $total_jiwa * 100, 1) : 0;
|
||||
|
||||
// ── Sebaran kategori ────────────────────────────────────────────────────────
|
||||
$kat_rows = $conn->query("
|
||||
SELECT kategori, COUNT(*) AS jml_kk, COALESCE(SUM(jumlah_jiwa),0) AS jml_jiwa
|
||||
FROM penduduk_miskin pm
|
||||
WHERE pm.is_active=1 AND pm.deleted_at IS NULL {$public_verif_pm_sql} {$operator_scope_pm_sql}
|
||||
GROUP BY kategori
|
||||
");
|
||||
$kategori = [];
|
||||
while ($row = $kat_rows->fetch_assoc()) $kategori[] = $row;
|
||||
|
||||
// ── Total ibadah aktif ──────────────────────────────────────────────────────
|
||||
if ($is_privileged && !$is_admin) {
|
||||
$total_ibadah = $operator_ibadah_id
|
||||
? (int)$conn->query("SELECT COUNT(*) AS n FROM rumah_ibadah WHERE deleted_at IS NULL AND id = {$operator_ibadah_id}")->fetch_assoc()['n']
|
||||
: 0;
|
||||
} else {
|
||||
$total_ibadah = (int)$conn->query(
|
||||
"SELECT COUNT(*) AS n FROM rumah_ibadah WHERE deleted_at IS NULL"
|
||||
)->fetch_assoc()['n'];
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'total_ibadah' => $total_ibadah,
|
||||
'total_kk' => (int)$base['total_kk'],
|
||||
'total_jiwa' => $total_jiwa,
|
||||
'jiwa_terjangkau' => $jiwa_terjangkau,
|
||||
'jiwa_blank_spot' => $jiwa_blank_spot,
|
||||
'pct_cakupan' => $pct_cakupan,
|
||||
'kategori' => $kategori,
|
||||
];
|
||||
|
||||
// ── Tabel rekapitulasi per ibadah (admin only) ──────────────────────────────
|
||||
if ($is_admin) {
|
||||
$rekap_rows = $conn->query("
|
||||
SELECT
|
||||
ri.id,
|
||||
ri.nama,
|
||||
ri.jenis,
|
||||
COUNT(pm.id) AS jml_kk,
|
||||
COALESCE(SUM(pm.jumlah_jiwa), 0) AS jml_jiwa,
|
||||
COALESCE(SUM(CASE WHEN pm.status_bantuan='Sudah Ditangani' THEN 1 ELSE 0 END), 0) AS jml_ditangani
|
||||
FROM rumah_ibadah ri
|
||||
LEFT JOIN penduduk_miskin pm
|
||||
ON pm.ibadah_id = ri.id
|
||||
AND pm.is_active = 1
|
||||
AND pm.deleted_at IS NULL
|
||||
WHERE ri.deleted_at IS NULL
|
||||
GROUP BY ri.id
|
||||
ORDER BY jml_jiwa DESC
|
||||
");
|
||||
$rekap = [];
|
||||
while ($row = $rekap_rows->fetch_assoc()) $rekap[] = $row;
|
||||
$payload['rekap_ibadah'] = $rekap;
|
||||
}
|
||||
|
||||
// ── Kebutuhan stats (hanya jika tabel sudah ada) ───────────────────────────
|
||||
$kebutuhan_exists = $conn->query(
|
||||
"SELECT 1 FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'kebutuhan' LIMIT 1"
|
||||
)->num_rows > 0;
|
||||
|
||||
$payload['kk_kebutuhan_open'] = 0;
|
||||
|
||||
if ($kebutuhan_exists) {
|
||||
$payload['kk_kebutuhan_open'] = (int)$conn->query("
|
||||
SELECT COUNT(DISTINCT penduduk_id) AS n
|
||||
FROM kebutuhan k
|
||||
JOIN penduduk_miskin pm ON pm.id = k.penduduk_id
|
||||
AND pm.is_active = 1
|
||||
AND pm.deleted_at IS NULL
|
||||
{$public_verif_pm_sql}
|
||||
{$operator_scope_pm_sql}
|
||||
WHERE k.status = 'Belum Terpenuhi'
|
||||
")->fetch_assoc()['n'];
|
||||
|
||||
if ($is_admin) {
|
||||
$rekap_kat = $conn->query("
|
||||
SELECT kategori,
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN status='Belum Terpenuhi' THEN 1 ELSE 0 END) AS belum,
|
||||
SUM(CASE WHEN status='Dalam Proses' THEN 1 ELSE 0 END) AS proses,
|
||||
SUM(CASE WHEN status='Terpenuhi' THEN 1 ELSE 0 END) AS terpenuhi
|
||||
FROM kebutuhan
|
||||
GROUP BY kategori ORDER BY belum DESC, kategori ASC
|
||||
");
|
||||
$rekap_kebutuhan = [];
|
||||
while ($r = $rekap_kat->fetch_assoc()) $rekap_kebutuhan[] = $r;
|
||||
$payload['rekap_kebutuhan'] = $rekap_kebutuhan;
|
||||
|
||||
$donatur_exists = $conn->query(
|
||||
"SELECT 1 FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'kontak_donatur' LIMIT 1"
|
||||
)->num_rows > 0;
|
||||
$payload['donatur_unread'] = $donatur_exists
|
||||
? (int)$conn->query("SELECT COUNT(*) AS n FROM kontak_donatur WHERE is_read=0")->fetch_assoc()['n']
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode(['status' => 'success', 'data' => $payload]);
|
||||
$conn->close();
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('administrator');
|
||||
|
||||
$sql = "
|
||||
SELECT u.id, u.username, u.nama_lengkap, u.role, u.ibadah_id,
|
||||
u.is_active, u.must_change_password, u.created_at,
|
||||
ri.nama AS nama_ibadah
|
||||
FROM users u
|
||||
LEFT JOIN rumah_ibadah ri ON u.ibadah_id = ri.id AND ri.deleted_at IS NULL
|
||||
ORDER BY u.created_at DESC
|
||||
";
|
||||
$result = $conn->query($sql);
|
||||
$data = [];
|
||||
while ($row = $result->fetch_assoc()) $data[] = $row;
|
||||
|
||||
echo json_encode(['status' => 'success', 'data' => $data]);
|
||||
$conn->close();
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('administrator');
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']); exit;
|
||||
}
|
||||
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
if (!$id) { echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']); exit; }
|
||||
|
||||
$chars = 'abcdefghjkmnpqrstuvwxyz';
|
||||
$digits = '23456789';
|
||||
$temp_pw = '';
|
||||
for ($i = 0; $i < 4; $i++) $temp_pw .= $chars[random_int(0, strlen($chars)-1)];
|
||||
for ($i = 0; $i < 4; $i++) $temp_pw .= $digits[random_int(0, strlen($digits)-1)];
|
||||
$temp_pw = str_shuffle($temp_pw);
|
||||
|
||||
$hash = password_hash($temp_pw, PASSWORD_BCRYPT);
|
||||
$stmt = $conn->prepare(
|
||||
"UPDATE users SET password=?, must_change_password=1, login_attempts=0, locked_until=NULL WHERE id=?"
|
||||
);
|
||||
$stmt->bind_param('si', $hash, $id);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'temp_password' => $temp_pw,
|
||||
'message' => 'Password direset. Sampaikan password sementara ini ke pengguna secara langsung.',
|
||||
]);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
|
||||
}
|
||||
$stmt->close(); $conn->close();
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('administrator');
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']); exit;
|
||||
}
|
||||
|
||||
$username = trim($_POST['username'] ?? '');
|
||||
$nama_lengkap = trim($_POST['nama_lengkap'] ?? '');
|
||||
$password = trim($_POST['password'] ?? '');
|
||||
$role = trim($_POST['role'] ?? '');
|
||||
$ibadah_id = ($_POST['ibadah_id'] ?? '') !== '' ? (int)$_POST['ibadah_id'] : null;
|
||||
|
||||
$valid_roles = ['administrator', 'operator', 'viewer'];
|
||||
if (!$username || !$nama_lengkap || !$password || !in_array($role, $valid_roles)) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data tidak lengkap atau role tidak valid']); exit;
|
||||
}
|
||||
if (strlen($password) < 8 || !preg_match('/[a-zA-Z]/', $password) || !preg_match('/[0-9]/', $password)) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Password min 8 karakter, kombinasi huruf dan angka']); exit;
|
||||
}
|
||||
if ($role === 'operator' && !$ibadah_id) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Operator harus dikaitkan ke rumah ibadah']); exit;
|
||||
}
|
||||
if ($role !== 'operator') {
|
||||
$ibadah_id = null;
|
||||
}
|
||||
if ($role === 'operator') {
|
||||
$ri_stmt = $conn->prepare("SELECT id FROM rumah_ibadah WHERE id = ? AND deleted_at IS NULL LIMIT 1");
|
||||
$ri_stmt->bind_param('i', $ibadah_id);
|
||||
$ri_stmt->execute();
|
||||
$ri = $ri_stmt->get_result()->fetch_assoc();
|
||||
$ri_stmt->close();
|
||||
if (!$ri) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Rumah ibadah operator tidak valid atau sudah dihapus']); exit;
|
||||
}
|
||||
}
|
||||
|
||||
$hash = password_hash($password, PASSWORD_BCRYPT);
|
||||
|
||||
$stmt = $conn->prepare(
|
||||
"INSERT INTO users (username, nama_lengkap, password, role, ibadah_id, must_change_password)
|
||||
VALUES (?, ?, ?, ?, ?, 1)"
|
||||
);
|
||||
$stmt->bind_param('ssssi', $username, $nama_lengkap, $hash, $role, $ibadah_id);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
echo json_encode(['status' => 'success', 'message' => 'Akun berhasil dibuat', 'id' => $conn->insert_id]);
|
||||
} else {
|
||||
$msg = str_contains($conn->error, 'Duplicate') ? "Username '$username' sudah digunakan" : $conn->error;
|
||||
echo json_encode(['status' => 'error', 'message' => $msg]);
|
||||
}
|
||||
$stmt->close(); $conn->close();
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('administrator');
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']); exit;
|
||||
}
|
||||
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
if (!$id) { echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']); exit; }
|
||||
|
||||
if ($id === get_user_id()) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Tidak dapat menonaktifkan akun sendiri']); exit;
|
||||
}
|
||||
|
||||
$target_stmt = $conn->prepare("SELECT role, is_active FROM users WHERE id = ? LIMIT 1");
|
||||
$target_stmt->bind_param('i', $id);
|
||||
$target_stmt->execute();
|
||||
$target = $target_stmt->get_result()->fetch_assoc();
|
||||
$target_stmt->close();
|
||||
if (!$target) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Akun tidak ditemukan']); exit;
|
||||
}
|
||||
|
||||
if ($target['role'] === 'administrator' && (int)$target['is_active'] === 1) {
|
||||
$admin_stmt = $conn->prepare(
|
||||
"SELECT COUNT(*) AS n FROM users WHERE role='administrator' AND is_active=1 AND id <> ?"
|
||||
);
|
||||
$admin_stmt->bind_param('i', $id);
|
||||
$admin_stmt->execute();
|
||||
$other_admins = (int)$admin_stmt->get_result()->fetch_assoc()['n'];
|
||||
$admin_stmt->close();
|
||||
if ($other_admins === 0) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Tidak dapat menonaktifkan administrator aktif terakhir']); exit;
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("UPDATE users SET is_active = NOT is_active WHERE id = ?");
|
||||
$stmt->bind_param('i', $id);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$s2 = $conn->prepare("SELECT is_active FROM users WHERE id = ?");
|
||||
$s2->bind_param('i', $id);
|
||||
$s2->execute();
|
||||
$r = $s2->get_result()->fetch_assoc();
|
||||
$s2->close();
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'is_active' => (int)$r['is_active'],
|
||||
'message' => $r['is_active'] ? 'Akun diaktifkan' : 'Akun dinonaktifkan',
|
||||
]);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
|
||||
}
|
||||
$stmt->close(); $conn->close();
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('administrator');
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']); exit;
|
||||
}
|
||||
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$nama_lengkap = trim($_POST['nama_lengkap'] ?? '');
|
||||
$role = trim($_POST['role'] ?? '');
|
||||
$ibadah_id = ($_POST['ibadah_id'] ?? '') !== '' ? (int)$_POST['ibadah_id'] : null;
|
||||
|
||||
if (!$id || !$nama_lengkap || !in_array($role, ['administrator','operator','viewer'])) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data tidak valid']); exit;
|
||||
}
|
||||
if ($role === 'operator' && !$ibadah_id) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Operator harus dikaitkan ke rumah ibadah']); exit;
|
||||
}
|
||||
if ($role !== 'operator') {
|
||||
$ibadah_id = null;
|
||||
}
|
||||
|
||||
$current_stmt = $conn->prepare("SELECT role, is_active FROM users WHERE id = ? LIMIT 1");
|
||||
$current_stmt->bind_param('i', $id);
|
||||
$current_stmt->execute();
|
||||
$current = $current_stmt->get_result()->fetch_assoc();
|
||||
$current_stmt->close();
|
||||
if (!$current) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Akun tidak ditemukan']); exit;
|
||||
}
|
||||
|
||||
if ($id === get_user_id() && $current['role'] === 'administrator' && $role !== 'administrator') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Tidak dapat menurunkan role akun sendiri']); exit;
|
||||
}
|
||||
|
||||
if ($current['role'] === 'administrator' && (int)$current['is_active'] === 1 && $role !== 'administrator') {
|
||||
$admin_stmt = $conn->prepare(
|
||||
"SELECT COUNT(*) AS n FROM users WHERE role='administrator' AND is_active=1 AND id <> ?"
|
||||
);
|
||||
$admin_stmt->bind_param('i', $id);
|
||||
$admin_stmt->execute();
|
||||
$other_admins = (int)$admin_stmt->get_result()->fetch_assoc()['n'];
|
||||
$admin_stmt->close();
|
||||
if ($other_admins === 0) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Tidak dapat mengubah administrator aktif terakhir']); exit;
|
||||
}
|
||||
}
|
||||
|
||||
if ($role === 'operator') {
|
||||
$ri_stmt = $conn->prepare("SELECT id FROM rumah_ibadah WHERE id = ? AND deleted_at IS NULL LIMIT 1");
|
||||
$ri_stmt->bind_param('i', $ibadah_id);
|
||||
$ri_stmt->execute();
|
||||
$ri = $ri_stmt->get_result()->fetch_assoc();
|
||||
$ri_stmt->close();
|
||||
if (!$ri) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Rumah ibadah operator tidak valid atau sudah dihapus']); exit;
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("UPDATE users SET nama_lengkap=?, role=?, ibadah_id=? WHERE id=?");
|
||||
$stmt->bind_param('ssii', $nama_lengkap, $role, $ibadah_id, $id);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
echo json_encode(['status' => 'success', 'message' => 'Akun berhasil diperbarui']);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
|
||||
}
|
||||
$stmt->close(); $conn->close();
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
// auth/change_password.php — Paksa ganti password saat pertama login
|
||||
require_once '../config.php';
|
||||
require_once '../auth/helper.php';
|
||||
|
||||
if (!is_logged_in()) {
|
||||
header('Location: login.php');
|
||||
exit;
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Ganti Password — <?= APP_NAME ?></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&display=swap" rel="stylesheet">
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root { --c-bg:#f5f0eb; --c-surface:#fafaf9; --c-border:#ddd8d2;
|
||||
--c-text:#201515; --c-muted:#7a7067; --c-accent:#0d7490;
|
||||
--c-accent-h:#0a5f7a; --c-danger:#ef4444; --font-body:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; }
|
||||
body { min-height:100vh; background:var(--c-bg); color:var(--c-text);
|
||||
font-family:var(--font-body); display:flex; align-items:center;
|
||||
justify-content:center; padding:20px; }
|
||||
.card { width:min(420px,100%); background:var(--c-surface);
|
||||
border:1px solid var(--c-border); border-radius:12px;
|
||||
padding:32px; box-shadow:0 4px 12px rgba(32,21,21,.08); }
|
||||
.notice { background:#fffbeb; border:1px solid #fde68a;
|
||||
border-radius:8px; padding:12px 16px; font-size:13px;
|
||||
color:#d97706; margin-bottom:22px; }
|
||||
.form-group { margin-bottom:16px; }
|
||||
.form-group label { display:block; font-size:13px; font-weight:500;
|
||||
color:#3d3530; margin-bottom:6px; }
|
||||
.form-group input { width:100%; padding:10px 14px; background:#fafaf9;
|
||||
border:1px solid var(--c-border); border-radius:8px;
|
||||
color:var(--c-text); font-size:14px; outline:none; height:40px;
|
||||
transition:border-color .15s; font-family:var(--font-body); }
|
||||
.form-group input:focus { border-color:#0d7490; }
|
||||
.hint { font-size:12px; color:var(--c-muted); margin-top:5px; }
|
||||
.btn { width:100%; padding:11px; background:#0d7490; color:#fff;
|
||||
border:none; border-radius:8px; font-size:14px; font-weight:600;
|
||||
cursor:pointer; margin-top:8px; transition:background .15s;
|
||||
font-family:var(--font-body); }
|
||||
.btn:hover { background:#0a5f7a; }
|
||||
.btn:disabled { opacity:.5; cursor:not-allowed; }
|
||||
.msg { margin-top:12px; font-size:13px; text-align:center;
|
||||
min-height:18px; }
|
||||
.msg.error { color:#ef4444; }
|
||||
.msg.success { color:#16a34a; }
|
||||
|
||||
/* Lucide & Spinner styles */
|
||||
.lucide { width: 16px; height: 16px; display: inline-block; vertical-align: middle; stroke-width: 2px; }
|
||||
.notice .lucide { color: #d97706; margin-right: 6px; }
|
||||
.spinner {
|
||||
display: inline-block; width: 14px; height: 14px;
|
||||
border: 2px solid #ddd8d2; border-top-color: #0d7490;
|
||||
border-radius: 50%; animation: spin .7s linear infinite;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="notice"><i data-lucide="alert-triangle"></i> Anda diminta mengganti password sebelum dapat mengakses sistem.</div>
|
||||
<form id="cpForm" onsubmit="doChange(event)">
|
||||
<div class="form-group">
|
||||
<label>Password Baru</label>
|
||||
<input type="password" id="new_password" name="new_password"
|
||||
placeholder="Min. 8 karakter" required>
|
||||
<div class="hint">Minimal 8 karakter, kombinasi huruf dan angka.</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Konfirmasi Password</label>
|
||||
<input type="password" id="confirm_password" name="confirm_password"
|
||||
placeholder="Ulangi password baru" required>
|
||||
</div>
|
||||
<button type="submit" class="btn" id="btnSave">Simpan Password</button>
|
||||
</form>
|
||||
<div class="msg" id="cpMsg"></div>
|
||||
</div>
|
||||
<script>
|
||||
window.APP_CSRF_TOKEN = <?= json_encode(csrf_token()) ?>;
|
||||
function appendCsrf(fd) {
|
||||
const token = window.APP_CSRF_TOKEN || null;
|
||||
if (token) fd.append('csrf_token', token);
|
||||
return fd;
|
||||
}
|
||||
async function doChange(e) {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('btnSave');
|
||||
const msg = document.getElementById('cpMsg');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner"></span> Menyimpan...';
|
||||
msg.className = 'msg';
|
||||
msg.textContent = '';
|
||||
|
||||
const fd = new FormData(document.getElementById('cpForm'));
|
||||
try {
|
||||
const res = await fetch('../api/auth/change_password.php', { method:'POST', body: appendCsrf(fd) });
|
||||
const j = await res.json();
|
||||
if (j.status === 'success') {
|
||||
msg.innerHTML = '<i data-lucide="check-circle" style="color:#16a34a;margin-right:4px;"></i> Password berhasil diubah. Mengalihkan...';
|
||||
msg.className = 'msg success';
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
setTimeout(() => { window.location.href = '../index.php'; }, 1200);
|
||||
} else {
|
||||
msg.textContent = j.message;
|
||||
msg.className = 'msg error';
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = 'Simpan Password';
|
||||
}
|
||||
} catch (_) {
|
||||
msg.textContent = 'Gagal menghubungi server.';
|
||||
msg.className = 'msg error';
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = 'Simpan Password';
|
||||
}
|
||||
}
|
||||
// Boot Lucide on load
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
// auth/helper.php — Session management dan RBAC helpers
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
$is_https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https')
|
||||
|| (($_SERVER['HTTP_X_FORWARDED_SSL'] ?? '') === 'on');
|
||||
|
||||
session_set_cookie_params([
|
||||
'lifetime' => 0,
|
||||
'httponly' => true,
|
||||
'secure' => $is_https,
|
||||
'samesite' => 'Strict',
|
||||
]);
|
||||
session_start();
|
||||
}
|
||||
|
||||
function is_logged_in(): bool {
|
||||
if (empty($_SESSION['user_id'])) return false;
|
||||
if (!defined('SESSION_TIMEOUT')) return true;
|
||||
$last = $_SESSION['last_activity'] ?? 0;
|
||||
if (time() - $last > SESSION_TIMEOUT) {
|
||||
session_destroy();
|
||||
return false;
|
||||
}
|
||||
$_SESSION['last_activity'] = time(); // extend only after passing timeout check
|
||||
return true;
|
||||
}
|
||||
|
||||
function require_password_changed(string $redirect = '../auth/change_password.php'): void {
|
||||
if (is_logged_in() && !empty($_SESSION['must_change_password'])) {
|
||||
header('Location: ' . $redirect);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
function get_role(): string {
|
||||
return $_SESSION['role'] ?? 'viewer';
|
||||
}
|
||||
|
||||
function get_user_id(): int {
|
||||
return (int) ($_SESSION['user_id'] ?? 0);
|
||||
}
|
||||
|
||||
function get_user_nama(): string {
|
||||
return $_SESSION['nama'] ?? '';
|
||||
}
|
||||
|
||||
function get_ibadah_id(): ?int {
|
||||
$id = $_SESSION['ibadah_id'] ?? null;
|
||||
return $id ? (int)$id : null;
|
||||
}
|
||||
|
||||
function csrf_token(): string {
|
||||
if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
return $_SESSION['csrf_token'];
|
||||
}
|
||||
|
||||
function require_csrf(): void {
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
return;
|
||||
}
|
||||
|
||||
$token = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
$session_token = $_SESSION['csrf_token'] ?? '';
|
||||
|
||||
if (!$token || !$session_token || !hash_equals($session_token, $token)) {
|
||||
http_response_code(403);
|
||||
die(json_encode(['status' => 'error', 'message' => 'Token CSRF tidak valid']));
|
||||
}
|
||||
}
|
||||
|
||||
// Hierarki: administrator > operator > viewer
|
||||
function has_role(string $required): bool {
|
||||
$hierarchy = ['viewer' => 0, 'operator' => 1, 'administrator' => 2];
|
||||
$current = get_role();
|
||||
return ($hierarchy[$current] ?? 0) >= ($hierarchy[$required] ?? 99);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gunakan di API endpoint. Jika tidak login / role kurang, die() dengan JSON error.
|
||||
* Contoh: require_auth('operator');
|
||||
*/
|
||||
function require_auth(string $min_role = 'viewer'): void {
|
||||
if (!is_logged_in()) {
|
||||
http_response_code(401);
|
||||
die(json_encode(['status' => 'error', 'message' => 'Tidak terautentikasi']));
|
||||
}
|
||||
if (!has_role($min_role)) {
|
||||
http_response_code(403);
|
||||
die(json_encode(['status' => 'error', 'message' => 'Akses ditolak']));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
<?php
|
||||
// auth/login.php — Halaman login standalone
|
||||
require_once '../config.php';
|
||||
require_once '../auth/helper.php';
|
||||
|
||||
if (is_logged_in()) {
|
||||
$dest = (get_role() === 'administrator') ? '../dashboard.php' : '../index.php';
|
||||
header('Location: ' . $dest);
|
||||
exit;
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Masuk — <?= APP_NAME ?></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&display=swap" rel="stylesheet">
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--sb-bg: #1c1612;
|
||||
--body-bg: #f5f0eb;
|
||||
--card-bg: #fafaf9;
|
||||
--card-border:#ddd8d2;
|
||||
--accent: #0d7490;
|
||||
--accent-h: #0a5f7a;
|
||||
--accent-dim: rgba(13,116,144,.06);
|
||||
--text: #201515;
|
||||
--text-sec: #3d3530;
|
||||
--text-muted: #7a7067;
|
||||
--danger: #ef4444;
|
||||
--danger-dim: rgba(239,68,68,.08);
|
||||
--danger-bdr: rgba(239,68,68,.25);
|
||||
--border: #ddd8d2;
|
||||
--border-f: #0d7490;
|
||||
--radius: 12px;
|
||||
--shadow: 0 4px 12px rgba(32,21,21,.08), 0 1px 2px rgba(32,21,21,.04);
|
||||
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
font-family: var(--font);
|
||||
background: var(--body-bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ── Layout: 2 kolom ────────────────────────────────── */
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page { grid-template-columns: 1fr; }
|
||||
.brand-side { display: none; }
|
||||
}
|
||||
|
||||
/* ── Sisi Kiri (brand) ──────────────────────────────── */
|
||||
.brand-side {
|
||||
background: var(--sb-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 48px 52px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.brand-side::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse 60% 50% at 30% 20%, rgba(14,165,233,.18) 0%, transparent 70%),
|
||||
radial-gradient(ellipse 50% 60% at 80% 80%, rgba(56,189,248,.12) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.brand-logo-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
background: #0d7490;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.brand-logo-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.brand-logo-sub {
|
||||
font-size: 11px;
|
||||
color: #a89f96;
|
||||
}
|
||||
|
||||
.brand-body {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.brand-headline {
|
||||
font-size: 30px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
line-height: 1.35;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.brand-headline span {
|
||||
color: #a89f96;
|
||||
}
|
||||
|
||||
.brand-desc {
|
||||
font-size: 14px;
|
||||
color: #a89f96;
|
||||
line-height: 1.7;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.brand-stats {
|
||||
display: flex;
|
||||
gap: 28px;
|
||||
margin-top: 36px;
|
||||
}
|
||||
|
||||
.brand-stat-num {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.brand-stat-label {
|
||||
font-size: 11px;
|
||||
color: #a89f96;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.brand-footer {
|
||||
font-size: 11px;
|
||||
color: #a89f96;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ── Sisi Kanan (form) ──────────────────────────────── */
|
||||
.form-side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 32px;
|
||||
background: var(--body-bg);
|
||||
}
|
||||
|
||||
.form-box {
|
||||
width: min(420px, 100%);
|
||||
}
|
||||
|
||||
.form-header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.form-welcome {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.form-welcome-sub {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Card ────────────────────────────────────────────── */
|
||||
.login-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 28px 32px 32px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
/* ── Alert ───────────────────────────────────────────── */
|
||||
.alert {
|
||||
display: none;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
background: var(--danger-dim);
|
||||
border: 1px solid var(--danger-bdr);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 13px;
|
||||
color: var(--danger);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.alert.show { display: flex; }
|
||||
.alert-icon { font-size: 16px; flex-shrink: 0; margin-top: 1px; }
|
||||
|
||||
/* ── Form Groups ─────────────────────────────────────── */
|
||||
.form-group { margin-bottom: 18px; }
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-sec);
|
||||
margin-bottom: 7px;
|
||||
letter-spacing: .2px;
|
||||
}
|
||||
|
||||
.input-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 11px 14px;
|
||||
background: #ede8e2;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 9px;
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color .2s, background .2s, box-shadow .2s;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
border-color: var(--accent);
|
||||
background: #fafaf9;
|
||||
box-shadow: 0 0 0 3px rgba(13,116,144,.08);
|
||||
}
|
||||
|
||||
.form-group input::placeholder { color: var(--text-muted); }
|
||||
|
||||
/* Password toggle */
|
||||
.input-wrap input[type="password"],
|
||||
.input-wrap input[type="text"] {
|
||||
padding-right: 44px;
|
||||
}
|
||||
|
||||
.pw-toggle {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-size: 17px;
|
||||
line-height: 1;
|
||||
padding: 4px;
|
||||
border-radius: 5px;
|
||||
transition: color .2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.pw-toggle:hover { color: var(--text-sec); }
|
||||
|
||||
/* ── Submit button ───────────────────────────────────── */
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 9px;
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-top: 6px;
|
||||
transition: background .2s, transform .1s, box-shadow .2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
letter-spacing: .2px;
|
||||
box-shadow: none;
|
||||
}
|
||||
.btn-login:hover:not(:disabled) {
|
||||
background: var(--accent-h);
|
||||
box-shadow: none;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.btn-login:active:not(:disabled) { transform: translateY(0); }
|
||||
.btn-login:disabled { opacity: .6; cursor: not-allowed; transform: none; box-shadow: none; }
|
||||
|
||||
.btn-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255,255,255,.35);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin .7s linear infinite;
|
||||
display: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-login.loading .btn-spinner { display: block; }
|
||||
.btn-login.loading .btn-label::after { content: '...'; }
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── Divider ─────────────────────────────────────────── */
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 22px 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.divider::before, .divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
/* ── Public link ─────────────────────────────────────── */
|
||||
.public-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 11px;
|
||||
background: transparent;
|
||||
color: var(--text-sec);
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 9px;
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: border-color .2s, color .2s, background .2s;
|
||||
}
|
||||
.public-btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
.public-btn-icon { font-size: 16px; }
|
||||
|
||||
/* ── Footer note ─────────────────────────────────────── */
|
||||
.form-note {
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Lucide Icons */
|
||||
.lucide {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke-width: 2px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.brand-logo-icon .lucide {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: #ffffff;
|
||||
}
|
||||
.brand-stat-num .lucide {
|
||||
color: #16a34a;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.alert-icon .lucide {
|
||||
color: var(--danger);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<!-- ═══ SISI KIRI: Brand ═══ -->
|
||||
<div class="brand-side">
|
||||
<div class="brand-logo">
|
||||
<div class="brand-logo-icon"><i data-lucide="map"></i></div>
|
||||
<div>
|
||||
<div class="brand-logo-name"><?= APP_NAME ?></div>
|
||||
<div class="brand-logo-sub">Sistem Pemetaan Kemiskinan</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="brand-body">
|
||||
<div class="brand-headline">
|
||||
Pemetaan Kemiskinan<br>Berbasis <span>Partisipasi</span><br>Rumah Ibadah
|
||||
</div>
|
||||
<div class="brand-desc">
|
||||
Platform kolaboratif untuk memetakan, memantau, dan menjangkau keluarga miskin
|
||||
melalui jaringan rumah ibadah di seluruh wilayah.
|
||||
</div>
|
||||
<div class="brand-stats">
|
||||
<div>
|
||||
<div class="brand-stat-num">GIS</div>
|
||||
<div class="brand-stat-label">Berbasis Peta</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="brand-stat-num">RT</div>
|
||||
<div class="brand-stat-label">Data Real-time</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="brand-stat-num"><i data-lucide="check"></i></div>
|
||||
<div class="brand-stat-label">Multi-peran</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="brand-footer">
|
||||
© <?= date('Y') ?> <?= APP_NAME ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ SISI KANAN: Form Login ═══ -->
|
||||
<div class="form-side">
|
||||
<div class="form-box">
|
||||
|
||||
<div class="form-header">
|
||||
<div class="form-welcome">Selamat datang 👋</div>
|
||||
<div class="form-welcome-sub">Masuk ke akun Anda untuk mengelola data pemetaan kemiskinan.</div>
|
||||
</div>
|
||||
|
||||
<div class="login-card">
|
||||
<div class="alert" id="alertMsg" role="alert">
|
||||
<span class="alert-icon"><i data-lucide="alert-triangle"></i></span>
|
||||
<span id="alertText"></span>
|
||||
</div>
|
||||
|
||||
<form id="loginForm" onsubmit="doLogin(event)" novalidate>
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username"
|
||||
autocomplete="username"
|
||||
placeholder="Masukkan username Anda"
|
||||
required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<div class="input-wrap">
|
||||
<input type="password" id="password" name="password"
|
||||
autocomplete="current-password"
|
||||
placeholder="Masukkan password Anda"
|
||||
required>
|
||||
<input type="password" style="display:none;"> <!-- Prevent autofill bug -->
|
||||
<button type="button" class="pw-toggle" id="pwToggle"
|
||||
aria-label="Tampilkan / Sembunyikan password"
|
||||
onclick="togglePw()">
|
||||
<i data-lucide="eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-login" id="btnLogin">
|
||||
<div class="btn-spinner" id="btnSpinner"></div>
|
||||
<span class="btn-label">Masuk</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="divider">atau</div>
|
||||
|
||||
<a href="../index.php" class="public-btn">
|
||||
<span class="public-btn-icon"><i data-lucide="map"></i></span>
|
||||
Lihat Peta sebagai Pengunjung
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="form-note">
|
||||
Lupa password? Hubungi administrator sistem Anda.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function togglePw() {
|
||||
const pw = document.getElementById('password');
|
||||
const btn = document.getElementById('pwToggle');
|
||||
const show = pw.type === 'password';
|
||||
pw.type = show ? 'text' : 'password';
|
||||
btn.innerHTML = show ? '<i data-lucide="eye-off"></i>' : '<i data-lucide="eye"></i>';
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
}
|
||||
// Boot Lucide on load
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
|
||||
async function doLogin(e) {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('btnLogin');
|
||||
const alert = document.getElementById('alertMsg');
|
||||
const alertTx = document.getElementById('alertText');
|
||||
|
||||
btn.disabled = true;
|
||||
btn.classList.add('loading');
|
||||
alert.classList.remove('show');
|
||||
|
||||
const fd = new FormData(document.getElementById('loginForm'));
|
||||
try {
|
||||
const res = await fetch('../api/auth/login.php', { method: 'POST', body: fd });
|
||||
const j = await res.json();
|
||||
if (j.status === 'success') {
|
||||
if (j.data.must_change_password) {
|
||||
window.location.href = 'change_password.php';
|
||||
} else {
|
||||
window.location.href = j.redirect || '../index.php';
|
||||
}
|
||||
} else {
|
||||
alertTx.textContent = j.message;
|
||||
alert.classList.add('show');
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('loading');
|
||||
document.getElementById('password').value = '';
|
||||
document.getElementById('password').focus();
|
||||
}
|
||||
} catch (_) {
|
||||
alertTx.textContent = 'Gagal menghubungi server. Periksa koneksi Anda.';
|
||||
alert.classList.add('show');
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
// config.php — Konfigurasi aplikasi WebGIS Poverty Mapping
|
||||
// Edit CENTER_LAT, CENTER_LNG, ZOOM_LEVEL untuk wilayah baru
|
||||
|
||||
define('APP_NAME', 'WebGIS Poverty Mapping');
|
||||
define('CENTER_LAT', -0.0557);
|
||||
define('CENTER_LNG', 109.3487);
|
||||
define('ZOOM_LEVEL', 15);
|
||||
|
||||
define('MAP_MIN_LAT', -0.35);
|
||||
define('MAP_MAX_LAT', 0.20);
|
||||
define('MAP_MIN_LNG', 109.15);
|
||||
define('MAP_MAX_LNG', 109.60);
|
||||
|
||||
define('SESSION_TIMEOUT', 7200); // 2 jam dalam detik
|
||||
define('MAX_LOGIN_ATTEMPTS', 5);
|
||||
define('LOCKOUT_MINUTES', 15);
|
||||
|
||||
define('DB_HOST', getenv('DB_HOST') ?: 'localhost');
|
||||
define('DB_USER', getenv('DB_USER') ?: 'root');
|
||||
define('DB_PASS', getenv('DB_PASS') ?: '');
|
||||
define('DB_NAME', getenv('DB_WEBGIS_NAME') ?: (getenv('DB_NAME') ?: 'db_webgis'));
|
||||
define('DB_PORT', (int)(getenv('DB_PORT') ?: 3307));
|
||||
@@ -0,0 +1,683 @@
|
||||
/* css/admin.css — Cal.com Design System */
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
/* Layout */
|
||||
--sb-w: 240px;
|
||||
/* Sidebar — coffee dark, satu-satunya dark zone */
|
||||
--sb-bg: #1c1612;
|
||||
--sb-active-bg: rgba(255,255,255,.08);
|
||||
--sb-active-bar: #0d7490;
|
||||
--sb-text: #a89f96;
|
||||
--sb-text-hover: #ffffff;
|
||||
--sb-section-label: #5c5148;
|
||||
/* Header */
|
||||
--hd-bg: #fafaf9;
|
||||
--hd-border: #ddd8d2;
|
||||
/* Page */
|
||||
--body-bg: #f5f0eb;
|
||||
/* Cards */
|
||||
--card-bg: #fafaf9;
|
||||
--card-border: #ddd8d2;
|
||||
--card-shadow: 0 1px 2px rgba(32,21,21,.06);
|
||||
/* Text */
|
||||
--text-primary: #201515;
|
||||
--text-secondary: #3d3530;
|
||||
--text-muted: #7a7067;
|
||||
/* Accent */
|
||||
--accent: #0d7490;
|
||||
--accent-hover: #0a5f7a;
|
||||
/* Inset surface */
|
||||
--canvas-inset: #ede8e2;
|
||||
/* Radii */
|
||||
--radius: 12px;
|
||||
--radius-btn: 8px;
|
||||
/* Font */
|
||||
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--font-mono: var(--font);
|
||||
}
|
||||
|
||||
html, body { height: 100%; font-family: var(--font); background: var(--body-bg); color: var(--text-primary); }
|
||||
|
||||
/* ── Layout Shell ───────────────────────────────────── */
|
||||
.al-wrap { display: flex; height: 100vh; overflow: hidden; }
|
||||
|
||||
/* ── Sidebar ─────────────────────────────────────────── */
|
||||
.al-sidebar {
|
||||
width: var(--sb-w);
|
||||
min-width: var(--sb-w);
|
||||
background: var(--sb-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
transition: width .3s cubic-bezier(.4,0,.2,1), min-width .3s;
|
||||
flex-shrink: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
.al-sidebar.collapsed { width: 60px; min-width: 60px; }
|
||||
.al-sidebar.collapsed .sb-label,
|
||||
.al-sidebar.collapsed .sb-section-label,
|
||||
.al-sidebar.collapsed .sb-logo-text,
|
||||
.al-sidebar.collapsed .sb-toggle-label { display: none; }
|
||||
.al-sidebar.collapsed .sb-item { justify-content: center; padding: 0 18px; }
|
||||
.al-sidebar.collapsed .sb-icon { margin-right: 0; }
|
||||
|
||||
.sb-logo {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 18px 16px 14px;
|
||||
border-bottom: 1px solid rgba(255,255,255,.06);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sb-logo-icon {
|
||||
width: 36px; height: 36px; border-radius: 9px;
|
||||
background: #0d7490;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 18px; flex-shrink: 0;
|
||||
}
|
||||
.sb-logo-text { overflow: hidden; }
|
||||
.sb-logo-name { font-size: 13px; font-weight: 600; color: #ffffff; line-height: 1.3; white-space: nowrap; letter-spacing: -0.3px; }
|
||||
.sb-logo-sub { font-size: 10px; color: var(--sb-text); white-space: nowrap; }
|
||||
|
||||
.sb-nav { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 8px 0; }
|
||||
.sb-nav::-webkit-scrollbar { width: 4px; }
|
||||
.sb-nav::-webkit-scrollbar-thumb { background: rgba(255,255,255,.1); border-radius: 4px; }
|
||||
|
||||
.sb-section-label {
|
||||
font-size: 10px; font-weight: 600; color: var(--sb-section-label);
|
||||
text-transform: uppercase; letter-spacing: .8px;
|
||||
padding: 12px 16px 4px; white-space: nowrap;
|
||||
}
|
||||
|
||||
.sb-item {
|
||||
display: flex; align-items: center; gap: 0;
|
||||
padding: 0 10px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
margin: 1px 8px;
|
||||
cursor: pointer;
|
||||
color: var(--sb-text);
|
||||
font-size: 13px; font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: background .15s, color .15s;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
}
|
||||
.sb-item:hover { background: rgba(255,255,255,.06); color: var(--sb-text-hover); }
|
||||
.sb-item.active { background: rgba(255,255,255,.08); color: #ffffff; }
|
||||
.sb-item.active::before {
|
||||
content: ''; position: absolute; left: -8px; top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 3px; height: 22px; border-radius: 0 3px 3px 0;
|
||||
background: #0d7490;
|
||||
}
|
||||
.sb-icon { font-size: 16px; width: 32px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; }
|
||||
.sb-label { overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.sb-footer {
|
||||
padding: 10px 8px;
|
||||
border-top: 1px solid rgba(255,255,255,.06);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sb-toggle-btn {
|
||||
display: flex; align-items: center; gap: 0;
|
||||
padding: 0 10px; height: 38px; width: 100%;
|
||||
background: transparent; border: none;
|
||||
border-radius: 8px; cursor: pointer;
|
||||
color: var(--sb-text); font-size: 13px; font-weight: 500;
|
||||
font-family: var(--font);
|
||||
transition: background .15s, color .15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sb-toggle-btn:hover { background: rgba(255,255,255,.06); color: var(--sb-text-hover); }
|
||||
.sb-toggle-icon { font-size: 16px; width: 32px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; transition: transform .3s; }
|
||||
.al-sidebar.collapsed .sb-toggle-icon { transform: rotate(180deg); }
|
||||
.sb-toggle-label { overflow: hidden; }
|
||||
|
||||
/* ── Main Area ──────────────────────────────────────── */
|
||||
.al-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
|
||||
|
||||
/* ── Header ─────────────────────────────────────────── */
|
||||
.al-header {
|
||||
height: 60px; background: var(--hd-bg);
|
||||
border-bottom: 1px solid var(--hd-border);
|
||||
display: flex; align-items: center;
|
||||
padding: 0 24px; gap: 16px;
|
||||
flex-shrink: 0;
|
||||
z-index: 40;
|
||||
}
|
||||
.hd-title-block { flex: 1; min-width: 0; }
|
||||
.hd-title { font-size: 18px; font-weight: 600; color: var(--text-primary); line-height: 1.2; letter-spacing: -0.3px; }
|
||||
.hd-subtitle { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
|
||||
.hd-right { display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
|
||||
|
||||
.hd-status {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: 12px; font-weight: 500; color: #16a34a;
|
||||
}
|
||||
.hd-status-dot {
|
||||
width: 7px; height: 7px; border-radius: 50%; background: #16a34a;
|
||||
animation: pulse-dot 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse-dot { 0%,100%{opacity:1;} 50%{opacity:.4;} }
|
||||
|
||||
.hd-notif-btn {
|
||||
position: relative; width: 36px; height: 36px;
|
||||
background: transparent; border: 1px solid var(--card-border);
|
||||
border-radius: 9px; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 16px; color: var(--text-secondary);
|
||||
transition: background .15s;
|
||||
}
|
||||
.hd-notif-btn:hover { background: #ede8e2; }
|
||||
.hd-notif-badge {
|
||||
position: absolute; top: -5px; right: -5px;
|
||||
min-width: 18px; height: 18px; border-radius: 9px;
|
||||
background: #ef4444; color: #fff;
|
||||
font-size: 10px; font-weight: 700;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 0 4px; border: 2px solid var(--hd-bg);
|
||||
}
|
||||
|
||||
/* ── Notification Panel ─────────────────────────────────────────────── */
|
||||
.notif-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.notif-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
width: 300px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(32,21,21,.14);
|
||||
z-index: 500;
|
||||
overflow: hidden;
|
||||
}
|
||||
.notif-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 13px 18px 11px;
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
}
|
||||
.notif-panel-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.notif-panel-close {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
border-radius: 5px;
|
||||
transition: background .15s;
|
||||
}
|
||||
.notif-panel-close:hover { background: var(--card-border); }
|
||||
.notif-panel-close .lucide { width: 15px; height: 15px; }
|
||||
.notif-list {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.notif-loading,
|
||||
.notif-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 28px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.notif-empty .lucide { width: 18px; height: 18px; opacity: .6; }
|
||||
.notif-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 11px 18px;
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
text-decoration: none;
|
||||
color: var(--text-primary);
|
||||
transition: background .15s;
|
||||
}
|
||||
.notif-item:last-child { border-bottom: none; }
|
||||
.notif-item:hover { background: #f0ebe4; }
|
||||
.notif-item-icon {
|
||||
width: 30px; height: 30px;
|
||||
border-radius: 7px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.notif-item-icon.type-pending_verif { background: rgba(234,179,8,.12); color: #ca8a04; }
|
||||
.notif-item-icon.type-donatur { background: rgba(59,130,246,.12); color: #2563eb; }
|
||||
.notif-item-icon.type-kebutuhan { background: rgba(239,68,68,.12); color: #dc2626; }
|
||||
.notif-item-icon.type-belum_ditangani { background: rgba(107,114,128,.12); color: #4b5563; }
|
||||
.notif-item-icon .lucide { width: 15px; height: 15px; }
|
||||
.notif-item-text {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.notif-item-count {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 2px 7px;
|
||||
border-radius: 20px;
|
||||
background: #f0ebe4;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hd-user {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 6px 10px; border-radius: 9px;
|
||||
border: 1px solid var(--card-border);
|
||||
cursor: default; transition: background .15s;
|
||||
}
|
||||
.hd-avatar {
|
||||
width: 36px; height: 36px; border-radius: 50%;
|
||||
background: #ede8e2; border: 1px solid #ddd8d2;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 13px; font-weight: 600; color: #201515;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.hd-user-name { font-size: 13px; font-weight: 600; color: var(--text-primary); }
|
||||
.hd-user-role { font-size: 11px; color: var(--text-muted); }
|
||||
|
||||
.hd-logout-btn {
|
||||
background: transparent; border: none;
|
||||
color: var(--text-muted); cursor: pointer; font-size: 15px;
|
||||
padding: 4px; border-radius: 6px;
|
||||
transition: color .15s, background .15s;
|
||||
line-height: 1; text-decoration: none;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.hd-logout-btn:hover { color: #ef4444; background: #fef2f2; }
|
||||
|
||||
.hd-hamburger {
|
||||
width: 36px; height: 36px; background: transparent;
|
||||
border: none; cursor: pointer; border-radius: 8px;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 5px;
|
||||
transition: background .15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.hd-hamburger:hover { background: #ede8e2; }
|
||||
.hd-hamburger span {
|
||||
display: block; width: 18px; height: 2px;
|
||||
background: var(--text-secondary); border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ── Content Area ─────────────────────────────────────── */
|
||||
.al-content { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 24px; }
|
||||
.al-content::-webkit-scrollbar { width: 6px; }
|
||||
.al-content::-webkit-scrollbar-thumb { background: #ddd8d2; border-radius: 6px; }
|
||||
|
||||
/* ── Dark Footer ─────────────────────────────────────── */
|
||||
.al-footer {
|
||||
background: #1c1612;
|
||||
color: #a89f96;
|
||||
font-size: 13px; font-weight: 400;
|
||||
padding: 14px 24px;
|
||||
border-top: 1px solid #130e0b;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.al-footer a { color: #a89f96; text-decoration: none; }
|
||||
.al-footer a:hover { color: #ffffff; }
|
||||
|
||||
/* ── Stats Cards ────────────────────────────────────── */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.stat-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
box-shadow: var(--card-shadow);
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
}
|
||||
.stat-card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
|
||||
.stat-icon {
|
||||
width: 40px; height: 40px; border-radius: 10px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 20px; flex-shrink: 0;
|
||||
background: #ede8e2;
|
||||
}
|
||||
.stat-icon.teal { background: #f0fdf4; }
|
||||
.stat-icon.purple { background: #faf5ff; }
|
||||
.stat-icon.green { background: #f0fdf4; }
|
||||
.stat-icon.slate { background: #ede8e2; }
|
||||
.stat-icon.amber { background: #fffbeb; }
|
||||
.stat-icon.blue { background: #eff6ff; }
|
||||
|
||||
.stat-label { font-size: 12px; font-weight: 500; color: var(--text-muted); line-height: 1.4; }
|
||||
.stat-value { font-size: 32px; font-weight: 600; color: var(--text-primary); letter-spacing: -1px; line-height: 1; }
|
||||
.stat-footer { font-size: 12px; color: var(--text-muted); font-weight: 500; text-decoration: none; }
|
||||
.stat-footer:hover { color: var(--text-primary); text-decoration: underline; }
|
||||
|
||||
/* ── Dashboard Grid ─────────────────────────────────── */
|
||||
.dash-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 340px;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 1300px) { .dash-grid { grid-template-columns: 1fr 1fr; } .dash-right { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(3,1fr); gap:16px; } }
|
||||
@media (max-width: 900px) { .dash-grid { grid-template-columns: 1fr; } .dash-right { grid-template-columns: 1fr; } }
|
||||
|
||||
.dash-charts { display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.dash-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
.dash-card-title {
|
||||
font-size: 15px; font-weight: 600; letter-spacing: -0.3px;
|
||||
color: var(--text-primary); margin-bottom: 16px;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
}
|
||||
.dash-card-subtitle { font-size: 11px; color: var(--text-muted); font-weight: 400; }
|
||||
.chart-wrap { position: relative; height: 220px; }
|
||||
|
||||
/* ── Side Panel ──────────────────────────────────────── */
|
||||
.dash-right { display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.dash-right .notif-item {
|
||||
display: flex; align-items: flex-start; gap: 10px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--hd-border);
|
||||
}
|
||||
.dash-right .notif-item:last-of-type { border-bottom: none; }
|
||||
.notif-dot {
|
||||
width: 32px; height: 32px; border-radius: 8px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 14px; flex-shrink: 0;
|
||||
}
|
||||
.notif-dot.danger { background: #fef2f2; }
|
||||
.notif-dot.warn { background: #fffbeb; }
|
||||
.notif-dot.info { background: #eff6ff; }
|
||||
.notif-text { flex: 1; min-width: 0; }
|
||||
.notif-title { font-size: 12px; font-weight: 600; color: var(--text-primary); }
|
||||
.notif-body { font-size: 11px; color: var(--text-secondary); margin-top: 2px; line-height: 1.4; }
|
||||
.notif-time { font-size: 10px; color: var(--text-muted); white-space: nowrap; flex-shrink: 0; margin-top: 2px; }
|
||||
|
||||
.quick-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.quick-btn {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 6px;
|
||||
padding: 12px 8px;
|
||||
background: #ede8e2; border: 1px solid #ddd8d2;
|
||||
border-radius: 9px; cursor: pointer;
|
||||
font-size: 12px; font-weight: 600; color: var(--text-secondary);
|
||||
font-family: var(--font); text-decoration: none;
|
||||
transition: background .15s, border-color .15s, color .15s;
|
||||
text-align: center; line-height: 1.3;
|
||||
}
|
||||
.quick-btn:hover { background: #0d7490; color: #ffffff; border-color: #0d7490; }
|
||||
.quick-btn-icon { font-size: 20px; }
|
||||
|
||||
.need-stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.need-stat {
|
||||
background: #ede8e2; border: 1px solid #ddd8d2;
|
||||
border-radius: 10px; padding: 12px 14px;
|
||||
}
|
||||
.need-stat-val { font-size: 22px; font-weight: 600; letter-spacing: -0.5px; color: var(--text-primary); }
|
||||
.need-stat-val.amber { color: #d97706; }
|
||||
.need-stat-val.blue { color: #2563eb; }
|
||||
.need-stat-val.yellow { color: #ca8a04; }
|
||||
.need-stat-val.green { color: #16a34a; }
|
||||
.need-stat-lbl { font-size: 11px; color: var(--text-muted); margin-top: 2px; line-height: 1.3; }
|
||||
|
||||
/* ── Table ───────────────────────────────────────────── */
|
||||
.table-card {
|
||||
background: var(--card-bg); border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius); box-shadow: var(--card-shadow); overflow: hidden;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.table-card-header {
|
||||
padding: 16px 20px; display: flex; align-items: center; justify-content: space-between;
|
||||
border-bottom: 1px solid var(--hd-border);
|
||||
}
|
||||
.table-card-title { font-size: 15px; font-weight: 600; letter-spacing: -0.3px; color: var(--text-primary); }
|
||||
.table-card-count { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
|
||||
|
||||
.btn-see-all {
|
||||
padding: 6px 14px; background: transparent;
|
||||
border: 1px solid var(--card-border); border-radius: 7px;
|
||||
font-size: 12px; font-weight: 600; color: var(--text-primary);
|
||||
cursor: pointer; font-family: var(--font);
|
||||
text-decoration: none; white-space: nowrap;
|
||||
transition: background .15s;
|
||||
}
|
||||
.btn-see-all:hover { background: #ede8e2; }
|
||||
|
||||
table.admin-table { width: 100%; border-collapse: collapse; }
|
||||
table.admin-table th {
|
||||
text-align: left; font-size: 12px; font-weight: 500;
|
||||
color: var(--text-muted); text-transform: uppercase; letter-spacing: .5px;
|
||||
padding: 12px 16px; background: #ede8e2;
|
||||
border-bottom: 1px solid var(--hd-border);
|
||||
}
|
||||
table.admin-table td { padding: 14px 16px; font-size: 13px; color: var(--text-primary); border-bottom: 1px solid #f3f4f6; vertical-align: middle; }
|
||||
table.admin-table tr:last-child td { border-bottom: none; }
|
||||
table.admin-table tr:hover td { background: #f5f0eb; }
|
||||
|
||||
.progress-bar-wrap { width: 100%; background: #ddd8d2; border-radius: 4px; height: 6px; overflow: hidden; }
|
||||
.progress-bar-fill { height: 100%; border-radius: 4px; background: #0d7490; transition: width .4s ease; }
|
||||
|
||||
/* ── Jenis Badges (pill shape) ───────────────────────── */
|
||||
.jenis-badge {
|
||||
display: inline-block; padding: 3px 10px; border-radius: 9999px;
|
||||
font-size: 11px; font-weight: 500;
|
||||
}
|
||||
.jenis-badge.masjid { background: #f0fdf4; color: #16a34a; }
|
||||
.jenis-badge.mushola { background: #eff6ff; color: #2563eb; }
|
||||
.jenis-badge.gereja { background: #faf5ff; color: #7c3aed; }
|
||||
.jenis-badge.pura { background: #fffbeb; color: #d97706; }
|
||||
.jenis-badge.vihara { background: #fff7ed; color: #ea580c; }
|
||||
.jenis-badge.klenteng { background: #fef2f2; color: #dc2626; }
|
||||
|
||||
.tbl-action-btn {
|
||||
width: 30px; height: 30px; border-radius: 7px;
|
||||
background: transparent; border: 1px solid var(--card-border);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
cursor: pointer; font-size: 14px; color: var(--text-muted);
|
||||
transition: background .15s, color .15s;
|
||||
text-decoration: none; line-height: 1;
|
||||
}
|
||||
.tbl-action-btn:hover { background: #ede8e2; color: #201515; border-color: #ddd8d2; }
|
||||
|
||||
.pagination {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 16px; border-top: 1px solid var(--hd-border);
|
||||
font-size: 12px; color: var(--text-muted);
|
||||
flex-wrap: wrap; gap: 8px;
|
||||
}
|
||||
.page-btns { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.page-btn {
|
||||
min-width: 30px; height: 30px; border-radius: 7px;
|
||||
background: transparent; border: 1px solid var(--card-border);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
cursor: pointer; font-size: 12px; font-weight: 600; color: var(--text-secondary);
|
||||
font-family: var(--font); padding: 0 6px;
|
||||
transition: background .15s;
|
||||
}
|
||||
.page-btn:hover:not(:disabled) { background: #ede8e2; color: #201515; border-color: #ddd8d2; }
|
||||
.page-btn.active { background: #0d7490; border-color: #0d7490; color: #fff; }
|
||||
.page-btn:disabled { opacity: .4; cursor: not-allowed; }
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────────── */
|
||||
.btn-primary {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
padding: 10px 20px; height: 40px;
|
||||
background: #0d7490; color: #ffffff;
|
||||
border: none; border-radius: 8px;
|
||||
font-family: var(--font); font-size: 14px; font-weight: 600;
|
||||
cursor: pointer; text-decoration: none; white-space: nowrap;
|
||||
transition: background .15s;
|
||||
}
|
||||
.btn-primary:hover { background: #0a5f7a; }
|
||||
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
|
||||
|
||||
.btn-secondary {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
padding: 10px 20px; height: 40px;
|
||||
background: #fafaf9; color: #201515;
|
||||
border: 1px solid #ddd8d2; border-radius: 8px;
|
||||
font-family: var(--font); font-size: 14px; font-weight: 600;
|
||||
cursor: pointer; text-decoration: none; white-space: nowrap;
|
||||
transition: background .15s;
|
||||
}
|
||||
.btn-secondary:hover { background: #ede8e2; }
|
||||
|
||||
.btn-danger {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
padding: 10px 20px; height: 40px;
|
||||
background: #ef4444; color: #ffffff;
|
||||
border: none; border-radius: 8px;
|
||||
font-family: var(--font); font-size: 14px; font-weight: 600;
|
||||
cursor: pointer; white-space: nowrap;
|
||||
transition: background .15s;
|
||||
}
|
||||
.btn-danger:hover { background: #dc2626; }
|
||||
|
||||
/* ── Confirm Modal ───────────────────────────────────── */
|
||||
.confirm-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(32,21,21,.55);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 20px; z-index: 9999;
|
||||
}
|
||||
.confirm-box {
|
||||
width: min(420px, 100%);
|
||||
background: #fafaf9;
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 4px 16px rgba(32,21,21,.1);
|
||||
}
|
||||
.confirm-title { font-size: 15px; font-weight: 600; color: var(--text-primary); margin-bottom: 10px; }
|
||||
.confirm-msg { font-size: 13px; color: var(--text-secondary); line-height: 1.6; margin: 0; }
|
||||
.confirm-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
|
||||
|
||||
/* ── Forms & Inputs ──────────────────────────────────── */
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
input[type="email"],
|
||||
input[type="password"],
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
height: 40px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #ddd8d2;
|
||||
border-radius: 8px;
|
||||
color: #201515;
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color .15s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
textarea { height: auto; min-height: 80px; }
|
||||
input:focus, select:focus, textarea:focus { border-color: #0d7490; }
|
||||
input:disabled, select:disabled, textarea:disabled { opacity: .5; cursor: not-allowed; background: #ede8e2; }
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 13px; font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* ── Badge Pills ─────────────────────────────────────── */
|
||||
.badge-pill {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
padding: 3px 10px; border-radius: 9999px;
|
||||
font-size: 12px; font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Kategori Kemiskinan */
|
||||
.badge-sangat-miskin { background: #fef2f2; color: #ef4444; }
|
||||
.badge-miskin { background: #fffbeb; color: #f59e0b; }
|
||||
.badge-hampir-miskin { background: #f0fdf4; color: #16a34a; }
|
||||
/* Status Bantuan */
|
||||
.badge-belum { background: #ede8e2; color: #7a7067; }
|
||||
.badge-proses { background: #eff6ff; color: #3b82f6; }
|
||||
.badge-sudah { background: #f0fdf4; color: #16a34a; }
|
||||
/* Blank Spot / Covered */
|
||||
.badge-blankspot { background: #fef2f2; color: #ef4444; }
|
||||
.badge-covered { background: #f0fdf4; color: #16a34a; }
|
||||
|
||||
/* ── General Utilities ──────────────────────────────── */
|
||||
.text-success { color: #16a34a; }
|
||||
.text-warn { color: #d97706; }
|
||||
.text-danger { color: #dc2626; }
|
||||
.link-muted {
|
||||
font-size: 13px; color: var(--text-primary);
|
||||
font-weight: 500; text-decoration: none;
|
||||
}
|
||||
.link-muted:hover { text-decoration: underline; }
|
||||
.spinner {
|
||||
display: inline-block; width: 14px; height: 14px;
|
||||
border: 2px solid #ddd8d2; border-top-color: #0d7490;
|
||||
border-radius: 50%; animation: spin .7s linear infinite;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── Page header for non-dashboard pages ────────────── */
|
||||
.page-header { margin-bottom: 24px; }
|
||||
.page-header h1 {
|
||||
font-size: 22px; font-weight: 600; letter-spacing: -0.5px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.page-header p { font-size: 14px; color: var(--text-muted); margin-top: 4px; }
|
||||
|
||||
/* ── Lucide Icons Styling ───────────────────────────── */
|
||||
.lucide {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke-width: 2px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.sb-logo-icon .lucide {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #ffffff;
|
||||
}
|
||||
.sb-icon .lucide {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.sb-toggle-icon .lucide {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.hd-notif-btn .lucide {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
.hd-logout-btn .lucide {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
require_once 'auth/helper.php';
|
||||
require_once 'koneksi.php';
|
||||
|
||||
if (!is_logged_in() || !has_role('administrator')) {
|
||||
header('Location: auth/login.php'); exit;
|
||||
}
|
||||
require_password_changed('auth/change_password.php');
|
||||
|
||||
$page_title = 'Dashboard Administrator';
|
||||
$page_subtitle = 'Ringkasan situasi kemiskinan di wilayah Anda';
|
||||
$active_nav = 'dashboard';
|
||||
include 'includes/page-start.php';
|
||||
?>
|
||||
|
||||
<!-- ── Stats Cards ──────────────────────────────────────── -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-top">
|
||||
<div class="stat-icon teal"><i data-lucide="map-pin"></i></div>
|
||||
<div class="stat-label">Total Rumah Ibadah Aktif</div>
|
||||
</div>
|
||||
<div class="stat-value" id="sc-ibadah"><span class="spinner"></span></div>
|
||||
<a href="pages/map.php" class="stat-footer">Lihat di peta →</a>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-top">
|
||||
<div class="stat-icon purple"><i data-lucide="home"></i></div>
|
||||
<div class="stat-label">Total KK Miskin Aktif</div>
|
||||
</div>
|
||||
<div class="stat-value" id="sc-kk"><span class="spinner"></span></div>
|
||||
<a href="pages/map.php" class="stat-footer">Lihat detail →</a>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-top">
|
||||
<div class="stat-icon green"><i data-lucide="users"></i></div>
|
||||
<div class="stat-label">Total Jiwa Miskin Aktif</div>
|
||||
</div>
|
||||
<div class="stat-value" id="sc-jiwa"><span class="spinner"></span></div>
|
||||
<a href="pages/map.php" class="stat-footer">Lihat detail →</a>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-top">
|
||||
<div class="stat-icon slate"><i data-lucide="check-circle-2"></i></div>
|
||||
<div class="stat-label">Total Jiwa Terjangkau</div>
|
||||
</div>
|
||||
<div class="stat-value" id="sc-terjangkau"><span class="spinner"></span></div>
|
||||
<a href="pages/map.php" class="stat-footer">Lihat detail →</a>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-top">
|
||||
<div class="stat-icon amber"><i data-lucide="alert-triangle"></i></div>
|
||||
<div class="stat-label">Total Jiwa Blank Spot</div>
|
||||
</div>
|
||||
<div class="stat-value" id="sc-blankspot"><span class="spinner"></span></div>
|
||||
<a href="pages/map.php" class="stat-footer">Filter di peta →</a>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-top">
|
||||
<div class="stat-icon blue"><i data-lucide="trending-up"></i></div>
|
||||
<div class="stat-label">Persentase Cakupan</div>
|
||||
</div>
|
||||
<div class="stat-value" id="sc-pct" style="font-size:22px;"><span class="spinner"></span></div>
|
||||
<a href="pages/map.php" class="stat-footer">Lihat detail →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Charts + Side Panel ──────────────────────────────── -->
|
||||
<div class="dash-grid">
|
||||
|
||||
<div class="dash-charts">
|
||||
|
||||
<!-- Donut: Sebaran Kategori -->
|
||||
<div class="dash-card">
|
||||
<div class="dash-card-title">
|
||||
Sebaran Kategori Kemiskinan
|
||||
<span class="dash-card-subtitle" id="donut-total"></span>
|
||||
</div>
|
||||
<div class="chart-wrap"><canvas id="chartKategori"></canvas></div>
|
||||
<div id="donut-legend" style="margin-top:14px;display:flex;flex-direction:column;gap:6px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Line: Tren Cakupan -->
|
||||
<div class="dash-card">
|
||||
<div class="dash-card-title">
|
||||
Tren Cakupan Penjangkauan
|
||||
<span class="dash-card-subtitle">(6 Bulan Terakhir)</span>
|
||||
</div>
|
||||
<div class="chart-wrap" style="height:260px;"><canvas id="chartTren"></canvas></div>
|
||||
<div id="tren-note" style="margin-top:8px;font-size:11px;color:var(--text-muted);">
|
||||
Persentase Cakupan = (Jiwa Terjangkau / Total Jiwa) × 100%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Right Panel -->
|
||||
<div class="dash-right">
|
||||
|
||||
<!-- Peringatan & Notifikasi -->
|
||||
<div class="dash-card">
|
||||
<div class="dash-card-title">
|
||||
Peringatan & Notifikasi
|
||||
<span id="dash-notif-badge"
|
||||
style="display:none;background:#ef4444;color:#fff;font-size:10px;font-weight:600;padding:1px 7px;border-radius:10px;"></span>
|
||||
</div>
|
||||
<div id="dash-notif-list">
|
||||
<div style="font-size:12px;color:var(--text-muted);">
|
||||
<span class="spinner"></span> Memuat...
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="link-muted" disabled style="display:block;text-align:center;margin-top:12px;border:0;background:transparent;width:100%;cursor:not-allowed;">
|
||||
Semua notifikasi aktif tampil di panel ini
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Aksi Cepat -->
|
||||
<div class="dash-card">
|
||||
<div class="dash-card-title">Aksi Cepat</div>
|
||||
<div class="quick-grid">
|
||||
<a href="pages/map.php" class="quick-btn">
|
||||
<span class="quick-btn-icon"><i data-lucide="map-pin"></i></span> Buka Peta
|
||||
</a>
|
||||
<a href="pages/penduduk.php" class="quick-btn">
|
||||
<span class="quick-btn-icon"><i data-lucide="home"></i></span> Kelola Penduduk
|
||||
</a>
|
||||
<a href="pages/import.php" class="quick-btn" id="qa-import">
|
||||
<span class="quick-btn-icon"><i data-lucide="file-input"></i></span> Import CSV Penduduk
|
||||
</a>
|
||||
<a href="pages/analisis.php" class="quick-btn" id="qa-recalc">
|
||||
<span class="quick-btn-icon"><i data-lucide="refresh-cw"></i></span> Hitung Ulang Semua
|
||||
</a>
|
||||
<a href="pages/users.php" class="quick-btn">
|
||||
<span class="quick-btn-icon"><i data-lucide="users"></i></span> Manajemen Pengguna
|
||||
</a>
|
||||
<a href="pages/laporan.php" class="quick-btn">
|
||||
<span class="quick-btn-icon"><i data-lucide="file-output"></i></span> Export Laporan
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistik Kebutuhan -->
|
||||
<div class="dash-card">
|
||||
<div class="dash-card-title">
|
||||
Statistik Kebutuhan
|
||||
<span class="dash-card-subtitle" id="need-date"></span>
|
||||
</div>
|
||||
<div class="need-stats-grid">
|
||||
<div class="need-stat">
|
||||
<div class="need-stat-val amber" id="ns-kk-open">—</div>
|
||||
<div class="need-stat-lbl">KK dengan Kebutuhan Belum Terpenuhi</div>
|
||||
</div>
|
||||
<div class="need-stat">
|
||||
<div class="need-stat-val blue" id="ns-belum">—</div>
|
||||
<div class="need-stat-lbl">Total Kebutuhan Belum Terpenuhi</div>
|
||||
</div>
|
||||
<div class="need-stat">
|
||||
<div class="need-stat-val yellow" id="ns-proses">—</div>
|
||||
<div class="need-stat-lbl">Dalam Proses</div>
|
||||
</div>
|
||||
<div class="need-stat">
|
||||
<div class="need-stat-val green" id="ns-terpenuhi">—</div>
|
||||
<div class="need-stat-lbl">Terpenuhi</div>
|
||||
</div>
|
||||
</div>
|
||||
<a href="papan-kebutuhan.php" class="link-muted" style="display:block;text-align:right;margin-top:10px;">
|
||||
Lihat detail kebutuhan →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div><!-- .dash-right -->
|
||||
</div><!-- .dash-grid -->
|
||||
|
||||
<!-- ── Tabel Rekapitulasi ────────────────────────────────── -->
|
||||
<div class="table-card">
|
||||
<div class="table-card-header">
|
||||
<div>
|
||||
<div class="table-card-title">Rekapitulasi per Rumah Ibadah</div>
|
||||
<div class="table-card-count" id="rekap-count"></div>
|
||||
</div>
|
||||
<a href="pages/map.php" class="btn-see-all">Lihat semua</a>
|
||||
</div>
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:44px;">No</th>
|
||||
<th>Nama Rumah Ibadah</th>
|
||||
<th>Jenis</th>
|
||||
<th style="text-align:right;">KK Binaan</th>
|
||||
<th style="text-align:right;">Jiwa Binaan</th>
|
||||
<th style="min-width:160px;">% Sudah Ditangani</th>
|
||||
<th style="width:88px;text-align:center;">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="rekapTbody">
|
||||
<tr><td colspan="7" style="text-align:center;padding:28px;color:var(--text-muted);">
|
||||
<span class="spinner"></span> Memuat data...
|
||||
</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination">
|
||||
<span id="rekap-info"></span>
|
||||
<div class="page-btns" id="rekap-pages"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ── Helpers ───────────────────────────────────────────────
|
||||
function fmt(n) { return Number(n || 0).toLocaleString('id-ID'); }
|
||||
function fmtP(n) { return Number(n || 0).toFixed(2).replace('.', ',') + '%'; }
|
||||
function esc(s) { const d = document.createElement('div'); d.appendChild(document.createTextNode(s ?? '')); return d.innerHTML; }
|
||||
function now_label() {
|
||||
const d = new Date();
|
||||
const M = ['Jan','Feb','Mar','Apr','Mei','Jun','Jul','Agt','Sep','Okt','Nov','Des'];
|
||||
return `Data per ${d.getDate()} ${M[d.getMonth()]} ${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
// ── Load Stats ────────────────────────────────────────────
|
||||
async function loadStats() {
|
||||
let j;
|
||||
try {
|
||||
const r = await fetch('api/stats/ambil.php?_=' + Date.now());
|
||||
j = await r.json();
|
||||
} catch(e) { return; }
|
||||
if (j.status !== 'success') return;
|
||||
const d = j.data;
|
||||
|
||||
document.getElementById('sc-ibadah').textContent = fmt(d.total_ibadah);
|
||||
document.getElementById('sc-kk').textContent = fmt(d.total_kk);
|
||||
document.getElementById('sc-jiwa').textContent = fmt(d.total_jiwa);
|
||||
document.getElementById('sc-terjangkau').textContent = fmt(d.jiwa_terjangkau);
|
||||
document.getElementById('sc-blankspot').textContent = fmt(d.jiwa_blank_spot);
|
||||
document.getElementById('sc-pct').textContent = fmtP(d.pct_cakupan);
|
||||
|
||||
// Kebutuhan stats
|
||||
const rk = d.rekap_kebutuhan || [];
|
||||
let belum = 0, proses = 0, terpenuhi = 0;
|
||||
rk.forEach(k => {
|
||||
belum += parseInt(k.belum || 0);
|
||||
proses += parseInt(k.proses || 0);
|
||||
terpenuhi += parseInt(k.terpenuhi || 0);
|
||||
});
|
||||
document.getElementById('ns-kk-open').textContent = fmt(d.kk_kebutuhan_open);
|
||||
document.getElementById('ns-belum').textContent = fmt(belum);
|
||||
document.getElementById('ns-proses').textContent = fmt(proses);
|
||||
document.getElementById('ns-terpenuhi').textContent = fmt(terpenuhi);
|
||||
document.getElementById('need-date').textContent = now_label();
|
||||
|
||||
renderDonut(d.kategori || []);
|
||||
renderRekap(d.rekap_ibadah || []);
|
||||
}
|
||||
|
||||
// ── Donut Chart ────────────────────────────────────────────
|
||||
let _donut = null;
|
||||
function renderDonut(rows) {
|
||||
const labels = rows.map(r => r.kategori);
|
||||
const data = rows.map(r => parseInt(r.jml_jiwa));
|
||||
const total = data.reduce((a, b) => a + b, 0);
|
||||
const colors = ['#ef4444', '#f59e0b', '#3b82f6'];
|
||||
|
||||
document.getElementById('donut-total').textContent = total > 0 ? fmt(total) + ' jiwa total' : '';
|
||||
|
||||
if (_donut) _donut.destroy();
|
||||
_donut = new Chart(document.getElementById('chartKategori'), {
|
||||
type: 'doughnut',
|
||||
data: { labels, datasets: [{ data, backgroundColor: colors, borderWidth: 3, borderColor: '#fff' }] },
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false, cutout: '65%',
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: { callbacks: { label: ctx => ` ${fmt(ctx.parsed)} jiwa (${fmtP(total > 0 ? ctx.parsed / total * 100 : 0)})` } }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('donut-legend').innerHTML = rows.map((r, i) => {
|
||||
const pct = total > 0 ? fmtP(parseInt(r.jml_jiwa) / total * 100) : '0%';
|
||||
return `<div style="display:flex;align-items:center;gap:8px;font-size:12px;">
|
||||
<div style="width:11px;height:11px;border-radius:3px;background:${colors[i]};flex-shrink:0;"></div>
|
||||
<span style="font-weight:600;color:var(--text-primary);">${esc(r.kategori)}</span>
|
||||
<span style="color:var(--text-muted);">${fmt(r.jml_jiwa)} jiwa · ${pct}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── Trend Line Chart ───────────────────────────────────────
|
||||
let _tren = null;
|
||||
async function loadTren() {
|
||||
let j;
|
||||
try {
|
||||
const r = await fetch('api/dashboard/trend.php?_=' + Date.now());
|
||||
j = await r.json();
|
||||
} catch(e) { return; }
|
||||
if (j.status !== 'success' || !j.data) return;
|
||||
|
||||
const labels = j.data.map(d => d.bulan);
|
||||
const data = j.data.map(d => parseFloat(d.pct_cakupan));
|
||||
|
||||
if (_tren) _tren.destroy();
|
||||
_tren = new Chart(document.getElementById('chartTren'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{
|
||||
label: 'Cakupan (%)', data,
|
||||
borderColor: '#0d7490', backgroundColor: 'rgba(13,116,144,.06)',
|
||||
fill: true, tension: 0.4,
|
||||
pointBackgroundColor: '#0d7490', pointBorderColor: '#fafaf9',
|
||||
pointBorderWidth: 2, pointRadius: 5, pointHoverRadius: 7,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: { min: -10, max: 110,
|
||||
ticks: { callback: v => v >= 0 && v <= 100 ? v + '%' : null, font: { size: 10 }, color: '#7a7067', stepSize: 10 },
|
||||
grid: { color: '#ede8e2' }
|
||||
},
|
||||
x: {
|
||||
ticks: { font: { size: 10 }, color: '#7a7067' },
|
||||
grid: { display: false }
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: { callbacks: { label: ctx => ' ' + fmtP(ctx.parsed.y) } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Notifikasi ─────────────────────────────────────────────
|
||||
function renderNotif(items, total) {
|
||||
const el = document.getElementById('dash-notif-list');
|
||||
const badge = document.getElementById('dash-notif-badge');
|
||||
if (!el) return;
|
||||
|
||||
if (badge) {
|
||||
badge.textContent = total > 99 ? '99+' : total;
|
||||
badge.style.display = total > 0 ? '' : 'none';
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
el.innerHTML = '<div style="font-size:12px;color:var(--text-muted);text-align:center;padding:12px 0;"><i data-lucide="check" style="color:var(--text-success);margin-right:4px;"></i> Tidak ada notifikasi baru.</div>';
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons({attrs: {class: 'lucide'}});
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = items.map(it => `
|
||||
<a href="${esc(it.page || '#')}" class="notif-item">
|
||||
<div class="notif-dot ${esc(it.type)}"><i data-lucide="${esc(it.icon)}"></i></div>
|
||||
<div class="notif-text">
|
||||
<div class="notif-title">${esc(it.label)}</div>
|
||||
<div class="notif-body">${fmt(it.count)} item aktif</div>
|
||||
</div>
|
||||
<div class="notif-time">Aktif</div>
|
||||
</a>`).join('');
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons({attrs: {class: 'lucide'}});
|
||||
}
|
||||
|
||||
async function loadDashboardNotifs() {
|
||||
const el = document.getElementById('dash-notif-list');
|
||||
if (el) {
|
||||
el.innerHTML = '<div style="font-size:12px;color:var(--text-muted);"><span class="spinner"></span> Memuat...</div>';
|
||||
}
|
||||
try {
|
||||
const r = await fetch('api/notif/ambil.php?_=' + Date.now(), { cache: 'no-store' });
|
||||
const j = await r.json();
|
||||
if (j.status !== 'success') throw new Error('notif failed');
|
||||
renderNotif(j.items || [], parseInt(j.total || 0));
|
||||
} catch (e) {
|
||||
if (el) el.innerHTML = '<div style="font-size:12px;color:var(--text-muted);text-align:center;padding:12px 0;">Gagal memuat notifikasi.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rekap Table + Pagination ───────────────────────────────
|
||||
const PER_PAGE = 5;
|
||||
let _allRows = [], _page = 1;
|
||||
|
||||
function renderRekap(rows) {
|
||||
_allRows = rows;
|
||||
_page = 1;
|
||||
document.getElementById('rekap-count').textContent =
|
||||
`Menampilkan ${Math.min(PER_PAGE, rows.length)} dari ${rows.length} rumah ibadah`;
|
||||
_drawPage();
|
||||
}
|
||||
|
||||
function _drawPage() {
|
||||
const start = (_page - 1) * PER_PAGE;
|
||||
const slice = _allRows.slice(start, start + PER_PAGE);
|
||||
const pages = Math.ceil(_allRows.length / PER_PAGE) || 1;
|
||||
const tbody = document.getElementById('rekapTbody');
|
||||
|
||||
if (slice.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:28px;color:var(--text-muted);">Belum ada data rumah ibadah.</td></tr>';
|
||||
document.getElementById('rekap-info').textContent = '';
|
||||
document.getElementById('rekap-pages').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const badgeCls = { Masjid:'masjid', Mushola:'mushola', Gereja:'gereja', Pura:'pura', Vihara:'vihara', Klenteng:'klenteng' };
|
||||
tbody.innerHTML = slice.map((r, i) => {
|
||||
const jiwa = parseInt(r.jml_jiwa || 0);
|
||||
const tang = parseInt(r.jml_ditangani || 0);
|
||||
const pct = jiwa > 0 ? Math.round(tang / jiwa * 100) : 0;
|
||||
const col = pct >= 60 ? '#16a34a' : pct >= 30 ? '#f59e0b' : '#ef4444';
|
||||
const badge = `<span class="jenis-badge ${badgeCls[r.jenis] || ''}">${esc(r.jenis)}</span>`;
|
||||
return `<tr>
|
||||
<td style="color:var(--text-muted);font-size:12px;">${start + i + 1}</td>
|
||||
<td style="font-weight:600;">${esc(r.nama)}</td>
|
||||
<td>${badge}</td>
|
||||
<td style="text-align:right;font-family:var(--font-mono);">${fmt(r.jml_kk)}</td>
|
||||
<td style="text-align:right;font-family:var(--font-mono);">${fmt(jiwa)}</td>
|
||||
<td>
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<div class="progress-bar-wrap" style="flex:1;">
|
||||
<div class="progress-bar-fill" style="width:${pct}%;background:${col};"></div>
|
||||
</div>
|
||||
<span style="font-size:12px;font-weight:600;color:${col};min-width:36px;">${pct}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
<a href="pages/map.php" class="tbl-action-btn" title="Lihat di Peta"><i data-lucide="eye"></i></a>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
const endRow = Math.min(start + PER_PAGE, _allRows.length);
|
||||
document.getElementById('rekap-info').textContent =
|
||||
`Menampilkan ${start + 1}–${endRow} dari ${_allRows.length} rumah ibadah`;
|
||||
|
||||
let html = `<button class="page-btn" onclick="goPage(${_page - 1})" ${_page === 1 ? 'disabled' : ''}><i data-lucide="chevron-left"></i></button>`;
|
||||
for (let p = 1; p <= pages; p++)
|
||||
html += `<button class="page-btn ${p === _page ? 'active' : ''}" onclick="goPage(${p})">${p}</button>`;
|
||||
html += `<button class="page-btn" onclick="goPage(${_page + 1})" ${_page === pages ? 'disabled' : ''}><i data-lucide="chevron-right"></i></button>`;
|
||||
document.getElementById('rekap-pages').innerHTML = html;
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons({attrs: {class: 'lucide'}});
|
||||
}
|
||||
|
||||
function goPage(p) {
|
||||
const pages = Math.ceil(_allRows.length / PER_PAGE) || 1;
|
||||
if (p < 1 || p > pages) return;
|
||||
_page = p;
|
||||
_drawPage();
|
||||
}
|
||||
|
||||
// ── Boot ──────────────────────────────────────────────────
|
||||
window.addEventListener('load', function () {
|
||||
loadStats();
|
||||
loadTren();
|
||||
loadDashboardNotifs();
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php include 'includes/page-end.php'; ?>
|
||||
@@ -0,0 +1,52 @@
|
||||
# Business Process SOP - WebGIS Poverty Mapping
|
||||
|
||||
## Status Dokumen
|
||||
|
||||
Dokumen ini adalah acuan proses bisnis aktif. Untuk detail teknis, lihat `docs/codebase-navigation.md`.
|
||||
|
||||
## Aktor
|
||||
|
||||
### Administrator
|
||||
|
||||
Administrator mengatur master data, user, verifikasi, import, export, dan recalculate proximity. Administrator juga bertanggung jawab memastikan data yang tampil ke publik sudah `Terverifikasi`.
|
||||
|
||||
### Operator
|
||||
|
||||
Operator mengelola warga, kebutuhan, dan status bantuan dalam rumah ibadah yang ditugaskan. Data warga yang dibuat operator masuk `Pending` sampai Administrator melakukan Verifikasi.
|
||||
|
||||
### Publik
|
||||
|
||||
Publik dapat melihat peta dan papan kebutuhan tanpa melihat NIK, nama KK, alamat detail, catatan, atau koordinat rumah warga. Publik diperlakukan sebagai `viewer` read-only.
|
||||
|
||||
### Donatur
|
||||
|
||||
Donatur mengirim kontak dan kategori minat bantuan melalui papan kebutuhan publik. Saat ini flow Donatur masih berupa kontak umum, belum pledge ke kebutuhan spesifik.
|
||||
|
||||
## Alur Data Warga
|
||||
|
||||
1. Administrator atau Operator menambahkan warga.
|
||||
2. Sistem menghitung Proximity dari titik warga ke rumah ibadah aktif.
|
||||
3. Data yang dibuat Administrator langsung `Terverifikasi`.
|
||||
4. Data yang dibuat Operator masuk `Pending`.
|
||||
5. Administrator melakukan Verifikasi: approve menjadi `Terverifikasi` atau reject menjadi `Ditolak`.
|
||||
6. Hanya warga `Terverifikasi` yang dapat masuk workflow bantuan/kebutuhan publik.
|
||||
|
||||
## Recalculate Proximity
|
||||
|
||||
Recalculate dapat mengubah rumah ibadah terdekat warga. Karena model saat ini `proximity-only`, assignment operator dapat berubah mengikuti hasil spasial terbaru setelah posisi/radius rumah ibadah diubah.
|
||||
|
||||
Untuk data kecil-menengah, recalculate langsung masih dapat dipahami dan dirawat. Jika jumlah warga/rumah ibadah menjadi besar, gunakan pendekatan batch atau queue agar proses tidak membuat request admin terasa lama.
|
||||
|
||||
## Status Bantuan dan Kebutuhan
|
||||
|
||||
Status bantuan warga bergerak dari `Belum Ditangani`, `Dalam Proses`, sampai `Sudah Ditangani`.
|
||||
|
||||
Status kebutuhan bergerak dari `Belum Terpenuhi`, `Dalam Proses`, sampai `Terpenuhi`.
|
||||
|
||||
## Catatan Audit
|
||||
|
||||
Setiap perubahan status bantuan atau kebutuhan sebaiknya memiliki Catatan agar riwayat mudah dipahami. Catatan menjawab pertanyaan sederhana: kenapa status ini berubah.
|
||||
|
||||
## Batas Flow Donatur Saat Ini
|
||||
|
||||
Donatur belum memilih kebutuhan spesifik. Admin membaca kontak donatur, lalu menindaklanjuti manual di luar sistem. Jika ingin closed-loop, perlu tabel donasi/pledge dan status konfirmasi.
|
||||
@@ -0,0 +1,303 @@
|
||||
# Codebase Audit Report - WebGIS Poverty Mapping
|
||||
|
||||
> Updated: 2026-06-03. Laporan ini merangkum proses bisnis aktif, wiring backend/frontend, lifecycle data, catatan logika, prioritas fix, dan saran pengembangan berdasarkan traverse codebase dan dokumentasi lokal.
|
||||
|
||||
## Ringkasan Eksekutif
|
||||
|
||||
Proyek ini adalah aplikasi WebGIS PHP prosedural + MySQLi + Leaflet untuk memetakan penduduk miskin, rumah ibadah, radius layanan, blank spot, status bantuan, kebutuhan bantuan, papan kebutuhan publik, import/export, dan dashboard admin.
|
||||
|
||||
Implementasi aktif sudah cukup konsisten dengan dokumentasi teknis di `README.md`, `docs/codebase-navigation.md`, dan `docs/superpowers/plans/2026-06-03-codebase-audit-refresh.md`. Dokumen lama di `Tugas/` dan sebagian plan historis bukan sumber kebenaran implementasi aktif karena masih membawa artifact lama atau rencana fase yang sudah selesai.
|
||||
|
||||
Status audit refresh 2026-06-03:
|
||||
|
||||
1. Validasi koordinat sudah dipusatkan di `includes/validation.php` dan dipakai oleh endpoint mutasi/import utama.
|
||||
2. Import admin sudah menghasilkan warga `Terverifikasi` beserta `verified_by` dan `verified_at`.
|
||||
3. Policy NIK sudah dibuat ketat: NIK dicek terhadap semua record, termasuk arsip/inactive, agar sejalan dengan unique index.
|
||||
4. Statistik operator sudah memakai scope rumah ibadah operator.
|
||||
5. Workflow kebutuhan dan status bantuan sudah dikunci untuk warga `Terverifikasi`.
|
||||
6. Heatmap publik sudah disembunyikan karena koordinat warga publik tetap dimasking.
|
||||
7. User management dan soft delete rumah ibadah sudah memiliki guard tambahan.
|
||||
|
||||
Risiko residual terbesar saat ini:
|
||||
|
||||
1. Form kontak donatur publik belum memiliki rate limit/CAPTCHA.
|
||||
2. Model ownership `proximity-only` bisa mengubah scope operator setelah recalculate; pertimbangkan model hybrid jika dipakai multi-operator serius.
|
||||
3. Test runner masih ad-hoc; belum ada satu command tunggal untuk semua static guard dan DB smoke test.
|
||||
4. Dependency CDN/tile provider eksternal perlu strategi mirror lokal untuk deployment jaringan tertutup.
|
||||
|
||||
## Triage Review Tambahan 2026-06-03
|
||||
|
||||
Review tambahan menyorot beberapa risiko bisnis yang perlu dibedakan antara bug yang sudah jelas, keputusan produk yang perlu dikunci, dan optimasi jangka menengah.
|
||||
|
||||
### Valid Dan Perlu Masuk Prioritas
|
||||
|
||||
1. Aksi kebutuhan pada warga `Pending` belum dikunci. Endpoint `api/kebutuhan/simpan.php` dan `api/kebutuhan/update_status.php` hanya mengecek warga aktif, tidak mengecek `status_verifikasi`. UI popup juga membangun section kebutuhan untuk user login tanpa membedakan status verifikasi. Jika warga ditolak admin, data kebutuhan yang sudah dibuat tetap tertinggal dan bisa menjadi data tidak relevan.
|
||||
2. Heatmap publik tidak akan berfungsi dengan data warga saat ini karena `api/penduduk/ambil.php` mengosongkan `lat` dan `lng` untuk non-privileged user, sementara `modules/heatmap.js` membutuhkan koordinat valid. Ini adalah tradeoff privasi vs visualisasi yang perlu diputuskan eksplisit.
|
||||
3. Import admin memang perlu policy yang lebih jelas. Solusi minimum adalah set hasil import admin ke `Terverifikasi`; solusi lebih matang adalah staging/bulk verify sebelum commit permanen.
|
||||
4. NIK vs soft delete memang perlu strategi lifecycle. Mengubah NIK saat soft delete adalah opsi cepat, tetapi perlu audit trail jelas agar data historis tidak membingungkan.
|
||||
5. Refresh data lintas modul sebaiknya distandarkan. Pola event atau `window.refreshAllData()` akan mengurangi state drift antar map, list, stats, dan heatmap.
|
||||
|
||||
### Perlu Keputusan Produk
|
||||
|
||||
1. Kepemilikan warga operator saat ini ditentukan oleh hasil proximity, bukan input bebas operator. Pada `api/penduduk/simpan.php`, data operator tidak dipindahkan diam-diam ke rumah ibadah lain; request ditolak jika titik tidak masuk rumah ibadah operator. Namun, `ibadah_id` warga dapat berubah setelah admin mengubah posisi/radius rumah ibadah atau menjalankan recalculation. Produk perlu memilih:
|
||||
- ownership dinamis berbasis proximity, cocok untuk analisis spasial otomatis;
|
||||
- ownership terkunci berbasis operator input, cocok untuk tanggung jawab operasional;
|
||||
- hybrid: simpan `assigned_ibadah_id` untuk ownership operasional dan `nearest_ibadah_id` untuk hasil analisis spasial.
|
||||
2. Spatial fuzzing untuk publik dapat menghidupkan heatmap publik, tetapi titik jitter 50-100 meter tetap perlu aturan konsisten agar tidak menciptakan kesan akurasi palsu. Alternatif yang lebih aman adalah endpoint agregat grid/kelurahan untuk heatmap publik, bukan titik warga individual.
|
||||
|
||||
### Keputusan Eksekusi Audit 2026-06-03
|
||||
|
||||
1. Model ownership warga untuk batch fix ini tetap `proximity-only`. `penduduk_miskin.ibadah_id` tetap menjadi hasil `calc_proximity()` dan menjadi dasar scope operator. Perubahan schema `assigned_ibadah_id`/`nearest_ibadah_id` tidak dilakukan dalam batch ini agar tidak mengubah kontrak bisnis besar tanpa PRD lanjutan.
|
||||
2. Strategi heatmap publik untuk batch fix ini adalah `hidden`: kontrol heatmap disembunyikan untuk viewer publik karena koordinat warga publik tetap dimasking. Endpoint agregat grid/kelurahan dapat dirancang sebagai pengembangan lanjutan jika produk membutuhkan visualisasi heatmap publik tanpa membocorkan koordinat rumah warga.
|
||||
|
||||
### Optimasi Jangka Menengah
|
||||
|
||||
1. Bounding box untuk Haversine bagus untuk pencarian kandidat terdekat, tetapi recalculation saat perubahan rumah ibadah tetap perlu menyentuh semua warga aktif jika sistem ingin memastikan semua assignment global tetap benar. Optimasi yang lebih tepat bisa berupa index lat/lng, batch processing, atau recalculation queue.
|
||||
2. Closed-loop donatur ke kebutuhan spesifik akan meningkatkan akuntabilitas, tetapi membutuhkan model data tambahan seperti `donasi`, `donasi_kebutuhan`, status pledge/confirmed, dan audit penyelesaian.
|
||||
|
||||
### Batas Skala Proximity Saat Ini
|
||||
|
||||
`calc_proximity()` melakukan scan semua rumah ibadah aktif untuk setiap warga. Ini sederhana dan benar untuk data kecil-menengah, tetapi recalculate/import dapat melambat jika jumlah warga atau rumah ibadah besar.
|
||||
|
||||
Opsi peningkatan:
|
||||
|
||||
1. Tambah index `lat` dan `lng` untuk membantu query kandidat.
|
||||
2. Gunakan bounding box kasar sebelum Haversine.
|
||||
3. Jalankan recalculate dalam batch.
|
||||
4. Simpan job recalculate jika proses lebih dari beberapa detik.
|
||||
|
||||
Jangan mengganti proximity engine sebelum ada data performa nyata. Algoritma saat ini mudah dipahami dan sudah punya test. Optimasi harus menjaga hasil assignment tetap sama.
|
||||
|
||||
## Dokumentasi Yang Berlaku
|
||||
|
||||
| File | Status | Catatan |
|
||||
| --- | --- | --- |
|
||||
| `README.md` | Aktif | Setup, stack, role, fitur, dan test. |
|
||||
| `docs/codebase-navigation.md` | Aktif | Referensi teknis paling lengkap untuk struktur, API, modul frontend, DB, RBAC, proximity. |
|
||||
| `setup_database.sql` | Aktif | Schema otoritatif dan seed admin default. |
|
||||
| `docs/superpowers/specs/2026-05-24-poverty-mapping-prd.md` | Historis/produk | PRD berguna untuk konteks fitur, tetapi status implementasi perlu diupdate. |
|
||||
| `docs/superpowers/plans/*.md` | Historis implementasi | Log rencana/fase kerja. |
|
||||
| `Tugas/*` | Artifact tugas lama | Masih menyebut jalan/parsil/POI; jangan dijadikan acuan implementasi aktif. |
|
||||
| `DESIGN.md` | Artifact desain lama | Bukan referensi desain aktif WebGIS. |
|
||||
|
||||
## Proses Bisnis Aktif
|
||||
|
||||
1. Administrator menyiapkan sistem dengan mengatur `config.php` dan menjalankan `setup_database.sql`.
|
||||
2. Administrator login memakai akun default `admin / Admin1234`, lalu wajib mengganti password.
|
||||
3. Administrator membuat dan mengelola `rumah_ibadah`: nama, jenis, alamat, kontak, koordinat, dan `radius_m`.
|
||||
4. Administrator membuat akun operator dan mengaitkannya ke satu rumah ibadah melalui `users.ibadah_id`.
|
||||
5. Administrator atau operator mendata `penduduk_miskin` berbasis koordinat.
|
||||
6. Backend menjalankan `calc_proximity()` di `koneksi.php` untuk menentukan rumah ibadah terdekat yang mencakup warga.
|
||||
7. Jika warga masuk radius salah satu rumah ibadah, sistem menyimpan `ibadah_id`, `jarak_m`, dan `is_blank_spot=0`; jika tidak, `ibadah_id=NULL`, `jarak_m` ke rumah ibadah terdekat, dan `is_blank_spot=1`.
|
||||
8. Data yang dibuat administrator langsung `Terverifikasi`; data yang dibuat operator masuk `Pending` dan perlu diverifikasi admin.
|
||||
9. Publik tanpa login diperlakukan sebagai viewer: hanya data `Terverifikasi`, PII dimasking, dan koordinat rumah warga tidak dikirim.
|
||||
10. Operator mengelola warga dalam scope rumah ibadahnya: status bantuan, riwayat bantuan, dan kebutuhan bantuan.
|
||||
11. Kebutuhan `Belum Terpenuhi` dari warga terverifikasi diagregasi ke `papan-kebutuhan.php`.
|
||||
12. Donatur publik mengirim kontak/minat bantuan; admin membaca dan menandai kontak donatur sebagai read.
|
||||
13. Admin mengakses dashboard, tren, import CSV, export CSV/PDF, arsip, verifikasi, user management, dan recalculation proximity.
|
||||
|
||||
## Wiring Backend
|
||||
|
||||
Backend tidak memakai framework MVC. File endpoint di `api/` berperan sebagai controller, `koneksi.php` dan `auth/helper.php` sebagai helper/service utama, dan schema di `setup_database.sql` sebagai model data.
|
||||
|
||||
### Entry Point Dan Helper
|
||||
|
||||
| Area | File | Peran |
|
||||
| --- | --- | --- |
|
||||
| Konfigurasi | `config.php` | Konstanta app, session, database, map center. |
|
||||
| DB/proximity | `koneksi.php` | Koneksi global `$conn`, `haversine_distance()`, `calc_proximity()`. |
|
||||
| Auth/RBAC | `auth/helper.php` | Session, role hierarchy, `require_auth()`, `has_role()`, CSRF. |
|
||||
| Peta publik/auth-aware | `index.php` | Shell utama Leaflet dan module loader. |
|
||||
| Dashboard | `dashboard.php` | Ringkasan admin dan trend dashboard. |
|
||||
| Halaman admin/operator | `pages/*.php` | UI domain per fitur. |
|
||||
| API | `api/**/*.php` | Endpoint JSON/CSV/HTML per domain. |
|
||||
|
||||
### Endpoint Per Domain
|
||||
|
||||
| Domain | Endpoint Utama | Akses |
|
||||
| --- | --- | --- |
|
||||
| Auth | `api/auth/login.php`, `change_password.php`, `logout.php` | Login/logout, ganti password, lockout. |
|
||||
| Rumah ibadah | `api/ibadah/ambil.php`, `simpan.php`, `update.php`, `hapus.php`, `update_posisi.php`, `update_radius.php`, `update_kontak.php`, `recalculate.php` | GET publik untuk data aktif; mutasi admin + CSRF. |
|
||||
| Penduduk | `api/penduduk/ambil.php`, `simpan.php`, `update_posisi.php`, `update_status.php`, `riwayat.php`, `verifikasi.php`, `hapus.php`, `nonaktifkan.php`, `arsip.php` | Public read masked; operator/admin mutasi scoped; verifikasi/admin. |
|
||||
| Kebutuhan | `api/kebutuhan/ambil.php`, `simpan.php`, `update_status.php` | Operator/admin scoped + CSRF untuk mutasi. |
|
||||
| Statistik | `api/stats/ambil.php`, `api/dashboard/trend.php` | Public/operator/admin stats; trend admin. |
|
||||
| Papan publik | `api/papan/ambil.php`, `kontak_donatur.php`, `donatur.php` | Public needs/donor form; admin donor inbox. |
|
||||
| Users | `api/users/ambil.php`, `simpan.php`, `update.php`, `reset_password.php`, `toggle_active.php` | Admin + CSRF untuk mutasi. |
|
||||
| Import/export | `api/import/csv.php`, `template.php`, `api/export/csv.php`, `pdf.php` | Admin. |
|
||||
|
||||
### Auth Dan Scope
|
||||
|
||||
- Role hierarchy: `viewer < operator < administrator`.
|
||||
- Unauthenticated map user diperlakukan sebagai viewer.
|
||||
- `require_auth('operator')` mengizinkan operator dan administrator.
|
||||
- Mutasi terautentikasi memakai `require_csrf()`.
|
||||
- Operator dibatasi oleh `get_ibadah_id()` dan `penduduk_miskin.ibadah_id` di endpoint penduduk/kebutuhan/riwayat.
|
||||
- Public endpoint penduduk hanya mengirim data terverifikasi dan memasking `nik`, `nama_kk`, `alamat`, `catatan`, `lat`, dan `lng`.
|
||||
|
||||
## Wiring Frontend
|
||||
|
||||
Frontend adalah PHP-rendered pages + vanilla JavaScript global modules. Tidak ada bundler, framework SPA, atau package manager JS.
|
||||
|
||||
### Entrypoint
|
||||
|
||||
| File | Peran |
|
||||
| --- | --- |
|
||||
| `index.html` | Redirect legacy ke `index.php`. |
|
||||
| `index.php` | Shell peta utama, inject `window.APP_USER`, Leaflet, Chart.js, module loader. |
|
||||
| `papan-kebutuhan.php` | Papan kebutuhan publik dan form kontak donatur. |
|
||||
| `auth/login.php` | Login form POST ke API auth. |
|
||||
| `auth/change_password.php` | Ganti password dengan CSRF. |
|
||||
| `dashboard.php` | Dashboard admin. |
|
||||
| `pages/map.php` | Iframe `../index.php?embedded=1` untuk map di shell admin. |
|
||||
| `pages/*.php` | Halaman domain admin/operator. |
|
||||
|
||||
### Modul Map
|
||||
|
||||
| Module | State/Entry | API Yang Dipakai |
|
||||
| --- | --- | --- |
|
||||
| `modules/ibadah.js` | `window.initIbadah()`, `window._ibadahList` | `api/ibadah/ambil.php`, `simpan.php`, `update_posisi.php`, `update_kontak.php`, `update_radius.php`, `hapus.php`, `recalculate.php`. |
|
||||
| `modules/penduduk.js` | `window.initPenduduk()`, `window._pendudukList` | `api/penduduk/ambil.php`, `simpan.php`, `update_posisi.php`, `update_status.php`, `riwayat.php`, `hapus.php`, `nonaktifkan.php`, `verifikasi.php`. |
|
||||
| `modules/kebutuhan.js` | Loaded only for authenticated user | `api/kebutuhan/ambil.php`, `simpan.php`, `update_status.php`. |
|
||||
| `modules/stats.js` | `window._showDashboard()`, `window._statsUpdate()` | `api/stats/ambil.php`. |
|
||||
| `modules/heatmap.js` | `window._toggleHeatmap()`, `window._heatmapUpdate()` | Uses `window._pendudukList`; no direct API. |
|
||||
|
||||
### Flow Frontend-ke-Frontend
|
||||
|
||||
- `index.php` injects `window.APP_USER` for role, login status, assigned `ibadahId`, and CSRF token.
|
||||
- `window.MAP_MODULES` loads module scripts sequentially; once complete, calls `initIbadah()` then `initPenduduk()`.
|
||||
- `ibadah.js` fills `window._ibadahList`, then `penduduk.js` uses it for client-side proximity preview.
|
||||
- `penduduk.js` fills `window._pendudukList`, then `stats.js` and `heatmap.js` read it for modal stats/heatmap.
|
||||
- `ibadah.js` can call `_pendudukRecalcAll()` after ibadah changes.
|
||||
- `penduduk.js` calls `_statsUpdate()`, `_heatmapUpdate()`, and blank spot counter update after list changes.
|
||||
- `pages/map.php` embeds map in iframe, so page state and map state communicate through API/server rather than shared memory.
|
||||
|
||||
## Data Lifecycle
|
||||
|
||||
### Rumah Ibadah
|
||||
|
||||
- Dibuat/diedit oleh admin.
|
||||
- `radius_m` menentukan cakupan layanan.
|
||||
- Perubahan posisi/radius/delete memicu recalculation semua penduduk aktif.
|
||||
- Delete bersifat soft delete dengan `deleted_at`.
|
||||
|
||||
### Penduduk Miskin
|
||||
|
||||
- Data aktif adalah `is_active=1 AND deleted_at IS NULL`.
|
||||
- `deleted_at` dipakai untuk koreksi entry salah.
|
||||
- `is_active=0` dipakai untuk warga pindah/meninggal/arsip real-world.
|
||||
- `status_verifikasi` mengontrol visibilitas publik: hanya `Terverifikasi` yang muncul ke publik/papan kebutuhan.
|
||||
- `status_bantuan` mengontrol workflow bantuan: `Belum Ditangani`, `Dalam Proses`, `Sudah Ditangani`.
|
||||
- `riwayat_bantuan` append-only untuk audit perubahan status bantuan.
|
||||
|
||||
### Kebutuhan
|
||||
|
||||
- Kebutuhan menempel ke `penduduk_miskin`.
|
||||
- Status: `Belum Terpenuhi`, `Dalam Proses`, `Terpenuhi`.
|
||||
- `riwayat_kebutuhan` append-only untuk audit status kebutuhan.
|
||||
- Papan publik mengagregasi kebutuhan `Belum Terpenuhi` dari warga aktif dan terverifikasi.
|
||||
|
||||
### User
|
||||
|
||||
- `administrator` mengelola sistem penuh.
|
||||
- `operator` wajib dikaitkan ke satu rumah ibadah untuk scope kerja.
|
||||
- `viewer` masih ada di backend sebagai fallback/kompatibilitas, tetapi UI tidak menawarkan pembuatan akun viewer baru.
|
||||
- Password default/temp memaksa `must_change_password=1`.
|
||||
|
||||
### Donatur
|
||||
|
||||
- Publik mengirim kontak ke `kontak_donatur`.
|
||||
- Admin melihat dan menandai semua unread sebagai read.
|
||||
- Belum ada rate limit/spam mitigation yang terdokumentasi.
|
||||
|
||||
## Catatan Logika Dan Risiko
|
||||
|
||||
### Prioritas Tinggi Audit Awal - Status Refresh 2026-06-03
|
||||
|
||||
| Temuan Audit Awal | Status Saat Ini | Referensi |
|
||||
| --- | --- | --- |
|
||||
| Validasi koordinat tidak konsisten. | Selesai: helper `validate_lat_lng()` dipakai di endpoint utama dan import. | `includes/validation.php`, `api/penduduk/simpan.php`, `api/penduduk/update_posisi.php`, `api/ibadah/simpan.php`, `api/ibadah/update.php`, `api/ibadah/update_posisi.php`, `api/import/csv.php` |
|
||||
| Import admin tidak mengisi `status_verifikasi`. | Selesai: import admin set `Terverifikasi`, `verified_by`, `verified_at`. | `api/import/csv.php` |
|
||||
| Unique NIK global tidak cocok dengan pengecekan duplikat hanya data aktif. | Selesai dengan policy ketat: semua record dicek, termasuk arsip/inactive. | `api/penduduk/simpan.php`, `api/import/csv.php`, `setup_database.sql` |
|
||||
| Statistik operator berpotensi global. | Selesai: stats memakai scope operator. | `api/stats/ambil.php` |
|
||||
| Update status bantuan dari popup map bisa membuat UI stale. | Sebagian besar selesai: update state lokal, refresh list, dan dispatch event data changed. Tetap perlu test manual end-to-end di browser. | `modules/penduduk.js`, `index.php` |
|
||||
| Kebutuhan dapat dibuat/diubah untuk warga `Pending`. | Selesai: endpoint menolak non-`Terverifikasi`, UI dibuat read-only/filtered. | `api/kebutuhan/simpan.php`, `api/kebutuhan/update_status.php`, `modules/kebutuhan.js`, `pages/kebutuhan.php` |
|
||||
| Heatmap publik kosong karena koordinat publik dimasking. | Selesai dengan strategi `hidden`: kontrol heatmap hanya untuk operator/admin. | `index.php`, `modules/heatmap.js` |
|
||||
|
||||
### Prioritas Menengah
|
||||
|
||||
| Temuan | Dampak | Referensi |
|
||||
| --- | --- | --- |
|
||||
| Soft delete rumah ibadah tidak memakai `AND deleted_at IS NULL` dan tidak cek `affected_rows`. | Request ke ID deleted/tidak ada bisa tampak sukses. | `api/ibadah/hapus.php:40` |
|
||||
| Update kontak rumah ibadah tidak mengecek `deleted_at IS NULL`. | Rumah ibadah soft-deleted masih bisa diubah kontaknya. | `api/ibadah/update_kontak.php:23` |
|
||||
| Client dan server sama-sama punya logic proximity. | Rawan drift jika aturan proximity berubah. | `koneksi.php:28`, `modules/penduduk.js` |
|
||||
| Heatmap memakai `_pendudukList` yang bisa sudah terfilter/visible. | Heatmap berubah diam-diam saat filter/layer berubah, tidak selalu merepresentasikan dataset penuh. | `modules/heatmap.js` |
|
||||
| UI status bantuan tidak mengirim `catatan`, padahal backend menyimpan audit note. | Audit trail kurang informatif. | `api/penduduk/update_status.php`, `modules/penduduk.js` |
|
||||
| Backend masih menerima create/update role `viewer`. | Kebijakan UI dan backend tidak sepenuhnya sinkron. | `api/users/simpan.php`, `api/users/update.php`, `README.md` |
|
||||
| Admin bisa self-demotion via update user. | Risiko kehilangan akses admin jika tidak ada admin lain. | `api/users/update.php` |
|
||||
|
||||
### Prioritas Rendah
|
||||
|
||||
| Temuan | Dampak | Referensi |
|
||||
| --- | --- | --- |
|
||||
| Sidebar punya dead link `Pengaturan Sistem` dan `Aktivitas & Audit Log`. | Navigasi terlihat belum selesai. | `includes/page-start.php` |
|
||||
| Naming legacy `searchPoi` dan toast POI masih ada. | Membingungkan karena fitur POI legacy sudah dihapus. | `index.php` |
|
||||
| Papan publik memakai inline `onclick` untuk string kategori. | Saat ini mitigated oleh enum kategori, tetapi pola rapuh. | `papan-kebutuhan.php` |
|
||||
| `DESIGN.md` berada di root walau bukan referensi aktif. | Developer baru bisa salah membaca arah desain. | `DESIGN.md` |
|
||||
|
||||
## Rekomendasi Fix
|
||||
|
||||
### Backend
|
||||
|
||||
1. Tambahkan helper validasi koordinat bersama, misalnya `validate_lat_lng($lat, $lng)` dan pakai di semua endpoint penduduk/ibadah/import.
|
||||
2. Putuskan policy import admin: jika import dianggap aksi admin final, set `status_verifikasi='Terverifikasi'`, `verified_by=get_user_id()`, `verified_at=NOW()` pada insert import.
|
||||
3. Selaraskan NIK dengan lifecycle soft delete/inactive:
|
||||
- opsi cepat: cek semua NIK termasuk inactive/deleted dan tampilkan pesan jelas;
|
||||
- opsi lebih baik: ubah constraint ke unique untuk NIK aktif saja via generated column/partial strategy sesuai DB yang dipakai.
|
||||
4. Kunci endpoint kebutuhan untuk warga non-`Terverifikasi`, atau set policy eksplisit bahwa kebutuhan hanya boleh dibuat setelah approve admin.
|
||||
5. Tambahkan operator scope ke `api/stats/ambil.php` bila operator tidak boleh melihat agregat global.
|
||||
6. Perketat endpoint soft-delete/update dengan `deleted_at IS NULL`, `affected_rows`, dan response 404/error saat data tidak ditemukan.
|
||||
7. Tambahkan guard user management untuk self-demotion dan admin terakhir.
|
||||
8. Ekstrak helper response JSON, method guard, CSRF/method utility, coordinate validation, recalc-all, dan operator scope check agar endpoint tidak terus menduplikasi pola.
|
||||
|
||||
### Frontend
|
||||
|
||||
1. Setelah update status bantuan berhasil, update state lokal lalu panggil refresh list/stat/heatmap.
|
||||
2. Jadikan backend sebagai sumber kebenaran proximity setelah perubahan posisi/radius rumah ibadah; reload penduduk dari API setelah recalc penting.
|
||||
3. Pisahkan dataset mentah dan dataset visible, misalnya `_pendudukAll` dan `_pendudukVisible`.
|
||||
4. Buat helper JS bersama untuk `fetchJson`, `appendCsrf`, escape, render badge, dan status update.
|
||||
5. Tambahkan modal input `catatan` untuk perubahan status bantuan/kebutuhan, atau hapus ekspektasi catatan dari audit UI jika tidak dipakai.
|
||||
6. Disable action kebutuhan/status bantuan untuk warga `Pending`/`Ditolak` di UI operator.
|
||||
7. Pilih strategi heatmap publik: sembunyikan kontrol untuk viewer, gunakan endpoint agregat grid, atau gunakan spatial fuzzing dengan disclaimer akurasi.
|
||||
8. Ganti inline event string di `papan-kebutuhan.php` dengan `data-*` dan `addEventListener`.
|
||||
9. Bersihkan dead link dan naming legacy POI.
|
||||
|
||||
### Dokumentasi/Operasional
|
||||
|
||||
1. Dokumen deployment utama sekarang ada di `docs/deployment-docker.md`; `docs/deployment-xampp.md` dipertahankan sebagai referensi legacy.
|
||||
2. Tambahkan dokumentasi dependency eksternal: Leaflet, Chart.js, leaflet.heat, Google Fonts, OSM/Esri/Carto tiles, Nominatim.
|
||||
3. Tambahkan `config.example.php` atau `.env.example` jika proyek akan dipakai lintas mesin.
|
||||
4. Update PRD dengan status implemented/partial/pending.
|
||||
5. Pindahkan atau arsipkan `DESIGN.md` ke `docs/archive/` agar root documentation tidak ambigu.
|
||||
|
||||
## Saran Pengembangan
|
||||
|
||||
- Tambahkan audit log admin yang bisa dicari untuk perubahan user, ibadah, verifikasi, dan import.
|
||||
- Tambahkan rate limiting atau CAPTCHA ringan untuk `api/papan/kontak_donatur.php`.
|
||||
- Tambahkan batch job/recalculate queue jika jumlah data penduduk besar, karena update radius/posisi ibadah saat ini loop semua warga aktif.
|
||||
- Tambahkan export laporan untuk blank spot dan kebutuhan per kategori.
|
||||
- Tambahkan map bounds/wilayah studi di config agar koordinat di luar Pontianak bisa ditolak lebih awal.
|
||||
- Tambahkan test runner tunggal untuk menjalankan seluruh `tests/test_*.php` dengan urutan yang jelas.
|
||||
- Pertimbangkan service/helper layer ringan untuk domain `Penduduk`, `Ibadah`, `Kebutuhan`, dan `User` agar endpoint procedural tetap mudah dirawat.
|
||||
|
||||
## Prioritas Eksekusi Disarankan
|
||||
|
||||
1. Fix validasi koordinat dan import verification policy.
|
||||
2. Kunci workflow kebutuhan/status bantuan untuk warga belum `Terverifikasi`.
|
||||
3. Fix NIK lifecycle/constraint mismatch.
|
||||
4. Putuskan ownership model: proximity-only, operator-locked, atau hybrid `assigned_ibadah_id` + `nearest_ibadah_id`.
|
||||
5. Putuskan strategi heatmap publik: hidden, agregat grid, atau spatial fuzzing.
|
||||
6. Scope operator untuk statistik bila memang data agregat harus terbatas.
|
||||
7. Fix stale UI status bantuan dan heatmap dataset.
|
||||
8. Hardening user management dan soft-delete endpoint.
|
||||
9. Cleanup dead link/legacy naming.
|
||||
10. Tambah deployment/config docs.
|
||||
@@ -0,0 +1,347 @@
|
||||
# Codebase Navigation — WebGIS Poverty Mapping
|
||||
|
||||
> Quick reference for navigating the project. Updated: 2026-06-03.
|
||||
|
||||
---
|
||||
|
||||
## Directory Tree at a Glance
|
||||
|
||||
```
|
||||
WebgisPovertyMapping/
|
||||
├── index.php ← Main app shell (auth-aware)
|
||||
├── index.html ← Redirect/legacy entry from older prototype
|
||||
├── dashboard.php ← Authenticated admin/operator shell
|
||||
├── papan-kebutuhan.php ← Public needs board page
|
||||
├── config.php ← All app constants (DB, session, map center)
|
||||
├── koneksi.php ← DB connection + Haversine + calc_proximity()
|
||||
├── setup_database.sql ← Full schema (idempotent, run to init DB)
|
||||
│
|
||||
├── auth/
|
||||
│ ├── helper.php ← RBAC core: is_logged_in(), require_auth(), has_role()
|
||||
│ ├── login.php ← Login page
|
||||
│ └── change_password.php ← Forced password-change page
|
||||
│
|
||||
├── pages/
|
||||
│ ├── map.php ← Dashboard wrapper embedding index.php?embedded=1
|
||||
│ ├── penduduk.php ← Operator/admin penduduk management
|
||||
│ ├── kebutuhan.php ← Operator/admin kebutuhan workflow
|
||||
│ ├── status-bantuan.php ← Operator/admin aid status workflow
|
||||
│ ├── ibadah.php ← Admin rumah ibadah management
|
||||
│ ├── users.php ← Admin user management
|
||||
│ ├── import.php ← Admin CSV import
|
||||
│ ├── laporan.php ← Admin export/report page
|
||||
│ └── analisis.php ← Admin analytics/recalculation page
|
||||
│
|
||||
├── modules/ ← Frontend JS modules (loaded by index.php)
|
||||
│ ├── ibadah.js ← Rumah Ibadah layer: markers, circles, CRUD
|
||||
│ ├── penduduk.js ← Penduduk layer: proximity coloring, CRUD, filters
|
||||
│ ├── stats.js ← Dashboard modal: cards + chart + rekap table
|
||||
│ ├── heatmap.js ← Heatmap overlay (leaflet.heat, weighted by jiwa)
|
||||
│ └── kebutuhan.js ← Kebutuhan bantuan popup/actions
|
||||
│
|
||||
├── css/
|
||||
│ └── admin.css ← Shared dashboard/page styling
|
||||
│
|
||||
├── includes/
|
||||
│ ├── page-start.php ← Authenticated page shell start
|
||||
│ └── page-end.php ← Authenticated page shell end
|
||||
│
|
||||
├── api/
|
||||
│ ├── auth/ ← login.php · logout.php · change_password.php
|
||||
│ ├── users/ ← ambil · simpan · update · reset_password · toggle_active
|
||||
│ ├── ibadah/ ← ambil · simpan · update · hapus · update_posisi · update_kontak · update_radius · recalculate
|
||||
│ ├── penduduk/ ← ambil · simpan · verifikasi · hapus · nonaktifkan · update_posisi · update_status · riwayat · arsip
|
||||
│ ├── stats/ ← ambil.php (aggregations)
|
||||
│ ├── kebutuhan/ ← ambil · simpan · update_status
|
||||
│ ├── papan/ ← public board + donor contact endpoints
|
||||
│ ├── import/ ← csv.php · template.php
|
||||
│ └── export/ ← csv.php · pdf.php
|
||||
│
|
||||
├── tests/
|
||||
│ ├── test_db.php ← Schema smoke test
|
||||
│ ├── test_auth_helper.php ← Auth helper unit tests
|
||||
│ ├── test_ibadah.php ← Ibadah column + radius tests
|
||||
│ ├── test_penduduk.php ← Penduduk columns + NIK index tests
|
||||
│ ├── test_kebutuhan.php ← Kebutuhan schema/query smoke tests
|
||||
│ ├── test_proximity.php ← Haversine + tie-breaking unit tests
|
||||
│ ├── test_verifikasi_binding.php ← Verification endpoint bind order guard
|
||||
│ ├── test_update_posisi_scope.php ← Penduduk move scope guard
|
||||
│ ├── test_operator_scope_endpoints.php ← Operator endpoint scope checks
|
||||
│ ├── test_public_verification_filters.php← Public verification filters
|
||||
│ ├── test_frontend_module_loader.php ← Module loader cache-busting guard
|
||||
│ ├── test_frontend_reliability_guards.php ← Loader/heatmap/recalc guard checks
|
||||
│ ├── test_no_legacy_poi.php ← Legacy POI removal guard
|
||||
│ ├── test_viewer_public_filters.php ← Viewer public-data leakage guard
|
||||
│ ├── test_operator_scope_strict.php ← Strict operator ibadah scope guard
|
||||
│ ├── test_penduduk_binding_types.php ← Penduduk/import bind type guard
|
||||
│ ├── test_csrf_enforcement.php ← CSRF helper/endpoint/frontend wiring guard
|
||||
│ ├── test_session_cookie_security.php ← Session cookie security guard
|
||||
│ ├── test_operator_ibadah_highlight.php ← Operator assigned-ibadah highlight guard
|
||||
│ └── test_viewer_account_ui_hidden.php ← Viewer account creation UI guard
|
||||
│
|
||||
├── Tugas/ ← Assignment docs (SRS, use-case diagram)
|
||||
└── docs/
|
||||
├── codebase-navigation.md ← This file
|
||||
└── superpowers/
|
||||
├── specs/ ← PRD and design specs
|
||||
└── plans/ ← Phase implementation plans
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Essential Files — Start Here
|
||||
|
||||
| File | Why You Need It |
|
||||
|------|-----------------|
|
||||
| [config.php](../config.php) | All configurable constants — DB credentials, map center (CENTER_LAT/LNG), SESSION_TIMEOUT, MAX_LOGIN_ATTEMPTS |
|
||||
| [koneksi.php](../koneksi.php) | DB connection + the two core functions: `haversine_distance()` and `calc_proximity()` — the heart of the proximity engine |
|
||||
| [setup_database.sql](../setup_database.sql) | Authoritative schema — run this to initialize or reset the DB |
|
||||
| [auth/helper.php](../auth/helper.php) | RBAC foundation used by every endpoint: `require_auth()`, `has_role()`, `is_logged_in()`, session timeout logic |
|
||||
| [index.php](../index.php) | Main SPA shell — role detection, toolbar rendering, all modals, map init, utility functions (`showToast`, `showDeleteConfirm`, etc.) |
|
||||
|
||||
## Related Documentation
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| [codebase-audit-report.md](codebase-audit-report.md) | End-to-end business process, backend/frontend wiring, data lifecycle, current logic concerns, fix priorities, and development suggestions. |
|
||||
| [business-process-sop.md](business-process-sop.md) | SOP proses bisnis aktif untuk administrator, operator, publik, donatur, verifikasi, proximity, dan catatan audit. |
|
||||
| [deployment-docker.md](deployment-docker.md) | Docker/Coolify deployment, reverse proxy routing, database container, and troubleshooting. |
|
||||
| [deployment-xampp.md](deployment-xampp.md) | Legacy XAMPP setup if the app must run without Docker. |
|
||||
| [superpowers/README.md](superpowers/README.md) | Index PRD, implementation plans, and the latest superpowers audit refresh. |
|
||||
| [superpowers/plans/2026-06-03-codebase-audit-refresh.md](superpowers/plans/2026-06-03-codebase-audit-refresh.md) | Latest codebase audit snapshot after hardening. |
|
||||
| [../README.md](../README.md) | Setup, stack, default account, roles, features, and test commands. |
|
||||
| [../setup_database.sql](../setup_database.sql) | Authoritative database schema and default admin seed. |
|
||||
|
||||
---
|
||||
|
||||
## Database Tables
|
||||
|
||||
### Core v1.0 Tables
|
||||
|
||||
**`rumah_ibadah`** — Worship places with service radius
|
||||
| Key Columns | Notes |
|
||||
|-------------|-------|
|
||||
| `id`, `nama`, `jenis` (ENUM 6 types), `lat/lng` | Core identity |
|
||||
| `radius_m` INT DEFAULT 500 | Validated 100–5000 |
|
||||
| `deleted_at` TIMESTAMP NULL | Soft delete — `WHERE deleted_at IS NULL` for all queries |
|
||||
|
||||
**`penduduk_miskin`** — Poor residents (core data)
|
||||
| Key Columns | Notes |
|
||||
|-------------|-------|
|
||||
| `id`, `nama_kk`, `nik` VARCHAR(16) UNIQUE | NIK nullable but unique |
|
||||
| `lat/lng`, `ibadah_id` FK, `jarak_m`, `is_blank_spot` | Proximity results — computed by `calc_proximity()` |
|
||||
| `kategori` ENUM 3 levels, `status_bantuan` ENUM 3 states | Classification + aid tracking |
|
||||
| `status_verifikasi` ENUM('Pending','Terverifikasi','Ditolak') | Public visibility gate |
|
||||
| `is_active` TINYINT(1) | 0 = deceased/relocated (archived) |
|
||||
| `deleted_at` TIMESTAMP NULL | Admin error correction (soft delete) |
|
||||
|
||||
> **`is_active=0` vs `deleted_at IS NOT NULL`:** Both hide the record from all active queries. `is_active=0` = real person, archived. `deleted_at` = data entry error, logically cancelled.
|
||||
|
||||
**`users`** — System accounts
|
||||
| Key Columns | Notes |
|
||||
|-------------|-------|
|
||||
| `role` ENUM('administrator','operator','viewer') | RBAC basis |
|
||||
| `ibadah_id` FK | Operator's assigned worship place |
|
||||
| `must_change_password` TINYINT(1) | Forces redirect to change_password.php on login |
|
||||
| `login_attempts`, `locked_until` | Brute-force protection state |
|
||||
|
||||
**`riwayat_bantuan`** — Aid status audit log (append-only)
|
||||
| Key Columns | Notes |
|
||||
|-------------|-------|
|
||||
| `penduduk_id`, `operator_id` FK | Who changed what |
|
||||
| `status_lama`, `status_baru`, `catatan` | Change record |
|
||||
| No `deleted_at` | Intentionally — immutable audit trail |
|
||||
|
||||
**`kebutuhan`** — Assistance needs per resident
|
||||
| Key Columns | Notes |
|
||||
|-------------|-------|
|
||||
| `penduduk_id`, `kategori`, `deskripsi`, `status` | Need details and fulfillment state |
|
||||
| `created_by`, `updated_by` | Operator/admin audit references |
|
||||
|
||||
**`riwayat_kebutuhan`** — Kebutuhan status audit log (append-only)
|
||||
| Key Columns | Notes |
|
||||
|-------------|-------|
|
||||
| `kebutuhan_id`, `operator_id` FK | Which need changed and who changed it |
|
||||
| `status_lama`, `status_baru`, `catatan` | Status transition record |
|
||||
| `created_at` | Append-only timestamp for audit chronology |
|
||||
|
||||
**`kontak_donatur`** — Public donor contact submissions
|
||||
| Key Columns | Notes |
|
||||
|-------------|-------|
|
||||
| `nama`, `kontak`, `kategori_minat`, `pesan` | Public contact form data |
|
||||
| `is_read` | Admin dashboard notification state |
|
||||
|
||||
## API Endpoint Map
|
||||
|
||||
### Public (no auth required)
|
||||
| Endpoint | Method | Purpose |
|
||||
|----------|--------|---------|
|
||||
| `api/ibadah/ambil.php` | GET | All active ibadah + KK/jiwa counts |
|
||||
| `api/penduduk/ambil.php` | GET | Active penduduk — PII masked for non-auth |
|
||||
| `api/stats/ambil.php` | GET | Aggregate stats; rekap_ibadah only for admin |
|
||||
| `api/papan/ambil.php` | GET | Public verified kebutuhan board aggregation |
|
||||
| `api/papan/kontak_donatur.php` | POST | Public donor contact submission |
|
||||
|
||||
### Operator+ (operator or admin)
|
||||
| Endpoint | Method | Purpose |
|
||||
|----------|--------|---------|
|
||||
| `api/penduduk/simpan.php` | POST | Add penduduk (auto-calls calc_proximity) |
|
||||
| `api/penduduk/update_posisi.php` | POST | Move marker (recalcs proximity) |
|
||||
| `api/penduduk/update_status.php` | POST | Change aid status + write audit log |
|
||||
| `api/penduduk/riwayat.php` | GET | Aid history for a penduduk (scoped to own ibadah for operators) |
|
||||
| `api/kebutuhan/ambil.php` | GET | List kebutuhan for a scoped penduduk |
|
||||
| `api/kebutuhan/simpan.php` | POST | Add kebutuhan for a scoped penduduk |
|
||||
| `api/kebutuhan/update_status.php` | POST | Update kebutuhan status + write audit log |
|
||||
|
||||
### Admin only
|
||||
| Endpoint | Method | Purpose |
|
||||
|----------|--------|---------|
|
||||
| `api/ibadah/simpan.php` | POST | Create ibadah |
|
||||
| `api/ibadah/update.php` | POST | Update ibadah master data from admin page |
|
||||
| `api/ibadah/hapus.php` | POST | Soft-delete ibadah (warns if operators linked) |
|
||||
| `api/ibadah/update_posisi.php` | POST | Move ibadah → triggers full recalc |
|
||||
| `api/ibadah/update_radius.php` | POST | Change radius → triggers full recalc |
|
||||
| `api/ibadah/update_kontak.php` | POST | Update contact only |
|
||||
| `api/ibadah/recalculate.php` | POST | Bulk recalculate all proximity |
|
||||
| `api/penduduk/verifikasi.php` | POST | Approve/reject pending penduduk records |
|
||||
| `api/penduduk/hapus.php` | POST | Soft-delete penduduk (data entry error) |
|
||||
| `api/penduduk/nonaktifkan.php` | POST | Set is_active=0 (deceased/relocated) |
|
||||
| `api/penduduk/arsip.php` | GET | List inactive/deleted warga for audit (up to 200 rows) |
|
||||
| `api/papan/donatur.php` | GET/POST | GET lists donor contacts; POST marks unread entries as read with CSRF |
|
||||
| `api/users/ambil.php` | GET | List all accounts |
|
||||
| `api/users/simpan.php` | POST | Create account (must_change_password=1 forced) |
|
||||
| `api/users/update.php` | POST | Edit nama/role/ibadah_id |
|
||||
| `api/users/reset_password.php` | POST | Generate temp password + must_change=1 |
|
||||
| `api/users/toggle_active.php` | POST | Enable/disable account (cannot self-deactivate) |
|
||||
| `api/import/csv.php` | POST | CSV import (mode=preview or mode=import) |
|
||||
| `api/import/template.php` | GET | Download CSV template |
|
||||
| `api/export/csv.php` | GET | Export warga binaan as CSV (no NIK) |
|
||||
| `api/export/pdf.php` | GET | Print-ready HTML report (no NIK) |
|
||||
|
||||
---
|
||||
|
||||
## Frontend Module Map
|
||||
|
||||
### `modules/ibadah.js`
|
||||
- **Entry:** `window.initIbadah()` — called by index.php on load
|
||||
- **Shared state:** `window._ibadahList[]` — read by penduduk.js for client-side proximity preview
|
||||
- **Key functions:**
|
||||
- `addToMap(data)` — creates draggable marker + radius circle
|
||||
- `buildInfoPopup(data)` — admin: editable kontak/radius/export links; viewer: read-only
|
||||
- `_ibadahHapus(id)` — two-step: operator-warning check → confirm → soft-delete
|
||||
- `_recalcAll()` — calls recalculate.php, disables all CRUD buttons during operation
|
||||
- `window._ibadahToggleLayer(bool)` / `_setBufferVisible(bool)` — layer controls
|
||||
|
||||
### `modules/penduduk.js`
|
||||
- **Entry:** `window.initPenduduk()` — called by index.php on load
|
||||
- **Shared state:** `window._pendudukList[]` — read by stats.js and heatmap.js
|
||||
- **Key functions:**
|
||||
- `buildInfoPopup(data, ibadah)` — proximity section + NIK (auth only) + status buttons (operator) + riwayat
|
||||
- `findNearestIbadah(lat, lng)` — client-side Haversine mirror of PHP `calc_proximity()`
|
||||
- `applyFilters()` — AND-logic filter across 4 dimensions + search
|
||||
- `window._pendudukRecalcAll()` — client-side repaint after ibadah change (no server call)
|
||||
- `_updateBlankSpotCounter()` / `_filterBlankSpot()` — blank spot counter + click-to-filter
|
||||
|
||||
### `modules/stats.js`
|
||||
- **Entry:** `window._showDashboard()` — opens modal
|
||||
- `window._statsUpdate()` — re-fetches and re-renders (only if modal visible)
|
||||
- Destroys and recreates Chart.js instance on each update to avoid memory leaks
|
||||
|
||||
### `modules/heatmap.js`
|
||||
- **Entry:** `window._toggleHeatmap(bool)` — creates/destroys heatmap layer
|
||||
- Intensity = `0.2 + 0.8 * (jumlah_jiwa / maxJiwa)` — proportional to household size
|
||||
- `window._heatmapSetKategori(k)` — filter heatmap by category
|
||||
|
||||
### `modules/kebutuhan.js`
|
||||
- **Entry:** loaded for authenticated users through `window.MAP_MODULES`
|
||||
- Builds kebutuhan controls inside penduduk popups
|
||||
- Calls `api/kebutuhan/ambil.php`, `api/kebutuhan/simpan.php`, and `api/kebutuhan/update_status.php`
|
||||
|
||||
---
|
||||
|
||||
## Core Algorithm: Proximity Engine
|
||||
|
||||
**Location:** [koneksi.php](../koneksi.php) — `calc_proximity($conn, $lat, $lng)`
|
||||
|
||||
**Logic:**
|
||||
1. Query all `rumah_ibadah WHERE deleted_at IS NULL ORDER BY id ASC`
|
||||
2. For each, compute Haversine distance
|
||||
3. Track two candidates: `best_in_radius` (nearest within radius) and `best_overall` (nearest regardless)
|
||||
4. Winner = `best_in_radius` if exists; else `best_overall` (blank spot)
|
||||
5. Tie-breaking: `ORDER BY id ASC` + strict `<` comparison → lowest id wins on equal distance
|
||||
|
||||
**Triggers:**
|
||||
- `api/penduduk/simpan.php` — on each new penduduk
|
||||
- `api/penduduk/update_posisi.php` — when penduduk moved
|
||||
- `api/ibadah/update_posisi.php` — after ibadah position change (loops ALL penduduk)
|
||||
- `api/ibadah/update_radius.php` — after radius change (loops ALL penduduk)
|
||||
- `api/ibadah/hapus.php` — after soft-delete (loops ALL penduduk)
|
||||
- `api/ibadah/recalculate.php` — manual bulk trigger (admin "Hitung Ulang Semua")
|
||||
- `api/import/csv.php` — per-row during import
|
||||
|
||||
**Client-side mirror:** `modules/penduduk.js` — `findNearestIbadah()` for instant visual feedback during drag/radius resize (no server call).
|
||||
|
||||
---
|
||||
|
||||
## RBAC Quick Reference
|
||||
|
||||
```
|
||||
viewer (0) → public/fallback read-only map, aggregated stats, PII masked
|
||||
operator (1) → + add/move penduduk, update aid status (own ibadah only)
|
||||
administrator (2) → + everything: CRUD ibadah, manage users, import/export, recalculate
|
||||
```
|
||||
|
||||
Operators are created by administrators in `pages/users.php` and must be linked to one `rumah_ibadah`.
|
||||
The shared admin shell renders navigation by role: administrators see dashboard/master/import/export/user management, while operators see map, penduduk, kebutuhan, and status bantuan only.
|
||||
Unauthenticated visitors are treated as `viewer`, so the admin user-management UI does not offer `viewer` for new accounts. The backend role remains for fallback RBAC and existing read-only accounts.
|
||||
|
||||
**Session flow:**
|
||||
1. Unauthenticated → `index.php` renders as `viewer` (no redirect)
|
||||
2. Login → `api/auth/login.php` → session set → if `must_change_password=1`, redirect to `auth/change_password.php`
|
||||
3. Every page/API: `auth/helper.php:is_logged_in()` validates session + renews `last_activity`
|
||||
4. Idle 2hr → session expired → treated as viewer on next request
|
||||
|
||||
---
|
||||
|
||||
## Legacy Status
|
||||
|
||||
The obsolete road/polyline, parcel/polygon, and business-location prototype features have been removed from the active app.
|
||||
|
||||
Active core:
|
||||
- `rumah_ibadah`
|
||||
- `penduduk_miskin`
|
||||
- `users`
|
||||
- `kebutuhan`
|
||||
- `riwayat_bantuan`
|
||||
- `riwayat_kebutuhan`
|
||||
- `kontak_donatur`
|
||||
|
||||
---
|
||||
|
||||
## Running Tests
|
||||
|
||||
Tests are intended to run from CLI/local PHP. Browser access to `/tests/` is blocked by `.htaccess` for deployment safety.
|
||||
|
||||
Run selected static/smoke tests from the project root:
|
||||
|
||||
```powershell
|
||||
& 'C:\Program Files\Xampp\php\php.exe' tests\run_all.php
|
||||
```
|
||||
|
||||
Some database smoke tests load `../config.php` or `../koneksi.php` and should be run from the `tests` directory after importing `setup_database.sql`.
|
||||
|
||||
---
|
||||
|
||||
## Configuration Quick-Start
|
||||
|
||||
Edit [config.php](../config.php) to set:
|
||||
```php
|
||||
define('CENTER_LAT', -0.0557); // Map center latitude
|
||||
define('CENTER_LNG', 109.3487); // Map center longitude
|
||||
define('ZOOM_LEVEL', 15); // Initial zoom
|
||||
define('DB_HOST', 'localhost');
|
||||
define('DB_PORT', 3307); // Local configured MySQL port
|
||||
define('DB_NAME', 'db_webgis');
|
||||
```
|
||||
|
||||
Default admin credentials (must change on first login): `admin` / `Admin1234`
|
||||
@@ -0,0 +1,105 @@
|
||||
# Deployment Docker
|
||||
|
||||
Panduan ini menjadi referensi utama untuk menjalankan WebGIS Poverty Mapping. XAMPP tidak lagi menjadi jalur deploy utama; gunakan XAMPP hanya sebagai fallback legacy.
|
||||
|
||||
## Runtime
|
||||
|
||||
- PHP 8.2 Apache dari `Dockerfile` root repository.
|
||||
- MariaDB 11.4 dari `docker-compose.yml`.
|
||||
- Database aplikasi final: `db_webgis`.
|
||||
- Database project kelas `01/`: `db_webgis_01`.
|
||||
- Aplikasi final tersedia di path `/WebgisPovertyMapping/`.
|
||||
|
||||
## Struktur Docker
|
||||
|
||||
File Docker berada di root repository `webgis/`:
|
||||
|
||||
```text
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
docker-compose.local.yml
|
||||
.env.example
|
||||
docker/apache-webgis.conf
|
||||
```
|
||||
|
||||
Service:
|
||||
|
||||
- `app`: Apache + PHP, melayani root landing page, `01/`, dan `WebgisPovertyMapping/`.
|
||||
- `db`: MariaDB, private untuk service `app`.
|
||||
|
||||
## Local Development
|
||||
|
||||
Jalankan dari root repository:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose -f docker-compose.yml -f docker-compose.local.yml up --build -d
|
||||
```
|
||||
|
||||
Buka:
|
||||
|
||||
```text
|
||||
http://localhost:8080/
|
||||
http://localhost:8080/WebgisPovertyMapping/
|
||||
```
|
||||
|
||||
Jika port `8080` sudah dipakai, tambahkan di `.env`:
|
||||
|
||||
```env
|
||||
APP_PORT=8081
|
||||
```
|
||||
|
||||
Lalu jalankan ulang compose lokal.
|
||||
|
||||
## Database Init
|
||||
|
||||
MariaDB menjalankan file berikut hanya saat volume database masih kosong:
|
||||
|
||||
```text
|
||||
01/setup_database.sql
|
||||
WebgisPovertyMapping/setup_database.sql
|
||||
```
|
||||
|
||||
Untuk reset database lokal:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.local.yml down -v
|
||||
docker compose -f docker-compose.yml -f docker-compose.local.yml up --build -d
|
||||
```
|
||||
|
||||
## Production Di Coolify
|
||||
|
||||
Gunakan `docker-compose.yml` saja.
|
||||
|
||||
1. Push root repository `webgis/` ke Git provider.
|
||||
2. Buat resource baru di Coolify dari repository tersebut.
|
||||
3. Pilih deployment type `Docker Compose`.
|
||||
4. Set compose file ke `docker-compose.yml`.
|
||||
5. Assign domain ke service `app`, misalnya `https://ifuntanhub.dev`.
|
||||
6. Set environment variable:
|
||||
|
||||
```env
|
||||
MARIADB_ROOT_PASSWORD=isi-password-kuat
|
||||
```
|
||||
|
||||
`docker-compose.yml` production hanya memakai `expose: 80`. Jangan tambahkan `ports:` untuk production, karena routing publik harus lewat reverse proxy Coolify.
|
||||
|
||||
## Reverse Proxy
|
||||
|
||||
Coolify/reverse proxy menerima traffic publik dari domain dan meneruskannya ke container `app` port `80`.
|
||||
|
||||
Alur request:
|
||||
|
||||
```text
|
||||
https://ifuntanhub.dev -> Coolify reverse proxy -> app:80 -> /WebgisPovertyMapping/
|
||||
```
|
||||
|
||||
Header `X-Forwarded-Proto: https` dipakai oleh aplikasi untuk mengenali HTTPS saat menentukan secure session cookie.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- App tidak bisa dibuka lokal: pastikan memakai `docker-compose.local.yml`, bukan hanya `docker-compose.yml`.
|
||||
- DB gagal terhubung: cek env `MARIADB_ROOT_PASSWORD`, service `db`, dan healthcheck MariaDB.
|
||||
- SQL init tidak berubah setelah edit schema: reset volume database; MariaDB hanya menjalankan init script saat volume kosong.
|
||||
- Domain Coolify tidak masuk ke app: pastikan domain diassign ke service `app` dan container port `80`.
|
||||
- Peta/chart/font gagal load: cek akses internet ke CDN dan tile provider eksternal.
|
||||
@@ -0,0 +1,101 @@
|
||||
# Deployment XAMPP Legacy
|
||||
|
||||
Panduan ini hanya referensi legacy jika aplikasi harus dijalankan tanpa Docker. Jalur utama saat ini adalah Docker Compose dan Coolify; lihat `deployment-docker.md`.
|
||||
|
||||
## Minimum Runtime
|
||||
|
||||
- Apache `>= 2.4`.
|
||||
- PHP `>= 7.4`; pengujian lokal berjalan di PHP `8.2`.
|
||||
- MySQL `>= 5.7` atau MariaDB `>= 10.5`.
|
||||
|
||||
## Port Dan Konfigurasi
|
||||
|
||||
- Contoh URL lokal: `http://localhost:8081/webgis/WebgisPovertyMapping/`.
|
||||
- Apache dapat berjalan di `80`, `8080`, atau `8081`; sesuaikan URL dengan port XAMPP.
|
||||
- MySQL/MariaDB default proyek memakai port `3307`.
|
||||
- Jika MySQL berjalan di `3306`, ubah `DB_PORT` di `config.php`.
|
||||
- Konfigurasi koneksi utama ada di `config.php`: `DB_HOST`, `DB_USER`, `DB_PASS`, `DB_NAME`, dan `DB_PORT`.
|
||||
|
||||
## Import Database
|
||||
|
||||
1. Buat database dari schema:
|
||||
|
||||
```powershell
|
||||
mysql -u root -P 3307 < setup_database.sql
|
||||
```
|
||||
|
||||
2. Alternatif: buka phpMyAdmin, pilih tab SQL, lalu jalankan isi `setup_database.sql`.
|
||||
3. Pastikan nama database sesuai `DB_NAME` di `config.php`.
|
||||
|
||||
## Akun Default
|
||||
|
||||
- Username: `admin`
|
||||
- Password awal: `Admin1234`
|
||||
- Setelah login pertama, admin wajib mengganti password.
|
||||
|
||||
## Proteksi Folder Non-Publik
|
||||
|
||||
Blokir folder tests dan tmp dari akses browser.
|
||||
|
||||
Kenapa: file di tests hanya untuk pengecekan developer. Beberapa test dapat menulis data ke database. Folder tmp dapat berisi session atau file sementara.
|
||||
|
||||
Pastikan `.htaccess` aktif dan Apache mengizinkan override:
|
||||
|
||||
```apache
|
||||
AllowOverride All
|
||||
```
|
||||
|
||||
Smoke test manual:
|
||||
|
||||
```text
|
||||
http://localhost:8081/webgis/WebgisPovertyMapping/tests/test_proximity.php
|
||||
```
|
||||
|
||||
Expected: `403 Forbidden`.
|
||||
|
||||
## Dependency Eksternal
|
||||
|
||||
- Leaflet CSS/JS dari `unpkg.com`.
|
||||
- Chart.js dari `cdn.jsdelivr.net`.
|
||||
- `leaflet.heat@0.2.0` dari `cdn.jsdelivr.net`.
|
||||
- Google Fonts `Inter`.
|
||||
- OpenStreetMap tiles dari `tile.openstreetmap.org`.
|
||||
- Esri imagery dari `server.arcgisonline.com`.
|
||||
- Carto dark tiles dari `basemaps.cartocdn.com`.
|
||||
- Nominatim reverse geocoding dari `nominatim.openstreetmap.org`.
|
||||
|
||||
Jika CDN atau tile server tidak dapat diakses, peta dasar, chart, heatmap, font, atau reverse geocoding bisa terganggu walaupun data aplikasi tetap tersedia.
|
||||
|
||||
## Strategi Mirror Lokal
|
||||
|
||||
Jika aplikasi dipakai di jaringan tertutup, siapkan salinan lokal untuk:
|
||||
|
||||
- Leaflet CSS/JS.
|
||||
- leaflet.heat.
|
||||
- Chart.js.
|
||||
- Lucide icons.
|
||||
- Google Fonts Inter.
|
||||
|
||||
Tile peta juga perlu strategi sendiri:
|
||||
|
||||
- Gunakan tile server internal; atau
|
||||
- Batasi penggunaan ke jaringan yang boleh mengakses OpenStreetMap, Esri, dan Carto.
|
||||
|
||||
## HTTPS Behind Reverse Proxy
|
||||
|
||||
Session cookie `Secure` aktif berdasarkan deteksi HTTPS di PHP. Jika Apache berada di belakang reverse proxy, pastikan header proxy HTTPS diteruskan dan server PHP mengenali request sebagai HTTPS.
|
||||
|
||||
## Abuse Control Publik
|
||||
|
||||
Form kontak donatur memiliki rate limit session sederhana: satu submit per 60 detik per browser session.
|
||||
Untuk deploy publik serius, pertimbangkan CAPTCHA atau rate limit IP di web server.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- DB gagal terhubung: cek service MySQL/MariaDB XAMPP, `DB_PORT`, username, password, dan nama database.
|
||||
- Port Apache berbeda: gunakan URL sesuai port aktif XAMPP, misalnya `8081`.
|
||||
- Import SQL gagal: pastikan MySQL/MariaDB aktif dan user punya izin membuat database/tabel.
|
||||
- CDN tidak bisa diakses: siapkan mirror lokal untuk Leaflet, Chart.js, leaflet.heat, dan font bila deployment berada di jaringan tertutup.
|
||||
- Tile peta lambat/gagal: cek akses ke OpenStreetMap, Esri, dan Carto.
|
||||
- Nominatim gagal: reverse geocoding akan kosong, tetapi input koordinat tetap bisa dipakai.
|
||||
- session timeout: nilai `SESSION_TIMEOUT` di `config.php` mengatur durasi sesi login.
|
||||
@@ -0,0 +1,105 @@
|
||||
<!-- page content ends here -->
|
||||
</main><!-- .al-content -->
|
||||
|
||||
<footer class="al-footer">
|
||||
<span>© 2026 WebGIS Pemetaan Kemiskinan</span>
|
||||
<span>Pemetaan Partisipatif Berbasis Rumah Ibadah</span>
|
||||
</footer>
|
||||
|
||||
</div><!-- .al-main -->
|
||||
</div><!-- .al-wrap -->
|
||||
|
||||
<!-- Chart.js v4 CDN -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
|
||||
<script>
|
||||
function toggleAdminSidebar() {
|
||||
const sb = document.getElementById('alSidebar');
|
||||
if (!sb) return;
|
||||
sb.classList.toggle('collapsed');
|
||||
try { localStorage.setItem('admin_sb_collapsed', sb.classList.contains('collapsed') ? '1' : '0'); } catch(e) {}
|
||||
}
|
||||
(function() {
|
||||
try {
|
||||
if (localStorage.getItem('admin_sb_collapsed') === '1') {
|
||||
const sb = document.getElementById('alSidebar');
|
||||
if (sb) sb.classList.add('collapsed');
|
||||
}
|
||||
} catch(e) {}
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
})();
|
||||
|
||||
// ── Notification panel ───────────────────────────────────────────────
|
||||
(function() {
|
||||
const btn = document.getElementById('hd-notif-btn');
|
||||
const panel = document.getElementById('notif-panel');
|
||||
const badge = document.getElementById('hd-notif-badge');
|
||||
const list = document.getElementById('notif-list');
|
||||
const close = document.getElementById('notif-close');
|
||||
if (!btn || !panel) return;
|
||||
|
||||
const root = window.APP_ROOT || '';
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
function renderItems(items) {
|
||||
if (!items.length) {
|
||||
return '<div class="notif-empty"><i data-lucide="check-circle"></i><span>Semua sudah beres!</span></div>';
|
||||
}
|
||||
return items.map(function(item) {
|
||||
return '<a href="' + esc(root + item.page) + '" class="notif-item">' +
|
||||
'<div class="notif-item-icon type-' + esc(item.type) + '">' +
|
||||
'<i data-lucide="' + esc(item.icon) + '"></i>' +
|
||||
'</div>' +
|
||||
'<div class="notif-item-text">' + esc(item.label) + '</div>' +
|
||||
'<div class="notif-item-count">' + esc(item.count) + '</div>' +
|
||||
'</a>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function loadNotifs() {
|
||||
list.innerHTML = '<div class="notif-loading">Memuat…</div>';
|
||||
fetch(root + 'api/notif/ambil.php')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.status !== 'success') throw new Error();
|
||||
var total = data.total || 0;
|
||||
badge.textContent = total > 99 ? '99+' : total;
|
||||
badge.style.display = total > 0 ? '' : 'none';
|
||||
list.innerHTML = renderItems(data.items || []);
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
})
|
||||
.catch(function() {
|
||||
list.innerHTML = '<div class="notif-empty">Gagal memuat notifikasi.</div>';
|
||||
});
|
||||
}
|
||||
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
var open = panel.style.display !== 'none';
|
||||
panel.style.display = open ? 'none' : 'block';
|
||||
if (!open) loadNotifs();
|
||||
});
|
||||
|
||||
if (close) {
|
||||
close.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
panel.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
if (panel.style.display !== 'none' &&
|
||||
!panel.contains(e.target) && e.target !== btn && !btn.contains(e.target)) {
|
||||
panel.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Load badge count on page load (silent)
|
||||
loadNotifs();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
/**
|
||||
* includes/page-start.php
|
||||
* Shared admin layout — output HTML shell + sidebar + header + open main content.
|
||||
*
|
||||
* Required vars (set by including page before this include):
|
||||
* string $page_title — e.g. 'Dashboard Administrator'
|
||||
* string $page_subtitle — e.g. 'Ringkasan situasi kemiskinan...' (can be '')
|
||||
* string $active_nav — slug of active nav item:
|
||||
* 'dashboard' | 'map' | 'ibadah' | 'penduduk' |
|
||||
* 'kebutuhan' | 'analisis' | 'status' | 'laporan' |
|
||||
* 'import' | 'users' | 'settings' | 'audit'
|
||||
*
|
||||
* Session must already be started; auth must be checked by calling page.
|
||||
*/
|
||||
|
||||
$_nav_role = get_role();
|
||||
$_nav_nama = get_user_nama();
|
||||
$_nav_init = mb_strtoupper(mb_substr(trim($_nav_nama), 0, 1, 'UTF-8'), 'UTF-8');
|
||||
|
||||
// Calculate relative path from the calling script's directory back to app root
|
||||
$_app_root = str_replace('\\', '/', dirname(__DIR__)); // parent of includes/ = app root
|
||||
$_script_dir = str_replace('\\', '/', dirname(realpath($_SERVER['SCRIPT_FILENAME'])));
|
||||
$_rel = ltrim(str_replace($_app_root, '', $_script_dir), '/');
|
||||
$_depth = ($_rel === '') ? 0 : (substr_count($_rel, '/') + 1);
|
||||
$_root = str_repeat('../', $_depth);
|
||||
|
||||
function _sb_item(string $href, string $icon, string $label, string $slug, string $active): string {
|
||||
$cls = ($active === $slug) ? 'sb-item active' : 'sb-item';
|
||||
$h = htmlspecialchars($href);
|
||||
$l = htmlspecialchars($label);
|
||||
return "<a href=\"{$h}\" class=\"{$cls}\"><span class=\"sb-icon\"><i data-lucide=\"{$icon}\"></i></span><span class=\"sb-label\">{$l}</span></a>\n";
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= htmlspecialchars($page_title) ?> — <?= APP_NAME ?></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&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="<?= $_root ?>css/admin.css">
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<script>
|
||||
window.APP_CSRF_TOKEN = <?= json_encode(is_logged_in() ? csrf_token() : null) ?>;
|
||||
window.APP_ROOT = <?= json_encode($_root) ?>;
|
||||
function appendCsrf(fd) {
|
||||
const token = window.APP_USER?.csrfToken || window.APP_CSRF_TOKEN || null;
|
||||
if (token) fd.append('csrf_token', token);
|
||||
return fd;
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="al-wrap">
|
||||
|
||||
<!-- ═══ SIDEBAR ═══ -->
|
||||
<aside class="al-sidebar" id="alSidebar">
|
||||
|
||||
<div class="sb-logo">
|
||||
<div class="sb-logo-icon" style="color: #fff;"><i data-lucide="map"></i></div>
|
||||
<div class="sb-logo-text">
|
||||
<div class="sb-logo-name"><?= APP_NAME ?></div>
|
||||
<div class="sb-logo-sub">Pemetaan Kemiskinan</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="sb-nav">
|
||||
<div class="sb-section-label">Navigasi Utama</div>
|
||||
|
||||
<?php if ($_nav_role === 'administrator'): ?>
|
||||
<?= _sb_item("{$_root}dashboard.php", 'layout-dashboard', 'Dashboard', 'dashboard', $active_nav) ?>
|
||||
<?= _sb_item("{$_root}pages/map.php", 'map', 'Peta Interaktif', 'map', $active_nav) ?>
|
||||
<?= _sb_item("{$_root}pages/ibadah.php", 'map-pin', 'Rumah Ibadah', 'ibadah', $active_nav) ?>
|
||||
<?= _sb_item("{$_root}pages/penduduk.php", 'home', 'Penduduk Miskin', 'penduduk', $active_nav) ?>
|
||||
<?= _sb_item("{$_root}pages/kebutuhan.php", 'heart', 'Kebutuhan & Papan Publik', 'kebutuhan', $active_nav) ?>
|
||||
<?= _sb_item("{$_root}pages/analisis.php", 'alert-triangle', 'Analisis & Blank Spot', 'analisis', $active_nav) ?>
|
||||
<?= _sb_item("{$_root}pages/status-bantuan.php",'clipboard-list', 'Status Bantuan', 'status', $active_nav) ?>
|
||||
<?= _sb_item("{$_root}pages/laporan.php", 'file-text', 'Laporan & Export', 'laporan', $active_nav) ?>
|
||||
<?= _sb_item("{$_root}pages/import.php", 'file-input', 'Import CSV', 'import', $active_nav) ?>
|
||||
|
||||
<div class="sb-section-label">Manajemen</div>
|
||||
<?= _sb_item("{$_root}pages/users.php", 'users', 'Pengguna & Akun', 'users', $active_nav) ?>
|
||||
<?php elseif ($_nav_role === 'operator'): ?>
|
||||
<?= _sb_item("{$_root}pages/map.php", 'map', 'Peta Interaktif', 'map', $active_nav) ?>
|
||||
<?= _sb_item("{$_root}pages/penduduk.php", 'home', 'Penduduk Miskin', 'penduduk', $active_nav) ?>
|
||||
<?= _sb_item("{$_root}pages/kebutuhan.php", 'heart', 'Kebutuhan & Papan Publik', 'kebutuhan', $active_nav) ?>
|
||||
<?= _sb_item("{$_root}pages/status-bantuan.php",'clipboard-list', 'Status Bantuan', 'status', $active_nav) ?>
|
||||
<?php else: ?>
|
||||
<?= _sb_item("{$_root}pages/map.php", 'map', 'Peta Interaktif', 'map', $active_nav) ?>
|
||||
<?php endif; ?>
|
||||
</nav>
|
||||
|
||||
<div class="sb-footer">
|
||||
<button class="sb-toggle-btn" onclick="toggleAdminSidebar()" title="Sembunyikan / Tampilkan Menu">
|
||||
<span class="sb-toggle-icon"><i data-lucide="chevron-left"></i></span>
|
||||
<span class="sb-toggle-label">Sembunyikan Menu</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</aside><!-- .al-sidebar -->
|
||||
|
||||
<!-- ═══ MAIN ═══ -->
|
||||
<div class="al-main">
|
||||
|
||||
<header class="al-header">
|
||||
<button class="hd-hamburger" onclick="toggleAdminSidebar()" aria-label="Toggle sidebar">
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
|
||||
<div class="hd-title-block">
|
||||
<div class="hd-title"><?= htmlspecialchars($page_title) ?></div>
|
||||
<?php if (!empty($page_subtitle)): ?>
|
||||
<div class="hd-subtitle"><?= htmlspecialchars($page_subtitle) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="hd-right">
|
||||
<div class="hd-status">
|
||||
<div class="hd-status-dot"></div>
|
||||
Online
|
||||
</div>
|
||||
|
||||
<div class="notif-wrap" id="notif-wrap">
|
||||
<button class="hd-notif-btn" title="Notifikasi" id="hd-notif-btn">
|
||||
<i data-lucide="bell"></i>
|
||||
<span class="hd-notif-badge" id="hd-notif-badge" style="display:none;">0</span>
|
||||
</button>
|
||||
<div class="notif-panel" id="notif-panel" style="display:none;" role="dialog" aria-label="Notifikasi">
|
||||
<div class="notif-panel-header">
|
||||
<span class="notif-panel-title">Notifikasi</span>
|
||||
<button class="notif-panel-close" id="notif-close" aria-label="Tutup"><i data-lucide="x"></i></button>
|
||||
</div>
|
||||
<div class="notif-list" id="notif-list">
|
||||
<div class="notif-loading">Memuat…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hd-user">
|
||||
<div class="hd-avatar"><?= htmlspecialchars($_nav_init ?: '?') ?></div>
|
||||
<div>
|
||||
<div class="hd-user-name"><?= htmlspecialchars($_nav_nama) ?></div>
|
||||
<div class="hd-user-role"><?= ucfirst(htmlspecialchars($_nav_role)) ?></div>
|
||||
</div>
|
||||
<a href="<?= $_root ?>api/auth/logout.php" class="hd-logout-btn" title="Keluar"><i data-lucide="log-out"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="al-content">
|
||||
<!-- page content starts here -->
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
// Shared validation helpers for request payloads.
|
||||
|
||||
function normalize_coordinate_value($value): array {
|
||||
if ($value === null) {
|
||||
return ['ok' => false, 'message' => 'Koordinat tidak boleh kosong'];
|
||||
}
|
||||
|
||||
$raw = is_string($value)
|
||||
? trim(str_replace([',', "\xe2\x88\x92"], ['.', '-'], $value))
|
||||
: $value;
|
||||
|
||||
if ($raw === '') {
|
||||
return ['ok' => false, 'message' => 'Koordinat tidak boleh kosong'];
|
||||
}
|
||||
|
||||
if (!is_numeric($raw)) {
|
||||
return ['ok' => false, 'message' => 'Koordinat harus berupa angka'];
|
||||
}
|
||||
|
||||
$float = (float)$raw;
|
||||
if (!is_finite($float)) {
|
||||
return ['ok' => false, 'message' => 'Koordinat tidak valid'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'value' => $float];
|
||||
}
|
||||
|
||||
function validate_lat_lng($lat, $lng, $strict_bounds = true): array {
|
||||
$lat_result = normalize_coordinate_value($lat);
|
||||
if (!$lat_result['ok']) {
|
||||
return ['ok' => false, 'message' => 'Latitude: ' . $lat_result['message']];
|
||||
}
|
||||
|
||||
$lng_result = normalize_coordinate_value($lng);
|
||||
if (!$lng_result['ok']) {
|
||||
return ['ok' => false, 'message' => 'Longitude: ' . $lng_result['message']];
|
||||
}
|
||||
|
||||
$lat_value = $lat_result['value'];
|
||||
$lng_value = $lng_result['value'];
|
||||
|
||||
if ($lat_value < -90 || $lat_value > 90) {
|
||||
return ['ok' => false, 'message' => 'Latitude di luar rentang -90 sampai 90'];
|
||||
}
|
||||
if ($lng_value < -180 || $lng_value > 180) {
|
||||
return ['ok' => false, 'message' => 'Longitude di luar rentang -180 sampai 180'];
|
||||
}
|
||||
|
||||
if (
|
||||
$strict_bounds
|
||||
&& defined('MAP_MIN_LAT') && defined('MAP_MAX_LAT')
|
||||
&& defined('MAP_MIN_LNG') && defined('MAP_MAX_LNG')
|
||||
&& (
|
||||
$lat_value < MAP_MIN_LAT || $lat_value > MAP_MAX_LAT
|
||||
|| $lng_value < MAP_MIN_LNG || $lng_value > MAP_MAX_LNG
|
||||
)
|
||||
) {
|
||||
return ['ok' => false, 'message' => 'Koordinat di luar wilayah studi'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'lat' => $lat_value, 'lng' => $lng_value];
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<html><head><meta http-equiv="refresh" content="0;url=index.php"></head>
|
||||
<body><a href="index.php">Klik di sini jika tidak otomatis redirect</a></body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
// koneksi.php — Koneksi database + Haversine helper
|
||||
if (!defined('DB_HOST')) require_once __DIR__ . '/config.php';
|
||||
|
||||
mysqli_report(MYSQLI_REPORT_OFF);
|
||||
$conn = @new mysqli(DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
error_log('Database connection failed: ' . $conn->connect_error);
|
||||
http_response_code(500);
|
||||
die(json_encode([
|
||||
'status' => 'error',
|
||||
'message' => 'Koneksi database gagal. Hubungi administrator.'
|
||||
]));
|
||||
}
|
||||
$conn->set_charset('utf8mb4');
|
||||
|
||||
function haversine_distance($lat1, $lon1, $lat2, $lon2) {
|
||||
$R = 6371000;
|
||||
$dLat = deg2rad($lat2 - $lat1);
|
||||
$dLon = deg2rad($lon2 - $lon1);
|
||||
$a = sin($dLat/2)**2 + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLon/2)**2;
|
||||
return $R * 2 * atan2(sqrt($a), sqrt(1 - $a));
|
||||
}
|
||||
|
||||
// PRD-correct proximity: nearest ibadah whose radius covers the point wins;
|
||||
// among ties (equal distance), ORDER BY id ASC via strict < ensures smallest id.
|
||||
// jarak_m = distance to nearest-overall ibadah when blank spot.
|
||||
function calc_proximity(mysqli $conn, float $lat, float $lng): array {
|
||||
$res = $conn->query(
|
||||
"SELECT id, lat, lng, radius_m FROM rumah_ibadah WHERE deleted_at IS NULL ORDER BY id ASC"
|
||||
);
|
||||
$nearest_overall_dist = PHP_FLOAT_MAX;
|
||||
$best_in_radius = null;
|
||||
$best_in_radius_dist = PHP_FLOAT_MAX;
|
||||
while ($ri = $res->fetch_assoc()) {
|
||||
$d = haversine_distance($lat, $lng, (float)$ri['lat'], (float)$ri['lng']);
|
||||
if ($d < $nearest_overall_dist) $nearest_overall_dist = $d;
|
||||
if ($d <= (float)$ri['radius_m'] && $d < $best_in_radius_dist) {
|
||||
$best_in_radius_dist = $d;
|
||||
$best_in_radius = $ri;
|
||||
}
|
||||
}
|
||||
if ($best_in_radius) {
|
||||
return [
|
||||
'ibadah_id' => (int)$best_in_radius['id'],
|
||||
'jarak_m' => round($best_in_radius_dist, 2),
|
||||
'is_blank_spot' => 0,
|
||||
];
|
||||
}
|
||||
return [
|
||||
'ibadah_id' => null,
|
||||
'jarak_m' => $nearest_overall_dist < PHP_FLOAT_MAX ? round($nearest_overall_dist, 2) : null,
|
||||
'is_blank_spot' => 1,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// modules/heatmap.js — Heatmap kepadatan, intensitas proporsional jumlah_jiwa (F9)
|
||||
(function () {
|
||||
let heatmapLayer = null;
|
||||
let active = false;
|
||||
let heatmapKategori = '';
|
||||
|
||||
function canUseHeatmap() {
|
||||
return !!(window.APP_USER?.isOp || window.APP_USER?.isAdmin);
|
||||
}
|
||||
|
||||
function buildHeatData() {
|
||||
const source = (window._pendudukAll || window._pendudukList || []).filter(function (p) {
|
||||
return !heatmapKategori || p.kategori === heatmapKategori;
|
||||
});
|
||||
const validSource = source
|
||||
.map(function (p) {
|
||||
const rawLat = p.lat;
|
||||
const rawLng = p.lng;
|
||||
const lat = Number(p.lat);
|
||||
const lng = Number(p.lng);
|
||||
return {
|
||||
hasCoordinate: rawLat !== null && rawLat !== '' && rawLng !== null && rawLng !== '',
|
||||
lat: lat,
|
||||
lng: lng,
|
||||
jumlah_jiwa: Number(p.jumlah_jiwa || 1)
|
||||
};
|
||||
})
|
||||
.filter(function (p) {
|
||||
const lat = p.lat;
|
||||
const lng = p.lng;
|
||||
return p.hasCoordinate && Number.isFinite(lat) && Number.isFinite(lng);
|
||||
});
|
||||
const maxJiwa = Math.max(1, ...validSource.map(function (p) {
|
||||
return p.jumlah_jiwa || 1;
|
||||
}));
|
||||
return validSource.map(function (p) {
|
||||
return [p.lat, p.lng, 0.2 + 0.8 * ((p.jumlah_jiwa || 1) / maxJiwa)];
|
||||
});
|
||||
}
|
||||
|
||||
window._heatmapUpdate = function () {
|
||||
if (!window.APP_USER?.isOp && !window.APP_USER?.isAdmin) {
|
||||
active = false;
|
||||
if (heatmapLayer && typeof map !== 'undefined' && map.hasLayer(heatmapLayer)) {
|
||||
map.removeLayer(heatmapLayer);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!active || !heatmapLayer) return;
|
||||
heatmapLayer.setLatLngs(buildHeatData());
|
||||
};
|
||||
|
||||
window._heatmapSetKategori = function (k) {
|
||||
heatmapKategori = k;
|
||||
window._heatmapUpdate();
|
||||
};
|
||||
|
||||
window._toggleHeatmap = function (visible) {
|
||||
if (!canUseHeatmap() || typeof L === 'undefined' || !L.heatLayer || typeof map === 'undefined') {
|
||||
active = false;
|
||||
const checkbox = document.getElementById('chkHeatmap');
|
||||
if (checkbox) checkbox.checked = false;
|
||||
if (heatmapLayer && typeof map !== 'undefined' && map.hasLayer(heatmapLayer)) {
|
||||
map.removeLayer(heatmapLayer);
|
||||
}
|
||||
return;
|
||||
}
|
||||
active = visible;
|
||||
if (active) {
|
||||
if (!heatmapLayer) {
|
||||
heatmapLayer = L.heatLayer([], {
|
||||
radius : 25,
|
||||
blur : 15,
|
||||
maxZoom : 17,
|
||||
gradient: { 0.4: 'blue', 0.6: 'cyan', 0.7: 'lime', 0.8: 'yellow', 1.0: 'red' }
|
||||
});
|
||||
}
|
||||
heatmapLayer.addTo(map);
|
||||
window._heatmapUpdate();
|
||||
showToast('Heatmap Kepadatan diaktifkan.');
|
||||
} else {
|
||||
if (heatmapLayer) map.removeLayer(heatmapLayer);
|
||||
showToast('Heatmap dinonaktifkan.');
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,887 @@
|
||||
// modules/ibadah.js — Manajemen Rumah Ibadah dengan Radius Buffer & Reverse Geocoding
|
||||
(function () {
|
||||
const layerGroup = L.layerGroup();
|
||||
window._ibadahLayer = layerGroup;
|
||||
|
||||
// Data list: [{id, lat, lng, nama, jenis, radius}] — dibaca oleh penduduk.js
|
||||
window._ibadahList = [];
|
||||
|
||||
let allMarkers = {}; // { id: { marker, circle } }
|
||||
let tempMarker = null;
|
||||
let _previewCircle = null;
|
||||
let active = false;
|
||||
let bufferVisible = true;
|
||||
|
||||
// ── Icon & Color Factory ──────────────────────────────────────────
|
||||
const JENIS_ICON = {
|
||||
'Masjid' : '🕌',
|
||||
'Mushola' : '🕌',
|
||||
'Gereja' : '⛪',
|
||||
'Pura' : '🛕',
|
||||
'Vihara' : '🏯',
|
||||
'Klenteng' : '🏮',
|
||||
'Lainnya' : '🕍',
|
||||
};
|
||||
|
||||
// Warna border circle per jenis (sesuai PRD F2)
|
||||
const JENIS_COLOR = {
|
||||
'Masjid' : { stroke: '#38bdf8', fill: '#0ea5e9' }, // biru
|
||||
'Mushola' : { stroke: '#67e8f9', fill: '#06b6d4' }, // cyan
|
||||
'Gereja' : { stroke: '#4ade80', fill: '#16a34a' }, // hijau
|
||||
'Pura' : { stroke: '#f97316', fill: '#ea580c' }, // oranye
|
||||
'Vihara' : { stroke: '#facc15', fill: '#ca8a04' }, // kuning
|
||||
'Klenteng' : { stroke: '#f43f5e', fill: '#be123c' }, // merah
|
||||
'Lainnya' : { stroke: '#a78bfa', fill: '#7c3aed' }, // ungu
|
||||
};
|
||||
|
||||
const OPERATOR_HIGHLIGHT = {
|
||||
stroke: '#f59e0b',
|
||||
fill: '#fbbf24',
|
||||
ring: 'rgba(245,158,11,0.22)',
|
||||
shadow: 'rgba(180,83,9,0.35)'
|
||||
};
|
||||
|
||||
function isOwnOperatorIbadah(id) {
|
||||
const appUser = window.APP_USER || {};
|
||||
if (!appUser.isOp || appUser.ibadahId === null || appUser.ibadahId === undefined) return false;
|
||||
return parseInt(appUser.ibadahId) === parseInt(id);
|
||||
}
|
||||
|
||||
// ── KK list helper (called on popupopen & after setPopupContent) ──
|
||||
function _refreshKkList(id) {
|
||||
const bodyEl = document.getElementById('kkListBody_' + id);
|
||||
const countEl = document.getElementById('kkCount_' + id);
|
||||
if (!bodyEl) return;
|
||||
|
||||
const list = (window._pendudukAll || window._pendudukList || []).filter(p => p.ibadah && parseInt(p.ibadah.id) === parseInt(id));
|
||||
|
||||
// Update live count (overrides stale DB value from initial load)
|
||||
if (countEl) {
|
||||
const totalJiwa = list.reduce((s, p) => s + (parseInt(p.jumlah_jiwa) || 1), 0);
|
||||
countEl.innerHTML = `<span style="color:#38bdf8;font-weight:700;">${list.length}</span> KK · <span style="color:#38bdf8;font-weight:700;">${totalJiwa}</span> jiwa`;
|
||||
}
|
||||
|
||||
if (list.length === 0) {
|
||||
bodyEl.innerHTML = '<span style="color:var(--c-muted);font-size:12px;">Belum ada warga binaan.</span>';
|
||||
return;
|
||||
}
|
||||
const MAX = 8;
|
||||
let html = list.slice(0, MAX).map(p => `
|
||||
<div onclick="window._pendudukFlyTo(${p.id})" style="
|
||||
padding:5px 8px;margin:2px 0;border-radius:6px;cursor:pointer;
|
||||
background:rgba(255,255,255,.04);display:flex;justify-content:space-between;
|
||||
align-items:center;border:1px solid rgba(255,255,255,.07);transition:background .15s;
|
||||
"
|
||||
onmouseover="this.style.background='rgba(255,255,255,.1)'"
|
||||
onmouseout="this.style.background='rgba(255,255,255,.04)'">
|
||||
<span style="font-size:12px;">${escapeHTML(p.nama_kk || '(Anonim)')}</span>
|
||||
<span style="font-size:10px;color:var(--c-muted);white-space:nowrap;margin-left:6px;">${p.jumlah_jiwa || 1} jiwa</span>
|
||||
</div>`).join('');
|
||||
if (list.length > MAX) {
|
||||
html += `<div style="font-size:11px;color:var(--c-muted);text-align:center;margin-top:4px;">+${list.length - MAX} lainnya</div>`;
|
||||
}
|
||||
bodyEl.innerHTML = html;
|
||||
}
|
||||
|
||||
function createIbadahIcon(jenis, ownOperatorIbadah = false) {
|
||||
const emoji = JENIS_ICON[jenis] || '🕍';
|
||||
const size = ownOperatorIbadah ? 46 : 36;
|
||||
const border = ownOperatorIbadah ? `4px solid ${OPERATOR_HIGHLIGHT.stroke}` : '2px solid rgba(56,189,248,0.8)';
|
||||
const background = ownOperatorIbadah ? 'linear-gradient(135deg, #fffbeb, #fef3c7)' : 'linear-gradient(135deg, #ffffff, #f0f9ff)';
|
||||
const shadow = ownOperatorIbadah
|
||||
? `0 0 0 8px ${OPERATOR_HIGHLIGHT.ring}, 0 8px 22px ${OPERATOR_HIGHLIGHT.shadow}`
|
||||
: '0 3px 12px rgba(14,165,233,0.45)';
|
||||
const fontSize = ownOperatorIbadah ? 18 : 16;
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="
|
||||
width:${size}px; height:${size}px;
|
||||
background: ${background};
|
||||
border: ${border};
|
||||
border-radius: 50% 50% 50% 0;
|
||||
transform: rotate(-45deg);
|
||||
box-shadow: ${shadow};
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
">
|
||||
<span style="transform:rotate(45deg); font-size:${fontSize}px; line-height:1;">${emoji}</span>
|
||||
</div>`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: ownOperatorIbadah ? [12, 46] : [8, 36],
|
||||
popupAnchor: ownOperatorIbadah ? [10, -48] : [10, -38]
|
||||
});
|
||||
}
|
||||
|
||||
const iconTemp = L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="
|
||||
width:32px; height:32px;
|
||||
background: rgba(56,189,248,0.15);
|
||||
border: 2px dashed #38bdf8;
|
||||
border-radius: 50%;
|
||||
animation: pulse-ibadah 1s ease-in-out infinite;
|
||||
"></div>`,
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16],
|
||||
});
|
||||
|
||||
// ── Tambah ke peta ────────────────────────────────────────────────
|
||||
function addToMap(data) {
|
||||
const lat = parseFloat(data.lat);
|
||||
const lng = parseFloat(data.lng);
|
||||
const radius = parseInt(data.radius_m) || 500;
|
||||
const jenis = data.jenis || 'Lainnya';
|
||||
const emoji = JENIS_ICON[jenis] || '🕍';
|
||||
const ownOperatorIbadah = isOwnOperatorIbadah(data.id);
|
||||
|
||||
// Lingkaran radius — warna berbeda per jenis
|
||||
const clr = JENIS_COLOR[jenis] || JENIS_COLOR['Lainnya'];
|
||||
const circle = L.circle([lat, lng], {
|
||||
radius: radius,
|
||||
color: ownOperatorIbadah ? OPERATOR_HIGHLIGHT.stroke : clr.stroke,
|
||||
weight: ownOperatorIbadah ? 3 : 1.5,
|
||||
opacity: ownOperatorIbadah ? 1 : 0.8,
|
||||
fillColor: ownOperatorIbadah ? OPERATOR_HIGHLIGHT.fill : clr.fill,
|
||||
fillOpacity: ownOperatorIbadah ? 0.12 : 0.07,
|
||||
});
|
||||
|
||||
const isAdmin = window.APP_USER && window.APP_USER.isAdmin;
|
||||
const marker = L.marker([lat, lng], {
|
||||
icon: createIbadahIcon(jenis, ownOperatorIbadah),
|
||||
draggable: isAdmin,
|
||||
zIndexOffset: ownOperatorIbadah ? 1000 : 100
|
||||
});
|
||||
|
||||
marker._ibadahId = data.id;
|
||||
marker._ibadahNama = data.nama;
|
||||
marker._ibadahJenis = jenis;
|
||||
marker._ibadahRadius = radius;
|
||||
marker._circle = circle;
|
||||
|
||||
// Drag: update posisi + recalculate penduduk
|
||||
marker.on('dragend', function (e) {
|
||||
const { lat: newLat, lng: newLng } = e.target.getLatLng();
|
||||
circle.setLatLng([newLat, newLng]);
|
||||
|
||||
// Update _ibadahList (parseInt to guard against string/number type mismatch)
|
||||
const entry = window._ibadahList.find(x => parseInt(x.id) === parseInt(data.id));
|
||||
if (entry) { entry.lat = newLat; entry.lng = newLng; }
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('id', data.id);
|
||||
fd.append('lat', newLat.toFixed(8));
|
||||
fd.append('lng', newLng.toFixed(8));
|
||||
|
||||
fetch('api/ibadah/update_posisi.php', { method: 'POST', body: appendCsrf(fd) })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status === 'success') {
|
||||
showToast(`Posisi "${escapeHTML(data.nama)}" diperbarui.`);
|
||||
// Recalculate semua penduduk
|
||||
if (typeof window._pendudukRecalcAll === 'function') {
|
||||
window._pendudukRecalcAll();
|
||||
}
|
||||
} else throw new Error(j.message);
|
||||
})
|
||||
.catch(err => showToast(err.message || 'Gagal update posisi.', 'error'));
|
||||
});
|
||||
|
||||
// Popup info
|
||||
marker.bindPopup(buildInfoPopup(data), { maxWidth: 340 });
|
||||
|
||||
// KK list: populate on popup open and after inline edits
|
||||
marker.on('popupopen', function () { _refreshKkList(data.id); });
|
||||
|
||||
if (bufferVisible) circle.addTo(layerGroup);
|
||||
marker.addTo(layerGroup);
|
||||
|
||||
// Simpan data lengkap agar bisa di-update nanti
|
||||
allMarkers[data.id] = { marker, circle, data: { ...data, radius_m: radius } };
|
||||
|
||||
// Update global list untuk penduduk.js
|
||||
updateIbadahList();
|
||||
}
|
||||
|
||||
function buildInfoPopup(data) {
|
||||
const isAdmin = window.APP_USER && window.APP_USER.isAdmin;
|
||||
const ownOperatorIbadah = isOwnOperatorIbadah(data.id);
|
||||
const jenis = data.jenis || 'Lainnya';
|
||||
const emoji = JENIS_ICON[jenis] || '🕍';
|
||||
const r = parseInt(data.radius_m) || 500;
|
||||
const alamatHtml = data.alamat
|
||||
? `<div class="info-row">
|
||||
<div class="info-row-icon"><i data-lucide="map-pin"></i></div>
|
||||
<div>
|
||||
<div class="info-row-label">Alamat</div>
|
||||
<div class="info-row-value" style="font-size:12px;line-height:1.4;">${escapeHTML(data.alamat)}</div>
|
||||
</div>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
const kontakValue = data.kontak ? escapeHTML(data.kontak) : '';
|
||||
const kontakHtml = (isAdmin || data.kontak) ? `
|
||||
<div class="info-row" style="align-items:flex-start;">
|
||||
<div class="info-row-icon"><i data-lucide="phone"></i></div>
|
||||
<div style="flex:1;">
|
||||
<div class="info-row-label">Kontak Pengurus</div>
|
||||
${isAdmin ? `
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-top:6px;">
|
||||
<input type="text" id="edit_kontak_${data.id}"
|
||||
value="${kontakValue}" placeholder="No HP/WA (Opsional)"
|
||||
style="flex:1;padding:6px 8px;border-radius:4px;border:1px solid rgba(255,255,255,0.15);background:rgba(0,0,0,0.2);color:var(--c-text);font-size:12px;font-family:var(--font-body);outline:none;">
|
||||
<button onclick="window._ibadahSimpanKontak(${data.id})"
|
||||
style="padding:6px 10px;background:rgba(124,58,237,.15);border:1px solid rgba(168,85,247,.4);border-radius:6px;color:#a855f7;font-size:12px;font-weight:700;cursor:pointer;transition:all .2s;display:inline-flex;align-items:center;gap:4px;"
|
||||
onmouseover="this.style.background='rgba(124,58,237,.25)'"
|
||||
onmouseout="this.style.background='rgba(124,58,237,.15)'">
|
||||
<i data-lucide="save" style="width:12px;height:12px;"></i> Simpan
|
||||
</button>
|
||||
</div>
|
||||
<div id="kontak_status_${data.id}" style="font-size:11px;margin-top:4px;font-family:var(--font-mono);"></div>
|
||||
` : `<div class="info-row-value" style="margin-top:4px;">${kontakValue || '—'}</div>`}
|
||||
</div>
|
||||
</div>` : '';
|
||||
|
||||
return `<div class="info-popup">
|
||||
<div class="info-popup-header">
|
||||
<div class="info-popup-icon" style="background:rgba(124,58,237,.2);">${emoji}</div>
|
||||
<div>
|
||||
<div class="info-popup-name">${escapeHTML(data.nama)}</div>
|
||||
<div class="info-popup-id">#${data.id} · ${escapeHTML(jenis)}</div>
|
||||
</div>
|
||||
</div>
|
||||
${ownOperatorIbadah ? `
|
||||
<div class="info-row" style="border-color:rgba(245,158,11,.35);background:rgba(245,158,11,.10);">
|
||||
<div class="info-row-icon" style="color:#f59e0b;"><i data-lucide="star"></i></div>
|
||||
<div>
|
||||
<div class="info-row-label" style="color:#92400e;">Rumah ibadah Anda</div>
|
||||
<div class="info-row-value" style="font-size:12px;line-height:1.4;color:#78350f;">Akun operator ini terhubung ke rumah ibadah tersebut.</div>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
${alamatHtml}
|
||||
${kontakHtml}
|
||||
${isAdmin ? `
|
||||
<div class="info-row" style="align-items:flex-start;">
|
||||
<div class="info-row-icon"><i data-lucide="ruler"></i></div>
|
||||
<div style="flex:1;">
|
||||
<div class="info-row-label">Radius Wilayah</div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-top:6px;">
|
||||
<input type="range" id="edit_radius_${data.id}"
|
||||
value="${r}" min="100" max="5000" step="50"
|
||||
oninput="document.getElementById('edit_radius_val_${data.id}').textContent=this.value; window._ibadahPreviewRadiusById(${data.id},this.value)"
|
||||
style="flex:1;height:4px;accent-color:#38bdf8;cursor:pointer;">
|
||||
<span id="edit_radius_val_${data.id}"
|
||||
style="min-width:45px;text-align:right;font-family:var(--font-mono);
|
||||
font-size:12px;color:#38bdf8;font-weight:700;">${r}m</span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:10px;color:var(--c-muted);margin-top:2px;">
|
||||
<span>100m</span><span>5000m</span>
|
||||
</div>
|
||||
<button onclick="window._ibadahSimpanRadius(${data.id})"
|
||||
style="margin-top:8px;width:100%;padding:6px;background:rgba(14,165,233,.12);
|
||||
border:1px solid rgba(56,189,248,.4);border-radius:7px;
|
||||
color:#38bdf8;font-size:12px;font-weight:700;cursor:pointer;
|
||||
font-family:var(--font-body);transition:all .2s;display:inline-flex;align-items:center;justify-content:center;gap:4px;"
|
||||
onmouseover="this.style.background='rgba(14,165,233,.25)'"
|
||||
onmouseout="this.style.background='rgba(14,165,233,.12)'">
|
||||
<i data-lucide="save" style="width:12px;height:12px;"></i> Simpan Radius
|
||||
</button>
|
||||
<div id="radius_status_${data.id}" style="font-size:11px;margin-top:4px;font-family:var(--font-mono);"></div>
|
||||
</div>
|
||||
</div>` : `
|
||||
<div class="info-row">
|
||||
<div class="info-row-icon"><i data-lucide="ruler"></i></div>
|
||||
<div>
|
||||
<div class="info-row-label">Radius Wilayah</div>
|
||||
<div class="info-row-value" style="font-family:var(--font-mono);color:#38bdf8;">${r}m</div>
|
||||
</div>
|
||||
</div>`}
|
||||
<div class="info-row">
|
||||
<div class="info-row-icon"><i data-lucide="users"></i></div>
|
||||
<div>
|
||||
<div class="info-row-label">Warga Binaan <span style="font-size:9px;color:var(--c-muted);font-weight:400;">(terverifikasi)</span></div>
|
||||
<div id="kkCount_${data.id}" class="info-row-value" style="font-family:var(--font-mono);">
|
||||
<span style="color:#38bdf8;font-weight:700;">${parseInt(data.total_kk) || 0}</span> KK
|
||||
·
|
||||
<span style="color:#38bdf8;font-weight:700;">${parseInt(data.total_jiwa) || 0}</span> jiwa
|
||||
</div>
|
||||
${parseInt(data.jiwa_terjangkau) > 0 || parseInt(data.jiwa_blankspot) > 0 ? `
|
||||
<div style="font-size:10px;color:var(--c-muted);margin-top:3px;">
|
||||
<span style="color:#22c55e;">● ${parseInt(data.jiwa_terjangkau)||0} terjangkau</span>
|
||||
·
|
||||
<span style="color:#ef4444;">● ${parseInt(data.jiwa_blankspot)||0} blank spot</span>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-row-icon"><i data-lucide="globe"></i></div>
|
||||
<div>
|
||||
<div class="info-row-label">Koordinat</div>
|
||||
<div class="info-row-value" style="font-family:var(--font-mono);font-size:11px;">
|
||||
${parseFloat(data.lat).toFixed(6)}, ${parseFloat(data.lng).toFixed(6)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${(window.APP_USER && window.APP_USER.loggedIn) ? `
|
||||
<div style="margin-top:8px;">
|
||||
<div class="info-row-label" style="margin-bottom:6px;font-size:11px;text-transform:uppercase;letter-spacing:.05em;">
|
||||
Daftar KK Binaan <span style="font-weight:400;color:var(--c-muted);">(klik untuk fokus)</span>
|
||||
</div>
|
||||
<div id="kkListBody_${data.id}" style="max-height:180px;overflow-y:auto;">
|
||||
<span style="color:var(--c-muted);font-size:12px;display:inline-flex;align-items:center;gap:4px;"><i data-lucide="loader" class="animate-spin" style="width:12px;height:12px;"></i> Memuat...</span>
|
||||
</div>
|
||||
</div>` : `
|
||||
<div style="margin-top:10px;padding:8px 10px;background:rgba(56,139,253,.06);
|
||||
border:1px solid rgba(56,139,253,.15);border-radius:8px;font-size:11px;
|
||||
color:var(--c-muted);line-height:1.5;text-align:center;">
|
||||
<i data-lucide="lock" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> Data detail warga hanya tersedia untuk petugas yang login.<br>
|
||||
<a href="auth/login.php" style="color:#38bdf8;text-decoration:none;font-weight:700;">Masuk →</a>
|
||||
</div>`}
|
||||
|
||||
${(window.APP_USER && window.APP_USER.isAdmin) ? `
|
||||
<div style="display:flex;gap:8px;margin-top:8px;">
|
||||
<a href="api/export/csv.php?ibadah_id=${data.id}"
|
||||
style="flex:1;display:flex;align-items:center;justify-content:center;gap:6px;
|
||||
padding:9px;background:rgba(56,139,253,.1);color:#79c0ff;
|
||||
border:1px solid rgba(56,139,253,.3);border-radius:7px;
|
||||
font-size:12px;font-weight:700;text-decoration:none;transition:all .2s;"
|
||||
onmouseover="this.style.background='rgba(56,139,253,.2)'"
|
||||
onmouseout="this.style.background='rgba(56,139,253,.1)'">
|
||||
<i data-lucide="file-text" style="width:12px;height:12px;"></i> Export CSV
|
||||
</a>
|
||||
<a href="api/export/pdf.php?ibadah_id=${data.id}" target="_blank"
|
||||
style="flex:1;display:flex;align-items:center;justify-content:center;gap:6px;
|
||||
padding:9px;background:rgba(248,81,73,.08);color:#ffb3ad;
|
||||
border:1px solid rgba(248,81,73,.25);border-radius:7px;
|
||||
font-size:12px;font-weight:700;text-decoration:none;transition:all .2s;"
|
||||
onmouseover="this.style.background='rgba(248,81,73,.18)'"
|
||||
onmouseout="this.style.background='rgba(248,81,73,.08)'">
|
||||
<i data-lucide="printer" style="width:12px;height:12px;"></i> Print PDF
|
||||
</a>
|
||||
</div>` : ''}
|
||||
${isAdmin ? `<button class="btn-hapus" onclick="window._ibadahHapus(${data.id}, this)" style="display:inline-flex;align-items:center;justify-content:center;gap:4px;"><i data-lucide="trash-2" style="width:12px;height:12px;"></i> Hapus Rumah Ibadah</button>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Update global list ────────────────────────────────────────────
|
||||
function updateIbadahList() {
|
||||
window._ibadahList = Object.values(allMarkers).map(entry => {
|
||||
const m = entry.marker;
|
||||
const ll = m.getLatLng();
|
||||
return {
|
||||
id: m._ibadahId,
|
||||
lat: ll.lat,
|
||||
lng: ll.lng,
|
||||
nama: m._ibadahNama,
|
||||
jenis: m._ibadahJenis,
|
||||
radius: m._ibadahRadius
|
||||
};
|
||||
});
|
||||
if (typeof window._statsUpdate === 'function') window._statsUpdate();
|
||||
}
|
||||
|
||||
// ── Load data ─────────────────────────────────────────────────────
|
||||
function loadData() {
|
||||
layerGroup.clearLayers();
|
||||
allMarkers = {};
|
||||
window._ibadahList = [];
|
||||
|
||||
fetch('api/ibadah/ambil.php?_=' + Date.now(), { cache: 'no-store' })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status !== 'success') throw new Error(j.message);
|
||||
j.data.forEach(addToMap);
|
||||
updateIbadahList();
|
||||
refreshList(j.data);
|
||||
// Setelah load ibadah, recalculate penduduk
|
||||
if (typeof window._pendudukRecalcAll === 'function') {
|
||||
window._pendudukRecalcAll();
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
showToast('Gagal memuat data ibadah.', 'error');
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Refresh panel list ────────────────────────────────────────────
|
||||
function refreshList(data) {
|
||||
if (typeof window.dlRefreshList !== 'function') return;
|
||||
const items = (data || []).map(d => ({
|
||||
id: d.id,
|
||||
name: d.nama,
|
||||
badge: d.jenis,
|
||||
badgeColor: '#a855f7',
|
||||
dotColor: '#a855f7',
|
||||
}));
|
||||
window.dlRefreshList('Ibadah', items);
|
||||
}
|
||||
|
||||
// ── Hapus ─────────────────────────────────────────────────────────
|
||||
window._ibadahHapus = function (id, btnEl) {
|
||||
// Step 1: cek operator terkait (confirmed=0)
|
||||
const fd0 = new FormData();
|
||||
fd0.append('id', id);
|
||||
fd0.append('confirmed', '0');
|
||||
|
||||
fetch('api/ibadah/hapus.php', { method: 'POST', body: appendCsrf(fd0) })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status === 'warning') {
|
||||
showDeleteConfirm(j.message + '\n\nLanjutkan hapus?').then(ok => {
|
||||
if (ok) _doHapusIbadah(id, btnEl);
|
||||
});
|
||||
} else if (j.status === 'success') {
|
||||
_applyHapusIbadah(id);
|
||||
showToast('Rumah ibadah berhasil dihapus.');
|
||||
} else {
|
||||
showToast(j.message, 'error');
|
||||
}
|
||||
})
|
||||
.catch(() => showToast('Gagal menghubungi server.', 'error'));
|
||||
};
|
||||
|
||||
function _doHapusIbadah(id, btnEl) {
|
||||
showDeleteConfirm('Yakin ingin menghapus permanen rumah ibadah ini?').then(ok => {
|
||||
if (!ok) return;
|
||||
btnEl.disabled = true;
|
||||
btnEl.innerHTML = '<i data-lucide="loader" class="animate-spin" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> Menghapus...';
|
||||
lucide.createIcons();
|
||||
const fd = new FormData();
|
||||
fd.append('id', id);
|
||||
fd.append('confirmed', '1');
|
||||
fetch('api/ibadah/hapus.php', { method: 'POST', body: appendCsrf(fd) })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status === 'success') {
|
||||
map.closePopup();
|
||||
_applyHapusIbadah(id);
|
||||
showToast('Rumah ibadah berhasil dihapus.');
|
||||
if (typeof window._pendudukRecalcAll === 'function') window._pendudukRecalcAll();
|
||||
} else {
|
||||
showToast(j.message, 'error');
|
||||
btnEl.disabled = false;
|
||||
btnEl.innerHTML = '<i data-lucide="trash-2" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> Hapus Rumah Ibadah';
|
||||
lucide.createIcons();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _applyHapusIbadah(id) {
|
||||
if (allMarkers[id]) {
|
||||
layerGroup.removeLayer(allMarkers[id].marker);
|
||||
layerGroup.removeLayer(allMarkers[id].circle);
|
||||
delete allMarkers[id];
|
||||
}
|
||||
updateIbadahList();
|
||||
refreshList(Object.values(allMarkers).map(e => ({
|
||||
id: e.marker._ibadahId,
|
||||
nama: e.marker._ibadahNama,
|
||||
jenis: e.marker._ibadahJenis,
|
||||
})));
|
||||
}
|
||||
|
||||
// ── Simpan perubahan kontak (dari popup info) ─────────────────────
|
||||
window._ibadahSimpanKontak = function (id) {
|
||||
const inputEl = document.getElementById('edit_kontak_' + id);
|
||||
const statusEl = document.getElementById('kontak_status_' + id);
|
||||
if (!inputEl) return;
|
||||
|
||||
const kontak = inputEl.value.trim();
|
||||
|
||||
statusEl.innerHTML = '<i data-lucide="loader" class="animate-spin" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> Menyimpan...';
|
||||
statusEl.style.color = 'var(--c-muted)';
|
||||
lucide.createIcons();
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('id', id);
|
||||
fd.append('kontak', kontak);
|
||||
|
||||
fetch('api/ibadah/update_kontak.php', { method: 'POST', body: appendCsrf(fd) })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status === 'success') {
|
||||
if (allMarkers[id]) {
|
||||
allMarkers[id].data.kontak = kontak;
|
||||
allMarkers[id].marker.setPopupContent(buildInfoPopup(allMarkers[id].data));
|
||||
_refreshKkList(id);
|
||||
}
|
||||
showToast(`Kontak berhasil diperbarui.`);
|
||||
} else throw new Error(j.message);
|
||||
})
|
||||
.catch(err => {
|
||||
statusEl.innerHTML = '<i data-lucide="x" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> ' + escapeHTML(err.message);
|
||||
statusEl.style.color = 'var(--c-danger)';
|
||||
lucide.createIcons();
|
||||
});
|
||||
};
|
||||
|
||||
// ── Simpan perubahan radius (dari popup info) ─────────────────────
|
||||
window._ibadahSimpanRadius = function (id) {
|
||||
const inputEl = document.getElementById('edit_radius_' + id);
|
||||
const statusEl = document.getElementById('radius_status_' + id);
|
||||
if (!inputEl) return;
|
||||
|
||||
const radius = Math.max(100, Math.min(5000, parseInt(inputEl.value) || 500));
|
||||
inputEl.value = radius; // normalize
|
||||
|
||||
statusEl.innerHTML = '<i data-lucide="loader" class="animate-spin" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> Menyimpan...';
|
||||
statusEl.style.color = 'var(--c-muted)';
|
||||
lucide.createIcons();
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('id', id);
|
||||
fd.append('radius_m', radius);
|
||||
|
||||
fetch('api/ibadah/update_radius.php', { method: 'POST', body: appendCsrf(fd) })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status === 'success') {
|
||||
if (allMarkers[id]) {
|
||||
allMarkers[id].circle.setRadius(radius);
|
||||
allMarkers[id].marker._ibadahRadius = radius;
|
||||
|
||||
// Update objek data yang disimpan
|
||||
allMarkers[id].data.radius_m = radius;
|
||||
|
||||
// Update isi popup agar saat dibuka lagi nilainya sudah yang baru
|
||||
allMarkers[id].marker.setPopupContent(buildInfoPopup(allMarkers[id].data));
|
||||
_refreshKkList(id);
|
||||
}
|
||||
|
||||
// Update _ibadahList
|
||||
const entry = window._ibadahList.find(x => parseInt(x.id) === id);
|
||||
if (entry) entry.radius = radius;
|
||||
|
||||
// Recalculate semua penduduk dengan radius baru
|
||||
if (typeof window._pendudukRecalcAll === 'function') {
|
||||
window._pendudukRecalcAll();
|
||||
}
|
||||
|
||||
// Alert jika ada warga baru yang jadi blank spot akibat radius diperkecil
|
||||
setTimeout(() => {
|
||||
const newBlankCount = (window._pendudukAll || window._pendudukList || [])
|
||||
.filter(p => !p.ibadah).length;
|
||||
if (newBlankCount > 0) {
|
||||
showToast(
|
||||
`⚠ Radius diperkecil — ${newBlankCount} warga kini menjadi Blank Spot.`,
|
||||
'error'
|
||||
);
|
||||
}
|
||||
if (typeof window._updateBlankSpotCounter === 'function') {
|
||||
window._updateBlankSpotCounter();
|
||||
}
|
||||
}, 200);
|
||||
|
||||
showToast(`Radius berhasil diubah ke ${radius}m.`);
|
||||
} else throw new Error(j.message);
|
||||
})
|
||||
.catch(err => {
|
||||
statusEl.innerHTML = '<i data-lucide="x" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> ' + escapeHTML(err.message);
|
||||
statusEl.style.color = 'var(--c-danger)';
|
||||
lucide.createIcons();
|
||||
});
|
||||
};
|
||||
|
||||
// ── Klik peta: Form tambah ibadah ─────────────────────────────────
|
||||
function onMapClick(e) {
|
||||
if (typeof activeTool === 'undefined' || activeTool !== 'ibadah') return;
|
||||
|
||||
const lat = e.latlng.lat.toFixed(8);
|
||||
const lng = e.latlng.lng.toFixed(8);
|
||||
|
||||
if (tempMarker) map.removeLayer(tempMarker);
|
||||
tempMarker = L.marker([lat, lng], { icon: iconTemp }).addTo(map);
|
||||
|
||||
// Buka popup dengan alamat "Loading..."
|
||||
const popup = L.popup({ maxWidth: 340, closeOnClick: false })
|
||||
.setLatLng(e.latlng)
|
||||
.setContent(buildFormPopup(lat, lng, 'Mengambil alamat...'))
|
||||
.openOn(map);
|
||||
|
||||
// Reverse Geocoding via Nominatim
|
||||
fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&accept-language=id`, {
|
||||
headers: { 'Accept-Language': 'id' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(geo => {
|
||||
const alamat = geo.display_name || '';
|
||||
// Update field alamat di form
|
||||
const el = document.getElementById('f_ibadah_alamat');
|
||||
if (el) el.value = alamat;
|
||||
})
|
||||
.catch(() => {
|
||||
const el = document.getElementById('f_ibadah_alamat');
|
||||
if (el) el.value = '';
|
||||
});
|
||||
|
||||
setTimeout(() => { const el = document.getElementById('f_ibadah_nama'); if (el) el.focus(); }, 200);
|
||||
|
||||
// Tampilkan preview circle radius saat popup terbuka
|
||||
if (_previewCircle) { layerGroup.removeLayer(_previewCircle); _previewCircle = null; }
|
||||
_previewCircle = L.circle([lat, lng], {
|
||||
radius: 500, color: '#38bdf8', weight: 1.5, opacity: 0.6,
|
||||
fillColor: '#0ea5e9', fillOpacity: 0.05, dashArray: '6,4'
|
||||
}).addTo(layerGroup);
|
||||
|
||||
// Hapus preview circle & temp marker saat popup ditutup
|
||||
const _markerThisClick = tempMarker;
|
||||
const _circleThisClick = _previewCircle;
|
||||
map.once('popupclose', function () {
|
||||
if (_circleThisClick) { try { layerGroup.removeLayer(_circleThisClick); } catch(_){} }
|
||||
if (_previewCircle === _circleThisClick) _previewCircle = null;
|
||||
if (_markerThisClick) { try { map.removeLayer(_markerThisClick); } catch(_){} }
|
||||
if (tempMarker === _markerThisClick) tempMarker = null;
|
||||
});
|
||||
}
|
||||
|
||||
function buildFormPopup(lat, lng, alamatPlaceholder) {
|
||||
return `<div class="form-popup">
|
||||
<div class="form-popup-header">
|
||||
<div class="form-popup-icon" style="background:rgba(14,165,233,.12);"><i data-lucide="map-pin"></i></div>
|
||||
<div>
|
||||
<div class="form-popup-title">Tambah Rumah Ibadah</div>
|
||||
<div class="form-popup-coords">${lat}, ${lng}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Nama Tempat *</label>
|
||||
<input type="text" id="f_ibadah_nama" placeholder="cth. Masjid Al-Ikhlas" maxlength="100" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Jenis</label>
|
||||
<select id="f_ibadah_jenis">
|
||||
<option value="Masjid">🕌 Masjid</option>
|
||||
<option value="Mushola">🕌 Mushola</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="form-group">
|
||||
<label><i data-lucide="ruler" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> Radius Wilayah</label>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-top:4px;">
|
||||
<input type="range" id="f_ibadah_radius" value="500" min="100" max="5000" step="50"
|
||||
oninput="document.getElementById('f_ibadah_radius_val').textContent=this.value+'m'; window._ibadahPreviewRadius(this.value)"
|
||||
style="flex:1;height:4px;accent-color:#38bdf8;cursor:pointer;">
|
||||
<span id="f_ibadah_radius_val"
|
||||
style="min-width:50px;text-align:right;font-family:var(--font-mono);
|
||||
font-size:12px;color:#38bdf8;font-weight:700;">500m</span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:10px;color:var(--c-muted);margin-top:2px;">
|
||||
<span>100m</span><span>5000m</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Alamat (Otomatis)</label>
|
||||
<input type="text" id="f_ibadah_alamat" value="${escapeHTML(alamatPlaceholder)}" placeholder="Mendeteksi alamat..." autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Kontak Pengurus</label>
|
||||
<input type="text" id="f_ibadah_kontak" placeholder="No HP/WA (Opsional)" maxlength="100" autocomplete="off">
|
||||
</div>
|
||||
<input type="hidden" id="f_ibadah_lat" value="${lat}">
|
||||
<input type="hidden" id="f_ibadah_lng" value="${lng}">
|
||||
<button class="btn-save" id="btnSimpanIbadah" onclick="window._ibadahSimpan()" style="background:linear-gradient(135deg,#7c3aed,#a855f7);display:inline-flex;align-items:center;justify-content:center;gap:4px;">
|
||||
<i data-lucide="save" style="width:14px;height:14px;"></i> Simpan Rumah Ibadah
|
||||
</button>
|
||||
<div class="form-status" id="ibadahStatus"></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Live preview radius (form tambah baru) ────────────────────────
|
||||
window._ibadahPreviewRadius = function (val) {
|
||||
const r = Math.max(100, Math.min(5000, parseInt(val) || 500));
|
||||
if (_previewCircle) _previewCircle.setRadius(r);
|
||||
};
|
||||
|
||||
// ── Live preview radius (edit marker yg sudah ada) ────────────────
|
||||
let _recalcDebounce = null;
|
||||
window._ibadahPreviewRadiusById = function (id, val) {
|
||||
const r = Math.max(100, Math.min(5000, parseInt(val) || 500));
|
||||
const targetId = parseInt(id);
|
||||
|
||||
// 1. Update visual circle & marker property
|
||||
if (allMarkers[targetId]) {
|
||||
allMarkers[targetId].circle.setRadius(r);
|
||||
allMarkers[targetId].marker._ibadahRadius = r;
|
||||
}
|
||||
|
||||
// 2. Paksa sinkronisasi window._ibadahList agar penduduk.js melihat radius terbaru
|
||||
if (window._ibadahList) {
|
||||
const entry = window._ibadahList.find(x => parseInt(x.id) === targetId);
|
||||
if (entry) {
|
||||
entry.radius = r;
|
||||
} else {
|
||||
// Jika tidak ketemu (jarang terjadi), panggil sync full
|
||||
updateIbadahList();
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Recalculate warna penduduk (debounce agar ringan)
|
||||
clearTimeout(_recalcDebounce);
|
||||
_recalcDebounce = setTimeout(() => {
|
||||
console.log(`[Ibadah] Recalculating proximity for Radius: ${r}m`);
|
||||
if (typeof window._pendudukRecalcAll === 'function') {
|
||||
window._pendudukRecalcAll();
|
||||
}
|
||||
}, 50); // Dipercepat ke 50ms untuk feel yang lebih instan
|
||||
};
|
||||
|
||||
// ── Simpan ────────────────────────────────────────────────────────
|
||||
window._ibadahSimpan = function () {
|
||||
const nama = document.getElementById('f_ibadah_nama').value.trim();
|
||||
const jenis = document.getElementById('f_ibadah_jenis').value;
|
||||
const radius = Math.max(100, Math.min(5000, parseInt(document.getElementById('f_ibadah_radius')?.value) || 500));
|
||||
const alamat = document.getElementById('f_ibadah_alamat').value.trim();
|
||||
const kontak = document.getElementById('f_ibadah_kontak')?.value.trim() || '';
|
||||
const lat = document.getElementById('f_ibadah_lat').value;
|
||||
const lng = document.getElementById('f_ibadah_lng').value;
|
||||
const status = document.getElementById('ibadahStatus');
|
||||
const btn = document.getElementById('btnSimpanIbadah');
|
||||
|
||||
if (!nama) {
|
||||
status.textContent = '⚠ Nama wajib diisi.';
|
||||
status.className = 'form-status error';
|
||||
document.getElementById('f_ibadah_nama').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i data-lucide="loader" class="animate-spin" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> Menyimpan...';
|
||||
lucide.createIcons();
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('nama', nama);
|
||||
fd.append('jenis', jenis);
|
||||
fd.append('alamat', alamat);
|
||||
fd.append('kontak', kontak);
|
||||
fd.append('lat', lat);
|
||||
fd.append('lng', lng);
|
||||
fd.append('radius_m', radius);
|
||||
|
||||
fetch('api/ibadah/simpan.php', { method: 'POST', body: appendCsrf(fd) })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status === 'success') {
|
||||
map.closePopup();
|
||||
if (_previewCircle) { layerGroup.removeLayer(_previewCircle); _previewCircle = null; }
|
||||
addToMap(j.data);
|
||||
updateIbadahList();
|
||||
refreshList(Object.values(allMarkers).map(e => ({
|
||||
id: e.marker._ibadahId,
|
||||
nama: e.marker._ibadahNama,
|
||||
jenis: e.marker._ibadahJenis,
|
||||
})));
|
||||
showToast(`"${escapeHTML(nama)}" berhasil ditambahkan!`);
|
||||
// Recalculate penduduk setelah ibadah baru ditambah
|
||||
if (typeof window._pendudukRecalcAll === 'function') {
|
||||
window._pendudukRecalcAll();
|
||||
}
|
||||
} else throw new Error(j.message);
|
||||
})
|
||||
.catch(err => {
|
||||
status.innerHTML = '<i data-lucide="x" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> ' + escapeHTML(err.message);
|
||||
status.className = 'form-status error';
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i data-lucide="save" style="width:14px;height:14px;vertical-align:middle;margin-right:4px;"></i> Simpan Rumah Ibadah';
|
||||
lucide.createIcons();
|
||||
});
|
||||
};
|
||||
|
||||
// ── Recalculate All proximity (Admin only, with race-condition guard) ──
|
||||
let _recalcInProgress = false;
|
||||
|
||||
window._recalcAll = function () {
|
||||
if (_recalcInProgress) {
|
||||
showToast('Proses hitung ulang sedang berjalan...', 'error');
|
||||
return;
|
||||
}
|
||||
showDeleteConfirm(
|
||||
'Proses ini dapat mengubah assignment warga ke rumah ibadah/operator berdasarkan radius terkini. Lanjutkan?',
|
||||
{ type: 'warn', iconName: 'refresh-cw', title: 'Konfirmasi Hitung Ulang Proximity', btnLabel: 'Ya, Hitung Ulang' }
|
||||
).then(confirmed => {
|
||||
if (!confirmed) return;
|
||||
_recalcInProgress = true;
|
||||
|
||||
const allBtns = document.querySelectorAll(
|
||||
'#btnToolIbadah, #btnToolPenduduk, #btnRecalcAll, .btn-hapus, .btn-save'
|
||||
);
|
||||
allBtns.forEach(b => { b.disabled = true; });
|
||||
|
||||
const btn = document.getElementById('btnRecalcAll');
|
||||
const icon = btn ? btn.querySelector('.tb-icon') : null;
|
||||
if (btn) {
|
||||
if (icon) icon.textContent = '⏳';
|
||||
btn.style.color = 'var(--c-muted)';
|
||||
}
|
||||
|
||||
showToast('Menghitung ulang proximity...');
|
||||
|
||||
fetch('api/ibadah/recalculate.php', { method: 'POST', body: appendCsrf(new FormData()) })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status === 'success') {
|
||||
showToast(`✓ ${j.message}`);
|
||||
if (typeof window._pendudukReload === 'function') {
|
||||
window._pendudukReload();
|
||||
} else if (typeof window._pendudukRecalcAll === 'function') {
|
||||
window._pendudukRecalcAll();
|
||||
}
|
||||
} else {
|
||||
showToast(j.message || 'Gagal menghitung ulang.', 'error');
|
||||
}
|
||||
})
|
||||
.catch(() => showToast('Gagal menghubungi server.', 'error'))
|
||||
.finally(() => {
|
||||
_recalcInProgress = false;
|
||||
allBtns.forEach(b => { b.disabled = false; });
|
||||
if (btn) {
|
||||
if (icon) icon.textContent = '🔄';
|
||||
btn.style.color = 'var(--c-warn)';
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
window._ibadahReload = loadData;
|
||||
|
||||
// ── Init ─────────────────────────────────────────────────────────
|
||||
window.initIbadah = function () {
|
||||
if (active) { loadData(); return; }
|
||||
active = true;
|
||||
|
||||
layerGroup.addTo(map);
|
||||
map.on('click', onMapClick);
|
||||
|
||||
window._dlFocusFns['Ibadah'] = function (id) {
|
||||
const entry = allMarkers[id];
|
||||
if (!entry) return;
|
||||
map.setView(entry.marker.getLatLng(), Math.max(map.getZoom(), 17), { animate: true });
|
||||
setTimeout(() => entry.marker.openPopup(), 350);
|
||||
};
|
||||
|
||||
loadData();
|
||||
};
|
||||
|
||||
// ── Toggle visibilitas layer ──────────────────────────────────────
|
||||
window._ibadahToggleLayer = function (visible) {
|
||||
if (visible) {
|
||||
if (!map.hasLayer(layerGroup)) map.addLayer(layerGroup);
|
||||
} else {
|
||||
map.removeLayer(layerGroup);
|
||||
if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; }
|
||||
}
|
||||
};
|
||||
|
||||
window._setBufferVisible = function (visible) {
|
||||
bufferVisible = visible;
|
||||
Object.values(allMarkers).forEach(function (entry) {
|
||||
if (visible) {
|
||||
if (!layerGroup.hasLayer(entry.circle)) entry.circle.addTo(layerGroup);
|
||||
} else {
|
||||
if (layerGroup.hasLayer(entry.circle)) layerGroup.removeLayer(entry.circle);
|
||||
}
|
||||
});
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,195 @@
|
||||
// modules/kebutuhan.js — Manajemen Kebutuhan per Warga Miskin (F14)
|
||||
(function () {
|
||||
const KATEGORI_ICON = {
|
||||
'Sembako' : 'shopping-cart',
|
||||
'Biaya Sekolah' : 'graduation-cap',
|
||||
'Biaya Kesehatan' : 'heart-pulse',
|
||||
'Modal Usaha' : 'briefcase',
|
||||
'Renovasi Rumah' : 'home',
|
||||
'Perlengkapan Rumah': 'armchair',
|
||||
'Pakaian' : 'shirt',
|
||||
'Lainnya' : 'clipboard-list',
|
||||
};
|
||||
const STATUS_COLOR = {
|
||||
'Belum Terpenuhi': '#ef4444',
|
||||
'Dalam Proses' : '#e3b341',
|
||||
'Terpenuhi' : '#3fb950',
|
||||
};
|
||||
const KATEGORI_LIST = [
|
||||
'Sembako','Biaya Sekolah','Biaya Kesehatan','Modal Usaha',
|
||||
'Renovasi Rumah','Perlengkapan Rumah','Pakaian','Lainnya',
|
||||
];
|
||||
|
||||
function isOp() { return window.APP_USER && (window.APP_USER.isOp || window.APP_USER.isAdmin); }
|
||||
function esc(str) {
|
||||
const d = document.createElement('div');
|
||||
d.appendChild(document.createTextNode(str ?? ''));
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// ── Bangun seksi kebutuhan untuk popup penduduk ───────────────────────────
|
||||
window._kebutuhanBuildSection = function (pendudukId, kebutuhanOpen, statusVerifikasi) {
|
||||
const count = parseInt(kebutuhanOpen) || 0;
|
||||
const canMutate = isOp() && statusVerifikasi === 'Terverifikasi';
|
||||
const badgeHtml = count > 0
|
||||
? `<span id="kebutuhanBadge_${pendudukId}"
|
||||
style="background:#ef444422;color:#ef4444;border:1px solid #ef444444;
|
||||
font-size:10px;font-weight:700;padding:2px 7px;border-radius:20px;
|
||||
margin-left:4px;">${count} Belum Terpenuhi</span>`
|
||||
: `<span id="kebutuhanBadge_${pendudukId}"></span>`;
|
||||
|
||||
const formHtml = canMutate ? `
|
||||
<div id="kebutuhanForm_${pendudukId}" style="display:none;margin-top:8px;padding:8px;
|
||||
background:rgba(255,255,255,.03);border-radius:6px;border:1px solid var(--c-border);">
|
||||
<select id="kf_kat_${pendudukId}"
|
||||
style="width:100%;padding:5px 8px;background:var(--c-surface2);border:1px solid var(--c-border);
|
||||
border-radius:6px;color:var(--c-text);font-size:12px;margin-bottom:6px;">
|
||||
${KATEGORI_LIST.map(k => `<option value="${esc(k)}">${esc(k)}</option>`).join('')}
|
||||
</select>
|
||||
<input type="text" id="kf_desc_${pendudukId}"
|
||||
placeholder="Deskripsi opsional (maks 300 karakter)" maxlength="300"
|
||||
style="width:100%;padding:5px 8px;background:var(--c-surface2);border:1px solid var(--c-border);
|
||||
<div style="display:flex;gap:6px;">
|
||||
<button onclick="window._kebutuhanSimpan(${pendudukId})"
|
||||
style="flex:1;padding:5px;font-size:11px;background:rgba(63,185,80,.1);color:#3fb950;
|
||||
border:1px solid rgba(63,185,80,.3);border-radius:6px;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;gap:4px;">
|
||||
<i data-lucide="save" style="width:12px;height:12px;"></i> Simpan
|
||||
</button>
|
||||
<button onclick="document.getElementById('kebutuhanForm_${pendudukId}').style.display='none'"
|
||||
style="padding:5px 10px;font-size:11px;background:transparent;color:var(--c-muted);
|
||||
border:1px solid var(--c-border);border-radius:6px;cursor:pointer;">
|
||||
Batal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button id="btnTambahKebutuhan_${pendudukId}"
|
||||
onclick="document.getElementById('kebutuhanForm_${pendudukId}').style.display='';
|
||||
document.getElementById('btnTambahKebutuhan_${pendudukId}').style.display='none';
|
||||
if(typeof lucide !== 'undefined') lucide.createIcons();"
|
||||
style="font-size:11px;padding:3px 8px;margin-top:6px;border-radius:4px;cursor:pointer;
|
||||
border:1px solid rgba(63,185,80,.3);background:transparent;color:#3fb950;">
|
||||
+ Tambah Kebutuhan
|
||||
</button>` : (isOp() ? `<div style="font-size:11px;color:var(--c-muted);font-weight:600;margin-top:6px;">Menunggu verifikasi admin</div>` : '');
|
||||
|
||||
return `<div class="info-row" style="align-items:flex-start;border-top:1px solid var(--c-border);padding-top:8px;margin-top:4px;">
|
||||
<div class="info-row-icon"><i data-lucide="clipboard-list"></i></div>
|
||||
<div style="flex:1;">
|
||||
<div class="info-row-label">Kebutuhan ${badgeHtml}</div>
|
||||
<div id="kebutuhanBody_${pendudukId}" data-verifikasi="${esc(statusVerifikasi || 'Pending')}" style="margin-top:4px;">
|
||||
<button onclick="window._kebutuhanLoad(${pendudukId})"
|
||||
style="font-size:11px;padding:2px 7px;border-radius:4px;cursor:pointer;
|
||||
border:1px solid var(--c-border);background:transparent;color:var(--c-muted);">
|
||||
Muat Kebutuhan
|
||||
</button>
|
||||
</div>
|
||||
${formHtml}
|
||||
</div>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
// ── Muat dan render daftar kebutuhan ─────────────────────────────────────
|
||||
window._kebutuhanLoad = function (pendudukId) {
|
||||
const body = document.getElementById('kebutuhanBody_' + pendudukId);
|
||||
if (!body) return;
|
||||
const statusVerifikasi = body.dataset.verifikasi || 'Pending';
|
||||
const canMutate = isOp() && statusVerifikasi === 'Terverifikasi';
|
||||
body.innerHTML = '<span style="font-size:11px;color:var(--c-muted);display:inline-flex;align-items:center;gap:4px;"><i data-lucide="loader-2" class="lucide animate-spin" style="width:12px;height:12px;"></i> Memuat...</span>';
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
|
||||
fetch('api/kebutuhan/ambil.php?penduduk_id=' + pendudukId + '&_=' + Date.now(), { cache: 'no-store' })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status !== 'success') throw new Error(j.message);
|
||||
const rows = j.data;
|
||||
const openCount = rows.filter(r => r.status === 'Belum Terpenuhi').length;
|
||||
if (typeof window._pendudukRefreshKebutuhanCount === 'function') {
|
||||
window._pendudukRefreshKebutuhanCount(pendudukId, openCount);
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
body.innerHTML = '<span style="font-size:11px;color:var(--c-muted);">Belum ada kebutuhan tercatat.</span>';
|
||||
return;
|
||||
}
|
||||
body.innerHTML = rows.map(r => {
|
||||
const sc = STATUS_COLOR[r.status] || '#8b949e';
|
||||
const icon = KATEGORI_ICON[r.kategori] || 'clipboard-list';
|
||||
const iconHtml = `<i data-lucide="${icon}" class="lucide-inline" style="width:14px;height:14px;vertical-align:middle;margin-right:4px;"></i>`;
|
||||
const statusBtns = canMutate
|
||||
? ['Belum Terpenuhi', 'Dalam Proses', 'Terpenuhi'].map(s =>
|
||||
`<button onclick="window._kebutuhanUpdateStatus(${r.id},'${s}',${pendudukId})"
|
||||
style="font-size:10px;padding:2px 6px;border-radius:4px;cursor:pointer;
|
||||
border:1px solid ${STATUS_COLOR[s]};
|
||||
background:${s === r.status ? STATUS_COLOR[s] + '33' : 'transparent'};
|
||||
color:${STATUS_COLOR[s]};font-weight:${s === r.status ? '700' : '400'};">${s}</button>`
|
||||
).join('')
|
||||
: `<span style="font-size:11px;color:${sc};font-weight:700;">${esc(r.status)}</span>`;
|
||||
return `<div style="padding:5px 0;border-bottom:1px solid rgba(255,255,255,.06);">
|
||||
<div style="font-size:12px;font-weight:600;color:var(--c-text);display:flex;align-items:center;gap:2px;">
|
||||
${iconHtml} ${esc(r.kategori)}
|
||||
</div>
|
||||
${r.deskripsi ? `<div style="font-size:11px;color:var(--c-muted);margin:2px 0;">${esc(r.deskripsi)}</div>` : ''}
|
||||
<div style="display:flex;gap:4px;margin-top:4px;flex-wrap:wrap;">${statusBtns}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
})
|
||||
.catch(err => {
|
||||
const b = document.getElementById('kebutuhanBody_' + pendudukId);
|
||||
if (b) {
|
||||
b.innerHTML = `<span style="font-size:11px;color:#ef4444;display:inline-flex;align-items:center;gap:4px;"><i data-lucide="x-circle" style="width:12px;height:12px;"></i> Gagal: ${esc(err.message)}</span>`;
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// ── Simpan kebutuhan baru ─────────────────────────────────────────────────
|
||||
window._kebutuhanSimpan = function (pendudukId) {
|
||||
const kat = document.getElementById('kf_kat_' + pendudukId)?.value;
|
||||
const desc = document.getElementById('kf_desc_' + pendudukId)?.value?.trim() || '';
|
||||
if (!kat) return;
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('penduduk_id', pendudukId);
|
||||
fd.append('kategori', kat);
|
||||
fd.append('deskripsi', desc);
|
||||
|
||||
fetch('api/kebutuhan/simpan.php', { method: 'POST', body: appendCsrf(fd) })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status !== 'success') throw new Error(j.message);
|
||||
const formEl = document.getElementById('kebutuhanForm_' + pendudukId);
|
||||
const btnEl = document.getElementById('btnTambahKebutuhan_' + pendudukId);
|
||||
const descEl = document.getElementById('kf_desc_' + pendudukId);
|
||||
if (formEl) formEl.style.display = 'none';
|
||||
if (btnEl) btnEl.style.display = '';
|
||||
if (descEl) descEl.value = '';
|
||||
window._kebutuhanLoad(pendudukId);
|
||||
if (typeof window.dispatchDataChanged === 'function') {
|
||||
window.dispatchDataChanged('kebutuhan', { pendudukId, mutation: 'create' });
|
||||
}
|
||||
showToast('Kebutuhan berhasil disimpan.');
|
||||
})
|
||||
.catch(err => showToast(err.message || 'Gagal menyimpan kebutuhan.', 'error'));
|
||||
};
|
||||
|
||||
// ── Update status kebutuhan ───────────────────────────────────────────────
|
||||
window._kebutuhanUpdateStatus = function (kebutuhanId, status, pendudukId) {
|
||||
const catatan = prompt('Catatan perubahan kebutuhan (opsional):', '');
|
||||
if (catatan === null) return;
|
||||
const fd = new FormData();
|
||||
fd.append('id', kebutuhanId);
|
||||
fd.append('status', status);
|
||||
fd.append('catatan', catatan);
|
||||
|
||||
fetch('api/kebutuhan/update_status.php', { method: 'POST', body: appendCsrf(fd) })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status !== 'success') throw new Error(j.message);
|
||||
window._kebutuhanLoad(pendudukId);
|
||||
if (typeof window.dispatchDataChanged === 'function') {
|
||||
window.dispatchDataChanged('kebutuhan', { pendudukId, kebutuhanId, mutation: 'status' });
|
||||
}
|
||||
showToast('Status kebutuhan diperbarui.');
|
||||
})
|
||||
.catch(err => showToast(err.message || 'Gagal memperbarui status.', 'error'));
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,975 @@
|
||||
// modules/penduduk.js — Manajemen Penduduk Miskin dengan Proximity Logic (Haversine)
|
||||
(function () {
|
||||
const layerGroup = L.layerGroup();
|
||||
window._pendudukLayer = layerGroup;
|
||||
window._pendudukAll = []; // Dataset penuh dari API.
|
||||
window._pendudukVisible = []; // Dataset yang sedang terlihat/listed.
|
||||
window._pendudukList = window._pendudukVisible; // Alias kompatibilitas.
|
||||
|
||||
let allMarkers = {}; // { id: circleMarker }
|
||||
let filterState = { search: '', kategori: '', keterjangkauan: '', jenisIbadah: '', statusBantuan: '', kebutuhanKategori: '', kebutuhanStatus: '' };
|
||||
let subLayerVisible = { terjangkau: true, blankspot: true };
|
||||
let tempMarker = null;
|
||||
let active = false;
|
||||
|
||||
// ── Warna ─────────────────────────────────────────────────────────
|
||||
const COLOR_TERJANGKAU = '#22c55e'; // Hijau — dalam radius (terjangkau)
|
||||
const COLOR_BLANK_SPOT = '#ef4444'; // Merah — di luar semua radius (blank spot)
|
||||
const STATUS_COLOR = { 'Belum Ditangani': '#ef4444', 'Dalam Proses': '#e3b341', 'Sudah Ditangani': '#3fb950' };
|
||||
|
||||
// ── Formula Haversine (return jarak dalam meter) ──────────────────
|
||||
function haversine(lat1, lng1, lat2, lng2) {
|
||||
const R = 6371000; // radius bumi dalam meter
|
||||
const toR = x => x * Math.PI / 180;
|
||||
const dLat = toR(lat2 - lat1);
|
||||
const dLng = toR(lng2 - lng1);
|
||||
const a = Math.sin(dLat / 2) ** 2
|
||||
+ Math.cos(toR(lat1)) * Math.cos(toR(lat2)) * Math.sin(dLng / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
// ── Cari ibadah terdekat dalam radius 500m ────────────────────────
|
||||
function findNearestIbadah(lat, lng) {
|
||||
let nearest = null;
|
||||
let minDist = Infinity;
|
||||
const list = window._ibadahList || [];
|
||||
|
||||
for (const ib of list) {
|
||||
const d = haversine(lat, lng, ib.lat, ib.lng);
|
||||
// Gunakan ib.radius yang sudah di-update real-time oleh ibadah.js
|
||||
const activeRadius = parseInt(ib.radius) || 500;
|
||||
if (d <= activeRadius && d < minDist) {
|
||||
minDist = d;
|
||||
nearest = { ...ib, jarak: Math.round(d), radius: activeRadius };
|
||||
}
|
||||
}
|
||||
return nearest; // null = di luar semua radius
|
||||
}
|
||||
|
||||
// ── Render / update warna satu marker ────────────────────────────
|
||||
function applyColor(cm, ibadah) {
|
||||
const color = ibadah ? COLOR_TERJANGKAU : COLOR_BLANK_SPOT;
|
||||
cm.setStyle({
|
||||
color : color,
|
||||
fillColor : color,
|
||||
fillOpacity: 0.55,
|
||||
weight : 2,
|
||||
});
|
||||
cm._nearestIbadah = ibadah;
|
||||
}
|
||||
|
||||
// ── Buat popup teks ───────────────────────────────────────────────
|
||||
function buildInfoPopup(data, ibadah) {
|
||||
const isAdmin = window.APP_USER && window.APP_USER.isAdmin;
|
||||
|
||||
const ibadahSection = ibadah
|
||||
? `<div class="info-row" style="background:rgba(34,197,94,.06);border-radius:8px;padding:6px 8px;margin:2px 0;">
|
||||
<div class="info-row-icon"><i data-lucide="map-pin"></i></div>
|
||||
<div style="flex:1;">
|
||||
<div class="info-row-label">Rumah Ibadah Terdekat</div>
|
||||
<div class="info-row-value" style="color:#22c55e;font-weight:600;">${escapeHTML(ibadah.nama)}</div>
|
||||
<div style="font-size:11px;color:var(--c-muted);margin-top:3px;display:inline-flex;align-items:center;gap:3px;flex-wrap:wrap;">
|
||||
<i data-lucide="ruler" style="width:12px;height:12px;vertical-align:middle;margin-right:2px;"></i> ${ibadah.jarak}m · <i data-lucide="circle" style="width:10px;height:10px;vertical-align:middle;margin-right:2px;"></i> Radius ${ibadah.radius || 500}m · ${ibadah.jenis || ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
: `<div class="info-row" style="background:rgba(239,68,68,.06);border-radius:8px;padding:6px 8px;margin:2px 0;">
|
||||
<div class="info-row-icon" style="color:#ef4444;"><i data-lucide="alert-triangle"></i></div>
|
||||
<div>
|
||||
<div class="info-row-label">Status Wilayah</div>
|
||||
<div class="info-row-value" style="color:#ef4444;font-weight:600;">Di luar semua radius ibadah (Blank Spot)</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const nikHtml = (data.nik && window.APP_USER && window.APP_USER.loggedIn)
|
||||
? `<div style="font-size:11px;color:var(--c-muted);margin-top:2px;">NIK: ${escapeHTML(data.nik)}</div>` : '';
|
||||
|
||||
const catatanHtml = (data.catatan && window.APP_USER && window.APP_USER.loggedIn)
|
||||
? `<div class="info-row">
|
||||
<div class="info-row-icon"><i data-lucide="file-text"></i></div>
|
||||
<div>
|
||||
<div class="info-row-label">Catatan Kondisi</div>
|
||||
<div class="info-row-value" style="font-size:12px;line-height:1.4;">${escapeHTML(data.catatan)}</div>
|
||||
</div>
|
||||
</div>` : '';
|
||||
|
||||
const isOp = window.APP_USER && window.APP_USER.isOp;
|
||||
const st = data.status_bantuan || 'Belum Ditangani';
|
||||
const stC = STATUS_COLOR[st] || '#8b949e';
|
||||
const sv = data.status_verifikasi || 'Pending';
|
||||
const statusBtns = sv === 'Terverifikasi'
|
||||
? ['Belum Ditangani', 'Dalam Proses', 'Sudah Ditangani'].map(s =>
|
||||
`<button onclick="window._updateStatus(${data.id},'${s}',this)"
|
||||
style="font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer;
|
||||
border:1px solid ${STATUS_COLOR[s]};
|
||||
background:${s === st ? STATUS_COLOR[s] + '33' : 'transparent'};
|
||||
color:${STATUS_COLOR[s]};font-weight:${s === st ? '700' : '400'};">${escapeHTML(s)}</button>`
|
||||
).join('')
|
||||
: `<span style="font-size:11px;color:var(--c-muted);font-weight:600;">Menunggu verifikasi admin</span>`;
|
||||
|
||||
const statusSection = isOp
|
||||
? `<div class="info-row" style="align-items:flex-start;">
|
||||
<div class="info-row-icon"><i data-lucide="clipboard-list"></i></div>
|
||||
<div style="flex:1;">
|
||||
<div class="info-row-label">Status Bantuan</div>
|
||||
<span style="display:inline-block;font-size:11px;font-weight:700;padding:2px 8px;border-radius:20px;
|
||||
background:${stC}22;color:${stC}0d;margin-top:3px;border:1px solid ${stC}44;color:${stC};">${escapeHTML(st)}</span>
|
||||
<div data-sg="${data.id}" style="display:flex;gap:4px;margin-top:6px;flex-wrap:wrap;">${statusBtns}</div>
|
||||
<button onclick="window._showRiwayat(${data.id})"
|
||||
style="font-size:11px;padding:3px 8px;margin-top:6px;border-radius:4px;cursor:pointer;
|
||||
border:1px solid var(--c-border);background:transparent;color:var(--c-muted);display:inline-flex;align-items:center;gap:4px;">
|
||||
<i data-lucide="history" style="width:11px;height:11px;"></i> Lihat Riwayat
|
||||
</button>
|
||||
</div>
|
||||
</div>` : '';
|
||||
|
||||
// ── Badge & aksi verifikasi ───────────────────────────────
|
||||
const svClr = { Terverifikasi: '#22c55e', Pending: '#e3b341', Ditolak: '#ef4444' }[sv] || '#8b949e';
|
||||
const svIcon = {
|
||||
Terverifikasi: '<i data-lucide="check-circle" style="width:11px;height:11px;vertical-align:middle;margin-right:4px;"></i>',
|
||||
Pending: '<i data-lucide="clock" style="width:11px;height:11px;vertical-align:middle;margin-right:4px;"></i>',
|
||||
Ditolak: '<i data-lucide="x-circle" style="width:11px;height:11px;vertical-align:middle;margin-right:4px;"></i>'
|
||||
}[sv] || '';
|
||||
const verifBadge = (isAdmin || isOp)
|
||||
? `<div style="display:inline-flex;align-items:center;font-size:10px;font-weight:700;
|
||||
padding:2px 8px;border-radius:20px;margin-top:4px;
|
||||
background:${svClr}20;color:${svClr};border:1px solid ${svClr}44;">
|
||||
${svIcon}<span>${sv}</span>
|
||||
</div>` : '';
|
||||
|
||||
const verifActions = (isAdmin && sv === 'Pending')
|
||||
? `<div style="display:flex;gap:6px;margin-top:8px;padding-top:10px;border-top:1px solid rgba(255,255,255,.08);">
|
||||
<button onclick="window._verifikasiPenduduk(${data.id},'approve',this)"
|
||||
style="flex:1;padding:7px;border-radius:7px;cursor:pointer;font-size:12px;font-weight:700;
|
||||
border:1px solid #22c55e;background:rgba(34,197,94,.1);color:#22c55e;display:inline-flex;align-items:center;justify-content:center;gap:4px;">
|
||||
<i data-lucide="check" style="width:12px;height:12px;"></i> Verifikasi
|
||||
</button>
|
||||
<button onclick="window._verifikasiPenduduk(${data.id},'reject',this)"
|
||||
style="flex:1;padding:7px;border-radius:7px;cursor:pointer;font-size:12px;font-weight:700;
|
||||
border:1px solid #ef4444;background:rgba(239,68,68,.1);color:#ef4444;display:inline-flex;align-items:center;justify-content:center;gap:4px;">
|
||||
<i data-lucide="x" style="width:12px;height:12px;"></i> Tolak
|
||||
</button>
|
||||
</div>` : '';
|
||||
|
||||
const actionBtns = isAdmin
|
||||
? `<div style="display:flex;gap:8px;margin-top:8px;">
|
||||
<button class="btn-hapus" style="flex:1;background:rgba(227,179,65,.08);color:#f0c240;border-color:rgba(227,179,65,.3);display:inline-flex;align-items:center;justify-content:center;gap:4px;"
|
||||
onclick="window._pendudukNonaktifkan(${data.id}, this)">
|
||||
<i data-lucide="user-x" style="width:12px;height:12px;"></i> Nonaktifkan
|
||||
</button>
|
||||
<button class="btn-hapus" style="flex:1;display:inline-flex;align-items:center;justify-content:center;gap:4px;"
|
||||
onclick="window._pendudukHapus(${data.id}, this)">
|
||||
<i data-lucide="trash-2" style="width:12px;height:12px;"></i> Hapus Data
|
||||
</button>
|
||||
</div>` : '';
|
||||
|
||||
const kebutuhanSection = (window.APP_USER && window.APP_USER.loggedIn && typeof window._kebutuhanBuildSection === 'function')
|
||||
? window._kebutuhanBuildSection(data.id, data.kebutuhan_open, sv)
|
||||
: '';
|
||||
|
||||
return `<div class="info-popup">
|
||||
<div class="info-popup-header">
|
||||
<div class="info-popup-icon" style="background:${ibadah ? 'rgba(34,197,94,.12)' : 'rgba(239,68,68,.12)'};"><i data-lucide="home"></i></div>
|
||||
<div>
|
||||
<div class="info-popup-name">${escapeHTML(data.nama_kk || 'Warga #' + data.id)}</div>
|
||||
${nikHtml}
|
||||
<div class="info-popup-id">#${data.id} · ${data.kategori || 'Miskin'}</div>
|
||||
${verifBadge}
|
||||
</div>
|
||||
</div>
|
||||
${ibadahSection}
|
||||
<div class="info-row">
|
||||
<div class="info-row-icon"><i data-lucide="users"></i></div>
|
||||
<div><div class="info-row-label">Jumlah Jiwa</div>
|
||||
<div class="info-row-value">${data.jumlah_jiwa} orang</div></div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-row-icon"><i data-lucide="map-pin"></i></div>
|
||||
<div><div class="info-row-label">Alamat</div>
|
||||
<div class="info-row-value">${escapeHTML(data.alamat || '-')}</div></div>
|
||||
</div>
|
||||
${catatanHtml}
|
||||
${statusSection}
|
||||
${kebutuhanSection}
|
||||
${verifActions}
|
||||
${actionBtns}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Tambah marker ke peta ─────────────────────────────────────────
|
||||
function addToMap(data) {
|
||||
const lat = parseFloat(data.lat);
|
||||
const lng = parseFloat(data.lng);
|
||||
|
||||
// Koordinat null = publik — simpan ke list saja tanpa marker
|
||||
if (isNaN(lat) || isNaN(lng)) {
|
||||
const ibadahRef = data.ibadah_id
|
||||
? { id: data.ibadah_id, nama: data.nama_ibadah, jenis: data.jenis_ibadah }
|
||||
: null;
|
||||
allMarkers[data.id] = { cm: null, dragHandle: null, data, _ibadahRef: ibadahRef };
|
||||
return;
|
||||
}
|
||||
|
||||
const ibadah = findNearestIbadah(lat, lng);
|
||||
const color = ibadah ? COLOR_TERJANGKAU : COLOR_BLANK_SPOT;
|
||||
|
||||
const cm = L.circleMarker([lat, lng], {
|
||||
radius : 8,
|
||||
color : color,
|
||||
fillColor : color,
|
||||
fillOpacity: 0.55,
|
||||
weight : 2,
|
||||
});
|
||||
|
||||
cm._pendudukId = data.id;
|
||||
cm._pendudukNama = data.nama_kk;
|
||||
cm._pendudukJiwa = data.jumlah_jiwa;
|
||||
cm._nearestIbadah = ibadah;
|
||||
|
||||
cm.bindPopup(buildInfoPopup(data, ibadah), { maxWidth: 280 });
|
||||
|
||||
// Drag to update position
|
||||
// circleMarker tidak draggable by default, gunakan workaround dengan L.marker invisible
|
||||
cm.on('click', function () {
|
||||
cm.openPopup();
|
||||
});
|
||||
|
||||
// Draggable via L.marker overlay (admin only)
|
||||
const isAdmin = window.APP_USER && window.APP_USER.isAdmin;
|
||||
const dragHandle = L.marker([lat, lng], {
|
||||
icon: L.divIcon({
|
||||
className: '',
|
||||
html: '<div style="width:16px;height:16px;cursor:move;"></div>',
|
||||
iconSize: [16, 16],
|
||||
iconAnchor: [8, 8],
|
||||
}),
|
||||
draggable: isAdmin,
|
||||
zIndexOffset: -500,
|
||||
opacity: 0,
|
||||
});
|
||||
|
||||
dragHandle._cmRef = cm;
|
||||
dragHandle._dataRef = data;
|
||||
|
||||
// Forward klik ke popup circleMarker
|
||||
dragHandle.on('click', function () {
|
||||
cm.openPopup();
|
||||
});
|
||||
dragHandle.on('drag', function (e) {
|
||||
const { lat: nLat, lng: nLng } = e.target.getLatLng();
|
||||
cm.setLatLng([nLat, nLng]);
|
||||
if (allMarkers[data.id]) {
|
||||
allMarkers[data.id].data.lat = nLat;
|
||||
allMarkers[data.id].data.lng = nLng;
|
||||
}
|
||||
updatePendudukList();
|
||||
});
|
||||
|
||||
dragHandle.on('dragend', function (e) {
|
||||
const { lat: nLat, lng: nLng } = e.target.getLatLng();
|
||||
cm.setLatLng([nLat, nLng]);
|
||||
cm.closePopup();
|
||||
|
||||
const ibadahBaru = findNearestIbadah(nLat, nLng);
|
||||
applyColor(cm, ibadahBaru);
|
||||
// Spread from allMarkers (live, may have updated status_bantuan etc.), not stale closure
|
||||
const updatedData = { ...allMarkers[data.id].data, lat: nLat, lng: nLng, is_blank_spot: ibadahBaru ? 0 : 1 };
|
||||
allMarkers[data.id].data = updatedData;
|
||||
cm.bindPopup(buildInfoPopup(updatedData, ibadahBaru), { maxWidth: 280 });
|
||||
updatePendudukList();
|
||||
refreshList();
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('id', data.id);
|
||||
fd.append('lat', nLat.toFixed(8));
|
||||
fd.append('lng', nLng.toFixed(8));
|
||||
fd.append('ibadah_id', ibadahBaru ? ibadahBaru.id : '');
|
||||
|
||||
fetch('api/penduduk/update_posisi.php', { method: 'POST', body: appendCsrf(fd) })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status === 'success') {
|
||||
showToast(`Posisi "${escapeHTML(data.nama_kk)}" diperbarui.`);
|
||||
} else throw new Error(j.message);
|
||||
})
|
||||
.catch(err => showToast(err.message || 'Gagal update posisi.', 'error'));
|
||||
});
|
||||
|
||||
cm.addTo(layerGroup);
|
||||
dragHandle.addTo(layerGroup);
|
||||
|
||||
allMarkers[data.id] = { cm, dragHandle, data };
|
||||
}
|
||||
|
||||
// ── Recalculate semua warna penduduk (dipanggil saat ibadah berubah) ──
|
||||
window._pendudukRecalcAll = function () {
|
||||
Object.values(allMarkers).forEach(({ cm, data }) => {
|
||||
if (!cm) return; // publik — tidak ada marker
|
||||
const ll = cm.getLatLng();
|
||||
const ibadah = findNearestIbadah(ll.lat, ll.lng);
|
||||
applyColor(cm, ibadah);
|
||||
const updatedData = { ...data, lat: ll.lat, lng: ll.lng, is_blank_spot: ibadah ? 0 : 1 };
|
||||
allMarkers[data.id].data = updatedData;
|
||||
cm.bindPopup(buildInfoPopup(updatedData, ibadah), { maxWidth: 280 });
|
||||
});
|
||||
updatePendudukList();
|
||||
refreshList();
|
||||
};
|
||||
|
||||
// ── Load data ─────────────────────────────────────────────────────
|
||||
function loadData() {
|
||||
// Reset blank spot filter so imported/reloaded data renders unfiltered
|
||||
_blankSpotFilterActive = false;
|
||||
const btn = document.getElementById('btnBlankSpot');
|
||||
if (btn) btn.style.background = 'transparent';
|
||||
|
||||
layerGroup.clearLayers();
|
||||
allMarkers = {};
|
||||
|
||||
fetch('api/penduduk/ambil.php?_=' + Date.now(), { cache: 'no-store' })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status !== 'success') throw new Error(j.message);
|
||||
j.data.forEach(addToMap);
|
||||
updatePendudukList();
|
||||
refreshList();
|
||||
})
|
||||
.catch(err => {
|
||||
showToast('Gagal memuat data penduduk.', 'error');
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Refresh panel list ────────────────────────────────────────────
|
||||
window._pendudukSetFilter = function () {
|
||||
filterState.search = (document.getElementById('searchPenduduk')?.value || '').toLowerCase().trim();
|
||||
filterState.kategori = document.getElementById('filterKategori')?.value || '';
|
||||
filterState.keterjangkauan = document.getElementById('filterKeterjangkauan')?.value || '';
|
||||
filterState.jenisIbadah = document.getElementById('filterJenisIbadah')?.value || '';
|
||||
filterState.statusBantuan = document.getElementById('filterStatusBantuan')?.value || '';
|
||||
filterState.kebutuhanKategori = document.getElementById('filterKebutuhanKategori')?.value || '';
|
||||
filterState.kebutuhanStatus = document.getElementById('filterKebutuhanStatus')?.value || '';
|
||||
applyFilters();
|
||||
};
|
||||
|
||||
// Update kebutuhan_open count in live data + badge (called by kebutuhan.js)
|
||||
window._pendudukRefreshKebutuhanCount = function (pendudukId, newCount) {
|
||||
const entry = allMarkers[pendudukId];
|
||||
if (!entry) return;
|
||||
entry.data.kebutuhan_open = newCount;
|
||||
const badgeEl = document.getElementById('kebutuhanBadge_' + pendudukId);
|
||||
if (badgeEl) {
|
||||
if (newCount > 0) {
|
||||
badgeEl.textContent = newCount + ' Belum Terpenuhi';
|
||||
badgeEl.style.cssText = 'background:#ef444422;color:#ef4444;border:1px solid #ef444444;font-size:10px;font-weight:700;padding:2px 7px;border-radius:20px;margin-left:4px;';
|
||||
} else {
|
||||
badgeEl.textContent = '';
|
||||
badgeEl.style.cssText = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window._setPendudukSubLayerVisible = function (sub, visible) {
|
||||
subLayerVisible[sub] = visible;
|
||||
applyFilters();
|
||||
};
|
||||
|
||||
function applyFilters() {
|
||||
Object.values(allMarkers).forEach(function (m) {
|
||||
// Entry publik tanpa marker — skip layer toggle
|
||||
if (!m.cm) return;
|
||||
const d = m.data;
|
||||
let show = true;
|
||||
|
||||
// Sub-layer toggles
|
||||
if (parseInt(d.is_blank_spot) === 1) {
|
||||
if (!subLayerVisible.blankspot) show = false;
|
||||
} else {
|
||||
if (!subLayerVisible.terjangkau) show = false;
|
||||
}
|
||||
|
||||
// Search: nama KK atau nama ibadah (OR), AND dengan filter lain
|
||||
if (show && filterState.search) {
|
||||
const q = filterState.search;
|
||||
const matchKK = (d.nama_kk || '').toLowerCase().includes(q);
|
||||
const matchIbadah = (d.nama_ibadah || '').toLowerCase().includes(q);
|
||||
if (!matchKK && !matchIbadah) show = false;
|
||||
}
|
||||
|
||||
// Kategori
|
||||
if (show && filterState.kategori && d.kategori !== filterState.kategori) show = false;
|
||||
|
||||
// Keterjangkauan
|
||||
if (show && filterState.keterjangkauan) {
|
||||
if (filterState.keterjangkauan === 'terjangkau' && parseInt(d.is_blank_spot) === 1) show = false;
|
||||
if (filterState.keterjangkauan === 'blankspot' && parseInt(d.is_blank_spot) !== 1) show = false;
|
||||
}
|
||||
|
||||
// Jenis ibadah terdekat
|
||||
if (show && filterState.jenisIbadah && d.jenis_ibadah !== filterState.jenisIbadah) show = false;
|
||||
|
||||
// Status bantuan
|
||||
if (show && filterState.statusBantuan && d.status_bantuan !== filterState.statusBantuan) show = false;
|
||||
|
||||
// Kebutuhan — kategori (cek apakah ada kebutuhan open di kategori tsb)
|
||||
if (show && filterState.kebutuhanKategori) {
|
||||
const openCats = (d.kebutuhan_kategori_open || '').split(',').filter(Boolean);
|
||||
if (!openCats.includes(filterState.kebutuhanKategori)) show = false;
|
||||
}
|
||||
|
||||
// Kebutuhan — status (ada ≥1 kebutuhan dalam status tsb)
|
||||
if (show && filterState.kebutuhanStatus) {
|
||||
const ks = filterState.kebutuhanStatus;
|
||||
if (ks === 'Belum Terpenuhi' && !(parseInt(d.kebutuhan_open) > 0)) show = false;
|
||||
if (ks === 'Dalam Proses' && !(parseInt(d.kebutuhan_proses) > 0)) show = false;
|
||||
if (ks === 'Terpenuhi' && !(parseInt(d.kebutuhan_terpenuhi) > 0)) show = false;
|
||||
}
|
||||
|
||||
if (show) {
|
||||
if (!layerGroup.hasLayer(m.cm)) m.cm.addTo(layerGroup);
|
||||
if (!layerGroup.hasLayer(m.dragHandle)) m.dragHandle.addTo(layerGroup);
|
||||
} else {
|
||||
if (layerGroup.hasLayer(m.cm)) layerGroup.removeLayer(m.cm);
|
||||
if (layerGroup.hasLayer(m.dragHandle)) layerGroup.removeLayer(m.dragHandle);
|
||||
}
|
||||
});
|
||||
|
||||
updatePendudukList();
|
||||
refreshList();
|
||||
}
|
||||
|
||||
function updatePendudukList() {
|
||||
function toListItem(m) {
|
||||
const hasMarker = !!m.cm;
|
||||
const ibadah = hasMarker ? m.cm._nearestIbadah : m._ibadahRef;
|
||||
return {
|
||||
id : m.data.id,
|
||||
lat : hasMarker ? m.cm.getLatLng().lat : (m.data.lat ?? null),
|
||||
lng : hasMarker ? m.cm.getLatLng().lng : (m.data.lng ?? null),
|
||||
jiwa : parseInt(m.data.jumlah_jiwa) || 1,
|
||||
jumlah_jiwa : parseInt(m.data.jumlah_jiwa) || 1,
|
||||
nama_kk : m.data.nama_kk,
|
||||
kategori : m.data.kategori || 'Miskin',
|
||||
is_blank_spot : parseInt(m.data.is_blank_spot) || 0,
|
||||
status_bantuan : m.data.status_bantuan || 'Belum Ditangani',
|
||||
ibadah_jenis : m.data.jenis_ibadah,
|
||||
ibadah_nama : m.data.nama_ibadah,
|
||||
isRadius : !!ibadah,
|
||||
ibadah : ibadah,
|
||||
kebutuhan_open : parseInt(m.data.kebutuhan_open) || 0,
|
||||
kebutuhan_proses : parseInt(m.data.kebutuhan_proses) || 0,
|
||||
kebutuhan_terpenuhi : parseInt(m.data.kebutuhan_terpenuhi) || 0,
|
||||
kebutuhan_kategori_open : m.data.kebutuhan_kategori_open || '',
|
||||
};
|
||||
}
|
||||
|
||||
const entries = Object.values(allMarkers);
|
||||
const visibleEntries = entries.filter(m => !m.cm || layerGroup.hasLayer(m.cm));
|
||||
|
||||
window._pendudukAll = entries.map(toListItem);
|
||||
window._pendudukVisible = visibleEntries.map(toListItem);
|
||||
window._pendudukList = window._pendudukVisible;
|
||||
|
||||
console.log('[Penduduk] List updated, all:', window._pendudukAll.length, 'visible:', window._pendudukVisible.length);
|
||||
if (typeof window._statsUpdate === 'function') window._statsUpdate();
|
||||
if (typeof window._heatmapUpdate === 'function') window._heatmapUpdate();
|
||||
if (typeof window._updateBlankSpotCounter === 'function') window._updateBlankSpotCounter();
|
||||
}
|
||||
|
||||
function refreshList() {
|
||||
const listEl = document.getElementById('dlListPenduduk');
|
||||
const countEl = document.getElementById('dlCountPenduduk');
|
||||
const headerEl = document.getElementById('dlHeaderPenduduk');
|
||||
if (!listEl) return;
|
||||
|
||||
const entries = Object.values(allMarkers).filter(m => !m.cm || layerGroup.hasLayer(m.cm));
|
||||
const total = entries.length;
|
||||
|
||||
if (countEl) countEl.textContent = total;
|
||||
|
||||
if (!total) {
|
||||
listEl.innerHTML = '<div class="dl-empty">Belum ada data.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const cats = [
|
||||
{ key: 'Sangat Miskin', color: '#ef4444' },
|
||||
{ key: 'Miskin', color: '#f59e0b' },
|
||||
{ key: 'Hampir Miskin', color: '#e3b341' },
|
||||
];
|
||||
|
||||
const counts = {}, jiwaMap = {};
|
||||
entries.forEach(m => {
|
||||
const k = m.data.kategori || 'Miskin';
|
||||
counts[k] = (counts[k] || 0) + 1;
|
||||
jiwaMap[k] = (jiwaMap[k] || 0) + (parseInt(m.data.jumlah_jiwa) || 1);
|
||||
});
|
||||
|
||||
listEl.innerHTML = cats
|
||||
.filter(c => counts[c.key])
|
||||
.map(c => {
|
||||
const n = counts[c.key];
|
||||
const j = jiwaMap[c.key];
|
||||
return '<div class="dl-item" style="cursor:default;">' +
|
||||
'<span class="dl-item-dot" style="background:' + c.color + ';"></span>' +
|
||||
'<span class="dl-item-name" style="color:var(--sb-text);">' + c.key + '</span>' +
|
||||
'<span class="dl-item-badge" style="color:' + c.color + ';border-color:' + c.color + '22;background:' + c.color + '11;">' +
|
||||
n + ' KK · ' + j + ' jiwa' +
|
||||
'</span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
if (headerEl) headerEl.classList.add('open');
|
||||
listEl.classList.add('open');
|
||||
}
|
||||
|
||||
// ── Hapus (soft delete — untuk data yang keliru diinput) ──────────
|
||||
window._pendudukHapus = function (id, btnEl) {
|
||||
showDeleteConfirm('Hapus data ini? Tindakan ini untuk data yang diinput salah.').then(confirmed => {
|
||||
if (!confirmed) return;
|
||||
btnEl.disabled = true;
|
||||
btnEl.innerHTML = '<i data-lucide="loader" class="animate-spin" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> Menghapus...';
|
||||
lucide.createIcons();
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('id', id);
|
||||
|
||||
fetch('api/penduduk/hapus.php', { method: 'POST', body: appendCsrf(fd) })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status === 'success') {
|
||||
map.closePopup();
|
||||
if (allMarkers[id]) {
|
||||
layerGroup.removeLayer(allMarkers[id].cm);
|
||||
layerGroup.removeLayer(allMarkers[id].dragHandle);
|
||||
delete allMarkers[id];
|
||||
}
|
||||
updatePendudukList();
|
||||
refreshList();
|
||||
showToast('Data penduduk dihapus (soft delete).');
|
||||
} else throw new Error(j.message);
|
||||
})
|
||||
.catch(err => {
|
||||
showToast(err.message || 'Gagal menghapus.', 'error');
|
||||
btnEl.disabled = false;
|
||||
btnEl.innerHTML = '<i data-lucide="trash-2" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> Hapus Data';
|
||||
lucide.createIcons();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// ── Nonaktifkan (warga meninggal / pindah — arsip tetap ada) ─────
|
||||
window._pendudukNonaktifkan = function (id, btnEl) {
|
||||
showDeleteConfirm(
|
||||
'Tandai warga ini sebagai tidak aktif (meninggal/pindah)? Data akan disimpan untuk arsip.',
|
||||
{ type: 'warn', iconName: 'user-x', title: 'Konfirmasi Nonaktifkan', btnLabel: 'Ya, Nonaktifkan' }
|
||||
).then(confirmed => {
|
||||
if (!confirmed) return;
|
||||
btnEl.disabled = true;
|
||||
btnEl.innerHTML = '<i data-lucide="loader" class="animate-spin" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> Memproses...';
|
||||
lucide.createIcons();
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('id', id);
|
||||
|
||||
fetch('api/penduduk/nonaktifkan.php', { method: 'POST', body: appendCsrf(fd) })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status === 'success') {
|
||||
map.closePopup();
|
||||
if (allMarkers[id]) {
|
||||
layerGroup.removeLayer(allMarkers[id].cm);
|
||||
layerGroup.removeLayer(allMarkers[id].dragHandle);
|
||||
delete allMarkers[id];
|
||||
}
|
||||
updatePendudukList();
|
||||
refreshList();
|
||||
showToast('Warga dinonaktifkan (arsip tersimpan).');
|
||||
} else throw new Error(j.message);
|
||||
})
|
||||
.catch(err => {
|
||||
showToast(err.message || 'Gagal.', 'error');
|
||||
btnEl.disabled = false;
|
||||
btnEl.innerHTML = '<i data-lucide="user-x" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> Nonaktifkan';
|
||||
lucide.createIcons();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// ── Klik peta: Form tambah penduduk ──────────────────────────────
|
||||
function onMapClick(e) {
|
||||
if (typeof activeTool === 'undefined' || activeTool !== 'penduduk') return;
|
||||
|
||||
const lat = e.latlng.lat.toFixed(8);
|
||||
const lng = e.latlng.lng.toFixed(8);
|
||||
|
||||
if (tempMarker) map.removeLayer(tempMarker);
|
||||
|
||||
// Preview warna sebelum disimpan
|
||||
const ibadahPreview = findNearestIbadah(parseFloat(lat), parseFloat(lng));
|
||||
const colorPreview = ibadahPreview ? COLOR_TERJANGKAU : COLOR_BLANK_SPOT;
|
||||
const statusPreview = ibadahPreview
|
||||
? `<span style="color:#22c55e;display:inline-flex;align-items:center;gap:4px;"><i data-lucide="check" style="width:12px;height:12px;"></i> Dalam radius <b>${escapeHTML(ibadahPreview.nama)}</b> (${ibadahPreview.jarak}m)</span>`
|
||||
: `<span style="color:#ef4444;display:inline-flex;align-items:center;gap:4px;"><i data-lucide="alert-triangle" style="width:12px;height:12px;"></i> Di luar semua radius ibadah (Blank Spot)</span>`;
|
||||
|
||||
tempMarker = L.circleMarker([lat, lng], {
|
||||
radius: 10, color: colorPreview, fillColor: colorPreview, fillOpacity: 0.4, weight: 2
|
||||
}).addTo(map);
|
||||
|
||||
L.popup({ maxWidth: 300, closeOnClick: false })
|
||||
.setLatLng(e.latlng)
|
||||
.setContent(`<div class="form-popup">
|
||||
<div class="form-popup-header">
|
||||
<div class="form-popup-icon" style="background:rgba(34,197,94,.12);"><i data-lucide="home"></i></div>
|
||||
<div>
|
||||
<div class="form-popup-title">Tambah Penduduk Miskin</div>
|
||||
<div class="form-popup-coords">${lat}, ${lng}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:8px 12px;background:rgba(255,255,255,0.04);border-radius:7px;font-size:12px;margin-bottom:14px;color:var(--c-muted);">
|
||||
${statusPreview}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Nama Kepala Keluarga *</label>
|
||||
<input type="text" id="f_pddk_nama" placeholder="cth. Budi Santoso" maxlength="150" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>NIK</label>
|
||||
<input type="text" id="f_pddk_nik" placeholder="16 digit NIK" maxlength="16" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Kategori</label>
|
||||
<select id="f_pddk_kategori">
|
||||
<option value="Sangat Miskin">Sangat Miskin</option>
|
||||
<option value="Miskin" selected>Miskin</option>
|
||||
<option value="Hampir Miskin">Hampir Miskin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Jumlah Jiwa</label>
|
||||
<input type="number" id="f_pddk_jiwa" value="1" min="1" max="99">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Alamat (Otomatis)</label>
|
||||
<textarea id="f_pddk_alamat" placeholder="Mendeteksi alamat..." rows="2">Mendeteksi alamat...</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Catatan Kondisi Khusus</label>
|
||||
<textarea id="f_pddk_catatan" placeholder="Kebutuhan khusus, kondisi rumah, dll." rows="2"
|
||||
style="width:100%;padding:8px 12px;background:var(--c-bg);border:1px solid var(--c-border);
|
||||
border-radius:7px;color:var(--c-text);font-family:var(--font-body);font-size:13px;
|
||||
outline:none;resize:vertical;"></textarea>
|
||||
</div>
|
||||
<input type="hidden" id="f_pddk_lat" value="${lat}">
|
||||
<input type="hidden" id="f_pddk_lng" value="${lng}">
|
||||
<input type="hidden" id="f_pddk_ibadah_id" value="${ibadahPreview ? ibadahPreview.id : ''}">
|
||||
<button class="btn-save" id="btnSimpanPenduduk" onclick="window._pendudukSimpan()"
|
||||
style="background:linear-gradient(135deg,#16a34a,#22c55e);display:inline-flex;align-items:center;justify-content:center;gap:4px;">
|
||||
<i data-lucide="save" style="width:14px;height:14px;"></i> Simpan Penduduk
|
||||
</button>
|
||||
<div class="form-status" id="pendudukStatus"></div>
|
||||
</div>`)
|
||||
.openOn(map);
|
||||
|
||||
// Hapus temp marker saat popup dibatalkan/ditutup
|
||||
const _markerThisClick = tempMarker;
|
||||
map.once('popupclose', function () {
|
||||
if (_markerThisClick) { try { map.removeLayer(_markerThisClick); } catch(_){} }
|
||||
if (tempMarker === _markerThisClick) tempMarker = null;
|
||||
});
|
||||
|
||||
// Reverse Geocoding via Nominatim
|
||||
fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&accept-language=id`, {
|
||||
headers: { 'Accept-Language': 'id' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(geo => {
|
||||
const alamat = geo.display_name || '';
|
||||
const el = document.getElementById('f_pddk_alamat');
|
||||
if (el) el.value = alamat;
|
||||
})
|
||||
.catch(() => {
|
||||
const el = document.getElementById('f_pddk_alamat');
|
||||
if (el) el.value = '';
|
||||
});
|
||||
|
||||
setTimeout(() => { const el = document.getElementById('f_pddk_nama'); if (el) el.focus(); }, 200);
|
||||
}
|
||||
|
||||
// ── Simpan ────────────────────────────────────────────────────────
|
||||
window._pendudukSimpan = function () {
|
||||
const nama = document.getElementById('f_pddk_nama').value.trim();
|
||||
const nik = document.getElementById('f_pddk_nik').value.trim();
|
||||
const kategori = document.getElementById('f_pddk_kategori').value;
|
||||
const alamat = document.getElementById('f_pddk_alamat').value.trim();
|
||||
const jiwa = parseInt(document.getElementById('f_pddk_jiwa').value) || 1;
|
||||
const lat = document.getElementById('f_pddk_lat').value;
|
||||
const lng = document.getElementById('f_pddk_lng').value;
|
||||
const ibadahId = document.getElementById('f_pddk_ibadah_id').value;
|
||||
const status = document.getElementById('pendudukStatus');
|
||||
const btn = document.getElementById('btnSimpanPenduduk');
|
||||
|
||||
if (!nama) {
|
||||
status.textContent = '⚠ Nama kepala keluarga wajib diisi.';
|
||||
status.className = 'form-status error';
|
||||
document.getElementById('f_pddk_nama').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i data-lucide="loader" class="animate-spin" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> Menyimpan...';
|
||||
lucide.createIcons();
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('nama_kk', nama);
|
||||
fd.append('nik', nik);
|
||||
fd.append('kategori', kategori);
|
||||
fd.append('alamat', alamat);
|
||||
fd.append('catatan', document.getElementById('f_pddk_catatan')?.value?.trim() || '');
|
||||
fd.append('jumlah_jiwa', jiwa);
|
||||
fd.append('lat', lat);
|
||||
fd.append('lng', lng);
|
||||
fd.append('ibadah_id', ibadahId);
|
||||
|
||||
fetch('api/penduduk/simpan.php', { method: 'POST', body: appendCsrf(fd) })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status === 'success') {
|
||||
map.closePopup();
|
||||
if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; }
|
||||
addToMap(j.data);
|
||||
updatePendudukList();
|
||||
refreshList();
|
||||
if (j.data.is_blank_spot) {
|
||||
showToast(`⚠ "${escapeHTML(nama)}" ditambahkan sebagai BLANK SPOT — tidak terjangkau ibadah manapun.`, 'error');
|
||||
} else {
|
||||
showToast(`"${escapeHTML(nama)}" berhasil ditambahkan!`);
|
||||
}
|
||||
} else throw new Error(j.message);
|
||||
})
|
||||
.catch(err => {
|
||||
status.innerHTML = '<i data-lucide="x" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> ' + escapeHTML(err.message);
|
||||
status.className = 'form-status error';
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i data-lucide="save" style="width:14px;height:14px;vertical-align:middle;margin-right:4px;"></i> Simpan Penduduk';
|
||||
lucide.createIcons();
|
||||
});
|
||||
};
|
||||
|
||||
// ── Update status bantuan ─────────────────────────────────────────
|
||||
window._updateStatus = function (id, newStatus, btnEl) {
|
||||
btnEl.disabled = true;
|
||||
const catatan = prompt('Catatan perubahan status (opsional):', '');
|
||||
if (catatan === null) {
|
||||
btnEl.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('id', id);
|
||||
fd.append('status', newStatus);
|
||||
fd.append('catatan', catatan);
|
||||
|
||||
fetch('api/penduduk/update_status.php', { method: 'POST', body: appendCsrf(fd) })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status !== 'success') throw new Error(j.message);
|
||||
const entry = allMarkers[id];
|
||||
if (entry && entry.cm) {
|
||||
entry.data.status_bantuan = newStatus;
|
||||
const ibadah = entry.cm._nearestIbadah;
|
||||
entry.cm.bindPopup(buildInfoPopup(entry.data, ibadah), { maxWidth: 280 });
|
||||
entry.cm.openPopup();
|
||||
}
|
||||
updatePendudukList();
|
||||
refreshList();
|
||||
if (typeof window.dispatchDataChanged === 'function') {
|
||||
window.dispatchDataChanged('penduduk', { id, mutation: 'status_bantuan' });
|
||||
}
|
||||
showToast('Status bantuan diperbarui.');
|
||||
})
|
||||
.catch(err => {
|
||||
btnEl.disabled = false;
|
||||
showToast(err.message || 'Gagal memperbarui status.', 'error');
|
||||
});
|
||||
};
|
||||
|
||||
// ── Lihat riwayat bantuan ─────────────────────────────────────────
|
||||
window._showRiwayat = function (id) {
|
||||
fetch('api/penduduk/riwayat.php?id=' + id + '&_=' + Date.now(), { cache: 'no-store' })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status !== 'success') throw new Error(j.message);
|
||||
const rows = j.data;
|
||||
|
||||
const tbody = rows.length === 0
|
||||
? '<tr><td colspan="4" style="text-align:center;padding:12px;color:var(--c-muted);">Belum ada riwayat.</td></tr>'
|
||||
: rows.map(r => {
|
||||
const lama = r.status_lama || '—';
|
||||
const baru = r.status_baru || '—';
|
||||
const tgl = r.created_at ? r.created_at.substring(0, 16) : '—';
|
||||
return `<tr style="border-bottom:1px solid var(--c-border);">
|
||||
<td style="padding:6px 8px;font-size:12px;">${escapeHTML(tgl)}</td>
|
||||
<td style="padding:6px 8px;font-size:12px;color:${STATUS_COLOR[baru]||'#ccc'};font-weight:700;">${escapeHTML(baru)}</td>
|
||||
<td style="padding:6px 8px;font-size:12px;color:var(--c-muted);">${escapeHTML(lama)}</td>
|
||||
<td style="padding:6px 8px;font-size:12px;color:var(--c-muted);">${escapeHTML(r.user_nama||'—')}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
let overlay = document.getElementById('_riwayatOverlay');
|
||||
if (!overlay) {
|
||||
overlay = document.createElement('div');
|
||||
overlay.id = '_riwayatOverlay';
|
||||
overlay.style.cssText = 'position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;';
|
||||
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
overlay.innerHTML = `
|
||||
<div style="background:var(--c-surface);border:1px solid var(--c-border);border-radius:12px;
|
||||
padding:20px;width:min(480px,94vw);max-height:80vh;overflow-y:auto;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px;">
|
||||
<div style="font-weight:700;font-size:14px;color:var(--c-text);">Riwayat Bantuan</div>
|
||||
<button onclick="document.getElementById('_riwayatOverlay').remove()"
|
||||
style="background:none;border:none;color:var(--c-muted);font-size:18px;cursor:pointer;">×</button>
|
||||
</div>
|
||||
<div style="overflow-x:auto;">
|
||||
<table style="width:100%;border-collapse:collapse;">
|
||||
<thead>
|
||||
<tr style="border-bottom:1px solid var(--c-border);">
|
||||
<th style="text-align:left;padding:6px 8px;font-size:11px;color:var(--c-muted);font-weight:600;">Waktu</th>
|
||||
<th style="text-align:left;padding:6px 8px;font-size:11px;color:var(--c-muted);font-weight:600;">Status Baru</th>
|
||||
<th style="text-align:left;padding:6px 8px;font-size:11px;color:var(--c-muted);font-weight:600;">Sebelumnya</th>
|
||||
<th style="text-align:left;padding:6px 8px;font-size:11px;color:var(--c-muted);font-weight:600;">Oleh</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${tbody}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>`;
|
||||
})
|
||||
.catch(err => showToast(err.message || 'Gagal memuat riwayat.', 'error'));
|
||||
};
|
||||
|
||||
// ── Blank Spot counter + filter ───────────────────────────────────
|
||||
window._updateBlankSpotCounter = function () {
|
||||
const count = Object.values(allMarkers).filter(m => m.cm && !m.cm._nearestIbadah).length;
|
||||
const btn = document.getElementById('btnBlankSpot');
|
||||
const lbl = document.getElementById('blankSpotCount');
|
||||
if (!btn || !lbl) return;
|
||||
lbl.textContent = count + ' Blank Spot';
|
||||
btn.style.display = count > 0 ? '' : 'none';
|
||||
};
|
||||
|
||||
let _blankSpotFilterActive = false;
|
||||
window._filterBlankSpot = function () {
|
||||
_blankSpotFilterActive = !_blankSpotFilterActive;
|
||||
const btn = document.getElementById('btnBlankSpot');
|
||||
if (btn) btn.style.background = _blankSpotFilterActive ? 'rgba(248,81,73,.15)' : 'transparent';
|
||||
|
||||
Object.values(allMarkers).forEach(m => {
|
||||
if (!m.cm) return; // publik — tidak ada marker
|
||||
const show = !_blankSpotFilterActive || !m.cm._nearestIbadah;
|
||||
if (show) {
|
||||
if (!layerGroup.hasLayer(m.cm)) {
|
||||
m.cm.addTo(layerGroup);
|
||||
m.dragHandle.addTo(layerGroup);
|
||||
}
|
||||
} else {
|
||||
layerGroup.removeLayer(m.cm);
|
||||
layerGroup.removeLayer(m.dragHandle);
|
||||
}
|
||||
});
|
||||
updatePendudukList();
|
||||
refreshList();
|
||||
};
|
||||
|
||||
// ── Reload dari server (dipanggil setelah recalculate massal) ────
|
||||
window._pendudukReload = function () {
|
||||
loadData();
|
||||
};
|
||||
|
||||
// ── FlyTo penduduk marker (dipanggil dari popup ibadah KK list) ───
|
||||
window._pendudukFlyTo = function (id) {
|
||||
const entry = allMarkers[id];
|
||||
if (!entry || !entry.cm) return; // publik tidak punya marker
|
||||
map.closePopup();
|
||||
map.flyTo(entry.cm.getLatLng(), Math.max(map.getZoom(), 17), { animate: true, duration: 0.8 });
|
||||
setTimeout(() => entry.cm.openPopup(), 900);
|
||||
};
|
||||
|
||||
// ── Verifikasi warga (admin only) ────────────────────────────────
|
||||
window._verifikasiPenduduk = function (id, aksi, btnEl) {
|
||||
const entry = allMarkers[id];
|
||||
const label = aksi === 'approve' ? 'Terverifikasi' : 'Ditolak';
|
||||
const nama = entry?.data?.nama_kk ? `"${entry.data.nama_kk}"` : `#${id}`;
|
||||
const title = aksi === 'approve' ? 'Konfirmasi Verifikasi' : 'Konfirmasi Tolak';
|
||||
const msg = `Ubah status verifikasi ${nama} menjadi <strong>${label}</strong>?`;
|
||||
showDeleteConfirm(
|
||||
`Ubah status verifikasi ${entry?.data?.nama_kk || '#'+id} menjadi ${label}?`,
|
||||
{
|
||||
type: 'warn',
|
||||
iconName: aksi === 'approve' ? 'check-circle' : 'x-circle',
|
||||
title,
|
||||
btnLabel: aksi === 'approve' ? 'Ya, Verifikasi' : 'Ya, Tolak'
|
||||
}
|
||||
).then(confirmed => {
|
||||
if (!confirmed) return;
|
||||
if (btnEl) {
|
||||
btnEl.disabled = true;
|
||||
btnEl.innerHTML = '<i data-lucide="loader" class="animate-spin" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i>';
|
||||
lucide.createIcons();
|
||||
}
|
||||
const fd = new FormData();
|
||||
fd.append('id', id);
|
||||
fd.append('aksi', aksi);
|
||||
fetch('api/penduduk/verifikasi.php', { method: 'POST', body: appendCsrf(fd) })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status !== 'success') throw new Error(j.message);
|
||||
const lbl = aksi === 'approve' ? 'Terverifikasi' : 'Ditolak';
|
||||
showToast(`Data #${id} → ${lbl}`, 'success');
|
||||
if (entry) {
|
||||
entry.data.status_verifikasi = j.status_verifikasi;
|
||||
if (entry.cm) {
|
||||
const ibadah = entry.cm._nearestIbadah;
|
||||
entry.cm.bindPopup(buildInfoPopup(entry.data, ibadah), { maxWidth: 280 });
|
||||
entry.cm.openPopup();
|
||||
}
|
||||
}
|
||||
refreshList();
|
||||
})
|
||||
.catch(err => {
|
||||
showToast(err.message || 'Gagal memperbarui verifikasi.', 'error');
|
||||
if (btnEl) btnEl.disabled = false;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// ── Init ─────────────────────────────────────────────────────────
|
||||
window.initPenduduk = function () {
|
||||
if (active) { loadData(); return; }
|
||||
active = true;
|
||||
|
||||
layerGroup.addTo(map);
|
||||
map.on('click', onMapClick);
|
||||
|
||||
window._dlFocusFns['Penduduk'] = function (id) {
|
||||
const entry = allMarkers[id];
|
||||
if (!entry || !entry.cm) return; // publik tidak punya marker
|
||||
map.setView(entry.cm.getLatLng(), Math.max(map.getZoom(), 17), { animate: true });
|
||||
setTimeout(() => entry.cm.openPopup(), 350);
|
||||
};
|
||||
|
||||
loadData();
|
||||
};
|
||||
|
||||
// ── Toggle visibilitas layer ──────────────────────────────────────
|
||||
window._pendudukToggleLayer = function (visible) {
|
||||
if (visible) {
|
||||
if (!map.hasLayer(layerGroup)) map.addLayer(layerGroup);
|
||||
} else {
|
||||
map.removeLayer(layerGroup);
|
||||
if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; }
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,210 @@
|
||||
// modules/stats.js — Dashboard statistik role-aware (v1.0)
|
||||
(function () {
|
||||
let charts = { kategori: null };
|
||||
|
||||
window._statsUpdate = function () {
|
||||
const modal = document.getElementById('modalStats');
|
||||
if (!modal) return;
|
||||
if (modal.classList.contains('show')) {
|
||||
loadAndRender();
|
||||
}
|
||||
};
|
||||
|
||||
window._showDashboard = function () {
|
||||
const modal = document.getElementById('modalStats');
|
||||
if (!modal) return;
|
||||
modal.classList.add('show');
|
||||
loadAndRender();
|
||||
};
|
||||
|
||||
window._hideDashboard = function () {
|
||||
const modal = document.getElementById('modalStats');
|
||||
if (!modal) return;
|
||||
modal.classList.remove('show');
|
||||
};
|
||||
|
||||
function loadAndRender() {
|
||||
fetch('api/stats/ambil.php?_=' + Date.now(), { cache: 'no-store' })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status !== 'success') return;
|
||||
renderCards(j.data);
|
||||
renderKategoriChart(j.data.kategori || []);
|
||||
renderRekapTable(j.data.rekap_ibadah ?? null);
|
||||
renderRekapKebutuhan(j.data.rekap_kebutuhan ?? null);
|
||||
updateDonaturBadge(j.data.donatur_unread ?? 0);
|
||||
})
|
||||
.catch(err => console.error('[Stats]', err));
|
||||
}
|
||||
|
||||
function set(id, val) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = val;
|
||||
}
|
||||
|
||||
function updateDonaturBadge(count) {
|
||||
const badge = document.getElementById('donaturBadge');
|
||||
if (!badge) return;
|
||||
badge.textContent = count > 0 ? count : '';
|
||||
badge.style.display = count > 0 ? '' : 'none';
|
||||
}
|
||||
|
||||
function renderCards(d) {
|
||||
set('statTotalIbadah', d.total_ibadah);
|
||||
set('statTotalKK', d.total_kk);
|
||||
set('statTotalJiwa', d.total_jiwa);
|
||||
set('statJiwaTerjangkau', d.jiwa_terjangkau);
|
||||
set('statJiwaBlankSpot', d.jiwa_blank_spot);
|
||||
set('statPctCakupan', d.pct_cakupan + '%');
|
||||
set('statKebutuhanOpen', d.kk_kebutuhan_open ?? 0);
|
||||
|
||||
const emptyMsg = document.getElementById('statsEmptyMsg');
|
||||
if (emptyMsg) {
|
||||
emptyMsg.style.display = (d.total_kk === 0 && d.total_ibadah === 0) ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function renderKategoriChart(rows) {
|
||||
const el = document.getElementById('chartKategori');
|
||||
if (!el) return;
|
||||
|
||||
const labels = rows.map(r => r.kategori);
|
||||
const data = rows.map(r => parseInt(r.jml_jiwa));
|
||||
const colors = {
|
||||
'Sangat Miskin': '#f85149',
|
||||
'Miskin' : '#e3b341',
|
||||
'Hampir Miskin' : '#388bfd',
|
||||
};
|
||||
const bgColors = labels.map(l => (colors[l] || '#8b949e') + '99');
|
||||
const bdColors = labels.map(l => colors[l] || '#8b949e');
|
||||
|
||||
if (charts.kategori) charts.kategori.destroy();
|
||||
charts.kategori = new Chart(el.getContext('2d'), {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{ data, backgroundColor: bgColors, borderColor: bdColors, borderWidth: 2, hoverOffset: 6 }]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: { color: '#8b949e', font: { size: 11 }, padding: 14 }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderRekapTable(rows) {
|
||||
const wrapper = document.getElementById('rekapTableWrapper');
|
||||
if (!wrapper) return;
|
||||
|
||||
if (rows === null) { wrapper.style.display = 'none'; return; }
|
||||
wrapper.style.display = '';
|
||||
|
||||
if (rows.length === 0) {
|
||||
wrapper.innerHTML = '<p style="color:var(--c-muted);font-size:13px;text-align:center;padding:16px 0;">Belum ada data rumah ibadah.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.appendChild(document.createTextNode(s ?? ''));
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
const tbody = rows.map(r => {
|
||||
const pct = r.jml_kk > 0 ? Math.round(r.jml_ditangani / r.jml_kk * 100) : 0;
|
||||
return `<tr>
|
||||
<td style="padding:6px 10px">${esc(r.nama)}</td>
|
||||
<td style="padding:6px 10px;color:var(--c-muted)">${esc(r.jenis)}</td>
|
||||
<td style="padding:6px 10px;text-align:right;font-family:var(--font-mono)">${r.jml_kk}</td>
|
||||
<td style="padding:6px 10px;text-align:right;font-family:var(--font-mono)">${r.jml_jiwa}</td>
|
||||
<td style="padding:6px 10px;text-align:right">
|
||||
<span style="color:${pct>=60?'#3fb950':'#e3b341'};font-weight:700;font-family:var(--font-mono)">${pct}%</span>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
wrapper.innerHTML = `
|
||||
<div style="font-size:11px;font-weight:700;color:var(--c-muted);text-transform:uppercase;
|
||||
letter-spacing:.6px;margin-bottom:10px;">Rekapitulasi per Rumah Ibadah</div>
|
||||
<div style="overflow-x:auto">
|
||||
<table style="width:100%;border-collapse:collapse;font-size:12px;">
|
||||
<thead>
|
||||
<tr style="border-bottom:1px solid var(--c-border)">
|
||||
<th style="text-align:left;padding:6px 10px;color:var(--c-muted);font-weight:600">Nama</th>
|
||||
<th style="text-align:left;padding:6px 10px;color:var(--c-muted);font-weight:600">Jenis</th>
|
||||
<th style="text-align:right;padding:6px 10px;color:var(--c-muted);font-weight:600">KK</th>
|
||||
<th style="text-align:right;padding:6px 10px;color:var(--c-muted);font-weight:600">Jiwa</th>
|
||||
<th style="text-align:right;padding:6px 10px;color:var(--c-muted);font-weight:600">% Ditangani</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${tbody}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
function renderRekapKebutuhan(rows) {
|
||||
const wrapper = document.getElementById('rekapKebutuhanWrapper');
|
||||
if (!wrapper) return;
|
||||
|
||||
if (rows === null) { wrapper.style.display = 'none'; return; }
|
||||
wrapper.style.display = '';
|
||||
|
||||
if (rows.length === 0) {
|
||||
wrapper.innerHTML = '<p style="color:var(--c-muted);font-size:13px;text-align:center;padding:16px 0;">Belum ada kebutuhan tercatat.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.appendChild(document.createTextNode(s ?? ''));
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
const ICON = {
|
||||
'Sembako': 'shopping-cart',
|
||||
'Biaya Sekolah': 'graduation-cap',
|
||||
'Biaya Kesehatan': 'heart-pulse',
|
||||
'Modal Usaha': 'briefcase',
|
||||
'Renovasi Rumah': 'home',
|
||||
'Perlengkapan Rumah': 'armchair',
|
||||
'Pakaian': 'shirt',
|
||||
'Lainnya': 'clipboard-list'
|
||||
};
|
||||
|
||||
const tbody = rows.map(r => {
|
||||
const iconName = ICON[r.kategori] || 'clipboard-list';
|
||||
const iconHtml = `<i data-lucide="${iconName}" class="lucide-inline" style="width:14px;height:14px;vertical-align:middle;margin-right:6px;"></i>`;
|
||||
return `<tr style="border-bottom:1px solid rgba(255,255,255,.05)">
|
||||
<td style="padding:6px 10px;display:flex;align-items:center;gap:2px;">${iconHtml} ${esc(r.kategori)}</td>
|
||||
<td style="padding:6px 10px;text-align:right;font-family:var(--font-mono);color:#ef4444">${r.belum}</td>
|
||||
<td style="padding:6px 10px;text-align:right;font-family:var(--font-mono);color:#e3b341">${r.proses}</td>
|
||||
<td style="padding:6px 10px;text-align:right;font-family:var(--font-mono);color:#3fb950">${r.terpenuhi}</td>
|
||||
<td style="padding:6px 10px;text-align:right;font-family:var(--font-mono);color:var(--c-muted)">${r.total}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
wrapper.innerHTML = `
|
||||
<div style="font-size:11px;font-weight:700;color:var(--c-muted);text-transform:uppercase;
|
||||
letter-spacing:.6px;margin-bottom:10px;">Rekapitulasi Kebutuhan per Kategori</div>
|
||||
<div style="overflow-x:auto">
|
||||
<table style="width:100%;border-collapse:collapse;font-size:12px;">
|
||||
<thead>
|
||||
<tr style="border-bottom:1px solid var(--c-border)">
|
||||
<th style="text-align:left;padding:6px 10px;color:var(--c-muted);font-weight:600">Kategori</th>
|
||||
<th style="text-align:right;padding:6px 10px;color:#ef4444;font-weight:600">Belum</th>
|
||||
<th style="text-align:right;padding:6px 10px;color:#e3b341;font-weight:600">Proses</th>
|
||||
<th style="text-align:right;padding:6px 10px;color:#3fb950;font-weight:600">Terpenuhi</th>
|
||||
<th style="text-align:right;padding:6px 10px;color:var(--c-muted);font-weight:600">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${tbody}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
}
|
||||
})();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user