first commit
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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)));
|
||||||
|
}
|
||||||
@@ -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.5–5 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.5–5 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.*
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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
@@ -0,0 +1,3 @@
|
|||||||
|
<?php
|
||||||
|
header('Location: index.html', true, 302);
|
||||||
|
exit;
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user