first commit

This commit is contained in:
2026-06-09 19:36:57 +07:00
commit 7b3bc9178d
18 changed files with 7302 additions and 0 deletions
+118
View File
@@ -0,0 +1,118 @@
<?php
// ============================================================
// API Manajemen Data Jalan (Line / Polyline)
// Mendukung: GET (list & detail), POST (create),
// PUT (update), DELETE (hapus)
// ============================================================
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { exit(0); }
require_once 'config.php';
$method = $_SERVER['REQUEST_METHOD'];
$input = json_decode(file_get_contents('php://input'), true) ?? [];
// ── Router ───────────────────────────────────────────────────
switch ($method) {
// ── READ (semua atau satu record) ────────────────────────
case 'GET':
if (isset($_GET['id'])) {
$id = (int) $_GET['id'];
$sql = "SELECT * FROM data_jalan WHERE id = ?";
$stmt = $conn->prepare($sql);
$stmt->bind_param('i', $id);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
if ($row) {
$row['geojson'] = json_decode($row['geojson']);
jsonResponse(['status' => 'success', 'data' => $row]);
} else {
jsonResponse(['status' => 'error', 'message' => 'Data tidak ditemukan'], 404);
}
} else {
$sql = "SELECT * FROM data_jalan ORDER BY id DESC";
$rows = $conn->query($sql)->fetch_all(MYSQLI_ASSOC);
foreach ($rows as &$r) {
$r['geojson'] = json_decode($r['geojson']);
}
jsonResponse(['status' => 'success', 'data' => $rows]);
}
break;
// ── CREATE ───────────────────────────────────────────────
case 'POST':
$nama_jalan = clean($input['nama_jalan'] ?? '');
$status_jalan = clean($input['status_jalan'] ?? '');
$panjang_m = (float) ($input['panjang_m'] ?? 0);
$keterangan = clean($input['keterangan'] ?? '');
$geojson = $input['geojson'] ?? null;
if (!$nama_jalan || !$status_jalan || !$geojson) {
jsonResponse(['status' => 'error', 'message' => 'Field nama_jalan, status_jalan, dan geojson wajib diisi'], 400);
}
$geojsonStr = is_string($geojson) ? $geojson : json_encode($geojson);
$sql = "INSERT INTO data_jalan (nama_jalan, status_jalan, panjang_m, geojson, keterangan) VALUES (?, ?, ?, ?, ?)";
$stmt = $conn->prepare($sql);
$stmt->bind_param('ssdss', $nama_jalan, $status_jalan, $panjang_m, $geojsonStr, $keterangan);
if ($stmt->execute()) {
jsonResponse(['status' => 'success', 'message' => 'Data jalan berhasil ditambahkan', 'id' => $conn->insert_id], 201);
} else {
jsonResponse(['status' => 'error', 'message' => $conn->error], 500);
}
break;
// ── UPDATE ───────────────────────────────────────────────
case 'PUT':
$id = (int) ($input['id'] ?? 0);
$nama_jalan = clean($input['nama_jalan'] ?? '');
$status_jalan = clean($input['status_jalan'] ?? '');
$panjang_m = (float) ($input['panjang_m'] ?? 0);
$keterangan = clean($input['keterangan'] ?? '');
$geojson = $input['geojson'] ?? null;
if (!$id || !$nama_jalan || !$status_jalan || !$geojson) {
jsonResponse(['status' => 'error', 'message' => 'Field id, nama_jalan, status_jalan, dan geojson wajib diisi'], 400);
}
$geojsonStr = is_string($geojson) ? $geojson : json_encode($geojson);
$sql = "UPDATE data_jalan SET nama_jalan=?, status_jalan=?, panjang_m=?, geojson=?, keterangan=? WHERE id=?";
$stmt = $conn->prepare($sql);
$stmt->bind_param('ssdssi', $nama_jalan, $status_jalan, $panjang_m, $geojsonStr, $keterangan, $id);
if ($stmt->execute()) {
jsonResponse(['status' => 'success', 'message' => 'Data jalan berhasil diperbarui']);
} else {
jsonResponse(['status' => 'error', 'message' => $conn->error], 500);
}
break;
// ── DELETE ───────────────────────────────────────────────
case 'DELETE':
$id = (int) ($_GET['id'] ?? ($input['id'] ?? 0));
if (!$id) {
jsonResponse(['status' => 'error', 'message' => 'Parameter id wajib diisi'], 400);
}
$stmt = $conn->prepare("DELETE FROM data_jalan WHERE id = ?");
$stmt->bind_param('i', $id);
if ($stmt->execute() && $stmt->affected_rows > 0) {
jsonResponse(['status' => 'success', 'message' => 'Data jalan berhasil dihapus']);
} else {
jsonResponse(['status' => 'error', 'message' => 'Data tidak ditemukan atau gagal dihapus'], 404);
}
break;
default:
jsonResponse(['status' => 'error', 'message' => 'Method tidak diizinkan'], 405);
}
+118
View File
@@ -0,0 +1,118 @@
<?php
// ============================================================
// API Manajemen Data Parsil Tanah (Polygon)
// Mendukung: GET (list & detail), POST (create),
// PUT (update), DELETE (hapus)
// ============================================================
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { exit(0); }
require_once 'config.php';
$method = $_SERVER['REQUEST_METHOD'];
$input = json_decode(file_get_contents('php://input'), true) ?? [];
// ── Router ───────────────────────────────────────────────────
switch ($method) {
// ── READ ─────────────────────────────────────────────────
case 'GET':
if (isset($_GET['id'])) {
$id = (int) $_GET['id'];
$stmt = $conn->prepare("SELECT * FROM data_parsil WHERE id = ?");
$stmt->bind_param('i', $id);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
if ($row) {
$row['geojson'] = json_decode($row['geojson']);
jsonResponse(['status' => 'success', 'data' => $row]);
} else {
jsonResponse(['status' => 'error', 'message' => 'Data tidak ditemukan'], 404);
}
} else {
$rows = $conn->query("SELECT * FROM data_parsil ORDER BY id DESC")->fetch_all(MYSQLI_ASSOC);
foreach ($rows as &$r) {
$r['geojson'] = json_decode($r['geojson']);
}
jsonResponse(['status' => 'success', 'data' => $rows]);
}
break;
// ── CREATE ───────────────────────────────────────────────
case 'POST':
$nama_parsil = clean($input['nama_parsil'] ?? '');
$status_hak = clean($input['status_hak'] ?? '');
$luas_m2 = (float) ($input['luas_m2'] ?? 0);
$pemilik = clean($input['pemilik'] ?? '');
$keterangan = clean($input['keterangan'] ?? '');
$geojson = $input['geojson'] ?? null;
if (!$nama_parsil || !$status_hak || !$geojson) {
jsonResponse(['status' => 'error', 'message' => 'Field nama_parsil, status_hak, dan geojson wajib diisi'], 400);
}
$geojsonStr = is_string($geojson) ? $geojson : json_encode($geojson);
$sql = "INSERT INTO data_parsil (nama_parsil, status_hak, luas_m2, geojson, pemilik, keterangan) VALUES (?, ?, ?, ?, ?, ?)";
$stmt = $conn->prepare($sql);
$stmt->bind_param('ssdsss', $nama_parsil, $status_hak, $luas_m2, $geojsonStr, $pemilik, $keterangan);
if ($stmt->execute()) {
jsonResponse(['status' => 'success', 'message' => 'Data parsil berhasil ditambahkan', 'id' => $conn->insert_id], 201);
} else {
jsonResponse(['status' => 'error', 'message' => $conn->error], 500);
}
break;
// ── UPDATE ───────────────────────────────────────────────
case 'PUT':
$id = (int) ($input['id'] ?? 0);
$nama_parsil = clean($input['nama_parsil'] ?? '');
$status_hak = clean($input['status_hak'] ?? '');
$luas_m2 = (float) ($input['luas_m2'] ?? 0);
$pemilik = clean($input['pemilik'] ?? '');
$keterangan = clean($input['keterangan'] ?? '');
$geojson = $input['geojson'] ?? null;
if (!$id || !$nama_parsil || !$status_hak || !$geojson) {
jsonResponse(['status' => 'error', 'message' => 'Field id, nama_parsil, status_hak, dan geojson wajib diisi'], 400);
}
$geojsonStr = is_string($geojson) ? $geojson : json_encode($geojson);
$sql = "UPDATE data_parsil SET nama_parsil=?, status_hak=?, luas_m2=?, geojson=?, pemilik=?, keterangan=? WHERE id=?";
$stmt = $conn->prepare($sql);
$stmt->bind_param('ssdsssi', $nama_parsil, $status_hak, $luas_m2, $geojsonStr, $pemilik, $keterangan, $id);
if ($stmt->execute()) {
jsonResponse(['status' => 'success', 'message' => 'Data parsil berhasil diperbarui']);
} else {
jsonResponse(['status' => 'error', 'message' => $conn->error], 500);
}
break;
// ── DELETE ───────────────────────────────────────────────
case 'DELETE':
$id = (int) ($_GET['id'] ?? ($input['id'] ?? 0));
if (!$id) {
jsonResponse(['status' => 'error', 'message' => 'Parameter id wajib diisi'], 400);
}
$stmt = $conn->prepare("DELETE FROM data_parsil WHERE id = ?");
$stmt->bind_param('i', $id);
if ($stmt->execute() && $stmt->affected_rows > 0) {
jsonResponse(['status' => 'success', 'message' => 'Data parsil berhasil dihapus']);
} else {
jsonResponse(['status' => 'error', 'message' => 'Data tidak ditemukan atau gagal dihapus'], 404);
}
break;
default:
jsonResponse(['status' => 'error', 'message' => 'Method tidak diizinkan'], 405);
}
+120
View File
@@ -0,0 +1,120 @@
<?php
// ============================================================
// API Manajemen Data SPBU (Point / Marker)
// Mendukung: GET (list & detail), POST (create),
// PUT (update), DELETE (hapus)
// ============================================================
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { exit(0); }
require_once 'config.php';
$method = $_SERVER['REQUEST_METHOD'];
$input = json_decode(file_get_contents('php://input'), true) ?? [];
// ── Router ───────────────────────────────────────────────────
switch ($method) {
// ── READ ─────────────────────────────────────────────────
case 'GET':
if (isset($_GET['id'])) {
$id = (int) $_GET['id'];
$stmt = $conn->prepare("SELECT * FROM data_spbu WHERE id = ?");
$stmt->bind_param('i', $id);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
if ($row) {
$row['geojson'] = json_decode($row['geojson']);
$row['buka_24jam'] = (bool) $row['buka_24jam'];
jsonResponse(['status' => 'success', 'data' => $row]);
} else {
jsonResponse(['status' => 'error', 'message' => 'Data tidak ditemukan'], 404);
}
} else {
$rows = $conn->query("SELECT * FROM data_spbu ORDER BY id DESC")->fetch_all(MYSQLI_ASSOC);
foreach ($rows as &$r) {
$r['geojson'] = json_decode($r['geojson']);
$r['buka_24jam'] = (bool) $r['buka_24jam'];
}
jsonResponse(['status' => 'success', 'data' => $rows]);
}
break;
// ── CREATE ───────────────────────────────────────────────
case 'POST':
$nama_spbu = clean($input['nama_spbu'] ?? '');
$telepon = clean($input['telepon'] ?? '');
$buka_24jam = isset($input['buka_24jam']) ? (int)(bool)$input['buka_24jam'] : 0;
$lat = (float) ($input['lat'] ?? 0);
$lng = (float) ($input['lng'] ?? 0);
$geojson = $input['geojson'] ?? null;
if (!$nama_spbu || !$geojson) {
jsonResponse(['status' => 'error', 'message' => 'Field nama_spbu dan geojson wajib diisi'], 400);
}
$geojsonStr = is_string($geojson) ? $geojson : json_encode($geojson);
$sql = "INSERT INTO data_spbu (nama_spbu, telepon, buka_24jam, lat, lng, geojson) VALUES (?, ?, ?, ?, ?, ?)";
$stmt = $conn->prepare($sql);
$stmt->bind_param('ssidds', $nama_spbu, $telepon, $buka_24jam, $lat, $lng, $geojsonStr);
if ($stmt->execute()) {
jsonResponse(['status' => 'success', 'message' => 'Data SPBU berhasil ditambahkan', 'id' => $conn->insert_id], 201);
} else {
jsonResponse(['status' => 'error', 'message' => $conn->error], 500);
}
break;
// ── UPDATE ───────────────────────────────────────────────
case 'PUT':
$id = (int) ($input['id'] ?? 0);
$nama_spbu = clean($input['nama_spbu'] ?? '');
$telepon = clean($input['telepon'] ?? '');
$buka_24jam = isset($input['buka_24jam']) ? (int)(bool)$input['buka_24jam'] : 0;
$lat = (float) ($input['lat'] ?? 0);
$lng = (float) ($input['lng'] ?? 0);
$geojson = $input['geojson'] ?? null;
if (!$id || !$nama_spbu || !$geojson) {
jsonResponse(['status' => 'error', 'message' => 'Field id, nama_spbu dan geojson wajib diisi'], 400);
}
$geojsonStr = is_string($geojson) ? $geojson : json_encode($geojson);
$sql = "UPDATE data_spbu SET nama_spbu=?, telepon=?, buka_24jam=?, lat=?, lng=?, geojson=? WHERE id=?";
$stmt = $conn->prepare($sql);
$stmt->bind_param('ssiddsi', $nama_spbu, $telepon, $buka_24jam, $lat, $lng, $geojsonStr, $id);
if ($stmt->execute()) {
jsonResponse(['status' => 'success', 'message' => 'Data SPBU berhasil diperbarui']);
} else {
jsonResponse(['status' => 'error', 'message' => $conn->error], 500);
}
break;
// ── DELETE ───────────────────────────────────────────────
case 'DELETE':
$id = (int) ($_GET['id'] ?? ($input['id'] ?? 0));
if (!$id) {
jsonResponse(['status' => 'error', 'message' => 'Parameter id wajib diisi'], 400);
}
$stmt = $conn->prepare("DELETE FROM data_spbu WHERE id = ?");
$stmt->bind_param('i', $id);
if ($stmt->execute() && $stmt->affected_rows > 0) {
jsonResponse(['status' => 'success', 'message' => 'Data SPBU berhasil dihapus']);
} else {
jsonResponse(['status' => 'error', 'message' => 'Data tidak ditemukan atau gagal dihapus'], 404);
}
break;
default:
jsonResponse(['status' => 'error', 'message' => 'Method tidak diizinkan'], 405);
}
+45
View File
@@ -0,0 +1,45 @@
<?php
// ============================================================
// Konfigurasi Koneksi Database
// WebGIS Manajemen Data Jalan & Parsil Tanah
// ============================================================
define('DB_HOST', 'localhost');
define('DB_USER', 'root'); // sesuaikan dengan user MySQL Anda
define('DB_PASS', ''); // sesuaikan dengan password MySQL Anda
define('DB_NAME', 'webgis_sig');
define('DB_CHARSET', 'utf8mb4');
// ── Buat koneksi ─────────────────────────────────────────────
$conn = new mysqli(DB_HOST, DB_USER, DB_PASS, DB_NAME);
if ($conn->connect_error) {
http_response_code(500);
die(json_encode([
'status' => 'error',
'message' => 'Koneksi database gagal: ' . $conn->connect_error
]));
}
$conn->set_charset(DB_CHARSET);
// ── Fungsi utilitas ──────────────────────────────────────────
/**
* Kembalikan response JSON dan hentikan eksekusi.
*/
function jsonResponse(array $data, int $httpCode = 200): void
{
http_response_code($httpCode);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
/**
* Sanitasi input string.
*/
function clean(string $value): string
{
return htmlspecialchars(strip_tags(trim($value)));
}
+138
View File
@@ -0,0 +1,138 @@
-- ============================================================
-- DATABASE: webgis_sig
-- Sistem Manajemen Data Jalan, Parsil Tanah & SPBU
-- ============================================================
CREATE DATABASE IF NOT EXISTS webgis_sig CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE webgis_sig;
-- ============================================================
-- TABEL: data_jalan (Line / Polyline)
-- Menyimpan data ruas jalan berdasarkan status jalan
-- Geometri disimpan dalam format GeoJSON (LineString)
-- ============================================================
CREATE TABLE IF NOT EXISTS data_jalan (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
nama_jalan VARCHAR(150) NOT NULL COMMENT 'Nama ruas jalan',
status_jalan ENUM(
'Jalan Nasional',
'Jalan Provinsi',
'Jalan Kabupaten'
) NOT NULL COMMENT 'Status / klasifikasi jalan',
panjang_m DECIMAL(12,2) DEFAULT 0.00 COMMENT 'Panjang jalan dalam meter (dihitung otomatis oleh LeafletJS)',
geojson LONGTEXT NOT NULL COMMENT 'Geometri LineString dalam format GeoJSON',
keterangan TEXT DEFAULT NULL COMMENT 'Keterangan tambahan',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- TABEL: data_parsil (Polygon)
-- Menyimpan data parsil tanah / kavling berdasarkan status kepemilikan
-- Geometri disimpan dalam format GeoJSON (Polygon)
-- ============================================================
CREATE TABLE IF NOT EXISTS data_parsil (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
nama_parsil VARCHAR(150) NOT NULL COMMENT 'Nama / nomor parsil',
status_hak ENUM(
'Sertifikat Hak Milik',
'Sertifikat Hak Guna Bangunan',
'Sertifikat Hak Guna Usaha',
'Sertifikat Hak Pakai'
) NOT NULL COMMENT 'Status kepemilikan / hak atas tanah',
luas_m2 DECIMAL(15,2) DEFAULT 0.00 COMMENT 'Luas tanah dalam meter persegi (dihitung otomatis oleh LeafletJS)',
geojson LONGTEXT NOT NULL COMMENT 'Geometri Polygon dalam format GeoJSON',
pemilik VARCHAR(150) DEFAULT NULL COMMENT 'Nama pemilik (opsional)',
keterangan TEXT DEFAULT NULL COMMENT 'Keterangan tambahan',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- DATA CONTOH Jalan
-- ============================================================
INSERT INTO data_jalan (nama_jalan, status_jalan, panjang_m, geojson, keterangan) VALUES
(
'Jl. Sudirman',
'Jalan Nasional',
1250.50,
'{"type":"LineString","coordinates":[[106.8272,-6.2088],[106.8290,-6.2075],[106.8310,-6.2060],[106.8330,-6.2045]]}',
'Jalan protokol pusat kota'
),
(
'Jl. Gatot Subroto',
'Jalan Provinsi',
980.00,
'{"type":"LineString","coordinates":[[106.8200,-6.2100],[106.8220,-6.2095],[106.8240,-6.2090],[106.8260,-6.2085]]}',
'Penghubung antar kecamatan'
),
(
'Jl. Pramuka',
'Jalan Kabupaten',
620.75,
'{"type":"LineString","coordinates":[[106.8150,-6.2180],[106.8160,-6.2170],[106.8175,-6.2160],[106.8185,-6.2150]]}',
'Jalan lingkungan kabupaten'
);
-- ============================================================
-- DATA CONTOH Parsil Tanah
-- ============================================================
INSERT INTO data_parsil (nama_parsil, status_hak, luas_m2, geojson, pemilik, keterangan) VALUES
(
'Parsil A-001',
'Sertifikat Hak Milik',
1200.00,
'{"type":"Polygon","coordinates":[[[106.8200,-6.2000],[106.8210,-6.2000],[106.8210,-6.2010],[106.8200,-6.2010],[106.8200,-6.2000]]]}',
'Budi Santoso',
'Tanah kavling perumahan'
),
(
'Parsil B-002',
'Sertifikat Hak Guna Bangunan',
850.50,
'{"type":"Polygon","coordinates":[[[106.8220,-6.2000],[106.8230,-6.2000],[106.8230,-6.2008],[106.8220,-6.2008],[106.8220,-6.2000]]]}',
'PT. Maju Bersama',
'Kavling komersial'
),
(
'Parsil C-003',
'Sertifikat Hak Guna Usaha',
2500.00,
'{"type":"Polygon","coordinates":[[[106.8240,-6.1990],[106.8260,-6.1990],[106.8260,-6.2010],[106.8240,-6.2010],[106.8240,-6.1990]]]}',
'CV. Tani Makmur',
'Lahan pertanian/perkebunan'
),
(
'Parsil D-004',
'Sertifikat Hak Pakai',
4000.00,
'{"type":"Polygon","coordinates":[[[106.8270,-6.1985],[106.8295,-6.1985],[106.8295,-6.2005],[106.8270,-6.2005],[106.8270,-6.1985]]]}',
'Pemerintah Kota',
'Lahan fasilitas umum'
);
-- ============================================================
-- TABEL: data_spbu (Point)
-- Menyimpan data SPBU (Stasiun Pengisian Bahan Bakar Umum)
-- Geometri disimpan dalam format GeoJSON (Point)
-- ============================================================
CREATE TABLE IF NOT EXISTS data_spbu (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
nama_spbu VARCHAR(150) NOT NULL COMMENT 'Nama SPBU / nama cabang',
telepon VARCHAR(20) DEFAULT NULL COMMENT 'Nomor telepon SPBU',
buka_24jam TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1 = buka 24 jam (marker hijau), 0 = tidak (marker merah)',
lat DECIMAL(10,7) NOT NULL COMMENT 'Latitude titik SPBU',
lng DECIMAL(10,7) NOT NULL COMMENT 'Longitude titik SPBU',
geojson LONGTEXT NOT NULL COMMENT 'Geometri Point dalam format GeoJSON',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- DATA CONTOH SPBU di Pontianak
-- ============================================================
INSERT INTO data_spbu (nama_spbu, telepon, buka_24jam, lat, lng, geojson) VALUES
('SPBU Jl. Ahmad Yani', '0561-123456', 1, -0.0200, 109.3300, '{"type":"Point","coordinates":[109.3300,-0.0200]}'),
('SPBU Jl. Gajah Mada', '0561-654321', 0, -0.0350, 109.3200, '{"type":"Point","coordinates":[109.3200,-0.0350]}'),
('SPBU Jl. Tanjungpura', '0561-789012', 1, -0.0100, 109.3450, '{"type":"Point","coordinates":[109.3450,-0.0100]}'),
('SPBU Jl. Sutan Syahrir','0561-345678', 0, -0.0480, 109.3380, '{"type":"Point","coordinates":[109.3380,-0.0480]}');
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,312 @@
# Penjelasan WebGIS — Bahan SRS & Manual Book
**WebGIS Pontianak — Pengentasan Kemiskinan Berbasis Rumah Ibadah**
*(Poverty Mapping — WebGIS Kota Pontianak)*
---
## 1. Gambaran Umum Sistem
### Nama & Tujuan
Sistem informasi geografis (WebGIS) untuk **pemetaan keluarga miskin** di **Kota Pontianak** dengan pendekatan **berbasis rumah ibadah** sebagai unit pembina sosial. Rumah ibadah (masjid, gereja, pura, vihara, klenteng, dll.) memiliki **radius cakupan wilayah**; keluarga miskin di dalam radius tersebut **otomatis ditugaskan** sebagai KK binaan rumah ibadah terdekat.
### Masalah yang Diselesaikan
- Data kemiskinan tersebar dan sulit divisualisasikan secara spasial
- Koordinasi bantuan sosial (PKH, BPNT, bansos, dll.) perlu dilacak per keluarga
- Masyarakat perlu saluran laporan tanpa harus login
- Pemkot (walikota) butuh dashboard agregat untuk pengambilan keputusan
- Pengurus rumah ibadah butuh akses terbatas hanya untuk KK binaannya
### Cakupan Geografis
6 kecamatan di Kota Pontianak:
- Pontianak Utara, Selatan, Timur, Barat, Tenggara, Kota
---
## 2. Arsitektur Teknis
### Stack Teknologi
| Komponen | Teknologi |
|----------|-----------|
| Frontend | HTML5, CSS3, JavaScript (vanilla), Leaflet 1.9.4 |
| Backend | PHP dengan PDO |
| Database | MySQL (`webgis_rumah_ibadah`) |
| Peta | CARTO Voyager tiles + OpenStreetMap Nominatim (reverse geocoding) |
| Ekspor | SheetJS (Excel), jsPDF + autoTable (PDF) |
| Autentikasi | PHP Session |
| Server dev | Laragon (Apache + MySQL) |
### Struktur File Utama
- `index.html` — seluruh antarmuka & logika frontend
- `index.php` — redirect ke `index.html`
- `config.php` — koneksi DB, utilitas (Haversine, auto-assign, rate limit, upload)
- `api_auth.php` — autentikasi
- `api_rumah_ibadah.php` — CRUD rumah ibadah
- `api_keluarga_miskin.php` — CRUD keluarga miskin
- `api_laporan.php` — laporan masyarakat
- `api_histori_bantuan.php` — histori bantuan
- `api_dashboard.php` — dashboard walikota
- `database.sql` — skema & data contoh
- `uploads/` — foto laporan masyarakat
### Alur Arsitektur
```
Browser (index.html)
↓ fetch JSON
PHP API (api_*.php)
↓ PDO
MySQL (webgis_rumah_ibadah)
```
Peta juga memanggil **Nominatim OpenStreetMap** untuk reverse geocoding (alamat otomatis dari koordinat).
---
## 3. Aktor / Pengguna Sistem
| Role | Deskripsi | Login |
|------|-----------|-------|
| **Masyarakat (publik)** | Melihat peta & data (read-only), mengirim laporan warga miskin | Tidak perlu login |
| **Admin** | Kelola penuh: rumah ibadah, KK, laporan, bantuan, reset password pengurus | `admin` / `admin123` |
| **Pengurus Rumah Ibadah** | Kelola KK binaan & histori bantuan di wilayahnya | Kode `RI-XXX-9999` / password pengurus |
| **Walikota** | Dashboard agregat, lihat data, ekspor; **tidak bisa** menambah/mengubah/hapus | `walikota` / `admin123` |
**Format login pengurus:** `RI-[JENIS 3 HURUF]-[4 DIGIT]`
Contoh: `RI-MSJ-4821` (Masjid), `RI-GRJ-7153` (Gereja)
---
## 4. Modul Fungsional (untuk SRS)
### 4.1 Modul Peta Interaktif (WebGIS)
- Peta interaktif berpusat di Pontianak (lat: -0.0263, lng: 109.3425, zoom 14)
- Layer marker:
- **Rumah ibadah** — warna per jenis (Masjid hijau, Gereja biru, dll.)
- **Keluarga miskin** — warna per tingkat (Sangat Miskin merah, Miskin oranye, Hampir Miskin kuning)
- **Radius cakupan** rumah ibadah (lingkaran saat zoom ke RI)
- **Pick lokasi di peta** — klik peta untuk set koordinat + auto-fill alamat via Nominatim
- Popup detail saat klik marker
- Legenda dinamis per tab aktif
### 4.2 Modul Rumah Ibadah
**Data:** nama, jenis, alamat, koordinat, radius (0.55 km), kecamatan, kode pengurus, password pengurus
| Aksi | Admin | Pengurus | Walikota | Publik |
|------|-------|----------|----------|--------|
| Lihat daftar & peta | ✓ | ✓ (hanya RI sendiri) | ✓ | ✓ |
| Tambah | ✓ | ✗ | ✗ | ✗ |
| Edit | ✓ | ✓ (RI sendiri) | ✗ | ✗ |
| Hapus | ✓ | ✗ | ✗ | ✗ |
| Reset password pengurus | ✓ | ✗ | ✗ | ✗ |
**Fitur khusus:**
- Saat tambah RI, sistem generate **kode pengurus** & **password acak** (8 karakter)
- Perubahan/hapus RI memicu **re-assign otomatis** semua KK ke RI terdekat
### 4.3 Modul Keluarga Miskin
**Data:** NIK, nama KK, tanggal lahir, pendidikan, jumlah anggota, alamat, kecamatan, koordinat, tingkat kemiskinan, status DTKS, riwayat penyakit (JSON), rumah ibadah binaan, jarak ke RI
**Tingkat kemiskinan:** Sangat Miskin, Miskin, Hampir Miskin
**Status DTKS:** Terdaftar DTKS, Belum DTKS, Sedang Diusulkan
**Riwayat penyakit:** Stunting, TBC, Hipertensi, Diabetes, Jantung, ODGJ, DBD, Gizi Buruk, Lainnya
**Auto-assign:** Algoritma **Haversine** — KK ditugaskan ke RI terdekat yang radiusnya mencakup lokasi KK.
**Filter:** kecamatan, tingkat kemiskinan, status DTKS
**Flag bisnis:** KK dengan status `Belum DTKS` ditandai **Eligible Bansos Tunai Pemkot**
| Aksi | Admin | Pengurus | Walikota |
|------|-------|----------|----------|
| Lihat | ✓ | ✓ (KK binaan) | ✓ |
| Tambah/Edit | ✓ | ✓ (KK binaan) | ✗ |
| Hapus | ✓ | ✗ | ✗ |
| Ekspor Excel/PDF | ✓ | ✗ | ✓ |
### 4.4 Modul Laporan Masyarakat
- **Tanpa login** — tombol floating *"Laporkan Warga Miskin"*
- Input: identitas warga (opsional), NIK (opsional, 16 digit), lokasi, alamat, deskripsi, foto (maks 5MB: JPG/PNG/GIF/WebP), identitas pelapor (boleh anonim)
- **Rate limit:** maks **5 laporan per jam per IP**
- Status: **Baru → Diverifikasi / Ditolak**
- Admin dapat: kelola status, isi alasan tolak, konversi ke data KK, buat KK baru dari laporan
| Aksi | Admin | Walikota | Publik |
|------|-------|----------|--------|
| Kirim laporan | ✓ | ✓ | ✓ |
| Lihat daftar | ✓ | ✓ | ✗ |
| Kelola/verifikasi | ✓ | ✗ | ✗ |
### 4.5 Modul Histori Bantuan
**Jenis bantuan:** PKH, BPNT/Sembako, Beras Bansos, KIS-PBI, Bansos Tunai Pemkot, Rutilahu, PMT Stunting, CSR Swasta, Lainnya
**Sumber dana:** APBN, APBD, CSR, Lainnya
- Pencatatan per KK: jenis, nominal (Rp), keterangan, tanggal penyaluran, sumber dana, penanggung jawab
- **Peringatan duplikasi** jika KK sudah punya PKH/BPNT/Beras Bansos
- Total bantuan per KK dihitung otomatis
### 4.6 Modul Dashboard Walikota
Tampil di sidebar saat walikota login (tab Rumah Ibadah & Keluarga Miskin). Menampilkan:
- Total KK, KK sudah/belum dapat bantuan (tahun berjalan)
- Distribusi per kecamatan, penyakit, DTKS, tingkat kemiskinan
- Bantuan per jenis & sumber dana + total nominal
- Jumlah laporan belum ditangani
- **Drill-down** klikabel → detail tabel + ekspor Excel
Admin juga dapat mengakses API dashboard yang sama.
### 4.7 Modul Autentikasi
- Login via username/password (JSON POST)
- Session PHP (`$_SESSION['user']`)
- Endpoint: `check`, `login`, `logout`
- Pengurus login via kode RI, bukan tabel `users`
---
## 5. Model Data
### Tabel Database (6 tabel)
1. **rumah_ibadah** — titik lokasi + radius + kredensial pengurus
2. **users** — admin & walikota
3. **keluarga_miskin** — data KK + auto-assign
4. **laporan_masyarakat** — laporan warga
5. **histori_bantuan** — riwayat penyaluran
6. **rate_limit** — pembatasan IP laporan
### Relasi Utama
- `rumah_ibadah``keluarga_miskin` (satu RI membina banyak KK)
- `keluarga_miskin``histori_bantuan` (satu KK punya banyak bantuan)
- `users``histori_bantuan` (penanggung jawab)
- `laporan_masyarakat``keluarga_miskin` (konversi laporan ke KK)
---
## 6. Alur Bisnis Utama (untuk Manual Book)
### Alur A — Pendaftaran Rumah Ibadah (Admin)
1. Login sebagai admin
2. Tab **Rumah Ibadah****Tambah**
3. Isi form / klik peta untuk koordinat
4. Atur radius cakupan (slider 0.55 km)
5. Simpan → sistem tampilkan **kode pengurus & password** (diserahkan ke pengurus RI)
6. Semua KK di wilayah otomatis di-assign ulang
### Alur B — Pengurus Mencatat KK Binaan
1. Login dengan kode `RI-XXX-9999`
2. Tab **Keluarga Miskin****Tambah**
3. Isi NIK, data keluarga, pilih penyakit, tentukan lokasi di peta
4. Simpan → KK terhubung ke RI pengurus
5. Klik **Bantuan** pada kartu KK → catat penyaluran bantuan
### Alur C — Laporan Masyarakat
1. Tanpa login, klik **Laporkan Warga Miskin**
2. Isi lokasi & deskripsi (identitas opsional)
3. Kirim → status **Baru**
4. Admin buka tab **Laporan****Kelola**
5. Verifikasi (bisa buat KK dari laporan) atau tolak dengan alasan
### Alur D — Monitoring Walikota
1. Login `walikota`
2. Lihat dashboard di sidebar (statistik & grafik bar)
3. Klik kartu untuk drill-down detail
4. Tab **Keluarga Miskin** → filter & **Ekspor Excel/PDF**
5. Tab **Laporan** → pantau laporan masuk (read-only untuk kelola)
---
## 7. Antarmuka Pengguna
| Area | Fungsi |
|------|--------|
| **Topbar** | Brand, navigasi tab (RI / KK / Laporan), info user, login/logout |
| **Peta (kiri)** | Visualisasi spasial, pick lokasi, zoom ke entitas |
| **Sidebar (kanan)** | Daftar data, filter, legenda, statistik, tombol tambah/ekspor |
| **Modal** | Form CRUD, login, laporan, bantuan, konfirmasi hapus, dashboard drill-down |
| **FAB** | Tombol mengambang laporan masyarakat |
| **Toast** | Notifikasi sukses/error |
**Responsif:** Di layar ≤768px, sidebar menjadi panel bawah setengah layar.
---
## 8. Kebutuhan Non-Fungsional (untuk SRS)
| Aspek | Spesifikasi |
|-------|-------------|
| **Keamanan** | Password bcrypt, session-based auth, role-based access control, sanitasi input, prepared statements PDO |
| **Ketersediaan data** | UTF-8 (`utf8mb4`), format JSON standar `{success, data, message}` |
| **Performa** | Single-page, data dimuat via AJAX paralel |
| **Batasan upload** | Foto laporan maks 5MB |
| **Rate limiting** | 5 laporan/jam/IP |
| **Kompatibilitas** | Browser modern dengan JavaScript; butuh koneksi internet (CDN peta & library) |
| **Deployment** | PHP 7.4+, MySQL 5.7+, web server Apache/Nginx |
---
## 9. Kerangka Dokumen SRS
1. **Pendahuluan** — latar belakang, tujuan, ruang lingkup, definisi (DTKS, KK, RI, WebGIS)
2. **Deskripsi Umum** — perspektif produk, fungsi produk, karakteristik pengguna
3. **Kebutuhan Fungsional** — per modul (FR-01 s/d FR-XX)
4. **Kebutuhan Data** — entitas & atribut dari database
5. **Kebutuhan Antarmuka** — layar peta, sidebar, modal, dashboard
6. **Kebutuhan Non-Fungsional** — keamanan, performa, reliabilitas
7. **Aturan Bisnis** — auto-assign Haversine, eligible bansos tunai, duplikasi bantuan, rate limit
8. **Matriks Hak Akses** — tabel role
9. **Integrasi Eksternal** — OpenStreetMap, Nominatim, CDN library
10. **Lampiran** — ERD, diagram alur, mockup UI
---
## 10. Kerangka Manual Book
**Bab 1 — Pengenalan**
Tentang sistem, siapa penggunanya, persyaratan (browser, Laragon/URL)
**Bab 2 — Akses & Login**
Login admin, walikota, pengurus (contoh kredensial demo)
**Bab 3 — Panduan Masyarakat**
Cara melihat peta, mengirim laporan, batas 5 laporan/jam
**Bab 4 — Panduan Admin**
Kelola rumah ibadah, KK, laporan, verifikasi & konversi ke KK
**Bab 5 — Panduan Pengurus Rumah Ibadah**
Login kode RI, input KK binaan, pencatatan bantuan
**Bab 6 — Panduan Walikota**
Dashboard, drill-down, ekspor data, tab laporan
**Bab 7 — Fitur Peta**
Navigasi, legenda, zoom, pick lokasi
**Bab 8 — Ekspor Laporan**
Excel & PDF (kolom yang diekspor)
**Bab 9 — Troubleshooting**
Gagal login, rate limit, KK di luar radius, koneksi database
---
## 11. Data Demo (untuk Testing)
| Akun | Username | Password |
|------|----------|----------|
| Admin | `admin` | `admin123` |
| Walikota | `walikota` | `admin123` |
| Pengurus Masjid Raya | `RI-MSJ-4821` | `pengurus123` |
| Pengurus Gereja | `RI-GRJ-7153` | `pengurus123` |
Database seed berisi: 2 rumah ibadah, 5 keluarga miskin, 1 laporan, 2 histori bantuan.
---
## 12. Ringkasan Nilai Sistem
Sistem ini menggabungkan **GIS**, **manajemen data sosial**, **partisipasi masyarakat**, dan **dashboard kebijakan** dalam satu aplikasi web. Konsep intinya: **rumah ibadah sebagai node pembina** dengan cakupan geografis terukur, sehingga program pengentasan kemiskinan dapat dipantau secara spasial dan berbasis data bantuan per keluarga.
---
*Dokumen ini dibuat sebagai bahan penyusunan SRS (Software Requirements Specification) dan Manual Book untuk WebGIS Pontianak.*
+111
View File
@@ -0,0 +1,111 @@
<?php
// ============================================================
// API: Autentikasi (Login / Logout / Cek Session)
// Mendukung 3 role: admin, pengurus, walikota
// ============================================================
require_once __DIR__ . '/config.php';
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
// ── GET: Cek session aktif ───────────────────────────────────
if ($method === 'GET' && $action === 'check') {
if (!empty($_SESSION['user'])) {
jsonResponse($_SESSION['user'], 'Session aktif.');
} else {
jsonResponse(null, 'Belum login.', false);
}
}
// ── POST: Login ──────────────────────────────────────────────
if ($method === 'POST' && $action === 'login') {
// Ambil data dari body JSON
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$username = trim($body['username'] ?? '');
$password = trim($body['password'] ?? '');
if (empty($username)) {
jsonResponse(null, 'Username wajib diisi.', false, 400);
}
// Cek apakah login sebagai pengurus (format kode_pengurus: RI-XXX-9999)
$isPengurusLogin = preg_match('/^RI-[A-Z]{3}-\d{4}$/', $username);
if ($isPengurusLogin) {
// ── Login pengurus via kode_pengurus di tabel rumah_ibadah ──
$stmt = $pdo->prepare("
SELECT id, kode_pengurus, password_pengurus, nama, jenis
FROM rumah_ibadah
WHERE kode_pengurus = ?
LIMIT 1
");
$stmt->execute([$username]);
$ri = $stmt->fetch();
if (!$ri) {
jsonResponse(null, 'Kode pengurus tidak ditemukan.', false, 401);
}
if (empty($password)) {
jsonResponse(null, 'Password pengurus wajib diisi.', false, 400);
}
if (!password_verify($password, $ri['password_pengurus'])) {
jsonResponse(null, 'Password pengurus salah.', false, 401);
}
// Set session sebagai pengurus
$_SESSION['user'] = [
'id' => 0, // Pengurus tidak punya ID di tabel users
'nama' => 'Pengurus ' . $ri['nama'],
'username' => $ri['kode_pengurus'],
'role' => 'pengurus',
'rumah_ibadah_id' => (int)$ri['id'],
];
jsonResponse($_SESSION['user'], 'Login berhasil sebagai pengurus.');
} else {
// ── Login admin / walikota via tabel users ──
$stmt = $pdo->prepare("
SELECT id, nama, username, password, role, rumah_ibadah_id
FROM users
WHERE username = ?
LIMIT 1
");
$stmt->execute([$username]);
$user = $stmt->fetch();
if (!$user) {
jsonResponse(null, 'Username tidak ditemukan.', false, 401);
}
if (empty($password)) {
jsonResponse(null, 'Password wajib diisi.', false, 400);
}
if (!password_verify($password, $user['password'])) {
jsonResponse(null, 'Password salah.', false, 401);
}
// Set session
$_SESSION['user'] = [
'id' => (int)$user['id'],
'nama' => $user['nama'],
'username' => $user['username'],
'role' => $user['role'],
'rumah_ibadah_id' => $user['rumah_ibadah_id'] ? (int)$user['rumah_ibadah_id'] : null,
];
jsonResponse($_SESSION['user'], 'Login berhasil sebagai ' . $user['role'] . '.');
}
}
// ── POST: Logout ─────────────────────────────────────────────
if ($method === 'POST' && $action === 'logout') {
session_destroy();
jsonResponse(null, 'Berhasil logout.');
}
// ── Endpoint tidak valid ─────────────────────────────────────
jsonResponse(null, 'Endpoint tidak valid.', false, 404);
+148
View File
@@ -0,0 +1,148 @@
<?php
// ============================================================
// API: Dashboard Walikota — Data agregasi read-only
// GET: walikota + admin only
// ============================================================
require_once __DIR__ . '/config.php';
$method = $_SERVER['REQUEST_METHOD'];
if ($method !== 'GET') {
jsonResponse(null, 'Hanya metode GET yang didukung.', false, 405);
}
$user = requireRole(['admin', 'walikota']);
$tahun = isset($_GET['tahun']) ? (int)$_GET['tahun'] : (int)date('Y');
// ── 1. Total KK per kecamatan ─────────────────────────────────
$stmt = $pdo->query("
SELECT kecamatan, COUNT(*) AS jumlah
FROM keluarga_miskin
GROUP BY kecamatan
ORDER BY FIELD(kecamatan,
'Pontianak Utara','Pontianak Selatan','Pontianak Timur',
'Pontianak Barat','Pontianak Tenggara','Pontianak Kota')
");
$kkPerKecamatan = $stmt->fetchAll();
// ── 2. KK yang SUDAH dapat bantuan tahun ini (dari histori_bantuan) ──
$stmt = $pdo->prepare("
SELECT COUNT(DISTINCT kk_id) AS jumlah
FROM histori_bantuan
WHERE YEAR(tanggal_penyaluran) = ?
");
$stmt->execute([$tahun]);
$kkSudahBantuan = (int)$stmt->fetch()['jumlah'];
// ── 3. Total KK ───────────────────────────────────────────────
$stmt = $pdo->query("SELECT COUNT(*) AS jumlah FROM keluarga_miskin");
$totalKK = (int)$stmt->fetch()['jumlah'];
$kkBelumBantuan = $totalKK - $kkSudahBantuan;
// ── 4. Distribusi status DTKS ────────────────────────────────
$stmt = $pdo->query("
SELECT status_dtks, COUNT(*) AS jumlah
FROM keluarga_miskin
GROUP BY status_dtks
");
$distribusiDtks = $stmt->fetchAll();
// ── 5. Distribusi riwayat penyakit ───────────────────────────
$stmt = $pdo->query("SELECT riwayat_penyakit FROM keluarga_miskin WHERE riwayat_penyakit IS NOT NULL AND riwayat_penyakit != '[]'");
$penyakitRows = $stmt->fetchAll();
$penyakitCount = [];
foreach ($penyakitRows as $row) {
$arr = json_decode($row['riwayat_penyakit'], true) ?? [];
foreach ($arr as $p) {
$penyakitCount[$p] = ($penyakitCount[$p] ?? 0) + 1;
}
}
arsort($penyakitCount);
// Format jadi array [{ penyakit, jumlah }]
$distribusiPenyakit = array_map(
fn($k, $v) => ['penyakit' => $k, 'jumlah' => $v],
array_keys($penyakitCount),
array_values($penyakitCount)
);
// ── 6. Laporan belum ditangani ────────────────────────────────
$stmt = $pdo->query("SELECT COUNT(*) AS jumlah FROM laporan_masyarakat WHERE status = 'Baru'");
$laporanBaru = (int)$stmt->fetch()['jumlah'];
// ── 7. Bantuan tersalurkan tahun ini per sumber dana ─────────
$stmt = $pdo->prepare("
SELECT sumber_dana,
COUNT(*) AS jumlah_entri,
COUNT(DISTINCT kk_id) AS jumlah_kk,
COALESCE(SUM(nominal), 0) AS total_nominal
FROM histori_bantuan
WHERE YEAR(tanggal_penyaluran) = ?
GROUP BY sumber_dana
ORDER BY total_nominal DESC
");
$stmt->execute([$tahun]);
$bantuanPerSumber = $stmt->fetchAll();
// Format nominal di setiap baris
foreach ($bantuanPerSumber as &$b) {
$b['total_nominal_formatted'] = 'Rp ' . number_format((int)$b['total_nominal'], 0, ',', '.');
}
unset($b);
// ── 8. Bantuan tersalurkan tahun ini per jenis ───────────────
$stmt = $pdo->prepare("
SELECT jenis_bantuan,
COUNT(*) AS jumlah_entri,
COUNT(DISTINCT kk_id) AS jumlah_kk,
COALESCE(SUM(nominal), 0) AS total_nominal
FROM histori_bantuan
WHERE YEAR(tanggal_penyaluran) = ?
GROUP BY jenis_bantuan
ORDER BY total_nominal DESC
");
$stmt->execute([$tahun]);
$bantuanPerJenis = $stmt->fetchAll();
foreach ($bantuanPerJenis as &$b) {
$b['total_nominal_formatted'] = 'Rp ' . number_format((int)$b['total_nominal'], 0, ',', '.');
}
unset($b);
// ── 9. Total entri bantuan + total nominal tahun ini ─────────
$stmt = $pdo->prepare("
SELECT COUNT(*) AS jumlah, COALESCE(SUM(nominal), 0) AS total_nominal
FROM histori_bantuan WHERE YEAR(tanggal_penyaluran) = ?
");
$stmt->execute([$tahun]);
$totalRow = $stmt->fetch();
$totalEntri = (int)$totalRow['jumlah'];
$totalNominal = (int)$totalRow['total_nominal'];
$totalNominalFormatted = 'Rp ' . number_format($totalNominal, 0, ',', '.');
// ── 10. Distribusi tingkat kemiskinan ────────────────────────
$stmt = $pdo->query("
SELECT tingkat_kemiskinan, COUNT(*) AS jumlah
FROM keluarga_miskin
GROUP BY tingkat_kemiskinan
");
$distribusiKemiskinan = $stmt->fetchAll();
// ── Kirim semua data ──────────────────────────────────────────
jsonResponse([
'tahun' => $tahun,
'total_kk' => $totalKK,
'kk_per_kecamatan' => $kkPerKecamatan,
'kk_sudah_bantuan' => $kkSudahBantuan,
'kk_belum_bantuan' => $kkBelumBantuan,
'distribusi_dtks' => $distribusiDtks,
'distribusi_penyakit' => $distribusiPenyakit,
'distribusi_kemiskinan' => $distribusiKemiskinan,
'laporan_baru' => $laporanBaru,
'bantuan_per_sumber' => $bantuanPerSumber,
'bantuan_per_jenis' => $bantuanPerJenis,
'total_entri_bantuan' => $totalEntri,
'total_nominal_bantuan' => $totalNominal,
'total_nominal_formatted'=> $totalNominalFormatted,
], "Data dashboard tahun $tahun.");
@@ -0,0 +1,214 @@
<?php
// ============================================================
// API: Histori Bantuan
// GET: publik | POST: admin+pengurus | DELETE: admin
// ============================================================
require_once __DIR__ . '/config.php';
$method = $_SERVER['REQUEST_METHOD'];
// Blokir aksi tulis walikota
blockWalikotaWrite();
// Helper: format angka ke Rupiah Indonesia
function formatRupiah(int $nominal): string {
return 'Rp ' . number_format($nominal, 0, ',', '.');
}
// ── GET: Ambil histori bantuan ──────────────────────────────
if ($method === 'GET') {
$kk_id = isset($_GET['kk_id']) ? (int)$_GET['kk_id'] : null;
$jenis = clean($_GET['jenis_bantuan'] ?? '');
$sumber = clean($_GET['sumber_dana'] ?? '');
$tahun = isset($_GET['tahun']) ? (int)$_GET['tahun'] : null;
// ── Mode BULK: filter per jenis atau sumber (untuk drilldown walikota) ──
if (!$kk_id && ($jenis || $sumber)) {
requireRole(['admin', 'walikota']);
$sql = "
SELECT hb.*, km.nama_kepala_keluarga, km.kecamatan, km.nik, km.alamat AS alamat_kk,
u.nama AS nama_penanggung_jawab
FROM histori_bantuan hb
JOIN keluarga_miskin km ON hb.kk_id = km.id
LEFT JOIN users u ON hb.penanggung_jawab = u.id
WHERE 1=1
";
$params = [];
if ($jenis) { $sql .= " AND hb.jenis_bantuan = ?"; $params[] = $jenis; }
if ($sumber) { $sql .= " AND hb.sumber_dana = ?"; $params[] = $sumber; }
if ($tahun) { $sql .= " AND YEAR(hb.tanggal_penyaluran) = ?"; $params[] = $tahun; }
$sql .= " ORDER BY hb.tanggal_penyaluran DESC";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll();
$totalNominal = 0;
foreach ($rows as &$r) {
$r['nominal_formatted'] = formatRupiah((int)$r['nominal']);
$totalNominal += (int)$r['nominal'];
}
unset($r);
jsonResponse($rows, count($rows) . ' data bantuan ditemukan.', true, 200, [
'total_nominal' => $totalNominal,
'total_nominal_formatted' => formatRupiah($totalNominal),
]);
}
// ── Mode PER-KK (existing) ──────────────────────────────
if (!$kk_id) {
jsonResponse(null, 'Parameter kk_id wajib diisi.', false, 400);
}
$stmt = $pdo->prepare("
SELECT hb.*,
u.nama AS nama_penanggung_jawab
FROM histori_bantuan hb
LEFT JOIN users u ON hb.penanggung_jawab = u.id
WHERE hb.kk_id = ?
ORDER BY hb.tanggal_penyaluran DESC
");
$stmt->execute([$kk_id]);
$rows = $stmt->fetchAll();
foreach ($rows as &$r) {
$r['nominal_formatted'] = formatRupiah((int)$r['nominal']);
}
unset($r);
$stmtTotal = $pdo->prepare("SELECT COALESCE(SUM(nominal), 0) AS total FROM histori_bantuan WHERE kk_id = ?");
$stmtTotal->execute([$kk_id]);
$totalNominal = (int)$stmtTotal->fetch()['total'];
jsonResponse($rows, 'Histori bantuan untuk KK ID ' . $kk_id . '.', true, 200, [
'total_bantuan' => $totalNominal,
'total_bantuan_formatted' => formatRupiah($totalNominal),
]);
}
// ── POST: Tambah histori bantuan (admin + pengurus) ──────────
if ($method === 'POST') {
$user = requireRole(['admin', 'pengurus']);
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$kk_id = (int)($body['kk_id'] ?? 0);
$jenis_bantuan = clean($body['jenis_bantuan'] ?? '');
$nominal = (int)($body['nominal'] ?? 0);
$keterangan = clean($body['keterangan'] ?? '');
$tanggal_penyaluran = clean($body['tanggal_penyaluran'] ?? '');
$sumber_dana = clean($body['sumber_dana'] ?? 'APBD');
if (!$kk_id || !$jenis_bantuan || !$tanggal_penyaluran) {
jsonResponse(null, 'KK ID, jenis bantuan, dan tanggal penyaluran wajib diisi.', false, 400);
}
if ($nominal < 0) {
jsonResponse(null, 'Nominal tidak boleh negatif.', false, 400);
}
// Validasi jenis_bantuan
$validJenis = ['PKH', 'BPNT/Sembako', 'Beras Bansos', 'KIS-PBI', 'Bansos Tunai Pemkot', 'Rutilahu', 'PMT Stunting', 'CSR Swasta', 'Lainnya'];
if (!in_array($jenis_bantuan, $validJenis)) {
jsonResponse(null, 'Jenis bantuan tidak valid.', false, 400);
}
// Validasi sumber_dana
$validSumber = ['APBN', 'APBD', 'CSR', 'Lainnya'];
if (!in_array($sumber_dana, $validSumber)) {
jsonResponse(null, 'Sumber dana tidak valid.', false, 400);
}
// Cek apakah KK ada
$stmt = $pdo->prepare("SELECT id, rumah_ibadah_id FROM keluarga_miskin WHERE id = ? LIMIT 1");
$stmt->execute([$kk_id]);
$kk = $stmt->fetch();
if (!$kk) {
jsonResponse(null, 'Data keluarga miskin tidak ditemukan.', false, 404);
}
// Pengurus hanya bisa tambah bantuan untuk KK binaan miliknya
if ($user['role'] === 'pengurus') {
if ((int)($kk['rumah_ibadah_id'] ?? 0) !== (int)$user['rumah_ibadah_id']) {
jsonResponse(null, 'Anda hanya dapat menambah bantuan untuk KK binaan rumah ibadah Anda.', false, 403);
}
}
// Cek duplikasi bantuan: PKH, BPNT/Sembako, Beras Bansos
$duplikasiWarning = false;
$bantuanPrioritas = ['PKH', 'BPNT/Sembako', 'Beras Bansos'];
if (in_array($jenis_bantuan, $bantuanPrioritas)) {
$placeholders = implode(',', array_fill(0, count($bantuanPrioritas), '?'));
$stmt = $pdo->prepare("
SELECT COUNT(*) AS cnt
FROM histori_bantuan
WHERE kk_id = ? AND jenis_bantuan IN ({$placeholders})
");
$stmt->execute(array_merge([$kk_id], $bantuanPrioritas));
$cnt = (int)$stmt->fetch()['cnt'];
if ($cnt > 0) {
$duplikasiWarning = true;
}
}
// Tentukan penanggung_jawab
$penanggungJawab = $user['id'];
if ($penanggungJawab === 0) {
$stAdmin = $pdo->query("SELECT id FROM users WHERE role = 'admin' LIMIT 1");
$admin = $stAdmin->fetch();
$penanggungJawab = $admin ? (int)$admin['id'] : 1;
}
// Simpan bantuan
$stmt = $pdo->prepare("
INSERT INTO histori_bantuan
(kk_id, jenis_bantuan, nominal, keterangan, tanggal_penyaluran, sumber_dana, penanggung_jawab)
VALUES (?, ?, ?, ?, ?, ?, ?)
");
$stmt->execute([
$kk_id, $jenis_bantuan, $nominal,
$keterangan ?: null,
$tanggal_penyaluran, $sumber_dana, $penanggungJawab,
]);
$newId = (int)$pdo->lastInsertId();
$message = 'Histori bantuan berhasil ditambahkan.';
if ($duplikasiWarning) {
$message .= ' PERHATIAN: KK ini sudah memiliki bantuan serupa (PKH/BPNT/Beras Bansos).';
}
jsonResponse(
['id' => $newId],
$message,
true,
200,
['duplikasi_warning' => $duplikasiWarning]
);
}
// ── DELETE: Hapus histori bantuan (admin only) ───────────────
if ($method === 'DELETE') {
$user = requireRole(['admin']);
$id = (int)($_GET['id'] ?? 0);
if (!$id) {
jsonResponse(null, 'ID histori bantuan wajib diisi.', false, 400);
}
$stmt = $pdo->prepare("DELETE FROM histori_bantuan WHERE id = ?");
$stmt->execute([$id]);
if ($stmt->rowCount() === 0) {
jsonResponse(null, 'Data histori bantuan tidak ditemukan.', false, 404);
}
jsonResponse(null, 'Histori bantuan berhasil dihapus.');
}
// ── Method tidak didukung ────────────────────────────────────
jsonResponse(null, 'Method tidak didukung.', false, 405);
@@ -0,0 +1,285 @@
<?php
// ============================================================
// API: Keluarga Miskin (CRUD + Filter + Auto-assign)
// GET: publik | POST: admin+pengurus | PUT: admin+pengurus | DELETE: admin
// ============================================================
require_once __DIR__ . '/config.php';
$method = $_SERVER['REQUEST_METHOD'];
// Blokir aksi tulis walikota
blockWalikotaWrite();
// ── GET: Ambil semua (dengan filter) atau satu KK ────────────
if ($method === 'GET') {
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
if ($id) {
// Ambil satu KK beserta info rumah ibadah terkait
$stmt = $pdo->prepare("
SELECT km.*, ri.nama AS nama_ri, ri.jenis AS jenis_ri, ri.kode_pengurus,
COALESCE((SELECT SUM(hb.nominal) FROM histori_bantuan hb WHERE hb.kk_id = km.id), 0) AS total_bantuan
FROM keluarga_miskin km
LEFT JOIN rumah_ibadah ri ON km.rumah_ibadah_id = ri.id
WHERE km.id = ?
");
$stmt->execute([$id]);
$row = $stmt->fetch();
if (!$row) {
jsonResponse(null, 'Data keluarga miskin tidak ditemukan.', false, 404);
}
// Cek akses pengurus — hanya boleh lihat KK binaan miliknya
$sess = $_SESSION['user'] ?? null;
if ($sess && $sess['role'] === 'pengurus') {
if ((int)($row['rumah_ibadah_id'] ?? 0) !== (int)$sess['rumah_ibadah_id']) {
jsonResponse(null, 'Akses ditolak. KK ini bukan binaan Anda.', false, 403);
}
}
// Decode riwayat_penyakit JSON
$row['riwayat_penyakit'] = json_decode($row['riwayat_penyakit'] ?? '[]', true);
// Flag eligible_bansos_tunai: hanya untuk KK dengan status_dtks = 'Belum DTKS'
$row['eligible_bansos_tunai'] = ($row['status_dtks'] === 'Belum DTKS');
// Format total_bantuan
$row['total_bantuan_formatted'] = 'Rp ' . number_format((int)$row['total_bantuan'], 0, ',', '.');
jsonResponse($row, 'Data keluarga miskin ditemukan.');
}
// ── Ambil semua dengan filter ──
$where = [];
$params = [];
// Filter berdasarkan role pengurus
$sess = $_SESSION['user'] ?? null;
if ($sess && $sess['role'] === 'pengurus' && !empty($sess['rumah_ibadah_id'])) {
$where[] = 'km.rumah_ibadah_id = ?';
$params[] = (int)$sess['rumah_ibadah_id'];
}
// Filter kecamatan
if (!empty($_GET['kecamatan'])) {
$where[] = 'km.kecamatan = ?';
$params[] = clean($_GET['kecamatan']);
}
// Filter tingkat_kemiskinan
if (!empty($_GET['tingkat_kemiskinan'])) {
$where[] = 'km.tingkat_kemiskinan = ?';
$params[] = clean($_GET['tingkat_kemiskinan']);
}
// Filter status_dtks
if (!empty($_GET['status_dtks'])) {
$where[] = 'km.status_dtks = ?';
$params[] = clean($_GET['status_dtks']);
}
// Filter rumah_ibadah_id
if (isset($_GET['rumah_ibadah_id']) && $_GET['rumah_ibadah_id'] !== '') {
$where[] = 'km.rumah_ibadah_id = ?';
$params[] = (int)$_GET['rumah_ibadah_id'];
}
$sql = "
SELECT km.*, ri.nama AS nama_ri, ri.jenis AS jenis_ri,
COALESCE((SELECT SUM(hb.nominal) FROM histori_bantuan hb WHERE hb.kk_id = km.id), 0) AS total_bantuan
FROM keluarga_miskin km
LEFT JOIN rumah_ibadah ri ON km.rumah_ibadah_id = ri.id
";
if (!empty($where)) {
$sql .= ' WHERE ' . implode(' AND ', $where);
}
$sql .= ' ORDER BY km.nama_kepala_keluarga ASC';
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll();
// Proses setiap baris: decode JSON + flag eligible + format total
foreach ($rows as &$r) {
$r['riwayat_penyakit'] = json_decode($r['riwayat_penyakit'] ?? '[]', true);
$r['eligible_bansos_tunai'] = ($r['status_dtks'] === 'Belum DTKS');
$r['total_bantuan_formatted'] = 'Rp ' . number_format((int)$r['total_bantuan'], 0, ',', '.');
}
jsonResponse($rows, 'Daftar keluarga miskin.', true, 200);
}
// ── POST: Tambah KK (admin + pengurus) ───────────────────────
if ($method === 'POST') {
$user = requireRole(['admin', 'pengurus']);
$body = json_decode(file_get_contents('php://input'), true) ?? [];
// Validasi input
$nik = clean($body['nik'] ?? '');
$nama_kepala_keluarga = clean($body['nama_kepala_keluarga'] ?? '');
$tanggal_lahir = clean($body['tanggal_lahir'] ?? '');
$pendidikan = clean($body['pendidikan'] ?? 'Tidak Sekolah');
$jumlah_anggota = (int)($body['jumlah_anggota'] ?? 1);
$alamat = clean($body['alamat'] ?? '');
$kecamatan = clean($body['kecamatan'] ?? '');
$lat = (float)($body['lat'] ?? 0);
$lng = (float)($body['lng'] ?? 0);
$tingkat_kemiskinan = clean($body['tingkat_kemiskinan'] ?? 'Miskin');
$status_dtks = clean($body['status_dtks'] ?? 'Belum DTKS');
$riwayat_penyakit = $body['riwayat_penyakit'] ?? [];
if (!$nik || !$nama_kepala_keluarga || !$alamat || !$kecamatan || !$lat || !$lng) {
jsonResponse(null, 'NIK, nama KK, alamat, kecamatan, dan koordinat wajib diisi.', false, 400);
}
// Cek NIK unik
$stmt = $pdo->prepare("SELECT id FROM keluarga_miskin WHERE nik = ? LIMIT 1");
$stmt->execute([$nik]);
if ($stmt->fetch()) {
jsonResponse(null, 'NIK sudah terdaftar dalam sistem.', false, 400);
}
// Auto-assign ke rumah ibadah terdekat dalam radius
$assign = autoAssign($pdo, $lat, $lng);
$riId = $assign['rumah_ibadah_id'];
$jarakKm = $assign['jarak_km'];
$riwayatJson = json_encode(is_array($riwayat_penyakit) ? $riwayat_penyakit : []);
// Pengurus: paksa assign ke RI miliknya (jika dalam radius)
if ($user['role'] === 'pengurus') {
$riId = (int)$user['rumah_ibadah_id'];
// Hitung jarak ke RI miliknya
$stRi = $pdo->prepare("SELECT lat, lng FROM rumah_ibadah WHERE id = ?");
$stRi->execute([$riId]);
$riData = $stRi->fetch();
$jarakKm = $riData ? haversine($lat, $lng, (float)$riData['lat'], (float)$riData['lng']) : null;
}
$stmt = $pdo->prepare("
INSERT INTO keluarga_miskin
(nik, nama_kepala_keluarga, tanggal_lahir, pendidikan, jumlah_anggota,
alamat, kecamatan, lat, lng, tingkat_kemiskinan, status_dtks,
riwayat_penyakit, rumah_ibadah_id, jarak_ke_ibadah)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
");
$stmt->execute([
$nik, $nama_kepala_keluarga,
$tanggal_lahir ?: null, $pendidikan, $jumlah_anggota,
$alamat, $kecamatan, $lat, $lng,
$tingkat_kemiskinan, $status_dtks,
$riwayatJson, $riId, $jarakKm
]);
$newId = (int)$pdo->lastInsertId();
$assignMsg = $riId
? "Auto-assign ke rumah ibadah (jarak: {$jarakKm} km)."
: "Tidak ada rumah ibadah dalam radius cakupan.";
jsonResponse([
'id' => $newId,
'rumah_ibadah_id' => $riId,
'jarak_km' => $jarakKm,
], 'Data keluarga miskin berhasil ditambahkan. ' . $assignMsg);
}
// ── PUT: Update KK ───────────────────────────────────────────
if ($method === 'PUT') {
$user = requireRole(['admin', 'pengurus']);
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$id = (int)($body['id'] ?? 0);
if (!$id) {
jsonResponse(null, 'ID keluarga miskin wajib diisi.', false, 400);
}
// Cek data ada
$stmt = $pdo->prepare("SELECT rumah_ibadah_id FROM keluarga_miskin WHERE id = ? LIMIT 1");
$stmt->execute([$id]);
$existing = $stmt->fetch();
if (!$existing) {
jsonResponse(null, 'Data keluarga miskin tidak ditemukan.', false, 404);
}
// Pengurus hanya bisa edit KK dalam RI-nya
if ($user['role'] === 'pengurus') {
if ((int)($existing['rumah_ibadah_id'] ?? 0) !== (int)$user['rumah_ibadah_id']) {
jsonResponse(null, 'Anda hanya dapat mengedit KK binaan rumah ibadah Anda.', false, 403);
}
}
$nik = clean($body['nik'] ?? '');
$nama_kepala_keluarga = clean($body['nama_kepala_keluarga'] ?? '');
$tanggal_lahir = clean($body['tanggal_lahir'] ?? '');
$pendidikan = clean($body['pendidikan'] ?? 'Tidak Sekolah');
$jumlah_anggota = (int)($body['jumlah_anggota'] ?? 1);
$alamat = clean($body['alamat'] ?? '');
$kecamatan = clean($body['kecamatan'] ?? '');
$lat = (float)($body['lat'] ?? 0);
$lng = (float)($body['lng'] ?? 0);
$tingkat_kemiskinan = clean($body['tingkat_kemiskinan'] ?? 'Miskin');
$status_dtks = clean($body['status_dtks'] ?? 'Belum DTKS');
$riwayat_penyakit = $body['riwayat_penyakit'] ?? [];
if (!$nik || !$nama_kepala_keluarga || !$alamat || !$kecamatan || !$lat || !$lng) {
jsonResponse(null, 'Semua field wajib diisi.', false, 400);
}
// Cek NIK unik (kecuali milik sendiri)
$stmt = $pdo->prepare("SELECT id FROM keluarga_miskin WHERE nik = ? AND id != ? LIMIT 1");
$stmt->execute([$nik, $id]);
if ($stmt->fetch()) {
jsonResponse(null, 'NIK sudah digunakan oleh KK lain.', false, 400);
}
// Re-calculate auto-assign
$assign = autoAssign($pdo, $lat, $lng);
$riId = $assign['rumah_ibadah_id'];
$jarakKm = $assign['jarak_km'];
$riwayatJson = json_encode(is_array($riwayat_penyakit) ? $riwayat_penyakit : []);
$stmt = $pdo->prepare("
UPDATE keluarga_miskin SET
nik = ?, nama_kepala_keluarga = ?, tanggal_lahir = ?, pendidikan = ?,
jumlah_anggota = ?, alamat = ?, kecamatan = ?, lat = ?, lng = ?,
tingkat_kemiskinan = ?, status_dtks = ?, riwayat_penyakit = ?,
rumah_ibadah_id = ?, jarak_ke_ibadah = ?
WHERE id = ?
");
$stmt->execute([
$nik, $nama_kepala_keluarga,
$tanggal_lahir ?: null, $pendidikan, $jumlah_anggota,
$alamat, $kecamatan, $lat, $lng,
$tingkat_kemiskinan, $status_dtks, $riwayatJson,
$riId, $jarakKm, $id
]);
jsonResponse([
'rumah_ibadah_id' => $riId,
'jarak_km' => $jarakKm,
], 'Data keluarga miskin berhasil diperbarui.');
}
// ── DELETE: Hapus KK (admin only) ────────────────────────────
if ($method === 'DELETE') {
$user = requireRole(['admin']);
$id = (int)($_GET['id'] ?? 0);
if (!$id) {
jsonResponse(null, 'ID keluarga miskin wajib diisi.', false, 400);
}
$stmt = $pdo->prepare("DELETE FROM keluarga_miskin WHERE id = ?");
$stmt->execute([$id]);
if ($stmt->rowCount() === 0) {
jsonResponse(null, 'Data tidak ditemukan.', false, 404);
}
jsonResponse(null, 'Data keluarga miskin berhasil dihapus.');
}
// ── Method tidak didukung ────────────────────────────────────
jsonResponse(null, 'Method tidak didukung.', false, 405);
+166
View File
@@ -0,0 +1,166 @@
<?php
// ============================================================
// API: Laporan Masyarakat
// POST: publik (tanpa login, rate-limited) | GET: admin+walikota | PUT: admin
// ============================================================
require_once __DIR__ . '/config.php';
$method = $_SERVER['REQUEST_METHOD'];
// ── POST: Submit laporan baru (tanpa auth, dengan rate limit) ─
if ($method === 'POST') {
// Rate limit: maks 5 submit per jam per IP
$ip = getClientIP();
if (!checkRateLimit($pdo, $ip)) {
jsonResponse(null, 'Anda telah mencapai batas maksimal 5 laporan per jam. Silakan coba lagi nanti.', false, 429);
}
// Ambil data dari form multipart (karena ada file upload)
$nama_terlapor = clean($_POST['nama_terlapor'] ?? '');
$nik_terlapor = clean($_POST['nik_terlapor'] ?? '');
$nama_pelapor = clean($_POST['nama_pelapor'] ?? '');
$no_hp = clean($_POST['no_hp'] ?? '');
$alamat_terlapor = clean($_POST['alamat_terlapor'] ?? '');
$lat = (float)($_POST['lat'] ?? 0);
$lng = (float)($_POST['lng'] ?? 0);
$deskripsi = clean($_POST['deskripsi'] ?? '');
if (!$alamat_terlapor || !$lat || !$lng || !$deskripsi) {
jsonResponse(null, 'Alamat, koordinat, dan deskripsi wajib diisi.', false, 400);
}
// Validasi NIK jika diisi (harus 16 digit angka)
if ($nik_terlapor && !preg_match('/^\d{16}$/', $nik_terlapor)) {
jsonResponse(null, 'NIK harus 16 digit angka.', false, 400);
}
// Handle upload foto (opsional)
$fotoPath = handleUpload('foto');
// Simpan laporan
$stmt = $pdo->prepare("
INSERT INTO laporan_masyarakat
(nama_terlapor, nik_terlapor, nama_pelapor, no_hp, alamat_terlapor, lat, lng, deskripsi, foto, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'Baru')
");
$stmt->execute([
$nama_terlapor ?: null,
$nik_terlapor ?: null,
$nama_pelapor ?: null,
$no_hp ?: null,
$alamat_terlapor,
$lat, $lng,
$deskripsi,
$fotoPath
]);
$newId = (int)$pdo->lastInsertId();
jsonResponse(['id' => $newId], 'Laporan berhasil dikirim. Terima kasih atas partisipasi Anda.');
}
// ── GET: Daftar laporan (admin & walikota) ───────────────────
if ($method === 'GET') {
$user = requireRole(['admin', 'walikota']);
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
if ($id) {
$stmt = $pdo->prepare("
SELECT lm.*,
u.nama AS nama_penangan,
kk.nama_kepala_keluarga AS nama_kk_converted
FROM laporan_masyarakat lm
LEFT JOIN users u ON lm.ditangani_oleh = u.id
LEFT JOIN keluarga_miskin kk ON lm.converted_to_kk_id = kk.id
WHERE lm.id = ?
");
$stmt->execute([$id]);
$row = $stmt->fetch();
if (!$row) {
jsonResponse(null, 'Laporan tidak ditemukan.', false, 404);
}
jsonResponse($row, 'Detail laporan.');
}
// Filter status
$where = [];
$params = [];
if (!empty($_GET['status'])) {
$where[] = 'lm.status = ?';
$params[] = clean($_GET['status']);
}
$sql = "
SELECT lm.*,
u.nama AS nama_penangan,
kk.nama_kepala_keluarga AS nama_kk_converted
FROM laporan_masyarakat lm
LEFT JOIN users u ON lm.ditangani_oleh = u.id
LEFT JOIN keluarga_miskin kk ON lm.converted_to_kk_id = kk.id
";
if (!empty($where)) {
$sql .= ' WHERE ' . implode(' AND ', $where);
}
$sql .= ' ORDER BY lm.created_at DESC';
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll();
jsonResponse($rows, 'Daftar laporan masyarakat.');
}
// ── PUT: Update status laporan (admin only) ──────────────────
if ($method === 'PUT') {
$user = requireRole(['admin']);
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$id = (int)($body['id'] ?? 0);
$status = clean($body['status'] ?? '');
$alasan_tolak = clean($body['alasan_tolak'] ?? '');
$converted_to_kk = isset($body['converted_to_kk_id']) ? (int)$body['converted_to_kk_id'] : null;
if (!$id || !$status) {
jsonResponse(null, 'ID dan status wajib diisi.', false, 400);
}
$validStatus = ['Baru', 'Diverifikasi', 'Ditolak'];
if (!in_array($status, $validStatus)) {
jsonResponse(null, 'Status tidak valid. Pilih: Baru, Diverifikasi, atau Ditolak.', false, 400);
}
// Jika ditolak, wajib ada alasan
if ($status === 'Ditolak' && empty($alasan_tolak)) {
jsonResponse(null, 'Alasan penolakan wajib diisi.', false, 400);
}
// Update laporan
$stmt = $pdo->prepare("
UPDATE laporan_masyarakat SET
status = ?,
alasan_tolak = ?,
ditangani_oleh = ?,
converted_to_kk_id = ?
WHERE id = ?
");
$stmt->execute([
$status,
$status === 'Ditolak' ? $alasan_tolak : null,
$user['id'],
$status === 'Diverifikasi' ? $converted_to_kk : null,
$id
]);
if ($stmt->rowCount() === 0) {
jsonResponse(null, 'Laporan tidak ditemukan.', false, 404);
}
jsonResponse(null, 'Status laporan berhasil diperbarui ke: ' . $status);
}
// ── Method tidak didukung ────────────────────────────────────
jsonResponse(null, 'Method tidak didukung.', false, 405);
+234
View File
@@ -0,0 +1,234 @@
<?php
// ============================================================
// API: Rumah Ibadah (CRUD)
// GET: publik | POST: admin | PUT: admin+pengurus | DELETE: admin
// ============================================================
require_once __DIR__ . '/config.php';
$method = $_SERVER['REQUEST_METHOD'];
// Blokir aksi tulis walikota
blockWalikotaWrite();
// ── GET: Ambil semua atau satu rumah ibadah ──────────────────
if ($method === 'GET') {
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
if ($id) {
// Ambil satu rumah ibadah beserta jumlah KK binaan
$stmt = $pdo->prepare("
SELECT ri.*,
(SELECT COUNT(*) FROM keluarga_miskin km WHERE km.rumah_ibadah_id = ri.id) AS jumlah_kk_binaan
FROM rumah_ibadah ri
WHERE ri.id = ?
");
$stmt->execute([$id]);
$row = $stmt->fetch();
if (!$row) {
jsonResponse(null, 'Rumah ibadah tidak ditemukan.', false, 404);
}
// Sembunyikan password_pengurus hash dari response
unset($row['password_pengurus']);
jsonResponse($row, 'Data rumah ibadah ditemukan.');
}
// Ambil semua — pengurus hanya melihat miliknya
$sess = $_SESSION['user'] ?? null;
if ($sess && $sess['role'] === 'pengurus' && !empty($sess['rumah_ibadah_id'])) {
$stmt = $pdo->prepare("
SELECT ri.*,
(SELECT COUNT(*) FROM keluarga_miskin km WHERE km.rumah_ibadah_id = ri.id) AS jumlah_kk_binaan
FROM rumah_ibadah ri
WHERE ri.id = ?
ORDER BY ri.nama ASC
");
$stmt->execute([$sess['rumah_ibadah_id']]);
} else {
$stmt = $pdo->query("
SELECT ri.*,
(SELECT COUNT(*) FROM keluarga_miskin km WHERE km.rumah_ibadah_id = ri.id) AS jumlah_kk_binaan
FROM rumah_ibadah ri
ORDER BY ri.nama ASC
");
}
$rows = $stmt->fetchAll();
// Sembunyikan password hash, tapi tampilkan kode_pengurus
foreach ($rows as &$r) {
unset($r['password_pengurus']);
}
jsonResponse($rows, 'Daftar rumah ibadah.', true, 200);
}
// ── POST: Tambah rumah ibadah (admin only) ───────────────────
if ($method === 'POST') {
$user = requireRole(['admin']);
$body = json_decode(file_get_contents('php://input'), true) ?? [];
// Validasi input wajib
$nama = clean($body['nama'] ?? '');
$jenis = clean($body['jenis'] ?? '');
$alamat = clean($body['alamat'] ?? '');
$lat = (float)($body['lat'] ?? 0);
$lng = (float)($body['lng'] ?? 0);
$radius_km = (float)($body['radius_km'] ?? 2.0);
$kecamatan = clean($body['kecamatan'] ?? '');
if (!$nama || !$jenis || !$alamat || !$lat || !$lng || !$kecamatan) {
jsonResponse(null, 'Nama, jenis, alamat, koordinat, dan kecamatan wajib diisi.', false, 400);
}
// Validasi jenis
$validJenis = ['Masjid', 'Gereja', 'Pura', 'Vihara', 'Klenteng', 'Lainnya'];
if (!in_array($jenis, $validJenis)) {
jsonResponse(null, 'Jenis rumah ibadah tidak valid.', false, 400);
}
// Validasi kecamatan
$validKecamatan = ['Pontianak Utara', 'Pontianak Selatan', 'Pontianak Timur', 'Pontianak Barat', 'Pontianak Tenggara', 'Pontianak Kota'];
if (!in_array($kecamatan, $validKecamatan)) {
jsonResponse(null, 'Kecamatan tidak valid.', false, 400);
}
// Generate kode pengurus dan password
$kodePengurus = generateKodePengurus($pdo, $jenis);
$passPlain = substr(bin2hex(random_bytes(4)), 0, 8); // 8 karakter acak
$passHash = password_hash($passPlain, PASSWORD_DEFAULT);
// Simpan ke database
$stmt = $pdo->prepare("
INSERT INTO rumah_ibadah (nama, jenis, alamat, lat, lng, radius_km, kode_pengurus, password_pengurus, kecamatan)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
");
$stmt->execute([$nama, $jenis, $alamat, $lat, $lng, $radius_km, $kodePengurus, $passHash, $kecamatan]);
$newId = (int)$pdo->lastInsertId();
// Re-assign semua KK setelah RI baru ditambahkan
reassignAllKK($pdo);
jsonResponse([
'id' => $newId,
'kode_pengurus' => $kodePengurus,
'password_pengurus' => $passPlain,
], 'Rumah ibadah berhasil ditambahkan. Kode pengurus: ' . $kodePengurus);
}
// ── PUT: Update rumah ibadah ─────────────────────────────────
if ($method === 'PUT') {
$user = requireRole(['admin', 'pengurus']);
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$id = (int)($body['id'] ?? 0);
if (!$id) {
jsonResponse(null, 'ID rumah ibadah wajib diisi.', false, 400);
}
// Pengurus hanya bisa edit RI miliknya sendiri
if ($user['role'] === 'pengurus' && (int)$user['rumah_ibadah_id'] !== $id) {
jsonResponse(null, 'Anda hanya dapat mengedit rumah ibadah Anda sendiri.', false, 403);
}
$nama = clean($body['nama'] ?? '');
$jenis = clean($body['jenis'] ?? '');
$alamat = clean($body['alamat'] ?? '');
$lat = (float)($body['lat'] ?? 0);
$lng = (float)($body['lng'] ?? 0);
$radius_km = (float)($body['radius_km'] ?? 2.0);
$kecamatan = clean($body['kecamatan'] ?? '');
if (!$nama || !$jenis || !$alamat || !$lat || !$lng || !$kecamatan) {
jsonResponse(null, 'Semua field wajib diisi.', false, 400);
}
$stmt = $pdo->prepare("
UPDATE rumah_ibadah
SET nama = ?, jenis = ?, alamat = ?, lat = ?, lng = ?, radius_km = ?, kecamatan = ?
WHERE id = ?
");
$stmt->execute([$nama, $jenis, $alamat, $lat, $lng, $radius_km, $kecamatan, $id]);
// Re-assign semua KK setelah radius/posisi berubah
reassignAllKK($pdo);
jsonResponse(null, 'Rumah ibadah berhasil diperbarui.');
}
// ── DELETE: Hapus rumah ibadah (admin only) ──────────────────
if ($method === 'DELETE') {
$user = requireRole(['admin']);
$id = (int)($_GET['id'] ?? 0);
if (!$id) {
jsonResponse(null, 'ID rumah ibadah wajib diisi.', false, 400);
}
$stmt = $pdo->prepare("DELETE FROM rumah_ibadah WHERE id = ?");
$stmt->execute([$id]);
if ($stmt->rowCount() === 0) {
jsonResponse(null, 'Rumah ibadah tidak ditemukan.', false, 404);
}
// Re-assign semua KK setelah RI dihapus
reassignAllKK($pdo);
jsonResponse(null, 'Rumah ibadah berhasil dihapus.');
}
// ── Helper: Re-assign semua KK ke RI terdekat ────────────────
function reassignAllKK(PDO $pdo): void {
$rows = $pdo->query("SELECT id, lat, lng FROM keluarga_miskin")->fetchAll();
foreach ($rows as $kk) {
$assign = autoAssign($pdo, (float)$kk['lat'], (float)$kk['lng']);
if ($assign['rumah_ibadah_id'] !== null) {
$stmt = $pdo->prepare("UPDATE keluarga_miskin SET rumah_ibadah_id = ?, jarak_ke_ibadah = ? WHERE id = ?");
$stmt->execute([$assign['rumah_ibadah_id'], $assign['jarak_km'], $kk['id']]);
} else {
$stmt = $pdo->prepare("UPDATE keluarga_miskin SET rumah_ibadah_id = NULL, jarak_ke_ibadah = NULL WHERE id = ?");
$stmt->execute([$kk['id']]);
}
}
}
// ── PATCH: Reset password pengurus (admin only) ──────────────
if ($method === 'PATCH') {
$user = requireRole(['admin']);
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$id = (int)($body['id'] ?? 0);
if (!$id) {
jsonResponse(null, 'ID rumah ibadah wajib diisi.', false, 400);
}
// Pastikan RI ada
$stmt = $pdo->prepare("SELECT id, nama, kode_pengurus FROM rumah_ibadah WHERE id = ? LIMIT 1");
$stmt->execute([$id]);
$ri = $stmt->fetch();
if (!$ri) {
jsonResponse(null, 'Rumah ibadah tidak ditemukan.', false, 404);
}
// Generate password baru
$passPlain = substr(bin2hex(random_bytes(4)), 0, 8); // 8 karakter acak
$passHash = password_hash($passPlain, PASSWORD_DEFAULT);
$stmt = $pdo->prepare("UPDATE rumah_ibadah SET password_pengurus = ? WHERE id = ?");
$stmt->execute([$passHash, $id]);
jsonResponse([
'id' => $id,
'kode_pengurus' => $ri['kode_pengurus'],
'password_pengurus' => $passPlain,
], 'Password pengurus berhasil direset untuk: ' . $ri['nama']);
}
// ── Method tidak didukung ────────────────────────────────────
jsonResponse(null, 'Method tidak didukung.', false, 405);
+267
View File
@@ -0,0 +1,267 @@
<?php
// ============================================================
// Konfigurasi Koneksi Database & Fungsi Utilitas
// WebGIS Pontianak — Pengentasan Kemiskinan Berbasis Rumah Ibadah
// ============================================================
// Konfigurasi database
define('DB_HOST', 'localhost');
define('DB_NAME', 'webgis_rumah_ibadah');
define('DB_USER', 'root');
define('DB_PASS', '');
define('DB_CHARSET', 'utf8mb4');
// Direktori upload foto laporan
define('UPLOAD_DIR', __DIR__ . '/uploads/');
// Mulai session
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
// ── Koneksi PDO ──────────────────────────────────────────────
try {
$pdo = new PDO(
"mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=" . DB_CHARSET,
DB_USER,
DB_PASS,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
} catch (PDOException $e) {
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'success' => false,
'data' => null,
'message' => 'Koneksi database gagal: ' . $e->getMessage()
]);
exit;
}
// ── CORS Headers ─────────────────────────────────────────────
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
// Handle preflight request
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
// ============================================================
// FUNGSI UTILITAS
// ============================================================
/**
* Kirim respon JSON standar lalu hentikan eksekusi.
* Format: {"success": true/false, "data": ..., "message": "..."}
*/
function jsonResponse($data = null, string $message = '', bool $success = true, int $httpCode = 200, array $extra = []): void {
http_response_code($httpCode);
header('Content-Type: application/json; charset=utf-8');
$response = [
'success' => $success,
'data' => $data,
'message' => $message
];
// Gabungkan field tambahan (misal: duplikasi_warning)
if (!empty($extra)) {
$response = array_merge($response, $extra);
}
echo json_encode($response, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
/**
* Bersihkan input string dari tag HTML.
*/
function clean(string $value): string {
return htmlspecialchars(strip_tags(trim($value)), ENT_QUOTES, 'UTF-8');
}
/**
* Hitung jarak Haversine antara dua koordinat dalam kilometer.
*/
function haversine(float $lat1, float $lng1, float $lat2, float $lng2): float {
$R = 6371; // Radius bumi dalam km
$dLat = deg2rad($lat2 - $lat1);
$dLng = deg2rad($lng2 - $lng1);
$a = sin($dLat / 2) * sin($dLat / 2) +
cos(deg2rad($lat1)) * cos(deg2rad($lat2)) *
sin($dLng / 2) * sin($dLng / 2);
$c = 2 * atan2(sqrt($a), sqrt(1 - $a));
return round($R * $c, 4);
}
/**
* Auto-assign: cari rumah ibadah terdekat yang radius-nya mencakup lokasi KK.
* Mengembalikan ['rumah_ibadah_id' => int|null, 'jarak_km' => float|null]
*/
function autoAssign(PDO $pdo, float $lat, float $lng): array {
$result = ['rumah_ibadah_id' => null, 'jarak_km' => null];
$stmt = $pdo->query("SELECT id, lat, lng, radius_km FROM rumah_ibadah");
$rows = $stmt->fetchAll();
$bestDist = PHP_FLOAT_MAX;
$bestId = null;
foreach ($rows as $row) {
$dist = haversine($lat, $lng, (float)$row['lat'], (float)$row['lng']);
// Hanya assign jika KK berada dalam radius RI dan merupakan yang terdekat
if ($dist <= (float)$row['radius_km'] && $dist < $bestDist) {
$bestDist = $dist;
$bestId = (int)$row['id'];
}
}
if ($bestId !== null) {
$result['rumah_ibadah_id'] = $bestId;
$result['jarak_km'] = $bestDist;
}
return $result;
}
/**
* Cek apakah user sudah login. Jika belum, kirim 401.
* Mengembalikan data session user.
*/
function requireLogin(): array {
if (empty($_SESSION['user'])) {
jsonResponse(null, 'Tidak diizinkan. Silakan login terlebih dahulu.', false, 401);
}
return $_SESSION['user'];
}
/**
* Cek apakah role user termasuk dalam daftar yang diizinkan.
* Jika tidak, kirim 403.
*/
function requireRole(array $allowedRoles): array {
$user = requireLogin();
if (!in_array($user['role'], $allowedRoles)) {
jsonResponse(null, 'Akses ditolak. Role Anda tidak memiliki izin.', false, 403);
}
return $user;
}
/**
* Blokir aksi tulis (POST/PUT/DELETE) untuk role walikota.
* Walikota hanya boleh GET.
*/
function blockWalikotaWrite(): void {
if (!empty($_SESSION['user']) && $_SESSION['user']['role'] === 'walikota') {
$method = $_SERVER['REQUEST_METHOD'];
if (in_array($method, ['POST', 'PUT', 'DELETE'])) {
jsonResponse(null, 'Walikota hanya memiliki akses baca (read-only).', false, 403);
}
}
}
/**
* Generate kode pengurus dengan format: RI-[JENIS 3 HURUF]-[RANDOM 4 DIGIT]
* Contoh: RI-MSJ-4821, RI-GRJ-7153
*/
function generateKodePengurus(PDO $pdo, string $jenis): string {
// Mapping jenis ke 3 huruf
$map = [
'Masjid' => 'MSJ',
'Gereja' => 'GRJ',
'Pura' => 'PRA',
'Vihara' => 'VHR',
'Klenteng' => 'KLT',
'Lainnya' => 'LNY',
];
$prefix = $map[$jenis] ?? 'LNY';
// Coba generate kode unik (maks 100 percobaan)
for ($i = 0; $i < 100; $i++) {
$angka = str_pad(random_int(0, 9999), 4, '0', STR_PAD_LEFT);
$kode = "RI-{$prefix}-{$angka}";
$stmt = $pdo->prepare("SELECT id FROM rumah_ibadah WHERE kode_pengurus = ? LIMIT 1");
$stmt->execute([$kode]);
if ($stmt->rowCount() === 0) {
return $kode;
}
}
// Fallback: tambahkan timestamp
return "RI-{$prefix}-" . substr(time(), -4);
}
/**
* Cek rate limit untuk IP tertentu. Maks 5 submit per jam.
* Mengembalikan true jika masih diizinkan, false jika melebihi batas.
*/
function checkRateLimit(PDO $pdo, string $ip): bool {
// Hapus record yang lebih dari 1 jam
$pdo->prepare("DELETE FROM rate_limit WHERE created_at < NOW() - INTERVAL 1 HOUR")->execute();
// Hitung jumlah submit dalam 1 jam terakhir
$stmt = $pdo->prepare("SELECT COUNT(*) as cnt FROM rate_limit WHERE ip_address = ? AND created_at >= NOW() - INTERVAL 1 HOUR");
$stmt->execute([$ip]);
$count = (int)$stmt->fetch()['cnt'];
if ($count >= 5) {
return false; // Sudah melebihi batas
}
// Catat submit baru
$stmt = $pdo->prepare("INSERT INTO rate_limit (ip_address) VALUES (?)");
$stmt->execute([$ip]);
return true;
}
/**
* Handle upload file foto. Mengembalikan path relatif file atau null.
*/
function handleUpload(string $fieldName): ?string {
if (!isset($_FILES[$fieldName]) || $_FILES[$fieldName]['error'] !== UPLOAD_ERR_OK) {
return null;
}
$file = $_FILES[$fieldName];
$allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
$maxSize = 5 * 1024 * 1024; // 5MB
if (!in_array($file['type'], $allowedTypes)) {
jsonResponse(null, 'Tipe file tidak diizinkan. Gunakan JPG, PNG, GIF, atau WebP.', false, 400);
}
if ($file['size'] > $maxSize) {
jsonResponse(null, 'Ukuran file melebihi batas 5MB.', false, 400);
}
// Buat direktori upload jika belum ada
if (!is_dir(UPLOAD_DIR)) {
mkdir(UPLOAD_DIR, 0755, true);
}
// Generate nama file unik
$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
$filename = 'laporan_' . time() . '_' . bin2hex(random_bytes(4)) . '.' . $ext;
$dest = UPLOAD_DIR . $filename;
if (!move_uploaded_file($file['tmp_name'], $dest)) {
jsonResponse(null, 'Gagal menyimpan file foto.', false, 500);
}
return 'uploads/' . $filename;
}
/**
* Dapatkan IP address client (mendukung proxy).
*/
function getClientIP(): string {
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
return explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0];
}
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
}
+289
View File
@@ -0,0 +1,289 @@
-- ============================================================
-- DATABASE: webgis_rumah_ibadah
-- WebGIS Pontianak — Pengentasan Kemiskinan Berbasis Rumah Ibadah
-- ============================================================
CREATE DATABASE IF NOT EXISTS webgis_rumah_ibadah
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE webgis_rumah_ibadah;
-- Hapus tabel lama (urutan sesuai dependensi FK)
DROP TABLE IF EXISTS histori_bantuan;
DROP TABLE IF EXISTS laporan_masyarakat;
DROP TABLE IF EXISTS keluarga_miskin;
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS rumah_ibadah;
DROP TABLE IF EXISTS rate_limit;
-- ============================================================
-- TABEL: rumah_ibadah
-- Menyimpan data titik lokasi rumah ibadah beserta radius cakupan
-- ============================================================
CREATE TABLE rumah_ibadah (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(200) NOT NULL COMMENT 'Nama rumah ibadah',
jenis ENUM('Masjid','Gereja','Pura','Vihara','Klenteng','Lainnya')
NOT NULL COMMENT 'Jenis rumah ibadah',
alamat TEXT NOT NULL COMMENT 'Alamat lengkap',
lat DECIMAL(10,7) NOT NULL COMMENT 'Latitude',
lng DECIMAL(10,7) NOT NULL COMMENT 'Longitude',
radius_km FLOAT NOT NULL DEFAULT 2.0 COMMENT 'Radius cakupan (km)',
kode_pengurus VARCHAR(20) NULL UNIQUE COMMENT 'Kode unik login pengurus, format RI-XXX-9999',
password_pengurus VARCHAR(255) NULL COMMENT 'Password pengurus (hashed)',
kecamatan ENUM('Pontianak Utara','Pontianak Selatan','Pontianak Timur',
'Pontianak Barat','Pontianak Tenggara','Pontianak Kota')
NOT NULL COMMENT 'Kecamatan lokasi',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- TABEL: users
-- Manajemen pengguna dengan 3 role: admin, pengurus, walikota
-- ============================================================
CREATE TABLE users (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(150) NOT NULL COMMENT 'Nama lengkap pengguna',
username VARCHAR(100) NOT NULL UNIQUE COMMENT 'Username login',
password VARCHAR(255) NOT NULL COMMENT 'Password ter-hash (bcrypt)',
role ENUM('admin','pengurus','walikota')
NOT NULL DEFAULT 'pengurus' COMMENT 'Role pengguna',
rumah_ibadah_id INT DEFAULT NULL COMMENT 'FK ke rumah_ibadah (hanya untuk pengurus)',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (rumah_ibadah_id) REFERENCES rumah_ibadah(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- TABEL: keluarga_miskin
-- Data keluarga miskin dengan auto-assign ke rumah ibadah terdekat
-- ============================================================
CREATE TABLE keluarga_miskin (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
nik VARCHAR(20) NOT NULL UNIQUE COMMENT 'NIK Kepala Keluarga',
nama_kepala_keluarga VARCHAR(200) NOT NULL COMMENT 'Nama Kepala Keluarga',
tanggal_lahir DATE NULL COMMENT 'Tanggal lahir KK',
pendidikan ENUM('Tidak Sekolah','SD','SMP','SMA','D3','S1','S2/S3')
NOT NULL DEFAULT 'Tidak Sekolah',
jumlah_anggota INT NOT NULL DEFAULT 1 COMMENT 'Jumlah anggota keluarga',
alamat TEXT NOT NULL COMMENT 'Alamat lengkap',
kecamatan ENUM('Pontianak Utara','Pontianak Selatan','Pontianak Timur',
'Pontianak Barat','Pontianak Tenggara','Pontianak Kota')
NOT NULL,
lat DECIMAL(10,7) NOT NULL,
lng DECIMAL(10,7) NOT NULL,
tingkat_kemiskinan ENUM('Sangat Miskin','Miskin','Hampir Miskin')
NOT NULL DEFAULT 'Miskin',
status_dtks ENUM('Terdaftar DTKS','Belum DTKS','Sedang Diusulkan')
NOT NULL DEFAULT 'Belum DTKS',
riwayat_penyakit JSON NULL COMMENT 'Array penyakit: Stunting, TBC, Hipertensi, dll',
rumah_ibadah_id INT DEFAULT NULL COMMENT 'Auto-assign ke RI terdekat',
jarak_ke_ibadah FLOAT DEFAULT NULL COMMENT 'Jarak ke RI terdekat (km)',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (rumah_ibadah_id) REFERENCES rumah_ibadah(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- TABEL: laporan_masyarakat
-- Laporan warga tentang kemiskinan (bisa anonim, tanpa login)
-- ============================================================
CREATE TABLE laporan_masyarakat (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
nama_terlapor VARCHAR(150) NULL COMMENT 'Nama warga yang dilaporkan (boleh null)',
nik_terlapor VARCHAR(16) NULL COMMENT 'NIK warga yang dilaporkan (boleh null)',
nama_pelapor VARCHAR(150) NULL COMMENT 'Boleh anonim (null)',
no_hp VARCHAR(20) NULL,
alamat_terlapor TEXT NOT NULL COMMENT 'Alamat yang dilaporkan',
lat DECIMAL(10,7) NOT NULL,
lng DECIMAL(10,7) NOT NULL,
deskripsi TEXT NOT NULL COMMENT 'Deskripsi laporan',
foto VARCHAR(255) NULL COMMENT 'Path file foto',
status ENUM('Baru','Diverifikasi','Ditolak')
NOT NULL DEFAULT 'Baru',
alasan_tolak TEXT NULL COMMENT 'Alasan jika ditolak',
ditangani_oleh INT NULL COMMENT 'FK ke users.id',
converted_to_kk_id INT NULL COMMENT 'FK ke keluarga_miskin.id jika dikonversi',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (ditangani_oleh) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (converted_to_kk_id) REFERENCES keluarga_miskin(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- TABEL: histori_bantuan
-- Riwayat bantuan yang diterima keluarga miskin
-- ============================================================
CREATE TABLE histori_bantuan (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
kk_id INT NOT NULL COMMENT 'FK ke keluarga_miskin',
jenis_bantuan ENUM('PKH','BPNT/Sembako','Beras Bansos','KIS-PBI',
'Bansos Tunai Pemkot','Rutilahu','PMT Stunting',
'CSR Swasta','Lainnya')
NOT NULL,
nominal BIGINT NOT NULL DEFAULT 0 COMMENT 'Nominal dalam rupiah (0 jika non-uang)',
keterangan TEXT NULL COMMENT 'Catatan bebas: 10 kg beras, 3 karung, dll',
tanggal_penyaluran DATE NOT NULL,
sumber_dana ENUM('APBN','APBD','CSR','Lainnya')
NOT NULL DEFAULT 'APBD',
penanggung_jawab INT NOT NULL COMMENT 'FK ke users.id',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (kk_id) REFERENCES keluarga_miskin(id) ON DELETE CASCADE,
FOREIGN KEY (penanggung_jawab) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- TABEL: rate_limit
-- Pembatasan submit laporan masyarakat per IP (maks 5/jam)
-- ============================================================
CREATE TABLE rate_limit (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
ip_address VARCHAR(45) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_ip_time (ip_address, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- DATA SEED: Rumah Ibadah (2 data)
-- ============================================================
INSERT INTO rumah_ibadah (nama, jenis, alamat, lat, lng, radius_km, kode_pengurus, password_pengurus, kecamatan) VALUES
(
'Masjid Raya Mujahidin',
'Masjid',
'Jl. Ahmad Yani No. 1, Pontianak Tenggara',
-0.0263330, 109.3425200,
2.5,
'RI-MSJ-4821',
-- password: pengurus123
'$2y$10$EQKGMwtGqPsn3kxOGkUf3eh3cnXOCEYqVNfCKDVk.Q7WYTCKvi5iG',
'Pontianak Tenggara'
),
(
'Gereja Katedral Santo Yosef',
'Gereja',
'Jl. Diponegoro No. 120, Pontianak Kota',
-0.0380000, 109.3422000,
2.0,
'RI-GRJ-7153',
-- password: pengurus123
'$2y$10$EQKGMwtGqPsn3kxOGkUf3eh3cnXOCEYqVNfCKDVk.Q7WYTCKvi5iG',
'Pontianak Kota'
);
-- ============================================================
-- DATA SEED: Users (1 admin, 1 walikota)
-- Password semua: admin123
-- ============================================================
INSERT INTO users (nama, username, password, role, rumah_ibadah_id) VALUES
(
'Administrator Sistem',
'admin',
'$2y$10$AIO3pPEflVaouN/G.rvgWeZmSjr0Nnjsd2PTna4lSHEoDTWr9KQpi',
'admin',
NULL
),
(
'Walikota Pontianak',
'walikota',
'$2y$10$AIO3pPEflVaouN/G.rvgWeZmSjr0Nnjsd2PTna4lSHEoDTWr9KQpi',
'walikota',
NULL
);
-- ============================================================
-- DATA SEED: Keluarga Miskin (5 data)
-- Auto-assign rumah_ibadah_id dilakukan oleh aplikasi saat disimpan
-- ============================================================
INSERT INTO keluarga_miskin (nik, nama_kepala_keluarga, tanggal_lahir, pendidikan, jumlah_anggota, alamat, kecamatan, lat, lng, tingkat_kemiskinan, status_dtks, riwayat_penyakit, rumah_ibadah_id, jarak_ke_ibadah) VALUES
(
'6171010101010001',
'Budi Santoso',
'1978-03-15',
'SD',
4,
'Gg. Mawar No. 3, Benua Melayu Darat',
'Pontianak Tenggara',
-0.0283000, 109.3440000,
'Sangat Miskin',
'Terdaftar DTKS',
'["Stunting","Gizi Buruk"]',
1,
0.2500
),
(
'6171010101010002',
'Siti Rahayu',
'1985-07-22',
'SMP',
6,
'Jl. Merdeka Gg. 5 No. 7, Mariana',
'Pontianak Kota',
-0.0370000, 109.3430000,
'Miskin',
'Terdaftar DTKS',
'["TBC","Hipertensi"]',
2,
0.1200
),
(
'6171010101010003',
'Wayan Dharma',
'1972-11-08',
'Tidak Sekolah',
3,
'Gg. Bali No. 2, Bansir Laut',
'Pontianak Tenggara',
-0.0290000, 109.3410000,
'Sangat Miskin',
'Belum DTKS',
'["Diabetes","Hipertensi","ODGJ"]',
1,
0.3400
),
(
'6171010101010004',
'Ahmad Fauzan',
'1990-01-30',
'SMA',
5,
'Jl. Tanjungpura Gg. 12, Parit Tokaya',
'Pontianak Selatan',
-0.0310000, 109.3350000,
'Miskin',
'Sedang Diusulkan',
'["Stunting"]',
NULL,
NULL
),
(
'6171010101010005',
'Ruslan Effendi',
'1968-09-05',
'SD',
7,
'Jl. Sutan Syahrir Gg. Melati, Batu Layang',
'Pontianak Utara',
-0.0200000, 109.3460000,
'Hampir Miskin',
'Terdaftar DTKS',
'["Jantung","DBD"]',
1,
0.7800
);
-- ============================================================
-- DATA SEED: Laporan Masyarakat (1 contoh)
-- ============================================================
INSERT INTO laporan_masyarakat (nama_pelapor, no_hp, alamat_terlapor, lat, lng, deskripsi, status) VALUES
(
'Pak RT 03',
'081234567890',
'Gg. Flamboyan No. 5, Pontianak Tenggara',
-0.0275000, 109.3435000,
'Terdapat keluarga dengan 5 anak yang sangat membutuhkan bantuan. Rumah tidak layak huni dan anak-anak putus sekolah.',
'Baru'
);
-- ============================================================
-- DATA SEED: Histori Bantuan (2 contoh)
-- ============================================================
INSERT INTO histori_bantuan (kk_id, jenis_bantuan, nominal, keterangan, tanggal_penyaluran, sumber_dana, penanggung_jawab) VALUES
(1, 'PKH', 3000000, 'Bantuan tahap I tahun 2026', '2026-01-15', 'APBN', 1),
(2, 'BPNT/Sembako', 0, '10 kg beras + minyak goreng', '2026-02-20', 'APBN', 1);
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
<?php
header('Location: index.html', true, 302);
exit;
+622
View File
@@ -0,0 +1,622 @@
<?php
if (!empty($_GET['q'])) {
switch ($_GET['q']) {
case 'info': phpinfo(); exit; break;
}
}
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WebGIS Portal — Kota Pontianak</title>
<meta name="description" content="Portal Sistem Informasi Geografis (WebGIS) Kota Pontianak — Pusat akses semua project WebGIS yang dibangun untuk pengelolaan data spasial wilayah." />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #060b14;
--bg-2: #0c1424;
--bg-card: rgba(255,255,255,0.04);
--bg-card-h: rgba(255,255,255,0.08);
--border: rgba(255,255,255,0.08);
--border-h: rgba(255,255,255,0.18);
--text-1: #f0f6ff;
--text-2: #8b9ab8;
--text-3: #4a5568;
--accent-1: #3b82f6;
--accent-2: #8b5cf6;
--accent-3: #06b6d4;
--green: #10b981;
--orange: #f59e0b;
--pink: #ec4899;
--radius: 16px;
--radius-sm: 10px;
--shadow: 0 8px 40px rgba(0,0,0,0.5);
--shadow-lg: 0 20px 80px rgba(0,0,0,0.7);
}
html { scroll-behavior: smooth; }
body {
font-family: 'Inter', sans-serif;
background: var(--bg);
color: var(--text-1);
min-height: 100vh;
overflow-x: hidden;
}
/* ── CANVAS BG ─────────────────────────────────────── */
#bg-canvas {
position: fixed; inset: 0;
z-index: 0; pointer-events: none;
}
.grid-overlay {
position: fixed; inset: 0; z-index: 0; pointer-events: none;
background-image:
linear-gradient(rgba(59,130,246,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(59,130,246,0.03) 1px, transparent 1px);
background-size: 60px 60px;
}
/* ── WRAPPER ───────────────────────────────────────── */
.wrapper {
position: relative; z-index: 1;
max-width: 1100px; margin: 0 auto; padding: 0 24px;
}
/* ── NAVBAR ────────────────────────────────────────── */
nav {
position: sticky; top: 0; z-index: 100;
padding: 0 24px;
background: rgba(6,11,20,0.85);
backdrop-filter: blur(20px);
border-bottom: 1px solid var(--border);
}
.nav-inner {
max-width: 1100px; margin: 0 auto;
height: 64px; display: flex; align-items: center; justify-content: space-between;
}
.nav-brand {
display: flex; align-items: center; gap: 10px;
font-family: 'Space Grotesk', sans-serif;
font-weight: 700; font-size: 1.05rem;
color: var(--text-1); text-decoration: none;
}
.nav-logo {
width: 36px; height: 36px;
background: linear-gradient(135deg, var(--accent-1), var(--accent-2));
border-radius: 10px; display: flex; align-items: center; justify-content: center;
font-size: 1rem; flex-shrink: 0;
box-shadow: 0 0 20px rgba(59,130,246,0.4);
}
.nav-links { display: flex; align-items: center; gap: 6px; }
.nav-links a {
padding: 7px 14px; border-radius: var(--radius-sm);
font-size: 0.82rem; font-weight: 500; color: var(--text-2);
text-decoration: none; transition: all 0.2s;
}
.nav-links a:hover { color: var(--text-1); background: var(--bg-card); }
.nav-badge {
display: inline-flex; align-items: center; gap: 5px;
padding: 5px 12px;
background: rgba(59,130,246,0.12); border: 1px solid rgba(59,130,246,0.25);
border-radius: 20px; font-size: 0.73rem; font-weight: 600; color: var(--accent-1);
}
.dot-live {
width: 7px; height: 7px; background: var(--green);
border-radius: 50%; animation: pulse 2s infinite;
}
@keyframes pulse { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:.5;transform:scale(.7)} }
/* ── HERO ──────────────────────────────────────────── */
.hero { padding: 100px 0 70px; text-align: center; }
.hero-eyebrow {
display: inline-flex; align-items: center; gap: 8px;
padding: 6px 16px; border-radius: 20px;
background: rgba(139,92,246,0.12); border: 1px solid rgba(139,92,246,0.25);
font-size: 0.75rem; font-weight: 600; color: var(--accent-2);
letter-spacing: 0.06em; text-transform: uppercase;
margin-bottom: 24px; animation: fadeUp 0.6s ease both;
}
.hero-title {
font-family: 'Space Grotesk', sans-serif;
font-size: clamp(2.2rem, 5vw, 3.8rem);
font-weight: 800; line-height: 1.1; letter-spacing: -0.02em;
margin-bottom: 20px; animation: fadeUp 0.6s ease 0.1s both;
}
.hero-title .gradient-text {
background: linear-gradient(135deg, var(--accent-1) 0%, var(--accent-2) 50%, var(--pink) 100%);
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
.hero-sub {
font-size: 1.05rem; color: var(--text-2); line-height: 1.7;
max-width: 560px; margin: 0 auto 40px;
animation: fadeUp 0.6s ease 0.2s both;
}
.hero-stats {
display: flex; justify-content: center; gap: 40px;
margin-top: 50px; padding-top: 40px;
border-top: 1px solid var(--border);
animation: fadeUp 0.6s ease 0.35s both;
}
.hero-stat { text-align: center; }
.hero-stat-num {
font-family: 'Space Grotesk', sans-serif;
font-size: 2rem; font-weight: 700;
background: linear-gradient(135deg, var(--accent-1), var(--accent-3));
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
.hero-stat-label {
font-size: 0.75rem; color: var(--text-3);
text-transform: uppercase; letter-spacing: 0.06em; margin-top: 4px; font-weight: 500;
}
@keyframes fadeUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── SECTION HEADER ────────────────────────────────── */
.section-header { margin-bottom: 36px; animation: fadeUp 0.6s ease 0.3s both; }
.section-label {
font-size: 0.72rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.1em; color: var(--accent-1);
margin-bottom: 10px; display: flex; align-items: center; gap: 8px;
}
.section-label::before { content:''; width:20px; height:2px; background:var(--accent-1); border-radius:2px; }
.section-title { font-family: 'Space Grotesk', sans-serif; font-size: 1.8rem; font-weight: 700; color: var(--text-1); }
/* ── PROJECT CARDS ─────────────────────────────────── */
.projects-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 28px;
margin-bottom: 80px;
}
.project-card {
position: relative;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 30px;
display: flex; flex-direction: column;
transition: all 0.3s ease;
overflow: hidden;
animation: fadeUp 0.6s ease both;
}
.project-card::before {
content: '';
position: absolute; inset: 0;
background: linear-gradient(135deg, var(--card-color-1, rgba(59,130,246,0.07)), var(--card-color-2, rgba(139,92,246,0.04)));
opacity: 0; transition: opacity 0.3s; border-radius: inherit;
}
.project-card:hover { border-color: var(--border-h); transform: translateY(-6px); box-shadow: var(--shadow-lg); background: var(--bg-card-h); }
.project-card:hover::before { opacity: 1; }
/* accent glow line on top */
.project-card::after {
content: '';
position: absolute; top: 0; left: 0; right: 0; height: 2px;
background: var(--card-accent, linear-gradient(90deg, var(--accent-1), var(--accent-2)));
transform: scaleX(0); transform-origin: left; transition: transform 0.4s ease;
}
.project-card:hover::after { transform: scaleX(1); }
.card-icon-wrap {
width: 58px; height: 58px; border-radius: 16px;
display: flex; align-items: center; justify-content: center;
font-size: 1.5rem; margin-bottom: 22px; flex-shrink: 0; position: relative;
}
.card-icon-wrap::after {
content: ''; position: absolute; inset: -1px;
border-radius: 17px; border: 1px solid rgba(255,255,255,0.12);
}
.card-number {
position: absolute; top: 20px; right: 24px;
font-family: 'Space Grotesk', sans-serif;
font-size: 3.5rem; font-weight: 800;
color: rgba(255,255,255,0.04); line-height: 1; user-select: none;
}
.card-tag-row { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 16px; }
.tag {
padding: 3px 10px; border-radius: 20px;
font-size: 0.65rem; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.05em;
}
.card-title {
font-family: 'Space Grotesk', sans-serif;
font-size: 1.25rem; font-weight: 700; color: var(--text-1);
margin-bottom: 10px; line-height: 1.3;
}
.card-desc {
font-size: 0.84rem; color: var(--text-2);
line-height: 1.7; margin-bottom: 24px; flex: 1;
}
.card-features {
display: flex; flex-direction: column; gap: 7px;
margin-bottom: 26px; padding: 14px;
background: rgba(255,255,255,0.03);
border-radius: var(--radius-sm); border: 1px solid var(--border);
}
.card-feature {
display: flex; align-items: center; gap: 8px;
font-size: 0.77rem; color: var(--text-2);
}
.card-feature i { width: 14px; text-align: center; font-size: 0.72rem; }
.card-footer {
display: flex; align-items: center;
justify-content: space-between; margin-top: auto;
}
.card-url {
font-size: 0.71rem; color: var(--text-3);
font-family: 'Courier New', monospace;
display: flex; align-items: center; gap: 5px;
}
.btn-open {
display: inline-flex; align-items: center; gap: 8px;
padding: 10px 20px; border-radius: var(--radius-sm);
font-size: 0.82rem; font-weight: 600; color: #fff;
text-decoration: none; transition: all 0.2s;
border: none; cursor: pointer; font-family: inherit;
position: relative; z-index: 10;
}
.btn-open:hover { filter: brightness(1.15); transform: translateX(3px); }
.btn-open i { font-size: 0.72rem; transition: transform 0.2s; }
.btn-open:hover i { transform: translateX(4px); }
/* ── CARD THEMES ───────────────────────────────────── */
.card-blue { --card-color-1: rgba(59,130,246,0.1); --card-color-2: rgba(6,182,212,0.05); --card-accent: linear-gradient(90deg,#3b82f6,#06b6d4); }
.card-purple { --card-color-1: rgba(139,92,246,0.1); --card-color-2: rgba(236,72,153,0.05); --card-accent: linear-gradient(90deg,#8b5cf6,#ec4899); }
/* ── TECH STACK ────────────────────────────────────── */
.tech-section {
padding: 50px 0; border-top: 1px solid var(--border); margin-bottom: 60px;
}
.tech-grid { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 24px; }
.tech-chip {
display: flex; align-items: center; gap: 8px;
padding: 8px 16px;
background: var(--bg-card); border: 1px solid var(--border);
border-radius: 8px; font-size: 0.78rem; font-weight: 500; color: var(--text-2);
transition: all 0.2s;
}
.tech-chip:hover { border-color: var(--border-h); color: var(--text-1); background: var(--bg-card-h); }
.tech-chip i { font-size: 0.85rem; }
/* ── FOOTER ────────────────────────────────────────── */
footer { border-top: 1px solid var(--border); padding: 32px 24px; }
.footer-inner {
max-width: 1100px; margin: 0 auto;
display: flex; align-items: center; justify-content: space-between;
flex-wrap: wrap; gap: 12px;
}
.footer-brand { display: flex; align-items: center; gap: 8px; font-size: 0.83rem; font-weight: 600; color: var(--text-2); }
.footer-info { font-size: 0.73rem; color: var(--text-3); }
.footer-info span { color: var(--text-2); }
/* ── SCROLL BAR ────────────────────────────────────── */
.scroll-indicator {
position: fixed; top: 0; left: 0; height: 3px;
background: linear-gradient(90deg, var(--accent-1), var(--accent-2), var(--pink));
z-index: 9999; transition: width 0.1s; width: 0%;
}
/* ── RESPONSIVE ────────────────────────────────────── */
@media(max-width: 720px) {
.hero { padding: 70px 0 50px; }
.hero-stats { gap: 24px; }
.projects-grid { grid-template-columns: 1fr; }
.nav-links { display: none; }
.footer-inner { flex-direction: column; text-align: center; }
}
</style>
</head>
<body>
<div class="scroll-indicator" id="scroll-bar"></div>
<canvas id="bg-canvas"></canvas>
<div class="grid-overlay"></div>
<!-- ── NAV ────────────────────────────────────────────── -->
<nav>
<div class="nav-inner">
<a class="nav-brand" href="#">
<div class="nav-logo"><i class="fas fa-globe" style="color:#fff"></i></div>
WebGIS Portal
</a>
<div class="nav-links">
<a href="#projects">Projects</a>
<a href="#stack">Tech Stack</a>
<a href="/?q=info" target="_blank">PHP Info</a>
</div>
<div class="nav-badge">
<span class="dot-live"></span>
2 Projects Online
</div>
</div>
</nav>
<!-- ── HERO ───────────────────────────────────────────── -->
<section class="hero">
<div class="wrapper">
<div class="hero-eyebrow">
<i class="fas fa-map-location-dot"></i>
Sistem Informasi Geografis
</div>
<h1 class="hero-title">
Portal <span class="gradient-text">WebGIS</span><br/>Kota Pontianak
</h1>
<p class="hero-sub">
Pusat akses seluruh project Sistem Informasi Geografis berbasis web untuk pengelolaan data spasial wilayah Kota Pontianak.
</p>
<div class="hero-stats">
<div class="hero-stat">
<div class="hero-stat-num">2</div>
<div class="hero-stat-label">Project Aktif</div>
</div>
<div class="hero-stat">
<div class="hero-stat-num">6+</div>
<div class="hero-stat-label">Layer Data</div>
</div>
<div class="hero-stat">
<div class="hero-stat-num">PHP</div>
<div class="hero-stat-label"><?php echo PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION; ?></div>
</div>
<div class="hero-stat">
<div class="hero-stat-num">GIS</div>
<div class="hero-stat-label">LeafletJS</div>
</div>
</div>
</div>
</section>
<!-- ── PROJECTS ────────────────────────────────────────── -->
<section id="projects">
<div class="wrapper">
<div class="section-header">
<div class="section-label">Daftar Project</div>
<h2 class="section-title">Semua WebGIS Project</h2>
</div>
<div class="projects-grid">
<!-- ══ CARD 1: WebGIS Layer ══════════════════════════ -->
<div class="project-card card-blue" style="animation-delay:0.1s">
<span class="card-number">01</span>
<div class="card-icon-wrap" style="background:linear-gradient(135deg,rgba(59,130,246,0.2),rgba(6,182,212,0.2))">
<i class="fas fa-layer-group" style="color:#3b82f6"></i>
</div>
<div class="card-tag-row">
<span class="tag" style="background:rgba(59,130,246,0.15);color:#60a5fa;border:1px solid rgba(59,130,246,0.3)">
<i class="fas fa-road"></i> Jalan
</span>
<span class="tag" style="background:rgba(139,92,246,0.15);color:#a78bfa;border:1px solid rgba(139,92,246,0.3)">
<i class="fas fa-draw-polygon"></i> Parsil
</span>
<span class="tag" style="background:rgba(16,185,129,0.15);color:#34d399;border:1px solid rgba(16,185,129,0.3)">
<i class="fas fa-gas-pump"></i> SPBU
</span>
</div>
<h3 class="card-title">WebGIS Layer — Jalan, Parsil & SPBU</h3>
<p class="card-desc">
Sistem informasi geografis untuk manajemen data jalan (nasional, provinsi, kabupaten), parsil tanah beserta status kepemilikan, dan sebaran SPBU di Kota Pontianak.
</p>
<div class="card-features">
<div class="card-feature">
<i class="fas fa-check-circle" style="color:#3b82f6"></i>
<span>Gambar polyline & polygon langsung di peta</span>
</div>
<div class="card-feature">
<i class="fas fa-check-circle" style="color:#3b82f6"></i>
<span>CRUD data jalan, parsil tanah & SPBU</span>
</div>
<div class="card-feature">
<i class="fas fa-check-circle" style="color:#3b82f6"></i>
<span>Kalkulasi panjang & luas otomatis (LeafletJS)</span>
</div>
<div class="card-feature">
<i class="fas fa-check-circle" style="color:#3b82f6"></i>
<span>Layer switcher: Street & Satellite view</span>
</div>
</div>
<div class="card-footer">
<div class="card-url">
<i class="fas fa-link"></i>
/Web GIS Layer/
</div>
<a href="http://localhost/Web%20GIS%20Layer/index.html"
class="btn-open"
style="background:linear-gradient(135deg,#3b82f6,#06b6d4)"
onclick="window.location.href=this.href; return false;">
Buka Project <i class="fas fa-arrow-right"></i>
</a>
</div>
</div>
<!-- ══ CARD 2: WebGIS Poverty Mapping ════════════════ -->
<div class="project-card card-purple" style="animation-delay:0.2s">
<span class="card-number">02</span>
<div class="card-icon-wrap" style="background:linear-gradient(135deg,rgba(139,92,246,0.2),rgba(236,72,153,0.2))">
<i class="fas fa-house-chimney-crack" style="color:#a855f7"></i>
</div>
<div class="card-tag-row">
<span class="tag" style="background:rgba(139,92,246,0.15);color:#a78bfa;border:1px solid rgba(139,92,246,0.3)">
<i class="fas fa-users"></i> Kemiskinan
</span>
<span class="tag" style="background:rgba(236,72,153,0.15);color:#f472b6;border:1px solid rgba(236,72,153,0.3)">
<i class="fas fa-mosque"></i> Rumah Ibadah
</span>
<span class="tag" style="background:rgba(245,158,11,0.15);color:#fbbf24;border:1px solid rgba(245,158,11,0.3)">
<i class="fas fa-hand-holding-heart"></i> Bantuan
</span>
</div>
<h3 class="card-title">WebGIS Poverty Mapping & Rumah Ibadah</h3>
<p class="card-desc">
Pemetaan keluarga miskin, histori penyaluran bantuan sosial, dan lokasi rumah ibadah berbasis peta interaktif. Dilengkapi dashboard analitik dan laporan spasial.
</p>
<div class="card-features">
<div class="card-feature">
<i class="fas fa-check-circle" style="color:#a855f7"></i>
<span>Pemetaan & manajemen data keluarga miskin</span>
</div>
<div class="card-feature">
<i class="fas fa-check-circle" style="color:#a855f7"></i>
<span>Histori & tracking bantuan sosial</span>
</div>
<div class="card-feature">
<i class="fas fa-check-circle" style="color:#a855f7"></i>
<span>Lokasi rumah ibadah (masjid, gereja, dll.)</span>
</div>
<div class="card-feature">
<i class="fas fa-check-circle" style="color:#a855f7"></i>
<span>Dashboard statistik & laporan lengkap</span>
</div>
</div>
<div class="card-footer">
<div class="card-url">
<i class="fas fa-link"></i>
/Web GIS Rumah Ibadah/
</div>
<a href="http://localhost/Web%20GIS%20Rumah%20Ibadah/index.html"
class="btn-open"
style="background:linear-gradient(135deg,#8b5cf6,#ec4899)"
onclick="window.location.href=this.href; return false;">
Buka Project <i class="fas fa-arrow-right"></i>
</a>
</div>
</div>
</div><!-- /.projects-grid -->
<!-- ── TECH STACK ──────────────────────────────────── -->
<section id="stack" class="tech-section">
<div class="section-label">Teknologi</div>
<h2 class="section-title" style="margin-bottom:0">Tech Stack yang Digunakan</h2>
<div class="tech-grid">
<div class="tech-chip"><i class="fab fa-html5" style="color:#e34f26"></i> HTML5 & CSS3</div>
<div class="tech-chip"><i class="fab fa-js" style="color:#f7df1e"></i> JavaScript</div>
<div class="tech-chip"><i class="fab fa-php" style="color:#777bb4"></i> PHP <?php echo phpversion(); ?></div>
<div class="tech-chip"><i class="fas fa-database" style="color:#4479a1"></i> MySQL / MariaDB</div>
<div class="tech-chip"><i class="fas fa-map" style="color:#3b82f6"></i> Leaflet.js 1.9.4</div>
<div class="tech-chip"><i class="fas fa-pencil-ruler" style="color:#06b6d4"></i> Leaflet.Draw</div>
<div class="tech-chip"><i class="fas fa-ruler" style="color:#10b981"></i> Leaflet.GeometryUtil</div>
<div class="tech-chip"><i class="fas fa-globe" style="color:#f59e0b"></i> OpenStreetMap</div>
<div class="tech-chip"><i class="fas fa-satellite" style="color:#8b5cf6"></i> Esri Satellite</div>
<div class="tech-chip"><i class="fab fa-font-awesome" style="color:#528dd7"></i> Font Awesome 6</div>
<div class="tech-chip"><i class="fas fa-server" style="color:#ec4899"></i> Laragon</div>
<div class="tech-chip"><i class="fas fa-code-branch" style="color:#22d3ee"></i> GeoJSON Format</div>
</div>
</section>
</div><!-- /.wrapper -->
</section>
<!-- ── FOOTER ──────────────────────────────────────────── -->
<footer>
<div class="footer-inner">
<div class="footer-brand">
<div class="nav-logo" style="width:28px;height:28px;font-size:.8rem">
<i class="fas fa-globe" style="color:#fff"></i>
</div>
WebGIS Portal — Kota Pontianak
<div class="footer-info">
Dibangun dengan <span style="color:#ec4899">♥</span> menggunakan LeafletJS & PHP
</div>
</div>
</footer>
<script>
/* ── ANIMATED PARTICLE CANVAS ─────────────────────────── */
(function() {
const canvas = document.getElementById('bg-canvas');
const ctx = canvas.getContext('2d');
let W, H, particles = [];
function resize() { W = canvas.width = window.innerWidth; H = canvas.height = window.innerHeight; }
resize();
window.addEventListener('resize', resize);
class Particle {
constructor() { this.reset(); }
reset() {
this.x = Math.random() * W; this.y = Math.random() * H;
this.r = Math.random() * 1.5 + 0.3;
this.vx = (Math.random() - 0.5) * 0.25; this.vy = (Math.random() - 0.5) * 0.25;
this.a = Math.random() * 0.5 + 0.1;
const p = ['59,130,246','139,92,246','6,182,212','16,185,129'];
this.color = p[Math.floor(Math.random() * p.length)];
}
update() { this.x += this.vx; this.y += this.vy; if (this.x<0||this.x>W||this.y<0||this.y>H) this.reset(); }
draw() { ctx.beginPath(); ctx.arc(this.x,this.y,this.r,0,Math.PI*2); ctx.fillStyle=`rgba(${this.color},${this.a})`; ctx.fill(); }
}
for (let i = 0; i < 120; i++) particles.push(new Particle());
function drawLines() {
for (let i = 0; i < particles.length; i++)
for (let j = i+1; j < particles.length; j++) {
const dx=particles[i].x-particles[j].x, dy=particles[i].y-particles[j].y;
const d=Math.sqrt(dx*dx+dy*dy);
if (d < 100) {
ctx.beginPath(); ctx.moveTo(particles[i].x,particles[i].y); ctx.lineTo(particles[j].x,particles[j].y);
ctx.strokeStyle=`rgba(59,130,246,${0.06*(1-d/100)})`; ctx.lineWidth=0.5; ctx.stroke();
}
}
}
function animate() {
ctx.clearRect(0,0,W,H);
particles.forEach(p=>{p.update();p.draw();});
drawLines();
requestAnimationFrame(animate);
}
animate();
})();
/* ── SCROLL PROGRESS ──────────────────────────────────── */
window.addEventListener('scroll', () => {
const st = document.documentElement.scrollTop;
const sh = document.documentElement.scrollHeight - document.documentElement.clientHeight;
document.getElementById('scroll-bar').style.width = (st/sh*100) + '%';
});
/* ── CARD TILT ────────────────────────────────────────── */
document.querySelectorAll('.project-card').forEach(card => {
card.addEventListener('mousemove', e => {
const r = card.getBoundingClientRect();
const x = ((e.clientX - r.left) / r.width - 0.5) * 8;
const y = ((e.clientY - r.top) / r.height - 0.5) * 8;
card.style.transform = `translateY(-6px) rotateX(${-y}deg) rotateY(${x}deg)`;
});
card.addEventListener('mouseleave', () => { card.style.transform = ''; });
});
/* ── INTERSECTION OBSERVER ────────────────────────────── */
const obs = new IntersectionObserver(entries => {
entries.forEach(e => { if (e.isIntersecting) e.target.style.animationPlayState = 'running'; });
}, { threshold: 0.1 });
document.querySelectorAll('.project-card,.tech-chip').forEach(el => {
el.style.animationPlayState = 'paused';
obs.observe(el);
});
</script>
</body>
</html>