add files

This commit is contained in:
2026-06-10 18:32:52 +07:00
parent 12c04e37eb
commit 3430850d69
45 changed files with 7872 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
FROM php:8.2-apache
RUN docker-php-ext-install mysqli pdo pdo_mysql \
&& a2enmod rewrite
COPY . /var/www/html/
RUN chown -R www-data:www-data /var/www/html
+136
View File
@@ -0,0 +1,136 @@
<?php
// ============================================================
// api.php REST API untuk WebGIS SPBU Pontianak
// Endpoint:
// GET api.php → ambil semua SPBU
// POST api.php → tambah SPBU baru
// PUT api.php?id=N → update SPBU
// DELETE api.php?id=N → hapus SPBU
// ============================================================
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { exit(0); }
require_once 'config.php';
$method = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int) $_GET['id'] : null;
// ── Ambil semua SPBU ─────────────────────────────────────────
if ($method === 'GET') {
$conn = getConnection();
$res = $conn->query(
"SELECT id, nama_spbu, nomor_wa, status_24jam,
latitude, longitude, created_at
FROM spbu ORDER BY created_at DESC"
);
$data = [];
while ($row = $res->fetch_assoc()) {
$row['id'] = (int) $row['id'];
$row['latitude'] = (float) $row['latitude'];
$row['longitude'] = (float) $row['longitude'];
$data[] = $row;
}
echo json_encode(['status' => 'success', 'data' => $data]);
$conn->close();
exit;
}
// ── Tambah SPBU baru ─────────────────────────────────────────
if ($method === 'POST') {
$body = json_decode(file_get_contents('php://input'), true);
$nama = trim($body['nama_spbu'] ?? '');
$wa = trim($body['nomor_wa'] ?? '');
$status = in_array($body['status_24jam'] ?? '', ['yes','no'])
? $body['status_24jam'] : 'no';
$lat = isset($body['latitude']) ? (float) $body['latitude'] : null;
$lng = isset($body['longitude']) ? (float) $body['longitude'] : null;
if (!$nama || !$wa || $lat === null || $lng === null) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Data tidak lengkap']);
exit;
}
$conn = getConnection();
$stmt = $conn->prepare(
"INSERT INTO spbu (nama_spbu, nomor_wa, status_24jam, latitude, longitude)
VALUES (?, ?, ?, ?, ?)"
);
$stmt->bind_param('sssdd', $nama, $wa, $status, $lat, $lng);
if ($stmt->execute()) {
echo json_encode([
'status' => 'success',
'message' => 'SPBU berhasil ditambahkan',
'id' => $stmt->insert_id
]);
} else {
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
}
$stmt->close();
$conn->close();
exit;
}
// ── Update SPBU ──────────────────────────────────────────────
if ($method === 'PUT' && $id) {
$body = json_decode(file_get_contents('php://input'), true);
$nama = trim($body['nama_spbu'] ?? '');
$wa = trim($body['nomor_wa'] ?? '');
$status = in_array($body['status_24jam'] ?? '', ['yes','no'])
? $body['status_24jam'] : 'no';
$lat = isset($body['latitude']) ? (float) $body['latitude'] : null;
$lng = isset($body['longitude']) ? (float) $body['longitude'] : null;
if (!$nama || !$wa || $lat === null || $lng === null) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Data tidak lengkap']);
exit;
}
$conn = getConnection();
$stmt = $conn->prepare(
"UPDATE spbu SET nama_spbu=?, nomor_wa=?, status_24jam=?,
latitude=?, longitude=?
WHERE id=?"
);
$stmt->bind_param('sssddi', $nama, $wa, $status, $lat, $lng, $id);
if ($stmt->execute()) {
echo json_encode(['status' => 'success', 'message' => 'SPBU berhasil diupdate']);
} else {
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
}
$stmt->close();
$conn->close();
exit;
}
// ── Hapus SPBU ───────────────────────────────────────────────
if ($method === 'DELETE' && $id) {
$conn = getConnection();
$stmt = $conn->prepare("DELETE FROM spbu WHERE id=?");
$stmt->bind_param('i', $id);
if ($stmt->execute()) {
echo json_encode(['status' => 'success', 'message' => 'SPBU berhasil dihapus']);
} else {
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
}
$stmt->close();
$conn->close();
exit;
}
http_response_code(405);
echo json_encode(['status' => 'error', 'message' => 'Method tidak diizinkan']);
+22
View File
@@ -0,0 +1,22 @@
<?php
// ============================================================
// config.php Konfigurasi Koneksi Database
// ============================================================
define('DB_HOST', getenv('DB_HOST') ?: 'localhost');
define('DB_USER', getenv('DB_USER') ?: 'root');
define('DB_PASS', getenv('DB_PASSWORD') ?: '');
define('DB_NAME', getenv('DB_NAME') ?: 'webgis_spbu');
function getConnection(): mysqli {
$conn = new mysqli(DB_HOST, DB_USER, DB_PASS, DB_NAME);
if ($conn->connect_error) {
http_response_code(500);
die(json_encode([
'status' => 'error',
'message' => 'Koneksi database gagal: ' . $conn->connect_error
]));
}
$conn->set_charset('utf8mb4');
return $conn;
}
+32
View File
@@ -0,0 +1,32 @@
-- ============================================================
-- WebGIS SPBU Pontianak
-- Database Structure
-- ============================================================
CREATE DATABASE IF NOT EXISTS webgis_spbu
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE webgis_spbu;
CREATE TABLE IF NOT EXISTS spbu (
id INT(11) NOT NULL AUTO_INCREMENT,
nama_spbu VARCHAR(150) NOT NULL,
nomor_wa VARCHAR(20) NOT NULL,
status_24jam ENUM('yes','no') NOT NULL DEFAULT 'no',
latitude DECIMAL(10,8) NOT NULL,
longitude DECIMAL(11,8) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ============================================================
-- Sample data beberapa SPBU di Pontianak
-- ============================================================
INSERT INTO spbu (nama_spbu, nomor_wa, status_24jam, latitude, longitude) VALUES
('SPBU Pertamina Jl. Ahmad Yani', '6281234567890', 'yes', -0.0262619, 109.3425923),
('SPBU Shell Jl. Tanjungpura', '6281298765432', 'no', -0.0318750, 109.3319870),
('SPBU Pertamina Jl. Gajahmada', '6281211223344', 'yes', -0.0393160, 109.3266190),
('SPBU Pertamina Jl. Sultan Hamid II', '6281355667788', 'no', -0.0159870, 109.3571200);
+976
View File
@@ -0,0 +1,976 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>WebGIS SPBU — Kota Pontianak</title>
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet"/>
<style>
/* ══════════════════════════════════════
RESET & VARIABLES
══════════════════════════════════════ */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d1117;
--surface: #161b22;
--surface2: #21262d;
--border: #30363d;
--text: #e6edf3;
--muted: #8b949e;
--accent: #f97316;
--accent-dk: #c2591a;
--green: #3fb950;
--red: #f85149;
--radius: 12px;
--shadow: 0 8px 32px rgba(0,0,0,.55);
}
html, body {
height: 100%;
font-family: 'Plus Jakarta Sans', sans-serif;
background: var(--bg);
color: var(--text);
}
/* ══════════════════════════════════════
LAYOUT
══════════════════════════════════════ */
.app {
display: grid;
grid-template-columns: 360px 1fr;
grid-template-rows: 60px 1fr;
height: 100vh;
}
/* ── Header ─────────────────────────── */
header {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 14px;
padding: 0 24px;
background: var(--surface);
border-bottom: 1px solid var(--border);
z-index: 10;
}
.logo-icon {
width: 34px; height: 34px;
background: var(--accent);
border-radius: 8px;
display: grid; place-items: center;
font-size: 18px;
}
header h1 {
font-size: 15px; font-weight: 700;
letter-spacing: -.2px;
}
header h1 span { color: var(--accent); }
.header-meta {
margin-left: auto;
font-family: 'Space Mono', monospace;
font-size: 11px;
color: var(--muted);
background: var(--surface2);
padding: 5px 12px;
border-radius: 20px;
border: 1px solid var(--border);
}
.dot-live {
display: inline-block;
width: 7px; height: 7px;
border-radius: 50%;
background: var(--green);
margin-right: 6px;
animation: pulse 1.8s infinite;
}
@keyframes pulse {
0%,100% { opacity: 1; }
50% { opacity: .35; }
}
/* ── Sidebar ─────────────────────────── */
aside {
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
padding: 18px 20px 14px;
border-bottom: 1px solid var(--border);
}
.sidebar-header h2 {
font-size: 13px; font-weight: 600;
text-transform: uppercase;
letter-spacing: .8px;
color: var(--muted);
margin-bottom: 4px;
}
.count-badge {
font-family: 'Space Mono', monospace;
font-size: 26px; font-weight: 700;
color: var(--accent);
line-height: 1;
}
.count-badge small { font-size: 12px; color: var(--muted); font-family: inherit; }
/* stats row */
.stats-row {
display: flex; gap: 8px;
padding: 12px 20px;
border-bottom: 1px solid var(--border);
}
.stat-pill {
flex: 1; text-align: center;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 4px;
font-size: 11px; color: var(--muted);
}
.stat-pill strong { display: block; font-size: 18px; font-weight: 700; margin-bottom: 2px; }
.stat-pill.green strong { color: var(--green); }
.stat-pill.red strong { color: var(--red); }
/* search */
.search-wrap {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
}
.search-wrap input {
width: 100%;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 12px 8px 34px;
color: var(--text);
font-size: 13px;
font-family: inherit;
outline: none;
transition: border-color .2s;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%238b949e' stroke-width='2'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='m21 21-4.35-4.35'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: 10px center;
}
.search-wrap input:focus { border-color: var(--accent); }
/* list */
#spbu-list {
flex: 1;
overflow-y: auto;
padding: 8px 10px;
}
#spbu-list::-webkit-scrollbar { width: 4px; }
#spbu-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
.spbu-card {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 13px 15px;
margin-bottom: 8px;
cursor: pointer;
transition: border-color .2s, transform .15s;
position: relative;
}
.spbu-card:hover {
border-color: var(--accent);
transform: translateX(3px);
}
.spbu-card.active { border-color: var(--accent); background: #1d2029; }
.card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
.card-name { font-size: 13px; font-weight: 600; line-height: 1.35; }
.badge {
font-size: 10px; font-weight: 700; padding: 2px 8px;
border-radius: 20px; white-space: nowrap; flex-shrink: 0;
letter-spacing: .4px; text-transform: uppercase;
}
.badge.yes { background: rgba(63,185,80,.18); color: var(--green); border: 1px solid var(--green); }
.badge.no { background: rgba(248,81,73,.15); color: var(--red); border: 1px solid var(--red); }
.card-wa {
font-size: 11px; color: var(--muted); margin-top: 5px;
display: flex; align-items: center; gap: 5px;
}
.card-wa svg { flex-shrink: 0; }
.card-actions {
display: flex; gap: 6px; margin-top: 10px;
}
.btn-sm {
flex: 1; padding: 5px 0;
font-size: 11px; font-weight: 600;
border-radius: 6px; cursor: pointer;
border: 1px solid var(--border);
background: var(--surface);
color: var(--muted);
font-family: inherit;
transition: all .18s;
}
.btn-sm:hover { background: var(--accent); color: #fff; border-color: var(--accent); }
.btn-sm.danger:hover { background: var(--red); border-color: var(--red); }
.btn-sm.move:hover { background: #f97316; border-color: #f97316; color: #fff; }
/* add-new CTA */
.cta-box {
padding: 14px 16px;
border-top: 1px solid var(--border);
}
.btn-add {
width: 100%;
background: var(--accent);
color: #fff;
border: none;
border-radius: var(--radius);
padding: 11px;
font-size: 13px; font-weight: 700;
font-family: inherit;
cursor: pointer;
transition: background .2s, transform .15s;
display: flex; align-items: center; justify-content: center; gap: 7px;
}
.btn-add:hover { background: var(--accent-dk); transform: translateY(-1px); }
/* ── Map ─────────────────────────────── */
#map {
width: 100%; height: 100%;
background: #1a1a2e;
}
/* ══════════════════════════════════════
MODAL
══════════════════════════════════════ */
.overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,.7);
backdrop-filter: blur(4px);
z-index: 1000;
display: flex; align-items: center; justify-content: center;
opacity: 0; pointer-events: none;
transition: opacity .25s;
}
.overlay.open { opacity: 1; pointer-events: all; }
.modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
padding: 28px 28px 24px;
width: 420px; max-width: 95vw;
box-shadow: var(--shadow);
transform: translateY(12px);
transition: transform .25s;
}
.overlay.open .modal { transform: translateY(0); }
.modal h3 {
font-size: 16px; font-weight: 700; margin-bottom: 20px;
display: flex; align-items: center; gap: 10px;
}
.modal h3 .icon {
width: 32px; height: 32px;
background: var(--accent);
border-radius: 8px;
display: grid; place-items: center;
font-size: 16px;
}
.form-group { margin-bottom: 16px; }
.form-group label {
display: block; font-size: 12px; font-weight: 600;
color: var(--muted); margin-bottom: 6px;
text-transform: uppercase; letter-spacing: .5px;
}
.form-group input,
.form-group select {
width: 100%;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 14px;
color: var(--text);
font-size: 14px;
font-family: inherit;
outline: none;
transition: border-color .2s;
appearance: none;
}
.form-group input:focus,
.form-group select:focus { border-color: var(--accent); }
.coord-row {
display: grid; grid-template-columns: 1fr 1fr; gap: 10px;
}
.form-actions {
display: flex; gap: 10px; margin-top: 20px;
}
.btn-cancel {
flex: 1; padding: 11px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--muted);
font-size: 13px; font-weight: 600;
font-family: inherit; cursor: pointer;
transition: all .2s;
}
.btn-cancel:hover { border-color: var(--red); color: var(--red); }
.btn-save {
flex: 2; padding: 11px;
background: var(--accent);
border: none; border-radius: var(--radius);
color: #fff;
font-size: 13px; font-weight: 700;
font-family: inherit; cursor: pointer;
transition: background .2s;
}
.btn-save:hover { background: var(--accent-dk); }
/* coord hint */
.coord-hint {
font-size: 11px; color: var(--muted);
background: var(--surface2);
border: 1px dashed var(--border);
border-radius: 8px;
padding: 8px 12px; margin-bottom: 16px;
display: flex; align-items: center; gap: 7px;
}
/* ── Floating bar pindah titik ───────── */
#move-bar {
position: absolute;
bottom: 32px; left: 50%; transform: translateX(-50%) translateY(80px);
z-index: 900;
background: var(--surface);
border: 1.5px solid var(--accent);
border-radius: 40px;
padding: 10px 16px 10px 20px;
display: flex; align-items: center; gap: 12px;
box-shadow: 0 8px 28px rgba(0,0,0,.6), 0 0 0 4px rgba(249,115,22,.12);
opacity: 0; pointer-events: none;
transition: transform .3s cubic-bezier(.34,1.56,.64,1), opacity .25s;
white-space: nowrap;
}
#move-bar.active {
transform: translateX(-50%) translateY(0);
opacity: 1; pointer-events: all;
}
.move-bar-label {
font-size: 12px; font-weight: 600; color: var(--accent);
display: flex; align-items: center; gap: 7px;
}
.move-bar-label .pulse-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--accent);
animation: pulse 1.2s infinite;
}
.btn-confirm {
padding: 6px 16px;
background: var(--green); border: none;
border-radius: 20px; color: #fff;
font-size: 12px; font-weight: 700;
font-family: inherit; cursor: pointer;
transition: opacity .2s;
}
.btn-confirm:hover { opacity: .85; }
.btn-cancel-move {
padding: 6px 14px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 20px; color: var(--muted);
font-size: 12px; font-weight: 600;
font-family: inherit; cursor: pointer;
transition: all .2s;
}
.btn-cancel-move:hover { border-color: var(--red); color: var(--red); }
/* ── Toast ───────────────────────────── */
#toast {
position: fixed; bottom: 24px; right: 24px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px 18px;
font-size: 13px; font-weight: 500;
box-shadow: var(--shadow);
z-index: 9999;
transform: translateY(20px);
opacity: 0;
transition: all .3s;
display: flex; align-items: center; gap: 9px;
}
#toast.show { transform: translateY(0); opacity: 1; }
#toast.success { border-color: var(--green); }
#toast.error { border-color: var(--red); }
/* ── Leaflet popup custom ────────────── */
.leaflet-popup-content-wrapper {
background: var(--surface) !important;
border: 1px solid var(--border) !important;
border-radius: 12px !important;
box-shadow: var(--shadow) !important;
color: var(--text) !important;
}
.leaflet-popup-tip { background: var(--surface) !important; }
.leaflet-popup-content { margin: 14px 16px !important; }
.popup-title { font-weight: 700; font-size: 14px; margin-bottom: 6px; }
.popup-wa { font-size: 12px; color: var(--muted); }
.popup-status { margin-top: 8px; }
/* empty state */
.empty-state {
text-align: center;
padding: 40px 20px;
color: var(--muted);
font-size: 13px;
}
.empty-state .emoji { font-size: 36px; margin-bottom: 10px; }
@media (max-width: 640px) {
.app { grid-template-columns: 1fr; grid-template-rows: 60px 1fr 260px; }
aside { border-right: none; border-bottom: 1px solid var(--border); }
}
</style>
</head>
<body>
<div class="app">
<!-- ═══════════ HEADER ═══════════ -->
<header>
<div class="logo-icon"></div>
<h1>WebGIS <span>SPBU</span> Pontianak</h1>
<div class="header-meta">
<span class="dot-live"></span>Live Map
</div>
</header>
<!-- ═══════════ SIDEBAR ═══════════ -->
<aside>
<div class="sidebar-header">
<h2>Total SPBU</h2>
<div class="count-badge" id="total-count">0 <small>titik</small></div>
</div>
<div class="stats-row">
<div class="stat-pill green">
<strong id="stat-24">0</strong>
24 Jam
</div>
<div class="stat-pill red">
<strong id="stat-non24">0</strong>
Non-24 Jam
</div>
</div>
<div class="search-wrap">
<input type="text" id="search-input" placeholder="Cari nama SPBU…" oninput="filterList()"/>
</div>
<div id="spbu-list">
<div class="empty-state">
<div class="emoji">🗺️</div>
Memuat data…
</div>
</div>
<div class="cta-box">
<button class="btn-add" onclick="openAddMode()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Klik Peta untuk Tambah SPBU
</button>
</div>
</aside>
<!-- ═══════════ MAP ═══════════ -->
<div id="map" style="position:relative;">
<!-- Floating bar konfirmasi pindah titik -->
<div id="move-bar">
<div class="move-bar-label">
<span class="pulse-dot"></span>
Geser pin ke lokasi baru
</div>
<button class="btn-confirm" onclick="confirmMove()">✅ Konfirmasi</button>
<button class="btn-cancel-move" onclick="cancelMove()">✕ Batal</button>
</div>
</div>
</div>
<!-- ═══════════ MODAL FORM ═══════════ -->
<div class="overlay" id="modal-overlay">
<div class="modal">
<h3>
<span class="icon"></span>
<span id="modal-title">Tambah SPBU</span>
</h3>
<div class="coord-hint" id="coord-hint">
📍 Koordinat diambil dari titik yang Anda klik di peta
</div>
<div class="form-group">
<label>Nama SPBU</label>
<input type="text" id="f-nama" placeholder="Contoh: SPBU Pertamina Jl. Ahmad Yani"/>
</div>
<div class="form-group">
<label>Nomor WhatsApp</label>
<input type="text" id="f-wa" placeholder="Contoh: 6281234567890"/>
</div>
<div class="form-group">
<label>Status 24 Jam</label>
<select id="f-status">
<option value="yes">✅ Ya Buka 24 Jam</option>
<option value="no">❌ Tidak Jam Terbatas</option>
</select>
</div>
<div class="coord-row">
<div class="form-group">
<label>Latitude</label>
<input type="number" id="f-lat" step="0.000001" placeholder="-0.0262619"/>
</div>
<div class="form-group">
<label>Longitude</label>
<input type="number" id="f-lng" step="0.000001" placeholder="109.3425923"/>
</div>
</div>
<div class="form-actions">
<button class="btn-cancel" onclick="closeModal()">Batal</button>
<button class="btn-save" onclick="saveSpbu()">💾 Simpan</button>
</div>
</div>
</div>
<!-- Toast -->
<div id="toast"></div>
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
// ═══════════════════════════════════════════════════════════
// STATE
// ═══════════════════════════════════════════════════════════
let spbuData = [];
let markers = {}; // id → marker
let editId = null;
let addMode = false;
let tempMarker = null;
// ═══════════════════════════════════════════════════════════
// MAP INIT — center: Pontianak
// ═══════════════════════════════════════════════════════════
const map = L.map('map', {
center: [-0.0263, 109.3425],
zoom: 13,
zoomControl: false
});
L.control.zoom({ position: 'bottomright' }).addTo(map);
// Tile layer OpenStreetMap
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© <a href="https://openstreetmap.org">OpenStreetMap</a> contributors',
maxZoom: 19
}).addTo(map);
// ── Custom icons ──────────────────────────────────────────
function makeIcon(is24) {
return L.divIcon({
className: '',
html: `<div style="
width:36px;height:36px;
background:${is24 ? '#3fb950' : '#f97316'};
border:3px solid #fff;
border-radius:50% 50% 50% 0;
transform:rotate(-45deg);
box-shadow:0 3px 10px rgba(0,0,0,.5);
"></div>`,
iconSize: [36, 36],
iconAnchor: [18, 36],
popupAnchor: [0, -38]
});
}
const tempIcon = L.divIcon({
className: '',
html: `<div style="
width:34px;height:34px;
background:#f97316;
border:3px solid #fff;
border-radius:50% 50% 50% 0;
transform:rotate(-45deg);
box-shadow:0 3px 14px rgba(249,115,22,.7);
animation: blink .7s infinite alternate;
"></div>
<style>@keyframes blink{from{opacity:1}to{opacity:.4}}</style>`,
iconSize: [34, 34], iconAnchor: [17, 34], popupAnchor: [0, -36]
});
// ═══════════════════════════════════════════════════════════
// API CALLS
// ═══════════════════════════════════════════════════════════
const API = 'api.php';
async function fetchAll() {
try {
const r = await fetch(API);
const j = await r.json();
if (j.status === 'success') {
spbuData = j.data;
renderList();
renderMarkers();
updateStats();
}
} catch(e) { showToast('Gagal memuat data. Pastikan server berjalan.', 'error'); }
}
async function saveSpbu() {
const nama = document.getElementById('f-nama').value.trim();
const wa = document.getElementById('f-wa').value.trim();
const status = document.getElementById('f-status').value;
const lat = parseFloat(document.getElementById('f-lat').value);
const lng = parseFloat(document.getElementById('f-lng').value);
if (!nama || !wa || isNaN(lat) || isNaN(lng)) {
showToast('Harap isi semua field dengan benar!', 'error'); return;
}
const payload = { nama_spbu: nama, nomor_wa: wa, status_24jam: status, latitude: lat, longitude: lng };
try {
const r = await fetch(API + (editId ? `?id=${editId}` : ''), {
method : editId ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify(payload)
});
const j = await r.json();
if (j.status === 'success') {
showToast(editId ? 'SPBU berhasil diperbarui! ✏️' : 'SPBU berhasil ditambahkan! ⛽', 'success');
closeModal();
fetchAll();
} else {
showToast('Gagal menyimpan: ' + j.message, 'error');
}
} catch(e) { showToast('Error koneksi ke server.', 'error'); }
}
async function deleteSpbu(id) {
if (!confirm('Yakin hapus SPBU ini?')) return;
try {
const r = await fetch(`${API}?id=${id}`, { method: 'DELETE' });
const j = await r.json();
if (j.status === 'success') {
showToast('SPBU berhasil dihapus! 🗑️', 'success');
fetchAll();
}
} catch(e) { showToast('Gagal menghapus.', 'error'); }
}
// ═══════════════════════════════════════════════════════════
// RENDER
// ═══════════════════════════════════════════════════════════
function renderMarkers() {
// clear old
Object.values(markers).forEach(m => map.removeLayer(m));
markers = {};
spbuData.forEach(s => {
const m = L.marker([s.latitude, s.longitude], { icon: makeIcon(s.status_24jam === 'yes') })
.addTo(map)
.bindPopup(popupHTML(s));
m.on('click', () => highlightCard(s.id));
markers[s.id] = m;
});
}
function popupHTML(s) {
return `
<div class="popup-title">⛽ ${escHtml(s.nama_spbu)}</div>
<div class="popup-wa">
📱 ${escHtml(s.nomor_wa)}
</div>
<div class="popup-status">
<span class="badge ${s.status_24jam}">
${s.status_24jam === 'yes' ? '✅ Buka 24 Jam' : '❌ Jam Terbatas'}
</span>
</div>
<div style="margin-top:10px;display:flex;gap:6px;flex-wrap:wrap">
<button onclick="openEdit(${s.id})" style="flex:1;padding:6px 0;font-size:11px;font-weight:600;border-radius:6px;cursor:pointer;border:1px solid #30363d;background:#21262d;color:#e6edf3;font-family:inherit">✏️ Edit</button>
<button onclick="startMovePin(${s.id})" style="flex:1;padding:6px 0;font-size:11px;font-weight:600;border-radius:6px;cursor:pointer;border:1px solid #f97316;background:rgba(249,115,22,.1);color:#f97316;font-family:inherit">📌 Pindahkan</button>
<button onclick="deleteSpbu(${s.id})" style="flex:1;padding:6px 0;font-size:11px;font-weight:600;border-radius:6px;cursor:pointer;border:1px solid #f85149;background:rgba(248,81,73,.1);color:#f85149;font-family:inherit">🗑️ Hapus</button>
</div>`;
}
function renderList(filter = '') {
const list = document.getElementById('spbu-list');
const q = filter.toLowerCase();
const filtered = spbuData.filter(s => s.nama_spbu.toLowerCase().includes(q));
if (!filtered.length) {
list.innerHTML = `<div class="empty-state"><div class="emoji">${filter ? '🔍' : '📍'}</div>${filter ? 'Tidak ditemukan' : 'Belum ada data SPBU'}</div>`;
return;
}
list.innerHTML = filtered.map(s => `
<div class="spbu-card" id="card-${s.id}" onclick="flyTo(${s.id})">
<div class="card-top">
<div class="card-name">${escHtml(s.nama_spbu)}</div>
<span class="badge ${s.status_24jam}">${s.status_24jam === 'yes' ? '24 Jam' : 'Non-24'}</span>
</div>
<div class="card-wa">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07A19.5 19.5 0 013.07 9.8 19.79 19.79 0 01.01 1.18C0 .1.68-.15 1.91.08A2 2 0 013.9 2.05v3a2 2 0 01-1.45 1.93 16 16 0 006.39 6.39A2 2 0 0110.77 12h.1l.07.06"/></svg>
${escHtml(s.nomor_wa)}
</div>
<div class="card-actions">
<button class="btn-sm" onclick="event.stopPropagation();openEdit(${s.id})">✏️ Edit</button>
<button class="btn-sm move" onclick="event.stopPropagation();startMovePin(${s.id})">📌 Pindahkan</button>
<button class="btn-sm danger" onclick="event.stopPropagation();deleteSpbu(${s.id})">🗑️ Hapus</button>
</div>
</div>`
).join('');
}
function updateStats() {
const total = spbuData.length;
const yes = spbuData.filter(s => s.status_24jam === 'yes').length;
document.getElementById('total-count').innerHTML = `${total} <small>titik</small>`;
document.getElementById('stat-24').textContent = yes;
document.getElementById('stat-non24').textContent = total - yes;
}
// ═══════════════════════════════════════════════════════════
// MAP INTERACTIONS
// ═══════════════════════════════════════════════════════════
let addModeActive = false;
function openAddMode() {
addModeActive = true;
map.getContainer().style.cursor = 'crosshair';
showToast('🗺️ Klik pada peta untuk menentukan lokasi SPBU', 'success');
}
map.on('click', function(e) {
if (!addModeActive) return;
addModeActive = false;
map.getContainer().style.cursor = '';
if (tempMarker) map.removeLayer(tempMarker);
tempMarker = L.marker([e.latlng.lat, e.latlng.lng], { icon: tempIcon }).addTo(map);
openModal(null, e.latlng.lat, e.latlng.lng);
});
function flyTo(id) {
const s = spbuData.find(x => x.id === id);
if (!s) return;
map.flyTo([s.latitude, s.longitude], 16, { duration: 1.2 });
setTimeout(() => {
if (markers[id]) markers[id].openPopup();
}, 1300);
highlightCard(id);
}
function highlightCard(id) {
document.querySelectorAll('.spbu-card').forEach(c => c.classList.remove('active'));
const card = document.getElementById(`card-${id}`);
if (card) {
card.classList.add('active');
card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
// ═══════════════════════════════════════════════════════════
// MODAL
// ═══════════════════════════════════════════════════════════
function openModal(id = null, lat = '', lng = '') {
editId = id;
const overlay = document.getElementById('modal-overlay');
document.getElementById('modal-title').textContent = id ? 'Edit SPBU' : 'Tambah SPBU Baru';
if (id) {
const s = spbuData.find(x => x.id == id);
if (!s) { showToast('Data SPBU tidak ditemukan!', 'error'); return; }
document.getElementById('f-nama').value = s.nama_spbu;
document.getElementById('f-wa').value = s.nomor_wa;
document.getElementById('f-status').value = s.status_24jam;
document.getElementById('f-lat').value = s.latitude;
document.getElementById('f-lng').value = s.longitude;
document.getElementById('coord-hint').style.display = 'none';
} else {
document.getElementById('f-nama').value = '';
document.getElementById('f-wa').value = '';
document.getElementById('f-status').value = 'no';
document.getElementById('f-lat').value = lat;
document.getElementById('f-lng').value = lng;
document.getElementById('coord-hint').style.display = 'flex';
}
overlay.classList.add('open');
}
function openEdit(id) {
id = parseInt(id);
if (markers[id]) markers[id].closePopup();
openModal(id);
}
function closeModal(cancelled = true) {
document.getElementById('modal-overlay').classList.remove('open');
editId = null;
if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; }
}
// ═══════════════════════════════════════════════════════════
// PINDAHKAN TITIK drag mode tanpa modal
// ═══════════════════════════════════════════════════════════
let _moveId = null;
let _moveOriginal = null;
function startMovePin(id) {
id = parseInt(id);
const s = spbuData.find(x => x.id == id);
if (!s) return;
// Tutup popup jika terbuka
if (markers[id]) markers[id].closePopup();
_moveId = id;
_moveOriginal = { lat: s.latitude, lng: s.longitude };
const m = markers[id];
m.dragging.enable();
// Ganti icon jadi warna biru/terang sebagai penanda aktif
m.setIcon(L.divIcon({
className: '',
html: `<div style="
width:40px;height:40px;
background:#3b82f6;
border:3px solid #fff;
border-radius:50% 50% 50% 0;
transform:rotate(-45deg);
box-shadow:0 0 0 6px rgba(59,130,246,.3),0 4px 14px rgba(0,0,0,.5);
"></div>`,
iconSize: [40,40], iconAnchor: [20,40], popupAnchor: [0,-42]
}));
// Zoom ke marker agar mudah drag
map.flyTo([s.latitude, s.longitude], 16, { duration: .8 });
// Tampilkan floating bar
document.getElementById('move-bar').classList.add('active');
showToast('📌 Geser pin biru ke lokasi baru lalu klik Konfirmasi', 'success');
}
async function confirmMove() {
if (!_moveId) return;
const m = markers[_moveId];
const ll = m.getLatLng();
const s = spbuData.find(x => x.id == _moveId);
try {
const r = await fetch(`api.php?id=${_moveId}`, {
method : 'PUT',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify({
nama_spbu : s.nama_spbu,
nomor_wa : s.nomor_wa,
status_24jam: s.status_24jam,
latitude : ll.lat,
longitude : ll.lng
})
});
const j = await r.json();
if (j.status === 'success') {
showToast('📍 Lokasi SPBU berhasil dipindahkan!', 'success');
_endMoveMode(false);
fetchAll();
} else {
showToast('Gagal menyimpan: ' + j.message, 'error');
}
} catch(e) { showToast('Error koneksi ke server.', 'error'); }
}
function cancelMove() {
_endMoveMode(true);
showToast('Pemindahan dibatalkan', 'error');
}
function _endMoveMode(restore) {
if (!_moveId) return;
const m = markers[_moveId];
m.dragging.disable();
// Kembalikan posisi & icon asli jika dibatalkan
if (restore && _moveOriginal) {
m.setLatLng([_moveOriginal.lat, _moveOriginal.lng]);
}
const s = spbuData.find(x => x.id == _moveId);
if (s) m.setIcon(makeIcon(s.status_24jam === 'yes'));
document.getElementById('move-bar').classList.remove('active');
_moveId = null;
_moveOriginal = null;
}
document.getElementById('modal-overlay').addEventListener('click', function(e) {
if (e.target === this) closeModal();
});
// ═══════════════════════════════════════════════════════════
// SEARCH
// ═══════════════════════════════════════════════════════════
function filterList() {
const q = document.getElementById('search-input').value;
renderList(q);
}
// ═══════════════════════════════════════════════════════════
// TOAST
// ═══════════════════════════════════════════════════════════
let toastTimer;
function showToast(msg, type = 'success') {
const t = document.getElementById('toast');
t.textContent = msg;
t.className = `show ${type}`;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { t.className = ''; }, 3200);
}
// ═══════════════════════════════════════════════════════════
// UTILS
// ═══════════════════════════════════════════════════════════
function escHtml(str) {
return String(str)
.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ═══════════════════════════════════════════════════════════
// INIT
// ═══════════════════════════════════════════════════════════
// Ekspos ke window agar bisa dipanggil dari dalam popup Leaflet
window.openEdit = openEdit;
window.deleteSpbu = deleteSpbu;
window.startMovePin = startMovePin;
fetchAll();
</script>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
FROM php:8.2-apache
RUN docker-php-ext-install mysqli pdo pdo_mysql \
&& a2enmod rewrite
COPY . /var/www/html/
RUN chown -R www-data:www-data /var/www/html
+136
View File
@@ -0,0 +1,136 @@
<?php
// ============================================================
// api.php REST API untuk WebGIS SPBU Pontianak
// Endpoint:
// GET api.php → ambil semua SPBU
// POST api.php → tambah SPBU baru
// PUT api.php?id=N → update SPBU
// DELETE api.php?id=N → hapus SPBU
// ============================================================
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { exit(0); }
require_once 'config.php';
$method = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int) $_GET['id'] : null;
// ── Ambil semua SPBU ─────────────────────────────────────────
if ($method === 'GET') {
$conn = getConnection();
$res = $conn->query(
"SELECT id, nama_spbu, nomor_wa, status_24jam,
latitude, longitude, created_at
FROM spbu ORDER BY created_at DESC"
);
$data = [];
while ($row = $res->fetch_assoc()) {
$row['id'] = (int) $row['id'];
$row['latitude'] = (float) $row['latitude'];
$row['longitude'] = (float) $row['longitude'];
$data[] = $row;
}
echo json_encode(['status' => 'success', 'data' => $data]);
$conn->close();
exit;
}
// ── Tambah SPBU baru ─────────────────────────────────────────
if ($method === 'POST') {
$body = json_decode(file_get_contents('php://input'), true);
$nama = trim($body['nama_spbu'] ?? '');
$wa = trim($body['nomor_wa'] ?? '');
$status = in_array($body['status_24jam'] ?? '', ['yes','no'])
? $body['status_24jam'] : 'no';
$lat = isset($body['latitude']) ? (float) $body['latitude'] : null;
$lng = isset($body['longitude']) ? (float) $body['longitude'] : null;
if (!$nama || !$wa || $lat === null || $lng === null) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Data tidak lengkap']);
exit;
}
$conn = getConnection();
$stmt = $conn->prepare(
"INSERT INTO spbu (nama_spbu, nomor_wa, status_24jam, latitude, longitude)
VALUES (?, ?, ?, ?, ?)"
);
$stmt->bind_param('sssdd', $nama, $wa, $status, $lat, $lng);
if ($stmt->execute()) {
echo json_encode([
'status' => 'success',
'message' => 'SPBU berhasil ditambahkan',
'id' => $stmt->insert_id
]);
} else {
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
}
$stmt->close();
$conn->close();
exit;
}
// ── Update SPBU ──────────────────────────────────────────────
if ($method === 'PUT' && $id) {
$body = json_decode(file_get_contents('php://input'), true);
$nama = trim($body['nama_spbu'] ?? '');
$wa = trim($body['nomor_wa'] ?? '');
$status = in_array($body['status_24jam'] ?? '', ['yes','no'])
? $body['status_24jam'] : 'no';
$lat = isset($body['latitude']) ? (float) $body['latitude'] : null;
$lng = isset($body['longitude']) ? (float) $body['longitude'] : null;
if (!$nama || !$wa || $lat === null || $lng === null) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Data tidak lengkap']);
exit;
}
$conn = getConnection();
$stmt = $conn->prepare(
"UPDATE spbu SET nama_spbu=?, nomor_wa=?, status_24jam=?,
latitude=?, longitude=?
WHERE id=?"
);
$stmt->bind_param('sssddi', $nama, $wa, $status, $lat, $lng, $id);
if ($stmt->execute()) {
echo json_encode(['status' => 'success', 'message' => 'SPBU berhasil diupdate']);
} else {
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
}
$stmt->close();
$conn->close();
exit;
}
// ── Hapus SPBU ───────────────────────────────────────────────
if ($method === 'DELETE' && $id) {
$conn = getConnection();
$stmt = $conn->prepare("DELETE FROM spbu WHERE id=?");
$stmt->bind_param('i', $id);
if ($stmt->execute()) {
echo json_encode(['status' => 'success', 'message' => 'SPBU berhasil dihapus']);
} else {
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
}
$stmt->close();
$conn->close();
exit;
}
http_response_code(405);
echo json_encode(['status' => 'error', 'message' => 'Method tidak diizinkan']);
+151
View File
@@ -0,0 +1,151 @@
<?php
// ============================================================
// api_jalan.php REST API CRUD Data Jalan (Polyline)
// GET api_jalan.php → semua data jalan
// GET api_jalan.php?id=N → satu data jalan
// POST api_jalan.php → tambah jalan
// PUT api_jalan.php?id=N → update jalan
// DELETE api_jalan.php?id=N → hapus jalan
// ============================================================
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { exit(0); }
require_once 'config.php';
$method = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int) $_GET['id'] : null;
// ── GET ──────────────────────────────────────────────────────
if ($method === 'GET') {
$conn = getConnection();
if ($id) {
$stmt = $conn->prepare("SELECT * FROM jalan WHERE id = ?");
$stmt->bind_param('i', $id);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
if ($row) {
$row['id'] = (int) $row['id'];
$row['panjang_meter'] = (float) $row['panjang_meter'];
$row['geojson'] = json_decode($row['geojson']);
echo json_encode(['status' => 'success', 'data' => $row]);
} else {
http_response_code(404);
echo json_encode(['status' => 'error', 'message' => 'Data tidak ditemukan']);
}
$stmt->close();
} else {
$res = $conn->query("SELECT * FROM jalan ORDER BY created_at DESC");
$data = [];
while ($row = $res->fetch_assoc()) {
$row['id'] = (int) $row['id'];
$row['panjang_meter'] = (float) $row['panjang_meter'];
$row['geojson'] = json_decode($row['geojson']);
$data[] = $row;
}
echo json_encode(['status' => 'success', 'data' => $data]);
}
$conn->close();
exit;
}
// ── POST (Tambah) ─────────────────────────────────────────────
if ($method === 'POST') {
$body = json_decode(file_get_contents('php://input'), true);
$nama = trim($body['nama_jalan'] ?? '');
$status = $body['status_jalan'] ?? '';
$panjang = (float) ($body['panjang_meter'] ?? 0);
$geojson = isset($body['geojson'])
? json_encode($body['geojson'])
: null;
$valid_status = ['Nasional', 'Provinsi', 'Kabupaten'];
if (!$nama || !in_array($status, $valid_status) || !$geojson || $panjang <= 0) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Data tidak lengkap atau tidak valid']);
exit;
}
$conn = getConnection();
$stmt = $conn->prepare(
"INSERT INTO jalan (nama_jalan, status_jalan, panjang_meter, geojson)
VALUES (?, ?, ?, ?)"
);
$stmt->bind_param('ssds', $nama, $status, $panjang, $geojson);
if ($stmt->execute()) {
echo json_encode([
'status' => 'success',
'message' => 'Data jalan berhasil ditambahkan',
'id' => $stmt->insert_id
]);
} else {
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
}
$stmt->close();
$conn->close();
exit;
}
// ── PUT (Update) ──────────────────────────────────────────────
if ($method === 'PUT' && $id) {
$body = json_decode(file_get_contents('php://input'), true);
$nama = trim($body['nama_jalan'] ?? '');
$status = $body['status_jalan'] ?? '';
$panjang = (float) ($body['panjang_meter'] ?? 0);
$geojson = isset($body['geojson'])
? json_encode($body['geojson'])
: null;
$valid_status = ['Nasional', 'Provinsi', 'Kabupaten'];
if (!$nama || !in_array($status, $valid_status) || !$geojson || $panjang <= 0) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Data tidak lengkap atau tidak valid']);
exit;
}
$conn = getConnection();
$stmt = $conn->prepare(
"UPDATE jalan SET nama_jalan=?, status_jalan=?, panjang_meter=?, geojson=?
WHERE id=?"
);
$stmt->bind_param('ssdsi', $nama, $status, $panjang, $geojson, $id);
if ($stmt->execute()) {
echo json_encode(['status' => 'success', 'message' => 'Data jalan berhasil diperbarui']);
} else {
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
}
$stmt->close();
$conn->close();
exit;
}
// ── DELETE ────────────────────────────────────────────────────
if ($method === 'DELETE' && $id) {
$conn = getConnection();
$stmt = $conn->prepare("DELETE FROM jalan WHERE id=?");
$stmt->bind_param('i', $id);
if ($stmt->execute()) {
echo json_encode(['status' => 'success', 'message' => 'Data jalan berhasil dihapus']);
} else {
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
}
$stmt->close();
$conn->close();
exit;
}
http_response_code(405);
echo json_encode(['status' => 'error', 'message' => 'Method tidak diizinkan']);
+146
View File
@@ -0,0 +1,146 @@
<?php
// ============================================================
// api_kerusakan.php REST API untuk Titik Kerusakan Jalan
// Endpoint:
// GET api_kerusakan.php → ambil semua titik kerusakan
// POST api_kerusakan.php → tambah titik baru
// PUT api_kerusakan.php?id=N → update titik
// DELETE api_kerusakan.php?id=N → hapus titik
// ============================================================
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { exit(0); }
require_once 'config.php';
$method = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int) $_GET['id'] : null;
// ── GET: Ambil semua titik kerusakan ──────────────────────────
if ($method === 'GET') {
$conn = getConnection();
$res = $conn->query(
"SELECT id, nama_kerusakan, tingkat_kerusakan, deskripsi,
latitude, longitude, created_at, updated_at
FROM kerusakan_jalan
ORDER BY created_at DESC"
);
$data = [];
while ($row = $res->fetch_assoc()) {
$row['id'] = (int) $row['id'];
$row['latitude'] = (float) $row['latitude'];
$row['longitude'] = (float) $row['longitude'];
$data[] = $row;
}
echo json_encode(['status' => 'success', 'data' => $data]);
$conn->close();
exit;
}
// ── POST: Tambah titik kerusakan baru ─────────────────────────
if ($method === 'POST') {
$body = json_decode(file_get_contents('php://input'), true);
$nama = trim($body['nama_kerusakan'] ?? '');
$tingkat = $body['tingkat_kerusakan'] ?? '';
$deskripsi = trim($body['deskripsi'] ?? '');
$lat = isset($body['latitude']) ? (float) $body['latitude'] : null;
$lng = isset($body['longitude']) ? (float) $body['longitude'] : null;
// Validasi tingkat kerusakan
$allowedTingkat = ['Rusak Berat', 'Rusak Sedang', 'Rusak Ringan'];
if (!in_array($tingkat, $allowedTingkat)) {
$tingkat = 'Rusak Sedang';
}
if (!$nama || $lat === null || $lng === null) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Nama dan koordinat wajib diisi']);
exit;
}
$conn = getConnection();
$stmt = $conn->prepare(
"INSERT INTO kerusakan_jalan (nama_kerusakan, tingkat_kerusakan, deskripsi, latitude, longitude)
VALUES (?, ?, ?, ?, ?)"
);
$stmt->bind_param('sssdd', $nama, $tingkat, $deskripsi, $lat, $lng);
if ($stmt->execute()) {
echo json_encode([
'status' => 'success',
'message' => 'Titik kerusakan berhasil ditambahkan',
'id' => $stmt->insert_id
]);
} else {
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
}
$stmt->close();
$conn->close();
exit;
}
// ── PUT: Update titik kerusakan ───────────────────────────────
if ($method === 'PUT' && $id) {
$body = json_decode(file_get_contents('php://input'), true);
$nama = trim($body['nama_kerusakan'] ?? '');
$tingkat = $body['tingkat_kerusakan'] ?? '';
$deskripsi = trim($body['deskripsi'] ?? '');
$lat = isset($body['latitude']) ? (float) $body['latitude'] : null;
$lng = isset($body['longitude']) ? (float) $body['longitude'] : null;
$allowedTingkat = ['Rusak Berat', 'Rusak Sedang', 'Rusak Ringan'];
if (!in_array($tingkat, $allowedTingkat)) {
$tingkat = 'Rusak Sedang';
}
if (!$nama || $lat === null || $lng === null) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Data tidak lengkap']);
exit;
}
$conn = getConnection();
$stmt = $conn->prepare(
"UPDATE kerusakan_jalan
SET nama_kerusakan=?, tingkat_kerusakan=?, deskripsi=?, latitude=?, longitude=?
WHERE id=?"
);
$stmt->bind_param('sssddi', $nama, $tingkat, $deskripsi, $lat, $lng, $id);
if ($stmt->execute()) {
echo json_encode(['status' => 'success', 'message' => 'Titik kerusakan berhasil diperbarui']);
} else {
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
}
$stmt->close();
$conn->close();
exit;
}
// ── DELETE: Hapus titik kerusakan ─────────────────────────────
if ($method === 'DELETE' && $id) {
$conn = getConnection();
$stmt = $conn->prepare("DELETE FROM kerusakan_jalan WHERE id=?");
$stmt->bind_param('i', $id);
if ($stmt->execute()) {
echo json_encode(['status' => 'success', 'message' => 'Titik kerusakan berhasil dihapus']);
} else {
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
}
$stmt->close();
$conn->close();
exit;
}
http_response_code(405);
echo json_encode(['status' => 'error', 'message' => 'Method tidak diizinkan']);
+154
View File
@@ -0,0 +1,154 @@
<?php
// ============================================================
// api_parsil.php REST API CRUD Data Parsil Tanah (Polygon)
// GET api_parsil.php → semua data parsil
// GET api_parsil.php?id=N → satu data parsil
// POST api_parsil.php → tambah parsil
// PUT api_parsil.php?id=N → update parsil
// DELETE api_parsil.php?id=N → hapus parsil
// ============================================================
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { exit(0); }
require_once 'config.php';
$method = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int) $_GET['id'] : null;
// ── GET ──────────────────────────────────────────────────────
if ($method === 'GET') {
$conn = getConnection();
if ($id) {
$stmt = $conn->prepare("SELECT * FROM parsil_tanah WHERE id = ?");
$stmt->bind_param('i', $id);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
if ($row) {
$row['id'] = (int) $row['id'];
$row['luas_meter2'] = (float) $row['luas_meter2'];
$row['geojson'] = json_decode($row['geojson']);
echo json_encode(['status' => 'success', 'data' => $row]);
} else {
http_response_code(404);
echo json_encode(['status' => 'error', 'message' => 'Data tidak ditemukan']);
}
$stmt->close();
} else {
$res = $conn->query("SELECT * FROM parsil_tanah ORDER BY created_at DESC");
$data = [];
while ($row = $res->fetch_assoc()) {
$row['id'] = (int) $row['id'];
$row['luas_meter2'] = (float) $row['luas_meter2'];
$row['geojson'] = json_decode($row['geojson']);
$data[] = $row;
}
echo json_encode(['status' => 'success', 'data' => $data]);
}
$conn->close();
exit;
}
// ── POST (Tambah) ─────────────────────────────────────────────
if ($method === 'POST') {
$body = json_decode(file_get_contents('php://input'), true);
$nama = trim($body['nama_pemilik'] ?? '');
$no_sert = trim($body['no_sertifikat'] ?? '');
$status = $body['status_sertifikat'] ?? '';
$luas = (float) ($body['luas_meter2'] ?? 0);
$geojson = isset($body['geojson'])
? json_encode($body['geojson'])
: null;
$valid_status = ['SHM', 'HGB', 'HGU', 'HP'];
if (!$nama || !$no_sert || !in_array($status, $valid_status) || !$geojson || $luas <= 0) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Data tidak lengkap atau tidak valid']);
exit;
}
$conn = getConnection();
$stmt = $conn->prepare(
"INSERT INTO parsil_tanah (nama_pemilik, no_sertifikat, status_sertifikat, luas_meter2, geojson)
VALUES (?, ?, ?, ?, ?)"
);
$stmt->bind_param('sssds', $nama, $no_sert, $status, $luas, $geojson);
if ($stmt->execute()) {
echo json_encode([
'status' => 'success',
'message' => 'Data parsil berhasil ditambahkan',
'id' => $stmt->insert_id
]);
} else {
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
}
$stmt->close();
$conn->close();
exit;
}
// ── PUT (Update) ──────────────────────────────────────────────
if ($method === 'PUT' && $id) {
$body = json_decode(file_get_contents('php://input'), true);
$nama = trim($body['nama_pemilik'] ?? '');
$no_sert = trim($body['no_sertifikat'] ?? '');
$status = $body['status_sertifikat'] ?? '';
$luas = (float) ($body['luas_meter2'] ?? 0);
$geojson = isset($body['geojson'])
? json_encode($body['geojson'])
: null;
$valid_status = ['SHM', 'HGB', 'HGU', 'HP'];
if (!$nama || !$no_sert || !in_array($status, $valid_status) || !$geojson || $luas <= 0) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Data tidak lengkap atau tidak valid']);
exit;
}
$conn = getConnection();
$stmt = $conn->prepare(
"UPDATE parsil_tanah
SET nama_pemilik=?, no_sertifikat=?, status_sertifikat=?, luas_meter2=?, geojson=?
WHERE id=?"
);
$stmt->bind_param('sssdsi', $nama, $no_sert, $status, $luas, $geojson, $id);
if ($stmt->execute()) {
echo json_encode(['status' => 'success', 'message' => 'Data parsil berhasil diperbarui']);
} else {
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
}
$stmt->close();
$conn->close();
exit;
}
// ── DELETE ────────────────────────────────────────────────────
if ($method === 'DELETE' && $id) {
$conn = getConnection();
$stmt = $conn->prepare("DELETE FROM parsil_tanah WHERE id=?");
$stmt->bind_param('i', $id);
if ($stmt->execute()) {
echo json_encode(['status' => 'success', 'message' => 'Data parsil berhasil dihapus']);
} else {
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
}
$stmt->close();
$conn->close();
exit;
}
http_response_code(405);
echo json_encode(['status' => 'error', 'message' => 'Method tidak diizinkan']);
+22
View File
@@ -0,0 +1,22 @@
<?php
// ============================================================
// config.php Konfigurasi Koneksi Database
// ============================================================
define('DB_HOST', getenv('DB_HOST') ?: 'localhost');
define('DB_USER', getenv('DB_USER') ?: 'root');
define('DB_PASS', getenv('DB_PASSWORD') ?: '');
define('DB_NAME', getenv('DB_NAME') ?: 'webgis_jalan_parsil');
function getConnection(): mysqli {
$conn = new mysqli(DB_HOST, DB_USER, DB_PASS, DB_NAME);
if ($conn->connect_error) {
http_response_code(500);
die(json_encode([
'status' => 'error',
'message' => 'Koneksi database gagal: ' . $conn->connect_error
]));
}
$conn->set_charset('utf8mb4');
return $conn;
}
+74
View File
@@ -0,0 +1,74 @@
-- ============================================================
-- WebGIS Manajemen Jalan & Parsil Tanah
-- Database Structure
-- Geometri disimpan dalam format GeoJSON (TEXT)
-- ============================================================
CREATE DATABASE IF NOT EXISTS webgis_jalan_parsil
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE webgis_jalan_parsil;
-- ============================================================
-- Tabel: jalan (Data Line / Polyline)
-- ============================================================
CREATE TABLE IF NOT EXISTS jalan (
id INT(11) NOT NULL AUTO_INCREMENT,
nama_jalan VARCHAR(200) NOT NULL,
status_jalan ENUM('Nasional','Provinsi','Kabupaten') NOT NULL,
panjang_meter DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT 'Dihitung otomatis oleh LeafletJS (meter)',
geojson TEXT NOT NULL COMMENT 'Geometri GeoJSON LineString',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Data jalan berupa polyline GeoJSON';
-- ============================================================
-- Tabel: parsil_tanah (Data Polygon)
-- ============================================================
CREATE TABLE IF NOT EXISTS parsil_tanah (
id INT(11) NOT NULL AUTO_INCREMENT,
nama_pemilik VARCHAR(200) NOT NULL,
no_sertifikat VARCHAR(100) NOT NULL,
status_sertifikat ENUM('SHM','HGB','HGU','HP') NOT NULL,
luas_meter2 DECIMAL(14,2) NOT NULL DEFAULT 0.00 COMMENT 'Dihitung otomatis oleh LeafletJS (m²)',
geojson TEXT NOT NULL COMMENT 'Geometri GeoJSON Polygon',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Data parsil tanah berupa polygon GeoJSON';
-- ============================================================
-- Sample data Jalan (di sekitar Pontianak)
-- ============================================================
INSERT INTO jalan (nama_jalan, status_jalan, panjang_meter, geojson) VALUES
(
'Jl. Ahmad Yani', 'Nasional', 2450.50,
'{"type":"LineString","coordinates":[[109.3300,-0.0200],[109.3380,-0.0240],[109.3450,-0.0270],[109.3520,-0.0290]]}'
),
(
'Jl. Sultan Syarif Abdurrahman', 'Provinsi', 1820.30,
'{"type":"LineString","coordinates":[[109.3250,-0.0350],[109.3310,-0.0330],[109.3370,-0.0310],[109.3420,-0.0290]]}'
),
(
'Jl. Parit H. Husin II', 'Kabupaten', 980.75,
'{"type":"LineString","coordinates":[[109.3480,-0.0420],[109.3510,-0.0400],[109.3540,-0.0380]]}'
);
-- ============================================================
-- Sample data Parsil Tanah (di sekitar Pontianak)
-- ============================================================
INSERT INTO parsil_tanah (nama_pemilik, no_sertifikat, status_sertifikat, luas_meter2, geojson) VALUES
(
'Budi Santoso', 'SHM-PTK-001234', 'SHM', 520.00,
'{"type":"Polygon","coordinates":[[[109.3320,-0.0280],[109.3330,-0.0280],[109.3330,-0.0290],[109.3320,-0.0290],[109.3320,-0.0280]]]}'
),
(
'PT. Maju Jaya', 'HGB-PTK-005678', 'HGB', 1250.00,
'{"type":"Polygon","coordinates":[[[109.3390,-0.0320],[109.3410,-0.0320],[109.3410,-0.0335],[109.3390,-0.0335],[109.3390,-0.0320]]]}'
),
(
'Koperasi Tani Makmur', 'HGU-PTK-009012', 'HGU', 3800.00,
'{"type":"Polygon","coordinates":[[[109.3460,-0.0380],[109.3490,-0.0380],[109.3490,-0.0405],[109.3460,-0.0405],[109.3460,-0.0380]]]}'
);
+1311
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
-- ============================================================
-- MIGRATION: Tambah Tabel Kerusakan Jalan
-- Jalankan query ini di database webgis_jalan_parsil yang sudah ada
-- (JANGAN jalankan ulang database.sql yang lama, cukup file ini saja)
-- ============================================================
USE webgis_jalan_parsil;
-- ============================================================
-- Tabel: kerusakan_jalan (Data Point / Marker)
-- ============================================================
CREATE TABLE IF NOT EXISTS kerusakan_jalan (
id INT(11) NOT NULL AUTO_INCREMENT,
nama_kerusakan VARCHAR(250) NOT NULL COMMENT 'Nama atau deskripsi singkat lokasi kerusakan',
tingkat_kerusakan ENUM('Rusak Berat','Rusak Sedang','Rusak Ringan') NOT NULL DEFAULT 'Rusak Sedang',
deskripsi TEXT COMMENT 'Deskripsi detail kondisi kerusakan (opsional)',
latitude DECIMAL(10,7) NOT NULL COMMENT 'Koordinat lintang (dari klik peta)',
longitude DECIMAL(10,7) NOT NULL COMMENT 'Koordinat bujur (dari klik peta)',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
INDEX idx_tingkat (tingkat_kerusakan),
INDEX idx_coords (latitude, longitude)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Titik kerusakan jalan berupa point marker';
-- ============================================================
-- Sample data Kerusakan Jalan (di sekitar Pontianak)
-- ============================================================
INSERT INTO kerusakan_jalan (nama_kerusakan, tingkat_kerusakan, deskripsi, latitude, longitude) VALUES
(
'Lubang Besar Jl. Ahmad Yani KM3',
'Rusak Berat',
'Lubang berdiameter ±1.5 meter, kedalaman 30cm, berbahaya untuk kendaraan roda dua.',
-0.0240, 109.3380
),
(
'Retakan Panjang Jl. Sultan Syarif',
'Rusak Sedang',
'Retakan memanjang sepanjang ±20 meter di bahu jalan, perlu penanganan segera.',
-0.0330, 109.3310
),
(
'Aspal Terkelupas Jl. Parit H. Husin',
'Rusak Ringan',
'Permukaan aspal terkelupas dan aus, belum membentuk lubang namun licin saat hujan.',
-0.0400, 109.3510
);
+8
View File
@@ -0,0 +1,8 @@
FROM php:8.2-apache
RUN docker-php-ext-install mysqli pdo pdo_mysql \
&& a2enmod rewrite
COPY . /var/www/html/
RUN chown -R www-data:www-data /var/www/html
+8
View File
File diff suppressed because one or more lines are too long
+198
View File
@@ -0,0 +1,198 @@
<!DOCTYPE html>
<html lang="id">
<head>
<base target="_top">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Choropleth Kepadatan Penduduk - Kota Pontianak</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: 8px 12px;
font: 14px/16px Arial, Helvetica, sans-serif;
background: white;
background: rgba(255,255,255,0.9);
box-shadow: 0 0 15px rgba(0,0,0,0.2);
border-radius: 5px;
}
.info h4 {
margin: 0 0 5px;
color: #555;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.info .kecamatan-name {
font-weight: bold;
font-size: 15px;
color: #222;
}
.info .density-val {
color: #666;
font-size: 13px;
margin-top: 2px;
}
.legend {
text-align: left;
line-height: 20px;
color: #444;
font: 13px Arial, Helvetica, sans-serif;
}
.legend i {
width: 18px;
height: 18px;
float: left;
margin-right: 8px;
opacity: 0.8;
border-radius: 2px;
}
.legend .title {
font-weight: bold;
font-size: 12px;
text-transform: uppercase;
color: #555;
margin-bottom: 6px;
display: block;
}
</style>
</head>
<body>
<div id="map"></div>
<script type="text/javascript" src="pontianak.js"></script>
<script type="text/javascript">
// Inisialisasi peta dengan koordinat pusat Kota Pontianak
const map = L.map('map').setView([-0.02655429055939295, 109.34202184397597], 13);
// Tile layer OpenStreetMap
const tiles = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map);
// Control info yang muncul saat hover
const info = L.control();
info.onAdd = function (map) {
this._div = L.DomUtil.create('div', 'info');
this.update();
return this._div;
};
info.update = function (props) {
if (props) {
this._div.innerHTML = `
<h4>Kepadatan Penduduk</h4>
<div class="kecamatan-name">${props.name}</div>
<div class="density-val">${props.density.toLocaleString('id-ID')} jiwa / km²</div>
`;
} else {
this._div.innerHTML = `<h4>Kepadatan Penduduk</h4><span style="color:#999;font-size:13px">Arahkan kursor ke kecamatan</span>`;
}
};
info.addTo(map);
// Warna berdasarkan kepadatan penduduk (jiwa/km²)
function getColor(d) {
return d > 10000 ? '#800026' :
d > 8000 ? '#BD0026' :
d > 6000 ? '#E31A1C' :
d > 4000 ? '#FC4E2A' :
d > 3000 ? '#FD8D3C' :
d > 2000 ? '#FEB24C' :
d > 1000 ? '#FED976' : '#FFEDA0';
}
function style(feature) {
return {
weight: 2,
opacity: 1,
color: 'white',
dashArray: '3',
fillOpacity: 0.75,
fillColor: getColor(feature.properties.density)
};
}
function highlightFeature(e) {
const layer = e.target;
layer.setStyle({
weight: 4,
color: '#444',
dashArray: '',
fillOpacity: 0.85
});
layer.bringToFront();
info.update(layer.feature.properties);
}
/* global pontianakData */
const geojson = L.geoJson(pontianakData, {
style,
onEachFeature
}).addTo(map);
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
});
}
map.attributionControl.addAttribution('Data Wilayah &copy; BPS Kota Pontianak');
// Legend
const legend = L.control({position: 'bottomright'});
legend.onAdd = function (map) {
const div = L.DomUtil.create('div', 'info legend');
const grades = [0, 1000, 2000, 3000, 4000, 6000, 8000, 10000];
const labels = ['<span class="title">Jiwa / km²</span>'];
let from, to;
for (let i = 0; i < grades.length; i++) {
from = grades[i];
to = grades[i + 1];
labels.push(
`<i style="background:${getColor(from + 1)}"></i> ` +
from.toLocaleString('id-ID') +
(to ? `&ndash;${to.toLocaleString('id-ID')}` : '+')
);
}
div.innerHTML = labels.join('<br>');
return div;
};
legend.addTo(map);
</script>
</body>
</html>
+1
View File
File diff suppressed because one or more lines are too long
+54
View File
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
FROM php:8.2-apache
RUN docker-php-ext-install mysqli pdo pdo_mysql \
&& a2enmod rewrite
COPY . /var/www/html/
RUN chown -R www-data:www-data /var/www/html
+136
View File
@@ -0,0 +1,136 @@
<?php
// ============================================================
// api.php REST API untuk WebGIS SPBU Pontianak
// Endpoint:
// GET api.php → ambil semua SPBU
// POST api.php → tambah SPBU baru
// PUT api.php?id=N → update SPBU
// DELETE api.php?id=N → hapus SPBU
// ============================================================
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { exit(0); }
require_once 'config.php';
$method = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int) $_GET['id'] : null;
// ── Ambil semua SPBU ─────────────────────────────────────────
if ($method === 'GET') {
$conn = getConnection();
$res = $conn->query(
"SELECT id, nama_spbu, nomor_wa, status_24jam,
latitude, longitude, created_at
FROM spbu ORDER BY created_at DESC"
);
$data = [];
while ($row = $res->fetch_assoc()) {
$row['id'] = (int) $row['id'];
$row['latitude'] = (float) $row['latitude'];
$row['longitude'] = (float) $row['longitude'];
$data[] = $row;
}
echo json_encode(['status' => 'success', 'data' => $data]);
$conn->close();
exit;
}
// ── Tambah SPBU baru ─────────────────────────────────────────
if ($method === 'POST') {
$body = json_decode(file_get_contents('php://input'), true);
$nama = trim($body['nama_spbu'] ?? '');
$wa = trim($body['nomor_wa'] ?? '');
$status = in_array($body['status_24jam'] ?? '', ['yes','no'])
? $body['status_24jam'] : 'no';
$lat = isset($body['latitude']) ? (float) $body['latitude'] : null;
$lng = isset($body['longitude']) ? (float) $body['longitude'] : null;
if (!$nama || !$wa || $lat === null || $lng === null) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Data tidak lengkap']);
exit;
}
$conn = getConnection();
$stmt = $conn->prepare(
"INSERT INTO spbu (nama_spbu, nomor_wa, status_24jam, latitude, longitude)
VALUES (?, ?, ?, ?, ?)"
);
$stmt->bind_param('sssdd', $nama, $wa, $status, $lat, $lng);
if ($stmt->execute()) {
echo json_encode([
'status' => 'success',
'message' => 'SPBU berhasil ditambahkan',
'id' => $stmt->insert_id
]);
} else {
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
}
$stmt->close();
$conn->close();
exit;
}
// ── Update SPBU ──────────────────────────────────────────────
if ($method === 'PUT' && $id) {
$body = json_decode(file_get_contents('php://input'), true);
$nama = trim($body['nama_spbu'] ?? '');
$wa = trim($body['nomor_wa'] ?? '');
$status = in_array($body['status_24jam'] ?? '', ['yes','no'])
? $body['status_24jam'] : 'no';
$lat = isset($body['latitude']) ? (float) $body['latitude'] : null;
$lng = isset($body['longitude']) ? (float) $body['longitude'] : null;
if (!$nama || !$wa || $lat === null || $lng === null) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Data tidak lengkap']);
exit;
}
$conn = getConnection();
$stmt = $conn->prepare(
"UPDATE spbu SET nama_spbu=?, nomor_wa=?, status_24jam=?,
latitude=?, longitude=?
WHERE id=?"
);
$stmt->bind_param('sssddi', $nama, $wa, $status, $lat, $lng, $id);
if ($stmt->execute()) {
echo json_encode(['status' => 'success', 'message' => 'SPBU berhasil diupdate']);
} else {
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
}
$stmt->close();
$conn->close();
exit;
}
// ── Hapus SPBU ───────────────────────────────────────────────
if ($method === 'DELETE' && $id) {
$conn = getConnection();
$stmt = $conn->prepare("DELETE FROM spbu WHERE id=?");
$stmt->bind_param('i', $id);
if ($stmt->execute()) {
echo json_encode(['status' => 'success', 'message' => 'SPBU berhasil dihapus']);
} else {
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
}
$stmt->close();
$conn->close();
exit;
}
http_response_code(405);
echo json_encode(['status' => 'error', 'message' => 'Method tidak diizinkan']);
+22
View File
@@ -0,0 +1,22 @@
<?php
// ============================================================
// config.php Konfigurasi Koneksi Database
// ============================================================
define('DB_HOST', getenv('DB_HOST') ?: 'localhost');
define('DB_USER', getenv('DB_USER') ?: 'root');
define('DB_PASS', getenv('DB_PASSWORD') ?: '');
define('DB_NAME', getenv('DB_NAME') ?: 'webgis_spbu');
function getConnection(): mysqli {
$conn = new mysqli(DB_HOST, DB_USER, DB_PASS, DB_NAME);
if ($conn->connect_error) {
http_response_code(500);
die(json_encode([
'status' => 'error',
'message' => 'Koneksi database gagal: ' . $conn->connect_error
]));
}
$conn->set_charset('utf8mb4');
return $conn;
}
+32
View File
@@ -0,0 +1,32 @@
-- ============================================================
-- WebGIS SPBU Pontianak
-- Database Structure
-- ============================================================
CREATE DATABASE IF NOT EXISTS webgis_spbu
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE webgis_spbu;
CREATE TABLE IF NOT EXISTS spbu (
id INT(11) NOT NULL AUTO_INCREMENT,
nama_spbu VARCHAR(150) NOT NULL,
nomor_wa VARCHAR(20) NOT NULL,
status_24jam ENUM('yes','no') NOT NULL DEFAULT 'no',
latitude DECIMAL(10,8) NOT NULL,
longitude DECIMAL(11,8) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ============================================================
-- Sample data beberapa SPBU di Pontianak
-- ============================================================
INSERT INTO spbu (nama_spbu, nomor_wa, status_24jam, latitude, longitude) VALUES
('SPBU Pertamina Jl. Ahmad Yani', '6281234567890', 'yes', -0.0262619, 109.3425923),
('SPBU Shell Jl. Tanjungpura', '6281298765432', 'no', -0.0318750, 109.3319870),
('SPBU Pertamina Jl. Gajahmada', '6281211223344', 'yes', -0.0393160, 109.3266190),
('SPBU Pertamina Jl. Sultan Hamid II', '6281355667788', 'no', -0.0159870, 109.3571200);
+1030
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
Options -Indexes
Options -MultiViews
# ── Security headers ──
<IfModule mod_headers.c>
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-XSS-Protection "1; mode=block"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
</IfModule>
# ── Block direct access to sensitive files ──
<FilesMatch "^(config\.php|database\.sql|\.env)$">
Order allow,deny
Deny from all
</FilesMatch>
# ── Allow uploads folder but block PHP execution inside it ──
<IfModule mod_rewrite.c>
RewriteEngine On
# Block PHP execution in uploads folder
RewriteRule ^uploads/.*\.php$ - [F,L]
# Route all API requests through index if needed
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^api/([^/]+)/?$ api/$1.php [L,QSA]
</IfModule>
# ── Cache static assets ──
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType image/jpeg "access plus 1 month"
ExpiresByType image/png "access plus 1 month"
ExpiresByType image/webp "access plus 1 month"
</IfModule>
+11
View File
@@ -0,0 +1,11 @@
FROM php:8.2-apache
RUN docker-php-ext-install mysqli pdo pdo_mysql \
&& a2enmod rewrite
COPY . /var/www/html/
RUN mkdir -p /var/www/html/uploads/bukti \
&& chown -R www-data:www-data /var/www/html/uploads
RUN chown -R www-data:www-data /var/www/html
+184
View File
@@ -0,0 +1,184 @@
# WebGIS Pengentasan Kemiskinan v3.0
## Kota Pontianak — Berbasis Komunitas & Rumah Ibadah
Sistem WebGIS berbasis PHP + MySQL + Leaflet.js untuk pemetaan, pendataan, dan distribusi bantuan sosial kepada penduduk miskin di Kota Pontianak.
---
## 📁 Struktur Folder
```
webgis_v3/
├── index.php ← Halaman utama (SPA: login + semua fitur)
├── config.php ← Konfigurasi database & helper functions
├── database.sql ← Skema database + data demo
├── .htaccess ← Konfigurasi Apache (keamanan + routing)
├── uploads/ ← Foto laporan masyarakat
└── api/
├── auth.php ← Login / logout / cek sesi
├── penduduk.php ← CRUD data penduduk + validasi pendidikan
├── keluarga.php ← CRUD data keluarga (KK)
├── rumah_ibadah.php ← CRUD rumah ibadah
├── bantuan.php ← Master bantuan + histori + prioritas otomatis
├── laporan.php ← Laporan masyarakat + upload foto
├── chat.php ← Chat antar pengurus
├── stats.php ← Dashboard KPI + GeoJSON untuk peta
├── pelatihan.php ← Manajemen pelatihan keterampilan
├── user.php ← Manajemen user & role (superadmin)
└── log.php ← Log aktivitas sistem
```
---
## 🚀 Cara Menjalankan di XAMPP
### Langkah 1 — Ekstrak & Taruh di htdocs
```
Ekstrak ZIP → salin folder webgis_v3 ke:
C:\xampp\htdocs\webgis_v3\
```
### Langkah 2 — Start XAMPP
Buka XAMPP Control Panel → Start **Apache** dan **MySQL**
### Langkah 3 — Import Database
1. Buka browser → `http://localhost/phpmyadmin`
2. Klik tab **SQL** di bagian atas
3. Salin seluruh isi file `database.sql`
4. Tempel ke kotak SQL → klik **Go**
Database `webgis_kemiskinan` akan terbuat otomatis dengan semua tabel dan data demo.
### Langkah 4 — Cek config.php (jika perlu)
```php
define('DB_HOST', 'localhost');
define('DB_USER', 'root'); // default XAMPP
define('DB_PASS', ''); // default XAMPP = kosong
define('DB_NAME', 'webgis_kemiskinan');
```
### Langkah 5 — Buka Aplikasi
```
http://localhost/webgis_v3
```
---
## 👥 Akun Demo
| Role | Username | Password | Akses |
|------|----------|----------|-------|
| 👑 Super Admin | `superadmin` | `password` | Semua fitur + manajemen user |
| 🏛️ Pimpinan | `pimpinan1` | `password` | Dashboard monitoring (read-only) |
| 🕌 Pengurus RI | `pengurus1` | `password` | Input data + distribusi bantuan |
| 🕌 Pengurus RI | `pengurus2` | `password` | Input data + distribusi bantuan |
| 🚑 Relawan | `relawan1` | `password` | Survey lapangan + laporan |
| 👤 Masyarakat | `masyarakat1` | `password` | Lihat peta + buat laporan |
> Laporan masyarakat bisa dikirim **tanpa login** (klik chip "Lapor Tanpa Login" di halaman login).
---
## 🗄️ Struktur Database (13 Tabel)
| Tabel | Deskripsi |
|-------|-----------|
| `role` | 5 role sistem |
| `user` | Akun pengguna + relasi ke role & rumah ibadah |
| `rumah_ibadah` | Data masjid, gereja, vihara, klenteng, pura |
| `keluarga` | Data Kartu Keluarga (KK) |
| `penduduk` | Data penduduk lengkap (NIK, umur otomatis, profil umur) |
| `pelatihan` | Program pelatihan keterampilan |
| `riwayat_pelatihan` | Peserta per pelatihan |
| `bantuan` | Master program bantuan (PKH, BPNT, BLT, dll) |
| `histori_bantuan` | Riwayat penyaluran bantuan per penduduk |
| `laporan` | Laporan kondisi warga dari masyarakat |
| `chat` | Pesan koordinasi antar pengurus |
| `log_aktivitas` | Audit trail semua aktivitas sistem |
---
## ⚡ Fitur Utama
### 🗺️ WebGIS Interaktif
- Peta gelap (dark mode) berbasis OpenStreetMap + CartoDB
- **3 warna marker**: 🔴 Miskin | 🟡 Rentan | 🟢 Mampu
- Marker laporan masyarakat dengan kode urgensi
- Layer toggle per kategori
- Radius distribusi adjustable (1001000m)
- Popup detail klik marker
### 👥 Manajemen Data Penduduk
- NIK 16 digit sebagai primary key
- **Umur dihitung otomatis** dari tanggal lahir (MySQL Generated Column)
- **Profil umur otomatis**: Balita / Anak / Remaja / Dewasa / Lansia
- Validasi pendidikan vs umur:
- Usia 715 → peringatan jika tidak sekolah
- Usia 1618 → peringatan jika tidak sekolah
- Relasi ke Kartu Keluarga
### 🏠 Manajemen Keluarga
- Data KK lengkap (No KK, RT/RW, kelurahan, kecamatan)
- Relasi ke rumah ibadah terdekat
- Daftar anggota keluarga dengan detail kondisi
### 🎁 Sistem Bantuan
- **Prioritas otomatis**: miskin + sakit = SANGAT TINGGI; lansia sendiri = SANGAT TINGGI
- Deteksi warga yang belum pernah menerima bantuan
- Histori penyaluran bantuan per individu yang transparan
- Catat distribusi langsung dari halaman bantuan
### 📢 Laporan Masyarakat
- Bisa dikirim **tanpa login**
- Upload foto dokumentasi (JPG/PNG, maks 5MB)
- 4 tingkat urgensi: Rendah / Sedang / Tinggi / 🆘 Darurat
- Alur status: Pending → Diverifikasi → Diproses → Selesai
- Koordinat laporan via klik peta atau GPS
### 💬 Chat Koordinasi
- Chat realtime antar pengurus rumah ibadah
- Polling otomatis setiap 8 detik
- Notifikasi dot merah jika ada pesan baru
- Pesan broadcast ke semua pengurus
### 📊 Dashboard & Statistik
- KPI card: total penduduk, KK, miskin, rentan, laporan aktif
- Progress bar distribusi per rumah ibadah
- Analisis singkat di sidebar
---
## 🔒 Keamanan
- Input sanitasi (anti XSS) di semua endpoint
- Prepared statements PDO (anti SQL Injection)
- Session-based auth dengan cookie HttpOnly
- Role-based access control (RBAC) di backend
- Block akses langsung ke `config.php` dan `database.sql` via `.htaccess`
- Block eksekusi PHP di folder `uploads/`
---
## 🛠️ Teknologi
| Komponen | Teknologi |
|----------|-----------|
| Backend | PHP 7.4+ (PDO, Sessions) |
| Database | MySQL 5.7+ / MariaDB 10.3+ |
| Peta | Leaflet.js v1.9.4 |
| Tile Map | CartoDB Dark Matter (OpenStreetMap) |
| Geocoding | Nominatim API (OSM) |
| Frontend | Vanilla JS ES6+, CSS3 Variables |
| Font | Plus Jakarta Sans + DM Mono |
---
## 📝 Catatan Pengembangan Lanjutan
1. **Backend database**: Tambah `localStorage` atau backend sinkronisasi untuk multi-device
2. **Export laporan**: Tambah ekspor PDF/Excel menggunakan mPDF atau PhpSpreadsheet
3. **Notifikasi push**: Integrasikan Firebase Cloud Messaging untuk notifikasi laporan darurat
4. **PWA offline**: Service Worker untuk penggunaan lapangan tanpa internet
5. **Integrasi DTKS**: Sinkronisasi dengan Data Terpadu Kesejahteraan Sosial Kemensos RI
---
*Dikembangkan sebagai tugas Rekayasa Perangkat Lunak — Program Studi Informatika*
*WebGIS Pengentasan Kemiskinan Berbasis Komunitas dan Rumah Ibadah — Kota Pontianak*
+60
View File
@@ -0,0 +1,60 @@
<?php
require_once __DIR__ . '/../config.php';
$action = $_GET['action'] ?? '';
switch ($action) {
case 'login':
if ($_SERVER['REQUEST_METHOD'] !== 'POST') jsonErr('Method not allowed', 405);
$b = json_decode(file_get_contents('php://input'), true);
$username = sanitize($b['username'] ?? '');
$password = $b['password'] ?? '';
if (!$username || !$password) jsonErr('Username dan password wajib diisi.');
$db = getDB();
$st = $db->prepare("SELECT u.id,u.username,u.password,u.nama_lengkap,u.telepon,u.aktif,
r.kode as role, r.nama as role_nama, u.rumah_ibadah_id,
ri.nama as ri_nama
FROM user u
JOIN role r ON r.id = u.role_id
LEFT JOIN rumah_ibadah ri ON ri.id = u.rumah_ibadah_id
WHERE u.username = ?");
$st->execute([$username]);
$user = $st->fetch();
if (!$user) jsonErr('Username atau password salah.', 401);
if (!$user['aktif']) jsonErr('Akun ini dinonaktifkan. Hubungi administrator.', 403);
$valid = password_verify($password, $user['password']) || $user['password'] === $password;
if (!$valid) jsonErr('Username atau password salah.', 401);
startSession();
$sess = [
'id' => (int)$user['id'],
'username' => $user['username'],
'nama_lengkap'=> $user['nama_lengkap'],
'role' => $user['role'],
'role_nama' => $user['role_nama'],
'ri_id' => $user['rumah_ibadah_id'] ? (int)$user['rumah_ibadah_id'] : null,
'ri_nama' => $user['ri_nama'],
];
$_SESSION['user'] = $sess;
log_aksi($sess['id'], 'LOGIN', 'user', $sess['id'], 'Login berhasil');
jsonOk($sess, 'Selamat datang, '.$user['nama_lengkap'].'!');
break;
case 'logout':
$u = getCurrentUser();
if ($u) log_aksi($u['id'], 'LOGOUT', 'user', $u['id'], 'Logout');
startSession(); session_destroy();
jsonOk(null, 'Logout berhasil.');
break;
case 'check':
$u = getCurrentUser();
if ($u) jsonOk($u);
else jsonErr('Tidak ada sesi aktif.', 401);
break;
default:
jsonErr('Action tidak dikenali.', 404);
}
+114
View File
@@ -0,0 +1,114 @@
<?php
require_once __DIR__ . '/../config.php';
$user = requireLogin();
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
$db = getDB();
// ── HISTORI BANTUAN ──
if ($action === 'histori') {
if ($method === 'GET') {
$pid = (int)($_GET['penduduk_id'] ?? 0);
if (!$pid) jsonErr('penduduk_id wajib.');
$st = $db->prepare("SELECT hb.*, b.nama as bantuan_nama, b.jenis, b.satuan, b.sumber,
u.nama_lengkap as disalurkan_nama
FROM histori_bantuan hb
JOIN bantuan b ON b.id=hb.bantuan_id
LEFT JOIN user u ON u.id=hb.disalurkan_oleh
WHERE hb.penduduk_id=? ORDER BY hb.tanggal DESC");
$st->execute([$pid]);
jsonOk($st->fetchAll());
}
if ($method === 'POST') {
requireRole(['superadmin','pengurus']);
$b = json_decode(file_get_contents('php://input'), true);
$pid = (int)($b['penduduk_id'] ?? 0);
$bid = (int)($b['bantuan_id'] ?? 0);
if (!$pid || !$bid) jsonErr('penduduk_id dan bantuan_id wajib.');
$db->prepare("INSERT INTO histori_bantuan (penduduk_id,bantuan_id,disalurkan_oleh,tanggal,jumlah,status,catatan) VALUES (?,?,?,?,?,?,?)")
->execute([$pid,$bid,$user['id'],$b['tanggal']??date('Y-m-d'),(float)($b['jumlah']??1),$b['status']??'disalurkan',sanitize($b['catatan']??'')]);
$newId=(int)$db->lastInsertId();
log_aksi($user['id'],'CREATE','histori_bantuan',$newId,"Catat bantuan penduduk_id:$pid bantuan_id:$bid");
jsonOk(['id'=>$newId],'Histori bantuan dicatat.');
}
if ($method === 'PUT') {
requireRole(['superadmin','pengurus']);
if (!$id) jsonErr('ID tidak valid.');
$b = json_decode(file_get_contents('php://input'), true);
$db->prepare("UPDATE histori_bantuan SET status=?,catatan=? WHERE id=?")
->execute([$b['status']??'disalurkan',sanitize($b['catatan']??''),$id]);
jsonOk(null,'Status bantuan diperbarui.');
}
jsonErr('Method tidak didukung.', 405);
}
// ── PRIORITAS OTOMATIS ──
if ($action === 'prioritas') {
$st = $db->query("
SELECT p.id, p.nik, p.nama, p.umur, p.profil_umur, p.kondisi_kesehatan,
p.status_ekonomi, p.penghasilan, p.status_perkawinan,
k.alamat, k.kelurahan, k.lat, k.lng,
(SELECT COUNT(*) FROM histori_bantuan hb WHERE hb.penduduk_id=p.id AND hb.status='disalurkan') as total_bantuan,
CASE
WHEN p.status_ekonomi='miskin' AND p.kondisi_kesehatan IN ('sakit_berat','disabilitas') THEN 'SANGAT TINGGI'
WHEN p.profil_umur='Lansia' AND p.status_perkawinan IN ('cerai_mati','cerai_hidup') THEN 'SANGAT TINGGI'
WHEN p.status_ekonomi='miskin' AND p.kondisi_kesehatan='sakit_ringan' THEN 'TINGGI'
WHEN p.status_ekonomi='miskin' AND p.profil_umur='Lansia' THEN 'TINGGI'
WHEN p.status_ekonomi='miskin' THEN 'SEDANG'
WHEN p.status_ekonomi='rentan' AND p.kondisi_kesehatan != 'sehat' THEN 'SEDANG'
ELSE 'RENDAH'
END as prioritas
FROM v_penduduk p
JOIN keluarga k ON k.id=p.keluarga_id
WHERE p.status_hidup='hidup'
ORDER BY FIELD(prioritas,'SANGAT TINGGI','TINGGI','SEDANG','RENDAH'), total_bantuan ASC
LIMIT 100
");
jsonOk($st->fetchAll());
}
// ── BELUM TERIMA BANTUAN ──
if ($action === 'belum_terima') {
$st = $db->query("
SELECT p.id, p.nik, p.nama, p.umur, p.profil_umur, p.status_ekonomi,
p.kondisi_kesehatan, k.kelurahan, k.alamat, k.lat, k.lng
FROM v_penduduk p
JOIN keluarga k ON k.id=p.keluarga_id
WHERE p.status_hidup='hidup'
AND p.status_ekonomi IN ('miskin','rentan')
AND p.id NOT IN (SELECT DISTINCT penduduk_id FROM histori_bantuan WHERE status='disalurkan')
ORDER BY p.status_ekonomi DESC, p.umur DESC
");
jsonOk($st->fetchAll());
}
// ── CRUD MASTER BANTUAN ──
switch ($method) {
case 'GET':
if ($id) {
$st = $db->prepare("SELECT * FROM bantuan WHERE id=?"); $st->execute([$id]);
jsonOk($st->fetch());
}
jsonOk($db->query("SELECT * FROM bantuan ORDER BY nama")->fetchAll());
break;
case 'POST':
requireRole(['superadmin','pengurus']);
$b = json_decode(file_get_contents('php://input'), true);
$db->prepare("INSERT INTO bantuan (nama,jenis,sumber,satuan,nilai_per_satuan,periode) VALUES (?,?,?,?,?,?)")
->execute([sanitize($b['nama']??''),$b['jenis']??'lainnya',sanitize($b['sumber']??''),sanitize($b['satuan']??''),(float)($b['nilai_per_satuan']??0),$b['periode']??'sekali']);
jsonOk(['id'=>(int)$db->lastInsertId()],'Bantuan ditambahkan.');
break;
case 'PUT':
requireRole(['superadmin','pengurus']);
if (!$id) jsonErr('ID tidak valid.');
$b = json_decode(file_get_contents('php://input'), true);
$db->prepare("UPDATE bantuan SET nama=?,jenis=?,sumber=?,satuan=?,nilai_per_satuan=?,periode=?,aktif=? WHERE id=?")
->execute([sanitize($b['nama']??''),$b['jenis']??'lainnya',sanitize($b['sumber']??''),sanitize($b['satuan']??''),(float)($b['nilai_per_satuan']??0),$b['periode']??'sekali',(int)($b['aktif']??1),$id]);
jsonOk(null,'Bantuan diperbarui.');
break;
default: jsonErr('Method tidak didukung.', 405);
}
+55
View File
@@ -0,0 +1,55 @@
<?php
require_once __DIR__ . '/../config.php';
$user = requireLogin();
$method = $_SERVER['REQUEST_METHOD'];
$db = getDB();
switch ($method) {
case 'GET':
$since = $_GET['since'] ?? '1970-01-01 00:00:00';
// ambil pesan broadcast + pesan ke/dari user ini
$st = $db->prepare("SELECT c.id, c.pesan, c.created_at, c.dibaca,
u.nama_lengkap as dari_nama, u.id as dari_id,
r.kode as dari_role, u.rumah_ibadah_id,
ri.nama as dari_ri,
c.ke_user_id
FROM chat c
JOIN user u ON u.id=c.dari_user_id
JOIN role r ON r.id=u.role_id
LEFT JOIN rumah_ibadah ri ON ri.id=u.rumah_ibadah_id
WHERE (c.ke_user_id IS NULL OR c.ke_user_id=? OR c.dari_user_id=?)
AND c.created_at > ?
ORDER BY c.created_at ASC LIMIT 100");
$st->execute([$user['id'], $user['id'], $since]);
$msgs = $st->fetchAll();
// tandai sudah dibaca
$db->prepare("UPDATE chat SET dibaca=1 WHERE ke_user_id=? AND dibaca=0")->execute([$user['id']]);
jsonOk($msgs);
break;
case 'POST':
// hanya pengurus, relawan, superadmin
requireRole(['superadmin','pengurus','relawan']);
$b = json_decode(file_get_contents('php://input'), true);
$pesan = sanitize($b['pesan'] ?? '');
if (!$pesan) jsonErr('Pesan tidak boleh kosong.');
$ke = !empty($b['ke_user_id']) ? (int)$b['ke_user_id'] : null;
$db->prepare("INSERT INTO chat (dari_user_id, ke_user_id, pesan) VALUES (?,?,?)")
->execute([$user['id'], $ke, $pesan]);
$newId = (int)$db->lastInsertId();
// ambil pesan lengkap untuk response
$st = $db->prepare("SELECT c.id, c.pesan, c.created_at, u.nama_lengkap as dari_nama,
r.kode as dari_role, ri.nama as dari_ri, c.ke_user_id
FROM chat c JOIN user u ON u.id=c.dari_user_id
JOIN role r ON r.id=u.role_id
LEFT JOIN rumah_ibadah ri ON ri.id=u.rumah_ibadah_id
WHERE c.id=?");
$st->execute([$newId]);
jsonOk($st->fetch(), 'Pesan terkirim.');
break;
default: jsonErr('Method tidak didukung.', 405);
}
+107
View File
@@ -0,0 +1,107 @@
<?php
require_once __DIR__ . '/../config.php';
$user = requireLogin();
$method = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
$db = getDB();
switch ($method) {
case 'GET':
if ($id) {
$st = $db->prepare("SELECT k.*, ri.nama as ri_nama, ri.jenis as ri_jenis
FROM keluarga k LEFT JOIN rumah_ibadah ri ON ri.id=k.rumah_ibadah_id
WHERE k.id=?");
$st->execute([$id]);
$row = $st->fetch();
if (!$row) jsonErr('Tidak ditemukan.', 404);
// anggota keluarga
$an = $db->prepare("SELECT id,nik,nama,jenis_kelamin,umur,profil_umur,
status_keluarga,status_perkawinan,status_ekonomi,
kondisi_kesehatan,pekerjaan,penghasilan,
status_pendidikan,pendidikan_terakhir
FROM penduduk WHERE keluarga_id=? AND status_hidup='hidup' ORDER BY
FIELD(status_keluarga,'kepala','istri','anak','orang_tua','lainnya')");
$an->execute([$id]);
$row['anggota'] = $an->fetchAll();
jsonOk($row);
break;
}
$where = []; $params = [];
if (!empty($_GET['status_ekonomi'])) { $where[] = "k.status_ekonomi=?"; $params[] = $_GET['status_ekonomi']; }
if (!empty($_GET['kelurahan'])) { $where[] = "k.kelurahan=?"; $params[] = $_GET['kelurahan']; }
if (!empty($_GET['rumah_ibadah_id'])) { $where[] = "k.rumah_ibadah_id=?"; $params[] = (int)$_GET['rumah_ibadah_id']; }
if (!empty($_GET['q'])) { $where[] = "(k.nama_kk LIKE ? OR k.no_kk LIKE ?)"; $params[] = '%'.$_GET['q'].'%'; $params[] = '%'.$_GET['q'].'%'; }
$wStr = $where ? 'WHERE '.implode(' AND ',$where) : '';
$st = $db->prepare("SELECT k.id, k.no_kk, k.nama_kk, k.alamat, k.rt, k.rw,
k.kelurahan, k.kecamatan, k.lat, k.lng, k.status_ekonomi,
ri.nama as ri_nama,
(SELECT COUNT(*) FROM penduduk p WHERE p.keluarga_id=k.id AND p.status_hidup='hidup') as jumlah_jiwa,
(SELECT COUNT(*) FROM histori_bantuan hb JOIN penduduk p ON p.id=hb.penduduk_id WHERE p.keluarga_id=k.id AND hb.status='disalurkan') as total_bantuan_diterima
FROM keluarga k LEFT JOIN rumah_ibadah ri ON ri.id=k.rumah_ibadah_id
$wStr ORDER BY k.nama_kk ASC LIMIT 500");
$st->execute($params);
jsonOk($st->fetchAll());
break;
case 'POST':
requireRole(['superadmin','pengurus','relawan']);
$b = json_decode(file_get_contents('php://input'), true);
$no_kk = sanitize($b['no_kk'] ?? '');
$nama_kk = sanitize($b['nama_kk'] ?? '');
if (!$no_kk || !$nama_kk) jsonErr('No KK dan nama KK wajib diisi.');
if (strlen($no_kk) !== 16) jsonErr('No KK harus 16 digit.');
$chk = $db->prepare("SELECT id FROM keluarga WHERE no_kk=?"); $chk->execute([$no_kk]);
if ($chk->fetch()) jsonErr('No KK sudah terdaftar.');
$db->prepare("INSERT INTO keluarga (no_kk,nama_kk,alamat,rt,rw,kelurahan,kecamatan,lat,lng,rumah_ibadah_id,status_ekonomi,created_by)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)")
->execute([
$no_kk, $nama_kk,
sanitize($b['alamat'] ?? ''),
sanitize($b['rt'] ?? ''), sanitize($b['rw'] ?? ''),
sanitize($b['kelurahan'] ?? ''), sanitize($b['kecamatan'] ?? ''),
isset($b['lat']) ? (float)$b['lat'] : null,
isset($b['lng']) ? (float)$b['lng'] : null,
!empty($b['rumah_ibadah_id']) ? (int)$b['rumah_ibadah_id'] : null,
$b['status_ekonomi'] ?? 'rentan',
$user['id']
]);
$newId = (int)$db->lastInsertId();
log_aksi($user['id'], 'CREATE', 'keluarga', $newId, "Tambah KK: $nama_kk");
jsonOk(['id'=>$newId], 'Keluarga berhasil ditambahkan.');
break;
case 'PUT':
requireRole(['superadmin','pengurus','relawan']);
if (!$id) jsonErr('ID tidak valid.');
$b = json_decode(file_get_contents('php://input'), true);
$db->prepare("UPDATE keluarga SET nama_kk=?,alamat=?,rt=?,rw=?,kelurahan=?,kecamatan=?,
lat=?,lng=?,rumah_ibadah_id=?,status_ekonomi=?,updated_at=NOW() WHERE id=?")
->execute([
sanitize($b['nama_kk'] ?? ''), sanitize($b['alamat'] ?? ''),
sanitize($b['rt'] ?? ''), sanitize($b['rw'] ?? ''),
sanitize($b['kelurahan'] ?? ''), sanitize($b['kecamatan'] ?? ''),
isset($b['lat']) ? (float)$b['lat'] : null,
isset($b['lng']) ? (float)$b['lng'] : null,
!empty($b['rumah_ibadah_id']) ? (int)$b['rumah_ibadah_id'] : null,
$b['status_ekonomi'] ?? 'rentan',
$id
]);
log_aksi($user['id'], 'UPDATE', 'keluarga', $id, "Edit KK ID: $id");
jsonOk(null, 'Data keluarga diperbarui.');
break;
case 'DELETE':
requireRole(['superadmin']);
if (!$id) jsonErr('ID tidak valid.');
$db->prepare("DELETE FROM keluarga WHERE id=?")->execute([$id]);
log_aksi($user['id'], 'DELETE', 'keluarga', $id, "Hapus KK ID: $id");
jsonOk(null, 'Data keluarga dihapus.');
break;
default: jsonErr('Method tidak didukung.', 405);
}
+74
View File
@@ -0,0 +1,74 @@
<?php
require_once __DIR__ . '/../config.php';
$method = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
$db = getDB();
switch ($method) {
case 'GET':
if ($id) {
$st = $db->prepare("SELECT l.*, p.nama as penduduk_nama, u.nama_lengkap as verifikator
FROM laporan l
LEFT JOIN penduduk p ON p.id=l.penduduk_id
LEFT JOIN user u ON u.id=l.diverifikasi_oleh
WHERE l.id=?");
$st->execute([$id]); jsonOk($st->fetch()); break;
}
$where=[]; $params=[];
if (!empty($_GET['status'])) { $where[]="l.status=?"; $params[]=$_GET['status']; }
if (!empty($_GET['urgensi'])) { $where[]="l.urgensi=?"; $params[]=$_GET['urgensi']; }
$w = $where ? 'WHERE '.implode(' AND ',$where) : '';
$st = $db->prepare("SELECT l.id, l.pelapor_nama, l.deskripsi, l.lat, l.lng,
l.alamat_laporan, l.urgensi, l.status, l.foto,
l.created_at, p.nama as penduduk_nama
FROM laporan l LEFT JOIN penduduk p ON p.id=l.penduduk_id
$w ORDER BY FIELD(l.urgensi,'darurat','tinggi','sedang','rendah'), l.created_at DESC");
$st->execute($params); jsonOk($st->fetchAll()); break;
case 'POST':
// masyarakat umum pun bisa lapor (tidak perlu login)
$pelapor = sanitize($_POST['pelapor_nama'] ?? '');
$telp = sanitize($_POST['pelapor_telepon']?? '');
$deskripsi = sanitize($_POST['deskripsi'] ?? '');
$alamat = sanitize($_POST['alamat_laporan'] ?? '');
$lat = isset($_POST['lat']) ? (float)$_POST['lat'] : null;
$lng = isset($_POST['lng']) ? (float)$_POST['lng'] : null;
$urgensi = $_POST['urgensi'] ?? 'sedang';
$pid = !empty($_POST['penduduk_id']) ? (int)$_POST['penduduk_id'] : null;
if (!$deskripsi) jsonErr('Deskripsi laporan wajib diisi.');
// Upload foto
$fotoPath = null;
if (!empty($_FILES['foto']['tmp_name'])) {
$ext = strtolower(pathinfo($_FILES['foto']['name'], PATHINFO_EXTENSION));
if (!in_array($ext, ['jpg','jpeg','png','webp'])) jsonErr('Format foto harus JPG/PNG/WebP.');
if ($_FILES['foto']['size'] > 5 * 1024 * 1024) jsonErr('Ukuran foto maks 5MB.');
$fname = 'laporan_' . time() . '_' . rand(1000,9999) . '.' . $ext;
move_uploaded_file($_FILES['foto']['tmp_name'], UPLOAD_DIR . $fname);
$fotoPath = 'uploads/' . $fname;
}
$db->prepare("INSERT INTO laporan (pelapor_nama,pelapor_telepon,penduduk_id,deskripsi,lat,lng,alamat_laporan,foto,urgensi) VALUES (?,?,?,?,?,?,?,?,?)")
->execute([$pelapor,$telp,$pid,$deskripsi,$lat,$lng,$alamat,$fotoPath,$urgensi]);
$newId = (int)$db->lastInsertId();
$u = getCurrentUser();
if ($u) log_aksi($u['id'],'CREATE','laporan',$newId,"Laporan baru: $urgensi");
jsonOk(['id'=>$newId],'Laporan berhasil dikirim. Terima kasih!'); break;
case 'PUT':
$u = requireLogin();
requireRole(['superadmin','pengurus','relawan']);
if (!$id) jsonErr('ID tidak valid.');
$b = json_decode(file_get_contents('php://input'), true);
$chk=$db->prepare("SELECT id FROM laporan WHERE id=?"); $chk->execute([$id]);
if (!$chk->fetch()) jsonErr('Laporan tidak ditemukan.',404);
$db->prepare("UPDATE laporan SET status=?,catatan_admin=?,diverifikasi_oleh=?,updated_at=NOW() WHERE id=?")
->execute([$b['status']??'pending',sanitize($b['catatan_admin']??''),$u['id'],$id]);
log_aksi($u['id'],'UPDATE','laporan',$id,"Update status: ".($b['status']??''));
jsonOk(null,'Status laporan diperbarui.'); break;
default: jsonErr('Method tidak didukung.',405);
}
+27
View File
@@ -0,0 +1,27 @@
<?php
// api/log.php — Log Aktivitas
require_once __DIR__ . '/../config.php';
requireRole(['superadmin', 'pimpinan']);
$db = getDB();
$limit = min((int)($_GET['limit'] ?? 100), 500);
$offset = (int)($_GET['offset'] ?? 0);
$tabel = $_GET['tabel'] ?? '';
$uid = (int)($_GET['user_id'] ?? 0);
$where = []; $params = [];
if ($tabel) { $where[] = "l.tabel=?"; $params[] = $tabel; }
if ($uid) { $where[] = "l.user_id=?"; $params[] = $uid; }
$w = $where ? 'WHERE '.implode(' AND ',$where) : '';
$st = getDB()->prepare("SELECT l.id, l.aksi, l.tabel, l.record_id, l.keterangan, l.ip_address, l.created_at,
u.username, u.nama_lengkap, r.kode as role
FROM log_aktivitas l
LEFT JOIN user u ON u.id=l.user_id
LEFT JOIN role r ON r.id=u.role_id
$w ORDER BY l.created_at DESC LIMIT ? OFFSET ?");
$params[] = $limit; $params[] = $offset;
$st->execute($params);
$total = (int)getDB()->query("SELECT COUNT(*) FROM log_aktivitas $w")->fetchColumn();
jsonOk(['rows' => $st->fetchAll(), 'total' => $total, 'limit' => $limit, 'offset' => $offset]);
+95
View File
@@ -0,0 +1,95 @@
<?php
// api/pelatihan.php — Manajemen Pelatihan & Riwayat
require_once __DIR__ . '/../config.php';
$user = requireLogin();
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
$db = getDB();
// ── RIWAYAT PELATIHAN PER PENDUDUK ──
if ($action === 'riwayat') {
$pid = (int)($_GET['penduduk_id'] ?? 0);
if (!$pid) jsonErr('penduduk_id wajib.');
$st = $db->prepare("SELECT rp.*, p.nama_pelatihan, p.penyelenggara, p.tanggal as tgl_pelatihan
FROM riwayat_pelatihan rp
JOIN pelatihan p ON p.id=rp.pelatihan_id
WHERE rp.penduduk_id=? ORDER BY rp.tanggal_ikut DESC");
$st->execute([$pid]);
jsonOk($st->fetchAll());
}
// ── DAFTARKAN PESERTA ──
if ($action === 'daftar' && $method === 'POST') {
requireRole(['superadmin', 'pengurus', 'relawan']);
$b = json_decode(file_get_contents('php://input'), true);
$pid = (int)($b['penduduk_id'] ?? 0);
$tid = (int)($b['pelatihan_id'] ?? 0);
if (!$pid || !$tid) jsonErr('penduduk_id dan pelatihan_id wajib.');
// validasi: cek umur >= 18 untuk pelatihan kerja
$p = $db->prepare("SELECT umur FROM penduduk WHERE id=?"); $p->execute([$pid]);
$row = $p->fetch();
if ($row && (int)$row['umur'] < 15) jsonErr('Peserta minimal berusia 15 tahun untuk mengikuti pelatihan.');
$db->prepare("INSERT INTO riwayat_pelatihan (penduduk_id, pelatihan_id, tanggal_ikut) VALUES (?,?,?)")
->execute([$pid, $tid, $b['tanggal_ikut'] ?? date('Y-m-d')]);
log_aksi($user['id'], 'CREATE', 'riwayat_pelatihan', (int)$db->lastInsertId(), "Daftar pelatihan pid:$pid tid:$tid");
jsonOk(null, 'Peserta berhasil didaftarkan ke pelatihan.');
}
// ── CRUD MASTER PELATIHAN ──
switch ($method) {
case 'GET':
if ($id) {
$st = $db->prepare("SELECT p.*, COUNT(rp.id) as jumlah_peserta FROM pelatihan p
LEFT JOIN riwayat_pelatihan rp ON rp.pelatihan_id=p.id WHERE p.id=? GROUP BY p.id");
$st->execute([$id]);
$row = $st->fetch();
if (!$row) jsonErr('Tidak ditemukan.', 404);
// peserta
$ps = $db->prepare("SELECT rp.id, rp.tanggal_ikut, pd.nama, pd.nik, pd.umur
FROM riwayat_pelatihan rp JOIN v_penduduk pd ON pd.id=rp.penduduk_id
WHERE rp.pelatihan_id=? ORDER BY rp.tanggal_ikut DESC");
$ps->execute([$id]);
$row['peserta'] = $ps->fetchAll();
jsonOk($row);
break;
}
$rows = $db->query("SELECT p.*, COUNT(rp.id) as jumlah_peserta
FROM pelatihan p LEFT JOIN riwayat_pelatihan rp ON rp.pelatihan_id=p.id
GROUP BY p.id ORDER BY p.tanggal DESC")->fetchAll();
jsonOk($rows);
break;
case 'POST':
requireRole(['superadmin', 'pengurus']);
$b = json_decode(file_get_contents('php://input'), true);
$nama = sanitize($b['nama_pelatihan'] ?? '');
if (!$nama) jsonErr('Nama pelatihan wajib diisi.');
$db->prepare("INSERT INTO pelatihan (nama_pelatihan, penyelenggara, tanggal, durasi_hari, deskripsi) VALUES (?,?,?,?,?)")
->execute([$nama, sanitize($b['penyelenggara'] ?? ''), $b['tanggal'] ?? null,
(int)($b['durasi_hari'] ?? 1), sanitize($b['deskripsi'] ?? '')]);
jsonOk(['id' => (int)$db->lastInsertId()], 'Pelatihan ditambahkan.');
break;
case 'PUT':
requireRole(['superadmin', 'pengurus']);
if (!$id) jsonErr('ID tidak valid.');
$b = json_decode(file_get_contents('php://input'), true);
$db->prepare("UPDATE pelatihan SET nama_pelatihan=?, penyelenggara=?, tanggal=?, durasi_hari=?, deskripsi=? WHERE id=?")
->execute([sanitize($b['nama_pelatihan'] ?? ''), sanitize($b['penyelenggara'] ?? ''),
$b['tanggal'] ?? null, (int)($b['durasi_hari'] ?? 1),
sanitize($b['deskripsi'] ?? ''), $id]);
jsonOk(null, 'Pelatihan diperbarui.');
break;
case 'DELETE':
requireRole(['superadmin']);
if (!$id) jsonErr('ID tidak valid.');
$db->prepare("DELETE FROM pelatihan WHERE id=?")->execute([$id]);
jsonOk(null, 'Pelatihan dihapus.');
break;
default: jsonErr('Method tidak didukung.', 405);
}
+174
View File
@@ -0,0 +1,174 @@
<?php
require_once __DIR__ . '/../config.php';
$user = requireLogin();
$method = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
$db = getDB();
switch ($method) {
// ── GET LIST / SINGLE ──
case 'GET':
if ($id) {
// single dengan histori bantuan & riwayat pelatihan
$st = $db->prepare("SELECT p.*, k.no_kk, k.alamat as alamat_kk, k.rt, k.rw,
k.kelurahan, k.kecamatan, k.rumah_ibadah_id
FROM v_penduduk p
JOIN keluarga k ON k.id = p.keluarga_id
WHERE p.id = ?");
$st->execute([$id]);
$row = $st->fetch();
if (!$row) jsonErr('Data tidak ditemukan.', 404);
// histori bantuan
$hb = $db->prepare("SELECT hb.*, b.nama as bantuan_nama, b.jenis, b.satuan,
u.nama_lengkap as disalurkan_nama
FROM histori_bantuan hb
JOIN bantuan b ON b.id = hb.bantuan_id
LEFT JOIN user u ON u.id = hb.disalurkan_oleh
WHERE hb.penduduk_id = ? ORDER BY hb.tanggal DESC");
$hb->execute([$id]);
$row['histori_bantuan'] = $hb->fetchAll();
// pelatihan
$plt = $db->prepare("SELECT rp.*, p.nama_pelatihan, p.penyelenggara
FROM riwayat_pelatihan rp
JOIN pelatihan p ON p.id = rp.pelatihan_id
WHERE rp.penduduk_id = ?");
$plt->execute([$id]);
$row['riwayat_pelatihan'] = $plt->fetchAll();
// validasi pendidikan
$warn = validasiPendidikan((int)$row['umur'], $row['pendidikan_terakhir'], $row['status_pendidikan']);
$row['peringatan_pendidikan'] = $warn;
jsonOk($row);
}
// list dengan filter
$where = ["p.status_hidup = 'hidup'"]; $params = [];
if (!empty($_GET['status_ekonomi'])) { $where[] = "p.status_ekonomi = ?"; $params[] = $_GET['status_ekonomi']; }
if (!empty($_GET['keluarga_id'])) { $where[] = "p.keluarga_id = ?"; $params[] = (int)$_GET['keluarga_id']; }
if (!empty($_GET['kelurahan'])) { $where[] = "k.kelurahan = ?"; $params[] = $_GET['kelurahan']; }
if (!empty($_GET['q'])) { $where[] = "(p.nama LIKE ? OR p.nik LIKE ?)"; $params[] = '%'.$_GET['q'].'%'; $params[] = '%'.$_GET['q'].'%'; }
$wStr = implode(' AND ', $where);
$st = $db->prepare("SELECT p.id, p.nik, p.nama, p.jenis_kelamin, p.tanggal_lahir, p.umur,
p.profil_umur, p.status_keluarga, p.status_perkawinan,
p.status_ekonomi, p.kondisi_kesehatan, p.pekerjaan,
p.penghasilan, p.lat, p.lng, p.keluarga_id,
k.no_kk, k.kelurahan, k.alamat as alamat_kk,
(SELECT COUNT(*) FROM histori_bantuan hb WHERE hb.penduduk_id=p.id AND hb.status='disalurkan') as total_bantuan
FROM v_penduduk p JOIN keluarga k ON k.id=p.keluarga_id
WHERE $wStr ORDER BY p.nama ASC LIMIT 500");
$st->execute($params);
jsonOk($st->fetchAll());
break;
// ── POST: Tambah ──
case 'POST':
requireRole(['superadmin','pengurus','relawan']);
$b = json_decode(file_get_contents('php://input'), true);
$nik = sanitize($b['nik'] ?? '');
$nama = sanitize($b['nama'] ?? '');
$tgl = $b['tanggal_lahir'] ?? '';
$kjId = (int)($b['keluarga_id'] ?? 0);
if (!$nik || strlen($nik) !== 16) jsonErr('NIK harus 16 digit.');
if (!$nama) jsonErr('Nama wajib diisi.');
if (!$tgl) jsonErr('Tanggal lahir wajib diisi.');
if (!$kjId) jsonErr('Keluarga (KK) wajib dipilih.');
// cek NIK unik
$chk = $db->prepare("SELECT id FROM penduduk WHERE nik=?"); $chk->execute([$nik]);
if ($chk->fetch()) jsonErr('NIK sudah terdaftar di sistem.');
$umur = hitungUmur($tgl);
$pendid = $b['pendidikan_terakhir'] ?? 'tidak_sekolah';
$stPend = $b['status_pendidikan'] ?? 'tidak_sekolah';
$db->prepare("INSERT INTO penduduk
(nik,nama,jenis_kelamin,tanggal_lahir,status_keluarga,status_perkawinan,status_hidup,
keluarga_id,pendidikan_terakhir,status_pendidikan,pekerjaan,penghasilan,
status_ekonomi,kondisi_kesehatan,lat,lng,catatan,created_by)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)")
->execute([
$nik, $nama,
$b['jenis_kelamin'] ?? 'L',
$tgl,
$b['status_keluarga'] ?? 'anggota',
$b['status_perkawinan'] ?? 'belum_kawin',
$b['status_hidup'] ?? 'hidup',
$kjId, $pendid, $stPend,
sanitize($b['pekerjaan'] ?? ''),
(float)($b['penghasilan'] ?? 0),
$b['status_ekonomi'] ?? 'rentan',
$b['kondisi_kesehatan'] ?? 'sehat',
isset($b['lat']) ? (float)$b['lat'] : null,
isset($b['lng']) ? (float)$b['lng'] : null,
sanitize($b['catatan'] ?? ''),
$user['id']
]);
$newId = (int)$db->lastInsertId();
log_aksi($user['id'], 'CREATE', 'penduduk', $newId, "Tambah: $nama (NIK: $nik)");
$warn = validasiPendidikan($umur, $pendid, $stPend);
jsonOk(['id'=>$newId,'peringatan'=>$warn], 'Penduduk berhasil ditambahkan.');
break;
// ── PUT: Edit ──
case 'PUT':
requireRole(['superadmin','pengurus','relawan']);
if (!$id) jsonErr('ID tidak valid.');
$b = json_decode(file_get_contents('php://input'), true);
$chk = $db->prepare("SELECT id,nik FROM penduduk WHERE id=?"); $chk->execute([$id]);
if (!$chk->fetch()) jsonErr('Data tidak ditemukan.', 404);
$tgl = $b['tanggal_lahir'] ?? '';
$umur = $tgl ? hitungUmur($tgl) : 0;
$pendid = $b['pendidikan_terakhir'] ?? 'tidak_sekolah';
$stPend = $b['status_pendidikan'] ?? 'tidak_sekolah';
$db->prepare("UPDATE penduduk SET
nama=?,jenis_kelamin=?,tanggal_lahir=?,status_keluarga=?,status_perkawinan=?,
status_hidup=?,keluarga_id=?,pendidikan_terakhir=?,status_pendidikan=?,
pekerjaan=?,penghasilan=?,status_ekonomi=?,kondisi_kesehatan=?,
lat=?,lng=?,catatan=?,updated_at=NOW() WHERE id=?")
->execute([
sanitize($b['nama'] ?? ''),
$b['jenis_kelamin'] ?? 'L',
$tgl,
$b['status_keluarga'] ?? 'anggota',
$b['status_perkawinan'] ?? 'belum_kawin',
$b['status_hidup'] ?? 'hidup',
(int)($b['keluarga_id'] ?? 0),
$pendid, $stPend,
sanitize($b['pekerjaan'] ?? ''),
(float)($b['penghasilan'] ?? 0),
$b['status_ekonomi'] ?? 'rentan',
$b['kondisi_kesehatan'] ?? 'sehat',
isset($b['lat']) ? (float)$b['lat'] : null,
isset($b['lng']) ? (float)$b['lng'] : null,
sanitize($b['catatan'] ?? ''),
$id
]);
log_aksi($user['id'], 'UPDATE', 'penduduk', $id, "Edit ID: $id");
$warn = validasiPendidikan($umur, $pendid, $stPend);
jsonOk(['peringatan'=>$warn], 'Data berhasil diperbarui.');
break;
// ── DELETE ──
case 'DELETE':
requireRole(['superadmin']);
if (!$id) jsonErr('ID tidak valid.');
$chk = $db->prepare("SELECT nama FROM penduduk WHERE id=?"); $chk->execute([$id]);
$row = $chk->fetch();
if (!$row) jsonErr('Data tidak ditemukan.', 404);
$db->prepare("DELETE FROM penduduk WHERE id=?")->execute([$id]);
log_aksi($user['id'], 'DELETE', 'penduduk', $id, 'Hapus: '.$row['nama']);
jsonOk(null, 'Data berhasil dihapus.');
break;
default: jsonErr('Method tidak didukung.', 405);
}
+52
View File
@@ -0,0 +1,52 @@
<?php
require_once __DIR__ . '/../config.php';
$user = requireLogin();
$method = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
$db = getDB();
switch ($method) {
case 'GET':
if ($id) {
$st = $db->prepare("SELECT ri.*,
(SELECT COUNT(*) FROM keluarga k WHERE k.rumah_ibadah_id=ri.id) as jumlah_kk,
(SELECT COUNT(*) FROM keluarga k JOIN penduduk p ON p.keluarga_id=k.id WHERE k.rumah_ibadah_id=ri.id AND p.status_hidup='hidup') as jumlah_jiwa,
(SELECT COUNT(*) FROM keluarga k WHERE k.rumah_ibadah_id=ri.id AND k.status_ekonomi='miskin') as kk_miskin
FROM rumah_ibadah ri WHERE ri.id=?");
$st->execute([$id]); jsonOk($st->fetch()); break;
}
$rows = $db->query("SELECT ri.id, ri.nama, ri.jenis, ri.kontak, ri.lat, ri.lng, ri.alamat, ri.kelurahan,
(SELECT COUNT(*) FROM keluarga k WHERE k.rumah_ibadah_id=ri.id) as jumlah_kk,
(SELECT COUNT(*) FROM keluarga k WHERE k.rumah_ibadah_id=ri.id AND k.status_ekonomi='miskin') as kk_miskin
FROM rumah_ibadah ri ORDER BY ri.nama")->fetchAll();
foreach ($rows as &$r) { $r['id']=(int)$r['id']; $r['lat']=(float)$r['lat']; $r['lng']=(float)$r['lng']; }
jsonOk($rows); break;
case 'POST':
requireRole(['superadmin']);
$b = json_decode(file_get_contents('php://input'), true);
$nama = sanitize($b['nama'] ?? '');
if (!$nama) jsonErr('Nama wajib diisi.');
$db->prepare("INSERT INTO rumah_ibadah (nama,jenis,kontak,lat,lng,alamat,kelurahan) VALUES (?,?,?,?,?,?,?)")
->execute([$nama,$b['jenis']??'Masjid',sanitize($b['kontak']??''),(float)($b['lat']??0),(float)($b['lng']??0),sanitize($b['alamat']??''),sanitize($b['kelurahan']??'')]);
jsonOk(['id'=>(int)$db->lastInsertId()],'Rumah ibadah ditambahkan.');
break;
case 'PUT':
requireRole(['superadmin','pengurus']);
if (!$id) jsonErr('ID tidak valid.');
$b = json_decode(file_get_contents('php://input'), true);
$db->prepare("UPDATE rumah_ibadah SET nama=?,jenis=?,kontak=?,lat=?,lng=?,alamat=?,kelurahan=?,updated_at=NOW() WHERE id=?")
->execute([sanitize($b['nama']??''),$b['jenis']??'Masjid',sanitize($b['kontak']??''),(float)($b['lat']??0),(float)($b['lng']??0),sanitize($b['alamat']??''),sanitize($b['kelurahan']??''),$id]);
jsonOk(null,'Data diperbarui.');
break;
case 'DELETE':
requireRole(['superadmin']);
if (!$id) jsonErr('ID tidak valid.');
$db->prepare("DELETE FROM rumah_ibadah WHERE id=?")->execute([$id]);
jsonOk(null,'Data dihapus.');
break;
default: jsonErr('Method tidak didukung.', 405);
}
+96
View File
@@ -0,0 +1,96 @@
<?php
require_once __DIR__ . '/../config.php';
requireLogin();
$db = getDB();
// KPI Utama
$totalPenduduk = (int)$db->query("SELECT COUNT(*) FROM penduduk WHERE status_hidup='hidup'")->fetchColumn();
$totalKeluarga = (int)$db->query("SELECT COUNT(*) FROM keluarga")->fetchColumn();
$totalMiskin = (int)$db->query("SELECT COUNT(*) FROM penduduk WHERE status_ekonomi='miskin' AND status_hidup='hidup'")->fetchColumn();
$totalRentan = (int)$db->query("SELECT COUNT(*) FROM penduduk WHERE status_ekonomi='rentan' AND status_hidup='hidup'")->fetchColumn();
$totalMampu = (int)$db->query("SELECT COUNT(*) FROM penduduk WHERE status_ekonomi='mampu' AND status_hidup='hidup'")->fetchColumn();
$totalLaporan = (int)$db->query("SELECT COUNT(*) FROM laporan WHERE status != 'selesai'")->fetchColumn();
$laporanDarurat= (int)$db->query("SELECT COUNT(*) FROM laporan WHERE urgensi='darurat' AND status NOT IN ('selesai')")->fetchColumn();
$totalBantuan = (int)$db->query("SELECT COUNT(*) FROM histori_bantuan WHERE status='disalurkan'")->fetchColumn();
$belumBantuan = (int)$db->query("SELECT COUNT(*) FROM penduduk WHERE status_ekonomi IN ('miskin','rentan') AND status_hidup='hidup' AND id NOT IN (SELECT DISTINCT penduduk_id FROM histori_bantuan WHERE status='disalurkan')")->fetchColumn();
$unreadChat = (int)$db->query("SELECT COUNT(*) FROM chat WHERE dibaca=0")->fetchColumn();
// Distribusi ekonomi per kelurahan
$byKelurahan = $db->query("SELECT k.kelurahan,
SUM(CASE WHEN k.status_ekonomi='miskin' THEN 1 ELSE 0 END) as miskin,
SUM(CASE WHEN k.status_ekonomi='rentan' THEN 1 ELSE 0 END) as rentan,
SUM(CASE WHEN k.status_ekonomi='mampu' THEN 1 ELSE 0 END) as mampu,
COUNT(*) as total
FROM keluarga k GROUP BY k.kelurahan ORDER BY miskin DESC LIMIT 10")->fetchAll();
// Distribusi bantuan per jenis
$byBantuan = $db->query("SELECT b.nama, b.jenis, COUNT(hb.id) as total_disalurkan
FROM bantuan b LEFT JOIN histori_bantuan hb ON hb.bantuan_id=b.id AND hb.status='disalurkan'
GROUP BY b.id ORDER BY total_disalurkan DESC")->fetchAll();
// Distribusi per rumah ibadah
$byRI = $db->query("SELECT ri.nama, ri.jenis, ri.lat, ri.lng,
COUNT(DISTINCT k.id) as jumlah_kk,
SUM(CASE WHEN k.status_ekonomi='miskin' THEN 1 ELSE 0 END) as kk_miskin,
(SELECT COUNT(*) FROM v_penduduk p JOIN keluarga k2 ON k2.id=p.keluarga_id WHERE k2.rumah_ibadah_id=ri.id AND p.status_hidup='hidup') as jumlah_jiwa
FROM rumah_ibadah ri LEFT JOIN keluarga k ON k.rumah_ibadah_id=ri.id
GROUP BY ri.id ORDER BY kk_miskin DESC")->fetchAll();
// Profil umur
$byUmur = $db->query("SELECT profil_umur, COUNT(*) as jumlah
FROM v_penduduk WHERE status_hidup='hidup' GROUP BY profil_umur")->fetchAll();
// Laporan per status
$byLaporan = $db->query("SELECT status, urgensi, COUNT(*) as jumlah
FROM laporan GROUP BY status, urgensi ORDER BY FIELD(urgensi,'darurat','tinggi','sedang','rendah')")->fetchAll();
// GeoJSON penduduk untuk peta
$geoRows = $db->query("SELECT p.id, p.nik, p.nama, p.umur, p.profil_umur, p.status_ekonomi,
p.kondisi_kesehatan, p.penghasilan, k.kelurahan, k.lat, k.lng, k.no_kk,
(SELECT COUNT(*) FROM histori_bantuan hb WHERE hb.penduduk_id=p.id AND hb.status='disalurkan') as total_bantuan
FROM v_penduduk p JOIN keluarga k ON k.id=p.keluarga_id
WHERE p.status_hidup='hidup' AND k.lat IS NOT NULL AND k.lng IS NOT NULL
LIMIT 1000")->fetchAll();
$features = [];
foreach ($geoRows as $r) {
$features[] = [
'type' => 'Feature',
'geometry' => ['type'=>'Point','coordinates'=>[(float)$r['lng'],(float)$r['lat']]],
'properties' => $r
];
}
// GeoJSON laporan untuk peta
$laporanRows = $db->query("SELECT id,deskripsi,lat,lng,urgensi,status,created_at,foto FROM laporan WHERE lat IS NOT NULL AND lng IS NOT NULL")->fetchAll();
$laporanFeatures = [];
foreach ($laporanRows as $r) {
$laporanFeatures[] = [
'type' => 'Feature',
'geometry' => ['type'=>'Point','coordinates'=>[(float)$r['lng'],(float)$r['lat']]],
'properties' => $r
];
}
jsonOk([
'kpi' => [
'total_penduduk' => $totalPenduduk,
'total_keluarga' => $totalKeluarga,
'total_miskin' => $totalMiskin,
'total_rentan' => $totalRentan,
'total_mampu' => $totalMampu,
'total_laporan_aktif' => $totalLaporan,
'laporan_darurat' => $laporanDarurat,
'total_bantuan_disalurkan' => $totalBantuan,
'belum_dapat_bantuan' => $belumBantuan,
'unread_chat' => $unreadChat,
'pct_miskin' => $totalPenduduk ? round($totalMiskin/$totalPenduduk*100,1) : 0,
],
'by_kelurahan' => $byKelurahan,
'by_bantuan' => $byBantuan,
'by_ri' => $byRI,
'by_umur' => $byUmur,
'by_laporan' => $byLaporan,
'geojson_penduduk' => ['type'=>'FeatureCollection','features'=>$features],
'geojson_laporan' => ['type'=>'FeatureCollection','features'=>$laporanFeatures],
]);
+87
View File
@@ -0,0 +1,87 @@
<?php
// api/user.php — Manajemen User (superadmin only)
require_once __DIR__ . '/../config.php';
$user = requireRole(['superadmin']);
$method = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
$db = getDB();
switch ($method) {
case 'GET':
if ($id) {
$st = $db->prepare("SELECT u.id, u.username, u.nama_lengkap, u.telepon, u.aktif,
r.kode as role, r.nama as role_nama, r.id as role_id,
ri.nama as ri_nama, u.rumah_ibadah_id, u.created_at
FROM user u JOIN role r ON r.id=u.role_id
LEFT JOIN rumah_ibadah ri ON ri.id=u.rumah_ibadah_id
WHERE u.id=?");
$st->execute([$id]);
jsonOk($st->fetch());
}
$rows = $db->query("SELECT u.id, u.username, u.nama_lengkap, u.telepon, u.aktif,
r.kode as role, r.nama as role_nama, ri.nama as ri_nama, u.created_at
FROM user u JOIN role r ON r.id=u.role_id
LEFT JOIN rumah_ibadah ri ON ri.id=u.rumah_ibadah_id
ORDER BY r.id, u.nama_lengkap")->fetchAll();
jsonOk($rows);
break;
case 'POST':
$b = json_decode(file_get_contents('php://input'), true);
$username = sanitize($b['username'] ?? '');
$password = $b['password'] ?? '';
$nama = sanitize($b['nama_lengkap'] ?? '');
$roleId = (int)($b['role_id'] ?? 5);
if (!$username || !$password || !$nama) jsonErr('Username, password, dan nama wajib diisi.');
if (strlen($username) < 4) jsonErr('Username minimal 4 karakter.');
if (strlen($password) < 6) jsonErr('Password minimal 6 karakter.');
$chk = $db->prepare("SELECT id FROM user WHERE username=?"); $chk->execute([$username]);
if ($chk->fetch()) jsonErr('Username sudah digunakan.');
$hash = password_hash($password, PASSWORD_BCRYPT);
$db->prepare("INSERT INTO user (username, password, nama_lengkap, role_id, rumah_ibadah_id, telepon)
VALUES (?,?,?,?,?,?)")
->execute([$username, $hash, $nama, $roleId,
!empty($b['rumah_ibadah_id']) ? (int)$b['rumah_ibadah_id'] : null,
sanitize($b['telepon'] ?? '')]);
$newId = (int)$db->lastInsertId();
log_aksi($user['id'], 'CREATE', 'user', $newId, "Tambah user: $username");
jsonOk(['id' => $newId], 'User berhasil ditambahkan.');
break;
case 'PUT':
if (!$id) jsonErr('ID tidak valid.');
$b = json_decode(file_get_contents('php://input'), true);
$nama = sanitize($b['nama_lengkap'] ?? '');
if (!$nama) jsonErr('Nama wajib diisi.');
$chk = $db->prepare("SELECT id FROM user WHERE id=?"); $chk->execute([$id]);
if (!$chk->fetch()) jsonErr('User tidak ditemukan.', 404);
// Update data dasar
$db->prepare("UPDATE user SET nama_lengkap=?, role_id=?, rumah_ibadah_id=?, telepon=?, aktif=? WHERE id=?")
->execute([$nama, (int)($b['role_id'] ?? 5),
!empty($b['rumah_ibadah_id']) ? (int)$b['rumah_ibadah_id'] : null,
sanitize($b['telepon'] ?? ''), (int)($b['aktif'] ?? 1), $id]);
// Ganti password jika diisi
if (!empty($b['password']) && strlen($b['password']) >= 6) {
$db->prepare("UPDATE user SET password=? WHERE id=?")
->execute([password_hash($b['password'], PASSWORD_BCRYPT), $id]);
}
log_aksi($user['id'], 'UPDATE', 'user', $id, "Edit user ID: $id");
jsonOk(null, 'Data user diperbarui.');
break;
case 'DELETE':
if (!$id) jsonErr('ID tidak valid.');
if ($id === $user['id']) jsonErr('Tidak dapat menghapus akun sendiri.');
$db->prepare("DELETE FROM user WHERE id=?")->execute([$id]);
log_aksi($user['id'], 'DELETE', 'user', $id, "Hapus user ID: $id");
jsonOk(null, 'User dihapus.');
break;
default: jsonErr('Method tidak didukung.', 405);
}
+101
View File
@@ -0,0 +1,101 @@
<?php
// ============================================================
// config.php — WebGIS Kemiskinan v3.0
// ============================================================
define('DB_HOST', getenv('DB_HOST') ?: 'localhost');
define('DB_USER', getenv('DB_USER') ?: 'root');
define('DB_PASS', getenv('DB_PASSWORD') ?: '');
define('DB_NAME', getenv('DB_NAME') ?: 'webgis_kemiskinan');
define('UPLOAD_DIR', __DIR__ . '/uploads/');
date_default_timezone_set('Asia/Pontianak');
function getDB(): PDO {
static $pdo = null;
if ($pdo === null) {
try {
$pdo = new PDO("mysql:host=".DB_HOST.";dbname=".DB_NAME.";charset=utf8mb4", DB_USER, DB_PASS, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
} catch (PDOException $e) {
jsonErr('Koneksi database gagal: ' . $e->getMessage(), 500);
}
}
return $pdo;
}
function startSession(): void {
if (session_status() === PHP_SESSION_NONE) {
session_name('webgis_v3');
session_set_cookie_params(['lifetime'=>28800,'path'=>'/','httponly'=>true,'samesite'=>'Strict']);
session_start();
}
}
function getCurrentUser(): ?array {
startSession();
return $_SESSION['user'] ?? null;
}
function requireLogin(): array {
$u = getCurrentUser();
if (!$u) jsonErr('Silakan login terlebih dahulu.', 401);
return $u;
}
function requireRole(array $roles): array {
$u = requireLogin();
if (!in_array($u['role'], $roles, true)) jsonErr('Akses ditolak untuk role: '.$u['role'], 403);
return $u;
}
function canWrite(): bool {
$u = getCurrentUser();
return $u && in_array($u['role'], ['superadmin','pengurus','relawan'], true);
}
function jsonOk($data = null, string $msg = 'Berhasil'): void {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['success'=>true,'message'=>$msg,'data'=>$data], JSON_UNESCAPED_UNICODE);
exit;
}
function jsonErr(string $msg, int $code = 400): void {
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['success'=>false,'message'=>$msg], JSON_UNESCAPED_UNICODE);
exit;
}
function log_aksi(int $userId, string $aksi, string $tabel, int $recId, string $ket = ''): void {
try {
getDB()->prepare("INSERT INTO log_aktivitas (user_id,aksi,tabel,record_id,keterangan,ip_address) VALUES (?,?,?,?,?,?)")
->execute([$userId, $aksi, $tabel, $recId, $ket, $_SERVER['REMOTE_ADDR'] ?? '']);
} catch (Exception $e) {}
}
function sanitize(string $s): string {
return htmlspecialchars(strip_tags(trim($s)), ENT_QUOTES, 'UTF-8');
}
// Hitung umur dari tanggal lahir
function hitungUmur(string $tgl): int {
return (int) date_diff(date_create($tgl), date_create('today'))->y;
}
// Validasi pendidikan vs umur
function validasiPendidikan(int $umur, string $pendidikan, string $statusPendidikan): ?string {
if ($umur >= 7 && $umur <= 15 && $statusPendidikan === 'tidak_sekolah'
&& in_array($pendidikan, ['tidak_sekolah','sd'])) {
return 'Peringatan: Usia 7-15 tahun seharusnya masih sekolah SD/SMP.';
}
if ($umur >= 16 && $umur <= 18 && $statusPendidikan === 'tidak_sekolah') {
return 'Peringatan: Usia 16-18 tahun seharusnya masih sekolah SMA/SMK.';
}
return null;
}
header('Content-Type: application/json; charset=utf-8');
if (!is_dir(UPLOAD_DIR)) mkdir(UPLOAD_DIR, 0755, true);
+375
View File
@@ -0,0 +1,375 @@
-- ================================================================
-- WebGIS Pengentasan Kemiskinan — Kota Pontianak v3.0
-- Database Lengkap: 13 tabel, 5 role, relasi kuat
-- Jalankan di phpMyAdmin > tab SQL > klik Go
-- ================================================================
DROP DATABASE IF EXISTS webgis_kemiskinan;
CREATE DATABASE webgis_kemiskinan CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE webgis_kemiskinan;
-- ────────────────────────────────────────────────
-- 1. ROLE
-- ────────────────────────────────────────────────
CREATE TABLE role (
id TINYINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
kode VARCHAR(30) NOT NULL UNIQUE,
nama VARCHAR(80) NOT NULL
) ENGINE=InnoDB;
INSERT INTO role (kode, nama) VALUES
('superadmin', 'Super Admin'),
('pimpinan', 'Pimpinan Daerah / Pemerintah'),
('pengurus', 'Pengurus Rumah Ibadah'),
('relawan', 'Relawan Lapangan'),
('masyarakat', 'Masyarakat Umum');
-- ────────────────────────────────────────────────
-- 2. RUMAH IBADAH
-- ────────────────────────────────────────────────
CREATE TABLE rumah_ibadah (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(200) NOT NULL,
jenis ENUM('Masjid','Gereja','Vihara','Klenteng','Pura') NOT NULL,
kontak VARCHAR(60),
lat DECIMAL(10,7) NOT NULL,
lng DECIMAL(10,7) NOT NULL,
alamat TEXT,
kelurahan VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;
-- ────────────────────────────────────────────────
-- 3. USER
-- ────────────────────────────────────────────────
CREATE TABLE user (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(80) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
nama_lengkap VARCHAR(150) NOT NULL,
role_id TINYINT UNSIGNED NOT NULL,
rumah_ibadah_id INT DEFAULT NULL,
telepon VARCHAR(20),
aktif TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (role_id) REFERENCES role(id),
FOREIGN KEY (rumah_ibadah_id) REFERENCES rumah_ibadah(id) ON DELETE SET NULL
) ENGINE=InnoDB;
-- ────────────────────────────────────────────────
-- 4. KELUARGA (KK)
-- ────────────────────────────────────────────────
CREATE TABLE keluarga (
id INT AUTO_INCREMENT PRIMARY KEY,
no_kk VARCHAR(20) NOT NULL UNIQUE,
nama_kk VARCHAR(200) NOT NULL, -- nama kepala keluarga
alamat TEXT,
rt VARCHAR(5),
rw VARCHAR(5),
kelurahan VARCHAR(100),
kecamatan VARCHAR(100),
lat DECIMAL(10,7),
lng DECIMAL(10,7),
rumah_ibadah_id INT DEFAULT NULL,
status_ekonomi ENUM('miskin','rentan','mampu') NOT NULL DEFAULT 'rentan',
created_by INT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (rumah_ibadah_id) REFERENCES rumah_ibadah(id) ON DELETE SET NULL,
FOREIGN KEY (created_by) REFERENCES user(id) ON DELETE SET NULL
) ENGINE=InnoDB;
-- ────────────────────────────────────────────────
-- 5. PENDUDUK
-- ────────────────────────────────────────────────
CREATE TABLE penduduk (
id INT AUTO_INCREMENT PRIMARY KEY,
nik VARCHAR(16) NOT NULL UNIQUE,
nama VARCHAR(200) NOT NULL,
jenis_kelamin ENUM('L','P') NOT NULL,
tanggal_lahir DATE NOT NULL,
-- umur & profil_umur dihitung via VIEW (kompatibel semua versi MySQL)
-- kolom ini diisi/diperbarui otomatis oleh PHP saat INSERT/UPDATE
status_keluarga ENUM('kepala','istri','anak','orang_tua','lainnya') NOT NULL DEFAULT 'kepala',
status_perkawinan ENUM('belum_kawin','kawin','cerai_hidup','cerai_mati') NOT NULL DEFAULT 'belum_kawin',
status_hidup ENUM('hidup','meninggal') NOT NULL DEFAULT 'hidup',
keluarga_id INT NOT NULL,
-- pendidikan
pendidikan_terakhir ENUM('tidak_sekolah','sd','smp','sma','smk','d3','s1','s2','s3') DEFAULT 'tidak_sekolah',
status_pendidikan ENUM('masih_sekolah','tidak_sekolah') DEFAULT 'tidak_sekolah',
-- ekonomi
pekerjaan VARCHAR(100),
penghasilan DECIMAL(12,2) DEFAULT 0,
status_ekonomi ENUM('miskin','rentan','mampu') NOT NULL DEFAULT 'rentan',
-- kondisi kesehatan
kondisi_kesehatan ENUM('sehat','sakit_ringan','sakit_berat','disabilitas') DEFAULT 'sehat',
-- lokasi (bisa berbeda dari KK jika merantau)
lat DECIMAL(10,7),
lng DECIMAL(10,7),
-- meta
foto VARCHAR(255),
catatan TEXT,
created_by INT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (keluarga_id) REFERENCES keluarga(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES user(id) ON DELETE SET NULL
) ENGINE=InnoDB;
-- ────────────────────────────────────────────────
-- 6. PELATIHAN
-- ────────────────────────────────────────────────
CREATE TABLE pelatihan (
id INT AUTO_INCREMENT PRIMARY KEY,
nama_pelatihan VARCHAR(200) NOT NULL,
penyelenggara VARCHAR(150),
tanggal DATE,
durasi_hari TINYINT UNSIGNED DEFAULT 1,
deskripsi TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
CREATE TABLE riwayat_pelatihan (
id INT AUTO_INCREMENT PRIMARY KEY,
penduduk_id INT NOT NULL,
pelatihan_id INT NOT NULL,
tanggal_ikut DATE,
sertifikat VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (penduduk_id) REFERENCES penduduk(id) ON DELETE CASCADE,
FOREIGN KEY (pelatihan_id) REFERENCES pelatihan(id) ON DELETE CASCADE
) ENGINE=InnoDB;
-- ────────────────────────────────────────────────
-- 7. BANTUAN
-- ────────────────────────────────────────────────
CREATE TABLE bantuan (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(200) NOT NULL,
jenis ENUM('sembako','uang_tunai','kesehatan','pendidikan','perumahan','lainnya') NOT NULL,
sumber VARCHAR(150), -- PKH, BPNT, Baznas, Dinsos, dll
satuan VARCHAR(50), -- karung, paket, Rp, dll
nilai_per_satuan DECIMAL(12,2) DEFAULT 0,
periode ENUM('sekali','bulanan','triwulan','tahunan') DEFAULT 'sekali',
aktif TINYINT(1) DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
CREATE TABLE histori_bantuan (
id INT AUTO_INCREMENT PRIMARY KEY,
penduduk_id INT NOT NULL,
bantuan_id INT NOT NULL,
disalurkan_oleh INT, -- user_id penyalur
tanggal DATE NOT NULL,
jumlah DECIMAL(10,2) DEFAULT 1,
status ENUM('dijadwalkan','disalurkan','gagal') DEFAULT 'dijadwalkan',
catatan TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (penduduk_id) REFERENCES penduduk(id) ON DELETE CASCADE,
FOREIGN KEY (bantuan_id) REFERENCES bantuan(id) ON DELETE CASCADE,
FOREIGN KEY (disalurkan_oleh) REFERENCES user(id) ON DELETE SET NULL
) ENGINE=InnoDB;
-- ────────────────────────────────────────────────
-- 8. LAPORAN MASYARAKAT
-- ────────────────────────────────────────────────
CREATE TABLE laporan (
id INT AUTO_INCREMENT PRIMARY KEY,
pelapor_nama VARCHAR(150), -- bisa anonim
pelapor_telepon VARCHAR(20),
penduduk_id INT DEFAULT NULL, -- jika tahu NIK
deskripsi TEXT NOT NULL,
lat DECIMAL(10,7),
lng DECIMAL(10,7),
alamat_laporan TEXT,
foto VARCHAR(255),
urgensi ENUM('rendah','sedang','tinggi','darurat') DEFAULT 'sedang',
status ENUM('pending','diverifikasi','diproses','selesai') DEFAULT 'pending',
diverifikasi_oleh INT DEFAULT NULL,
catatan_admin TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (penduduk_id) REFERENCES penduduk(id) ON DELETE SET NULL,
FOREIGN KEY (diverifikasi_oleh) REFERENCES user(id) ON DELETE SET NULL
) ENGINE=InnoDB;
-- ────────────────────────────────────────────────
-- 9. CHAT
-- ────────────────────────────────────────────────
CREATE TABLE chat (
id INT AUTO_INCREMENT PRIMARY KEY,
dari_user_id INT NOT NULL,
ke_user_id INT DEFAULT NULL, -- NULL = broadcast ke semua pengurus
pesan TEXT NOT NULL,
dibaca TINYINT(1) DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (dari_user_id) REFERENCES user(id) ON DELETE CASCADE,
FOREIGN KEY (ke_user_id) REFERENCES user(id) ON DELETE SET NULL
) ENGINE=InnoDB;
-- ────────────────────────────────────────────────
-- 10. LOG AKTIVITAS
-- ────────────────────────────────────────────────
CREATE TABLE log_aktivitas (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT,
aksi VARCHAR(60) NOT NULL,
tabel VARCHAR(60),
record_id INT,
keterangan TEXT,
ip_address VARCHAR(45),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE SET NULL
) ENGINE=InnoDB;
-- ────────────────────────────────────────────────
-- VIEW: Hitung umur & profil_umur otomatis
-- Kompatibel semua versi MySQL/MariaDB
-- ────────────────────────────────────────────────
CREATE OR REPLACE VIEW v_penduduk AS
SELECT
p.*,
TIMESTAMPDIFF(YEAR, p.tanggal_lahir, CURDATE()) AS umur,
CASE
WHEN TIMESTAMPDIFF(YEAR, p.tanggal_lahir, CURDATE()) < 5 THEN 'Balita'
WHEN TIMESTAMPDIFF(YEAR, p.tanggal_lahir, CURDATE()) < 12 THEN 'Anak'
WHEN TIMESTAMPDIFF(YEAR, p.tanggal_lahir, CURDATE()) < 18 THEN 'Remaja'
WHEN TIMESTAMPDIFF(YEAR, p.tanggal_lahir, CURDATE()) < 60 THEN 'Dewasa'
ELSE 'Lansia'
END AS profil_umur
FROM penduduk p;
-- ────────────────────────────────────────────────
-- INDEX PERFORMA
-- ────────────────────────────────────────────────
CREATE INDEX idx_penduduk_nik ON penduduk(nik);
CREATE INDEX idx_penduduk_keluarga ON penduduk(keluarga_id);
CREATE INDEX idx_penduduk_ekonomi ON penduduk(status_ekonomi);
CREATE INDEX idx_penduduk_hidup ON penduduk(status_hidup);
CREATE INDEX idx_keluarga_ekonomi ON keluarga(status_ekonomi);
CREATE INDEX idx_keluarga_ri ON keluarga(rumah_ibadah_id);
CREATE INDEX idx_histori_penduduk ON histori_bantuan(penduduk_id);
CREATE INDEX idx_histori_tgl ON histori_bantuan(tanggal);
CREATE INDEX idx_laporan_status ON laporan(status);
CREATE INDEX idx_laporan_urgensi ON laporan(urgensi);
CREATE INDEX idx_chat_ke ON chat(ke_user_id);
CREATE INDEX idx_log_user ON log_aktivitas(user_id);
-- ================================================================
-- DATA AWAL
-- ================================================================
-- RUMAH IBADAH
INSERT INTO rumah_ibadah (nama, jenis, kontak, lat, lng, alamat, kelurahan) VALUES
('Masjid Raya Mujahidin', 'Masjid', '0561-731234', -0.0193000, 109.3421000, 'Jl. Ahmad Yani No.1', 'Akcaya'),
('Gereja Katedral St. Yosef', 'Gereja', '0561-734567', -0.0298000, 109.3334000, 'Jl. Pattimura No.8', 'Tengah'),
('Masjid Al-Falah', 'Masjid', '0561-762111', -0.0089000, 109.3508000, 'Jl. Sultan Syarif No.3', 'Batu Layang'),
('Vihara Bodhisattva Karaniya', 'Vihara', '0561-748999', -0.0351000, 109.3287000, 'Jl. Diponegoro No.12', 'Parit Tokaya'),
('Gereja Bethel Indonesia', 'Gereja', '0561-712888', -0.0431000, 109.3376000, 'Jl. Imam Bonjol No.5', 'Benua Melayu Darat'),
('Masjid Jami Sultan Syarif', 'Masjid', '0561-721000', -0.0145000, 109.3289000, 'Jl. Tanjung Raya II No.2', 'Saigon'),
('Klenteng Kwan Tie Miau', 'Klenteng','0561-737000', -0.0265000, 109.3301000, 'Jl. Diponegoro No.3', 'Mariana');
-- USER (password = "password" plaintext, bisa di-hash bcrypt via PHP)
INSERT INTO user (username, password, nama_lengkap, role_id, rumah_ibadah_id, telepon) VALUES
('superadmin', 'password', 'Super Administrator', 1, NULL, '08100000001'),
('pimpinan1', 'password', 'H. Andi Rachman (Walikota)', 2, NULL, '08100000002'),
('pengurus1', 'password', 'Ustadz H. Mahmud (Mujahidin)',3, 1, '08100000003'),
('pengurus2', 'password', 'Rm. Petrus Lioe (Katedral)', 3, 2, '08100000004'),
('pengurus3', 'password', 'Pak Budi (Al-Falah)', 3, 3, '08100000005'),
('relawan1', 'password', 'Sri Wahyuni (Relawan Utara)', 4, NULL, '08100000006'),
('relawan2', 'password', 'Doni Pratama (Relawan Selatan)',4,NULL, '08100000007'),
('masyarakat1', 'password', 'Warga Umum 1', 5, NULL, '08100000008');
-- KELUARGA
INSERT INTO keluarga (no_kk, nama_kk, alamat, rt, rw, kelurahan, kecamatan, lat, lng, rumah_ibadah_id, status_ekonomi, created_by) VALUES
('6171011234560001','Suharto', 'Jl. Tanjung Raya I No.12', '03','05','Saigon', 'Pontianak Timur', -0.0211000, 109.3445000, 1, 'miskin', 1),
('6171011234560002','Ahmad Fauzi', 'Jl. Parit Makmur No.7', '01','02','Batu Layang', 'Pontianak Utara', -0.0178000, 109.3398000, 3, 'miskin', 1),
('6171011234560003','Yulianus Sari', 'Jl. Veteran No.3', '02','01','Tengah', 'Pontianak Kota', -0.0323000, 109.3312000, 2, 'rentan', 1),
('6171011234560004','Robert Tanto', 'Jl. Gajahmada No.21', '04','03','Mariana', 'Pontianak Kota', -0.0289000, 109.3356000, 7, 'rentan', 1),
('6171011234560005','Samsul Bahri', 'Jl. Khatulistiwa No.5', '01','01','Batu Layang', 'Pontianak Utara', -0.0072000, 109.3521000, 3, 'miskin', 1),
('6171011234560006','Dewi Rahmawati', 'Jl. Sultan Hamid II No.8','02','04','Benua Melayu Darat', 'Pontianak Selatan',-0.0431000, 109.3401000, 5, 'miskin', 1),
('6171011234560007','Suparjo', 'Jl. Budi Utomo No.3', '05','02','Saigon', 'Pontianak Timur', -0.0148000, 109.3467000, 6, 'rentan', 1),
('6171011234560008','Maria Goreti Ngo','Jl. Sutoyo No.15', '03','01','Parit Tokaya', 'Pontianak Selatan',-0.0372000, 109.3271000, 4, 'miskin', 1),
('6171011234560009','Lim Beng Huat', 'Jl. Diponegoro No.4', '01','01','Mariana', 'Pontianak Kota', -0.0340000, 109.3290000, 7, 'rentan', 1),
('6171011234560010','Daud Kristianto', 'Jl. Hos Cokroaminoto No.9','04','02','Akcaya', 'Pontianak Selatan',-0.0198000, 109.3372000, 1, 'miskin', 1);
-- PENDUDUK (contoh lengkap dengan NIK, umur, dll)
INSERT INTO penduduk (nik, nama, jenis_kelamin, tanggal_lahir, status_keluarga, status_perkawinan, keluarga_id, pendidikan_terakhir, status_pendidikan, pekerjaan, penghasilan, status_ekonomi, kondisi_kesehatan, lat, lng, created_by) VALUES
('6171011234560001','Suharto', 'L','1975-03-12','kepala','kawin', 1,'sd', 'tidak_sekolah','Buruh Harian', 500000, 'miskin', 'sehat', -0.0211000,109.3445000,1),
('6171011234560002','Sri Mulyani', 'P','1978-07-20','istri', 'kawin', 1,'smp', 'tidak_sekolah','Ibu Rumah Tangga',0, 'miskin', 'sakit_ringan', -0.0211000,109.3445000,1),
('6171011234560003','Budi Suharto', 'L','2008-01-15','anak', 'belum_kawin', 1,'smp', 'masih_sekolah', NULL, 0, 'miskin', 'sehat', -0.0211000,109.3445000,1),
('6171011234560004','Ahmad Fauzi', 'L','1968-11-05','kepala','kawin', 2,'sd', 'tidak_sekolah','Nelayan', 800000,'miskin', 'sehat', -0.0178000,109.3398000,1),
('6171011234560005','Siti Aminah', 'P','1972-04-17','istri', 'kawin', 2,'sd', 'tidak_sekolah','Pedagang Kecil',300000,'miskin', 'sehat', -0.0178000,109.3398000,1),
('6171011234560006','Yulianus Sari', 'L','1980-09-22','kepala','kawin', 3,'sma', 'tidak_sekolah','Ojek Online', 1200000,'rentan','sehat', -0.0323000,109.3312000,1),
('6171011234560007','Robert Tanto', 'L','1985-06-18','kepala','kawin', 4,'sma', 'tidak_sekolah','Karyawan Swasta',2000000,'rentan','sehat', -0.0289000,109.3356000,1),
('6171011234560008','Samsul Bahri', 'L','1958-12-30','kepala','kawin', 5,'sd', 'tidak_sekolah','Petani', 400000,'miskin','sakit_ringan', -0.0072000,109.3521000,1),
('6171011234560009','Dewi Rahmawati', 'P','1990-02-14','kepala','cerai_mati', 6,'smp', 'tidak_sekolah','Buruh Cuci', 600000,'miskin','sehat', -0.0431000,109.3401000,1),
('6171011234560010','Nur Azizah', 'P','2010-05-10','anak', 'belum_kawin', 6,'sd', 'masih_sekolah', NULL, 0, 'miskin', 'sehat', -0.0431000,109.3401000,1),
('6171011234560011','Suparjo', 'L','1970-08-25','kepala','kawin', 7,'sd', 'tidak_sekolah','Kuli Bangunan', 700000,'rentan','sehat', -0.0148000,109.3467000,1),
('6171011234560012','Maria Goreti', 'P','1963-10-08','kepala','cerai_mati', 8,'sd', 'tidak_sekolah','Penjual Sayur', 450000,'miskin','disabilitas', -0.0372000,109.3271000,1),
('6171011234560013','Lim Beng Huat', 'L','1955-03-17','kepala','kawin', 9,'smp', 'tidak_sekolah','Pensiunan', 800000,'rentan','sakit_ringan', -0.0340000,109.3290000,1),
('6171011234560014','Daud Kristianto', 'L','1972-07-04','kepala','kawin', 10,'sma', 'tidak_sekolah','Pedagang', 900000,'miskin','sehat', -0.0198000,109.3372000,1),
('6171011234560015','Yanto Pramono', 'L','2007-09-12','anak', 'belum_kawin', 7,'smp', 'masih_sekolah', NULL, 0, 'rentan','sehat', -0.0148000,109.3467000,1);
-- PELATIHAN
INSERT INTO pelatihan (nama_pelatihan, penyelenggara, tanggal, durasi_hari, deskripsi) VALUES
('Pelatihan Menjahit Dasar', 'Dinsos Pontianak', '2024-03-15', 7, 'Pelatihan menjahit untuk ibu rumah tangga'),
('Kursus Memasak & Katering', 'Baznas Kalbar', '2024-04-20', 5, 'Pelatihan memasak dan usaha katering'),
('Budidaya Sayuran Hidroponik', 'Dinas Pertanian', '2024-05-10', 3, 'Teknik menanam sayuran di lahan terbatas'),
('Keterampilan Las Listrik', 'BLK Pontianak', '2024-06-05', 14, 'Keterampilan las untuk remaja putus sekolah'),
('Digital Marketing UMKM', 'Kominfo Pontianak', '2024-07-01', 2, 'Pemasaran online untuk usaha mikro');
-- RIWAYAT PELATIHAN
INSERT INTO riwayat_pelatihan (penduduk_id, pelatihan_id, tanggal_ikut) VALUES
(6, 1, '2024-03-15'), (9, 1, '2024-03-15'),
(7, 2, '2024-04-20'), (12, 3, '2024-05-10');
-- BANTUAN
INSERT INTO bantuan (nama, jenis, sumber, satuan, nilai_per_satuan, periode) VALUES
('PKH (Program Keluarga Harapan)', 'uang_tunai', 'Kemensos RI', 'Rp', 750000, 'triwulan'),
('BPNT (Sembako)', 'sembako', 'Kemensos RI', 'paket', 200000, 'bulanan'),
('BLT Dana Desa', 'uang_tunai', 'APBDes', 'Rp', 300000, 'bulanan'),
('Bantuan Baznas Kalbar', 'sembako', 'Baznas Kalbar', 'paket', 150000, 'bulanan'),
('JKN PBI (BPJS Gratis)', 'kesehatan', 'APBN/APBD', 'kartu', 0, 'tahunan'),
('Beasiswa Anak Miskin', 'pendidikan', 'Pemkot Pontianak', 'Rp', 500000, 'triwulan'),
('Bantuan Sembako Masjid Mujahidin','sembako', 'ZIS Mujahidin', 'paket', 100000, 'bulanan'),
('Program Bedah Rumah', 'perumahan', 'APBD Kota', 'unit', 15000000,'sekali');
-- HISTORI BANTUAN
INSERT INTO histori_bantuan (penduduk_id, bantuan_id, disalurkan_oleh, tanggal, jumlah, status, catatan) VALUES
(4, 1, 3, '2024-01-15', 1, 'disalurkan', 'PKH triwulan 1 2024'),
(4, 2, 3, '2024-01-20', 1, 'disalurkan', 'BPNT Januari'),
(4, 2, 3, '2024-02-20', 1, 'disalurkan', 'BPNT Februari'),
(9, 1, 4, '2024-01-15', 1, 'disalurkan', 'PKH triwulan 1 2024'),
(9, 2, 4, '2024-01-22', 1, 'disalurkan', 'BPNT Januari'),
(1, 4, 3, '2024-02-01', 1, 'disalurkan', 'Bantuan Baznas Februari'),
(8, 4, 3, '2024-02-01', 1, 'disalurkan', 'Bantuan Baznas Februari'),
(12, 7, 4, '2024-02-05', 1, 'disalurkan', 'Sembako Katedral Februari'),
(14, 3, 5, '2024-02-10', 1, 'disalurkan', 'BLT Dana Desa'),
(7, 2, 3, '2024-03-20', 1, 'dijadwalkan','Dijadwalkan Maret');
-- LAPORAN MASYARAKAT
INSERT INTO laporan (pelapor_nama, penduduk_id, deskripsi, lat, lng, alamat_laporan, urgensi, status) VALUES
('Anonim', 8, 'Pak Samsul Bahri sakit keras sudah 2 minggu, tidak ada yang merawat dan tidak punya biaya berobat.', -0.0072000, 109.3521000, 'Jl. Khatulistiwa Pontianak Utara', 'darurat', 'diverifikasi'),
('Ani Marlina', 12, 'Ibu Maria sudah tua dan mengalami cacat, tinggal sendirian dan kesulitan memenuhi kebutuhan sehari-hari.', -0.0372000, 109.3271000, 'Jl. Sutoyo Pontianak Selatan', 'tinggi', 'diproses'),
('Warga RT 03', NULL, 'Ada keluarga baru pindah di Jl. Karet yang tampak sangat tidak mampu, rumah tidak layak huni.', -0.0195000, 109.3388000, 'Jl. Karet RT 03 RW 02', 'sedang', 'pending'),
('Pak Hasan', 1, 'Keluarga Suharto belum dapat bantuan sama sekali padahal sangat membutuhkan.', -0.0211000, 109.3445000, 'Jl. Tanjung Raya I', 'tinggi', 'pending');
-- CHAT
INSERT INTO chat (dari_user_id, ke_user_id, pesan) VALUES
(3, NULL, 'Mohon perhatian semua pengurus: ada warga darurat di wilayah Utara, Pak Samsul Bahri butuh bantuan segera 🆘'),
(4, 3, 'Siap, kami dari Katedral akan kirim relawan ke sana hari ini.'),
(3, 4, 'Terima kasih Rm. Petrus. Koordinasi dengan Pak Budi dari Al-Falah ya karena lebih dekat.'),
(5, NULL, 'Update: bantuan sembako sudah diserahkan ke Pak Samsul. Beliau butuh juga bantuan biaya berobat.'),
(6, NULL, 'Laporan survey: 3 rumah tidak layak huni ditemukan di Jl. Karet. Foto sudah diupload.');
-- LOG AKTIVITAS
INSERT INTO log_aktivitas (user_id, aksi, tabel, record_id, keterangan) VALUES
(1, 'CREATE', 'keluarga', 1, 'Input keluarga baru: Suharto'),
(1, 'CREATE', 'penduduk', 1, 'Input penduduk baru: NIK 6171011234560001'),
(3, 'UPDATE', 'histori_bantuan',1, 'Catat penyaluran PKH ke Ahmad Fauzi'),
(3, 'CREATE', 'laporan', 1, 'Laporan darurat: Samsul Bahri sakit keras'),
(4, 'UPDATE', 'laporan', 2, 'Update status laporan Maria Goreti ke diproses'),
(6, 'CREATE', 'laporan', 3, 'Survey lapangan: 3 rumah tidak layak di Jl. Karet');
+767
View File
@@ -0,0 +1,767 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>WebGIS Kemiskinan Kota Pontianak</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{
--bg:#0a0d14;--bg2:#111520;--bg3:#181d2e;--bg4:#1e253a;--bg5:#252e45;
--b:rgba(255,255,255,.06);--b2:rgba(255,255,255,.11);--b3:rgba(255,255,255,.17);
--t:#dde2f0;--t2:#7e899e;--t3:#4a5368;
--blue:#4f8ef7;--bdim:rgba(79,142,247,.14);--bglow:rgba(79,142,247,.28);
--green:#34d399;--gdim:rgba(52,211,153,.13);
--red:#f87171;--rdim:rgba(248,113,113,.13);
--amber:#fbbf24;--adim:rgba(251,191,36,.13);
--purple:#a78bfa;--pdim:rgba(167,139,250,.13);
--rose:#fb7185;--rosedim:rgba(251,113,133,.13);
--r:8px;--rs:6px;--rl:12px;
--font:'Inter',sans-serif;--mono:'JetBrains Mono',monospace;
--shadow:0 4px 24px rgba(0,0,0,.45);
}
*{box-sizing:border-box;margin:0;padding:0;-webkit-font-smoothing:antialiased}
html,body{height:100%;font-family:var(--font);background:var(--bg);color:var(--t);font-size:13px;overflow:hidden}
body{display:flex;flex-direction:column}
::-webkit-scrollbar{width:4px;height:4px}
::-webkit-scrollbar-thumb{background:var(--bg5);border-radius:4px}
/* LOGIN */
#login-screen{position:fixed;inset:0;background:var(--bg);z-index:99999;display:flex;align-items:center;justify-content:center;background-image:radial-gradient(ellipse at 20% 50%,rgba(79,142,247,.06) 0%,transparent 60%),radial-gradient(ellipse at 80% 20%,rgba(167,139,250,.05) 0%,transparent 50%)}
#login-screen.hidden{display:none}
.lcard{background:var(--bg2);border:1px solid var(--b2);border-radius:16px;padding:40px 36px;width:420px;max-width:96vw;box-shadow:var(--shadow)}
.lbrand{display:flex;align-items:center;gap:14px;margin-bottom:32px;padding-bottom:24px;border-bottom:1px solid var(--b)}
.lbrand-icon{width:44px;height:44px;background:linear-gradient(135deg,rgba(79,142,247,.9),rgba(167,139,250,.9));border-radius:11px;display:flex;align-items:center;justify-content:center;flex-shrink:0}
.lbrand h1{font-size:15px;font-weight:700;letter-spacing:-.3px;line-height:1.2}
.lbrand p{font-size:11px;color:var(--t3);margin-top:3px;font-family:var(--mono)}
.lheading{font-size:22px;font-weight:700;letter-spacing:-.5px;margin-bottom:6px}
.lsub{font-size:12px;color:var(--t3);margin-bottom:24px;line-height:1.6}
.lerr{font-size:12px;color:var(--red);background:var(--rdim);border:1px solid rgba(248,113,113,.2);border-radius:var(--rs);padding:10px 13px;margin-bottom:16px;display:none;line-height:1.5}
.lf{margin-bottom:16px}
.lf label{display:flex;align-items:center;gap:6px;font-size:10px;font-weight:700;color:var(--t3);text-transform:uppercase;letter-spacing:.7px;margin-bottom:7px}
.lf input{width:100%;padding:11px 14px;background:var(--bg3);border:1px solid var(--b2);border-radius:var(--rs);color:var(--t);font-family:var(--font);font-size:13px;outline:none;transition:all .18s}
.lf input:focus{border-color:var(--blue);background:var(--bg4);box-shadow:0 0 0 3px var(--bdim)}
.lf input::placeholder{color:var(--t3)}
.lbtn{width:100%;height:46px;background:linear-gradient(135deg,var(--blue),#6366f1);border:none;border-radius:var(--rs);color:#fff;font-family:var(--font);font-size:14px;font-weight:600;cursor:pointer;transition:all .18s;margin-top:8px;letter-spacing:-.1px;box-shadow:0 2px 12px rgba(79,142,247,.35)}
.lbtn:hover{opacity:.92;transform:translateY(-1px)}
.lbtn:disabled{background:var(--bg4);box-shadow:none;cursor:not-allowed;transform:none;color:var(--t3)}
.laccs{margin-top:20px;padding:14px;background:var(--bg3);border:1px solid var(--b);border-radius:var(--rs)}
.laccs-title{font-size:10px;font-weight:700;color:var(--t3);text-transform:uppercase;letter-spacing:.7px;margin-bottom:10px}
.laccs table{width:100%;border-collapse:collapse;font-size:11px}
.laccs td{padding:4px 0}
.laccs td:first-child{color:var(--t2);font-weight:600;width:44%}
.laccs td:last-child{font-family:var(--mono);color:var(--t3);font-size:10px}
/* TOPBAR */
#topbar{height:52px;background:var(--bg2);border-bottom:1px solid var(--b);display:flex;align-items:center;padding:0 14px;gap:6px;flex-shrink:0;z-index:200}
.brand{display:flex;align-items:center;gap:10px;margin-right:auto}
.brand-icon{width:30px;height:30px;background:linear-gradient(135deg,var(--blue),var(--purple));border-radius:8px;display:flex;align-items:center;justify-content:center;flex-shrink:0}
.brand h1{font-size:13px;font-weight:700;letter-spacing:-.3px}
.brand p{font-size:10px;color:var(--t3);font-family:var(--mono)}
.upill{display:flex;align-items:center;gap:7px;padding:5px 11px;background:var(--bg3);border:1px solid var(--b2);border-radius:20px}
.upill .uname{font-size:11px;font-weight:600}
.rbadge{font-size:9px;font-weight:700;padding:2px 8px;border-radius:20px;text-transform:uppercase;letter-spacing:.5px}
.r-superadmin{background:var(--pdim);color:var(--purple)}
.r-pimpinan{background:var(--bdim);color:var(--blue)}
.r-pengurus{background:var(--gdim);color:var(--green)}
.r-relawan{background:var(--adim);color:var(--amber)}
.r-masyarakat{background:var(--b);color:var(--t2)}
.nav-btn{height:32px;padding:0 12px;border-radius:var(--rs);border:1px solid transparent;background:transparent;color:var(--t2);font-family:var(--font);font-size:12px;font-weight:500;cursor:pointer;display:inline-flex;align-items:center;gap:6px;transition:all .15s;white-space:nowrap}
.nav-btn:hover{background:var(--bg3);color:var(--t)}
.nav-btn.active{background:var(--bdim);color:var(--blue);border-color:rgba(79,142,247,.3)}
.ndot{width:6px;height:6px;border-radius:50%;background:var(--red);flex-shrink:0;animation:pulse 1.8s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.2}}
.tsep{width:1px;height:22px;background:var(--b2);flex-shrink:0;margin:0 2px}
/* BUTTONS */
.btn{height:32px;padding:0 13px;border-radius:var(--rs);border:1px solid var(--b2);background:var(--bg3);color:var(--t);font-family:var(--font);font-size:12px;font-weight:500;cursor:pointer;display:inline-flex;align-items:center;gap:6px;transition:all .15s;white-space:nowrap}
.btn:hover{background:var(--bg4);border-color:var(--b3)}.btn:active{transform:scale(.97)}.btn:disabled{opacity:.3;cursor:not-allowed;pointer-events:none}
.btn-primary{background:var(--blue);border-color:var(--blue);color:#fff}.btn-primary:hover{background:#3b7cf0}
.btn-success{background:var(--green);border-color:var(--green);color:#0a0d14;font-weight:600}.btn-success:hover{background:#2ec48e}
.btn-danger{background:var(--red);border-color:var(--red);color:#fff}.btn-danger:hover{background:#f25c5c}
.btn-ghost{background:transparent;border-color:transparent;color:var(--t2)}.btn-ghost:hover{background:var(--bg3);color:var(--t);border-color:var(--b)}
.btn-sm{height:28px;padding:0 10px;font-size:11px;border-radius:5px}
.btn-xs{height:24px;padding:0 8px;font-size:10px;border-radius:4px}
/* LAYOUT */
#app{display:flex;flex:1;overflow:hidden}
#sidebar{width:284px;min-width:284px;background:var(--bg2);border-right:1px solid var(--b);display:flex;flex-direction:column;overflow:hidden;transition:width .25s,min-width .25s}
#sidebar.off{width:0;min-width:0}
.stabs{display:flex;border-bottom:1px solid var(--b);flex-shrink:0}
.stab{flex:1;padding:10px 4px;text-align:center;cursor:pointer;font-size:9px;font-weight:700;color:var(--t3);border-bottom:2px solid transparent;transition:all .15s;text-transform:uppercase;letter-spacing:.5px;white-space:nowrap}
.stab:hover{color:var(--t2)}.stab.active{color:var(--blue);border-bottom-color:var(--blue)}
.sbody{flex:1;overflow-y:auto;padding:14px;scrollbar-width:thin}
/* STAT CARDS */
.sgrid{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:16px}
.scard{background:var(--bg3);border:1px solid var(--b);border-radius:var(--r);padding:12px 14px;cursor:pointer;transition:all .15s}
.scard:hover{border-color:var(--b2)}
.scard .num{font-size:24px;font-weight:700;font-family:var(--mono);line-height:1;margin-bottom:4px}
.scard .lbl{font-size:9px;font-weight:700;color:var(--t3);text-transform:uppercase;letter-spacing:.5px}
.cb{color:var(--blue)}.cr{color:var(--red)}.cg{color:var(--green)}.ca{color:var(--amber)}.cp{color:var(--purple)}.cro{color:var(--rose)}
.slbl{font-size:9px;font-weight:700;color:var(--t3);text-transform:uppercase;letter-spacing:.8px;margin:16px 0 9px;display:flex;align-items:center;justify-content:space-between}
/* ITEM CARDS */
.ilist{display:flex;flex-direction:column;gap:5px}
.icard{background:var(--bg3);border:1px solid var(--b);border-radius:var(--r);padding:10px 12px;cursor:pointer;transition:all .15s}
.icard:hover{border-color:var(--b2);background:var(--bg4)}
.ihead{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:3px}
.iname{font-size:12px;font-weight:600;color:var(--t);line-height:1.3}
.isub{font-size:11px;color:var(--t3);margin-bottom:3px;line-height:1.4}
.imeta{font-size:10px;color:var(--t3);font-family:var(--mono)}
.badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:20px;font-size:10px;font-weight:600;white-space:nowrap;flex-shrink:0}
.b-g{background:var(--gdim);color:var(--green)}.b-r{background:var(--rdim);color:var(--red)}
.b-b{background:var(--bdim);color:var(--blue)}.b-a{background:var(--adim);color:var(--amber)}
.b-p{background:var(--pdim);color:var(--purple)}.b-ro{background:var(--rosedim);color:var(--rose)}
.b-gray{background:var(--b2);color:var(--t2)}
.frow{display:flex;gap:6px;margin-bottom:10px}
.frow input,.frow select{flex:1;height:30px;padding:0 10px;background:var(--bg3);border:1px solid var(--b2);border-radius:var(--rs);color:var(--t);font-family:var(--font);font-size:11px;outline:none;transition:all .15s}
.frow input:focus,.frow select:focus{border-color:var(--blue)}.frow input::placeholder{color:var(--t3)}
.frow select option{background:var(--bg3)}
.prog{height:3px;background:var(--bg5);border-radius:2px;overflow:hidden;margin-top:6px}
.pfill{height:100%;border-radius:2px;transition:width .4s}
/* MAP */
#map-container{flex:1;position:relative;display:flex;flex-direction:column;overflow:hidden}
#map{flex:1}
.leaflet-container{background:#0d1120;font-family:var(--font)}
.leaflet-control-attribution{background:rgba(10,13,20,.9)!important;color:var(--t3)!important;font-size:9px!important}
.leaflet-control-zoom{border:1px solid var(--b2)!important;border-radius:var(--rs)!important;overflow:hidden}
.leaflet-control-zoom a{background:var(--bg2)!important;border:none!important;border-bottom:1px solid var(--b)!important;color:var(--t2)!important;width:28px!important;height:28px!important;line-height:28px!important}
.leaflet-control-zoom a:last-child{border-bottom:none!important}
.leaflet-control-zoom a:hover{background:var(--bg4)!important;color:var(--t)!important}
.leaflet-popup-content-wrapper{background:var(--bg2)!important;border:1px solid var(--b2)!important;border-radius:var(--r)!important;box-shadow:var(--shadow)!important;color:var(--t)!important;font-family:var(--font)!important;padding:0!important}
.leaflet-popup-content{margin:0!important}
.leaflet-popup-tip{background:var(--bg2)!important}
.leaflet-popup-close-button{color:var(--t3)!important;top:10px!important;right:12px!important;font-size:16px!important}
.mpop{padding:14px 16px;min-width:200px;max-width:256px}
.mpop-name{font-size:13px;font-weight:700;margin-bottom:5px;padding-right:20px;letter-spacing:-.2px}
.mpop-info{font-size:11px;color:var(--t2);margin-bottom:10px;line-height:1.65}
.mpop-btn{width:100%;padding:7px;background:var(--bdim);border:1px solid rgba(79,142,247,.25);border-radius:var(--rs);color:var(--blue);font-family:var(--font);font-size:11px;font-weight:600;cursor:pointer;transition:all .15s}
.mpop-btn:hover{background:var(--blue);color:#fff}
/* MAP LAYER PANEL */
#mcp{position:absolute;top:12px;right:12px;z-index:500;background:var(--bg2);border:1px solid var(--b2);border-radius:var(--r);padding:12px 14px;min-width:190px;box-shadow:0 4px 18px rgba(0,0,0,.4)}
.mcp-h{font-size:9px;font-weight:700;color:var(--t3);text-transform:uppercase;letter-spacing:.8px;margin-bottom:10px}
.lyr{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:7px;font-size:11px;color:var(--t2)}
.lyr:last-of-type{margin-bottom:0}
.lyr-l{display:flex;align-items:center;gap:7px}
.ldot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.tog{width:30px;height:16px;background:var(--bg5);border-radius:8px;cursor:pointer;position:relative;transition:background .2s;flex-shrink:0;border:1px solid var(--b2)}
.tog.on{background:var(--blue);border-color:var(--blue)}
.tog::after{content:'';position:absolute;width:11px;height:11px;background:#fff;border-radius:50%;top:2px;left:2px;transition:left .2s}
.tog.on::after{left:15px}
.mcp-div{height:1px;background:var(--b);margin:10px 0}
.rad-lbl{display:flex;justify-content:space-between;font-size:10px;color:var(--t3);margin-bottom:5px}
.rad-lbl span:last-child{font-family:var(--mono);color:var(--blue);font-weight:600}
input[type=range]{width:100%;height:3px;-webkit-appearance:none;background:var(--bg5);border-radius:2px;outline:none;cursor:pointer}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:13px;height:13px;border-radius:50%;background:var(--blue);border:2px solid var(--bg2);box-shadow:0 0 0 2px var(--bglow)}
/* MODALS */
.overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:9999;align-items:center;justify-content:center;backdrop-filter:blur(3px)}
.overlay.open{display:flex;animation:fi .18s ease}
@keyframes fi{from{opacity:0}to{opacity:1}}
.modal{background:var(--bg2);border:1px solid var(--b2);border-radius:var(--rl);padding:24px;width:90%;max-width:520px;max-height:90vh;overflow-y:auto;scrollbar-width:thin;animation:su .2s ease;box-shadow:var(--shadow)}
.modal.wide{max-width:700px}
@keyframes su{from{transform:translateY(16px);opacity:0}to{transform:none;opacity:1}}
.mtitle{font-size:16px;font-weight:700;margin-bottom:20px;display:flex;align-items:center;gap:10px;letter-spacing:-.3px}
.mico{width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;flex-shrink:0}
.mi-b{background:var(--bdim)}.mi-g{background:var(--gdim)}.mi-a{background:var(--adim)}.mi-r{background:var(--rdim)}
.fg{margin-bottom:14px}
.fg label{display:block;font-size:10px;font-weight:700;color:var(--t3);text-transform:uppercase;letter-spacing:.6px;margin-bottom:6px}
.fg input,.fg select,.fg textarea{width:100%;padding:9px 12px;background:var(--bg3);border:1px solid var(--b2);border-radius:var(--rs);color:var(--t);font-family:var(--font);font-size:12px;outline:none;transition:all .18s}
.fg input:focus,.fg select:focus,.fg textarea:focus{border-color:var(--blue);background:var(--bg4);box-shadow:0 0 0 3px var(--bdim)}
.fg input::placeholder{color:var(--t3)}.fg input[readonly]{background:var(--bg);color:var(--t2)}
.fg select option{background:var(--bg3)}.fg textarea{resize:vertical;min-height:72px;line-height:1.6}
.fr2{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.fr3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px}
.fhint{font-size:10px;color:var(--t3);margin-top:6px;display:flex;align-items:center;gap:7px;flex-wrap:wrap}
.wbox{background:rgba(251,191,36,.08);border:1px solid rgba(251,191,36,.25);border-radius:var(--rs);padding:9px 12px;font-size:11px;color:var(--amber);margin-top:8px;line-height:1.5;display:none}
.mfooter{display:flex;gap:8px;justify-content:flex-end;margin-top:20px;padding-top:16px;border-top:1px solid var(--b)}
.dtbl{width:100%;border-collapse:collapse;font-size:12px}
.dtbl td{padding:7px 0;vertical-align:top;border-bottom:1px solid var(--b);line-height:1.5}
.dtbl td:first-child{color:var(--t3);width:40%;padding-right:12px;font-size:11px}
.dtbl tr:last-child td{border-bottom:none}
.dklist{background:var(--bg3);border:1px solid var(--b);border-radius:var(--rs);overflow:hidden;max-height:200px;overflow-y:auto}
.dkrow{display:flex;justify-content:space-between;align-items:center;padding:7px 11px;border-bottom:1px solid var(--b);font-size:11px}
.dkrow:last-child{border-bottom:none}
/* CHAT */
.chat-msgs{overflow-y:auto;display:flex;flex-direction:column;gap:10px;min-height:300px;max-height:420px;padding:2px 0}
.cmsg{max-width:82%}
.cmsg.mine{align-self:flex-end}.cmsg.other{align-self:flex-start}
.cmsg-meta{font-size:9px;color:var(--t3);margin-bottom:3px;font-weight:700;text-transform:uppercase;letter-spacing:.3px}
.cmsg.mine .cmsg-meta{text-align:right}
.cmsg-bubble{padding:9px 13px;border-radius:10px;font-size:12px;line-height:1.6}
.cmsg.mine .cmsg-bubble{background:var(--blue);color:#fff;border-bottom-right-radius:3px}
.cmsg.other .cmsg-bubble{background:var(--bg4);border:1px solid var(--b2);border-bottom-left-radius:3px}
.cmsg-time{font-size:9px;opacity:.55;margin-top:3px;font-family:var(--mono)}
.cmsg.mine .cmsg-time{text-align:right}
.cinp-row{display:flex;gap:8px;margin-top:12px;padding-top:12px;border-top:1px solid var(--b)}
.cinp{flex:1;padding:9px 12px;background:var(--bg3);border:1px solid var(--b2);border-radius:var(--rs);color:var(--t);font-family:var(--font);font-size:12px;outline:none;resize:none;height:38px;transition:all .15s;line-height:1.4}
.cinp:focus{border-color:var(--blue);background:var(--bg4)}
/* DATA TABLE */
.dtable{width:100%;border-collapse:collapse;font-size:12px}
.dtable thead tr{background:var(--bg3)}
.dtable th{padding:9px 12px;text-align:left;font-weight:700;font-size:9px;text-transform:uppercase;letter-spacing:.5px;color:var(--t2);border-bottom:1px solid var(--b2);white-space:nowrap}
.dtable td{padding:9px 12px;border-bottom:1px solid var(--b);vertical-align:middle;line-height:1.4}
.dtable tr:hover td{background:rgba(255,255,255,.015)}
.dtable tr:last-child td{border-bottom:none}
/* PAGE */
.pwrap{flex:1;overflow-y:auto;background:var(--bg);padding:20px}
.pinner{max-width:960px;margin:0 auto}
.pheader{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;gap:12px;flex-wrap:wrap}
.ptitle{font-size:20px;font-weight:700;letter-spacing:-.5px}
.ptitle small{font-size:13px;font-weight:400;color:var(--t3);margin-left:8px}
.scard2{background:var(--bg2);border:1px solid var(--b);border-radius:var(--r);padding:16px;margin-bottom:14px}
.scard2 h3{font-size:13px;font-weight:600;margin-bottom:14px;letter-spacing:-.2px;display:flex;align-items:center;gap:8px;color:var(--t2)}
/* URGENCY */
.u-darurat{background:var(--rosedim);color:var(--rose)}
.u-tinggi{background:var(--rdim);color:var(--red)}
.u-sedang{background:var(--adim);color:var(--amber)}
.u-rendah{background:var(--gdim);color:var(--green)}
/* TOAST */
#toast{position:fixed;bottom:20px;right:20px;z-index:99999;display:flex;flex-direction:column;gap:8px;pointer-events:none;max-width:340px}
.ti{padding:11px 15px;border-radius:var(--rs);font-size:12px;font-weight:500;animation:tin .25s ease;pointer-events:auto;line-height:1.5;box-shadow:0 4px 16px rgba(0,0,0,.4)}
@keyframes tin{from{transform:translateX(24px);opacity:0}to{transform:none;opacity:1}}
.t-ok{background:#15803d;color:#fff}.t-err{background:#b91c1c;color:#fff}
.t-info{background:var(--bg4);color:var(--t);border:1px solid var(--b2)}.t-warn{background:#92400e;color:#fef3c7}
/* LOADING */
#loading{position:fixed;inset:0;background:rgba(10,13,20,.8);z-index:99998;display:none;align-items:center;justify-content:center;flex-direction:column;gap:14px}
#loading.show{display:flex}
.spin{width:32px;height:32px;border:2px solid var(--b2);border-top-color:var(--blue);border-radius:50%;animation:sp .65s linear infinite}
@keyframes sp{to{transform:rotate(360deg)}}
.empty{text-align:center;padding:32px 16px;color:var(--t3)}
.empty svg{opacity:.2;margin:0 auto 10px;display:block}
.empty p{font-size:12px}
</style>
</head>
<body>
<div id="loading"><div class="spin"></div><span style="font-size:12px;color:var(--t2)">Memuat sistem...</span></div>
<div id="toast"></div>
<!-- ===== LOGIN ===== -->
<div id="login-screen">
<div class="lcard">
<div class="lbrand">
<div class="lbrand-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 2a8 8 0 0 0-8 8c0 5.25 8 14 8 14s8-8.75 8-14a8 8 0 0 0-8-8z"/></svg>
</div>
<div><h1>WebGIS Pengentasan Kemiskinan</h1><p>Kota Pontianak &middot; v3.0</p></div>
</div>
<div class="lheading">Masuk ke Sistem</div>
<p class="lsub">Masukkan username dan password yang telah diberikan oleh administrator sistem.</p>
<div class="lerr" id="lerr"></div>
<div class="lf">
<label>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
Username
</label>
<input type="text" id="lu" placeholder="Masukkan username" autocomplete="username">
</div>
<div class="lf">
<label>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
Password
</label>
<input type="password" id="lp" placeholder="Masukkan password" autocomplete="current-password">
</div>
<button class="lbtn" id="lbtn" onclick="doLogin()">Masuk ke Sistem</button>
<div class="laccs">
<div class="laccs-title">Akun Tersedia &mdash; semua password: <span style="font-family:var(--mono);color:var(--blue)">password</span></div>
<table>
<tr><td>Super Admin</td><td>superadmin</td></tr>
<tr><td>Pimpinan Daerah</td><td>pimpinan1</td></tr>
<tr><td>Pengurus Masjid</td><td>pengurus1</td></tr>
<tr><td>Pengurus Gereja</td><td>pengurus2</td></tr>
<tr><td>Pengurus Al-Falah</td><td>pengurus3</td></tr>
<tr><td>Relawan Lapangan</td><td>relawan1</td></tr>
<tr><td>Masyarakat Umum</td><td>masyarakat1</td></tr>
</table>
</div>
</div>
</div>
<!-- ===== TOPBAR ===== -->
<div id="topbar">
<button class="btn btn-ghost btn-sm" onclick="toggleSB()" style="padding:0 9px">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<div class="brand">
<div class="brand-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 2a8 8 0 0 0-8 8c0 5.25 8 14 8 14s8-8.75 8-14a8 8 0 0 0-8-8z"/></svg>
</div>
<div><h1>WebGIS Kemiskinan &mdash; Kota Pontianak</h1><p>Sistem Pemetaan &amp; Distribusi Bantuan Sosial Berbasis Komunitas</p></div>
</div>
<button class="nav-btn" id="nb-peta" onclick="goPage('peta')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21"/><line x1="9" y1="3" x2="9" y2="18"/><line x1="15" y1="6" x2="15" y2="21"/></svg>
Peta
</button>
<button class="nav-btn" id="nb-penduduk" onclick="goPage('penduduk')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
Penduduk
</button>
<button class="nav-btn" id="nb-bantuan" onclick="goPage('bantuan')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 12 20 22 4 22 4 12"/><rect x="2" y="7" width="20" height="5"/><line x1="12" y1="22" x2="12" y2="7"/><path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z"/><path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"/></svg>
Bantuan
</button>
<button class="nav-btn" id="nb-laporan" onclick="goPage('laporan')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
Laporan
<span class="ndot" id="dlap" style="display:none"></span>
</button>
<button class="nav-btn" id="nb-chat" onclick="goPage('chat')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
Koordinasi
<span class="ndot" id="dchat" style="display:none"></span>
</button>
<div class="tsep"></div>
<div class="upill">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--t3)"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
<span class="uname" id="uname"></span>
<span class="rbadge" id="urole"></span>
<button class="btn btn-ghost btn-xs" onclick="doLogout()" title="Keluar" style="padding:0 6px;margin-left:2px">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
</button>
</div>
</div>
<!-- ===== MAIN APP ===== -->
<div id="app">
<div id="sidebar">
<div class="stabs" id="stabs"></div>
<div class="sbody" id="sbody"></div>
</div>
<div id="map-container">
<div id="map"></div>
<div id="mcp">
<div class="mcp-h">Layer Peta</div>
<div class="lyr"><div class="lyr-l"><div class="ldot" style="background:#f87171"></div>Penduduk Miskin</div><div class="tog on" id="ly-miskin" onclick="togLyr(this,'miskin')"></div></div>
<div class="lyr"><div class="lyr-l"><div class="ldot" style="background:#fbbf24"></div>Penduduk Rentan</div><div class="tog on" id="ly-rentan" onclick="togLyr(this,'rentan')"></div></div>
<div class="lyr"><div class="lyr-l"><div class="ldot" style="background:#34d399"></div>Penduduk Mampu</div><div class="tog" id="ly-mampu" onclick="togLyr(this,'mampu')"></div></div>
<div class="lyr"><div class="lyr-l"><div class="ldot" style="background:#4f8ef7"></div>Rumah Ibadah</div><div class="tog on" id="ly-ri" onclick="togLyr(this,'ri')"></div></div>
<div class="lyr"><div class="lyr-l"><div class="ldot" style="background:#fb7185"></div>Laporan Warga</div><div class="tog on" id="ly-lap" onclick="togLyr(this,'laporan')"></div></div>
<div class="mcp-div"></div>
<div class="rad-lbl"><span>Radius Distribusi</span><span id="rv">300 m</span></div>
<input type="range" min="100" max="1000" step="50" value="300" id="rsl" oninput="setRad(this.value)">
</div>
</div>
</div>
<!-- ===== MODAL KK ===== -->
<div class="overlay" id="modal-kk">
<div class="modal">
<div class="mtitle"><div class="mico mi-b"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--blue)" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg></div><span id="mkk-t">Tambah Kartu Keluarga</span></div>
<div class="fr2"><div class="fg"><label>No. KK (16 digit)</label><input type="text" id="kk-nokk" maxlength="16" placeholder="617101XXXXXXXXXX"></div><div class="fg"><label>Nama Kepala Keluarga</label><input type="text" id="kk-nama" placeholder="Nama sesuai KK"></div></div>
<div class="fg"><label>Alamat Lengkap</label><input type="text" id="kk-alamat" placeholder="Jl. ..."></div>
<div class="fr3"><div class="fg"><label>RT</label><input type="text" id="kk-rt" maxlength="4" placeholder="03"></div><div class="fg"><label>RW</label><input type="text" id="kk-rw" maxlength="4" placeholder="05"></div><div class="fg"><label>Status Ekonomi</label><select id="kk-se"><option value="miskin">Miskin</option><option value="rentan" selected>Rentan</option><option value="mampu">Mampu</option></select></div></div>
<div class="fr2"><div class="fg"><label>Kelurahan</label><input type="text" id="kk-kel" placeholder="Saigon"></div><div class="fg"><label>Kecamatan</label><input type="text" id="kk-kec" placeholder="Pontianak Timur"></div></div>
<div class="fr2"><div class="fg"><label>Latitude</label><input type="number" id="kk-lat" step="0.000001" placeholder="-0.021100"></div><div class="fg"><label>Longitude</label><input type="number" id="kk-lng" step="0.000001" placeholder="109.344500"></div></div>
<div class="fhint">
<button class="btn btn-sm" onclick="pickFor('kk')"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 2a8 8 0 0 0-8 8c0 5.25 8 14 8 14s8-8.75 8-14a8 8 0 0 0-8-8z"/></svg> Pilih di Peta</button>
<button class="btn btn-sm" onclick="gpsTo('kk')"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg> GPS Saya</button>
</div>
<div class="fg" style="margin-top:12px"><label>Rumah Ibadah Terdekat</label><select id="kk-ri"><option value="">-- Pilih --</option></select></div>
<div class="mfooter"><button class="btn btn-ghost" onclick="closeM('kk')">Batal</button><button class="btn btn-primary" onclick="saveKK()">Simpan KK</button></div>
</div>
</div>
<!-- ===== MODAL PENDUDUK ===== -->
<div class="overlay" id="modal-p">
<div class="modal wide">
<div class="mtitle"><div class="mico mi-g"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--green)" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></div><span id="mp-t">Tambah Data Penduduk</span></div>
<div class="fr2"><div class="fg"><label>NIK (16 digit)</label><input type="text" id="p-nik" maxlength="16" placeholder="6171XXXXXXXXXXXX"></div><div class="fg"><label>Nama Lengkap</label><input type="text" id="p-nama" placeholder="Nama sesuai KTP"></div></div>
<div class="fr3"><div class="fg"><label>Jenis Kelamin</label><select id="p-jk"><option value="L">Laki-laki</option><option value="P">Perempuan</option></select></div><div class="fg"><label>Tanggal Lahir</label><input type="date" id="p-tgl" oninput="cekPend()"></div><div class="fg"><label>Status dalam KK</label><select id="p-sk"><option value="kepala">Kepala KK</option><option value="istri">Istri</option><option value="anak">Anak</option><option value="orang_tua">Orang Tua</option><option value="lainnya">Lainnya</option></select></div></div>
<div class="fr3"><div class="fg"><label>Status Perkawinan</label><select id="p-sp"><option value="belum_kawin">Belum Kawin</option><option value="kawin">Kawin</option><option value="cerai_hidup">Cerai Hidup</option><option value="cerai_mati">Cerai Mati</option></select></div><div class="fg"><label>Status Hidup</label><select id="p-sh"><option value="hidup">Hidup</option><option value="meninggal">Meninggal</option></select></div><div class="fg"><label>Status Ekonomi</label><select id="p-se"><option value="miskin">Miskin</option><option value="rentan" selected>Rentan</option><option value="mampu">Mampu</option></select></div></div>
<div class="fr3"><div class="fg"><label>Pendidikan Terakhir</label><select id="p-pend" onchange="cekPend()"><option value="tidak_sekolah">Tidak Sekolah</option><option value="sd">SD</option><option value="smp">SMP</option><option value="sma">SMA</option><option value="smk">SMK</option><option value="d3">D3</option><option value="s1">S1</option><option value="s2">S2</option><option value="s3">S3</option></select></div><div class="fg"><label>Status Pendidikan</label><select id="p-spend" onchange="cekPend()"><option value="tidak_sekolah">Sudah / Tidak Sekolah</option><option value="masih_sekolah">Masih Sekolah</option></select></div><div class="fg"><label>Kondisi Kesehatan</label><select id="p-kes"><option value="sehat">Sehat</option><option value="sakit_ringan">Sakit Ringan</option><option value="sakit_berat">Sakit Berat</option><option value="disabilitas">Disabilitas</option></select></div></div>
<div class="wbox" id="wp"></div>
<div class="fr2"><div class="fg"><label>Pekerjaan</label><input type="text" id="p-kerja" placeholder="Buruh Harian, Nelayan..."></div><div class="fg"><label>Penghasilan / Bulan (Rp)</label><input type="number" id="p-gaji" placeholder="0" min="0"></div></div>
<div class="fg"><label>Kartu Keluarga (KK)</label><select id="p-kk"><option value="">-- Pilih KK --</option></select></div>
<div class="fg"><label>Catatan</label><textarea id="p-cat" placeholder="Kondisi khusus atau keterangan tambahan..."></textarea></div>
<div class="mfooter"><button class="btn btn-ghost" onclick="closeM('p')">Batal</button><button class="btn btn-success" onclick="saveP()">Simpan Penduduk</button></div>
</div>
</div>
<!-- ===== MODAL LAPORAN ===== -->
<div class="overlay" id="modal-lap">
<div class="modal">
<div class="mtitle"><div class="mico mi-r"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--red)" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></div>Buat Laporan Kondisi Warga</div>
<div class="fr2"><div class="fg"><label>Nama Pelapor <span style="opacity:.5;font-weight:400">(opsional)</span></label><input type="text" id="lap-nama" placeholder="Nama atau Anonim"></div><div class="fg"><label>Telepon <span style="opacity:.5;font-weight:400">(opsional)</span></label><input type="text" id="lap-telp" placeholder="08xx-xxxx-xxxx"></div></div>
<div class="fg"><label>Deskripsi Kondisi yang Dilaporkan</label><textarea id="lap-desc" placeholder="Jelaskan kondisi warga secara detail..."></textarea></div>
<div class="fg"><label>Alamat Lokasi</label><input type="text" id="lap-alamat" placeholder="Jl. ... RT/RW Kelurahan ..."></div>
<div class="fr2"><div class="fg"><label>Latitude</label><input type="number" id="lap-lat" step="0.000001"></div><div class="fg"><label>Longitude</label><input type="number" id="lap-lng" step="0.000001"></div></div>
<div class="fhint">
<button class="btn btn-sm" onclick="pickFor('lap')"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 2a8 8 0 0 0-8 8c0 5.25 8 14 8 14s8-8.75 8-14a8 8 0 0 0-8-8z"/></svg> Pilih di Peta</button>
<button class="btn btn-sm" onclick="gpsTo('lap')"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg> GPS Saya</button>
</div>
<div class="fr2" style="margin-top:12px"><div class="fg"><label>Tingkat Urgensi</label><select id="lap-urg"><option value="rendah">Rendah</option><option value="sedang" selected>Sedang</option><option value="tinggi">Tinggi</option><option value="darurat">DARURAT</option></select></div><div class="fg"><label>Foto <span style="opacity:.5;font-weight:400">maks 5MB</span></label><input type="file" id="lap-foto" accept="image/*" style="padding:6px;font-size:11px"></div></div>
<div class="mfooter"><button class="btn btn-ghost" onclick="closeM('lap')">Batal</button><button class="btn btn-danger" onclick="saveLap()">Kirim Laporan</button></div>
</div>
</div>
<!-- ===== MODAL DETAIL ===== -->
<div class="overlay" id="modal-det">
<div class="modal wide">
<div class="mtitle" id="det-h"></div>
<div id="det-b"></div>
<div class="mfooter" id="det-f"></div>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const EP={auth:'api/auth.php',p:'api/penduduk.php',kk:'api/keluarga.php',ri:'api/rumah_ibadah.php',b:'api/bantuan.php',lap:'api/laporan.php',chat:'api/chat.php',stats:'api/stats.php'};
const PT=[-0.0263,109.3425];
const EC={miskin:'#f87171',rentan:'#fbbf24',mampu:'#34d399'};
const UC={darurat:'#fb7185',tinggi:'#f87171',sedang:'#fbbf24',rendah:'#34d399'};
let map,cu=null,riD=[],pD=[],lapD=[],bD=[],kkD=[];
let lyrs={miskin:true,rentan:true,mampu:false,ri:true,laporan:true};
let rad=300,pick=null,eKK=null,eP=null;
let mk={ri:[],p:[],lap:[],c:[]};
let cpoll=null,lastC='1970-01-01 00:00:00',curPg='peta';
const ld=v=>document.getElementById('loading').classList.toggle('show',v);
function toast(m,t='info'){const e=document.createElement('div');e.className='ti t-'+t;e.textContent=m;document.getElementById('toast').appendChild(e);setTimeout(()=>e.remove(),3800);}
async function rq(ep,method='GET',body=null,qs=''){
const r=await fetch(ep+(qs?'?'+qs:''),{method,headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});
const d=await r.json();if(!d.success)throw new Error(d.message||'Error');return d.data;
}
// SVG helpers
const riSVG=j=>{const m=`<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M2 20h20M4 20V10c0-1.1.4-2.1 1.2-2.8L12 3l6.8 4.2c.8.7 1.2 1.7 1.2 2.8v10"/><path d="M9 20v-5a3 3 0 0 1 6 0v5"/><path d="M12 3V1m-4 3L6 2m10 2 2-2"/></svg>`;const g=`<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M3 20h18M5 20V9.5L12 4l7 5.5V20"/><path d="M9 20v-5h6v5"/><line x1="12" y1="4" x2="12" y2="1"/><line x1="10" y1="2" x2="14" y2="2"/></svg>`;const v=`<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M2 20h20M4 20v-7l8-9 8 9v7"/><path d="M9 20v-4h6v4"/><path d="M1 13h3m16 0h3"/></svg>`;if(!j)return m;const k=j.toLowerCase();return k==='masjid'?m:k==='gereja'?g:v;};
const icoUser=`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`;
const icoHome=`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>`;
const icoWarn=`<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`;
const icoGift=`<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 12 20 22 4 22 4 12"/><rect x="2" y="7" width="20" height="5"/><line x1="12" y1="22" x2="12" y2="7"/><path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z"/><path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"/></svg>`;
// LOGIN
async function doLogin(){
const u=document.getElementById('lu').value.trim(),p=document.getElementById('lp').value;
const err=document.getElementById('lerr'),btn=document.getElementById('lbtn');
err.style.display='none';
if(!u||!p){err.textContent='Username dan password wajib diisi.';err.style.display='block';return;}
btn.disabled=true;btn.textContent='Memverifikasi...';
try{
cu=await rq(EP.auth,'POST',{username:u,password:p},'action=login');
applyRole();document.getElementById('login-screen').classList.add('hidden');
toast('Selamat datang, '+cu.nama_lengkap,'ok');
initApp();
}catch(e){err.textContent=e.message;err.style.display='block';btn.disabled=false;btn.textContent='Masuk ke Sistem';}
}
async function doLogout(){
try{await rq(EP.auth,'POST',null,'action=logout');}catch(e){}
if(cpoll)clearInterval(cpoll);cu=null;if(map){map.remove();map=null;}
document.getElementById('login-screen').classList.remove('hidden');
document.getElementById('lp').value='';document.getElementById('lerr').style.display='none';
document.getElementById('lbtn').disabled=false;document.getElementById('lbtn').textContent='Masuk ke Sistem';
}
function applyRole(){
if(!cu)return;
document.getElementById('uname').textContent=cu.nama_lengkap;
const rb=document.getElementById('urole');rb.textContent=cu.role_nama;rb.className='rbadge r-'+cu.role;
const H=id=>{const e=document.getElementById(id);if(e)e.style.display='none';};
if(cu.role==='masyarakat'){H('nb-bantuan');H('nb-chat');}
if(cu.role==='pimpinan'){H('nb-bantuan');H('nb-chat');}
}
// APP INIT
function initApp(){
initMap();loadAll();
if(['superadmin','pengurus','relawan'].includes(cu.role)) cpoll=setInterval(pollChat,8000);
buildSB();goPage('peta');
}
// MAP
function initMap(){
map=L.map('map',{zoomControl:true}).setView(PT,13);
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',{attribution:'&copy; OSM &copy; CARTO',maxZoom:19}).addTo(map);
map.on('click',onMapClick);
}
function onMapClick(e){
if(!pick)return;
const{lat,lng}=e.latlng;
if(pick==='kk'){document.getElementById('kk-lat').value=lat.toFixed(6);document.getElementById('kk-lng').value=lng.toFixed(6);map.closePopup();openM('kk');}
if(pick==='lap'){document.getElementById('lap-lat').value=lat.toFixed(6);document.getElementById('lap-lng').value=lng.toFixed(6);map.closePopup();openM('lap');}
pick=null;map.getContainer().style.cursor='';
}
function pickFor(t){pick=t;map.getContainer().style.cursor='crosshair';closeM(t==='kk'?'kk':'lap');L.popup({closeButton:false}).setLatLng(map.getCenter()).setContent('<div style="background:var(--bg3);border:1px solid var(--b2);border-radius:8px;padding:10px 14px;color:var(--t);font-size:12px">Klik lokasi di peta untuk memilih koordinat</div>').openOn(map);}
function gpsTo(t){if(!navigator.geolocation){toast('GPS tidak didukung','err');return;}navigator.geolocation.getCurrentPosition(pos=>{const pref=t==='kk'?'kk-':'lap-';document.getElementById(pref+'lat').value=pos.coords.latitude.toFixed(6);document.getElementById(pref+'lng').value=pos.coords.longitude.toFixed(6);},()=>toast('Gagal mengambil GPS','err'));}
function setRad(v){rad=parseInt(v);document.getElementById('rv').textContent=v+' m';mk.c.forEach(c=>c.setRadius(rad));}
function togLyr(el,t){el.classList.toggle('on');lyrs[t]=el.classList.contains('on');rerender();}
// LOAD
async function loadAll(){
ld(true);
try{
const[ri,p,lap,b,kk]=await Promise.all([rq(EP.ri),rq(EP.p),rq(EP.lap),rq(EP.b),rq(EP.kk)]);
riD=ri||[];pD=p||[];lapD=lap||[];bD=b||[];kkD=kk||[];
rerender();rSB();checkAlerts();toast('Data berhasil dimuat','ok');
}catch(e){toast('Gagal memuat data: '+e.message,'err');}
finally{ld(false);}
}
// MARKERS
function clrMk(){[...mk.ri,...mk.p,...mk.lap].forEach(m=>map&&map.removeLayer(m));mk.c.forEach(c=>map&&map.removeLayer(c));mk.ri=[];mk.p=[];mk.lap=[];mk.c=[];}
function rerender(){
clrMk();
if(lyrs.ri) riD.forEach(addRI);
pD.forEach(p=>{if(lyrs[p.status_ekonomi]) addP(p);});
if(lyrs.laporan) lapD.forEach(l=>{if(l.lat&&l.lng) addLap(l);});
}
function addRI(ri){
const m=L.marker([ri.lat,ri.lng],{icon:L.divIcon({html:`<div style="width:34px;height:34px;background:#111520;border:2px solid #4f8ef7;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#4f8ef7;box-shadow:0 0 0 4px rgba(79,142,247,.18),0 3px 10px rgba(0,0,0,.6)">${riSVG(ri.jenis)}</div>`,iconSize:[34,34],iconAnchor:[17,17],className:''})}).addTo(map);
m.bindPopup(`<div class="mpop"><div class="mpop-name">${ri.nama}</div><div class="mpop-info">${ri.jenis} &middot; ${ri.kelurahan||'—'}<br>${ri.kontak||'—'}<br><strong>${ri.kk_miskin||0}</strong> KK miskin dalam jangkauan</div><button class="mpop-btn" onclick="detRI(${ri.id});map.closePopup()">Lihat Detail</button></div>`,{maxWidth:240});
const c=L.circle([ri.lat,ri.lng],{radius:rad,color:'#4f8ef7',fillColor:'#4f8ef7',fillOpacity:.05,dashArray:'5,5',weight:1.5}).addTo(map);
mk.ri.push(m);mk.c.push(c);
}
function addP(p){
const col=EC[p.status_ekonomi]||'#888';const sk=p.kondisi_kesehatan&&p.kondisi_kesehatan!=='sehat';
const m=L.marker([p.lat||0,p.lng||0],{icon:L.divIcon({html:`<div style="width:12px;height:12px;background:${col};border-radius:50%;border:2px solid rgba(255,255,255,.8);box-shadow:0 0 0 3px ${sk?'rgba(251,113,133,.5)':'rgba(255,255,255,.1)'},0 2px 6px rgba(0,0,0,.5)"></div>`,iconSize:[12,12],iconAnchor:[6,6],className:''})}).addTo(map);
m.bindPopup(`<div class="mpop"><div class="mpop-name">${p.nama}</div><div class="mpop-info">NIK: ${p.nik||'—'}<br>${p.umur||'?'} thn &middot; ${p.profil_umur||'—'}<br>Ekonomi: <strong style="color:${col}">${(p.status_ekonomi||'').toUpperCase()}</strong><br>Kesehatan: ${p.kondisi_kesehatan||'sehat'}<br>Bantuan: ${p.total_bantuan||0}x</div><button class="mpop-btn" onclick="detP(${p.id});map.closePopup()">Lihat Detail Lengkap</button></div>`,{maxWidth:240});
mk.p.push(m);
}
function addLap(l){
const col=UC[l.urgensi]||'#888';
const m=L.marker([l.lat,l.lng],{icon:L.divIcon({html:`<div style="width:17px;height:17px;background:${col};border-radius:50%;border:2.5px solid #fff;box-shadow:0 0 0 3px ${col}44,0 3px 8px rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center">${l.urgensi==='darurat'?'<span style="font-size:9px;color:#fff;font-weight:900;line-height:1">!</span>':''}</div>`,iconSize:[17,17],iconAnchor:[8,8],className:''})}).addTo(map);
m.bindPopup(`<div class="mpop"><div class="mpop-name">Laporan Warga</div><div class="mpop-info"><span class="badge u-${l.urgensi}" style="display:inline-flex;margin-bottom:5px">${(l.urgensi||'').toUpperCase()}</span><br>${(l.deskripsi||'').substring(0,80)}${(l.deskripsi||'').length>80?'…':''}</div><button class="mpop-btn" onclick="detLap(${l.id});map.closePopup()">Lihat Detail</button></div>`,{maxWidth:240});
mk.lap.push(m);
}
// SIDEBAR
function buildSB(){
document.getElementById('stabs').innerHTML=['Ringkasan','Ibadah','Keluarga','Prioritas'].map((t,i)=>`<div class="stab${i===0?' active':''}" onclick="showTab(${i})">${t}</div>`).join('');
showTab(0);
}
function showTab(i){
document.querySelectorAll('.stab').forEach((e,j)=>e.classList.toggle('active',i===j));
if(i===0)rDash();else if(i===1)rRITab();else if(i===2)rKKTab();else rPrioTab();
}
function rSB(){const i=[...document.querySelectorAll('.stab')].findIndex(e=>e.classList.contains('active'));showTab(i>=0?i:0);}
function rDash(){
const mis=pD.filter(p=>p.status_ekonomi==='miskin').length,ren=pD.filter(p=>p.status_ekonomi==='rentan').length;
const bel=pD.filter(p=>['miskin','rentan'].includes(p.status_ekonomi)&&!parseInt(p.total_bantuan)).length;
const lAkt=lapD.filter(l=>l.status!=='selesai').length,lDar=lapD.filter(l=>l.urgensi==='darurat'&&l.status!=='selesai').length;
document.getElementById('sbody').innerHTML=`
<div class="sgrid">
<div class="scard" onclick="goPage('penduduk')"><div class="num cb">${pD.length}</div><div class="lbl">Total Penduduk</div></div>
<div class="scard"><div class="num">${kkD.length}</div><div class="lbl">Total KK</div></div>
<div class="scard"><div class="num cr">${mis}</div><div class="lbl">Miskin</div></div>
<div class="scard"><div class="num ca">${ren}</div><div class="lbl">Rentan</div></div>
<div class="scard"><div class="num cro">${bel}</div><div class="lbl">Belum Dibantu</div></div>
<div class="scard" onclick="goPage('laporan')"><div class="num cp">${lAkt}${lDar?`<span style="font-size:11px;color:var(--rose)"> (${lDar}!)</span>`:''}</div><div class="lbl">Laporan Aktif</div></div>
</div>
<div class="slbl">Jangkauan per Rumah Ibadah</div>
<div style="display:flex;flex-direction:column;gap:6px">
${riD.map(ri=>{const k=kkD.filter(x=>x.rumah_ibadah_id==ri.id);const m=k.filter(x=>x.status_ekonomi==='miskin').length;const p=k.length?Math.round(m/k.length*100):0;return`<div style="background:var(--bg3);border:1px solid var(--b);border-radius:var(--rs);padding:9px 11px"><div style="display:flex;justify-content:space-between;align-items:center;font-size:11px;margin-bottom:4px"><span style="display:flex;align-items:center;gap:5px;color:var(--t2);font-weight:500">${riSVG(ri.jenis)} ${ri.nama.length>22?ri.nama.substring(0,22)+'…':ri.nama}</span><span style="font-family:var(--mono);font-size:10px;color:var(--t3)">${k.length} KK</span></div><div class="prog"><div class="pfill" style="width:${p}%;background:var(--red)"></div></div></div>`;}).join('')}
</div>
<div class="slbl" style="margin-top:18px">Legenda</div>
<div style="display:flex;flex-direction:column;gap:7px;font-size:11px;color:var(--t2)">
${[['#f87171','Penduduk Miskin'],['#fbbf24','Penduduk Rentan'],['#34d399','Penduduk Mampu'],['#4f8ef7','Rumah Ibadah'],['#fb7185','Laporan Darurat']].map(([c,l])=>`<div style="display:flex;align-items:center;gap:8px"><div style="width:9px;height:9px;background:${c};border-radius:50%;flex-shrink:0"></div>${l}</div>`).join('')}
</div>`;
}
function rRITab(){
const cA=cu?.role==='superadmin';
document.getElementById('sbody').innerHTML=`
${cA?`<button class="btn btn-primary btn-sm" style="width:100%;justify-content:center;margin-bottom:10px" onclick="toast('Fitur ini untuk Super Admin','info')"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> Tambah Rumah Ibadah</button>`:''}
<div class="frow"><input type="text" id="q-ri" placeholder="Cari nama..." oninput="fRI()"></div>
<div class="ilist" id="l-ri">${riCards(riD)}</div>`;
}
function riCards(d){if(!d.length)return`<div class="empty"><svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg><p>Tidak ada data</p></div>`;return d.map(r=>{const m=kkD.filter(k=>k.rumah_ibadah_id==r.id&&k.status_ekonomi==='miskin').length;return`<div class="icard" onclick="detRI(${r.id})"><div class="ihead"><div class="iname" style="display:flex;align-items:center;gap:6px;color:var(--blue)">${riSVG(r.jenis)} ${r.nama}</div><span class="badge b-b">${r.jenis}</span></div><div class="isub">${r.kelurahan||'—'}</div><div class="imeta">${r.jumlah_kk||0} KK &ensp;&middot;&ensp; ${m} miskin</div></div>`;}).join('');}
function fRI(){const q=(document.getElementById('q-ri')?.value||'').toLowerCase();const el=document.getElementById('l-ri');if(el)el.innerHTML=riCards(riD.filter(r=>r.nama.toLowerCase().includes(q)));}
function rKKTab(){
const cW=['superadmin','pengurus','relawan'].includes(cu?.role);
document.getElementById('sbody').innerHTML=`
${cW?`<div style="display:flex;gap:6px;margin-bottom:10px">
<button class="btn btn-primary btn-sm" style="flex:1;justify-content:center" onclick="openAddKK()"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> KK</button>
<button class="btn btn-success btn-sm" style="flex:1;justify-content:center" onclick="openAddP()"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> Penduduk</button>
</div>`:''}
<div class="frow"><input type="text" id="q-kk" placeholder="Cari nama / No. KK..." oninput="fKK()"></div>
<div class="frow"><select id="fse-kk" onchange="fKK()"><option value="">Semua Status</option><option value="miskin">Miskin</option><option value="rentan">Rentan</option><option value="mampu">Mampu</option></select></div>
<div class="ilist" id="l-kk">${kkCards(kkD)}</div>`;
}
function kkCards(d){if(!d.length)return`<div class="empty"><svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg><p>Tidak ada data</p></div>`;return d.slice(0,60).map(k=>`<div class="icard" onclick="detKK(${k.id})"><div class="ihead"><div class="iname" style="display:flex;align-items:center;gap:6px">${icoHome} ${k.nama_kk}</div><span class="badge ${k.status_ekonomi==='miskin'?'b-r':k.status_ekonomi==='rentan'?'b-a':'b-g'}">${k.status_ekonomi}</span></div><div class="isub">${(k.alamat||'—').substring(0,38)}</div><div class="imeta">${k.jumlah_jiwa||0} jiwa &ensp;&middot;&ensp; Bantuan: ${k.total_bantuan_diterima||0}x</div></div>`).join('');}
function fKK(){const q=(document.getElementById('q-kk')?.value||'').toLowerCase();const fse=document.getElementById('fse-kk')?.value||'';const f=kkD.filter(k=>(!q||(k.nama_kk.toLowerCase().includes(q)||k.no_kk.includes(q)))&&(!fse||k.status_ekonomi===fse));const el=document.getElementById('l-kk');if(el)el.innerHTML=kkCards(f);}
async function rPrioTab(){
document.getElementById('sbody').innerHTML=`<div class="empty" style="margin-top:50px"><div class="spin" style="margin:0 auto 12px"></div><p>Menganalisis prioritas...</p></div>`;
try{
const d=await rq(EP.b,'GET',null,'action=prioritas');
document.getElementById('sbody').innerHTML=`
<div style="font-size:10px;color:var(--t3);margin-bottom:10px;padding:10px;background:var(--bg3);border:1px solid var(--b);border-radius:var(--rs);line-height:1.6">Warga miskin dengan penyakit berat atau lansia yang hidup sendiri mendapat prioritas tertinggi.</div>
<div class="ilist">${d.slice(0,30).map(p=>{const c=p.prioritas==='SANGAT TINGGI'?'b-ro':p.prioritas==='TINGGI'?'b-r':p.prioritas==='SEDANG'?'b-a':'b-g';return`<div class="icard" onclick="detP(${p.id})"><div class="ihead"><div class="iname" style="display:flex;align-items:center;gap:6px">${icoUser} ${p.nama}</div><span class="badge ${c}">${p.prioritas}</span></div><div class="isub">${p.umur||'?'} thn &middot; ${p.kondisi_kesehatan||'—'}</div><div class="imeta">${p.kelurahan||'—'} &middot; Bantuan: ${p.total_bantuan||0}x</div></div>`;}).join('')}</div>`;
}catch(e){toast(e.message,'err');}
}
// PAGES
function goPage(pg){
curPg=pg;
['peta','penduduk','bantuan','laporan','chat'].forEach(x=>document.getElementById('nb-'+x)?.classList.remove('active'));
document.getElementById('nb-'+pg)?.classList.add('active');
if(pg==='peta') rebuildPeta();
else if(pg==='penduduk') pgPenduduk();
else if(pg==='bantuan') pgBantuan();
else if(pg==='laporan') pgLaporan();
else if(pg==='chat') pgChat();
}
function rebuildPeta(){
document.getElementById('app').innerHTML=`
<div id="sidebar"><div class="stabs" id="stabs"></div><div class="sbody" id="sbody"></div></div>
<div id="map-container">
<div id="map"></div>
<div id="mcp">
<div class="mcp-h">Layer Peta</div>
<div class="lyr"><div class="lyr-l"><div class="ldot" style="background:#f87171"></div>Penduduk Miskin</div><div class="tog ${lyrs.miskin?'on':''}" id="ly-miskin" onclick="togLyr(this,'miskin')"></div></div>
<div class="lyr"><div class="lyr-l"><div class="ldot" style="background:#fbbf24"></div>Penduduk Rentan</div><div class="tog ${lyrs.rentan?'on':''}" id="ly-rentan" onclick="togLyr(this,'rentan')"></div></div>
<div class="lyr"><div class="lyr-l"><div class="ldot" style="background:#34d399"></div>Penduduk Mampu</div><div class="tog ${lyrs.mampu?'on':''}" id="ly-mampu" onclick="togLyr(this,'mampu')"></div></div>
<div class="lyr"><div class="lyr-l"><div class="ldot" style="background:#4f8ef7"></div>Rumah Ibadah</div><div class="tog ${lyrs.ri?'on':''}" id="ly-ri" onclick="togLyr(this,'ri')"></div></div>
<div class="lyr"><div class="lyr-l"><div class="ldot" style="background:#fb7185"></div>Laporan Warga</div><div class="tog ${lyrs.laporan?'on':''}" id="ly-lap" onclick="togLyr(this,'laporan')"></div></div>
<div class="mcp-div"></div>
<div class="rad-lbl"><span>Radius Distribusi</span><span id="rv">${rad} m</span></div>
<input type="range" min="100" max="1000" step="50" value="${rad}" id="rsl" oninput="setRad(this.value)">
</div>
</div>`;
if(map){map.remove();map=null;}
initMap();rerender();buildSB();
}
function pgPenduduk(){
const cW=['superadmin','pengurus','relawan'].includes(cu?.role);
document.getElementById('app').innerHTML=`
<div class="pwrap">
<div class="pinner">
<div class="pheader">
<div class="ptitle">Data Penduduk <small>${pD.length} terdaftar</small></div>
${cW?`<div style="display:flex;gap:8px">
<button class="btn btn-success" onclick="openAddP()"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> Tambah Penduduk</button>
<button class="btn btn-primary" onclick="openAddKK()"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> Tambah KK</button>
</div>`:''}
</div>
<div style="display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap">
<input type="text" id="qp" placeholder="Cari nama atau NIK..." style="flex:1;min-width:180px;height:34px;padding:0 12px;background:var(--bg2);border:1px solid var(--b2);border-radius:var(--rs);color:var(--t);font-size:12px;outline:none" oninput="fP()">
<select id="fsep" style="height:34px;padding:0 10px;background:var(--bg2);border:1px solid var(--b2);border-radius:var(--rs);color:var(--t);font-size:12px;outline:none" onchange="fP()"><option value="">Semua Ekonomi</option><option value="miskin">Miskin</option><option value="rentan">Rentan</option><option value="mampu">Mampu</option></select>
<select id="fkesp" style="height:34px;padding:0 10px;background:var(--bg2);border:1px solid var(--b2);border-radius:var(--rs);color:var(--t);font-size:12px;outline:none" onchange="fP()"><option value="">Semua Kesehatan</option><option value="sehat">Sehat</option><option value="sakit_ringan">Sakit Ringan</option><option value="sakit_berat">Sakit Berat</option><option value="disabilitas">Disabilitas</option></select>
</div>
<div style="background:var(--bg2);border:1px solid var(--b);border-radius:var(--r);overflow:hidden">
<div id="ptbl" style="overflow-x:auto">${pTbl(pD)}</div>
</div>
</div>
</div>`;
}
function pTbl(d){
if(!d.length)return`<div class="empty"><svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/></svg><p>Tidak ada data</p></div>`;
return`<table class="dtable"><thead><tr><th>NIK</th><th>Nama</th><th>JK</th><th>Umur</th><th>Profil</th><th>Ekonomi</th><th>Kesehatan</th><th>Pekerjaan</th><th>Bantuan</th><th></th></tr></thead><tbody>${d.slice(0,100).map(p=>`<tr><td style="font-family:var(--mono);font-size:10px;color:var(--t3)">${p.nik||'—'}</td><td style="font-weight:600">${p.nama}</td><td>${p.jenis_kelamin||'—'}</td><td style="font-family:var(--mono)">${p.umur||'?'}</td><td><span class="badge b-b">${p.profil_umur||'—'}</span></td><td><span class="badge ${p.status_ekonomi==='miskin'?'b-r':p.status_ekonomi==='rentan'?'b-a':'b-g'}">${p.status_ekonomi}</span></td><td style="font-size:11px;color:${p.kondisi_kesehatan==='sehat'?'var(--green)':p.kondisi_kesehatan==='disabilitas'?'var(--rose)':'var(--amber)'}">${p.kondisi_kesehatan||'sehat'}</td><td style="font-size:11px;color:var(--t2)">${p.pekerjaan||'—'}</td><td style="text-align:center;font-family:var(--mono)">${p.total_bantuan||0}x</td><td><button class="btn btn-xs btn-ghost" onclick="detP(${p.id})">Detail</button></td></tr>`).join('')}${d.length>100?`<tr><td colspan="10" style="text-align:center;padding:12px;font-size:11px;color:var(--t3)">Menampilkan 100 dari ${d.length}. Gunakan filter untuk mempersempit pencarian.</td></tr>`:''}</tbody></table>`;
}
function fP(){const q=(document.getElementById('qp')?.value||'').toLowerCase();const fse=document.getElementById('fsep')?.value||'';const fkes=document.getElementById('fkesp')?.value||'';const f=pD.filter(p=>(!q||p.nama.toLowerCase().includes(q)||(p.nik||'').includes(q))&&(!fse||p.status_ekonomi===fse)&&(!fkes||p.kondisi_kesehatan===fkes));const el=document.getElementById('ptbl');if(el)el.innerHTML=pTbl(f);}
function pgBantuan(){
document.getElementById('app').innerHTML=`
<div class="pwrap">
<div class="pinner">
<div class="pheader"><div class="ptitle">Manajemen Bantuan Sosial</div></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:14px">
<div class="scard2"><h3>${icoGift} Program Bantuan</h3><div style="display:flex;flex-direction:column;gap:6px">${bD.map(b=>`<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid var(--b);font-size:12px"><span>${b.nama}</span><span class="badge b-b">${b.jenis}</span></div>`).join('')}</div></div>
<div class="scard2"><h3>${icoWarn} Belum Terima Bantuan</h3><div id="blist" style="display:flex;flex-direction:column;gap:5px;max-height:240px;overflow-y:auto;font-size:12px"><span style="color:var(--t3)">Memuat...</span></div></div>
</div>
<div class="scard2">
<h3>Catat Penyaluran Bantuan</h3>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr auto;gap:10px;align-items:end">
<div><div style="font-size:10px;color:var(--t3);font-weight:700;text-transform:uppercase;letter-spacing:.6px;margin-bottom:6px">Penduduk</div><select id="hb-pid" style="width:100%;height:32px;padding:0 9px;background:var(--bg3);border:1px solid var(--b2);border-radius:var(--rs);color:var(--t);font-size:11px;outline:none"><option value="">-- Pilih --</option>${pD.map(p=>`<option value="${p.id}">${p.nama}</option>`).join('')}</select></div>
<div><div style="font-size:10px;color:var(--t3);font-weight:700;text-transform:uppercase;letter-spacing:.6px;margin-bottom:6px">Program Bantuan</div><select id="hb-bid" style="width:100%;height:32px;padding:0 9px;background:var(--bg3);border:1px solid var(--b2);border-radius:var(--rs);color:var(--t);font-size:11px;outline:none"><option value="">-- Pilih --</option>${bD.map(b=>`<option value="${b.id}">${b.nama}</option>`).join('')}</select></div>
<div><div style="font-size:10px;color:var(--t3);font-weight:700;text-transform:uppercase;letter-spacing:.6px;margin-bottom:6px">Tanggal</div><input type="date" id="hb-tgl" value="${new Date().toISOString().split('T')[0]}" style="width:100%;height:32px;padding:0 9px;background:var(--bg3);border:1px solid var(--b2);border-radius:var(--rs);color:var(--t);font-size:12px;outline:none"></div>
<button class="btn btn-success" onclick="catatB()">Catat</button>
</div>
</div>
</div>
</div>`;
rq(EP.b,'GET',null,'action=belum_terima').then(d=>{const el=document.getElementById('blist');if(!el)return;if(!d.length){el.innerHTML=`<div style="color:var(--green);font-size:12px">Semua warga sudah pernah menerima bantuan</div>`;return;}el.innerHTML=d.slice(0,20).map(p=>`<div style="display:flex;justify-content:space-between;padding:5px 0;border-bottom:1px solid var(--b)"><span>${p.nama}</span><span class="badge ${p.status_ekonomi==='miskin'?'b-r':'b-a'}">${p.status_ekonomi}</span></div>`).join('');}).catch(()=>{});
}
async function catatB(){const pid=document.getElementById('hb-pid')?.value;const bid=document.getElementById('hb-bid')?.value;const tgl=document.getElementById('hb-tgl')?.value;if(!pid||!bid){toast('Pilih penduduk dan program bantuan','err');return;}try{await rq(EP.b,'POST',{penduduk_id:parseInt(pid),bantuan_id:parseInt(bid),tanggal:tgl,status:'disalurkan'},'action=histori');toast('Penyaluran bantuan berhasil dicatat','ok');}catch(e){toast(e.message,'err');}}
function pgLaporan(){
document.getElementById('app').innerHTML=`
<div class="pwrap">
<div class="pinner">
<div class="pheader">
<div class="ptitle">Laporan Masyarakat <small>${lapD.filter(l=>l.status!=='selesai').length} aktif</small></div>
<button class="btn btn-danger" onclick="openM('lap')"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> Buat Laporan</button>
</div>
<div style="display:flex;gap:7px;margin-bottom:16px;flex-wrap:wrap">${['','pending','diverifikasi','diproses','selesai'].map((s,i)=>`<button class="btn btn-sm ${i===0?'btn-primary':''}" id="fb-${s||'all'}" onclick="fLap('${s}',this)">${s||'Semua'}</button>`).join('')}</div>
<div id="lap-list" style="display:flex;flex-direction:column;gap:10px">${lapCards(lapD)}</div>
</div>
</div>`;
document.getElementById('dlap').style.display='none';
}
function lapCards(d){if(!d.length)return`<div class="empty"><svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg><p>Tidak ada laporan</p></div>`;const cW=['superadmin','pengurus','relawan'].includes(cu?.role);return d.map(l=>{const bd=l.urgensi==='darurat'?'rgba(251,113,133,.3)':l.urgensi==='tinggi'?'rgba(248,113,113,.2)':'var(--b)';return`<div style="background:var(--bg2);border:1px solid ${bd};border-radius:var(--r);padding:16px"><div style="display:flex;justify-content:space-between;gap:12px;margin-bottom:8px"><div style="font-size:13px;font-weight:600;line-height:1.5;letter-spacing:-.2px">${(l.deskripsi||'').substring(0,90)}${(l.deskripsi||'').length>90?'…':''}</div><div style="display:flex;gap:5px;flex-shrink:0;flex-wrap:wrap;justify-content:flex-end"><span class="badge u-${l.urgensi}">${(l.urgensi||'').toUpperCase()}</span><span class="badge b-gray">${l.status}</span></div></div><div style="font-size:11px;color:var(--t3);margin-bottom:10px">${l.alamat_laporan||'—'} &middot; ${l.pelapor_nama||'Anonim'} &middot; ${new Date(l.created_at).toLocaleDateString('id-ID')}</div><div style="display:flex;gap:7px;align-items:center;flex-wrap:wrap"><button class="btn btn-sm" onclick="detLap(${l.id})">Lihat Detail</button>${cW?`<select class="btn btn-sm" style="height:28px;font-size:11px;cursor:pointer" onchange="uLS(${l.id},this.value,this)"><option value="">Update Status</option>${['pending','diverifikasi','diproses','selesai'].map(s=>`<option value="${s}"${l.status===s?' selected':''}>${s}</option>`).join('')}</select>`:''}</div></div>`;}).join('');}
async function fLap(s,btn){document.querySelectorAll('[id^="fb-"]').forEach(b=>b.classList.remove('btn-primary'));btn?.classList.add('btn-primary');try{const d=await rq(EP.lap,'GET',null,s?'status='+s:'');const el=document.getElementById('lap-list');if(el)el.innerHTML=lapCards(d);}catch(e){toast(e.message,'err');}}
async function uLS(id,s,sel){if(!s)return;try{await rq(EP.lap,'PUT',{status:s},'id='+id);const i=lapD.findIndex(l=>l.id===id);if(i>-1)lapD[i].status=s;toast('Status diperbarui','ok');}catch(e){toast(e.message,'err');if(sel)sel.value='';}}
function pgChat(){
document.getElementById('app').innerHTML=`
<div class="pwrap">
<div class="pinner" style="max-width:680px">
<div class="pheader"><div class="ptitle">Koordinasi Pengurus</div><span style="font-size:11px;color:var(--t3)">Pesan dapat dilihat semua pengurus & relawan</span></div>
<div style="background:var(--bg2);border:1px solid var(--b2);border-radius:var(--r);padding:18px">
<div class="chat-msgs" id="cmsgs"></div>
<div class="cinp-row">
<textarea class="cinp" id="cinp" placeholder="Tulis pesan... (Enter untuk kirim)" onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendChat();}"></textarea>
<button class="btn btn-primary btn-sm" onclick="sendChat()" style="height:38px;padding:0 14px">Kirim</button>
</div>
</div>
</div>
</div>`;
loadChat();document.getElementById('dchat').style.display='none';
}
async function loadChat(){try{const msgs=await rq(EP.chat,'GET',null,'since='+lastC);if(!msgs.length)return;lastC=msgs[msgs.length-1].created_at;const el=document.getElementById('cmsgs');if(!el)return;msgs.forEach(m=>{const mine=m.dari_id===cu.id;const d=document.createElement('div');d.className='cmsg '+(mine?'mine':'other');d.innerHTML=`<div class="cmsg-meta">${mine?'Anda':m.dari_nama}${m.dari_ri?' &middot; '+m.dari_ri:''}</div><div class="cmsg-bubble">${m.pesan}</div><div class="cmsg-time">${new Date(m.created_at).toLocaleTimeString('id',{hour:'2-digit',minute:'2-digit'})}</div>`;el.appendChild(d);});el.scrollTop=el.scrollHeight;}catch(e){}}
async function pollChat(){if(curPg==='chat'){loadChat();return;}try{const msgs=await rq(EP.chat,'GET',null,'since='+lastC);if(msgs.length){lastC=msgs[msgs.length-1].created_at;document.getElementById('dchat').style.display='inline-block';}}catch(e){}}
async function sendChat(){const inp=document.getElementById('cinp');const p=inp?.value.trim();if(!p)return;try{await rq(EP.chat,'POST',{pesan:p});inp.value='';loadChat();}catch(e){toast(e.message,'err');}}
// DETAILS
async function detRI(id){ld(true);try{const ri=await rq(EP.ri,'GET',null,'id='+id);const kk2=kkD.filter(k=>k.rumah_ibadah_id==id);document.getElementById('det-h').innerHTML=`<div class="mico mi-b">${riSVG(ri.jenis)}</div>${ri.nama}`;document.getElementById('det-b').innerHTML=`<span class="badge b-b" style="margin-bottom:14px;display:inline-block">${ri.jenis}</span><table class="dtbl"><tr><td>Kontak</td><td>${ri.kontak||'—'}</td></tr><tr><td>Alamat</td><td>${ri.alamat||'—'}</td></tr><tr><td>Kelurahan</td><td>${ri.kelurahan||'—'}</td></tr><tr><td>Jumlah KK</td><td>${ri.jumlah_kk||0} KK (${ri.jumlah_jiwa||0} jiwa)</td></tr><tr><td>KK Miskin</td><td style="color:var(--red);font-weight:600">${ri.kk_miskin||0} KK</td></tr></table>${kk2.length?`<div style="font-size:10px;font-weight:700;color:var(--t3);text-transform:uppercase;letter-spacing:.6px;margin:16px 0 8px">KK Terdampak (${kk2.length})</div><div class="dklist">${kk2.map(k=>`<div class="dkrow"><span>${k.nama_kk}</span><span class="badge ${k.status_ekonomi==='miskin'?'b-r':k.status_ekonomi==='rentan'?'b-a':'b-g'}">${k.status_ekonomi}</span></div>`).join('')}</div>`:''}`;document.getElementById('det-f').innerHTML=`<button class="btn btn-ghost" onclick="closeM('det')">Tutup</button>`;openM('det');}catch(e){toast(e.message,'err');}finally{ld(false);}}
async function detP(id){ld(true);try{const p=await rq(EP.p,'GET',null,'id='+id);const col=EC[p.status_ekonomi]||'#888';document.getElementById('det-h').innerHTML=`<div class="mico mi-g"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--green)" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></div>${p.nama}`;document.getElementById('det-b').innerHTML=`<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px"><span class="badge ${p.status_ekonomi==='miskin'?'b-r':p.status_ekonomi==='rentan'?'b-a':'b-g'}">${(p.status_ekonomi||'').toUpperCase()}</span><span class="badge b-b">${p.profil_umur||'—'}</span><span class="badge ${p.kondisi_kesehatan==='sehat'?'b-g':'b-r'}">${p.kondisi_kesehatan||'sehat'}</span></div>${p.peringatan_pendidikan?`<div class="wbox" style="display:block;margin-bottom:14px">${p.peringatan_pendidikan}</div>`:''}<table class="dtbl"><tr><td>NIK</td><td style="font-family:var(--mono)">${p.nik||'—'}</td></tr><tr><td>Jenis Kelamin</td><td>${p.jenis_kelamin==='L'?'Laki-laki':'Perempuan'}</td></tr><tr><td>Tanggal Lahir</td><td>${p.tanggal_lahir||'—'} (${p.umur||'?'} tahun)</td></tr><tr><td>Status Keluarga</td><td>${(p.status_keluarga||'—').replace('_',' ')}</td></tr><tr><td>Status Perkawinan</td><td>${(p.status_perkawinan||'—').replace(/_/g,' ')}</td></tr><tr><td>Pekerjaan</td><td>${p.pekerjaan||'—'}</td></tr><tr><td>Penghasilan</td><td>Rp ${Number(p.penghasilan||0).toLocaleString('id-ID')}/bulan</td></tr><tr><td>Pendidikan</td><td>${(p.pendidikan_terakhir||'—').toUpperCase()} &middot; ${(p.status_pendidikan||'—').replace('_',' ')}</td></tr><tr><td>No. KK</td><td style="font-family:var(--mono)">${p.no_kk||'—'}</td></tr><tr><td>Alamat</td><td>${p.alamat_kk||'—'}${p.rt?' RT '+p.rt+'/RW '+p.rw:''}</td></tr><tr><td>Kelurahan</td><td>${p.kelurahan||'—'}, ${p.kecamatan||'—'}</td></tr></table>${p.histori_bantuan?.length?`<div style="font-size:10px;font-weight:700;color:var(--t3);text-transform:uppercase;letter-spacing:.6px;margin:16px 0 8px">Histori Bantuan (${p.histori_bantuan.length}x)</div><div class="dklist">${p.histori_bantuan.map(h=>`<div class="dkrow"><span>${h.bantuan_nama}</span><div style="display:flex;align-items:center;gap:8px"><span style="font-family:var(--mono);font-size:10px;color:var(--t3)">${h.tanggal}</span><span class="badge ${h.status==='disalurkan'?'b-g':'b-a'}">${h.status}</span></div></div>`).join('')}</div>`:`<div style="font-size:12px;padding:10px;background:var(--rdim);border-radius:var(--rs);margin-top:14px;color:var(--red)">Belum pernah menerima bantuan apapun</div>`}${p.riwayat_pelatihan?.length?`<div style="font-size:10px;font-weight:700;color:var(--t3);text-transform:uppercase;letter-spacing:.6px;margin:16px 0 8px">Riwayat Pelatihan</div><div class="dklist">${p.riwayat_pelatihan.map(r=>`<div class="dkrow"><span>${r.nama_pelatihan}</span><span style="font-family:var(--mono);font-size:10px;color:var(--t3)">${r.tanggal_ikut}</span></div>`).join('')}</div>`:''}`;const cW=['superadmin','pengurus','relawan'].includes(cu?.role);document.getElementById('det-f').innerHTML=`<button class="btn btn-ghost" onclick="closeM('det')">Tutup</button>${cW?`<button class="btn" onclick="closeM('det');openEditP(${p.id})">Edit Data</button>`:''}${cu?.role==='superadmin'?`<button class="btn btn-danger" onclick="delP(${p.id})">Hapus</button>`:''}`;openM('det');}catch(e){toast(e.message,'err');}finally{ld(false);}}
async function detKK(id){ld(true);try{const k=await rq(EP.kk,'GET',null,'id='+id);document.getElementById('det-h').innerHTML=`<div class="mico mi-b">${icoHome}</div>${k.nama_kk}`;document.getElementById('det-b').innerHTML=`<span class="badge ${k.status_ekonomi==='miskin'?'b-r':k.status_ekonomi==='rentan'?'b-a':'b-g'}" style="margin-bottom:14px;display:inline-block">${(k.status_ekonomi||'').toUpperCase()}</span><table class="dtbl"><tr><td>No. KK</td><td style="font-family:var(--mono)">${k.no_kk||'—'}</td></tr><tr><td>Alamat</td><td>${k.alamat||'—'} RT ${k.rt||'-'}/RW ${k.rw||'-'}</td></tr><tr><td>Kelurahan</td><td>${k.kelurahan||'—'}, ${k.kecamatan||'—'}</td></tr><tr><td>Rumah Ibadah</td><td>${k.ri_nama||'—'}</td></tr></table>${k.anggota?.length?`<div style="font-size:10px;font-weight:700;color:var(--t3);text-transform:uppercase;letter-spacing:.6px;margin:16px 0 8px">Anggota (${k.anggota.length} jiwa)</div><div class="dklist">${k.anggota.map(a=>`<div class="dkrow"><span><strong>${a.nama}</strong> &middot; ${(a.status_keluarga||'').replace('_',' ')} &middot; ${a.umur}thn</span><span class="badge ${a.kondisi_kesehatan==='sehat'?'b-g':a.kondisi_kesehatan==='disabilitas'?'b-ro':'b-a'}">${a.kondisi_kesehatan||'sehat'}</span></div>`).join('')}</div>`:''}`;const cW=['superadmin','pengurus','relawan'].includes(cu?.role);document.getElementById('det-f').innerHTML=`<button class="btn btn-ghost" onclick="closeM('det')">Tutup</button>${cW?`<button class="btn btn-success" onclick="closeM('det');openAddP(${id})">+ Tambah Anggota</button>`:''}`;openM('det');}catch(e){toast(e.message,'err');}finally{ld(false);}}
async function detLap(id){const l=lapD.find(x=>x.id===id);if(!l)return;const cW=['superadmin','pengurus','relawan'].includes(cu?.role);document.getElementById('det-h').innerHTML=`<div class="mico mi-r">${icoWarn}</div>Laporan Masyarakat`;document.getElementById('det-b').innerHTML=`<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px"><span class="badge u-${l.urgensi}">${(l.urgensi||'').toUpperCase()}</span><span class="badge b-gray">${l.status}</span></div><table class="dtbl"><tr><td>Deskripsi</td><td style="line-height:1.7">${l.deskripsi||'—'}</td></tr><tr><td>Pelapor</td><td>${l.pelapor_nama||'Anonim'}${l.pelapor_telepon?' ('+l.pelapor_telepon+')':''}</td></tr><tr><td>Lokasi</td><td>${l.alamat_laporan||'—'}</td></tr><tr><td>Tanggal</td><td>${new Date(l.created_at).toLocaleDateString('id-ID',{weekday:'long',day:'numeric',month:'long',year:'numeric'})}</td></tr></table>${l.foto?`<img src="${l.foto}" style="width:100%;max-height:200px;object-fit:cover;border-radius:var(--rs);margin-top:14px">`:''}${cW?`<div style="margin-top:16px;padding-top:16px;border-top:1px solid var(--b)"><div style="font-size:10px;font-weight:700;color:var(--t3);text-transform:uppercase;letter-spacing:.6px;margin-bottom:8px">Update Status</div><div style="display:flex;gap:7px;flex-wrap:wrap">${['diverifikasi','diproses','selesai'].map(s=>`<button class="btn btn-sm ${l.status===s?'btn-primary':''}" onclick="uLS(${l.id},'${s}',null);closeM('det');toast('Status diperbarui','ok')">${s}</button>`).join('')}</div></div>`:''}`;document.getElementById('det-f').innerHTML=`<button class="btn btn-ghost" onclick="closeM('det')">Tutup</button>`;openM('det');}
// FORMS
function openAddKK(){eKK=null;document.getElementById('mkk-t').textContent='Tambah Kartu Keluarga';['kk-nokk','kk-nama','kk-alamat','kk-rt','kk-rw','kk-kel','kk-kec','kk-lat','kk-lng'].forEach(id=>{const el=document.getElementById(id);if(el)el.value='';});document.getElementById('kk-se').value='rentan';const sel=document.getElementById('kk-ri');if(sel)sel.innerHTML='<option value="">-- Pilih --</option>'+riD.map(r=>`<option value="${r.id}">${r.nama}</option>`).join('');openM('kk');}
async function saveKK(){const no=document.getElementById('kk-nokk')?.value.trim(),nm=document.getElementById('kk-nama')?.value.trim();if(!no||!nm){toast('No KK dan nama wajib diisi','err');return;}if(no.length!==16){toast('No KK harus 16 digit','err');return;}const body={no_kk:no,nama_kk:nm,alamat:document.getElementById('kk-alamat')?.value||'',rt:document.getElementById('kk-rt')?.value||'',rw:document.getElementById('kk-rw')?.value||'',kelurahan:document.getElementById('kk-kel')?.value||'',kecamatan:document.getElementById('kk-kec')?.value||'',lat:parseFloat(document.getElementById('kk-lat')?.value)||null,lng:parseFloat(document.getElementById('kk-lng')?.value)||null,rumah_ibadah_id:document.getElementById('kk-ri')?.value||null,status_ekonomi:document.getElementById('kk-se')?.value||'rentan'};ld(true);try{if(eKK){await rq(EP.kk,'PUT',body,'id='+eKK);eKK=null;toast('KK diperbarui','ok');}else{await rq(EP.kk,'POST',body);toast('KK berhasil ditambahkan','ok');}closeM('kk');const kk=await rq(EP.kk);kkD=kk||[];fKK();rDash();}catch(e){toast(e.message,'err');}finally{ld(false);}}
function openAddP(kkId=null){eP=null;document.getElementById('mp-t').textContent='Tambah Data Penduduk';document.getElementById('p-nik').readOnly=false;['p-nik','p-nama','p-tgl','p-kerja','p-gaji','p-cat'].forEach(id=>{const el=document.getElementById(id);if(el)el.value='';});['p-jk','p-sk','p-sp','p-sh','p-se','p-pend','p-spend','p-kes'].forEach(id=>{const el=document.getElementById(id);if(!el)return;if(id==='p-sh')el.value='hidup';else if(id==='p-se')el.value='rentan';else if(id==='p-pend')el.value='tidak_sekolah';else if(id==='p-spend')el.value='tidak_sekolah';else if(id==='p-kes')el.value='sehat';else el.value=el.options[0]?.value;});document.getElementById('wp').style.display='none';const sel=document.getElementById('p-kk');if(sel)sel.innerHTML='<option value="">-- Pilih KK --</option>'+kkD.map(k=>`<option value="${k.id}"${k.id==kkId?' selected':''}>${k.nama_kk} (${k.no_kk})</option>`).join('');openM('p');}
async function openEditP(id){const p=pD.find(x=>x.id===id);if(!p){toast('Data tidak ditemukan','err');return;}eP=id;document.getElementById('mp-t').textContent='Edit Data Penduduk';document.getElementById('p-nik').value=p.nik||'';document.getElementById('p-nik').readOnly=true;document.getElementById('p-nama').value=p.nama||'';document.getElementById('p-tgl').value=p.tanggal_lahir||'';document.getElementById('p-jk').value=p.jenis_kelamin||'L';document.getElementById('p-sk').value=p.status_keluarga||'kepala';document.getElementById('p-sp').value=p.status_perkawinan||'belum_kawin';document.getElementById('p-sh').value=p.status_hidup||'hidup';document.getElementById('p-se').value=p.status_ekonomi||'rentan';document.getElementById('p-pend').value=p.pendidikan_terakhir||'tidak_sekolah';document.getElementById('p-spend').value=p.status_pendidikan||'tidak_sekolah';document.getElementById('p-kes').value=p.kondisi_kesehatan||'sehat';document.getElementById('p-kerja').value=p.pekerjaan||'';document.getElementById('p-gaji').value=p.penghasilan||0;document.getElementById('p-cat').value=p.catatan||'';const sel=document.getElementById('p-kk');if(sel)sel.innerHTML='<option value="">-- Pilih KK --</option>'+kkD.map(k=>`<option value="${k.id}"${k.id==p.keluarga_id?' selected':''}>${k.nama_kk} (${k.no_kk})</option>`).join('');cekPend();openM('p');}
async function saveP(){const nm=document.getElementById('p-nama')?.value.trim(),tgl=document.getElementById('p-tgl')?.value,kkid=document.getElementById('p-kk')?.value,nik=document.getElementById('p-nik')?.value.trim();if(!nm||!tgl||!kkid){toast('Nama, tanggal lahir, dan KK wajib diisi','err');return;}if(!eP&&(!nik||nik.length!==16)){toast('NIK harus 16 digit','err');return;}const body={nik,nama:nm,tanggal_lahir:tgl,jenis_kelamin:document.getElementById('p-jk')?.value||'L',status_keluarga:document.getElementById('p-sk')?.value||'kepala',status_perkawinan:document.getElementById('p-sp')?.value||'belum_kawin',status_hidup:document.getElementById('p-sh')?.value||'hidup',status_ekonomi:document.getElementById('p-se')?.value||'rentan',pendidikan_terakhir:document.getElementById('p-pend')?.value||'tidak_sekolah',status_pendidikan:document.getElementById('p-spend')?.value||'tidak_sekolah',kondisi_kesehatan:document.getElementById('p-kes')?.value||'sehat',pekerjaan:document.getElementById('p-kerja')?.value||'',penghasilan:parseFloat(document.getElementById('p-gaji')?.value||0),keluarga_id:parseInt(kkid),catatan:document.getElementById('p-cat')?.value||''};ld(true);try{let res;if(eP){res=await rq(EP.p,'PUT',body,'id='+eP);eP=null;toast('Data penduduk diperbarui','ok');}else{res=await rq(EP.p,'POST',body);toast('Penduduk berhasil ditambahkan','ok');}if(res?.peringatan)setTimeout(()=>toast('Perhatian: '+res.peringatan,'warn'),500);closeM('p');const pd=await rq(EP.p);pD=pd||[];rerender();rSB();}catch(e){toast(e.message,'err');}finally{ld(false);}}
async function delP(id){if(!confirm('Hapus data penduduk ini secara permanen?'))return;ld(true);try{await rq(EP.p,'DELETE',null,'id='+id);pD=pD.filter(p=>p.id!==id);closeM('det');rerender();rSB();toast('Data dihapus','ok');}catch(e){toast(e.message,'err');}finally{ld(false);}}
function cekPend(){const tgl=document.getElementById('p-tgl')?.value;if(!tgl)return;const umur=Math.floor((new Date()-new Date(tgl))/31557600000);const pend=document.getElementById('p-pend')?.value;const spend=document.getElementById('p-spend')?.value;const el=document.getElementById('wp');if(!el)return;let w='';if(umur>=7&&umur<=15&&spend==='tidak_sekolah'&&['tidak_sekolah','sd'].includes(pend))w=`Usia ${umur} tahun seharusnya masih bersekolah SD/SMP.`;else if(umur>=16&&umur<=18&&spend==='tidak_sekolah')w=`Usia ${umur} tahun seharusnya masih bersekolah SMA/SMK.`;el.style.display=w?'block':'none';el.textContent=w?'Perhatian: '+w:'';}
async function saveLap(){const desc=document.getElementById('lap-desc')?.value.trim();if(!desc){toast('Deskripsi laporan wajib diisi','err');return;}const fd=new FormData();fd.append('deskripsi',desc);fd.append('pelapor_nama',document.getElementById('lap-nama')?.value||'');fd.append('pelapor_telepon',document.getElementById('lap-telp')?.value||'');fd.append('alamat_laporan',document.getElementById('lap-alamat')?.value||'');fd.append('urgensi',document.getElementById('lap-urg')?.value||'sedang');const lat=document.getElementById('lap-lat')?.value;const lng=document.getElementById('lap-lng')?.value;if(lat)fd.append('lat',lat);if(lng)fd.append('lng',lng);const foto=document.getElementById('lap-foto')?.files?.[0];if(foto)fd.append('foto',foto);ld(true);try{const r=await fetch(EP.lap,{method:'POST',body:fd});const d=await r.json();if(!d.success)throw new Error(d.message);toast('Laporan berhasil dikirim. Terima kasih!','ok');closeM('lap');const lp=await rq(EP.lap);lapD=lp||[];rerender();checkAlerts();}catch(e){toast(e.message,'err');}finally{ld(false);}}
function checkAlerts(){const dar=lapD.filter(l=>l.urgensi==='darurat'&&l.status!=='selesai');document.getElementById('dlap').style.display=dar.length?'inline-block':'none';}
// MODAL
function openM(n){document.getElementById('modal-'+n)?.classList.add('open');}
function closeM(n){document.getElementById('modal-'+n)?.classList.remove('open');}
document.querySelectorAll('.overlay').forEach(o=>o.addEventListener('click',e=>{if(e.target===o)closeM(o.id.replace('modal-',''));}));
function toggleSB(){document.getElementById('sidebar')?.classList.toggle('off');}
document.getElementById('lp')?.addEventListener('keydown',e=>{if(e.key==='Enter')doLogin();});
document.getElementById('lu')?.addEventListener('keydown',e=>{if(e.key==='Enter')document.getElementById('lp')?.focus();});
</script>
</body>
</html>
View File
+7
View File
@@ -0,0 +1,7 @@
# Block semua eksekusi PHP di folder uploads
<FilesMatch "\.php$">
Order allow,deny
Deny from all
</FilesMatch>
Options -Indexes
+651
View File
@@ -0,0 +1,651 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Portal WebGIS Terpadu</title>
<style>
:root {
--bg: #080b10;
--metal-1: #111820;
--metal-2: #1c252e;
--metal-3: #2d3740;
--text: #eef6f8;
--muted: #a5b3ba;
--line: rgba(183, 205, 215, 0.18);
--primary: #55d7e8;
--primary-dark: #82f1ff;
--accent: #d7ad62;
--blue: #66a8ff;
--red: #ff716d;
--purple: #b48cff;
--shadow: 0 24px 70px rgba(0, 0, 0, 0.42);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: Arial, Helvetica, sans-serif;
color: var(--text);
background:
radial-gradient(circle at 16% 14%, rgba(85, 215, 232, 0.22), transparent 24%),
radial-gradient(circle at 86% 18%, rgba(215, 173, 98, 0.14), transparent 25%),
radial-gradient(circle at 50% 100%, rgba(102, 168, 255, 0.13), transparent 34%),
linear-gradient(145deg, #080b10 0%, #111922 48%, #06080c 100%);
overflow-x: hidden;
}
body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
background-image:
linear-gradient(rgba(183, 205, 215, 0.055) 1px, transparent 1px),
linear-gradient(90deg, rgba(183, 205, 215, 0.055) 1px, transparent 1px);
background-size: 46px 46px;
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.92), transparent 86%);
animation: gridDrift 18s linear infinite;
}
body::after {
content: "";
position: fixed;
inset: 0;
z-index: -1;
background:
linear-gradient(110deg, transparent 0 35%, rgba(255, 255, 255, 0.055) 45%, transparent 56%),
repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.025) 0 1px, transparent 1px 5px);
opacity: 0.62;
pointer-events: none;
animation: scanSweep 7s ease-in-out infinite;
}
.future-bg {
position: fixed;
inset: 0;
z-index: -1;
overflow: hidden;
pointer-events: none;
}
.future-bg::before {
content: "";
position: absolute;
inset: -20%;
background:
radial-gradient(circle, rgba(85, 215, 232, 0.9) 0 2px, transparent 3px),
radial-gradient(circle, rgba(215, 173, 98, 0.72) 0 1px, transparent 2px),
radial-gradient(circle, rgba(180, 140, 255, 0.8) 0 1px, transparent 2px);
background-size: 180px 180px, 260px 260px, 340px 340px;
background-position: 0 0, 80px 120px, 180px 40px;
filter: drop-shadow(0 0 8px rgba(85, 215, 232, 0.4));
opacity: 0.42;
animation: starField 28s linear infinite;
}
.future-bg::after {
content: "";
position: absolute;
inset: 0;
background:
linear-gradient(90deg, transparent 0 8%, rgba(85, 215, 232, 0.24) 9%, transparent 10% 42%, rgba(215, 173, 98, 0.18) 43%, transparent 44%),
linear-gradient(135deg, transparent 0 46%, rgba(102, 168, 255, 0.16) 47%, transparent 49%),
linear-gradient(45deg, transparent 0 54%, rgba(180, 140, 255, 0.13) 55%, transparent 57%);
background-size: 520px 100%, 420px 420px, 620px 620px;
opacity: 0.55;
transform: perspective(900px) rotateX(58deg) translateY(22%);
transform-origin: bottom;
animation: dataHighway 9s linear infinite;
}
.energy-ring {
position: absolute;
width: 44vmax;
height: 44vmax;
left: 50%;
top: 48%;
border: 1px solid rgba(85, 215, 232, 0.16);
border-radius: 50%;
box-shadow:
0 0 70px rgba(85, 215, 232, 0.12),
inset 0 0 70px rgba(102, 168, 255, 0.08);
transform: translate(-50%, -50%);
animation: haloRotate 24s linear infinite;
}
.energy-ring::before,
.energy-ring::after {
content: "";
position: absolute;
inset: 12%;
border-radius: 50%;
border: 1px dashed rgba(215, 173, 98, 0.2);
}
.energy-ring::after {
inset: 25%;
border-color: rgba(180, 140, 255, 0.18);
animation: innerHaloRotate 14s linear infinite reverse;
}
.beam {
position: absolute;
left: var(--x);
top: -22%;
width: 2px;
height: 44%;
background: linear-gradient(to bottom, transparent, var(--beam-color), transparent);
box-shadow: 0 0 18px var(--beam-color);
opacity: 0;
transform: rotate(24deg);
animation: lightBeam var(--duration) linear infinite;
animation-delay: var(--delay);
}
a {
color: inherit;
text-decoration: none;
}
.page {
width: min(1120px, calc(100% - 32px));
min-height: 100vh;
margin: 0 auto;
padding: 50px 0 36px;
display: flex;
flex-direction: column;
gap: 34px;
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 28px;
align-items: center;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
width: fit-content;
margin-bottom: 16px;
padding: 8px 12px;
border: 1px solid rgba(85, 215, 232, 0.28);
border-radius: 999px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.035));
color: var(--primary-dark);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 0 24px rgba(85, 215, 232, 0.12);
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
}
h1 {
margin: 0;
font-size: clamp(34px, 5vw, 62px);
line-height: 1.02;
letter-spacing: 0;
text-shadow: 0 0 34px rgba(85, 215, 232, 0.22);
}
.lead {
max-width: 650px;
margin: 18px 0 0;
color: var(--muted);
font-size: 18px;
line-height: 1.65;
}
.map-panel {
position: relative;
min-height: 235px;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 8px;
background:
linear-gradient(145deg, rgba(255, 255, 255, 0.13), rgba(255, 255, 255, 0.035)),
linear-gradient(35deg, transparent 44%, rgba(85, 215, 232, 0.28) 45%, rgba(85, 215, 232, 0.28) 47%, transparent 48%),
linear-gradient(130deg, transparent 48%, rgba(215, 173, 98, 0.24) 49%, rgba(215, 173, 98, 0.24) 51%, transparent 52%),
linear-gradient(160deg, #18212a, #0c1117);
box-shadow: var(--shadow);
isolation: isolate;
}
.map-panel::before,
.map-panel::after {
content: "";
position: absolute;
border-radius: 999px;
background: var(--primary);
box-shadow:
72px 48px 0 var(--accent),
146px 112px 0 var(--blue),
216px 70px 0 var(--red),
96px 154px 0 var(--purple);
width: 13px;
height: 13px;
left: 38px;
top: 42px;
filter: drop-shadow(0 0 10px currentColor);
animation: nodePulse 2.6s ease-in-out infinite;
}
.map-panel::after {
width: 130px;
height: 130px;
left: auto;
right: -42px;
top: -34px;
border: 26px solid rgba(85, 215, 232, 0.12);
background: transparent;
box-shadow: none;
animation: orbitRing 9s linear infinite;
}
.map-lines {
position: absolute;
inset: 0;
background-image:
linear-gradient(30deg, transparent 0 47%, rgba(215, 173, 98, 0.42) 48% 50%, transparent 51%),
linear-gradient(155deg, transparent 0 49%, rgba(85, 215, 232, 0.22) 50% 52%, transparent 53%);
background-size: 180px 120px, 220px 160px;
animation: routeFlow 12s linear infinite;
}
.grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 18px;
}
.card {
grid-column: span 2;
position: relative;
min-height: 218px;
display: flex;
flex-direction: column;
justify-content: space-between;
overflow: hidden;
padding: 22px;
border: 1px solid var(--line);
border-radius: 8px;
background:
linear-gradient(150deg, rgba(255, 255, 255, 0.13), rgba(255, 255, 255, 0.035) 48%, rgba(255, 255, 255, 0.07)),
linear-gradient(145deg, #151d25, #0c1118);
box-shadow: 0 16px 42px rgba(0, 0, 0, 0.34), inset 0 1px 0 rgba(255, 255, 255, 0.13);
transition: transform 220ms ease, box-shadow 220ms ease, border-color 220ms ease, background 220ms ease;
}
.card:nth-child(4),
.card:nth-child(5) {
grid-column: span 3;
}
.card::before {
content: "";
position: absolute;
inset: 0;
border-top: 4px solid var(--card-color);
background:
linear-gradient(110deg, transparent 0 28%, rgba(255, 255, 255, 0.16) 42%, transparent 56%),
radial-gradient(circle at 86% 18%, var(--card-soft), transparent 34%);
opacity: 0.8;
pointer-events: none;
transform: translateX(-38%);
transition: transform 520ms ease;
}
.card::after {
content: "";
position: absolute;
inset: 12px;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 6px;
pointer-events: none;
}
.card:hover,
.card:focus-visible {
transform: translateY(-7px);
border-color: color-mix(in srgb, var(--card-color), white 34%);
box-shadow: 0 26px 58px rgba(0, 0, 0, 0.45), 0 0 34px color-mix(in srgb, var(--card-color), transparent 64%);
outline: none;
}
.card:hover::before,
.card:focus-visible::before {
transform: translateX(38%);
}
.card-top,
.card-bottom {
position: relative;
z-index: 1;
}
.icon {
display: inline-grid;
place-items: center;
width: 46px;
height: 46px;
border-radius: 8px;
color: var(--card-color);
background:
linear-gradient(145deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0.035)),
var(--card-soft);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 0 20px color-mix(in srgb, var(--card-color), transparent 72%);
}
.icon svg {
width: 25px;
height: 25px;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
fill: none;
}
h2 {
margin: 18px 0 10px;
font-size: 22px;
line-height: 1.25;
letter-spacing: 0;
color: #f6fbfc;
}
.meta {
margin: 0;
color: var(--muted);
font-size: 14px;
line-height: 1.55;
}
.card-bottom {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 24px;
color: var(--primary-dark);
font-size: 14px;
font-weight: 700;
}
.arrow {
display: inline-grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 999px;
background: linear-gradient(145deg, var(--card-color), color-mix(in srgb, var(--card-color), black 32%));
color: #071014;
transition: transform 180ms ease, box-shadow 180ms ease;
flex: 0 0 auto;
box-shadow: 0 0 18px color-mix(in srgb, var(--card-color), transparent 55%);
}
.card:hover .arrow,
.card:focus-visible .arrow {
transform: translateX(3px) rotate(-12deg);
}
footer {
color: var(--muted);
font-size: 13px;
text-align: center;
}
@keyframes gridDrift {
from { background-position: 0 0, 0 0; }
to { background-position: 46px 46px, 46px 46px; }
}
@keyframes scanSweep {
0%, 100% { transform: translateX(-18%); opacity: 0.4; }
50% { transform: translateX(18%); opacity: 0.72; }
}
@keyframes routeFlow {
from { background-position: 0 0, 0 0; }
to { background-position: 180px 120px, -220px 160px; }
}
@keyframes nodePulse {
0%, 100% { opacity: 0.68; transform: scale(0.92); }
50% { opacity: 1; transform: scale(1.16); }
}
@keyframes orbitRing {
from { transform: rotate(0deg) scale(1); }
50% { transform: rotate(180deg) scale(1.08); }
to { transform: rotate(360deg) scale(1); }
}
@keyframes starField {
from { transform: translate3d(0, 0, 0) rotate(0deg); }
to { transform: translate3d(-180px, 180px, 0) rotate(4deg); }
}
@keyframes dataHighway {
from {
background-position: 0 0, 0 0, 0 0;
}
to {
background-position: 520px 0, 420px 420px, -620px 620px;
}
}
@keyframes haloRotate {
from { transform: translate(-50%, -50%) rotate(0deg); }
to { transform: translate(-50%, -50%) rotate(360deg); }
}
@keyframes innerHaloRotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes lightBeam {
0% {
opacity: 0;
transform: translate3d(-12vw, -20vh, 0) rotate(24deg);
}
12% {
opacity: 0.7;
}
48% {
opacity: 0.18;
}
100% {
opacity: 0;
transform: translate3d(24vw, 140vh, 0) rotate(24deg);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
}
}
@media (max-width: 900px) {
.page {
padding-top: 34px;
}
.hero {
grid-template-columns: 1fr;
}
.map-panel {
min-height: 185px;
}
.card,
.card:nth-child(4),
.card:nth-child(5) {
grid-column: span 3;
}
}
@media (max-width: 640px) {
.page {
width: min(100% - 24px, 1120px);
gap: 24px;
}
.lead {
font-size: 16px;
}
.grid {
grid-template-columns: 1fr;
}
.card,
.card:nth-child(4),
.card:nth-child(5) {
grid-column: auto;
min-height: 190px;
}
}
</style>
</head>
<body>
<div class="future-bg" aria-hidden="true">
<div class="energy-ring"></div>
<span class="beam" style="--x: 8%; --duration: 8s; --delay: -1s; --beam-color: rgba(85, 215, 232, 0.72);"></span>
<span class="beam" style="--x: 34%; --duration: 11s; --delay: -5s; --beam-color: rgba(215, 173, 98, 0.52);"></span>
<span class="beam" style="--x: 61%; --duration: 9s; --delay: -3s; --beam-color: rgba(102, 168, 255, 0.58);"></span>
<span class="beam" style="--x: 86%; --duration: 12s; --delay: -7s; --beam-color: rgba(180, 140, 255, 0.5);"></span>
</div>
<main class="page">
<section class="hero" aria-labelledby="page-title">
<div>
<div class="eyebrow">
<span aria-hidden="true">GIS</span>
<span>5 aplikasi peta interaktif</span>
</div>
<h1 id="page-title">Portal WebGIS Terpadu</h1>
<p class="lead">Pilih salah satu aplikasi WebGIS untuk membuka peta interaktif.</p>
</div>
<div class="map-panel" aria-hidden="true">
<div class="map-lines"></div>
</div>
</section>
<section class="grid" aria-label="Daftar aplikasi WebGIS">
<a class="card" href="01/index.html" style="--card-color: #55d7e8; --card-soft: rgba(85, 215, 232, 0.16);">
<div class="card-top">
<span class="icon" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path d="M4 21V5a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v16"></path>
<path d="M4 11h11"></path>
<path d="M17 8h1.5l1.5 2v7a2 2 0 0 0 4 0v-6"></path>
<path d="M8 21h3"></path>
</svg>
</span>
<h2>Webgis SPBU Point</h2>
<p class="meta">Peta sebaran titik SPBU untuk melihat lokasi fasilitas secara cepat.</p>
</div>
<div class="card-bottom">
<span>Buka project</span>
<span class="arrow" aria-hidden="true">&rarr;</span>
</div>
</a>
<a class="card" href="02/index.html" style="--card-color: #66a8ff; --card-soft: rgba(102, 168, 255, 0.16);">
<div class="card-top">
<span class="icon" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path d="M5 20c3-4 3-12 6-16"></path>
<path d="M19 20c-3-4-3-12-6-16"></path>
<path d="M12 4v4"></path>
<path d="M12 12v4"></path>
<path d="M3 20h18"></path>
</svg>
</span>
<h2>Webgis Jalan Parsil</h2>
<p class="meta">Visualisasi data jalan dan persil dalam satu tampilan spasial.</p>
</div>
<div class="card-bottom">
<span>Buka project</span>
<span class="arrow" aria-hidden="true">&rarr;</span>
</div>
</a>
<a class="card" href="03/index.html" style="--card-color: #d7ad62; --card-soft: rgba(215, 173, 98, 0.16);">
<div class="card-top">
<span class="icon" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path d="M9 18 3 21V6l6-3 6 3 6-3v15l-6 3-6-3Z"></path>
<path d="M9 3v15"></path>
<path d="M15 6v15"></path>
</svg>
</span>
<h2>Webgis Chropleth</h2>
<p class="meta">Peta tematik choropleth untuk membaca pola wilayah berbasis warna.</p>
</div>
<div class="card-bottom">
<span>Buka project</span>
<span class="arrow" aria-hidden="true">&rarr;</span>
</div>
</a>
<a class="card" href="04/index.html" style="--card-color: #ff716d; --card-soft: rgba(255, 113, 109, 0.15);">
<div class="card-top">
<span class="icon" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path d="M12 21s7-4.4 7-11a7 7 0 1 0-14 0c0 6.6 7 11 7 11Z"></path>
<circle cx="12" cy="10" r="2.5"></circle>
<path d="M16 5.5h2.5V3"></path>
<path d="M18.5 5.5 15.5 2.5"></path>
</svg>
</span>
<h2>Webgis SPBU Upgrade</h2>
<p class="meta">Versi pengembangan peta SPBU dengan tampilan dan data yang diperbarui.</p>
</div>
<div class="card-bottom">
<span>Buka project</span>
<span class="arrow" aria-hidden="true">&rarr;</span>
</div>
</a>
<a class="card" href="05/index.php" style="--card-color: #b48cff; --card-soft: rgba(180, 140, 255, 0.15);">
<div class="card-top">
<span class="icon" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path d="M4 19V5"></path>
<path d="M4 19h17"></path>
<rect x="7" y="11" width="3" height="5"></rect>
<rect x="12" y="8" width="3" height="8"></rect>
<rect x="17" y="4" width="3" height="12"></rect>
</svg>
</span>
<h2>Webgis Kemiskinan</h2>
<p class="meta">Peta dan data wilayah untuk mendukung pembacaan indikator kemiskinan.</p>
</div>
<div class="card-bottom">
<span>Buka project</span>
<span class="arrow" aria-hidden="true">&rarr;</span>
</div>
</a>
</section>
<footer>
WebGis by: Muhammad Taufiq Fauzan--D1041231023--Informatika UNTAN
</footer>
</main>
</body>
</html>
+78
View File
@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Manual Book WebGIS Project UAS</title>
<style>
body {
margin: 0;
font-family: Arial, Helvetica, sans-serif;
color: #17212b;
background: #f3f6f8;
line-height: 1.65;
}
main {
width: min(920px, calc(100% - 32px));
margin: 0 auto;
padding: 42px 0;
}
h1, h2 {
line-height: 1.2;
color: #0e1720;
}
section {
margin: 18px 0;
padding: 22px;
border: 1px solid #dce4ea;
border-radius: 8px;
background: #fff;
}
code {
padding: 2px 5px;
border-radius: 4px;
background: #edf2f5;
}
ul {
padding-left: 22px;
}
</style>
</head>
<body>
<main>
<h1>Manual Book WebGIS Project UAS</h1>
<p>Dokumen ini berisi ringkasan struktur project, akses aplikasi, dan konfigurasi database untuk deployment.</p>
<section>
<h2>Struktur Aplikasi</h2>
<ul>
<li><code>01</code> - Webgis SPBU Point</li>
<li><code>02</code> - Webgis Jalan Parsil</li>
<li><code>03</code> - Webgis Chropleth</li>
<li><code>04</code> - Webgis SPBU Upgrade</li>
<li><code>05</code> - Webgis Kemiskinan</li>
</ul>
</section>
<section>
<h2>Akses Lokal</h2>
<p>Buka portal utama melalui <code>http://localhost/combinewebgis/</code>, lalu pilih aplikasi WebGIS dari card yang tersedia.</p>
</section>
<section>
<h2>Database</h2>
<p>Aplikasi PHP membaca konfigurasi database dari environment variable <code>DB_HOST</code>, <code>DB_USER</code>, <code>DB_PASSWORD</code>, dan <code>DB_NAME</code>. Jika variable belum tersedia, aplikasi memakai fallback lokal XAMPP.</p>
</section>
<section>
<h2>Deployment Docker</h2>
<p>Setiap folder aplikasi memiliki <code>Dockerfile</code> sendiri agar bisa dideploy sebagai service terpisah di Coolify.</p>
</section>
</main>
</body>
</html>