Initial commit
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
$host = 'localhost';
|
||||
$dbname = 'latihan_webgis';
|
||||
$username = 'root';
|
||||
$password = '';
|
||||
|
||||
try {
|
||||
$db = new PDO("mysql:host=$host;dbname=$dbname;charset=utf8mb4", $username, $password);
|
||||
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
} catch (PDOException $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Koneksi database gagal: ' . $e->getMessage()]);
|
||||
exit;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
require __DIR__ . '/db.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit(); }
|
||||
|
||||
$action = $_GET['action'] ?? '';
|
||||
if ($action === '') {
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$action = $input['action'] ?? '';
|
||||
}
|
||||
|
||||
try {
|
||||
switch ($action) {
|
||||
case 'list':
|
||||
$rows = $db->query("SELECT * FROM jalan ORDER BY createdat DESC")->fetchAll();
|
||||
echo json_encode(['status' => 'success', 'data' => $rows]);
|
||||
break;
|
||||
|
||||
case 'create':
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$nama = trim($input['nama'] ?? '');
|
||||
$status = $input['status'] ?? 'kabupaten';
|
||||
$panjang = isset($input['panjang_meter']) ? (float)$input['panjang_meter'] : null;
|
||||
$geom = $input['geom'] ?? null;
|
||||
|
||||
if (!$nama) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Nama jalan wajib diisi.']);
|
||||
break;
|
||||
}
|
||||
if (!in_array($status, ['nasional','provinsi','kabupaten'])) $status = 'kabupaten';
|
||||
|
||||
// Validasi dan konversi geometri LineString
|
||||
$geojson = null;
|
||||
if (is_array($geom)) {
|
||||
if (isset($geom['type']) && $geom['type'] === 'LineString' && isset($geom['coordinates'])) {
|
||||
$geojson = json_encode($geom);
|
||||
} elseif (isset($geom[0]) && is_array($geom[0])) {
|
||||
// array of points [[lng,lat],...]
|
||||
$geojson = json_encode([
|
||||
'type' => 'LineString',
|
||||
'coordinates' => $geom
|
||||
]);
|
||||
}
|
||||
} elseif (is_string($geom)) {
|
||||
$decoded = json_decode($geom, true);
|
||||
if ($decoded && isset($decoded['type']) && $decoded['type'] === 'LineString') {
|
||||
$geojson = $geom;
|
||||
} else {
|
||||
echo json_encode(['status'=>'error','message'=>'Format geometri tidak valid.']);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$geojson) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Geometri tidak valid. Harus berupa GeoJSON LineString atau array koordinat.']);
|
||||
break;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("INSERT INTO jalan (nama, status, panjang_meter, geom) VALUES (?, ?, ?, ?)");
|
||||
$stmt->execute([$nama, $status, $panjang, $geojson]);
|
||||
echo json_encode(['status'=>'success','message'=>'Data jalan berhasil disimpan!','id'=>$db->lastInsertId()]);
|
||||
break;
|
||||
|
||||
case 'update':
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$id = (int)($input['id'] ?? 0);
|
||||
$nama = trim($input['nama'] ?? '');
|
||||
$status = $input['status'] ?? 'kabupaten';
|
||||
|
||||
if (!$id) { echo json_encode(['status'=>'error','message'=>'ID tidak valid.']); break; }
|
||||
if (!$nama) { echo json_encode(['status'=>'error','message'=>'Nama jalan wajib diisi.']); break; }
|
||||
if (!in_array($status, ['nasional','provinsi','kabupaten'])) $status = 'kabupaten';
|
||||
|
||||
$stmt = $db->prepare("UPDATE jalan SET nama=?, status=?, updatedat=NOW() WHERE id=?");
|
||||
$stmt->execute([$nama, $status, $id]);
|
||||
echo json_encode(['status'=>'success','message'=>'Data jalan berhasil diperbarui.']);
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$id = (int)($input['id'] ?? 0);
|
||||
if (!$id) { echo json_encode(['status'=>'error','message'=>'ID tidak valid.']); break; }
|
||||
$stmt = $db->prepare("DELETE FROM jalan WHERE id=?");
|
||||
$stmt->execute([$id]);
|
||||
echo json_encode(['status'=>'success','message'=>'Data jalan berhasil dihapus.']);
|
||||
break;
|
||||
|
||||
default:
|
||||
echo json_encode(['status'=>'error','message'=>'Aksi tidak dikenali.']);
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
echo json_encode(['status'=>'error','message'=>'Database error: '.$e->getMessage()]);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
require __DIR__ . '/db.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit(); }
|
||||
|
||||
$action = $_GET['action'] ?? '';
|
||||
if ($action === '') {
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$action = $input['action'] ?? '';
|
||||
}
|
||||
|
||||
try {
|
||||
switch ($action) {
|
||||
case 'list':
|
||||
$rows = $db->query("SELECT * FROM parsil ORDER BY createdat DESC")->fetchAll();
|
||||
echo json_encode(['status' => 'success', 'data' => $rows]);
|
||||
break;
|
||||
|
||||
case 'create':
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$nama = trim($input['nama'] ?? '');
|
||||
$status = $input['status_kepemilikan'] ?? 'SHM';
|
||||
$luas = isset($input['luas_m2']) ? (float)$input['luas_m2'] : null;
|
||||
$geom = $input['geom'] ?? null;
|
||||
|
||||
if (!$nama) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Nama parsil wajib diisi.']);
|
||||
break;
|
||||
}
|
||||
$validStatus = ['SHM','HGB','HGU','HP'];
|
||||
if (!in_array($status, $validStatus)) $status = 'SHM';
|
||||
|
||||
// Validasi dan konversi geometri Polygon
|
||||
$geojson = null;
|
||||
if (is_array($geom)) {
|
||||
if (isset($geom['type']) && $geom['type'] === 'Polygon' && isset($geom['coordinates'])) {
|
||||
$geojson = json_encode($geom);
|
||||
} elseif (isset($geom[0]) && is_array($geom[0])) {
|
||||
// array of rings [[lng,lat],...]
|
||||
$geojson = json_encode([
|
||||
'type' => 'Polygon',
|
||||
'coordinates' => $geom
|
||||
]);
|
||||
}
|
||||
} elseif (is_string($geom)) {
|
||||
$decoded = json_decode($geom, true);
|
||||
if ($decoded && isset($decoded['type']) && $decoded['type'] === 'Polygon') {
|
||||
$geojson = $geom;
|
||||
} else {
|
||||
echo json_encode(['status'=>'error','message'=>'Format geometri tidak valid.']);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$geojson) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Geometri tidak valid. Harus berupa GeoJSON Polygon atau array koordinat.']);
|
||||
break;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("INSERT INTO parsil (nama, status_kepemilikan, luas_m2, geom) VALUES (?, ?, ?, ?)");
|
||||
$stmt->execute([$nama, $status, $luas, $geojson]);
|
||||
echo json_encode(['status'=>'success','message'=>'Data parsil berhasil disimpan!','id'=>$db->lastInsertId()]);
|
||||
break;
|
||||
|
||||
case 'update':
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$id = (int)($input['id'] ?? 0);
|
||||
$nama = trim($input['nama'] ?? '');
|
||||
$status = $input['status_kepemilikan'] ?? 'SHM';
|
||||
$geom = $input['geom'] ?? null;
|
||||
$luas = isset($input['luas_m2']) ? (float)$input['luas_m2'] : null;
|
||||
|
||||
if (!$id) { echo json_encode(['status'=>'error','message'=>'ID tidak valid.']); break; }
|
||||
if (!$nama) { echo json_encode(['status'=>'error','message'=>'Nama parsil wajib diisi.']); break; }
|
||||
$validStatus = ['SHM','HGB','HGU','HP'];
|
||||
if (!in_array($status, $validStatus)) $status = 'SHM';
|
||||
|
||||
// If geometry provided, validate/convert to GeoJSON string
|
||||
$geojson = null;
|
||||
if ($geom) {
|
||||
if (is_array($geom)) {
|
||||
if (isset($geom['type']) && $geom['type'] === 'Polygon' && isset($geom['coordinates'])) {
|
||||
$geojson = json_encode($geom);
|
||||
} elseif (isset($geom[0]) && is_array($geom[0])) {
|
||||
$geojson = json_encode(['type'=>'Polygon','coordinates'=>$geom]);
|
||||
}
|
||||
} elseif (is_string($geom)) {
|
||||
$decoded = json_decode($geom, true);
|
||||
if ($decoded && isset($decoded['type']) && $decoded['type'] === 'Polygon') $geojson = $geom;
|
||||
}
|
||||
if (!$geojson) { echo json_encode(['status'=>'error','message'=>'Format geometri tidak valid untuk update.']); break; }
|
||||
}
|
||||
|
||||
// Build update query dynamically
|
||||
$fields = ['nama = ?', 'status_kepemilikan = ?', 'updatedat = NOW()'];
|
||||
$params = [$nama, $status];
|
||||
if ($luas !== null) { $fields[] = 'luas_m2 = ?'; $params[] = $luas; }
|
||||
if ($geojson !== null) { $fields[] = 'geom = ?'; $params[] = $geojson; }
|
||||
$params[] = $id;
|
||||
|
||||
$sql = "UPDATE parsil SET " . implode(', ', $fields) . " WHERE id=?";
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
echo json_encode(['status'=>'success','message'=>'Data parsil berhasil diperbarui.']);
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$id = (int)($input['id'] ?? 0);
|
||||
if (!$id) { echo json_encode(['status'=>'error','message'=>'ID tidak valid.']); break; }
|
||||
$stmt = $db->prepare("DELETE FROM parsil WHERE id=?");
|
||||
$stmt->execute([$id]);
|
||||
echo json_encode(['status'=>'success','message'=>'Data parsil berhasil dihapus.']);
|
||||
break;
|
||||
|
||||
default:
|
||||
echo json_encode(['status'=>'error','message'=>'Aksi tidak dikenali.']);
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
echo json_encode(['status'=>'error','message'=>'Database error: '.$e->getMessage()]);
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
require __DIR__ . '/db.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit(); }
|
||||
|
||||
$action = $_GET['action'] ?? '';
|
||||
if ($action === '') {
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$action = $input['action'] ?? '';
|
||||
}
|
||||
|
||||
try {
|
||||
switch ($action) {
|
||||
case 'list':
|
||||
$rows = $db->query("SELECT id, namaspbu, nomorwa, statusbuka,
|
||||
JSON_EXTRACT(koordinat, '$.coordinates[1]') as lat,
|
||||
JSON_EXTRACT(koordinat, '$.coordinates[0]') as lng,
|
||||
koordinat
|
||||
FROM spbu ORDER BY createdat DESC")->fetchAll();
|
||||
foreach ($rows as &$r) {
|
||||
$r['lat'] = (float)$r['lat'];
|
||||
$r['lng'] = (float)$r['lng'];
|
||||
}
|
||||
echo json_encode(['status' => 'success', 'data' => $rows]);
|
||||
break;
|
||||
|
||||
case 'create':
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$namaspbu = trim($input['namaspbu'] ?? '');
|
||||
$nomorwa = trim($input['nomorwa'] ?? '');
|
||||
$statusbuka = $input['statusbuka'] ?? 'limited';
|
||||
$koordinat = $input['koordinat'] ?? null;
|
||||
|
||||
if (!$namaspbu) {
|
||||
echo json_encode(['status'=>'error','message'=>'Nama SPBU wajib diisi.']);
|
||||
break;
|
||||
}
|
||||
if (!in_array($statusbuka, ['full','limited'])) $statusbuka = 'limited';
|
||||
|
||||
// Validasi dan konversi geometri
|
||||
$geojson = null;
|
||||
if (is_array($koordinat)) {
|
||||
// Cek apakah sudah dalam format GeoJSON Point
|
||||
if (isset($koordinat['type']) && $koordinat['type'] === 'Point' && isset($koordinat['coordinates'])) {
|
||||
$geojson = json_encode($koordinat);
|
||||
}
|
||||
// Mungkin hanya array [lng, lat]
|
||||
elseif (isset($koordinat[0]) && isset($koordinat[1])) {
|
||||
$geojson = json_encode([
|
||||
'type' => 'Point',
|
||||
'coordinates' => [(float)$koordinat[0], (float)$koordinat[1]]
|
||||
]);
|
||||
}
|
||||
// Mungkin objek dengan lat,lng
|
||||
elseif (isset($koordinat['lat']) && isset($koordinat['lng'])) {
|
||||
$geojson = json_encode([
|
||||
'type' => 'Point',
|
||||
'coordinates' => [(float)$koordinat['lng'], (float)$koordinat['lat']]
|
||||
]);
|
||||
}
|
||||
} elseif (is_string($koordinat)) {
|
||||
// Coba parse JSON
|
||||
$decoded = json_decode($koordinat, true);
|
||||
if ($decoded && isset($decoded['type']) && $decoded['type'] === 'Point') {
|
||||
$geojson = $koordinat;
|
||||
} else {
|
||||
echo json_encode(['status'=>'error','message'=>'Format koordinat tidak valid.']);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$geojson) {
|
||||
echo json_encode(['status'=>'error','message'=>'Geometri tidak valid. Harus berupa GeoJSON Point atau koordinat [lng, lat].']);
|
||||
break;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("INSERT INTO spbu (namaspbu, nomorwa, statusbuka, koordinat) VALUES (?, ?, ?, ?)");
|
||||
$stmt->execute([$namaspbu, $nomorwa, $statusbuka, $geojson]);
|
||||
echo json_encode(['status'=>'success','message'=>'Data berhasil disimpan!','id'=>$db->lastInsertId()]);
|
||||
break;
|
||||
|
||||
case 'update':
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$id = (int)($input['id'] ?? 0);
|
||||
$namaspbu = trim($input['namaspbu'] ?? '');
|
||||
$nomorwa = trim($input['nomorwa'] ?? '');
|
||||
$statusbuka = $input['statusbuka'] ?? 'limited';
|
||||
$koordinat = $input['koordinat'] ?? null;
|
||||
|
||||
if (!$id) {
|
||||
echo json_encode(['status'=>'error','message'=>'ID tidak valid.']);
|
||||
break;
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// JIKA ADA KOORDINAT → UPDATE KOORDINAT
|
||||
// ===============================
|
||||
if ($koordinat) {
|
||||
$geojson = json_encode($koordinat);
|
||||
|
||||
$stmt = $db->prepare("UPDATE spbu SET koordinat=?, updatedat=NOW() WHERE id=?");
|
||||
$stmt->execute([$geojson, $id]);
|
||||
|
||||
echo json_encode(['status'=>'success','message'=>'Koordinat berhasil diperbarui!']);
|
||||
break;
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// UPDATE DATA BIASA
|
||||
// ===============================
|
||||
if (!$namaspbu) {
|
||||
echo json_encode(['status'=>'error','message'=>'Nama SPBU wajib diisi.']);
|
||||
break;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("UPDATE spbu SET namaspbu=?, nomorwa=?, statusbuka=?, updatedat=NOW() WHERE id=?");
|
||||
$stmt->execute([$namaspbu, $nomorwa, $statusbuka, $id]);
|
||||
|
||||
echo json_encode(['status'=>'success','message'=>'Data berhasil diperbarui!']);
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$id = (int)($input['id'] ?? 0);
|
||||
if (!$id) { echo json_encode(['status'=>'error','message'=>'ID tidak valid.']); break; }
|
||||
$stmt = $db->prepare("DELETE FROM spbu WHERE id=?");
|
||||
$stmt->execute([$id]);
|
||||
echo json_encode(['status'=>'success','message'=>'Data berhasil dihapus.']);
|
||||
break;
|
||||
|
||||
default:
|
||||
echo json_encode(['status'=>'error','message'=>'Aksi tidak dikenali.']);
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
echo json_encode(['status'=>'error','message'=>'Database error: '.$e->getMessage()]);
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
:root {
|
||||
--bg: #f0f4f8;
|
||||
--sf: #ffffff;
|
||||
--sf2: #f7f9fc;
|
||||
--sf3: #eef2f7;
|
||||
--bd: #dde3ec;
|
||||
--bdh: #b0bac8;
|
||||
--tx: #1a2030;
|
||||
--txs: #4a5568;
|
||||
--txd: #8a96a8;
|
||||
|
||||
--spbu-24-fill: #22c55e;
|
||||
--spbu-24-bg: rgba(34,197,94,.10);
|
||||
--spbu-24-ring: rgba(34,197,94,.25);
|
||||
--spbu-ltd-fill: #f87171;
|
||||
--spbu-ltd-bg: rgba(248,113,113,.10);
|
||||
--spbu-ltd-ring: rgba(248,113,113,.25);
|
||||
|
||||
--jalan-nas: #f97316;
|
||||
--jalan-nas-bg: rgba(249,115,22,.10);
|
||||
--jalan-nas-ring: rgba(249,115,22,.28);
|
||||
--jalan-prov: #3b82f6;
|
||||
--jalan-prov-bg: rgba(59,130,246,.10);
|
||||
--jalan-prov-ring: rgba(59,130,246,.28);
|
||||
--jalan-kab: #10b981;
|
||||
--jalan-kab-bg: rgba(16,185,129,.10);
|
||||
--jalan-kab-ring: rgba(16,185,129,.28);
|
||||
|
||||
--shm-stroke: #d97706; --shm-fill: #fef3c7; --shm-bg: rgba(217,119,6,.10); --shm-ring: rgba(217,119,6,.28);
|
||||
--hgb-stroke: #8b5cf6; --hgb-fill: #ede9fe; --hgb-bg: rgba(139,92,246,.10); --hgb-ring: rgba(139,92,246,.28);
|
||||
--hgu-stroke: #0891b2; --hgu-fill: #cffafe; --hgu-bg: rgba(8,145,178,.10); --hgu-ring: rgba(8,145,178,.28);
|
||||
--hp-stroke: #ec4899; --hp-fill: #fce7f3; --hp-bg: rgba(236,72,153,.10); --hp-ring: rgba(236,72,153,.28);
|
||||
|
||||
--green: #16a34a;
|
||||
--greenb: rgba(22,163,74,.10);
|
||||
--greenr: rgba(22,163,74,.25);
|
||||
--red: #dc2626;
|
||||
--redb: rgba(220,38,38,.10);
|
||||
--redr: rgba(220,38,38,.25);
|
||||
--blue: #2563eb;
|
||||
--blueb: rgba(37,99,235,.10);
|
||||
--bluer: rgba(37,99,235,.25);
|
||||
--amber: #d97706;
|
||||
--amberb: rgba(217,119,6,.10);
|
||||
--amberr: rgba(217,119,6,.25);
|
||||
--purple: #7c3aed;
|
||||
--purpleb: rgba(124,58,237,.10);
|
||||
--purpler: rgba(124,58,237,.25);
|
||||
--indigo: #4f46e5;
|
||||
--indigob: rgba(79,70,229,.10);
|
||||
--indigor: rgba(79,70,229,.25);
|
||||
|
||||
--sh-sm: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.06);
|
||||
--sh: 0 4px 16px rgba(0,0,0,.10), 0 1px 4px rgba(0,0,0,.06);
|
||||
--sh-lg: 0 12px 40px rgba(0,0,0,.14), 0 4px 12px rgba(0,0,0,.08);
|
||||
|
||||
--sidebar: 275px;
|
||||
--topbar: 52px;
|
||||
--radius: 10px;
|
||||
--radius-lg: 14px;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body {
|
||||
height: 100%; overflow: hidden;
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
font-size: 14px; background: var(--bg); color: var(--tx);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
#app { display: flex; height: 100vh; }
|
||||
|
||||
/* ── LOADING ── */
|
||||
#loading { position:fixed; inset:0; z-index:9999; background:var(--bg); display:flex; align-items:center; justify-content:center; }
|
||||
.ld-inner { display:flex; flex-direction:column; align-items:center; gap:14px; }
|
||||
.ld-inner p { font-size:13px; color:var(--txd); }
|
||||
.spin { width:32px; height:32px; border:3px solid var(--bd); border-top-color:var(--green); border-radius:50%; animation:rot .7s linear infinite; }
|
||||
@keyframes rot { to { transform:rotate(360deg); } }
|
||||
|
||||
/* ── SIDEBAR ── */
|
||||
#sidebar { width:var(--sidebar); min-width:var(--sidebar); background:var(--sf); border-right:1px solid var(--bd); display:flex; flex-direction:column; box-shadow:var(--sh-sm); z-index:900; }
|
||||
.sb-header { padding:14px 16px; border-bottom:1px solid var(--bd); }
|
||||
.sb-brand { display:flex; align-items:center; gap:10px; }
|
||||
.sb-icon { width:36px; height:36px; border-radius:9px; flex-shrink:0; background:var(--greenb); border:1px solid var(--greenr); display:flex; align-items:center; justify-content:center; color:var(--green); font-size:18px; }
|
||||
.sb-title { font-size:14px; font-weight:800; }
|
||||
.sb-sub { font-size:10px; color:var(--txd); margin-top:1px; }
|
||||
.sb-scroll { flex:1; overflow-y:auto; padding:12px; display:flex; flex-direction:column; gap:14px; }
|
||||
.sb-scroll::-webkit-scrollbar { width:4px; }
|
||||
.sb-scroll::-webkit-scrollbar-thumb { background:var(--bd); border-radius:4px; }
|
||||
.sb-section { display:flex; flex-direction:column; gap:6px; }
|
||||
.sb-label { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.8px; color:var(--txd); }
|
||||
|
||||
/* ── LEGEND CARD ── */
|
||||
.legend-card { background:var(--sf2); border:1px solid var(--bd); border-radius:var(--radius); padding:10px 12px; display:flex; flex-direction:column; gap:7px; }
|
||||
.leg-row { display:flex; align-items:center; gap:9px; font-size:12px; color:var(--txs); font-weight:500; }
|
||||
.leg-dot { width:12px; height:12px; border-radius:50%; flex-shrink:0; border:2px solid rgba(0,0,0,.08); }
|
||||
.leg-dot.spbu-24 { background:var(--spbu-24-fill); box-shadow:0 0 0 3px var(--spbu-24-bg); }
|
||||
.leg-dot.spbu-ltd { background:var(--spbu-ltd-fill); box-shadow:0 0 0 3px var(--spbu-ltd-bg); }
|
||||
.leg-line { width:24px; height:4px; border-radius:3px; flex-shrink:0; }
|
||||
.leg-line.jalan-nas { background:var(--jalan-nas); }
|
||||
.leg-line.jalan-prov { background:var(--jalan-prov); }
|
||||
.leg-line.jalan-kab { background:var(--jalan-kab); }
|
||||
.leg-poly { width:18px; height:13px; border-radius:3px; flex-shrink:0; border:2px solid; }
|
||||
.leg-poly.parsil-shm { background:var(--shm-fill); border-color:var(--shm-stroke); }
|
||||
.leg-poly.parsil-hgb { background:var(--hgb-fill); border-color:var(--hgb-stroke); }
|
||||
.leg-poly.parsil-hgu { background:var(--hgu-fill); border-color:var(--hgu-stroke); }
|
||||
.leg-poly.parsil-hp { background:var(--hp-fill); border-color:var(--hp-stroke); }
|
||||
|
||||
/* ── STATS ── */
|
||||
.stat-grid { display:grid; grid-template-columns:1fr 1fr; gap:7px; }
|
||||
.stat-item { background:var(--sf2); border:1px solid var(--bd); border-radius:var(--radius); padding:10px; text-align:center; }
|
||||
.stat-item.green { background:var(--greenb); border-color:var(--greenr); }
|
||||
.stat-item.blue { background:var(--blueb); border-color:var(--bluer); }
|
||||
.stat-item.purple { background:var(--purpleb);border-color:var(--purpler); }
|
||||
.stat-num { font-size:22px; font-weight:800; font-family:'Fira Code',monospace; color:var(--tx); }
|
||||
.stat-item.green .stat-num { color:var(--green); }
|
||||
.stat-item.blue .stat-num { color:var(--blue); }
|
||||
.stat-item.purple .stat-num { color:var(--purple); }
|
||||
.stat-lbl { font-size:10px; color:var(--txd); margin-top:2px; }
|
||||
|
||||
/* ── SPBU LIST ── */
|
||||
.spbu-item { background:var(--sf); border:1px solid var(--bd); border-radius:var(--radius); padding:9px 11px; cursor:pointer; transition:border-color .18s, background .18s; margin-bottom:5px; display:flex; flex-direction:column; gap:6px; }
|
||||
.spbu-item:hover { border-color:var(--green); background:var(--greenb); }
|
||||
.si-top { display:flex; align-items:center; justify-content:space-between; gap:6px; }
|
||||
.si-name { font-size:13px; font-weight:700; display:flex; align-items:center; gap:7px; }
|
||||
.si-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
|
||||
.si-dot.dot-green { background:var(--spbu-24-fill); box-shadow:0 0 0 2px var(--spbu-24-bg); }
|
||||
.si-dot.dot-red { background:var(--spbu-ltd-fill); box-shadow:0 0 0 2px var(--spbu-ltd-bg); }
|
||||
.si-actions { display:flex; gap:4px; flex-shrink:0; }
|
||||
.empty { text-align:center; padding:24px 0; font-size:12px; color:var(--txd); }
|
||||
|
||||
/* ── ICON BUTTONS ── */
|
||||
.ib { width:34px; height:34px; border-radius:8px; background:var(--sf2); border:1px solid var(--bd); color:var(--txs); cursor:pointer; display:flex; align-items:center; justify-content:center; transition:all .18s; flex-shrink:0; font-size:13px; }
|
||||
.ib.edt { background:linear-gradient(180deg,#fff8f0,#fff1e6); border-color:rgba(217,119,6,.12); color:var(--amber); }
|
||||
.ib.del { background:linear-gradient(180deg,#fff7f7,#fff0f0); border-color:rgba(220,38,38,.08); color:var(--red); }
|
||||
.ib:hover { transform:translateY(-2px); box-shadow:0 6px 18px rgba(10,20,40,.06); }
|
||||
|
||||
/* ── BADGE ── */
|
||||
.badge { display:inline-flex; align-items:center; font-size:10px; font-weight:700; padding:2px 8px; border-radius:20px; border:1px solid; }
|
||||
.badge.bg { background:var(--spbu-24-bg); border-color:var(--spbu-24-ring); color:#15803d; }
|
||||
.badge.br { background:var(--spbu-ltd-bg); border-color:var(--spbu-ltd-ring); color:#dc2626; }
|
||||
.badge.bb-orange { background:var(--jalan-nas-bg); border-color:var(--jalan-nas-ring); color:#c2410c; }
|
||||
.badge.bb-blue { background:var(--jalan-prov-bg); border-color:var(--jalan-prov-ring); color:#1d4ed8; }
|
||||
.badge.bb-green { background:var(--jalan-kab-bg); border-color:var(--jalan-kab-ring); color:#065f46; }
|
||||
.badge.bp-shm { background:var(--shm-bg); border-color:var(--shm-ring); color:var(--shm-stroke); }
|
||||
.badge.bp-hgb { background:var(--hgb-bg); border-color:var(--hgb-ring); color:var(--hgb-stroke); }
|
||||
.badge.bp-hgu { background:var(--hgu-bg); border-color:var(--hgu-ring); color:var(--hgu-stroke); }
|
||||
.badge.bp-hp { background:var(--hp-bg); border-color:var(--hp-ring); color:var(--hp-stroke); }
|
||||
|
||||
/* ── GEOM SELECTOR ── */
|
||||
.geom-selector { display:flex; gap:3px; background:var(--sf2); border:1px solid var(--bd); border-radius:9px; padding:3px; }
|
||||
.geom-btn { flex:1; display:flex; align-items:center; justify-content:center; gap:5px; padding:6px 8px; border-radius:7px; border:none; background:transparent; color:var(--txs); font-family:'Plus Jakarta Sans',sans-serif; font-size:11px; font-weight:600; cursor:pointer; transition:all .18s; white-space:nowrap; }
|
||||
.geom-btn:hover { background:var(--sf); color:var(--tx); }
|
||||
.geom-btn.active { background:var(--green); color:#fff; box-shadow:0 2px 8px rgba(22,163,74,.3); }
|
||||
.geom-hint { font-size:10px; color:var(--txd); text-align:left; margin-top:4px; min-height:14px; padding-left:2px; transition:color .18s; display:flex; align-items:center; gap:5px; }
|
||||
.geom-hint .gh-dot { width:6px; height:6px; border-radius:50%; background:var(--bdh); flex-shrink:0; transition:background .18s; }
|
||||
.geom-hint.on { color:var(--green); font-weight:600; }
|
||||
.geom-hint.on .gh-dot { background:var(--green); box-shadow:0 0 0 2px rgba(22,163,74,.2); }
|
||||
|
||||
/* ── MAIN ── */
|
||||
#main { flex:1; display:flex; flex-direction:column; overflow:hidden; position:relative; }
|
||||
#topbar { height:var(--topbar); min-height:var(--topbar); background:var(--sf); border-bottom:1px solid var(--bd); display:flex; align-items:center; padding:0 12px; gap:8px; box-shadow:var(--sh-sm); overflow:visible; position:relative; z-index:850; }
|
||||
.coord-pill { flex:0 0 auto; font-family:'Fira Code',monospace; font-size:11px; color:var(--txd); background:var(--sf2); border:1px solid var(--bd); border-radius:7px; padding:4px 10px; display:flex; align-items:center; gap:5px; white-space:nowrap; }
|
||||
.pill-sep { color:var(--bdh); }
|
||||
|
||||
/* ── SEARCH ── */
|
||||
#search-wrap {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
max-width: 340px;
|
||||
z-index: 1300;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
background: var(--sf2);
|
||||
border: 1.5px solid var(--bd);
|
||||
border-radius: 9px;
|
||||
padding: 0 10px;
|
||||
height: 34px;
|
||||
transition: border-color .18s, box-shadow .18s;
|
||||
}
|
||||
|
||||
.search-box:focus-within {
|
||||
border-color: var(--green);
|
||||
box-shadow: 0 0 0 3px rgba(22,163,74,.08);
|
||||
}
|
||||
|
||||
.search-icon { font-size: 13px; flex-shrink: 0; opacity: .6; }
|
||||
|
||||
#search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
font-size: 13px;
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
#search-input::placeholder { color: var(--txd); }
|
||||
|
||||
#search-results {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--sf);
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: 11px;
|
||||
box-shadow: var(--sh-lg);
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(6px);
|
||||
transition: opacity .18s, transform .18s;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#search-results.open {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.sr-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 12px;
|
||||
cursor: pointer;
|
||||
transition: background .14s;
|
||||
border-bottom: 1px solid var(--sf3);
|
||||
}
|
||||
|
||||
.sr-item:last-child { border-bottom: none; }
|
||||
|
||||
.sr-item:hover { background: var(--sf2); }
|
||||
|
||||
.sr-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
background: var(--sf2);
|
||||
border: 1px solid var(--bd);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sr-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
||||
.sr-label { font-size: 13px; font-weight: 700; color: var(--tx); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.sr-sub { font-size: 10px; color: var(--txd); font-weight: 500; }
|
||||
.sr-empty { padding: 16px 12px; font-size: 12px; color: var(--txd); text-align: center; }
|
||||
|
||||
/* ── LAYER DROPDOWN ── */
|
||||
.layer-wrap { position:relative; flex-shrink:0; z-index:1100; }
|
||||
.layer-btn { height:34px; padding:0 12px; border-radius:8px; background:var(--sf2); border:1px solid var(--bd); color:var(--txs); font-size:12px; font-weight:600; font-family:'Plus Jakarta Sans',sans-serif; cursor:pointer; display:flex; align-items:center; gap:6px; transition:all .18s; white-space:nowrap; }
|
||||
.layer-btn:hover { border-color:var(--bdh); color:var(--tx); }
|
||||
.layer-menu { position:absolute; top:calc(100% + 6px); right:0; background:var(--sf); border:1px solid var(--bd); border-radius:11px; padding:6px; min-width:200px; box-shadow:var(--sh-lg); z-index:1200; opacity:0; pointer-events:none; transform:translateY(6px); transition:opacity .18s, transform .18s; }
|
||||
.layer-menu.open { opacity:1; pointer-events:all; transform:translateY(0); }
|
||||
.lm-label { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.8px; color:var(--txd); padding:4px 8px 6px; }
|
||||
.lm-item { width:100%; display:flex; align-items:center; gap:8px; padding:7px 8px; border-radius:7px; border:none; background:transparent; color:var(--txs); font-size:12px; font-family:'Plus Jakarta Sans',sans-serif; cursor:pointer; transition:all .15s; text-align:left; }
|
||||
.lm-item:hover { background:var(--sf2); color:var(--tx); }
|
||||
.lm-item.active { color:var(--tx); font-weight:600; }
|
||||
|
||||
/* ── MAP ── */
|
||||
#map-wrap { flex:1; position:relative; overflow:hidden; }
|
||||
#map { width:100%; height:100%; }
|
||||
.leaflet-container.crosshair-cursor { cursor:crosshair !important; }
|
||||
|
||||
/* ── DRAW TOOLBAR ── */
|
||||
#draw-toolbar, #geom-edit-toolbar {
|
||||
position:absolute; bottom:28px; left:50%; transform:translateX(-50%) translateY(12px);
|
||||
z-index:1000; display:flex; align-items:center; gap:5px;
|
||||
background:var(--sf); border:1px solid var(--bd); border-radius:14px;
|
||||
padding:8px 14px; box-shadow:var(--sh-lg);
|
||||
pointer-events:all; opacity:0; visibility:hidden;
|
||||
transition:opacity .22s, transform .22s, visibility .22s;
|
||||
}
|
||||
#draw-toolbar.visible, #geom-edit-toolbar.visible {
|
||||
opacity:1; visibility:visible; transform:translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
/* geom-edit toolbar sits a bit higher so they don't overlap */
|
||||
#geom-edit-toolbar { bottom: 28px; }
|
||||
#draw-toolbar.visible ~ #geom-edit-toolbar.visible { bottom: 90px; }
|
||||
|
||||
.dt-hint { font-size:12px; font-weight:600; color:var(--txs); padding:0 4px; white-space:nowrap; }
|
||||
.dt-counter { font-family:'Fira Code',monospace; font-size:12px; font-weight:600; color:var(--blue); background:var(--blueb); border:1px solid var(--bluer); border-radius:7px; padding:4px 10px; white-space:nowrap; }
|
||||
.geom-edit-counter { color:var(--indigo); background:var(--indigob); border-color:var(--indigor); min-width:120px; }
|
||||
.dt-btn { display:flex; align-items:center; gap:5px; padding:7px 13px; border-radius:9px; border:1.5px solid var(--bd); background:var(--sf2); color:var(--txs); font-family:'Plus Jakarta Sans',sans-serif; font-size:12px; font-weight:600; cursor:pointer; transition:all .18s; white-space:nowrap; flex-shrink:0; }
|
||||
.dt-btn:hover { border-color:var(--bdh); background:var(--sf); color:var(--tx); }
|
||||
.dt-btn:disabled { opacity:.4; cursor:not-allowed; }
|
||||
.dt-btn.undo:hover { background:var(--amberb); border-color:var(--amberr); color:var(--amber); }
|
||||
.dt-btn.redo:hover { background:var(--blueb); border-color:var(--bluer); color:var(--blue); }
|
||||
.dt-btn.cancel { background:var(--redb); border-color:var(--redr); color:var(--red); }
|
||||
.dt-btn.cancel:hover { background:var(--red); color:#fff; border-color:var(--red); }
|
||||
.dt-btn.finish { background:var(--greenb); border-color:var(--greenr); color:var(--green); }
|
||||
.dt-btn.finish:hover { background:var(--green); color:#fff; border-color:var(--green); }
|
||||
|
||||
/* ── POPUP ── */
|
||||
.leaflet-popup-content-wrapper { background:var(--sf) !important; border:1px solid var(--bd) !important; border-radius:14px !important; box-shadow:var(--sh-lg) !important; padding:0 !important; overflow:hidden !important; color:var(--tx) !important; }
|
||||
.leaflet-popup-content { margin:0 !important; width:auto !important; }
|
||||
.leaflet-popup-tip-container { display:none; }
|
||||
.leaflet-popup-close-button { top:10px !important; right:12px !important; width:26px !important; height:26px !important; font-size:14px !important; line-height:26px !important; color:var(--txd) !important; background:var(--sf2) !important; border-radius:8px !important; border:1px solid var(--bd) !important; }
|
||||
|
||||
/* ── POPUP DETAIL CARD ── */
|
||||
.pcard-detail { min-width:280px; max-width:360px; font-family:'Plus Jakarta Sans',sans-serif; }
|
||||
.pcd-title { font-size:16px; font-weight:800; color:var(--tx); padding:12px 16px 4px; display:flex; align-items:center; gap:8px; border-bottom:1px solid var(--bd); margin-bottom:8px; }
|
||||
.pcd-title[data-type="spbu"]::before { content:"⛽"; font-size:18px; }
|
||||
.pcd-title[data-type="jalan"]::before { content:"🛣️"; font-size:18px; }
|
||||
.pcd-title[data-type="parsil"]::before { content:"🗺️"; font-size:18px; }
|
||||
.pcd-rows { display:flex; flex-direction:column; gap:10px; padding:0 16px 12px; }
|
||||
.pcd-row { display:flex; align-items:center; justify-content:space-between; background:var(--sf2); padding:8px 12px; border-radius:12px; border:1px solid var(--bd); transition:all .2s; }
|
||||
.pcd-row:hover { background:var(--sf); border-color:var(--bdh); }
|
||||
.pcd-label { font-size:12px; font-weight:600; color:var(--txd); display:flex; align-items:center; gap:6px; }
|
||||
.pcd-val { font-size:13px; font-weight:700; color:var(--tx); text-align:right; word-break:break-word; max-width:180px; }
|
||||
.pcd-actions { display:flex; gap:7px; padding:8px 16px 16px; border-top:1px solid var(--bd); margin-top:4px; flex-wrap:wrap; }
|
||||
|
||||
/* ── POPUP BUTTONS ── */
|
||||
.pbtn {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all .2s;
|
||||
border: none;
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
}
|
||||
|
||||
.pbtn-edit {
|
||||
background: linear-gradient(135deg, #fffbeb, #fff3e0);
|
||||
color: #d97706;
|
||||
border: 1.5px solid #fde68a;
|
||||
}
|
||||
.pbtn-edit:hover {
|
||||
background: linear-gradient(135deg, #fef3c7, #ffedd5);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 10px rgba(217,119,6,.15);
|
||||
}
|
||||
|
||||
.pbtn-move {
|
||||
background: linear-gradient(135deg, #eef2ff, #e0e7ff);
|
||||
color: #4f46e5;
|
||||
border: 1.5px solid #c7d2fe;
|
||||
}
|
||||
.pbtn-move:hover {
|
||||
background: linear-gradient(135deg, #e0e7ff, #c7d2fe);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 10px rgba(79,70,229,.15);
|
||||
}
|
||||
|
||||
.pbtn-del {
|
||||
background: linear-gradient(135deg, #fef2f2, #fee2e2);
|
||||
color: #dc2626;
|
||||
border: 1.5px solid #fecaca;
|
||||
}
|
||||
.pbtn-del:hover {
|
||||
background: linear-gradient(135deg, #fee2e2, #fecaca);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 10px rgba(220,38,38,.15);
|
||||
}
|
||||
|
||||
/* ── OVERLAY / MODAL ── */
|
||||
.overlay { position:fixed; inset:0; z-index:2000; background:rgba(15,20,35,.45); backdrop-filter:blur(6px); display:flex; align-items:center; justify-content:center; opacity:0; pointer-events:none; transition:opacity .22s; }
|
||||
.overlay.open { opacity:1; pointer-events:all; }
|
||||
.modal { background:var(--sf); border:1px solid var(--bd); border-radius:var(--radius-lg); padding:22px; width:460px; max-width:96vw; box-shadow:var(--sh-lg); transform:translateY(14px) scale(.98); transition:transform .22s; }
|
||||
.overlay.open .modal { transform:translateY(0) scale(1); }
|
||||
.modal-head { display:flex; align-items:center; gap:11px; margin-bottom:16px; }
|
||||
.modal-head h2 { font-size:15px; font-weight:800; }
|
||||
.form-group { margin-bottom:12px; }
|
||||
.form-group label { display:block; font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.5px; color:var(--txd); margin-bottom:5px; }
|
||||
.form-group input, .form-group select { width:100%; padding:10px 12px; background:var(--sf2); border:1.5px solid var(--bd); border-radius:10px; color:var(--tx); font-size:14px; font-family:'Plus Jakarta Sans',sans-serif; outline:none; transition:border-color .18s, box-shadow .18s; appearance:none; }
|
||||
.form-group input:focus, .form-group select:focus { border-color:var(--green); box-shadow:0 0 0 3px rgba(22,163,74,.08); }
|
||||
.form-group input::placeholder { color:var(--txd); }
|
||||
.form-group select { cursor:pointer; background-image:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="%234a5568" stroke-width="2"><polyline points="6 9 12 15 18 9"></polyline></svg>'); background-repeat:no-repeat; background-position:right 10px center; background-size:16px; padding-right:32px; }
|
||||
.modal-foot { display:flex; gap:8px; margin-top:16px; }
|
||||
.btn-ghost { flex:1; padding:10px; border-radius:8px; border:1.5px solid var(--bd); background:transparent; color:var(--txs); font-size:13px; font-weight:600; font-family:'Plus Jakarta Sans',sans-serif; cursor:pointer; transition:all .18s; }
|
||||
.btn-ghost:hover { border-color:var(--bdh); color:var(--tx); background:var(--sf2); }
|
||||
.btn-primary { flex:1.4; padding:10px; border-radius:8px; border:none; color:#fff; font-size:13px; font-weight:700; font-family:'Plus Jakarta Sans',sans-serif; cursor:pointer; transition:opacity .18s, transform .12s; display:flex; align-items:center; justify-content:center; gap:6px; }
|
||||
.btn-primary:hover { opacity:.9; transform:translateY(-1px); }
|
||||
.btn-primary.green { background:linear-gradient(135deg,#22c55e,#16a34a); }
|
||||
.btn-primary.blue { background:linear-gradient(135deg,#3b82f6,#2563eb); }
|
||||
.btn-primary.purple { background:linear-gradient(135deg,#a78bfa,#7c3aed); }
|
||||
.btn-primary.red { background:linear-gradient(135deg,#f87171,#dc2626); }
|
||||
|
||||
/* ── CUSTOM CONFIRM DIALOG ── */
|
||||
.confirm-modal {
|
||||
width: 380px;
|
||||
max-width: 94vw;
|
||||
text-align: center;
|
||||
padding: 28px 24px 22px;
|
||||
}
|
||||
|
||||
.confirm-icon {
|
||||
font-size: 38px;
|
||||
margin-bottom: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.confirm-title {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: var(--tx);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.confirm-message {
|
||||
font-size: 13px;
|
||||
color: var(--txs);
|
||||
margin-bottom: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.confirm-modal .modal-foot {
|
||||
margin-top: 20px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.confirm-modal .btn-ghost { flex: 0 0 auto; min-width: 90px; }
|
||||
.confirm-modal .btn-primary { flex: 0 0 auto; min-width: 120px; }
|
||||
|
||||
/* ── MAP LABELS ── */
|
||||
.label-tooltip, .label-tooltip-line, .label-tooltip-polygon {
|
||||
background: rgba(255,255,255,.95);
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: 6px;
|
||||
padding: 5px 9px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--tx);
|
||||
box-shadow: var(--sh-sm);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── TOAST ── */
|
||||
#toasts { position:fixed; bottom:18px; right:18px; z-index:9999; display:flex; flex-direction:column; gap:6px; }
|
||||
.toast { background:var(--sf); border:1px solid var(--bd); border-radius:9px; padding:9px 14px; font-size:12px; font-weight:600; display:flex; align-items:center; gap:8px; min-width:220px; box-shadow:var(--sh); animation:tslide .22s ease; }
|
||||
.toast.ok { border-color:var(--greenr); color:var(--green); }
|
||||
.toast.er { border-color:var(--redr); color:var(--red); }
|
||||
@keyframes tslide { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:translateY(0); } }
|
||||
|
||||
/* ── RESPONSIVE ── */
|
||||
@media (max-width:640px) {
|
||||
.modal { width:92vw; padding:16px; }
|
||||
.pcard-detail { min-width:200px; max-width:260px; }
|
||||
#search-wrap { max-width:160px; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
-- Tabel SPBU (Point)
|
||||
CREATE TABLE `spbu` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`namaspbu` varchar(150) NOT NULL,
|
||||
`nomorwa` varchar(20) DEFAULT NULL,
|
||||
`statusbuka` enum('full','limited') DEFAULT 'limited',
|
||||
`koordinat` JSON DEFAULT NULL COMMENT 'GeoJSON Point',
|
||||
`lat` decimal(10,8) GENERATED ALWAYS AS (JSON_EXTRACT(`koordinat`, '$.coordinates[1]')) STORED,
|
||||
`lng` decimal(11,8) GENERATED ALWAYS AS (JSON_EXTRACT(`koordinat`, '$.coordinates[0]')) STORED,
|
||||
`createdat` timestamp NOT NULL DEFAULT current_timestamp(),
|
||||
`updatedat` timestamp NULL DEFAULT NULL ON UPDATE current_timestamp(),
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
-- Tabel Jalan (Polyline)
|
||||
CREATE TABLE `jalan` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`nama` varchar(150) NOT NULL,
|
||||
`status` enum('nasional','provinsi','kabupaten') NOT NULL DEFAULT 'kabupaten',
|
||||
`panjang_meter` decimal(12,2) DEFAULT NULL,
|
||||
`geom` JSON NOT NULL COMMENT 'GeoJSON LineString',
|
||||
`createdat` timestamp NOT NULL DEFAULT current_timestamp(),
|
||||
`updatedat` timestamp NULL DEFAULT NULL ON UPDATE current_timestamp(),
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
-- Tabel Parsil Tanah (Polygon)
|
||||
CREATE TABLE `parsil` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`nama` varchar(150) NOT NULL,
|
||||
`status_kepemilikan` enum('SHM','HGB','HGU','HP') NOT NULL DEFAULT 'SHM',
|
||||
`luas_m2` decimal(14,2) DEFAULT NULL,
|
||||
`geom` JSON NOT NULL COMMENT 'GeoJSON Polygon',
|
||||
`createdat` timestamp NOT NULL DEFAULT current_timestamp(),
|
||||
`updatedat` timestamp NULL DEFAULT NULL ON UPDATE current_timestamp(),
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
+283
@@ -0,0 +1,283 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WebGIS SPBU, Jalan & Parsil</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Fira+Code:wght@400;500&display=swap">
|
||||
</head>
|
||||
<body>
|
||||
<div id="loading">
|
||||
<div class="ld-inner">
|
||||
<div class="spin"></div>
|
||||
<p>Memuat WebGIS…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="app">
|
||||
<!-- SIDEBAR -->
|
||||
<aside id="sidebar">
|
||||
<div class="sb-header">
|
||||
<div class="sb-brand">
|
||||
<div class="sb-icon">⛽</div>
|
||||
<div>
|
||||
<div class="sb-title">WebGIS SPBU</div>
|
||||
<div class="sb-sub">Jalan & Parsil Tanah</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sb-scroll">
|
||||
<!-- Mode Geometri -->
|
||||
<div class="sb-section">
|
||||
<div class="sb-label">Tipe Geometri</div>
|
||||
<div class="geom-selector">
|
||||
<button class="geom-btn" data-geom="point">📍 Point</button>
|
||||
<button class="geom-btn" data-geom="polyline">📏 Line</button>
|
||||
<button class="geom-btn" data-geom="polygon">🗺️ Polygon</button>
|
||||
</div>
|
||||
<div class="geom-hint" id="geom-hint">
|
||||
<span class="gh-dot"></span>
|
||||
<span id="geom-hint-text">Klik tipe untuk mulai menggambar</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legenda SPBU -->
|
||||
<div class="sb-section">
|
||||
<div class="sb-label">Legenda SPBU</div>
|
||||
<div class="legend-card">
|
||||
<div class="leg-row"><span class="leg-dot spbu-24"></span> Buka 24 Jam</div>
|
||||
<div class="leg-row"><span class="leg-dot spbu-ltd"></span> Tidak 24 Jam</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legenda Jalan -->
|
||||
<div class="sb-section">
|
||||
<div class="sb-label">Legenda Jalan</div>
|
||||
<div class="legend-card">
|
||||
<div class="leg-row"><span class="leg-line jalan-nas"></span> Jalan Nasional</div>
|
||||
<div class="leg-row"><span class="leg-line jalan-prov"></span> Jalan Provinsi</div>
|
||||
<div class="leg-row"><span class="leg-line jalan-kab"></span> Jalan Kabupaten</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legenda Parsil -->
|
||||
<div class="sb-section">
|
||||
<div class="sb-label">Legenda Parsil Tanah</div>
|
||||
<div class="legend-card">
|
||||
<div class="leg-row"><span class="leg-poly parsil-shm"></span> SHM — Hak Milik</div>
|
||||
<div class="leg-row"><span class="leg-poly parsil-hgb"></span> HGB — Hak Guna Bangunan</div>
|
||||
<div class="leg-row"><span class="leg-poly parsil-hgu"></span> HGU — Hak Guna Usaha</div>
|
||||
<div class="leg-row"><span class="leg-poly parsil-hp"></span> HP — Hak Pakai</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistik -->
|
||||
<div class="sb-section">
|
||||
<div class="sb-label">Statistik</div>
|
||||
<div class="stat-grid">
|
||||
<div class="stat-item"><div class="stat-num" id="cnt-total">—</div><div class="stat-lbl">Total SPBU</div></div>
|
||||
<div class="stat-item green"><div class="stat-num" id="cnt-24">—</div><div class="stat-lbl">Buka 24 Jam</div></div>
|
||||
<div class="stat-item blue"><div class="stat-num" id="cnt-jalan">—</div><div class="stat-lbl">Jalan</div></div>
|
||||
<div class="stat-item purple"><div class="stat-num" id="cnt-parsil">—</div><div class="stat-lbl">Parsil</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Daftar SPBU -->
|
||||
<div class="sb-section">
|
||||
<div class="sb-label">Daftar SPBU</div>
|
||||
<div id="spbu-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- MAIN PANEL -->
|
||||
<div id="main">
|
||||
<div id="topbar">
|
||||
<div class="coord-pill">
|
||||
<span>📍</span>
|
||||
<span id="cur-lat">—</span><span class="pill-sep">|</span><span id="cur-lng">—</span>
|
||||
</div>
|
||||
|
||||
<!-- SEARCH -->
|
||||
<div id="search-wrap">
|
||||
<div class="search-box">
|
||||
<span class="search-icon">🔍</span>
|
||||
<input id="search-input" type="text" placeholder="Cari SPBU, Jalan, Parsil…" autocomplete="off">
|
||||
</div>
|
||||
<div id="search-results"></div>
|
||||
</div>
|
||||
|
||||
<div class="layer-wrap" id="lw">
|
||||
<button class="layer-btn" id="ldbtn">🗺️ Layer <span class="chev">▼</span></button>
|
||||
<div class="layer-menu" id="layer-menu">
|
||||
<div class="lm-label">Tampilkan</div>
|
||||
<button class="lm-item active" id="lm-point">📍 SPBU</button>
|
||||
<button class="lm-item active" id="lm-jalan">📏 Jalan</button>
|
||||
<button class="lm-item active" id="lm-parsil">🗺️ Parsil</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="map-wrap">
|
||||
<div id="map"></div>
|
||||
|
||||
<!-- Draw toolbar -->
|
||||
<div id="draw-toolbar">
|
||||
<div class="dt-hint">ℹ️ <span id="dt-hint-text">Klik peta untuk menggambar</span></div>
|
||||
<span class="dt-counter" id="dt-counter">0 titik</span>
|
||||
<button class="dt-btn undo" id="btn-undo" disabled>↩️ Undo</button>
|
||||
<button class="dt-btn redo" id="btn-redo" disabled>↪️ Redo</button>
|
||||
<button class="dt-btn cancel" id="btn-cancel-draw">❌ Batal</button>
|
||||
<button class="dt-btn finish" id="btn-finish">✅ Selesai</button>
|
||||
</div>
|
||||
|
||||
<!-- Geom Edit toolbar -->
|
||||
<div id="geom-edit-toolbar">
|
||||
<div class="dt-hint">📐 <span id="geom-edit-hint">Drag titik untuk edit geometri</span></div>
|
||||
<span class="dt-counter geom-edit-counter" id="geom-edit-info"></span>
|
||||
<button class="dt-btn cancel" id="btn-geom-cancel">❌ Batal</button>
|
||||
<button class="dt-btn finish" id="btn-geom-save">💾 Simpan</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======================== MODALS ======================== -->
|
||||
|
||||
<!-- Modal Tambah SPBU -->
|
||||
<div class="overlay" id="m-add-spbu">
|
||||
<div class="modal">
|
||||
<div class="modal-head"><h2>Tambah SPBU</h2></div>
|
||||
<div class="form-group"><label>Nama SPBU</label><input id="spbu-nama" placeholder="Contoh: SPBU MJ 1234"></div>
|
||||
<div class="form-group"><label>No WhatsApp</label><input id="spbu-wa" placeholder="Contoh: 62812345678"></div>
|
||||
<div class="form-group">
|
||||
<label>Status</label>
|
||||
<select id="spbu-status"><option value="full">24 Jam</option><option value="limited">Tidak 24 Jam</option></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Koordinat</label>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;">
|
||||
<input id="spbu-lat" placeholder="Latitude" readonly>
|
||||
<input id="spbu-lng" placeholder="Longitude" readonly>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="btn-ghost" onclick="closeModal('m-add-spbu')">Batal</button>
|
||||
<button class="btn-primary green" onclick="saveSpbu()">Simpan</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Tambah Jalan -->
|
||||
<div class="overlay" id="m-add-jalan">
|
||||
<div class="modal">
|
||||
<div class="modal-head"><h2>Tambah Jalan</h2></div>
|
||||
<div class="form-group"><label>Nama Jalan</label><input id="j-nama" placeholder="Contoh: Jalan Ahmad Yani"></div>
|
||||
<div class="form-group">
|
||||
<label>Status Jalan</label>
|
||||
<select id="j-status"><option value="nasional">Nasional</option><option value="provinsi">Provinsi</option><option value="kabupaten">Kabupaten</option></select>
|
||||
</div>
|
||||
<div class="form-group"><label>Panjang (meter)</label><input id="j-panjang" readonly></div>
|
||||
<div class="modal-foot">
|
||||
<button class="btn-ghost" onclick="closeModal('m-add-jalan')">Batal</button>
|
||||
<button class="btn-primary green" onclick="saveJalan()">Simpan</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Tambah Parsil -->
|
||||
<div class="overlay" id="m-add-parsil">
|
||||
<div class="modal">
|
||||
<div class="modal-head"><h2>Tambah Parsil</h2></div>
|
||||
<div class="form-group"><label>Nama Parsil</label><input id="p-nama" placeholder="Contoh: Tanah A"></div>
|
||||
<div class="form-group">
|
||||
<label>Status Kepemilikan</label>
|
||||
<select id="p-status"><option value="SHM">SHM</option><option value="HGB">HGB</option><option value="HGU">HGU</option><option value="HP">HP</option></select>
|
||||
</div>
|
||||
<div class="form-group"><label>Luas (m²)</label><input id="p-luas" readonly></div>
|
||||
<div class="modal-foot">
|
||||
<button class="btn-ghost" onclick="closeModal('m-add-parsil')">Batal</button>
|
||||
<button class="btn-primary purple" onclick="saveParsil()">Simpan</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Edit SPBU -->
|
||||
<div class="overlay" id="m-edit-spbu">
|
||||
<div class="modal">
|
||||
<div class="modal-head"><h2>Edit SPBU</h2></div>
|
||||
<input type="hidden" id="e-spbu-id">
|
||||
<div class="form-group"><label>Nama SPBU</label><input id="e-spbu-nama"></div>
|
||||
<div class="form-group"><label>No WA</label><input id="e-spbu-wa"></div>
|
||||
<div class="form-group">
|
||||
<label>Status</label>
|
||||
<select id="e-spbu-status"><option value="full">24 Jam</option><option value="limited">Tidak 24 Jam</option></select>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="btn-ghost" onclick="closeModal('m-edit-spbu')">Batal</button>
|
||||
<button class="btn-primary green" onclick="updateSpbu()">Update</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Edit Jalan -->
|
||||
<div class="overlay" id="m-edit-jalan">
|
||||
<div class="modal">
|
||||
<div class="modal-head"><h2>Edit Jalan</h2></div>
|
||||
<input type="hidden" id="e-jalan-id">
|
||||
<div class="form-group"><label>Nama Jalan</label><input id="e-j-nama"></div>
|
||||
<div class="form-group">
|
||||
<label>Status</label>
|
||||
<select id="e-j-status"><option value="nasional">Nasional</option><option value="provinsi">Provinsi</option><option value="kabupaten">Kabupaten</option></select>
|
||||
</div>
|
||||
<div class="form-group"><label>Panjang (meter)</label><input id="e-j-panjang" readonly></div>
|
||||
<div class="modal-foot">
|
||||
<button class="btn-ghost" onclick="closeModal('m-edit-jalan')">Batal</button>
|
||||
<button class="btn-primary blue" onclick="updateJalan()">Update</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Edit Parsil -->
|
||||
<div class="overlay" id="m-edit-parsil">
|
||||
<div class="modal">
|
||||
<div class="modal-head"><h2>Edit Parsil</h2></div>
|
||||
<input type="hidden" id="e-parsil-id">
|
||||
<div class="form-group"><label>Nama Parsil</label><input id="e-p-nama"></div>
|
||||
<div class="form-group">
|
||||
<label>Status Kepemilikan</label>
|
||||
<select id="e-p-status"><option value="SHM">SHM</option><option value="HGB">HGB</option><option value="HGU">HGU</option><option value="HP">HP</option></select>
|
||||
</div>
|
||||
<div class="form-group"><label>Luas (m²)</label><input id="e-p-luas" readonly></div>
|
||||
<div class="modal-foot">
|
||||
<button class="btn-ghost" onclick="closeModal('m-edit-parsil')">Batal</button>
|
||||
<button class="btn-primary purple" onclick="updateParsil()">Update</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======================== CUSTOM CONFIRM DIALOG ======================== -->
|
||||
<div class="overlay" id="confirm-overlay">
|
||||
<div class="modal confirm-modal">
|
||||
<div class="confirm-icon">⚠️</div>
|
||||
<h2 id="confirm-title" class="confirm-title">Konfirmasi</h2>
|
||||
<p id="confirm-message" class="confirm-message">Apakah Anda yakin?</p>
|
||||
<div class="modal-foot">
|
||||
<button class="btn-ghost" id="confirm-cancel">Batal</button>
|
||||
<button class="btn-primary red" id="confirm-ok">Ya, Hapus</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toasts"></div>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@turf/turf@6/turf.min.js"></script>
|
||||
<script src="js/main.js"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css"/>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+967
@@ -0,0 +1,967 @@
|
||||
// ===============================
|
||||
// KONFIGURASI API
|
||||
// ===============================
|
||||
const API = {
|
||||
spbu: 'api/spbu.php',
|
||||
jalan: 'api/jalan.php',
|
||||
parsil: 'api/parsil.php'
|
||||
};
|
||||
|
||||
// ===============================
|
||||
// WARNA TEMA
|
||||
// ===============================
|
||||
const COLORS = {
|
||||
spbu24: { fill: '#22c55e', stroke: '#15803d', glow: 'rgba(34,197,94,.35)' },
|
||||
spbuLtd: { fill: '#f87171', stroke: '#dc2626', glow: 'rgba(248,113,113,.35)' },
|
||||
jalanNasional: '#f97316',
|
||||
jalanProvinsi: '#3b82f6',
|
||||
jalanKabupaten: '#10b981',
|
||||
SHM: { stroke: '#d97706', fill: '#fef3c7' },
|
||||
HGB: { stroke: '#8b5cf6', fill: '#ede9fe' },
|
||||
HGU: { stroke: '#0891b2', fill: '#cffafe' },
|
||||
HP: { stroke: '#ec4899', fill: '#fce7f3' },
|
||||
};
|
||||
|
||||
// ===============================
|
||||
// STATE GLOBAL
|
||||
// ===============================
|
||||
let map;
|
||||
let layers = { spbu: null, jalan: null, parsil: null };
|
||||
|
||||
let currentGeom = null;
|
||||
let drawPoints = [];
|
||||
let tempLayer = null;
|
||||
let savedDrawPoints = [];
|
||||
|
||||
let allSpbu = [], allJalan = [], allParsil = [];
|
||||
let isDraggingMarker = false;
|
||||
|
||||
let vertexMarkers = [];
|
||||
let undoStack = [];
|
||||
let redoStack = [];
|
||||
|
||||
// Edit-in-place state
|
||||
let editingFeature = null; // { type:'jalan'|'parsil', id, layer, geomLayer, vertexMarkers }
|
||||
|
||||
// ===============================
|
||||
// CUSTOM CONFIRM DIALOG
|
||||
// ===============================
|
||||
function showConfirm(message, title = 'Konfirmasi') {
|
||||
return new Promise(resolve => {
|
||||
const overlay = document.getElementById('confirm-overlay');
|
||||
document.getElementById('confirm-title').textContent = title;
|
||||
document.getElementById('confirm-message').textContent = message;
|
||||
overlay.classList.add('open');
|
||||
|
||||
const btnOk = document.getElementById('confirm-ok');
|
||||
const btnCancel = document.getElementById('confirm-cancel');
|
||||
|
||||
function cleanup() {
|
||||
overlay.classList.remove('open');
|
||||
btnOk.removeEventListener('click', onOk);
|
||||
btnCancel.removeEventListener('click', onCancel);
|
||||
}
|
||||
function onOk() { cleanup(); resolve(true); }
|
||||
function onCancel() { cleanup(); resolve(false); }
|
||||
|
||||
btnOk.addEventListener('click', onOk);
|
||||
btnCancel.addEventListener('click', onCancel);
|
||||
});
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// TOAST NOTIFICATION
|
||||
// ===============================
|
||||
function showToast(msg, type = 'ok') {
|
||||
const container = document.getElementById('toasts');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
toast.innerHTML = (type === 'ok' ? '✅ ' : '❌ ') + msg;
|
||||
container.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 3200);
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// BUAT ICON SVG MARKER SPBU
|
||||
// ===============================
|
||||
function makeSpbuIcon(is24) {
|
||||
const c = is24 ? COLORS.spbu24 : COLORS.spbuLtd;
|
||||
const svg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="40" viewBox="0 0 32 40">
|
||||
<defs>
|
||||
<filter id="glow-${is24 ? 'g' : 'r'}" x="-40%" y="-40%" width="180%" height="180%">
|
||||
<feGaussianBlur stdDeviation="2.5" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<ellipse cx="16" cy="38" rx="7" ry="2.5" fill="rgba(0,0,0,0.18)"/>
|
||||
<path d="M16 2 C9.4 2 4 7.4 4 14 C4 22 16 36 16 36 C16 36 28 22 28 14 C28 7.4 22.6 2 16 2Z"
|
||||
fill="${c.fill}" stroke="${c.stroke}" stroke-width="1.8"
|
||||
filter="url(#glow-${is24 ? 'g' : 'r'})"/>
|
||||
<circle cx="16" cy="14" r="7" fill="white" opacity="0.92"/>
|
||||
<text x="16" y="18.5" text-anchor="middle" font-size="10" font-family="sans-serif">⛽</text>
|
||||
</svg>`;
|
||||
|
||||
return L.divIcon({
|
||||
html: svg,
|
||||
className: '',
|
||||
iconSize: [32, 40],
|
||||
iconAnchor: [16, 38],
|
||||
popupAnchor: [0, -38]
|
||||
});
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// INIT MAP
|
||||
// ===============================
|
||||
function initMap() {
|
||||
map = L.map('map').setView([-0.0263, 109.3425], 14);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap',
|
||||
maxZoom: 19
|
||||
}).addTo(map);
|
||||
|
||||
map.on('click', onMapClick);
|
||||
|
||||
map.on('mousemove', e => {
|
||||
document.getElementById('cur-lat').innerText = e.latlng.lat.toFixed(5);
|
||||
document.getElementById('cur-lng').innerText = e.latlng.lng.toFixed(5);
|
||||
});
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// LOAD DATA
|
||||
// ===============================
|
||||
async function loadAllData() {
|
||||
try {
|
||||
const [spbuRes, jalanRes, parsilRes] = await Promise.all([
|
||||
fetch(API.spbu + '?action=list').then(r => r.json()),
|
||||
fetch(API.jalan + '?action=list').then(r => r.json()),
|
||||
fetch(API.parsil + '?action=list').then(r => r.json())
|
||||
]);
|
||||
|
||||
allSpbu = spbuRes.data || [];
|
||||
allJalan = jalanRes.data || [];
|
||||
allParsil = parsilRes.data || [];
|
||||
|
||||
renderSpbuLayer();
|
||||
renderJalanLayer();
|
||||
renderParsilLayer();
|
||||
renderSpbuList();
|
||||
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// RENDER SPBU (POINT) — tanpa tooltip bubble
|
||||
// ===============================
|
||||
function renderSpbuLayer() {
|
||||
if (layers.spbu) map.removeLayer(layers.spbu);
|
||||
layers.spbu = L.layerGroup();
|
||||
|
||||
allSpbu.forEach(s => {
|
||||
const is24 = s.statusbuka === 'full';
|
||||
const icon = makeSpbuIcon(is24);
|
||||
|
||||
// ❌ Tidak ada .bindTooltip — bubble nama dihapus
|
||||
const marker = L.marker([s.lat, s.lng], { icon, draggable: true, title: s.namaspbu });
|
||||
|
||||
marker.bindPopup(buildSpbuPopup(s));
|
||||
|
||||
marker.on('click', () => {
|
||||
if (currentGeom) return;
|
||||
marker.openPopup();
|
||||
});
|
||||
|
||||
marker.on('dragstart', () => {
|
||||
isDraggingMarker = true;
|
||||
marker.setOpacity(0.65);
|
||||
});
|
||||
|
||||
marker.on('dragend', async () => {
|
||||
isDraggingMarker = false;
|
||||
marker.setOpacity(1);
|
||||
const newPos = marker.getLatLng();
|
||||
await updateSpbuCoordinates(s.id, newPos.lat, newPos.lng);
|
||||
s.lat = newPos.lat;
|
||||
s.lng = newPos.lng;
|
||||
marker.setPopupContent(buildSpbuPopup(s));
|
||||
showToast('Koordinat SPBU diperbarui');
|
||||
});
|
||||
|
||||
layers.spbu.addLayer(marker);
|
||||
});
|
||||
|
||||
layers.spbu.addTo(map);
|
||||
}
|
||||
|
||||
function buildSpbuPopup(s) {
|
||||
const nama = s.namaspbu || '—';
|
||||
const is24 = s.statusbuka === 'full';
|
||||
const status = is24 ? 'Buka 24 Jam' : 'Tidak 24 Jam';
|
||||
const wa = s.nomorwa || '—';
|
||||
const coord = (s.lat && s.lng) ? `${Number(s.lat).toFixed(6)}, ${Number(s.lng).toFixed(6)}` : '—';
|
||||
const badgeCls = is24 ? 'bg' : 'br';
|
||||
|
||||
return `
|
||||
<div class="pcard-detail">
|
||||
<div class="pcd-title" data-type="spbu">${nama}</div>
|
||||
<div class="pcd-rows">
|
||||
<div class="pcd-row">
|
||||
<span class="pcd-label">📍 Koordinat</span>
|
||||
<span class="pcd-val">${coord}</span>
|
||||
</div>
|
||||
<div class="pcd-row">
|
||||
<span class="pcd-label">⏰ Status</span>
|
||||
<span class="badge ${badgeCls}">${status}</span>
|
||||
</div>
|
||||
${wa !== '—' ? `<div class="pcd-row"><span class="pcd-label">📱 WhatsApp</span><span class="pcd-val">${wa}</span></div>` : ''}
|
||||
</div>
|
||||
<div class="pcd-actions">
|
||||
<button class="pbtn pbtn-edit" onclick="editSpbu(${s.id})">✏️ Edit</button>
|
||||
<button class="pbtn pbtn-del" onclick="hapusSpbu(${s.id})">🗑️ Hapus</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// EDIT / DELETE SPBU
|
||||
// ===============================
|
||||
function editSpbu(id) {
|
||||
const s = allSpbu.find(x => x.id == id);
|
||||
if (!s) return showToast('Data SPBU tidak ditemukan', 'er');
|
||||
document.getElementById('e-spbu-id').value = s.id;
|
||||
document.getElementById('e-spbu-nama').value = s.namaspbu || '';
|
||||
document.getElementById('e-spbu-wa').value = s.nomorwa || '';
|
||||
document.getElementById('e-spbu-status').value = s.statusbuka || 'limited';
|
||||
openModal('m-edit-spbu');
|
||||
}
|
||||
|
||||
async function hapusSpbu(id) {
|
||||
const ok = await showConfirm('Hapus SPBU ini? Tindakan tidak dapat dibatalkan.', 'Hapus SPBU');
|
||||
if (!ok) return;
|
||||
try {
|
||||
const res = await fetch(API.spbu, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({action:'delete',id}) });
|
||||
const j = await res.json();
|
||||
if (j.status==='success') { loadAllData(); showToast('SPBU berhasil dihapus'); }
|
||||
else showToast(j.message||'Gagal hapus', 'er');
|
||||
} catch(e) { showToast('Gagal: '+e.message, 'er'); }
|
||||
}
|
||||
|
||||
async function updateSpbu() {
|
||||
const id = parseInt(document.getElementById('e-spbu-id').value||0);
|
||||
const namaspbu = document.getElementById('e-spbu-nama').value;
|
||||
const nomorwa = document.getElementById('e-spbu-wa').value;
|
||||
const statusbuka= document.getElementById('e-spbu-status').value;
|
||||
try {
|
||||
const res = await fetch(API.spbu, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({action:'update',id,namaspbu,nomorwa,statusbuka}) });
|
||||
const j = await res.json();
|
||||
if (j.status==='success') { closeModal('m-edit-spbu'); loadAllData(); showToast('SPBU diperbarui'); }
|
||||
else showToast(j.message||'Gagal update', 'er');
|
||||
} catch(e) { showToast('Gagal: '+e.message, 'er'); }
|
||||
}
|
||||
|
||||
async function updateSpbuCoordinates(id, lat, lng) {
|
||||
const spbu = allSpbu.find(x => x.id == id);
|
||||
if (!spbu) return;
|
||||
await fetch(API.spbu, {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ action:'update', id, namaspbu:spbu.namaspbu, nomorwa:spbu.nomorwa, statusbuka:spbu.statusbuka, koordinat:{type:'Point', coordinates:[lng,lat]} })
|
||||
});
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// RENDER LIST & STATS
|
||||
// ===============================
|
||||
function renderSpbuList() {
|
||||
const el = document.getElementById('spbu-list');
|
||||
if (!el) return;
|
||||
|
||||
document.getElementById('cnt-total').innerText = allSpbu.length;
|
||||
document.getElementById('cnt-24').innerText = allSpbu.filter(s=>s.statusbuka==='full').length;
|
||||
document.getElementById('cnt-jalan').innerText = allJalan.length;
|
||||
document.getElementById('cnt-parsil').innerText = allParsil.length;
|
||||
|
||||
if (!allSpbu.length) { el.innerHTML = '<div class="empty">Belum ada SPBU tercatat.</div>'; return; }
|
||||
|
||||
el.innerHTML = allSpbu.map(s => {
|
||||
const lat = parseFloat(s.lat)||0, lng = parseFloat(s.lng)||0;
|
||||
const dot = s.statusbuka==='full' ? 'dot-green' : 'dot-red';
|
||||
return `
|
||||
<div class="spbu-item" onclick="flyTo(${lat},${lng})">
|
||||
<div class="si-top">
|
||||
<div class="si-name"><span class="si-dot ${dot}"></span>${s.namaspbu||'—'}</div>
|
||||
<div class="si-actions" onclick="event.stopPropagation()">
|
||||
<button class="ib edt" title="Edit" onclick="editSpbu(${s.id})">✏️</button>
|
||||
<button class="ib del" title="Hapus" onclick="hapusSpbu(${s.id})">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function flyTo(lat, lng) {
|
||||
if (map) map.setView([lat, lng], 16);
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// SEARCH
|
||||
// ===============================
|
||||
function setupSearch() {
|
||||
const input = document.getElementById('search-input');
|
||||
const results = document.getElementById('search-results');
|
||||
if (!input || !results) return;
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
const q = input.value.trim().toLowerCase();
|
||||
results.innerHTML = '';
|
||||
if (!q) { results.classList.remove('open'); return; }
|
||||
|
||||
const hits = [];
|
||||
|
||||
allSpbu.forEach(s => {
|
||||
if ((s.namaspbu||'').toLowerCase().includes(q)) {
|
||||
hits.push({ label: s.namaspbu, sub: 'SPBU · Point', icon: '⛽', action: () => { flyTo(s.lat, s.lng); input.value=''; results.classList.remove('open'); } });
|
||||
}
|
||||
});
|
||||
allJalan.forEach(j => {
|
||||
if ((j.nama||'').toLowerCase().includes(q)) {
|
||||
hits.push({ label: j.nama, sub: `Jalan ${j.status||''}`, icon: '🛣️', action: () => {
|
||||
try { const geom = JSON.parse(j.geom); const coords = geom.coordinates; const mid = coords[Math.floor(coords.length/2)]; map.setView([mid[1],mid[0]],16); } catch(e){}
|
||||
input.value=''; results.classList.remove('open');
|
||||
}});
|
||||
}
|
||||
});
|
||||
allParsil.forEach(p => {
|
||||
if ((p.nama||'').toLowerCase().includes(q)) {
|
||||
hits.push({ label: p.nama, sub: `Parsil · ${p.status_kepemilikan||'SHM'}`, icon: '🗺️', action: () => {
|
||||
try { const geom = JSON.parse(p.geom); const coords = geom.coordinates[0]; const cx = coords.reduce((a,c)=>a+c[0],0)/coords.length; const cy = coords.reduce((a,c)=>a+c[1],0)/coords.length; map.setView([cy,cx],17); } catch(e){}
|
||||
input.value=''; results.classList.remove('open');
|
||||
}});
|
||||
}
|
||||
});
|
||||
|
||||
if (!hits.length) {
|
||||
results.innerHTML = '<div class="sr-empty">Tidak ada hasil</div>';
|
||||
} else {
|
||||
results.innerHTML = hits.slice(0,8).map((h,i) => `
|
||||
<div class="sr-item" data-idx="${i}">
|
||||
<span class="sr-icon">${h.icon}</span>
|
||||
<div class="sr-text">
|
||||
<div class="sr-label">${h.label}</div>
|
||||
<div class="sr-sub">${h.sub}</div>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
results.querySelectorAll('.sr-item').forEach((el,i) => {
|
||||
el.addEventListener('click', () => hits[i].action());
|
||||
});
|
||||
}
|
||||
results.classList.add('open');
|
||||
});
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
if (!e.target.closest('#search-wrap')) results.classList.remove('open');
|
||||
});
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// RENDER JALAN (LINE)
|
||||
// ===============================
|
||||
function renderJalanLayer() {
|
||||
if (layers.jalan) map.removeLayer(layers.jalan);
|
||||
|
||||
const geojson = allJalan.map(j => ({
|
||||
type: 'Feature',
|
||||
geometry: JSON.parse(j.geom),
|
||||
properties: j
|
||||
}));
|
||||
|
||||
layers.jalan = L.geoJSON(geojson, {
|
||||
style: f => {
|
||||
const s = f.properties.status;
|
||||
let color = COLORS.jalanKabupaten;
|
||||
if (s === 'nasional') color = COLORS.jalanNasional;
|
||||
else if (s === 'provinsi') color = COLORS.jalanProvinsi;
|
||||
return { color, weight: 4, opacity: 0.9 };
|
||||
},
|
||||
onEachFeature: (f, layer) => {
|
||||
layer.bindPopup(buildJalanPopup(f.properties));
|
||||
layer.on('click', e => {
|
||||
if (currentGeom) return;
|
||||
L.DomEvent.stopPropagation(e);
|
||||
layer.openPopup();
|
||||
});
|
||||
}
|
||||
}).addTo(map);
|
||||
}
|
||||
|
||||
function buildJalanPopup(p) {
|
||||
const nama = p.nama || '—';
|
||||
const status = p.status || 'kabupaten';
|
||||
let statusLabel='Kabupaten', badgeClass='bb-green';
|
||||
if (status==='nasional') { statusLabel='Nasional'; badgeClass='bb-orange'; }
|
||||
else if (status==='provinsi') { statusLabel='Provinsi'; badgeClass='bb-blue'; }
|
||||
const panjang = p.panjang_meter ? Number(p.panjang_meter).toFixed(2)+' m' : '—';
|
||||
|
||||
return `
|
||||
<div class="pcard-detail">
|
||||
<div class="pcd-title" data-type="jalan">${nama}</div>
|
||||
<div class="pcd-rows">
|
||||
<div class="pcd-row">
|
||||
<span class="pcd-label">🛣️ Status</span>
|
||||
<span class="badge ${badgeClass}">${statusLabel}</span>
|
||||
</div>
|
||||
<div class="pcd-row">
|
||||
<span class="pcd-label">📏 Panjang</span>
|
||||
<span class="pcd-val">${panjang}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pcd-actions">
|
||||
<button class="pbtn pbtn-edit" onclick="editJalan(${p.id})">✏️ Edit Atribut</button>
|
||||
<button class="pbtn pbtn-move" onclick="startEditGeomJalan(${p.id})">📐 Edit Geometri</button>
|
||||
<button class="pbtn pbtn-del" onclick="hapusJalan(${p.id})">🗑️ Hapus</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function editJalan(id) {
|
||||
const j = allJalan.find(x=>x.id==id);
|
||||
if (!j) return showToast('Data jalan tidak ditemukan', 'er');
|
||||
document.getElementById('e-jalan-id').value = j.id;
|
||||
document.getElementById('e-j-nama').value = j.nama || '';
|
||||
document.getElementById('e-j-status').value = j.status || 'kabupaten';
|
||||
document.getElementById('e-j-panjang').value = j.panjang_meter || '';
|
||||
openModal('m-edit-jalan');
|
||||
}
|
||||
|
||||
async function hapusJalan(id) {
|
||||
const ok = await showConfirm('Hapus jalan ini? Tindakan tidak dapat dibatalkan.', 'Hapus Jalan');
|
||||
if (!ok) return;
|
||||
try {
|
||||
const res = await fetch(API.jalan, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({action:'delete',id}) });
|
||||
const j = await res.json();
|
||||
if (j.status==='success') { loadAllData(); showToast('Jalan berhasil dihapus'); }
|
||||
else showToast(j.message||'Gagal hapus', 'er');
|
||||
} catch(e) { showToast('Gagal: '+e.message, 'er'); }
|
||||
}
|
||||
|
||||
async function updateJalan() {
|
||||
const id = parseInt(document.getElementById('e-jalan-id').value||0);
|
||||
const nama = document.getElementById('e-j-nama').value;
|
||||
const status = document.getElementById('e-j-status').value;
|
||||
const panjang_meter= parseFloat(document.getElementById('e-j-panjang').value);
|
||||
try {
|
||||
const res = await fetch(API.jalan, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({action:'update',id,nama,status,panjang_meter}) });
|
||||
const j = await res.json();
|
||||
if (j.status==='success') { closeModal('m-edit-jalan'); loadAllData(); showToast('Jalan diperbarui'); }
|
||||
else showToast(j.message||'Gagal update', 'er');
|
||||
} catch(e) { showToast('Gagal: '+e.message, 'er'); }
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// EDIT GEOMETRI JALAN (drag vertex)
|
||||
// ===============================
|
||||
function startEditGeomJalan(id) {
|
||||
map.closePopup();
|
||||
stopEditGeom(); // clear any existing
|
||||
|
||||
const j = allJalan.find(x => x.id == id);
|
||||
if (!j) return;
|
||||
const geom = JSON.parse(j.geom);
|
||||
let coords = geom.coordinates.map(c => [c[1], c[0]]); // [lat,lng]
|
||||
|
||||
// Highlight line
|
||||
const geomLayer = L.polyline(coords, { color: '#f97316', weight: 4, dashArray: '8,5', opacity: 1 }).addTo(map);
|
||||
|
||||
const vms = [];
|
||||
const updatePolyline = () => {
|
||||
const pts = vms.map(vm => vm.getLatLng());
|
||||
geomLayer.setLatLngs(pts);
|
||||
// Update panjang preview
|
||||
const turfCoords = pts.map(p => [p.lng, p.lat]);
|
||||
if (turfCoords.length >= 2) {
|
||||
const panjang = turf.length(turf.lineString(turfCoords), { units: 'meters' });
|
||||
document.getElementById('geom-edit-info').textContent = `Panjang: ${panjang.toFixed(2)} m`;
|
||||
}
|
||||
};
|
||||
|
||||
coords.forEach((c, i) => {
|
||||
const vm = L.marker(c, {
|
||||
draggable: true,
|
||||
icon: vertexIcon()
|
||||
}).addTo(map);
|
||||
vm.on('drag', updatePolyline);
|
||||
vms.push(vm);
|
||||
});
|
||||
|
||||
editingFeature = { type: 'jalan', id, geomLayer, vms };
|
||||
showEditGeomToolbar('Drag titik untuk edit geometri jalan');
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// RENDER PARSIL (POLYGON)
|
||||
// ===============================
|
||||
function renderParsilLayer() {
|
||||
if (layers.parsil) map.removeLayer(layers.parsil);
|
||||
|
||||
const geojson = allParsil.map(p => ({
|
||||
type: 'Feature',
|
||||
geometry: JSON.parse(p.geom),
|
||||
properties: p
|
||||
}));
|
||||
|
||||
layers.parsil = L.geoJSON(geojson, {
|
||||
style: f => {
|
||||
const c = COLORS[f.properties.status_kepemilikan] || COLORS.SHM;
|
||||
return { color: c.stroke, fillColor: c.fill, weight: 2.5, fillOpacity: 0.45 };
|
||||
},
|
||||
onEachFeature: (f, layer) => {
|
||||
layer.bindPopup(buildParsilPopup(f.properties));
|
||||
layer.on('click', e => {
|
||||
if (currentGeom) return;
|
||||
L.DomEvent.stopPropagation(e);
|
||||
layer.openPopup();
|
||||
});
|
||||
}
|
||||
}).addTo(map);
|
||||
}
|
||||
|
||||
function buildParsilPopup(props) {
|
||||
const nama = props.nama || '—';
|
||||
const status = props.status_kepemilikan || 'SHM';
|
||||
const luas = props.luas_m2 ? Number(props.luas_m2).toFixed(2)+' m²' : '—';
|
||||
const badgeMap = { SHM:'bp-shm', HGB:'bp-hgb', HGU:'bp-hgu', HP:'bp-hp' };
|
||||
const badgeClass = badgeMap[status] || 'bp-shm';
|
||||
|
||||
return `
|
||||
<div class="pcard-detail">
|
||||
<div class="pcd-title" data-type="parsil">${nama}</div>
|
||||
<div class="pcd-rows">
|
||||
<div class="pcd-row">
|
||||
<span class="pcd-label">📋 Kepemilikan</span>
|
||||
<span class="badge ${badgeClass}">${status}</span>
|
||||
</div>
|
||||
<div class="pcd-row">
|
||||
<span class="pcd-label">📐 Luas</span>
|
||||
<span class="pcd-val">${luas}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pcd-actions">
|
||||
<button class="pbtn pbtn-edit" onclick="editParsil(${props.id})">✏️ Edit Atribut</button>
|
||||
<button class="pbtn pbtn-move" onclick="startEditGeomParsil(${props.id})">📐 Edit Geometri</button>
|
||||
<button class="pbtn pbtn-del" onclick="hapusParsil(${props.id})">🗑️ Hapus</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function editParsil(id) {
|
||||
const p = allParsil.find(x=>x.id==id);
|
||||
if (!p) return showToast('Data parsil tidak ditemukan', 'er');
|
||||
document.getElementById('e-parsil-id').value = p.id;
|
||||
document.getElementById('e-p-nama').value = p.nama || '';
|
||||
document.getElementById('e-p-status').value = p.status_kepemilikan || 'SHM';
|
||||
document.getElementById('e-p-luas').value = p.luas_m2 || '';
|
||||
openModal('m-edit-parsil');
|
||||
}
|
||||
|
||||
async function hapusParsil(id) {
|
||||
const ok = await showConfirm('Hapus parsil ini? Tindakan tidak dapat dibatalkan.', 'Hapus Parsil');
|
||||
if (!ok) return;
|
||||
try {
|
||||
const res = await fetch(API.parsil, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({action:'delete',id}) });
|
||||
const j = await res.json();
|
||||
if (j.status==='success') { loadAllData(); showToast('Parsil berhasil dihapus'); }
|
||||
else showToast(j.message||'Gagal hapus', 'er');
|
||||
} catch(e) { showToast('Gagal: '+e.message, 'er'); }
|
||||
}
|
||||
|
||||
async function updateParsil() {
|
||||
const id = parseInt(document.getElementById('e-parsil-id').value||0);
|
||||
const nama = document.getElementById('e-p-nama').value;
|
||||
const status_kepemilikan= document.getElementById('e-p-status').value;
|
||||
const luas_m2 = parseFloat(document.getElementById('e-p-luas').value);
|
||||
try {
|
||||
const res = await fetch(API.parsil, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({action:'update',id,nama,status_kepemilikan,luas_m2}) });
|
||||
const j = await res.json();
|
||||
if (j.status==='success') { closeModal('m-edit-parsil'); loadAllData(); showToast('Parsil diperbarui'); }
|
||||
else showToast(j.message||'Gagal update', 'er');
|
||||
} catch(e) { showToast('Gagal: '+e.message, 'er'); }
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// EDIT GEOMETRI PARSIL (drag vertex)
|
||||
// ===============================
|
||||
function startEditGeomParsil(id) {
|
||||
map.closePopup();
|
||||
stopEditGeom();
|
||||
|
||||
const p = allParsil.find(x => x.id == id);
|
||||
if (!p) return;
|
||||
const geom = JSON.parse(p.geom);
|
||||
// outer ring, drop closing duplicate
|
||||
let coords = geom.coordinates[0].slice(0, -1).map(c => [c[1], c[0]]);
|
||||
|
||||
const geomLayer = L.polygon(coords, {
|
||||
color: '#8b5cf6', fillColor: '#ede9fe', weight: 2.5,
|
||||
dashArray: '8,5', fillOpacity: 0.3
|
||||
}).addTo(map);
|
||||
|
||||
const vms = [];
|
||||
const updatePolygon = () => {
|
||||
const pts = vms.map(vm => vm.getLatLng());
|
||||
geomLayer.setLatLngs(pts);
|
||||
const turfCoords = [...pts.map(p => [p.lng, p.lat])];
|
||||
turfCoords.push(turfCoords[0]);
|
||||
if (turfCoords.length >= 4) {
|
||||
const luas = turf.area(turf.polygon([turfCoords]));
|
||||
document.getElementById('geom-edit-info').textContent = `Luas: ${luas.toFixed(2)} m²`;
|
||||
}
|
||||
};
|
||||
|
||||
coords.forEach(c => {
|
||||
const vm = L.marker(c, { draggable: true, icon: vertexIcon() }).addTo(map);
|
||||
vm.on('drag', updatePolygon);
|
||||
vms.push(vm);
|
||||
});
|
||||
|
||||
editingFeature = { type: 'parsil', id, geomLayer, vms };
|
||||
showEditGeomToolbar('Drag titik untuk edit geometri parsil');
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// VERTEX ICON
|
||||
// ===============================
|
||||
function vertexIcon() {
|
||||
return L.divIcon({
|
||||
html: `<div style="width:14px;height:14px;background:#fff;border:2.5px solid #6366f1;border-radius:50%;box-shadow:0 2px 8px rgba(99,102,241,.4);cursor:grab;"></div>`,
|
||||
className: '',
|
||||
iconSize: [14, 14],
|
||||
iconAnchor: [7, 7]
|
||||
});
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// GEOM EDIT TOOLBAR
|
||||
// ===============================
|
||||
function showEditGeomToolbar(hint) {
|
||||
const tb = document.getElementById('geom-edit-toolbar');
|
||||
document.getElementById('geom-edit-hint').textContent = hint;
|
||||
document.getElementById('geom-edit-info').textContent = '';
|
||||
tb.classList.add('visible');
|
||||
}
|
||||
|
||||
function stopEditGeom() {
|
||||
if (!editingFeature) return;
|
||||
if (editingFeature.geomLayer) map.removeLayer(editingFeature.geomLayer);
|
||||
editingFeature.vms.forEach(vm => { try { map.removeLayer(vm); } catch(e){} });
|
||||
editingFeature = null;
|
||||
document.getElementById('geom-edit-toolbar').classList.remove('visible');
|
||||
}
|
||||
|
||||
async function saveEditGeom() {
|
||||
if (!editingFeature) return;
|
||||
const { type, id, vms } = editingFeature;
|
||||
const pts = vms.map(vm => vm.getLatLng());
|
||||
|
||||
try {
|
||||
if (type === 'jalan') {
|
||||
const coords = pts.map(p => [p.lng, p.lat]);
|
||||
const panjang = turf.length(turf.lineString(coords), { units: 'meters' });
|
||||
const j = allJalan.find(x => x.id == id);
|
||||
const res = await fetch(API.jalan, {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({
|
||||
action: 'update', id,
|
||||
nama: j.nama, status: j.status,
|
||||
panjang_meter: panjang,
|
||||
geom: { type: 'LineString', coordinates: coords }
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.status === 'success') { showToast('Geometri jalan diperbarui'); stopEditGeom(); loadAllData(); }
|
||||
else showToast(data.message || 'Gagal simpan', 'er');
|
||||
|
||||
} else if (type === 'parsil') {
|
||||
const coords = pts.map(p => [p.lng, p.lat]);
|
||||
coords.push(coords[0]);
|
||||
const luas = turf.area(turf.polygon([coords]));
|
||||
const p = allParsil.find(x => x.id == id);
|
||||
const res = await fetch(API.parsil, {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({
|
||||
action: 'update', id,
|
||||
nama: p.nama, status_kepemilikan: p.status_kepemilikan,
|
||||
luas_m2: luas,
|
||||
geom: { type: 'Polygon', coordinates: [coords] }
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.status === 'success') { showToast('Geometri parsil diperbarui'); stopEditGeom(); loadAllData(); }
|
||||
else showToast(data.message || 'Gagal simpan', 'er');
|
||||
}
|
||||
} catch(e) { showToast('Gagal: '+e.message, 'er'); }
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// HANDLE MAP CLICK (DRAW)
|
||||
// ===============================
|
||||
function onMapClick(e) {
|
||||
if (isDraggingMarker) return;
|
||||
if (!currentGeom) return;
|
||||
|
||||
if (currentGeom === 'point') {
|
||||
document.getElementById('spbu-lat').value = e.latlng.lat;
|
||||
document.getElementById('spbu-lng').value = e.latlng.lng;
|
||||
openModal('m-add-spbu');
|
||||
return;
|
||||
}
|
||||
|
||||
drawPoints.push({ lat: e.latlng.lat, lng: e.latlng.lng });
|
||||
undoStack.push({ lat: e.latlng.lat, lng: e.latlng.lng });
|
||||
redoStack = [];
|
||||
drawTemp();
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// GAMBAR SEMENTARA
|
||||
// ===============================
|
||||
function drawTemp() {
|
||||
if (tempLayer) { map.removeLayer(tempLayer); tempLayer = null; }
|
||||
vertexMarkers.forEach(m => { try { map.removeLayer(m); } catch(e){} });
|
||||
vertexMarkers = [];
|
||||
|
||||
if (drawPoints.length === 0) { updateCounter(); return; }
|
||||
|
||||
const latlngs = drawPoints.map(p => [p.lat, p.lng]);
|
||||
|
||||
if (currentGeom === 'polyline' && drawPoints.length >= 2) {
|
||||
tempLayer = L.polyline(latlngs, { color: COLORS.jalanProvinsi, weight: 3, dashArray:'7,5', opacity:.85 }).addTo(map);
|
||||
}
|
||||
if (currentGeom === 'polygon' && drawPoints.length >= 3) {
|
||||
tempLayer = L.polygon(latlngs, { color: COLORS.SHM.stroke, weight: 2.5, dashArray:'7,5', fillColor: COLORS.SHM.fill, fillOpacity:.25 }).addTo(map);
|
||||
}
|
||||
|
||||
drawPoints.forEach(p => {
|
||||
const vm = L.circleMarker([p.lat, p.lng], {
|
||||
radius: 6, color:'#fff', weight: 2.5,
|
||||
fillColor: currentGeom==='polygon' ? COLORS.SHM.stroke : COLORS.jalanProvinsi,
|
||||
fillOpacity: 1
|
||||
}).addTo(map);
|
||||
vertexMarkers.push(vm);
|
||||
});
|
||||
|
||||
updateCounter();
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// UNDO / REDO
|
||||
// ===============================
|
||||
function undoDraw() {
|
||||
if (!drawPoints.length) return;
|
||||
redoStack.push(drawPoints.pop());
|
||||
drawTemp();
|
||||
}
|
||||
|
||||
function redoDraw() {
|
||||
if (!redoStack.length) return;
|
||||
drawPoints.push(redoStack.pop());
|
||||
drawTemp();
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// SELESAI DRAW
|
||||
// ===============================
|
||||
function finishDrawing() {
|
||||
if (currentGeom === 'polyline') {
|
||||
if (drawPoints.length < 2) { showToast('Minimal 2 titik untuk membuat jalan!', 'er'); return; }
|
||||
finishJalanDrawing();
|
||||
} else if (currentGeom === 'polygon') {
|
||||
if (drawPoints.length < 3) { showToast('Minimal 3 titik untuk membuat parsil!', 'er'); return; }
|
||||
finishParsilDrawing();
|
||||
}
|
||||
}
|
||||
|
||||
function finishJalanDrawing() {
|
||||
savedDrawPoints = drawPoints.map(p => ({ lat: p.lat, lng: p.lng }));
|
||||
const coords = savedDrawPoints.map(p => [p.lng, p.lat]);
|
||||
const panjang = turf.length(turf.lineString(coords), { units:'meters' });
|
||||
document.getElementById('j-nama').value = '';
|
||||
document.getElementById('j-status').value = 'kabupaten';
|
||||
document.getElementById('j-panjang').value = panjang.toFixed(2);
|
||||
openModal('m-add-jalan');
|
||||
}
|
||||
|
||||
function finishParsilDrawing() {
|
||||
savedDrawPoints = drawPoints.map(p => ({ lat: p.lat, lng: p.lng }));
|
||||
const coords = savedDrawPoints.map(p => [p.lng, p.lat]);
|
||||
coords.push(coords[0]);
|
||||
const luas = turf.area(turf.polygon([coords]));
|
||||
document.getElementById('p-nama').value = '';
|
||||
document.getElementById('p-status').value = 'SHM';
|
||||
document.getElementById('p-luas').value = luas.toFixed(2);
|
||||
openModal('m-add-parsil');
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// SAVE SPBU
|
||||
// ===============================
|
||||
async function saveSpbu() {
|
||||
const lat = parseFloat(document.getElementById('spbu-lat').value);
|
||||
const lng = parseFloat(document.getElementById('spbu-lng').value);
|
||||
const namaspbu = document.getElementById('spbu-nama').value.trim();
|
||||
if (!namaspbu) { showToast('Nama SPBU wajib diisi!', 'er'); return; }
|
||||
if (isNaN(lat)||isNaN(lng)) { showToast('Koordinat tidak valid!', 'er'); return; }
|
||||
|
||||
try {
|
||||
const res = await fetch(API.spbu, { method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ action:'create', namaspbu, nomorwa:document.getElementById('spbu-wa').value,
|
||||
statusbuka:document.getElementById('spbu-status').value, koordinat:{type:'Point',coordinates:[lng,lat]} }) });
|
||||
const data = await res.json();
|
||||
if (data.status!=='success') { showToast(data.message||'Gagal simpan', 'er'); return; }
|
||||
closeModal('m-add-spbu'); deactivateGeom(); loadAllData(); showToast('SPBU berhasil ditambahkan');
|
||||
} catch(e) { showToast('Error: '+e.message, 'er'); }
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// SAVE JALAN
|
||||
// ===============================
|
||||
async function saveJalan() {
|
||||
const nama = document.getElementById('j-nama').value.trim();
|
||||
if (!nama) { showToast('Nama jalan wajib diisi!', 'er'); return; }
|
||||
if (savedDrawPoints.length < 2) { showToast('Data koordinat hilang, silakan gambar ulang.', 'er'); return; }
|
||||
|
||||
try {
|
||||
const res = await fetch(API.jalan, { method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ action:'create', nama, status:document.getElementById('j-status').value,
|
||||
panjang_meter:parseFloat(document.getElementById('j-panjang').value),
|
||||
geom:{ type:'LineString', coordinates:savedDrawPoints.map(p=>[p.lng,p.lat]) } }) });
|
||||
const data = await res.json();
|
||||
if (data.status!=='success') { showToast(data.message||'Gagal simpan', 'er'); return; }
|
||||
closeModal('m-add-jalan'); deactivateGeom(); cancelDraw(); loadAllData(); showToast('Jalan berhasil ditambahkan');
|
||||
} catch(e) { showToast('Error: '+e.message, 'er'); }
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// SAVE PARSIL
|
||||
// ===============================
|
||||
async function saveParsil() {
|
||||
const nama = document.getElementById('p-nama').value.trim();
|
||||
if (!nama) { showToast('Nama parsil wajib diisi!', 'er'); return; }
|
||||
if (savedDrawPoints.length < 3) { showToast('Data koordinat hilang, silakan gambar ulang.', 'er'); return; }
|
||||
|
||||
const coords = savedDrawPoints.map(p => [p.lng, p.lat]);
|
||||
coords.push(coords[0]);
|
||||
|
||||
try {
|
||||
const res = await fetch(API.parsil, { method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ action:'create', nama, status_kepemilikan:document.getElementById('p-status').value,
|
||||
luas_m2:parseFloat(document.getElementById('p-luas').value),
|
||||
geom:{ type:'Polygon', coordinates:[coords] } }) });
|
||||
const data = await res.json();
|
||||
if (data.status!=='success') { showToast(data.message||'Gagal simpan', 'er'); return; }
|
||||
closeModal('m-add-parsil'); deactivateGeom(); cancelDraw(); loadAllData(); showToast('Parsil berhasil ditambahkan');
|
||||
} catch(e) { showToast('Error: '+e.message, 'er'); }
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// CONTROL DRAW
|
||||
// ===============================
|
||||
function cancelDraw() {
|
||||
drawPoints = []; undoStack = []; redoStack = []; savedDrawPoints = [];
|
||||
if (tempLayer) { try { map.removeLayer(tempLayer); } catch(e){} tempLayer = null; }
|
||||
vertexMarkers.forEach(m => { try { map.removeLayer(m); } catch(e){} });
|
||||
vertexMarkers = [];
|
||||
updateCounter();
|
||||
document.getElementById('draw-toolbar').classList.remove('visible');
|
||||
}
|
||||
|
||||
function deactivateGeom() {
|
||||
currentGeom = null;
|
||||
document.querySelectorAll('.geom-btn').forEach(b => b.classList.remove('active'));
|
||||
document.getElementById('geom-hint').classList.remove('on');
|
||||
document.getElementById('map').classList.remove('crosshair-cursor');
|
||||
document.getElementById('draw-toolbar').classList.remove('visible');
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// MODAL
|
||||
// ===============================
|
||||
function openModal(id) { document.getElementById(id).classList.add('open'); }
|
||||
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
|
||||
|
||||
// ===============================
|
||||
// COUNTER
|
||||
// ===============================
|
||||
function updateCounter() {
|
||||
const n = drawPoints.length;
|
||||
document.getElementById('dt-counter').innerText = n + ' titik';
|
||||
document.getElementById('btn-undo').disabled = n === 0;
|
||||
document.getElementById('btn-redo').disabled = redoStack.length === 0;
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// INIT
|
||||
// ===============================
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initMap();
|
||||
loadAllData();
|
||||
setupSearch();
|
||||
|
||||
document.getElementById('btn-finish').onclick = finishDrawing;
|
||||
document.getElementById('btn-undo').onclick = undoDraw;
|
||||
document.getElementById('btn-redo').onclick = redoDraw;
|
||||
document.getElementById('btn-cancel-draw').onclick = () => { cancelDraw(); deactivateGeom(); };
|
||||
|
||||
// Geom edit toolbar buttons
|
||||
document.getElementById('btn-geom-save').onclick = saveEditGeom;
|
||||
document.getElementById('btn-geom-cancel').onclick = stopEditGeom;
|
||||
|
||||
document.querySelectorAll('.geom-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const geom = btn.dataset.geom;
|
||||
if (currentGeom === geom) { cancelDraw(); deactivateGeom(); return; }
|
||||
cancelDraw();
|
||||
currentGeom = geom;
|
||||
document.querySelectorAll('.geom-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
document.getElementById('map').classList.add('crosshair-cursor');
|
||||
const toolbar = document.getElementById('draw-toolbar');
|
||||
toolbar.classList.toggle('visible', geom==='polyline'||geom==='polygon');
|
||||
const hint = document.getElementById('geom-hint');
|
||||
const hintText = document.getElementById('geom-hint-text');
|
||||
hint.classList.add('on');
|
||||
if (geom==='point') hintText.innerText = 'Klik peta untuk tambah SPBU';
|
||||
if (geom==='polyline') hintText.innerText = 'Klik peta untuk menambah titik jalan, klik Selesai jika sudah';
|
||||
if (geom==='polygon') hintText.innerText = 'Klik peta untuk menambah titik parsil, klik Selesai jika sudah';
|
||||
});
|
||||
});
|
||||
|
||||
// Layer toggle
|
||||
const ldbtn = document.getElementById('ldbtn');
|
||||
const layerMenu = document.getElementById('layer-menu');
|
||||
if (ldbtn && layerMenu) {
|
||||
ldbtn.addEventListener('click', e => { e.stopPropagation(); layerMenu.classList.toggle('open'); });
|
||||
document.addEventListener('click', () => layerMenu.classList.remove('open'));
|
||||
|
||||
document.getElementById('lm-point').addEventListener('click', function() {
|
||||
this.classList.toggle('active');
|
||||
if (this.classList.contains('active')) { if (layers.spbu) layers.spbu.addTo(map); }
|
||||
else { if (layers.spbu) map.removeLayer(layers.spbu); }
|
||||
});
|
||||
document.getElementById('lm-jalan').addEventListener('click', function() {
|
||||
this.classList.toggle('active');
|
||||
if (this.classList.contains('active')) { if (layers.jalan) layers.jalan.addTo(map); }
|
||||
else { if (layers.jalan) map.removeLayer(layers.jalan); }
|
||||
});
|
||||
document.getElementById('lm-parsil').addEventListener('click', function() {
|
||||
this.classList.toggle('active');
|
||||
if (this.classList.contains('active')) { if (layers.parsil) layers.parsil.addTo(map); }
|
||||
else { if (layers.parsil) map.removeLayer(layers.parsil); }
|
||||
});
|
||||
}
|
||||
});
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Kepadatan Penduduk Kota Pontianak 2025</title>
|
||||
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
||||
|
||||
<style>
|
||||
html, body { height: 100%; margin: 0; }
|
||||
#map { width: 100%; height: 100vh; }
|
||||
|
||||
.info {
|
||||
padding: 6px 10px;
|
||||
font: 14px/16px Arial, Helvetica, sans-serif;
|
||||
background: rgba(255,255,255,0.9);
|
||||
box-shadow: 0 0 15px rgba(0,0,0,0.2);
|
||||
border-radius: 5px;
|
||||
min-width: 180px;
|
||||
}
|
||||
.info h4 { margin: 0 0 6px; color: #555; font-size: 13px; }
|
||||
.info .kec-name { font-weight: bold; font-size: 15px; }
|
||||
.info .kec-density { color: #333; margin-top: 2px; }
|
||||
|
||||
.legend { line-height: 20px; color: #444; }
|
||||
.legend i {
|
||||
width: 18px; height: 18px;
|
||||
float: left; margin-right: 8px;
|
||||
opacity: 0.75; border-radius: 2px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="map"></div>
|
||||
<script src="kec-ptk.js"></script>
|
||||
|
||||
<script>
|
||||
// ── Peta: center Pontianak, zoom 12 ──────────────────────────────────────
|
||||
const map = L.map('map').setView([-0.027, 109.335], 12);
|
||||
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
}).addTo(map);
|
||||
|
||||
// ── Kontrol info hover ───────────────────────────────────────────────────
|
||||
const info = L.control({ position: 'topright' });
|
||||
|
||||
info.onAdd = function () {
|
||||
this._div = L.DomUtil.create('div', 'info');
|
||||
this.update();
|
||||
return this._div;
|
||||
};
|
||||
|
||||
info.update = function (props) {
|
||||
if (props) {
|
||||
const penduduk = props.Penduduk.toLocaleString('id-ID');
|
||||
this._div.innerHTML =
|
||||
'<h4>Kota Pontianak</h4>' +
|
||||
'<div class="kec-name">' + props.Ket + '</div>' +
|
||||
'<div class="kec-density">' + penduduk + ' jiwa</div>';
|
||||
} else {
|
||||
this._div.innerHTML =
|
||||
'<h4>Kota Pontianak</h4>Arahkan kursor ke kecamatan';
|
||||
}
|
||||
};
|
||||
|
||||
info.addTo(map);
|
||||
|
||||
// ── Warna berdasarkan jumlah penduduk ────────────────────────────────────
|
||||
// Rentang: 50.589 (Tenggara) – 153.809 (Barat)
|
||||
function getColor(penduduk) {
|
||||
return penduduk > 150000 ? '#800026' :
|
||||
penduduk > 130000 ? '#BD0026' :
|
||||
penduduk > 110000 ? '#E31A1C' :
|
||||
penduduk > 90000 ? '#FC4E2A' :
|
||||
penduduk > 70000 ? '#FD8D3C' :
|
||||
penduduk > 50000 ? '#FEB24C' :
|
||||
'#FFEDA0';
|
||||
}
|
||||
|
||||
// Menambahkan properti 'Penduduk' ke setiap feature (sudah ada) dan style
|
||||
function style(feature) {
|
||||
const penduduk = feature.properties.Penduduk;
|
||||
return {
|
||||
weight: 2,
|
||||
opacity: 1,
|
||||
color: 'white',
|
||||
dashArray: '3',
|
||||
fillOpacity: 0.75,
|
||||
fillColor: getColor(penduduk)
|
||||
};
|
||||
}
|
||||
|
||||
function highlightFeature(e) {
|
||||
const layer = e.target;
|
||||
layer.setStyle({
|
||||
weight: 4,
|
||||
color: '#333',
|
||||
dashArray: '',
|
||||
fillOpacity: 0.85
|
||||
});
|
||||
layer.bringToFront();
|
||||
info.update(layer.feature.properties);
|
||||
}
|
||||
|
||||
function resetHighlight(e) {
|
||||
geojson.resetStyle(e.target);
|
||||
info.update();
|
||||
}
|
||||
|
||||
function zoomToFeature(e) {
|
||||
map.fitBounds(e.target.getBounds());
|
||||
}
|
||||
|
||||
function onEachFeature(feature, layer) {
|
||||
layer.on({
|
||||
mouseover: highlightFeature,
|
||||
mouseout: resetHighlight,
|
||||
click: zoomToFeature
|
||||
});
|
||||
}
|
||||
|
||||
/* global statesData */
|
||||
const geojson = L.geoJson(statesData, {
|
||||
style: style,
|
||||
onEachFeature: onEachFeature
|
||||
}).addTo(map);
|
||||
|
||||
// Zoom agar semua kecamatan langsung kelihatan
|
||||
map.fitBounds(geojson.getBounds());
|
||||
|
||||
// ── Atribusi data ─────────────────────────────────────────────────────────
|
||||
map.attributionControl.addAttribution(
|
||||
'Data penduduk © <a href="https://disdukcapil.pontianak.go.id/posts/penduduk-kota-pontianak-semester-ii-tahun-2025-berjumlah-693685-jiwa" target="_blank">Disdukcapil Kota Pontianak 2025</a>'
|
||||
);
|
||||
|
||||
// ── Legenda ───────────────────────────────────────────────────────────────
|
||||
const legend = L.control({ position: 'bottomright' });
|
||||
|
||||
legend.onAdd = function () {
|
||||
const div = L.DomUtil.create('div', 'info legend');
|
||||
const grades = [50000, 70000, 90000, 110000, 130000, 150000];
|
||||
const labels = ['<strong>Jumlah Penduduk</strong>'];
|
||||
|
||||
for (let i = 0; i < grades.length; i++) {
|
||||
const from = grades[i];
|
||||
const to = grades[i + 1];
|
||||
const fmt = n => n.toLocaleString('id-ID');
|
||||
labels.push(
|
||||
'<i style="background:' + getColor(from + 1) + '"></i> ' +
|
||||
fmt(from) + (to ? ' – ' + fmt(to) : '+')
|
||||
);
|
||||
}
|
||||
|
||||
div.innerHTML = labels.join('<br>');
|
||||
return div;
|
||||
};
|
||||
|
||||
legend.addTo(map);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+19407
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
$host = 'localhost';
|
||||
$dbname = 'latihan_webgis';
|
||||
$username = 'root';
|
||||
$password = '';
|
||||
|
||||
try {
|
||||
$db = new PDO("mysql:host=$host;dbname=$dbname;charset=utf8mb4", $username, $password);
|
||||
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
} catch (PDOException $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Koneksi database gagal: ' . $e->getMessage()]);
|
||||
exit;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
require __DIR__ . '/db.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit(); }
|
||||
|
||||
$action = $_GET['action'] ?? '';
|
||||
if ($action === '') {
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$action = $input['action'] ?? '';
|
||||
}
|
||||
|
||||
try {
|
||||
switch ($action) {
|
||||
case 'list':
|
||||
$rows = $db->query("SELECT * FROM jalan ORDER BY createdat DESC")->fetchAll();
|
||||
echo json_encode(['status' => 'success', 'data' => $rows]);
|
||||
break;
|
||||
|
||||
case 'create':
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$nama = trim($input['nama'] ?? '');
|
||||
$status = $input['status'] ?? 'kabupaten';
|
||||
$panjang = isset($input['panjang_meter']) ? (float)$input['panjang_meter'] : null;
|
||||
$geom = $input['geom'] ?? null;
|
||||
|
||||
if (!$nama) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Nama jalan wajib diisi.']);
|
||||
break;
|
||||
}
|
||||
if (!in_array($status, ['nasional','provinsi','kabupaten'])) $status = 'kabupaten';
|
||||
|
||||
// Validasi dan konversi geometri LineString
|
||||
$geojson = null;
|
||||
if (is_array($geom)) {
|
||||
if (isset($geom['type']) && $geom['type'] === 'LineString' && isset($geom['coordinates'])) {
|
||||
$geojson = json_encode($geom);
|
||||
} elseif (isset($geom[0]) && is_array($geom[0])) {
|
||||
// array of points [[lng,lat],...]
|
||||
$geojson = json_encode([
|
||||
'type' => 'LineString',
|
||||
'coordinates' => $geom
|
||||
]);
|
||||
}
|
||||
} elseif (is_string($geom)) {
|
||||
$decoded = json_decode($geom, true);
|
||||
if ($decoded && isset($decoded['type']) && $decoded['type'] === 'LineString') {
|
||||
$geojson = $geom;
|
||||
} else {
|
||||
echo json_encode(['status'=>'error','message'=>'Format geometri tidak valid.']);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$geojson) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Geometri tidak valid. Harus berupa GeoJSON LineString atau array koordinat.']);
|
||||
break;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("INSERT INTO jalan (nama, status, panjang_meter, geom) VALUES (?, ?, ?, ?)");
|
||||
$stmt->execute([$nama, $status, $panjang, $geojson]);
|
||||
echo json_encode(['status'=>'success','message'=>'Data jalan berhasil disimpan!','id'=>$db->lastInsertId()]);
|
||||
break;
|
||||
|
||||
case 'update':
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$id = (int)($input['id'] ?? 0);
|
||||
$nama = trim($input['nama'] ?? '');
|
||||
$status = $input['status'] ?? 'kabupaten';
|
||||
|
||||
if (!$id) { echo json_encode(['status'=>'error','message'=>'ID tidak valid.']); break; }
|
||||
if (!$nama) { echo json_encode(['status'=>'error','message'=>'Nama jalan wajib diisi.']); break; }
|
||||
if (!in_array($status, ['nasional','provinsi','kabupaten'])) $status = 'kabupaten';
|
||||
|
||||
$stmt = $db->prepare("UPDATE jalan SET nama=?, status=?, updatedat=NOW() WHERE id=?");
|
||||
$stmt->execute([$nama, $status, $id]);
|
||||
echo json_encode(['status'=>'success','message'=>'Data jalan berhasil diperbarui.']);
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$id = (int)($input['id'] ?? 0);
|
||||
if (!$id) { echo json_encode(['status'=>'error','message'=>'ID tidak valid.']); break; }
|
||||
$stmt = $db->prepare("DELETE FROM jalan WHERE id=?");
|
||||
$stmt->execute([$id]);
|
||||
echo json_encode(['status'=>'success','message'=>'Data jalan berhasil dihapus.']);
|
||||
break;
|
||||
|
||||
default:
|
||||
echo json_encode(['status'=>'error','message'=>'Aksi tidak dikenali.']);
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
echo json_encode(['status'=>'error','message'=>'Database error: '.$e->getMessage()]);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
require __DIR__ . '/db.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit(); }
|
||||
|
||||
$action = $_GET['action'] ?? '';
|
||||
if ($action === '') {
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$action = $input['action'] ?? '';
|
||||
}
|
||||
|
||||
try {
|
||||
switch ($action) {
|
||||
case 'list':
|
||||
$rows = $db->query("SELECT * FROM parsil ORDER BY createdat DESC")->fetchAll();
|
||||
echo json_encode(['status' => 'success', 'data' => $rows]);
|
||||
break;
|
||||
|
||||
case 'create':
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$nama = trim($input['nama'] ?? '');
|
||||
$status = $input['status_kepemilikan'] ?? 'SHM';
|
||||
$luas = isset($input['luas_m2']) ? (float)$input['luas_m2'] : null;
|
||||
$geom = $input['geom'] ?? null;
|
||||
|
||||
if (!$nama) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Nama parsil wajib diisi.']);
|
||||
break;
|
||||
}
|
||||
$validStatus = ['SHM','HGB','HGU','HP'];
|
||||
if (!in_array($status, $validStatus)) $status = 'SHM';
|
||||
|
||||
// Validasi dan konversi geometri Polygon
|
||||
$geojson = null;
|
||||
if (is_array($geom)) {
|
||||
if (isset($geom['type']) && $geom['type'] === 'Polygon' && isset($geom['coordinates'])) {
|
||||
$geojson = json_encode($geom);
|
||||
} elseif (isset($geom[0]) && is_array($geom[0])) {
|
||||
// array of rings [[lng,lat],...]
|
||||
$geojson = json_encode([
|
||||
'type' => 'Polygon',
|
||||
'coordinates' => $geom
|
||||
]);
|
||||
}
|
||||
} elseif (is_string($geom)) {
|
||||
$decoded = json_decode($geom, true);
|
||||
if ($decoded && isset($decoded['type']) && $decoded['type'] === 'Polygon') {
|
||||
$geojson = $geom;
|
||||
} else {
|
||||
echo json_encode(['status'=>'error','message'=>'Format geometri tidak valid.']);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$geojson) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Geometri tidak valid. Harus berupa GeoJSON Polygon atau array koordinat.']);
|
||||
break;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("INSERT INTO parsil (nama, status_kepemilikan, luas_m2, geom) VALUES (?, ?, ?, ?)");
|
||||
$stmt->execute([$nama, $status, $luas, $geojson]);
|
||||
echo json_encode(['status'=>'success','message'=>'Data parsil berhasil disimpan!','id'=>$db->lastInsertId()]);
|
||||
break;
|
||||
|
||||
case 'update':
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$id = (int)($input['id'] ?? 0);
|
||||
$nama = trim($input['nama'] ?? '');
|
||||
$status = $input['status_kepemilikan'] ?? 'SHM';
|
||||
$geom = $input['geom'] ?? null;
|
||||
$luas = isset($input['luas_m2']) ? (float)$input['luas_m2'] : null;
|
||||
|
||||
if (!$id) { echo json_encode(['status'=>'error','message'=>'ID tidak valid.']); break; }
|
||||
if (!$nama) { echo json_encode(['status'=>'error','message'=>'Nama parsil wajib diisi.']); break; }
|
||||
$validStatus = ['SHM','HGB','HGU','HP'];
|
||||
if (!in_array($status, $validStatus)) $status = 'SHM';
|
||||
|
||||
// If geometry provided, validate/convert to GeoJSON string
|
||||
$geojson = null;
|
||||
if ($geom) {
|
||||
if (is_array($geom)) {
|
||||
if (isset($geom['type']) && $geom['type'] === 'Polygon' && isset($geom['coordinates'])) {
|
||||
$geojson = json_encode($geom);
|
||||
} elseif (isset($geom[0]) && is_array($geom[0])) {
|
||||
$geojson = json_encode(['type'=>'Polygon','coordinates'=>$geom]);
|
||||
}
|
||||
} elseif (is_string($geom)) {
|
||||
$decoded = json_decode($geom, true);
|
||||
if ($decoded && isset($decoded['type']) && $decoded['type'] === 'Polygon') $geojson = $geom;
|
||||
}
|
||||
if (!$geojson) { echo json_encode(['status'=>'error','message'=>'Format geometri tidak valid untuk update.']); break; }
|
||||
}
|
||||
|
||||
// Build update query dynamically
|
||||
$fields = ['nama = ?', 'status_kepemilikan = ?', 'updatedat = NOW()'];
|
||||
$params = [$nama, $status];
|
||||
if ($luas !== null) { $fields[] = 'luas_m2 = ?'; $params[] = $luas; }
|
||||
if ($geojson !== null) { $fields[] = 'geom = ?'; $params[] = $geojson; }
|
||||
$params[] = $id;
|
||||
|
||||
$sql = "UPDATE parsil SET " . implode(', ', $fields) . " WHERE id=?";
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
echo json_encode(['status'=>'success','message'=>'Data parsil berhasil diperbarui.']);
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$id = (int)($input['id'] ?? 0);
|
||||
if (!$id) { echo json_encode(['status'=>'error','message'=>'ID tidak valid.']); break; }
|
||||
$stmt = $db->prepare("DELETE FROM parsil WHERE id=?");
|
||||
$stmt->execute([$id]);
|
||||
echo json_encode(['status'=>'success','message'=>'Data parsil berhasil dihapus.']);
|
||||
break;
|
||||
|
||||
default:
|
||||
echo json_encode(['status'=>'error','message'=>'Aksi tidak dikenali.']);
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
echo json_encode(['status'=>'error','message'=>'Database error: '.$e->getMessage()]);
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
require __DIR__ . '/db.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit(); }
|
||||
|
||||
$action = $_GET['action'] ?? '';
|
||||
if ($action === '') {
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$action = $input['action'] ?? '';
|
||||
}
|
||||
|
||||
try {
|
||||
switch ($action) {
|
||||
case 'list':
|
||||
$rows = $db->query("SELECT id, namaspbu, nomorwa, statusbuka,
|
||||
JSON_EXTRACT(koordinat, '$.coordinates[1]') as lat,
|
||||
JSON_EXTRACT(koordinat, '$.coordinates[0]') as lng,
|
||||
koordinat
|
||||
FROM spbu ORDER BY createdat DESC")->fetchAll();
|
||||
foreach ($rows as &$r) {
|
||||
$r['lat'] = (float)$r['lat'];
|
||||
$r['lng'] = (float)$r['lng'];
|
||||
}
|
||||
echo json_encode(['status' => 'success', 'data' => $rows]);
|
||||
break;
|
||||
|
||||
case 'create':
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$namaspbu = trim($input['namaspbu'] ?? '');
|
||||
$nomorwa = trim($input['nomorwa'] ?? '');
|
||||
$statusbuka = $input['statusbuka'] ?? 'limited';
|
||||
$koordinat = $input['koordinat'] ?? null;
|
||||
|
||||
if (!$namaspbu) {
|
||||
echo json_encode(['status'=>'error','message'=>'Nama SPBU wajib diisi.']);
|
||||
break;
|
||||
}
|
||||
if (!in_array($statusbuka, ['full','limited'])) $statusbuka = 'limited';
|
||||
|
||||
// Validasi dan konversi geometri
|
||||
$geojson = null;
|
||||
if (is_array($koordinat)) {
|
||||
// Cek apakah sudah dalam format GeoJSON Point
|
||||
if (isset($koordinat['type']) && $koordinat['type'] === 'Point' && isset($koordinat['coordinates'])) {
|
||||
$geojson = json_encode($koordinat);
|
||||
}
|
||||
// Mungkin hanya array [lng, lat]
|
||||
elseif (isset($koordinat[0]) && isset($koordinat[1])) {
|
||||
$geojson = json_encode([
|
||||
'type' => 'Point',
|
||||
'coordinates' => [(float)$koordinat[0], (float)$koordinat[1]]
|
||||
]);
|
||||
}
|
||||
// Mungkin objek dengan lat,lng
|
||||
elseif (isset($koordinat['lat']) && isset($koordinat['lng'])) {
|
||||
$geojson = json_encode([
|
||||
'type' => 'Point',
|
||||
'coordinates' => [(float)$koordinat['lng'], (float)$koordinat['lat']]
|
||||
]);
|
||||
}
|
||||
} elseif (is_string($koordinat)) {
|
||||
// Coba parse JSON
|
||||
$decoded = json_decode($koordinat, true);
|
||||
if ($decoded && isset($decoded['type']) && $decoded['type'] === 'Point') {
|
||||
$geojson = $koordinat;
|
||||
} else {
|
||||
echo json_encode(['status'=>'error','message'=>'Format koordinat tidak valid.']);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$geojson) {
|
||||
echo json_encode(['status'=>'error','message'=>'Geometri tidak valid. Harus berupa GeoJSON Point atau koordinat [lng, lat].']);
|
||||
break;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("INSERT INTO spbu (namaspbu, nomorwa, statusbuka, koordinat) VALUES (?, ?, ?, ?)");
|
||||
$stmt->execute([$namaspbu, $nomorwa, $statusbuka, $geojson]);
|
||||
echo json_encode(['status'=>'success','message'=>'Data berhasil disimpan!','id'=>$db->lastInsertId()]);
|
||||
break;
|
||||
|
||||
case 'update':
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$id = (int)($input['id'] ?? 0);
|
||||
$namaspbu = trim($input['namaspbu'] ?? '');
|
||||
$nomorwa = trim($input['nomorwa'] ?? '');
|
||||
$statusbuka = $input['statusbuka'] ?? 'limited';
|
||||
$koordinat = $input['koordinat'] ?? null;
|
||||
|
||||
if (!$id) {
|
||||
echo json_encode(['status'=>'error','message'=>'ID tidak valid.']);
|
||||
break;
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// JIKA ADA KOORDINAT → UPDATE KOORDINAT
|
||||
// ===============================
|
||||
if ($koordinat) {
|
||||
$geojson = json_encode($koordinat);
|
||||
|
||||
$stmt = $db->prepare("UPDATE spbu SET koordinat=?, updatedat=NOW() WHERE id=?");
|
||||
$stmt->execute([$geojson, $id]);
|
||||
|
||||
echo json_encode(['status'=>'success','message'=>'Koordinat berhasil diperbarui!']);
|
||||
break;
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// UPDATE DATA BIASA
|
||||
// ===============================
|
||||
if (!$namaspbu) {
|
||||
echo json_encode(['status'=>'error','message'=>'Nama SPBU wajib diisi.']);
|
||||
break;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("UPDATE spbu SET namaspbu=?, nomorwa=?, statusbuka=?, updatedat=NOW() WHERE id=?");
|
||||
$stmt->execute([$namaspbu, $nomorwa, $statusbuka, $id]);
|
||||
|
||||
echo json_encode(['status'=>'success','message'=>'Data berhasil diperbarui!']);
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$id = (int)($input['id'] ?? 0);
|
||||
if (!$id) { echo json_encode(['status'=>'error','message'=>'ID tidak valid.']); break; }
|
||||
$stmt = $db->prepare("DELETE FROM spbu WHERE id=?");
|
||||
$stmt->execute([$id]);
|
||||
echo json_encode(['status'=>'success','message'=>'Data berhasil dihapus.']);
|
||||
break;
|
||||
|
||||
default:
|
||||
echo json_encode(['status'=>'error','message'=>'Aksi tidak dikenali.']);
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
echo json_encode(['status'=>'error','message'=>'Database error: '.$e->getMessage()]);
|
||||
}
|
||||
@@ -0,0 +1,645 @@
|
||||
/* ============================================================
|
||||
WEBGIS SPBU — style.css (versi lengkap & diperbaiki)
|
||||
============================================================ */
|
||||
|
||||
/* ── CSS VARIABLES ── */
|
||||
:root {
|
||||
--bg: #f0f4f8;
|
||||
--sf: #ffffff;
|
||||
--sf2: #f7f9fc;
|
||||
--sf3: #eef2f7;
|
||||
--bd: #dde3ec;
|
||||
--bdh: #b0bac8;
|
||||
--tx: #1a2030;
|
||||
--txs: #4a5568;
|
||||
--txd: #8a96a8;
|
||||
|
||||
--spbu-24-fill: #22c55e;
|
||||
--spbu-24-bg: rgba(34,197,94,.10);
|
||||
--spbu-24-ring: rgba(34,197,94,.25);
|
||||
--spbu-ltd-fill: #f87171;
|
||||
--spbu-ltd-bg: rgba(248,113,113,.10);
|
||||
--spbu-ltd-ring: rgba(248,113,113,.25);
|
||||
|
||||
--jalan-nas: #f97316;
|
||||
--jalan-nas-bg: rgba(249,115,22,.10);
|
||||
--jalan-nas-ring: rgba(249,115,22,.28);
|
||||
--jalan-prov: #3b82f6;
|
||||
--jalan-prov-bg: rgba(59,130,246,.10);
|
||||
--jalan-prov-ring: rgba(59,130,246,.28);
|
||||
--jalan-kab: #10b981;
|
||||
--jalan-kab-bg: rgba(16,185,129,.10);
|
||||
--jalan-kab-ring: rgba(16,185,129,.28);
|
||||
|
||||
--shm-stroke: #d97706; --shm-fill: #fef3c7; --shm-bg: rgba(217,119,6,.10); --shm-ring: rgba(217,119,6,.28);
|
||||
--hgb-stroke: #8b5cf6; --hgb-fill: #ede9fe; --hgb-bg: rgba(139,92,246,.10); --hgb-ring: rgba(139,92,246,.28);
|
||||
--hgu-stroke: #0891b2; --hgu-fill: #cffafe; --hgu-bg: rgba(8,145,178,.10); --hgu-ring: rgba(8,145,178,.28);
|
||||
--hp-stroke: #ec4899; --hp-fill: #fce7f3; --hp-bg: rgba(236,72,153,.10); --hp-ring: rgba(236,72,153,.28);
|
||||
|
||||
--green: #16a34a;
|
||||
--greenb: rgba(22,163,74,.10);
|
||||
--greenr: rgba(22,163,74,.28);
|
||||
--red: #dc2626;
|
||||
--redb: rgba(220,38,38,.10);
|
||||
--redr: rgba(220,38,38,.28);
|
||||
--blue: #2563eb;
|
||||
--blueb: rgba(37,99,235,.10);
|
||||
--bluer: rgba(37,99,235,.28);
|
||||
--amber: #d97706;
|
||||
--amberb: rgba(217,119,6,.10);
|
||||
--amberr: rgba(217,119,6,.28);
|
||||
--purple: #7c3aed;
|
||||
--purpleb: rgba(124,58,237,.10);
|
||||
--purpler: rgba(124,58,237,.28);
|
||||
--indigo: #4f46e5;
|
||||
--indigob: rgba(79,70,229,.10);
|
||||
--indigor: rgba(79,70,229,.28);
|
||||
|
||||
--sh-sm: 0 1px 3px rgba(0,0,0,.07), 0 1px 2px rgba(0,0,0,.05);
|
||||
--sh: 0 4px 16px rgba(0,0,0,.09), 0 1px 4px rgba(0,0,0,.05);
|
||||
--sh-lg: 0 12px 40px rgba(0,0,0,.13), 0 4px 12px rgba(0,0,0,.07);
|
||||
|
||||
--sidebar: 275px;
|
||||
--topbar: 52px;
|
||||
--radius: 10px;
|
||||
--radius-lg: 14px;
|
||||
}
|
||||
|
||||
/* ── RESET ── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body {
|
||||
height: 100%; overflow: hidden;
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
font-size: 14px; background: var(--bg); color: var(--tx);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
#app { display: flex; height: 100vh; }
|
||||
|
||||
/* ── LOADING ── */
|
||||
#loading {
|
||||
position: fixed; inset: 0; z-index: 9999;
|
||||
background: var(--bg);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.ld-inner { display: flex; flex-direction: column; align-items: center; gap: 14px; }
|
||||
.ld-inner p { font-size: 13px; color: var(--txd); }
|
||||
.spin {
|
||||
width: 32px; height: 32px;
|
||||
border: 3px solid var(--bd); border-top-color: var(--green);
|
||||
border-radius: 50%; animation: rot .7s linear infinite;
|
||||
}
|
||||
@keyframes rot { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── SIDEBAR ── */
|
||||
#sidebar {
|
||||
width: var(--sidebar); min-width: var(--sidebar);
|
||||
background: var(--sf); border-right: 1px solid var(--bd);
|
||||
display: flex; flex-direction: column;
|
||||
box-shadow: var(--sh-sm); z-index: 900;
|
||||
}
|
||||
.sb-header { padding: 14px 16px; border-bottom: 1px solid var(--bd); }
|
||||
.sb-brand { display: flex; align-items: center; gap: 10px; }
|
||||
.sb-icon {
|
||||
width: 36px; height: 36px; border-radius: 9px; flex-shrink: 0;
|
||||
background: var(--greenb); border: 1px solid var(--greenr);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--green); font-size: 18px;
|
||||
}
|
||||
.sb-title { font-size: 14px; font-weight: 800; color: var(--tx); }
|
||||
.sb-sub { font-size: 10px; color: var(--txd); margin-top: 1px; }
|
||||
|
||||
.sb-scroll {
|
||||
flex: 1; overflow-y: auto; padding: 12px;
|
||||
display: flex; flex-direction: column; gap: 14px;
|
||||
}
|
||||
.sb-scroll::-webkit-scrollbar { width: 4px; }
|
||||
.sb-scroll::-webkit-scrollbar-thumb { background: var(--bd); border-radius: 4px; }
|
||||
|
||||
.sb-section { display: flex; flex-direction: column; gap: 6px; }
|
||||
.sb-label {
|
||||
font-size: 10px; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: .8px;
|
||||
color: var(--green); /* ← hijau tema */
|
||||
}
|
||||
|
||||
/* ── LEGEND ── */
|
||||
.legend-card {
|
||||
background: var(--sf2); border: 1px solid var(--bd);
|
||||
border-radius: var(--radius); padding: 10px 12px;
|
||||
display: flex; flex-direction: column; gap: 7px;
|
||||
}
|
||||
.leg-row { display: flex; align-items: center; gap: 9px; font-size: 12px; color: var(--txs); font-weight: 500; }
|
||||
.leg-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; border: 2px solid rgba(0,0,0,.08); }
|
||||
.leg-dot.spbu-24 { background: var(--spbu-24-fill); box-shadow: 0 0 0 3px var(--spbu-24-bg); }
|
||||
.leg-dot.spbu-ltd { background: var(--spbu-ltd-fill); box-shadow: 0 0 0 3px var(--spbu-ltd-bg); }
|
||||
.leg-line { width: 24px; height: 4px; border-radius: 3px; flex-shrink: 0; }
|
||||
.leg-line.jalan-nas { background: var(--jalan-nas); }
|
||||
.leg-line.jalan-prov { background: var(--jalan-prov); }
|
||||
.leg-line.jalan-kab { background: var(--jalan-kab); }
|
||||
.leg-poly { width: 18px; height: 13px; border-radius: 3px; flex-shrink: 0; border: 2px solid; }
|
||||
.leg-poly.parsil-shm { background: var(--shm-fill); border-color: var(--shm-stroke); }
|
||||
.leg-poly.parsil-hgb { background: var(--hgb-fill); border-color: var(--hgb-stroke); }
|
||||
.leg-poly.parsil-hgu { background: var(--hgu-fill); border-color: var(--hgu-stroke); }
|
||||
.leg-poly.parsil-hp { background: var(--hp-fill); border-color: var(--hp-stroke); }
|
||||
|
||||
/* ── STATS ── */
|
||||
.stat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }
|
||||
.stat-item {
|
||||
background: var(--sf2); border: 1px solid var(--bd);
|
||||
border-radius: var(--radius); padding: 10px; text-align: center;
|
||||
}
|
||||
.stat-item.green { background: var(--greenb); border-color: var(--greenr); }
|
||||
.stat-item.blue { background: var(--blueb); border-color: var(--bluer); }
|
||||
.stat-item.purple { background: var(--purpleb); border-color: var(--purpler); }
|
||||
.stat-num { font-size: 22px; font-weight: 800; font-family: 'Fira Code', monospace; color: var(--tx); }
|
||||
.stat-item.green .stat-num { color: var(--green); }
|
||||
.stat-item.blue .stat-num { color: var(--blue); }
|
||||
.stat-item.purple .stat-num { color: var(--purple); }
|
||||
.stat-lbl { font-size: 10px; color: var(--txd); margin-top: 2px; }
|
||||
|
||||
/* ── SPBU LIST ── */
|
||||
.spbu-item {
|
||||
background: var(--sf); border: 1px solid var(--bd);
|
||||
border-radius: var(--radius); padding: 9px 11px;
|
||||
cursor: pointer; margin-bottom: 5px;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
transition: border-color .18s, background .18s;
|
||||
}
|
||||
.spbu-item:hover { border-color: var(--green); background: var(--greenb); }
|
||||
.si-top { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
|
||||
.si-name { font-size: 13px; font-weight: 700; display: flex; align-items: center; gap: 7px; }
|
||||
.si-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.si-dot.dot-green { background: var(--spbu-24-fill); box-shadow: 0 0 0 2px var(--spbu-24-bg); }
|
||||
.si-dot.dot-red { background: var(--spbu-ltd-fill); box-shadow: 0 0 0 2px var(--spbu-ltd-bg); }
|
||||
.si-actions { display: flex; gap: 4px; flex-shrink: 0; }
|
||||
.empty { text-align: center; padding: 24px 0; font-size: 12px; color: var(--txd); }
|
||||
|
||||
/* icon buttons — SERAGAM HIJAU */
|
||||
.ib {
|
||||
width: 30px; height: 30px; border-radius: 8px;
|
||||
background: var(--greenb); border: 1px solid var(--greenr);
|
||||
color: var(--green); /* ← hijau tema */
|
||||
cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: all .18s; flex-shrink: 0; font-size: 13px;
|
||||
}
|
||||
.ib.del {
|
||||
background: var(--redb); border-color: var(--redr); color: var(--red);
|
||||
}
|
||||
.ib:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(22,163,74,.15); }
|
||||
.ib.del:hover { box-shadow: 0 4px 12px rgba(220,38,38,.15); }
|
||||
|
||||
/* ── BADGE ── */
|
||||
.badge {
|
||||
display: inline-flex; align-items: center;
|
||||
font-size: 10px; font-weight: 700;
|
||||
padding: 2px 8px; border-radius: 20px; border: 1px solid;
|
||||
}
|
||||
.badge.bg { background: var(--spbu-24-bg); border-color: var(--spbu-24-ring); color: #15803d; }
|
||||
.badge.br { background: var(--spbu-ltd-bg); border-color: var(--spbu-ltd-ring); color: #dc2626; }
|
||||
.badge.bb-orange { background: var(--jalan-nas-bg); border-color: var(--jalan-nas-ring); color: #c2410c; }
|
||||
.badge.bb-blue { background: var(--jalan-prov-bg); border-color: var(--jalan-prov-ring); color: #1d4ed8; }
|
||||
.badge.bb-green { background: var(--jalan-kab-bg); border-color: var(--jalan-kab-ring); color: #065f46; }
|
||||
.badge.bp-shm { background: var(--shm-bg); border-color: var(--shm-ring); color: var(--shm-stroke); }
|
||||
.badge.bp-hgb { background: var(--hgb-bg); border-color: var(--hgb-ring); color: var(--hgb-stroke); }
|
||||
.badge.bp-hgu { background: var(--hgu-bg); border-color: var(--hgu-ring); color: var(--hgu-stroke); }
|
||||
.badge.bp-hp { background: var(--hp-bg); border-color: var(--hp-ring); color: var(--hp-stroke); }
|
||||
|
||||
/* ── GEOM SELECTOR ── */
|
||||
.geom-selector {
|
||||
display: flex; gap: 3px;
|
||||
background: var(--sf2); border: 1px solid var(--bd);
|
||||
border-radius: 9px; padding: 3px;
|
||||
}
|
||||
.geom-btn {
|
||||
flex: 1; display: flex; align-items: center; justify-content: center;
|
||||
gap: 5px; padding: 6px 8px; border-radius: 7px;
|
||||
border: none; background: transparent; color: var(--txs);
|
||||
font-family: 'Plus Jakarta Sans', sans-serif; font-size: 11px; font-weight: 600;
|
||||
cursor: pointer; transition: all .18s; white-space: nowrap;
|
||||
}
|
||||
.geom-btn:hover { background: var(--sf); color: var(--tx); }
|
||||
.geom-btn.active { background: var(--green); color: #fff; box-shadow: 0 2px 8px rgba(22,163,74,.3); }
|
||||
.geom-hint {
|
||||
font-size: 10px; color: var(--txd); text-align: left;
|
||||
margin-top: 4px; min-height: 14px; padding-left: 2px;
|
||||
transition: color .18s; display: flex; align-items: center; gap: 5px;
|
||||
}
|
||||
.geom-hint .gh-dot {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--bdh); flex-shrink: 0; transition: background .18s;
|
||||
}
|
||||
.geom-hint.on { color: var(--green); font-weight: 600; }
|
||||
.geom-hint.on .gh-dot { background: var(--green); box-shadow: 0 0 0 2px rgba(22,163,74,.2); }
|
||||
|
||||
/* ── MAIN ── */
|
||||
#main { flex: 1; display: flex; flex-direction: column; overflow: hidden; position: relative; }
|
||||
|
||||
/* ── TOPBAR ── */
|
||||
#topbar {
|
||||
height: var(--topbar); min-height: var(--topbar);
|
||||
background: var(--sf); border-bottom: 1px solid var(--bd);
|
||||
display: flex; align-items: center; padding: 0 12px; gap: 8px;
|
||||
box-shadow: var(--sh-sm); overflow: visible;
|
||||
position: relative; z-index: 850;
|
||||
}
|
||||
|
||||
/* koordinat pill */
|
||||
.coord-pill {
|
||||
flex: 0 0 auto; font-family: 'Fira Code', monospace;
|
||||
font-size: 11px; color: var(--txd);
|
||||
background: var(--sf2); border: 1px solid var(--bd);
|
||||
border-radius: 7px; padding: 4px 10px;
|
||||
display: flex; align-items: center; gap: 5px; white-space: nowrap;
|
||||
}
|
||||
.coord-pill > span:first-child { color: var(--green); } /* ← ikon hijau */
|
||||
.pill-sep { color: var(--bdh); }
|
||||
|
||||
/* ── SEARCH ── */
|
||||
#search-wrap { flex: 1; position: relative; max-width: 340px; z-index: 1300; }
|
||||
.search-box {
|
||||
display: flex; align-items: center; gap: 7px;
|
||||
background: var(--sf2); border: 1.5px solid var(--bd);
|
||||
border-radius: 9px; padding: 0 10px; height: 34px;
|
||||
transition: border-color .18s, box-shadow .18s;
|
||||
}
|
||||
.search-box:focus-within {
|
||||
border-color: var(--green);
|
||||
box-shadow: 0 0 0 3px rgba(22,163,74,.08);
|
||||
}
|
||||
.search-icon {
|
||||
font-size: 13px; flex-shrink: 0;
|
||||
color: var(--green); /* ← hijau tema */
|
||||
}
|
||||
#search-input {
|
||||
flex: 1; border: none; background: transparent; outline: none;
|
||||
font-family: 'Plus Jakarta Sans', sans-serif; font-size: 13px; color: var(--tx);
|
||||
}
|
||||
#search-input::placeholder { color: var(--txd); }
|
||||
#search-results {
|
||||
position: absolute; top: calc(100% + 6px); left: 0; right: 0;
|
||||
background: var(--sf); border: 1px solid var(--bd);
|
||||
border-radius: 11px; box-shadow: var(--sh-lg);
|
||||
overflow: hidden; opacity: 0; pointer-events: none;
|
||||
transform: translateY(6px);
|
||||
transition: opacity .18s, transform .18s;
|
||||
max-height: 320px; overflow-y: auto;
|
||||
}
|
||||
#search-results.open { opacity: 1; pointer-events: all; transform: translateY(0); }
|
||||
.sr-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 9px 12px; cursor: pointer;
|
||||
transition: background .14s; border-bottom: 1px solid var(--sf3);
|
||||
}
|
||||
.sr-item:last-child { border-bottom: none; }
|
||||
.sr-item:hover { background: var(--sf2); }
|
||||
.sr-icon {
|
||||
width: 30px; height: 30px; border-radius: 8px;
|
||||
background: var(--greenb); border: 1px solid var(--greenr); /* ← hijau tema */
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 14px; flex-shrink: 0;
|
||||
}
|
||||
.sr-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
||||
.sr-label { font-size: 13px; font-weight: 700; color: var(--tx); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.sr-sub { font-size: 10px; color: var(--txd); font-weight: 500; }
|
||||
.sr-empty { padding: 16px 12px; font-size: 12px; color: var(--txd); text-align: center; }
|
||||
|
||||
/* ── LAYER DROPDOWN ── */
|
||||
.layer-wrap { position: absolute; top: 10px; right: 10px; flex-shrink: 0; z-index: 1100; }
|
||||
.layer-btn {
|
||||
height: 34px; padding: 0 12px; border-radius: 8px;
|
||||
background: var(--sf); border: 1px solid var(--bd);
|
||||
color: var(--txs); font-size: 12px; font-weight: 600;
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
cursor: pointer; display: flex; align-items: center; gap: 6px;
|
||||
transition: all .18s; white-space: nowrap; box-shadow: var(--sh-sm);
|
||||
}
|
||||
.layer-btn:hover { border-color: var(--bdh); color: var(--tx); }
|
||||
.layer-btn svg { color: var(--green); flex-shrink: 0; } /* ← ikon SVG hijau */
|
||||
.layer-btn .chev { color: var(--green); font-size: 10px; } /* ← chevron hijau */
|
||||
|
||||
.layer-menu {
|
||||
position: absolute; top: calc(100% + 6px); right: 0;
|
||||
background: var(--sf); border: 1px solid var(--bd);
|
||||
border-radius: 13px; padding: 8px 6px; min-width: 210px;
|
||||
box-shadow: var(--sh-lg); z-index: 1200;
|
||||
opacity: 0; pointer-events: none;
|
||||
transform: translateY(6px);
|
||||
transition: opacity .18s, transform .18s;
|
||||
}
|
||||
.layer-menu.open { opacity: 1; pointer-events: all; transform: translateY(0); }
|
||||
|
||||
.lm-label {
|
||||
font-size: 10px; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: .8px; color: var(--green); /* ← hijau tema */
|
||||
padding: 4px 10px 8px;
|
||||
}
|
||||
|
||||
/* Item utama layer */
|
||||
.lm-item {
|
||||
width: 100%; display: flex; align-items: center; gap: 8px;
|
||||
padding: 7px 10px; border-radius: 8px; cursor: pointer;
|
||||
transition: background .14s; font-size: 13px;
|
||||
font-weight: 600; color: var(--tx); user-select: none;
|
||||
}
|
||||
.lm-item:hover { background: var(--sf2); }
|
||||
.lm-item input[type="checkbox"] { display: none; }
|
||||
.lm-icon { font-size: 14px; }
|
||||
|
||||
/* Custom checkbox */
|
||||
.lm-check {
|
||||
width: 16px; height: 16px; border-radius: 5px;
|
||||
border: 1.5px solid var(--bdh); background: var(--sf2); flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: all .16s; position: relative;
|
||||
}
|
||||
.lm-check.sm { width: 14px; height: 14px; border-radius: 4px; }
|
||||
.lm-item input:checked ~ .lm-check,
|
||||
.lm-sub-item input:checked ~ .lm-check {
|
||||
background: var(--green); border-color: var(--green);
|
||||
}
|
||||
.lm-item input:checked ~ .lm-check::after,
|
||||
.lm-sub-item input:checked ~ .lm-check::after {
|
||||
content: ''; display: block;
|
||||
width: 4px; height: 7px;
|
||||
border: 2px solid #fff; border-top: none; border-left: none;
|
||||
transform: rotate(45deg) translate(-1px, -1px);
|
||||
}
|
||||
|
||||
/* Sub-option SPBU */
|
||||
.lm-sub {
|
||||
margin: 2px 0 4px 10px; padding: 4px 0 4px 10px;
|
||||
border-left: 2px solid var(--greenr); /* ← hijau tema */
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
max-height: 100px; overflow: hidden;
|
||||
transition: max-height .2s ease, opacity .2s ease; opacity: 1;
|
||||
}
|
||||
.lm-sub.hidden { max-height: 0; opacity: 0; pointer-events: none; }
|
||||
.lm-sub-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 5px 8px; border-radius: 7px; cursor: pointer;
|
||||
font-size: 12px; font-weight: 500; color: var(--txs);
|
||||
user-select: none; transition: background .14s;
|
||||
}
|
||||
.lm-sub-item:hover { background: var(--sf2); }
|
||||
.lm-sub-item input[type="checkbox"] { display: none; }
|
||||
.lm-sub-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
|
||||
.lm-sub-dot.dot-green { background: var(--spbu-24-fill); box-shadow: 0 0 0 2px var(--spbu-24-bg); }
|
||||
.lm-sub-dot.dot-red { background: var(--spbu-ltd-fill); box-shadow: 0 0 0 2px var(--spbu-ltd-bg); }
|
||||
|
||||
/* ── MAP ── */
|
||||
#map-wrap { flex: 1; position: relative; overflow: hidden; }
|
||||
#map { width: 100%; height: 100%; }
|
||||
.leaflet-container.crosshair-cursor { cursor: crosshair !important; }
|
||||
|
||||
/* ── DRAW TOOLBAR ── */
|
||||
#draw-toolbar,
|
||||
#geom-edit-toolbar {
|
||||
position: absolute; bottom: 28px; left: 50%;
|
||||
transform: translateX(-50%) translateY(12px);
|
||||
z-index: 1000; display: flex; align-items: center; gap: 5px;
|
||||
background: var(--sf); border: 1px solid var(--bd);
|
||||
border-radius: 14px; padding: 8px 14px;
|
||||
box-shadow: var(--sh-lg); pointer-events: all;
|
||||
opacity: 0; visibility: hidden;
|
||||
transition: opacity .22s, transform .22s, visibility .22s;
|
||||
}
|
||||
#draw-toolbar.visible,
|
||||
#geom-edit-toolbar.visible {
|
||||
opacity: 1; visibility: visible;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
#geom-edit-toolbar { bottom: 28px; }
|
||||
#draw-toolbar.visible ~ #geom-edit-toolbar.visible { bottom: 90px; }
|
||||
|
||||
.dt-hint { font-size: 12px; font-weight: 600; color: var(--green); padding: 0 4px; white-space: nowrap; } /* ← hijau */
|
||||
.dt-counter {
|
||||
font-family: 'Fira Code', monospace; font-size: 12px; font-weight: 600;
|
||||
color: var(--green); background: var(--greenb); /* ← hijau */
|
||||
border: 1px solid var(--greenr); border-radius: 7px;
|
||||
padding: 4px 10px; white-space: nowrap;
|
||||
}
|
||||
.geom-edit-counter {
|
||||
color: var(--indigo); background: var(--indigob);
|
||||
border-color: var(--indigor); min-width: 120px;
|
||||
}
|
||||
.dt-btn {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
padding: 7px 13px; border-radius: 9px;
|
||||
border: 1.5px solid var(--greenr); /* ← hijau */
|
||||
background: var(--greenb); color: var(--green); /* ← hijau */
|
||||
font-family: 'Plus Jakarta Sans', sans-serif; font-size: 12px; font-weight: 600;
|
||||
cursor: pointer; transition: all .18s; white-space: nowrap; flex-shrink: 0;
|
||||
}
|
||||
.dt-btn:hover { background: var(--sf); border-color: var(--bdh); color: var(--tx); }
|
||||
.dt-btn:disabled { opacity: .4; cursor: not-allowed; }
|
||||
.dt-btn.undo:hover { background: var(--amberb); border-color: var(--amberr); color: var(--amber); }
|
||||
.dt-btn.redo:hover { background: var(--blueb); border-color: var(--bluer); color: var(--blue); }
|
||||
.dt-btn.cancel {
|
||||
background: var(--redb); border-color: var(--redr); color: var(--red);
|
||||
}
|
||||
.dt-btn.cancel:hover { background: var(--red); color: #fff; border-color: var(--red); }
|
||||
.dt-btn.finish {
|
||||
background: var(--green); border-color: var(--green); color: #fff;
|
||||
}
|
||||
.dt-btn.finish:hover { opacity: .88; }
|
||||
|
||||
/* ── POPUP / PCARD ── */
|
||||
.leaflet-popup-content-wrapper {
|
||||
background: var(--sf) !important; border: 1px solid var(--bd) !important;
|
||||
border-radius: 16px !important; box-shadow: var(--sh-lg) !important;
|
||||
padding: 0 !important; overflow: hidden !important; color: var(--tx) !important;
|
||||
}
|
||||
.leaflet-popup-content { margin: 0 !important; width: auto !important; }
|
||||
.leaflet-popup-tip-container { display: none; }
|
||||
.leaflet-popup-close-button {
|
||||
top: 10px !important; right: 12px !important;
|
||||
width: 26px !important; height: 26px !important;
|
||||
font-size: 14px !important; line-height: 26px !important;
|
||||
color: var(--txd) !important; background: var(--sf2) !important;
|
||||
border-radius: 8px !important; border: 1px solid var(--bd) !important;
|
||||
}
|
||||
|
||||
/* Popup card */
|
||||
.pcard-detail { min-width: 300px; max-width: 360px; font-family: 'Plus Jakarta Sans', sans-serif; }
|
||||
|
||||
/* Header popup */
|
||||
.pcd-header {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 14px 16px 12px;
|
||||
border-bottom: 1px solid var(--bd);
|
||||
}
|
||||
.pcd-header-icon {
|
||||
width: 38px; height: 38px; border-radius: 10px; flex-shrink: 0;
|
||||
background: var(--greenb); border: 1px solid var(--greenr);
|
||||
display: flex; align-items: center; justify-content: center; font-size: 18px;
|
||||
}
|
||||
.pcd-header-icon.jalan { background: var(--jalan-prov-bg); border-color: var(--jalan-prov-ring); }
|
||||
.pcd-header-icon.parsil { background: var(--shm-bg); border-color: var(--shm-ring); }
|
||||
.pcd-header-text { flex: 1; min-width: 0; }
|
||||
.pcd-title {
|
||||
font-size: 15px; font-weight: 800; color: var(--tx);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.pcd-subtitle { font-size: 11px; color: var(--txd); margin-top: 2px; }
|
||||
|
||||
/* Rows popup */
|
||||
.pcd-rows { display: flex; flex-direction: column; gap: 6px; padding: 12px 16px; }
|
||||
.pcd-row {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
background: var(--sf2); padding: 8px 12px;
|
||||
border-radius: 10px; border: 1px solid var(--bd);
|
||||
}
|
||||
.pcd-label { font-size: 11px; font-weight: 600; color: var(--txd); display: flex; align-items: center; gap: 5px; white-space: nowrap; }
|
||||
.pcd-val { font-size: 13px; font-weight: 700; color: var(--tx); text-align: right; word-break: break-word; max-width: 180px; }
|
||||
|
||||
/* Actions popup */
|
||||
.pcd-actions {
|
||||
display: flex; gap: 6px; padding: 10px 16px 14px;
|
||||
border-top: 1px solid var(--bd);
|
||||
}
|
||||
.pbtn {
|
||||
flex: 1; display: flex; align-items: center; justify-content: center;
|
||||
gap: 5px; padding: 8px 10px; border-radius: 10px;
|
||||
font-size: 12px; font-weight: 700; cursor: pointer;
|
||||
transition: all .18s; border: 1.5px solid; font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
}
|
||||
/* Edit → hijau tema */
|
||||
.pbtn-edit {
|
||||
background: var(--greenb); color: var(--green); border-color: var(--greenr);
|
||||
}
|
||||
.pbtn-edit:hover { background: var(--green); color: #fff; border-color: var(--green); transform: translateY(-1px); }
|
||||
/* Move → indigo */
|
||||
.pbtn-move {
|
||||
background: var(--indigob); color: var(--indigo); border-color: var(--indigor);
|
||||
}
|
||||
.pbtn-move:hover { background: var(--indigo); color: #fff; border-color: var(--indigo); transform: translateY(-1px); }
|
||||
/* Delete → merah */
|
||||
.pbtn-del {
|
||||
background: var(--redb); color: var(--red); border-color: var(--redr);
|
||||
}
|
||||
.pbtn-del:hover { background: var(--red); color: #fff; border-color: var(--red); transform: translateY(-1px); }
|
||||
|
||||
/* ── MODAL ── */
|
||||
.overlay {
|
||||
position: fixed; inset: 0; z-index: 2000;
|
||||
background: rgba(15,20,35,.45); backdrop-filter: blur(6px);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
opacity: 0; pointer-events: none; transition: opacity .22s;
|
||||
}
|
||||
.overlay.open { opacity: 1; pointer-events: all; }
|
||||
|
||||
.modal {
|
||||
background: var(--sf); border: 1px solid var(--bd);
|
||||
border-radius: var(--radius-lg); width: 440px; max-width: 96vw;
|
||||
box-shadow: var(--sh-lg); overflow: hidden;
|
||||
transform: translateY(14px) scale(.98); transition: transform .22s;
|
||||
}
|
||||
.overlay.open .modal { transform: translateY(0) scale(1); }
|
||||
|
||||
/* Modal header strip */
|
||||
.modal-head {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 16px 20px 14px;
|
||||
border-bottom: 1px solid var(--bd);
|
||||
background: var(--greenb); /* ← hijau strip */
|
||||
}
|
||||
.modal-head-icon {
|
||||
width: 34px; height: 34px; border-radius: 9px; flex-shrink: 0;
|
||||
background: var(--green); color: #fff;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 16px;
|
||||
}
|
||||
.modal-head h2 { font-size: 15px; font-weight: 800; color: var(--green); } /* ← hijau */
|
||||
|
||||
/* Modal body */
|
||||
.modal-body { padding: 16px 20px; display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
/* Form group */
|
||||
.form-group { display: flex; flex-direction: column; gap: 5px; }
|
||||
.form-group label {
|
||||
font-size: 11px; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: .5px; color: var(--green); /* ← hijau */
|
||||
}
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%; padding: 10px 12px;
|
||||
background: var(--sf2); border: 1.5px solid var(--bd);
|
||||
border-radius: 10px; color: var(--tx);
|
||||
font-size: 13px; font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
outline: none; transition: border-color .18s, box-shadow .18s;
|
||||
appearance: none;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
border-color: var(--green);
|
||||
box-shadow: 0 0 0 3px rgba(22,163,74,.08);
|
||||
}
|
||||
.form-group input::placeholder { color: var(--txd); }
|
||||
.form-group input[readonly] { background: var(--sf3); color: var(--txd); cursor: default; }
|
||||
.form-group select {
|
||||
cursor: pointer;
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="%2316a34a" stroke-width="2"><polyline points="6 9 12 15 18 9"></polyline></svg>');
|
||||
background-repeat: no-repeat; background-position: right 10px center; background-size: 16px;
|
||||
padding-right: 32px;
|
||||
}
|
||||
|
||||
/* Form grid (2 kolom) */
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
|
||||
/* Modal footer */
|
||||
.modal-foot {
|
||||
display: flex; gap: 8px;
|
||||
padding: 12px 20px 16px;
|
||||
border-top: 1px solid var(--bd);
|
||||
background: var(--sf2);
|
||||
}
|
||||
.btn-ghost {
|
||||
flex: 1; padding: 10px; border-radius: 9px;
|
||||
border: 1.5px solid var(--bd); background: transparent;
|
||||
color: var(--txs); font-size: 13px; font-weight: 600;
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
cursor: pointer; transition: all .18s;
|
||||
}
|
||||
.btn-ghost:hover { border-color: var(--bdh); color: var(--tx); background: var(--sf); }
|
||||
.btn-primary {
|
||||
flex: 1.4; padding: 10px; border-radius: 9px; border: none; color: #fff;
|
||||
font-size: 13px; font-weight: 700; font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
cursor: pointer; transition: opacity .18s, transform .12s;
|
||||
display: flex; align-items: center; justify-content: center; gap: 6px;
|
||||
}
|
||||
.btn-primary:hover { opacity: .9; transform: translateY(-1px); }
|
||||
.btn-primary.green { background: linear-gradient(135deg, #22c55e, #16a34a); }
|
||||
.btn-primary.blue { background: linear-gradient(135deg, #3b82f6, #2563eb); }
|
||||
.btn-primary.purple { background: linear-gradient(135deg, #a78bfa, #7c3aed); }
|
||||
.btn-primary.red { background: linear-gradient(135deg, #f87171, #dc2626); }
|
||||
|
||||
/* ── CONFIRM MODAL ── */
|
||||
.confirm-modal { width: 360px; max-width: 94vw; }
|
||||
.confirm-body { padding: 28px 24px 8px; text-align: center; }
|
||||
.confirm-icon { font-size: 36px; margin-bottom: 10px; line-height: 1; }
|
||||
.confirm-title { font-size: 16px; font-weight: 800; color: var(--tx); margin-bottom: 6px; }
|
||||
.confirm-message { font-size: 13px; color: var(--txs); line-height: 1.5; }
|
||||
|
||||
/* ── TOAST ── */
|
||||
#toasts {
|
||||
position: fixed; bottom: 18px; right: 18px; z-index: 9999;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
}
|
||||
.toast {
|
||||
background: var(--sf); border: 1px solid var(--bd);
|
||||
border-radius: 9px; padding: 9px 14px;
|
||||
font-size: 12px; font-weight: 600;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
min-width: 220px; box-shadow: var(--sh);
|
||||
animation: tslide .22s ease;
|
||||
}
|
||||
.toast.ok { border-color: var(--greenr); color: var(--green); }
|
||||
.toast.er { border-color: var(--redr); color: var(--red); }
|
||||
@keyframes tslide { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
/* ── RESPONSIVE ── */
|
||||
@media (max-width: 640px) {
|
||||
.modal { width: 92vw; }
|
||||
.pcard-detail { min-width: 200px; max-width: 260px; }
|
||||
#search-wrap { max-width: 160px; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
-- Tabel SPBU (Point)
|
||||
CREATE TABLE `spbu` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`namaspbu` varchar(150) NOT NULL,
|
||||
`nomorwa` varchar(20) DEFAULT NULL,
|
||||
`statusbuka` enum('full','limited') DEFAULT 'limited',
|
||||
`koordinat` JSON DEFAULT NULL COMMENT 'GeoJSON Point',
|
||||
`lat` decimal(10,8) GENERATED ALWAYS AS (JSON_EXTRACT(`koordinat`, '$.coordinates[1]')) STORED,
|
||||
`lng` decimal(11,8) GENERATED ALWAYS AS (JSON_EXTRACT(`koordinat`, '$.coordinates[0]')) STORED,
|
||||
`createdat` timestamp NOT NULL DEFAULT current_timestamp(),
|
||||
`updatedat` timestamp NULL DEFAULT NULL ON UPDATE current_timestamp(),
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
-- Tabel Jalan (Polyline)
|
||||
CREATE TABLE `jalan` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`nama` varchar(150) NOT NULL,
|
||||
`status` enum('nasional','provinsi','kabupaten') NOT NULL DEFAULT 'kabupaten',
|
||||
`panjang_meter` decimal(12,2) DEFAULT NULL,
|
||||
`geom` JSON NOT NULL COMMENT 'GeoJSON LineString',
|
||||
`createdat` timestamp NOT NULL DEFAULT current_timestamp(),
|
||||
`updatedat` timestamp NULL DEFAULT NULL ON UPDATE current_timestamp(),
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
-- Tabel Parsil Tanah (Polygon)
|
||||
CREATE TABLE `parsil` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`nama` varchar(150) NOT NULL,
|
||||
`status_kepemilikan` enum('SHM','HGB','HGU','HP') NOT NULL DEFAULT 'SHM',
|
||||
`luas_m2` decimal(14,2) DEFAULT NULL,
|
||||
`geom` JSON NOT NULL COMMENT 'GeoJSON Polygon',
|
||||
`createdat` timestamp NOT NULL DEFAULT current_timestamp(),
|
||||
`updatedat` timestamp NULL DEFAULT NULL ON UPDATE current_timestamp(),
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
+420
@@ -0,0 +1,420 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WebGIS SPBU, Jalan & Parsil</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Fira+Code:wght@400;500&display=swap">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="loading">
|
||||
<div class="ld-inner">
|
||||
<div class="spin"></div>
|
||||
<p>Memuat WebGIS…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="app">
|
||||
|
||||
<!-- ═══════════ SIDEBAR ═══════════ -->
|
||||
<aside id="sidebar">
|
||||
<div class="sb-header">
|
||||
<div class="sb-brand">
|
||||
<div class="sb-icon">⛽</div>
|
||||
<div>
|
||||
<div class="sb-title">WebGIS SPBU</div>
|
||||
<div class="sb-sub">Jalan & Parsil Tanah</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sb-scroll">
|
||||
|
||||
<!-- Tipe Geometri -->
|
||||
<div class="sb-section">
|
||||
<div class="sb-label">Tipe Geometri</div>
|
||||
<div class="geom-selector">
|
||||
<button class="geom-btn" data-geom="point">📍 Point</button>
|
||||
<button class="geom-btn" data-geom="polyline">📏 Line</button>
|
||||
<button class="geom-btn" data-geom="polygon">🗺️ Polygon</button>
|
||||
</div>
|
||||
<div class="geom-hint" id="geom-hint">
|
||||
<span class="gh-dot"></span>
|
||||
<span id="geom-hint-text">Klik tipe untuk mulai menggambar</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legenda SPBU -->
|
||||
<div class="sb-section">
|
||||
<div class="sb-label">Legenda SPBU</div>
|
||||
<div class="legend-card">
|
||||
<div class="leg-row"><span class="leg-dot spbu-24"></span> Buka 24 Jam</div>
|
||||
<div class="leg-row"><span class="leg-dot spbu-ltd"></span> Tidak 24 Jam</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legenda Jalan -->
|
||||
<div class="sb-section">
|
||||
<div class="sb-label">Legenda Jalan</div>
|
||||
<div class="legend-card">
|
||||
<div class="leg-row"><span class="leg-line jalan-nas"></span> Jalan Nasional</div>
|
||||
<div class="leg-row"><span class="leg-line jalan-prov"></span> Jalan Provinsi</div>
|
||||
<div class="leg-row"><span class="leg-line jalan-kab"></span> Jalan Kabupaten</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legenda Parsil -->
|
||||
<div class="sb-section">
|
||||
<div class="sb-label">Legenda Parsil Tanah</div>
|
||||
<div class="legend-card">
|
||||
<div class="leg-row"><span class="leg-poly parsil-shm"></span> SHM — Hak Milik</div>
|
||||
<div class="leg-row"><span class="leg-poly parsil-hgb"></span> HGB — Hak Guna Bangunan</div>
|
||||
<div class="leg-row"><span class="leg-poly parsil-hgu"></span> HGU — Hak Guna Usaha</div>
|
||||
<div class="leg-row"><span class="leg-poly parsil-hp"></span> HP — Hak Pakai</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistik -->
|
||||
<div class="sb-section">
|
||||
<div class="sb-label">Statistik</div>
|
||||
<div class="stat-grid">
|
||||
<div class="stat-item"><div class="stat-num" id="cnt-total">—</div><div class="stat-lbl">Total SPBU</div></div>
|
||||
<div class="stat-item green"><div class="stat-num" id="cnt-24">—</div><div class="stat-lbl">Buka 24 Jam</div></div>
|
||||
<div class="stat-item blue"><div class="stat-num" id="cnt-jalan">—</div><div class="stat-lbl">Jalan</div></div>
|
||||
<div class="stat-item purple"><div class="stat-num" id="cnt-parsil">—</div><div class="stat-lbl">Parsil</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Daftar SPBU -->
|
||||
<div class="sb-section">
|
||||
<div class="sb-label">Daftar SPBU</div>
|
||||
<div id="spbu-list"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ═══════════ MAIN ═══════════ -->
|
||||
<div id="main">
|
||||
<div id="topbar">
|
||||
<div class="coord-pill">
|
||||
<span>📍</span>
|
||||
<span id="cur-lat">—</span><span class="pill-sep">|</span><span id="cur-lng">—</span>
|
||||
</div>
|
||||
|
||||
<div id="search-wrap">
|
||||
<div class="search-box">
|
||||
<span class="search-icon">🔍</span>
|
||||
<input id="search-input" type="text" placeholder="Cari SPBU, Jalan, Parsil…" autocomplete="off">
|
||||
</div>
|
||||
<div id="search-results"></div>
|
||||
</div>
|
||||
|
||||
<!-- Layer toggle button -->
|
||||
<div class="layer-wrap" id="lw">
|
||||
<button class="layer-btn" id="ldbtn">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 1L14.5 4.5L8 8L1.5 4.5L8 1Z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>
|
||||
<path d="M1.5 8L8 11.5L14.5 8" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>
|
||||
<path d="M1.5 11.5L8 15L14.5 11.5" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Layer <span class="chev">▼</span>
|
||||
</button>
|
||||
<div class="layer-menu" id="layer-menu">
|
||||
<div class="lm-label">Tampilkan Layer</div>
|
||||
|
||||
<label class="lm-item">
|
||||
<input type="checkbox" id="lm-point" checked>
|
||||
<span class="lm-check"></span>
|
||||
<span class="lm-icon">📍</span>
|
||||
<span>SPBU</span>
|
||||
</label>
|
||||
|
||||
<div class="lm-sub" id="lm-sub-spbu">
|
||||
<label class="lm-sub-item">
|
||||
<input type="checkbox" id="lm-spbu24" checked>
|
||||
<span class="lm-check sm"></span>
|
||||
<span class="lm-sub-dot dot-green"></span>
|
||||
<span>Buka 24 Jam</span>
|
||||
</label>
|
||||
<label class="lm-sub-item">
|
||||
<input type="checkbox" id="lm-spbuLtd" checked>
|
||||
<span class="lm-check sm"></span>
|
||||
<span class="lm-sub-dot dot-red"></span>
|
||||
<span>Tidak 24 Jam</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="lm-item">
|
||||
<input type="checkbox" id="lm-jalan" checked>
|
||||
<span class="lm-check"></span>
|
||||
<span class="lm-icon">📏</span>
|
||||
<span>Jalan</span>
|
||||
</label>
|
||||
|
||||
<label class="lm-item">
|
||||
<input type="checkbox" id="lm-parsil" checked>
|
||||
<span class="lm-check"></span>
|
||||
<span class="lm-icon">🗺️</span>
|
||||
<span>Parsil</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="map-wrap">
|
||||
<div id="map"></div>
|
||||
|
||||
<!-- Draw toolbar -->
|
||||
<div id="draw-toolbar">
|
||||
<div class="dt-hint">ℹ️ <span id="dt-hint-text">Klik peta untuk menggambar</span></div>
|
||||
<span class="dt-counter" id="dt-counter">0 titik</span>
|
||||
<button class="dt-btn undo" id="btn-undo" disabled>↩️ Undo</button>
|
||||
<button class="dt-btn redo" id="btn-redo" disabled>↪️ Redo</button>
|
||||
<button class="dt-btn cancel" id="btn-cancel-draw">❌ Batal</button>
|
||||
<button class="dt-btn finish" id="btn-finish">✅ Selesai</button>
|
||||
</div>
|
||||
|
||||
<!-- Geom Edit toolbar -->
|
||||
<div id="geom-edit-toolbar">
|
||||
<div class="dt-hint">📐 <span id="geom-edit-hint">Drag titik untuk edit geometri</span></div>
|
||||
<span class="dt-counter geom-edit-counter" id="geom-edit-info"></span>
|
||||
<button class="dt-btn cancel" id="btn-geom-cancel">❌ Batal</button>
|
||||
<button class="dt-btn finish" id="btn-geom-save">💾 Simpan</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ MODALS ═══════════ -->
|
||||
|
||||
<!-- Tambah SPBU -->
|
||||
<div class="overlay" id="m-add-spbu">
|
||||
<div class="modal">
|
||||
<div class="modal-head">
|
||||
<div class="modal-head-icon">⛽</div>
|
||||
<h2>Tambah SPBU</h2>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Nama SPBU</label>
|
||||
<input id="spbu-nama" placeholder="Contoh: SPBU MJ 1234">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>No WhatsApp</label>
|
||||
<input id="spbu-wa" placeholder="Contoh: 62812345678">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Status Operasional</label>
|
||||
<select id="spbu-status">
|
||||
<option value="full">Buka 24 Jam</option>
|
||||
<option value="limited">Tidak 24 Jam</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Koordinat</label>
|
||||
<div class="form-row">
|
||||
<input id="spbu-lat" placeholder="Latitude" readonly>
|
||||
<input id="spbu-lng" placeholder="Longitude" readonly>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="btn-ghost" onclick="closeModal('m-add-spbu')">Batal</button>
|
||||
<button class="btn-primary green" onclick="saveSpbu()">💾 Simpan SPBU</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tambah Jalan -->
|
||||
<div class="overlay" id="m-add-jalan">
|
||||
<div class="modal">
|
||||
<div class="modal-head">
|
||||
<div class="modal-head-icon">🛣️</div>
|
||||
<h2>Tambah Jalan</h2>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Nama Jalan</label>
|
||||
<input id="j-nama" placeholder="Contoh: Jalan Ahmad Yani">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Status Jalan</label>
|
||||
<select id="j-status">
|
||||
<option value="nasional">Nasional</option>
|
||||
<option value="provinsi">Provinsi</option>
|
||||
<option value="kabupaten">Kabupaten</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Panjang (meter)</label>
|
||||
<input id="j-panjang" readonly>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="btn-ghost" onclick="closeModal('m-add-jalan')">Batal</button>
|
||||
<button class="btn-primary green" onclick="saveJalan()">💾 Simpan Jalan</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tambah Parsil -->
|
||||
<div class="overlay" id="m-add-parsil">
|
||||
<div class="modal">
|
||||
<div class="modal-head">
|
||||
<div class="modal-head-icon">🗺️</div>
|
||||
<h2>Tambah Parsil</h2>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Nama Parsil</label>
|
||||
<input id="p-nama" placeholder="Contoh: Tanah A">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Status Kepemilikan</label>
|
||||
<select id="p-status">
|
||||
<option value="SHM">SHM — Hak Milik</option>
|
||||
<option value="HGB">HGB — Hak Guna Bangunan</option>
|
||||
<option value="HGU">HGU — Hak Guna Usaha</option>
|
||||
<option value="HP">HP — Hak Pakai</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Luas (m²)</label>
|
||||
<input id="p-luas" readonly>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="btn-ghost" onclick="closeModal('m-add-parsil')">Batal</button>
|
||||
<button class="btn-primary purple" onclick="saveParsil()">💾 Simpan Parsil</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit SPBU -->
|
||||
<div class="overlay" id="m-edit-spbu">
|
||||
<div class="modal">
|
||||
<div class="modal-head">
|
||||
<div class="modal-head-icon">✏️</div>
|
||||
<h2>Edit SPBU</h2>
|
||||
</div>
|
||||
<input type="hidden" id="e-spbu-id">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Nama SPBU</label>
|
||||
<input id="e-spbu-nama">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>No WhatsApp</label>
|
||||
<input id="e-spbu-wa">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Status Operasional</label>
|
||||
<select id="e-spbu-status">
|
||||
<option value="full">Buka 24 Jam</option>
|
||||
<option value="limited">Tidak 24 Jam</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="btn-ghost" onclick="closeModal('m-edit-spbu')">Batal</button>
|
||||
<button class="btn-primary green" onclick="updateSpbu()">✅ Update SPBU</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Jalan -->
|
||||
<div class="overlay" id="m-edit-jalan">
|
||||
<div class="modal">
|
||||
<div class="modal-head">
|
||||
<div class="modal-head-icon">✏️</div>
|
||||
<h2>Edit Jalan</h2>
|
||||
</div>
|
||||
<input type="hidden" id="e-jalan-id">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Nama Jalan</label>
|
||||
<input id="e-j-nama">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Status Jalan</label>
|
||||
<select id="e-j-status">
|
||||
<option value="nasional">Nasional</option>
|
||||
<option value="provinsi">Provinsi</option>
|
||||
<option value="kabupaten">Kabupaten</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Panjang (meter)</label>
|
||||
<input id="e-j-panjang" readonly>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="btn-ghost" onclick="closeModal('m-edit-jalan')">Batal</button>
|
||||
<button class="btn-primary blue" onclick="updateJalan()">✅ Update Jalan</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Parsil -->
|
||||
<div class="overlay" id="m-edit-parsil">
|
||||
<div class="modal">
|
||||
<div class="modal-head">
|
||||
<div class="modal-head-icon">✏️</div>
|
||||
<h2>Edit Parsil</h2>
|
||||
</div>
|
||||
<input type="hidden" id="e-parsil-id">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Nama Parsil</label>
|
||||
<input id="e-p-nama">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Status Kepemilikan</label>
|
||||
<select id="e-p-status">
|
||||
<option value="SHM">SHM — Hak Milik</option>
|
||||
<option value="HGB">HGB — Hak Guna Bangunan</option>
|
||||
<option value="HGU">HGU — Hak Guna Usaha</option>
|
||||
<option value="HP">HP — Hak Pakai</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Luas (m²)</label>
|
||||
<input id="e-p-luas" readonly>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="btn-ghost" onclick="closeModal('m-edit-parsil')">Batal</button>
|
||||
<button class="btn-primary purple" onclick="updateParsil()">✅ Update Parsil</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Dialog -->
|
||||
<div class="overlay" id="confirm-overlay">
|
||||
<div class="modal confirm-modal">
|
||||
<div class="modal-head">
|
||||
<div class="modal-head-icon" style="background:#dc2626;">⚠️</div>
|
||||
<h2 id="confirm-title" style="color:var(--red);">Konfirmasi</h2>
|
||||
</div>
|
||||
<div class="confirm-body">
|
||||
<p id="confirm-message" class="confirm-message">Apakah Anda yakin?</p>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="btn-ghost" id="confirm-cancel">Tidak, Batal</button>
|
||||
<button class="btn-primary red" id="confirm-ok">🗑️ Ya, Hapus</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toasts"></div>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@turf/turf@6/turf.min.js"></script>
|
||||
<script src="js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+923
@@ -0,0 +1,923 @@
|
||||
// ===============================
|
||||
// KONFIGURASI API
|
||||
// ===============================
|
||||
const API = {
|
||||
spbu: 'api/spbu.php',
|
||||
jalan: 'api/jalan.php',
|
||||
parsil: 'api/parsil.php'
|
||||
};
|
||||
|
||||
// ===============================
|
||||
// WARNA TEMA
|
||||
// ===============================
|
||||
const COLORS = {
|
||||
spbu24: { fill: '#22c55e', stroke: '#15803d', glow: 'rgba(34,197,94,.35)' },
|
||||
spbuLtd: { fill: '#f87171', stroke: '#dc2626', glow: 'rgba(248,113,113,.35)' },
|
||||
jalanNasional: '#f97316',
|
||||
jalanProvinsi: '#3b82f6',
|
||||
jalanKabupaten: '#10b981',
|
||||
SHM: { stroke: '#d97706', fill: '#fef3c7' },
|
||||
HGB: { stroke: '#8b5cf6', fill: '#ede9fe' },
|
||||
HGU: { stroke: '#0891b2', fill: '#cffafe' },
|
||||
HP: { stroke: '#ec4899', fill: '#fce7f3' },
|
||||
};
|
||||
|
||||
// ===============================
|
||||
// STATE GLOBAL
|
||||
// ===============================
|
||||
let map;
|
||||
let layers = { jalan: null, parsil: null };
|
||||
let spbu24Group, spbuLtdGroup;
|
||||
|
||||
let currentGeom = null;
|
||||
let drawPoints = [];
|
||||
let tempLayer = null;
|
||||
let savedDrawPoints = [];
|
||||
|
||||
let allSpbu = [], allJalan = [], allParsil = [];
|
||||
let isDraggingMarker = false;
|
||||
|
||||
let vertexMarkers = [];
|
||||
let undoStack = [];
|
||||
let redoStack = [];
|
||||
|
||||
let editingFeature = null;
|
||||
|
||||
// ===============================
|
||||
// CONFIRM DIALOG
|
||||
// ===============================
|
||||
function showConfirm(message, title = 'Konfirmasi') {
|
||||
return new Promise(resolve => {
|
||||
const overlay = document.getElementById('confirm-overlay');
|
||||
const titleEl = document.getElementById('confirm-title');
|
||||
const msgEl = document.getElementById('confirm-message');
|
||||
if (titleEl) titleEl.textContent = title;
|
||||
if (msgEl) msgEl.textContent = message;
|
||||
overlay.classList.add('open');
|
||||
const btnOk = document.getElementById('confirm-ok');
|
||||
const btnCancel = document.getElementById('confirm-cancel');
|
||||
function cleanup() {
|
||||
overlay.classList.remove('open');
|
||||
btnOk.removeEventListener('click', onOk);
|
||||
btnCancel.removeEventListener('click', onCancel);
|
||||
}
|
||||
function onOk() { cleanup(); resolve(true); }
|
||||
function onCancel() { cleanup(); resolve(false); }
|
||||
btnOk.addEventListener('click', onOk);
|
||||
btnCancel.addEventListener('click', onCancel);
|
||||
});
|
||||
}
|
||||
|
||||
function showToast(msg, type = 'ok') {
|
||||
const container = document.getElementById('toasts');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
toast.innerHTML = (type === 'ok' ? '✅ ' : '❌ ') + msg;
|
||||
container.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 3200);
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// ICON SPBU
|
||||
// ===============================
|
||||
function makeSpbuIcon(is24) {
|
||||
const c = is24 ? COLORS.spbu24 : COLORS.spbuLtd;
|
||||
const id = is24 ? 'g' : 'r';
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="40" viewBox="0 0 32 40">
|
||||
<defs>
|
||||
<filter id="glow-${id}" x="-40%" y="-40%" width="180%" height="180%">
|
||||
<feGaussianBlur stdDeviation="2.5" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<ellipse cx="16" cy="38" rx="7" ry="2.5" fill="rgba(0,0,0,0.18)"/>
|
||||
<path d="M16 2 C9.4 2 4 7.4 4 14 C4 22 16 36 16 36 C16 36 28 22 28 14 C28 7.4 22.6 2 16 2Z"
|
||||
fill="${c.fill}" stroke="${c.stroke}" stroke-width="1.8" filter="url(#glow-${id})"/>
|
||||
<circle cx="16" cy="14" r="7" fill="white" opacity="0.92"/>
|
||||
<text x="16" y="18.5" text-anchor="middle" font-size="10" font-family="sans-serif">⛽</text>
|
||||
</svg>`;
|
||||
return L.divIcon({ html: svg, className: '', iconSize: [32, 40], iconAnchor: [16, 38], popupAnchor: [0, -38] });
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// INIT MAP
|
||||
// ===============================
|
||||
function initMap() {
|
||||
map = L.map('map').setView([-0.0263, 109.3425], 14);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap', maxZoom: 19
|
||||
}).addTo(map);
|
||||
|
||||
map.on('click', onMapClick);
|
||||
map.on('mousemove', e => {
|
||||
document.getElementById('cur-lat').innerText = e.latlng.lat.toFixed(5);
|
||||
document.getElementById('cur-lng').innerText = e.latlng.lng.toFixed(5);
|
||||
});
|
||||
|
||||
spbu24Group = L.layerGroup().addTo(map);
|
||||
spbuLtdGroup = L.layerGroup().addTo(map);
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// BUILD POPUP — SPBU
|
||||
// ===============================
|
||||
function buildSpbuPopup(s) {
|
||||
const nama = s.namaspbu || '—';
|
||||
const is24 = s.statusbuka === 'full';
|
||||
const coord = (s.lat && s.lng) ? `${Number(s.lat).toFixed(6)}, ${Number(s.lng).toFixed(6)}` : '—';
|
||||
const wa = s.nomorwa || '';
|
||||
const badgeCls = is24 ? 'bg' : 'br';
|
||||
const statusLabel = is24 ? 'Buka 24 Jam' : 'Tidak 24 Jam';
|
||||
|
||||
return `
|
||||
<div class="pcard-detail">
|
||||
<div class="pcd-header">
|
||||
<div class="pcd-header-icon">⛽</div>
|
||||
<div class="pcd-header-text">
|
||||
<div class="pcd-title">${nama}</div>
|
||||
<div class="pcd-subtitle">SPBU</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pcd-rows">
|
||||
<div class="pcd-row">
|
||||
<span class="pcd-label">📍 Koordinat</span>
|
||||
<span class="pcd-val">${coord}</span>
|
||||
</div>
|
||||
<div class="pcd-row">
|
||||
<span class="pcd-label">⏰ Status</span>
|
||||
<span class="badge ${badgeCls}">${statusLabel}</span>
|
||||
</div>
|
||||
${wa ? `<div class="pcd-row">
|
||||
<span class="pcd-label">📱 WhatsApp</span>
|
||||
<span class="pcd-val">${wa}</span>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
<div class="pcd-actions">
|
||||
<button class="pbtn pbtn-edit" onclick="editSpbu(${s.id})">✏️ Edit</button>
|
||||
<button class="pbtn pbtn-del" onclick="hapusSpbu(${s.id})">🗑️ Hapus</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// BUILD POPUP — JALAN
|
||||
// ===============================
|
||||
function buildJalanPopup(p) {
|
||||
const nama = p.nama || '—';
|
||||
const status = p.status || 'kabupaten';
|
||||
const panjang = p.panjang_meter ? Number(p.panjang_meter).toFixed(2) + ' m' : '—';
|
||||
let statusLabel = 'Kabupaten', badgeClass = 'bb-green';
|
||||
if (status === 'nasional') { statusLabel = 'Nasional'; badgeClass = 'bb-orange'; }
|
||||
if (status === 'provinsi') { statusLabel = 'Provinsi'; badgeClass = 'bb-blue'; }
|
||||
|
||||
return `
|
||||
<div class="pcard-detail">
|
||||
<div class="pcd-header">
|
||||
<div class="pcd-header-icon jalan">🛣️</div>
|
||||
<div class="pcd-header-text">
|
||||
<div class="pcd-title">${nama}</div>
|
||||
<div class="pcd-subtitle">Jalan</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pcd-rows">
|
||||
<div class="pcd-row">
|
||||
<span class="pcd-label">🛣️ Status</span>
|
||||
<span class="badge ${badgeClass}">${statusLabel}</span>
|
||||
</div>
|
||||
<div class="pcd-row">
|
||||
<span class="pcd-label">📏 Panjang</span>
|
||||
<span class="pcd-val">${panjang}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pcd-actions">
|
||||
<button class="pbtn pbtn-edit" onclick="editJalan(${p.id})">✏️ Edit Atribut</button>
|
||||
<button class="pbtn pbtn-move" onclick="startEditGeomJalan(${p.id})">📐 Edit Geometri</button>
|
||||
<button class="pbtn pbtn-del" onclick="hapusJalan(${p.id})">🗑️ Hapus</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// BUILD POPUP — PARSIL
|
||||
// ===============================
|
||||
function buildParsilPopup(props) {
|
||||
const nama = props.nama || '—';
|
||||
const status = props.status_kepemilikan || 'SHM';
|
||||
const luas = props.luas_m2 ? Number(props.luas_m2).toFixed(2) + ' m²' : '—';
|
||||
const badgeMap = { SHM: 'bp-shm', HGB: 'bp-hgb', HGU: 'bp-hgu', HP: 'bp-hp' };
|
||||
const badgeClass = badgeMap[status] || 'bp-shm';
|
||||
|
||||
return `
|
||||
<div class="pcard-detail">
|
||||
<div class="pcd-header">
|
||||
<div class="pcd-header-icon parsil">🗺️</div>
|
||||
<div class="pcd-header-text">
|
||||
<div class="pcd-title">${nama}</div>
|
||||
<div class="pcd-subtitle">Parsil Tanah</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pcd-rows">
|
||||
<div class="pcd-row">
|
||||
<span class="pcd-label">📋 Kepemilikan</span>
|
||||
<span class="badge ${badgeClass}">${status}</span>
|
||||
</div>
|
||||
<div class="pcd-row">
|
||||
<span class="pcd-label">📐 Luas</span>
|
||||
<span class="pcd-val">${luas}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pcd-actions">
|
||||
<button class="pbtn pbtn-edit" onclick="editParsil(${props.id})">✏️ Edit Atribut</button>
|
||||
<button class="pbtn pbtn-move" onclick="startEditGeomParsil(${props.id})">📐 Edit Geometri</button>
|
||||
<button class="pbtn pbtn-del" onclick="hapusParsil(${props.id})">🗑️ Hapus</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// RENDER SPBU
|
||||
// ===============================
|
||||
function renderSpbuLayer() {
|
||||
spbu24Group.clearLayers();
|
||||
spbuLtdGroup.clearLayers();
|
||||
|
||||
allSpbu.forEach(s => {
|
||||
const is24 = s.statusbuka === 'full';
|
||||
const icon = makeSpbuIcon(is24);
|
||||
const marker = L.marker([s.lat, s.lng], { icon, draggable: true, title: s.namaspbu });
|
||||
marker.bindPopup(buildSpbuPopup(s));
|
||||
marker.on('click', () => { if (!currentGeom) marker.openPopup(); });
|
||||
marker.on('dragstart', () => { isDraggingMarker = true; marker.setOpacity(0.65); });
|
||||
marker.on('dragend', async () => {
|
||||
isDraggingMarker = false;
|
||||
marker.setOpacity(1);
|
||||
const newPos = marker.getLatLng();
|
||||
await updateSpbuCoordinates(s.id, newPos.lat, newPos.lng);
|
||||
s.lat = newPos.lat; s.lng = newPos.lng;
|
||||
marker.setPopupContent(buildSpbuPopup(s));
|
||||
showToast('Koordinat SPBU diperbarui');
|
||||
});
|
||||
if (is24) spbu24Group.addLayer(marker);
|
||||
else spbuLtdGroup.addLayer(marker);
|
||||
});
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// LOAD DATA
|
||||
// ===============================
|
||||
async function loadAllData() {
|
||||
try {
|
||||
const [spbuRes, jalanRes, parsilRes] = await Promise.all([
|
||||
fetch(API.spbu + '?action=list').then(r => r.json()),
|
||||
fetch(API.jalan + '?action=list').then(r => r.json()),
|
||||
fetch(API.parsil + '?action=list').then(r => r.json())
|
||||
]);
|
||||
allSpbu = spbuRes.data || [];
|
||||
allJalan = jalanRes.data || [];
|
||||
allParsil = parsilRes.data || [];
|
||||
renderSpbuLayer();
|
||||
renderJalanLayer();
|
||||
renderParsilLayer();
|
||||
renderSpbuList();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// RENDER JALAN
|
||||
// ===============================
|
||||
function renderJalanLayer() {
|
||||
if (layers.jalan) map.removeLayer(layers.jalan);
|
||||
const geojson = allJalan.map(j => ({
|
||||
type: 'Feature', geometry: JSON.parse(j.geom), properties: j
|
||||
}));
|
||||
layers.jalan = L.geoJSON(geojson, {
|
||||
style: f => {
|
||||
const s = f.properties.status;
|
||||
let color = COLORS.jalanKabupaten;
|
||||
if (s === 'nasional') color = COLORS.jalanNasional;
|
||||
else if (s === 'provinsi') color = COLORS.jalanProvinsi;
|
||||
return { color, weight: 4, opacity: 0.9 };
|
||||
},
|
||||
onEachFeature: (f, layer) => {
|
||||
layer.bindPopup(buildJalanPopup(f.properties));
|
||||
layer.on('click', e => { if (!currentGeom) { L.DomEvent.stopPropagation(e); layer.openPopup(); } });
|
||||
}
|
||||
}).addTo(map);
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// RENDER PARSIL
|
||||
// ===============================
|
||||
function renderParsilLayer() {
|
||||
if (layers.parsil) map.removeLayer(layers.parsil);
|
||||
const geojson = allParsil.map(p => ({
|
||||
type: 'Feature', geometry: JSON.parse(p.geom), properties: p
|
||||
}));
|
||||
layers.parsil = L.geoJSON(geojson, {
|
||||
style: f => {
|
||||
const c = COLORS[f.properties.status_kepemilikan] || COLORS.SHM;
|
||||
return { color: c.stroke, fillColor: c.fill, weight: 2.5, fillOpacity: 0.45 };
|
||||
},
|
||||
onEachFeature: (f, layer) => {
|
||||
layer.bindPopup(buildParsilPopup(f.properties));
|
||||
layer.on('click', e => { if (!currentGeom) { L.DomEvent.stopPropagation(e); layer.openPopup(); } });
|
||||
}
|
||||
}).addTo(map);
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// LIST SPBU & STATS
|
||||
// ===============================
|
||||
function renderSpbuList() {
|
||||
const el = document.getElementById('spbu-list');
|
||||
if (!el) return;
|
||||
document.getElementById('cnt-total').innerText = allSpbu.length;
|
||||
document.getElementById('cnt-24').innerText = allSpbu.filter(s => s.statusbuka === 'full').length;
|
||||
document.getElementById('cnt-jalan').innerText = allJalan.length;
|
||||
document.getElementById('cnt-parsil').innerText = allParsil.length;
|
||||
|
||||
if (!allSpbu.length) {
|
||||
el.innerHTML = '<div class="empty">Belum ada SPBU tercatat.</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = allSpbu.map(s => {
|
||||
const lat = parseFloat(s.lat) || 0;
|
||||
const lng = parseFloat(s.lng) || 0;
|
||||
const dot = s.statusbuka === 'full' ? 'dot-green' : 'dot-red';
|
||||
return `
|
||||
<div class="spbu-item" onclick="flyTo(${lat},${lng})">
|
||||
<div class="si-top">
|
||||
<div class="si-name">
|
||||
<span class="si-dot ${dot}"></span>
|
||||
${s.namaspbu || '—'}
|
||||
</div>
|
||||
<div class="si-actions" onclick="event.stopPropagation()">
|
||||
<button class="ib" title="Edit" onclick="editSpbu(${s.id})">✏️</button>
|
||||
<button class="ib del" title="Hapus" onclick="hapusSpbu(${s.id})">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function flyTo(lat, lng) { if (map) map.setView([lat, lng], 16); }
|
||||
|
||||
// ===============================
|
||||
// EDIT / HAPUS SPBU
|
||||
// ===============================
|
||||
function editSpbu(id) {
|
||||
const s = allSpbu.find(x => x.id == id);
|
||||
if (!s) return showToast('Data SPBU tidak ditemukan', 'er');
|
||||
document.getElementById('e-spbu-id').value = s.id;
|
||||
document.getElementById('e-spbu-nama').value = s.namaspbu || '';
|
||||
document.getElementById('e-spbu-wa').value = s.nomorwa || '';
|
||||
document.getElementById('e-spbu-status').value = s.statusbuka || 'limited';
|
||||
openModal('m-edit-spbu');
|
||||
}
|
||||
|
||||
async function hapusSpbu(id) {
|
||||
const ok = await showConfirm('Hapus SPBU ini? Tindakan tidak dapat dibatalkan.', 'Hapus SPBU');
|
||||
if (!ok) return;
|
||||
try {
|
||||
const res = await fetch(API.spbu, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'delete', id })
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.status === 'success') { loadAllData(); showToast('SPBU berhasil dihapus'); }
|
||||
else showToast(j.message || 'Gagal hapus', 'er');
|
||||
} catch (e) { showToast('Gagal: ' + e.message, 'er'); }
|
||||
}
|
||||
|
||||
async function updateSpbu() {
|
||||
const id = parseInt(document.getElementById('e-spbu-id').value || 0);
|
||||
const namaspbu = document.getElementById('e-spbu-nama').value;
|
||||
const nomorwa = document.getElementById('e-spbu-wa').value;
|
||||
const statusbuka = document.getElementById('e-spbu-status').value;
|
||||
try {
|
||||
const res = await fetch(API.spbu, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'update', id, namaspbu, nomorwa, statusbuka })
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.status === 'success') { closeModal('m-edit-spbu'); loadAllData(); showToast('SPBU diperbarui'); }
|
||||
else showToast(j.message || 'Gagal update', 'er');
|
||||
} catch (e) { showToast('Gagal: ' + e.message, 'er'); }
|
||||
}
|
||||
|
||||
async function updateSpbuCoordinates(id, lat, lng) {
|
||||
const spbu = allSpbu.find(x => x.id == id);
|
||||
if (!spbu) return;
|
||||
await fetch(API.spbu, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'update', id,
|
||||
namaspbu: spbu.namaspbu, nomorwa: spbu.nomorwa, statusbuka: spbu.statusbuka,
|
||||
koordinat: { type: 'Point', coordinates: [lng, lat] }
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// EDIT / HAPUS JALAN
|
||||
// ===============================
|
||||
function editJalan(id) {
|
||||
const j = allJalan.find(x => x.id == id);
|
||||
if (!j) return showToast('Data jalan tidak ditemukan', 'er');
|
||||
document.getElementById('e-jalan-id').value = j.id;
|
||||
document.getElementById('e-j-nama').value = j.nama || '';
|
||||
document.getElementById('e-j-status').value = j.status || 'kabupaten';
|
||||
document.getElementById('e-j-panjang').value = j.panjang_meter || '';
|
||||
openModal('m-edit-jalan');
|
||||
}
|
||||
|
||||
async function hapusJalan(id) {
|
||||
const ok = await showConfirm('Hapus jalan ini? Tindakan tidak dapat dibatalkan.', 'Hapus Jalan');
|
||||
if (!ok) return;
|
||||
try {
|
||||
const res = await fetch(API.jalan, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'delete', id })
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.status === 'success') { loadAllData(); showToast('Jalan berhasil dihapus'); }
|
||||
else showToast(j.message || 'Gagal hapus', 'er');
|
||||
} catch (e) { showToast('Gagal: ' + e.message, 'er'); }
|
||||
}
|
||||
|
||||
async function updateJalan() {
|
||||
const id = parseInt(document.getElementById('e-jalan-id').value || 0);
|
||||
const nama = document.getElementById('e-j-nama').value;
|
||||
const status = document.getElementById('e-j-status').value;
|
||||
const panjang_meter = parseFloat(document.getElementById('e-j-panjang').value);
|
||||
try {
|
||||
const res = await fetch(API.jalan, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'update', id, nama, status, panjang_meter })
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.status === 'success') { closeModal('m-edit-jalan'); loadAllData(); showToast('Jalan diperbarui'); }
|
||||
else showToast(j.message || 'Gagal update', 'er');
|
||||
} catch (e) { showToast('Gagal: ' + e.message, 'er'); }
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// EDIT GEOMETRI JALAN
|
||||
// ===============================
|
||||
function startEditGeomJalan(id) {
|
||||
map.closePopup();
|
||||
stopEditGeom();
|
||||
const j = allJalan.find(x => x.id == id);
|
||||
if (!j) return;
|
||||
const geom = JSON.parse(j.geom);
|
||||
let coords = geom.coordinates.map(c => [c[1], c[0]]);
|
||||
const geomLayer = L.polyline(coords, { color: '#f97316', weight: 4, dashArray: '8,5', opacity: 1 }).addTo(map);
|
||||
const vms = [];
|
||||
const updatePolyline = () => {
|
||||
const pts = vms.map(vm => vm.getLatLng());
|
||||
geomLayer.setLatLngs(pts);
|
||||
const turfCoords = pts.map(p => [p.lng, p.lat]);
|
||||
if (turfCoords.length >= 2) {
|
||||
const panjang = turf.length(turf.lineString(turfCoords), { units: 'meters' });
|
||||
document.getElementById('geom-edit-info').textContent = `Panjang: ${panjang.toFixed(2)} m`;
|
||||
}
|
||||
};
|
||||
coords.forEach(c => {
|
||||
const vm = L.marker(c, { draggable: true, icon: vertexIcon() }).addTo(map);
|
||||
vm.on('drag', updatePolyline);
|
||||
vms.push(vm);
|
||||
});
|
||||
editingFeature = { type: 'jalan', id, geomLayer, vms };
|
||||
showEditGeomToolbar('Drag titik untuk edit geometri jalan');
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// EDIT / HAPUS PARSIL
|
||||
// ===============================
|
||||
function editParsil(id) {
|
||||
const p = allParsil.find(x => x.id == id);
|
||||
if (!p) return showToast('Data parsil tidak ditemukan', 'er');
|
||||
document.getElementById('e-parsil-id').value = p.id;
|
||||
document.getElementById('e-p-nama').value = p.nama || '';
|
||||
document.getElementById('e-p-status').value = p.status_kepemilikan || 'SHM';
|
||||
document.getElementById('e-p-luas').value = p.luas_m2 || '';
|
||||
openModal('m-edit-parsil');
|
||||
}
|
||||
|
||||
async function hapusParsil(id) {
|
||||
const ok = await showConfirm('Hapus parsil ini? Tindakan tidak dapat dibatalkan.', 'Hapus Parsil');
|
||||
if (!ok) return;
|
||||
try {
|
||||
const res = await fetch(API.parsil, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'delete', id })
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.status === 'success') { loadAllData(); showToast('Parsil berhasil dihapus'); }
|
||||
else showToast(j.message || 'Gagal hapus', 'er');
|
||||
} catch (e) { showToast('Gagal: ' + e.message, 'er'); }
|
||||
}
|
||||
|
||||
async function updateParsil() {
|
||||
const id = parseInt(document.getElementById('e-parsil-id').value || 0);
|
||||
const nama = document.getElementById('e-p-nama').value;
|
||||
const status_kepemilikan = document.getElementById('e-p-status').value;
|
||||
const luas_m2 = parseFloat(document.getElementById('e-p-luas').value);
|
||||
try {
|
||||
const res = await fetch(API.parsil, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'update', id, nama, status_kepemilikan, luas_m2 })
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.status === 'success') { closeModal('m-edit-parsil'); loadAllData(); showToast('Parsil diperbarui'); }
|
||||
else showToast(j.message || 'Gagal update', 'er');
|
||||
} catch (e) { showToast('Gagal: ' + e.message, 'er'); }
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// EDIT GEOMETRI PARSIL
|
||||
// ===============================
|
||||
function startEditGeomParsil(id) {
|
||||
map.closePopup();
|
||||
stopEditGeom();
|
||||
const p = allParsil.find(x => x.id == id);
|
||||
if (!p) return;
|
||||
const geom = JSON.parse(p.geom);
|
||||
let coords = geom.coordinates[0].slice(0, -1).map(c => [c[1], c[0]]);
|
||||
const geomLayer = L.polygon(coords, { color: '#8b5cf6', fillColor: '#ede9fe', weight: 2.5, dashArray: '8,5', fillOpacity: 0.3 }).addTo(map);
|
||||
const vms = [];
|
||||
const updatePolygon = () => {
|
||||
const pts = vms.map(vm => vm.getLatLng());
|
||||
geomLayer.setLatLngs(pts);
|
||||
const turfCoords = [...pts.map(p => [p.lng, p.lat])];
|
||||
turfCoords.push(turfCoords[0]);
|
||||
if (turfCoords.length >= 4) {
|
||||
const luas = turf.area(turf.polygon([turfCoords]));
|
||||
document.getElementById('geom-edit-info').textContent = `Luas: ${luas.toFixed(2)} m²`;
|
||||
}
|
||||
};
|
||||
coords.forEach(c => {
|
||||
const vm = L.marker(c, { draggable: true, icon: vertexIcon() }).addTo(map);
|
||||
vm.on('drag', updatePolygon);
|
||||
vms.push(vm);
|
||||
});
|
||||
editingFeature = { type: 'parsil', id, geomLayer, vms };
|
||||
showEditGeomToolbar('Drag titik untuk edit geometri parsil');
|
||||
}
|
||||
|
||||
function vertexIcon() {
|
||||
return L.divIcon({
|
||||
html: `<div style="width:14px;height:14px;background:#fff;border:2.5px solid #16a34a;border-radius:50%;box-shadow:0 2px 8px rgba(22,163,74,.4);cursor:grab;"></div>`,
|
||||
className: '', iconSize: [14, 14], iconAnchor: [7, 7]
|
||||
});
|
||||
}
|
||||
|
||||
function showEditGeomToolbar(hint) {
|
||||
const tb = document.getElementById('geom-edit-toolbar');
|
||||
document.getElementById('geom-edit-hint').textContent = hint;
|
||||
document.getElementById('geom-edit-info').textContent = '';
|
||||
tb.classList.add('visible');
|
||||
}
|
||||
|
||||
function stopEditGeom() {
|
||||
if (!editingFeature) return;
|
||||
if (editingFeature.geomLayer) map.removeLayer(editingFeature.geomLayer);
|
||||
editingFeature.vms.forEach(vm => { try { map.removeLayer(vm); } catch (e) {} });
|
||||
editingFeature = null;
|
||||
document.getElementById('geom-edit-toolbar').classList.remove('visible');
|
||||
}
|
||||
|
||||
async function saveEditGeom() {
|
||||
if (!editingFeature) return;
|
||||
const { type, id, vms } = editingFeature;
|
||||
const pts = vms.map(vm => vm.getLatLng());
|
||||
try {
|
||||
if (type === 'jalan') {
|
||||
const coords = pts.map(p => [p.lng, p.lat]);
|
||||
const panjang = turf.length(turf.lineString(coords), { units: 'meters' });
|
||||
const j = allJalan.find(x => x.id == id);
|
||||
const res = await fetch(API.jalan, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'update', id, nama: j.nama, status: j.status, panjang_meter: panjang, geom: { type: 'LineString', coordinates: coords } })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.status === 'success') { showToast('Geometri jalan diperbarui'); stopEditGeom(); loadAllData(); }
|
||||
else showToast(data.message || 'Gagal simpan', 'er');
|
||||
} else if (type === 'parsil') {
|
||||
const coords = pts.map(p => [p.lng, p.lat]);
|
||||
coords.push(coords[0]);
|
||||
const luas = turf.area(turf.polygon([coords]));
|
||||
const p = allParsil.find(x => x.id == id);
|
||||
const res = await fetch(API.parsil, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'update', id, nama: p.nama, status_kepemilikan: p.status_kepemilikan, luas_m2: luas, geom: { type: 'Polygon', coordinates: [coords] } })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.status === 'success') { showToast('Geometri parsil diperbarui'); stopEditGeom(); loadAllData(); }
|
||||
else showToast(data.message || 'Gagal simpan', 'er');
|
||||
}
|
||||
} catch (e) { showToast('Gagal: ' + e.message, 'er'); }
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// DRAWING
|
||||
// ===============================
|
||||
function onMapClick(e) {
|
||||
if (isDraggingMarker) return;
|
||||
if (!currentGeom) return;
|
||||
if (currentGeom === 'point') {
|
||||
document.getElementById('spbu-lat').value = e.latlng.lat;
|
||||
document.getElementById('spbu-lng').value = e.latlng.lng;
|
||||
openModal('m-add-spbu');
|
||||
return;
|
||||
}
|
||||
drawPoints.push({ lat: e.latlng.lat, lng: e.latlng.lng });
|
||||
undoStack.push({ lat: e.latlng.lat, lng: e.latlng.lng });
|
||||
redoStack = [];
|
||||
drawTemp();
|
||||
}
|
||||
|
||||
function drawTemp() {
|
||||
if (tempLayer) { map.removeLayer(tempLayer); tempLayer = null; }
|
||||
vertexMarkers.forEach(m => { try { map.removeLayer(m); } catch (e) {} });
|
||||
vertexMarkers = [];
|
||||
if (!drawPoints.length) { updateCounter(); return; }
|
||||
const latlngs = drawPoints.map(p => [p.lat, p.lng]);
|
||||
if (currentGeom === 'polyline' && drawPoints.length >= 2)
|
||||
tempLayer = L.polyline(latlngs, { color: COLORS.jalanProvinsi, weight: 3, dashArray: '7,5', opacity: .85 }).addTo(map);
|
||||
if (currentGeom === 'polygon' && drawPoints.length >= 3)
|
||||
tempLayer = L.polygon(latlngs, { color: COLORS.SHM.stroke, weight: 2.5, dashArray: '7,5', fillColor: COLORS.SHM.fill, fillOpacity: .25 }).addTo(map);
|
||||
drawPoints.forEach(p => {
|
||||
const vm = L.circleMarker([p.lat, p.lng], {
|
||||
radius: 6, color: '#fff', weight: 2.5,
|
||||
fillColor: currentGeom === 'polygon' ? COLORS.SHM.stroke : COLORS.jalanProvinsi, fillOpacity: 1
|
||||
}).addTo(map);
|
||||
vertexMarkers.push(vm);
|
||||
});
|
||||
updateCounter();
|
||||
}
|
||||
|
||||
function undoDraw() { if (!drawPoints.length) return; redoStack.push(drawPoints.pop()); drawTemp(); }
|
||||
function redoDraw() { if (!redoStack.length) return; drawPoints.push(redoStack.pop()); drawTemp(); }
|
||||
|
||||
function finishDrawing() {
|
||||
if (currentGeom === 'polyline') {
|
||||
if (drawPoints.length < 2) { showToast('Minimal 2 titik untuk membuat jalan!', 'er'); return; }
|
||||
finishJalanDrawing();
|
||||
} else if (currentGeom === 'polygon') {
|
||||
if (drawPoints.length < 3) { showToast('Minimal 3 titik untuk membuat parsil!', 'er'); return; }
|
||||
finishParsilDrawing();
|
||||
}
|
||||
}
|
||||
|
||||
function finishJalanDrawing() {
|
||||
savedDrawPoints = drawPoints.map(p => ({ lat: p.lat, lng: p.lng }));
|
||||
const coords = savedDrawPoints.map(p => [p.lng, p.lat]);
|
||||
const panjang = turf.length(turf.lineString(coords), { units: 'meters' });
|
||||
document.getElementById('j-nama').value = '';
|
||||
document.getElementById('j-status').value = 'kabupaten';
|
||||
document.getElementById('j-panjang').value = panjang.toFixed(2);
|
||||
openModal('m-add-jalan');
|
||||
}
|
||||
|
||||
function finishParsilDrawing() {
|
||||
savedDrawPoints = drawPoints.map(p => ({ lat: p.lat, lng: p.lng }));
|
||||
const coords = savedDrawPoints.map(p => [p.lng, p.lat]);
|
||||
coords.push(coords[0]);
|
||||
const luas = turf.area(turf.polygon([coords]));
|
||||
document.getElementById('p-nama').value = '';
|
||||
document.getElementById('p-status').value = 'SHM';
|
||||
document.getElementById('p-luas').value = luas.toFixed(2);
|
||||
openModal('m-add-parsil');
|
||||
}
|
||||
|
||||
async function saveSpbu() {
|
||||
const lat = parseFloat(document.getElementById('spbu-lat').value);
|
||||
const lng = parseFloat(document.getElementById('spbu-lng').value);
|
||||
const namaspbu = document.getElementById('spbu-nama').value.trim();
|
||||
if (!namaspbu) { showToast('Nama SPBU wajib diisi!', 'er'); return; }
|
||||
if (isNaN(lat) || isNaN(lng)) { showToast('Koordinat tidak valid!', 'er'); return; }
|
||||
try {
|
||||
const res = await fetch(API.spbu, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'create', namaspbu,
|
||||
nomorwa: document.getElementById('spbu-wa').value,
|
||||
statusbuka: document.getElementById('spbu-status').value,
|
||||
koordinat: { type: 'Point', coordinates: [lng, lat] }
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.status !== 'success') { showToast(data.message || 'Gagal simpan', 'er'); return; }
|
||||
closeModal('m-add-spbu'); deactivateGeom(); loadAllData(); showToast('SPBU berhasil ditambahkan');
|
||||
} catch (e) { showToast('Error: ' + e.message, 'er'); }
|
||||
}
|
||||
|
||||
async function saveJalan() {
|
||||
const nama = document.getElementById('j-nama').value.trim();
|
||||
if (!nama) { showToast('Nama jalan wajib diisi!', 'er'); return; }
|
||||
if (savedDrawPoints.length < 2) { showToast('Data koordinat hilang, silakan gambar ulang.', 'er'); return; }
|
||||
try {
|
||||
const res = await fetch(API.jalan, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'create', nama,
|
||||
status: document.getElementById('j-status').value,
|
||||
panjang_meter: parseFloat(document.getElementById('j-panjang').value),
|
||||
geom: { type: 'LineString', coordinates: savedDrawPoints.map(p => [p.lng, p.lat]) }
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.status !== 'success') { showToast(data.message || 'Gagal simpan', 'er'); return; }
|
||||
closeModal('m-add-jalan'); deactivateGeom(); cancelDraw(); loadAllData(); showToast('Jalan berhasil ditambahkan');
|
||||
} catch (e) { showToast('Error: ' + e.message, 'er'); }
|
||||
}
|
||||
|
||||
async function saveParsil() {
|
||||
const nama = document.getElementById('p-nama').value.trim();
|
||||
if (!nama) { showToast('Nama parsil wajib diisi!', 'er'); return; }
|
||||
if (savedDrawPoints.length < 3) { showToast('Data koordinat hilang, silakan gambar ulang.', 'er'); return; }
|
||||
const coords = savedDrawPoints.map(p => [p.lng, p.lat]);
|
||||
coords.push(coords[0]);
|
||||
try {
|
||||
const res = await fetch(API.parsil, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'create', nama,
|
||||
status_kepemilikan: document.getElementById('p-status').value,
|
||||
luas_m2: parseFloat(document.getElementById('p-luas').value),
|
||||
geom: { type: 'Polygon', coordinates: [coords] }
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.status !== 'success') { showToast(data.message || 'Gagal simpan', 'er'); return; }
|
||||
closeModal('m-add-parsil'); deactivateGeom(); cancelDraw(); loadAllData(); showToast('Parsil berhasil ditambahkan');
|
||||
} catch (e) { showToast('Error: ' + e.message, 'er'); }
|
||||
}
|
||||
|
||||
function cancelDraw() {
|
||||
drawPoints = []; undoStack = []; redoStack = []; savedDrawPoints = [];
|
||||
if (tempLayer) { try { map.removeLayer(tempLayer); } catch (e) {} tempLayer = null; }
|
||||
vertexMarkers.forEach(m => { try { map.removeLayer(m); } catch (e) {} });
|
||||
vertexMarkers = [];
|
||||
updateCounter();
|
||||
document.getElementById('draw-toolbar').classList.remove('visible');
|
||||
}
|
||||
|
||||
function deactivateGeom() {
|
||||
currentGeom = null;
|
||||
document.querySelectorAll('.geom-btn').forEach(b => b.classList.remove('active'));
|
||||
document.getElementById('geom-hint').classList.remove('on');
|
||||
document.getElementById('map').classList.remove('crosshair-cursor');
|
||||
document.getElementById('draw-toolbar').classList.remove('visible');
|
||||
}
|
||||
|
||||
function openModal(id) { document.getElementById(id).classList.add('open'); }
|
||||
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
|
||||
|
||||
function updateCounter() {
|
||||
const n = drawPoints.length;
|
||||
document.getElementById('dt-counter').innerText = n + ' titik';
|
||||
document.getElementById('btn-undo').disabled = n === 0;
|
||||
document.getElementById('btn-redo').disabled = redoStack.length === 0;
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// SEARCH
|
||||
// ===============================
|
||||
function setupSearch() {
|
||||
const input = document.getElementById('search-input');
|
||||
const results = document.getElementById('search-results');
|
||||
if (!input || !results) return;
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
const q = input.value.trim().toLowerCase();
|
||||
results.innerHTML = '';
|
||||
if (!q) { results.classList.remove('open'); return; }
|
||||
|
||||
const hits = [];
|
||||
allSpbu.forEach(s => {
|
||||
if ((s.namaspbu || '').toLowerCase().includes(q))
|
||||
hits.push({ label: s.namaspbu, sub: 'SPBU · Point', icon: '⛽',
|
||||
action: () => { flyTo(s.lat, s.lng); input.value = ''; results.classList.remove('open'); } });
|
||||
});
|
||||
allJalan.forEach(j => {
|
||||
if ((j.nama || '').toLowerCase().includes(q))
|
||||
hits.push({ label: j.nama, sub: `Jalan ${j.status || ''}`, icon: '🛣️',
|
||||
action: () => {
|
||||
try { const geom = JSON.parse(j.geom); const mid = geom.coordinates[Math.floor(geom.coordinates.length/2)]; map.setView([mid[1], mid[0]], 16); } catch (e) {}
|
||||
input.value = ''; results.classList.remove('open');
|
||||
} });
|
||||
});
|
||||
allParsil.forEach(p => {
|
||||
if ((p.nama || '').toLowerCase().includes(q))
|
||||
hits.push({ label: p.nama, sub: `Parsil · ${p.status_kepemilikan || 'SHM'}`, icon: '🗺️',
|
||||
action: () => {
|
||||
try { const geom = JSON.parse(p.geom); const coords = geom.coordinates[0]; const cx = coords.reduce((a,c)=>a+c[0],0)/coords.length; const cy = coords.reduce((a,c)=>a+c[1],0)/coords.length; map.setView([cy,cx],17); } catch (e) {}
|
||||
input.value = ''; results.classList.remove('open');
|
||||
} });
|
||||
});
|
||||
|
||||
if (!hits.length) {
|
||||
results.innerHTML = '<div class="sr-empty">Tidak ada hasil</div>';
|
||||
} else {
|
||||
results.innerHTML = hits.slice(0, 8).map((h, i) =>
|
||||
`<div class="sr-item" data-idx="${i}">
|
||||
<span class="sr-icon">${h.icon}</span>
|
||||
<div class="sr-text">
|
||||
<div class="sr-label">${h.label}</div>
|
||||
<div class="sr-sub">${h.sub}</div>
|
||||
</div>
|
||||
</div>`
|
||||
).join('');
|
||||
results.querySelectorAll('.sr-item').forEach((el, i) => {
|
||||
el.addEventListener('click', () => hits[i].action());
|
||||
});
|
||||
}
|
||||
results.classList.add('open');
|
||||
});
|
||||
document.addEventListener('click', e => {
|
||||
if (!e.target.closest('#search-wrap')) results.classList.remove('open');
|
||||
});
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// INIT
|
||||
// ===============================
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initMap();
|
||||
loadAllData();
|
||||
setupSearch();
|
||||
|
||||
document.getElementById('btn-finish').onclick = finishDrawing;
|
||||
document.getElementById('btn-undo').onclick = undoDraw;
|
||||
document.getElementById('btn-redo').onclick = redoDraw;
|
||||
document.getElementById('btn-cancel-draw').onclick = () => { cancelDraw(); deactivateGeom(); };
|
||||
document.getElementById('btn-geom-save').onclick = saveEditGeom;
|
||||
document.getElementById('btn-geom-cancel').onclick = stopEditGeom;
|
||||
|
||||
// Geom buttons
|
||||
document.querySelectorAll('.geom-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const geom = btn.dataset.geom;
|
||||
if (currentGeom === geom) { cancelDraw(); deactivateGeom(); return; }
|
||||
cancelDraw();
|
||||
currentGeom = geom;
|
||||
document.querySelectorAll('.geom-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
document.getElementById('map').classList.add('crosshair-cursor');
|
||||
document.getElementById('draw-toolbar').classList.toggle('visible', geom === 'polyline' || geom === 'polygon');
|
||||
const hint = document.getElementById('geom-hint');
|
||||
const hintText = document.getElementById('geom-hint-text');
|
||||
hint.classList.add('on');
|
||||
if (geom === 'point') hintText.innerText = 'Klik peta untuk tambah SPBU';
|
||||
if (geom === 'polyline') hintText.innerText = 'Klik peta untuk menambah titik jalan, klik Selesai jika sudah';
|
||||
if (geom === 'polygon') hintText.innerText = 'Klik peta untuk menambah titik parsil, klik Selesai jika sudah';
|
||||
});
|
||||
});
|
||||
|
||||
// Layer toggle custom
|
||||
const ldbtn = document.getElementById('ldbtn');
|
||||
const layerMenu = document.getElementById('layer-menu');
|
||||
if (ldbtn && layerMenu) {
|
||||
ldbtn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
layerMenu.classList.toggle('open');
|
||||
const chev = ldbtn.querySelector('.chev');
|
||||
if (chev) chev.textContent = layerMenu.classList.contains('open') ? '▲' : '▼';
|
||||
});
|
||||
document.addEventListener('click', () => {
|
||||
layerMenu.classList.remove('open');
|
||||
const chev = ldbtn.querySelector('.chev');
|
||||
if (chev) chev.textContent = '▼';
|
||||
});
|
||||
|
||||
const cbPoint = document.getElementById('lm-point');
|
||||
const cbSpbu24 = document.getElementById('lm-spbu24');
|
||||
const cbSpbuLtd = document.getElementById('lm-spbuLtd');
|
||||
const subSpbu = document.getElementById('lm-sub-spbu');
|
||||
|
||||
function syncSpbuLayers() {
|
||||
const spbuOn = cbPoint.checked;
|
||||
subSpbu.classList.toggle('hidden', !spbuOn);
|
||||
if (spbuOn && cbSpbu24.checked) spbu24Group.addTo(map); else map.removeLayer(spbu24Group);
|
||||
if (spbuOn && cbSpbuLtd.checked) spbuLtdGroup.addTo(map); else map.removeLayer(spbuLtdGroup);
|
||||
}
|
||||
cbPoint.addEventListener('change', syncSpbuLayers);
|
||||
cbSpbu24.addEventListener('change', syncSpbuLayers);
|
||||
cbSpbuLtd.addEventListener('change', syncSpbuLayers);
|
||||
|
||||
document.getElementById('lm-jalan').addEventListener('change', function () {
|
||||
if (this.checked) { if (layers.jalan) layers.jalan.addTo(map); }
|
||||
else { if (layers.jalan) map.removeLayer(layers.jalan); }
|
||||
});
|
||||
document.getElementById('lm-parsil').addEventListener('change', function () {
|
||||
if (this.checked) { if (layers.parsil) layers.parsil.addTo(map); }
|
||||
else { if (layers.parsil) map.removeLayer(layers.parsil); }
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
require_once 'config/db.php';
|
||||
try {
|
||||
$pdo->exec("ALTER TABLE laporan_warga ADD COLUMN dokumentasi TEXT NULL DEFAULT NULL AFTER catatan_admin");
|
||||
echo "Column dokumentasi added";
|
||||
} catch (Exception $e) {
|
||||
echo "Error: " . $e->getMessage();
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
// api/anggota_keluarga.php
|
||||
header('Content-Type: application/json');
|
||||
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') { http_response_code(204); exit; }
|
||||
|
||||
require_once '../config/db.php';
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
require_once 'middleware.php';
|
||||
$currentUser = requireAuth(['admin', 'petugas', 'koordinator']);
|
||||
if ($currentUser['role'] === 'koordinator') {
|
||||
echo json_encode(['success' => true, 'data' => [], 'total' => 0]);
|
||||
break;
|
||||
}
|
||||
|
||||
$wargaId = (int)($_GET['rumah_warga_id'] ?? 0);
|
||||
if (!$wargaId) { echo json_encode(['success' => false, 'message' => 'rumah_warga_id diperlukan']); break; }
|
||||
|
||||
$res = $conn->query("SELECT * FROM anggota_keluarga WHERE rumah_warga_id=$wargaId ORDER BY tanggal_lahir ASC");
|
||||
$data = [];
|
||||
$today = new DateTime();
|
||||
while ($row = $res->fetch_assoc()) {
|
||||
// Hitung usia
|
||||
if ($row['tanggal_lahir']) {
|
||||
$lahir = new DateTime($row['tanggal_lahir']);
|
||||
$row['usia'] = (int)$today->diff($lahir)->y;
|
||||
} else {
|
||||
$row['usia'] = null;
|
||||
}
|
||||
$data[] = $row;
|
||||
}
|
||||
echo json_encode(['success' => true, 'data' => $data, 'total' => count($data)]);
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$wargaId = (int)($input['rumah_warga_id'] ?? 0);
|
||||
$nama = $conn->real_escape_string(trim($input['nama'] ?? ''));
|
||||
$nik = $conn->real_escape_string($input['nik'] ?? '');
|
||||
$tl = !empty($input['tanggal_lahir']) ? "'" . $conn->real_escape_string($input['tanggal_lahir']) . "'" : 'NULL';
|
||||
$jk = $conn->real_escape_string($input['jenis_kelamin'] ?? 'laki_laki');
|
||||
$pend = $conn->real_escape_string($input['pendidikan'] ?? 'sd');
|
||||
$pek = $conn->real_escape_string($input['pekerjaan'] ?? '');
|
||||
$stsBk = $conn->real_escape_string($input['status_bekerja'] ?? 'tidak_bekerja');
|
||||
$kes = $conn->real_escape_string($input['kondisi_kesehatan'] ?? 'sehat');
|
||||
$bisa = (int)($input['dapat_bekerja'] ?? 1);
|
||||
|
||||
if (!$wargaId || !$nama) { echo json_encode(['success' => false, 'message' => 'ID warga dan nama wajib diisi']); break; }
|
||||
|
||||
$sql = "INSERT INTO anggota_keluarga (rumah_warga_id,nama,nik,tanggal_lahir,jenis_kelamin,pendidikan,pekerjaan,status_bekerja,kondisi_kesehatan,dapat_bekerja)
|
||||
VALUES ($wargaId,'$nama','$nik',$tl,'$jk','$pend','$pek','$stsBk','$kes',$bisa)";
|
||||
|
||||
if ($conn->query($sql)) {
|
||||
// Re-hitung rekomendasi induk
|
||||
rehitungRekomendasi($conn, $wargaId);
|
||||
echo json_encode(['success' => true, 'message' => 'Anggota berhasil ditambahkan', 'id' => $conn->insert_id]);
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => 'Gagal: ' . $conn->error]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$id = (int)($input['id'] ?? 0);
|
||||
$nama = $conn->real_escape_string(trim($input['nama'] ?? ''));
|
||||
$nik = $conn->real_escape_string($input['nik'] ?? '');
|
||||
$tl = !empty($input['tanggal_lahir']) ? "'" . $conn->real_escape_string($input['tanggal_lahir']) . "'" : 'NULL';
|
||||
$jk = $conn->real_escape_string($input['jenis_kelamin'] ?? 'laki_laki');
|
||||
$pend = $conn->real_escape_string($input['pendidikan'] ?? 'sd');
|
||||
$pek = $conn->real_escape_string($input['pekerjaan'] ?? '');
|
||||
$stsBk = $conn->real_escape_string($input['status_bekerja'] ?? 'tidak_bekerja');
|
||||
$kes = $conn->real_escape_string($input['kondisi_kesehatan'] ?? 'sehat');
|
||||
$bisa = (int)($input['dapat_bekerja'] ?? 1);
|
||||
|
||||
if (!$id) { echo json_encode(['success' => false, 'message' => 'ID tidak valid']); break; }
|
||||
|
||||
$sql = "UPDATE anggota_keluarga SET
|
||||
nama='$nama', nik='$nik', tanggal_lahir=$tl, jenis_kelamin='$jk',
|
||||
pendidikan='$pend', pekerjaan='$pek', status_bekerja='$stsBk',
|
||||
kondisi_kesehatan='$kes', dapat_bekerja=$bisa
|
||||
WHERE id=$id";
|
||||
|
||||
if ($conn->query($sql)) {
|
||||
// Ambil warga_id lalu re-hitung rekomendasi
|
||||
$r = $conn->query("SELECT rumah_warga_id FROM anggota_keluarga WHERE id=$id");
|
||||
if ($row = $r->fetch_assoc()) rehitungRekomendasi($conn, (int)$row['rumah_warga_id']);
|
||||
echo json_encode(['success' => true, 'message' => 'Anggota berhasil diperbarui']);
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => 'Gagal: ' . $conn->error]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
if (!$id) { $input = json_decode(file_get_contents('php://input'), true); $id = (int)($input['id'] ?? 0); }
|
||||
|
||||
// Ambil warga_id sebelum hapus
|
||||
$r = $conn->query("SELECT rumah_warga_id FROM anggota_keluarga WHERE id=$id");
|
||||
$wargaId = ($row = $r->fetch_assoc()) ? (int)$row['rumah_warga_id'] : 0;
|
||||
|
||||
if ($conn->query("DELETE FROM anggota_keluarga WHERE id=$id")) {
|
||||
if ($wargaId) rehitungRekomendasi($conn, $wargaId);
|
||||
echo json_encode(['success' => true, 'message' => 'Anggota berhasil dihapus']);
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => 'Gagal: ' . $conn->error]);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(405);
|
||||
echo json_encode(['error' => 'Method not allowed']);
|
||||
}
|
||||
$conn->close();
|
||||
|
||||
// ── Helper: re-hitung & simpan rekomendasi ────────────────────────────────
|
||||
function rehitungRekomendasi(mysqli $conn, int $wargaId): void
|
||||
{
|
||||
$res = $conn->query("SELECT penghasilan_bulanan, status_pekerjaan, is_rekomendasi_manual FROM rumah_warga WHERE id=$wargaId");
|
||||
if (!($row = $res->fetch_assoc())) return;
|
||||
if (isset($row['is_rekomendasi_manual']) && (int)$row['is_rekomendasi_manual'] === 1) return;
|
||||
|
||||
$penghasilan = (float)$row['penghasilan_bulanan'];
|
||||
$stsPekerjaan = $row['status_pekerjaan'];
|
||||
|
||||
$today = new DateTime();
|
||||
$resA = $conn->query("SELECT tanggal_lahir, status_bekerja, dapat_bekerja, kondisi_kesehatan FROM anggota_keluarga WHERE rumah_warga_id=$wargaId");
|
||||
$anggota = [];
|
||||
while ($a = $resA->fetch_assoc()) $anggota[] = $a;
|
||||
|
||||
$adaProduktif = false;
|
||||
$adaProduktifBisaKerja = false;
|
||||
$semuaProduktifTakBisa = true;
|
||||
|
||||
foreach ($anggota as $a) {
|
||||
$usia = null;
|
||||
if ($a['tanggal_lahir']) {
|
||||
$lahir = new DateTime($a['tanggal_lahir']);
|
||||
$usia = (int)$today->diff($lahir)->y;
|
||||
}
|
||||
if ($usia !== null && $usia >= 18 && $usia <= 55) {
|
||||
$adaProduktif = true;
|
||||
$bisaKerja = (int)$a['dapat_bekerja'] === 1 && in_array($a['kondisi_kesehatan'], ['sehat', 'sakit_ringan']);
|
||||
if ($bisaKerja) $semuaProduktifTakBisa = false;
|
||||
if ($bisaKerja && $a['status_bekerja'] !== 'bekerja') $adaProduktifBisaKerja = true;
|
||||
}
|
||||
}
|
||||
|
||||
$perluRutin = !$adaProduktif || ($adaProduktif && $semuaProduktifTakBisa) || $penghasilan < 500000 || $stsPekerjaan === 'tidak_bekerja';
|
||||
$perluPelatihan = $adaProduktifBisaKerja;
|
||||
|
||||
$rek = 'tidak_ada';
|
||||
if ($perluRutin && $perluPelatihan) $rek = 'keduanya';
|
||||
elseif ($perluRutin) $rek = 'bantuan_rutin';
|
||||
elseif ($perluPelatihan) $rek = 'bantuan_pelatihan';
|
||||
|
||||
$conn->query("UPDATE rumah_warga SET rekomendasi_bantuan='$rek' WHERE id=$wargaId");
|
||||
}
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
// api/auth.php
|
||||
header('Content-Type: application/json');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Authorization');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
|
||||
require_once '../config/db.php';
|
||||
require_once 'middleware.php';
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$action = $_GET['action'] ?? 'status';
|
||||
|
||||
try {
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
if ($action === 'status') {
|
||||
// Cek status login (Guest support)
|
||||
$user = optionalAuth();
|
||||
if ($user) {
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'logged_in' => true,
|
||||
'user' => [
|
||||
'id' => $user['id'],
|
||||
'username' => $user['username'],
|
||||
'role' => $user['role'],
|
||||
'rumah_ibadah_id' => $user['rumah_ibadah_id'],
|
||||
'no_telp' => $user['no_telp'] ?? ''
|
||||
]
|
||||
]);
|
||||
} else {
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'logged_in' => false,
|
||||
'user' => null,
|
||||
'message' => 'Guest Mode: Pengguna belum login.'
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => 'Action GET tidak dikenal.']);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
if ($action === 'login') {
|
||||
// Proses Login
|
||||
$body = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$username = trim($body['username'] ?? '');
|
||||
$password = trim($body['password'] ?? '');
|
||||
|
||||
if (!$username || !$password) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => 'Username dan Password wajib diisi.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Cari user berdasarkan username
|
||||
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = ?");
|
||||
$stmt->execute([$username]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
if ($user && password_verify($password, $user['password'])) {
|
||||
// Simpan data penting ke session
|
||||
$_SESSION['user'] = [
|
||||
'id' => (int)$user['id'],
|
||||
'username' => $user['username'],
|
||||
'role' => $user['role'],
|
||||
'rumah_ibadah_id' => $user['rumah_ibadah_id'] ? (int)$user['rumah_ibadah_id'] : null,
|
||||
'no_telp' => $user['no_telp'] ?? ''
|
||||
];
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => 'Login berhasil! Selamat datang, ' . $user['username'],
|
||||
'user' => $_SESSION['user']
|
||||
]);
|
||||
} else {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'message' => 'Username atau Password salah.']);
|
||||
}
|
||||
} elseif ($action === 'register') {
|
||||
// Proses Registrasi Mandiri (hanya untuk kontributor & koordinator)
|
||||
$body = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$username = trim($body['username'] ?? '');
|
||||
$email = trim($body['email'] ?? '');
|
||||
$password = trim($body['password'] ?? '');
|
||||
$noTelp = trim($body['no_telp'] ?? '');
|
||||
$role = trim($body['role'] ?? '');
|
||||
$rumahIbadahId = isset($body['rumah_ibadah_id']) && $body['rumah_ibadah_id'] !== '' ? (int)$body['rumah_ibadah_id'] : null;
|
||||
|
||||
// Validasi field wajib
|
||||
if (!$username || !$email || !$password || !$noTelp || !$role) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => 'Semua field wajib diisi (username, email, password, no_telp, role).']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Validasi panjang & kekuatan password
|
||||
if (strlen($password) < 6) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => 'Password minimal 6 karakter.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Validasi format email
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => 'Format email tidak valid.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Whitelist role — HANYA kontributor dan koordinator yang boleh mendaftar sendiri
|
||||
$allowedRoles = ['kontributor', 'koordinator'];
|
||||
if (!in_array($role, $allowedRoles)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'message' => 'Peran "' . $role . '" tidak diizinkan untuk registrasi mandiri.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Koordinator wajib memilih rumah ibadah
|
||||
if ($role === 'koordinator' && !$rumahIbadahId) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => 'Koordinator Rumah Ibadah wajib memilih rumah ibadah yang dikelola.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Kontributor tidak boleh terikat ke rumah ibadah
|
||||
if ($role === 'kontributor') {
|
||||
$rumahIbadahId = null;
|
||||
}
|
||||
|
||||
// Cek duplikasi username
|
||||
$stmtCheck = $pdo->prepare("SELECT id FROM users WHERE username = ? LIMIT 1");
|
||||
$stmtCheck->execute([$username]);
|
||||
if ($stmtCheck->fetch()) {
|
||||
http_response_code(409);
|
||||
echo json_encode(['success' => false, 'message' => 'Username "' . $username . '" sudah terdaftar. Gunakan username lain.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Cek duplikasi email
|
||||
$stmtCheck2 = $pdo->prepare("SELECT id FROM users WHERE email = ? LIMIT 1");
|
||||
$stmtCheck2->execute([$email]);
|
||||
if ($stmtCheck2->fetch()) {
|
||||
http_response_code(409);
|
||||
echo json_encode(['success' => false, 'message' => 'Email "' . $email . '" sudah terdaftar. Gunakan email lain.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Validasi rumah ibadah ada di database (untuk koordinator)
|
||||
if ($role === 'koordinator' && $rumahIbadahId) {
|
||||
$stmtIbadah = $pdo->prepare("SELECT id FROM rumah_ibadah WHERE id = ? LIMIT 1");
|
||||
$stmtIbadah->execute([$rumahIbadahId]);
|
||||
if (!$stmtIbadah->fetch()) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => 'Rumah ibadah yang dipilih tidak valid atau tidak ditemukan.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Hash password dengan bcrypt
|
||||
$hashedPassword = password_hash($password, PASSWORD_BCRYPT);
|
||||
|
||||
// Simpan user baru ke database
|
||||
$stmtInsert = $pdo->prepare("INSERT INTO users (username, email, password, no_telp, role, rumah_ibadah_id) VALUES (?, ?, ?, ?, ?, ?)");
|
||||
$stmtInsert->execute([$username, $email, $hashedPassword, $noTelp, $role, $rumahIbadahId]);
|
||||
|
||||
$roleLabel = $role === 'koordinator' ? 'Koordinator Rumah Ibadah' : 'Kontributor';
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => 'Registrasi berhasil! Selamat bergabung sebagai ' . $roleLabel . '. Silakan login dengan akun Anda.',
|
||||
'username' => $username
|
||||
]);
|
||||
|
||||
} elseif ($action === 'logout') {
|
||||
// Proses Logout
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
// Hapus semua data session dan hancurkan
|
||||
$_SESSION = [];
|
||||
if (ini_get("session.use_cookies")) {
|
||||
$params = session_get_cookie_params();
|
||||
setcookie(session_name(), '', time() - 42000,
|
||||
$params["path"], $params["domain"],
|
||||
$params["secure"], $params["httponly"]
|
||||
);
|
||||
}
|
||||
session_destroy();
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => 'Logout berhasil. Anda sekarang masuk sebagai Guest.'
|
||||
]);
|
||||
} else {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => 'Action POST tidak dikenal.']);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(405);
|
||||
echo json_encode(['error' => 'Method not allowed']);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'message' => 'Terjadi kesalahan sistem: ' . $e->getMessage()]);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
// api/kebutuhan.php
|
||||
header('Content-Type: application/json');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Authorization');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
|
||||
require_once '../config/db.php';
|
||||
require_once 'middleware.php';
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
try {
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
$currentUser = optionalAuth();
|
||||
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
|
||||
|
||||
if ($id) {
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT k.*, ri.nama AS nama_rumah_ibadah, ri.alamat, ri.latitude, ri.longitude,
|
||||
COALESCE(SUM(CASE WHEN ko.status = 'confirmed' THEN ko.jumlah ELSE 0 END), 0) AS jumlah_terkumpul
|
||||
FROM kebutuhan k
|
||||
JOIN rumah_ibadah ri ON k.rumah_ibadah_id = ri.id
|
||||
LEFT JOIN kontribusi ko ON k.id = ko.kebutuhan_id
|
||||
WHERE k.id = ?
|
||||
GROUP BY k.id"
|
||||
);
|
||||
$stmt->execute([$id]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
if ($row) {
|
||||
$row['sisa_kebutuhan'] = max(0, (float)$row['jumlah_dibutuhkan'] - (float)$row['jumlah_terkumpul']);
|
||||
echo json_encode(['success' => true, 'data' => $row, 'viewer_role' => $currentUser ? $currentUser['role'] : 'guest']);
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => 'Kebutuhan tidak ditemukan']);
|
||||
}
|
||||
} else {
|
||||
$sql = "SELECT k.*, ri.nama AS nama_rumah_ibadah, ri.alamat, ri.latitude, ri.longitude,
|
||||
COALESCE(SUM(CASE WHEN ko.status = 'confirmed' THEN ko.jumlah ELSE 0 END), 0) AS jumlah_terkumpul
|
||||
FROM kebutuhan k
|
||||
JOIN rumah_ibadah ri ON k.rumah_ibadah_id = ri.id
|
||||
LEFT JOIN kontribusi ko ON k.id = ko.kebutuhan_id
|
||||
GROUP BY k.id
|
||||
ORDER BY k.created_at DESC";
|
||||
|
||||
$rows = $pdo->query($sql)->fetchAll();
|
||||
foreach ($rows as &$r) {
|
||||
$r['sisa_kebutuhan'] = max(0, (float)$r['jumlah_dibutuhkan'] - (float)$r['jumlah_terkumpul']);
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'data' => $rows, 'viewer_role' => $currentUser ? $currentUser['role'] : 'guest']);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
$currentUser = requireAuth(['admin', 'koordinator']);
|
||||
$body = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
|
||||
$rumahIbadahId = (int)($body['rumah_ibadah_id'] ?? 0);
|
||||
$jenisBantuan = trim($body['jenis_bantuan'] ?? '');
|
||||
$kategori = trim($body['kategori'] ?? 'barang');
|
||||
$satuan = trim($body['satuan'] ?? 'unit');
|
||||
$jumlahReq = (float)($body['jumlah_dibutuhkan'] ?? 0);
|
||||
|
||||
if ($currentUser['role'] === 'koordinator' && $currentUser['rumah_ibadah_id'] != $rumahIbadahId) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'message' => 'Koordinator hanya dapat mengelola kebutuhan rumah ibadah mereka sendiri.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$allowedSatuan = ['unit','orang','jam','shift','nominal'];
|
||||
if (!in_array($satuan, $allowedSatuan)) $satuan = 'unit';
|
||||
|
||||
if (!$rumahIbadahId || !$jenisBantuan || $jumlahReq <= 0) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => 'Parameter input tidak valid atau jumlah harus lebih besar dari 0']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare("INSERT INTO kebutuhan (rumah_ibadah_id, jenis_bantuan, kategori, jumlah_dibutuhkan, satuan) VALUES (?, ?, ?, ?, ?)");
|
||||
$stmt->execute([$rumahIbadahId, $jenisBantuan, $kategori, $jumlahReq, $satuan]);
|
||||
} catch (PDOException $e) {
|
||||
$stmt = $pdo->prepare("INSERT INTO kebutuhan (rumah_ibadah_id, jenis_bantuan, kategori, jumlah_dibutuhkan) VALUES (?, ?, ?, ?)");
|
||||
$stmt->execute([$rumahIbadahId, $jenisBantuan, $kategori, $jumlahReq]);
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'message' => 'Kebutuhan bantuan berhasil ditambahkan', 'id' => $pdo->lastInsertId()]);
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
$currentUser = requireAuth(['admin', 'koordinator']);
|
||||
$body = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$id = (int)($body['id'] ?? 0);
|
||||
|
||||
$stmt = $pdo->prepare("SELECT rumah_ibadah_id FROM kebutuhan WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
$kebutuhan = $stmt->fetch();
|
||||
|
||||
if (!$kebutuhan) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['success' => false, 'message' => 'Kebutuhan tidak ditemukan']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($currentUser['role'] === 'koordinator' && $currentUser['rumah_ibadah_id'] != $kebutuhan['rumah_ibadah_id']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'message' => 'Koordinator tidak berhak merubah kebutuhan rumah ibadah lain.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$satuanIn = trim($body['satuan'] ?? 'unit');
|
||||
$allowedSatuan = ['unit','orang','jam','shift','nominal'];
|
||||
if (!in_array($satuanIn, $allowedSatuan)) $satuanIn = 'unit';
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare("UPDATE kebutuhan SET jenis_bantuan = ?, kategori = ?, jumlah_dibutuhkan = ?, satuan = ? WHERE id = ?");
|
||||
$stmt->execute([
|
||||
trim($body['jenis_bantuan']),
|
||||
trim($body['kategori']),
|
||||
(float)$body['jumlah_dibutuhkan'],
|
||||
$satuanIn,
|
||||
$id
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
$stmt = $pdo->prepare("UPDATE kebutuhan SET jenis_bantuan = ?, kategori = ?, jumlah_dibutuhkan = ? WHERE id = ?");
|
||||
$stmt->execute([
|
||||
trim($body['jenis_bantuan']),
|
||||
trim($body['kategori']),
|
||||
(float)$body['jumlah_dibutuhkan'],
|
||||
$id
|
||||
]);
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'message' => 'Kebutuhan berhasil diperbarui']);
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
$currentUser = requireAuth(['admin', 'koordinator']);
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
|
||||
$stmt = $pdo->prepare("SELECT rumah_ibadah_id FROM kebutuhan WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
$kebutuhan = $stmt->fetch();
|
||||
|
||||
if (!$kebutuhan) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['success' => false, 'message' => 'Kebutuhan tidak ditemukan']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($currentUser['role'] === 'koordinator' && $currentUser['rumah_ibadah_id'] != $kebutuhan['rumah_ibadah_id']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'message' => 'Koordinator tidak berhak menghapus kebutuhan rumah ibadah lain.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("DELETE FROM kebutuhan WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
|
||||
echo json_encode(['success' => true, 'message' => 'Kebutuhan berhasil dihapus']);
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(405);
|
||||
echo json_encode(['error' => 'Method not allowed']);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
<?php
|
||||
// api/kemandirian.php
|
||||
// Fitur 3: Potensi Kemandirian Warga
|
||||
// Hitung skor kemandirian berdasarkan: penghasilan, status kerja anggota, jumlah tanggungan
|
||||
// Method: GET (list/detail/statistik), POST (catat perkembangan manual), PUT (set exit plan)
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
|
||||
|
||||
require_once '../config/db.php';
|
||||
require_once 'middleware.php';
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
HELPER
|
||||
───────────────────────────────────────────── */
|
||||
function getKonfig(PDO $pdo, string $kunci, $default = null) {
|
||||
$stmt = $pdo->prepare("SELECT nilai, tipe FROM konfigurasi_sistem WHERE kunci = ?");
|
||||
$stmt->execute([$kunci]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) return $default;
|
||||
return match($row['tipe']) {
|
||||
'integer' => (int)$row['nilai'],
|
||||
'decimal' => (float)$row['nilai'],
|
||||
'boolean' => filter_var($row['nilai'], FILTER_VALIDATE_BOOLEAN),
|
||||
default => $row['nilai'],
|
||||
};
|
||||
}
|
||||
|
||||
function hitungUsia(?string $tanggalLahir): ?int {
|
||||
if (!$tanggalLahir) return null;
|
||||
$lahir = new DateTime($tanggalLahir);
|
||||
$today = new DateTime();
|
||||
return (int)$today->diff($lahir)->y;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
CORE: Hitung Skor Kemandirian
|
||||
|
||||
Komponen:
|
||||
A) Skor Penghasilan (bobot configurable, default 40%)
|
||||
- 0% : penghasilan = 0
|
||||
- 50% : penghasilan = 50% threshold
|
||||
- 100%: penghasilan >= threshold UMR
|
||||
|
||||
B) Skor Status Kerja (default 35%)
|
||||
- Dihitung dari anggota usia produktif (18-55)
|
||||
- Poin per anggota: bekerja_tetap=100, tidak_tetap=60, tidak_bekerja=0, pelajar=30
|
||||
- Bonus: sudah bekerja tetap setelah ikut pelatihan
|
||||
|
||||
C) Skor Tanggungan (default 25%)
|
||||
- Tanggungan sedikit (1-2) = skor tinggi
|
||||
- Tanggungan banyak (> 6) = skor rendah
|
||||
|
||||
Status kemandirian:
|
||||
- 0-20 : sangat_bergantung
|
||||
- 21-40 : bergantung
|
||||
- 41-60 : berkembang
|
||||
- 61-79 : berpotensi_mandiri
|
||||
- 80+ : mandiri (siap exit)
|
||||
───────────────────────────────────────────── */
|
||||
function hitungSkorKemandirian(
|
||||
PDO $pdo,
|
||||
array $warga,
|
||||
array $anggotaList,
|
||||
float $thresholdPenghasilan,
|
||||
int $bobotPenghasilan,
|
||||
int $bobotStatusKerja,
|
||||
int $bobotTanggungan
|
||||
): array {
|
||||
$penghasilan = (float)$warga['penghasilan_bulanan'];
|
||||
$tanggungan = (int)$warga['jumlah_tanggungan'];
|
||||
$statusKerja = $warga['status_pekerjaan'];
|
||||
|
||||
/* ── A: Skor Penghasilan ── */
|
||||
$skorPenghasilan = 0;
|
||||
if ($thresholdPenghasilan > 0) {
|
||||
$skorPenghasilan = (int)min(100, round($penghasilan / $thresholdPenghasilan * 100));
|
||||
}
|
||||
|
||||
/* ── B: Skor Status Kerja ── */
|
||||
$bobotStatusKerjaMap = [
|
||||
'tetap' => 100,
|
||||
'tidak_tetap' => 60,
|
||||
'pensiun' => 50,
|
||||
'tidak_bekerja'=> 0,
|
||||
];
|
||||
|
||||
// Skor kepala keluarga
|
||||
$poinKK = $bobotStatusKerjaMap[$statusKerja] ?? 0;
|
||||
$anggotaProd = array_filter($anggotaList, function($a) {
|
||||
$usia = hitungUsia($a['tanggal_lahir']);
|
||||
return $usia !== null && $usia >= 18 && $usia <= 55;
|
||||
});
|
||||
|
||||
if (count($anggotaProd) > 0) {
|
||||
$totalPoin = $poinKK;
|
||||
foreach ($anggotaProd as $a) {
|
||||
$stsBekMap = [
|
||||
'bekerja' => 100,
|
||||
'pensiunan' => 50,
|
||||
'pelajar' => 30,
|
||||
'tidak_bekerja' => 0,
|
||||
];
|
||||
$poin = $stsBekMap[$a['status_bekerja']] ?? 0;
|
||||
// Diskon: tidak bisa bekerja / disabilitas
|
||||
if (!$a['dapat_bekerja'] || in_array($a['kondisi_kesehatan'], ['sakit_berat','disabilitas'])) {
|
||||
$poin = (int)($poin * 0.3);
|
||||
}
|
||||
$totalPoin += $poin;
|
||||
}
|
||||
$jumlahDitimbang = count($anggotaProd) + 1; // +1 untuk KK
|
||||
$skorStatusKerja = (int)round($totalPoin / ($jumlahDitimbang * 100) * 100);
|
||||
} else {
|
||||
// Tidak ada anggota produktif, skor bergantung murni dari KK
|
||||
$skorStatusKerja = $poinKK;
|
||||
}
|
||||
|
||||
/* ── C: Skor Tanggungan ── */
|
||||
// Makin sedikit tanggungan relatif terhadap yang bekerja = makin mandiri
|
||||
$anggotaBekerja = count(array_filter($anggotaList, fn($a) => $a['status_bekerja'] === 'bekerja'));
|
||||
$pencariNafkah = $anggotaBekerja + ($statusKerja !== 'tidak_bekerja' ? 1 : 0);
|
||||
|
||||
if ($tanggungan <= 0) {
|
||||
$skorTanggungan = 100;
|
||||
} elseif ($pencariNafkah >= $tanggungan) {
|
||||
$skorTanggungan = 100; // semua tanggungan punya pemasukan
|
||||
} else {
|
||||
$rasio = $pencariNafkah / $tanggungan;
|
||||
$skorTanggungan = (int)min(100, round($rasio * 100));
|
||||
}
|
||||
|
||||
/* ── Skor Total (weighted) ── */
|
||||
$totalBobot = $bobotPenghasilan + $bobotStatusKerja + $bobotTanggungan;
|
||||
if ($totalBobot <= 0) $totalBobot = 100;
|
||||
|
||||
$skorTotal = (int)round(
|
||||
($skorPenghasilan * $bobotPenghasilan
|
||||
+ $skorStatusKerja * $bobotStatusKerja
|
||||
+ $skorTanggungan * $bobotTanggungan) / $totalBobot
|
||||
);
|
||||
|
||||
/* ── Status ── */
|
||||
$status = match(true) {
|
||||
$skorTotal >= 80 => 'mandiri',
|
||||
$skorTotal >= 61 => 'berpotensi_mandiri',
|
||||
$skorTotal >= 41 => 'berkembang',
|
||||
$skorTotal >= 21 => 'bergantung',
|
||||
default => 'sangat_bergantung',
|
||||
};
|
||||
|
||||
/* ── Estimasi waktu menuju mandiri ── */
|
||||
$estimasiKenaikan = 5; // asumsi naik 5 poin/bulan dengan pelatihan
|
||||
$bulanMenujuMandiri = $skorTotal >= 80 ? 0 : (int)ceil((80 - $skorTotal) / $estimasiKenaikan);
|
||||
|
||||
/* ── Rekomendasi aksi ── */
|
||||
$rekomendasi = [];
|
||||
if ($skorPenghasilan < 50) $rekomendasi[] = 'Tingkatkan penghasilan melalui pelatihan vokasional';
|
||||
if ($skorStatusKerja < 50) $rekomendasi[] = 'Dukung anggota produktif untuk mendapat pekerjaan tetap';
|
||||
if ($skorTanggungan < 50) $rekomendasi[] = 'Pertimbangkan program KB/pemberdayaan tanggungan';
|
||||
if (count($rekomendasi) === 0) $rekomendasi[] = 'Pertahankan perkembangan yang ada, pantau secara berkala';
|
||||
|
||||
return [
|
||||
'skor_total' => $skorTotal,
|
||||
'skor_penghasilan' => $skorPenghasilan,
|
||||
'skor_status_kerja' => $skorStatusKerja,
|
||||
'skor_tanggungan' => $skorTanggungan,
|
||||
'status' => $status,
|
||||
'estimasi_bulan_mandiri' => $bulanMenujuMandiri,
|
||||
'rekomendasi' => $rekomendasi,
|
||||
'detail' => [
|
||||
'penghasilan' => $penghasilan,
|
||||
'threshold' => $thresholdPenghasilan,
|
||||
'jumlah_tanggungan' => $tanggungan,
|
||||
'pencari_nafkah' => $pencariNafkah,
|
||||
'anggota_produktif' => count($anggotaProd),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
UPDATE skor kemandirian batch + simpan riwayat
|
||||
───────────────────────────────────────────── */
|
||||
function updateKemandirianBatch(PDO $pdo, bool $simpanRiwayat = false): int {
|
||||
$thres = getKonfig($pdo, 'threshold_penghasilan_mandiri', 3500000);
|
||||
$bPeng = getKonfig($pdo, 'bobot_penghasilan', 40);
|
||||
$bKerja = getKonfig($pdo, 'bobot_status_kerja', 35);
|
||||
$bTangg = getKonfig($pdo, 'bobot_tanggungan', 25);
|
||||
|
||||
$wargaList = $pdo->query("
|
||||
SELECT id, penghasilan_bulanan, jumlah_tanggungan, status_pekerjaan,
|
||||
skor_kemandirian as skor_lama
|
||||
FROM rumah_warga
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$stmtAnggota = $pdo->prepare("
|
||||
SELECT nama, tanggal_lahir, status_bekerja, kondisi_kesehatan, dapat_bekerja
|
||||
FROM anggota_keluarga WHERE rumah_warga_id = ?
|
||||
");
|
||||
$stmtUpdate = $pdo->prepare("
|
||||
UPDATE rumah_warga SET skor_kemandirian = ?, status_kemandirian = ? WHERE id = ?
|
||||
");
|
||||
$stmtRiwayat = $pdo->prepare("
|
||||
INSERT INTO riwayat_kemandirian
|
||||
(rumah_warga_id, tanggal, skor, status, penghasilan_saat_ini)
|
||||
VALUES (?, CURDATE(), ?, ?, ?)
|
||||
");
|
||||
|
||||
$updated = 0;
|
||||
foreach ($wargaList as $w) {
|
||||
$stmtAnggota->execute([(int)$w['id']]);
|
||||
$anggota = $stmtAnggota->fetchAll(PDO::FETCH_ASSOC);
|
||||
$calc = hitungSkorKemandirian($pdo, $w, $anggota, $thres, $bPeng, $bKerja, $bTangg);
|
||||
|
||||
$stmtUpdate->execute([$calc['skor_total'], $calc['status'], (int)$w['id']]);
|
||||
|
||||
if ($simpanRiwayat && $calc['skor_total'] !== (int)$w['skor_lama']) {
|
||||
$stmtRiwayat->execute([
|
||||
(int)$w['id'],
|
||||
$calc['skor_total'],
|
||||
$calc['status'],
|
||||
(float)$w['penghasilan_bulanan'],
|
||||
]);
|
||||
}
|
||||
$updated++;
|
||||
}
|
||||
return $updated;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
ROUTING
|
||||
───────────────────────────────────────────── */
|
||||
try {
|
||||
// Hanya Admin dan Petugas Lapangan yang memiliki akses ke modul kemandirian warga
|
||||
requireAuth(['admin', 'petugas']);
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$action = $_GET['action'] ?? 'list';
|
||||
|
||||
if ($method === 'GET') {
|
||||
|
||||
if ($action === 'list') {
|
||||
// Recalculate dulu
|
||||
updateKemandirianBatch($pdo, true);
|
||||
|
||||
$statusFilter = $_GET['status'] ?? null;
|
||||
$minSkor = (int)($_GET['min_skor'] ?? 0);
|
||||
$limit = (int)($_GET['limit'] ?? 50);
|
||||
$thresholdSk = getKonfig($pdo, 'threshold_skor_mandiri', 70);
|
||||
|
||||
$where = [];
|
||||
if ($statusFilter) $where[] = "rw.status_kemandirian = " . $pdo->quote($statusFilter);
|
||||
if ($minSkor > 0) $where[] = "rw.skor_kemandirian >= $minSkor";
|
||||
$whereStr = $where ? "WHERE " . implode(" AND ", $where) : "";
|
||||
|
||||
$rows = $pdo->query("
|
||||
SELECT rw.id, rw.nama_kepala_keluarga, rw.no_kk,
|
||||
rw.penghasilan_bulanan, rw.jumlah_tanggungan,
|
||||
rw.status_pekerjaan, rw.kategori_kemiskinan,
|
||||
rw.skor_kemandirian, rw.status_kemandirian,
|
||||
rw.catatan_kemandirian, rw.tanggal_exit_plan,
|
||||
rw.rekomendasi_bantuan, rw.status_bantuan,
|
||||
rw.kelurahan, rw.kecamatan,
|
||||
rw.latitude, rw.longitude
|
||||
FROM rumah_warga rw
|
||||
$whereStr
|
||||
ORDER BY rw.skor_kemandirian DESC
|
||||
LIMIT $limit
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Tambah detail skor per row
|
||||
$thres = getKonfig($pdo, 'threshold_penghasilan_mandiri', 3500000);
|
||||
$bPeng = getKonfig($pdo, 'bobot_penghasilan', 40);
|
||||
$bKerja = getKonfig($pdo, 'bobot_status_kerja', 35);
|
||||
$bTangg = getKonfig($pdo, 'bobot_tanggungan', 25);
|
||||
$stmtA = $pdo->prepare("SELECT * FROM anggota_keluarga WHERE rumah_warga_id = ?");
|
||||
|
||||
foreach ($rows as &$r) {
|
||||
$stmtA->execute([(int)$r['id']]);
|
||||
$anggota = $stmtA->fetchAll(PDO::FETCH_ASSOC);
|
||||
$r['detail_skor'] = hitungSkorKemandirian($pdo, $r, $anggota, $thres, $bPeng, $bKerja, $bTangg);
|
||||
}
|
||||
|
||||
$summary = $pdo->query("
|
||||
SELECT status_kemandirian, COUNT(*) as jumlah, ROUND(AVG(skor_kemandirian)) as avg_skor
|
||||
FROM rumah_warga GROUP BY status_kemandirian
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$siapExit = $pdo->query("
|
||||
SELECT COUNT(*) FROM rumah_warga
|
||||
WHERE skor_kemandirian >= $thresholdSk
|
||||
AND status_bantuan != 'sudah_terima'
|
||||
")->fetchColumn();
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'data' => $rows,
|
||||
'summary' => $summary,
|
||||
'siap_exit' => (int)$siapExit,
|
||||
]);
|
||||
|
||||
} elseif ($action === 'detail') {
|
||||
$wargaId = (int)($_GET['id'] ?? 0);
|
||||
|
||||
$warga = $pdo->prepare("SELECT * FROM rumah_warga WHERE id = ?");
|
||||
$warga->execute([$wargaId]);
|
||||
$w = $warga->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$w) { echo json_encode(['success' => false, 'message' => 'Warga tidak ditemukan']); exit; }
|
||||
|
||||
$stmtA = $pdo->prepare("SELECT * FROM anggota_keluarga WHERE rumah_warga_id = ?");
|
||||
$stmtA->execute([$wargaId]);
|
||||
$anggota = $stmtA->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$thres = getKonfig($pdo, 'threshold_penghasilan_mandiri', 3500000);
|
||||
$bPeng = getKonfig($pdo, 'bobot_penghasilan', 40);
|
||||
$bKerja = getKonfig($pdo, 'bobot_status_kerja', 35);
|
||||
$bTangg = getKonfig($pdo, 'bobot_tanggungan', 25);
|
||||
|
||||
$skor = hitungSkorKemandirian($pdo, $w, $anggota, $thres, $bPeng, $bKerja, $bTangg);
|
||||
|
||||
// Riwayat
|
||||
$riwayat = $pdo->prepare("
|
||||
SELECT * FROM riwayat_kemandirian WHERE rumah_warga_id = ?
|
||||
ORDER BY tanggal DESC LIMIT 12
|
||||
");
|
||||
$riwayat->execute([$wargaId]);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'data' => $w,
|
||||
'skor' => $skor,
|
||||
'anggota' => $anggota,
|
||||
'riwayat' => $riwayat->fetchAll(PDO::FETCH_ASSOC),
|
||||
]);
|
||||
|
||||
} elseif ($action === 'statistik') {
|
||||
updateKemandirianBatch($pdo, false);
|
||||
$thresholdSk = getKonfig($pdo, 'threshold_skor_mandiri', 70);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'distribusi_status' => $pdo->query("
|
||||
SELECT status_kemandirian as status, COUNT(*) as jumlah,
|
||||
ROUND(AVG(skor_kemandirian)) as avg_skor,
|
||||
ROUND(AVG(penghasilan_bulanan)) as avg_penghasilan
|
||||
FROM rumah_warga GROUP BY status_kemandirian ORDER BY avg_skor DESC
|
||||
")->fetchAll(PDO::FETCH_ASSOC),
|
||||
'siap_exit_plan' => (int)$pdo->query("
|
||||
SELECT COUNT(*) FROM rumah_warga WHERE skor_kemandirian >= $thresholdSk
|
||||
")->fetchColumn(),
|
||||
'sudah_punya_exit_plan' => (int)$pdo->query("
|
||||
SELECT COUNT(*) FROM rumah_warga WHERE tanggal_exit_plan IS NOT NULL
|
||||
")->fetchColumn(),
|
||||
'avg_skor_global' => (float)$pdo->query("
|
||||
SELECT ROUND(AVG(skor_kemandirian),1) FROM rumah_warga
|
||||
")->fetchColumn(),
|
||||
'trend_bulan_ini' => $pdo->query("
|
||||
SELECT DATE(tanggal) as tgl, ROUND(AVG(skor)) as avg_skor, COUNT(*) as jumlah
|
||||
FROM riwayat_kemandirian
|
||||
WHERE tanggal >= DATE_SUB(NOW(), INTERVAL 30 DAY)
|
||||
GROUP BY DATE(tanggal) ORDER BY tgl
|
||||
")->fetchAll(PDO::FETCH_ASSOC),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
} elseif ($method === 'POST') {
|
||||
// Catat perkembangan/catatan manual dari petugas
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
$wargaId = (int)($body['warga_id'] ?? 0);
|
||||
|
||||
if (!$wargaId) { echo json_encode(['success' => false, 'message' => 'warga_id wajib']); exit; }
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE rumah_warga
|
||||
SET catatan_kemandirian = ?, tanggal_exit_plan = ?, updated_at = NOW()
|
||||
WHERE id = ?
|
||||
");
|
||||
$stmt->execute([
|
||||
$body['catatan'] ?? null,
|
||||
$body['tanggal_exit_plan'] ?? null,
|
||||
$wargaId,
|
||||
]);
|
||||
|
||||
// Hitung ulang dan simpan ke riwayat
|
||||
$warga = $pdo->prepare("SELECT * FROM rumah_warga WHERE id = ?");
|
||||
$warga->execute([$wargaId]);
|
||||
$w = $warga->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
$stmtA = $pdo->prepare("SELECT * FROM anggota_keluarga WHERE rumah_warga_id = ?");
|
||||
$stmtA->execute([$wargaId]);
|
||||
$anggota = $stmtA->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$thres = getKonfig($pdo, 'threshold_penghasilan_mandiri', 3500000);
|
||||
$bPeng = getKonfig($pdo, 'bobot_penghasilan', 40);
|
||||
$bKerja = getKonfig($pdo, 'bobot_status_kerja', 35);
|
||||
$bTangg = getKonfig($pdo, 'bobot_tanggungan', 25);
|
||||
$calc = hitungSkorKemandirian($pdo, $w, $anggota, $thres, $bPeng, $bKerja, $bTangg);
|
||||
|
||||
$pdo->prepare("
|
||||
INSERT INTO riwayat_kemandirian
|
||||
(rumah_warga_id, tanggal, skor, status, penghasilan_saat_ini, catatan)
|
||||
VALUES (?, CURDATE(), ?, ?, ?, ?)
|
||||
")->execute([
|
||||
$wargaId, $calc['skor_total'], $calc['status'],
|
||||
(float)$w['penghasilan_bulanan'], $body['catatan'] ?? null
|
||||
]);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => 'Catatan kemandirian berhasil disimpan',
|
||||
'skor' => $calc,
|
||||
]);
|
||||
|
||||
} elseif ($method === 'PUT') {
|
||||
// Update status kemandirian manual oleh admin (override sistem)
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
$wargaId = (int)($body['warga_id'] ?? 0);
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE rumah_warga
|
||||
SET status_kemandirian = ?,
|
||||
catatan_kemandirian = ?,
|
||||
tanggal_exit_plan = ?,
|
||||
updated_at = NOW()
|
||||
WHERE id = ?
|
||||
");
|
||||
$stmt->execute([
|
||||
$body['status_kemandirian'] ?? null,
|
||||
$body['catatan'] ?? null,
|
||||
$body['tanggal_exit_plan'] ?? null,
|
||||
$wargaId,
|
||||
]);
|
||||
|
||||
echo json_encode(['success' => true, 'message' => 'Status kemandirian diperbarui']);
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
// api/kontribusi.php
|
||||
header('Content-Type: application/json');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Authorization');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
|
||||
require_once '../config/db.php';
|
||||
require_once 'middleware.php';
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
try {
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
// Wajib login untuk melihat riwayat kontribusi
|
||||
$currentUser = requireAuth(['admin', 'koordinator', 'kontributor']);
|
||||
|
||||
if ($currentUser['role'] === 'kontributor') {
|
||||
// Hanya riwayat kontribusi milik sendiri
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT ko.id, ko.user_id, ko.kebutuhan_id, ko.status, ko.created_at, ko.updated_at,
|
||||
ko.jumlah,
|
||||
ko.jenis_kontribusi AS tipe_kontribusi,
|
||||
ko.detail_barang_jasa AS catatan,
|
||||
k.jenis_bantuan,
|
||||
ri.nama AS nama_rumah_ibadah,
|
||||
ri.nama AS nama_ibadah
|
||||
FROM kontribusi ko
|
||||
JOIN kebutuhan k ON ko.kebutuhan_id = k.id
|
||||
JOIN rumah_ibadah ri ON k.rumah_ibadah_id = ri.id
|
||||
WHERE ko.user_id = ?
|
||||
ORDER BY ko.created_at DESC
|
||||
");
|
||||
$stmt->execute([$currentUser['id']]);
|
||||
$rows = $stmt->fetchAll();
|
||||
} else {
|
||||
// Admin atau Koordinator melihat kontribusi
|
||||
$where = "";
|
||||
$params = [];
|
||||
if ($currentUser['role'] === 'koordinator') {
|
||||
$where = "WHERE k.rumah_ibadah_id = ?";
|
||||
$params[] = $currentUser['rumah_ibadah_id'];
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT ko.id, ko.user_id, ko.kebutuhan_id, ko.status, ko.created_at, ko.updated_at,
|
||||
ko.jumlah,
|
||||
ko.jenis_kontribusi AS tipe_kontribusi,
|
||||
ko.detail_barang_jasa AS catatan,
|
||||
k.jenis_bantuan,
|
||||
ri.nama AS nama_rumah_ibadah,
|
||||
ri.nama AS nama_ibadah,
|
||||
u.username AS nama_kontributor,
|
||||
u.email AS kontak_kontributor
|
||||
FROM kontribusi ko
|
||||
JOIN kebutuhan k ON ko.kebutuhan_id = k.id
|
||||
JOIN rumah_ibadah ri ON k.rumah_ibadah_id = ri.id
|
||||
JOIN users u ON ko.user_id = u.id
|
||||
$where
|
||||
ORDER BY ko.created_at DESC
|
||||
");
|
||||
$stmt->execute($params);
|
||||
$rows = $stmt->fetchAll();
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'data' => $rows]);
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
// Wajib login sebagai Kontributor untuk berdonasi
|
||||
$currentUser = requireAuth(['kontributor']);
|
||||
$body = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
|
||||
$kebutuhanId = (int)($body['kebutuhan_id'] ?? 0);
|
||||
$jenisKontribusi = trim($body['jenis_kontribusi'] ?? $body['tipe_kontribusi'] ?? ''); // uang/barang/jasa
|
||||
$jumlahDonasi = (float)($body['jumlah'] ?? 0);
|
||||
$detail = trim($body['detail_barang_jasa'] ?? $body['catatan'] ?? '');
|
||||
|
||||
if (!$kebutuhanId || !$jenisKontribusi || $jumlahDonasi <= 0) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => 'Input data donasi tidak lengkap/valid.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// VALIDASI SISA KEBUTUHAN
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT k.jumlah_dibutuhkan, k.kategori,
|
||||
COALESCE(SUM(CASE WHEN ko.status = 'confirmed' THEN ko.jumlah ELSE 0 END), 0) AS jumlah_terkumpul
|
||||
FROM kebutuhan k
|
||||
LEFT JOIN kontribusi ko ON k.id = ko.kebutuhan_id
|
||||
WHERE k.id = ?
|
||||
GROUP BY k.id
|
||||
");
|
||||
$stmt->execute([$kebutuhanId]);
|
||||
$kebutuhan = $stmt->fetch();
|
||||
|
||||
if (!$kebutuhan) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['success' => false, 'message' => 'Data kebutuhan bantuan tidak ditemukan.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Hitung sisa kebutuhan secara dinamis
|
||||
$sisaKebutuhan = (float)$kebutuhan['jumlah_dibutuhkan'] - (float)$kebutuhan['jumlah_terkumpul'];
|
||||
|
||||
if ($jumlahDonasi > $sisaKebutuhan) {
|
||||
http_response_code(400);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'message' => sprintf(
|
||||
'Donasi ditolak! Jumlah kontribusi (%.2f) melebihi sisa kebutuhan bantuan saat ini (%.2f).',
|
||||
$jumlahDonasi,
|
||||
$sisaKebutuhan
|
||||
)
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Simpan kontribusi (default status = pending)
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO kontribusi (user_id, kebutuhan_id, jenis_kontribusi, jumlah, detail_barang_jasa, status)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending')
|
||||
");
|
||||
$stmt->execute([$currentUser['id'], $kebutuhanId, $jenisKontribusi, $jumlahDonasi, $detail]);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => 'Terima kasih atas kontribusi Anda! Status kontribusi saat ini menunggu verifikasi (pending).',
|
||||
'id' => $pdo->lastInsertId()
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
// Konfirmasi kontribusi (pending -> confirmed)
|
||||
// Hanya Admin atau Koordinator terkait
|
||||
$currentUser = requireAuth(['admin', 'koordinator']);
|
||||
$body = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$kontribusiId = (int)($body['id'] ?? 0);
|
||||
$newStatus = trim($body['status'] ?? 'pending'); // pending/confirmed
|
||||
|
||||
if (!$kontribusiId || !in_array($newStatus, ['pending', 'confirmed'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => 'Parameter input status tidak valid.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Ambil detail kontribusi
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT ko.jumlah, ko.status AS status_sebelumnya, ko.kebutuhan_id, k.rumah_ibadah_id
|
||||
FROM kontribusi ko
|
||||
JOIN kebutuhan k ON ko.kebutuhan_id = k.id
|
||||
WHERE ko.id = ?
|
||||
");
|
||||
$stmt->execute([$kontribusiId]);
|
||||
$kontribusi = $stmt->fetch();
|
||||
|
||||
if (!$kontribusi) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['success' => false, 'message' => 'Data kontribusi tidak ditemukan.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Batasan wilayah untuk Koordinator
|
||||
if ($currentUser['role'] === 'koordinator' && $currentUser['rumah_ibadah_id'] != $kontribusi['rumah_ibadah_id']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'message' => 'Koordinator hanya berhak mengonfirmasi donasi untuk rumah ibadahnya sendiri.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Jika status dirubah ke 'confirmed' dari 'pending'
|
||||
if ($newStatus === 'confirmed' && $kontribusi['status_sebelumnya'] !== 'confirmed') {
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT k.jumlah_dibutuhkan,
|
||||
COALESCE(SUM(CASE WHEN ko.status = 'confirmed' THEN ko.jumlah ELSE 0 END), 0) AS jumlah_terkumpul
|
||||
FROM kebutuhan k
|
||||
LEFT JOIN kontribusi ko ON k.id = ko.kebutuhan_id
|
||||
WHERE k.id = ?
|
||||
GROUP BY k.id
|
||||
");
|
||||
$stmt->execute([$kontribusi['kebutuhan_id']]);
|
||||
$kebutuhan = $stmt->fetch();
|
||||
|
||||
$sisaKebutuhan = (float)$kebutuhan['jumlah_dibutuhkan'] - (float)$kebutuhan['jumlah_terkumpul'];
|
||||
|
||||
if ($kontribusi['jumlah'] > $sisaKebutuhan) {
|
||||
http_response_code(400);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'message' => 'Gagal konfirmasi! Total confirmed donasi melebihi kapasitas kebutuhan.'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("UPDATE kontribusi SET status = ? WHERE id = ?");
|
||||
$stmt->execute([$newStatus, $kontribusiId]);
|
||||
|
||||
echo json_encode(['success' => true, 'message' => 'Status kontribusi berhasil diperbarui ke ' . $newStatus]);
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(405);
|
||||
echo json_encode(['error' => 'Method not allowed']);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
<?php
|
||||
// api/laporan.php — Pelaporan Warga oleh Kontributor & Koordinator
|
||||
header('Content-Type: application/json');
|
||||
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') { http_response_code(204); exit; }
|
||||
|
||||
require_once '../config/db.php';
|
||||
require_once 'middleware.php';
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
if ($method === 'POST' && isset($_POST['_method'])) {
|
||||
$method = strtoupper($_POST['_method']);
|
||||
}
|
||||
$action = $_GET['action'] ?? '';
|
||||
|
||||
try {
|
||||
switch ($method) {
|
||||
|
||||
// ── GET: List laporan ──────────────────────────────────────────────────
|
||||
case 'GET':
|
||||
$currentUser = requireAuth(['admin', 'petugas', 'koordinator', 'kontributor']);
|
||||
$role = $currentUser['role'];
|
||||
$userId = (int)$currentUser['id'];
|
||||
|
||||
// Jika ada action=stats untuk admin dashboard
|
||||
if ($action === 'stats') {
|
||||
if (!in_array($role, ['admin', 'petugas'])) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'message' => 'Forbidden']);
|
||||
exit;
|
||||
}
|
||||
$stmtStats = $pdo->query("
|
||||
SELECT status, COUNT(*) as jumlah
|
||||
FROM laporan_warga
|
||||
GROUP BY status
|
||||
");
|
||||
$stats = [];
|
||||
while ($r = $stmtStats->fetch(PDO::FETCH_ASSOC)) {
|
||||
$stats[$r['status']] = (int)$r['jumlah'];
|
||||
}
|
||||
$stmtTotal = $pdo->query("SELECT COUNT(*) FROM laporan_warga");
|
||||
echo json_encode(['success' => true, 'stats' => $stats, 'total' => (int)$stmtTotal->fetchColumn()]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Filter berdasarkan role
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
// Jika ada action=get_petugas
|
||||
if ($action === 'get_petugas') {
|
||||
if (!in_array($role, ['admin', 'petugas'])) {
|
||||
http_response_code(403); echo json_encode(['success' => false, 'message' => 'Forbidden']); exit;
|
||||
}
|
||||
$stmt = $pdo->query("SELECT username FROM users WHERE role = 'petugas' ORDER BY username ASC");
|
||||
$petugas = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
echo json_encode(['success' => true, 'petugas' => $petugas]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($role === 'kontributor') {
|
||||
// Kontributor hanya lihat laporan milik sendiri
|
||||
$where[] = 'lw.pelapor_id = ?';
|
||||
$params[] = $userId;
|
||||
} elseif ($role === 'koordinator') {
|
||||
// Koordinator lihat laporan dari wilayahnya + laporan milik sendiri
|
||||
$where[] = '(lw.pelapor_id = ? OR lw.koordinator_ibadah_id = ?)';
|
||||
$params[] = $userId;
|
||||
$params[] = !empty($currentUser['rumah_ibadah_id']) ? (int)$currentUser['rumah_ibadah_id'] : 0;
|
||||
} elseif ($role === 'petugas') {
|
||||
// Petugas hanya lihat laporan yang ditugaskan ke mereka
|
||||
$where[] = 'lw.ditugaskan_ke = ?';
|
||||
$params[] = $currentUser['username'];
|
||||
}
|
||||
// Admin: lihat semua
|
||||
|
||||
// Filter status
|
||||
if (!empty($_GET['status'])) {
|
||||
$where[] = 'lw.status = ?';
|
||||
$params[] = $_GET['status'];
|
||||
}
|
||||
|
||||
// Filter prioritas
|
||||
if (!empty($_GET['prioritas'])) {
|
||||
$where[] = 'lw.prioritas = ?';
|
||||
$params[] = $_GET['prioritas'];
|
||||
}
|
||||
|
||||
$whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
$sql = "
|
||||
SELECT
|
||||
lw.*,
|
||||
rw.nama_kepala_keluarga,
|
||||
rw.kelurahan,
|
||||
rw.kecamatan,
|
||||
rw.kategori_kemiskinan,
|
||||
rw.status_bantuan,
|
||||
u.username AS pelapor_nama,
|
||||
u.role AS pelapor_role
|
||||
FROM laporan_warga lw
|
||||
JOIN rumah_warga rw ON rw.id = lw.rumah_warga_id
|
||||
JOIN users u ON u.id = lw.pelapor_id
|
||||
$whereSql
|
||||
ORDER BY lw.created_at DESC
|
||||
LIMIT 100
|
||||
";
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$data = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Summary counts for filter badges
|
||||
$stmtSummary = $pdo->query("SELECT status, COUNT(*) as n FROM laporan_warga GROUP BY status");
|
||||
$summary = [];
|
||||
while ($r = $stmtSummary->fetch(PDO::FETCH_ASSOC)) {
|
||||
$summary[$r['status']] = (int)$r['n'];
|
||||
}
|
||||
|
||||
$response = ['success' => true, 'data' => $data, 'summary' => $summary, 'total' => count($data)];
|
||||
|
||||
// Sertakan list petugas jika user admin
|
||||
if ($role === 'admin' || $role === 'petugas') {
|
||||
$stmtP = $pdo->query("SELECT username FROM users WHERE role = 'petugas' ORDER BY username ASC");
|
||||
$response['petugas'] = $stmtP->fetchAll(PDO::FETCH_COLUMN);
|
||||
}
|
||||
|
||||
echo json_encode($response);
|
||||
break;
|
||||
|
||||
// ── POST: Buat laporan baru ──────────────────────────────────────────
|
||||
case 'POST':
|
||||
$currentUser = requireAuth(['koordinator', 'kontributor']);
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
|
||||
$rumahWargaId = (int)($input['rumah_warga_id'] ?? 0);
|
||||
$jenisLaporan = trim($input['jenis_laporan'] ?? '');
|
||||
$deskripsi = trim($input['deskripsi'] ?? '');
|
||||
|
||||
// Penentuan prioritas otomatis berdasarkan jenis laporan
|
||||
$prioritasMap = [
|
||||
'warga_meninggal' => 'tinggi',
|
||||
'warga_pindah' => 'tinggi',
|
||||
'warga_mampu' => 'tinggi',
|
||||
'data_ganda' => 'sedang',
|
||||
'data_tidak_sesuai'=> 'sedang',
|
||||
'lainnya' => 'rendah'
|
||||
];
|
||||
$prioritas = $prioritasMap[$jenisLaporan] ?? 'sedang';
|
||||
|
||||
if (!$rumahWargaId || !$jenisLaporan || !$deskripsi) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => 'Data tidak lengkap: rumah_warga_id, jenis_laporan, dan deskripsi wajib diisi.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Cek warga ada
|
||||
$stmtCek = $pdo->prepare("SELECT id, nama_kepala_keluarga FROM rumah_warga WHERE id = ?");
|
||||
$stmtCek->execute([$rumahWargaId]);
|
||||
$warga = $stmtCek->fetch();
|
||||
if (!$warga) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['success' => false, 'message' => 'Data warga tidak ditemukan.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Cek apakah laporan duplikat dalam 7 hari terakhir
|
||||
$stmtDupCheck = $pdo->prepare("
|
||||
SELECT id FROM laporan_warga
|
||||
WHERE rumah_warga_id = ? AND pelapor_id = ?
|
||||
AND status NOT IN ('selesai', 'ditolak')
|
||||
AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||
");
|
||||
$stmtDupCheck->execute([$rumahWargaId, $currentUser['id']]);
|
||||
if ($stmtDupCheck->fetch()) {
|
||||
http_response_code(409);
|
||||
echo json_encode(['success' => false, 'message' => 'Anda sudah membuat laporan untuk warga ini dalam 7 hari terakhir. Harap tunggu laporan sebelumnya diproses.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$koordinatorIbadahId = !empty($currentUser['rumah_ibadah_id']) ? (int)$currentUser['rumah_ibadah_id'] : null;
|
||||
|
||||
$stmtInsert = $pdo->prepare("
|
||||
INSERT INTO laporan_warga
|
||||
(rumah_warga_id, pelapor_id, koordinator_ibadah_id, jenis_laporan, deskripsi, prioritas, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'pending')
|
||||
");
|
||||
$stmtInsert->execute([
|
||||
$rumahWargaId,
|
||||
$currentUser['id'],
|
||||
$koordinatorIbadahId,
|
||||
$jenisLaporan,
|
||||
$deskripsi,
|
||||
$prioritas,
|
||||
]);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => 'Laporan berhasil dikirim. Admin akan meninjau laporan Anda segera.',
|
||||
'id' => $pdo->lastInsertId()
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
$currentUser = requireAuth(['admin', 'petugas']);
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
if (!$input) $input = $_POST;
|
||||
|
||||
$id = (int)($input['id'] ?? 0);
|
||||
$status = $input['status'] ?? '';
|
||||
$catatanAdmin = trim($input['catatan_admin'] ?? '');
|
||||
$ditugaskanKe = trim($input['ditugaskan_ke'] ?? '');
|
||||
|
||||
// Ambil dari old state
|
||||
$stmtDok = $pdo->prepare("SELECT dokumentasi FROM laporan_warga WHERE id = ?");
|
||||
$stmtDok->execute([$id]);
|
||||
$oldDok = $stmtDok->fetchColumn();
|
||||
|
||||
$dokumentasi = trim($input['dokumentasi'] ?? $oldDok ?? '');
|
||||
|
||||
// Handle file upload
|
||||
if (isset($_FILES['dokumentasi_file']) && $_FILES['dokumentasi_file']['error'] === UPLOAD_ERR_OK) {
|
||||
$uploadDir = '../uploads/dokumentasi/';
|
||||
if (!is_dir($uploadDir)) {
|
||||
mkdir($uploadDir, 0755, true);
|
||||
}
|
||||
$ext = strtolower(pathinfo($_FILES['dokumentasi_file']['name'], PATHINFO_EXTENSION));
|
||||
$allowedExts = ['jpg', 'jpeg', 'png', 'pdf'];
|
||||
if (in_array($ext, $allowedExts)) {
|
||||
$filename = 'dok_' . $id . '_' . time() . '.' . $ext;
|
||||
$dest = $uploadDir . $filename;
|
||||
if (move_uploaded_file($_FILES['dokumentasi_file']['tmp_name'], $dest)) {
|
||||
$dokumentasi = 'uploads/dokumentasi/' . $filename;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$allowedStatus = ['pending', 'ditinjau', 'diproses', 'selesai', 'ditolak'];
|
||||
if (!$id || !in_array($status, $allowedStatus)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => 'ID dan status valid wajib diisi.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Jika petugas, pastikan hanya bisa update dokumentasi, status, catatan. (ditugaskan_ke biarkan tetap)
|
||||
if ($currentUser['role'] === 'petugas') {
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE laporan_warga
|
||||
SET status = ?, catatan_admin = ?, dokumentasi = ?, updated_at = NOW()
|
||||
WHERE id = ? AND ditugaskan_ke = ?
|
||||
");
|
||||
$stmt->execute([$status, $catatanAdmin, $dokumentasi, $id, $currentUser['username']]);
|
||||
} else {
|
||||
// Admin bisa update semua
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE laporan_warga
|
||||
SET status = ?, catatan_admin = ?, ditugaskan_ke = ?, dokumentasi = ?, updated_at = NOW()
|
||||
WHERE id = ?
|
||||
");
|
||||
$stmt->execute([$status, $catatanAdmin, $ditugaskanKe ?: null, $dokumentasi, $id]);
|
||||
}
|
||||
|
||||
$statusLabel = [
|
||||
'pending' => 'Menunggu',
|
||||
'ditinjau' => 'Sedang Ditinjau',
|
||||
'diproses' => 'Sedang Diproses',
|
||||
'selesai' => 'Selesai',
|
||||
'ditolak' => 'Ditolak',
|
||||
];
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => 'Laporan berhasil diperbarui ke status: ' . ($statusLabel[$status] ?? $status)
|
||||
]);
|
||||
break;
|
||||
|
||||
// ── DELETE: Hapus laporan (admin only) ──────────────────────────────
|
||||
case 'DELETE':
|
||||
$currentUser = requireAuth(['admin']);
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
if (!$id) {
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$id = (int)($input['id'] ?? 0);
|
||||
}
|
||||
if (!$id) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => 'ID laporan wajib diisi.']);
|
||||
exit;
|
||||
}
|
||||
$stmt = $pdo->prepare("DELETE FROM laporan_warga WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
echo json_encode(['success' => true, 'message' => 'Laporan berhasil dihapus.']);
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(405);
|
||||
echo json_encode(['error' => 'Method not allowed']);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'message' => 'Kesalahan sistem: ' . $e->getMessage()]);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
// api/middleware.php
|
||||
|
||||
// Memulai session jika belum dimulai
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
// Pastikan session cookie aman dan hanya terakses lewat HTTP (mencegah XSS hijack)
|
||||
ini_set('session.cookie_httponly', 1);
|
||||
session_start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware untuk memvalidasi user yang login dan mencocokkan perannya (RBAC).
|
||||
* Jika user belum login, akan mengembalikan 401 Unauthorized.
|
||||
* Jika role user tidak ada dalam daftar role yang diizinkan, mengembalikan 403 Forbidden.
|
||||
*
|
||||
* @param array $allowedRoles Daftar role yang diizinkan (contoh: ['admin', 'petugas'])
|
||||
* @return array Data user yang saat ini sedang login dari Session
|
||||
*/
|
||||
function requireAuth(array $allowedRoles = []): array
|
||||
{
|
||||
// Cek apakah ada data user di Session
|
||||
if (!isset($_SESSION['user']) || !is_array($_SESSION['user'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'message' => 'Unauthorized: Silakan login terlebih dahulu untuk mengakses fitur ini.'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$user = $_SESSION['user'];
|
||||
|
||||
// Cek kecocokan role (RBAC) jika list allowedRoles tidak kosong
|
||||
if (!empty($allowedRoles) && !in_array($user['role'], $allowedRoles)) {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'message' => 'Forbidden: Peran Anda (' . $user['role'] . ') tidak memiliki hak akses ke fitur ini.'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware Opsional untuk mendukung Guest Mode.
|
||||
* Jika user login, akan mengembalikan data user.
|
||||
* Jika belum login, akan mengembalikan null (sebagai Guest) dan proses tetap berlanjut.
|
||||
*
|
||||
* @return array|null Data user jika login, atau null jika guest
|
||||
*/
|
||||
function optionalAuth(): ?array
|
||||
{
|
||||
if (isset($_SESSION['user']) && is_array($_SESSION['user'])) {
|
||||
return $_SESSION['user'];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
require_once '../config/db.php';
|
||||
|
||||
$sql = "
|
||||
CREATE TABLE IF NOT EXISTS `laporan_warga` (
|
||||
`id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||
`rumah_warga_id` INT(11) NOT NULL,
|
||||
`pelapor_id` INT(11) NOT NULL,
|
||||
`koordinator_ibadah_id` INT(11) NULL DEFAULT NULL,
|
||||
`jenis_laporan` ENUM(
|
||||
'data_tidak_sesuai',
|
||||
'warga_mampu',
|
||||
'warga_pindah',
|
||||
'warga_meninggal',
|
||||
'data_ganda',
|
||||
'lainnya'
|
||||
) NOT NULL DEFAULT 'data_tidak_sesuai',
|
||||
`deskripsi` TEXT NOT NULL,
|
||||
`prioritas` ENUM('rendah', 'sedang', 'tinggi') NOT NULL DEFAULT 'sedang',
|
||||
`status` ENUM(
|
||||
'pending',
|
||||
'ditinjau',
|
||||
'diproses',
|
||||
'selesai',
|
||||
'ditolak'
|
||||
) NOT NULL DEFAULT 'pending',
|
||||
`catatan_admin` TEXT NULL DEFAULT NULL,
|
||||
`ditugaskan_ke` VARCHAR(150) NULL DEFAULT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP(),
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP() ON UPDATE CURRENT_TIMESTAMP(),
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `fk_laporan_warga` (`rumah_warga_id`),
|
||||
KEY `fk_laporan_pelapor` (`pelapor_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
";
|
||||
|
||||
try {
|
||||
$pdo->exec($sql);
|
||||
echo "Tabel laporan_warga berhasil dibuat!";
|
||||
} catch (Exception $e) {
|
||||
echo "Error: " . $e->getMessage();
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
<?php
|
||||
// api/redistribusi.php
|
||||
// Fitur 1: Redistribusi Bantuan antar Rumah Ibadah
|
||||
// Method: GET (analisis), POST (buat saran manual), PUT (update status), DELETE
|
||||
|
||||
header('Content-Type: application/json');
|
||||
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') { http_response_code(200); exit; }
|
||||
|
||||
require_once '../config/db.php';
|
||||
require_once 'middleware.php';
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
HELPER: ambil konfigurasi dari DB
|
||||
───────────────────────────────────────────── */
|
||||
function getKonfig(PDO $pdo, string $kunci, $default = null) {
|
||||
$stmt = $pdo->prepare("SELECT nilai, tipe FROM konfigurasi_sistem WHERE kunci = ?");
|
||||
$stmt->execute([$kunci]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) return $default;
|
||||
return match($row['tipe']) {
|
||||
'integer' => (int)$row['nilai'],
|
||||
'decimal' => (float)$row['nilai'],
|
||||
'boolean' => filter_var($row['nilai'], FILTER_VALIDATE_BOOLEAN),
|
||||
default => $row['nilai'],
|
||||
};
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
CORE: Hitung beban tiap rumah ibadah
|
||||
berdasarkan radius dan jumlah warga miskin
|
||||
───────────────────────────────────────────── */
|
||||
function hitungBebanIbadah(PDO $pdo, float $radius): array {
|
||||
// Ambil semua rumah ibadah
|
||||
$ibadahList = $pdo->query("SELECT * FROM rumah_ibadah ORDER BY id")->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Ambil semua rumah warga dengan status relevan
|
||||
$wargaList = $pdo->query("
|
||||
SELECT id, latitude, longitude, kategori_kemiskinan,
|
||||
status_bantuan, rekomendasi_bantuan, jumlah_tanggungan,
|
||||
penghasilan_bulanan, status_kemandirian, skor_kemandirian,
|
||||
dikelola_oleh_ibadah_id
|
||||
FROM rumah_warga
|
||||
WHERE status_kemandirian NOT IN ('mandiri')
|
||||
AND kategori_kemiskinan IN ('sangat_miskin','miskin','rentan_miskin')
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($ibadahList as $ibadah) {
|
||||
$lat1 = (float)$ibadah['latitude'];
|
||||
$lng1 = (float)$ibadah['longitude'];
|
||||
$rad = $ibadah['radius_primer'] ?? $radius;
|
||||
$kap = (int)($ibadah['kapasitas_kk'] ?? 20);
|
||||
|
||||
$wargaDalam = [];
|
||||
foreach ($wargaList as $warga) {
|
||||
$kelolaId = $warga['dikelola_oleh_ibadah_id'];
|
||||
if ($kelolaId !== null) {
|
||||
if ((int)$kelolaId === (int)$ibadah['id']) {
|
||||
// Explicitly assigned to this ibadah
|
||||
$d = haversine($lat1, $lng1, (float)$warga['latitude'], (float)$warga['longitude']);
|
||||
$wargaDalam[] = array_merge($warga, ['jarak_meter' => round($d)]);
|
||||
}
|
||||
} else {
|
||||
// Not assigned explicitly, check radius
|
||||
$d = haversine($lat1, $lng1, (float)$warga['latitude'], (float)$warga['longitude']);
|
||||
if ($d <= $rad) {
|
||||
$wargaDalam[] = array_merge($warga, ['jarak_meter' => round($d)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hitung kategori
|
||||
$sangat = count(array_filter($wargaDalam, fn($w) => $w['kategori_kemiskinan'] === 'sangat_miskin'));
|
||||
$miskin = count(array_filter($wargaDalam, fn($w) => $w['kategori_kemiskinan'] === 'miskin'));
|
||||
$rentan = count(array_filter($wargaDalam, fn($w) => $w['kategori_kemiskinan'] === 'rentan_miskin'));
|
||||
$total = count($wargaDalam);
|
||||
|
||||
// Indeks beban: (sangat_miskin*3 + miskin*2 + rentan*1) / kapasitas
|
||||
// Skor 0-100, > 100 = overload
|
||||
$bobotBeban = ($sangat * 3 + $miskin * 2 + $rentan * 1);
|
||||
$maxBobot = $kap * 3; // kalau semua sangat miskin
|
||||
$pctBeban = $maxBobot > 0 ? round(min($bobotBeban / $maxBobot * 100, 200)) : 0;
|
||||
|
||||
// Rasio kemiskinan: berapa % warga dalam radius yang masuk kategori miskin
|
||||
$rasioMiskin = $total > 0 ? round(($sangat + $miskin) / $total * 100) : 0;
|
||||
|
||||
// Status beban
|
||||
$statusBeban = match(true) {
|
||||
$total === 0 => 'kosong',
|
||||
$total < $kap * 0.5 => 'ringan',
|
||||
$total <= $kap => 'normal',
|
||||
$total <= $kap * 1.3 => 'padat',
|
||||
default => 'overload',
|
||||
};
|
||||
|
||||
$result[$ibadah['id']] = [
|
||||
'ibadah' => $ibadah,
|
||||
'radius_dipakai' => $rad,
|
||||
'kapasitas' => $kap,
|
||||
'total_warga' => $total,
|
||||
'sangat_miskin' => $sangat,
|
||||
'miskin' => $miskin,
|
||||
'rentan_miskin' => $rentan,
|
||||
'rasio_miskin' => $rasioMiskin,
|
||||
'bobot_beban' => $bobotBeban,
|
||||
'pct_beban' => $pctBeban,
|
||||
'status_beban' => $statusBeban,
|
||||
'surplus_kapasitas' => max(0, $kap - $total),
|
||||
'defisit_kapasitas' => max(0, $total - $kap),
|
||||
'warga_list' => $wargaDalam,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
CORE: Generate saran redistribusi otomatis
|
||||
───────────────────────────────────────────── */
|
||||
function generateSaranRedistribusi(array $bebanData): array {
|
||||
$saranList = [];
|
||||
|
||||
// Pisahkan ibadah surplus vs defisit
|
||||
$surplus = array_filter($bebanData, fn($d) => $d['status_beban'] === 'ringan' || $d['status_beban'] === 'kosong');
|
||||
$defisit = array_filter($bebanData, fn($d) => in_array($d['status_beban'], ['padat','overload']));
|
||||
|
||||
// Sort: defisit paling parah dulu, surplus paling longgar dulu
|
||||
uasort($surplus, fn($a,$b) => $a['pct_beban'] <=> $b['pct_beban']);
|
||||
uasort($defisit, fn($a,$b) => $b['pct_beban'] <=> $a['pct_beban']);
|
||||
|
||||
foreach ($defisit as $def) {
|
||||
foreach ($surplus as &$sur) {
|
||||
if ($sur['surplus_kapasitas'] <= 0) continue;
|
||||
|
||||
$ibadahDef = $def['ibadah'];
|
||||
$ibadahSur = $sur['ibadah'];
|
||||
|
||||
// Hitung jarak antar kedua ibadah
|
||||
$jarakKm = round(haversine(
|
||||
(float)$ibadahDef['latitude'], (float)$ibadahDef['longitude'],
|
||||
(float)$ibadahSur['latitude'], (float)$ibadahSur['longitude']
|
||||
) / 1000, 2);
|
||||
|
||||
// Hanya sarankan jika jarak tidak terlalu jauh (< 5km)
|
||||
if ($jarakKm > 5) continue;
|
||||
|
||||
$jumlahPindah = min($def['defisit_kapasitas'], $sur['surplus_kapasitas']);
|
||||
if ($jumlahPindah <= 0) continue;
|
||||
|
||||
// Estimasi dana: rata-rata penghasilan warga * faktor bantuan
|
||||
$estDana = $jumlahPindah * 200000; // estimasi Rp200rb/KK/bulan
|
||||
|
||||
// Tingkat prioritas
|
||||
$prioritas = match(true) {
|
||||
$def['rasio_miskin'] >= 80 => 'tinggi',
|
||||
$def['rasio_miskin'] >= 50 => 'sedang',
|
||||
default => 'rendah',
|
||||
};
|
||||
|
||||
$saranList[] = [
|
||||
'pengirim_id' => (int)$ibadahSur['id'],
|
||||
'pengirim_nama' => $ibadahSur['nama'],
|
||||
'penerima_id' => (int)$ibadahDef['id'],
|
||||
'penerima_nama' => $ibadahDef['nama'],
|
||||
'jumlah_kk' => $jumlahPindah,
|
||||
'estimasi_dana' => $estDana,
|
||||
'jarak_km' => $jarakKm,
|
||||
'prioritas' => $prioritas,
|
||||
'alasan' => sprintf(
|
||||
'%s memiliki surplus %d kapasitas (beban %d%%), sementara %s kelebihan %d KK (beban %d%%) dengan rasio kemiskinan %d%%.',
|
||||
$ibadahSur['nama'],
|
||||
$sur['surplus_kapasitas'],
|
||||
$sur['pct_beban'],
|
||||
$ibadahDef['nama'],
|
||||
$def['defisit_kapasitas'],
|
||||
$def['pct_beban'],
|
||||
$def['rasio_miskin']
|
||||
),
|
||||
];
|
||||
|
||||
// Kurangi surplus yang sudah dipakai
|
||||
$sur['surplus_kapasitas'] -= $jumlahPindah;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort hasil saran berdasarkan prioritas
|
||||
usort($saranList, function($a, $b) {
|
||||
$p = ['tinggi' => 0, 'sedang' => 1, 'rendah' => 2];
|
||||
return ($p[$a['prioritas']] ?? 9) <=> ($p[$b['prioritas']] ?? 9);
|
||||
});
|
||||
|
||||
return $saranList;
|
||||
}
|
||||
|
||||
function haversine(float $lat1, float $lng1, float $lat2, float $lng2): float {
|
||||
$R = 6371000;
|
||||
$dLat = deg2rad($lat2 - $lat1);
|
||||
$dLng = deg2rad($lng2 - $lng1);
|
||||
$a = sin($dLat/2)**2 + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLng/2)**2;
|
||||
return $R * 2 * atan2(sqrt($a), sqrt(1-$a));
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
ROUTING
|
||||
───────────────────────────────────────────── */
|
||||
try {
|
||||
// Hanya Admin yang bisa mengakses fitur redistribusi bantuan / analisis spasial
|
||||
requireAuth(['admin']);
|
||||
|
||||
// Guard: PDO connection must be available
|
||||
if (!$pdo) {
|
||||
throw new RuntimeException('Koneksi database (PDO) tidak tersedia. Pastikan XAMPP MySQL aktif.');
|
||||
}
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
if ($method === 'GET') {
|
||||
$action = $_GET['action'] ?? 'analisis';
|
||||
|
||||
if ($action === 'analisis') {
|
||||
// Analisis beban semua rumah ibadah
|
||||
$radius = (float)($_GET['radius'] ?? getKonfig($pdo, 'kapasitas_default_ibadah') ?? 500);
|
||||
$beban = hitungBebanIbadah($pdo, $radius);
|
||||
|
||||
// Hapus warga_list dari response utama (terlalu besar)
|
||||
$ringkasan = array_map(function($d) {
|
||||
$r = $d;
|
||||
unset($r['warga_list']);
|
||||
return $r;
|
||||
}, $beban);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'data' => array_values($ringkasan),
|
||||
'saran' => generateSaranRedistribusi($beban),
|
||||
'summary' => [
|
||||
'total_ibadah' => count($beban),
|
||||
'overload' => count(array_filter($beban, fn($d) => $d['status_beban'] === 'overload')),
|
||||
'padat' => count(array_filter($beban, fn($d) => $d['status_beban'] === 'padat')),
|
||||
'normal' => count(array_filter($beban, fn($d) => $d['status_beban'] === 'normal')),
|
||||
'ringan' => count(array_filter($beban, fn($d) => $d['status_beban'] === 'ringan')),
|
||||
'kosong' => count(array_filter($beban, fn($d) => $d['status_beban'] === 'kosong')),
|
||||
],
|
||||
]);
|
||||
|
||||
} elseif ($action === 'riwayat') {
|
||||
// Riwayat keputusan redistribusi
|
||||
$limit = (int)($_GET['limit'] ?? 20);
|
||||
$status = $_GET['status'] ?? null;
|
||||
$sql = "SELECT r.*,
|
||||
ip.nama as pengirim_nama, ip.jenis as pengirim_jenis,
|
||||
ir.nama as penerima_nama, ir.jenis as penerima_jenis
|
||||
FROM redistribusi_bantuan r
|
||||
JOIN rumah_ibadah ip ON r.ibadah_pengirim_id = ip.id
|
||||
JOIN rumah_ibadah ir ON r.ibadah_penerima_id = ir.id";
|
||||
if ($status) $sql .= " WHERE r.status = " . $pdo->quote($status);
|
||||
$sql .= " ORDER BY r.tanggal_saran DESC LIMIT $limit";
|
||||
$rows = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
||||
echo json_encode(['success' => true, 'data' => $rows]);
|
||||
|
||||
} elseif ($action === 'detail') {
|
||||
// Detail beban 1 ibadah
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
$radius = (float)($_GET['radius'] ?? 500);
|
||||
$beban = hitungBebanIbadah($pdo, $radius);
|
||||
if (!isset($beban[$id])) {
|
||||
echo json_encode(['success' => false, 'message' => 'Rumah ibadah tidak ditemukan']);
|
||||
exit;
|
||||
}
|
||||
echo json_encode(['success' => true, 'data' => $beban[$id]]);
|
||||
}
|
||||
|
||||
} elseif ($method === 'POST') {
|
||||
// Simpan saran redistribusi (dari sistem atau manual admin)
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO redistribusi_bantuan
|
||||
(ibadah_pengirim_id, ibadah_penerima_id, jumlah_kk, estimasi_dana,
|
||||
status, catatan_sistem, catatan_admin, periode_bulan)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
$stmt->execute([
|
||||
(int)$body['pengirim_id'],
|
||||
(int)$body['penerima_id'],
|
||||
(int)($body['jumlah_kk'] ?? 0),
|
||||
(float)($body['estimasi_dana'] ?? 0),
|
||||
$body['status'] ?? 'saran_sistem',
|
||||
$body['catatan_sistem'] ?? null,
|
||||
$body['catatan_admin'] ?? null,
|
||||
$body['periode_bulan'] ?? date('Y-m'),
|
||||
]);
|
||||
|
||||
$redistId = (int)$pdo->lastInsertId();
|
||||
|
||||
// Jika disetujui, alihkan warga dari penerima (defisit) ke pengirim (surplus)
|
||||
if (($body['status'] ?? '') === 'disetujui') {
|
||||
$penerimaId = (int)$body['penerima_id'];
|
||||
$pengirimId = (int)$body['pengirim_id'];
|
||||
$limit = (int)($body['jumlah_kk'] ?? 0);
|
||||
|
||||
if (isset($body['warga_ids']) && is_array($body['warga_ids']) && count($body['warga_ids']) > 0) {
|
||||
$ids = array_map('intval', $body['warga_ids']);
|
||||
$placeholders = str_repeat('?,', count($ids) - 1) . '?';
|
||||
$updateStmt = $pdo->prepare("UPDATE rumah_warga SET dikelola_oleh_ibadah_id = ? WHERE id IN ($placeholders)");
|
||||
$params = array_merge([$pengirimId], $ids);
|
||||
$updateStmt->execute($params);
|
||||
} elseif ($limit > 0) {
|
||||
// Ambil radius penerima (untuk mencari warga yang saat ini masuk radiusnya)
|
||||
$stmtPen = $pdo->prepare("SELECT latitude, longitude, radius_primer FROM rumah_ibadah WHERE id = ?");
|
||||
$stmtPen->execute([$penerimaId]);
|
||||
$penerima = $stmtPen->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
// Ambil koordinat pengirim (untuk mencari warga terdekat ke pengirim)
|
||||
$stmtPeng = $pdo->prepare("SELECT latitude, longitude FROM rumah_ibadah WHERE id = ?");
|
||||
$stmtPeng->execute([$pengirimId]);
|
||||
$pengirim = $stmtPeng->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($penerima && $pengirim) {
|
||||
$radPenerima = (float)($penerima['radius_primer'] ?? 500);
|
||||
|
||||
// Ambil warga yang dikelola oleh penerima atau belum dikelola siapapun
|
||||
$stmtWarga = $pdo->prepare("SELECT id, latitude, longitude, dikelola_oleh_ibadah_id FROM rumah_warga WHERE (dikelola_oleh_ibadah_id = ? OR dikelola_oleh_ibadah_id IS NULL) AND kategori_kemiskinan IN ('sangat_miskin', 'miskin', 'rentan_miskin') AND status_kemandirian != 'mandiri'");
|
||||
$stmtWarga->execute([$penerimaId]);
|
||||
$wargaList = $stmtWarga->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$eligible = [];
|
||||
foreach ($wargaList as $w) {
|
||||
// Jika sudah secara eksplisit dikelola oleh penerima, atau jika NULL dan masih dalam radius penerima
|
||||
$isManaged = ($w['dikelola_oleh_ibadah_id'] == $penerimaId);
|
||||
$distPenerima = haversine((float)$w['latitude'], (float)$w['longitude'], (float)$penerima['latitude'], (float)$penerima['longitude']);
|
||||
|
||||
if ($isManaged || $distPenerima <= $radPenerima) {
|
||||
$distPengirim = haversine((float)$w['latitude'], (float)$w['longitude'], (float)$pengirim['latitude'], (float)$pengirim['longitude']);
|
||||
$w['dist_pengirim'] = $distPengirim;
|
||||
$eligible[] = $w;
|
||||
}
|
||||
}
|
||||
|
||||
// Urutkan warga berdasarkan jarak terdekat ke rumah ibadah pengirim (surplus)
|
||||
usort($eligible, fn($a, $b) => $a['dist_pengirim'] <=> $b['dist_pengirim']);
|
||||
|
||||
// Ambil sejumlah $limit
|
||||
$toTransfer = array_slice($eligible, 0, $limit);
|
||||
|
||||
if (!empty($toTransfer)) {
|
||||
$ids = array_column($toTransfer, 'id');
|
||||
$placeholders = str_repeat('?,', count($ids) - 1) . '?';
|
||||
$updateStmt = $pdo->prepare("UPDATE rumah_warga SET dikelola_oleh_ibadah_id = ? WHERE id IN ($placeholders)");
|
||||
$params = array_merge([$pengirimId], $ids);
|
||||
$updateStmt->execute($params);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => 'Saran redistribusi berhasil disimpan',
|
||||
'id' => $redistId,
|
||||
]);
|
||||
|
||||
} elseif ($method === 'PUT') {
|
||||
// Update status keputusan admin
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
$id = (int)($body['id'] ?? 0);
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE redistribusi_bantuan
|
||||
SET status = ?,
|
||||
catatan_admin = ?,
|
||||
disetujui_oleh = ?,
|
||||
tanggal_keputusan = NOW()
|
||||
WHERE id = ?
|
||||
");
|
||||
$stmt->execute([
|
||||
$body['status'],
|
||||
$body['catatan_admin'] ?? null,
|
||||
$body['disetujui_oleh'] ?? null,
|
||||
$id,
|
||||
]);
|
||||
|
||||
echo json_encode(['success' => true, 'message' => 'Status redistribusi diperbarui']);
|
||||
|
||||
} elseif ($method === 'DELETE') {
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
$stmt = $pdo->prepare("DELETE FROM redistribusi_bantuan WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
echo json_encode(['success' => true, 'message' => 'Data redistribusi dihapus']);
|
||||
}
|
||||
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
// api/rumah_ibadah.php
|
||||
header('Content-Type: application/json');
|
||||
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') { http_response_code(204); exit; }
|
||||
|
||||
require_once '../config/db.php';
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
switch ($method) {
|
||||
// ── GET ──────────────────────────────────────────────────────────────────
|
||||
case 'GET':
|
||||
if (isset($_GET['id'])) {
|
||||
$id = (int)$_GET['id'];
|
||||
$result = $conn->query("SELECT * FROM rumah_ibadah WHERE id = $id");
|
||||
if ($row = $result->fetch_assoc()) {
|
||||
echo json_encode(['success' => true, 'data' => $row]);
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => 'Data tidak ditemukan']);
|
||||
}
|
||||
} else {
|
||||
// Filter opsional
|
||||
$where = [];
|
||||
if (!empty($_GET['jenis'])) $where[] = "jenis = '" . $conn->real_escape_string($_GET['jenis']) . "'";
|
||||
if (!empty($_GET['kecamatan'])) $where[] = "kecamatan LIKE '%" . $conn->real_escape_string($_GET['kecamatan']) . "%'";
|
||||
if (!empty($_GET['search'])) $where[] = "(nama LIKE '%" . $conn->real_escape_string($_GET['search']) . "%' OR alamat LIKE '%" . $conn->real_escape_string($_GET['search']) . "%')";
|
||||
|
||||
$sql = "SELECT * FROM rumah_ibadah" . ($where ? " WHERE " . implode(' AND ', $where) : "") . " ORDER BY nama ASC";
|
||||
$result = $conn->query($sql);
|
||||
if ($result === false) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'message' => 'Gagal mengambil data dari tabel "rumah_ibadah": ' . $conn->error . '. Pastikan Anda telah mengimpor berkas database "database/bansos_gis.sql" di phpMyAdmin Anda!'
|
||||
]);
|
||||
break;
|
||||
}
|
||||
$data = [];
|
||||
while ($row = $result->fetch_assoc()) $data[] = $row;
|
||||
echo json_encode(['success' => true, 'data' => $data, 'total' => count($data)]);
|
||||
}
|
||||
break;
|
||||
|
||||
// ── POST ─────────────────────────────────────────────────────────────────
|
||||
case 'POST':
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
|
||||
$nama = $conn->real_escape_string(trim($input['nama'] ?? ''));
|
||||
$jenis = $conn->real_escape_string($input['jenis'] ?? 'masjid');
|
||||
$alamat = $conn->real_escape_string($input['alamat'] ?? '');
|
||||
$kelurahan = $conn->real_escape_string($input['kelurahan'] ?? '');
|
||||
$kecamatan = $conn->real_escape_string($input['kecamatan'] ?? '');
|
||||
$kota = $conn->real_escape_string($input['kota'] ?? '');
|
||||
$lat = (float)($input['latitude'] ?? 0);
|
||||
$lng = (float)($input['longitude'] ?? 0);
|
||||
$pj = $conn->real_escape_string($input['penanggung_jawab'] ?? '');
|
||||
$kontak = $conn->real_escape_string($input['kontak'] ?? '');
|
||||
$ket = $conn->real_escape_string($input['keterangan'] ?? '');
|
||||
|
||||
$kap = (int)($input['kapasitas_kk'] ?? 20);
|
||||
$rad = (int)($input['radius_primer'] ?? 500);
|
||||
$anggaran = (float)($input['anggaran_bulanan'] ?? 0);
|
||||
|
||||
if (!$nama || !$lat || !$lng) {
|
||||
echo json_encode(['success' => false, 'message' => 'Nama, latitude, dan longitude wajib diisi']);
|
||||
break;
|
||||
}
|
||||
|
||||
$sql = "INSERT INTO rumah_ibadah (nama,jenis,alamat,kelurahan,kecamatan,kota,latitude,longitude,penanggung_jawab,kontak,keterangan,kapasitas_kk,radius_primer,anggaran_bulanan)
|
||||
VALUES ('$nama','$jenis','$alamat','$kelurahan','$kecamatan','$kota',$lat,$lng,'$pj','$kontak','$ket',$kap,$rad,$anggaran)";
|
||||
|
||||
if ($conn->query($sql)) {
|
||||
echo json_encode(['success' => true, 'message' => 'Data berhasil disimpan', 'id' => $conn->insert_id]);
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => 'Gagal menyimpan: ' . $conn->error]);
|
||||
}
|
||||
break;
|
||||
|
||||
// ── PUT ──────────────────────────────────────────────────────────────────
|
||||
case 'PUT':
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$id = (int)($input['id'] ?? 0);
|
||||
$nama = $conn->real_escape_string(trim($input['nama'] ?? ''));
|
||||
$jenis = $conn->real_escape_string($input['jenis'] ?? 'masjid');
|
||||
$alamat = $conn->real_escape_string($input['alamat'] ?? '');
|
||||
$kelurahan = $conn->real_escape_string($input['kelurahan'] ?? '');
|
||||
$kecamatan = $conn->real_escape_string($input['kecamatan'] ?? '');
|
||||
$kota = $conn->real_escape_string($input['kota'] ?? '');
|
||||
$lat = (float)($input['latitude'] ?? 0);
|
||||
$lng = (float)($input['longitude'] ?? 0);
|
||||
$pj = $conn->real_escape_string($input['penanggung_jawab'] ?? '');
|
||||
$kontak = $conn->real_escape_string($input['kontak'] ?? '');
|
||||
$ket = $conn->real_escape_string($input['keterangan'] ?? '');
|
||||
|
||||
$kap = (int)($input['kapasitas_kk'] ?? 20);
|
||||
$rad = (int)($input['radius_primer'] ?? 500);
|
||||
$anggaran = (float)($input['anggaran_bulanan'] ?? 0);
|
||||
|
||||
if (!$id) { echo json_encode(['success' => false, 'message' => 'ID tidak valid']); break; }
|
||||
|
||||
$sql = "UPDATE rumah_ibadah SET
|
||||
nama='$nama', jenis='$jenis', alamat='$alamat',
|
||||
kelurahan='$kelurahan', kecamatan='$kecamatan', kota='$kota',
|
||||
latitude=$lat, longitude=$lng,
|
||||
penanggung_jawab='$pj', kontak='$kontak', keterangan='$ket',
|
||||
kapasitas_kk=$kap, radius_primer=$rad, anggaran_bulanan=$anggaran
|
||||
WHERE id=$id";
|
||||
|
||||
if ($conn->query($sql)) {
|
||||
echo json_encode(['success' => true, 'message' => 'Data berhasil diperbarui']);
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => 'Gagal memperbarui: ' . $conn->error]);
|
||||
}
|
||||
break;
|
||||
|
||||
// ── DELETE ───────────────────────────────────────────────────────────────
|
||||
case 'DELETE':
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
if (!$id) {
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$id = (int)($input['id'] ?? 0);
|
||||
}
|
||||
if ($conn->query("DELETE FROM rumah_ibadah WHERE id=$id")) {
|
||||
echo json_encode(['success' => true, 'message' => 'Data berhasil dihapus']);
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => 'Gagal menghapus: ' . $conn->error]);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(405);
|
||||
echo json_encode(['error' => 'Method not allowed']);
|
||||
}
|
||||
$conn->close();
|
||||
@@ -0,0 +1,511 @@
|
||||
<?php
|
||||
// api/rumah_warga.php
|
||||
header('Content-Type: application/json');
|
||||
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') { http_response_code(204); exit; }
|
||||
|
||||
require_once '../config/db.php';
|
||||
require_once 'middleware.php';
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// FUNGSI: Hitung rekomendasi bantuan otomatis berdasarkan data anggota
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
function hitungRekomendasi(mysqli $conn, int $wargaId, float $penghasilan, string $statusPekerjaan): string
|
||||
{
|
||||
$today = new DateTime();
|
||||
|
||||
// Ambil semua anggota keluarga
|
||||
$res = $conn->query("SELECT tanggal_lahir, status_bekerja, dapat_bekerja, kondisi_kesehatan FROM anggota_keluarga WHERE rumah_warga_id = $wargaId");
|
||||
$anggota = [];
|
||||
while ($r = $res->fetch_assoc()) $anggota[] = $r;
|
||||
|
||||
$adaProduktif = false; // usia 18-55 th
|
||||
$adaProduktifBisaKerja = false; // produktif & bisa kerja & belum bekerja tetap
|
||||
$semuaProduktifTakBisa = true; // semua yg produktif tidak bisa kerja
|
||||
|
||||
foreach ($anggota as $a) {
|
||||
$usia = null;
|
||||
if ($a['tanggal_lahir']) {
|
||||
$lahir = new DateTime($a['tanggal_lahir']);
|
||||
$usia = (int)$today->diff($lahir)->y;
|
||||
}
|
||||
|
||||
if ($usia !== null && $usia >= 18 && $usia <= 55) {
|
||||
$adaProduktif = true;
|
||||
$bisaKerja = (int)$a['dapat_bekerja'] === 1
|
||||
&& in_array($a['kondisi_kesehatan'], ['sehat', 'sakit_ringan']);
|
||||
|
||||
if ($bisaKerja) $semuaProduktifTakBisa = false;
|
||||
|
||||
// Produktif, bisa kerja, belum bekerja tetap → kandidat pelatihan
|
||||
if ($bisaKerja && $a['status_bekerja'] !== 'bekerja') {
|
||||
$adaProduktifBisaKerja = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Kriteria bantuan rutin
|
||||
$perluRutin = !$adaProduktif
|
||||
|| ($adaProduktif && $semuaProduktifTakBisa)
|
||||
|| $penghasilan < 500000
|
||||
|| $statusPekerjaan === 'tidak_bekerja';
|
||||
|
||||
// Kriteria bantuan pelatihan
|
||||
$perluPelatihan = $adaProduktifBisaKerja;
|
||||
|
||||
if ($perluRutin && $perluPelatihan) return 'keduanya';
|
||||
if ($perluRutin) return 'bantuan_rutin';
|
||||
if ($perluPelatihan) return 'bantuan_pelatihan';
|
||||
return 'tidak_ada';
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// FUNGSI: Hitung kategori kesejahteraan otomatis berdasarkan data warga
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
function hitungKategoriKesejahteraan(float $penghasilan, int $tanggungan, string $statusTempatTinggal, string $sumberAir): string
|
||||
{
|
||||
// Logika sederhana:
|
||||
// Jika penghasilan sangat rendah dan tanggungan banyak
|
||||
$pendapatanPerKapita = $tanggungan > 0 ? $penghasilan / $tanggungan : $penghasilan;
|
||||
|
||||
$skor = 0;
|
||||
|
||||
if ($pendapatanPerKapita < 300000) {
|
||||
$skor += 40;
|
||||
} elseif ($pendapatanPerKapita < 600000) {
|
||||
$skor += 20;
|
||||
}
|
||||
|
||||
if ($statusTempatTinggal === 'numpang' || $statusTempatTinggal === 'sewa') {
|
||||
$skor += 20;
|
||||
}
|
||||
|
||||
if ($sumberAir === 'sungai' || $sumberAir === 'hujan') {
|
||||
$skor += 10;
|
||||
}
|
||||
|
||||
if ($skor >= 50) return 'sangat_miskin';
|
||||
if ($skor >= 20) return 'miskin';
|
||||
return 'rentan_miskin';
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// FUNGSI: Memeriksa akses koordinator berdasarkan kelurahan/kecamatan ATAU radius geografis <= 5km
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
function checkCoordinatorAccess(mysqli $conn, int $koorIbadahId, float $lat, float $lng, string $kelurahan, string $kecamatan): bool
|
||||
{
|
||||
$wilRes = $conn->query("SELECT latitude, longitude, kelurahan, kecamatan FROM rumah_ibadah WHERE id = $koorIbadahId");
|
||||
if ($wil = $wilRes->fetch_assoc()) {
|
||||
// Cek 1: Pencocokan nama kelurahan/kecamatan (sebagai fallback tangguh)
|
||||
$kel = strtolower($wil['kelurahan']);
|
||||
$kec = strtolower($wil['kecamatan']);
|
||||
$wKel = strtolower(trim($kelurahan));
|
||||
$wKec = strtolower(trim($kecamatan));
|
||||
if (($kel !== '' && strpos($wKel, $kel) !== false) || ($kec !== '' && strpos($wKec, $kec) !== false)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Cek 2: Jarak radius geografis <= 5000m (karena batas slider maksimal 5km)
|
||||
$lat1 = (float)$wil['latitude'];
|
||||
$lng1 = (float)$wil['longitude'];
|
||||
if ($lat1 !== 0.0 && $lng1 !== 0.0 && $lat !== 0.0 && $lng !== 0.0) {
|
||||
$earthRadius = 6371000;
|
||||
$dLat = deg2rad($lat - $lat1);
|
||||
$dLng = deg2rad($lng - $lng1);
|
||||
$a = sin($dLat/2) * sin($dLat/2) + cos(deg2rad($lat1)) * cos(deg2rad($lat)) * sin($dLng/2) * sin($dLng/2);
|
||||
$c = 2 * atan2(sqrt($a), sqrt(1-$a));
|
||||
$dist = $earthRadius * $c;
|
||||
if ($dist <= 5000.0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
switch ($method) {
|
||||
// ── GET ──────────────────────────────────────────────────────────────────
|
||||
case 'GET':
|
||||
// Izinkan guest mode (opsional) untuk melihat data rumah warga
|
||||
$currentUser = optionalAuth();
|
||||
$isGuest = $currentUser === null;
|
||||
|
||||
// Jika tidak login (guest), validasi akses
|
||||
if ($isGuest) {
|
||||
$currentUser = ['role' => 'guest'];
|
||||
} else {
|
||||
// Validasi role jika sudah login
|
||||
if (!in_array($currentUser['role'], ['admin', 'petugas', 'koordinator', 'kontributor'])) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'message' => 'Forbidden: Role Anda tidak memiliki akses.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($_GET['id'])) {
|
||||
$id = (int)$_GET['id'];
|
||||
$res = $conn->query("SELECT * FROM rumah_warga WHERE id=$id");
|
||||
if ($row = $res->fetch_assoc()) {
|
||||
// Batasi Koordinator agar hanya bisa melihat detail warga di wilayah tugasnya sendiri (radius / kelurahan / kecamatan)
|
||||
if ($currentUser['role'] === 'koordinator') {
|
||||
$koorIbadahId = (int)$currentUser['rumah_ibadah_id'];
|
||||
$lat = (float)($row['latitude'] ?? 0);
|
||||
$lng = (float)($row['longitude'] ?? 0);
|
||||
$kelurahan = $row['kelurahan'] ?? '';
|
||||
$kecamatan = $row['kecamatan'] ?? '';
|
||||
if (!checkCoordinatorAccess($conn, $koorIbadahId, $lat, $lng, $kelurahan, $kecamatan)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'message' => 'Forbidden: Detail warga di luar wilayah tugas Anda.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Sertakan anggota keluarga jika bukan koordinator / kontributor / guest
|
||||
if ($currentUser['role'] === 'koordinator') {
|
||||
$row['anggota'] = [];
|
||||
unset($row['no_kk']);
|
||||
unset($row['nik']);
|
||||
unset($row['penghasilan_bulanan']);
|
||||
unset($row['pekerjaan']);
|
||||
unset($row['status_pekerjaan']);
|
||||
unset($row['luas_rumah']);
|
||||
unset($row['jenis_lantai']);
|
||||
unset($row['jenis_dinding']);
|
||||
unset($row['sumber_air']);
|
||||
unset($row['status_tempat_tinggal']);
|
||||
unset($row['keterangan']);
|
||||
unset($row['skor_kepercayaan']);
|
||||
unset($row['is_rekomendasi_manual']);
|
||||
unset($row['is_kategori_manual']);
|
||||
// KEEP: kategori_kemiskinan, jumlah_tanggungan (needed for display)
|
||||
} elseif ($currentUser['role'] === 'kontributor' || $currentUser['role'] === 'guest') {
|
||||
$row['anggota'] = [];
|
||||
unset($row['no_kk']);
|
||||
unset($row['nik']);
|
||||
unset($row['penghasilan_bulanan']);
|
||||
unset($row['pekerjaan']);
|
||||
unset($row['status_pekerjaan']);
|
||||
unset($row['luas_rumah']);
|
||||
unset($row['jenis_lantai']);
|
||||
unset($row['jenis_dinding']);
|
||||
unset($row['sumber_air']);
|
||||
unset($row['status_tempat_tinggal']);
|
||||
unset($row['keterangan']);
|
||||
unset($row['skor_kepercayaan']);
|
||||
unset($row['is_rekomendasi_manual']);
|
||||
unset($row['is_kategori_manual']);
|
||||
unset($row['created_at']);
|
||||
unset($row['updated_at']);
|
||||
// KEEP: kategori_kemiskinan, jumlah_tanggungan, nama_kepala_keluarga (needed for display)
|
||||
} else {
|
||||
$resA = $conn->query("SELECT * FROM anggota_keluarga WHERE rumah_warga_id=$id ORDER BY tanggal_lahir ASC");
|
||||
$anggota = [];
|
||||
while ($a = $resA->fetch_assoc()) $anggota[] = $a;
|
||||
$row['anggota'] = $anggota;
|
||||
}
|
||||
echo json_encode(['success' => true, 'data' => $row]);
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => 'Data tidak ditemukan']);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Filter pencarian
|
||||
$where = [];
|
||||
if (!empty($_GET['kategori'])) $where[] = "kategori_kemiskinan = '" . $conn->real_escape_string($_GET['kategori']) . "'";
|
||||
if (!empty($_GET['status'])) $where[] = "status_bantuan = '" . $conn->real_escape_string($_GET['status']) . "'";
|
||||
if (!empty($_GET['rekomendasi'])) $where[] = "rekomendasi_bantuan = '" . $conn->real_escape_string($_GET['rekomendasi']) . "'";
|
||||
if (!empty($_GET['kecamatan'])) $where[] = "kecamatan LIKE '%" . $conn->real_escape_string($_GET['kecamatan']) . "%'";
|
||||
if (!empty($_GET['kelurahan'])) $where[] = "kelurahan LIKE '%" . $conn->real_escape_string($_GET['kelurahan']) . "%'";
|
||||
if (!empty($_GET['search'])) $where[] = "(nama_kepala_keluarga LIKE '%" . $conn->real_escape_string($_GET['search']) . "%' OR no_kk LIKE '%" . $conn->real_escape_string($_GET['search']) . "%' OR nik LIKE '%" . $conn->real_escape_string($_GET['search']) . "%')";
|
||||
|
||||
// Filter wilayah untuk koordinator tidak lagi dibatasi per kelurahan/kecamatan di SQL,
|
||||
// melainkan akan difilter secara dinamis berdasarkan radius geografis di frontend.
|
||||
|
||||
$sql = "SELECT * FROM rumah_warga" . ($where ? " WHERE " . implode(' AND ', $where) : "") . " ORDER BY nama_kepala_keluarga ASC";
|
||||
$result = $conn->query($sql);
|
||||
if ($result === false) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'message' => 'Gagal mengambil data dari tabel "rumah_warga": ' . $conn->error
|
||||
]);
|
||||
break;
|
||||
}
|
||||
$data = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
if ($currentUser['role'] === 'koordinator') {
|
||||
unset($row['no_kk']);
|
||||
unset($row['nik']);
|
||||
unset($row['penghasilan_bulanan']);
|
||||
unset($row['pekerjaan']);
|
||||
unset($row['status_pekerjaan']);
|
||||
unset($row['luas_rumah']);
|
||||
unset($row['jenis_lantai']);
|
||||
unset($row['jenis_dinding']);
|
||||
unset($row['sumber_air']);
|
||||
unset($row['status_tempat_tinggal']);
|
||||
unset($row['keterangan']);
|
||||
unset($row['skor_kepercayaan']);
|
||||
unset($row['is_rekomendasi_manual']);
|
||||
// KEEP: kategori_kemiskinan, jumlah_tanggungan (needed for frontend display)
|
||||
} elseif ($currentUser['role'] === 'kontributor' || $currentUser['role'] === 'guest') {
|
||||
unset($row['no_kk']);
|
||||
unset($row['nik']);
|
||||
unset($row['penghasilan_bulanan']);
|
||||
unset($row['pekerjaan']);
|
||||
unset($row['status_pekerjaan']);
|
||||
unset($row['luas_rumah']);
|
||||
unset($row['jenis_lantai']);
|
||||
unset($row['jenis_dinding']);
|
||||
unset($row['sumber_air']);
|
||||
unset($row['status_tempat_tinggal']);
|
||||
unset($row['keterangan']);
|
||||
unset($row['skor_kepercayaan']);
|
||||
unset($row['is_rekomendasi_manual']);
|
||||
unset($row['created_at']);
|
||||
unset($row['updated_at']);
|
||||
// KEEP: kategori_kemiskinan, jumlah_tanggungan, nama_kepala_keluarga (needed for frontend display)
|
||||
}
|
||||
$data[] = $row;
|
||||
}
|
||||
echo json_encode(['success' => true, 'data' => $data, 'total' => count($data)]);
|
||||
break;
|
||||
|
||||
// ── POST ─────────────────────────────────────────────────────────────────
|
||||
case 'POST':
|
||||
// Membatasi akses: Hanya Admin dan Petugas Lapangan yang bisa menginput
|
||||
requireAuth(['admin', 'petugas']);
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
|
||||
$no_kk = $conn->real_escape_string(trim($input['no_kk'] ?? ''));
|
||||
$nama = $conn->real_escape_string(trim($input['nama_kepala_keluarga'] ?? ''));
|
||||
$nik = $conn->real_escape_string($input['nik'] ?? '');
|
||||
$alamat = $conn->real_escape_string($input['alamat'] ?? '');
|
||||
$kelurahan = $conn->real_escape_string($input['kelurahan'] ?? '');
|
||||
$kecamatan = $conn->real_escape_string($input['kecamatan'] ?? '');
|
||||
$kota = $conn->real_escape_string($input['kota'] ?? '');
|
||||
$lat = (float)($input['latitude'] ?? 0);
|
||||
$lng = (float)($input['longitude'] ?? 0);
|
||||
$tanggungan = (int)($input['jumlah_tanggungan'] ?? 1);
|
||||
$penghasilan= (float)($input['penghasilan_bulanan'] ?? 0);
|
||||
$pekerjaan = $conn->real_escape_string($input['pekerjaan'] ?? '');
|
||||
$stsPekerjaan = $conn->real_escape_string($input['status_pekerjaan'] ?? 'tidak_bekerja');
|
||||
$luas = !empty($input['luas_rumah']) ? (float)$input['luas_rumah'] : 'NULL';
|
||||
$lantai = $conn->real_escape_string($input['jenis_lantai'] ?? 'semen');
|
||||
$dinding = $conn->real_escape_string($input['jenis_dinding'] ?? 'tembok');
|
||||
$air = $conn->real_escape_string($input['sumber_air'] ?? 'sumur');
|
||||
$tinggal = $conn->real_escape_string($input['status_tempat_tinggal'] ?? 'milik_sendiri');
|
||||
$kategoriInput = $conn->real_escape_string($input['kategori_kemiskinan'] ?? 'miskin');
|
||||
$isKatManual = ($kategoriInput !== 'sistem') ? 1 : 0;
|
||||
$kategori = ($isKatManual === 1) ? $kategoriInput : hitungKategoriKesejahteraan($penghasilan, $tanggungan, $tinggal, $air);
|
||||
|
||||
$stsbansos = $conn->real_escape_string($input['status_bantuan'] ?? 'belum_terima');
|
||||
$ket = $conn->real_escape_string($input['keterangan'] ?? '');
|
||||
$dikelola = !empty($input['dikelola_oleh_ibadah_id']) ? (int)$input['dikelola_oleh_ibadah_id'] : 'NULL';
|
||||
|
||||
// Validasi
|
||||
if (!$nama || !$lat || !$lng) {
|
||||
echo json_encode(['success' => false, 'message' => 'Nama KK, latitude, dan longitude wajib diisi']); break;
|
||||
}
|
||||
if (!$no_kk || strlen(preg_replace('/\D/', '', $no_kk)) !== 16) {
|
||||
echo json_encode(['success' => false, 'message' => 'Nomor KK harus 16 digit angka']); break;
|
||||
}
|
||||
// Cek duplikat KK
|
||||
$cek = $conn->query("SELECT id FROM rumah_warga WHERE no_kk='$no_kk'");
|
||||
if ($cek->num_rows > 0) {
|
||||
echo json_encode(['success' => false, 'message' => 'Nomor KK sudah terdaftar']); break;
|
||||
}
|
||||
|
||||
$luasVal = is_numeric($luas) ? $luas : 'NULL';
|
||||
$rekInput = $conn->real_escape_string($input['rekomendasi_bantuan'] ?? 'sistem');
|
||||
$isManual = ($rekInput !== 'sistem') ? 1 : 0;
|
||||
|
||||
$sql = "INSERT INTO rumah_warga
|
||||
(no_kk,nama_kepala_keluarga,nik,alamat,kelurahan,kecamatan,kota,latitude,longitude,
|
||||
jumlah_tanggungan,penghasilan_bulanan,pekerjaan,status_pekerjaan,
|
||||
luas_rumah,jenis_lantai,jenis_dinding,sumber_air,status_tempat_tinggal,
|
||||
kategori_kemiskinan,is_kategori_manual,status_bantuan,rekomendasi_bantuan,is_rekomendasi_manual,keterangan,
|
||||
dikelola_oleh_ibadah_id, tanggal_verifikasi, skor_kepercayaan, status_verifikasi)
|
||||
VALUES
|
||||
('$no_kk','$nama','$nik','$alamat','$kelurahan','$kecamatan','$kota',$lat,$lng,
|
||||
$tanggungan,$penghasilan,'$pekerjaan','$stsPekerjaan',
|
||||
$luasVal,'$lantai','$dinding','$air','$tinggal',
|
||||
'$kategori',$isKatManual,'$stsbansos','bantuan_rutin',$isManual,'$ket',
|
||||
$dikelola, CURRENT_DATE(), 100, 'terverifikasi')";
|
||||
|
||||
if ($conn->query($sql)) {
|
||||
$newId = $conn->insert_id;
|
||||
|
||||
// Simpan anggota jika ada
|
||||
if (!empty($input['anggota']) && is_array($input['anggota'])) {
|
||||
simpanAnggota($conn, $newId, $input['anggota']);
|
||||
}
|
||||
|
||||
// Hitung rekomendasi otomatis
|
||||
if ($isManual === 1) {
|
||||
$rek = $rekInput;
|
||||
} else {
|
||||
$rek = hitungRekomendasi($conn, $newId, $penghasilan, $stsPekerjaan);
|
||||
}
|
||||
$conn->query("UPDATE rumah_warga SET rekomendasi_bantuan='$rek' WHERE id=$newId");
|
||||
|
||||
echo json_encode(['success' => true, 'message' => 'Data berhasil disimpan', 'id' => $newId, 'rekomendasi' => $rek]);
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => 'Gagal menyimpan: ' . $conn->error]);
|
||||
}
|
||||
break;
|
||||
|
||||
// ── PUT ──────────────────────────────────────────────────────────────────
|
||||
case 'PUT':
|
||||
// Membatasi akses: Admin, Petugas Lapangan, dan Koordinator Rumah Ibadah
|
||||
$currentUser = requireAuth(['admin', 'petugas', 'koordinator']);
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$id = (int)($input['id'] ?? 0);
|
||||
if (!$id) { echo json_encode(['success' => false, 'message' => 'ID tidak valid']); break; }
|
||||
|
||||
if ($currentUser['role'] === 'koordinator') {
|
||||
$stsbansos = $conn->real_escape_string($input['status_bantuan'] ?? 'belum_terima');
|
||||
|
||||
// Cek apakah warga berada di wilayah tugas koordinator (radius / kelurahan / kecamatan)
|
||||
$koorIbadahId = (int)$currentUser['rumah_ibadah_id'];
|
||||
$wargaRes = $conn->query("SELECT latitude, longitude, kelurahan, kecamatan FROM rumah_warga WHERE id = $id");
|
||||
if ($wargaRow = $wargaRes->fetch_assoc()) {
|
||||
$lat = (float)($wargaRow['latitude'] ?? 0);
|
||||
$lng = (float)($wargaRow['longitude'] ?? 0);
|
||||
$kelurahan = $wargaRow['kelurahan'] ?? '';
|
||||
$kecamatan = $wargaRow['kecamatan'] ?? '';
|
||||
if (!checkCoordinatorAccess($conn, $koorIbadahId, $lat, $lng, $kelurahan, $kecamatan)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'message' => 'Forbidden: Warga di luar wilayah tugas Anda.']);
|
||||
exit;
|
||||
}
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => 'Warga tidak ditemukan.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$sql = "UPDATE rumah_warga SET status_bantuan='$stsbansos' WHERE id=$id";
|
||||
if ($conn->query($sql)) {
|
||||
echo json_encode(['success' => true, 'message' => 'Status bantuan warga berhasil diperbarui']);
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => 'Gagal memperbarui status bantuan: ' . $conn->error]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$no_kk = $conn->real_escape_string(trim($input['no_kk'] ?? ''));
|
||||
$nama = $conn->real_escape_string(trim($input['nama_kepala_keluarga'] ?? ''));
|
||||
$nik = $conn->real_escape_string($input['nik'] ?? '');
|
||||
$alamat = $conn->real_escape_string($input['alamat'] ?? '');
|
||||
$kelurahan = $conn->real_escape_string($input['kelurahan'] ?? '');
|
||||
$kecamatan = $conn->real_escape_string($input['kecamatan'] ?? '');
|
||||
$kota = $conn->real_escape_string($input['kota'] ?? '');
|
||||
$lat = (float)($input['latitude'] ?? 0);
|
||||
$lng = (float)($input['longitude'] ?? 0);
|
||||
$tanggungan = (int)($input['jumlah_tanggungan'] ?? 1);
|
||||
$penghasilan= (float)($input['penghasilan_bulanan'] ?? 0);
|
||||
$pekerjaan = $conn->real_escape_string($input['pekerjaan'] ?? '');
|
||||
$stsPekerjaan = $conn->real_escape_string($input['status_pekerjaan'] ?? 'tidak_bekerja');
|
||||
$luasVal = !empty($input['luas_rumah']) ? (float)$input['luas_rumah'] : 'NULL';
|
||||
$lantai = $conn->real_escape_string($input['jenis_lantai'] ?? 'semen');
|
||||
$dinding = $conn->real_escape_string($input['jenis_dinding'] ?? 'tembok');
|
||||
$air = $conn->real_escape_string($input['sumber_air'] ?? 'sumur');
|
||||
$tinggal = $conn->real_escape_string($input['status_tempat_tinggal'] ?? 'milik_sendiri');
|
||||
$kategoriInput = $conn->real_escape_string($input['kategori_kemiskinan'] ?? 'miskin');
|
||||
$isKatManual = ($kategoriInput !== 'sistem') ? 1 : 0;
|
||||
$kategori = ($isKatManual === 1) ? $kategoriInput : hitungKategoriKesejahteraan($penghasilan, $tanggungan, $tinggal, $air);
|
||||
|
||||
$stsbansos = $conn->real_escape_string($input['status_bantuan'] ?? 'belum_terima');
|
||||
$rekInput = $conn->real_escape_string($input['rekomendasi_bantuan'] ?? 'sistem');
|
||||
$isManual = ($rekInput !== 'sistem') ? 1 : 0;
|
||||
$ket = $conn->real_escape_string($input['keterangan'] ?? '');
|
||||
$dikelola = isset($input['dikelola_oleh_ibadah_id']) && $input['dikelola_oleh_ibadah_id'] !== '' ? (int)$input['dikelola_oleh_ibadah_id'] : 'NULL';
|
||||
|
||||
// Cek duplikat KK (kecuali diri sendiri)
|
||||
$cek = $conn->query("SELECT id FROM rumah_warga WHERE no_kk='$no_kk' AND id != $id");
|
||||
if ($cek->num_rows > 0) {
|
||||
echo json_encode(['success' => false, 'message' => 'Nomor KK sudah digunakan data lain']); break;
|
||||
}
|
||||
|
||||
// Update anggota jika ada
|
||||
if (!empty($input['anggota']) && is_array($input['anggota'])) {
|
||||
$conn->query("DELETE FROM anggota_keluarga WHERE rumah_warga_id=$id");
|
||||
simpanAnggota($conn, $id, $input['anggota']);
|
||||
}
|
||||
|
||||
// Hitung rekomendasi otomatis
|
||||
if ($isManual === 1) {
|
||||
$rek = $rekInput;
|
||||
} else {
|
||||
$rek = hitungRekomendasi($conn, $id, $penghasilan, $stsPekerjaan);
|
||||
}
|
||||
|
||||
$sql = "UPDATE rumah_warga SET
|
||||
no_kk='$no_kk', nama_kepala_keluarga='$nama', nik='$nik',
|
||||
alamat='$alamat', kelurahan='$kelurahan', kecamatan='$kecamatan', kota='$kota',
|
||||
latitude=$lat, longitude=$lng,
|
||||
jumlah_tanggungan=$tanggungan, penghasilan_bulanan=$penghasilan,
|
||||
pekerjaan='$pekerjaan', status_pekerjaan='$stsPekerjaan',
|
||||
luas_rumah=$luasVal, jenis_lantai='$lantai', jenis_dinding='$dinding',
|
||||
sumber_air='$air', status_tempat_tinggal='$tinggal',
|
||||
kategori_kemiskinan='$kategori', is_kategori_manual=$isKatManual, status_bantuan='$stsbansos',
|
||||
rekomendasi_bantuan='$rek', is_rekomendasi_manual=$isManual, keterangan='$ket',
|
||||
dikelola_oleh_ibadah_id=$dikelola
|
||||
WHERE id=$id";
|
||||
|
||||
if ($conn->query($sql)) {
|
||||
echo json_encode(['success' => true, 'message' => 'Data berhasil diperbarui', 'rekomendasi' => $rek]);
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => 'Gagal memperbarui: ' . $conn->error]);
|
||||
}
|
||||
break;
|
||||
|
||||
// ── DELETE ───────────────────────────────────────────────────────────────
|
||||
case 'DELETE':
|
||||
// Membatasi akses: HANYA Admin yang boleh menghapus data warga
|
||||
requireAuth(['admin']);
|
||||
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
if (!$id) {
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$id = (int)($input['id'] ?? 0);
|
||||
}
|
||||
if ($conn->query("DELETE FROM rumah_warga WHERE id=$id")) {
|
||||
echo json_encode(['success' => true, 'message' => 'Data berhasil dihapus']);
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => 'Gagal menghapus: ' . $conn->error]);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(405);
|
||||
echo json_encode(['error' => 'Method not allowed']);
|
||||
}
|
||||
$conn->close();
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
function simpanAnggota(mysqli $conn, int $wargaId, array $list): void
|
||||
{
|
||||
foreach ($list as $a) {
|
||||
$nama = $conn->real_escape_string(trim($a['nama'] ?? ''));
|
||||
if (!$nama) continue;
|
||||
$nik = $conn->real_escape_string($a['nik'] ?? '');
|
||||
$tl = !empty($a['tanggal_lahir']) ? "'" . $conn->real_escape_string($a['tanggal_lahir']) . "'" : 'NULL';
|
||||
$jk = $conn->real_escape_string($a['jenis_kelamin'] ?? 'laki_laki');
|
||||
$pend = $conn->real_escape_string($a['pendidikan'] ?? 'sd');
|
||||
$pek = $conn->real_escape_string($a['pekerjaan'] ?? '');
|
||||
$stsBk = $conn->real_escape_string($a['status_bekerja'] ?? 'tidak_bekerja');
|
||||
$kes = $conn->real_escape_string($a['kondisi_kesehatan'] ?? 'sehat');
|
||||
$bisa = (int)($a['dapat_bekerja'] ?? 1);
|
||||
|
||||
$conn->query("INSERT INTO anggota_keluarga (rumah_warga_id,nama,nik,tanggal_lahir,jenis_kelamin,pendidikan,pekerjaan,status_bekerja,kondisi_kesehatan,dapat_bekerja)
|
||||
VALUES ($wargaId,'$nama','$nik',$tl,'$jk','$pend','$pek','$stsBk','$kes',$bisa)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
// api/statistik.php
|
||||
header('Content-Type: application/json');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
|
||||
require_once '../config/db.php';
|
||||
|
||||
$stats = [];
|
||||
|
||||
// Total rumah ibadah per jenis
|
||||
$result = $conn->query("SELECT jenis, COUNT(*) as total FROM rumah_ibadah GROUP BY jenis");
|
||||
if ($result === false) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'message' => 'Gagal memuat statistik. Tabel "rumah_ibadah" tidak ditemukan. Pastikan Anda telah mengimpor berkas database "database/bansos_gis.sql" di phpMyAdmin Anda!'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
$ibadah = [];
|
||||
while ($row = $result->fetch_assoc()) $ibadah[$row['jenis']] = (int)$row['total'];
|
||||
$stats['rumah_ibadah'] = $ibadah;
|
||||
$stats['total_ibadah'] = array_sum($ibadah);
|
||||
|
||||
// Total rumah warga per kategori
|
||||
$result = $conn->query("SELECT kategori_kemiskinan, COUNT(*) as total FROM rumah_warga GROUP BY kategori_kemiskinan");
|
||||
$warga = [];
|
||||
while ($row = $result->fetch_assoc()) $warga[$row['kategori_kemiskinan']] = (int)$row['total'];
|
||||
$stats['kategori_kemiskinan'] = $warga;
|
||||
$stats['total_warga'] = array_sum($warga);
|
||||
|
||||
// Status bantuan
|
||||
$result = $conn->query("SELECT status_bantuan, COUNT(*) as total FROM rumah_warga GROUP BY status_bantuan");
|
||||
$bansos = [];
|
||||
while ($row = $result->fetch_assoc()) $bansos[$row['status_bantuan']] = (int)$row['total'];
|
||||
$stats['status_bansos'] = $bansos;
|
||||
|
||||
// Rekomendasi bantuan
|
||||
$result = $conn->query("SELECT rekomendasi_bantuan, COUNT(*) as total FROM rumah_warga GROUP BY rekomendasi_bantuan");
|
||||
$rek = [];
|
||||
while ($row = $result->fetch_assoc()) $rek[$row['rekomendasi_bantuan']] = (int)$row['total'];
|
||||
$stats['rekomendasi_bantuan'] = $rek;
|
||||
|
||||
// Total jiwa
|
||||
$result = $conn->query("SELECT SUM(jumlah_tanggungan) as total FROM rumah_warga");
|
||||
$row = $result->fetch_assoc();
|
||||
$stats['total_jiwa'] = (int)$row['total'];
|
||||
|
||||
// Total anggota di DB
|
||||
$result = $conn->query("SELECT COUNT(*) as total FROM anggota_keluarga");
|
||||
$row = $result->fetch_assoc();
|
||||
$stats['total_anggota_db'] = (int)$row['total'];
|
||||
|
||||
// Rata-rata penghasilan
|
||||
$result = $conn->query("SELECT AVG(penghasilan_bulanan) as avg_ph FROM rumah_warga");
|
||||
$row = $result->fetch_assoc();
|
||||
$stats['rata_penghasilan'] = round((float)$row['avg_ph'], 0);
|
||||
|
||||
echo json_encode(['success' => true, 'data' => $stats]);
|
||||
$conn->close();
|
||||
@@ -0,0 +1,316 @@
|
||||
<?php
|
||||
// api/verifikasi.php
|
||||
// Fitur 2: Keterbaruan Data & Verifikasi Berkala
|
||||
// Method: GET (list warga perlu update), POST (catat verifikasi), GET?action=config, PUT (update config)
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
|
||||
|
||||
require_once '../config/db.php';
|
||||
require_once 'middleware.php';
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
HELPER
|
||||
───────────────────────────────────────────── */
|
||||
function getKonfig(PDO $pdo, string $kunci, $default = null) {
|
||||
$stmt = $pdo->prepare("SELECT nilai, tipe FROM konfigurasi_sistem WHERE kunci = ?");
|
||||
$stmt->execute([$kunci]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) return $default;
|
||||
return match($row['tipe']) {
|
||||
'integer' => (int)$row['nilai'],
|
||||
'decimal' => (float)$row['nilai'],
|
||||
'boolean' => filter_var($row['nilai'], FILTER_VALIDATE_BOOLEAN),
|
||||
default => $row['nilai'],
|
||||
};
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
CORE: Hitung skor kepercayaan data
|
||||
Formula: mulai 100, kurangi seiring waktu
|
||||
Decay rate: linear -100/interval_bulan per bulan
|
||||
───────────────────────────────────────────── */
|
||||
function hitungSkorKepercayaan(
|
||||
?string $tanggalVerifikasi,
|
||||
int $intervalBulan,
|
||||
int $minSkor = 40
|
||||
): array {
|
||||
if (!$tanggalVerifikasi) {
|
||||
return ['skor' => 0, 'hari_sejak' => null, 'status' => 'kadaluarsa'];
|
||||
}
|
||||
|
||||
$tglVerif = new DateTime($tanggalVerifikasi);
|
||||
$today = new DateTime();
|
||||
|
||||
// Jika tanggal verifikasi di masa depan (karena beda timezone / lag server), anggap 0 hari
|
||||
if ($tglVerif > $today) {
|
||||
$hariSejak = 0;
|
||||
} else {
|
||||
$diff = $today->diff($tglVerif);
|
||||
$hariSejak = $diff->days;
|
||||
}
|
||||
$intervalHari = $intervalBulan * 30;
|
||||
|
||||
// Skor linear: 100 di hari 0, turun ke 0 di hari intervalHari
|
||||
$skor = max(0, (int)round(100 - ($hariSejak / $intervalHari * 100)));
|
||||
|
||||
$status = match(true) {
|
||||
$skor >= 80 => 'terverifikasi',
|
||||
$skor >= $minSkor => 'perlu_update',
|
||||
default => 'kadaluarsa',
|
||||
};
|
||||
|
||||
// Berapa hari lagi sampai perlu_update dan kadaluarsa
|
||||
$hariMenujuPerluUpdate = max(0, (int)($intervalHari * 0.2) - $hariSejak);
|
||||
$hariMenujuKadaluarsa = max(0, (int)($intervalHari * ((100 - $minSkor) / 100)) - $hariSejak);
|
||||
|
||||
return [
|
||||
'skor' => $skor,
|
||||
'hari_sejak_verifikasi' => $hariSejak,
|
||||
'interval_hari' => $intervalHari,
|
||||
'status' => $status,
|
||||
'hari_menuju_perlu_update' => $hariMenujuPerluUpdate,
|
||||
'hari_menuju_kadaluarsa' => $hariMenujuKadaluarsa,
|
||||
'tanggal_kadaluarsa' => (clone $tglVerif)->modify("+{$intervalHari} days")->format('Y-m-d'),
|
||||
];
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
UPDATE skor semua warga secara batch
|
||||
Formula: mulai 100, kurangi seiring waktu
|
||||
───────────────────────────────────────────── */
|
||||
function updateSkopBatch(PDO $pdo): int {
|
||||
$interval = getKonfig($pdo, 'interval_verifikasi_bulan', 6);
|
||||
$minSkor = getKonfig($pdo, 'skor_data_kadaluarsa', 40);
|
||||
|
||||
$wargaList = $pdo->query("SELECT id, tanggal_verifikasi FROM rumah_warga")->fetchAll(PDO::FETCH_ASSOC);
|
||||
$updated = 0;
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE rumah_warga SET skor_kepercayaan = ?, status_verifikasi = ? WHERE id = ?
|
||||
");
|
||||
|
||||
foreach ($wargaList as $w) {
|
||||
$calc = hitungSkorKepercayaan($w['tanggal_verifikasi'], $interval, $minSkor);
|
||||
$stmt->execute([$calc['skor'], $calc['status'], (int)$w['id']]);
|
||||
$updated++;
|
||||
}
|
||||
|
||||
return $updated;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
ROUTING
|
||||
───────────────────────────────────────────── */
|
||||
try {
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$action = $_GET['action'] ?? 'list';
|
||||
|
||||
if ($method === 'GET') {
|
||||
// Hanya Admin dan Petugas Lapangan yang boleh melihat data verifikasi
|
||||
requireAuth(['admin', 'petugas']);
|
||||
|
||||
if ($action === 'list') {
|
||||
// Daftar warga yang perlu diverifikasi
|
||||
$statusFilter = $_GET['status'] ?? null; // terverifikasi|perlu_update|kadaluarsa
|
||||
$limit = (int)($_GET['limit'] ?? 50);
|
||||
$offset = (int)($_GET['offset'] ?? 0);
|
||||
|
||||
// Update skor dulu secara batch
|
||||
updateSkopBatch($pdo);
|
||||
|
||||
if ($statusFilter === 'semua') {
|
||||
$where = "";
|
||||
} elseif ($statusFilter === 'terverifikasi' || $statusFilter === 'perlu_update' || $statusFilter === 'kadaluarsa') {
|
||||
$where = "WHERE rw.status_verifikasi = " . $pdo->quote($statusFilter);
|
||||
} else {
|
||||
// Default: Hanya warga yang perlu verifikasi (perlu_update atau kadaluarsa)
|
||||
$where = "WHERE rw.status_verifikasi IN ('perlu_update', 'kadaluarsa')";
|
||||
}
|
||||
$sql = "
|
||||
SELECT rw.id, rw.nama_kepala_keluarga, rw.no_kk,
|
||||
rw.kelurahan, rw.kecamatan,
|
||||
rw.tanggal_verifikasi, rw.petugas_verifikasi,
|
||||
rw.skor_kepercayaan, rw.status_verifikasi,
|
||||
rw.kategori_kemiskinan, rw.status_bantuan,
|
||||
rw.latitude, rw.longitude
|
||||
FROM rumah_warga rw
|
||||
$where
|
||||
ORDER BY rw.skor_kepercayaan ASC, rw.tanggal_verifikasi ASC
|
||||
LIMIT $limit OFFSET $offset
|
||||
";
|
||||
$rows = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Enrichment: hitung detail decay per row
|
||||
$interval = getKonfig($pdo, 'interval_verifikasi_bulan', 6);
|
||||
$minSkor = getKonfig($pdo, 'skor_data_kadaluarsa', 40);
|
||||
foreach ($rows as &$r) {
|
||||
$r['detail_skor'] = hitungSkorKepercayaan(
|
||||
$r['tanggal_verifikasi'], $interval, $minSkor
|
||||
);
|
||||
}
|
||||
|
||||
// Summary count
|
||||
$summary = $pdo->query("
|
||||
SELECT status_verifikasi, COUNT(*) as jumlah
|
||||
FROM rumah_warga
|
||||
GROUP BY status_verifikasi
|
||||
")->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'data' => $rows,
|
||||
'summary' => $summary,
|
||||
'total' => count($rows),
|
||||
]);
|
||||
|
||||
} elseif ($action === 'config') {
|
||||
// Ambil semua konfigurasi
|
||||
$rows = $pdo->query("SELECT * FROM konfigurasi_sistem ORDER BY id")->fetchAll(PDO::FETCH_ASSOC);
|
||||
echo json_encode(['success' => true, 'data' => $rows]);
|
||||
|
||||
} elseif ($action === 'riwayat') {
|
||||
// Riwayat verifikasi untuk 1 warga
|
||||
$wargaId = (int)($_GET['warga_id'] ?? 0);
|
||||
$rows = $pdo->prepare("
|
||||
SELECT * FROM riwayat_verifikasi
|
||||
WHERE rumah_warga_id = ?
|
||||
ORDER BY tanggal DESC LIMIT 20
|
||||
");
|
||||
$rows->execute([$wargaId]);
|
||||
echo json_encode(['success' => true, 'data' => $rows->fetchAll(PDO::FETCH_ASSOC)]);
|
||||
|
||||
} elseif ($action === 'statistik') {
|
||||
updateSkopBatch($pdo);
|
||||
$data = [
|
||||
'per_status' => $pdo->query("
|
||||
SELECT status_verifikasi, COUNT(*) as jumlah,
|
||||
ROUND(AVG(skor_kepercayaan)) as avg_skor
|
||||
FROM rumah_warga GROUP BY status_verifikasi
|
||||
")->fetchAll(PDO::FETCH_ASSOC),
|
||||
'avg_skor_global' => (float)$pdo->query("SELECT ROUND(AVG(skor_kepercayaan),1) FROM rumah_warga")->fetchColumn(),
|
||||
'paling_lama' => $pdo->query("
|
||||
SELECT nama_kepala_keluarga, tanggal_verifikasi, skor_kepercayaan
|
||||
FROM rumah_warga ORDER BY skor_kepercayaan ASC LIMIT 5
|
||||
")->fetchAll(PDO::FETCH_ASSOC),
|
||||
];
|
||||
echo json_encode(['success' => true, 'data' => $data]);
|
||||
}
|
||||
|
||||
} elseif ($method === 'POST') {
|
||||
// Catat verifikasi baru untuk 1 warga
|
||||
// Hanya Admin, Petugas Lapangan, dan Koordinator
|
||||
$currentUser = requireAuth(['admin', 'petugas', 'koordinator']);
|
||||
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
$wargaId = (int)($body['warga_id'] ?? 0);
|
||||
$petugas = trim($body['petugas'] ?? '');
|
||||
$catatan = $body['catatan'] ?? null;
|
||||
$tanggal = $body['tanggal'] ?? date('Y-m-d');
|
||||
|
||||
if (!$wargaId) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => 'warga_id wajib diisi']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Jika koordinator, lakukan pengecekan wilayah
|
||||
if ($currentUser['role'] === 'koordinator') {
|
||||
$koorIbadahId = (int)$currentUser['rumah_ibadah_id'];
|
||||
$wilayahRes = $pdo->prepare("SELECT kelurahan, kecamatan FROM rumah_ibadah WHERE id = ?");
|
||||
$wilayahRes->execute([$koorIbadahId]);
|
||||
if ($wil = $wilayahRes->fetch(PDO::FETCH_ASSOC)) {
|
||||
$kel = $wil['kelurahan'];
|
||||
$kec = $wil['kecamatan'];
|
||||
|
||||
$wargaRes = $pdo->prepare("SELECT kelurahan, kecamatan FROM rumah_warga WHERE id = ?");
|
||||
$wargaRes->execute([$wargaId]);
|
||||
if ($wargaRow = $wargaRes->fetch(PDO::FETCH_ASSOC)) {
|
||||
$wKel = strtolower(trim($wargaRow['kelurahan'] ?? ''));
|
||||
$wKec = strtolower(trim($wargaRow['kecamatan'] ?? ''));
|
||||
if (strpos($wKel, strtolower($kel)) === false && strpos($wKec, strtolower($kec)) === false) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'message' => 'Forbidden: Warga di luar wilayah tugas Anda.']);
|
||||
exit;
|
||||
}
|
||||
} else {
|
||||
http_response_code(404);
|
||||
echo json_encode(['success' => false, 'message' => 'Warga tidak ditemukan.']);
|
||||
exit;
|
||||
}
|
||||
} else {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'message' => 'Forbidden: Wilayah tugas tidak terkonfigurasi.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Ambil skor lama
|
||||
$oldRow = $pdo->prepare("SELECT skor_kepercayaan, status_verifikasi FROM rumah_warga WHERE id = ?");
|
||||
$oldRow->execute([$wargaId]);
|
||||
$old = $oldRow->fetch(PDO::FETCH_ASSOC);
|
||||
$skorLama = $old ? (int)$old['skor_kepercayaan'] : null;
|
||||
|
||||
// Reset skor ke 100 & update tanggal verifikasi
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE rumah_warga
|
||||
SET tanggal_verifikasi = ?,
|
||||
petugas_verifikasi = ?,
|
||||
skor_kepercayaan = 100,
|
||||
status_verifikasi = 'terverifikasi',
|
||||
updated_at = NOW()
|
||||
WHERE id = ?
|
||||
");
|
||||
$stmt->execute([$tanggal, $petugas ?: null, $wargaId]);
|
||||
|
||||
// Simpan riwayat
|
||||
$riwayat = $pdo->prepare("
|
||||
INSERT INTO riwayat_verifikasi
|
||||
(rumah_warga_id, tanggal, petugas, skor_sebelum, skor_sesudah, catatan)
|
||||
VALUES (?, ?, ?, ?, 100, ?)
|
||||
");
|
||||
$riwayat->execute([$wargaId, $tanggal, $petugas ?: null, $skorLama, $catatan]);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => 'Verifikasi berhasil dicatat. Skor kepercayaan data dikembalikan ke 100.',
|
||||
'skor_lama' => $skorLama,
|
||||
'skor_baru' => 100,
|
||||
]);
|
||||
|
||||
} elseif ($method === 'PUT') {
|
||||
// Update konfigurasi sistem
|
||||
// HANYA Admin yang boleh merubah konfigurasi sistem
|
||||
requireAuth(['admin']);
|
||||
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
if (!is_array($body)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => 'Body tidak valid']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("UPDATE konfigurasi_sistem SET nilai = ? WHERE kunci = ?");
|
||||
$updated = 0;
|
||||
foreach ($body as $kunci => $nilai) {
|
||||
$stmt->execute([(string)$nilai, $kunci]);
|
||||
$updated += $stmt->rowCount();
|
||||
}
|
||||
|
||||
// Setelah konfigurasi diupdate, recalculate semua skor
|
||||
updateSkopBatch($pdo);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => "$updated konfigurasi berhasil diperbarui. Skor kepercayaan seluruh data telah dihitung ulang.",
|
||||
]);
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||
}
|
||||
@@ -0,0 +1,943 @@
|
||||
/* ================================================================
|
||||
TAMBAHAN CSS — 3 Fitur Baru WebGIS Bansos
|
||||
Append ke bagian bawah assets/css/style.css yang sudah ada
|
||||
================================================================ */
|
||||
|
||||
/* ─── Panel Header ─── */
|
||||
.fb-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
padding: 10px 0 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* ─── Sub Tabs ─── */
|
||||
.fb-sub-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
padding: 3px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.fb-sub-tab {
|
||||
flex: 1;
|
||||
padding: 5px 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.fb-sub-tab.active {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.fb-sub-panel { display: none; }
|
||||
.fb-sub-panel.active { display: block; }
|
||||
|
||||
/* ─── States ─── */
|
||||
.fb-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 20px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
justify-content: center;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.fb-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 24px 16px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.fb-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
background: rgba(239,68,68,0.08);
|
||||
border-radius: 8px;
|
||||
color: var(--danger);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.fb-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 20px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
FITUR 1: REDISTRIBUSI
|
||||
================================================================ */
|
||||
.redistribusi-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.redist-sum-item {
|
||||
border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
padding: 6px 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.redist-sum-val {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.redist-sum-key {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── Beban Card ── */
|
||||
.redist-card {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid var(--border);
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.redist-card:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||
|
||||
.redist-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.redist-card-nama {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.redist-card-sub {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.redist-progress-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.redist-progress-bar {
|
||||
flex: 1;
|
||||
height: 5px;
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.redist-progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.redist-progress-pct {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
min-width: 32px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.redist-stat-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.redist-stat-item {
|
||||
text-align: center;
|
||||
padding: 4px 2px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.redist-stat-val {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.redist-stat-key {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── Saran Card ── */
|
||||
.saran-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.saran-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.saran-flow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.saran-ibadah {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.saran-ibadah.surplus {
|
||||
background: rgba(16,185,129,0.12);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.saran-ibadah.defisit {
|
||||
background: rgba(239,68,68,0.12);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.saran-arrow {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.saran-alasan {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.saran-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ── Riwayat Item ── */
|
||||
.riwayat-item {
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
FITUR 2: VERIFIKASI / KETERBARUAN DATA
|
||||
================================================================ */
|
||||
.verif-summary-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.verif-sum-card {
|
||||
border-radius: 8px;
|
||||
padding: 8px 6px;
|
||||
text-align: center;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.verif-sum-card.ok { background: rgba(16,185,129,0.1); border-color: rgba(16,185,129,0.3); }
|
||||
.verif-sum-card.warn { background: rgba(245,158,11,0.1); border-color: rgba(245,158,11,0.3); }
|
||||
.verif-sum-card.err { background: rgba(239,68,68,0.1); border-color: rgba(239,68,68,0.3); }
|
||||
|
||||
.verif-sum-card span {
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.verif-sum-card.ok span { color: #10b981; }
|
||||
.verif-sum-card.warn span { color: #f59e0b; }
|
||||
.verif-sum-card.err span { color: #ef4444; }
|
||||
|
||||
.verif-sum-card small {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
/* ── Verif Item ── */
|
||||
.verif-item {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.verif-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.verif-item-nama {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.verif-item-sub {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.skor-bar-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.skor-bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skor-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.skor-pct {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
min-width: 28px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.verif-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.verif-info-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(99,102,241,0.08);
|
||||
border: 1px solid rgba(99,102,241,0.2);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ── Konfigurasi ── */
|
||||
.konfig-intro {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.konfig-item {
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.konfig-item:last-child { border-bottom: none; }
|
||||
|
||||
.konfig-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.konfig-desc {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
FITUR 3: KEMANDIRIAN
|
||||
================================================================ */
|
||||
.kemandirian-siap-badge {
|
||||
margin-left: auto;
|
||||
background: rgba(16,185,129,0.12);
|
||||
color: #10b981;
|
||||
border-radius: 20px;
|
||||
padding: 2px 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.kemandirian-item {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
margin-bottom: 8px;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.kemandirian-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||
|
||||
.kemandirian-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.kemandirian-nama {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.kemandirian-sub {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* ── Gauge Grid ── */
|
||||
.kemandirian-gauges {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
justify-content: space-around;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 6px;
|
||||
padding: 8px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.kemandirian-gauge {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.gauge-val {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.gauge-label {
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.kemandirian-est {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.kemandirian-rek {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.kemandirian-rek-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
/* ── Animation ── */
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* ── Responsive fine-tuning ── */
|
||||
@media (max-width: 320px) {
|
||||
.redistribusi-summary { grid-template-columns: repeat(3, 1fr); }
|
||||
.redist-stat-row { grid-template-columns: repeat(2, 1fr); }
|
||||
.kemandirian-gauges { gap: 2px; }
|
||||
}
|
||||
|
||||
/* ─── Side by Side Modals ─── */
|
||||
@media (min-width: 1100px) {
|
||||
body.side-by-side-modals #modal-premium-verifikasi {
|
||||
justify-content: center;
|
||||
padding-right: 560px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
body.side-by-side-modals #modal-warga {
|
||||
justify-content: center;
|
||||
padding-left: 540px;
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
pointer-events: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
body.side-by-side-modals #modal-warga .modal {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
RBAC & LOGIN & DONASI FRONTEND STYLES
|
||||
================================================================ */
|
||||
.user-profile-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.user-avatar-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #6366f1, #4338ca);
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 2px 5px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
.user-avatar.avatar-guest {
|
||||
background: linear-gradient(135deg, #94a3b8, #64748b);
|
||||
box-shadow: 0 2px 5px rgba(100, 116, 139, 0.3);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.user-role-badge {
|
||||
display: inline-block;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.badge-guest { background: rgba(148, 163, 184, 0.12); color: #64748b; }
|
||||
.badge-admin { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
|
||||
.badge-petugas { background: rgba(245, 158, 11, 0.12); color: #d97706; }
|
||||
.badge-koordinator { background: rgba(99, 102, 241, 0.12); color: #6366f1; }
|
||||
.badge-kontributor { background: rgba(16, 185, 129, 0.12); color: #10b981; }
|
||||
|
||||
.btn-auth-action {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 5px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-auth-action:hover {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Kebutuhan & Donasi Cards */
|
||||
.kebutuhan-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
margin-bottom: 10px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.kebutuhan-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
border-color: var(--accent-light);
|
||||
}
|
||||
|
||||
.kebutuhan-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.kebutuhan-ibadah-nama {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.kebutuhan-bantuan-jenis {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.kebutuhan-progress-section {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.kebutuhan-progress-text {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.kebutuhan-progress-bar {
|
||||
height: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.kebutuhan-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #10b981, #34d399);
|
||||
border-radius: 3px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.kebutuhan-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.kebutuhan-sisa {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.kebutuhan-sisa.kebutuhan-penuh {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Riwayat Kontribusi Item */
|
||||
.kontribusi-item {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.kontribusi-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
PREMIUM DISTINCT LOGIN CARD REDESIGN (LIGHT CLASSIC PREMIUM)
|
||||
================================================================ */
|
||||
.login-premium-modal {
|
||||
border-radius: 16px !important;
|
||||
overflow: hidden !important;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.02), 0 0 0 1px rgba(99, 102, 241, 0.15) !important;
|
||||
border: none !important;
|
||||
background: #ffffff !important;
|
||||
animation: modalSlideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes modalSlideUp {
|
||||
from { transform: translateY(12px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.login-premium-header {
|
||||
background: #ffffff !important;
|
||||
padding: 26px 20px 18px 20px !important;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
border-bottom: 1px solid #f1f5f9 !important;
|
||||
}
|
||||
|
||||
.login-premium-header .modal-title {
|
||||
color: #1e293b !important;
|
||||
font-size: 16px !important;
|
||||
font-weight: 800 !important;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-premium-header .login-sub-title {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.login-premium-header .modal-close {
|
||||
color: #64748b !important;
|
||||
background: #f1f5f9 !important;
|
||||
border-radius: 50% !important;
|
||||
width: 26px !important;
|
||||
height: 26px !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
transition: all 0.2s !important;
|
||||
position: absolute !important;
|
||||
top: 15px !important;
|
||||
right: 15px !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.login-premium-header .modal-close:hover {
|
||||
background: #e2e8f0 !important;
|
||||
color: #0f172a !important;
|
||||
}
|
||||
|
||||
.login-premium-logo-wrap {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background: rgba(99, 102, 241, 0.08) !important;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 12px auto;
|
||||
border: 1px solid rgba(99, 102, 241, 0.15);
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.08) !important;
|
||||
}
|
||||
|
||||
.login-premium-logo-wrap svg {
|
||||
stroke: #4f46e5;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.login-premium-body {
|
||||
padding: 24px 22px !important;
|
||||
background: #ffffff !important;
|
||||
}
|
||||
|
||||
.login-premium-input-group {
|
||||
position: relative;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.login-premium-input-group label {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #475569;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 6px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.login-premium-input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.login-premium-input-wrapper svg {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
color: #94a3b8;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.login-premium-input-wrapper .form-control {
|
||||
padding-left: 38px !important;
|
||||
height: 42px !important;
|
||||
border-radius: 8px !important;
|
||||
border: 1px solid #cbd5e1 !important;
|
||||
background: #f8fafc !important;
|
||||
color: #0f172a !important;
|
||||
font-size: 12px !important;
|
||||
font-weight: 500 !important;
|
||||
transition: all 0.2s ease-in-out !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.login-premium-input-wrapper .form-control::placeholder {
|
||||
color: #94a3b8 !important;
|
||||
}
|
||||
|
||||
.login-premium-input-wrapper .form-control:focus {
|
||||
border-color: #6366f1 !important;
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15) !important;
|
||||
background: #ffffff !important;
|
||||
}
|
||||
|
||||
.login-premium-input-wrapper .form-control:focus + svg {
|
||||
color: #6366f1 !important;
|
||||
}
|
||||
|
||||
.login-premium-footer {
|
||||
padding: 0 22px 26px 22px !important;
|
||||
border-top: none !important;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
background: #ffffff !important;
|
||||
}
|
||||
|
||||
.login-premium-footer .btn {
|
||||
height: 42px !important;
|
||||
border-radius: 8px !important;
|
||||
font-size: 12px !important;
|
||||
font-weight: 700 !important;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.login-premium-footer .btn-primary {
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5) !important;
|
||||
border: none !important;
|
||||
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.15) !important;
|
||||
transition: all 0.2s;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.login-premium-footer .btn-primary:hover {
|
||||
background: linear-gradient(135deg, #7073ff, #6366f1) !important;
|
||||
box-shadow: 0 6px 16px rgba(79, 70, 229, 0.25) !important;
|
||||
transform: translateY(-1.5px);
|
||||
}
|
||||
|
||||
.login-premium-footer .btn-ghost {
|
||||
border: 1px solid #cbd5e1 !important;
|
||||
background: #ffffff !important;
|
||||
color: #475569 !important;
|
||||
}
|
||||
|
||||
.login-premium-footer .btn-ghost:hover {
|
||||
background: #f1f5f9 !important;
|
||||
border-color: #cbd5e1 !important;
|
||||
}
|
||||
|
||||
.login-hint-premium {
|
||||
margin-top: 18px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,847 @@
|
||||
/* assets/css/style.css */
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600;700&family=DM+Mono:wght@400;500&display=swap');
|
||||
|
||||
:root {
|
||||
--bg-deep: #f8fafc;
|
||||
--bg-panel: #ffffff;
|
||||
--bg-card: #ffffff;
|
||||
--bg-hover: #f1f5f9;
|
||||
--border: #e2e8f0;
|
||||
--border-light: #cbd5e1;
|
||||
--border-subtle: #f1f5f9;
|
||||
|
||||
--accent: #4f46e5;
|
||||
--accent-light: #6366f1;
|
||||
--accent-glow: rgba(99, 102, 241, 0.1);
|
||||
--accent-2: #8b5cf6;
|
||||
|
||||
--text-primary: #1e293b;
|
||||
--text-secondary:#475569;
|
||||
--text-muted: #64748b;
|
||||
--text-hint: #94a3b8;
|
||||
|
||||
--success: #10b981;
|
||||
--success-bg: #ecfdf5;
|
||||
--success-text: #065f46;
|
||||
--warning: #f59e0b;
|
||||
--warning-bg: #fffbeb;
|
||||
--warning-text: #92400e;
|
||||
--danger: #ef4444;
|
||||
--danger-bg: #fef2f2;
|
||||
--danger-text: #991b1b;
|
||||
--info: #3b82f6;
|
||||
--info-bg: #eff6ff;
|
||||
--info-text: #1e40af;
|
||||
|
||||
--icon-tint: #64748b;
|
||||
--icon-active: #4f46e5;
|
||||
|
||||
--radius-xs: 4px;
|
||||
--radius-sm: 8px;
|
||||
--radius: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 20px;
|
||||
|
||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.05);
|
||||
--shadow: 0 4px 6px -1px rgba(0,0,0,0.05), 0 2px 4px -1px rgba(0,0,0,0.03);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.08);
|
||||
|
||||
--font: 'DM Sans', sans-serif;
|
||||
--mono: 'DM Mono', monospace;
|
||||
--sidebar-w: 300px;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
font-family: var(--font);
|
||||
background: var(--bg-deep);
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
LAYOUT
|
||||
───────────────────────────────────────────── */
|
||||
#app { display: flex; height: 100vh; width: 100vw; }
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
SIDEBAR
|
||||
───────────────────────────────────────────── */
|
||||
#sidebar {
|
||||
width: var(--sidebar-w);
|
||||
min-width: var(--sidebar-w);
|
||||
background: var(--bg-panel);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 18px 16px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
|
||||
.logo { display: flex; align-items: center; gap: 10px; margin-bottom: 3px; }
|
||||
|
||||
.logo-icon {
|
||||
width: 32px; height: 32px;
|
||||
background: var(--accent);
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.logo-icon svg { width: 17px; height: 17px; }
|
||||
.logo-text { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; color: var(--text-primary); }
|
||||
.logo-sub { font-size: 10px; color: var(--text-muted); font-weight: 400; letter-spacing: 0.2px; margin-left: 42px; line-height: 1.4; }
|
||||
|
||||
/* Tabs */
|
||||
.sidebar-tabs { display: flex; border-bottom: 1px solid var(--border); flex-shrink: 0; padding: 0 4px; }
|
||||
|
||||
.tab-btn {
|
||||
flex: 1; padding: 10px 4px;
|
||||
background: transparent; border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font); font-size: 11px; font-weight: 600;
|
||||
cursor: pointer; transition: all 0.2s; letter-spacing: 0.3px;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 5px;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.tab-btn svg { width: 16px; height: 16px; stroke: currentColor; fill: none; }
|
||||
.tab-btn:hover { color: var(--text-secondary); }
|
||||
.tab-btn.active { color: var(--accent-light); border-bottom-color: var(--accent-light); }
|
||||
|
||||
.sidebar-content { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 14px 12px; }
|
||||
.sidebar-content::-webkit-scrollbar { width: 3px; }
|
||||
.sidebar-content::-webkit-scrollbar-track { background: transparent; }
|
||||
.sidebar-content::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 2px; }
|
||||
|
||||
.tab-panel { display: none; }
|
||||
.tab-panel.active { display: block; }
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
STATS
|
||||
───────────────────────────────────────────── */
|
||||
.stats-grid {
|
||||
display: grid; grid-template-columns: 1fr 1fr;
|
||||
gap: 6px; margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 12px 10px;
|
||||
text-align: center; transition: border-color 0.2s;
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.stat-card::before {
|
||||
content: ''; position: absolute; top: 0; left: 0; right: 0;
|
||||
height: 2px; background: var(--accent); opacity: 0.4;
|
||||
}
|
||||
.stat-card--rutin::before { background: var(--danger); opacity: 0.5; }
|
||||
.stat-card--pelatihan::before { background: var(--success); opacity: 0.5; }
|
||||
.stat-card:hover { border-color: var(--border-light); }
|
||||
|
||||
.stat-number {
|
||||
font-size: 24px; font-weight: 700; font-family: var(--font);
|
||||
color: var(--accent-light); letter-spacing: -0.5px; line-height: 1.1;
|
||||
}
|
||||
.stat-card--rutin .stat-number { color: var(--danger); }
|
||||
.stat-card--pelatihan .stat-number { color: var(--success); }
|
||||
.stat-label { font-size: 9px; color: var(--text-muted); font-weight: 500; text-transform: uppercase; letter-spacing: 0.6px; margin-top: 3px; }
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
EKONOMI GRID (stat avg penghasilan & anggota)
|
||||
───────────────────────────────────────────── */
|
||||
.ekon-grid { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
|
||||
|
||||
.ekon-card {
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 10px 12px;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
}
|
||||
.ekon-label { font-size: 11px; color: var(--text-muted); font-weight: 500; }
|
||||
.ekon-value { font-size: 13px; font-weight: 700; font-family: var(--font); color: var(--text-primary); }
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
SECTION TITLE
|
||||
───────────────────────────────────────────── */
|
||||
.section-title {
|
||||
font-size: 9px; font-weight: 600; color: var(--text-muted);
|
||||
text-transform: uppercase; letter-spacing: 1px;
|
||||
margin: 14px 0 8px;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.section-title svg { width: 12px; height: 12px; stroke: var(--text-muted); fill: none; flex-shrink: 0; }
|
||||
.section-title::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
LEGEND
|
||||
───────────────────────────────────────────── */
|
||||
.legend-group-label {
|
||||
font-size: 9px; color: var(--text-hint); font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.8px;
|
||||
margin-bottom: 5px; padding-left: 2px;
|
||||
}
|
||||
.legend-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 5px 6px; border-radius: var(--radius-sm); margin-bottom: 2px;
|
||||
font-size: 12px; font-weight: 400; color: var(--text-secondary);
|
||||
transition: background 0.15s; cursor: default;
|
||||
}
|
||||
.legend-item:hover { background: var(--bg-card); }
|
||||
.legend-icon {
|
||||
width: 22px; height: 22px; border-radius: 6px;
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
}
|
||||
.legend-icon svg { width: 13px; height: 13px; stroke: var(--icon-tint); fill: none; }
|
||||
.legend-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
RADIUS CONTROL
|
||||
───────────────────────────────────────────── */
|
||||
.radius-control {
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 12px; margin-bottom: 10px;
|
||||
}
|
||||
.radius-label {
|
||||
font-size: 11px; font-weight: 500; color: var(--text-secondary);
|
||||
margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center;
|
||||
}
|
||||
.radius-value { font-family: var(--font); font-size: 13px; font-weight: 600; color: var(--accent-light); }
|
||||
.radius-slider {
|
||||
width: 100%; -webkit-appearance: none; appearance: none;
|
||||
height: 3px; border-radius: 2px;
|
||||
background: linear-gradient(to right, var(--accent) 0%, var(--accent) 50%, var(--border-light) 50%);
|
||||
outline: none; cursor: pointer; margin-bottom: 10px;
|
||||
}
|
||||
.radius-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; width: 15px; height: 15px; border-radius: 50%;
|
||||
background: var(--accent-light); border: 2px solid var(--bg-panel);
|
||||
cursor: pointer; transition: transform 0.15s; box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.radius-slider::-webkit-slider-thumb:hover { transform: scale(1.15); }
|
||||
.radius-presets { display: flex; gap: 5px; }
|
||||
.preset-btn {
|
||||
flex: 1; padding: 5px; background: var(--bg-deep);
|
||||
border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
color: var(--text-muted); font-size: 10px; font-weight: 600;
|
||||
font-family: var(--font); cursor: pointer; transition: all 0.15s; letter-spacing: 0.2px;
|
||||
}
|
||||
.preset-btn:hover { border-color: var(--accent); color: var(--accent-light); }
|
||||
.preset-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
SEARCH & DATA LIST
|
||||
───────────────────────────────────────────── */
|
||||
.search-box { position: relative; margin-bottom: 8px; }
|
||||
.search-box input {
|
||||
width: 100%; padding: 8px 10px 8px 32px;
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm); color: var(--text-primary);
|
||||
font-family: var(--font); font-size: 12px; outline: none; transition: border-color 0.2s;
|
||||
}
|
||||
.search-box input::placeholder { color: var(--text-muted); }
|
||||
.search-box input:focus { border-color: var(--accent); }
|
||||
.search-icon { position: absolute; left: 9px; top: 50%; transform: translateY(-50%); pointer-events: none; }
|
||||
.search-icon svg { width: 14px; height: 14px; stroke: var(--text-muted); fill: none; }
|
||||
|
||||
.data-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
|
||||
.data-item {
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 10px;
|
||||
cursor: pointer; transition: all 0.18s;
|
||||
display: flex; gap: 10px; align-items: flex-start;
|
||||
}
|
||||
.data-item:hover { border-color: var(--accent); background: var(--bg-hover); }
|
||||
.item-icon {
|
||||
width: 28px; height: 28px; border-radius: 7px;
|
||||
background: var(--bg-deep); border: 1px solid var(--border);
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
}
|
||||
.item-icon svg { width: 14px; height: 14px; stroke: var(--icon-tint); fill: none; }
|
||||
.item-info { flex: 1; min-width: 0; }
|
||||
.item-name { font-size: 12px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 2px; color: var(--text-primary); }
|
||||
.item-sub { font-size: 10px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.item-badge { font-size: 9px; font-weight: 600; padding: 2px 7px; border-radius: 20px; flex-shrink: 0; align-self: flex-start; margin-top: 2px; letter-spacing: 0.3px; }
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
BUTTONS
|
||||
───────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
padding: 8px 14px; border: none; border-radius: var(--radius-sm);
|
||||
font-family: var(--font); font-size: 12px; font-weight: 600;
|
||||
cursor: pointer; transition: all 0.18s; text-decoration: none;
|
||||
}
|
||||
.btn svg { width: 14px; height: 14px; stroke: currentColor; fill: none; }
|
||||
.btn-primary { background: var(--accent); color: #fff; }
|
||||
.btn-primary:hover { background: #4338ca; }
|
||||
.btn-success { background: #059669; color: #fff; }
|
||||
.btn-success:hover { background: #047857; }
|
||||
.btn-danger { background: #dc2626; color: #fff; }
|
||||
.btn-danger:hover { background: #b91c1c; }
|
||||
.btn-ghost { background: var(--bg-card); color: var(--text-secondary); border: 1px solid var(--border); }
|
||||
.btn-ghost:hover { border-color: var(--border-light); color: var(--text-primary); }
|
||||
.btn-sm { padding: 5px 10px; font-size: 11px; }
|
||||
.btn-xs { padding: 3px 8px; font-size: 10px; }
|
||||
.btn-block { width: 100%; }
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
MAP AREA
|
||||
───────────────────────────────────────────── */
|
||||
#map-container { flex: 1; position: relative; overflow: hidden; }
|
||||
#map { width: 100%; height: 100%; }
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
TOOLBAR
|
||||
───────────────────────────────────────────── */
|
||||
#map-toolbar {
|
||||
position: absolute; bottom: 20px; right: 16px;
|
||||
z-index: 1000; display: flex; flex-direction: column; gap: 6px;
|
||||
}
|
||||
.tool-btn {
|
||||
width: 38px; height: 38px; border-radius: var(--radius-sm);
|
||||
background: var(--bg-panel); border: 1px solid var(--border);
|
||||
color: var(--text-muted); cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: all 0.18s; box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.tool-btn svg { width: 16px; height: 16px; stroke: currentColor; fill: none; }
|
||||
.tool-btn:hover { background: var(--bg-hover); color: var(--text-primary); border-color: var(--accent); }
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
INFO PANEL
|
||||
───────────────────────────────────────────── */
|
||||
#info-panel {
|
||||
position: absolute; top: 16px; right: 16px;
|
||||
width: 280px; background: var(--bg-panel);
|
||||
border: 1px solid var(--border); border-radius: var(--radius-lg);
|
||||
z-index: 1000; display: none; box-shadow: var(--shadow-lg); overflow: hidden;
|
||||
}
|
||||
#info-panel.show { display: block; animation: slideIn 0.22s ease; }
|
||||
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateY(-8px) scale(0.98); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
.info-header {
|
||||
padding: 13px 14px 11px; border-bottom: 1px solid var(--border);
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
.info-title { font-size: 13px; font-weight: 700; display: flex; align-items: center; gap: 8px; color: var(--text-primary); }
|
||||
.info-title-icon {
|
||||
width: 26px; height: 26px; border-radius: 6px;
|
||||
background: var(--bg-panel); border: 1px solid var(--border);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.info-title-icon svg { width: 14px; height: 14px; stroke: var(--icon-tint); fill: none; }
|
||||
.info-close {
|
||||
width: 24px; height: 24px; border-radius: 6px;
|
||||
background: transparent; border: 1px solid transparent;
|
||||
color: var(--text-muted); font-size: 14px; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center; transition: all 0.18s;
|
||||
}
|
||||
.info-close:hover { background: var(--danger-bg); color: var(--danger); }
|
||||
.info-body { padding: 12px 14px; max-height: calc(100vh - 200px); overflow-y: auto; }
|
||||
.info-body::-webkit-scrollbar { width: 3px; }
|
||||
.info-body::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 2px; }
|
||||
|
||||
.info-row { display: flex; gap: 8px; margin-bottom: 7px; font-size: 12px; align-items: flex-start; }
|
||||
.info-key { color: var(--text-muted); font-weight: 500; min-width: 82px; flex-shrink: 0; padding-top: 1px; }
|
||||
.info-val { color: var(--text-secondary); word-break: break-word; line-height: 1.5; }
|
||||
|
||||
.address-box {
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm); padding: 8px 10px;
|
||||
font-size: 11px; color: var(--text-muted); line-height: 1.5;
|
||||
margin: 0 0 10px; display: flex; gap: 6px; align-items: flex-start;
|
||||
}
|
||||
.addr-icon svg { width: 12px; height: 12px; stroke: var(--text-muted); fill: none; flex-shrink: 0; margin-top: 1px; }
|
||||
|
||||
.info-coords {
|
||||
font-family: var(--mono); font-size: 10px; color: var(--text-muted);
|
||||
text-align: center; padding: 6px;
|
||||
background: var(--bg-card); border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border); margin-top: 8px;
|
||||
}
|
||||
|
||||
.info-section-title {
|
||||
font-size: 9px; font-weight: 600; color: var(--text-muted);
|
||||
text-transform: uppercase; letter-spacing: 0.8px;
|
||||
margin: 12px 0 6px; display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.info-section-title svg { width: 11px; height: 11px; stroke: currentColor; fill: none; }
|
||||
|
||||
/* Anggota list in info panel */
|
||||
.anggota-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.anggota-item { display: flex; gap: 8px; align-items: flex-start; padding: 6px 8px; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-sm); }
|
||||
.anggota-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; margin-top: 4px; }
|
||||
.anggota-info { flex: 1; min-width: 0; }
|
||||
.anggota-name { font-size: 11px; font-weight: 600; color: var(--text-primary); }
|
||||
.anggota-meta { font-size: 10px; color: var(--text-muted); line-height: 1.5; margin-top: 1px; }
|
||||
|
||||
/* Rekomendasi box in info panel */
|
||||
.rek-box {
|
||||
border-radius: var(--radius-sm); padding: 8px 10px; margin: 8px 0;
|
||||
border-left: 3px solid;
|
||||
}
|
||||
.rek-box.rutin { background: var(--danger-bg); border-color: var(--danger); }
|
||||
.rek-box.pelatihan { background: var(--success-bg); border-color: var(--success); }
|
||||
.rek-box.keduanya { background: var(--warning-bg); border-color: var(--warning); }
|
||||
.rek-box.tidak-ada { background: var(--bg-hover); border-color: var(--border-light); }
|
||||
.rek-box-title { font-size: 10px; font-weight: 700; color: var(--text-primary); margin-bottom: 3px; }
|
||||
.rek-box-desc { font-size: 10px; color: var(--text-muted); line-height: 1.5; }
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
BADGES
|
||||
───────────────────────────────────────────── */
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 20px; font-size: 10px; font-weight: 600; letter-spacing: 0.2px; }
|
||||
.badge-sangat-miskin { background: var(--danger-bg); color: var(--danger-text); }
|
||||
.badge-miskin { background: var(--info-bg); color: var(--info-text); }
|
||||
.badge-rentan-miskin { background: var(--warning-bg); color: var(--warning-text); }
|
||||
|
||||
.badge-belum_terima { background: var(--danger-bg); color: var(--danger-text); }
|
||||
.badge-sudah_terima { background: var(--success-bg); color: var(--success-text); }
|
||||
.badge-sedang_proses { background: var(--warning-bg); color: var(--warning-text); }
|
||||
|
||||
/* STATUS_CONFIG cls — harus cocok dengan app.js */
|
||||
.badge-belum-terima { background: var(--danger-bg); color: var(--danger-text); }
|
||||
.badge-sudah-terima { background: var(--success-bg); color: var(--success-text); }
|
||||
.badge-sedang-proses { background: var(--warning-bg); color: var(--warning-text); }
|
||||
|
||||
/* REK_CONFIG cls */
|
||||
.badge-bantuan-rutin { background: var(--danger-bg); color: var(--danger-text); }
|
||||
.badge-bantuan-pelatihan { background: var(--success-bg); color: var(--success-text); }
|
||||
.badge-keduanya { background: var(--warning-bg); color: var(--warning-text); }
|
||||
.badge-tidak-ada { background: var(--bg-hover); color: var(--text-muted); }
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
MODAL
|
||||
───────────────────────────────────────────── */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
||||
z-index: 9000; display: none; align-items: center; justify-content: center;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.modal-overlay.show { display: flex; }
|
||||
#modal-warga, #modal-ibadah { z-index: 9100; }
|
||||
|
||||
.modal {
|
||||
background: var(--bg-panel); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xl); width: 480px;
|
||||
max-width: 95vw; max-height: 90vh; overflow-y: auto;
|
||||
box-shadow: var(--shadow-lg); animation: modalIn 0.22s ease;
|
||||
}
|
||||
@keyframes modalIn {
|
||||
from { opacity: 0; transform: scale(0.96) translateY(-16px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
.modal::-webkit-scrollbar { width: 3px; }
|
||||
.modal::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 2px; }
|
||||
|
||||
.modal-header {
|
||||
padding: 16px 18px; border-bottom: 1px solid var(--border);
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
position: sticky; top: 0; background: var(--bg-panel); z-index: 1;
|
||||
}
|
||||
.modal-title { font-size: 14px; font-weight: 700; color: var(--text-primary); }
|
||||
.modal-close {
|
||||
width: 26px; height: 26px; border-radius: var(--radius-sm);
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
color: var(--text-muted); font-size: 13px; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center; transition: all 0.18s;
|
||||
}
|
||||
.modal-close:hover { background: var(--danger-bg); color: var(--danger); border-color: transparent; }
|
||||
|
||||
/* Modal sub-tabs */
|
||||
.modal-tabs {
|
||||
display: flex; border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-card); flex-shrink: 0;
|
||||
}
|
||||
.modal-tab-btn {
|
||||
flex: 1; padding: 9px 6px;
|
||||
background: transparent; border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-muted); font-family: var(--font);
|
||||
font-size: 11px; font-weight: 600; cursor: pointer;
|
||||
transition: all 0.2s; margin-bottom: -1px;
|
||||
}
|
||||
.modal-tab-btn:hover { color: var(--text-secondary); }
|
||||
.modal-tab-btn.active { color: var(--accent-light); border-bottom-color: var(--accent-light); }
|
||||
.modal-tab-panel { display: none; }
|
||||
.modal-tab-panel.active { display: block; }
|
||||
|
||||
.modal-body { padding: 18px; }
|
||||
.modal-footer {
|
||||
padding: 12px 18px; border-top: 1px solid var(--border);
|
||||
display: flex; gap: 8px; justify-content: flex-end;
|
||||
position: sticky; bottom: 0; background: var(--bg-panel); z-index: 1;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
FORM
|
||||
───────────────────────────────────────────── */
|
||||
.form-group { margin-bottom: 13px; }
|
||||
.form-label { display: block; font-size: 10px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.6px; margin-bottom: 5px; }
|
||||
.form-label span { color: var(--danger); }
|
||||
|
||||
.form-control {
|
||||
width: 100%; padding: 8px 11px;
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm); color: var(--text-primary);
|
||||
font-family: var(--font); font-size: 13px; outline: none;
|
||||
transition: border-color 0.18s, box-shadow 0.18s;
|
||||
}
|
||||
.form-control::placeholder { color: var(--text-muted); }
|
||||
.form-control:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
|
||||
select.form-control {
|
||||
cursor: pointer; -webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat; background-position: right 10px center; padding-right: 32px;
|
||||
}
|
||||
textarea.form-control { resize: vertical; min-height: 68px; }
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.form-hint { font-size: 10px; color: var(--text-muted); margin-top: 4px; }
|
||||
|
||||
.coord-display {
|
||||
font-family: var(--mono); font-size: 11px; color: var(--accent-light);
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm); padding: 8px 10px;
|
||||
}
|
||||
|
||||
/* Kondisi rumah preview */
|
||||
.kondisi-rumah-preview {
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 12px; margin-top: 4px;
|
||||
}
|
||||
.kpr-title { font-size: 10px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.6px; margin-bottom: 10px; }
|
||||
.kpr-grid { display: flex; gap: 6px; }
|
||||
.kpr-item {
|
||||
flex: 1; display: flex; flex-direction: column; align-items: center; gap: 5px;
|
||||
background: var(--bg-deep); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm); padding: 8px 6px;
|
||||
font-size: 10px; color: var(--text-secondary); text-align: center;
|
||||
}
|
||||
.kpr-item svg { color: var(--icon-tint); }
|
||||
|
||||
/* Anggota form items */
|
||||
.anggota-form-item {
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); overflow: hidden;
|
||||
}
|
||||
.anggota-form-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 8px 12px; background: var(--bg-hover);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.anggota-form-title { font-size: 11px; font-weight: 600; color: var(--text-secondary); }
|
||||
.anggota-form-body {
|
||||
padding: 12px; display: grid; grid-template-columns: 1fr 1fr; gap: 8px;
|
||||
}
|
||||
.anggota-form-body .form-group { margin-bottom: 0; }
|
||||
|
||||
/* Rekomendasi preview box */
|
||||
.rek-preview {
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 10px 12px;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
TOAST
|
||||
───────────────────────────────────────────── */
|
||||
#toast-container { position: fixed; bottom: 80px; right: 20px; z-index: 9999; display: flex; flex-direction: column; gap: 6px; }
|
||||
.toast {
|
||||
background: var(--bg-panel); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 10px 14px;
|
||||
display: flex; align-items: center; gap: 10px; min-width: 240px;
|
||||
box-shadow: var(--shadow-lg); animation: toastIn 0.25s ease;
|
||||
font-size: 12px; color: var(--text-secondary);
|
||||
}
|
||||
@keyframes toastIn { from { opacity: 0; transform: translateX(16px); } to { opacity: 1; transform: translateX(0); } }
|
||||
.toast.success { border-left: 3px solid var(--success); }
|
||||
.toast.error { border-left: 3px solid var(--danger); }
|
||||
.toast.info { border-left: 3px solid var(--accent-light); }
|
||||
.toast-icon svg { width: 15px; height: 15px; stroke: currentColor; fill: none; }
|
||||
.toast.success .toast-icon { color: var(--success); }
|
||||
.toast.error .toast-icon { color: var(--danger); }
|
||||
.toast.info .toast-icon { color: var(--accent-light); }
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
RADIUS INFO BAR
|
||||
───────────────────────────────────────────── */
|
||||
#radius-info {
|
||||
position: absolute; bottom: 72px; left: 50%; transform: translateX(-50%);
|
||||
z-index: 1000; background: var(--bg-panel);
|
||||
border: 1px solid var(--border-light); border-radius: var(--radius);
|
||||
padding: 9px 14px; display: none; gap: 10px; align-items: center;
|
||||
box-shadow: var(--shadow); font-size: 12px; white-space: nowrap;
|
||||
}
|
||||
#radius-info.show { display: flex; }
|
||||
.ri-label { color: var(--text-muted); }
|
||||
.ri-val { color: var(--accent-light); font-weight: 600; font-family: var(--font); }
|
||||
.ri-count { background: var(--danger-bg); color: var(--danger-text); padding: 2px 8px; border-radius: 20px; font-weight: 700; font-size: 11px; }
|
||||
.ri-close { cursor: pointer; color: var(--text-muted); display: flex; align-items: center; justify-content: center; transition: color 0.18s; }
|
||||
.ri-close:hover { color: var(--danger); }
|
||||
.ri-close svg { width: 12px; height: 12px; stroke: currentColor; fill: none; }
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
MODE INDICATOR
|
||||
───────────────────────────────────────────── */
|
||||
#mode-indicator {
|
||||
position: absolute; top: 16px; left: 16px; z-index: 1000;
|
||||
background: var(--bg-panel); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 8px 12px;
|
||||
display: none; align-items: center; gap: 8px;
|
||||
font-size: 12px; font-weight: 500; color: var(--text-secondary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
#mode-indicator.show { display: flex; }
|
||||
.mode-dot { width: 7px; height: 7px; border-radius: 50%; animation: pulse 1.2s infinite; }
|
||||
.mode-dot.ibadah { background: var(--success); }
|
||||
.mode-dot.warga { background: var(--accent-light); }
|
||||
@keyframes pulse { 0%, 100% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.5); opacity: 0.5; } }
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
LEAFLET OVERRIDES
|
||||
───────────────────────────────────────────── */
|
||||
.leaflet-popup-content-wrapper {
|
||||
background: var(--bg-panel) !important; border: 1px solid var(--border) !important;
|
||||
border-radius: var(--radius) !important; box-shadow: var(--shadow-lg) !important; color: var(--text-primary) !important;
|
||||
}
|
||||
.leaflet-popup-content { color: var(--text-primary) !important; font-family: var(--font) !important; }
|
||||
.leaflet-popup-tip { background: var(--bg-panel) !important; }
|
||||
.leaflet-popup-close-button { color: var(--text-muted) !important; }
|
||||
.leaflet-control-zoom a {
|
||||
background: var(--bg-panel) !important; color: var(--text-secondary) !important;
|
||||
border-color: var(--border) !important; font-family: var(--font) !important;
|
||||
}
|
||||
.leaflet-control-zoom a:hover { background: var(--bg-hover) !important; }
|
||||
.leaflet-control-attribution { display: none !important; }
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
SCROLLBAR
|
||||
───────────────────────────────────────────── */
|
||||
::-webkit-scrollbar { width: 5px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
LOADING / EMPTY
|
||||
───────────────────────────────────────────── */
|
||||
.loading { display: flex; align-items: center; justify-content: center; padding: 28px; color: var(--text-muted); font-size: 12px; gap: 8px; }
|
||||
.spinner { width: 16px; height: 16px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.6s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.empty-state { text-align: center; padding: 24px 16px; color: var(--text-muted); font-size: 12px; }
|
||||
.empty-state svg { width: 28px; height: 28px; stroke: var(--text-hint); fill: none; margin: 0 auto 8px; display: block; }
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
HOW TO USE BOX
|
||||
───────────────────────────────────────────── */
|
||||
.howto-box {
|
||||
font-size: 11px; color: var(--text-muted); line-height: 1.8;
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm); padding: 10px 12px;
|
||||
}
|
||||
.howto-box b { color: var(--text-secondary); font-weight: 600; }
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
LAPORAN WARGA
|
||||
───────────────────────────────────────────── */
|
||||
/* Tab badge counter */
|
||||
.tab-badge {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
min-width: 16px; height: 16px; padding: 0 4px;
|
||||
background: var(--danger); color: #fff;
|
||||
border-radius: 10px; font-size: 9px; font-weight: 700;
|
||||
margin-left: 3px; line-height: 1;
|
||||
}
|
||||
|
||||
/* Laporan card */
|
||||
.laporan-card {
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 11px 12px;
|
||||
margin-bottom: 6px; transition: border-color 0.18s;
|
||||
border-left: 3px solid var(--border);
|
||||
}
|
||||
.laporan-card:hover { border-color: var(--accent); }
|
||||
.laporan-card.prioritas-tinggi { border-left-color: var(--danger); }
|
||||
.laporan-card.prioritas-sedang { border-left-color: var(--warning); }
|
||||
.laporan-card.prioritas-rendah { border-left-color: var(--success); }
|
||||
|
||||
.laporan-card-header {
|
||||
display: flex; justify-content: space-between; align-items: flex-start;
|
||||
gap: 8px; margin-bottom: 6px;
|
||||
}
|
||||
.laporan-card-nama {
|
||||
font-size: 12px; font-weight: 600; color: var(--text-primary);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.laporan-card-meta {
|
||||
font-size: 10px; color: var(--text-muted); margin-top: 2px;
|
||||
}
|
||||
.laporan-card-jenis {
|
||||
font-size: 11px; color: var(--text-secondary);
|
||||
margin-bottom: 5px; font-weight: 500;
|
||||
}
|
||||
.laporan-card-desc {
|
||||
font-size: 11px; color: var(--text-muted); line-height: 1.5;
|
||||
background: var(--bg-deep); border-radius: var(--radius-sm);
|
||||
padding: 6px 8px; margin-bottom: 6px;
|
||||
border-left: 2px solid var(--border-light);
|
||||
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.laporan-card-footer {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
gap: 6px; flex-wrap: wrap;
|
||||
}
|
||||
.laporan-card-petugas {
|
||||
font-size: 10px; color: var(--success); font-weight: 600;
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
}
|
||||
.laporan-catatan-admin {
|
||||
font-size: 10px; color: var(--text-muted);
|
||||
background: var(--info-bg); border: 1px solid rgba(59,130,246,0.15);
|
||||
border-radius: var(--radius-sm); padding: 5px 8px;
|
||||
margin-top: 5px; line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Status badges laporan */
|
||||
.badge-laporan-pending { background: rgba(245,158,11,0.12); color: #92400e; }
|
||||
.badge-laporan-ditinjau { background: rgba(59,130,246,0.12); color: #1e40af; }
|
||||
.badge-laporan-diproses { background: rgba(139,92,246,0.12); color: #5b21b6; }
|
||||
.badge-laporan-selesai { background: rgba(16,185,129,0.12); color: #065f46; }
|
||||
.badge-laporan-ditolak { background: rgba(239,68,68,0.12); color: #991b1b; }
|
||||
|
||||
/* Prioritas badges */
|
||||
.badge-prioritas-tinggi { background: rgba(239,68,68,0.12); color: var(--danger-text); }
|
||||
.badge-prioritas-sedang { background: rgba(245,158,11,0.12); color: var(--warning-text); }
|
||||
.badge-prioritas-rendah { background: rgba(16,185,129,0.12); color: var(--success-text); }
|
||||
|
||||
/* Admin action area in laporan card */
|
||||
.laporan-admin-actions {
|
||||
display: flex; gap: 5px; flex-wrap: wrap; margin-top: 8px;
|
||||
padding-top: 8px; border-top: 1px dashed var(--border);
|
||||
}
|
||||
.laporan-admin-actions select {
|
||||
flex: 1; min-width: 110px; font-size: 10px; padding: 4px 8px;
|
||||
border-radius: var(--radius-sm); border: 1px solid var(--border);
|
||||
background: var(--bg-deep); color: var(--text-secondary);
|
||||
font-family: var(--font); cursor: pointer;
|
||||
}
|
||||
.laporan-admin-actions input[type="text"] {
|
||||
flex: 1.5; min-width: 100px; font-size: 10px; padding: 4px 8px;
|
||||
border-radius: var(--radius-sm); border: 1px solid var(--border);
|
||||
background: var(--bg-deep); color: var(--text-secondary);
|
||||
font-family: var(--font);
|
||||
}
|
||||
|
||||
/* Laporan filter tabs */
|
||||
.laporan-filter-tabs {
|
||||
display: flex; gap: 4px; flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.laporan-filter-tab {
|
||||
padding: 4px 10px; border-radius: 20px; font-size: 10px; font-weight: 600;
|
||||
border: 1px solid var(--border); background: var(--bg-card);
|
||||
color: var(--text-muted); cursor: pointer; transition: all 0.18s;
|
||||
font-family: var(--font);
|
||||
}
|
||||
.laporan-filter-tab:hover { border-color: var(--accent); color: var(--accent-light); }
|
||||
.laporan-filter-tab.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
|
||||
/* Laporkan button in info panel */
|
||||
.btn-laporkan {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
width: 100%; margin-top: 8px; padding: 7px 12px;
|
||||
background: rgba(239,68,68,0.08); border: 1px solid rgba(239,68,68,0.25);
|
||||
border-radius: var(--radius-sm); color: var(--danger);
|
||||
font-family: var(--font); font-size: 11px; font-weight: 600;
|
||||
cursor: pointer; transition: all 0.18s;
|
||||
}
|
||||
.btn-laporkan:hover {
|
||||
background: rgba(239,68,68,0.15); border-color: var(--danger);
|
||||
}
|
||||
.btn-laporkan svg { width: 13px; height: 13px; stroke: currentColor; fill: none; flex-shrink: 0; }
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
MOBILE STYLES & HAMBURGER MENU
|
||||
───────────────────────────────────────────── */
|
||||
.mobile-menu-btn {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
z-index: 2000;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.mobile-menu-btn svg { width: 22px; height: 22px; }
|
||||
.mobile-menu-btn:hover { background: var(--bg-hover); color: var(--accent); border-color: var(--accent); }
|
||||
|
||||
/* Overlay for mobile sidebar */
|
||||
.sidebar-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.4);
|
||||
z-index: 2500;
|
||||
backdrop-filter: blur(2px);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.sidebar-overlay.show {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mobile-menu-btn {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#sidebar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
height: 100%;
|
||||
z-index: 3000;
|
||||
transition: left 0.3s ease;
|
||||
box-shadow: var(--shadow-lg);
|
||||
width: 85vw; /* Adjust width for mobile */
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
#sidebar.show {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
#mode-indicator {
|
||||
top: 16px;
|
||||
left: 70px; /* Make space for hamburger button */
|
||||
}
|
||||
|
||||
.radius-control {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
+2061
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
$host = 'localhost';
|
||||
$dbname = 'bansos_gis';
|
||||
$user = 'root';
|
||||
$pass = '';
|
||||
|
||||
// Legacy MySQLi connection used by older APIs
|
||||
try {
|
||||
$conn = new mysqli($host, $user, $pass, $dbname);
|
||||
$conn->set_charset('utf8mb4');
|
||||
if ($conn->connect_error) {
|
||||
throw new Exception($conn->connect_error);
|
||||
}
|
||||
// Self-healing migration for manual recommendation column
|
||||
try {
|
||||
$checkCol = $conn->query("SHOW COLUMNS FROM `rumah_warga` LIKE 'is_rekomendasi_manual'");
|
||||
if ($checkCol && $checkCol->num_rows === 0) {
|
||||
$conn->query("ALTER TABLE `rumah_warga` ADD COLUMN `is_rekomendasi_manual` TINYINT(1) NOT NULL DEFAULT 0 AFTER `rekomendasi_bantuan`");
|
||||
}
|
||||
|
||||
$checkColKat = $conn->query("SHOW COLUMNS FROM `rumah_warga` LIKE 'is_kategori_manual'");
|
||||
if ($checkColKat && $checkColKat->num_rows === 0) {
|
||||
$conn->query("ALTER TABLE `rumah_warga` ADD COLUMN `is_kategori_manual` TINYINT(1) NOT NULL DEFAULT 0 AFTER `kategori_kemiskinan`");
|
||||
}
|
||||
|
||||
$checkColDikelola = $conn->query("SHOW COLUMNS FROM `rumah_warga` LIKE 'dikelola_oleh_ibadah_id'");
|
||||
if ($checkColDikelola && $checkColDikelola->num_rows === 0) {
|
||||
$conn->query("ALTER TABLE `rumah_warga` ADD COLUMN `dikelola_oleh_ibadah_id` INT NULL DEFAULT NULL AFTER `keterangan`");
|
||||
}
|
||||
} catch (Exception $ex) {}
|
||||
|
||||
// Self-healing migration for no_telp column in users table
|
||||
try {
|
||||
$checkColUsers = $conn->query("SHOW COLUMNS FROM `users` LIKE 'no_telp'");
|
||||
if ($checkColUsers && $checkColUsers->num_rows === 0) {
|
||||
$conn->query("ALTER TABLE `users` ADD COLUMN `no_telp` VARCHAR(20) NULL DEFAULT NULL AFTER `email`");
|
||||
}
|
||||
} catch (Exception $ex) {}
|
||||
|
||||
// Self-healing migration for RBAC & Kebutuhan tables
|
||||
try {
|
||||
$checkUsers = $conn->query("SHOW TABLES LIKE 'users'");
|
||||
if ($checkUsers && $checkUsers->num_rows === 0) {
|
||||
$sqlFile = dirname(__DIR__) . '/database/migrations.sql';
|
||||
if (file_exists($sqlFile)) {
|
||||
$sqlContent = file_get_contents($sqlFile);
|
||||
if ($conn->multi_query($sqlContent)) {
|
||||
do {
|
||||
if ($result = $conn->store_result()) {
|
||||
$result->free();
|
||||
}
|
||||
} while ($conn->more_results() && $conn->next_result());
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception $ex) {}
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
die(json_encode([
|
||||
'success' => false,
|
||||
'message' => 'Koneksi database MySQLi gagal: ' . $e->getMessage() . '. Pastikan XAMPP Apache dan MySQL Anda aktif, serta database "bansos_gis" telah dibuat di phpMyAdmin.'
|
||||
]));
|
||||
}
|
||||
|
||||
// New PDO connection used by new APIs (redistribusi, etc.)
|
||||
try {
|
||||
$pdo = new PDO("mysql:host=$host;dbname=$dbname;charset=utf8mb4", $user, $pass, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
// Set to null — APIs that require PDO must check and throw themselves
|
||||
$pdo = null;
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
-- phpMyAdmin SQL Dump
|
||||
-- version 5.2.1
|
||||
-- https://www.phpmyadmin.net/
|
||||
--
|
||||
-- Host: 127.0.0.1
|
||||
-- Generation Time: May 17, 2026 at 01:04 PM
|
||||
-- Server version: 10.4.32-MariaDB
|
||||
-- PHP Version: 8.2.12
|
||||
|
||||
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
|
||||
START TRANSACTION;
|
||||
SET time_zone = "+00:00";
|
||||
|
||||
|
||||
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
|
||||
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
|
||||
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
|
||||
/*!40101 SET NAMES utf8mb4 */;
|
||||
|
||||
--
|
||||
-- Database: `bansos_gis`
|
||||
--
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
||||
--
|
||||
-- Table structure for table `anggota_keluarga`
|
||||
--
|
||||
|
||||
CREATE TABLE `anggota_keluarga` (
|
||||
`id` int(11) NOT NULL,
|
||||
`rumah_warga_id` int(11) NOT NULL,
|
||||
`nama` varchar(200) NOT NULL,
|
||||
`nik` varchar(16) DEFAULT NULL,
|
||||
`tanggal_lahir` date DEFAULT NULL,
|
||||
`jenis_kelamin` enum('laki_laki','perempuan') DEFAULT 'laki_laki',
|
||||
`pendidikan` enum('tidak_sekolah','sd','smp','sma','d3','s1','s2','s3') DEFAULT 'sd',
|
||||
`pekerjaan` varchar(100) DEFAULT NULL,
|
||||
`status_bekerja` enum('bekerja','tidak_bekerja','pelajar','pensiunan') DEFAULT 'tidak_bekerja',
|
||||
`kondisi_kesehatan` enum('sehat','sakit_ringan','sakit_berat','disabilitas') DEFAULT 'sehat',
|
||||
`dapat_bekerja` tinyint(1) DEFAULT 1,
|
||||
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
|
||||
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
--
|
||||
-- Dumping data for table `anggota_keluarga`
|
||||
--
|
||||
|
||||
INSERT INTO `anggota_keluarga` (`id`, `rumah_warga_id`, `nama`, `nik`, `tanggal_lahir`, `jenis_kelamin`, `pendidikan`, `pekerjaan`, `status_bekerja`, `kondisi_kesehatan`, `dapat_bekerja`, `created_at`, `updated_at`) VALUES
|
||||
(1, 1, 'Budi Santoso', '1234567890123456', '1985-06-15', 'laki_laki', 'smp', 'Buruh Harian', 'bekerja', 'sehat', 1, '2026-05-17 06:06:37', '2026-05-17 06:06:37'),
|
||||
(2, 1, 'Rina Santoso', '1234567890123457', '1988-03-20', 'perempuan', 'sd', 'Ibu Rumah Tangga', 'tidak_bekerja', 'sehat', 1, '2026-05-17 06:06:37', '2026-05-17 06:06:37'),
|
||||
(3, 1, 'Doni Santoso', '1234567890123458', '2008-11-10', 'laki_laki', 'smp', NULL, 'pelajar', 'sehat', 0, '2026-05-17 06:06:37', '2026-05-17 06:06:37'),
|
||||
(4, 1, 'Dewi Santoso', '1234567890123459', '2012-07-25', 'perempuan', 'sd', NULL, 'pelajar', 'sehat', 0, '2026-05-17 06:06:37', '2026-05-17 06:06:37'),
|
||||
(5, 2, 'Siti Aminah', '9876543210987654', '1970-01-12', 'perempuan', 'sd', NULL, 'tidak_bekerja', 'sakit_ringan', 0, '2026-05-17 06:06:37', '2026-05-17 06:06:37'),
|
||||
(6, 2, 'Ahmad Firdaus', '9876543210987655', '2005-09-30', 'laki_laki', 'sma', NULL, 'pelajar', 'sehat', 1, '2026-05-17 06:06:37', '2026-05-17 06:06:37');
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
||||
--
|
||||
-- Table structure for table `konfigurasi_sistem`
|
||||
--
|
||||
|
||||
CREATE TABLE `konfigurasi_sistem` (
|
||||
`id` int(11) NOT NULL,
|
||||
`kunci` varchar(100) NOT NULL COMMENT 'key konfigurasi',
|
||||
`nilai` text NOT NULL COMMENT 'value konfigurasi',
|
||||
`label` varchar(200) DEFAULT NULL COMMENT 'label tampil di UI',
|
||||
`deskripsi` text DEFAULT NULL,
|
||||
`tipe` enum('integer','decimal','string','boolean','json') DEFAULT 'string',
|
||||
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
--
|
||||
-- Dumping data for table `konfigurasi_sistem`
|
||||
--
|
||||
|
||||
INSERT INTO `konfigurasi_sistem` (`id`, `kunci`, `nilai`, `label`, `deskripsi`, `tipe`, `updated_at`) VALUES
|
||||
(1, 'interval_verifikasi_bulan', '6', 'Interval Verifikasi Data (Bulan)', 'Berapa bulan sekali data rumah warga harus diverifikasi ulang', 'integer', '2026-05-17 10:41:31'),
|
||||
(2, 'skor_data_kadaluarsa', '40', 'Skor Minimum Data Valid (%)', 'Data dengan skor kepercayaan di bawah nilai ini dianggap perlu reverifikasi', 'integer', '2026-05-17 10:41:31'),
|
||||
(3, 'threshold_penghasilan_mandiri', '3500000', 'Threshold Penghasilan Mandiri (Rp)', 'Penghasilan bulanan minimum agar warga dianggap berpotensi mandiri (default = UMR)', 'decimal', '2026-05-17 10:41:31'),
|
||||
(4, 'bobot_penghasilan', '40', 'Bobot Skor: Penghasilan (%)', 'Kontribusi penghasilan terhadap skor kemandirian (total 3 bobot harus = 100)', 'integer', '2026-05-17 10:41:31'),
|
||||
(5, 'bobot_status_kerja', '35', 'Bobot Skor: Status Kerja (%)', 'Kontribusi status pekerjaan anggota terhadap skor kemandirian', 'integer', '2026-05-17 10:41:31'),
|
||||
(6, 'bobot_tanggungan', '25', 'Bobot Skor: Tanggungan (%)', 'Kontribusi jumlah tanggungan terhadap skor kemandirian', 'integer', '2026-05-17 10:41:31'),
|
||||
(7, 'threshold_skor_mandiri', '70', 'Threshold Skor Kemandirian (%)', 'Skor minimum agar warga masuk kategori \"Berpotensi Mandiri\"', 'integer', '2026-05-17 10:41:31'),
|
||||
(8, 'kapasitas_default_ibadah', '20', 'Kapasitas Default per Rumah Ibadah (KK)', 'Jumlah KK yang idealnya ditangani 1 rumah ibadah jika tidak diset manual', 'integer', '2026-05-17 10:41:31');
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
||||
--
|
||||
-- Table structure for table `redistribusi_bantuan`
|
||||
--
|
||||
|
||||
CREATE TABLE `redistribusi_bantuan` (
|
||||
`id` int(11) NOT NULL,
|
||||
`ibadah_pengirim_id` int(11) NOT NULL COMMENT 'Rumah ibadah yang punya surplus',
|
||||
`ibadah_penerima_id` int(11) NOT NULL COMMENT 'Rumah ibadah yang butuh tambahan',
|
||||
`jumlah_kk` smallint(5) UNSIGNED DEFAULT 0 COMMENT 'Jumlah KK yang dialihkan',
|
||||
`estimasi_dana` decimal(15,2) DEFAULT 0.00 COMMENT 'Estimasi dana yang ikut dialihkan',
|
||||
`status` enum('saran_sistem','disetujui','ditolak','selesai') DEFAULT 'saran_sistem',
|
||||
`catatan_sistem` text DEFAULT NULL COMMENT 'Alasan/penjelasan dari sistem',
|
||||
`catatan_admin` text DEFAULT NULL COMMENT 'Catatan keputusan admin',
|
||||
`disetujui_oleh` varchar(150) DEFAULT NULL,
|
||||
`tanggal_saran` timestamp NOT NULL DEFAULT current_timestamp(),
|
||||
`tanggal_keputusan` timestamp NULL DEFAULT NULL,
|
||||
`periode_bulan` char(7) DEFAULT NULL COMMENT 'Format YYYY-MM, periode redistribusi'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
||||
--
|
||||
-- Table structure for table `riwayat_kemandirian`
|
||||
--
|
||||
|
||||
CREATE TABLE `riwayat_kemandirian` (
|
||||
`id` int(11) NOT NULL,
|
||||
`rumah_warga_id` int(11) NOT NULL,
|
||||
`tanggal` date NOT NULL,
|
||||
`skor` tinyint(3) UNSIGNED DEFAULT 0,
|
||||
`status` enum('sangat_bergantung','bergantung','berkembang','berpotensi_mandiri','mandiri') DEFAULT 'bergantung',
|
||||
`penghasilan_saat_ini` decimal(15,2) DEFAULT 0.00,
|
||||
`catatan` text DEFAULT NULL,
|
||||
`created_at` timestamp NOT NULL DEFAULT current_timestamp()
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
||||
--
|
||||
-- Table structure for table `riwayat_verifikasi`
|
||||
--
|
||||
|
||||
CREATE TABLE `riwayat_verifikasi` (
|
||||
`id` int(11) NOT NULL,
|
||||
`rumah_warga_id` int(11) NOT NULL,
|
||||
`tanggal` date NOT NULL,
|
||||
`petugas` varchar(150) DEFAULT NULL,
|
||||
`skor_sebelum` tinyint(3) UNSIGNED DEFAULT NULL,
|
||||
`skor_sesudah` tinyint(3) UNSIGNED DEFAULT NULL,
|
||||
`perubahan` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL COMMENT 'Field yang berubah saat verifikasi' CHECK (json_valid(`perubahan`)),
|
||||
`catatan` text DEFAULT NULL,
|
||||
`created_at` timestamp NOT NULL DEFAULT current_timestamp()
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
||||
--
|
||||
-- Table structure for table `rumah_ibadah`
|
||||
--
|
||||
|
||||
CREATE TABLE `rumah_ibadah` (
|
||||
`id` int(11) NOT NULL,
|
||||
`nama` varchar(200) NOT NULL,
|
||||
`jenis` enum('masjid','gereja','klenteng','pura','vihara','lainnya') NOT NULL DEFAULT 'masjid',
|
||||
`alamat` text DEFAULT NULL,
|
||||
`kelurahan` varchar(100) DEFAULT NULL,
|
||||
`kecamatan` varchar(100) DEFAULT NULL,
|
||||
`kota` varchar(100) DEFAULT NULL,
|
||||
`latitude` decimal(10,8) NOT NULL,
|
||||
`longitude` decimal(11,8) NOT NULL,
|
||||
`penanggung_jawab` varchar(150) DEFAULT NULL,
|
||||
`kontak` varchar(50) DEFAULT NULL,
|
||||
`keterangan` text DEFAULT NULL,
|
||||
`kapasitas_kk` smallint(5) UNSIGNED DEFAULT 20 COMMENT 'Kapasitas maksimal KK yang dapat ditangani',
|
||||
`anggaran_bulanan` decimal(15,2) DEFAULT 0.00 COMMENT 'Estimasi anggaran bantuan tersedia per bulan (Rp)',
|
||||
`radius_primer` smallint(5) UNSIGNED DEFAULT 500 COMMENT 'Radius cakupan utama dalam meter',
|
||||
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
|
||||
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
--
|
||||
-- Dumping data for table `rumah_ibadah`
|
||||
--
|
||||
|
||||
INSERT INTO `rumah_ibadah` (`id`, `nama`, `jenis`, `alamat`, `kelurahan`, `kecamatan`, `kota`, `latitude`, `longitude`, `penanggung_jawab`, `kontak`, `keterangan`, `kapasitas_kk`, `anggaran_bulanan`, `radius_primer`, `created_at`, `updated_at`) VALUES
|
||||
(1, 'Masjid Al-Ikhlas', 'masjid', 'Jl. Merdeka No. 1', 'Sungai Jawi', 'Pontianak Kota', 'Pontianak', -0.02436519, 109.32911396, 'H. Ahmad Fauzi', '08123456789', 'Masjid utama kecamatan', 20, 0.00, 500, '2026-05-17 06:06:37', '2026-05-17 07:29:38'),
|
||||
(2, 'Paroki Santo Yoseph Katedral', 'gereja', 'Jl. Katedral No. 5', 'Mariana', 'Pontianak Kota', 'Pontianak', -0.02727270, 109.33816910, 'Pastor Yohanes', '08234567890', 'Gereja Katedral', 20, 0.00, 500, '2026-05-17 06:06:37', '2026-05-17 06:06:37');
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
||||
--
|
||||
-- Table structure for table `rumah_warga`
|
||||
--
|
||||
|
||||
CREATE TABLE `rumah_warga` (
|
||||
`id` int(11) NOT NULL,
|
||||
`no_kk` varchar(16) NOT NULL,
|
||||
`nama_kepala_keluarga` varchar(200) NOT NULL,
|
||||
`nik` varchar(16) DEFAULT NULL,
|
||||
`alamat` text DEFAULT NULL,
|
||||
`kelurahan` varchar(100) DEFAULT NULL,
|
||||
`kecamatan` varchar(100) DEFAULT NULL,
|
||||
`kota` varchar(100) DEFAULT NULL,
|
||||
`latitude` decimal(10,8) NOT NULL,
|
||||
`longitude` decimal(11,8) NOT NULL,
|
||||
`jumlah_tanggungan` int(11) DEFAULT 1,
|
||||
`penghasilan_bulanan` decimal(15,2) DEFAULT 0.00,
|
||||
`pekerjaan` varchar(100) DEFAULT NULL,
|
||||
`status_pekerjaan` enum('tetap','tidak_tetap','tidak_bekerja','pensiun') DEFAULT 'tidak_bekerja',
|
||||
`luas_rumah` decimal(8,2) DEFAULT NULL COMMENT 'm2',
|
||||
`jenis_lantai` enum('tanah','semen','keramik','kayu','lainnya') DEFAULT 'semen',
|
||||
`jenis_dinding` enum('tembok','kayu','bambu','lainnya') DEFAULT 'tembok',
|
||||
`sumber_air` enum('pdam','sumur','sungai','hujan','lainnya') DEFAULT 'sumur',
|
||||
`status_tempat_tinggal` enum('milik_sendiri','sewa','numpang','dinas') DEFAULT 'milik_sendiri',
|
||||
`kategori_kemiskinan` enum('sangat_miskin','miskin','rentan_miskin') DEFAULT 'miskin',
|
||||
`status_bantuan` enum('belum_terima','sudah_terima','sedang_proses') DEFAULT 'belum_terima',
|
||||
`rekomendasi_bantuan` enum('bantuan_rutin','bantuan_pelatihan','keduanya','tidak_ada') DEFAULT 'bantuan_rutin',
|
||||
`keterangan` text DEFAULT NULL,
|
||||
`tanggal_verifikasi` date DEFAULT NULL COMMENT 'Tanggal terakhir data diverifikasi petugas',
|
||||
`petugas_verifikasi` varchar(150) DEFAULT NULL COMMENT 'Nama petugas yang melakukan verifikasi',
|
||||
`skor_kepercayaan` tinyint(3) UNSIGNED DEFAULT 100 COMMENT 'Skor kepercayaan data 0-100 (100=baru diverifikasi)',
|
||||
`status_verifikasi` enum('terverifikasi','perlu_update','kadaluarsa') DEFAULT 'terverifikasi' COMMENT 'Status keterbaruan data',
|
||||
`skor_kemandirian` tinyint(3) UNSIGNED DEFAULT 0 COMMENT 'Skor potensi kemandirian 0-100 (dihitung otomatis)',
|
||||
`status_kemandirian` enum('sangat_bergantung','bergantung','berkembang','berpotensi_mandiri','mandiri') DEFAULT 'bergantung' COMMENT 'Status kemandirian warga',
|
||||
`catatan_kemandirian` text DEFAULT NULL COMMENT 'Catatan perkembangan kemandirian dari petugas',
|
||||
`tanggal_exit_plan` date DEFAULT NULL COMMENT 'Target tanggal warga keluar dari daftar bantuan',
|
||||
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
|
||||
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
--
|
||||
-- Dumping data for table `rumah_warga`
|
||||
--
|
||||
|
||||
INSERT INTO `rumah_warga` (`id`, `no_kk`, `nama_kepala_keluarga`, `nik`, `alamat`, `kelurahan`, `kecamatan`, `kota`, `latitude`, `longitude`, `jumlah_tanggungan`, `penghasilan_bulanan`, `pekerjaan`, `status_pekerjaan`, `luas_rumah`, `jenis_lantai`, `jenis_dinding`, `sumber_air`, `status_tempat_tinggal`, `kategori_kemiskinan`, `status_bantuan`, `rekomendasi_bantuan`, `keterangan`, `tanggal_verifikasi`, `petugas_verifikasi`, `skor_kepercayaan`, `status_verifikasi`, `skor_kemandirian`, `status_kemandirian`, `catatan_kemandirian`, `tanggal_exit_plan`, `created_at`, `updated_at`) VALUES
|
||||
(1, '1234567890987654', 'Budi Santoso', '1234567890123456', 'Jl. Gang Melati No. 3', 'Sungai Jawi', 'Pontianak Kota', 'Pontianak', -0.02399236, 109.32659805, 4, 800000.00, 'Buruh Harian', 'tidak_tetap', 36.00, 'semen', 'kayu', 'sumur', 'sewa', 'miskin', 'belum_terima', 'bantuan_pelatihan', 'Kepala keluarga masih produktif', '2026-05-17', NULL, 100, 'terverifikasi', 0, 'bergantung', NULL, NULL, '2026-05-17 06:06:37', '2026-05-17 10:41:31'),
|
||||
(2, '0987654321234567', 'Siti Aminah', '9876543210987654', 'Jl. Flamboyan No. 12', 'Sungai Jawi', 'Pontianak Kota', 'Pontianak', -0.02216577, 109.32456493, 2, 0.00, 'Ibu Rumah Tangga', 'tidak_bekerja', 24.00, 'tanah', 'bambu', 'sungai', 'numpang', 'sangat_miskin', 'belum_terima', 'bantuan_rutin', 'Suami meninggal, tidak ada penghasilan', '2026-05-17', NULL, 100, 'terverifikasi', 0, 'bergantung', NULL, NULL, '2026-05-17 06:06:37', '2026-05-17 10:41:31'),
|
||||
(3, '1111111111111111', 'Test User', '1111111111111111', 'Test Alamat', 'Test', 'Test', 'Pontianak', -0.02300000, 109.32600000, 1, 500000.00, 'Test', 'tidak_bekerja', 30.00, 'semen', 'tembok', 'sumur', 'milik_sendiri', 'miskin', 'belum_terima', 'bantuan_rutin', 'Test data', '2026-05-17', NULL, 100, 'terverifikasi', 0, 'bergantung', NULL, NULL, '2026-05-17 08:08:57', '2026-05-17 10:41:31'),
|
||||
(4, '9999999999999999', 'Ahmad Wijaya', '', '', '', '', '', -0.02300000, 109.32600000, 1, 0.00, '', 'tidak_bekerja', NULL, 'semen', 'tembok', 'sumur', 'milik_sendiri', 'miskin', 'belum_terima', 'bantuan_rutin', '', '2026-05-17', NULL, 100, 'terverifikasi', 0, 'bergantung', NULL, NULL, '2026-05-17 08:13:55', '2026-05-17 10:41:31');
|
||||
|
||||
--
|
||||
-- Indexes for dumped tables
|
||||
--
|
||||
|
||||
--
|
||||
-- Indexes for table `anggota_keluarga`
|
||||
--
|
||||
ALTER TABLE `anggota_keluarga`
|
||||
ADD PRIMARY KEY (`id`),
|
||||
ADD KEY `fk_anggota_warga` (`rumah_warga_id`);
|
||||
|
||||
--
|
||||
-- Indexes for table `konfigurasi_sistem`
|
||||
--
|
||||
ALTER TABLE `konfigurasi_sistem`
|
||||
ADD PRIMARY KEY (`id`),
|
||||
ADD UNIQUE KEY `uk_kunci` (`kunci`);
|
||||
|
||||
--
|
||||
-- Indexes for table `redistribusi_bantuan`
|
||||
--
|
||||
ALTER TABLE `redistribusi_bantuan`
|
||||
ADD PRIMARY KEY (`id`),
|
||||
ADD KEY `fk_redist_pengirim` (`ibadah_pengirim_id`),
|
||||
ADD KEY `fk_redist_penerima` (`ibadah_penerima_id`),
|
||||
ADD KEY `idx_periode` (`periode_bulan`),
|
||||
ADD KEY `idx_status` (`status`);
|
||||
|
||||
--
|
||||
-- Indexes for table `riwayat_kemandirian`
|
||||
--
|
||||
ALTER TABLE `riwayat_kemandirian`
|
||||
ADD PRIMARY KEY (`id`),
|
||||
ADD KEY `fk_kemandirian_warga` (`rumah_warga_id`);
|
||||
|
||||
--
|
||||
-- Indexes for table `riwayat_verifikasi`
|
||||
--
|
||||
ALTER TABLE `riwayat_verifikasi`
|
||||
ADD PRIMARY KEY (`id`),
|
||||
ADD KEY `fk_riwayat_warga` (`rumah_warga_id`);
|
||||
|
||||
--
|
||||
-- Indexes for table `rumah_ibadah`
|
||||
--
|
||||
ALTER TABLE `rumah_ibadah`
|
||||
ADD PRIMARY KEY (`id`);
|
||||
|
||||
--
|
||||
-- Indexes for table `rumah_warga`
|
||||
--
|
||||
ALTER TABLE `rumah_warga`
|
||||
ADD PRIMARY KEY (`id`),
|
||||
ADD UNIQUE KEY `no_kk` (`no_kk`),
|
||||
ADD UNIQUE KEY `uk_no_kk` (`no_kk`);
|
||||
|
||||
--
|
||||
-- AUTO_INCREMENT for dumped tables
|
||||
--
|
||||
|
||||
--
|
||||
-- AUTO_INCREMENT for table `anggota_keluarga`
|
||||
--
|
||||
ALTER TABLE `anggota_keluarga`
|
||||
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=7;
|
||||
|
||||
--
|
||||
-- AUTO_INCREMENT for table `konfigurasi_sistem`
|
||||
--
|
||||
ALTER TABLE `konfigurasi_sistem`
|
||||
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=9;
|
||||
|
||||
--
|
||||
-- AUTO_INCREMENT for table `redistribusi_bantuan`
|
||||
--
|
||||
ALTER TABLE `redistribusi_bantuan`
|
||||
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
|
||||
|
||||
--
|
||||
-- AUTO_INCREMENT for table `riwayat_kemandirian`
|
||||
--
|
||||
ALTER TABLE `riwayat_kemandirian`
|
||||
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
|
||||
|
||||
--
|
||||
-- AUTO_INCREMENT for table `riwayat_verifikasi`
|
||||
--
|
||||
ALTER TABLE `riwayat_verifikasi`
|
||||
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
|
||||
|
||||
--
|
||||
-- AUTO_INCREMENT for table `rumah_ibadah`
|
||||
--
|
||||
ALTER TABLE `rumah_ibadah`
|
||||
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=3;
|
||||
|
||||
--
|
||||
-- AUTO_INCREMENT for table `rumah_warga`
|
||||
--
|
||||
ALTER TABLE `rumah_warga`
|
||||
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=5;
|
||||
|
||||
--
|
||||
-- Constraints for dumped tables
|
||||
--
|
||||
|
||||
--
|
||||
-- Constraints for table `anggota_keluarga`
|
||||
--
|
||||
ALTER TABLE `anggota_keluarga`
|
||||
ADD CONSTRAINT `fk_anggota_warga` FOREIGN KEY (`rumah_warga_id`) REFERENCES `rumah_warga` (`id`) ON DELETE CASCADE;
|
||||
|
||||
--
|
||||
-- Constraints for table `redistribusi_bantuan`
|
||||
--
|
||||
ALTER TABLE `redistribusi_bantuan`
|
||||
ADD CONSTRAINT `fk_redist_penerima` FOREIGN KEY (`ibadah_penerima_id`) REFERENCES `rumah_ibadah` (`id`) ON DELETE CASCADE,
|
||||
ADD CONSTRAINT `fk_redist_pengirim` FOREIGN KEY (`ibadah_pengirim_id`) REFERENCES `rumah_ibadah` (`id`) ON DELETE CASCADE;
|
||||
|
||||
--
|
||||
-- Constraints for table `riwayat_kemandirian`
|
||||
--
|
||||
ALTER TABLE `riwayat_kemandirian`
|
||||
ADD CONSTRAINT `fk_kemandirian_warga` FOREIGN KEY (`rumah_warga_id`) REFERENCES `rumah_warga` (`id`) ON DELETE CASCADE;
|
||||
|
||||
--
|
||||
-- Constraints for table `riwayat_verifikasi`
|
||||
--
|
||||
ALTER TABLE `riwayat_verifikasi`
|
||||
ADD CONSTRAINT `fk_riwayat_warga` FOREIGN KEY (`rumah_warga_id`) REFERENCES `rumah_warga` (`id`) ON DELETE CASCADE;
|
||||
COMMIT;
|
||||
|
||||
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
|
||||
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
|
||||
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
|
||||
@@ -0,0 +1,44 @@
|
||||
-- laporan_migration.sql
|
||||
-- Jalankan script ini di phpMyAdmin untuk database "bansos_gis"
|
||||
-- Fitur: Pelaporan Warga oleh Kontributor & Koordinator
|
||||
|
||||
-- Tambah kolom no_telp ke tabel users jika belum ada
|
||||
ALTER TABLE `users`
|
||||
ADD COLUMN IF NOT EXISTS `no_telp` VARCHAR(20) NULL DEFAULT NULL
|
||||
COMMENT 'Nomor telepon/WA pengguna';
|
||||
|
||||
-- Buat tabel laporan_warga
|
||||
CREATE TABLE IF NOT EXISTS `laporan_warga` (
|
||||
`id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||
`rumah_warga_id` INT(11) NOT NULL COMMENT 'Warga yang dilaporkan',
|
||||
`pelapor_id` INT(11) NOT NULL COMMENT 'User yang membuat laporan',
|
||||
`koordinator_ibadah_id` INT(11) NULL DEFAULT NULL COMMENT 'Rumah ibadah pelapor (jika koordinator)',
|
||||
`jenis_laporan` ENUM(
|
||||
'data_tidak_sesuai',
|
||||
'warga_mampu',
|
||||
'warga_pindah',
|
||||
'warga_meninggal',
|
||||
'data_ganda',
|
||||
'lainnya'
|
||||
) NOT NULL DEFAULT 'data_tidak_sesuai' COMMENT 'Jenis ketidaksesuaian yang dilaporkan',
|
||||
`deskripsi` TEXT NOT NULL COMMENT 'Penjelasan detail dari pelapor',
|
||||
`prioritas` ENUM('rendah', 'sedang', 'tinggi') NOT NULL DEFAULT 'sedang',
|
||||
`status` ENUM(
|
||||
'pending',
|
||||
'ditinjau',
|
||||
'diproses',
|
||||
'selesai',
|
||||
'ditolak'
|
||||
) NOT NULL DEFAULT 'pending' COMMENT 'Status penanganan laporan',
|
||||
`catatan_admin` TEXT NULL DEFAULT NULL COMMENT 'Catatan atau keputusan admin',
|
||||
`ditugaskan_ke` VARCHAR(150) NULL DEFAULT NULL COMMENT 'Nama petugas yang ditugaskan cek lapangan',
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP(),
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP() ON UPDATE CURRENT_TIMESTAMP(),
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `fk_laporan_warga` (`rumah_warga_id`),
|
||||
KEY `fk_laporan_pelapor` (`pelapor_id`),
|
||||
KEY `idx_laporan_status` (`status`),
|
||||
KEY `idx_laporan_prioritas` (`prioritas`),
|
||||
CONSTRAINT `fk_laporan_warga` FOREIGN KEY (`rumah_warga_id`) REFERENCES `rumah_warga` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_laporan_pelapor` FOREIGN KEY (`pelapor_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
@@ -0,0 +1,79 @@
|
||||
-- migrations.sql
|
||||
-- Run this SQL script in phpMyAdmin or your MySQL client for database "bansos_gis"
|
||||
|
||||
-- 1. Membuat tabel 'users' untuk multi-role
|
||||
CREATE TABLE IF NOT EXISTS `users` (
|
||||
`id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||
`username` VARCHAR(100) NOT NULL UNIQUE,
|
||||
`email` VARCHAR(150) NOT NULL UNIQUE,
|
||||
`password` VARCHAR(255) NOT NULL,
|
||||
`role` ENUM('admin', 'petugas', 'koordinator', 'kontributor') NOT NULL DEFAULT 'kontributor',
|
||||
`rumah_ibadah_id` INT(11) NULL DEFAULT NULL COMMENT 'Terhubung ke rumah ibadah tertentu untuk Petugas & Koordinator',
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP(),
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP() ON UPDATE CURRENT_TIMESTAMP(),
|
||||
PRIMARY KEY (`id`),
|
||||
CONSTRAINT `fk_users_rumah_ibadah` FOREIGN KEY (`rumah_ibadah_id`) REFERENCES `rumah_ibadah` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
-- 2. Membuat tabel 'kebutuhan' rumah ibadah
|
||||
CREATE TABLE IF NOT EXISTS `kebutuhan` (
|
||||
`id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||
`rumah_ibadah_id` INT(11) NOT NULL,
|
||||
`jenis_bantuan` VARCHAR(150) NOT NULL COMMENT 'Contoh: Beras, Uang Operasional, Jasa Renovasi, dll',
|
||||
`kategori` ENUM('uang', 'barang', 'jasa') NOT NULL DEFAULT 'barang',
|
||||
`satuan` VARCHAR(32) NULL DEFAULT 'unit' COMMENT 'satuan untuk jumlah_dibutuhkan (orang/jam/shift/unit/nominal)',
|
||||
`jumlah_dibutuhkan` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP(),
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP() ON UPDATE CURRENT_TIMESTAMP(),
|
||||
PRIMARY KEY (`id`),
|
||||
CONSTRAINT `fk_kebutuhan_rumah_ibadah` FOREIGN KEY (`rumah_ibadah_id`) REFERENCES `rumah_ibadah` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
-- 3. Membuat tabel 'kontribusi' oleh kontributor
|
||||
CREATE TABLE IF NOT EXISTS `kontribusi` (
|
||||
`id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT(11) NOT NULL,
|
||||
`kebutuhan_id` INT(11) NOT NULL,
|
||||
`jenis_kontribusi` ENUM('uang', 'barang', 'jasa') NOT NULL,
|
||||
`jumlah` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
|
||||
`detail_barang_jasa` TEXT NULL COMMENT 'Deskripsi detail barang/jasa yang disumbangkan',
|
||||
`status` ENUM('pending', 'confirmed') NOT NULL DEFAULT 'pending',
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP(),
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP() ON UPDATE CURRENT_TIMESTAMP(),
|
||||
PRIMARY KEY (`id`),
|
||||
CONSTRAINT `fk_kontribusi_users` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_kontribusi_kebutuhan` FOREIGN KEY (`kebutuhan_id`) REFERENCES `kebutuhan` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
-- 4. Seed User Awal (Password: password123)
|
||||
-- Hash bcrypt untuk password123: $2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi
|
||||
INSERT INTO `users` (`username`, `email`, `password`, `role`, `rumah_ibadah_id`)
|
||||
VALUES
|
||||
('admin', 'admin@webgis.com', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'admin', NULL)
|
||||
ON DUPLICATE KEY UPDATE `username`=`username`;
|
||||
|
||||
INSERT INTO `users` (`username`, `email`, `password`, `role`, `rumah_ibadah_id`)
|
||||
VALUES
|
||||
('petugas', 'petugas@webgis.com', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'petugas', 1)
|
||||
ON DUPLICATE KEY UPDATE `username`=`username`;
|
||||
|
||||
INSERT INTO `users` (`username`, `email`, `password`, `role`, `rumah_ibadah_id`)
|
||||
VALUES
|
||||
('koordinator', 'koordinator@webgis.com', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'koordinator', 1)
|
||||
ON DUPLICATE KEY UPDATE `username`=`username`;
|
||||
|
||||
INSERT INTO `users` (`username`, `email`, `password`, `role`, `rumah_ibadah_id`)
|
||||
VALUES
|
||||
('kontributor', 'kontributor@webgis.com', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'kontributor', NULL)
|
||||
ON DUPLICATE KEY UPDATE `username`=`username`;
|
||||
|
||||
-- 5. Seed Kebutuhan Awal untuk Demonstrasi
|
||||
INSERT INTO `kebutuhan` (`id`, `rumah_ibadah_id`, `jenis_bantuan`, `kategori`, `jumlah_dibutuhkan`)
|
||||
VALUES
|
||||
(1, 1, 'Beras Sembako (KK)', 'barang', 10.00),
|
||||
(2, 1, 'Dana Operasional Santunan', 'uang', 5000000.00),
|
||||
(3, 2, 'Relawan Pembagi Paket Beras', 'jasa', 5.00)
|
||||
ON DUPLICATE KEY UPDATE `id`=`id`;
|
||||
|
||||
-- Backfill/ensure `satuan` exists for existing installations
|
||||
ALTER TABLE `kebutuhan` ADD COLUMN IF NOT EXISTS `satuan` VARCHAR(32) NULL DEFAULT 'unit' COMMENT 'satuan untuk jumlah_dibutuhkan (orang/jam/shift/unit/nominal)';
|
||||
+1226
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
+688
@@ -0,0 +1,688 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WebGIS Portal — Devi Natalia</title>
|
||||
<meta name="description" content="Portal WebGIS Devi Natalia — Sistem Informasi Geografis C, Kota Pontianak.">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:ital,wght@0,400;0,500;0,600;1,400&family=Plus+Jakarta+Sans:wght@700;800&display=swap"
|
||||
rel="stylesheet">
|
||||
<style>
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
--ink: #12153d;
|
||||
--ink-mid: #3730a3;
|
||||
--violet: #4f46e5;
|
||||
--violet-lt: #818cf8;
|
||||
--violet-xs: #eef2ff;
|
||||
--slate: #64748b;
|
||||
--border: #e8eaf6;
|
||||
--surface: #ffffff;
|
||||
--bg: #f7f8fd;
|
||||
|
||||
--c1: #4f46e5;
|
||||
/* indigo — proj 02 */
|
||||
--c1-lt: #eef2ff;
|
||||
--c1-bd: #c7d2fe;
|
||||
|
||||
--c2: #0891b2;
|
||||
/* cyan — proj 03 */
|
||||
--c2-lt: #ecfeff;
|
||||
--c2-bd: #a5f3fc;
|
||||
|
||||
--c3: #7c3aed;
|
||||
/* purple — proj 04 */
|
||||
--c3-lt: #f5f3ff;
|
||||
--c3-bd: #ddd6fe;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ─────────── NAV ─────────── */
|
||||
nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: rgba(247, 248, 253, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0 2rem;
|
||||
height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.nav-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.nav-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--violet);
|
||||
}
|
||||
|
||||
.nav-name {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.nav-badge {
|
||||
font-size: 0.73rem;
|
||||
color: var(--slate);
|
||||
background: var(--violet-xs);
|
||||
border: 1px solid var(--c1-bd);
|
||||
border-radius: 5px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
/* ─────────── HERO ─────────── */
|
||||
.hero {
|
||||
background: var(--ink);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 5.5rem 2rem 4.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Subtle dot grid */
|
||||
.hero::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: radial-gradient(rgba(129, 140, 248, 0.25) 1px, transparent 1px);
|
||||
background-size: 28px 28px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Single soft glow */
|
||||
.hero-glow {
|
||||
position: absolute;
|
||||
width: 560px;
|
||||
height: 340px;
|
||||
background: radial-gradient(ellipse, rgba(79, 70, 229, 0.35) 0%, transparent 70%);
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-inner {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid rgba(129, 140, 248, 0.3);
|
||||
color: #a5b4fc;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
border-radius: 999px;
|
||||
padding: 0.3rem 1rem;
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
.hero-chip-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: #818cf8;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
font-size: clamp(1.9rem, 5.5vw, 3rem);
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
color: #ffffff;
|
||||
letter-spacing: -0.035em;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.hero h1 em {
|
||||
font-style: normal;
|
||||
color: var(--violet-lt);
|
||||
}
|
||||
|
||||
.hero-sub {
|
||||
font-size: 0.975rem;
|
||||
color: #94a3b8;
|
||||
line-height: 1.7;
|
||||
max-width: 480px;
|
||||
margin: 0 auto 3rem;
|
||||
}
|
||||
|
||||
/* Identity block */
|
||||
.hero-id {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(129, 140, 248, 0.18);
|
||||
border-radius: 14px;
|
||||
padding: 1rem 2rem;
|
||||
}
|
||||
|
||||
.hero-id-name {
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.hero-id-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 0.78rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.hero-id-sep {
|
||||
color: rgba(148, 163, 184, 0.4);
|
||||
}
|
||||
|
||||
/* ─────────── MAIN ─────────── */
|
||||
main {
|
||||
flex: 1;
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
padding: 3.5rem 2rem 4rem;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
.section-head-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--slate);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.section-head-rule {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.section-head-count {
|
||||
font-size: 0.72rem;
|
||||
color: var(--slate);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ─────────── CARDS ─────────── */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(295px, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
transition: transform 0.22s ease, box-shadow 0.22s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 16px 40px rgba(18, 21, 61, 0.09);
|
||||
}
|
||||
|
||||
/* Top accent line */
|
||||
.card-line {
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.c1 .card-line {
|
||||
background: linear-gradient(90deg, var(--c1), #a5b4fc);
|
||||
}
|
||||
|
||||
.c2 .card-line {
|
||||
background: linear-gradient(90deg, var(--c2), #67e8f9);
|
||||
}
|
||||
|
||||
.c3 .card-line {
|
||||
background: linear-gradient(90deg, var(--c3), #c4b5fd);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 1.6rem 1.6rem 1.4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.1rem;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 11px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.35rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.c1 .card-icon {
|
||||
background: var(--c1-lt);
|
||||
}
|
||||
|
||||
.c2 .card-icon {
|
||||
background: var(--c2-lt);
|
||||
}
|
||||
|
||||
.c3 .card-icon {
|
||||
background: var(--c3-lt);
|
||||
}
|
||||
|
||||
.card-num {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
border-radius: 5px;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
.c1 .card-num {
|
||||
color: var(--c1);
|
||||
background: var(--c1-lt);
|
||||
border: 1px solid var(--c1-bd);
|
||||
}
|
||||
|
||||
.c2 .card-num {
|
||||
color: var(--c2);
|
||||
background: var(--c2-lt);
|
||||
border: 1px solid var(--c2-bd);
|
||||
}
|
||||
|
||||
.c3 .card-num {
|
||||
color: var(--c3);
|
||||
background: var(--c3-lt);
|
||||
border: 1px solid var(--c3-bd);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
font-size: 1.08rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
margin-bottom: 0.6rem;
|
||||
color: var(--ink);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 0.875rem;
|
||||
color: var(--slate);
|
||||
line-height: 1.65;
|
||||
flex: 1;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.card-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
border-radius: 5px;
|
||||
padding: 2px 7px;
|
||||
}
|
||||
|
||||
.c1 .tag {
|
||||
color: var(--c1);
|
||||
background: var(--c1-lt);
|
||||
}
|
||||
|
||||
.c2 .tag {
|
||||
color: var(--c2);
|
||||
background: var(--c2-lt);
|
||||
}
|
||||
|
||||
.c3 .tag {
|
||||
color: var(--c3);
|
||||
background: var(--c3-lt);
|
||||
}
|
||||
|
||||
.card-cta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 1.1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-open {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border-radius: 999px;
|
||||
padding: 0.4rem 1rem;
|
||||
border: 1px solid transparent;
|
||||
transition: gap 0.2s, opacity 0.2s;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.c1 .btn-open {
|
||||
color: var(--c1);
|
||||
background: var(--c1-lt);
|
||||
border-color: var(--c1-bd);
|
||||
}
|
||||
|
||||
.c2 .btn-open {
|
||||
color: var(--c2);
|
||||
background: var(--c2-lt);
|
||||
border-color: var(--c2-bd);
|
||||
}
|
||||
|
||||
.c3 .btn-open {
|
||||
color: var(--c3);
|
||||
background: var(--c3-lt);
|
||||
border-color: var(--c3-bd);
|
||||
}
|
||||
|
||||
.card:hover .btn-open {
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.btn-arrow {
|
||||
display: inline-block;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.card:hover .btn-arrow {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
/* ─────────── FOOTER ─────────── */
|
||||
footer {
|
||||
background: var(--ink);
|
||||
color: #64748b;
|
||||
padding: 1.5rem 2rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.footer-inner {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.footer-left {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.footer-left strong {
|
||||
color: #818cf8;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.footer-nim {
|
||||
background: rgba(129, 140, 248, 0.12);
|
||||
border: 1px solid rgba(129, 140, 248, 0.2);
|
||||
color: #818cf8;
|
||||
border-radius: 5px;
|
||||
padding: 1px 7px;
|
||||
font-family: monospace;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
/* ─────────── ANIMATION ─────────── */
|
||||
@keyframes fadeUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(14px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.hero-inner {
|
||||
animation: fadeUp 0.55s ease both;
|
||||
}
|
||||
|
||||
.card {
|
||||
animation: fadeUp 0.45s ease both;
|
||||
}
|
||||
|
||||
.card:nth-child(1) {
|
||||
animation-delay: 0.08s;
|
||||
}
|
||||
|
||||
.card:nth-child(2) {
|
||||
animation-delay: 0.16s;
|
||||
}
|
||||
|
||||
.card:nth-child(3) {
|
||||
animation-delay: 0.24s;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
.hero-inner,
|
||||
.card {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.hero {
|
||||
padding: 4rem 1.25rem 3.5rem;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 2.5rem 1.25rem 3rem;
|
||||
}
|
||||
|
||||
nav {
|
||||
padding: 0 1.25rem;
|
||||
}
|
||||
|
||||
.footer-inner {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<!-- ── HERO ── -->
|
||||
<section class="hero">
|
||||
<div class="hero-glow"></div>
|
||||
<div class="hero-inner">
|
||||
<div class="hero-chip">
|
||||
<div class="hero-chip-dot"></div>
|
||||
Sistem Informasi Geografis C
|
||||
</div>
|
||||
<h1>Website <em>Sistem Informasi</em><br>Geografis Kota Pontianak</h1>
|
||||
<p class="hero-sub">Kumpulan proyek pemetaan digital interaktif — dari data kependudukan, infrastruktur
|
||||
energi, hingga distribusi bantuan sosial.</p>
|
||||
<div class="hero-id">
|
||||
<span class="hero-id-name">Devi Natalia</span>
|
||||
<div class="hero-id-meta">
|
||||
<span>D1041231027</span>
|
||||
<span class="hero-id-sep">·</span>
|
||||
<span>Sistem Informasi Geografis C</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── PROJECTS ── -->
|
||||
<main>
|
||||
<div class="section-head">
|
||||
<span class="section-head-label">Daftar Proyek</span>
|
||||
<div class="section-head-rule"></div>
|
||||
<span class="section-head-count">3 proyek</span>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
|
||||
<!-- Proyek 02 -->
|
||||
<a href="02/index.html" class="card c1">
|
||||
<div class="card-line"></div>
|
||||
<div class="card-body">
|
||||
<div class="card-head">
|
||||
<div class="card-icon">👥</div>
|
||||
<span class="card-num">Proyek 02</span>
|
||||
</div>
|
||||
<h2 class="card-title">Kepadatan Penduduk Pontianak 2025</h2>
|
||||
<p class="card-desc">Visualisasi sebaran dan kepadatan penduduk per kecamatan di Kota Pontianak
|
||||
dengan peta choropleth interaktif dan data terkini 2025.</p>
|
||||
<div class="card-tags">
|
||||
<span class="tag">Demografi</span>
|
||||
<span class="tag">Choropleth</span>
|
||||
<span class="tag">Kependudukan</span>
|
||||
</div>
|
||||
<div class="card-cta">
|
||||
<span class="btn-open">Buka Proyek <span class="btn-arrow">→</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Proyek 03 -->
|
||||
<a href="03/index.html" class="card c2">
|
||||
<div class="card-line"></div>
|
||||
<div class="card-body">
|
||||
<div class="card-head">
|
||||
<div class="card-icon">⛽</div>
|
||||
<span class="card-num">Proyek 03</span>
|
||||
</div>
|
||||
<h2 class="card-title">Pemetaan SPBU, Jalan & Parsil</h2>
|
||||
<p class="card-desc">SIG infrastruktur yang memetakan lokasi SPBU berdasarkan jam operasional,
|
||||
jaringan jalan, dan bidang parsil tanah dengan filter layer.</p>
|
||||
<div class="card-tags">
|
||||
<span class="tag">Infrastruktur</span>
|
||||
<span class="tag">Layer Filter</span>
|
||||
<span class="tag">Parsil Tanah</span>
|
||||
</div>
|
||||
<div class="card-cta">
|
||||
<span class="btn-open">Buka Proyek <span class="btn-arrow">→</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Proyek 04 -->
|
||||
<a href="04/index.html" class="card c3">
|
||||
<div class="card-line"></div>
|
||||
<div class="card-body">
|
||||
<div class="card-head">
|
||||
<div class="card-icon">🤝</div>
|
||||
<span class="card-num">Proyek 04</span>
|
||||
</div>
|
||||
<h2 class="card-title">WebGIS Bantuan Sosial</h2>
|
||||
<p class="card-desc">Pemetaan penyaluran bansos berbasis lokasi rumah ibadah dan rumah warga,
|
||||
dilengkapi analisis radius layanan dan rekomendasi bantuan.</p>
|
||||
<div class="card-tags">
|
||||
<span class="tag">Bansos</span>
|
||||
<span class="tag">Radius Layanan</span>
|
||||
<span class="tag">Rumah Ibadah</span>
|
||||
</div>
|
||||
<div class="card-cta">
|
||||
<span class="btn-open">Buka Proyek <span class="btn-arrow">→</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- ── FOOTER ── -->
|
||||
<footer>
|
||||
<div class="footer-inner">
|
||||
<span class="footer-left">Tugas <strong>Sistem Informasi Geografis C</strong> · Kota Pontianak, 2025</span>
|
||||
<div class="footer-right">
|
||||
Devi Natalia
|
||||
<span class="footer-nim">D1041231027</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Reference in New Issue
Block a user