update besar besaran

This commit is contained in:
TigerStable
2026-06-12 16:36:31 +07:00
parent 515c830342
commit 017fe392bc
63 changed files with 4219 additions and 75 deletions
+21
View File
@@ -0,0 +1,21 @@
<?php
$host = 'localhost';
$db = 'webgis_bansos';
$user = 'root';
$pass = ''; // Default XAMPP password is empty
$charset = 'utf8mb4';
$dsn = "mysql:host=$host;dbname=$db;charset=$charset";
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
$pdo = new PDO($dsn, $user, $pass, $options);
} catch (\PDOException $e) {
echo json_encode(['error' => 'Database connection failed: ' . $e->getMessage()]);
exit;
}
?>
+92
View File
@@ -0,0 +1,92 @@
<?php
header('Access-Control-Allow-Origin: *');
header('Content-Type: application/json');
header('Access-Control-Allow-Methods: GET, POST');
header('Access-Control-Allow-Headers: Content-Type');
require_once 'db.php';
$action = $_GET['action'] ?? '';
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
if ($action === 'alldb') {
$stmt = $pdo->query("SELECT * FROM features");
$rows = $stmt->fetchAll();
$db = [
'spbu' => [],
'jalan' => [],
'parsil' => [],
'warga' => [],
'rumahIbadah' => []
];
foreach ($rows as $row) {
$layer = $row['layer'];
$data = json_decode($row['data'], true);
// Ensure ID is part of the data
if (!isset($data['id'])) {
$data['id'] = $row['id'];
}
if (!isset($db[$layer])) {
$db[$layer] = [];
}
$db[$layer][] = $data;
}
echo json_encode($db);
exit;
}
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
if ($action === 'add') {
$layer = $input['layer'];
$data = $input['data'];
$id = $data['id']; // Expecting frontend to generate ID or we could generate it here
$stmt = $pdo->prepare("INSERT INTO features (id, layer, data) VALUES (?, ?, ?)");
$stmt->execute([$id, $layer, json_encode($data)]);
echo json_encode(['success' => true]);
exit;
}
if ($action === 'update') {
$layer = $input['layer'];
$id = $input['id'];
$patch = $input['patch'];
// Fetch existing data
$stmt = $pdo->prepare("SELECT data FROM features WHERE id = ? AND layer = ?");
$stmt->execute([$id, $layer]);
$row = $stmt->fetch();
if ($row) {
$currentData = json_decode($row['data'], true);
$newData = array_merge($currentData, $patch);
$updateStmt = $pdo->prepare("UPDATE features SET data = ? WHERE id = ? AND layer = ?");
$updateStmt->execute([json_encode($newData), $id, $layer]);
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'error' => 'Record not found']);
}
exit;
}
if ($action === 'remove') {
$layer = $input['layer'];
$id = $input['id'];
$stmt = $pdo->prepare("DELETE FROM features WHERE id = ? AND layer = ?");
$stmt->execute([$id, $layer]);
echo json_encode(['success' => true]);
exit;
}
}
echo json_encode(['error' => 'Invalid action']);
?>
+12
View File
@@ -0,0 +1,12 @@
CREATE DATABASE IF NOT EXISTS webgis_bansos;
USE webgis_bansos;
CREATE TABLE IF NOT EXISTS features (
id VARCHAR(100) PRIMARY KEY,
layer VARCHAR(50) NOT NULL,
data JSON NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Index for faster queries by layer
CREATE INDEX idx_layer ON features(layer);
-49
View File
@@ -1,49 +0,0 @@
# Rencana Penggabungan (Merge) & Pembaruan Fitur
## Tujuan
1. **Penggabungan Folder:** Menghapus folder `webgis` yang lama (karena sudah usang) dan mengubah nama folder `webgis02` menjadi `webgis`. Hal ini dilakukan agar proyek menjadi satu kesatuan (tidak terpisah-pisah) dan tetap mempertahankan kode versi terbaru yang ada di `webgis02`.
2. **Penggabungan Fitur Warga & Bansos:** Menggabungkan layer "Warga Miskin" dan "Rumah Bansos" menjadi satu layer tunggal **"Pendataan Warga & Bansos"** (`warga`). Tujuannya adalah menyederhanakan proses pendataan warga sekitar sekaligus menandai warga mana saja yang menerima bansos, tanpa harus membuat titik/layer yang terpisah.
3. **Fungsi Rumah Ibadah:** Tetap menggunakan Rumah Ibadah sebagai "Hub" (radius 300m) untuk mendistribusikan bansos kepada warga yang terdata di sekitarnya.
> [!WARNING]
> **User Review Required**
> Proses ini akan **menghapus folder `webgis` lama** dan mengganti nama `webgis02` menjadi `webgis`. Harap pastikan tidak ada kode penting di `webgis` lama yang belum dipindahkan. Silakan setujui rencana ini untuk mulai mengeksekusi!
---
## Proposed Changes
### 1. Konsolidasi Folder
- Menghapus folder `webgis` lama.
- Me-rename folder `webgis02` menjadi `webgis`.
### 2. Penggabungan Layer "Warga" dan "Bansos" di `webgis/app.js`
#### [MODIFY] `app.js`
- **Konfigurasi Layer (`CFG`)**:
- Menghapus konfigurasi `CFG.bansos`.
- Memperbarui `CFG.warga` dengan ikon, warna, dan status penerimaan gabungan.
- **Form UI (`Forms.warga`)**:
- Mengadopsi sistem multi-tab yang sebelumnya kita buat untuk bansos (Data KK, Ekonomi, Pendidikan, Kesehatan, Riwayat Bansos) ke dalam form `warga`.
- **Popup & Card (`Popup.warga` & `Card.warga`)**:
- Memperbarui informasi popup dan card agar menampilkan status jangkauan (radius) dan riwayat bansos warga.
- **Render Map (`renderMap`)**:
- Menghapus logic render khusus `bansos`.
- Mengimplementasikan kalkulasi radius `rumahIbadah` langsung ke marker `warga` (menandai warga mana yang masuk jangkauan 300m).
- **Dashboard Analitik (`Dashboard.render`)**:
- Memperbarui tab "Ringkasan" dan "Distribusi" agar membaca data gabungan dari database `warga`.
### 3. Pembersihan di `webgis/index.html`
#### [MODIFY] `index.html`
- Menghapus tab navigasi (nav-pill) untuk "Rumah Bansos", menyisakan 5 tab (SPBU, Jalan, Parsil, Warga & Bansos, Rumah Ibadah).
---
## Verification Plan
### Manual Verification
1. Verifikasi struktur folder hanya tersisa 1 direktori utama (`webgis`).
2. Buka `webgis/index.html` di browser.
3. Klik tab "Warga & Bansos" dan coba tambahkan data. Form multi-tab harus muncul lengkap.
4. Aktifkan tab "Rumah Ibadah" dan periksa apakah lingkaran radius 300m tergambar dan Warga di sekitarnya mendapatkan tanda terjangkau.
5. Periksa Dashboard Analitik, pastikan data warga dan distribusi bansos terbaca dengan benar.
+261
View File
@@ -0,0 +1,261 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Portal WebGIS - Praktikum SIG</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-color: #0f172a;
--card-bg: rgba(30, 41, 59, 0.7);
--card-border: rgba(255, 255, 255, 0.1);
--text-main: #f8fafc;
--text-muted: #cbd5e1;
--accent-1: #34d47e; /* SPBU green */
--accent-2: #f0a030; /* Jalan orange */
--accent-3: #9b7bfa; /* Parsil purple */
--accent-hover: #3b82f6;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'DM Sans', sans-serif;
background-color: var(--bg-color);
background-image:
radial-gradient(at 0% 0%, rgba(52, 212, 126, 0.15) 0px, transparent 50%),
radial-gradient(at 100% 0%, rgba(155, 123, 250, 0.15) 0px, transparent 50%),
radial-gradient(at 100% 100%, rgba(240, 160, 48, 0.15) 0px, transparent 50%);
color: var(--text-main);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
}
header {
text-align: center;
margin-bottom: 4rem;
animation: fadeInDown 0.8s ease-out;
}
h1 {
font-size: 3rem;
font-weight: 700;
margin-bottom: 1rem;
background: linear-gradient(135deg, #fff, #cbd5e1);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -1px;
}
p.subtitle {
font-size: 1.25rem;
color: var(--text-muted);
max-width: 600px;
margin: 0 auto;
line-height: 1.6;
}
.grid-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
width: 100%;
max-width: 1200px;
animation: fadeInUp 0.8s ease-out;
}
.card {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 20px;
padding: 2.5rem 2rem;
text-decoration: none;
color: var(--text-main);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
}
.card::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 4px;
background: var(--accent-1);
transition: height 0.3s ease;
opacity: 0.8;
}
.card:nth-child(2)::before { background: var(--accent-2); }
.card:nth-child(3)::before { background: linear-gradient(90deg, var(--accent-3), #2ec4b6); }
.card:hover {
transform: translateY(-10px);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
border-color: rgba(255, 255, 255, 0.2);
}
.card-title {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.75rem;
}
.card-title-icon {
font-size: 1.75rem;
}
.card-desc {
color: var(--text-muted);
line-height: 1.6;
margin-bottom: 2rem;
flex-grow: 1;
}
.card-features {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 2rem;
}
.feature-tag {
font-size: 0.75rem;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: var(--text-muted);
}
.card-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: rgba(255, 255, 255, 0.1);
color: white;
border-radius: 10px;
font-weight: 500;
transition: all 0.2s ease;
margin-top: auto;
}
.card:hover .card-btn {
background: white;
color: var(--bg-color);
}
.card:hover .card-btn svg {
transform: translateX(4px);
}
.card-btn svg {
transition: transform 0.2s ease;
}
@keyframes fadeInDown {
from { opacity: 0; transform: translateY(-20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
footer {
margin-top: 4rem;
color: var(--text-muted);
font-size: 0.875rem;
opacity: 0.7;
}
</style>
</head>
<body>
<header>
<h1>Portal WebGIS</h1>
<p class="subtitle">Pilih modul pertemuan di bawah ini untuk mengakses sistem pemetaan geospasial.</p>
</header>
<div class="grid-container">
<!-- Pertemuan 1 -->
<a href="pertemuan_1/index.html" class="card">
<div class="card-title">
<span class="card-title-icon">📍</span> Pertemuan 1
</div>
<p class="card-desc">Fokus pada digitasi Point (Titik) untuk mendata persebaran dan informasi Stasiun Bahan Bakar Umum (SPBU).</p>
<div class="card-features">
<span class="feature-tag">Point</span>
<span class="feature-tag">Data SPBU</span>
</div>
<div class="card-btn">
Buka Aplikasi
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg>
</div>
</a>
<!-- Pertemuan 2 -->
<a href="pertemuan_2/index.html" class="card">
<div class="card-title">
<span class="card-title-icon">🛣️</span> Pertemuan 2
</div>
<p class="card-desc">Pengembangan digitasi spasial lanjutan dengan menambahkan fitur Polyline (Jalan) dan Polygon (Parsil Tanah).</p>
<div class="card-features">
<span class="feature-tag">Point</span>
<span class="feature-tag">Polyline</span>
<span class="feature-tag">Polygon</span>
</div>
<div class="card-btn">
Buka Aplikasi
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg>
</div>
</a>
<!-- Pertemuan 3 -->
<a href="pertemuan_3/index.html" class="card">
<div class="card-title">
<span class="card-title-icon">🌐</span> Pertemuan 3
</div>
<p class="card-desc">Sistem komprehensif Distribusi Bansos Berbasis Rumah Ibadah dengan dashboard analitik dan radius spasial otomatis.</p>
<div class="card-features">
<span class="feature-tag">All Geometries</span>
<span class="feature-tag">Data Warga</span>
<span class="feature-tag">Dashboard Analitik</span>
</div>
<div class="card-btn">
Buka Aplikasi
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg>
</div>
</a>
</div>
<footer>
&copy; Praktikum Sistem Informasi Geografis
</footer>
</body>
</html>
Submodule pertemuan_1/UAS_SIG added at 30bf02df8b
+125
View File
@@ -0,0 +1,125 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>WebGIS Sistem Distribusi Bansos Berbasis Rumah Ibadah</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<link rel="stylesheet" href="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.css"/>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<div class="app">
<!-- ═══ TOPBAR ═══ -->
<header class="topbar">
<div class="brand">
<div class="brand-mark">
<svg viewBox="0 0 24 24"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/></svg>
</div>
<div class="brand-name">WebGIS Distribusi Bansos<span>Berbasis Rumah Ibadah — Radius 300m</span></div>
</div>
<div class="topbar-div"></div>
<nav class="nav-pills" id="nav-pills">
<button class="nav-pill active" data-layer="spbu">
<span class="pill-dot" style="background:#34d47e"></span>SPBU
<span class="pill-badge" id="badge-spbu">0</span>
</button>
</nav>
<div class="topbar-stat"><span class="stat-dot"></span><span id="total-count">0 fitur</span></div>
</header>
<!-- ═══ MAIN ═══ -->
<div class="main">
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-head">
<div>
<div class="sidebar-title" id="sb-title">Data SPBU</div>
<div class="sidebar-sub" id="sb-sub">Stasiun Bahan Bakar Umum</div>
</div>
<button class="btn-add" id="btn-add">
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>Tambah
</button>
</div>
<div class="sidebar-body" id="sb-list"></div>
</aside>
<!-- Map -->
<div class="map-area">
<div id="map"></div>
<!-- Draw indicator -->
<div class="draw-bar hidden" id="draw-bar">
<span class="draw-pulse"></span>
<span id="draw-text">Mode gambar aktif</span>
<button class="btn-cancel-draw" id="btn-cancel">Batal</button>
</div>
<!-- Legend -->
<div class="map-legend" id="legend">
<div class="legend-title">Legenda</div>
<div id="legend-items"></div>
</div>
</div>
</div>
</div>
<!-- ═══ MODAL ═══ -->
<div class="modal-ov hidden" id="modal-ov">
<div class="modal">
<div class="modal-head">
<div class="modal-head-left">
<div class="modal-icon" id="modal-icon">📍</div>
<div>
<div class="modal-title" id="modal-title">Form Data</div>
<div class="modal-sub" id="modal-sub"></div>
</div>
</div>
<button class="modal-close" id="modal-close"></button>
</div>
<div class="modal-body" id="modal-body"></div>
<div class="modal-foot">
<button class="btn btn-ghost" id="modal-cancel">Batal</button>
<button class="btn btn-primary" id="modal-save">Simpan Data</button>
</div>
</div>
</div>
<!-- ═══ DASHBOARD ═══ -->
<div class="dash-panel hidden" id="dash-panel">
<div class="dash-inner">
<div class="dash-head">
<div>
<div class="dash-head-title">Dashboard Analitik Kemiskinan</div>
<div class="dash-head-sub">Ringkasan & analisis data geospasial untuk pengambilan kebijakan</div>
</div>
<button class="modal-close" onclick="Dashboard.toggle()"></button>
</div>
<div class="dash-body" id="dash-body"></div>
</div>
</div>
<!-- ═══ TOASTS ═══ -->
<div class="toasts" id="toasts"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.js"></script>
<script src="js/utils/helpers.js"></script>
<script src="js/utils/store.js"></script>
<script src="js/utils/toast.js"></script>
<script src="js/config/config.js"></script>
<script src="js/components/forms.js"></script>
<script src="js/components/popup.js"></script>
<script src="js/components/card.js"></script>
<script src="js/components/legend.js"></script>
<script src="js/modules/draw.js"></script>
<script src="js/modules/modal.js"></script>
<script src="js/modules/sidebar.js"></script>
<script src="js/modules/dashboard.js"></script>
<script src="js/modules/map.js"></script>
<script src="js/app.js"></script>
</body>
</html>
+71
View File
@@ -0,0 +1,71 @@
/* ═══════════════════════════════════════════════════════════
14. APP ROOT
═══════════════════════════════════════════════════════════ */
const App = {
state:{ activeLayer:'spbu' },
_groups:{},
async init(){
// Map
this._map=L.map('map',{zoomControl:false}).setView([-0.056031,109.348641],13);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',{
attribution:'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',maxZoom:19
}).addTo(this._map);
L.control.zoom({position:'topright'}).addTo(this._map);
['spbu'].forEach(k=>{
this._groups[k]=L.layerGroup().addTo(this._map);
});
// Expose map for Draw
App.map={
instance: this._map,
zoomTo:(ln,id)=>{
const r=Store.find(ln,id); if(!r) return;
if(r.lat!=null) this._map.setView([r.lat,r.lng],17);
else if(r.coords?.length) try{this._map.fitBounds(L.latLngBounds(r.coords),{padding:[40,40]})}catch{}
highlightCard(id);
}
};
Draw.init(this._map);
// Nav
document.getElementById('nav-pills').addEventListener('click',e=>{
const pill=e.target.closest('.nav-pill'); if(!pill) return;
Draw.cancel();
this.state.activeLayer=pill.dataset.layer;
document.querySelectorAll('.nav-pill').forEach(p=>p.classList.remove('active'));
pill.classList.add('active');
renderSidebar(this.state.activeLayer);
renderLegend(this.state.activeLayer);
});
// Add btn
document.getElementById('btn-add').addEventListener('click',()=>Draw.start(this.state.activeLayer));
// Modal
document.getElementById('modal-save').addEventListener('click', Modal.submit);
document.getElementById('modal-cancel').addEventListener('click', Modal.close);
document.getElementById('modal-close').addEventListener('click', Modal.close);
document.getElementById('modal-ov').addEventListener('click',e=>{ if(e.target===e.currentTarget) Modal.close(); });
await Store.load();
this.refresh();
},
crud:{
edit(ln,id){ App.state.activeLayer=ln; Modal.openEdit(ln,id); },
async delete(ln,id){
if(!confirm('Yakin hapus data ini?')) return;
await Store.remove(ln,id); App._map.closePopup();
App.refresh(); Toast.info('Data dihapus');
},
},
refresh(){
renderMap(); renderSidebar(this.state.activeLayer);
renderLegend(this.state.activeLayer); updateBadges();
},
};
document.addEventListener('DOMContentLoaded',()=>App.init());
@@ -42,11 +42,11 @@ const Modal = (() => {
function close(){ ov().classList.add('hidden'); Draw.clearPending(); } function close(){ ov().classList.add('hidden'); Draw.clearPending(); }
function submit(){ async function submit(){
const data=Forms.readAll(_ln); if(!data) return; const data=Forms.readAll(_ln); if(!data) return;
if(_editId){ if(_editId){
data.updatedAt=H.today(); data.updatedAt=H.today();
Store.update(_ln,_editId,data); await Store.update(_ln,_editId,data);
Toast.success('Data berhasil diperbarui'); Toast.success('Data berhasil diperbarui');
} else { } else {
const geo=_geo; const geo=_geo;
@@ -58,7 +58,7 @@ const Modal = (() => {
data.coords=geo.getLatLngs()[0].map(l=>[l.lat,l.lng]); data.coords=geo.getLatLngs()[0].map(l=>[l.lat,l.lng]);
} }
data.id=H.uid(); data.createdAt=H.today(); data.updatedAt=H.today(); data.id=H.uid(); data.createdAt=H.today(); data.updatedAt=H.today();
Store.add(_ln,data); await Store.add(_ln,data);
Toast.success('Data berhasil disimpan'); Toast.success('Data berhasil disimpan');
} }
close(); App.refresh(); close(); App.refresh();
+55
View File
@@ -0,0 +1,55 @@
/* ═══════════════════════════════════════════════════════════
2. STORAGE (MySQL API Version)
═══════════════════════════════════════════════════════════ */
const Store = (() => {
const API_URL = '../api/index.php';
let db = {
spbu: [],
jalan: [],
parsil: [],
warga: [],
rumahIbadah: []
};
const request = async (action, payload = {}) => {
try {
const res = await fetch(`${API_URL}?action=${action}`, {
method: action === 'alldb' ? 'GET' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: action === 'alldb' ? null : JSON.stringify(payload)
});
return await res.json();
} catch (err) {
console.error('API Error:', err);
throw err;
}
};
return {
load: async () => {
try {
const data = await request('alldb');
if (data) db = { ...db, ...data };
} catch (e) {
console.error("Gagal load data dari server", e);
}
},
all: ln => db[ln]||[],
find: (ln,id) => (db[ln]||[]).find(r=>r.id===id)||null,
add: async (ln,rec) => {
db[ln].push(rec);
await request('add', { layer: ln, data: rec });
},
update: async (ln,id,patch) => {
const i=db[ln].findIndex(r=>r.id===id); if(i<0)return;
db[ln][i]={...db[ln][i],...patch};
await request('update', { layer: ln, id: id, patch: patch });
},
remove: async (ln,id) => {
db[ln]=db[ln].filter(r=>r.id!==id);
await request('remove', { layer: ln, id: id });
},
allDb: () => db,
};
})();
+165
View File
@@ -0,0 +1,165 @@
# WebGIS Sistem Distribusi Bansos Berbasis Rumah Ibadah
> Aplikasi pemetaan geospasial (*WebGIS*) interaktif untuk pendataan infrastruktur, profil warga rentan, dan perencanaan distribusi Bantuan Sosial (Bansos) berbasis jangkauan Rumah Ibadah.
Aplikasi ini menggunakan pendekatan **radius spasial 300 meter** untuk secara otomatis mendeteksi apakah seorang warga masuk dalam jangkauan agen distribusi (Rumah Ibadah) atau tidak — membantu pemerintah desa/kelurahan dalam pengambilan keputusan yang akurat dan berbasis data.
---
## 📌 Fitur Utama
| No | Fitur | Tipe Layer | Deskripsi |
|----|-------|-----------|-----------|
| 1 | **Warga & Bansos** | Point | Profil lengkap KK (ekonomi, kesehatan, pendidikan) + riwayat bansos |
| 2 | **Rumah Ibadah** | Point + Buffer | Titik *hub* distribusi bansos; lingkaran jangkauan 300m otomatis |
| 3 | **SPBU** | Point | Ketersediaan BBM & aksesibilitas |
| 4 | **Jalan** | Polyline | Status, kondisi, dan dampak sosial ruas jalan |
| 5 | **Parsil Tanah** | Polygon | Status kepemilikan sertifikat tanah |
| 6 | **Dashboard Analitik** | — | Ringkasan kemiskinan, distribusi bansos, dan rekomendasi kebijakan |
---
## 🗂️ Data yang Dikumpulkan
### 👨‍👩‍👧 Warga & Bansos
Data yang dikumpulkan mencakup 5 dimensi:
- **Data KK:** Nama, NIK, No. KK, jumlah anggota, alamat, tingkat kemiskinan
- **Ekonomi:** Pendapatan, pekerjaan, kondisi rumah, aset, air bersih, listrik
- **Pendidikan:** Tingkat pendidikan, jumlah anak sekolah/putus sekolah, penerima KIP
- **Kesehatan:** Kepesertaan BPJS, jenis penyakit kronis, anggota difabel
- **Riwayat Bansos:** Program (PKH, BPNT, BLT, dll), tahun, dan status penerimaan
### 🕌 Rumah Ibadah
Nama, jenis (Masjid/Gereja/Pura/dll), pengurus, kapasitas jamaah, radius binaan, program sosial aktif, dan alokasi dana sosial per bulan.
### ⛽ SPBU
Nama, jam operasional (24 jam / tidak), jenis BBM, harga, dan aksesibilitas disabilitas.
### 🛣️ Jalan
Nama ruas, status (Nasional/Provinsi/Kabupaten/Desa), kondisi, lebar, tahun perbaikan, dan dampak sosial.
### 🏗️ Parsil Tanah
Nama, NIB, status sertifikat (SHM/HGB/HGU/dll), NJOP, penggunaan lahan, dan status sengketa.
---
## 🏗️ Arsitektur & Struktur Folder
Aplikasi ini dibangun menggunakan arsitektur **modular Vanilla JavaScript** tanpa *build tool* atau *framework*, sehingga bisa langsung dijalankan di browser.
```text
webgis/
├── index.html # Halaman utama & markup HTML
├── README.md # Dokumentasi proyek ini
├── css/
│ └── style.css # Seluruh styling & tema UI
└── js/
├── app.js # Root: Inisialisasi & event binding utama
├── config/
│ └── config.js # CFG: Kontrak data (warna, label, atribut tiap layer)
├── utils/
│ ├── helpers.js # H: Utilitas (uid, format angka/jarak/luas, geometri)
│ ├── store.js # Store: Baca/tulis LocalStorage
│ └── toast.js # Toast: Notifikasi UI (sukses/error/info)
├── components/
│ ├── forms.js # Forms: Generator HTML form input per layer
│ ├── popup.js # Popup: Generator HTML popup Leaflet per layer
│ ├── card.js # Card: Generator HTML item daftar di sidebar
│ └── legend.js # renderLegend: Fungsi render legenda peta
└── modules/
├── map.js # renderMap: Render semua marker/layer ke peta
├── draw.js # Draw: Integrasi Leaflet Draw (titik, garis, poligon)
├── modal.js # Modal: Manajemen pop-up form tambah/edit
├── sidebar.js # renderSidebar, updateBadges, highlightCard
└── dashboard.js # Dashboard: Kalkulasi analitik & render statistik
```
---
## 💾 Penyimpanan Data (LocalStorage)
Semua data tersimpan di *LocalStorage* browser Anda menggunakan key berikut:
| Layer | LocalStorage Key |
|-------|-----------------|
| SPBU | `wg2_spbu` |
| Jalan | `wg2_jalan` |
| Parsil Tanah | `wg2_parsil` |
| Warga & Bansos | `wg2_warga` |
| Rumah Ibadah | `wg2_ri` |
> **Catatan Penting:** Data tersimpan di browser lokal Anda. Untuk memindahkan data ke perangkat lain, gunakan fitur *Export/Import* browser atau salin nilai dari DevTools > Application > LocalStorage.
---
## 🚀 Cara Menjalankan
Tidak perlu instalasi apapun! Cukup:
1. Unduh atau *clone* repositori ini:
```bash
git clone <url-repo>
```
2. Buka folder `webgis/`.
3. Klik dua kali pada **`index.html`** untuk membukanya langsung di browser.
4. Aplikasi siap digunakan. ✅
> Direkomendasikan menggunakan **Google Chrome** atau **Mozilla Firefox** versi terbaru.
> Anda juga bisa menggunakan ekstensi **Live Server** di VSCode untuk pengalaman pengembangan yang lebih baik.
---
## 🗺️ Panduan Penggunaan
### Menambah Data Baru
1. Pilih layer yang ingin diisi melalui **navigasi tab** di bagian atas (SPBU, Jalan, Parsil, Warga, Rumah Ibadah).
2. Klik tombol **`+ Tambah`** di sidebar kiri.
3. **Klik pada peta** untuk menandai lokasi (untuk Point), atau **gambar garis/poligon** (untuk Jalan/Parsil).
4. Isi formulir atribut yang muncul, lalu klik **Simpan Data**.
### Mengedit atau Menghapus Data
- Klik marker/layer di peta → Popup muncul → Klik **Edit** atau **Hapus**.
- Atau klik kartu di sidebar → gunakan tombol **Edit** / **Hapus** / **Zoom**.
### Melihat Dashboard Analitik
- Klik tombol **📊 Dashboard Analitik** di topbar kanan.
- Tab **Ringkasan Utama**: Statistik jumlah KK, distribusi kemiskinan, status jalan.
- Tab **Distribusi Bansos**: Peta panas jangkauan Rumah Ibadah, beban distribusi per *hub*.
---
## 🛠️ Teknologi yang Digunakan
| Teknologi | Versi | Fungsi |
|-----------|-------|--------|
| HTML5 & CSS3 | — | Struktur & tampilan UI |
| Vanilla JavaScript | ES6+ | Logika aplikasi & state management |
| [Leaflet.js](https://leafletjs.com/) | 1.9.4 | Render peta interaktif |
| [Leaflet Draw](https://github.com/Leaflet/Leaflet.draw) | 1.0.4 | Digitasi titik, garis, dan poligon |
| [DM Sans & DM Mono](https://fonts.google.com/) | — | Tipografi UI |
| LocalStorage API | — | Basis data *client-side* |
| OpenStreetMap | — | Tile peta dasar |
---
## 🔧 Panduan Modifikasi Kode
| Ingin mengubah... | Buka file... |
|-------------------|--------------|
| Warna marker atau label layer | [`js/config/config.js`](js/config/config.js) |
| Isian form input | [`js/components/forms.js`](js/components/forms.js) |
| Tampilan popup peta | [`js/components/popup.js`](js/components/popup.js) |
| Tampilan kartu di sidebar | [`js/components/card.js`](js/components/card.js) |
| Logika & tampilan dashboard | [`js/modules/dashboard.js`](js/modules/dashboard.js) |
| Cara layer dirender di peta | [`js/modules/map.js`](js/modules/map.js) |
| Tema / warna UI global | [`css/style.css`](css/style.css) |
---
## 📄 Lisensi
Proyek ini dibuat untuk keperluan akademik mata kuliah **Sistem Informasi Geografis (SIG)**. Bebas digunakan dan dimodifikasi untuk keperluan non-komersial.
---
*Dikembangkan dengan ❤️ untuk mendukung pemerataan distribusi Bantuan Sosial berbasis data geospasial.*
Submodule pertemuan_2/UAS_SIG added at 30bf02df8b
+420
View File
@@ -0,0 +1,420 @@
/* ═══════════════════════════════════════════
DESIGN TOKENS
═══════════════════════════════════════════ */
:root{
--bg0:#07090f;--bg1:#0d1120;--bg2:#131929;--bg3:#1a2235;--bg4:#212d42;--bg5:#2a3a52;
--border0:#1a2438;--border1:#243045;--border2:#2e3e58;--border3:#3d5070;
--text0:#f0f4ff;--text1:#c8d4ea;--text2:#8fa3c0;--text3:#5a7292;--text4:#3a5070;
--accent:#5b9cf6;--accent2:#3a7de4;--accent-glow:rgba(91,156,246,.18);
--green:#34d47e;--green2:#22aa60;--green-s:rgba(52,212,126,.14);
--red:#f05c5c;--red2:#c83e3e;--red-s:rgba(240,92,92,.14);
--amber:#f0a030;--amber2:#c47c18;--amber-s:rgba(240,160,48,.14);
--purple:#9b7bfa;--purple2:#7a5ae0;--purple-s:rgba(155,123,250,.14);
--teal:#2ec4b6;--teal2:#1ea090;--teal-s:rgba(46,196,182,.14);
--orange:#f07840;--orange2:#c45820;--orange-s:rgba(240,120,64,.14);
--pink:#f06090;--pink-s:rgba(240,96,144,.14);
--font:'DM Sans',system-ui,sans-serif;
--mono:'DM Mono',monospace;
--r4:4px;--r6:6px;--r8:8px;--r10:10px;--r12:12px;--r16:16px;--rfull:9999px;
--shadow-sm:0 2px 8px rgba(0,0,0,.35);
--shadow-md:0 4px 20px rgba(0,0,0,.45);
--shadow-lg:0 8px 40px rgba(0,0,0,.6);
--transition:.15s ease;
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;overflow:hidden;font-family:var(--font)}
body{background:var(--bg0);color:var(--text0);font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased}
button{font-family:var(--font);cursor:pointer;border:none;outline:none;background:none}
input,select,textarea{font-family:var(--font);outline:none}
::-webkit-scrollbar{width:4px;height:4px}
::-webkit-scrollbar-thumb{background:var(--border2);border-radius:var(--rfull)}
/* ═══════════════════════════════════════════
LAYOUT
═══════════════════════════════════════════ */
.app{display:flex;flex-direction:column;height:100vh}
.topbar{height:52px;background:var(--bg1);border-bottom:1px solid var(--border1);
display:flex;align-items:center;padding:0 16px;gap:12px;flex-shrink:0;z-index:500}
.main{display:flex;flex:1;overflow:hidden}
.sidebar{width:320px;background:var(--bg1);border-right:1px solid var(--border0);
display:flex;flex-direction:column;flex-shrink:0;overflow:hidden}
.map-area{flex:1;position:relative}
#map{width:100%;height:100%}
/* ═══════════════════════════════════════════
TOPBAR
═══════════════════════════════════════════ */
.brand{display:flex;align-items:center;gap:10px}
.brand-mark{width:32px;height:32px;border-radius:var(--r8);
background:linear-gradient(135deg,var(--accent),var(--purple));
display:flex;align-items:center;justify-content:center;flex-shrink:0}
.brand-mark svg{width:17px;height:17px;fill:#fff}
.brand-name{font-size:14px;font-weight:700;color:var(--text0);letter-spacing:-.3px;line-height:1.1}
.brand-name span{display:block;font-size:10px;font-weight:400;color:var(--text3);letter-spacing:.2px}
.topbar-div{width:1px;height:26px;background:var(--border1)}
.nav-pills{display:flex;gap:3px}
.nav-pill{display:flex;align-items:center;gap:6px;padding:5px 12px;border-radius:var(--r6);
font-size:12px;font-weight:500;color:var(--text2);transition:background var(--transition),color var(--transition)}
.nav-pill:hover{background:var(--bg3);color:var(--text0)}
.nav-pill.active{background:var(--accent);color:#fff}
.nav-pill.active .pill-badge{background:rgba(255,255,255,.25);color:#fff}
.pill-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
.pill-badge{min-width:17px;height:17px;padding:0 4px;border-radius:var(--rfull);
background:var(--bg4);color:var(--text3);font-size:10px;font-weight:700;
display:inline-flex;align-items:center;justify-content:center}
.topbar-spacer{flex:1}
/* Dashboard toggle */
.dash-btn{display:flex;align-items:center;gap:6px;padding:6px 14px;border-radius:var(--r6);
font-size:12px;font-weight:600;background:var(--purple-s);color:var(--purple);
border:1px solid rgba(155,123,250,.25);transition:background var(--transition)}
.dash-btn:hover{background:rgba(155,123,250,.25)}
.topbar-stat{font-size:11px;color:var(--text3);display:flex;align-items:center;gap:5px}
.stat-dot{width:6px;height:6px;border-radius:50%;background:var(--green)}
/* ═══════════════════════════════════════════
SIDEBAR
═══════════════════════════════════════════ */
.sidebar-head{padding:12px 14px;border-bottom:1px solid var(--border0);
display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
.sidebar-title{font-size:13px;font-weight:700;color:var(--text0)}
.sidebar-sub{font-size:10px;color:var(--text3);margin-top:1px}
.btn-add{display:flex;align-items:center;gap:4px;padding:5px 10px;border-radius:var(--r6);
font-size:11px;font-weight:700;background:var(--accent-glow);color:var(--accent);
border:1px solid rgba(91,156,246,.25);transition:background var(--transition)}
.btn-add:hover{background:rgba(91,156,246,.28)}
.sidebar-body{flex:1;overflow-y:auto;padding:10px}
/* Data Cards */
.dcard{background:var(--bg2);border:1px solid var(--border0);border-radius:var(--r10);
margin-bottom:8px;overflow:hidden;transition:border-color var(--transition)}
.dcard:hover{border-color:var(--border2)}
.dcard.active{border-color:var(--accent)}
.dcard-head{padding:9px 11px;display:flex;align-items:center;gap:8px;cursor:pointer}
.dcard-head:hover{background:var(--bg3)}
.dcard-sym{width:9px;height:9px;flex-shrink:0}
.dcard-sym.circle{border-radius:50%}
.dcard-sym.line{height:4px;border-radius:2px;width:20px}
.dcard-sym.poly{border-radius:2px;width:13px;border:2px solid currentColor;background:transparent}
.dcard-name{font-size:12px;font-weight:600;color:var(--text0);flex:1;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.dcard-attrs{padding:4px 11px 8px;border-top:1px solid var(--border0)}
.attr{display:flex;justify-content:space-between;padding:3px 0;
border-bottom:1px solid var(--border0);font-size:11px}
.attr:last-child{border-bottom:none}
.attr-k{color:var(--text3)}
.attr-v{color:var(--text2);font-weight:500;text-align:right;max-width:60%}
.dcard-acts{display:flex;gap:5px;padding:7px 11px;border-top:1px solid var(--border0);background:var(--bg1)}
/* Badges */
.badge{display:inline-flex;align-items:center;padding:1px 7px;border-radius:var(--rfull);font-size:10px;font-weight:700}
.badge-green {background:var(--green-s);color:var(--green)}
.badge-red {background:var(--red-s);color:var(--red)}
.badge-amber {background:var(--amber-s);color:var(--amber)}
.badge-accent {background:var(--accent-glow);color:var(--accent)}
.badge-purple {background:var(--purple-s);color:var(--purple)}
.badge-teal {background:var(--teal-s);color:var(--teal)}
.badge-orange {background:var(--orange-s);color:var(--orange)}
.badge-pink {background:var(--pink-s);color:var(--pink)}
/* Btn small */
.bsm{padding:3px 9px;border-radius:var(--r4);font-size:10px;font-weight:700;border:1px solid transparent;transition:background var(--transition)}
.bsm-zoom {background:var(--green-s);color:var(--green);border-color:rgba(52,212,126,.2)}
.bsm-zoom:hover{background:rgba(52,212,126,.24)}
.bsm-edit {background:var(--accent-glow);color:var(--accent);border-color:rgba(91,156,246,.2)}
.bsm-edit:hover{background:rgba(91,156,246,.25)}
.bsm-del {background:var(--red-s);color:var(--red);border-color:rgba(240,92,92,.2)}
.bsm-del:hover{background:rgba(240,92,92,.24)}
/* Empty state */
.empty{text-align:center;padding:48px 20px;color:var(--text4)}
.empty svg{margin:0 auto 12px;display:block;opacity:.2}
.empty p{font-size:12px;line-height:1.7}
/* ═══════════════════════════════════════════
MAP OVERLAYS
═══════════════════════════════════════════ */
.map-legend{position:absolute;bottom:20px;left:12px;z-index:800;
background:var(--bg1);border:1px solid var(--border1);border-radius:var(--r10);
padding:10px 13px;min-width:160px;box-shadow:var(--shadow-md)}
.legend-title{font-size:9px;font-weight:700;color:var(--text3);
text-transform:uppercase;letter-spacing:.8px;margin-bottom:8px}
.legend-row{display:flex;align-items:center;gap:8px;margin-bottom:5px;
font-size:11px;color:var(--text2)}
.legend-row:last-child{margin-bottom:0}
.leg-c{width:10px;height:10px;border-radius:50%;flex-shrink:0}
.leg-l{width:20px;height:4px;border-radius:2px;flex-shrink:0}
.leg-p{width:13px;height:11px;border-radius:2px;flex-shrink:0;border:2px solid}
.draw-bar{position:absolute;bottom:20px;left:50%;transform:translateX(-50%);z-index:800;
background:var(--bg1);border:1px solid var(--border2);border-radius:var(--rfull);
padding:7px 16px;font-size:11px;font-weight:600;color:var(--text0);
display:flex;align-items:center;gap:10px;box-shadow:var(--shadow-md);white-space:nowrap}
.draw-bar.hidden{display:none}
.draw-pulse{width:7px;height:7px;border-radius:50%;background:var(--green);flex-shrink:0;
animation:pulse 1.5s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.4;transform:scale(.75)}}
.btn-cancel-draw{background:var(--red-s);color:var(--red);border:1px solid rgba(240,92,92,.2);
border-radius:var(--r4);padding:3px 9px;font-size:10px;font-weight:700;transition:background var(--transition)}
.btn-cancel-draw:hover{background:rgba(240,92,92,.24)}
/* Leaflet popup */
.leaflet-popup-content-wrapper{background:var(--bg2)!important;border:1px solid var(--border2)!important;
border-radius:var(--r10)!important;box-shadow:var(--shadow-lg)!important;color:var(--text0)!important}
.leaflet-popup-tip{background:var(--bg2)!important}
.leaflet-popup-content{margin:0!important;padding:0!important}
.leaflet-popup-close-button{color:var(--text2)!important;font-size:14px!important;top:8px!important;right:10px!important}
.popup-wrap{padding:12px 14px;min-width:220px;max-width:300px}
.popup-title{font-size:13px;font-weight:700;color:var(--text0);margin-bottom:8px;
display:flex;align-items:center;gap:7px}
.popup-sym{width:10px;height:10px;border-radius:50%;flex-shrink:0}
.popup-section{font-size:9px;font-weight:700;color:var(--text4);text-transform:uppercase;
letter-spacing:.8px;margin:8px 0 4px;padding-bottom:4px;border-bottom:1px solid var(--border0)}
.popup-row{display:flex;justify-content:space-between;gap:10px;padding:3px 0;
border-bottom:1px solid var(--border0);font-size:11px}
.popup-row:last-of-type{border-bottom:none}
.popup-k{color:var(--text3)}
.popup-v{color:var(--text1);font-weight:500;text-align:right;max-width:55%}
.popup-acts{display:flex;gap:5px;margin-top:9px;padding-top:8px;border-top:1px solid var(--border0)}
/* ═══════════════════════════════════════════
MODAL
═══════════════════════════════════════════ */
.modal-ov{position:fixed;inset:0;background:rgba(0,0,0,.72);z-index:9999;
display:flex;align-items:center;justify-content:center;backdrop-filter:blur(4px);
animation:fadeIn .15s ease}
.modal-ov.hidden{display:none}
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
.modal{background:var(--bg2);border:1px solid var(--border2);border-radius:var(--r16);
width:520px;max-width:calc(100vw - 24px);max-height:88vh;overflow-y:auto;
box-shadow:var(--shadow-lg);animation:mIn .18s ease}
@keyframes mIn{from{transform:translateY(-10px) scale(.97);opacity:0}to{transform:none;opacity:1}}
.modal-head{padding:16px 18px 12px;border-bottom:1px solid var(--border0);
display:flex;align-items:center;justify-content:space-between;
position:sticky;top:0;background:var(--bg2);z-index:1}
.modal-head-left{display:flex;align-items:center;gap:10px}
.modal-icon{width:30px;height:30px;border-radius:var(--r8);display:flex;align-items:center;justify-content:center;font-size:15px}
.modal-title{font-size:14px;font-weight:700;color:var(--text0)}
.modal-sub{font-size:10px;color:var(--text3);margin-top:1px}
.modal-close{width:26px;height:26px;border-radius:var(--r6);background:var(--bg3);
border:1px solid var(--border1);color:var(--text2);font-size:12px;
display:flex;align-items:center;justify-content:center;transition:background var(--transition)}
.modal-close:hover{background:var(--bg4);color:var(--text0)}
.modal-body{padding:16px 18px}
.modal-foot{padding:12px 18px;border-top:1px solid var(--border0);
display:flex;gap:8px;justify-content:flex-end}
/* Form */
.form-sect{font-size:9px;font-weight:700;color:var(--text4);text-transform:uppercase;
letter-spacing:.8px;margin:14px 0 8px;padding-bottom:6px;border-bottom:1px solid var(--border0)}
.form-sect:first-child{margin-top:0}
.form-row{display:grid;gap:10px;margin-bottom:10px}
.form-row.cols2{grid-template-columns:1fr 1fr}
.form-row.cols3{grid-template-columns:1fr 1fr 1fr}
.fg{display:flex;flex-direction:column;gap:5px}
.flabel{font-size:11px;font-weight:600;color:var(--text2);letter-spacing:.2px}
.flabel .req{color:var(--red);margin-left:2px}
.finput{padding:8px 10px;background:var(--bg3);border:1px solid var(--border1);
border-radius:var(--r6);color:var(--text0);font-size:12px;font-family:var(--font);
transition:border-color var(--transition),box-shadow var(--transition);width:100%}
.finput:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(91,156,246,.12)}
.finput:disabled{opacity:.45;cursor:not-allowed}
.finput[readonly]{background:var(--bg4);color:var(--text2);cursor:default}
select.finput{cursor:pointer}
select.finput option{background:var(--bg3)}
textarea.finput{resize:vertical;min-height:64px}
.info-box{background:var(--bg4);border:1px solid var(--border1);border-radius:var(--r6);
padding:8px 10px;margin-bottom:10px;font-size:11px;color:var(--text2);line-height:1.6}
.info-box strong{color:var(--text0)}
.info-box.warn{border-color:rgba(240,160,48,.3);background:var(--amber-s)}
/* Radio toggle */
.rtoggle{display:flex;gap:6px}
.ropt{flex:1;display:flex;align-items:center;justify-content:center;gap:5px;
padding:8px;border-radius:var(--r6);border:1px solid var(--border1);cursor:pointer;
font-size:11px;font-weight:600;transition:all var(--transition);color:var(--text3);
background:var(--bg3);user-select:none}
.ropt input{display:none}
.ropt.yes{border-color:rgba(52,212,126,.35);color:var(--green);background:var(--green-s)}
.ropt.no {border-color:rgba(240,92,92,.35);color:var(--red);background:var(--red-s)}
/* Buttons */
.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;
padding:8px 18px;border-radius:var(--r6);font-size:13px;font-weight:600;
font-family:var(--font);transition:background var(--transition)}
.btn-primary{background:var(--accent);color:#fff}
.btn-primary:hover{background:var(--accent2)}
.btn-ghost{background:var(--bg3);color:var(--text2);border:1px solid var(--border1)}
.btn-ghost:hover{background:var(--bg4);color:var(--text0)}
.hidden{display:none!important}
/* ═══════════════════════════════════════════
DASHBOARD PANEL
═══════════════════════════════════════════ */
.dash-panel{position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:8000;
display:flex;align-items:stretch;justify-content:flex-end;backdrop-filter:blur(4px);
animation:fadeIn .15s ease}
.dash-panel.hidden{display:none}
.dash-inner{width:680px;max-width:95vw;background:var(--bg1);border-left:1px solid var(--border1);
display:flex;flex-direction:column;overflow:hidden}
.dash-head{padding:18px 20px;border-bottom:1px solid var(--border0);
display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
.dash-head-title{font-size:16px;font-weight:700;color:var(--text0)}
.dash-head-sub{font-size:11px;color:var(--text3);margin-top:2px}
.dash-body{flex:1;overflow-y:auto;padding:18px 20px}
/* Stat cards */
.stat-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:18px}
.stat-card{background:var(--bg2);border:1px solid var(--border0);border-radius:var(--r10);
padding:12px 14px;position:relative;overflow:hidden}
.stat-card::before{content:'';position:absolute;top:0;left:0;right:0;height:2px}
.stat-card.c-accent::before{background:var(--accent)}
.stat-card.c-green::before{background:var(--green)}
.stat-card.c-amber::before{background:var(--amber)}
.stat-card.c-purple::before{background:var(--purple)}
.stat-card.c-red::before{background:var(--red)}
.stat-card.c-teal::before{background:var(--teal)}
.sc-val{font-size:26px;font-weight:700;color:var(--text0);font-family:var(--mono);line-height:1}
.sc-label{font-size:10px;color:var(--text3);margin-top:4px}
.sc-sub{font-size:11px;color:var(--text2);margin-top:6px}
/* Dash sections */
.dash-sect{margin-bottom:20px}
.dash-sect-title{font-size:11px;font-weight:700;color:var(--text2);
text-transform:uppercase;letter-spacing:.6px;margin-bottom:10px;
padding-bottom:6px;border-bottom:1px solid var(--border0)}
.risk-table{width:100%;border-collapse:collapse;font-size:11px}
.risk-table th{text-align:left;padding:6px 10px;color:var(--text3);font-weight:600;
border-bottom:1px solid var(--border0);font-size:10px}
.risk-table td{padding:7px 10px;border-bottom:1px solid var(--border0);color:var(--text1)}
.risk-table tr:last-child td{border-bottom:none}
.risk-table tr:hover td{background:var(--bg2)}
.risk-bar-wrap{display:flex;align-items:center;gap:7px}
.risk-bar{height:5px;border-radius:3px;background:var(--bg4);flex:1}
.risk-bar-fill{height:100%;border-radius:3px;transition:width .3s ease}
.prog-row{display:flex;align-items:center;justify-content:space-between;
padding:6px 0;border-bottom:1px solid var(--border0);font-size:11px}
.prog-row:last-child{border-bottom:none}
.prog-name{color:var(--text1)}
.prog-count{font-family:var(--mono);font-weight:600;color:var(--text0)}
.intervensi-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.intervensi-card{background:var(--bg2);border:1px solid var(--border0);border-radius:var(--r8);padding:10px 12px}
.ic-label{font-size:10px;color:var(--text3);margin-bottom:4px}
.ic-val{font-size:13px;font-weight:700;color:var(--text0)}
.ic-sub{font-size:10px;color:var(--text2);margin-top:3px}
/* Toast */
.toasts{position:fixed;bottom:22px;right:22px;z-index:10000;display:flex;flex-direction:column;gap:7px;pointer-events:none}
.toast{background:var(--bg2);border:1px solid var(--border2);border-radius:var(--r10);
padding:10px 14px;font-size:12px;color:var(--text0);box-shadow:var(--shadow-lg);
display:flex;align-items:center;gap:9px;min-width:240px;pointer-events:all;
animation:tIn .2s ease}
.toast.success{border-left:3px solid var(--green)}
.toast.error{border-left:3px solid var(--red)}
.toast.info{border-left:3px solid var(--accent)}
@keyframes tIn{from{transform:translateX(110%);opacity:0}to{transform:none;opacity:1}}
@keyframes tOut{from{transform:none;opacity:1}to{transform:translateX(110%);opacity:0}}
.toast.out{animation:tOut .2s ease forwards}
/* ═══════════════════════════════════════════
BANSOS DISTRIBUTION SYSTEM
═══════════════════════════════════════════ */
/* History table (multi-entry) inside modal */
.history-table{width:100%;border-collapse:collapse;margin-top:6px;font-size:11px}
.history-table th{text-align:left;padding:5px 8px;color:var(--text3);font-weight:600;
font-size:10px;border-bottom:1px solid var(--border1);background:var(--bg3)}
.history-table td{padding:6px 8px;border-bottom:1px solid var(--border0);color:var(--text1)}
.history-table tr:last-child td{border-bottom:none}
.history-table tr:hover td{background:var(--bg3)}
.history-table .status-aktif{color:var(--green);font-weight:700}
.history-table .status-berhenti{color:var(--red);font-weight:700}
.history-table .status-proses{color:var(--amber);font-weight:700}
/* Dynamic add-row button */
.btn-add-row{display:inline-flex;align-items:center;gap:4px;margin-top:6px;
padding:4px 10px;border-radius:var(--r4);font-size:10px;font-weight:700;
background:var(--green-s);color:var(--green);border:1px solid rgba(52,212,126,.25);
cursor:pointer;transition:background var(--transition)}
.btn-add-row:hover{background:rgba(52,212,126,.24)}
.btn-del-row{padding:2px 7px;border-radius:var(--r4);font-size:10px;font-weight:700;
background:var(--red-s);color:var(--red);border:1px solid rgba(240,92,92,.2);
cursor:pointer;transition:background var(--transition)}
.btn-del-row:hover{background:rgba(240,92,92,.24)}
/* Radius coverage indicator in popup */
.coverage-badge{display:inline-flex;align-items:center;gap:5px;padding:3px 9px;
border-radius:var(--rfull);font-size:10px;font-weight:700;margin-top:4px}
.coverage-badge.covered{background:var(--green-s);color:var(--green);border:1px solid rgba(52,212,126,.25)}
.coverage-badge.uncovered{background:var(--red-s);color:var(--red);border:1px solid rgba(240,92,92,.25)}
.coverage-badge svg{width:10px;height:10px;fill:currentColor;flex-shrink:0}
/* Radius circle legend */
.legend-radius{display:flex;align-items:center;gap:7px;margin-top:6px;
padding-top:6px;border-top:1px solid var(--border0);font-size:10px;color:var(--text3)}
.leg-radius-sym{width:14px;height:14px;border-radius:50%;border:2px dashed var(--teal);
background:rgba(46,196,182,.08);flex-shrink:0}
/* Dashboard distribution tab */
.dash-tab-bar{display:flex;gap:3px;margin-bottom:14px;background:var(--bg2);
border:1px solid var(--border0);border-radius:var(--r8);padding:3px}
.dash-tab{flex:1;padding:6px 12px;border-radius:var(--r6);font-size:11px;font-weight:600;
color:var(--text3);cursor:pointer;transition:all var(--transition);text-align:center}
.dash-tab.active{background:var(--accent);color:#fff}
.dash-tab:not(.active):hover{background:var(--bg3);color:var(--text1)}
/* Coverage summary cards */
.coverage-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:16px}
.coverage-card{background:var(--bg2);border:1px solid var(--border0);border-radius:var(--r10);
padding:12px 14px;display:flex;align-items:center;gap:12px}
.cc-icon{width:34px;height:34px;border-radius:var(--r8);display:flex;align-items:center;
justify-content:center;font-size:16px;flex-shrink:0}
.cc-icon.green{background:var(--green-s)}
.cc-icon.red{background:var(--red-s)}
.cc-icon.amber{background:var(--amber-s)}
.cc-icon.teal{background:var(--teal-s)}
.cc-info{min-width:0}
.cc-val{font-size:20px;font-weight:700;color:var(--text0);font-family:var(--mono)}
.cc-label{font-size:10px;color:var(--text3);margin-top:1px}
/* Hub assignment table */
.hub-table{width:100%;border-collapse:collapse;font-size:11px}
.hub-table th{text-align:left;padding:7px 10px;color:var(--text3);font-weight:600;
font-size:10px;border-bottom:1px solid var(--border1);background:var(--bg2);
position:sticky;top:0}
.hub-table td{padding:7px 10px;border-bottom:1px solid var(--border0);color:var(--text1)}
.hub-table tr:last-child td{border-bottom:none}
.hub-table tr:hover td{background:var(--bg2)}
.hub-name-cell{display:flex;align-items:center;gap:7px}
.hub-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.hub-count-bar{display:flex;align-items:center;gap:7px}
.hub-bar{height:5px;border-radius:3px;background:var(--bg4);flex:1;min-width:60px}
.hub-bar-fill{height:100%;border-radius:3px;transition:width .4s ease}
/* Multi-section form tab inside modal */
.form-tabs{display:flex;gap:2px;background:var(--bg3);border-radius:var(--r6);
padding:3px;margin-bottom:14px}
.form-tab{flex:1;padding:5px 8px;border-radius:var(--r4);font-size:10px;font-weight:700;
color:var(--text3);cursor:pointer;transition:all var(--transition);text-align:center}
.form-tab.active{background:var(--accent);color:#fff}
.form-tab:not(.active):hover{background:var(--bg4);color:var(--text1)}
.form-section-panel{display:none}
.form-section-panel.active{display:block}
/* Pendidikan tag badges */
.pendidikan-grid{display:flex;flex-wrap:wrap;gap:5px;margin-top:4px}
.pddk-tag{display:inline-flex;align-items:center;gap:4px;padding:3px 8px;
border-radius:var(--rfull);font-size:10px;font-weight:600;cursor:pointer;
border:1px solid var(--border1);background:var(--bg3);color:var(--text3);
transition:all var(--transition);user-select:none}
.pddk-tag.selected{background:var(--accent-glow);border-color:rgba(91,156,246,.4);color:var(--accent)}
/* Penyakit checklist */
.penyakit-grid{display:grid;grid-template-columns:1fr 1fr;gap:5px;margin-top:4px}
.penyakit-check{display:flex;align-items:center;gap:6px;padding:5px 8px;
border-radius:var(--r6);border:1px solid var(--border0);background:var(--bg3);
cursor:pointer;font-size:11px;color:var(--text2);transition:all var(--transition);user-select:none}
.penyakit-check input{accent-color:var(--red);width:12px;height:12px}
.penyakit-check.checked{border-color:rgba(240,92,92,.35);background:var(--red-s);color:var(--red)}
/* Badge pink (already in root vars) */
.badge-pink{background:var(--pink-s);color:var(--pink)}
+133
View File
@@ -0,0 +1,133 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>WebGIS Sistem Distribusi Bansos Berbasis Rumah Ibadah</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<link rel="stylesheet" href="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.css"/>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<div class="app">
<!-- ═══ TOPBAR ═══ -->
<header class="topbar">
<div class="brand">
<div class="brand-mark">
<svg viewBox="0 0 24 24"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/></svg>
</div>
<div class="brand-name">WebGIS Distribusi Bansos<span>Berbasis Rumah Ibadah — Radius 300m</span></div>
</div>
<div class="topbar-div"></div>
<nav class="nav-pills" id="nav-pills">
<button class="nav-pill active" data-layer="spbu">
<span class="pill-dot" style="background:#34d47e"></span>SPBU
<span class="pill-badge" id="badge-spbu">0</span>
</button>
<button class="nav-pill" data-layer="jalan">
<span class="pill-dot" style="background:#f0a030"></span>Jalan
<span class="pill-badge" id="badge-jalan">0</span>
</button>
<button class="nav-pill" data-layer="parsil">
<span class="pill-dot" style="background:#9b7bfa"></span>Parsil
<span class="pill-badge" id="badge-parsil">0</span>
</button>
</nav>
<div class="topbar-stat"><span class="stat-dot"></span><span id="total-count">0 fitur</span></div>
</header>
<!-- ═══ MAIN ═══ -->
<div class="main">
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-head">
<div>
<div class="sidebar-title" id="sb-title">Data SPBU</div>
<div class="sidebar-sub" id="sb-sub">Stasiun Bahan Bakar Umum</div>
</div>
<button class="btn-add" id="btn-add">
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>Tambah
</button>
</div>
<div class="sidebar-body" id="sb-list"></div>
</aside>
<!-- Map -->
<div class="map-area">
<div id="map"></div>
<!-- Draw indicator -->
<div class="draw-bar hidden" id="draw-bar">
<span class="draw-pulse"></span>
<span id="draw-text">Mode gambar aktif</span>
<button class="btn-cancel-draw" id="btn-cancel">Batal</button>
</div>
<!-- Legend -->
<div class="map-legend" id="legend">
<div class="legend-title">Legenda</div>
<div id="legend-items"></div>
</div>
</div>
</div>
</div>
<!-- ═══ MODAL ═══ -->
<div class="modal-ov hidden" id="modal-ov">
<div class="modal">
<div class="modal-head">
<div class="modal-head-left">
<div class="modal-icon" id="modal-icon">📍</div>
<div>
<div class="modal-title" id="modal-title">Form Data</div>
<div class="modal-sub" id="modal-sub"></div>
</div>
</div>
<button class="modal-close" id="modal-close"></button>
</div>
<div class="modal-body" id="modal-body"></div>
<div class="modal-foot">
<button class="btn btn-ghost" id="modal-cancel">Batal</button>
<button class="btn btn-primary" id="modal-save">Simpan Data</button>
</div>
</div>
</div>
<!-- ═══ DASHBOARD ═══ -->
<div class="dash-panel hidden" id="dash-panel">
<div class="dash-inner">
<div class="dash-head">
<div>
<div class="dash-head-title">Dashboard Analitik Kemiskinan</div>
<div class="dash-head-sub">Ringkasan & analisis data geospasial untuk pengambilan kebijakan</div>
</div>
<button class="modal-close" onclick="Dashboard.toggle()"></button>
</div>
<div class="dash-body" id="dash-body"></div>
</div>
</div>
<!-- ═══ TOASTS ═══ -->
<div class="toasts" id="toasts"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.js"></script>
<script src="js/utils/helpers.js"></script>
<script src="js/utils/store.js"></script>
<script src="js/utils/toast.js"></script>
<script src="js/config/config.js"></script>
<script src="js/components/forms.js"></script>
<script src="js/components/popup.js"></script>
<script src="js/components/card.js"></script>
<script src="js/components/legend.js"></script>
<script src="js/modules/draw.js"></script>
<script src="js/modules/modal.js"></script>
<script src="js/modules/sidebar.js"></script>
<script src="js/modules/dashboard.js"></script>
<script src="js/modules/map.js"></script>
<script src="js/app.js"></script>
</body>
</html>
+71
View File
@@ -0,0 +1,71 @@
/* ═══════════════════════════════════════════════════════════
14. APP ROOT
═══════════════════════════════════════════════════════════ */
const App = {
state:{ activeLayer:'spbu' },
_groups:{},
async init(){
// Map
this._map=L.map('map',{zoomControl:false}).setView([-0.056031,109.348641],13);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',{
attribution:'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',maxZoom:19
}).addTo(this._map);
L.control.zoom({position:'topright'}).addTo(this._map);
['spbu','jalan','parsil'].forEach(k=>{
this._groups[k]=L.layerGroup().addTo(this._map);
});
// Expose map for Draw
App.map={
instance: this._map,
zoomTo:(ln,id)=>{
const r=Store.find(ln,id); if(!r) return;
if(r.lat!=null) this._map.setView([r.lat,r.lng],17);
else if(r.coords?.length) try{this._map.fitBounds(L.latLngBounds(r.coords),{padding:[40,40]})}catch{}
highlightCard(id);
}
};
Draw.init(this._map);
// Nav
document.getElementById('nav-pills').addEventListener('click',e=>{
const pill=e.target.closest('.nav-pill'); if(!pill) return;
Draw.cancel();
this.state.activeLayer=pill.dataset.layer;
document.querySelectorAll('.nav-pill').forEach(p=>p.classList.remove('active'));
pill.classList.add('active');
renderSidebar(this.state.activeLayer);
renderLegend(this.state.activeLayer);
});
// Add btn
document.getElementById('btn-add').addEventListener('click',()=>Draw.start(this.state.activeLayer));
// Modal
document.getElementById('modal-save').addEventListener('click', Modal.submit);
document.getElementById('modal-cancel').addEventListener('click', Modal.close);
document.getElementById('modal-close').addEventListener('click', Modal.close);
document.getElementById('modal-ov').addEventListener('click',e=>{ if(e.target===e.currentTarget) Modal.close(); });
await Store.load();
this.refresh();
},
crud:{
edit(ln,id){ App.state.activeLayer=ln; Modal.openEdit(ln,id); },
async delete(ln,id){
if(!confirm('Yakin hapus data ini?')) return;
await Store.remove(ln,id); App._map.closePopup();
App.refresh(); Toast.info('Data dihapus');
},
},
refresh(){
renderMap(); renderSidebar(this.state.activeLayer);
renderLegend(this.state.activeLayer); updateBadges();
},
};
document.addEventListener('DOMContentLoaded',()=>App.init());
+63
View File
@@ -0,0 +1,63 @@
/* ═══════════════════════════════════════════════════════════
7. CARD BUILDERS (sidebar)
═══════════════════════════════════════════════════════════ */
const Card = {
_acts:(ln,id)=>`<div class="dcard-acts">
<button class="bsm bsm-zoom" onclick="App.map.zoomTo('${ln}','${id}')">Zoom</button>
<button class="bsm bsm-edit" onclick="App.crud.edit('${ln}','${id}')">Edit</button>
<button class="bsm bsm-del" onclick="App.crud.delete('${ln}','${id}')">Hapus</button></div>`,
spbu(r){ const clr=CFG.spbu.color(r),bdg=CFG.spbu.badge(r);
return `<div class="dcard" id="card-${r.id}">
<div class="dcard-head" onclick="App.map.zoomTo('spbu','${r.id}')">
<span class="dcard-sym circle" style="background:${clr}"></span>
<span class="dcard-name">${H.esc(r.nama)}</span><span class="badge ${bdg.c}">${bdg.t}</span></div>
<div class="dcard-attrs">
<div class="attr"><span class="attr-k">WA</span><span class="attr-v">${H.esc(r.wa||'-')}</span></div>
<div class="attr"><span class="attr-k">BBM</span><span class="attr-v">${H.esc(r.jenis_bbm||'-')}</span></div>
</div>${this._acts('spbu',r.id)}</div>`; },
jalan(r){ const clr=CFG.jalan.color(r),bdg=CFG.jalan.badge(r);
return `<div class="dcard" id="card-${r.id}">
<div class="dcard-head" onclick="App.map.zoomTo('jalan','${r.id}')">
<span class="dcard-sym line" style="background:${clr}"></span>
<span class="dcard-name">${H.esc(r.nama)}</span><span class="badge ${bdg.c}">${bdg.t}</span></div>
<div class="dcard-attrs">
<div class="attr"><span class="attr-k">Panjang</span><span class="attr-v">${H.fmDist(r.panjang)}</span></div>
<div class="attr"><span class="attr-k">Kondisi</span><span class="attr-v">${H.esc(r.kondisi||'-')}</span></div>
</div>${this._acts('jalan',r.id)}</div>`; },
parsil(r){ const clr=CFG.parsil.color(r),bdg=CFG.parsil.badge(r);
return `<div class="dcard" id="card-${r.id}">
<div class="dcard-head" onclick="App.map.zoomTo('parsil','${r.id}')">
<span class="dcard-sym poly" style="color:${clr};height:9px"></span>
<span class="dcard-name">${H.esc(r.nama)}</span><span class="badge ${bdg.c}">${bdg.t}</span></div>
<div class="dcard-attrs">
<div class="attr"><span class="attr-k">Luas</span><span class="attr-v">${H.fmArea(r.luas)}</span></div>
<div class="attr"><span class="attr-k">Sengketa</span><span class="attr-v">${H.esc(r.sengketa||'-')}</span></div>
</div>${this._acts('parsil',r.id)}</div>`; },
warga(r){ const clr=CFG.warga.color(r),bdg=CFG.warga.badge(r);
const pendapatan=r.pendapatan?'Rp '+parseInt(r.pendapatan).toLocaleString('id-ID'):'-';
const histCount=r.history_bansos?.length||0;
return `<div class="dcard" id="card-${r.id}">
<div class="dcard-head" onclick="App.map.zoomTo('warga','${r.id}')">
<span class="dcard-sym circle" style="background:${clr}"></span>
<span class="dcard-name">${H.esc(r.nama)}</span><span class="badge ${bdg.c}">${bdg.t}</span></div>
<div class="dcard-attrs">
<div class="attr"><span class="attr-k">Anggota</span><span class="attr-v">${r.jml_anggota||'-'} jiwa</span></div>
<div class="attr"><span class="attr-k">Pendapatan</span><span class="attr-v">${pendapatan}</span></div>
<div class="attr"><span class="attr-k">Bansos</span><span class="attr-v">${H.esc(r.bansos||'-')}</span></div>
<div class="attr"><span class="attr-k">Riwayat</span><span class="attr-v">${histCount} program</span></div>
</div>${this._acts('warga',r.id)}</div>`; },
rumahIbadah(r){ const clr=CFG.rumahIbadah.color(r),bdg=CFG.rumahIbadah.badge(r);
return `<div class="dcard" id="card-${r.id}">
<div class="dcard-head" onclick="App.map.zoomTo('rumahIbadah','${r.id}')">
<span class="dcard-sym circle" style="background:${clr}"></span>
<span class="dcard-name">${H.esc(r.nama)}</span><span class="badge ${bdg.c}">${bdg.t}</span></div>
<div class="dcard-attrs">
<div class="attr"><span class="attr-k">Ketua</span><span class="attr-v">${H.esc(r.ketua||'-')}</span></div>
<div class="attr"><span class="attr-k">Binaan</span><span class="attr-v">${r.jml_binaan||'-'} KK</span></div>
</div>${this._acts('rumahIbadah',r.id)}</div>`; },
};
+361
View File
@@ -0,0 +1,361 @@
/* ═══════════════════════════════════════════════════════════
5. FORM BUILDERS
═══════════════════════════════════════════════════════════ */
const Forms = {
spbu(rec, latlng){
const lat=latlng?latlng.lat.toFixed(6):(rec?.lat?.toFixed(6)||'');
const lng=latlng?latlng.lng.toFixed(6):(rec?.lng?.toFixed(6)||'');
const v=f=>rec?H.esc(rec[f]||''):'';
return `
<div class="info-box">📍 Koordinat: <strong>${lat}, ${lng}</strong></div>
<div class="form-sect">Identitas & Operasional</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Nama SPBU<span class="req">*</span></label>
<input class="finput" id="f-nama" placeholder="cth: SPBU 64.754.01" value="${v('nama')}"/></div>
<div class="fg"><label class="flabel">No. WhatsApp</label>
<input class="finput" id="f-wa" placeholder="081234..." value="${v('wa')}"/></div>
</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Jenis BBM Tersedia</label>
<select class="finput" id="f-jenis_bbm">
${['Pertalite & Pertamax','Pertalite Saja','Pertamax Series','Solar & Pertamina Dex','Semua Jenis'].map(j=>`<option ${v('jenis_bbm')===j?'selected':''}>${j}</option>`).join('')}
</select></div>
<div class="fg"><label class="flabel">Buka 24 Jam?</label>
<div class="rtoggle" id="rtog">
<label class="ropt ${rec?.buka24!==false?'yes':''}" data-v="true">
<input type="radio" name="buka24" value="true" ${rec?.buka24!==false?'checked':''}/>✓ Ya
</label>
<label class="ropt ${rec?.buka24===false?'no':''}" data-v="false">
<input type="radio" name="buka24" value="false" ${rec?.buka24===false?'checked':''}/>✗ Tidak
</label>
</div></div>
</div>
<div class="form-sect">Aksesibilitas & Sosial</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Pelayanan Disabilitas</label>
<select class="finput" id="f-disabilitas">
${['Ada','Tidak Ada','Dalam Proses'].map(j=>`<option ${v('disabilitas')===j?'selected':''}>${j}</option>`).join('')}
</select></div>
<div class="fg"><label class="flabel">Harga BBM Subsidi</label>
<select class="finput" id="f-harga_bbm">
${['Sesuai HET','Di Atas HET','Tidak Menjual Subsidi'].map(j=>`<option ${v('harga_bbm')===j?'selected':''}>${j}</option>`).join('')}
</select></div>
</div>
<div class="form-sect">Catatan Lapangan</div>
<div class="fg"><label class="flabel">Catatan / Keterangan Tambahan</label>
<textarea class="finput" id="f-catatan" placeholder="Kondisi antrean, ketersediaan stok, dll...">${v('catatan')}</textarea></div>`;
},
jalan(rec, len){
const pj=len??rec?.panjang??0; const v=f=>rec?H.esc(rec[f]||''):'';
return `
<div class="info-box">📏 Panjang terukur otomatis: <strong>${H.fmDist(pj)}</strong></div>
<input type="hidden" id="f-panjang" value="${pj}"/>
<div class="form-sect">Identitas Ruas Jalan</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Nama Ruas Jalan<span class="req">*</span></label>
<input class="finput" id="f-nama" placeholder="cth: Jl. H. Rais A Rahman" value="${v('nama')}"/></div>
<div class="fg"><label class="flabel">Status Jalan<span class="req">*</span></label>
<select class="finput" id="f-status">
${CFG.jalan.statuses.map(s=>`<option ${v('status')===s?'selected':''}>${s}</option>`).join('')}
</select></div>
</div>
<div class="form-sect">Kondisi & Infrastruktur</div>
<div class="form-row cols3">
<div class="fg"><label class="flabel">Kondisi Jalan</label>
<select class="finput" id="f-kondisi">
${CFG.jalan.kondisi.map(k=>`<option ${v('kondisi')===k?'selected':''}>${k}</option>`).join('')}
</select></div>
<div class="fg"><label class="flabel">Tahun Perbaikan Terakhir</label>
<input class="finput" id="f-tahun_perbaikan" type="number" min="2000" max="2030" placeholder="2023" value="${v('tahun_perbaikan')}"/></div>
<div class="fg"><label class="flabel">Lebar Jalan (meter)</label>
<input class="finput" id="f-lebar" type="number" min="1" max="50" placeholder="4" value="${v('lebar')}"/></div>
</div>
<div class="form-sect">Dampak Sosial-Ekonomi</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Dilalui Angkutan Umum</label>
<select class="finput" id="f-angkutan">
${['Ya','Tidak'].map(k=>`<option ${v('angkutan')===k?'selected':''}>${k}</option>`).join('')}
</select></div>
<div class="fg"><label class="flabel">Akses ke Fasilitas Publik</label>
<select class="finput" id="f-akses_publik">
${['Pasar','RS/Puskesmas','Sekolah','Kantor Pemerintah','Tidak Ada'].map(k=>`<option ${v('akses_publik')===k?'selected':''}>${k}</option>`).join('')}
</select></div>
</div>
<div class="fg"><label class="flabel">Catatan Kondisi</label>
<textarea class="finput" id="f-catatan" placeholder="Kondisi berlubang, banjir saat hujan, dll...">${v('catatan')}</textarea></div>`;
},
parsil(rec, area){
const ls=area??rec?.luas??0; const v=f=>rec?H.esc(rec[f]||''):'';
return `
<div class="info-box">📐 Luas terukur otomatis: <strong>${H.fmArea(ls)}</strong></div>
<input type="hidden" id="f-luas" value="${ls}"/>
<div class="form-sect">Identitas Parsil</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Nama / ID Parsil<span class="req">*</span></label>
<input class="finput" id="f-nama" placeholder="cth: Kavling A-01" value="${v('nama')}"/></div>
<div class="fg"><label class="flabel">Nomor Induk Bidang (NIB)</label>
<input class="finput" id="f-nib" placeholder="14.02.01.001" value="${v('nib')}"/></div>
</div>
<div class="form-sect">Status Kepemilikan</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Status Sertifikat<span class="req">*</span></label>
<select class="finput" id="f-status">
${CFG.parsil.statuses.map(s=>`<option ${v('status')===s?'selected':''}>${s}</option>`).join('')}
</select></div>
<div class="fg"><label class="flabel">Sengketa / Masalah Hukum</label>
<select class="finput" id="f-sengketa">
${['Tidak Ada','Ada Sengketa','Dalam Proses PTSL','Klaim Pihak Lain'].map(k=>`<option ${v('sengketa')===k?'selected':''}>${k}</option>`).join('')}
</select></div>
</div>
<div class="form-sect">Penilaian & Pemanfaatan</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">NJOP (Rp/m²)</label>
<input class="finput" id="f-njop" type="number" placeholder="500000" value="${v('njop')}"/></div>
<div class="fg"><label class="flabel">Penggunaan Lahan</label>
<select class="finput" id="f-penggunaan">
${['Rumah Tinggal','Lahan Kosong','Pertanian','Usaha/Warung','Fasilitas Umum','Lainnya'].map(k=>`<option ${v('penggunaan')===k?'selected':''}>${k}</option>`).join('')}
</select></div>
</div>
<div class="fg"><label class="flabel">Catatan Tambahan</label>
<textarea class="finput" id="f-catatan" placeholder="Kondisi, penghuni, riwayat...">${v('catatan')}</textarea></div>`;
},
warga(rec, latlng){
const lat=latlng?latlng.lat.toFixed(6):(rec?.lat?.toFixed(6)||'');
const lng=latlng?latlng.lng.toFixed(6):(rec?.lng?.toFixed(6)||'');
const v=f=>rec?H.esc(rec[f]||''):'';
const c=v=>rec?.penyakit?.includes(v)?'checked':'';
const hist=rec?.history_bansos||[];
const ri = Store.all('rumahIbadah');
const hubOpts = ['Belum ditugaskan', ...ri.map(r=>r.nama)].map(opt=>`<option ${v('hub_ri')===opt?'selected':''}>${opt}</option>`).join('');
return `
<div class="info-box warn">⚠️ Data warga bersifat sensitif. Pastikan survei akurat.</div>
<div class="info-box">📍 Koordinat: <strong>${lat}, ${lng}</strong></div>
<div class="form-tabs">
<div class="form-tab active" data-panel="kk">Data KK</div>
<div class="form-tab" data-panel="eko">Ekonomi</div>
<div class="form-tab" data-panel="pddk">Pddk</div>
<div class="form-tab" data-panel="kes">Kes</div>
<div class="form-tab" data-panel="hist">Bansos</div>
</div>
<!-- TAB: Data KK -->
<div class="form-section-panel active" id="panel-kk">
<div class="form-row cols2">
<div class="fg"><label class="flabel">Nama KK<span class="req">*</span></label>
<input class="finput" id="f-nama" placeholder="Nama KTP" value="${v('nama')}"/></div>
<div class="fg"><label class="flabel">NIK</label>
<input class="finput" id="f-nik" placeholder="3471..." maxlength="16" value="${v('nik')}"/></div>
</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">No. KK</label>
<input class="finput" id="f-no_kk" value="${v('no_kk')}"/></div>
<div class="fg"><label class="flabel">WA/HP</label>
<input class="finput" id="f-wa" value="${v('wa')}"/></div>
</div>
<div class="form-row cols3">
<div class="fg"><label class="flabel">Tingkat Kemiskinan</label>
<select class="finput" id="f-tingkat_kemiskinan">
${CFG.warga.tingkat.map(t=>`<option ${v('tingkat_kemiskinan')===t?'selected':''}>${t}</option>`).join('')}
</select></div>
<div class="fg"><label class="flabel">Jml Anggota</label>
<input class="finput" id="f-jml_anggota" type="number" placeholder="4" value="${v('jml_anggota')}"/></div>
<div class="fg"><label class="flabel">Jml Lansia</label>
<input class="finput" id="f-jml_lansia" type="number" placeholder="0" value="${v('jml_lansia')}"/></div>
</div>
<div class="fg"><label class="flabel">Alamat</label>
<textarea class="finput" id="f-alamat" style="min-height:40px">${v('alamat')}</textarea></div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Penerima Bansos</label>
<select class="finput" id="f-bansos">
${CFG.warga.bantuan.map(b=>`<option ${v('bansos')===b?'selected':''}>${b}</option>`).join('')}
</select></div>
<div class="fg"><label class="flabel">Hub Rumah Ibadah</label>
<select class="finput" id="f-hub_ri">${hubOpts}</select></div>
</div>
</div>
<!-- TAB: Ekonomi -->
<div class="form-section-panel" id="panel-eko">
<div class="form-row cols2">
<div class="fg"><label class="flabel">Pendapatan (Rp/Bln)</label>
<input class="finput" id="f-pendapatan" type="number" value="${v('pendapatan')}"/></div>
<div class="fg"><label class="flabel">Pekerjaan KK</label>
<select class="finput" id="f-pekerjaan">
${CFG.warga.pekerjaan.map(p=>`<option ${v('pekerjaan')===p?'selected':''}>${p}</option>`).join('')}
</select></div>
</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Kondisi Rumah</label>
<select class="finput" id="f-kondisi_rumah">
${CFG.warga.kondisiRumah.map(k=>`<option ${v('kondisi_rumah')===k?'selected':''}>${k}</option>`).join('')}
</select></div>
<div class="fg"><label class="flabel">Aset (Tanah/Kendaraan)</label>
<select class="finput" id="f-kepemilikan_aset">
${['Tidak Ada','Motor Tua','Motor Baru','Tanah','Mobil'].map(j=>`<option ${v('kepemilikan_aset')===j?'selected':''}>${j}</option>`).join('')}
</select></div>
</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Air Bersih</label>
<select class="finput" id="f-air_bersih">
${['PDAM','Sumur','Sungai/Tadah Hujan'].map(j=>`<option ${v('air_bersih')===j?'selected':''}>${j}</option>`).join('')}
</select></div>
<div class="fg"><label class="flabel">Listrik</label>
<select class="finput" id="f-listrik">
${['Sendiri (>900W)','Subsidi (450/900W)','Numpang/Tidak Ada'].map(j=>`<option ${v('listrik')===j?'selected':''}>${j}</option>`).join('')}
</select></div>
</div>
</div>
<!-- TAB: Pendidikan -->
<div class="form-section-panel" id="panel-pddk">
<div class="form-row cols2">
<div class="fg"><label class="flabel">Pendidikan Terakhir KK</label>
<select class="finput" id="f-pendidikan_kk">
${CFG.warga.tingkatPendidikan.map(j=>`<option ${v('pendidikan_kk')===j?'selected':''}>${j}</option>`).join('')}
</select></div>
<div class="fg"><label class="flabel">Anak Bersekolah</label>
<input class="finput" id="f-jml_anak" type="number" placeholder="1" value="${v('jml_anak')}"/></div>
</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Anak Putus Sekolah</label>
<input class="finput" id="f-anak_putus_sekolah" type="number" placeholder="0" value="${v('anak_putus_sekolah')}"/></div>
<div class="fg"><label class="flabel">Penerima KIP/Beasiswa?</label>
<select class="finput" id="f-penerima_beasiswa">
${['Ya','Tidak'].map(j=>`<option ${v('penerima_beasiswa')===j?'selected':''}>${j}</option>`).join('')}
</select></div>
</div>
</div>
<!-- TAB: Kesehatan -->
<div class="form-section-panel" id="panel-kes">
<div class="form-row cols2">
<div class="fg"><label class="flabel">Kepesertaan BPJS</label>
<select class="finput" id="f-bpjs">
${['PBI (Gratis)','Mandiri','Perusahaan','Tidak Punya BPJS'].map(j=>`<option ${v('bpjs')===j?'selected':''}>${j}</option>`).join('')}
</select></div>
<div class="fg"><label class="flabel">Anggota Difabel/Kronis</label>
<input class="finput" id="f-jml_sakit_kronis" type="number" value="${v('jml_sakit_kronis')}"/></div>
</div>
<div class="fg"><label class="flabel">Jenis Penyakit Khusus</label>
<div class="penyakit-grid">
${CFG.warga.jenisPenyakit.map(p=>`<label class="penyakit-check ${c(p)}"><input type="checkbox" name="penyakit" value="${p}" ${c(p)}/>${p}</label>`).join('')}
</div>
</div>
</div>
<!-- TAB: Riwayat Bansos -->
<div class="form-section-panel" id="panel-hist">
<table class="history-table">
<thead><tr><th>Program</th><th style="width:60px">Thn</th><th>Status</th><th style="width:28px"></th></tr></thead>
<tbody id="hist-tbody">
${hist.map(h=>`
<tr>
<td><input class="finput hist-program" style="padding:3px" value="${H.esc(h.program)}"/></td>
<td><input class="finput hist-tahun" style="padding:3px" type="number" value="${h.tahun}"/></td>
<td><select class="finput hist-status" style="padding:3px"><option ${h.status==='Aktif'?'selected':''}>Aktif</option><option ${h.status==='Berhenti'?'selected':''}>Berhenti</option></select></td>
<td><button class="btn-del-row" type="button" onclick="this.closest('tr').remove()">✕</button></td>
</tr>`).join('')}
</tbody>
</table>
<button class="btn-add-row" type="button" id="btn-add-hist">+ Tambah</button>
<div class="fg" style="margin-top:10px"><label class="flabel">Catatan / Rekomendasi</label>
<textarea class="finput" id="f-catatan" style="min-height:50px">${v('catatan')}</textarea></div>
</div>`;
},
rumahIbadah(rec, latlng){
const lat=latlng?latlng.lat.toFixed(6):(rec?.lat?.toFixed(6)||'');
const lng=latlng?latlng.lng.toFixed(6):(rec?.lng?.toFixed(6)||'');
const v=f=>rec?H.esc(rec[f]||''):'';
return `
<div class="info-box">📍 Koordinat: <strong>${lat}, ${lng}</strong></div>
<div class="form-sect">Identitas Rumah Ibadah</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Nama Rumah Ibadah<span class="req">*</span></label>
<input class="finput" id="f-nama" placeholder="cth: Masjid Al-Ikhlas" value="${v('nama')}"/></div>
<div class="fg"><label class="flabel">Jenis<span class="req">*</span></label>
<select class="finput" id="f-jenis">
${CFG.rumahIbadah.jenis.map(j=>`<option ${v('jenis')===j?'selected':''}>${j}</option>`).join('')}
</select></div>
</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Nama Ketua / Imam / Pastor</label>
<input class="finput" id="f-ketua" placeholder="Nama lengkap" value="${v('ketua')}"/></div>
<div class="fg"><label class="flabel">No. WhatsApp Pengurus</label>
<input class="finput" id="f-wa" placeholder="081..." value="${v('wa')}"/></div>
</div>
<div class="form-sect">Kapasitas & Jangkauan</div>
<div class="form-row cols3">
<div class="fg"><label class="flabel">Kapasitas Jamaah</label>
<input class="finput" id="f-kapasitas" type="number" placeholder="200" value="${v('kapasitas')}"/></div>
<div class="fg"><label class="flabel">Radius Wilayah Binaan (m)</label>
<input class="finput" id="f-radius" type="number" placeholder="300" value="${v('radius')||300}"/></div>
<div class="fg"><label class="flabel">Jml Warga Binaan</label>
<input class="finput" id="f-jml_binaan" type="number" placeholder="150" value="${v('jml_binaan')}"/></div>
</div>
<div class="form-sect">Program Sosial</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Program Aktif</label>
<select class="finput" id="f-program">
${['Baznas/Zakat','Bantuan Sembako','Santunan Anak Yatim','Pemberdayaan UMKM','Beasiswa','Belum Ada'].map(k=>`<option ${v('program')===k?'selected':''}>${k}</option>`).join('')}
</select></div>
<div class="fg"><label class="flabel">Dana Sosial/Bulan (Rp)</label>
<input class="finput" id="f-dana_sosial" type="number" placeholder="2000000" value="${v('dana_sosial')}"/></div>
</div>
<div class="fg"><label class="flabel">Catatan / Kebutuhan Dukungan</label>
<textarea class="finput" id="f-catatan" placeholder="Kebutuhan data, pelatihan, dll...">${v('catatan')}</textarea></div>`;
},
readAll(ln){
const g = id => document.getElementById(id)?.value?.trim();
if(ln==='spbu'){
const nm=g('f-nama'); if(!nm){Toast.error('Nama SPBU wajib diisi!');return null;}
const buka=document.querySelector('input[name="buka24"]:checked')?.value!=='false';
return {nama:nm,wa:g('f-wa'),buka24:buka,jenis_bbm:g('f-jenis_bbm'),disabilitas:g('f-disabilitas'),harga_bbm:g('f-harga_bbm'),catatan:g('f-catatan')};
}
if(ln==='jalan'){
const nm=g('f-nama'); if(!nm){Toast.error('Nama jalan wajib diisi!');return null;}
return {nama:nm,status:g('f-status'),panjang:parseFloat(g('f-panjang'))||0,kondisi:g('f-kondisi'),tahun_perbaikan:g('f-tahun_perbaikan'),lebar:g('f-lebar'),angkutan:g('f-angkutan'),akses_publik:g('f-akses_publik'),catatan:g('f-catatan')};
}
if(ln==='parsil'){
const nm=g('f-nama'); if(!nm){Toast.error('Nama parsil wajib diisi!');return null;}
return {nama:nm,status:g('f-status'),luas:parseFloat(g('f-luas'))||0,nib:g('f-nib'),sengketa:g('f-sengketa'),njop:g('f-njop'),penggunaan:g('f-penggunaan'),catatan:g('f-catatan')};
}
if(ln==='warga'){
const nm=g('f-nama'); if(!nm){Toast.error('Nama KK wajib diisi!');return null;}
const penyakit=[...document.querySelectorAll('input[name="penyakit"]:checked')].map(el=>el.value);
const history_bansos=[];
document.querySelectorAll('#hist-tbody tr').forEach(tr=>{
const prog=tr.querySelector('.hist-program')?.value?.trim();
const thn=tr.querySelector('.hist-tahun')?.value?.trim();
const sts=tr.querySelector('.hist-status')?.value;
if(prog) history_bansos.push({program:prog,tahun:parseInt(thn)||new Date().getFullYear(),status:sts});
});
return {
nama: nm, nik: g('f-nik'), no_kk: g('f-no_kk'), wa: g('f-wa'), tingkat_kemiskinan: g('f-tingkat_kemiskinan'),
jml_anggota: parseInt(g('f-jml_anggota'))||0, jml_lansia: parseInt(g('f-jml_lansia'))||0,
alamat: g('f-alamat'), bansos: g('f-bansos'), hub_ri: g('f-hub_ri'),
pendapatan: g('f-pendapatan'), pekerjaan: g('f-pekerjaan'),
kondisi_rumah: g('f-kondisi_rumah'), kepemilikan_aset: g('f-kepemilikan_aset'),
air_bersih: g('f-air_bersih'), listrik: g('f-listrik'),
pendidikan_kk: g('f-pendidikan_kk'), jml_anak: parseInt(g('f-jml_anak'))||0,
anak_putus_sekolah: parseInt(g('f-anak_putus_sekolah'))||0, penerima_beasiswa: g('f-penerima_beasiswa'),
bpjs: g('f-bpjs'), jml_sakit_kronis: parseInt(g('f-jml_sakit_kronis'))||0,
penyakit, history_bansos, catatan: g('f-catatan'),
};
}
if(ln==='rumahIbadah'){
const nm=g('f-nama'); if(!nm){Toast.error('Nama Rumah Ibadah wajib diisi!');return null;}
return {
nama: nm, jenis: g('f-jenis'), ketua: g('f-ketua'), wa: g('f-wa'),
kapasitas: parseInt(g('f-kapasitas'))||0, radius: parseFloat(g('f-radius'))||300,
jml_binaan: parseInt(g('f-jml_binaan'))||0, program: g('f-program'),
dana_sosial: g('f-dana_sosial'), catatan: g('f-catatan')
};
}
}
};
+18
View File
@@ -0,0 +1,18 @@
/* ═══════════════════════════════════════════════════════════
10. LEGEND
═══════════════════════════════════════════════════════════ */
function renderLegend(ln){
const el=document.getElementById('legend-items');
const rows=CFG[ln].legend||[];
el.innerHTML=rows.map(r=>{
let sym='';
if(r.sym==='c') sym=`<span class="leg-c" style="background:${r.clr}"></span>`;
else if(r.sym==='l') sym=`<span class="leg-l" style="background:${r.clr}"></span>`;
else sym=`<span class="leg-p" style="border-color:${r.clr};background:${r.clr}33"></span>`;
return `<div class="legend-row">${sym}<span>${r.l}</span></div>`;
}).join('');
// Add radius note for rumahIbadah and bansos
if(ln==='rumahIbadah'||ln==='warga'){
el.innerHTML+=`<div class="legend-radius"><span class="leg-radius-sym"></span>Radius Hub 300m</div>`;
}
}
+82
View File
@@ -0,0 +1,82 @@
/* ═══════════════════════════════════════════════════════════
6. POPUP BUILDERS
═══════════════════════════════════════════════════════════ */
const Popup = {
_row:(k,v)=>`<div class="popup-row"><span class="popup-k">${k}</span><span class="popup-v">${H.esc(v||'-')}</span></div>`,
_sect:t=>`<div class="popup-section">${t}</div>`,
_acts:(ln,id)=>`<div class="popup-acts"><button class="bsm bsm-edit" onclick="App.crud.edit('${ln}','${id}')">Edit</button><button class="bsm bsm-del" onclick="App.crud.delete('${ln}','${id}')">Hapus</button></div>`,
spbu(r){ const cfg=CFG.spbu,clr=cfg.color(r),bdg=cfg.badge(r);
return `<div class="popup-wrap"><div class="popup-title"><span class="popup-sym" style="background:${clr}"></span>${H.esc(r.nama)}</div>
<span class="badge ${bdg.c}">${bdg.t}</span>
${this._sect('Operasional')}
${this._row('No. WA',r.wa)}${this._row('Jenis BBM',r.jenis_bbm)}${this._row('Harga BBM',r.harga_bbm)}${this._row('Akses Disabilitas',r.disabilitas)}
${r.catatan?this._row('Catatan',r.catatan):''}
${this._acts('spbu',r.id)}</div>`; },
jalan(r){ const cfg=CFG.jalan,bdg=cfg.badge(r);
return `<div class="popup-wrap"><div class="popup-title">🛣️ ${H.esc(r.nama)}</div>
<span class="badge ${bdg.c}">${bdg.t}</span>
${this._sect('Spesifikasi')}
${this._row('Panjang',H.fmDist(r.panjang))}${this._row('Lebar',r.lebar?(r.lebar+' m'):'-')}${this._row('Kondisi',r.kondisi)}${this._row('Perbaikan Terakhir',r.tahun_perbaikan||'-')}
${this._sect('Dampak Sosial')}
${this._row('Angkutan Umum',r.angkutan)}${this._row('Akses ke',r.akses_publik)}
${r.catatan?this._row('Catatan',r.catatan):''}
${this._acts('jalan',r.id)}</div>`; },
parsil(r){ const cfg=CFG.parsil,bdg=cfg.badge(r);
return `<div class="popup-wrap"><div class="popup-title">🏗️ ${H.esc(r.nama)}</div>
<span class="badge ${bdg.c}">${bdg.t}</span>
${this._sect('Data Hukum')}
${this._row('NIB',r.nib||'-')}${this._row('Status Sengketa',r.sengketa)}${this._row('Penggunaan',r.penggunaan)}
${this._sect('Penilaian')}
${this._row('Luas',H.fmArea(r.luas))}${this._row('NJOP/m²',r.njop?'Rp '+parseInt(r.njop).toLocaleString('id-ID'):'-')}
${r.catatan?this._row('Catatan',r.catatan):''}
${this._acts('parsil',r.id)}</div>`; },
warga(r){ const cfg=CFG.warga,clr=cfg.color(r),bdg=cfg.badge(r);
const ri=Store.all('rumahIbadah');
let nearestRI=null, nearestDist=Infinity;
ri.forEach(hub=>{
if(hub.lat==null||r.lat==null) return;
const d=L.latLng(r.lat,r.lng).distanceTo(L.latLng(hub.lat,hub.lng));
if(d<nearestDist){nearestDist=d;nearestRI=hub;}
});
const radius=nearestRI?(parseFloat(nearestRI.radius)||300):300;
const covered=nearestRI&&nearestDist<=radius;
const coverageHtml=nearestRI
?`<div class="coverage-badge ${covered?'covered':'uncovered'}">
<svg viewBox="0 0 24 24"><path d="${covered?'M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z':'M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z'}"/></svg>
${covered?'Terjangkau Hub: '+H.esc(nearestRI.nama)+' ('+Math.round(nearestDist)+' m)':'Tidak Terjangkau ('+Math.round(nearestDist)+' m dari RI)'}
</div>`
:'<div class="coverage-badge uncovered">Belum ada Rumah Ibadah Hub</div>';
const histHtml=r.history_bansos?.length
?r.history_bansos.map(h=>`<div style="font-size:10px;padding:3px 0;border-bottom:1px solid var(--border0);display:flex;justify-content:space-between"><span>${H.esc(h.program)} (${h.tahun||'-'})</span><span class="status-${(h.status||'').toLowerCase().replace(' ','')}" style="font-weight:700">${H.esc(h.status)}</span></div>`).join('')
:'<div style="font-size:10px;color:var(--text4)">Belum ada riwayat bansos</div>';
return `<div class="popup-wrap"><div class="popup-title"><span class="popup-sym" style="background:${clr}"></span>${H.esc(r.nama)}</div>
<span class="badge ${bdg.c}">${bdg.t}</span>
${coverageHtml}
${this._sect('Data Keluarga')}
${this._row('NIK',r.nik||'-')}${this._row('No. KK',r.no_kk||'-')}${this._row('Jml Anggota',r.jml_anggota+' jiwa')}
${this._sect('Ekonomi')}
${this._row('Pekerjaan KK',r.pekerjaan)}${this._row('Pendapatan/Bln',r.pendapatan?'Rp '+parseInt(r.pendapatan).toLocaleString('id-ID'):'-')}${this._row('Kondisi Rumah',r.kondisi_rumah)}
${this._sect('Fasilitas')}
${this._row('Air Bersih',r.air_bersih)}${this._row('Listrik',r.listrik)}
${this._sect('Bansos')}
${this._row('Bansos Saat Ini',r.bansos)}${this._row('Hub RI',r.hub_ri||'-')}
<div style="margin-top:2px">${histHtml}</div>
${r.catatan?this._row('Rekomendasi',r.catatan):''}
${this._acts('warga',r.id)}</div>`; },
rumahIbadah(r){ const cfg=CFG.rumahIbadah,clr=cfg.color(r),bdg=cfg.badge(r);
return `<div class="popup-wrap"><div class="popup-title"><span class="popup-sym" style="background:${clr}"></span>${H.esc(r.nama)}</div>
<span class="badge ${bdg.c}">${bdg.t}</span>
${this._sect('Kontak')}
${this._row('Ketua/Imam',r.ketua||'-')}${this._row('No. WA',r.wa||'-')}
${this._sect('Kapasitas & Binaan')}
${this._row('Kapasitas',r.kapasitas?r.kapasitas+' orang':'-')}${this._row('Radius Binaan',(r.radius||300)+' m')}${this._row('Warga Binaan',r.jml_binaan?r.jml_binaan+' KK':'-')}
${this._sect('Program Sosial')}
${this._row('Program Aktif',r.program)}${this._row('Dana Sosial/Bln',r.dana_sosial?'Rp '+parseInt(r.dana_sosial).toLocaleString('id-ID'):'-')}
${r.catatan?this._row('Catatan',r.catatan):''}
${this._acts('rumahIbadah',r.id)}</div>`; },
};
+57
View File
@@ -0,0 +1,57 @@
/* ═══════════════════════════════════════════════════════════
4. LAYER CONFIG (atribut + warna per layer)
── Ini adalah "kontrak data" antara programmer & client ──
═══════════════════════════════════════════════════════════ */
const CFG = {
spbu: {
label:'Data SPBU', sub:'Stasiun Bahan Bakar Umum', icon:'⛽', drawType:'point',
color: r => r.buka24?'#34d47e':'#f05c5c',
badge: r => r.buka24?{t:'24 Jam',c:'badge-green'}:{t:'Tutup',c:'badge-red'},
legend:[{l:'Buka 24 Jam',clr:'#34d47e',sym:'c'},{l:'Tidak 24 Jam',clr:'#f05c5c',sym:'c'}],
},
jalan: {
label:'Data Jalan', sub:'Ruas jalan berdasarkan status', icon:'🛣️', drawType:'polyline',
statuses:['Nasional','Provinsi','Kabupaten','Desa'],
colors:{'Nasional':'#f05c5c','Provinsi':'#f0a030','Kabupaten':'#5b9cf6','Desa':'#34d47e'},
badgeClass:{'Nasional':'badge-red','Provinsi':'badge-amber','Kabupaten':'badge-accent','Desa':'badge-green'},
kondisi:['Baik','Sedang','Rusak Ringan','Rusak Berat'],
color: r => ({'Nasional':'#f05c5c','Provinsi':'#f0a030','Kabupaten':'#5b9cf6','Desa':'#34d47e'}[r.status]||'#888'),
badge: r => ({t:r.status, c:{'Nasional':'badge-red','Provinsi':'badge-amber','Kabupaten':'badge-accent','Desa':'badge-green'}[r.status]||'badge-accent'}),
legend:[{l:'Nasional',clr:'#f05c5c',sym:'l'},{l:'Provinsi',clr:'#f0a030',sym:'l'},{l:'Kabupaten',clr:'#5b9cf6',sym:'l'},{l:'Desa',clr:'#34d47e',sym:'l'}],
},
parsil: {
label:'Data Parsil Tanah', sub:'Persil kavling berdasarkan kepemilikan', icon:'🏗️', drawType:'polygon',
statuses:['SHM','HGB','HGU','HP','Belum Bersertifikat'],
colors:{'SHM':'#34d47e','HGB':'#5b9cf6','HGU':'#f0a030','HP':'#9b7bfa','Belum Bersertifikat':'#f05c5c'},
badgeClass:{'SHM':'badge-green','HGB':'badge-accent','HGU':'badge-amber','HP':'badge-purple','Belum Bersertifikat':'badge-red'},
color: r => ({'SHM':'#34d47e','HGB':'#5b9cf6','HGU':'#f0a030','HP':'#9b7bfa','Belum Bersertifikat':'#f05c5c'}[r.status]||'#888'),
badge: r => ({t:r.status,c:{'SHM':'badge-green','HGB':'badge-accent','HGU':'badge-amber','HP':'badge-purple','Belum Bersertifikat':'badge-red'}[r.status]||'badge-accent'}),
legend:[{l:'SHM',clr:'#34d47e',sym:'p'},{l:'HGB',clr:'#5b9cf6',sym:'p'},{l:'HGU',clr:'#f0a030',sym:'p'},{l:'HP',clr:'#9b7bfa',sym:'p'},{l:'Belum Sertifikat',clr:'#f05c5c',sym:'p'}],
},
warga: {
label:'Data Warga & Bansos', sub:'Keluarga rentan & sasaran distribusi bansos', icon:'👨‍👩‍👧', drawType:'point',
tingkat:['Sangat Miskin','Miskin','Hampir Miskin','Rentan'],
colors:{'Sangat Miskin':'#f05c5c','Miskin':'#f0a030','Hampir Miskin':'#f0c040','Rentan':'#5b9cf6'},
badgeClass:{'Sangat Miskin':'badge-red','Miskin':'badge-orange','Hampir Miskin':'badge-amber','Rentan':'badge-accent'},
color: r => ({'Sangat Miskin':'#f05c5c','Miskin':'#f0a030','Hampir Miskin':'#f0c040','Rentan':'#5b9cf6'}[r.tingkat_kemiskinan]||'#888'),
badge: r => ({t:r.tingkat_kemiskinan,c:{'Sangat Miskin':'badge-red','Miskin':'badge-orange','Hampir Miskin':'badge-amber','Rentan':'badge-accent'}[r.tingkat_kemiskinan]||'badge-accent'}),
legend:[{l:'Sangat Miskin',clr:'#f05c5c',sym:'c'},{l:'Miskin',clr:'#f0a030',sym:'c'},{l:'Hampir Miskin',clr:'#f0c040',sym:'c'},{l:'Rentan',clr:'#5b9cf6',sym:'c'}],
bantuan:['PKH','BPNT/Sembako','BLT Dana Desa','BLT BBM','PIP/KIP','KIS/BPJS','Zakat/Infaq RI','Bansos Pemda','Belum Ada'],
kondisiRumah:['Permanen','Semi Permanen','Tidak Layak Huni','Menumpang'],
pekerjaan:['Buruh Harian','Petani/Nelayan','Pedagang Kecil','PNS/TNI/Polri','Pensiunan','Tidak Bekerja','Lainnya'],
statusPenerimaan:['Aktif Menerima','Belum Menerima','Proses Verifikasi','Pernah Menerima'],
sumberPendapatan:['Buruh Harian','Pedagang Kecil','Petani/Nelayan','PNS/TNI/Polri','Pensiunan','Tidak Bekerja','Lainnya'],
jenisPenyakit:['Hipertensi','Diabetes','TBC','Jantung','Stroke','Kanker','Gangguan Jiwa','Disabilitas','Tidak Ada'],
tingkatPendidikan:['Tidak Sekolah','SD/Sederajat','SMP/Sederajat','SMA/Sederajat','D3/S1','S2/S3'],
},
rumahIbadah: {
label:'Rumah Ibadah', sub:'Masjid, Gereja, Pura, Vihara, Klenteng', icon:'🕌', drawType:'point',
jenis:['Masjid','Musholla','Gereja Kristen','Gereja Katolik','Pura','Vihara','Klenteng'],
colors:{'Masjid':'#2ec4b6','Musholla':'#2ec4b6','Gereja Kristen':'#5b9cf6','Gereja Katolik':'#5b9cf6','Pura':'#f0a030','Vihara':'#9b7bfa','Klenteng':'#f07840'},
badgeClass:{'Masjid':'badge-teal','Musholla':'badge-teal','Gereja Kristen':'badge-accent','Gereja Katolik':'badge-accent','Pura':'badge-amber','Vihara':'badge-purple','Klenteng':'badge-orange'},
color: r => ({'Masjid':'#2ec4b6','Musholla':'#2ec4b6','Gereja Kristen':'#5b9cf6','Gereja Katolik':'#5b9cf6','Pura':'#f0a030','Vihara':'#9b7bfa','Klenteng':'#f07840'}[r.jenis]||'#888'),
badge: r => ({t:r.jenis,c:({'Masjid':'badge-teal','Musholla':'badge-teal','Gereja Kristen':'badge-accent','Gereja Katolik':'badge-accent','Pura':'badge-amber','Vihara':'badge-purple','Klenteng':'badge-orange'}[r.jenis]||'badge-teal')}),
legend:[{l:'Masjid/Musholla',clr:'#2ec4b6',sym:'c'},{l:'Gereja',clr:'#5b9cf6',sym:'c'},{l:'Pura',clr:'#f0a030',sym:'c'},{l:'Vihara/Klenteng',clr:'#9b7bfa',sym:'c'}],
coverageRadius: 300,
},
};
+182
View File
@@ -0,0 +1,182 @@
/* ═══════════════════════════════════════════════════════════
12. DASHBOARD ANALITIK
═══════════════════════════════════════════════════════════ */
const Dashboard = {
toggle(){
document.getElementById('dash-panel').classList.toggle('hidden');
if(!document.getElementById('dash-panel').classList.contains('hidden')) this.render();
},
render(){
const db=Store.allDb();
const warga=db.warga||[], ri=db.rumahIbadah||[], jalan=db.jalan||[], parsil=db.parsil||[];
const totalWarga=warga.reduce((s,r)=>s+(parseInt(r.jml_anggota)||1),0);
const miskinBerat=warga.filter(r=>r.tingkat_kemiskinan==='Sangat Miskin'||r.tingkat_kemiskinan==='Miskin').length;
const blmBansos=warga.filter(r=>r.bansos==='Belum Ada').length;
const blmSertif=parsil.filter(r=>r.status==='Belum Bersertifikat').length;
const jalanRusak=jalan.filter(r=>r.kondisi==='Rusak Berat'||r.kondisi==='Rusak Ringan').length;
const totalRI=ri.length;
// Radius coverage for Warga layer
let coveredKK=0, uncoveredKK=0;
const coverageHubs = ri.map(hub=>({ ...hub, count:0 }));
warga.forEach(b => {
if(!b.lat) return;
let nearestDist=Infinity, nearestHub=null;
ri.forEach(hub=>{
if(!hub.lat) return;
const d=L.latLng(b.lat,b.lng).distanceTo(L.latLng(hub.lat,hub.lng));
if(d<nearestDist){nearestDist=d;nearestHub=hub;}
});
if(nearestHub && nearestDist<=(parseFloat(nearestHub.radius)||300)){
coveredKK++;
const h = coverageHubs.find(h=>h.id===nearestHub.id);
if(h) h.count++;
} else {
uncoveredKK++;
}
});
// tingkat kemiskinan breakdown
const tkBreak={};
CFG.warga.tingkat.forEach(t=>{ tkBreak[t]=warga.filter(r=>r.tingkat_kemiskinan===t).length; });
const maxTK=Math.max(...Object.values(tkBreak),1);
// bansos breakdown
const bansosBreak={};
CFG.warga.bantuan.forEach(b=>{ const c=warga.filter(r=>r.bansos===b).length; if(c>0) bansosBreak[b]=c; });
// jalan status breakdown
const jalanBreak={};
CFG.jalan.statuses.forEach(s=>{ jalanBreak[s]=jalan.filter(r=>r.status===s).length; });
document.getElementById('dash-body').innerHTML=`
<div class="dash-tab-bar">
<div class="dash-tab active" onclick="Dashboard.switchTab('ringkasan')">Ringkasan Utama</div>
<div class="dash-tab" onclick="Dashboard.switchTab('distribusi')">Distribusi Bansos</div>
</div>
<!-- TAB RINGKASAN -->
<div id="dash-panel-ringkasan">
<div class="stat-grid">
<div class="stat-card c-red"><div class="sc-val">${H.fmNum(warga.length)}</div><div class="sc-label">KK Warga Miskin Terdata</div><div class="sc-sub">${H.fmNum(totalWarga)} jiwa total</div></div>
<div class="stat-card c-amber"><div class="sc-val">${H.fmNum(miskinBerat)}</div><div class="sc-label">Miskin & Sangat Miskin</div><div class="sc-sub">${warga.length?Math.round(miskinBerat/warga.length*100):0}% dari total KK</div></div>
<div class="stat-card c-accent"><div class="sc-val">${H.fmNum(blmBansos)}</div><div class="sc-label">KK Belum Terima Bansos</div><div class="sc-sub">Perlu intervensi segera</div></div>
<div class="stat-card c-teal"><div class="sc-val">${H.fmNum(totalRI)}</div><div class="sc-label">Rumah Ibadah Aktif</div><div class="sc-sub">Agen lapangan terdaftar</div></div>
<div class="stat-card c-purple"><div class="sc-val">${H.fmNum(blmSertif)}</div><div class="sc-label">Parsil Belum Bersertifikat</div><div class="sc-sub">Kandidat program PTSL</div></div>
<div class="stat-card c-green"><div class="sc-val">${H.fmNum(jalanRusak)}</div><div class="sc-label">Ruas Jalan Rusak</div><div class="sc-sub">Dari ${H.fmNum(jalan.length)} ruas terdata</div></div>
</div>
<div class="dash-sect">
<div class="dash-sect-title">Sebaran Tingkat Kemiskinan</div>
<table class="risk-table"><thead><tr><th>Kategori</th><th>Jumlah KK</th><th>Proporsi</th></tr></thead><tbody>
${CFG.warga.tingkat.map(t=>{
const n=tkBreak[t]||0, pct=warga.length?Math.round(n/warga.length*100):0;
const clr={'Sangat Miskin':'#f05c5c','Miskin':'#f0a030','Hampir Miskin':'#f0c040','Rentan':'#5b9cf6'}[t];
return \`<tr><td>\${t}</td><td>\${H.fmNum(n)} KK</td><td><div class="risk-bar-wrap"><div class="risk-bar"><div class="risk-bar-fill" style="width:\${Math.round(n/maxTK*100)}%;background:\${clr}"></div></div>\${pct}%</div></td></tr>\`;
}).join('')}
</tbody></table>
</div>
<div class="dash-sect">
<div class="dash-sect-title">Penerima Program Bantuan Sosial</div>
${Object.entries(bansosBreak).length?Object.entries(bansosBreak).sort((a,b)=>b[1]-a[1]).map(([b,n])=>`
<div class="prog-row"><span class="prog-name">${b}</span><span class="prog-count">${n} KK</span></div>`).join('')
:'<div style="color:var(--text4);font-size:12px">Belum ada data warga tercatat.</div>'}
</div>
<div class="dash-sect">
<div class="dash-sect-title">Status Ruas Jalan</div>
<table class="risk-table"><thead><tr><th>Status</th><th>Jumlah Ruas</th><th>Total Panjang</th></tr></thead><tbody>
${CFG.jalan.statuses.map(s=>{
const ruas=jalan.filter(r=>r.status===s);
const pj=ruas.reduce((t,r)=>t+(r.panjang||0),0);
return \`<tr><td><span class="badge \${CFG.jalan.badgeClass[s]}">\${s}</span></td><td>\${ruas.length} ruas</td><td>\${H.fmDist(pj)}</td></tr>\`;
}).join('')}
</tbody></table>
</div>
<div class="dash-sect">
<div class="dash-sect-title">Rekomendasi Intervensi Kebijakan</div>
<div class="intervensi-grid">
<div class="intervensi-card"><div class="ic-label">🏠 Prioritas Bansos</div>
<div class="ic-val">${H.fmNum(blmBansos)} KK</div>
<div class="ic-sub">Warga belum menerima bantuan apapun</div></div>
<div class="intervensi-card"><div class="ic-label">📜 Prioritas PTSL</div>
<div class="ic-val">${H.fmNum(blmSertif)} Parsil</div>
<div class="ic-sub">Lahan tanpa sertifikat, akses kredit terbatas</div></div>
<div class="intervensi-card"><div class="ic-label">🛣️ Prioritas Perbaikan Jalan</div>
<div class="ic-val">${H.fmNum(jalanRusak)} Ruas</div>
<div class="ic-sub">Jalan rusak ringan-berat perlu perbaikan</div></div>
<div class="intervensi-card"><div class="ic-label">🕌 Coverage Rumah Ibadah</div>
<div class="ic-val">${H.fmNum(totalRI)} RI Aktif</div>
<div class="ic-sub">Total dana sosial: Rp ${H.fmNum(ri.reduce((t,r)=>t+(parseInt(r.dana_sosial)||0),0))}</div></div>
</div>
</div>
</div>
<!-- TAB DISTRIBUSI BANSOS -->
<div id="dash-panel-distribusi" style="display:none">
<div class="coverage-grid">
<div class="coverage-card">
<div class="cc-icon green">✓</div>
<div class="cc-info">
<div class="cc-val">${coveredKK} KK</div>
<div class="cc-label">Terjangkau Hub (≤300m)</div>
</div>
</div>
<div class="coverage-card">
<div class="cc-icon red">✕</div>
<div class="cc-info">
<div class="cc-val">${uncoveredKK} KK</div>
<div class="cc-label">Di Luar Jangkauan Hub</div>
</div>
</div>
<div class="coverage-card" style="grid-column:1/3;padding:16px">
<div class="cc-icon teal" style="width:40px;height:40px">🕌</div>
<div class="cc-info">
<div class="cc-val">${totalRI} Rumah Ibadah</div>
<div class="cc-label">Agen Distribusi Lapangan Terdaftar</div>
</div>
</div>
</div>
<div class="dash-sect">
<div class="dash-sect-title">Beban Distribusi per Rumah Ibadah</div>
<table class="hub-table">
<thead><tr><th>Nama Hub</th><th>Kapasitas</th><th>Beban KK</th></tr></thead>
<tbody>
${coverageHubs.sort((a,b)=>b.count-a.count).map(h=>{
const clr=CFG.rumahIbadah.color(h);
const max = Math.max(...coverageHubs.map(x=>x.count), 1);
const pct = Math.round(h.count/max*100);
return \`<tr>
<td><div class="hub-name-cell"><span class="hub-dot" style="background:\${clr}"></span>\${H.esc(h.nama)}</div></td>
<td>\${h.jml_binaan||'-'} KK</td>
<td>
<div class="hub-count-bar">
<div class="hub-bar"><div class="hub-bar-fill" style="width:\${pct}%;background:\${pct>80?'var(--amber)':clr}"></div></div>
<span>\${h.count} KK</span>
</div>
</td>
</tr>\`;
}).join('')}
</tbody>
</table>
${coverageHubs.length===0?'<div style="text-align:center;color:var(--text4);font-size:11px;padding:20px">Belum ada data Rumah Ibadah.</div>':''}
</div>
</div>`;
},
switchTab(tabId){
document.querySelectorAll('.dash-tab').forEach(t=>t.classList.remove('active'));
document.querySelectorAll('[id^="dash-panel-"]').forEach(p=>p.style.display='none');
if(tabId === 'ringkasan'){
document.querySelectorAll('.dash-tab')[0].classList.add('active');
document.getElementById('dash-panel-ringkasan').style.display='block';
} else {
document.querySelectorAll('.dash-tab')[1].classList.add('active');
document.getElementById('dash-panel-distribusi').style.display='block';
}
}
};
+38
View File
@@ -0,0 +1,38 @@
/* ═══════════════════════════════════════════════════════════
8. DRAW MANAGER
═══════════════════════════════════════════════════════════ */
const Draw = (() => {
let _map, _drawn, _pending, _handler, _active=false;
const BAR=()=>document.getElementById('draw-bar');
const TXT=()=>document.getElementById('draw-indicator-text');
function init(m){
_map=m; _drawn=new L.FeatureGroup().addTo(_map);
_map.on(L.Draw.Event.CREATED, e=>{
_drawn.clearLayers(); _drawn.addLayer(e.layer);
_pending=e.layer; stop(); Modal.openAdd(App.state.activeLayer, e.layer);
});
_map.on('click', e=>{
if(!_active||App.state.activeLayer!=='spbu'&&App.state.activeLayer!=='warga'&&App.state.activeLayer!=='rumahIbadah') return;
const mk=L.marker(e.latlng);
_drawn.clearLayers(); _drawn.addLayer(mk);
_pending=mk; stop(); Modal.openAdd(App.state.activeLayer, mk);
});
document.getElementById('btn-cancel').addEventListener('click', cancel);
}
function start(ln){
_active=true;
const msgs={spbu:'Klik peta untuk menandai lokasi SPBU',jalan:'Klik titik-titik ruas jalan — klik ganda untuk selesai',parsil:'Klik titik-titik batas kavling — klik ganda untuk selesai',warga:'Klik peta untuk menandai lokasi rumah warga miskin',rumahIbadah:'Klik peta untuk menandai lokasi rumah ibadah'};
document.getElementById('draw-text').textContent=msgs[ln]||'Mode gambar aktif';
BAR().classList.remove('hidden');
if(ln==='jalan'){ _handler=new L.Draw.Polyline(_map,{shapeOptions:{color:'#f0a030',weight:4}}); _handler.enable(); }
else if(ln==='parsil'){ _handler=new L.Draw.Polygon(_map,{shapeOptions:{color:'#9b7bfa',weight:2,fillOpacity:.2}}); _handler.enable(); }
}
function stop(){ _active=false; BAR().classList.add('hidden'); if(_handler){_handler.disable();_handler=null;} }
function cancel(){ stop(); _drawn.clearLayers(); _pending=null; }
function clearPending(){ _drawn.clearLayers(); _pending=null; }
return {init, start, stop, cancel, clearPending};
})();
+63
View File
@@ -0,0 +1,63 @@
/* ═══════════════════════════════════════════════════════════
13. MAP RENDER
═══════════════════════════════════════════════════════════ */
function renderMap(){
const db=Store.allDb();
// SPBU
App._groups.spbu.clearLayers();
db.spbu.forEach(r=>{
const m=L.circleMarker([r.lat,r.lng],{radius:9,fillColor:CFG.spbu.color(r),color:'#fff',weight:2,fillOpacity:1});
m.bindPopup(Popup.spbu(r),{maxWidth:320});
m.on('click',()=>highlightCard(r.id));
App._groups.spbu.addLayer(m);
});
// Warga
App._groups.warga.clearLayers();
db.warga.forEach(r=>{
const clr=CFG.warga.color(r);
// Check if in any RI radius
const inRadius=db.rumahIbadah.some(hub=>{
if(!hub.lat) return false;
const d=L.latLng(r.lat,r.lng).distanceTo(L.latLng(hub.lat,hub.lng));
return d<=(parseFloat(hub.radius)||300);
});
const iconHtml=`<div style="width:22px;height:22px;background:${clr};border:2px solid #fff;border-radius:50%;
display:flex;align-items:center;justify-content:center;box-shadow:0 2px 6px rgba(0,0,0,.4);
font-size:11px;line-height:1">👨‍👩‍👧</div>${inRadius?'':'<div style="position:absolute;top:-4px;right:-4px;width:10px;height:10px;background:#f05c5c;border-radius:50%;border:1px solid #fff"></div>'}`;
const icon=L.divIcon({html:`<div style="position:relative">${iconHtml}</div>`,className:'',iconSize:[22,22],iconAnchor:[11,11]});
const m=L.marker([r.lat,r.lng],{icon});
m.bindPopup(Popup.warga(r),{maxWidth:360});
m.on('click',()=>highlightCard(r.id));
App._groups.warga.addLayer(m);
});
// Rumah Ibadah
App._groups.rumahIbadah.clearLayers();
db.rumahIbadah.forEach(r=>{
const clr=CFG.rumahIbadah.color(r);
const m=L.circleMarker([r.lat,r.lng],{radius:10,fillColor:clr,color:'#fff',weight:2.5,fillOpacity:1});
m.bindPopup(Popup.rumahIbadah(r),{maxWidth:320});
m.on('click',()=>highlightCard(r.id));
App._groups.rumahIbadah.addLayer(m);
// Always draw radius 300m coverage circle
const radiusM=parseFloat(r.radius)||300;
const ring=L.circle([r.lat,r.lng],{radius:radiusM,color:clr,weight:1.5,fillColor:clr,fillOpacity:.06,dashArray:'6 4'});
App._groups.rumahIbadah.addLayer(ring);
});
// Jalan
App._groups.jalan.clearLayers();
db.jalan.forEach(r=>{
const l=L.polyline(r.coords,{color:CFG.jalan.color(r),weight:4,opacity:.9});
l.bindPopup(Popup.jalan(r),{maxWidth:300});
l.on('click',()=>highlightCard(r.id));
App._groups.jalan.addLayer(l);
});
// Parsil
App._groups.parsil.clearLayers();
db.parsil.forEach(r=>{
const clr=CFG.parsil.color(r);
const p=L.polygon(r.coords,{color:clr,weight:2,fillColor:clr,fillOpacity:.2});
p.bindPopup(Popup.parsil(r),{maxWidth:300});
p.on('click',()=>highlightCard(r.id));
App._groups.parsil.addLayer(p);
});
}
+119
View File
@@ -0,0 +1,119 @@
/* ═══════════════════════════════════════════════════════════
9. MODAL MANAGER
═══════════════════════════════════════════════════════════ */
const Modal = (() => {
let _ln=null, _editId=null, _geo=null;
const ov=()=>document.getElementById('modal-ov');
function openAdd(ln, leafletLayer){
_ln=ln; _editId=null; _geo=leafletLayer;
const cfg=CFG[ln];
document.getElementById('modal-icon').textContent=cfg.icon;
document.getElementById('modal-title').textContent='Tambah '+cfg.label;
document.getElementById('modal-sub').textContent='Isi atribut data baru';
let html='';
if(ln==='spbu') html=Forms.spbu(null, leafletLayer.getLatLng());
else if(ln==='jalan') html=Forms.jalan(null, H.geoLen(leafletLayer.getLatLngs()));
else if(ln==='parsil') html=Forms.parsil(null, H.geoArea(leafletLayer.getLatLngs()[0]));
else if(ln==='warga') html=Forms.warga(null, leafletLayer.getLatLng());
else if(ln==='rumahIbadah') html=Forms.rumahIbadah(null, leafletLayer.getLatLng());
document.getElementById('modal-body').innerHTML=html;
_bindRadio(); _bindFormTabs(); _bindHistBtn(); ov().classList.remove('hidden');
setTimeout(()=>document.getElementById('f-nama')?.focus(),60);
}
function openEdit(ln, id){
_ln=ln; _editId=id; _geo=null;
const rec=Store.find(ln,id); if(!rec) return;
const cfg=CFG[ln];
document.getElementById('modal-icon').textContent=cfg.icon;
document.getElementById('modal-title').textContent='Edit '+cfg.label;
document.getElementById('modal-sub').textContent=H.esc(rec.nama);
let html='';
if(ln==='spbu') html=Forms.spbu(rec,null);
else if(ln==='jalan') html=Forms.jalan(rec,null);
else if(ln==='parsil') html=Forms.parsil(rec,null);
else if(ln==='warga') html=Forms.warga(rec,null);
else if(ln==='rumahIbadah') html=Forms.rumahIbadah(rec,null);
document.getElementById('modal-body').innerHTML=html;
_bindRadio(); _bindFormTabs(); _bindHistBtn(); ov().classList.remove('hidden');
setTimeout(()=>document.getElementById('f-nama')?.focus(),60);
}
function close(){ ov().classList.add('hidden'); Draw.clearPending(); }
async function submit(){
const data=Forms.readAll(_ln); if(!data) return;
if(_editId){
data.updatedAt=H.today();
await Store.update(_ln,_editId,data);
Toast.success('Data berhasil diperbarui');
} else {
const geo=_geo;
if(_ln==='spbu'||_ln==='warga'||_ln==='rumahIbadah'){
const ll=geo.getLatLng(); data.lat=ll.lat; data.lng=ll.lng;
} else if(_ln==='jalan'){
data.coords=geo.getLatLngs().map(l=>[l.lat,l.lng]);
} else if(_ln==='parsil'){
data.coords=geo.getLatLngs()[0].map(l=>[l.lat,l.lng]);
}
data.id=H.uid(); data.createdAt=H.today(); data.updatedAt=H.today();
await Store.add(_ln,data);
Toast.success('Data berhasil disimpan');
}
close(); App.refresh();
}
function _bindRadio(){
document.querySelectorAll('.ropt').forEach(el=>{
el.addEventListener('click',()=>{
const parent=el.closest('.rtoggle'); if(!parent) return;
parent.querySelectorAll('.ropt').forEach(r=>r.classList.remove('yes','no'));
const v=el.dataset.v; el.classList.add(v==='true'?'yes':'no');
const inp=el.querySelector('input'); if(inp) inp.checked=true;
});
});
// Bind penyakit checkboxes
document.querySelectorAll('.penyakit-check input').forEach(inp=>{
inp.addEventListener('change', e=>{
if(e.target.checked) e.target.parentElement.classList.add('checked');
else e.target.parentElement.classList.remove('checked');
});
});
}
function _bindFormTabs(){
const tabs=document.querySelectorAll('.form-tab');
if(!tabs.length) return;
tabs.forEach(tab=>{
tab.addEventListener('click',()=>{
tabs.forEach(t=>t.classList.remove('active'));
document.querySelectorAll('.form-section-panel').forEach(p=>p.classList.remove('active'));
tab.classList.add('active');
const panelId='panel-'+tab.dataset.panel;
document.getElementById(panelId)?.classList.add('active');
});
});
}
function _bindHistBtn(){
const btn=document.getElementById('btn-add-hist');
if(!btn) return;
btn.addEventListener('click',()=>{
const tbody=document.getElementById('hist-tbody');
if(!tbody) return;
const tr=document.createElement('tr');
tr.innerHTML=`
<td><input class="finput hist-program" style="padding:3px 6px;font-size:10px" placeholder="cth: PKH"/></td>
<td><input class="finput hist-tahun" style="padding:3px 6px;font-size:10px;width:70px" type="number" min="2000" max="2030" placeholder="${new Date().getFullYear()}"/></td>
<td><select class="finput hist-status" style="padding:3px 6px;font-size:10px">
<option>Aktif</option><option>Berhenti</option><option>Dalam Proses</option>
</select></td>
<td><input class="finput hist-nominal" style="padding:3px 6px;font-size:10px" type="number" placeholder="Rp"/></td>
<td><button class="btn-del-row" type="button" onclick="this.closest('tr').remove()">✕</button></td>`;
tbody.appendChild(tr);
});
}
return {openAdd, openEdit, close, submit};
})();
+31
View File
@@ -0,0 +1,31 @@
/* ═══════════════════════════════════════════════════════════
11. SIDEBAR
═══════════════════════════════════════════════════════════ */
function renderSidebar(ln){
const cfg=CFG[ln];
document.getElementById('sb-title').textContent=cfg.label;
document.getElementById('sb-sub').textContent=cfg.sub;
const data=Store.all(ln);
const list=document.getElementById('sb-list');
if(!data.length){
list.innerHTML=`<div class="empty"><svg width="44" height="44" viewBox="0 0 24 24" fill="currentColor"><path d="M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/></svg><p>Belum ada data.<br/>Klik <strong>Tambah</strong> untuk mulai digitasi.</p></div>`;
return;
}
list.innerHTML=data.map(r=>Card[ln](r)).join('');
}
function updateBadges(){
const db=Store.allDb();
['spbu','jalan','parsil','warga','rumahIbadah'].forEach(k=>{
const el=document.getElementById('badge-'+k);
if(el) el.textContent=db[k]?.length||0;
});
const total=Object.values(db).reduce((s,a)=>s+a.length,0);
document.getElementById('total-count').textContent=total+' fitur';
}
function highlightCard(id){
document.querySelectorAll('.dcard').forEach(c=>c.classList.remove('active'));
const c=document.getElementById('card-'+id);
if(c){ c.classList.add('active'); c.scrollIntoView({behavior:'smooth',block:'nearest'}); }
}
+18
View File
@@ -0,0 +1,18 @@
'use strict';
/* ═══════════════════════════════════════════════════════════
1. HELPERS
═══════════════════════════════════════════════════════════ */
const H = {
uid: () => Math.random().toString(36).slice(2,9) + Date.now().toString(36),
esc: s => String(s||'').replace(/[&<>"']/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[c])),
fmDist: m => m==null?'-': m>=1000? (m/1000).toFixed(2)+' km': Math.round(m).toLocaleString('id-ID')+' m',
fmArea: m => m==null?'-': m>=10000? (m/10000).toFixed(4)+' ha': Math.round(m).toLocaleString('id-ID')+' m²',
fmNum: n => (n||0).toLocaleString('id-ID'),
today: () => new Date().toISOString().slice(0,10),
geoLen: lls => { let t=0; for(let i=0;i<lls.length-1;i++) t+=lls[i].distanceTo(lls[i+1]); return Math.round(t); },
geoArea: lls => {
const R=6371008.8; let a=0,n=lls.length;
for(let i=0;i<n;i++){const j=(i+1)%n,φ1=lls[i].lat*Math.PI/180,φ2=lls[j].lat*Math.PI/180,=(lls[j].lng-lls[i].lng)*Math.PI/180;a+=*(2+Math.sin(φ1)+Math.sin(φ2));}
return Math.round(Math.abs(a*R*R/2));
},
};
+55
View File
@@ -0,0 +1,55 @@
/* ═══════════════════════════════════════════════════════════
2. STORAGE (MySQL API Version)
═══════════════════════════════════════════════════════════ */
const Store = (() => {
const API_URL = '../api/index.php';
let db = {
spbu: [],
jalan: [],
parsil: [],
warga: [],
rumahIbadah: []
};
const request = async (action, payload = {}) => {
try {
const res = await fetch(`${API_URL}?action=${action}`, {
method: action === 'alldb' ? 'GET' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: action === 'alldb' ? null : JSON.stringify(payload)
});
return await res.json();
} catch (err) {
console.error('API Error:', err);
throw err;
}
};
return {
load: async () => {
try {
const data = await request('alldb');
if (data) db = { ...db, ...data };
} catch (e) {
console.error("Gagal load data dari server", e);
}
},
all: ln => db[ln]||[],
find: (ln,id) => (db[ln]||[]).find(r=>r.id===id)||null,
add: async (ln,rec) => {
db[ln].push(rec);
await request('add', { layer: ln, data: rec });
},
update: async (ln,id,patch) => {
const i=db[ln].findIndex(r=>r.id===id); if(i<0)return;
db[ln][i]={...db[ln][i],...patch};
await request('update', { layer: ln, id: id, patch: patch });
},
remove: async (ln,id) => {
db[ln]=db[ln].filter(r=>r.id!==id);
await request('remove', { layer: ln, id: id });
},
allDb: () => db,
};
})();
+9
View File
@@ -0,0 +1,9 @@
/* ═══════════════════════════════════════════════════════════
3. TOAST
═══════════════════════════════════════════════════════════ */
const Toast = {
_show(msg,type){ const c=document.getElementById('toasts'),el=document.createElement('div');
el.className=`toast ${type}`;el.innerHTML=`<span>${{success:'✓',error:'✕',info:''}[type]||''}</span>${H.esc(msg)}`;
c.appendChild(el);setTimeout(()=>{el.classList.add('out');el.addEventListener('animationend',()=>el.remove(),{once:true})},2800); },
success: m=>Toast._show(m,'success'), error: m=>Toast._show(m,'error'), info: m=>Toast._show(m,'info'),
};
+2
View File
@@ -0,0 +1,2 @@
<script src="app.js"></script>
<link rel="stylesheet" href="style.css">
+165
View File
@@ -0,0 +1,165 @@
# WebGIS Sistem Distribusi Bansos Berbasis Rumah Ibadah
> Aplikasi pemetaan geospasial (*WebGIS*) interaktif untuk pendataan infrastruktur, profil warga rentan, dan perencanaan distribusi Bantuan Sosial (Bansos) berbasis jangkauan Rumah Ibadah.
Aplikasi ini menggunakan pendekatan **radius spasial 300 meter** untuk secara otomatis mendeteksi apakah seorang warga masuk dalam jangkauan agen distribusi (Rumah Ibadah) atau tidak — membantu pemerintah desa/kelurahan dalam pengambilan keputusan yang akurat dan berbasis data.
---
## 📌 Fitur Utama
| No | Fitur | Tipe Layer | Deskripsi |
|----|-------|-----------|-----------|
| 1 | **Warga & Bansos** | Point | Profil lengkap KK (ekonomi, kesehatan, pendidikan) + riwayat bansos |
| 2 | **Rumah Ibadah** | Point + Buffer | Titik *hub* distribusi bansos; lingkaran jangkauan 300m otomatis |
| 3 | **SPBU** | Point | Ketersediaan BBM & aksesibilitas |
| 4 | **Jalan** | Polyline | Status, kondisi, dan dampak sosial ruas jalan |
| 5 | **Parsil Tanah** | Polygon | Status kepemilikan sertifikat tanah |
| 6 | **Dashboard Analitik** | — | Ringkasan kemiskinan, distribusi bansos, dan rekomendasi kebijakan |
---
## 🗂️ Data yang Dikumpulkan
### 👨‍👩‍👧 Warga & Bansos
Data yang dikumpulkan mencakup 5 dimensi:
- **Data KK:** Nama, NIK, No. KK, jumlah anggota, alamat, tingkat kemiskinan
- **Ekonomi:** Pendapatan, pekerjaan, kondisi rumah, aset, air bersih, listrik
- **Pendidikan:** Tingkat pendidikan, jumlah anak sekolah/putus sekolah, penerima KIP
- **Kesehatan:** Kepesertaan BPJS, jenis penyakit kronis, anggota difabel
- **Riwayat Bansos:** Program (PKH, BPNT, BLT, dll), tahun, dan status penerimaan
### 🕌 Rumah Ibadah
Nama, jenis (Masjid/Gereja/Pura/dll), pengurus, kapasitas jamaah, radius binaan, program sosial aktif, dan alokasi dana sosial per bulan.
### ⛽ SPBU
Nama, jam operasional (24 jam / tidak), jenis BBM, harga, dan aksesibilitas disabilitas.
### 🛣️ Jalan
Nama ruas, status (Nasional/Provinsi/Kabupaten/Desa), kondisi, lebar, tahun perbaikan, dan dampak sosial.
### 🏗️ Parsil Tanah
Nama, NIB, status sertifikat (SHM/HGB/HGU/dll), NJOP, penggunaan lahan, dan status sengketa.
---
## 🏗️ Arsitektur & Struktur Folder
Aplikasi ini dibangun menggunakan arsitektur **modular Vanilla JavaScript** tanpa *build tool* atau *framework*, sehingga bisa langsung dijalankan di browser.
```text
webgis/
├── index.html # Halaman utama & markup HTML
├── README.md # Dokumentasi proyek ini
├── css/
│ └── style.css # Seluruh styling & tema UI
└── js/
├── app.js # Root: Inisialisasi & event binding utama
├── config/
│ └── config.js # CFG: Kontrak data (warna, label, atribut tiap layer)
├── utils/
│ ├── helpers.js # H: Utilitas (uid, format angka/jarak/luas, geometri)
│ ├── store.js # Store: Baca/tulis LocalStorage
│ └── toast.js # Toast: Notifikasi UI (sukses/error/info)
├── components/
│ ├── forms.js # Forms: Generator HTML form input per layer
│ ├── popup.js # Popup: Generator HTML popup Leaflet per layer
│ ├── card.js # Card: Generator HTML item daftar di sidebar
│ └── legend.js # renderLegend: Fungsi render legenda peta
└── modules/
├── map.js # renderMap: Render semua marker/layer ke peta
├── draw.js # Draw: Integrasi Leaflet Draw (titik, garis, poligon)
├── modal.js # Modal: Manajemen pop-up form tambah/edit
├── sidebar.js # renderSidebar, updateBadges, highlightCard
└── dashboard.js # Dashboard: Kalkulasi analitik & render statistik
```
---
## 💾 Penyimpanan Data (LocalStorage)
Semua data tersimpan di *LocalStorage* browser Anda menggunakan key berikut:
| Layer | LocalStorage Key |
|-------|-----------------|
| SPBU | `wg2_spbu` |
| Jalan | `wg2_jalan` |
| Parsil Tanah | `wg2_parsil` |
| Warga & Bansos | `wg2_warga` |
| Rumah Ibadah | `wg2_ri` |
> **Catatan Penting:** Data tersimpan di browser lokal Anda. Untuk memindahkan data ke perangkat lain, gunakan fitur *Export/Import* browser atau salin nilai dari DevTools > Application > LocalStorage.
---
## 🚀 Cara Menjalankan
Tidak perlu instalasi apapun! Cukup:
1. Unduh atau *clone* repositori ini:
```bash
git clone <url-repo>
```
2. Buka folder `webgis/`.
3. Klik dua kali pada **`index.html`** untuk membukanya langsung di browser.
4. Aplikasi siap digunakan. ✅
> Direkomendasikan menggunakan **Google Chrome** atau **Mozilla Firefox** versi terbaru.
> Anda juga bisa menggunakan ekstensi **Live Server** di VSCode untuk pengalaman pengembangan yang lebih baik.
---
## 🗺️ Panduan Penggunaan
### Menambah Data Baru
1. Pilih layer yang ingin diisi melalui **navigasi tab** di bagian atas (SPBU, Jalan, Parsil, Warga, Rumah Ibadah).
2. Klik tombol **`+ Tambah`** di sidebar kiri.
3. **Klik pada peta** untuk menandai lokasi (untuk Point), atau **gambar garis/poligon** (untuk Jalan/Parsil).
4. Isi formulir atribut yang muncul, lalu klik **Simpan Data**.
### Mengedit atau Menghapus Data
- Klik marker/layer di peta → Popup muncul → Klik **Edit** atau **Hapus**.
- Atau klik kartu di sidebar → gunakan tombol **Edit** / **Hapus** / **Zoom**.
### Melihat Dashboard Analitik
- Klik tombol **📊 Dashboard Analitik** di topbar kanan.
- Tab **Ringkasan Utama**: Statistik jumlah KK, distribusi kemiskinan, status jalan.
- Tab **Distribusi Bansos**: Peta panas jangkauan Rumah Ibadah, beban distribusi per *hub*.
---
## 🛠️ Teknologi yang Digunakan
| Teknologi | Versi | Fungsi |
|-----------|-------|--------|
| HTML5 & CSS3 | — | Struktur & tampilan UI |
| Vanilla JavaScript | ES6+ | Logika aplikasi & state management |
| [Leaflet.js](https://leafletjs.com/) | 1.9.4 | Render peta interaktif |
| [Leaflet Draw](https://github.com/Leaflet/Leaflet.draw) | 1.0.4 | Digitasi titik, garis, dan poligon |
| [DM Sans & DM Mono](https://fonts.google.com/) | — | Tipografi UI |
| LocalStorage API | — | Basis data *client-side* |
| OpenStreetMap | — | Tile peta dasar |
---
## 🔧 Panduan Modifikasi Kode
| Ingin mengubah... | Buka file... |
|-------------------|--------------|
| Warna marker atau label layer | [`js/config/config.js`](js/config/config.js) |
| Isian form input | [`js/components/forms.js`](js/components/forms.js) |
| Tampilan popup peta | [`js/components/popup.js`](js/components/popup.js) |
| Tampilan kartu di sidebar | [`js/components/card.js`](js/components/card.js) |
| Logika & tampilan dashboard | [`js/modules/dashboard.js`](js/modules/dashboard.js) |
| Cara layer dirender di peta | [`js/modules/map.js`](js/modules/map.js) |
| Tema / warna UI global | [`css/style.css`](css/style.css) |
---
## 📄 Lisensi
Proyek ini dibuat untuk keperluan akademik mata kuliah **Sistem Informasi Geografis (SIG)**. Bebas digunakan dan dimodifikasi untuk keperluan non-komersial.
---
*Dikembangkan dengan ❤️ untuk mendukung pemerataan distribusi Bantuan Sosial berbasis data geospasial.*
Submodule pertemuan_3/UAS_SIG added at 30bf02df8b
+420
View File
@@ -0,0 +1,420 @@
/* ═══════════════════════════════════════════
DESIGN TOKENS
═══════════════════════════════════════════ */
:root{
--bg0:#07090f;--bg1:#0d1120;--bg2:#131929;--bg3:#1a2235;--bg4:#212d42;--bg5:#2a3a52;
--border0:#1a2438;--border1:#243045;--border2:#2e3e58;--border3:#3d5070;
--text0:#f0f4ff;--text1:#c8d4ea;--text2:#8fa3c0;--text3:#5a7292;--text4:#3a5070;
--accent:#5b9cf6;--accent2:#3a7de4;--accent-glow:rgba(91,156,246,.18);
--green:#34d47e;--green2:#22aa60;--green-s:rgba(52,212,126,.14);
--red:#f05c5c;--red2:#c83e3e;--red-s:rgba(240,92,92,.14);
--amber:#f0a030;--amber2:#c47c18;--amber-s:rgba(240,160,48,.14);
--purple:#9b7bfa;--purple2:#7a5ae0;--purple-s:rgba(155,123,250,.14);
--teal:#2ec4b6;--teal2:#1ea090;--teal-s:rgba(46,196,182,.14);
--orange:#f07840;--orange2:#c45820;--orange-s:rgba(240,120,64,.14);
--pink:#f06090;--pink-s:rgba(240,96,144,.14);
--font:'DM Sans',system-ui,sans-serif;
--mono:'DM Mono',monospace;
--r4:4px;--r6:6px;--r8:8px;--r10:10px;--r12:12px;--r16:16px;--rfull:9999px;
--shadow-sm:0 2px 8px rgba(0,0,0,.35);
--shadow-md:0 4px 20px rgba(0,0,0,.45);
--shadow-lg:0 8px 40px rgba(0,0,0,.6);
--transition:.15s ease;
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;overflow:hidden;font-family:var(--font)}
body{background:var(--bg0);color:var(--text0);font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased}
button{font-family:var(--font);cursor:pointer;border:none;outline:none;background:none}
input,select,textarea{font-family:var(--font);outline:none}
::-webkit-scrollbar{width:4px;height:4px}
::-webkit-scrollbar-thumb{background:var(--border2);border-radius:var(--rfull)}
/* ═══════════════════════════════════════════
LAYOUT
═══════════════════════════════════════════ */
.app{display:flex;flex-direction:column;height:100vh}
.topbar{height:52px;background:var(--bg1);border-bottom:1px solid var(--border1);
display:flex;align-items:center;padding:0 16px;gap:12px;flex-shrink:0;z-index:500}
.main{display:flex;flex:1;overflow:hidden}
.sidebar{width:320px;background:var(--bg1);border-right:1px solid var(--border0);
display:flex;flex-direction:column;flex-shrink:0;overflow:hidden}
.map-area{flex:1;position:relative}
#map{width:100%;height:100%}
/* ═══════════════════════════════════════════
TOPBAR
═══════════════════════════════════════════ */
.brand{display:flex;align-items:center;gap:10px}
.brand-mark{width:32px;height:32px;border-radius:var(--r8);
background:linear-gradient(135deg,var(--accent),var(--purple));
display:flex;align-items:center;justify-content:center;flex-shrink:0}
.brand-mark svg{width:17px;height:17px;fill:#fff}
.brand-name{font-size:14px;font-weight:700;color:var(--text0);letter-spacing:-.3px;line-height:1.1}
.brand-name span{display:block;font-size:10px;font-weight:400;color:var(--text3);letter-spacing:.2px}
.topbar-div{width:1px;height:26px;background:var(--border1)}
.nav-pills{display:flex;gap:3px}
.nav-pill{display:flex;align-items:center;gap:6px;padding:5px 12px;border-radius:var(--r6);
font-size:12px;font-weight:500;color:var(--text2);transition:background var(--transition),color var(--transition)}
.nav-pill:hover{background:var(--bg3);color:var(--text0)}
.nav-pill.active{background:var(--accent);color:#fff}
.nav-pill.active .pill-badge{background:rgba(255,255,255,.25);color:#fff}
.pill-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
.pill-badge{min-width:17px;height:17px;padding:0 4px;border-radius:var(--rfull);
background:var(--bg4);color:var(--text3);font-size:10px;font-weight:700;
display:inline-flex;align-items:center;justify-content:center}
.topbar-spacer{flex:1}
/* Dashboard toggle */
.dash-btn{display:flex;align-items:center;gap:6px;padding:6px 14px;border-radius:var(--r6);
font-size:12px;font-weight:600;background:var(--purple-s);color:var(--purple);
border:1px solid rgba(155,123,250,.25);transition:background var(--transition)}
.dash-btn:hover{background:rgba(155,123,250,.25)}
.topbar-stat{font-size:11px;color:var(--text3);display:flex;align-items:center;gap:5px}
.stat-dot{width:6px;height:6px;border-radius:50%;background:var(--green)}
/* ═══════════════════════════════════════════
SIDEBAR
═══════════════════════════════════════════ */
.sidebar-head{padding:12px 14px;border-bottom:1px solid var(--border0);
display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
.sidebar-title{font-size:13px;font-weight:700;color:var(--text0)}
.sidebar-sub{font-size:10px;color:var(--text3);margin-top:1px}
.btn-add{display:flex;align-items:center;gap:4px;padding:5px 10px;border-radius:var(--r6);
font-size:11px;font-weight:700;background:var(--accent-glow);color:var(--accent);
border:1px solid rgba(91,156,246,.25);transition:background var(--transition)}
.btn-add:hover{background:rgba(91,156,246,.28)}
.sidebar-body{flex:1;overflow-y:auto;padding:10px}
/* Data Cards */
.dcard{background:var(--bg2);border:1px solid var(--border0);border-radius:var(--r10);
margin-bottom:8px;overflow:hidden;transition:border-color var(--transition)}
.dcard:hover{border-color:var(--border2)}
.dcard.active{border-color:var(--accent)}
.dcard-head{padding:9px 11px;display:flex;align-items:center;gap:8px;cursor:pointer}
.dcard-head:hover{background:var(--bg3)}
.dcard-sym{width:9px;height:9px;flex-shrink:0}
.dcard-sym.circle{border-radius:50%}
.dcard-sym.line{height:4px;border-radius:2px;width:20px}
.dcard-sym.poly{border-radius:2px;width:13px;border:2px solid currentColor;background:transparent}
.dcard-name{font-size:12px;font-weight:600;color:var(--text0);flex:1;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.dcard-attrs{padding:4px 11px 8px;border-top:1px solid var(--border0)}
.attr{display:flex;justify-content:space-between;padding:3px 0;
border-bottom:1px solid var(--border0);font-size:11px}
.attr:last-child{border-bottom:none}
.attr-k{color:var(--text3)}
.attr-v{color:var(--text2);font-weight:500;text-align:right;max-width:60%}
.dcard-acts{display:flex;gap:5px;padding:7px 11px;border-top:1px solid var(--border0);background:var(--bg1)}
/* Badges */
.badge{display:inline-flex;align-items:center;padding:1px 7px;border-radius:var(--rfull);font-size:10px;font-weight:700}
.badge-green {background:var(--green-s);color:var(--green)}
.badge-red {background:var(--red-s);color:var(--red)}
.badge-amber {background:var(--amber-s);color:var(--amber)}
.badge-accent {background:var(--accent-glow);color:var(--accent)}
.badge-purple {background:var(--purple-s);color:var(--purple)}
.badge-teal {background:var(--teal-s);color:var(--teal)}
.badge-orange {background:var(--orange-s);color:var(--orange)}
.badge-pink {background:var(--pink-s);color:var(--pink)}
/* Btn small */
.bsm{padding:3px 9px;border-radius:var(--r4);font-size:10px;font-weight:700;border:1px solid transparent;transition:background var(--transition)}
.bsm-zoom {background:var(--green-s);color:var(--green);border-color:rgba(52,212,126,.2)}
.bsm-zoom:hover{background:rgba(52,212,126,.24)}
.bsm-edit {background:var(--accent-glow);color:var(--accent);border-color:rgba(91,156,246,.2)}
.bsm-edit:hover{background:rgba(91,156,246,.25)}
.bsm-del {background:var(--red-s);color:var(--red);border-color:rgba(240,92,92,.2)}
.bsm-del:hover{background:rgba(240,92,92,.24)}
/* Empty state */
.empty{text-align:center;padding:48px 20px;color:var(--text4)}
.empty svg{margin:0 auto 12px;display:block;opacity:.2}
.empty p{font-size:12px;line-height:1.7}
/* ═══════════════════════════════════════════
MAP OVERLAYS
═══════════════════════════════════════════ */
.map-legend{position:absolute;bottom:20px;left:12px;z-index:800;
background:var(--bg1);border:1px solid var(--border1);border-radius:var(--r10);
padding:10px 13px;min-width:160px;box-shadow:var(--shadow-md)}
.legend-title{font-size:9px;font-weight:700;color:var(--text3);
text-transform:uppercase;letter-spacing:.8px;margin-bottom:8px}
.legend-row{display:flex;align-items:center;gap:8px;margin-bottom:5px;
font-size:11px;color:var(--text2)}
.legend-row:last-child{margin-bottom:0}
.leg-c{width:10px;height:10px;border-radius:50%;flex-shrink:0}
.leg-l{width:20px;height:4px;border-radius:2px;flex-shrink:0}
.leg-p{width:13px;height:11px;border-radius:2px;flex-shrink:0;border:2px solid}
.draw-bar{position:absolute;bottom:20px;left:50%;transform:translateX(-50%);z-index:800;
background:var(--bg1);border:1px solid var(--border2);border-radius:var(--rfull);
padding:7px 16px;font-size:11px;font-weight:600;color:var(--text0);
display:flex;align-items:center;gap:10px;box-shadow:var(--shadow-md);white-space:nowrap}
.draw-bar.hidden{display:none}
.draw-pulse{width:7px;height:7px;border-radius:50%;background:var(--green);flex-shrink:0;
animation:pulse 1.5s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.4;transform:scale(.75)}}
.btn-cancel-draw{background:var(--red-s);color:var(--red);border:1px solid rgba(240,92,92,.2);
border-radius:var(--r4);padding:3px 9px;font-size:10px;font-weight:700;transition:background var(--transition)}
.btn-cancel-draw:hover{background:rgba(240,92,92,.24)}
/* Leaflet popup */
.leaflet-popup-content-wrapper{background:var(--bg2)!important;border:1px solid var(--border2)!important;
border-radius:var(--r10)!important;box-shadow:var(--shadow-lg)!important;color:var(--text0)!important}
.leaflet-popup-tip{background:var(--bg2)!important}
.leaflet-popup-content{margin:0!important;padding:0!important}
.leaflet-popup-close-button{color:var(--text2)!important;font-size:14px!important;top:8px!important;right:10px!important}
.popup-wrap{padding:12px 14px;min-width:220px;max-width:300px}
.popup-title{font-size:13px;font-weight:700;color:var(--text0);margin-bottom:8px;
display:flex;align-items:center;gap:7px}
.popup-sym{width:10px;height:10px;border-radius:50%;flex-shrink:0}
.popup-section{font-size:9px;font-weight:700;color:var(--text4);text-transform:uppercase;
letter-spacing:.8px;margin:8px 0 4px;padding-bottom:4px;border-bottom:1px solid var(--border0)}
.popup-row{display:flex;justify-content:space-between;gap:10px;padding:3px 0;
border-bottom:1px solid var(--border0);font-size:11px}
.popup-row:last-of-type{border-bottom:none}
.popup-k{color:var(--text3)}
.popup-v{color:var(--text1);font-weight:500;text-align:right;max-width:55%}
.popup-acts{display:flex;gap:5px;margin-top:9px;padding-top:8px;border-top:1px solid var(--border0)}
/* ═══════════════════════════════════════════
MODAL
═══════════════════════════════════════════ */
.modal-ov{position:fixed;inset:0;background:rgba(0,0,0,.72);z-index:9999;
display:flex;align-items:center;justify-content:center;backdrop-filter:blur(4px);
animation:fadeIn .15s ease}
.modal-ov.hidden{display:none}
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
.modal{background:var(--bg2);border:1px solid var(--border2);border-radius:var(--r16);
width:520px;max-width:calc(100vw - 24px);max-height:88vh;overflow-y:auto;
box-shadow:var(--shadow-lg);animation:mIn .18s ease}
@keyframes mIn{from{transform:translateY(-10px) scale(.97);opacity:0}to{transform:none;opacity:1}}
.modal-head{padding:16px 18px 12px;border-bottom:1px solid var(--border0);
display:flex;align-items:center;justify-content:space-between;
position:sticky;top:0;background:var(--bg2);z-index:1}
.modal-head-left{display:flex;align-items:center;gap:10px}
.modal-icon{width:30px;height:30px;border-radius:var(--r8);display:flex;align-items:center;justify-content:center;font-size:15px}
.modal-title{font-size:14px;font-weight:700;color:var(--text0)}
.modal-sub{font-size:10px;color:var(--text3);margin-top:1px}
.modal-close{width:26px;height:26px;border-radius:var(--r6);background:var(--bg3);
border:1px solid var(--border1);color:var(--text2);font-size:12px;
display:flex;align-items:center;justify-content:center;transition:background var(--transition)}
.modal-close:hover{background:var(--bg4);color:var(--text0)}
.modal-body{padding:16px 18px}
.modal-foot{padding:12px 18px;border-top:1px solid var(--border0);
display:flex;gap:8px;justify-content:flex-end}
/* Form */
.form-sect{font-size:9px;font-weight:700;color:var(--text4);text-transform:uppercase;
letter-spacing:.8px;margin:14px 0 8px;padding-bottom:6px;border-bottom:1px solid var(--border0)}
.form-sect:first-child{margin-top:0}
.form-row{display:grid;gap:10px;margin-bottom:10px}
.form-row.cols2{grid-template-columns:1fr 1fr}
.form-row.cols3{grid-template-columns:1fr 1fr 1fr}
.fg{display:flex;flex-direction:column;gap:5px}
.flabel{font-size:11px;font-weight:600;color:var(--text2);letter-spacing:.2px}
.flabel .req{color:var(--red);margin-left:2px}
.finput{padding:8px 10px;background:var(--bg3);border:1px solid var(--border1);
border-radius:var(--r6);color:var(--text0);font-size:12px;font-family:var(--font);
transition:border-color var(--transition),box-shadow var(--transition);width:100%}
.finput:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(91,156,246,.12)}
.finput:disabled{opacity:.45;cursor:not-allowed}
.finput[readonly]{background:var(--bg4);color:var(--text2);cursor:default}
select.finput{cursor:pointer}
select.finput option{background:var(--bg3)}
textarea.finput{resize:vertical;min-height:64px}
.info-box{background:var(--bg4);border:1px solid var(--border1);border-radius:var(--r6);
padding:8px 10px;margin-bottom:10px;font-size:11px;color:var(--text2);line-height:1.6}
.info-box strong{color:var(--text0)}
.info-box.warn{border-color:rgba(240,160,48,.3);background:var(--amber-s)}
/* Radio toggle */
.rtoggle{display:flex;gap:6px}
.ropt{flex:1;display:flex;align-items:center;justify-content:center;gap:5px;
padding:8px;border-radius:var(--r6);border:1px solid var(--border1);cursor:pointer;
font-size:11px;font-weight:600;transition:all var(--transition);color:var(--text3);
background:var(--bg3);user-select:none}
.ropt input{display:none}
.ropt.yes{border-color:rgba(52,212,126,.35);color:var(--green);background:var(--green-s)}
.ropt.no {border-color:rgba(240,92,92,.35);color:var(--red);background:var(--red-s)}
/* Buttons */
.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;
padding:8px 18px;border-radius:var(--r6);font-size:13px;font-weight:600;
font-family:var(--font);transition:background var(--transition)}
.btn-primary{background:var(--accent);color:#fff}
.btn-primary:hover{background:var(--accent2)}
.btn-ghost{background:var(--bg3);color:var(--text2);border:1px solid var(--border1)}
.btn-ghost:hover{background:var(--bg4);color:var(--text0)}
.hidden{display:none!important}
/* ═══════════════════════════════════════════
DASHBOARD PANEL
═══════════════════════════════════════════ */
.dash-panel{position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:8000;
display:flex;align-items:stretch;justify-content:flex-end;backdrop-filter:blur(4px);
animation:fadeIn .15s ease}
.dash-panel.hidden{display:none}
.dash-inner{width:680px;max-width:95vw;background:var(--bg1);border-left:1px solid var(--border1);
display:flex;flex-direction:column;overflow:hidden}
.dash-head{padding:18px 20px;border-bottom:1px solid var(--border0);
display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
.dash-head-title{font-size:16px;font-weight:700;color:var(--text0)}
.dash-head-sub{font-size:11px;color:var(--text3);margin-top:2px}
.dash-body{flex:1;overflow-y:auto;padding:18px 20px}
/* Stat cards */
.stat-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:18px}
.stat-card{background:var(--bg2);border:1px solid var(--border0);border-radius:var(--r10);
padding:12px 14px;position:relative;overflow:hidden}
.stat-card::before{content:'';position:absolute;top:0;left:0;right:0;height:2px}
.stat-card.c-accent::before{background:var(--accent)}
.stat-card.c-green::before{background:var(--green)}
.stat-card.c-amber::before{background:var(--amber)}
.stat-card.c-purple::before{background:var(--purple)}
.stat-card.c-red::before{background:var(--red)}
.stat-card.c-teal::before{background:var(--teal)}
.sc-val{font-size:26px;font-weight:700;color:var(--text0);font-family:var(--mono);line-height:1}
.sc-label{font-size:10px;color:var(--text3);margin-top:4px}
.sc-sub{font-size:11px;color:var(--text2);margin-top:6px}
/* Dash sections */
.dash-sect{margin-bottom:20px}
.dash-sect-title{font-size:11px;font-weight:700;color:var(--text2);
text-transform:uppercase;letter-spacing:.6px;margin-bottom:10px;
padding-bottom:6px;border-bottom:1px solid var(--border0)}
.risk-table{width:100%;border-collapse:collapse;font-size:11px}
.risk-table th{text-align:left;padding:6px 10px;color:var(--text3);font-weight:600;
border-bottom:1px solid var(--border0);font-size:10px}
.risk-table td{padding:7px 10px;border-bottom:1px solid var(--border0);color:var(--text1)}
.risk-table tr:last-child td{border-bottom:none}
.risk-table tr:hover td{background:var(--bg2)}
.risk-bar-wrap{display:flex;align-items:center;gap:7px}
.risk-bar{height:5px;border-radius:3px;background:var(--bg4);flex:1}
.risk-bar-fill{height:100%;border-radius:3px;transition:width .3s ease}
.prog-row{display:flex;align-items:center;justify-content:space-between;
padding:6px 0;border-bottom:1px solid var(--border0);font-size:11px}
.prog-row:last-child{border-bottom:none}
.prog-name{color:var(--text1)}
.prog-count{font-family:var(--mono);font-weight:600;color:var(--text0)}
.intervensi-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.intervensi-card{background:var(--bg2);border:1px solid var(--border0);border-radius:var(--r8);padding:10px 12px}
.ic-label{font-size:10px;color:var(--text3);margin-bottom:4px}
.ic-val{font-size:13px;font-weight:700;color:var(--text0)}
.ic-sub{font-size:10px;color:var(--text2);margin-top:3px}
/* Toast */
.toasts{position:fixed;bottom:22px;right:22px;z-index:10000;display:flex;flex-direction:column;gap:7px;pointer-events:none}
.toast{background:var(--bg2);border:1px solid var(--border2);border-radius:var(--r10);
padding:10px 14px;font-size:12px;color:var(--text0);box-shadow:var(--shadow-lg);
display:flex;align-items:center;gap:9px;min-width:240px;pointer-events:all;
animation:tIn .2s ease}
.toast.success{border-left:3px solid var(--green)}
.toast.error{border-left:3px solid var(--red)}
.toast.info{border-left:3px solid var(--accent)}
@keyframes tIn{from{transform:translateX(110%);opacity:0}to{transform:none;opacity:1}}
@keyframes tOut{from{transform:none;opacity:1}to{transform:translateX(110%);opacity:0}}
.toast.out{animation:tOut .2s ease forwards}
/* ═══════════════════════════════════════════
BANSOS DISTRIBUTION SYSTEM
═══════════════════════════════════════════ */
/* History table (multi-entry) inside modal */
.history-table{width:100%;border-collapse:collapse;margin-top:6px;font-size:11px}
.history-table th{text-align:left;padding:5px 8px;color:var(--text3);font-weight:600;
font-size:10px;border-bottom:1px solid var(--border1);background:var(--bg3)}
.history-table td{padding:6px 8px;border-bottom:1px solid var(--border0);color:var(--text1)}
.history-table tr:last-child td{border-bottom:none}
.history-table tr:hover td{background:var(--bg3)}
.history-table .status-aktif{color:var(--green);font-weight:700}
.history-table .status-berhenti{color:var(--red);font-weight:700}
.history-table .status-proses{color:var(--amber);font-weight:700}
/* Dynamic add-row button */
.btn-add-row{display:inline-flex;align-items:center;gap:4px;margin-top:6px;
padding:4px 10px;border-radius:var(--r4);font-size:10px;font-weight:700;
background:var(--green-s);color:var(--green);border:1px solid rgba(52,212,126,.25);
cursor:pointer;transition:background var(--transition)}
.btn-add-row:hover{background:rgba(52,212,126,.24)}
.btn-del-row{padding:2px 7px;border-radius:var(--r4);font-size:10px;font-weight:700;
background:var(--red-s);color:var(--red);border:1px solid rgba(240,92,92,.2);
cursor:pointer;transition:background var(--transition)}
.btn-del-row:hover{background:rgba(240,92,92,.24)}
/* Radius coverage indicator in popup */
.coverage-badge{display:inline-flex;align-items:center;gap:5px;padding:3px 9px;
border-radius:var(--rfull);font-size:10px;font-weight:700;margin-top:4px}
.coverage-badge.covered{background:var(--green-s);color:var(--green);border:1px solid rgba(52,212,126,.25)}
.coverage-badge.uncovered{background:var(--red-s);color:var(--red);border:1px solid rgba(240,92,92,.25)}
.coverage-badge svg{width:10px;height:10px;fill:currentColor;flex-shrink:0}
/* Radius circle legend */
.legend-radius{display:flex;align-items:center;gap:7px;margin-top:6px;
padding-top:6px;border-top:1px solid var(--border0);font-size:10px;color:var(--text3)}
.leg-radius-sym{width:14px;height:14px;border-radius:50%;border:2px dashed var(--teal);
background:rgba(46,196,182,.08);flex-shrink:0}
/* Dashboard distribution tab */
.dash-tab-bar{display:flex;gap:3px;margin-bottom:14px;background:var(--bg2);
border:1px solid var(--border0);border-radius:var(--r8);padding:3px}
.dash-tab{flex:1;padding:6px 12px;border-radius:var(--r6);font-size:11px;font-weight:600;
color:var(--text3);cursor:pointer;transition:all var(--transition);text-align:center}
.dash-tab.active{background:var(--accent);color:#fff}
.dash-tab:not(.active):hover{background:var(--bg3);color:var(--text1)}
/* Coverage summary cards */
.coverage-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:16px}
.coverage-card{background:var(--bg2);border:1px solid var(--border0);border-radius:var(--r10);
padding:12px 14px;display:flex;align-items:center;gap:12px}
.cc-icon{width:34px;height:34px;border-radius:var(--r8);display:flex;align-items:center;
justify-content:center;font-size:16px;flex-shrink:0}
.cc-icon.green{background:var(--green-s)}
.cc-icon.red{background:var(--red-s)}
.cc-icon.amber{background:var(--amber-s)}
.cc-icon.teal{background:var(--teal-s)}
.cc-info{min-width:0}
.cc-val{font-size:20px;font-weight:700;color:var(--text0);font-family:var(--mono)}
.cc-label{font-size:10px;color:var(--text3);margin-top:1px}
/* Hub assignment table */
.hub-table{width:100%;border-collapse:collapse;font-size:11px}
.hub-table th{text-align:left;padding:7px 10px;color:var(--text3);font-weight:600;
font-size:10px;border-bottom:1px solid var(--border1);background:var(--bg2);
position:sticky;top:0}
.hub-table td{padding:7px 10px;border-bottom:1px solid var(--border0);color:var(--text1)}
.hub-table tr:last-child td{border-bottom:none}
.hub-table tr:hover td{background:var(--bg2)}
.hub-name-cell{display:flex;align-items:center;gap:7px}
.hub-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.hub-count-bar{display:flex;align-items:center;gap:7px}
.hub-bar{height:5px;border-radius:3px;background:var(--bg4);flex:1;min-width:60px}
.hub-bar-fill{height:100%;border-radius:3px;transition:width .4s ease}
/* Multi-section form tab inside modal */
.form-tabs{display:flex;gap:2px;background:var(--bg3);border-radius:var(--r6);
padding:3px;margin-bottom:14px}
.form-tab{flex:1;padding:5px 8px;border-radius:var(--r4);font-size:10px;font-weight:700;
color:var(--text3);cursor:pointer;transition:all var(--transition);text-align:center}
.form-tab.active{background:var(--accent);color:#fff}
.form-tab:not(.active):hover{background:var(--bg4);color:var(--text1)}
.form-section-panel{display:none}
.form-section-panel.active{display:block}
/* Pendidikan tag badges */
.pendidikan-grid{display:flex;flex-wrap:wrap;gap:5px;margin-top:4px}
.pddk-tag{display:inline-flex;align-items:center;gap:4px;padding:3px 8px;
border-radius:var(--rfull);font-size:10px;font-weight:600;cursor:pointer;
border:1px solid var(--border1);background:var(--bg3);color:var(--text3);
transition:all var(--transition);user-select:none}
.pddk-tag.selected{background:var(--accent-glow);border-color:rgba(91,156,246,.4);color:var(--accent)}
/* Penyakit checklist */
.penyakit-grid{display:grid;grid-template-columns:1fr 1fr;gap:5px;margin-top:4px}
.penyakit-check{display:flex;align-items:center;gap:6px;padding:5px 8px;
border-radius:var(--r6);border:1px solid var(--border0);background:var(--bg3);
cursor:pointer;font-size:11px;color:var(--text2);transition:all var(--transition);user-select:none}
.penyakit-check input{accent-color:var(--red);width:12px;height:12px}
.penyakit-check.checked{border-color:rgba(240,92,92,.35);background:var(--red-s);color:var(--red)}
/* Badge pink (already in root vars) */
.badge-pink{background:var(--pink-s);color:var(--pink)}
+4 -3
View File
@@ -5,7 +5,7 @@ const App = {
state:{ activeLayer:'spbu' }, state:{ activeLayer:'spbu' },
_groups:{}, _groups:{},
init(){ async init(){
// Map // Map
this._map=L.map('map',{zoomControl:false}).setView([-0.056031,109.348641],13); this._map=L.map('map',{zoomControl:false}).setView([-0.056031,109.348641],13);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',{ L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',{
@@ -49,14 +49,15 @@ const App = {
document.getElementById('modal-close').addEventListener('click', Modal.close); document.getElementById('modal-close').addEventListener('click', Modal.close);
document.getElementById('modal-ov').addEventListener('click',e=>{ if(e.target===e.currentTarget) Modal.close(); }); document.getElementById('modal-ov').addEventListener('click',e=>{ if(e.target===e.currentTarget) Modal.close(); });
await Store.load();
this.refresh(); this.refresh();
}, },
crud:{ crud:{
edit(ln,id){ App.state.activeLayer=ln; Modal.openEdit(ln,id); }, edit(ln,id){ App.state.activeLayer=ln; Modal.openEdit(ln,id); },
delete(ln,id){ async delete(ln,id){
if(!confirm('Yakin hapus data ini?')) return; if(!confirm('Yakin hapus data ini?')) return;
Store.remove(ln,id); App._map.closePopup(); await Store.remove(ln,id); App._map.closePopup();
App.refresh(); Toast.info('Data dihapus'); App.refresh(); Toast.info('Data dihapus');
}, },
}, },
+63
View File
@@ -0,0 +1,63 @@
/* ═══════════════════════════════════════════════════════════
7. CARD BUILDERS (sidebar)
═══════════════════════════════════════════════════════════ */
const Card = {
_acts:(ln,id)=>`<div class="dcard-acts">
<button class="bsm bsm-zoom" onclick="App.map.zoomTo('${ln}','${id}')">Zoom</button>
<button class="bsm bsm-edit" onclick="App.crud.edit('${ln}','${id}')">Edit</button>
<button class="bsm bsm-del" onclick="App.crud.delete('${ln}','${id}')">Hapus</button></div>`,
spbu(r){ const clr=CFG.spbu.color(r),bdg=CFG.spbu.badge(r);
return `<div class="dcard" id="card-${r.id}">
<div class="dcard-head" onclick="App.map.zoomTo('spbu','${r.id}')">
<span class="dcard-sym circle" style="background:${clr}"></span>
<span class="dcard-name">${H.esc(r.nama)}</span><span class="badge ${bdg.c}">${bdg.t}</span></div>
<div class="dcard-attrs">
<div class="attr"><span class="attr-k">WA</span><span class="attr-v">${H.esc(r.wa||'-')}</span></div>
<div class="attr"><span class="attr-k">BBM</span><span class="attr-v">${H.esc(r.jenis_bbm||'-')}</span></div>
</div>${this._acts('spbu',r.id)}</div>`; },
jalan(r){ const clr=CFG.jalan.color(r),bdg=CFG.jalan.badge(r);
return `<div class="dcard" id="card-${r.id}">
<div class="dcard-head" onclick="App.map.zoomTo('jalan','${r.id}')">
<span class="dcard-sym line" style="background:${clr}"></span>
<span class="dcard-name">${H.esc(r.nama)}</span><span class="badge ${bdg.c}">${bdg.t}</span></div>
<div class="dcard-attrs">
<div class="attr"><span class="attr-k">Panjang</span><span class="attr-v">${H.fmDist(r.panjang)}</span></div>
<div class="attr"><span class="attr-k">Kondisi</span><span class="attr-v">${H.esc(r.kondisi||'-')}</span></div>
</div>${this._acts('jalan',r.id)}</div>`; },
parsil(r){ const clr=CFG.parsil.color(r),bdg=CFG.parsil.badge(r);
return `<div class="dcard" id="card-${r.id}">
<div class="dcard-head" onclick="App.map.zoomTo('parsil','${r.id}')">
<span class="dcard-sym poly" style="color:${clr};height:9px"></span>
<span class="dcard-name">${H.esc(r.nama)}</span><span class="badge ${bdg.c}">${bdg.t}</span></div>
<div class="dcard-attrs">
<div class="attr"><span class="attr-k">Luas</span><span class="attr-v">${H.fmArea(r.luas)}</span></div>
<div class="attr"><span class="attr-k">Sengketa</span><span class="attr-v">${H.esc(r.sengketa||'-')}</span></div>
</div>${this._acts('parsil',r.id)}</div>`; },
warga(r){ const clr=CFG.warga.color(r),bdg=CFG.warga.badge(r);
const pendapatan=r.pendapatan?'Rp '+parseInt(r.pendapatan).toLocaleString('id-ID'):'-';
const histCount=r.history_bansos?.length||0;
return `<div class="dcard" id="card-${r.id}">
<div class="dcard-head" onclick="App.map.zoomTo('warga','${r.id}')">
<span class="dcard-sym circle" style="background:${clr}"></span>
<span class="dcard-name">${H.esc(r.nama)}</span><span class="badge ${bdg.c}">${bdg.t}</span></div>
<div class="dcard-attrs">
<div class="attr"><span class="attr-k">Anggota</span><span class="attr-v">${r.jml_anggota||'-'} jiwa</span></div>
<div class="attr"><span class="attr-k">Pendapatan</span><span class="attr-v">${pendapatan}</span></div>
<div class="attr"><span class="attr-k">Bansos</span><span class="attr-v">${H.esc(r.bansos||'-')}</span></div>
<div class="attr"><span class="attr-k">Riwayat</span><span class="attr-v">${histCount} program</span></div>
</div>${this._acts('warga',r.id)}</div>`; },
rumahIbadah(r){ const clr=CFG.rumahIbadah.color(r),bdg=CFG.rumahIbadah.badge(r);
return `<div class="dcard" id="card-${r.id}">
<div class="dcard-head" onclick="App.map.zoomTo('rumahIbadah','${r.id}')">
<span class="dcard-sym circle" style="background:${clr}"></span>
<span class="dcard-name">${H.esc(r.nama)}</span><span class="badge ${bdg.c}">${bdg.t}</span></div>
<div class="dcard-attrs">
<div class="attr"><span class="attr-k">Ketua</span><span class="attr-v">${H.esc(r.ketua||'-')}</span></div>
<div class="attr"><span class="attr-k">Binaan</span><span class="attr-v">${r.jml_binaan||'-'} KK</span></div>
</div>${this._acts('rumahIbadah',r.id)}</div>`; },
};
+361
View File
@@ -0,0 +1,361 @@
/* ═══════════════════════════════════════════════════════════
5. FORM BUILDERS
═══════════════════════════════════════════════════════════ */
const Forms = {
spbu(rec, latlng){
const lat=latlng?latlng.lat.toFixed(6):(rec?.lat?.toFixed(6)||'');
const lng=latlng?latlng.lng.toFixed(6):(rec?.lng?.toFixed(6)||'');
const v=f=>rec?H.esc(rec[f]||''):'';
return `
<div class="info-box">📍 Koordinat: <strong>${lat}, ${lng}</strong></div>
<div class="form-sect">Identitas & Operasional</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Nama SPBU<span class="req">*</span></label>
<input class="finput" id="f-nama" placeholder="cth: SPBU 64.754.01" value="${v('nama')}"/></div>
<div class="fg"><label class="flabel">No. WhatsApp</label>
<input class="finput" id="f-wa" placeholder="081234..." value="${v('wa')}"/></div>
</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Jenis BBM Tersedia</label>
<select class="finput" id="f-jenis_bbm">
${['Pertalite & Pertamax','Pertalite Saja','Pertamax Series','Solar & Pertamina Dex','Semua Jenis'].map(j=>`<option ${v('jenis_bbm')===j?'selected':''}>${j}</option>`).join('')}
</select></div>
<div class="fg"><label class="flabel">Buka 24 Jam?</label>
<div class="rtoggle" id="rtog">
<label class="ropt ${rec?.buka24!==false?'yes':''}" data-v="true">
<input type="radio" name="buka24" value="true" ${rec?.buka24!==false?'checked':''}/>✓ Ya
</label>
<label class="ropt ${rec?.buka24===false?'no':''}" data-v="false">
<input type="radio" name="buka24" value="false" ${rec?.buka24===false?'checked':''}/>✗ Tidak
</label>
</div></div>
</div>
<div class="form-sect">Aksesibilitas & Sosial</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Pelayanan Disabilitas</label>
<select class="finput" id="f-disabilitas">
${['Ada','Tidak Ada','Dalam Proses'].map(j=>`<option ${v('disabilitas')===j?'selected':''}>${j}</option>`).join('')}
</select></div>
<div class="fg"><label class="flabel">Harga BBM Subsidi</label>
<select class="finput" id="f-harga_bbm">
${['Sesuai HET','Di Atas HET','Tidak Menjual Subsidi'].map(j=>`<option ${v('harga_bbm')===j?'selected':''}>${j}</option>`).join('')}
</select></div>
</div>
<div class="form-sect">Catatan Lapangan</div>
<div class="fg"><label class="flabel">Catatan / Keterangan Tambahan</label>
<textarea class="finput" id="f-catatan" placeholder="Kondisi antrean, ketersediaan stok, dll...">${v('catatan')}</textarea></div>`;
},
jalan(rec, len){
const pj=len??rec?.panjang??0; const v=f=>rec?H.esc(rec[f]||''):'';
return `
<div class="info-box">📏 Panjang terukur otomatis: <strong>${H.fmDist(pj)}</strong></div>
<input type="hidden" id="f-panjang" value="${pj}"/>
<div class="form-sect">Identitas Ruas Jalan</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Nama Ruas Jalan<span class="req">*</span></label>
<input class="finput" id="f-nama" placeholder="cth: Jl. H. Rais A Rahman" value="${v('nama')}"/></div>
<div class="fg"><label class="flabel">Status Jalan<span class="req">*</span></label>
<select class="finput" id="f-status">
${CFG.jalan.statuses.map(s=>`<option ${v('status')===s?'selected':''}>${s}</option>`).join('')}
</select></div>
</div>
<div class="form-sect">Kondisi & Infrastruktur</div>
<div class="form-row cols3">
<div class="fg"><label class="flabel">Kondisi Jalan</label>
<select class="finput" id="f-kondisi">
${CFG.jalan.kondisi.map(k=>`<option ${v('kondisi')===k?'selected':''}>${k}</option>`).join('')}
</select></div>
<div class="fg"><label class="flabel">Tahun Perbaikan Terakhir</label>
<input class="finput" id="f-tahun_perbaikan" type="number" min="2000" max="2030" placeholder="2023" value="${v('tahun_perbaikan')}"/></div>
<div class="fg"><label class="flabel">Lebar Jalan (meter)</label>
<input class="finput" id="f-lebar" type="number" min="1" max="50" placeholder="4" value="${v('lebar')}"/></div>
</div>
<div class="form-sect">Dampak Sosial-Ekonomi</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Dilalui Angkutan Umum</label>
<select class="finput" id="f-angkutan">
${['Ya','Tidak'].map(k=>`<option ${v('angkutan')===k?'selected':''}>${k}</option>`).join('')}
</select></div>
<div class="fg"><label class="flabel">Akses ke Fasilitas Publik</label>
<select class="finput" id="f-akses_publik">
${['Pasar','RS/Puskesmas','Sekolah','Kantor Pemerintah','Tidak Ada'].map(k=>`<option ${v('akses_publik')===k?'selected':''}>${k}</option>`).join('')}
</select></div>
</div>
<div class="fg"><label class="flabel">Catatan Kondisi</label>
<textarea class="finput" id="f-catatan" placeholder="Kondisi berlubang, banjir saat hujan, dll...">${v('catatan')}</textarea></div>`;
},
parsil(rec, area){
const ls=area??rec?.luas??0; const v=f=>rec?H.esc(rec[f]||''):'';
return `
<div class="info-box">📐 Luas terukur otomatis: <strong>${H.fmArea(ls)}</strong></div>
<input type="hidden" id="f-luas" value="${ls}"/>
<div class="form-sect">Identitas Parsil</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Nama / ID Parsil<span class="req">*</span></label>
<input class="finput" id="f-nama" placeholder="cth: Kavling A-01" value="${v('nama')}"/></div>
<div class="fg"><label class="flabel">Nomor Induk Bidang (NIB)</label>
<input class="finput" id="f-nib" placeholder="14.02.01.001" value="${v('nib')}"/></div>
</div>
<div class="form-sect">Status Kepemilikan</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Status Sertifikat<span class="req">*</span></label>
<select class="finput" id="f-status">
${CFG.parsil.statuses.map(s=>`<option ${v('status')===s?'selected':''}>${s}</option>`).join('')}
</select></div>
<div class="fg"><label class="flabel">Sengketa / Masalah Hukum</label>
<select class="finput" id="f-sengketa">
${['Tidak Ada','Ada Sengketa','Dalam Proses PTSL','Klaim Pihak Lain'].map(k=>`<option ${v('sengketa')===k?'selected':''}>${k}</option>`).join('')}
</select></div>
</div>
<div class="form-sect">Penilaian & Pemanfaatan</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">NJOP (Rp/m²)</label>
<input class="finput" id="f-njop" type="number" placeholder="500000" value="${v('njop')}"/></div>
<div class="fg"><label class="flabel">Penggunaan Lahan</label>
<select class="finput" id="f-penggunaan">
${['Rumah Tinggal','Lahan Kosong','Pertanian','Usaha/Warung','Fasilitas Umum','Lainnya'].map(k=>`<option ${v('penggunaan')===k?'selected':''}>${k}</option>`).join('')}
</select></div>
</div>
<div class="fg"><label class="flabel">Catatan Tambahan</label>
<textarea class="finput" id="f-catatan" placeholder="Kondisi, penghuni, riwayat...">${v('catatan')}</textarea></div>`;
},
warga(rec, latlng){
const lat=latlng?latlng.lat.toFixed(6):(rec?.lat?.toFixed(6)||'');
const lng=latlng?latlng.lng.toFixed(6):(rec?.lng?.toFixed(6)||'');
const v=f=>rec?H.esc(rec[f]||''):'';
const c=v=>rec?.penyakit?.includes(v)?'checked':'';
const hist=rec?.history_bansos||[];
const ri = Store.all('rumahIbadah');
const hubOpts = ['Belum ditugaskan', ...ri.map(r=>r.nama)].map(opt=>`<option ${v('hub_ri')===opt?'selected':''}>${opt}</option>`).join('');
return `
<div class="info-box warn">⚠️ Data warga bersifat sensitif. Pastikan survei akurat.</div>
<div class="info-box">📍 Koordinat: <strong>${lat}, ${lng}</strong></div>
<div class="form-tabs">
<div class="form-tab active" data-panel="kk">Data KK</div>
<div class="form-tab" data-panel="eko">Ekonomi</div>
<div class="form-tab" data-panel="pddk">Pddk</div>
<div class="form-tab" data-panel="kes">Kes</div>
<div class="form-tab" data-panel="hist">Bansos</div>
</div>
<!-- TAB: Data KK -->
<div class="form-section-panel active" id="panel-kk">
<div class="form-row cols2">
<div class="fg"><label class="flabel">Nama KK<span class="req">*</span></label>
<input class="finput" id="f-nama" placeholder="Nama KTP" value="${v('nama')}"/></div>
<div class="fg"><label class="flabel">NIK</label>
<input class="finput" id="f-nik" placeholder="3471..." maxlength="16" value="${v('nik')}"/></div>
</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">No. KK</label>
<input class="finput" id="f-no_kk" value="${v('no_kk')}"/></div>
<div class="fg"><label class="flabel">WA/HP</label>
<input class="finput" id="f-wa" value="${v('wa')}"/></div>
</div>
<div class="form-row cols3">
<div class="fg"><label class="flabel">Tingkat Kemiskinan</label>
<select class="finput" id="f-tingkat_kemiskinan">
${CFG.warga.tingkat.map(t=>`<option ${v('tingkat_kemiskinan')===t?'selected':''}>${t}</option>`).join('')}
</select></div>
<div class="fg"><label class="flabel">Jml Anggota</label>
<input class="finput" id="f-jml_anggota" type="number" placeholder="4" value="${v('jml_anggota')}"/></div>
<div class="fg"><label class="flabel">Jml Lansia</label>
<input class="finput" id="f-jml_lansia" type="number" placeholder="0" value="${v('jml_lansia')}"/></div>
</div>
<div class="fg"><label class="flabel">Alamat</label>
<textarea class="finput" id="f-alamat" style="min-height:40px">${v('alamat')}</textarea></div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Penerima Bansos</label>
<select class="finput" id="f-bansos">
${CFG.warga.bantuan.map(b=>`<option ${v('bansos')===b?'selected':''}>${b}</option>`).join('')}
</select></div>
<div class="fg"><label class="flabel">Hub Rumah Ibadah</label>
<select class="finput" id="f-hub_ri">${hubOpts}</select></div>
</div>
</div>
<!-- TAB: Ekonomi -->
<div class="form-section-panel" id="panel-eko">
<div class="form-row cols2">
<div class="fg"><label class="flabel">Pendapatan (Rp/Bln)</label>
<input class="finput" id="f-pendapatan" type="number" value="${v('pendapatan')}"/></div>
<div class="fg"><label class="flabel">Pekerjaan KK</label>
<select class="finput" id="f-pekerjaan">
${CFG.warga.pekerjaan.map(p=>`<option ${v('pekerjaan')===p?'selected':''}>${p}</option>`).join('')}
</select></div>
</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Kondisi Rumah</label>
<select class="finput" id="f-kondisi_rumah">
${CFG.warga.kondisiRumah.map(k=>`<option ${v('kondisi_rumah')===k?'selected':''}>${k}</option>`).join('')}
</select></div>
<div class="fg"><label class="flabel">Aset (Tanah/Kendaraan)</label>
<select class="finput" id="f-kepemilikan_aset">
${['Tidak Ada','Motor Tua','Motor Baru','Tanah','Mobil'].map(j=>`<option ${v('kepemilikan_aset')===j?'selected':''}>${j}</option>`).join('')}
</select></div>
</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Air Bersih</label>
<select class="finput" id="f-air_bersih">
${['PDAM','Sumur','Sungai/Tadah Hujan'].map(j=>`<option ${v('air_bersih')===j?'selected':''}>${j}</option>`).join('')}
</select></div>
<div class="fg"><label class="flabel">Listrik</label>
<select class="finput" id="f-listrik">
${['Sendiri (>900W)','Subsidi (450/900W)','Numpang/Tidak Ada'].map(j=>`<option ${v('listrik')===j?'selected':''}>${j}</option>`).join('')}
</select></div>
</div>
</div>
<!-- TAB: Pendidikan -->
<div class="form-section-panel" id="panel-pddk">
<div class="form-row cols2">
<div class="fg"><label class="flabel">Pendidikan Terakhir KK</label>
<select class="finput" id="f-pendidikan_kk">
${CFG.warga.tingkatPendidikan.map(j=>`<option ${v('pendidikan_kk')===j?'selected':''}>${j}</option>`).join('')}
</select></div>
<div class="fg"><label class="flabel">Anak Bersekolah</label>
<input class="finput" id="f-jml_anak" type="number" placeholder="1" value="${v('jml_anak')}"/></div>
</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Anak Putus Sekolah</label>
<input class="finput" id="f-anak_putus_sekolah" type="number" placeholder="0" value="${v('anak_putus_sekolah')}"/></div>
<div class="fg"><label class="flabel">Penerima KIP/Beasiswa?</label>
<select class="finput" id="f-penerima_beasiswa">
${['Ya','Tidak'].map(j=>`<option ${v('penerima_beasiswa')===j?'selected':''}>${j}</option>`).join('')}
</select></div>
</div>
</div>
<!-- TAB: Kesehatan -->
<div class="form-section-panel" id="panel-kes">
<div class="form-row cols2">
<div class="fg"><label class="flabel">Kepesertaan BPJS</label>
<select class="finput" id="f-bpjs">
${['PBI (Gratis)','Mandiri','Perusahaan','Tidak Punya BPJS'].map(j=>`<option ${v('bpjs')===j?'selected':''}>${j}</option>`).join('')}
</select></div>
<div class="fg"><label class="flabel">Anggota Difabel/Kronis</label>
<input class="finput" id="f-jml_sakit_kronis" type="number" value="${v('jml_sakit_kronis')}"/></div>
</div>
<div class="fg"><label class="flabel">Jenis Penyakit Khusus</label>
<div class="penyakit-grid">
${CFG.warga.jenisPenyakit.map(p=>`<label class="penyakit-check ${c(p)}"><input type="checkbox" name="penyakit" value="${p}" ${c(p)}/>${p}</label>`).join('')}
</div>
</div>
</div>
<!-- TAB: Riwayat Bansos -->
<div class="form-section-panel" id="panel-hist">
<table class="history-table">
<thead><tr><th>Program</th><th style="width:60px">Thn</th><th>Status</th><th style="width:28px"></th></tr></thead>
<tbody id="hist-tbody">
${hist.map(h=>`
<tr>
<td><input class="finput hist-program" style="padding:3px" value="${H.esc(h.program)}"/></td>
<td><input class="finput hist-tahun" style="padding:3px" type="number" value="${h.tahun}"/></td>
<td><select class="finput hist-status" style="padding:3px"><option ${h.status==='Aktif'?'selected':''}>Aktif</option><option ${h.status==='Berhenti'?'selected':''}>Berhenti</option></select></td>
<td><button class="btn-del-row" type="button" onclick="this.closest('tr').remove()">✕</button></td>
</tr>`).join('')}
</tbody>
</table>
<button class="btn-add-row" type="button" id="btn-add-hist">+ Tambah</button>
<div class="fg" style="margin-top:10px"><label class="flabel">Catatan / Rekomendasi</label>
<textarea class="finput" id="f-catatan" style="min-height:50px">${v('catatan')}</textarea></div>
</div>`;
},
rumahIbadah(rec, latlng){
const lat=latlng?latlng.lat.toFixed(6):(rec?.lat?.toFixed(6)||'');
const lng=latlng?latlng.lng.toFixed(6):(rec?.lng?.toFixed(6)||'');
const v=f=>rec?H.esc(rec[f]||''):'';
return `
<div class="info-box">📍 Koordinat: <strong>${lat}, ${lng}</strong></div>
<div class="form-sect">Identitas Rumah Ibadah</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Nama Rumah Ibadah<span class="req">*</span></label>
<input class="finput" id="f-nama" placeholder="cth: Masjid Al-Ikhlas" value="${v('nama')}"/></div>
<div class="fg"><label class="flabel">Jenis<span class="req">*</span></label>
<select class="finput" id="f-jenis">
${CFG.rumahIbadah.jenis.map(j=>`<option ${v('jenis')===j?'selected':''}>${j}</option>`).join('')}
</select></div>
</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Nama Ketua / Imam / Pastor</label>
<input class="finput" id="f-ketua" placeholder="Nama lengkap" value="${v('ketua')}"/></div>
<div class="fg"><label class="flabel">No. WhatsApp Pengurus</label>
<input class="finput" id="f-wa" placeholder="081..." value="${v('wa')}"/></div>
</div>
<div class="form-sect">Kapasitas & Jangkauan</div>
<div class="form-row cols3">
<div class="fg"><label class="flabel">Kapasitas Jamaah</label>
<input class="finput" id="f-kapasitas" type="number" placeholder="200" value="${v('kapasitas')}"/></div>
<div class="fg"><label class="flabel">Radius Wilayah Binaan (m)</label>
<input class="finput" id="f-radius" type="number" placeholder="300" value="${v('radius')||300}"/></div>
<div class="fg"><label class="flabel">Jml Warga Binaan</label>
<input class="finput" id="f-jml_binaan" type="number" placeholder="150" value="${v('jml_binaan')}"/></div>
</div>
<div class="form-sect">Program Sosial</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Program Aktif</label>
<select class="finput" id="f-program">
${['Baznas/Zakat','Bantuan Sembako','Santunan Anak Yatim','Pemberdayaan UMKM','Beasiswa','Belum Ada'].map(k=>`<option ${v('program')===k?'selected':''}>${k}</option>`).join('')}
</select></div>
<div class="fg"><label class="flabel">Dana Sosial/Bulan (Rp)</label>
<input class="finput" id="f-dana_sosial" type="number" placeholder="2000000" value="${v('dana_sosial')}"/></div>
</div>
<div class="fg"><label class="flabel">Catatan / Kebutuhan Dukungan</label>
<textarea class="finput" id="f-catatan" placeholder="Kebutuhan data, pelatihan, dll...">${v('catatan')}</textarea></div>`;
},
readAll(ln){
const g = id => document.getElementById(id)?.value?.trim();
if(ln==='spbu'){
const nm=g('f-nama'); if(!nm){Toast.error('Nama SPBU wajib diisi!');return null;}
const buka=document.querySelector('input[name="buka24"]:checked')?.value!=='false';
return {nama:nm,wa:g('f-wa'),buka24:buka,jenis_bbm:g('f-jenis_bbm'),disabilitas:g('f-disabilitas'),harga_bbm:g('f-harga_bbm'),catatan:g('f-catatan')};
}
if(ln==='jalan'){
const nm=g('f-nama'); if(!nm){Toast.error('Nama jalan wajib diisi!');return null;}
return {nama:nm,status:g('f-status'),panjang:parseFloat(g('f-panjang'))||0,kondisi:g('f-kondisi'),tahun_perbaikan:g('f-tahun_perbaikan'),lebar:g('f-lebar'),angkutan:g('f-angkutan'),akses_publik:g('f-akses_publik'),catatan:g('f-catatan')};
}
if(ln==='parsil'){
const nm=g('f-nama'); if(!nm){Toast.error('Nama parsil wajib diisi!');return null;}
return {nama:nm,status:g('f-status'),luas:parseFloat(g('f-luas'))||0,nib:g('f-nib'),sengketa:g('f-sengketa'),njop:g('f-njop'),penggunaan:g('f-penggunaan'),catatan:g('f-catatan')};
}
if(ln==='warga'){
const nm=g('f-nama'); if(!nm){Toast.error('Nama KK wajib diisi!');return null;}
const penyakit=[...document.querySelectorAll('input[name="penyakit"]:checked')].map(el=>el.value);
const history_bansos=[];
document.querySelectorAll('#hist-tbody tr').forEach(tr=>{
const prog=tr.querySelector('.hist-program')?.value?.trim();
const thn=tr.querySelector('.hist-tahun')?.value?.trim();
const sts=tr.querySelector('.hist-status')?.value;
if(prog) history_bansos.push({program:prog,tahun:parseInt(thn)||new Date().getFullYear(),status:sts});
});
return {
nama: nm, nik: g('f-nik'), no_kk: g('f-no_kk'), wa: g('f-wa'), tingkat_kemiskinan: g('f-tingkat_kemiskinan'),
jml_anggota: parseInt(g('f-jml_anggota'))||0, jml_lansia: parseInt(g('f-jml_lansia'))||0,
alamat: g('f-alamat'), bansos: g('f-bansos'), hub_ri: g('f-hub_ri'),
pendapatan: g('f-pendapatan'), pekerjaan: g('f-pekerjaan'),
kondisi_rumah: g('f-kondisi_rumah'), kepemilikan_aset: g('f-kepemilikan_aset'),
air_bersih: g('f-air_bersih'), listrik: g('f-listrik'),
pendidikan_kk: g('f-pendidikan_kk'), jml_anak: parseInt(g('f-jml_anak'))||0,
anak_putus_sekolah: parseInt(g('f-anak_putus_sekolah'))||0, penerima_beasiswa: g('f-penerima_beasiswa'),
bpjs: g('f-bpjs'), jml_sakit_kronis: parseInt(g('f-jml_sakit_kronis'))||0,
penyakit, history_bansos, catatan: g('f-catatan'),
};
}
if(ln==='rumahIbadah'){
const nm=g('f-nama'); if(!nm){Toast.error('Nama Rumah Ibadah wajib diisi!');return null;}
return {
nama: nm, jenis: g('f-jenis'), ketua: g('f-ketua'), wa: g('f-wa'),
kapasitas: parseInt(g('f-kapasitas'))||0, radius: parseFloat(g('f-radius'))||300,
jml_binaan: parseInt(g('f-jml_binaan'))||0, program: g('f-program'),
dana_sosial: g('f-dana_sosial'), catatan: g('f-catatan')
};
}
}
};
+18
View File
@@ -0,0 +1,18 @@
/* ═══════════════════════════════════════════════════════════
10. LEGEND
═══════════════════════════════════════════════════════════ */
function renderLegend(ln){
const el=document.getElementById('legend-items');
const rows=CFG[ln].legend||[];
el.innerHTML=rows.map(r=>{
let sym='';
if(r.sym==='c') sym=`<span class="leg-c" style="background:${r.clr}"></span>`;
else if(r.sym==='l') sym=`<span class="leg-l" style="background:${r.clr}"></span>`;
else sym=`<span class="leg-p" style="border-color:${r.clr};background:${r.clr}33"></span>`;
return `<div class="legend-row">${sym}<span>${r.l}</span></div>`;
}).join('');
// Add radius note for rumahIbadah and bansos
if(ln==='rumahIbadah'||ln==='warga'){
el.innerHTML+=`<div class="legend-radius"><span class="leg-radius-sym"></span>Radius Hub 300m</div>`;
}
}
+82
View File
@@ -0,0 +1,82 @@
/* ═══════════════════════════════════════════════════════════
6. POPUP BUILDERS
═══════════════════════════════════════════════════════════ */
const Popup = {
_row:(k,v)=>`<div class="popup-row"><span class="popup-k">${k}</span><span class="popup-v">${H.esc(v||'-')}</span></div>`,
_sect:t=>`<div class="popup-section">${t}</div>`,
_acts:(ln,id)=>`<div class="popup-acts"><button class="bsm bsm-edit" onclick="App.crud.edit('${ln}','${id}')">Edit</button><button class="bsm bsm-del" onclick="App.crud.delete('${ln}','${id}')">Hapus</button></div>`,
spbu(r){ const cfg=CFG.spbu,clr=cfg.color(r),bdg=cfg.badge(r);
return `<div class="popup-wrap"><div class="popup-title"><span class="popup-sym" style="background:${clr}"></span>${H.esc(r.nama)}</div>
<span class="badge ${bdg.c}">${bdg.t}</span>
${this._sect('Operasional')}
${this._row('No. WA',r.wa)}${this._row('Jenis BBM',r.jenis_bbm)}${this._row('Harga BBM',r.harga_bbm)}${this._row('Akses Disabilitas',r.disabilitas)}
${r.catatan?this._row('Catatan',r.catatan):''}
${this._acts('spbu',r.id)}</div>`; },
jalan(r){ const cfg=CFG.jalan,bdg=cfg.badge(r);
return `<div class="popup-wrap"><div class="popup-title">🛣️ ${H.esc(r.nama)}</div>
<span class="badge ${bdg.c}">${bdg.t}</span>
${this._sect('Spesifikasi')}
${this._row('Panjang',H.fmDist(r.panjang))}${this._row('Lebar',r.lebar?(r.lebar+' m'):'-')}${this._row('Kondisi',r.kondisi)}${this._row('Perbaikan Terakhir',r.tahun_perbaikan||'-')}
${this._sect('Dampak Sosial')}
${this._row('Angkutan Umum',r.angkutan)}${this._row('Akses ke',r.akses_publik)}
${r.catatan?this._row('Catatan',r.catatan):''}
${this._acts('jalan',r.id)}</div>`; },
parsil(r){ const cfg=CFG.parsil,bdg=cfg.badge(r);
return `<div class="popup-wrap"><div class="popup-title">🏗️ ${H.esc(r.nama)}</div>
<span class="badge ${bdg.c}">${bdg.t}</span>
${this._sect('Data Hukum')}
${this._row('NIB',r.nib||'-')}${this._row('Status Sengketa',r.sengketa)}${this._row('Penggunaan',r.penggunaan)}
${this._sect('Penilaian')}
${this._row('Luas',H.fmArea(r.luas))}${this._row('NJOP/m²',r.njop?'Rp '+parseInt(r.njop).toLocaleString('id-ID'):'-')}
${r.catatan?this._row('Catatan',r.catatan):''}
${this._acts('parsil',r.id)}</div>`; },
warga(r){ const cfg=CFG.warga,clr=cfg.color(r),bdg=cfg.badge(r);
const ri=Store.all('rumahIbadah');
let nearestRI=null, nearestDist=Infinity;
ri.forEach(hub=>{
if(hub.lat==null||r.lat==null) return;
const d=L.latLng(r.lat,r.lng).distanceTo(L.latLng(hub.lat,hub.lng));
if(d<nearestDist){nearestDist=d;nearestRI=hub;}
});
const radius=nearestRI?(parseFloat(nearestRI.radius)||300):300;
const covered=nearestRI&&nearestDist<=radius;
const coverageHtml=nearestRI
?`<div class="coverage-badge ${covered?'covered':'uncovered'}">
<svg viewBox="0 0 24 24"><path d="${covered?'M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z':'M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z'}"/></svg>
${covered?'Terjangkau Hub: '+H.esc(nearestRI.nama)+' ('+Math.round(nearestDist)+' m)':'Tidak Terjangkau ('+Math.round(nearestDist)+' m dari RI)'}
</div>`
:'<div class="coverage-badge uncovered">Belum ada Rumah Ibadah Hub</div>';
const histHtml=r.history_bansos?.length
?r.history_bansos.map(h=>`<div style="font-size:10px;padding:3px 0;border-bottom:1px solid var(--border0);display:flex;justify-content:space-between"><span>${H.esc(h.program)} (${h.tahun||'-'})</span><span class="status-${(h.status||'').toLowerCase().replace(' ','')}" style="font-weight:700">${H.esc(h.status)}</span></div>`).join('')
:'<div style="font-size:10px;color:var(--text4)">Belum ada riwayat bansos</div>';
return `<div class="popup-wrap"><div class="popup-title"><span class="popup-sym" style="background:${clr}"></span>${H.esc(r.nama)}</div>
<span class="badge ${bdg.c}">${bdg.t}</span>
${coverageHtml}
${this._sect('Data Keluarga')}
${this._row('NIK',r.nik||'-')}${this._row('No. KK',r.no_kk||'-')}${this._row('Jml Anggota',r.jml_anggota+' jiwa')}
${this._sect('Ekonomi')}
${this._row('Pekerjaan KK',r.pekerjaan)}${this._row('Pendapatan/Bln',r.pendapatan?'Rp '+parseInt(r.pendapatan).toLocaleString('id-ID'):'-')}${this._row('Kondisi Rumah',r.kondisi_rumah)}
${this._sect('Fasilitas')}
${this._row('Air Bersih',r.air_bersih)}${this._row('Listrik',r.listrik)}
${this._sect('Bansos')}
${this._row('Bansos Saat Ini',r.bansos)}${this._row('Hub RI',r.hub_ri||'-')}
<div style="margin-top:2px">${histHtml}</div>
${r.catatan?this._row('Rekomendasi',r.catatan):''}
${this._acts('warga',r.id)}</div>`; },
rumahIbadah(r){ const cfg=CFG.rumahIbadah,clr=cfg.color(r),bdg=cfg.badge(r);
return `<div class="popup-wrap"><div class="popup-title"><span class="popup-sym" style="background:${clr}"></span>${H.esc(r.nama)}</div>
<span class="badge ${bdg.c}">${bdg.t}</span>
${this._sect('Kontak')}
${this._row('Ketua/Imam',r.ketua||'-')}${this._row('No. WA',r.wa||'-')}
${this._sect('Kapasitas & Binaan')}
${this._row('Kapasitas',r.kapasitas?r.kapasitas+' orang':'-')}${this._row('Radius Binaan',(r.radius||300)+' m')}${this._row('Warga Binaan',r.jml_binaan?r.jml_binaan+' KK':'-')}
${this._sect('Program Sosial')}
${this._row('Program Aktif',r.program)}${this._row('Dana Sosial/Bln',r.dana_sosial?'Rp '+parseInt(r.dana_sosial).toLocaleString('id-ID'):'-')}
${r.catatan?this._row('Catatan',r.catatan):''}
${this._acts('rumahIbadah',r.id)}</div>`; },
};
+57
View File
@@ -0,0 +1,57 @@
/* ═══════════════════════════════════════════════════════════
4. LAYER CONFIG (atribut + warna per layer)
── Ini adalah "kontrak data" antara programmer & client ──
═══════════════════════════════════════════════════════════ */
const CFG = {
spbu: {
label:'Data SPBU', sub:'Stasiun Bahan Bakar Umum', icon:'⛽', drawType:'point',
color: r => r.buka24?'#34d47e':'#f05c5c',
badge: r => r.buka24?{t:'24 Jam',c:'badge-green'}:{t:'Tutup',c:'badge-red'},
legend:[{l:'Buka 24 Jam',clr:'#34d47e',sym:'c'},{l:'Tidak 24 Jam',clr:'#f05c5c',sym:'c'}],
},
jalan: {
label:'Data Jalan', sub:'Ruas jalan berdasarkan status', icon:'🛣️', drawType:'polyline',
statuses:['Nasional','Provinsi','Kabupaten','Desa'],
colors:{'Nasional':'#f05c5c','Provinsi':'#f0a030','Kabupaten':'#5b9cf6','Desa':'#34d47e'},
badgeClass:{'Nasional':'badge-red','Provinsi':'badge-amber','Kabupaten':'badge-accent','Desa':'badge-green'},
kondisi:['Baik','Sedang','Rusak Ringan','Rusak Berat'],
color: r => ({'Nasional':'#f05c5c','Provinsi':'#f0a030','Kabupaten':'#5b9cf6','Desa':'#34d47e'}[r.status]||'#888'),
badge: r => ({t:r.status, c:{'Nasional':'badge-red','Provinsi':'badge-amber','Kabupaten':'badge-accent','Desa':'badge-green'}[r.status]||'badge-accent'}),
legend:[{l:'Nasional',clr:'#f05c5c',sym:'l'},{l:'Provinsi',clr:'#f0a030',sym:'l'},{l:'Kabupaten',clr:'#5b9cf6',sym:'l'},{l:'Desa',clr:'#34d47e',sym:'l'}],
},
parsil: {
label:'Data Parsil Tanah', sub:'Persil kavling berdasarkan kepemilikan', icon:'🏗️', drawType:'polygon',
statuses:['SHM','HGB','HGU','HP','Belum Bersertifikat'],
colors:{'SHM':'#34d47e','HGB':'#5b9cf6','HGU':'#f0a030','HP':'#9b7bfa','Belum Bersertifikat':'#f05c5c'},
badgeClass:{'SHM':'badge-green','HGB':'badge-accent','HGU':'badge-amber','HP':'badge-purple','Belum Bersertifikat':'badge-red'},
color: r => ({'SHM':'#34d47e','HGB':'#5b9cf6','HGU':'#f0a030','HP':'#9b7bfa','Belum Bersertifikat':'#f05c5c'}[r.status]||'#888'),
badge: r => ({t:r.status,c:{'SHM':'badge-green','HGB':'badge-accent','HGU':'badge-amber','HP':'badge-purple','Belum Bersertifikat':'badge-red'}[r.status]||'badge-accent'}),
legend:[{l:'SHM',clr:'#34d47e',sym:'p'},{l:'HGB',clr:'#5b9cf6',sym:'p'},{l:'HGU',clr:'#f0a030',sym:'p'},{l:'HP',clr:'#9b7bfa',sym:'p'},{l:'Belum Sertifikat',clr:'#f05c5c',sym:'p'}],
},
warga: {
label:'Data Warga & Bansos', sub:'Keluarga rentan & sasaran distribusi bansos', icon:'👨‍👩‍👧', drawType:'point',
tingkat:['Sangat Miskin','Miskin','Hampir Miskin','Rentan'],
colors:{'Sangat Miskin':'#f05c5c','Miskin':'#f0a030','Hampir Miskin':'#f0c040','Rentan':'#5b9cf6'},
badgeClass:{'Sangat Miskin':'badge-red','Miskin':'badge-orange','Hampir Miskin':'badge-amber','Rentan':'badge-accent'},
color: r => ({'Sangat Miskin':'#f05c5c','Miskin':'#f0a030','Hampir Miskin':'#f0c040','Rentan':'#5b9cf6'}[r.tingkat_kemiskinan]||'#888'),
badge: r => ({t:r.tingkat_kemiskinan,c:{'Sangat Miskin':'badge-red','Miskin':'badge-orange','Hampir Miskin':'badge-amber','Rentan':'badge-accent'}[r.tingkat_kemiskinan]||'badge-accent'}),
legend:[{l:'Sangat Miskin',clr:'#f05c5c',sym:'c'},{l:'Miskin',clr:'#f0a030',sym:'c'},{l:'Hampir Miskin',clr:'#f0c040',sym:'c'},{l:'Rentan',clr:'#5b9cf6',sym:'c'}],
bantuan:['PKH','BPNT/Sembako','BLT Dana Desa','BLT BBM','PIP/KIP','KIS/BPJS','Zakat/Infaq RI','Bansos Pemda','Belum Ada'],
kondisiRumah:['Permanen','Semi Permanen','Tidak Layak Huni','Menumpang'],
pekerjaan:['Buruh Harian','Petani/Nelayan','Pedagang Kecil','PNS/TNI/Polri','Pensiunan','Tidak Bekerja','Lainnya'],
statusPenerimaan:['Aktif Menerima','Belum Menerima','Proses Verifikasi','Pernah Menerima'],
sumberPendapatan:['Buruh Harian','Pedagang Kecil','Petani/Nelayan','PNS/TNI/Polri','Pensiunan','Tidak Bekerja','Lainnya'],
jenisPenyakit:['Hipertensi','Diabetes','TBC','Jantung','Stroke','Kanker','Gangguan Jiwa','Disabilitas','Tidak Ada'],
tingkatPendidikan:['Tidak Sekolah','SD/Sederajat','SMP/Sederajat','SMA/Sederajat','D3/S1','S2/S3'],
},
rumahIbadah: {
label:'Rumah Ibadah', sub:'Masjid, Gereja, Pura, Vihara, Klenteng', icon:'🕌', drawType:'point',
jenis:['Masjid','Musholla','Gereja Kristen','Gereja Katolik','Pura','Vihara','Klenteng'],
colors:{'Masjid':'#2ec4b6','Musholla':'#2ec4b6','Gereja Kristen':'#5b9cf6','Gereja Katolik':'#5b9cf6','Pura':'#f0a030','Vihara':'#9b7bfa','Klenteng':'#f07840'},
badgeClass:{'Masjid':'badge-teal','Musholla':'badge-teal','Gereja Kristen':'badge-accent','Gereja Katolik':'badge-accent','Pura':'badge-amber','Vihara':'badge-purple','Klenteng':'badge-orange'},
color: r => ({'Masjid':'#2ec4b6','Musholla':'#2ec4b6','Gereja Kristen':'#5b9cf6','Gereja Katolik':'#5b9cf6','Pura':'#f0a030','Vihara':'#9b7bfa','Klenteng':'#f07840'}[r.jenis]||'#888'),
badge: r => ({t:r.jenis,c:({'Masjid':'badge-teal','Musholla':'badge-teal','Gereja Kristen':'badge-accent','Gereja Katolik':'badge-accent','Pura':'badge-amber','Vihara':'badge-purple','Klenteng':'badge-orange'}[r.jenis]||'badge-teal')}),
legend:[{l:'Masjid/Musholla',clr:'#2ec4b6',sym:'c'},{l:'Gereja',clr:'#5b9cf6',sym:'c'},{l:'Pura',clr:'#f0a030',sym:'c'},{l:'Vihara/Klenteng',clr:'#9b7bfa',sym:'c'}],
coverageRadius: 300,
},
};
+182
View File
@@ -0,0 +1,182 @@
/* ═══════════════════════════════════════════════════════════
12. DASHBOARD ANALITIK
═══════════════════════════════════════════════════════════ */
const Dashboard = {
toggle(){
document.getElementById('dash-panel').classList.toggle('hidden');
if(!document.getElementById('dash-panel').classList.contains('hidden')) this.render();
},
render(){
const db=Store.allDb();
const warga=db.warga||[], ri=db.rumahIbadah||[], jalan=db.jalan||[], parsil=db.parsil||[];
const totalWarga=warga.reduce((s,r)=>s+(parseInt(r.jml_anggota)||1),0);
const miskinBerat=warga.filter(r=>r.tingkat_kemiskinan==='Sangat Miskin'||r.tingkat_kemiskinan==='Miskin').length;
const blmBansos=warga.filter(r=>r.bansos==='Belum Ada').length;
const blmSertif=parsil.filter(r=>r.status==='Belum Bersertifikat').length;
const jalanRusak=jalan.filter(r=>r.kondisi==='Rusak Berat'||r.kondisi==='Rusak Ringan').length;
const totalRI=ri.length;
// Radius coverage for Warga layer
let coveredKK=0, uncoveredKK=0;
const coverageHubs = ri.map(hub=>({ ...hub, count:0 }));
warga.forEach(b => {
if(!b.lat) return;
let nearestDist=Infinity, nearestHub=null;
ri.forEach(hub=>{
if(!hub.lat) return;
const d=L.latLng(b.lat,b.lng).distanceTo(L.latLng(hub.lat,hub.lng));
if(d<nearestDist){nearestDist=d;nearestHub=hub;}
});
if(nearestHub && nearestDist<=(parseFloat(nearestHub.radius)||300)){
coveredKK++;
const h = coverageHubs.find(h=>h.id===nearestHub.id);
if(h) h.count++;
} else {
uncoveredKK++;
}
});
// tingkat kemiskinan breakdown
const tkBreak={};
CFG.warga.tingkat.forEach(t=>{ tkBreak[t]=warga.filter(r=>r.tingkat_kemiskinan===t).length; });
const maxTK=Math.max(...Object.values(tkBreak),1);
// bansos breakdown
const bansosBreak={};
CFG.warga.bantuan.forEach(b=>{ const c=warga.filter(r=>r.bansos===b).length; if(c>0) bansosBreak[b]=c; });
// jalan status breakdown
const jalanBreak={};
CFG.jalan.statuses.forEach(s=>{ jalanBreak[s]=jalan.filter(r=>r.status===s).length; });
document.getElementById('dash-body').innerHTML=`
<div class="dash-tab-bar">
<div class="dash-tab active" onclick="Dashboard.switchTab('ringkasan')">Ringkasan Utama</div>
<div class="dash-tab" onclick="Dashboard.switchTab('distribusi')">Distribusi Bansos</div>
</div>
<!-- TAB RINGKASAN -->
<div id="dash-panel-ringkasan">
<div class="stat-grid">
<div class="stat-card c-red"><div class="sc-val">${H.fmNum(warga.length)}</div><div class="sc-label">KK Warga Miskin Terdata</div><div class="sc-sub">${H.fmNum(totalWarga)} jiwa total</div></div>
<div class="stat-card c-amber"><div class="sc-val">${H.fmNum(miskinBerat)}</div><div class="sc-label">Miskin & Sangat Miskin</div><div class="sc-sub">${warga.length?Math.round(miskinBerat/warga.length*100):0}% dari total KK</div></div>
<div class="stat-card c-accent"><div class="sc-val">${H.fmNum(blmBansos)}</div><div class="sc-label">KK Belum Terima Bansos</div><div class="sc-sub">Perlu intervensi segera</div></div>
<div class="stat-card c-teal"><div class="sc-val">${H.fmNum(totalRI)}</div><div class="sc-label">Rumah Ibadah Aktif</div><div class="sc-sub">Agen lapangan terdaftar</div></div>
<div class="stat-card c-purple"><div class="sc-val">${H.fmNum(blmSertif)}</div><div class="sc-label">Parsil Belum Bersertifikat</div><div class="sc-sub">Kandidat program PTSL</div></div>
<div class="stat-card c-green"><div class="sc-val">${H.fmNum(jalanRusak)}</div><div class="sc-label">Ruas Jalan Rusak</div><div class="sc-sub">Dari ${H.fmNum(jalan.length)} ruas terdata</div></div>
</div>
<div class="dash-sect">
<div class="dash-sect-title">Sebaran Tingkat Kemiskinan</div>
<table class="risk-table"><thead><tr><th>Kategori</th><th>Jumlah KK</th><th>Proporsi</th></tr></thead><tbody>
${CFG.warga.tingkat.map(t=>{
const n=tkBreak[t]||0, pct=warga.length?Math.round(n/warga.length*100):0;
const clr={'Sangat Miskin':'#f05c5c','Miskin':'#f0a030','Hampir Miskin':'#f0c040','Rentan':'#5b9cf6'}[t];
return \`<tr><td>\${t}</td><td>\${H.fmNum(n)} KK</td><td><div class="risk-bar-wrap"><div class="risk-bar"><div class="risk-bar-fill" style="width:\${Math.round(n/maxTK*100)}%;background:\${clr}"></div></div>\${pct}%</div></td></tr>\`;
}).join('')}
</tbody></table>
</div>
<div class="dash-sect">
<div class="dash-sect-title">Penerima Program Bantuan Sosial</div>
${Object.entries(bansosBreak).length?Object.entries(bansosBreak).sort((a,b)=>b[1]-a[1]).map(([b,n])=>`
<div class="prog-row"><span class="prog-name">${b}</span><span class="prog-count">${n} KK</span></div>`).join('')
:'<div style="color:var(--text4);font-size:12px">Belum ada data warga tercatat.</div>'}
</div>
<div class="dash-sect">
<div class="dash-sect-title">Status Ruas Jalan</div>
<table class="risk-table"><thead><tr><th>Status</th><th>Jumlah Ruas</th><th>Total Panjang</th></tr></thead><tbody>
${CFG.jalan.statuses.map(s=>{
const ruas=jalan.filter(r=>r.status===s);
const pj=ruas.reduce((t,r)=>t+(r.panjang||0),0);
return \`<tr><td><span class="badge \${CFG.jalan.badgeClass[s]}">\${s}</span></td><td>\${ruas.length} ruas</td><td>\${H.fmDist(pj)}</td></tr>\`;
}).join('')}
</tbody></table>
</div>
<div class="dash-sect">
<div class="dash-sect-title">Rekomendasi Intervensi Kebijakan</div>
<div class="intervensi-grid">
<div class="intervensi-card"><div class="ic-label">🏠 Prioritas Bansos</div>
<div class="ic-val">${H.fmNum(blmBansos)} KK</div>
<div class="ic-sub">Warga belum menerima bantuan apapun</div></div>
<div class="intervensi-card"><div class="ic-label">📜 Prioritas PTSL</div>
<div class="ic-val">${H.fmNum(blmSertif)} Parsil</div>
<div class="ic-sub">Lahan tanpa sertifikat, akses kredit terbatas</div></div>
<div class="intervensi-card"><div class="ic-label">🛣️ Prioritas Perbaikan Jalan</div>
<div class="ic-val">${H.fmNum(jalanRusak)} Ruas</div>
<div class="ic-sub">Jalan rusak ringan-berat perlu perbaikan</div></div>
<div class="intervensi-card"><div class="ic-label">🕌 Coverage Rumah Ibadah</div>
<div class="ic-val">${H.fmNum(totalRI)} RI Aktif</div>
<div class="ic-sub">Total dana sosial: Rp ${H.fmNum(ri.reduce((t,r)=>t+(parseInt(r.dana_sosial)||0),0))}</div></div>
</div>
</div>
</div>
<!-- TAB DISTRIBUSI BANSOS -->
<div id="dash-panel-distribusi" style="display:none">
<div class="coverage-grid">
<div class="coverage-card">
<div class="cc-icon green">✓</div>
<div class="cc-info">
<div class="cc-val">${coveredKK} KK</div>
<div class="cc-label">Terjangkau Hub (≤300m)</div>
</div>
</div>
<div class="coverage-card">
<div class="cc-icon red">✕</div>
<div class="cc-info">
<div class="cc-val">${uncoveredKK} KK</div>
<div class="cc-label">Di Luar Jangkauan Hub</div>
</div>
</div>
<div class="coverage-card" style="grid-column:1/3;padding:16px">
<div class="cc-icon teal" style="width:40px;height:40px">🕌</div>
<div class="cc-info">
<div class="cc-val">${totalRI} Rumah Ibadah</div>
<div class="cc-label">Agen Distribusi Lapangan Terdaftar</div>
</div>
</div>
</div>
<div class="dash-sect">
<div class="dash-sect-title">Beban Distribusi per Rumah Ibadah</div>
<table class="hub-table">
<thead><tr><th>Nama Hub</th><th>Kapasitas</th><th>Beban KK</th></tr></thead>
<tbody>
${coverageHubs.sort((a,b)=>b.count-a.count).map(h=>{
const clr=CFG.rumahIbadah.color(h);
const max = Math.max(...coverageHubs.map(x=>x.count), 1);
const pct = Math.round(h.count/max*100);
return \`<tr>
<td><div class="hub-name-cell"><span class="hub-dot" style="background:\${clr}"></span>\${H.esc(h.nama)}</div></td>
<td>\${h.jml_binaan||'-'} KK</td>
<td>
<div class="hub-count-bar">
<div class="hub-bar"><div class="hub-bar-fill" style="width:\${pct}%;background:\${pct>80?'var(--amber)':clr}"></div></div>
<span>\${h.count} KK</span>
</div>
</td>
</tr>\`;
}).join('')}
</tbody>
</table>
${coverageHubs.length===0?'<div style="text-align:center;color:var(--text4);font-size:11px;padding:20px">Belum ada data Rumah Ibadah.</div>':''}
</div>
</div>`;
},
switchTab(tabId){
document.querySelectorAll('.dash-tab').forEach(t=>t.classList.remove('active'));
document.querySelectorAll('[id^="dash-panel-"]').forEach(p=>p.style.display='none');
if(tabId === 'ringkasan'){
document.querySelectorAll('.dash-tab')[0].classList.add('active');
document.getElementById('dash-panel-ringkasan').style.display='block';
} else {
document.querySelectorAll('.dash-tab')[1].classList.add('active');
document.getElementById('dash-panel-distribusi').style.display='block';
}
}
};
+38
View File
@@ -0,0 +1,38 @@
/* ═══════════════════════════════════════════════════════════
8. DRAW MANAGER
═══════════════════════════════════════════════════════════ */
const Draw = (() => {
let _map, _drawn, _pending, _handler, _active=false;
const BAR=()=>document.getElementById('draw-bar');
const TXT=()=>document.getElementById('draw-indicator-text');
function init(m){
_map=m; _drawn=new L.FeatureGroup().addTo(_map);
_map.on(L.Draw.Event.CREATED, e=>{
_drawn.clearLayers(); _drawn.addLayer(e.layer);
_pending=e.layer; stop(); Modal.openAdd(App.state.activeLayer, e.layer);
});
_map.on('click', e=>{
if(!_active||App.state.activeLayer!=='spbu'&&App.state.activeLayer!=='warga'&&App.state.activeLayer!=='rumahIbadah') return;
const mk=L.marker(e.latlng);
_drawn.clearLayers(); _drawn.addLayer(mk);
_pending=mk; stop(); Modal.openAdd(App.state.activeLayer, mk);
});
document.getElementById('btn-cancel').addEventListener('click', cancel);
}
function start(ln){
_active=true;
const msgs={spbu:'Klik peta untuk menandai lokasi SPBU',jalan:'Klik titik-titik ruas jalan — klik ganda untuk selesai',parsil:'Klik titik-titik batas kavling — klik ganda untuk selesai',warga:'Klik peta untuk menandai lokasi rumah warga miskin',rumahIbadah:'Klik peta untuk menandai lokasi rumah ibadah'};
document.getElementById('draw-text').textContent=msgs[ln]||'Mode gambar aktif';
BAR().classList.remove('hidden');
if(ln==='jalan'){ _handler=new L.Draw.Polyline(_map,{shapeOptions:{color:'#f0a030',weight:4}}); _handler.enable(); }
else if(ln==='parsil'){ _handler=new L.Draw.Polygon(_map,{shapeOptions:{color:'#9b7bfa',weight:2,fillOpacity:.2}}); _handler.enable(); }
}
function stop(){ _active=false; BAR().classList.add('hidden'); if(_handler){_handler.disable();_handler=null;} }
function cancel(){ stop(); _drawn.clearLayers(); _pending=null; }
function clearPending(){ _drawn.clearLayers(); _pending=null; }
return {init, start, stop, cancel, clearPending};
})();
+63
View File
@@ -0,0 +1,63 @@
/* ═══════════════════════════════════════════════════════════
13. MAP RENDER
═══════════════════════════════════════════════════════════ */
function renderMap(){
const db=Store.allDb();
// SPBU
App._groups.spbu.clearLayers();
db.spbu.forEach(r=>{
const m=L.circleMarker([r.lat,r.lng],{radius:9,fillColor:CFG.spbu.color(r),color:'#fff',weight:2,fillOpacity:1});
m.bindPopup(Popup.spbu(r),{maxWidth:320});
m.on('click',()=>highlightCard(r.id));
App._groups.spbu.addLayer(m);
});
// Warga
App._groups.warga.clearLayers();
db.warga.forEach(r=>{
const clr=CFG.warga.color(r);
// Check if in any RI radius
const inRadius=db.rumahIbadah.some(hub=>{
if(!hub.lat) return false;
const d=L.latLng(r.lat,r.lng).distanceTo(L.latLng(hub.lat,hub.lng));
return d<=(parseFloat(hub.radius)||300);
});
const iconHtml=`<div style="width:22px;height:22px;background:${clr};border:2px solid #fff;border-radius:50%;
display:flex;align-items:center;justify-content:center;box-shadow:0 2px 6px rgba(0,0,0,.4);
font-size:11px;line-height:1">👨‍👩‍👧</div>${inRadius?'':'<div style="position:absolute;top:-4px;right:-4px;width:10px;height:10px;background:#f05c5c;border-radius:50%;border:1px solid #fff"></div>'}`;
const icon=L.divIcon({html:`<div style="position:relative">${iconHtml}</div>`,className:'',iconSize:[22,22],iconAnchor:[11,11]});
const m=L.marker([r.lat,r.lng],{icon});
m.bindPopup(Popup.warga(r),{maxWidth:360});
m.on('click',()=>highlightCard(r.id));
App._groups.warga.addLayer(m);
});
// Rumah Ibadah
App._groups.rumahIbadah.clearLayers();
db.rumahIbadah.forEach(r=>{
const clr=CFG.rumahIbadah.color(r);
const m=L.circleMarker([r.lat,r.lng],{radius:10,fillColor:clr,color:'#fff',weight:2.5,fillOpacity:1});
m.bindPopup(Popup.rumahIbadah(r),{maxWidth:320});
m.on('click',()=>highlightCard(r.id));
App._groups.rumahIbadah.addLayer(m);
// Always draw radius 300m coverage circle
const radiusM=parseFloat(r.radius)||300;
const ring=L.circle([r.lat,r.lng],{radius:radiusM,color:clr,weight:1.5,fillColor:clr,fillOpacity:.06,dashArray:'6 4'});
App._groups.rumahIbadah.addLayer(ring);
});
// Jalan
App._groups.jalan.clearLayers();
db.jalan.forEach(r=>{
const l=L.polyline(r.coords,{color:CFG.jalan.color(r),weight:4,opacity:.9});
l.bindPopup(Popup.jalan(r),{maxWidth:300});
l.on('click',()=>highlightCard(r.id));
App._groups.jalan.addLayer(l);
});
// Parsil
App._groups.parsil.clearLayers();
db.parsil.forEach(r=>{
const clr=CFG.parsil.color(r);
const p=L.polygon(r.coords,{color:clr,weight:2,fillColor:clr,fillOpacity:.2});
p.bindPopup(Popup.parsil(r),{maxWidth:300});
p.on('click',()=>highlightCard(r.id));
App._groups.parsil.addLayer(p);
});
}
+119
View File
@@ -0,0 +1,119 @@
/* ═══════════════════════════════════════════════════════════
9. MODAL MANAGER
═══════════════════════════════════════════════════════════ */
const Modal = (() => {
let _ln=null, _editId=null, _geo=null;
const ov=()=>document.getElementById('modal-ov');
function openAdd(ln, leafletLayer){
_ln=ln; _editId=null; _geo=leafletLayer;
const cfg=CFG[ln];
document.getElementById('modal-icon').textContent=cfg.icon;
document.getElementById('modal-title').textContent='Tambah '+cfg.label;
document.getElementById('modal-sub').textContent='Isi atribut data baru';
let html='';
if(ln==='spbu') html=Forms.spbu(null, leafletLayer.getLatLng());
else if(ln==='jalan') html=Forms.jalan(null, H.geoLen(leafletLayer.getLatLngs()));
else if(ln==='parsil') html=Forms.parsil(null, H.geoArea(leafletLayer.getLatLngs()[0]));
else if(ln==='warga') html=Forms.warga(null, leafletLayer.getLatLng());
else if(ln==='rumahIbadah') html=Forms.rumahIbadah(null, leafletLayer.getLatLng());
document.getElementById('modal-body').innerHTML=html;
_bindRadio(); _bindFormTabs(); _bindHistBtn(); ov().classList.remove('hidden');
setTimeout(()=>document.getElementById('f-nama')?.focus(),60);
}
function openEdit(ln, id){
_ln=ln; _editId=id; _geo=null;
const rec=Store.find(ln,id); if(!rec) return;
const cfg=CFG[ln];
document.getElementById('modal-icon').textContent=cfg.icon;
document.getElementById('modal-title').textContent='Edit '+cfg.label;
document.getElementById('modal-sub').textContent=H.esc(rec.nama);
let html='';
if(ln==='spbu') html=Forms.spbu(rec,null);
else if(ln==='jalan') html=Forms.jalan(rec,null);
else if(ln==='parsil') html=Forms.parsil(rec,null);
else if(ln==='warga') html=Forms.warga(rec,null);
else if(ln==='rumahIbadah') html=Forms.rumahIbadah(rec,null);
document.getElementById('modal-body').innerHTML=html;
_bindRadio(); _bindFormTabs(); _bindHistBtn(); ov().classList.remove('hidden');
setTimeout(()=>document.getElementById('f-nama')?.focus(),60);
}
function close(){ ov().classList.add('hidden'); Draw.clearPending(); }
async function submit(){
const data=Forms.readAll(_ln); if(!data) return;
if(_editId){
data.updatedAt=H.today();
await Store.update(_ln,_editId,data);
Toast.success('Data berhasil diperbarui');
} else {
const geo=_geo;
if(_ln==='spbu'||_ln==='warga'||_ln==='rumahIbadah'){
const ll=geo.getLatLng(); data.lat=ll.lat; data.lng=ll.lng;
} else if(_ln==='jalan'){
data.coords=geo.getLatLngs().map(l=>[l.lat,l.lng]);
} else if(_ln==='parsil'){
data.coords=geo.getLatLngs()[0].map(l=>[l.lat,l.lng]);
}
data.id=H.uid(); data.createdAt=H.today(); data.updatedAt=H.today();
await Store.add(_ln,data);
Toast.success('Data berhasil disimpan');
}
close(); App.refresh();
}
function _bindRadio(){
document.querySelectorAll('.ropt').forEach(el=>{
el.addEventListener('click',()=>{
const parent=el.closest('.rtoggle'); if(!parent) return;
parent.querySelectorAll('.ropt').forEach(r=>r.classList.remove('yes','no'));
const v=el.dataset.v; el.classList.add(v==='true'?'yes':'no');
const inp=el.querySelector('input'); if(inp) inp.checked=true;
});
});
// Bind penyakit checkboxes
document.querySelectorAll('.penyakit-check input').forEach(inp=>{
inp.addEventListener('change', e=>{
if(e.target.checked) e.target.parentElement.classList.add('checked');
else e.target.parentElement.classList.remove('checked');
});
});
}
function _bindFormTabs(){
const tabs=document.querySelectorAll('.form-tab');
if(!tabs.length) return;
tabs.forEach(tab=>{
tab.addEventListener('click',()=>{
tabs.forEach(t=>t.classList.remove('active'));
document.querySelectorAll('.form-section-panel').forEach(p=>p.classList.remove('active'));
tab.classList.add('active');
const panelId='panel-'+tab.dataset.panel;
document.getElementById(panelId)?.classList.add('active');
});
});
}
function _bindHistBtn(){
const btn=document.getElementById('btn-add-hist');
if(!btn) return;
btn.addEventListener('click',()=>{
const tbody=document.getElementById('hist-tbody');
if(!tbody) return;
const tr=document.createElement('tr');
tr.innerHTML=`
<td><input class="finput hist-program" style="padding:3px 6px;font-size:10px" placeholder="cth: PKH"/></td>
<td><input class="finput hist-tahun" style="padding:3px 6px;font-size:10px;width:70px" type="number" min="2000" max="2030" placeholder="${new Date().getFullYear()}"/></td>
<td><select class="finput hist-status" style="padding:3px 6px;font-size:10px">
<option>Aktif</option><option>Berhenti</option><option>Dalam Proses</option>
</select></td>
<td><input class="finput hist-nominal" style="padding:3px 6px;font-size:10px" type="number" placeholder="Rp"/></td>
<td><button class="btn-del-row" type="button" onclick="this.closest('tr').remove()">✕</button></td>`;
tbody.appendChild(tr);
});
}
return {openAdd, openEdit, close, submit};
})();
+31
View File
@@ -0,0 +1,31 @@
/* ═══════════════════════════════════════════════════════════
11. SIDEBAR
═══════════════════════════════════════════════════════════ */
function renderSidebar(ln){
const cfg=CFG[ln];
document.getElementById('sb-title').textContent=cfg.label;
document.getElementById('sb-sub').textContent=cfg.sub;
const data=Store.all(ln);
const list=document.getElementById('sb-list');
if(!data.length){
list.innerHTML=`<div class="empty"><svg width="44" height="44" viewBox="0 0 24 24" fill="currentColor"><path d="M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/></svg><p>Belum ada data.<br/>Klik <strong>Tambah</strong> untuk mulai digitasi.</p></div>`;
return;
}
list.innerHTML=data.map(r=>Card[ln](r)).join('');
}
function updateBadges(){
const db=Store.allDb();
['spbu','jalan','parsil','warga','rumahIbadah'].forEach(k=>{
const el=document.getElementById('badge-'+k);
if(el) el.textContent=db[k]?.length||0;
});
const total=Object.values(db).reduce((s,a)=>s+a.length,0);
document.getElementById('total-count').textContent=total+' fitur';
}
function highlightCard(id){
document.querySelectorAll('.dcard').forEach(c=>c.classList.remove('active'));
const c=document.getElementById('card-'+id);
if(c){ c.classList.add('active'); c.scrollIntoView({behavior:'smooth',block:'nearest'}); }
}
+18
View File
@@ -0,0 +1,18 @@
'use strict';
/* ═══════════════════════════════════════════════════════════
1. HELPERS
═══════════════════════════════════════════════════════════ */
const H = {
uid: () => Math.random().toString(36).slice(2,9) + Date.now().toString(36),
esc: s => String(s||'').replace(/[&<>"']/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[c])),
fmDist: m => m==null?'-': m>=1000? (m/1000).toFixed(2)+' km': Math.round(m).toLocaleString('id-ID')+' m',
fmArea: m => m==null?'-': m>=10000? (m/10000).toFixed(4)+' ha': Math.round(m).toLocaleString('id-ID')+' m²',
fmNum: n => (n||0).toLocaleString('id-ID'),
today: () => new Date().toISOString().slice(0,10),
geoLen: lls => { let t=0; for(let i=0;i<lls.length-1;i++) t+=lls[i].distanceTo(lls[i+1]); return Math.round(t); },
geoArea: lls => {
const R=6371008.8; let a=0,n=lls.length;
for(let i=0;i<n;i++){const j=(i+1)%n,φ1=lls[i].lat*Math.PI/180,φ2=lls[j].lat*Math.PI/180,=(lls[j].lng-lls[i].lng)*Math.PI/180;a+=*(2+Math.sin(φ1)+Math.sin(φ2));}
return Math.round(Math.abs(a*R*R/2));
},
};
+57
View File
@@ -0,0 +1,57 @@
/* ═══════════════════════════════════════════════════════════
2. STORAGE (MySQL API Version)
═══════════════════════════════════════════════════════════ */
const Store = (() => {
const API_URL = '../api/index.php'; // Or absolute URL if hosted elsewhere
let db = {
spbu: [],
jalan: [],
parsil: [],
warga: [],
rumahIbadah: []
};
const request = async (action, payload = {}) => {
try {
const res = await fetch(`${API_URL}?action=${action}`, {
method: action === 'alldb' ? 'GET' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: action === 'alldb' ? null : JSON.stringify(payload)
});
return await res.json();
} catch (err) {
console.error('API Error:', err);
throw err;
}
};
return {
load: async () => {
try {
const data = await request('alldb');
if (data) {
db = { ...db, ...data };
}
} catch (e) {
console.error("Gagal load data dari server", e);
}
},
all: ln => db[ln]||[],
find: (ln,id) => (db[ln]||[]).find(r=>r.id===id)||null,
add: async (ln,rec) => {
db[ln].push(rec);
await request('add', { layer: ln, data: rec });
},
update: async (ln,id,patch) => {
const i=db[ln].findIndex(r=>r.id===id); if(i<0)return;
db[ln][i]={...db[ln][i],...patch};
await request('update', { layer: ln, id: id, patch: patch });
},
remove: async (ln,id) => {
db[ln]=db[ln].filter(r=>r.id!==id);
await request('remove', { layer: ln, id: id });
},
allDb: () => db,
};
})();
+9
View File
@@ -0,0 +1,9 @@
/* ═══════════════════════════════════════════════════════════
3. TOAST
═══════════════════════════════════════════════════════════ */
const Toast = {
_show(msg,type){ const c=document.getElementById('toasts'),el=document.createElement('div');
el.className=`toast ${type}`;el.innerHTML=`<span>${{success:'✓',error:'✕',info:''}[type]||''}</span>${H.esc(msg)}`;
c.appendChild(el);setTimeout(()=>{el.classList.add('out');el.addEventListener('animationend',()=>el.remove(),{once:true})},2800); },
success: m=>Toast._show(m,'success'), error: m=>Toast._show(m,'error'), info: m=>Toast._show(m,'info'),
};
+2
View File
@@ -0,0 +1,2 @@
<script src="app.js"></script>
<link rel="stylesheet" href="style.css">
-20
View File
@@ -1,20 +0,0 @@
/* ═══════════════════════════════════════════════════════════
2. STORAGE
═══════════════════════════════════════════════════════════ */
const Store = (() => {
const KEYS = { spbu:'wg2_spbu', jalan:'wg2_jalan', parsil:'wg2_parsil', warga:'wg2_warga', rumahIbadah:'wg2_ri' };
const _r = k => { try{ return JSON.parse(localStorage.getItem(k)||'[]') }catch{return[]} };
const _w = (k,d) => { try{ localStorage.setItem(k,JSON.stringify(d)) }catch{} };
let db = Object.fromEntries(Object.entries(KEYS).map(([k,v])=>[k,_r(v)]));
return {
all: ln => db[ln]||[],
find:(ln,id) => (db[ln]||[]).find(r=>r.id===id)||null,
add:(ln,rec) => { db[ln].push(rec); _w(KEYS[ln],db[ln]); },
update:(ln,id,patch) => {
const i=db[ln].findIndex(r=>r.id===id); if(i<0)return;
db[ln][i]={...db[ln][i],...patch}; _w(KEYS[ln],db[ln]);
},
remove:(ln,id) => { db[ln]=db[ln].filter(r=>r.id!==id); _w(KEYS[ln],db[ln]); },
allDb: () => db,
};
})();