tambah fitur add tempat ibadah

This commit is contained in:
tistopandita
2026-06-11 13:04:28 +07:00
parent 6535ae815f
commit 7ef7620d2a
3 changed files with 426 additions and 8 deletions
+204 -7
View File
@@ -157,6 +157,25 @@
z-index: 2000;
}
.toast.show { opacity: 1; transform: translateY(0); }
.ibadah-map { height: 300px; position: relative; border-radius: var(--radius); overflow: hidden; border: 1px solid #dbe2ea; background: #eef2f7; }
.coord-actions { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.ibadah-badge-gereja-katolik { background: #ede9fe; color: #4c1d95; }
.ibadah-badge-gpib { background: #dbeafe; color: #1e3a8a; }
.ibadah-badge-gki { background: #cffafe; color: #164e63; }
.ibadah-badge-hkbp { background: #dcfce7; color: #14532d; }
.ibadah-badge-pentakosta { background: #ffedd5; color: #7c2d12; }
.ibadah-pin {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: 999px;
background: #7c3aed;
color: #fff;
border: 3px solid #fff;
box-shadow: 0 10px 22px rgba(15, 23, 42, .25);
font-size: 16px;
}
@media (max-width: 980px) {
.shell { grid-template-columns: 1fr; }
.sidebar { position: relative; height: auto; }
@@ -183,6 +202,7 @@
<button data-section="keluarga">Data Keluarga</button>
<button data-section="bantuan">Data Bantuan</button>
<button data-section="kriteria">Kriteria SAW</button>
<button data-section="ibadah">⛪ Tempat Ibadah</button>
<button data-section="peta">Preview Peta</button>
<a href="/poverty-map.html">Buka Peta Poverty</a>
<a href="/admin.html">Admin SPBU</a>
@@ -312,6 +332,46 @@
</div>
</section>
<section class="section" id="ibadah">
<article class="card">
<div class="card-head"><h3>Form Tempat Ibadah</h3><button class="btn secondary small" id="resetIbadah">Reset</button></div>
<div class="card-body">
<form id="ibadahForm">
<input type="hidden" id="id_ibadah" />
<div class="form-grid">
<label>Nama<input id="ibadah_nama" required /></label>
<label>Jenis<select id="ibadah_jenis" required></select></label>
<label>Alamat<input id="ibadah_alamat" required /></label>
<label>Kelurahan<input id="ibadah_kelurahan" required /></label>
<div class="full">
<label>Koordinat dari Peta</label>
<div id="ibadahMap" class="ibadah-map"></div>
</div>
<label>Lat<input id="ibadah_lat" type="number" step="any" readonly required /></label>
<label>Lng<input id="ibadah_lng" type="number" step="any" readonly required /></label>
<div class="full coord-actions">
<button class="btn secondary small" id="toggleManualIbadah" type="button">✏️ Edit Manual</button>
<button class="btn danger small" id="clearIbadahPin" type="button">× Hapus Pin</button>
</div>
</div>
<div class="actions">
<button class="btn" id="ibadahSubmit" type="submit">Simpan Tempat Ibadah</button>
<button class="btn secondary" id="cancelIbadahEdit" type="button" style="display:none">Batal Edit</button>
</div>
</form>
</div>
</article>
<article class="card">
<div class="card-head"><h3>Daftar Tempat Ibadah</h3><span id="ibadahCount">0 data</span></div>
<div class="table-wrap">
<table>
<thead><tr><th>#</th><th>Nama</th><th>Jenis</th><th>Alamat</th><th>Kelurahan</th><th>Koordinat</th><th>Aksi</th></tr></thead>
<tbody id="ibadahBody"></tbody>
</table>
</div>
</article>
</section>
<section class="section" id="peta">
<article class="card">
<div class="card-head"><h3>Preview Peta Keluarga Prioritas</h3><span>Klik peta untuk mengisi koordinat form keluarga.</span></div>
@@ -324,8 +384,11 @@
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const API = "/api/poverty";
const state = { keluarga: [], bantuan: [], riwayat: [], kriteria: [], dashboard: null, analytics: null };
const state = { keluarga: [], bantuan: [], riwayat: [], kriteria: [], ibadah: [], dashboard: null, analytics: null };
const today = new Date().toISOString().slice(0, 10);
const DEFAULT_CENTER = [-0.0263, 109.3425];
const IBADAH_TYPES = ["Gereja Katolik", "GPIB", "GKI", "HKBP", "Pentakosta"];
const mapsInitialized = { ibadah: false };
const labels = {
pekerjaan: { pengangguran: "Pengangguran", buruh_harian: "Buruh Harian", petani: "Petani", nelayan: "Nelayan", pedagang_kecil: "Pedagang Kecil", karyawan: "Karyawan", wiraswasta: "Wiraswasta", pns: "PNS", pensiunan: "Pensiunan", lainnya: "Lainnya" },
pendidikan: { tidak_sekolah: "Tidak Sekolah", sd: "SD", smp: "SMP", sma: "SMA", diploma: "Diploma", sarjana: "Sarjana" },
@@ -338,6 +401,7 @@
const rp = (value) => new Intl.NumberFormat("id-ID", { style: "currency", currency: "IDR", maximumFractionDigits: 0 }).format(Number(value || 0));
const fmt = (value) => value ? String(value).slice(0, 10) : "";
const badgeClass = (kategori) => kategori === "Sangat Membutuhkan" ? "sangat" : kategori === "Membutuhkan" ? "butuh" : kategori === "Rentan" ? "rentan" : "mampu";
const ibadahBadgeClass = (jenis) => `ibadah-badge-${String(jenis || "").toLowerCase().replace(/\s+/g, "-")}`;
async function api(path, options = {}) {
const response = await fetch(`${API}${path}`, { headers: { "Content-Type": "application/json", ...(options.headers || {}) }, ...options });
@@ -423,6 +487,87 @@
`).join("") || `<tr><td colspan="5">Belum ada data kriteria.</td></tr>`;
}
function renderIbadah() {
el("ibadahCount").textContent = `${state.ibadah.length} data`;
el("ibadahBody").innerHTML = state.ibadah.map((item, index) => `
<tr>
<td>${index + 1}</td>
<td><strong>${esc(item.nama)}</strong></td>
<td><span class="badge ${ibadahBadgeClass(item.jenis)}">${esc(item.jenis)}</span></td>
<td>${esc(item.alamat)}</td>
<td>${esc(item.kelurahan)}</td>
<td>${Number(item.lat).toFixed(6)}, ${Number(item.lng).toFixed(6)}</td>
<td><button class="btn secondary small" onclick="editIbadah(${item.id})">Edit</button> <button class="btn danger small" onclick="hapus('ibadah', ${item.id})">Hapus</button></td>
</tr>
`).join("") || `<tr><td colspan="7">Belum ada data tempat ibadah.</td></tr>`;
}
let ibadahMap, ibadahMarker, ibadahManual = false;
const ibadahIcon = L.divIcon({
className: "",
html: `<div class="ibadah-pin">⛪</div>`,
iconSize: [32, 32],
iconAnchor: [16, 32],
});
function ensureIbadahMap() {
if (mapsInitialized.ibadah) return;
mapsInitialized.ibadah = true;
ibadahMap = L.map("ibadahMap").setView(DEFAULT_CENTER, 12);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 19, attribution: "&copy; OpenStreetMap" }).addTo(ibadahMap);
ibadahMap.on("click", (event) => setIbadahPin(event.latlng.lat, event.latlng.lng));
setTimeout(() => ibadahMap.invalidateSize(), 80);
}
function setIbadahFields(lat, lng) {
el("ibadah_lat").value = Number(lat).toFixed(7);
el("ibadah_lng").value = Number(lng).toFixed(7);
}
function setIbadahPin(lat, lng, zoom = false) {
const point = [Number(lat), Number(lng)];
if (!Number.isFinite(point[0]) || !Number.isFinite(point[1])) return;
ensureIbadahMap();
setIbadahFields(point[0], point[1]);
if (!ibadahMarker) {
ibadahMarker = L.marker(point, { draggable: true, icon: ibadahIcon }).addTo(ibadahMap);
ibadahMarker.on("dragend", () => {
const latlng = ibadahMarker.getLatLng();
setIbadahFields(latlng.lat, latlng.lng);
});
} else {
ibadahMarker.setLatLng(point);
}
if (zoom) ibadahMap.setView(point, 15);
}
function clearIbadahPin() {
if (ibadahMarker) {
ibadahMarker.remove();
ibadahMarker = null;
}
el("ibadah_lat").value = "";
el("ibadah_lng").value = "";
}
function syncIbadahManualPin() {
const lat = Number(el("ibadah_lat").value);
const lng = Number(el("ibadah_lng").value);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return;
setIbadahPin(lat, lng, true);
}
function ibadahPayload() {
return {
nama: el("ibadah_nama").value.trim(),
jenis: el("ibadah_jenis").value,
alamat: el("ibadah_alamat").value.trim(),
kelurahan: el("ibadah_kelurahan").value.trim(),
lat: Number(el("ibadah_lat").value),
lng: Number(el("ibadah_lng").value),
};
}
let map, layer;
function ensureMap() {
if (map) return;
@@ -449,11 +594,11 @@
}
async function loadAll() {
const [dashboard, analytics, keluarga, bantuan, riwayat, kriteria] = await Promise.all([
api("/dashboard"), api("/analytics"), api("/keluarga"), api("/bantuan"), api("/riwayat-bantuan"), api("/kriteria")
const [dashboard, analytics, keluarga, bantuan, riwayat, kriteria, ibadah] = await Promise.all([
api("/dashboard"), api("/analytics"), api("/keluarga"), api("/bantuan"), api("/riwayat-bantuan"), api("/kriteria"), api("/ibadah")
]);
Object.assign(state, { dashboard, analytics, keluarga, bantuan, riwayat, kriteria });
renderDashboard(); renderKeluarga(); renderBantuan(); renderKriteria();
Object.assign(state, { dashboard, analytics, keluarga, bantuan, riwayat, kriteria, ibadah });
renderDashboard(); renderKeluarga(); renderBantuan(); renderKriteria(); renderIbadah();
}
function familyPayload() {
@@ -486,6 +631,18 @@
function resetBantuan() { el("bantuanForm").reset(); el("id_bantuan").value = ""; el("tanggal_bantuan").value = today; }
function resetRiwayat() { el("riwayatForm").reset(); el("id_riwayat").value = ""; el("tanggal_diterima").value = today; }
function resetKriteria() { el("kriteriaForm").reset(); el("id_kriteria").value = ""; }
function resetIbadah() {
el("ibadahForm").reset();
el("id_ibadah").value = "";
el("ibadah_lat").readOnly = true;
el("ibadah_lng").readOnly = true;
ibadahManual = false;
el("toggleManualIbadah").textContent = "✏️ Edit Manual";
el("ibadahSubmit").textContent = "Simpan Tempat Ibadah";
el("cancelIbadahEdit").style.display = "none";
clearIbadahPin();
if (ibadahMap) ibadahMap.setView(DEFAULT_CENTER, 12);
}
window.editKeluarga = (id) => {
const item = state.keluarga.find((row) => row.id_keluarga === id);
@@ -509,6 +666,22 @@
if (!item) return;
el("id_kriteria").value = id; el("kode").value = item.kode; el("nama_kriteria").value = item.nama_kriteria; el("bobot").value = item.bobot; el("atribut").value = item.atribut;
};
window.editIbadah = (id) => {
const item = state.ibadah.find((row) => row.id === id);
if (!item) return;
document.querySelector('[data-section="ibadah"]').click();
el("id_ibadah").value = id;
el("ibadah_nama").value = item.nama;
el("ibadah_jenis").value = item.jenis;
el("ibadah_alamat").value = item.alamat;
el("ibadah_kelurahan").value = item.kelurahan;
el("ibadahSubmit").textContent = "Update Tempat Ibadah";
el("cancelIbadahEdit").style.display = "inline-flex";
setTimeout(() => {
setIbadahPin(item.lat, item.lng, true);
el("ibadah").scrollIntoView({ behavior: "smooth", block: "start" });
}, 100);
};
window.hapus = async (resource, id) => {
if (!confirm("Hapus/nonaktifkan data ini?")) return;
await api(`/${resource}/${id}`, { method: "DELETE" });
@@ -522,7 +695,13 @@
document.querySelectorAll(".section").forEach((item) => item.classList.remove("active"));
button.classList.add("active");
el(button.dataset.section).classList.add("active");
setTimeout(() => map?.invalidateSize(), 80);
setTimeout(() => {
map?.invalidateSize();
if (button.dataset.section === "ibadah") {
ensureIbadahMap();
ibadahMap?.invalidateSize();
}
}, 80);
});
});
@@ -553,11 +732,28 @@
await api(id ? `/kriteria/${id}` : "/kriteria", { method: id ? "PUT" : "POST", body: JSON.stringify(payload) });
resetKriteria(); toast("Kriteria tersimpan."); await loadAll();
});
document.getElementById("ibadahForm").addEventListener("submit", async (event) => {
event.preventDefault();
const id = el("id_ibadah").value;
await api(id ? `/ibadah/${id}` : "/ibadah", { method: id ? "PUT" : "POST", body: JSON.stringify(ibadahPayload()) });
resetIbadah(); toast("Tempat ibadah tersimpan."); await loadAll();
});
el("resetKeluarga").addEventListener("click", resetKeluarga);
el("resetBantuan").addEventListener("click", resetBantuan);
el("resetRiwayat").addEventListener("click", resetRiwayat);
el("resetKriteria").addEventListener("click", resetKriteria);
el("resetIbadah").addEventListener("click", resetIbadah);
el("cancelIbadahEdit").addEventListener("click", resetIbadah);
el("clearIbadahPin").addEventListener("click", clearIbadahPin);
el("toggleManualIbadah").addEventListener("click", () => {
ibadahManual = !ibadahManual;
el("ibadah_lat").readOnly = !ibadahManual;
el("ibadah_lng").readOnly = !ibadahManual;
el("toggleManualIbadah").textContent = ibadahManual ? "🔒 Kunci Koordinat" : "✏️ Edit Manual";
});
el("ibadah_lat").addEventListener("blur", syncIbadahManualPin);
el("ibadah_lng").addEventListener("blur", syncIbadahManualPin);
el("familySearch").addEventListener("input", renderKeluarga);
el("recalculateBtn").addEventListener("click", async () => { await api("/recalculate", { method: "POST" }); toast("SAW berhasil dihitung ulang."); await loadAll(); });
@@ -565,7 +761,8 @@
fillOptions("pendidikan", labels.pendidikan);
fillOptions("kondisi_rumah", labels.kondisi_rumah);
fillOptions("kondisi_kesehatan", labels.kondisi_kesehatan);
resetKeluarga(); resetBantuan(); resetRiwayat(); resetKriteria();
el("ibadah_jenis").innerHTML = IBADAH_TYPES.map((jenis) => `<option value="${jenis}">${jenis}</option>`).join("");
resetKeluarga(); resetBantuan(); resetRiwayat(); resetKriteria(); resetIbadah();
loadAll().catch((error) => toast(error.message));
</script>
</body>
+91 -1
View File
@@ -170,6 +170,19 @@
z-index: 2000;
}
.toast.show { opacity: 1; transform: translateY(0); }
.ibadah-legend { display: none; }
.ibadah-legend.active { display: block; }
.ibadah-map-marker {
display: grid;
place-items: center;
width: 30px;
height: 30px;
border-radius: 999px;
color: white;
border: 3px solid white;
box-shadow: 0 10px 24px rgba(15, 23, 42, .28);
font-size: 15px;
}
@media (max-width: 880px) {
body { overflow: hidden; }
.app { grid-template-columns: 1fr; }
@@ -222,6 +235,7 @@
<input id="searchInput" placeholder="Nama, alamat, atau wilayah" />
</label>
<label class="switch"><span>Heatmap persebaran</span><input id="heatToggle" type="checkbox" checked /></label>
<label class="switch"><span>⛪ Tempat Ibadah</span><input id="ibadahToggle" type="checkbox" /></label>
</section>
<section class="panel">
@@ -234,6 +248,17 @@
</div>
</section>
<section class="panel ibadah-legend" id="ibadahLegendPanel">
<h2>Legenda Tempat Ibadah</h2>
<div class="legend">
<span><i class="dot" style="background:#7c3aed"></i>Gereja Katolik</span>
<span><i class="dot" style="background:#2563eb"></i>GPIB</span>
<span><i class="dot" style="background:#0891b2"></i>GKI</span>
<span><i class="dot" style="background:#16a34a"></i>HKBP</span>
<span><i class="dot" style="background:#ea580c"></i>Pentakosta</span>
</div>
</section>
<section class="panel">
<h2>Statistik Wilayah</h2>
<div id="areaStats"></div>
@@ -262,11 +287,12 @@
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const API = "/api/poverty";
const state = { rows: [], dashboard: null, markers: new Map() };
const state = { rows: [], dashboard: null, markers: new Map(), ibadah: [], ibadahLoaded: false };
const map = L.map("map", { zoomControl: true }).setView([-0.0263, 109.3425], 12);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 19, attribution: "&copy; OpenStreetMap" }).addTo(map);
const markerLayer = L.layerGroup().addTo(map);
const heatLayer = L.layerGroup().addTo(map);
const ibadahLayer = L.layerGroup();
const el = (id) => document.getElementById(id);
const esc = (value) => String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char]));
@@ -274,6 +300,13 @@
const score = (value) => Number(value || 0).toFixed(3);
const badgeClass = (kategori) => kategori === "Sangat Membutuhkan" ? "sangat" : kategori === "Membutuhkan" ? "butuh" : kategori === "Rentan" ? "rentan" : "mampu";
const color = (kategori) => kategori === "Sangat Membutuhkan" ? "#dc2626" : kategori === "Membutuhkan" ? "#f97316" : kategori === "Rentan" ? "#facc15" : "#22c55e";
const ibadahColors = {
"Gereja Katolik": "#7c3aed",
GPIB: "#2563eb",
GKI: "#0891b2",
HKBP: "#16a34a",
Pentakosta: "#ea580c",
};
function toast(message) {
el("toast").textContent = message;
@@ -350,6 +383,47 @@
`;
}
function ibadahIcon(jenis) {
const markerColor = ibadahColors[jenis] || "#64748b";
return L.divIcon({
className: "",
html: `<div class="ibadah-map-marker" style="background:${markerColor}">⛪</div>`,
iconSize: [30, 30],
iconAnchor: [15, 30],
popupAnchor: [0, -28],
});
}
function ibadahPopupHtml(item) {
const markerColor = ibadahColors[item.jenis] || "#64748b";
return `
<div class="popup">
<h3>${esc(item.nama)}</h3>
<span class="badge" style="background:${markerColor}22;color:${markerColor};border:1px solid ${markerColor}44">${esc(item.jenis)}</span>
<p style="margin-top:10px">${esc(item.alamat)}<br>${esc(item.kelurahan)}</p>
</div>
`;
}
function renderIbadahMarkers() {
ibadahLayer.clearLayers();
state.ibadah.forEach((item) => {
const latlng = [Number(item.lat), Number(item.lng)];
if (!Number.isFinite(latlng[0]) || !Number.isFinite(latlng[1])) return;
L.marker(latlng, { icon: ibadahIcon(item.jenis) })
.bindPopup(ibadahPopupHtml(item))
.addTo(ibadahLayer);
});
}
async function loadIbadahLayer(force = false) {
if (state.ibadahLoaded && !force) return;
const payload = await fetchJson("/ibadah");
state.ibadah = payload.data || [];
state.ibadahLoaded = true;
renderIbadahMarkers();
}
function renderMarkers() {
markerLayer.clearLayers();
heatLayer.clearLayers();
@@ -408,6 +482,22 @@
let debounce;
["categoryFilter", "heatToggle"].forEach((id) => el(id).addEventListener("change", () => loadData().catch((error) => toast(error.message))));
el("ibadahToggle").addEventListener("change", async () => {
try {
if (el("ibadahToggle").checked) {
await loadIbadahLayer(true);
ibadahLayer.addTo(map);
el("ibadahLegendPanel").classList.add("active");
} else {
map.removeLayer(ibadahLayer);
el("ibadahLegendPanel").classList.remove("active");
}
} catch (error) {
el("ibadahToggle").checked = false;
el("ibadahLegendPanel").classList.remove("active");
toast(error.message);
}
});
el("searchInput").addEventListener("input", () => {
clearTimeout(debounce);
debounce = setTimeout(() => loadData().catch((error) => toast(error.message)), 260);