This commit is contained in:
2026-06-10 18:07:01 +07:00
parent c4295c5395
commit 5257946cfa
69 changed files with 10741 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
FROM php:8.2-apache
RUN docker-php-ext-install mysqli pdo pdo_mysql \
&& a2enmod rewrite
COPY . /var/www/html/
RUN chown -R www-data:www-data /var/www/html
+13
View File
@@ -0,0 +1,13 @@
<?php
include 'koneksi.php';
$result = $conn->query("SELECT * FROM spbu");
$data = [];
while ($row = $result->fetch_assoc()) {
$data[] = $row;
}
echo json_encode($data);
?>
+14
View File
@@ -0,0 +1,14 @@
<?php
include 'koneksi.php';
$id = $_POST['id'];
$nama = $_POST['nama'];
$wa = $_POST['wa'];
$status = $_POST['status'];
$conn->query("UPDATE spbu SET
nama_spbu='$nama',
no_wa='$wa',
status='$status'
WHERE id=$id");
?>
+7
View File
@@ -0,0 +1,7 @@
<?php
include 'koneksi.php';
$id = $_GET['id'];
$conn->query("DELETE FROM spbu WHERE id=$id");
?>
+80
View File
@@ -0,0 +1,80 @@
<!DOCTYPE html>
<html lang="en">
<head>
<base target="_top">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Quick Start - Leaflet</title>
<link rel="shortcut icon" type="image/x-icon" href="docs/images/favicon.ico" />
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<style>
html, body {
height: 100%;
margin: 0;
}
.leaflet-container {
height: 400px;
width: 600px;
max-width: 100%;
max-height: 100%;
}
</style>
</head>
<body>
<div id="map" style="width: 600px; height: 400px;"></div>
<script>
const map = L.map('map').setView([-0.0553, 109.3495], 13);
const tiles = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map);
const marker = L.marker([-0.0553, 109.3495]).addTo(map)
.bindPopup('<b>Hello world!</b><br />I am a popup.').openPopup();
const circle = L.circle([-0.056, 109.35], {
color: 'red',
fillColor: '#f03',
fillOpacity: 0.5,
radius: 500
}).addTo(map).bindPopup('I am a circle.');
const polygon = L.polygon([
[-0.054, 109.34],
[-0.057, 109.36],
[-0.052, 109.355]
]).addTo(map).bindPopup('I am a polygon.');
const popup = L.popup()
.setLatLng([51.513, -0.09])
.setContent('I am a standalone popup.')
.openOn(map);
function onMapClick(e) {
popup
.setLatLng(e.latlng)
.setContent(`You clicked the map at ${e.latlng.toString()}`)
.openOn(map);
}
map.on('click', onMapClick);
</script>
</body>
</html>
+195
View File
@@ -0,0 +1,195 @@
<!DOCTYPE html>
<html>
<head>
<title>GIS SPBU PRO FINAL</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css"/>
<script src="https://unpkg.com/leaflet/dist/leaflet.js"></script>
<style>
#map { height: 100vh; }
.btn {
padding: 4px 8px;
margin-top: 5px;
border: none;
cursor: pointer;
}
.edit { background: orange; color: white; }
.delete { background: red; color: white; }
</style>
</head>
<body>
<div id="map"></div>
<script>
const map = L.map('map').setView([-0.02, 109.34], 13);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19
}).addTo(map);
let markers = [];
// ICON
const greenIcon = new L.Icon({
iconUrl: 'https://maps.google.com/mapfiles/ms/icons/green-dot.png',
iconSize: [32, 32]
});
const redIcon = new L.Icon({
iconUrl: 'https://maps.google.com/mapfiles/ms/icons/red-dot.png',
iconSize: [32, 32]
});
// ================= LOAD DATA =================
function loadData() {
markers.forEach(m => map.removeLayer(m));
markers = [];
fetch('ambil.php')
.then(res => res.json())
.then(data => {
data.forEach(d => {
let icon = d.status === 'yes' ? greenIcon : redIcon;
const marker = L.marker([d.latitude, d.longitude], {
icon: icon,
draggable: true
}).addTo(map);
// DRAG SIMPAN LOKASI (FIX)
marker.on('dragend', function(e){
const pos = e.target.getLatLng();
const dataSend = new URLSearchParams();
dataSend.append("id", d.id);
dataSend.append("lat", pos.lat);
dataSend.append("lng", pos.lng);
fetch('update_lokasi.php', {
method: 'POST',
body: dataSend
})
.then(res => res.text())
.then(res => {
console.log(res);
alert("Lokasi berhasil diupdate!");
});
});
marker.bindPopup(`
<b>${d.nama_spbu}</b><br>
WA: ${d.no_wa}<br>
24 Jam: <span style="color:${d.status === 'yes' ? 'green' : 'red'}">${d.status}</span><br>
<button class="btn edit" onclick="editData(${d.id}, '${d.nama_spbu}', '${d.no_wa}', '${d.status}')">Edit</button>
<button class="btn delete" onclick="hapusData(${d.id})">Hapus</button>
`);
markers.push(marker);
});
});
}
// ================= EDIT =================
function editData(id, nama, wa, status) {
const form = `
<form id="editForm">
Nama:<br>
<input type="text" name="nama" value="${nama}"><br>
WA:<br>
<input type="text" name="wa" value="${wa}"><br>
Status:<br>
<select name="status">
<option ${status=='yes'?'selected':''} value="yes">Yes</option>
<option ${status=='no'?'selected':''} value="no">No</option>
</select><br><br>
<button type="submit">Update</button>
</form>
`;
L.popup().setLatLng(map.getCenter()).setContent(form).openOn(map);
setTimeout(()=>{
document.getElementById('editForm').addEventListener('submit', function(e){
e.preventDefault();
const data = new FormData(this);
data.append('id', id);
fetch('edit.php', {
method:'POST',
body:data
}).then(()=>{
alert("Data diupdate!");
loadData();
});
});
},300);
}
// ================= HAPUS =================
function hapusData(id) {
if(confirm("Yakin hapus?")) {
fetch('hapus.php?id=' + id)
.then(()=>{
alert("Data dihapus!");
loadData();
});
}
}
// ================= TAMBAH =================
map.on('click', function(e) {
const lat = e.latlng.lat;
const lng = e.latlng.lng;
const form = `
<form id="formInput">
Nama SPBU:<br>
<input type="text" name="nama_spbu" required><br>
No WA:<br>
<input type="text" name="no_wa" required><br>
Status:<br>
<select name="status">
<option value="yes">Yes</option>
<option value="no">No</option>
</select><br><br>
<input type="hidden" name="latitude" value="${lat}">
<input type="hidden" name="longitude" value="${lng}">
<button type="submit">Simpan</button>
</form>
`;
L.popup().setLatLng(e.latlng).setContent(form).openOn(map);
setTimeout(()=>{
document.getElementById('formInput').addEventListener('submit', function(ev){
ev.preventDefault();
const data = new FormData(this);
fetch('simpan.php',{
method:'POST',
body:data
}).then(()=>{
alert("Tersimpan!");
loadData();
});
});
},300);
});
loadData();
</script>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
<?php
$host = getenv('DB_HOST');
$user = getenv('DB_USER');
$pass = getenv('DB_PASSWORD');
$db = getenv('DB_NAME');
$conn = mysqli_connect($host, $user, $pass, $db);
if (!$conn) {
die("Koneksi database gagal: " . mysqli_connect_error());
}
?>
+18
View File
@@ -0,0 +1,18 @@
<?php
include 'koneksi.php';
$nama = $_POST['nama_spbu'];
$wa = $_POST['no_wa'];
$status = $_POST['status'];
$lat = $_POST['latitude'];
$lng = $_POST['longitude'];
$sql = "INSERT INTO spbu (nama_spbu, no_wa, status, latitude, longitude)
VALUES ('$nama', '$wa', '$status', '$lat', '$lng')";
if ($conn->query($sql)) {
echo "success";
} else {
echo "error";
}
?>
+15
View File
@@ -0,0 +1,15 @@
<?php
include 'koneksi.php';
$id = $_POST['id'];
$lat = $_POST['lat'];
$lng = $_POST['lng'];
$sql = "UPDATE spbu SET latitude='$lat', longitude='$lng' WHERE id='$id'";
if ($conn->query($sql)) {
echo "success";
} else {
echo "error: " . $conn->error;
}
?>
+8
View File
@@ -0,0 +1,8 @@
FROM php:8.2-apache
RUN docker-php-ext-install mysqli pdo pdo_mysql \
&& a2enmod rewrite
COPY . /var/www/html/
RUN chown -R www-data:www-data /var/www/html
+54
View File
@@ -0,0 +1,54 @@
-- ============================================================
-- DATABASE PROYEK 02 — Jalanan, Parsil, Jalan Rusak
-- Import ini ke phpMyAdmin (dari server, bukan dari database)
-- ============================================================
SET FOREIGN_KEY_CHECKS = 0;
-- Database data_jalan: tabel jalan & parsil
CREATE DATABASE IF NOT EXISTS data_jalan
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE data_jalan;
DROP TABLE IF EXISTS parsil;
DROP TABLE IF EXISTS jalan;
CREATE TABLE jalan (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(150) NOT NULL,
status ENUM('baik','sedang','rusak_ringan','rusak_berat') DEFAULT 'baik',
panjang DECIMAL(10,2) DEFAULT 0,
geometry LONGTEXT COMMENT 'GeoJSON LineString',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE parsil (
id INT AUTO_INCREMENT PRIMARY KEY,
pemilik VARCHAR(150) NOT NULL,
status ENUM('milik_sendiri','sewa','hibah','sengketa') DEFAULT 'milik_sendiri',
luas DECIMAL(12,2) DEFAULT 0,
geometry LONGTEXT COMMENT 'GeoJSON Polygon',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Database webgis: tabel jalan_rusak
CREATE DATABASE IF NOT EXISTS webgis
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE webgis;
DROP TABLE IF EXISTS jalan_rusak;
CREATE TABLE jalan_rusak (
id INT AUTO_INCREMENT PRIMARY KEY,
keterangan TEXT,
tingkat ENUM('ringan','sedang','berat') DEFAULT 'sedang',
geometry LONGTEXT COMMENT 'GeoJSON geometry',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
SET FOREIGN_KEY_CHECKS = 1;
-- ============================================================
-- SELESAI — 2 database, 3 tabel siap dipakai
-- ============================================================
+12
View File
@@ -0,0 +1,12 @@
<?php
$host = getenv('DB_HOST');
$user = getenv('DB_USER');
$pass = getenv('DB_PASSWORD');
$db = getenv('DB_NAME');
$conn = mysqli_connect($host, $user, $pass, $db);
if (!$conn) {
die("Koneksi database gagal: " . mysqli_connect_error());
}
?>
+8
View File
@@ -0,0 +1,8 @@
<?php
include 'db.php';
$id = (int)($_GET['id'] ?? 0);
if ($conn->query("DELETE FROM jalan WHERE id=$id")) {
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'error' => $conn->error]);
}
+8
View File
@@ -0,0 +1,8 @@
<?php
include 'db.php';
$id = (int)($_GET['id'] ?? 0);
if ($conn->query("DELETE FROM parsil WHERE id=$id")) {
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'error' => $conn->error]);
}
+8
View File
@@ -0,0 +1,8 @@
<?php
include 'koneksi.php';
$id = (int)($_GET['id'] ?? 0);
if ($conn->query("DELETE FROM jalan_rusak WHERE id=$id")) {
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'error' => $conn->error]);
}
+11
View File
@@ -0,0 +1,11 @@
<?php
include 'db.php';
header('Content-Type: application/json');
$result = $conn->query("SELECT * FROM jalan");
$data = [];
while ($row = $result->fetch_assoc()) {
$row['geometry'] = json_decode($row['geometry']);
$data[] = $row;
}
echo json_encode($data);
+11
View File
@@ -0,0 +1,11 @@
<?php
include 'db.php';
header('Content-Type: application/json');
$result = $conn->query("SELECT * FROM parsil");
$data = [];
while ($row = $result->fetch_assoc()) {
$row['geometry'] = json_decode($row['geometry']);
$data[] = $row;
}
echo json_encode($data);
+11
View File
@@ -0,0 +1,11 @@
<?php
include 'koneksi.php';
header('Content-Type: application/json');
$q = $conn->query("SELECT * FROM jalan_rusak");
$data = [];
while ($d = $q->fetch_assoc()) {
$d['geometry'] = json_decode($d['geometry']);
$data[] = $d;
}
echo json_encode($data);
+341
View File
@@ -0,0 +1,341 @@
<!DOCTYPE html>
<html>
<head>
<title>WEB GIS FINAL</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css"/>
<link rel="stylesheet" href="https://unpkg.com/leaflet-draw/dist/leaflet.draw.css"/>
<script src="https://unpkg.com/leaflet/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet-draw/dist/leaflet.draw.js"></script>
<script src="https://unpkg.com/leaflet-geometryutil"></script>
<style>
#map { height: 100vh; }
#searchBox{
position:absolute;
top:10px;
left:260px;
z-index:999;
padding:8px;
width:250px;
}
.modal {
display:none;
position:fixed;
z-index:999;
left:0;top:0;
width:100%;height:100%;
background:rgba(0,0,0,0.5);
}
.modal-content{
background:white;
padding:20px;
width:300px;
margin:120px auto;
border-radius:10px;
text-align:center;
}
</style>
</head>
<body>
<input type="text" id="searchBox" placeholder="Cari semua data...">
<div id="map"></div>
<div id="formModal" class="modal">
<div class="modal-content">
<h3 id="formTitle"></h3>
<input type="text" id="input1"><br>
<select id="input2"></select><br>
<button onclick="submitForm()">Simpan</button>
<button onclick="closeForm()">Batal</button>
</div>
</div>
<script>
// ================= MAP =================
var map = L.map('map').setView([-0.05,109.34],13);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);
var drawnItems = new L.FeatureGroup();
map.addLayer(drawnItems);
let semuaLayer = [];
let currentLayer = null;
let currentType = null;
// ================= WARNA =================
function warnaJalan(s){
return s=="Nasional"?"red":s=="Provinsi"?"blue":"green";
}
function warnaParsil(s){
return s=="SHM"?"yellow":
s=="HGB"?"orange":
s=="HGU"?"brown":
"purple";
}
// ================= FORMAT =================
function formatMeter(m){ return m.toFixed(2)+" m"; }
function formatMeter2(m){ return m.toFixed(2)+" m²"; }
// ================= FORM =================
function openForm(type, layer){
currentLayer = layer;
currentType = type;
document.getElementById("formModal").style.display="block";
let select = document.getElementById("input2");
select.innerHTML="";
if(type=="jalan"){
formTitle.innerText="Input Jalan";
input1.placeholder="Nama Jalan";
["Nasional","Provinsi","Kabupaten"].forEach(v=>{
let o=document.createElement("option");
o.value=v;o.text=v;
select.appendChild(o);
});
}
else if(type=="parsil"){
formTitle.innerText="Input Parsil";
input1.placeholder="Nama Pemilik";
["SHM","HGB","HGU","HP"].forEach(v=>{
let o=document.createElement("option");
o.value=v;o.text=v;
select.appendChild(o);
});
}
else if(type=="rusak"){
formTitle.innerText="Jalan Rusak";
input1.placeholder="Keterangan";
["Ringan","Sedang","Berat"].forEach(v=>{
let o=document.createElement("option");
o.value=v;o.text=v;
select.appendChild(o);
});
}
}
function closeForm(){
document.getElementById("formModal").style.display="none";
input1.value = "";
currentLayer = null;
currentType = null;
// Re-enable draw control
map.addControl(drawControl);
}
function submitForm(){
let v1 = input1.value.trim();
let v2 = input2.value;
if(!v1){
alert("Mohon isi nama/keterangan terlebih dahulu!");
return;
}
if(!currentLayer || !currentType){
alert("Tidak ada layer yang dipilih!");
closeForm();
return;
}
// Simpan referensi layer sebelum closeForm me-reset currentLayer
let layer = currentLayer;
let type = currentType;
closeForm();
if(type=="jalan") prosesJalan(layer, v1, v2);
else if(type=="parsil") prosesParsil(layer, v1, v2);
else if(type=="rusak") prosesRusak(layer, v1, v2);
}
// ================= DRAW =================
var drawControl = new L.Control.Draw({
edit:{ featureGroup: drawnItems },
draw:{
polyline:true,
polygon:true,
rectangle:false,
circle:false,
marker:true
}
});
map.addControl(drawControl);
map.on('draw:created', function(e){
var layer = e.layer;
drawnItems.addLayer(layer);
// Disable draw sementara sampai form disubmit
map.removeControl(drawControl);
if(e.layerType=="polyline") openForm("jalan",layer);
if(e.layerType=="polygon") openForm("parsil",layer);
if(e.layerType=="marker") openForm("rusak",layer);
});
// ================= JALAN =================
function prosesJalan(layer,nama,status){
let latlngs = layer.getLatLngs();
let panjang=0;
for(let i=0;i<latlngs.length-1;i++){
panjang += latlngs[i].distanceTo(latlngs[i+1]);
}
layer.setStyle({color:warnaJalan(status)});
fetch('save_jalan.php',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({nama,status,panjang,geometry:layer.toGeoJSON().geometry})
})
.then(r=>r.json())
.then(res=> { if(res.success) layer.id=res.id; else console.error('save_jalan error:',res.error); });
layer.bindPopup(`<b>Jalan</b><br>${nama}<br>Panjang: ${formatMeter(panjang)}`);
semuaLayer.push({layer:layer,nama:nama});
}
// ================= PARSIL =================
function prosesParsil(layer,pemilik,status){
let luas = L.GeometryUtil.geodesicArea(layer.getLatLngs()[0]);
layer.setStyle({color:warnaParsil(status)});
fetch('save_parsil.php',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({pemilik,status,luas,geometry:layer.toGeoJSON().geometry})
})
.then(r=>r.json())
.then(res=> { if(res.success) layer.id=res.id; else console.error('save_parsil error:',res.error); });
layer.bindPopup(`<b>Parsil</b><br>${pemilik}<br>Luas: ${formatMeter2(luas)}`);
semuaLayer.push({layer:layer,nama:pemilik});
}
// ================= RUSAK =================
function prosesRusak(layer,keterangan,tingkat){
let icon = L.icon({
iconUrl: tingkat=="Ringan"?
"https://maps.google.com/mapfiles/ms/icons/green-dot.png":
tingkat=="Sedang"?
"https://maps.google.com/mapfiles/ms/icons/yellow-dot.png":
"https://maps.google.com/mapfiles/ms/icons/red-dot.png",
iconSize:[25,41],
iconAnchor:[12,41]
});
layer.setIcon(icon);
fetch('save_rusak.php',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({keterangan,tingkat,geometry:layer.toGeoJSON().geometry})
})
.then(r=>r.json())
.then(res=> { if(res.success) layer.id=res.id; else console.error('save_rusak error:',res.error); });
layer.bindPopup(`<b>Rusak</b><br>${keterangan}<br><b>Tingkat:</b> ${tingkat}`);
semuaLayer.push({layer:layer,nama:keterangan});
}
// ================= LOAD =================
fetch('get_jalan.php').then(r=>r.json()).then(data=>{
data.forEach(j=>{
let geo=j.geometry;
let layer=L.geoJSON(geo,{style:{color:warnaJalan(j.status)}}).addTo(map);
layer.eachLayer(l=>{
l.bindPopup(`<b>Jalan</b><br>${j.nama}<br>Panjang: ${formatMeter(j.panjang)}`);
semuaLayer.push({layer:l,nama:j.nama});
});
});
});
fetch('get_parsil.php').then(r=>r.json()).then(data=>{
data.forEach(p=>{
let geo=p.geometry;
let layer=L.geoJSON(geo,{style:{color:warnaParsil(p.status)}}).addTo(map);
layer.eachLayer(l=>{
l.bindPopup(`<b>Parsil</b><br>${p.pemilik}<br>Luas: ${formatMeter2(p.luas)}`);
semuaLayer.push({layer:l,nama:p.pemilik});
});
});
});
fetch('get_rusak.php').then(r=>r.json()).then(data=>{
data.forEach(r=>{
let geo=r.geometry;
let icon = L.icon({
iconUrl: r.tingkat=="Ringan"?
"https://maps.google.com/mapfiles/ms/icons/green-dot.png":
r.tingkat=="Sedang"?
"https://maps.google.com/mapfiles/ms/icons/yellow-dot.png":
"https://maps.google.com/mapfiles/ms/icons/red-dot.png",
iconSize:[25,41],
iconAnchor:[12,41]
});
let layer=L.geoJSON(geo,{
pointToLayer:(f,latlng)=>L.marker(latlng,{icon:icon})
}).addTo(map);
layer.eachLayer(l=>{
l.bindPopup(`<b>Rusak</b><br>${r.keterangan}<br><b>Tingkat:</b> ${r.tingkat}`);
semuaLayer.push({layer:l,nama:r.keterangan});
});
});
});
// ================= SEARCH =================
document.getElementById("searchBox").addEventListener("keyup",function(){
let key=this.value.toLowerCase();
semuaLayer.forEach(o=>{
let cocok=o.nama.toLowerCase().includes(key);
if(o.layer.setStyle){
o.layer.setStyle({
opacity:cocok?1:0.1,
fillOpacity:cocok?0.7:0.1
});
}
if(o.layer.setOpacity){
o.layer.setOpacity(cocok?1:0.2);
}
if(cocok){
try{ map.fitBounds(o.layer.getBounds()); }catch(e){}
}
});
});
</script>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
<?php
$host = getenv('DB_HOST');
$user = getenv('DB_USER');
$pass = getenv('DB_PASSWORD');
$db = getenv('DB_NAME');
$conn = mysqli_connect($host, $user, $pass, $db);
if (!$conn) {
die("Koneksi database gagal: " . mysqli_connect_error());
}
?>
+23
View File
@@ -0,0 +1,23 @@
<?php
include 'db.php';
$data = json_decode(file_get_contents("php://input"), true);
if (!$data) {
echo json_encode(['success' => false, 'error' => 'Data tidak valid']);
exit;
}
$nama = $conn->real_escape_string($data['nama'] ?? '');
$status = $conn->real_escape_string($data['status'] ?? 'baik');
$panjang = floatval($data['panjang'] ?? 0);
$geometry = $conn->real_escape_string(json_encode($data['geometry']));
$sql = "INSERT INTO jalan (nama, status, panjang, geometry)
VALUES ('$nama', '$status', '$panjang', '$geometry')";
if ($conn->query($sql)) {
echo json_encode(['success' => true, 'id' => $conn->insert_id]);
} else {
echo json_encode(['success' => false, 'error' => $conn->error]);
}
+23
View File
@@ -0,0 +1,23 @@
<?php
include 'db.php';
$data = json_decode(file_get_contents("php://input"), true);
if (!$data) {
echo json_encode(['success' => false, 'error' => 'Data tidak valid']);
exit;
}
$pemilik = $conn->real_escape_string($data['pemilik'] ?? '');
$status = $conn->real_escape_string($data['status'] ?? 'milik_sendiri');
$luas = floatval($data['luas'] ?? 0);
$geometry = $conn->real_escape_string(json_encode($data['geometry']));
$sql = "INSERT INTO parsil (pemilik, status, luas, geometry)
VALUES ('$pemilik', '$status', '$luas', '$geometry')";
if ($conn->query($sql)) {
echo json_encode(['success' => true, 'id' => $conn->insert_id]);
} else {
echo json_encode(['success' => false, 'error' => $conn->error]);
}
+22
View File
@@ -0,0 +1,22 @@
<?php
include 'koneksi.php';
$data = json_decode(file_get_contents("php://input"));
if (!$data) {
echo json_encode(['success' => false, 'error' => 'Data tidak valid']);
exit;
}
$keterangan = $conn->real_escape_string($data->keterangan ?? '');
$tingkat = $conn->real_escape_string($data->tingkat ?? 'sedang');
$geometry = $conn->real_escape_string(json_encode($data->geometry));
$sql = "INSERT INTO jalan_rusak (keterangan, tingkat, geometry)
VALUES ('$keterangan', '$tingkat', '$geometry')";
if ($conn->query($sql)) {
echo json_encode(['success' => true, 'id' => $conn->insert_id]);
} else {
echo json_encode(['success' => false, 'error' => $conn->error]);
}
+13
View File
@@ -0,0 +1,13 @@
<?php
include 'db.php';
$data = json_decode(file_get_contents("php://input"), true);
$id = (int)($data['id'] ?? 0);
$panjang = floatval($data['panjang'] ?? 0);
$geometry = $conn->real_escape_string(json_encode($data['geometry']));
if ($conn->query("UPDATE jalan SET panjang='$panjang', geometry='$geometry' WHERE id=$id")) {
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'error' => $conn->error]);
}
+13
View File
@@ -0,0 +1,13 @@
<?php
include 'db.php';
$data = json_decode(file_get_contents("php://input"), true);
$id = (int)($data['id'] ?? 0);
$luas = floatval($data['luas'] ?? 0);
$geometry = $conn->real_escape_string(json_encode($data['geometry']));
if ($conn->query("UPDATE parsil SET luas='$luas', geometry='$geometry' WHERE id=$id")) {
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'error' => $conn->error]);
}
+568
View File
@@ -0,0 +1,568 @@
<!DOCTYPE html>
<html lang="id">
<head>
<base target="_top">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WebGIS Choropleth — Kota Pontianak</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<!-- Google Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=Syne:wght@400;700;800&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0d1117;
--surface: #161b22;
--border: #30363d;
--accent: #58a6ff;
--text: #e6edf3;
--muted: #8b949e;
--c1: #fff7bc;
--c2: #fec44f;
--c3: #fb9a29;
--c4: #ec7014;
--c5: #cc4c02;
--c6: #8c2d04;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Space Mono', monospace;
background: var(--bg);
color: var(--text);
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── HEADER ─────────────────────────────────────── */
header {
display: flex;
align-items: center;
gap: 16px;
padding: 14px 24px;
background: var(--surface);
border-bottom: 1px solid var(--border);
z-index: 1000;
flex-shrink: 0;
}
.logo-dot {
width: 10px; height: 10px;
background: var(--accent);
border-radius: 50%;
box-shadow: 0 0 12px var(--accent);
animation: pulse 2.4s ease-in-out infinite;
}
@keyframes pulse {
0%,100% { opacity:1; transform:scale(1); }
50% { opacity:.5; transform:scale(1.4); }
}
header h1 {
font-family: 'Syne', sans-serif;
font-size: 16px;
font-weight: 800;
letter-spacing: .05em;
color: var(--text);
}
header span.sub {
font-size: 11px;
color: var(--muted);
margin-left: 4px;
font-weight: 400;
}
.mode-btns {
margin-left: auto;
display: flex;
gap: 6px;
}
.mode-btn {
padding: 5px 14px;
border: 1px solid var(--border);
border-radius: 4px;
background: transparent;
color: var(--muted);
font-family: 'Space Mono', monospace;
font-size: 11px;
cursor: pointer;
transition: all .2s;
}
.mode-btn.active,
.mode-btn:hover {
border-color: var(--accent);
color: var(--accent);
background: rgba(88,166,255,.08);
}
/* ── MAIN ────────────────────────────────────────── */
.main {
display: flex;
flex: 1;
overflow: hidden;
}
/* ── SIDEBAR ─────────────────────────────────────── */
.sidebar {
width: 280px;
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow-y: auto;
flex-shrink: 0;
}
.sidebar-section {
padding: 18px 20px;
border-bottom: 1px solid var(--border);
}
.sidebar-section h3 {
font-family: 'Syne', sans-serif;
font-size: 11px;
font-weight: 700;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 12px;
}
/* Legend */
.legend-item {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 7px;
font-size: 11px;
}
.legend-swatch {
width: 24px; height: 14px;
border-radius: 2px;
flex-shrink: 0;
}
.legend-label { color: var(--text); }
.legend-sub { color: var(--muted); font-size: 10px; }
/* Stat cards */
.stat-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.stat-card {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px 12px;
cursor: pointer;
transition: border-color .2s;
}
.stat-card:hover, .stat-card.active {
border-color: var(--accent);
}
.stat-name {
font-size: 10px;
color: var(--muted);
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.stat-value {
font-family: 'Syne', sans-serif;
font-size: 15px;
font-weight: 800;
color: var(--text);
}
.stat-unit { font-size: 10px; color: var(--muted); font-weight: 400; }
/* Detail panel */
#detail-panel {
display: none;
}
#detail-panel.visible {
display: block;
}
.detail-name {
font-family: 'Syne', sans-serif;
font-size: 18px;
font-weight: 800;
color: var(--text);
margin-bottom: 14px;
line-height: 1.2;
}
.detail-row {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 7px 0;
border-bottom: 1px solid var(--border);
font-size: 12px;
}
.detail-row:last-child { border-bottom: none; }
.detail-key { color: var(--muted); }
.detail-val { color: var(--text); font-weight: 700; }
.density-bar {
height: 4px;
background: var(--border);
border-radius: 2px;
margin-top: 10px;
overflow: hidden;
}
.density-fill {
height: 100%;
border-radius: 2px;
background: linear-gradient(90deg, var(--c2), var(--c5));
transition: width .6s ease;
}
#placeholder {
color: var(--muted);
font-size: 12px;
line-height: 1.6;
}
#placeholder em {
display: block;
font-size: 24px;
margin-bottom: 8px;
opacity: .4;
}
/* ── MAP ─────────────────────────────────────────── */
#map {
flex: 1;
background: #0a0f16;
}
/* Override Leaflet UI for dark mode */
.leaflet-control-zoom a {
background: var(--surface) !important;
color: var(--text) !important;
border-color: var(--border) !important;
}
.leaflet-control-zoom a:hover {
background: var(--border) !important;
}
.leaflet-bar { border: 1px solid var(--border) !important; }
/* ── INFO TOOLTIP ────────────────────────────────── */
.info-box {
background: var(--surface) !important;
border: 1px solid var(--border) !important;
border-radius: 6px;
padding: 10px 14px;
font-family: 'Space Mono', monospace;
font-size: 12px;
color: var(--text) !important;
box-shadow: 0 4px 20px rgba(0,0,0,.4) !important;
min-width: 160px;
}
.info-box strong {
display: block;
font-family: 'Syne', sans-serif;
font-size: 14px;
font-weight: 800;
margin-bottom: 6px;
color: var(--accent);
}
.info-box span {
display: block;
color: var(--muted);
font-size: 11px;
}
/* ── TOOLTIP POPUPS ─────────────────────────────── */
.leaflet-popup-content-wrapper {
background: var(--surface) !important;
border: 1px solid var(--border) !important;
border-radius: 8px !important;
box-shadow: 0 8px 32px rgba(0,0,0,.6) !important;
}
.leaflet-popup-tip { background: var(--surface) !important; }
.leaflet-popup-content {
color: var(--text) !important;
font-family: 'Space Mono', monospace !important;
font-size: 12px !important;
margin: 12px 16px !important;
}
/* scrollbar */
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
</style>
</head>
<body>
<header>
<div class="logo-dot"></div>
<div>
<h1>WebGIS Pontianak <span class="sub">// Choropleth</span></h1>
</div>
<div class="mode-btns">
<button class="mode-btn active" onclick="setMode('density',this)">Kepadatan</button>
<button class="mode-btn" onclick="setMode('penduduk',this)">Penduduk</button>
</div>
</header>
<div class="main">
<!-- SIDEBAR -->
<div class="sidebar">
<!-- Legend -->
<div class="sidebar-section" id="legend-wrap">
<h3>Legenda</h3>
<div id="legend-items"></div>
</div>
<!-- Kecamatan Cards -->
<div class="sidebar-section">
<h3>Kecamatan</h3>
<div class="stat-grid" id="stat-grid"></div>
</div>
<!-- Detail -->
<div class="sidebar-section">
<h3>Detail</h3>
<div id="detail-panel">
<div class="detail-name" id="d-name"></div>
<div class="detail-row"><span class="detail-key">Penduduk</span><span class="detail-val" id="d-pop"></span></div>
<div class="detail-row"><span class="detail-key">Luas Wilayah</span><span class="detail-val" id="d-area"></span></div>
<div class="detail-row"><span class="detail-key">Kepadatan</span><span class="detail-val" id="d-den"></span></div>
<div class="density-bar"><div class="density-fill" id="d-bar"></div></div>
</div>
<div id="placeholder">
<em></em>
Klik area di peta untuk melihat data kecamatan secara detail.
</div>
</div>
</div>
<!-- MAP -->
<div id="map"></div>
</div>
<script src="pontianak.js"></script>
<script>
// ── CONFIG ────────────────────────────────────────────────────────────────────
const MODES = {
density: {
key: 'density',
label: 'Kepadatan Penduduk',
unit: 'jiwa/km²',
breaks: [2000, 5000, 7000, 10000, 12000],
colors: ['#fff7bc','#fec44f','#fb9a29','#ec7014','#cc4c02','#8c2d04'],
fmt: v => v.toLocaleString('id-ID') + ' jiwa/km²',
},
penduduk: {
key: 'penduduk',
label: 'Jumlah Penduduk',
unit: 'jiwa',
breaks: [75000, 85000, 95000, 105000, 115000],
colors: ['#c6dbef','#9ecae1','#6baed6','#3182bd','#2171b5','#084594'],
fmt: v => v.toLocaleString('id-ID') + ' jiwa',
}
};
let currentMode = 'density';
let geojsonLayer, lastSelected;
// ── MAP INIT ──────────────────────────────────────────────────────────────────
const map = L.map('map', { zoomControl: true }).setView([-0.02, 109.34], 12);
// Dark OSM tiles
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap contributors © CARTO',
maxZoom: 19
}).addTo(map);
// ── HELPERS ───────────────────────────────────────────────────────────────────
function getColor(value, mode) {
const m = MODES[mode];
for (let i = 0; i < m.breaks.length; i++) {
if (value <= m.breaks[i]) return m.colors[i];
}
return m.colors[m.colors.length - 1];
}
function style(feature) {
const val = feature.properties[MODES[currentMode].key];
return {
fillColor: getColor(val, currentMode),
fillOpacity: 0.72,
color: '#0d1117',
weight: 1.5,
opacity: 1,
};
}
function highlightStyle(feature) {
return {
fillOpacity: 0.9,
color: '#58a6ff',
weight: 2.5,
};
}
// ── SIDEBAR UPDATERS ──────────────────────────────────────────────────────────
function buildLegend() {
const m = MODES[currentMode];
const container = document.getElementById('legend-items');
container.innerHTML = '';
const labels = [
`< ${m.breaks[0].toLocaleString('id-ID')}`,
...m.breaks.slice(0,-1).map((v,i) => `${v.toLocaleString('id-ID')} ${m.breaks[i+1].toLocaleString('id-ID')}`),
`> ${m.breaks[m.breaks.length-1].toLocaleString('id-ID')}`,
];
m.colors.forEach((c,i) => {
container.innerHTML += `
<div class="legend-item">
<div class="legend-swatch" style="background:${c}"></div>
<span class="legend-label">${labels[i]} <span class="legend-sub">${m.unit}</span></span>
</div>`;
});
}
function buildCards() {
const grid = document.getElementById('stat-grid');
grid.innerHTML = '';
const m = MODES[currentMode];
pontianakData.features.forEach(f => {
const p = f.properties;
const val = p[m.key];
grid.innerHTML += `
<div class="stat-card" id="card-${slugify(p.Ket)}" onclick="selectByName('${p.Ket}')">
<div class="stat-name">${p.Ket.replace('Pontianak ','')}</div>
<div class="stat-value">${formatShort(val)} <span class="stat-unit">${m.unit.split('/')[0]}</span></div>
</div>`;
});
}
function slugify(s) { return s.replace(/\s+/g,'_'); }
function formatShort(v) {
if (v >= 1000) return (v/1000).toFixed(1) + 'k';
return v.toLocaleString('id-ID');
}
function showDetail(props) {
document.getElementById('detail-panel').classList.add('visible');
document.getElementById('placeholder').style.display = 'none';
document.getElementById('d-name').textContent = props.Ket;
document.getElementById('d-pop').textContent = props.penduduk.toLocaleString('id-ID') + ' jiwa';
document.getElementById('d-area').textContent = props.luas_km2.toFixed(2) + ' km²';
document.getElementById('d-den').textContent = props.density.toLocaleString('id-ID') + ' jiwa/km²';
// Bar fill based on density max (~12000)
const pct = Math.min(100, (props.density / 13000) * 100);
document.getElementById('d-bar').style.width = pct + '%';
// Highlight card
document.querySelectorAll('.stat-card').forEach(c => c.classList.remove('active'));
const card = document.getElementById('card-' + slugify(props.Ket));
if (card) card.classList.add('active');
}
// ── INTERACTION ───────────────────────────────────────────────────────────────
function onEachFeature(feature, layer) {
layer.on({
mouseover(e) {
const l = e.target;
l.setStyle(highlightStyle(feature));
l.bringToFront();
infoBox.update(feature.properties);
},
mouseout(e) {
if (lastSelected !== e.target) {
geojsonLayer.resetStyle(e.target);
}
infoBox.update(null);
},
click(e) {
map.fitBounds(e.target.getBounds(), { padding: [40, 40] });
if (lastSelected && lastSelected !== e.target) {
geojsonLayer.resetStyle(lastSelected);
}
e.target.setStyle(highlightStyle(feature));
e.target.bringToFront();
lastSelected = e.target;
showDetail(feature.properties);
}
});
}
function selectByName(name) {
geojsonLayer.eachLayer(l => {
if (l.feature.properties.Ket === name) {
map.fitBounds(l.getBounds(), { padding: [40, 40] });
if (lastSelected && lastSelected !== l) geojsonLayer.resetStyle(lastSelected);
l.setStyle(highlightStyle(l.feature));
l.bringToFront();
lastSelected = l;
showDetail(l.feature.properties);
}
});
}
// ── INFO BOX ──────────────────────────────────────────────────────────────────
const infoBox = L.control({ position: 'topright' });
infoBox.onAdd = function() {
this._div = L.DomUtil.create('div', 'info-box');
this.update(null);
return this._div;
};
infoBox.update = function(props) {
if (!props) {
this._div.innerHTML = '<strong>Peta Pontianak</strong><span>Arahkan kursor ke area</span>';
return;
}
const m = MODES[currentMode];
const val = props[m.key];
this._div.innerHTML = `
<strong>${props.Ket}</strong>
<span>${m.label}</span>
<span style="color:#e6edf3;font-size:14px;font-weight:700;margin-top:2px;display:block">
${val.toLocaleString('id-ID')} <small style="color:#8b949e;font-size:10px">${m.unit}</small>
</span>`;
};
infoBox.addTo(map);
// ── RENDER ─────────────────────────────────────────────────────────────────────
function renderMap() {
if (geojsonLayer) map.removeLayer(geojsonLayer);
lastSelected = null;
geojsonLayer = L.geoJson(pontianakData, {
style,
onEachFeature,
}).addTo(map);
buildLegend();
buildCards();
// Reset detail
document.getElementById('detail-panel').classList.remove('visible');
document.getElementById('placeholder').style.display = '';
document.querySelectorAll('.stat-card').forEach(c => c.classList.remove('active'));
}
function setMode(mode, btn) {
currentMode = mode;
document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderMap();
if (lastSelected) infoBox.update(null);
}
// Init
renderMap();
</script>
</body>
</html>
+54
View File
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
FROM php:8.2-apache
RUN docker-php-ext-install mysqli pdo pdo_mysql \
&& a2enmod rewrite
COPY . /var/www/html/
RUN chown -R www-data:www-data /var/www/html
+1
View File
File diff suppressed because one or more lines are too long
+8
View File
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
FROM php:8.2-apache
RUN docker-php-ext-install mysqli pdo pdo_mysql \
&& a2enmod rewrite
COPY . /var/www/html/
RUN chown -R www-data:www-data /var/www/html
+13
View File
@@ -0,0 +1,13 @@
<?php
include 'koneksi.php';
$result = $conn->query("SELECT * FROM spbu");
$data = [];
while ($row = $result->fetch_assoc()) {
$data[] = $row;
}
echo json_encode($data);
?>
+14
View File
@@ -0,0 +1,14 @@
<?php
include 'koneksi.php';
$id = $_POST['id'];
$nama = $_POST['nama'];
$wa = $_POST['wa'];
$status = $_POST['status'];
$conn->query("UPDATE spbu SET
nama_spbu='$nama',
no_wa='$wa',
status='$status'
WHERE id=$id");
?>
+7
View File
@@ -0,0 +1,7 @@
<?php
include 'koneksi.php';
$id = $_GET['id'];
$conn->query("DELETE FROM spbu WHERE id=$id");
?>
+344
View File
@@ -0,0 +1,344 @@
<!DOCTYPE html>
<html>
<head>
<title>GIS SPBU PRO FINAL</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css"/>
<script src="https://unpkg.com/leaflet/dist/leaflet.js"></script>
<style>
#map{
height:100vh;
}
body{
margin:0;
font-family:'Segoe UI',sans-serif;
}
/* tombol popup marker */
.btn{
padding:8px 14px;
border:none;
border-radius:10px;
cursor:pointer;
font-size:14px;
font-weight:700;
margin-top:8px;
margin-right:6px;
transition:.2s;
}
.btn:hover{
transform:translateY(-1px);
}
.edit{
background:#f59e0b;
color:white;
}
.delete{
background:#ef4444;
color:white;
}
/* popup utama */
.leaflet-popup-content-wrapper{
border-radius:24px;
box-shadow:0 14px 35px rgba(0,0,0,.18);
padding:0;
overflow:hidden;
}
/* ukuran NORMAL semua popup */
.leaflet-popup-content{
margin:0 !important;
min-width:300px;
max-width:360px;
padding:22px;
box-sizing:border-box;
}
.leaflet-popup-tip{
width:18px;
height:18px;
box-shadow:none;
}
/* close */
.leaflet-popup-close-button{
top:12px !important;
right:14px !important;
font-size:28px !important;
color:#888 !important;
}
/* form popup */
.leaflet-popup-content form{
display:flex;
flex-direction:column;
gap:12px;
}
.leaflet-popup-content label{
font-size:15px;
font-weight:700;
color:#333;
margin-bottom:-4px;
}
.leaflet-popup-content input,
.leaflet-popup-content select{
width:100%;
box-sizing:border-box;
padding:12px 14px;
border:1px solid #ddd;
border-radius:14px;
font-size:15px;
outline:none;
background:#fff;
}
.leaflet-popup-content input:focus,
.leaflet-popup-content select:focus{
border-color:#ff8a00;
box-shadow:0 0 0 4px rgba(255,138,0,.12);
}
.leaflet-popup-content button[type="submit"]{
margin-top:8px;
padding:13px;
border:none;
border-radius:16px;
background:linear-gradient(90deg,#ff9800,#ff6d00);
color:white;
font-size:17px;
font-weight:800;
cursor:pointer;
transition:.2s;
}
.leaflet-popup-content button[type="submit"]:hover{
transform:translateY(-1px);
}
/* popup marker info biar rapi */
.leaflet-popup-content b{
font-size:18px;
color:#111;
}
.leaflet-popup-content span{
font-weight:700;
}
/* layer control checkbox TIDAK DIUBAH */
.leaflet-control-layers{
border-radius:18px !important;
box-shadow:0 8px 22px rgba(0,0,0,.15);
overflow:hidden;
}
.leaflet-control-layers-expanded{
padding:12px;
font-size:15px;
}
</style>
</head>
<body>
<div id="map"></div>
<script>
const map = L.map('map').setView([-0.02, 109.34], 13);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19
}).addTo(map);
// ================= LAYER GROUPS =================
const layer24Jam = L.layerGroup().addTo(map);
const layerNon24Jam = L.layerGroup().addTo(map);
// Overlay layers untuk Layers Control
const overlayLayers = {
"🟢 SPBU Buka 24 Jam" : layer24Jam,
"🔴 SPBU Tidak Buka 24 Jam" : layerNon24Jam
};
// Tambahkan Layers Control ke peta
L.control.layers(null, overlayLayers, { collapsed: false }).addTo(map);
// ================= ICON =================
const greenIcon = new L.Icon({
iconUrl: 'https://maps.google.com/mapfiles/ms/icons/green-dot.png',
iconSize: [32, 32]
});
const redIcon = new L.Icon({
iconUrl: 'https://maps.google.com/mapfiles/ms/icons/red-dot.png',
iconSize: [32, 32]
});
let markerList = [];
// ================= LOAD DATA =================
function loadData() {
layer24Jam.clearLayers();
layerNon24Jam.clearLayers();
markerList = [];
fetch('ambil.php')
.then(res => res.json())
.then(data => {
data.forEach(d => {
const is24Jam = d.status === 'yes';
const icon = is24Jam ? greenIcon : redIcon;
const marker = L.marker([d.latitude, d.longitude], {
icon: icon,
draggable: true
});
// Tambahkan ke layer group yang sesuai
if (is24Jam) {
layer24Jam.addLayer(marker);
} else {
layerNon24Jam.addLayer(marker);
}
// DRAG SIMPAN LOKASI
marker.on('dragend', function(e){
const pos = e.target.getLatLng();
const dataSend = new URLSearchParams();
dataSend.append("id", d.id);
dataSend.append("lat", pos.lat);
dataSend.append("lng", pos.lng);
fetch('update_lokasi.php', {
method: 'POST',
body: dataSend
})
.then(res => res.text())
.then(res => {
console.log(res);
alert("Lokasi berhasil diupdate!");
});
});
marker.bindPopup(`
<b>${d.nama_spbu}</b><br>
WA: ${d.no_wa}<br>
24 Jam: <span style="color:${is24Jam ? 'green' : 'red'}">${is24Jam ? 'Ya' : 'Tidak'}</span><br>
<button class="btn edit" onclick="editData(${d.id}, '${d.nama_spbu}', '${d.no_wa}', '${d.status}')">Edit</button>
<button class="btn delete" onclick="hapusData(${d.id})">Hapus</button>
`);
markerList.push(marker);
});
});
}
// ================= EDIT =================
function editData(id, nama, wa, status) {
const form = `
<form id="editForm">
Nama:<br>
<input type="text" name="nama" value="${nama}"><br>
WA:<br>
<input type="text" name="wa" value="${wa}"><br>
Status:<br>
<select name="status">
<option ${status=='yes'?'selected':''} value="yes">Yes (Buka 24 Jam)</option>
<option ${status=='no'?'selected':''} value="no">No (Tidak 24 Jam)</option>
</select><br><br>
<button type="submit">Update</button>
</form>
`;
L.popup().setLatLng(map.getCenter()).setContent(form).openOn(map);
setTimeout(()=>{
document.getElementById('editForm').addEventListener('submit', function(e){
e.preventDefault();
const data = new FormData(this);
data.append('id', id);
fetch('edit.php', {
method:'POST',
body:data
}).then(()=>{
alert("Data diupdate!");
loadData();
});
});
},300);
}
// ================= HAPUS =================
function hapusData(id) {
if(confirm("Yakin hapus?")) {
fetch('hapus.php?id=' + id)
.then(()=>{
alert("Data dihapus!");
loadData();
});
}
}
// ================= TAMBAH =================
map.on('click', function(e) {
const lat = e.latlng.lat;
const lng = e.latlng.lng;
const form = `
<form id="formInput">
Nama SPBU:<br>
<input type="text" name="nama_spbu" required><br>
No WA:<br>
<input type="text" name="no_wa" required><br>
Status:<br>
<select name="status">
<option value="yes">Yes (Buka 24 Jam)</option>
<option value="no">No (Tidak 24 Jam)</option>
</select><br><br>
<input type="hidden" name="latitude" value="${lat}">
<input type="hidden" name="longitude" value="${lng}">
<button type="submit">Simpan</button>
</form>
`;
L.popup().setLatLng(e.latlng).setContent(form).openOn(map);
setTimeout(()=>{
document.getElementById('formInput').addEventListener('submit', function(ev){
ev.preventDefault();
const data = new FormData(this);
fetch('simpan.php',{
method:'POST',
body:data
}).then(()=>{
alert("Tersimpan!");
loadData();
});
});
},300);
});
loadData();
</script>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
<?php
$host = getenv('DB_HOST');
$user = getenv('DB_USER');
$pass = getenv('DB_PASSWORD');
$db = getenv('DB_NAME');
$conn = mysqli_connect($host, $user, $pass, $db);
if (!$conn) {
die("Koneksi database gagal: " . mysqli_connect_error());
}
?>
+18
View File
@@ -0,0 +1,18 @@
<?php
include 'koneksi.php';
$nama = $_POST['nama_spbu'];
$wa = $_POST['no_wa'];
$status = $_POST['status'];
$lat = $_POST['latitude'];
$lng = $_POST['longitude'];
$sql = "INSERT INTO spbu (nama_spbu, no_wa, status, latitude, longitude)
VALUES ('$nama', '$wa', '$status', '$lat', '$lng')";
if ($conn->query($sql)) {
echo "success";
} else {
echo "error";
}
?>
+15
View File
@@ -0,0 +1,15 @@
<?php
include 'koneksi.php';
$id = $_POST['id'];
$lat = $_POST['lat'];
$lng = $_POST['lng'];
$sql = "UPDATE spbu SET latitude='$lat', longitude='$lng' WHERE id='$id'";
if ($conn->query($sql)) {
echo "success";
} else {
echo "error: " . $conn->error;
}
?>
+9
View File
@@ -0,0 +1,9 @@
FROM php:8.2-apache
RUN docker-php-ext-install mysqli pdo pdo_mysql \
&& a2enmod rewrite
COPY webgis_uas_FIXED_v2/webgis_uas_FIXED/ /var/www/html/
RUN mkdir -p /var/www/html/uploads/bukti \
&& chown -R www-data:www-data /var/www/html
@@ -0,0 +1,77 @@
# WebGIS Pengentasan Kemiskinan v2.0
**Berbasis Masyarakat & Rumah Ibadah — Kota Pontianak**
## Teknologi
- PHP 7.4+ (Backend)
- MySQL 5.7+ / MariaDB (Database)
- Leaflet.js 1.9 + MarkerCluster (Peta)
- Chart.js 4.4 (Dashboard)
## Instalasi
1. **Buat database** dan import `database.sql`:
```sql
mysql -u root -p < database.sql
```
2. **Edit konfigurasi** di `koneksi.php`:
```php
define('DB_HOST', 'localhost');
define('DB_USER', 'root');
define('DB_PASS', 'password_anda');
define('DB_NAME', 'webgis_kemiskinan2');
```
3. **Letakkan folder** di `htdocs/` (XAMPP) atau `www/` (Laragon)
4. **Pastikan folder `uploads/`** bisa ditulis:
```bash
chmod 755 uploads/
```
5. **Akses** di browser: `http://localhost/webgis_v2/`
## Akun Demo (Password semua: `password123`)
| Username | Role | Akses |
|------------|-------------------|-------|
| admin | Administrator | Full |
| pengurus1 | Pengurus Masjid | CRUD + Verifikasi |
| pimpinan | Pimpinan Daerah | Read-only + Dashboard |
| warga | Masyarakat | Lihat + Laporan |
## Struktur File
```
webgis_v2/
├── index.php ← Halaman utama (peta WebGIS)
├── login.php ← Login multi-role
├── logout.php
├── dashboard.php ← Dashboard analitik + grafik
├── keluarga.php ← Manajemen KK
├── riwayat_pendidikan.php ← Riwayat pendidikan & pelatihan
├── koneksi.php ← Config + helper functions
├── database.sql ← Schema + data dummy
├── api/
│ ├── penduduk.php ← API CRUD penduduk
│ ├── keluarga.php ← API CRUD keluarga
│ ├── laporan.php ← API laporan masyarakat
│ ├── bantuan.php ← API histori bantuan
│ ├── pesan.php ← API chat pengurus
│ ├── rumah_ibadah.php ← API rumah ibadah
│ ├── riwayat_pendidikan.php
│ ├── notifikasi.php
│ └── log.php
└── uploads/ ← Foto penduduk, laporan, bukti bantuan
```
## Fitur Utama
- 🗺️ **WebGIS Leaflet** — marker warna merah/kuning/hijau, cluster, radius rumah ibadah
- 👥 **CRUD Penduduk** — NIK, umur otomatis, validasi pendidikan vs usia
- 🏠 **Manajemen KK** — relasi keluarga, auto-detect rumah ibadah terdekat
- 📢 **Laporan Masyarakat** — publik, anonim, tracking status, urgensi darurat
- 🎁 **Tracking Bantuan** — histori lengkap, cek sudah/belum dibantu
- 🔥 **Prioritas Otomatis** — deteksi miskin + sakit = DARURAT
- 📊 **Dashboard Analitik** — grafik Chart.js, sebaran per kecamatan
- 💬 **Chat Pengurus** — komunikasi antar rumah ibadah
- 🔐 **Multi-Role** — Admin / Pengurus / Pimpinan / Masyarakat
- 📝 **Log Aktivitas** — semua perubahan data tercatat
@@ -0,0 +1,133 @@
<?php
// api/bantuan.php — Histori Bantuan & Jenis Bantuan
require_once '../koneksi.php';
requireLogin();
header('Content-Type: application/json');
$action = $_GET['action'] ?? 'list';
// ============================================================
// LIST histori bantuan
// ============================================================
if ($action === 'list') {
$penduduk_id = (int)($_GET['penduduk_id'] ?? 0);
$where = $penduduk_id ? "WHERE hb.penduduk_id=$penduduk_id" : '';
$sql = "SELECT hb.*,
p.nama AS nama_penerima, p.nik,
jb.nama AS nama_bantuan, jb.kategori,
pb.nama_program,
u.nama AS nama_penyalur,
ri.nama AS nama_ibadah
FROM histori_bantuan hb
JOIN penduduk p ON hb.penduduk_id = p.id
JOIN jenis_bantuan jb ON hb.jenis_bantuan_id = jb.id
LEFT JOIN program_bantuan pb ON hb.program_bantuan_id = pb.id
LEFT JOIN users u ON hb.disalurkan_oleh = u.id
LEFT JOIN rumah_ibadah ri ON hb.rumah_ibadah_id = ri.id
$where
ORDER BY hb.tanggal_penyaluran DESC
LIMIT 200";
$rows = $conn->query($sql);
jsonSuccess($rows ? $rows->fetch_all(MYSQLI_ASSOC) : []);
}
// ============================================================
// JENIS BANTUAN
// ============================================================
if ($action === 'jenis') {
$rows = $conn->query("SELECT * FROM jenis_bantuan WHERE aktif=1 ORDER BY kategori, nama");
jsonSuccess($rows ? $rows->fetch_all(MYSQLI_ASSOC) : []);
}
// ============================================================
// PROGRAM BANTUAN
// ============================================================
if ($action === 'program') {
$rows = $conn->query("SELECT pb.*, jb.nama AS nama_jenis, u.nama AS nama_pj
FROM program_bantuan pb
JOIN jenis_bantuan jb ON pb.jenis_bantuan_id = jb.id
LEFT JOIN users u ON pb.penanggungjawab = u.id
ORDER BY pb.created_at DESC");
jsonSuccess($rows ? $rows->fetch_all(MYSQLI_ASSOC) : []);
}
// ============================================================
// CEK RIWAYAT — apakah sudah pernah dibantu jenis tertentu
// ============================================================
if ($action === 'cek') {
$penduduk_id = (int)($_GET['penduduk_id'] ?? 0);
$jenis_id = (int)($_GET['jenis_bantuan_id'] ?? 0);
if (!$penduduk_id) jsonError('penduduk_id wajib');
$where = "WHERE hb.penduduk_id=$penduduk_id AND hb.status_penyaluran='disalurkan'";
if ($jenis_id) $where .= " AND hb.jenis_bantuan_id=$jenis_id";
$rows = $conn->query("SELECT hb.*, jb.nama AS nama_bantuan FROM histori_bantuan hb JOIN jenis_bantuan jb ON hb.jenis_bantuan_id=jb.id $where ORDER BY hb.tanggal_penyaluran DESC");
$data = $rows ? $rows->fetch_all(MYSQLI_ASSOC) : [];
jsonSuccess(['pernah_dibantu' => count($data) > 0, 'riwayat' => $data, 'total' => count($data)]);
}
// ============================================================
// SAVE — catat penyaluran bantuan
// ============================================================
if ($action === 'save' && $_SERVER['REQUEST_METHOD'] === 'POST') {
requireRole('admin', 'pengurus');
$penduduk_id = (int)($_POST['penduduk_id'] ?? 0);
$keluarga_id = (int)($_POST['keluarga_id'] ?? 0) ?: 'NULL';
$program_id = (int)($_POST['program_bantuan_id'] ?? 0) ?: 'NULL';
$jenis_id = (int)($_POST['jenis_bantuan_id'] ?? 0);
$jumlah = (float)($_POST['jumlah'] ?? 1);
$nilai = (float)($_POST['nilai_rupiah'] ?? 0);
$tgl = sanitize($conn, $_POST['tanggal_penyaluran'] ?? date('Y-m-d'));
$status = sanitize($conn, $_POST['status_penyaluran'] ?? 'disalurkan');
$catatan = sanitize($conn, $_POST['catatan'] ?? '');
if (!$penduduk_id) jsonError('penduduk_id wajib');
if (!$jenis_id) jsonError('jenis_bantuan_id wajib');
// Ambil satuan dari jenis bantuan
$jb = dbFirst($conn, "SELECT satuan, nama FROM jenis_bantuan WHERE id=?", [$jenis_id], 'i');
$satuan = $jb ? sanitize($conn, $jb['satuan']) : 'paket';
// Auto set rumah ibadah dari keluarga
$ibadah_id = 'NULL';
$uid = currentUserId();
$userIbadah = dbFirst($conn, "SELECT rumah_ibadah_id FROM users WHERE id=?", [$uid], 'i');
if ($userIbadah && $userIbadah['rumah_ibadah_id']) {
$ibadah_id = (int)$userIbadah['rumah_ibadah_id'];
}
// Upload bukti
$bukti = 'NULL';
if (!empty($_FILES['bukti_foto']['name'])) {
$f = uploadFile($_FILES['bukti_foto'], 'bantuan', ['jpg','jpeg','png']);
if ($f) $bukti = "'$f'";
}
$sql = "INSERT INTO histori_bantuan (penduduk_id, keluarga_id, program_bantuan_id, jenis_bantuan_id, jumlah, satuan, nilai_rupiah, tanggal_penyaluran, status_penyaluran, disalurkan_oleh, rumah_ibadah_id, bukti_foto, catatan)
VALUES ($penduduk_id, $keluarga_id, $program_id, $jenis_id, $jumlah, '$satuan', $nilai, '$tgl', '$status', $uid, $ibadah_id, $bukti, '$catatan')";
if (!$conn->query($sql)) jsonError('Gagal simpan: ' . $conn->error);
$newId = $conn->insert_id;
logAktivitas($conn, 'SALURKAN_BANTUAN', 'histori_bantuan', $newId);
jsonSuccess(['id' => $newId], 'Bantuan berhasil dicatat');
}
// ============================================================
// STATISTIK BANTUAN
// ============================================================
if ($action === 'statistik') {
$stat = [];
$stat['total_disalurkan'] = $conn->query("SELECT COUNT(*) c FROM histori_bantuan WHERE status_penyaluran='disalurkan'")->fetch_assoc()['c'];
$stat['total_penerima'] = $conn->query("SELECT COUNT(DISTINCT penduduk_id) c FROM histori_bantuan WHERE status_penyaluran='disalurkan'")->fetch_assoc()['c'];
$stat['total_nilai'] = $conn->query("SELECT SUM(nilai_rupiah) c FROM histori_bantuan WHERE status_penyaluran='disalurkan'")->fetch_assoc()['c'];
$stat['per_kategori'] = $conn->query("SELECT jb.kategori, COUNT(*) total, SUM(hb.nilai_rupiah) nilai FROM histori_bantuan hb JOIN jenis_bantuan jb ON hb.jenis_bantuan_id=jb.id WHERE hb.status_penyaluran='disalurkan' GROUP BY jb.kategori")->fetch_all(MYSQLI_ASSOC);
$stat['per_ibadah'] = $conn->query("SELECT ri.nama, ri.jenis, COUNT(hb.id) total FROM histori_bantuan hb JOIN rumah_ibadah ri ON hb.rumah_ibadah_id=ri.id WHERE hb.status_penyaluran='disalurkan' GROUP BY ri.id ORDER BY total DESC")->fetch_all(MYSQLI_ASSOC);
jsonSuccess($stat);
}
jsonError('Action tidak dikenal');
@@ -0,0 +1,99 @@
<?php
require_once '../koneksi.php';
requireLogin();
$action = $_GET['action'] ?? 'list';
if ($action === 'list') {
requireRole('admin','pengurus','pimpinan');
$role = getRole();
$riId = $_SESSION['rumah_ibadah_id'] ?? null;
$filterRumah = ($role==='pengurus' && $riId) ? "AND jp.rumah_ibadah_id=$riId" : '';
$tgl = sanitize($conn, $_GET['tanggal'] ?? date('Y-m-d'));
$week = (int)($_GET['week'] ?? 0);
$senin = new DateTime('monday this week');
$senin->modify("$week weeks");
$minggu = clone $senin; $minggu->modify('+6 days');
$dari = $senin->format('Y-m-d'); $sampai = $minggu->format('Y-m-d');
$rows = $conn->query("
SELECT jp.*, u.nama AS nama_pengurus, ri.nama AS nama_ibadah,
ug.nama AS nama_pengganti
FROM jadwal_piket jp
JOIN users u ON jp.user_id=u.id
JOIN rumah_ibadah ri ON jp.rumah_ibadah_id=ri.id
LEFT JOIN users ug ON jp.digantikan_oleh=ug.id
WHERE jp.tanggal BETWEEN '$dari' AND '$sampai' $filterRumah
ORDER BY jp.tanggal, FIELD(jp.shift,'pagi','siang','sore','malam','full_day')
")->fetch_all(MYSQLI_ASSOC);
jsonSuccess(['data'=>$rows,'minggu_dari'=>$dari,'minggu_sampai'=>$sampai]);
}
if ($action==='hari_ini') {
$role = getRole(); $riId = $_SESSION['rumah_ibadah_id'] ?? null;
$filterRumah = ($role==='pengurus' && $riId) ? "AND jp.rumah_ibadah_id=$riId" : '';
$rows = $conn->query("
SELECT jp.*, u.nama AS nama_pengurus, ri.nama AS nama_ibadah
FROM jadwal_piket jp JOIN users u ON jp.user_id=u.id
JOIN rumah_ibadah ri ON jp.rumah_ibadah_id=ri.id
WHERE jp.tanggal=CURDATE() AND jp.status IN ('terjadwal','hadir') $filterRumah
")->fetch_all(MYSQLI_ASSOC);
jsonSuccess($rows);
}
if ($action==='save' && $_SERVER['REQUEST_METHOD']==='POST') {
requireRole('admin','pengurus');
$uid = (int)($_POST['user_id'] ?? 0);
$riId = (int)($_POST['rumah_ibadah_id'] ?? 0);
$tgl = sanitize($conn, $_POST['tanggal'] ?? '');
$shift = sanitize($conn, $_POST['shift'] ?? 'full_day');
$jMul = sanitize($conn, $_POST['jam_mulai'] ?? '08:00');
$jSls = sanitize($conn, $_POST['jam_selesai'] ?? '17:00');
$ket = sanitize($conn, $_POST['keterangan'] ?? '');
if (!$uid || !$riId || !$tgl) jsonError('Data tidak lengkap');
$cek = $conn->query("SELECT id FROM jadwal_piket WHERE user_id=$uid AND tanggal='$tgl' AND shift='$shift'")->fetch_assoc();
if ($cek) jsonError('Jadwal sudah ada untuk pengurus ini di shift & tanggal yang sama');
$conn->query("INSERT INTO jadwal_piket (user_id,rumah_ibadah_id,tanggal,shift,jam_mulai,jam_selesai,keterangan) VALUES ($uid,$riId,'$tgl','$shift','$jMul','$jSls','$ket')");
logAktivitas($conn,'CREATE_JADWAL','jadwal_piket',$conn->insert_id);
jsonSuccess(['id'=>$conn->insert_id],'Jadwal berhasil ditambahkan');
}
if ($action==='bulk' && $_SERVER['REQUEST_METHOD']==='POST') {
requireRole('admin','pengurus');
$uid = (int)($_POST['user_id'] ?? 0);
$riId = (int)($_POST['rumah_ibadah_id'] ?? 0);
$dari = sanitize($conn, $_POST['tanggal_mulai'] ?? '');
$sampai = sanitize($conn, $_POST['tanggal_selesai'] ?? '');
$shift = sanitize($conn, $_POST['shift'] ?? 'full_day');
$jMul = sanitize($conn, $_POST['jam_mulai'] ?? '08:00');
$jSls = sanitize($conn, $_POST['jam_selesai'] ?? '17:00');
$hari = $_POST['hari_aktif'] ?? [];
$cur = new DateTime($dari); $end = new DateTime($sampai); $added = 0;
while ($cur <= $end) {
if (in_array($cur->format('w'), $hari)) {
$t = $cur->format('Y-m-d');
$cek = $conn->query("SELECT id FROM jadwal_piket WHERE user_id=$uid AND tanggal='$t' AND shift='$shift'")->fetch_assoc();
if (!$cek) { $conn->query("INSERT INTO jadwal_piket (user_id,rumah_ibadah_id,tanggal,shift,jam_mulai,jam_selesai) VALUES ($uid,$riId,'$t','$shift','$jMul','$jSls')"); $added++; }
}
$cur->modify('+1 day');
}
jsonSuccess(['added'=>$added],"$added jadwal berhasil dibuat");
}
if ($action==='update_status' && $_SERVER['REQUEST_METHOD']==='POST') {
requireRole('admin','pengurus');
$id = (int)($_POST['id'] ?? 0);
$status = sanitize($conn, $_POST['status'] ?? '');
$gantiId= (int)($_POST['digantikan_oleh'] ?? 0) ?: 'NULL';
$conn->query("UPDATE jadwal_piket SET status='$status',digantikan_oleh=$gantiId WHERE id=$id");
logAktivitas($conn,'UPDATE_JADWAL','jadwal_piket',$id);
jsonSuccess(null,'Status diperbarui');
}
if ($action==='delete' && $_SERVER['REQUEST_METHOD']==='POST') {
requireRole('admin','pengurus');
$id = (int)($_POST['id'] ?? 0);
$conn->query("DELETE FROM jadwal_piket WHERE id=$id");
logAktivitas($conn,'DELETE_JADWAL','jadwal_piket',$id);
jsonSuccess(null,'Jadwal dihapus');
}
jsonError('Action tidak dikenal');
@@ -0,0 +1,92 @@
<?php
// api/keluarga.php — CRUD Kartu Keluarga
require_once '../koneksi.php';
requireLogin();
header('Content-Type: application/json');
$action = $_GET['action'] ?? 'list';
if ($action === 'list') {
$search = sanitize($conn, $_GET['search'] ?? '');
$where = $search ? "WHERE k.no_kk LIKE '%$search%' OR k.alamat LIKE '%$search%'" : '';
$sql = "SELECT k.*,
ri.nama AS nama_ibadah,
(SELECT COUNT(*) FROM penduduk p WHERE p.keluarga_id = k.id) AS jumlah_anggota,
(SELECT COUNT(*) FROM penduduk p2 WHERE p2.keluarga_id = k.id AND p2.status_ekonomi = 'miskin') AS jumlah_miskin,
u.nama AS nama_verifikator
FROM keluarga k
LEFT JOIN rumah_ibadah ri ON k.rumah_ibadah_id = ri.id
LEFT JOIN users u ON k.verified_by = u.id
$where
ORDER BY FIELD(k.status_ekonomi,'miskin','rentan','mampu'), k.no_kk
LIMIT 200";
$rows = $conn->query($sql);
jsonSuccess($rows ? $rows->fetch_all(MYSQLI_ASSOC) : []);
}
if ($action === 'get') {
$id = (int)($_GET['id'] ?? 0);
$kk = dbFirst($conn, "SELECT k.*, ri.nama AS nama_ibadah FROM keluarga k LEFT JOIN rumah_ibadah ri ON k.rumah_ibadah_id=ri.id WHERE k.id=?", [$id], 'i');
if (!$kk) jsonError('Tidak ditemukan', 404);
$kk['anggota'] = dbQuery($conn, "SELECT id, nik, nama, jenis_kelamin, TIMESTAMPDIFF(YEAR,tanggal_lahir,CURDATE()) AS umur, status_keluarga, pekerjaan, status_ekonomi, kondisi_kesehatan FROM penduduk WHERE keluarga_id=? ORDER BY FIELD(status_keluarga,'kepala_keluarga','istri','anak','orang_tua','famili_lain','lainnya')", [$id], 'i');
jsonSuccess($kk);
}
if ($action === 'save' && $_SERVER['REQUEST_METHOD'] === 'POST') {
requireRole('admin', 'pengurus');
$id = (int)($_POST['id'] ?? 0);
$no_kk = sanitize($conn, $_POST['no_kk'] ?? '');
$alamat = sanitize($conn, $_POST['alamat'] ?? '');
$rt = sanitize($conn, $_POST['rt'] ?? '');
$rw = sanitize($conn, $_POST['rw'] ?? '');
$kel = sanitize($conn, $_POST['kelurahan'] ?? '');
$kec = sanitize($conn, $_POST['kecamatan'] ?? '');
$kota = sanitize($conn, $_POST['kota'] ?? 'Pontianak');
$lat = !empty($_POST['lat']) ? (float)$_POST['lat'] : 'NULL';
$lng = !empty($_POST['lng']) ? (float)$_POST['lng'] : 'NULL';
$status_ek = sanitize($conn, $_POST['status_ekonomi'] ?? 'rentan');
$ibadah_id = (int)($_POST['rumah_ibadah_id'] ?? 0) ?: 'NULL';
// Auto-detect rumah ibadah terdekat jika lat/lng ada dan ibadah belum dipilih
if ($lat !== 'NULL' && $lng !== 'NULL' && $ibadah_id === 'NULL') {
$terdekat = cariRumahIbadahTerdekat($conn, (float)$lat, (float)$lng);
if ($terdekat) $ibadah_id = $terdekat['id'];
}
if (strlen($no_kk) !== 16) jsonError('No. KK harus 16 digit');
if (!$alamat) jsonError('Alamat wajib diisi');
if ($id > 0) {
$conn->query("UPDATE keluarga SET no_kk='$no_kk',alamat='$alamat',rt='$rt',rw='$rw',kelurahan='$kel',kecamatan='$kec',kota='$kota',lat=$lat,lng=$lng,status_ekonomi='$status_ek',rumah_ibadah_id=$ibadah_id WHERE id=$id");
logAktivitas($conn, 'UPDATE_KELUARGA', 'keluarga', $id);
jsonSuccess(['id'=>$id], 'Data keluarga diperbarui');
} else {
$dup = $conn->query("SELECT id FROM keluarga WHERE no_kk='$no_kk'")->fetch_assoc();
if ($dup) jsonError('No. KK sudah terdaftar');
$conn->query("INSERT INTO keluarga (no_kk,alamat,rt,rw,kelurahan,kecamatan,kota,lat,lng,status_ekonomi,rumah_ibadah_id) VALUES ('$no_kk','$alamat','$rt','$rw','$kel','$kec','$kota',$lat,$lng,'$status_ek',$ibadah_id)");
$newId = $conn->insert_id;
logAktivitas($conn, 'CREATE_KELUARGA', 'keluarga', $newId);
jsonSuccess(['id'=>$newId], 'Keluarga berhasil ditambahkan');
}
}
if ($action === 'verifikasi' && $_SERVER['REQUEST_METHOD'] === 'POST') {
requireRole('admin', 'pengurus');
$id = (int)($_POST['id'] ?? 0);
$uid = currentUserId();
$conn->query("UPDATE keluarga SET status_verifikasi='terverifikasi', verified_by=$uid, verified_at=NOW() WHERE id=$id");
logAktivitas($conn, 'VERIFIKASI_KELUARGA', 'keluarga', $id);
jsonSuccess(null, 'Keluarga terverifikasi');
}
if ($action === 'delete' && $_SERVER['REQUEST_METHOD'] === 'POST') {
requireRole('admin');
$id = (int)($_POST['id'] ?? 0);
// Cek masih ada anggota
$anggota = $conn->query("SELECT COUNT(*) c FROM penduduk WHERE keluarga_id=$id")->fetch_assoc()['c'];
if ($anggota > 0) jsonError("Masih ada $anggota anggota. Pindahkan anggota terlebih dahulu.");
$conn->query("DELETE FROM keluarga WHERE id=$id");
jsonSuccess(null, 'Keluarga dihapus');
}
jsonError('Action tidak dikenal');
@@ -0,0 +1,115 @@
<?php
// api/laporan.php — CRUD Laporan Masyarakat
require_once '../koneksi.php';
header('Content-Type: application/json');
$action = $_GET['action'] ?? 'list';
if ($action === 'list') {
requireLogin();
$status = sanitize($conn, $_GET['status'] ?? '');
$where = $status ? "WHERE l.status='$status'" : '';
$sql = "SELECT l.*, p.nama AS nama_penduduk, u1.nama AS nama_verifikator, u2.nama AS nama_prosesor
FROM laporan l
LEFT JOIN penduduk p ON l.penduduk_id = p.id
LEFT JOIN users u1 ON l.diverifikasi_oleh = u1.id
LEFT JOIN users u2 ON l.diproses_oleh = u2.id
$where ORDER BY FIELD(l.urgensi,'darurat','tinggi','sedang','rendah'), l.created_at DESC
LIMIT 100";
$rows = $conn->query($sql);
jsonSuccess($rows ? $rows->fetch_all(MYSQLI_ASSOC) : []);
}
if ($action === 'map') {
$sql = "SELECT id, kode_laporan, nama_pelapor, deskripsi, kategori, urgensi, status, lat, lng
FROM laporan WHERE lat IS NOT NULL AND lng IS NOT NULL AND status != 'selesai'";
$rows = $conn->query($sql);
jsonSuccess($rows ? $rows->fetch_all(MYSQLI_ASSOC) : []);
}
if ($action === 'save' && $_SERVER['REQUEST_METHOD'] === 'POST') {
// Public endpoint — siapapun bisa lapor (tidak perlu login)
$nama_pelapor = sanitize($conn, $_POST['nama_pelapor'] ?? '');
$no_hp = sanitize($conn, $_POST['no_hp_pelapor'] ?? '');
$nama_terlapor = sanitize($conn, $_POST['nama_terlapor'] ?? '');
$alamat = sanitize($conn, $_POST['alamat_kejadian'] ?? '');
$deskripsi = sanitize($conn, $_POST['deskripsi'] ?? '');
$kategori = sanitize($conn, $_POST['kategori'] ?? 'kemiskinan');
$urgensi = sanitize($conn, $_POST['urgensi'] ?? 'sedang');
$lat = !empty($_POST['lat']) ? (float)$_POST['lat'] : 'NULL';
$lng = !empty($_POST['lng']) ? (float)$_POST['lng'] : 'NULL';
$penduduk_id = (int)($_POST['penduduk_id'] ?? 0) ?: 'NULL';
if (!$deskripsi || strlen($deskripsi) < 10) jsonError('Deskripsi terlalu singkat (min 10 karakter)');
if (!$alamat) jsonError('Alamat kejadian wajib diisi');
// Generate kode laporan
$thn = date('Y');
$last = $conn->query("SELECT COUNT(*) c FROM laporan WHERE YEAR(created_at)=$thn")->fetch_assoc()['c'];
$kode = 'LPR-' . $thn . '-' . str_pad($last + 1, 4, '0', STR_PAD_LEFT);
// Upload foto
$foto = 'NULL';
if (!empty($_FILES['foto']['name'])) {
$f = uploadFile($_FILES['foto'], 'laporan', ['jpg','jpeg','png']);
if ($f) $foto = "'$f'";
}
$nama_pelapor_val = $nama_pelapor ? "'$nama_pelapor'" : 'NULL';
$no_hp_val = $no_hp ? "'$no_hp'" : 'NULL';
$nama_terl_val = $nama_terlapor ? "'$nama_terlapor'" : 'NULL';
$alamat_val = $alamat ? "'$alamat'" : 'NULL';
$sql = "INSERT INTO laporan (kode_laporan, nama_pelapor, no_hp_pelapor, nama_terlapor, penduduk_id, alamat_kejadian, lat, lng, deskripsi, kategori, urgensi, foto)
VALUES ('$kode', $nama_pelapor_val, $no_hp_val, $nama_terl_val, $penduduk_id, $alamat_val, $lat, $lng, '$deskripsi', '$kategori', '$urgensi', $foto)";
if (!$conn->query($sql)) jsonError('Gagal simpan laporan: ' . $conn->error);
$newId = $conn->insert_id;
// Jika urgensi darurat, kirim notifikasi ke semua pengurus
if ($urgensi === 'darurat') {
$pengurus = $conn->query("SELECT u.id FROM users u JOIN roles r ON u.role_id=r.id WHERE r.slug IN ('admin','pengurus') AND u.aktif=1");
while ($p = $pengurus->fetch_assoc()) {
kirimNotifikasi($conn, $p['id'], '🚨 Laporan DARURAT Masuk!', "Kode: $kode$deskripsi", 'danger', '#');
}
}
logAktivitas($conn, 'CREATE_LAPORAN', 'laporan', $newId);
jsonSuccess(['id' => $newId, 'kode' => $kode], 'Laporan berhasil dikirim. Kode: ' . $kode);
}
if ($action === 'update_status' && $_SERVER['REQUEST_METHOD'] === 'POST') {
requireLogin();
requireRole('admin', 'pengurus');
$id = (int)($_POST['id'] ?? 0);
$status = sanitize($conn, $_POST['status'] ?? '');
$catatan = sanitize($conn, $_POST['catatan'] ?? '');
$uid = currentUserId();
$allowed = ['diverifikasi', 'diproses', 'selesai', 'ditolak'];
if (!in_array($status, $allowed)) jsonError('Status tidak valid');
$extra = '';
if ($status === 'diverifikasi') $extra = ", diverifikasi_oleh=$uid, diverifikasi_at=NOW(), catatan_verifikasi='$catatan'";
elseif ($status === 'diproses') $extra = ", diproses_oleh=$uid, diproses_at=NOW()";
elseif ($status === 'selesai') $extra = ", diselesaikan_at=NOW()";
$conn->query("UPDATE laporan SET status='$status'$extra WHERE id=$id");
logAktivitas($conn, 'UPDATE_STATUS_LAPORAN', 'laporan', $id);
jsonSuccess(null, 'Status laporan diperbarui');
}
if ($action === 'delete' && $_SERVER['REQUEST_METHOD'] === 'POST') {
requireLogin();
requireRole('admin', 'pengurus');
$id = (int)($_POST['id'] ?? 0);
if (!$id) jsonError('ID tidak valid');
$old = $conn->query("SELECT * FROM laporan WHERE id=$id")->fetch_assoc();
if (!$old) jsonError('Laporan tidak ditemukan');
$conn->query("DELETE FROM laporan WHERE id=$id");
logAktivitas($conn, 'DELETE_LAPORAN', 'laporan', $id, $old);
jsonSuccess(null, 'Laporan berhasil dihapus');
}
jsonError('Action tidak dikenal');
@@ -0,0 +1,34 @@
<?php
// api/log.php — Log Aktivitas Sistem
require_once '../koneksi.php';
requireLogin();
requireRole('admin', 'pengurus', 'pimpinan');
header('Content-Type: application/json');
$action = $_GET['action'] ?? 'list';
if ($action === 'list') {
$limit = min((int)($_GET['limit'] ?? 100), 500);
$filter = sanitize($conn, $_GET['aksi'] ?? '');
$where = $filter ? "WHERE la.aksi LIKE '%$filter%'" : '';
$sql = "SELECT la.*, u.nama AS nama_user, u.username
FROM log_aktivitas la
LEFT JOIN users u ON la.user_id = u.id
$where
ORDER BY la.created_at DESC
LIMIT $limit";
$rows = $conn->query($sql);
jsonSuccess($rows ? $rows->fetch_all(MYSQLI_ASSOC) : []);
}
if ($action === 'ringkasan') {
$stat = [];
$stat['total_hari_ini'] = $conn->query("SELECT COUNT(*) c FROM log_aktivitas WHERE DATE(created_at)=CURDATE()")->fetch_assoc()['c'];
$stat['per_aksi'] = $conn->query("SELECT aksi, COUNT(*) total FROM log_aktivitas GROUP BY aksi ORDER BY total DESC LIMIT 10")->fetch_all(MYSQLI_ASSOC);
$stat['per_user'] = $conn->query("SELECT u.nama, COUNT(la.id) total FROM log_aktivitas la JOIN users u ON la.user_id=u.id GROUP BY la.user_id ORDER BY total DESC LIMIT 10")->fetch_all(MYSQLI_ASSOC);
jsonSuccess($stat);
}
jsonError('Action tidak dikenal');
@@ -0,0 +1,30 @@
<?php
// api/notifikasi.php
require_once '../koneksi.php';
requireLogin();
header('Content-Type: application/json');
$action = $_GET['action'] ?? 'list';
$uid = currentUserId();
if ($action === 'list') {
$rows = $conn->query("SELECT * FROM notifikasi WHERE user_id=$uid ORDER BY created_at DESC LIMIT 30");
jsonSuccess($rows ? $rows->fetch_all(MYSQLI_ASSOC) : []);
}
if ($action === 'baca' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$id = (int)($_POST['id'] ?? 0);
if ($id) {
$conn->query("UPDATE notifikasi SET dibaca=1 WHERE id=$id AND user_id=$uid");
} else {
$conn->query("UPDATE notifikasi SET dibaca=1 WHERE user_id=$uid");
}
jsonSuccess(null, 'OK');
}
if ($action === 'count') {
$c = $conn->query("SELECT COUNT(*) c FROM notifikasi WHERE user_id=$uid AND dibaca=0")->fetch_assoc()['c'];
jsonSuccess(['count' => (int)$c]);
}
jsonError('Action tidak dikenal');
@@ -0,0 +1,251 @@
<?php
// api/penduduk.php — CRUD lengkap untuk penduduk
require_once '../koneksi.php';
requireLogin();
header('Content-Type: application/json');
$action = $_GET['action'] ?? 'list';
// ============================================================
// LIST — untuk tabel panel
// ============================================================
if ($action === 'list') {
$search = sanitize($conn, $_GET['search'] ?? '');
$status = sanitize($conn, $_GET['status'] ?? '');
$where = ["1=1"];
if ($search) $where[] = "(p.nama LIKE '%$search%' OR p.nik LIKE '%$search%')";
if ($status) $where[] = "p.status_ekonomi = '$status'";
$wStr = implode(' AND ', $where);
$sql = "SELECT p.id, p.nik, p.nama, p.jenis_kelamin, p.tanggal_lahir,
TIMESTAMPDIFF(YEAR, p.tanggal_lahir, CURDATE()) AS umur,
p.status_keluarga, p.status_kawin, p.status_hidup,
p.pekerjaan, p.penghasilan, p.status_ekonomi, p.kondisi_kesehatan,
p.pendidikan_terakhir, p.status_pendidikan,
p.lat, p.lng, p.keluarga_id,
k.no_kk, k.alamat AS alamat_kk, k.kelurahan, k.kecamatan,
(SELECT COUNT(*) FROM histori_bantuan hb WHERE hb.penduduk_id = p.id AND hb.status_penyaluran = 'disalurkan') AS total_bantuan_diterima,
(SELECT MAX(hb2.tanggal_penyaluran) FROM histori_bantuan hb2 WHERE hb2.penduduk_id = p.id) AS terakhir_dibantu
FROM penduduk p
LEFT JOIN keluarga k ON p.keluarga_id = k.id
WHERE $wStr
ORDER BY FIELD(p.status_ekonomi,'miskin','rentan','mampu'), p.nama
LIMIT 200";
$rows = $conn->query($sql);
if (!$rows) jsonError('Query gagal: ' . $conn->error);
jsonSuccess($rows->fetch_all(MYSQLI_ASSOC));
}
// ============================================================
// MAP — optimized for Leaflet markers
// ============================================================
if ($action === 'map') {
$filter = sanitize($conn, $_GET['filter'] ?? 'all');
$search = sanitize($conn, $_GET['search'] ?? '');
$where = ["p.status_hidup = 'hidup'", "p.lat IS NOT NULL", "p.lng IS NOT NULL"];
if ($filter === 'miskin') $where[] = "p.status_ekonomi = 'miskin'";
elseif ($filter === 'rentan') $where[] = "p.status_ekonomi = 'rentan'";
elseif ($filter === 'mampu') $where[] = "p.status_ekonomi = 'mampu'";
elseif ($filter === 'prioritas') {
$where[] = "(p.status_ekonomi = 'miskin' OR p.kondisi_kesehatan IN ('sakit_berat','disabilitas'))";
}
if ($search) $where[] = "(p.nama LIKE '%$search%' OR p.nik LIKE '%$search%')";
$wStr = implode(' AND ', $where);
$sql = "SELECT p.id, p.nik, p.nama,
TIMESTAMPDIFF(YEAR, p.tanggal_lahir, CURDATE()) AS umur,
p.status_ekonomi, p.kondisi_kesehatan, p.pekerjaan,
p.lat, p.lng,
(SELECT COUNT(*) FROM histori_bantuan hb WHERE hb.penduduk_id = p.id AND hb.status_penyaluran = 'disalurkan') AS total_bantuan_diterima
FROM penduduk p
WHERE $wStr
LIMIT 500";
$rows = $conn->query($sql);
if (!$rows) jsonError('Query gagal: ' . $conn->error);
jsonSuccess($rows->fetch_all(MYSQLI_ASSOC));
}
// ============================================================
// GET — single penduduk dengan detail
// ============================================================
if ($action === 'get') {
$id = (int)($_GET['id'] ?? 0);
if (!$id) jsonError('ID tidak valid');
$p = dbFirst($conn, "SELECT p.*,
TIMESTAMPDIFF(YEAR, p.tanggal_lahir, CURDATE()) AS umur,
k.no_kk, k.alamat AS alamat_kk, k.rt, k.rw, k.kelurahan, k.kecamatan,
ri.nama AS nama_ibadah, ri.jenis AS jenis_ibadah,
(SELECT COUNT(*) FROM histori_bantuan hb WHERE hb.penduduk_id = p.id AND hb.status_penyaluran = 'disalurkan') AS total_bantuan_diterima,
(SELECT MAX(hb2.tanggal_penyaluran) FROM histori_bantuan hb2 WHERE hb2.penduduk_id = p.id) AS terakhir_dibantu
FROM penduduk p
LEFT JOIN keluarga k ON p.keluarga_id = k.id
LEFT JOIN rumah_ibadah ri ON k.rumah_ibadah_id = ri.id
WHERE p.id = ?", [$id], 'i');
if (!$p) jsonError('Data tidak ditemukan', 404);
// Riwayat pendidikan
$p['riwayat_pendidikan'] = dbQuery($conn, "SELECT * FROM riwayat_pendidikan WHERE penduduk_id=? ORDER BY tahun_masuk DESC", [$id], 'i');
// Histori bantuan
$p['histori_bantuan'] = dbQuery($conn, "SELECT hb.*, jb.nama AS nama_bantuan, jb.kategori, u.nama AS nama_penyalur
FROM histori_bantuan hb
JOIN jenis_bantuan jb ON hb.jenis_bantuan_id = jb.id
LEFT JOIN users u ON hb.disalurkan_oleh = u.id
WHERE hb.penduduk_id = ?
ORDER BY hb.tanggal_penyaluran DESC", [$id], 'i');
// Anggota keluarga lain
if ($p['keluarga_id']) {
$p['satu_keluarga'] = dbQuery($conn, "SELECT id, nik, nama, TIMESTAMPDIFF(YEAR, tanggal_lahir, CURDATE()) AS umur, status_keluarga, pekerjaan, status_ekonomi FROM penduduk WHERE keluarga_id=? AND id!=? ORDER BY status_keluarga", [$p['keluarga_id'], $id], 'ii');
}
jsonSuccess($p);
}
// ============================================================
// SAVE — INSERT / UPDATE
// ============================================================
if ($action === 'save' && $_SERVER['REQUEST_METHOD'] === 'POST') {
requireRole('admin', 'pengurus');
$id = (int)($_POST['id'] ?? 0);
$nik = sanitize($conn, $_POST['nik'] ?? '');
$nama = sanitize($conn, $_POST['nama'] ?? '');
$jk = sanitize($conn, $_POST['jenis_kelamin'] ?? 'L');
$tgl_lahir = sanitize($conn, $_POST['tanggal_lahir'] ?? '');
$tempat_lahir = sanitize($conn, $_POST['tempat_lahir'] ?? '');
$status_kk = sanitize($conn, $_POST['status_keluarga'] ?? 'anak');
$status_kawin = sanitize($conn, $_POST['status_kawin'] ?? 'belum_kawin');
$status_hidup = sanitize($conn, $_POST['status_hidup'] ?? 'hidup');
$tgl_meninggal = sanitize($conn, $_POST['tanggal_meninggal'] ?? '');
$keluarga_id = (int)($_POST['keluarga_id'] ?? 0);
$pendidikan = sanitize($conn, $_POST['pendidikan_terakhir'] ?? 'tidak_sekolah');
$status_pddk = sanitize($conn, $_POST['status_pendidikan'] ?? 'tidak_sekolah');
$pekerjaan = sanitize($conn, $_POST['pekerjaan'] ?? '');
$penghasilan = (float)($_POST['penghasilan'] ?? 0);
$status_ekonomi = sanitize($conn, $_POST['status_ekonomi'] ?? 'rentan');
$kondisi_sehat = sanitize($conn, $_POST['kondisi_kesehatan'] ?? 'sehat');
$catatan_sehat = sanitize($conn, $_POST['catatan_kesehatan'] ?? '');
$lat = !empty($_POST['lat']) ? (float)$_POST['lat'] : null;
$lng = !empty($_POST['lng']) ? (float)$_POST['lng'] : null;
// Validasi NIK
if (strlen($nik) !== 16 || !ctype_digit($nik)) jsonError('NIK harus 16 digit angka');
if (!$nama) jsonError('Nama tidak boleh kosong');
if (!$tgl_lahir) jsonError('Tanggal lahir wajib diisi');
// Hitung umur untuk validasi pendidikan
$umur = (int)date_diff(date_create($tgl_lahir), date_create())->y;
$validasi = validasiPendidikan($umur, $status_pddk, $pendidikan);
// Validasi non-blocking (hanya warning, tidak stop save)
// Upload foto
$foto = '';
if (!empty($_FILES['foto']['name'])) {
$foto = uploadFile($_FILES['foto'], 'pddk', ['jpg','jpeg','png','webp']);
}
$keluarga_val = $keluarga_id ? $keluarga_id : 'NULL';
$tgl_meninggal_val = ($status_hidup === 'meninggal' && $tgl_meninggal) ? "'$tgl_meninggal'" : 'NULL';
$lat_val = $lat !== null ? $lat : 'NULL';
$lng_val = $lng !== null ? $lng : 'NULL';
$foto_set = $foto ? "'$foto'" : ($id > 0 ? "(SELECT foto FROM penduduk WHERE id=$id)" : 'NULL');
if ($id > 0) {
// UPDATE
$sql = "UPDATE penduduk SET
nik='$nik', nama='$nama', jenis_kelamin='$jk', tanggal_lahir='$tgl_lahir',
tempat_lahir='$tempat_lahir', status_keluarga='$status_kk', status_kawin='$status_kawin',
status_hidup='$status_hidup', tanggal_meninggal=$tgl_meninggal_val,
keluarga_id=$keluarga_val, pendidikan_terakhir='$pendidikan', status_pendidikan='$status_pddk',
pekerjaan='$pekerjaan', penghasilan=$penghasilan, status_ekonomi='$status_ekonomi',
kondisi_kesehatan='$kondisi_sehat', catatan_kesehatan='$catatan_sehat',
lat=$lat_val, lng=$lng_val,
foto=$foto_set, updated_by=" . currentUserId() . "
WHERE id=$id";
if (!$conn->query($sql)) jsonError('Gagal update: ' . $conn->error);
logAktivitas($conn, 'UPDATE_PENDUDUK', 'penduduk', $id);
jsonSuccess(['id' => $id, 'warnings' => $validasi], 'Data penduduk berhasil diperbarui');
} else {
// INSERT — cek NIK duplikat
$existing = $conn->query("SELECT id FROM penduduk WHERE nik='$nik'")->fetch_assoc();
if ($existing) jsonError('NIK sudah terdaftar di sistem');
$sql = "INSERT INTO penduduk (nik, nama, jenis_kelamin, tanggal_lahir, tempat_lahir,
status_keluarga, status_kawin, status_hidup, tanggal_meninggal,
keluarga_id, pendidikan_terakhir, status_pendidikan,
pekerjaan, penghasilan, status_ekonomi, kondisi_kesehatan, catatan_kesehatan,
lat, lng, foto, created_by)
VALUES ('$nik','$nama','$jk','$tgl_lahir','$tempat_lahir',
'$status_kk','$status_kawin','$status_hidup',$tgl_meninggal_val,
$keluarga_val,'$pendidikan','$status_pddk',
'$pekerjaan',$penghasilan,'$status_ekonomi','$kondisi_sehat','$catatan_sehat',
$lat_val,$lng_val,$foto_set," . currentUserId() . ")";
if (!$conn->query($sql)) jsonError('Gagal simpan: ' . $conn->error);
$newId = $conn->insert_id;
logAktivitas($conn, 'CREATE_PENDUDUK', 'penduduk', $newId);
// Update status ekonomi keluarga jika lebih parah
if ($keluarga_id && $status_ekonomi === 'miskin') {
$conn->query("UPDATE keluarga SET status_ekonomi='miskin' WHERE id=$keluarga_id AND status_ekonomi='rentan'");
}
jsonSuccess(['id' => $newId, 'warnings' => $validasi], 'Data penduduk berhasil ditambahkan');
}
}
// ============================================================
// DELETE
// ============================================================
if ($action === 'delete' && $_SERVER['REQUEST_METHOD'] === 'POST') {
requireRole('admin');
$id = (int)($_POST['id'] ?? 0);
if (!$id) jsonError('ID tidak valid');
if (!$conn->query("DELETE FROM penduduk WHERE id=$id")) {
jsonError('Gagal hapus: ' . $conn->error);
}
logAktivitas($conn, 'DELETE_PENDUDUK', 'penduduk', $id);
jsonSuccess(null, 'Data berhasil dihapus');
}
// ============================================================
// PRIORITAS — warga belum/sedikit dibantu, urut prioritas
// ============================================================
if ($action === 'prioritas') {
$sql = "SELECT p.id, p.nik, p.nama, p.status_ekonomi, p.kondisi_kesehatan,
TIMESTAMPDIFF(YEAR, p.tanggal_lahir, CURDATE()) AS umur,
k.kelurahan, k.kecamatan, k.lat, k.lng,
ri.nama AS nama_ibadah,
(SELECT COUNT(*) FROM histori_bantuan hb WHERE hb.penduduk_id = p.id AND hb.status_penyaluran = 'disalurkan') AS total_bantuan,
CASE
WHEN p.status_ekonomi = 'miskin' AND p.kondisi_kesehatan IN ('sakit_berat','disabilitas') THEN 'DARURAT'
WHEN p.status_ekonomi = 'miskin' THEN 'TINGGI'
WHEN p.status_ekonomi = 'rentan' AND p.kondisi_kesehatan IN ('sakit_berat','disabilitas') THEN 'TINGGI'
WHEN p.status_ekonomi = 'rentan' THEN 'SEDANG'
ELSE 'RENDAH'
END AS prioritas
FROM penduduk p
LEFT JOIN keluarga k ON p.keluarga_id = k.id
LEFT JOIN rumah_ibadah ri ON k.rumah_ibadah_id = ri.id
WHERE p.status_hidup = 'hidup'
AND (p.status_ekonomi IN ('miskin','rentan') OR p.kondisi_kesehatan IN ('sakit_berat','disabilitas'))
HAVING total_bantuan = 0
ORDER BY FIELD(prioritas,'DARURAT','TINGGI','SEDANG','RENDAH'), p.status_ekonomi
LIMIT 50";
$rows = $conn->query($sql);
jsonSuccess($rows ? $rows->fetch_all(MYSQLI_ASSOC) : []);
}
jsonError('Action tidak dikenal: ' . $action);
@@ -0,0 +1,83 @@
<?php
require_once '../koneksi.php';
requireLogin();
$action = $_GET['action'] ?? 'list';
if ($action === 'list') {
requireRole('admin','pengurus','pimpinan');
$role = getRole();
$uid = currentUserId();
$riId = $_SESSION['rumah_ibadah_id'] ?? null;
$filterRumah = ($role==='pengurus' && $riId) ? "AND k.rumah_ibadah_id=$riId" : '';
$search = sanitize($conn, $_GET['q'] ?? '');
$jenis = sanitize($conn, $_GET['jenis'] ?? '');
$wS = $search ? "AND (p.nama LIKE '%$search%' OR p.nik LIKE '%$search%')" : '';
$wJ = $jenis ? "AND pk.jenis='$jenis'" : '';
$rows = $conn->query("
SELECT pk.*, p.nama AS nama_penduduk, p.nik,
TIMESTAMPDIFF(YEAR,p.tanggal_lahir,CURDATE()) AS umur,
u.nama AS dicatat_nama, uv.nama AS diverifikasi_nama
FROM peristiwa_kependudukan pk
JOIN penduduk p ON pk.penduduk_id=p.id
LEFT JOIN keluarga k ON p.keluarga_id=k.id
LEFT JOIN users u ON pk.dicatat_oleh=u.id
LEFT JOIN users uv ON pk.diverifikasi_oleh=uv.id
WHERE 1=1 $filterRumah $wS $wJ
ORDER BY pk.tanggal DESC, pk.created_at DESC LIMIT 200
")->fetch_all(MYSQLI_ASSOC);
jsonSuccess($rows);
}
if ($action === 'save' && $_SERVER['REQUEST_METHOD']==='POST') {
requireRole('admin','pengurus');
$pid = (int)($_POST['penduduk_id'] ?? 0);
$jenis = sanitize($conn, $_POST['jenis'] ?? '');
$tgl = sanitize($conn, $_POST['tanggal'] ?? date('Y-m-d'));
$ket = sanitize($conn, $_POST['keterangan'] ?? '');
$asal = sanitize($conn, $_POST['alamat_asal'] ?? '');
$tuju = sanitize($conn, $_POST['alamat_tujuan'] ?? '');
$sebab = sanitize($conn, $_POST['sebab_meninggal'] ?? '');
$oleh = currentUserId();
if (!$pid || !$jenis) jsonError('Data tidak lengkap');
$kidR = $conn->query("SELECT keluarga_id FROM penduduk WHERE id=$pid")->fetch_assoc();
$kid = $kidR['keluarga_id'] ?? null;
$dokumen = null;
if (!empty($_FILES['dokumen']['name']))
$dokumen = uploadFile($_FILES['dokumen'],'peristiwa',['jpg','jpeg','png','pdf']);
$stmt = $conn->prepare("INSERT INTO peristiwa_kependudukan
(penduduk_id,keluarga_id,jenis,tanggal,keterangan,alamat_asal,alamat_tujuan,sebab_meninggal,dokumen,dicatat_oleh)
VALUES (?,?,?,?,?,?,?,?,?,?)");
$stmt->bind_param('iisssssssi',$pid,$kid,$jenis,$tgl,$ket,$asal,$tuju,$sebab,$dokumen,$oleh);
if (!$stmt->execute()) jsonError($conn->error);
$newId = $conn->insert_id;
logAktivitas($conn,'CREATE_PERISTIWA','peristiwa_kependudukan',$newId);
// Sync ke penduduk
if ($jenis==='meninggal')
$conn->query("UPDATE penduduk SET status_hidup='meninggal',tanggal_meninggal='$tgl',updated_by=$oleh WHERE id=$pid");
elseif ($jenis==='menikah')
$conn->query("UPDATE penduduk SET status_kawin='kawin',updated_by=$oleh WHERE id=$pid");
elseif ($jenis==='cerai')
$conn->query("UPDATE penduduk SET status_kawin='cerai_hidup',updated_by=$oleh WHERE id=$pid");
elseif ($jenis==='pindah_keluar')
$conn->query("UPDATE penduduk SET keluarga_id=NULL,domisili_sama_kk=0,updated_by=$oleh WHERE id=$pid");
jsonSuccess(['id'=>$newId],'Peristiwa berhasil dicatat');
}
if ($action==='verifikasi' && $_SERVER['REQUEST_METHOD']==='POST') {
requireRole('admin','pengurus');
$id = (int)($_POST['id'] ?? 0);
$uid = currentUserId();
$conn->query("UPDATE peristiwa_kependudukan SET diverifikasi=1,diverifikasi_oleh=$uid,diverifikasi_at=NOW() WHERE id=$id");
logAktivitas($conn,'VERIFIKASI_PERISTIWA','peristiwa_kependudukan',$id);
jsonSuccess(null,'Berhasil diverifikasi');
}
if ($action==='delete' && $_SERVER['REQUEST_METHOD']==='POST') {
requireRole('admin');
$id = (int)($_POST['id'] ?? 0);
$conn->query("DELETE FROM peristiwa_kependudukan WHERE id=$id");
logAktivitas($conn,'DELETE_PERISTIWA','peristiwa_kependudukan',$id);
jsonSuccess(null,'Berhasil dihapus');
}
jsonError('Action tidak dikenal');
@@ -0,0 +1,60 @@
<?php
// api/pesan.php — Chat antar pengurus rumah ibadah
require_once '../koneksi.php';
requireLogin();
header('Content-Type: application/json');
$action = $_GET['action'] ?? 'list';
if ($action === 'list') {
$ibadah_id = (int)($_GET['rumah_ibadah_id'] ?? 0);
$limit = (int)($_GET['limit'] ?? 50);
$where = $ibadah_id ? "AND m.rumah_ibadah_id=$ibadah_id" : "AND m.penerima_id IS NULL";
$sql = "SELECT m.*, u.nama AS nama_pengirim, ri.nama AS nama_ibadah
FROM pesan m
JOIN users u ON m.pengirim_id = u.id
LEFT JOIN rumah_ibadah ri ON m.rumah_ibadah_id = ri.id
WHERE 1=1 $where
ORDER BY m.created_at DESC
LIMIT $limit";
$rows = $conn->query($sql);
$data = $rows ? array_reverse($rows->fetch_all(MYSQLI_ASSOC)) : [];
jsonSuccess($data);
}
if ($action === 'send' && $_SERVER['REQUEST_METHOD'] === 'POST') {
requireRole('admin', 'pengurus', 'pimpinan');
$isi = sanitize($conn, $_POST['isi'] ?? '');
$penerima = (int)($_POST['penerima_id'] ?? 0) ?: 'NULL';
$ibadah_id = (int)($_POST['rumah_ibadah_id'] ?? 0) ?: 'NULL';
$uid = currentUserId();
if (!$isi) jsonError('Pesan tidak boleh kosong');
// Auto set ibadah dari user jika belum diset
if ($ibadah_id === 'NULL') {
$u = dbFirst($conn, "SELECT rumah_ibadah_id FROM users WHERE id=?", [$uid], 'i');
if ($u && $u['rumah_ibadah_id']) $ibadah_id = (int)$u['rumah_ibadah_id'];
}
$sql = "INSERT INTO pesan (pengirim_id, penerima_id, rumah_ibadah_id, isi) VALUES ($uid, $penerima, $ibadah_id, '$isi')";
if (!$conn->query($sql)) jsonError('Gagal kirim: ' . $conn->error);
jsonSuccess(['id' => $conn->insert_id], 'Pesan terkirim');
}
if ($action === 'unread') {
$uid = currentUserId();
$count = $conn->query("SELECT COUNT(*) c FROM pesan WHERE penerima_id=$uid AND dibaca=0")->fetch_assoc()['c'];
jsonSuccess(['count' => (int)$count]);
}
if ($action === 'baca' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$uid = currentUserId();
$conn->query("UPDATE pesan SET dibaca=1 WHERE penerima_id=$uid AND dibaca=0");
jsonSuccess(null, 'Ditandai sudah dibaca');
}
jsonError('Action tidak dikenal');
@@ -0,0 +1,105 @@
<?php
require_once '../koneksi.php';
requireLogin();
$action = $_GET['action'] ?? 'list';
if ($action==='list') {
$rows = $conn->query("SELECT * FROM rekomendasi_pelatihan ORDER BY kategori,nama")->fetch_all(MYSQLI_ASSOC);
jsonSuccess($rows);
}
if ($action==='list_penduduk') {
requireRole('admin','pengurus');
$role = getRole(); $riId = $_SESSION['rumah_ibadah_id'] ?? null;
$filterRumah = ($role==='pengurus' && $riId) ? "AND k.rumah_ibadah_id=$riId" : '';
$search = sanitize($conn, $_GET['q'] ?? '');
$wS = $search ? "AND (p.nama LIKE '%$search%' OR p.nik LIKE '%$search%')" : '';
$rows = $conn->query("
SELECT p.id,p.nama,p.nik,p.jenis_kelamin,p.status_ekonomi,p.kondisi_kesehatan,
p.kebutuhan_pelatihan, TIMESTAMPDIFF(YEAR,p.tanggal_lahir,CURDATE()) AS umur,
ri.nama AS ibadah
FROM penduduk p LEFT JOIN keluarga k ON p.keluarga_id=k.id
LEFT JOIN rumah_ibadah ri ON k.rumah_ibadah_id=ri.id
WHERE p.status_hidup='hidup' $filterRumah $wS ORDER BY p.nama LIMIT 200
")->fetch_all(MYSQLI_ASSOC);
jsonSuccess($rows);
}
if ($action==='list_riwayat') {
requireRole('admin','pengurus','pimpinan');
$pid = (int)($_GET['penduduk_id'] ?? 0);
$wP = $pid ? "AND pp.penduduk_id=$pid" : '';
$rows = $conn->query("
SELECT pp.*,p.nama AS nama_penduduk,rp.nama AS nama_pelatihan,
rp.kategori,rp.penyelenggara,rp.durasi,
u.nama AS direkomendasikan_oleh
FROM penduduk_pelatihan pp
JOIN penduduk p ON pp.penduduk_id=p.id
JOIN rekomendasi_pelatihan rp ON pp.rekomendasi_pelatihan_id=rp.id
LEFT JOIN users u ON pp.direkomendasikan_oleh=u.id
WHERE 1=1 $wP ORDER BY pp.tanggal_rekomendasi DESC LIMIT 200
")->fetch_all(MYSQLI_ASSOC);
jsonSuccess($rows);
}
if ($action==='save_master' && $_SERVER['REQUEST_METHOD']==='POST') {
requireRole('admin','pengurus');
$nama = sanitize($conn, trim($_POST['nama'] ?? ''));
$kat = sanitize($conn, $_POST['kategori'] ?? 'vokasi');
$desk = sanitize($conn, $_POST['deskripsi'] ?? '');
$penye = sanitize($conn, $_POST['penyelenggara'] ?? '');
$kontak = sanitize($conn, $_POST['kontak_penyelenggara'] ?? '');
$umin = (int)($_POST['usia_min'] ?? 15);
$umaks = (int)($_POST['usia_maks'] ?? 60);
$gender = sanitize($conn, $_POST['gender'] ?? 'semua');
$sasaran = sanitize($conn, $_POST['status_ekonomi_sasaran'] ?? 'semua');
$prsyrt = sanitize($conn, $_POST['prasyarat'] ?? '');
$durasi = sanitize($conn, $_POST['durasi'] ?? '');
$biaya = floatval($_POST['biaya'] ?? 0);
if (!$nama) jsonError('Nama pelatihan wajib diisi');
$conn->query("INSERT INTO rekomendasi_pelatihan (nama,kategori,deskripsi,penyelenggara,kontak_penyelenggara,usia_min,usia_maks,gender,status_ekonomi_sasaran,prasyarat,durasi,biaya) VALUES ('$nama','$kat','$desk','$penye','$kontak',$umin,$umaks,'$gender','$sasaran','$prsyrt','$durasi',$biaya)");
logAktivitas($conn,'CREATE_REKOMENDASI','rekomendasi_pelatihan',$conn->insert_id);
jsonSuccess(['id'=>$conn->insert_id],"Pelatihan '$nama' berhasil ditambahkan");
}
if ($action==='rekomendasikan' && $_SERVER['REQUEST_METHOD']==='POST') {
requireRole('admin','pengurus');
$pid = (int)($_POST['penduduk_id'] ?? 0);
$rekId = (int)($_POST['rekomendasi_pelatihan_id'] ?? 0);
$cat = sanitize($conn, $_POST['catatan'] ?? '');
$oleh = currentUserId();
if (!$pid || !$rekId) jsonError('Data tidak lengkap');
$cek = $conn->query("SELECT id FROM penduduk_pelatihan WHERE penduduk_id=$pid AND rekomendasi_pelatihan_id=$rekId AND status NOT IN ('selesai','batal')")->fetch_assoc();
if ($cek) jsonError('Pelatihan ini sudah direkomendasikan dan masih aktif');
$tgl = date('Y-m-d');
$conn->query("INSERT INTO penduduk_pelatihan (penduduk_id,rekomendasi_pelatihan_id,status,tanggal_rekomendasi,catatan,direkomendasikan_oleh) VALUES ($pid,$rekId,'direkomendasikan','$tgl','$cat',$oleh)");
logAktivitas($conn,'REKOMENDASIKAN_PELATIHAN','penduduk_pelatihan',$conn->insert_id);
jsonSuccess(['id'=>$conn->insert_id],'Rekomendasi berhasil dikirim');
}
if ($action==='update_status' && $_SERVER['REQUEST_METHOD']==='POST') {
requireRole('admin','pengurus');
$id = (int)($_POST['id'] ?? 0);
$status = sanitize($conn, $_POST['status'] ?? '');
$tMul = sanitize($conn, $_POST['tanggal_mulai'] ?? '');
$tSls = sanitize($conn, $_POST['tanggal_selesai'] ?? '');
$sertif = null;
if (!empty($_FILES['sertifikat']['name']))
$sertif = uploadFile($_FILES['sertifikat'],'sertif',['jpg','jpeg','png','pdf']);
$setMul = $tMul ? ",tanggal_mulai='$tMul'" : '';
$setSls = $tSls ? ",tanggal_selesai='$tSls'" : '';
$setSrt = $sertif ? ",sertifikat='$sertif'" : '';
$conn->query("UPDATE penduduk_pelatihan SET status='$status' $setMul $setSls $setSrt WHERE id=$id");
logAktivitas($conn,'UPDATE_STATUS_PELATIHAN','penduduk_pelatihan',$id);
jsonSuccess(null,'Status diperbarui');
}
if ($action==='toggle_aktif' && $_SERVER['REQUEST_METHOD']==='POST') {
requireRole('admin','pengurus');
$id = (int)($_POST['id'] ?? 0);
$val = (int)($_POST['aktif'] ?? 0);
$conn->query("UPDATE rekomendasi_pelatihan SET aktif=$val WHERE id=$id");
jsonSuccess(null,$val?'Diaktifkan':'Dinonaktifkan');
}
jsonError('Action tidak dikenal');
@@ -0,0 +1,71 @@
<?php
// api/riwayat_pendidikan.php — CRUD Riwayat Pendidikan & Pelatihan
require_once '../koneksi.php';
requireLogin();
requireRole('admin','pengurus');
header('Content-Type: application/json');
$action = $_GET['action'] ?? 'list';
if ($action === 'list') {
$search = sanitize($conn, $_GET['search'] ?? '');
$where = $search ? "AND (p.nama LIKE '%$search%' OR p.nik LIKE '%$search%')" : '';
$sql = "SELECT rp.*, p.nama AS nama_penduduk, p.nik,
TIMESTAMPDIFF(YEAR, p.tanggal_lahir, CURDATE()) AS umur_penduduk
FROM riwayat_pendidikan rp
JOIN penduduk p ON rp.penduduk_id = p.id
WHERE 1=1 $where
ORDER BY rp.tahun_masuk DESC, p.nama
LIMIT 200";
$rows = $conn->query($sql);
jsonSuccess($rows ? $rows->fetch_all(MYSQLI_ASSOC) : []);
}
if ($action === 'get_by_penduduk') {
$pid = (int)($_GET['penduduk_id'] ?? 0);
if (!$pid) jsonError('penduduk_id wajib');
$rows = $conn->query("SELECT * FROM riwayat_pendidikan WHERE penduduk_id=$pid ORDER BY tahun_masuk DESC");
jsonSuccess($rows ? $rows->fetch_all(MYSQLI_ASSOC) : []);
}
if ($action === 'save' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$id = (int)($_POST['id'] ?? 0);
$penduduk_id = (int)($_POST['penduduk_id'] ?? 0);
$jenis = sanitize($conn, $_POST['jenis'] ?? 'formal');
$institusi = sanitize($conn, $_POST['nama_institusi'] ?? '');
$jenjang = sanitize($conn, $_POST['jenjang'] ?? '');
$jurusan = sanitize($conn, $_POST['jurusan'] ?? '');
$thn_masuk = (int)($_POST['tahun_masuk'] ?? 0);
$thn_lulus = !empty($_POST['tahun_lulus']) ? (int)$_POST['tahun_lulus'] : null;
$status = sanitize($conn, $_POST['status'] ?? 'lulus');
if (!$penduduk_id) jsonError('penduduk_id wajib');
// Validasi logika
$p = dbFirst($conn, "SELECT TIMESTAMPDIFF(YEAR,tanggal_lahir,CURDATE()) AS umur FROM penduduk WHERE id=?", [$penduduk_id], 'i');
$umur = $p ? (int)$p['umur'] : 0;
$warns = validasiPendidikan($umur, $status === 'sedang_berjalan' ? 'masih_sekolah' : 'lulus', strtolower($jenjang));
$thn_lulus_val = $thn_lulus ? $thn_lulus : 'NULL';
$thn_masuk_val = $thn_masuk ?: 'NULL';
if ($id > 0) {
$conn->query("UPDATE riwayat_pendidikan SET jenis='$jenis',nama_institusi='$institusi',jenjang='$jenjang',jurusan='$jurusan',tahun_masuk=$thn_masuk_val,tahun_lulus=$thn_lulus_val,status='$status' WHERE id=$id AND penduduk_id=$penduduk_id");
logAktivitas($conn, 'UPDATE_RIWAYAT_PDDK', 'riwayat_pendidikan', $id);
jsonSuccess(['id' => $id, 'warnings' => $warns], 'Riwayat pendidikan diperbarui');
} else {
$conn->query("INSERT INTO riwayat_pendidikan (penduduk_id,jenis,nama_institusi,jenjang,jurusan,tahun_masuk,tahun_lulus,status) VALUES ($penduduk_id,'$jenis','$institusi','$jenjang','$jurusan',$thn_masuk_val,$thn_lulus_val,'$status')");
$newId = $conn->insert_id;
logAktivitas($conn, 'CREATE_RIWAYAT_PDDK', 'riwayat_pendidikan', $newId);
jsonSuccess(['id' => $newId, 'warnings' => $warns], 'Riwayat pendidikan ditambahkan');
}
}
if ($action === 'delete' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$id = (int)($_POST['id'] ?? 0);
$conn->query("DELETE FROM riwayat_pendidikan WHERE id=$id");
logAktivitas($conn, 'DELETE_RIWAYAT_PDDK', 'riwayat_pendidikan', $id);
jsonSuccess(null, 'Dihapus');
}
jsonError('Action tidak dikenal');
@@ -0,0 +1,59 @@
<?php
// api/rumah_ibadah.php — CRUD Rumah Ibadah
require_once '../koneksi.php';
requireLogin();
header('Content-Type: application/json');
$action = $_GET['action'] ?? 'list';
if ($action === 'list') {
$rows = $conn->query("SELECT ri.*,
(SELECT COUNT(*) FROM keluarga k WHERE k.rumah_ibadah_id = ri.id) AS jumlah_kk,
(SELECT COUNT(*) FROM keluarga k2 JOIN penduduk p ON p.keluarga_id = k2.id WHERE k2.rumah_ibadah_id = ri.id AND p.status_ekonomi = 'miskin') AS jumlah_miskin
FROM rumah_ibadah ri WHERE ri.aktif=1 ORDER BY ri.nama");
jsonSuccess($rows ? $rows->fetch_all(MYSQLI_ASSOC) : []);
}
if ($action === 'get') {
$id = (int)($_GET['id'] ?? 0);
$ri = dbFirst($conn, "SELECT * FROM rumah_ibadah WHERE id=?", [$id], 'i');
if (!$ri) jsonError('Tidak ditemukan', 404);
$ri['keluarga'] = dbQuery($conn, "SELECT k.*, (SELECT COUNT(*) FROM penduduk p WHERE p.keluarga_id=k.id) AS jumlah_anggota FROM keluarga k WHERE k.rumah_ibadah_id=?", [$id], 'i');
jsonSuccess($ri);
}
if ($action === 'save' && $_SERVER['REQUEST_METHOD'] === 'POST') {
requireRole('admin');
$id = (int)($_POST['id'] ?? 0);
$nama = sanitize($conn, $_POST['nama'] ?? '');
$jenis = sanitize($conn, $_POST['jenis'] ?? 'Masjid');
$kontak = sanitize($conn, $_POST['kontak'] ?? '');
$alamat = sanitize($conn, $_POST['alamat'] ?? '');
$kel = sanitize($conn, $_POST['kelurahan'] ?? '');
$kec = sanitize($conn, $_POST['kecamatan'] ?? '');
$lat = (float)($_POST['lat'] ?? 0);
$lng = (float)($_POST['lng'] ?? 0);
$radius = (int)($_POST['radius'] ?? 500);
if (!$nama) jsonError('Nama wajib diisi');
if (!$lat || !$lng) jsonError('Koordinat wajib diisi');
if ($id > 0) {
$conn->query("UPDATE rumah_ibadah SET nama='$nama',jenis='$jenis',kontak='$kontak',alamat='$alamat',kelurahan='$kel',kecamatan='$kec',lat=$lat,lng=$lng,radius=$radius WHERE id=$id");
logAktivitas($conn, 'UPDATE_IBADAH', 'rumah_ibadah', $id);
jsonSuccess(['id'=>$id], 'Berhasil diperbarui');
} else {
$conn->query("INSERT INTO rumah_ibadah (nama,jenis,kontak,alamat,kelurahan,kecamatan,lat,lng,radius) VALUES ('$nama','$jenis','$kontak','$alamat','$kel','$kec',$lat,$lng,$radius)");
$newId = $conn->insert_id;
logAktivitas($conn, 'CREATE_IBADAH', 'rumah_ibadah', $newId);
jsonSuccess(['id'=>$newId], 'Rumah ibadah berhasil ditambahkan');
}
}
if ($action === 'delete' && $_SERVER['REQUEST_METHOD'] === 'POST') {
requireRole('admin');
$id = (int)($_POST['id'] ?? 0);
$conn->query("UPDATE rumah_ibadah SET aktif=0 WHERE id=$id");
logAktivitas($conn, 'DELETE_IBADAH', 'rumah_ibadah', $id);
jsonSuccess(null, 'Berhasil dihapus');
}
jsonError('Action tidak dikenal');
@@ -0,0 +1,345 @@
<?php
require_once 'koneksi.php';
requireLogin();
requireRole('admin','pengurus','pimpinan');
// Fetch all stats server-side
$totalPenduduk = $conn->query("SELECT COUNT(*) c FROM penduduk WHERE status_hidup='hidup'")->fetch_assoc()['c'];
$totalMiskin = $conn->query("SELECT COUNT(*) c FROM penduduk WHERE status_ekonomi='miskin' AND status_hidup='hidup'")->fetch_assoc()['c'];
$totalRentan = $conn->query("SELECT COUNT(*) c FROM penduduk WHERE status_ekonomi='rentan' AND status_hidup='hidup'")->fetch_assoc()['c'];
$totalMampu = $conn->query("SELECT COUNT(*) c FROM penduduk WHERE status_ekonomi='mampu' AND status_hidup='hidup'")->fetch_assoc()['c'];
$totalKeluarga = $conn->query("SELECT COUNT(*) c FROM keluarga")->fetch_assoc()['c'];
$totalBantuan = $conn->query("SELECT COUNT(*) c FROM histori_bantuan WHERE status_penyaluran='disalurkan'")->fetch_assoc()['c'];
$nilaiSalur = $conn->query("SELECT COALESCE(SUM(nilai_rupiah),0) c FROM histori_bantuan WHERE status_penyaluran='disalurkan'")->fetch_assoc()['c'];
$totalLaporan = $conn->query("SELECT COUNT(*) c FROM laporan WHERE status='pending'")->fetch_assoc()['c'];
$belumDibantu = $conn->query("SELECT COUNT(DISTINCT p.id) c FROM penduduk p WHERE p.status_ekonomi='miskin' AND p.status_hidup='hidup' AND p.id NOT IN (SELECT DISTINCT penduduk_id FROM histori_bantuan WHERE status_penyaluran='disalurkan')")->fetch_assoc()['c'];
// Charts data
$perKecamatan = $conn->query("SELECT k.kecamatan, COUNT(p.id) total, SUM(p.status_ekonomi='miskin') miskin, SUM(p.status_ekonomi='rentan') rentan, SUM(p.status_ekonomi='mampu') mampu FROM penduduk p JOIN keluarga k ON p.keluarga_id=k.id WHERE p.status_hidup='hidup' AND k.kecamatan!='' GROUP BY k.kecamatan ORDER BY total DESC LIMIT 10")->fetch_all(MYSQLI_ASSOC);
$perBulan = $conn->query("SELECT DATE_FORMAT(tanggal_penyaluran,'%Y-%m') bln, COUNT(*) total, SUM(nilai_rupiah) nilai FROM histori_bantuan WHERE status_penyaluran='disalurkan' AND tanggal_penyaluran >= DATE_SUB(NOW(), INTERVAL 12 MONTH) GROUP BY bln ORDER BY bln")->fetch_all(MYSQLI_ASSOC);
$perIbadah = $conn->query("SELECT ri.nama, ri.jenis, COUNT(hb.id) total FROM histori_bantuan hb JOIN rumah_ibadah ri ON hb.rumah_ibadah_id=ri.id GROUP BY ri.id ORDER BY total DESC")->fetch_all(MYSQLI_ASSOC);
$perPendidikan = $conn->query("SELECT pendidikan_terakhir, COUNT(*) total FROM penduduk WHERE status_hidup='hidup' GROUP BY pendidikan_terakhir ORDER BY FIELD(pendidikan_terakhir,'tidak_sekolah','sd','smp','sma','smk','d1','d2','d3','s1','s2','s3')")->fetch_all(MYSQLI_ASSOC);
$anakTidakSek = $conn->query("SELECT COUNT(*) c FROM penduduk WHERE status_hidup='hidup' AND TIMESTAMPDIFF(YEAR,tanggal_lahir,CURDATE()) BETWEEN 7 AND 15 AND status_pendidikan NOT IN ('masih_sekolah')")->fetch_assoc()['c'];
$laporanStatus = $conn->query("SELECT status, COUNT(*) total FROM laporan GROUP BY status")->fetch_all(MYSQLI_ASSOC);
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Dashboard Analitik — WebGIS Kemiskinan</title>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#080c11;--surface:#0f1620;--surface2:#161f2c;--surface3:#1e2a3a;
--border:#243044;--border2:#2f3f57;
--text:#e8edf3;--muted:#6b7e96;
--red:#e85050;--green:#27ae72;--yellow:#f0b429;--blue:#4a9eff;--purple:#9b59b6;
--font-head:'Plus Jakarta Sans',sans-serif;--font-body:'Inter',sans-serif;
}
body{font-family:var(--font-body);background:var(--bg);color:var(--text);min-height:100vh;}
.topnav{display:flex;align-items:center;gap:14px;padding:14px 24px;background:var(--surface);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:100;}
.nav-logo{font-family:var(--font-head);font-weight:800;font-size:15px;}
.nav-links{display:flex;gap:6px;flex:1;}
.nav-link{padding:7px 14px;border-radius:8px;color:var(--muted);font-size:13px;font-weight:500;text-decoration:none;transition:background .15s,color .15s;}
.nav-link:hover,.nav-link.active{background:rgba(232,80,80,.12);color:var(--red);}
.nav-user{font-size:12px;color:var(--muted);}
.container{max-width:1400px;margin:0 auto;padding:28px 24px;}
h2{font-family:var(--font-head);font-size:22px;font-weight:800;margin-bottom:22px;}
h3{font-family:var(--font-head);font-size:14px;font-weight:700;margin-bottom:14px;color:var(--muted);text-transform:uppercase;letter-spacing:.6px;}
/* KPI GRID */
.kpi-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:14px;margin-bottom:28px;}
.kpi{background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:18px 20px;position:relative;overflow:hidden;}
.kpi::before{content:'';position:absolute;top:0;left:0;right:0;height:3px;background:currentColor;opacity:.6;}
.kpi-value{font-family:var(--font-head);font-size:32px;font-weight:800;line-height:1;margin-bottom:6px;}
.kpi-label{font-size:12px;color:var(--muted);font-weight:500;}
.kpi-sub{font-size:11px;color:var(--muted);margin-top:6px;opacity:.7;}
.c-red{color:var(--red);}
.c-yellow{color:var(--yellow);}
.c-green{color:var(--green);}
.c-blue{color:var(--blue);}
.c-purple{color:var(--purple);}
.c-orange{color:#e67e22;}
/* ALERT */
.alert{border-radius:12px;padding:14px 18px;font-size:13px;margin-bottom:22px;display:flex;align-items:center;gap:10px;}
.alert-danger{background:rgba(232,80,80,.1);border:1px solid rgba(232,80,80,.3);color:#ff8080;}
.alert-warning{background:rgba(240,180,41,.08);border:1px solid rgba(240,180,41,.25);color:var(--yellow);}
/* CHART GRID */
.chart-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:28px;}
.chart-card{background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:22px;}
.chart-wrap{position:relative;height:260px;}
.chart-full{grid-column:1/-1;}
.chart-wrap canvas{max-height:260px;}
/* TABLE */
.tbl-card{background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:22px;margin-bottom:28px;}
table{width:100%;border-collapse:collapse;font-size:13px;}
th{text-align:left;padding:8px 12px;background:var(--surface2);color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.5px;font-weight:600;border-bottom:1px solid var(--border);}
td{padding:9px 12px;border-bottom:1px solid var(--border2);}
tr:hover td{background:rgba(255,255,255,.02);}
.badge{display:inline-block;padding:2px 8px;border-radius:20px;font-size:10px;font-weight:600;text-transform:uppercase;}
.b-miskin{background:rgba(232,80,80,.2);color:var(--red);}
.b-rentan{background:rgba(240,180,41,.2);color:var(--yellow);}
.b-mampu{background:rgba(39,174,114,.2);color:var(--green);}
.bar-container{background:var(--surface2);border-radius:4px;height:8px;overflow:hidden;}
.bar-fill{height:100%;border-radius:4px;transition:width .5s ease;}
@media(max-width:900px){.chart-grid{grid-template-columns:1fr;}.kpi-grid{grid-template-columns:repeat(2,1fr);}}
</style>
</head>
<body>
<nav class="topnav">
<div class="nav-logo">🗺️ WebGIS Kemiskinan</div>
<div class="nav-links">
<a href="index.php" class="nav-link">Peta</a>
<a href="dashboard.php" class="nav-link active">Dashboard</a>
<a href="keluarga.php" class="nav-link">Keluarga</a>
<?php if(hasRole('admin','pengurus')): ?>
<a href="riwayat_pendidikan.php" class="nav-link">Pendidikan</a>
<?php endif; ?>
</div>
<div class="nav-user">👤 <?= htmlspecialchars($_SESSION['nama']) ?> (<?= $_SESSION['role_nama'] ?>)</div>
<a href="logout.php" style="color:var(--red);font-size:12px;text-decoration:none;margin-left:10px">Logout</a>
</nav>
<div class="container">
<h2>📊 Dashboard Analitik Pengentasan Kemiskinan</h2>
<?php if($belumDibantu > 0): ?>
<div class="alert alert-danger">
🚨 <strong><?= $belumDibantu ?> warga miskin</strong> belum pernah menerima bantuan apapun. Segera tindak lanjuti!
<a href="index.php" style="color:var(--red);margin-left:auto;font-weight:600;font-size:12px">→ Lihat di Peta</a>
</div>
<?php endif; ?>
<?php if($anakTidakSek > 0): ?>
<div class="alert alert-warning">
⚠️ <strong><?= $anakTidakSek ?> anak usia 715 tahun</strong> terdeteksi tidak/belum sekolah. Butuh verifikasi dan intervensi.
</div>
<?php endif; ?>
<?php if($totalLaporan > 0): ?>
<div class="alert alert-warning">
📢 <strong><?= $totalLaporan ?> laporan masyarakat</strong> masih berstatus <em>pending</em>. Harap segera ditindaklanjuti.
</div>
<?php endif; ?>
<!-- KPI -->
<div class="kpi-grid">
<div class="kpi c-blue">
<div class="kpi-value"><?= number_format($totalPenduduk) ?></div>
<div class="kpi-label">Total Penduduk</div>
<div class="kpi-sub"><?= number_format($totalKeluarga) ?> kartu keluarga</div>
</div>
<div class="kpi c-red">
<div class="kpi-value"><?= number_format($totalMiskin) ?></div>
<div class="kpi-label">Warga Miskin</div>
<div class="kpi-sub"><?= $totalPenduduk ? round($totalMiskin/$totalPenduduk*100,1) : 0 ?>% dari total</div>
</div>
<div class="kpi c-yellow">
<div class="kpi-value"><?= number_format($totalRentan) ?></div>
<div class="kpi-label">Warga Rentan</div>
<div class="kpi-sub"><?= $totalPenduduk ? round($totalRentan/$totalPenduduk*100,1) : 0 ?>% dari total</div>
</div>
<div class="kpi c-green">
<div class="kpi-value"><?= number_format($totalBantuan) ?></div>
<div class="kpi-label">Bantuan Tersalurkan</div>
<div class="kpi-sub">Rp <?= number_format($nilaiSalur,0,',','.') ?></div>
</div>
<div class="kpi c-orange">
<div class="kpi-value"><?= number_format($belumDibantu) ?></div>
<div class="kpi-label">Miskin Belum Dibantu</div>
<div class="kpi-sub"><?= $totalMiskin ? round($belumDibantu/$totalMiskin*100,1) : 0 ?>% dari miskin</div>
</div>
<div class="kpi c-purple">
<div class="kpi-value"><?= number_format($totalLaporan) ?></div>
<div class="kpi-label">Laporan Pending</div>
<div class="kpi-sub">Perlu ditindaklanjuti</div>
</div>
</div>
<!-- CHARTS ROW 1 -->
<div class="chart-grid">
<div class="chart-card">
<h3>Distribusi Status Ekonomi</h3>
<div class="chart-wrap"><canvas id="chartEkonomi"></canvas></div>
</div>
<div class="chart-card">
<h3>Tingkat Pendidikan Penduduk</h3>
<div class="chart-wrap"><canvas id="chartPendidikan"></canvas></div>
</div>
<div class="chart-card chart-full">
<h3>Trend Penyaluran Bantuan (12 Bulan Terakhir)</h3>
<div class="chart-wrap"><canvas id="chartBulan"></canvas></div>
</div>
<div class="chart-card">
<h3>Status Laporan Masyarakat</h3>
<div class="chart-wrap"><canvas id="chartLaporan"></canvas></div>
</div>
<div class="chart-card">
<h3>Penyaluran per Rumah Ibadah</h3>
<div class="chart-wrap"><canvas id="chartIbadah"></canvas></div>
</div>
</div>
<!-- TABLE SEBARAN PER KECAMATAN -->
<div class="tbl-card">
<h3>Sebaran Warga per Kecamatan</h3>
<table>
<thead>
<tr><th>Kecamatan</th><th>Total</th><th>Miskin</th><th>Rentan</th><th>Mampu</th><th>% Miskin</th><th>Proporsi</th></tr>
</thead>
<tbody>
<?php foreach($perKecamatan as $row):
$pct = $row['total'] > 0 ? round($row['miskin']/$row['total']*100,1) : 0;
$barW = $row['total'] > 0 ? round($row['miskin']/$row['total']*100) : 0;
?>
<tr>
<td><strong><?= htmlspecialchars($row['kecamatan']) ?></strong></td>
<td><?= number_format($row['total']) ?></td>
<td><span class="badge b-miskin"><?= $row['miskin'] ?></span></td>
<td><span class="badge b-rentan"><?= $row['rentan'] ?></span></td>
<td><span class="badge b-mampu"><?= $row['mampu'] ?></span></td>
<td style="color:<?= $pct>30?'var(--red)':($pct>15?'var(--yellow)':'var(--green)') ?>;font-weight:600"><?= $pct ?>%</td>
<td style="width:140px">
<div class="bar-container">
<div class="bar-fill" style="width:<?= $barW ?>%;background:var(--red)"></div>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<script>
Chart.defaults.color = '#6b7e96';
Chart.defaults.borderColor = '#243044';
Chart.defaults.font.family = 'Inter';
const palette = {
red: '#e85050', yellow: '#f0b429', green: '#27ae72',
blue: '#4a9eff', purple: '#9b59b6', orange: '#e67e22'
};
// Ekonomi donut
new Chart(document.getElementById('chartEkonomi'), {
type: 'doughnut',
data: {
labels: ['Miskin', 'Rentan', 'Mampu'],
datasets: [{
data: [<?= $totalMiskin ?>, <?= $totalRentan ?>, <?= $totalMampu ?>],
backgroundColor: [palette.red, palette.yellow, palette.green],
borderWidth: 0, hoverOffset: 8
}]
},
options: {
plugins: { legend: { position: 'bottom' } },
cutout: '65%',
maintainAspectRatio: false
}
});
// Pendidikan bar
const pddk = <?= json_encode(array_column($perPendidikan,'pendidikan_terakhir')) ?>;
const pddkVal = <?= json_encode(array_column($perPendidikan,'total')) ?>;
const pddkLabel = pddk.map(p => p.replace('_',' ').toUpperCase());
new Chart(document.getElementById('chartPendidikan'), {
type: 'bar',
data: {
labels: pddkLabel,
datasets: [{
label: 'Jumlah',
data: pddkVal,
backgroundColor: pddkLabel.map((_, i) => `hsl(${200+i*18},70%,55%)`),
borderRadius: 6, borderWidth: 0
}]
},
options: {
plugins: { legend: { display: false } },
scales: { x: { grid: { display: false } }, y: { grid: { color: '#243044' } } },
maintainAspectRatio: false
}
});
// Trend bantuan per bulan
const bulanLabels = <?= json_encode(array_column($perBulan,'bln')) ?>;
const bulanTotal = <?= json_encode(array_map('intval', array_column($perBulan,'total'))) ?>;
const bulanNilai = <?= json_encode(array_map('floatval', array_column($perBulan,'nilai'))) ?>;
new Chart(document.getElementById('chartBulan'), {
type: 'line',
data: {
labels: bulanLabels,
datasets: [
{
label: 'Jumlah Bantuan',
data: bulanTotal,
borderColor: palette.blue,
backgroundColor: 'rgba(74,158,255,0.08)',
fill: true, tension: 0.4, pointRadius: 5, yAxisID: 'y'
},
{
label: 'Nilai (Rp)',
data: bulanNilai,
borderColor: palette.green,
backgroundColor: 'rgba(39,174,114,0.06)',
fill: true, tension: 0.4, pointRadius: 5, yAxisID: 'y1'
}
]
},
options: {
plugins: { legend: { position: 'top' } },
scales: {
x: { grid: { color: '#243044' } },
y: { position: 'left', grid: { color: '#243044' }, title: { display: true, text: 'Jumlah', color: '#6b7e96' } },
y1: { position: 'right', grid: { drawOnChartArea: false }, title: { display: true, text: 'Nilai (Rp)', color: '#6b7e96' } }
},
maintainAspectRatio: false
}
});
// Status laporan
const lapStatus = <?= json_encode($laporanStatus) ?>;
const lapColors = { pending: palette.purple, diverifikasi: palette.blue, diproses: palette.yellow, selesai: palette.green, ditolak: palette.red };
new Chart(document.getElementById('chartLaporan'), {
type: 'pie',
data: {
labels: lapStatus.map(l => l.status),
datasets: [{
data: lapStatus.map(l => l.total),
backgroundColor: lapStatus.map(l => lapColors[l.status] || palette.blue),
borderWidth: 0, hoverOffset: 6
}]
},
options: { plugins: { legend: { position: 'bottom' } }, maintainAspectRatio: false }
});
// Penyaluran per rumah ibadah
const ibadahData = <?= json_encode($perIbadah) ?>;
new Chart(document.getElementById('chartIbadah'), {
type: 'bar',
data: {
labels: ibadahData.map(r => r.nama.length > 20 ? r.nama.substring(0,20)+'…' : r.nama),
datasets: [{
label: 'Bantuan Tersalurkan',
data: ibadahData.map(r => r.total),
backgroundColor: [palette.blue, palette.green, palette.purple, palette.orange, palette.yellow, palette.red],
borderRadius: 6, borderWidth: 0
}]
},
options: {
indexAxis: 'y',
plugins: { legend: { display: false } },
scales: { x: { grid: { color: '#243044' } }, y: { grid: { display: false } } },
maintainAspectRatio: false
}
});
</script>
</body>
</html>
@@ -0,0 +1,536 @@
-- ============================================================
-- WebGIS UAS — INSTALL / REINSTALL DATABASE
-- Jalankan file ini untuk setup database dari awal
-- Data yang sudah ada akan DIMIGRASI (tidak hilang)
-- ============================================================
CREATE DATABASE IF NOT EXISTS webgisuas CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE webgisuas;
SET FOREIGN_KEY_CHECKS = 0;
-- ============================================================
-- LANGKAH 1: DROP tabel lama yang mungkin corrupt
-- ============================================================
DROP TABLE IF EXISTS histori_bantuan;
DROP TABLE IF EXISTS jadwal_piket;
DROP TABLE IF EXISTS jenis_bantuan;
DROP TABLE IF EXISTS keluarga;
DROP TABLE IF EXISTS laporan;
DROP TABLE IF EXISTS log_aktivitas;
DROP TABLE IF EXISTS notifikasi;
DROP TABLE IF EXISTS penduduk;
DROP TABLE IF EXISTS penduduk_pelatihan;
DROP TABLE IF EXISTS peristiwa_kependudukan;
DROP TABLE IF EXISTS pesan;
DROP TABLE IF EXISTS program_bantuan;
DROP TABLE IF EXISTS rekomendasi_pelatihan;
DROP TABLE IF EXISTS riwayat_pendidikan;
DROP TABLE IF EXISTS roles;
DROP TABLE IF EXISTS rumah_ibadah;
DROP TABLE IF EXISTS users;
DROP VIEW IF EXISTS v_laporan_aktif;
DROP VIEW IF EXISTS v_penduduk;
DROP VIEW IF EXISTS v_pengurus_piket_hari_ini;
DROP VIEW IF EXISTS v_prioritas_bantuan;
DROP VIEW IF EXISTS v_rekap_bantuan;
DROP VIEW IF EXISTS v_validasi_pendidikan;
-- ============================================================
-- LANGKAH 2: BUAT ULANG SEMUA TABEL
-- ============================================================
CREATE TABLE roles (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(50) NOT NULL,
slug VARCHAR(50) UNIQUE NOT NULL,
deskripsi TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE rumah_ibadah (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(150) NOT NULL,
jenis ENUM('Masjid','Musholla','Gereja Protestan','Gereja Katolik','Pura','Vihara','Klenteng','Lainnya') NOT NULL,
kontak VARCHAR(50), alamat TEXT, kelurahan VARCHAR(100), kecamatan VARCHAR(100),
lat DECIMAL(11,8) NOT NULL, lng DECIMAL(11,8) NOT NULL,
radius INT DEFAULT 500, foto VARCHAR(255), aktif TINYINT(1) DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(100) NOT NULL, username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(100), password VARCHAR(255) NOT NULL,
role_id INT NOT NULL DEFAULT 4, rumah_ibadah_id INT NULL,
avatar VARCHAR(255), aktif TINYINT(1) DEFAULT 1,
last_login TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (role_id) REFERENCES roles(id),
FOREIGN KEY (rumah_ibadah_id) REFERENCES rumah_ibadah(id) ON DELETE SET NULL
);
CREATE TABLE keluarga (
id INT AUTO_INCREMENT PRIMARY KEY,
no_kk VARCHAR(20) UNIQUE NOT NULL, alamat TEXT NOT NULL,
rt VARCHAR(5), rw VARCHAR(5), kelurahan VARCHAR(100), kecamatan VARCHAR(100),
kota VARCHAR(100) DEFAULT 'Pontianak', lat DECIMAL(11,8), lng DECIMAL(11,8),
rumah_ibadah_id INT NULL,
status_ekonomi ENUM('miskin','rentan','mampu') DEFAULT 'rentan',
status_verifikasi ENUM('belum','terverifikasi','ditolak') DEFAULT 'belum',
verified_by INT NULL, verified_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (rumah_ibadah_id) REFERENCES rumah_ibadah(id) ON DELETE SET NULL,
FOREIGN KEY (verified_by) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE penduduk (
id INT AUTO_INCREMENT PRIMARY KEY,
nik VARCHAR(16) UNIQUE NOT NULL, nama VARCHAR(150) NOT NULL,
jenis_kelamin ENUM('L','P') NOT NULL, tanggal_lahir DATE NOT NULL,
tempat_lahir VARCHAR(100),
status_keluarga ENUM('kepala_keluarga','istri','anak','orang_tua','famili_lain','lainnya') DEFAULT 'anak',
status_kawin ENUM('belum_kawin','kawin','cerai_hidup','cerai_mati') DEFAULT 'belum_kawin',
status_hidup ENUM('hidup','meninggal') DEFAULT 'hidup',
tanggal_meninggal DATE NULL, keluarga_id INT NULL,
pendidikan_terakhir ENUM('tidak_sekolah','sd','smp','sma','smk','d1','d2','d3','s1','s2','s3') DEFAULT 'tidak_sekolah',
status_pendidikan ENUM('tidak_sekolah','masih_sekolah','lulus','putus_sekolah') DEFAULT 'tidak_sekolah',
pekerjaan VARCHAR(100), penghasilan DECIMAL(12,2) DEFAULT 0,
status_ekonomi ENUM('miskin','rentan','mampu') DEFAULT 'rentan',
kondisi_kesehatan ENUM('sehat','sakit_ringan','sakit_berat','disabilitas') DEFAULT 'sehat',
catatan_kesehatan TEXT, domisili_sama_kk TINYINT(1) DEFAULT 1,
alamat_domisili TEXT, lat DECIMAL(11,8), lng DECIMAL(11,8),
foto VARCHAR(255),
no_hp VARCHAR(20) NULL,
kebutuhan_pelatihan TEXT NULL,
prioritas_bantuan ENUM('darurat','tinggi','sedang','rendah') NULL,
created_by INT NULL, updated_by INT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (keluarga_id) REFERENCES keluarga(id) ON DELETE SET NULL,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE riwayat_pendidikan (
id INT AUTO_INCREMENT PRIMARY KEY, penduduk_id INT NOT NULL,
jenis ENUM('formal','pelatihan','kursus') DEFAULT 'formal',
nama_institusi VARCHAR(150), jenjang VARCHAR(50), jurusan VARCHAR(100),
tahun_masuk YEAR, tahun_lulus YEAR NULL,
status ENUM('lulus','putus','sedang_berjalan') DEFAULT 'lulus',
sertifikat VARCHAR(255), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (penduduk_id) REFERENCES penduduk(id) ON DELETE CASCADE
);
CREATE TABLE jenis_bantuan (
id INT AUTO_INCREMENT PRIMARY KEY,
kode VARCHAR(20) UNIQUE NOT NULL, nama VARCHAR(100) NOT NULL,
kategori ENUM('sembako','pendidikan','kesehatan','ekonomi','infrastruktur','lainnya') NOT NULL,
satuan VARCHAR(50) DEFAULT 'paket', deskripsi TEXT,
sumber_dana VARCHAR(100), aktif TINYINT(1) DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE program_bantuan (
id INT AUTO_INCREMENT PRIMARY KEY, nama_program VARCHAR(150) NOT NULL,
jenis_bantuan_id INT NOT NULL, anggaran DECIMAL(15,2) DEFAULT 0,
target_penerima INT DEFAULT 0, tanggal_mulai DATE, tanggal_selesai DATE,
status ENUM('perencanaan','berjalan','selesai','dibatalkan') DEFAULT 'perencanaan',
penanggungjawab INT NULL, deskripsi TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (jenis_bantuan_id) REFERENCES jenis_bantuan(id),
FOREIGN KEY (penanggungjawab) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE histori_bantuan (
id INT AUTO_INCREMENT PRIMARY KEY,
penduduk_id INT NOT NULL, keluarga_id INT NULL,
program_bantuan_id INT NULL, jenis_bantuan_id INT NOT NULL,
jumlah DECIMAL(10,2) DEFAULT 1, satuan VARCHAR(50),
nilai_rupiah DECIMAL(12,2) DEFAULT 0, tanggal_penyaluran DATE NOT NULL,
status_penyaluran ENUM('dijadwalkan','disalurkan','gagal','dikembalikan') DEFAULT 'dijadwalkan',
disalurkan_oleh INT NULL, rumah_ibadah_id INT NULL,
bukti_foto VARCHAR(255), catatan TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (penduduk_id) REFERENCES penduduk(id) ON DELETE CASCADE,
FOREIGN KEY (keluarga_id) REFERENCES keluarga(id) ON DELETE SET NULL,
FOREIGN KEY (program_bantuan_id) REFERENCES program_bantuan(id) ON DELETE SET NULL,
FOREIGN KEY (jenis_bantuan_id) REFERENCES jenis_bantuan(id),
FOREIGN KEY (disalurkan_oleh) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (rumah_ibadah_id) REFERENCES rumah_ibadah(id) ON DELETE SET NULL
);
CREATE TABLE laporan (
id INT AUTO_INCREMENT PRIMARY KEY,
kode_laporan VARCHAR(20) UNIQUE, nama_pelapor VARCHAR(100),
no_hp_pelapor VARCHAR(20), penduduk_id INT NULL,
nama_terlapor VARCHAR(150), alamat_kejadian TEXT,
lat DECIMAL(11,8), lng DECIMAL(11,8), deskripsi TEXT NOT NULL,
kategori ENUM('kemiskinan','kesehatan','bencana','kekerasan','pendidikan','lainnya') DEFAULT 'kemiskinan',
urgensi ENUM('rendah','sedang','tinggi','darurat') DEFAULT 'sedang',
foto VARCHAR(255),
status ENUM('pending','diverifikasi','diproses','selesai','ditolak') DEFAULT 'pending',
diverifikasi_oleh INT NULL, diverifikasi_at TIMESTAMP NULL,
catatan_verifikasi TEXT, diproses_oleh INT NULL, diproses_at TIMESTAMP NULL,
diselesaikan_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (penduduk_id) REFERENCES penduduk(id) ON DELETE SET NULL,
FOREIGN KEY (diverifikasi_oleh) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (diproses_oleh) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE pesan (
id INT AUTO_INCREMENT PRIMARY KEY,
pengirim_id INT NOT NULL, penerima_id INT NULL,
rumah_ibadah_id INT NULL, isi TEXT NOT NULL,
tipe ENUM('teks','gambar','file') DEFAULT 'teks',
lampiran VARCHAR(255), dibaca TINYINT(1) DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (pengirim_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (penerima_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (rumah_ibadah_id) REFERENCES rumah_ibadah(id) ON DELETE CASCADE
);
CREATE TABLE log_aktivitas (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NULL, aksi VARCHAR(100) NOT NULL,
tabel_target VARCHAR(50), id_target INT NULL,
data_lama JSON NULL, data_baru JSON NULL,
ip_address VARCHAR(45), user_agent TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE notifikasi (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL, judul VARCHAR(150) NOT NULL,
isi TEXT, tipe ENUM('info','warning','success','danger') DEFAULT 'info',
url_target VARCHAR(255), dibaca TINYINT(1) DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE peristiwa_kependudukan (
id INT AUTO_INCREMENT PRIMARY KEY,
penduduk_id INT NOT NULL, keluarga_id INT NULL,
jenis ENUM('lahir','meninggal','menikah','cerai','pindah_masuk','pindah_keluar','ganti_nama','perubahan_status_ekonomi') NOT NULL,
tanggal DATE NOT NULL, keterangan TEXT,
alamat_asal TEXT NULL, alamat_tujuan TEXT NULL,
sebab_meninggal VARCHAR(150) NULL, dokumen VARCHAR(255) NULL,
dicatat_oleh INT NULL, diverifikasi TINYINT(1) DEFAULT 0,
diverifikasi_oleh INT NULL, diverifikasi_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (penduduk_id) REFERENCES penduduk(id) ON DELETE CASCADE,
FOREIGN KEY (keluarga_id) REFERENCES keluarga(id) ON DELETE SET NULL,
FOREIGN KEY (dicatat_oleh) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (diverifikasi_oleh) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE rekomendasi_pelatihan (
id INT AUTO_INCREMENT PRIMARY KEY, nama VARCHAR(100) NOT NULL,
kategori ENUM('vokasi','kewirausahaan','digital','pertanian','kesehatan','pendidikan','lainnya') NOT NULL,
deskripsi TEXT, penyelenggara VARCHAR(150), kontak_penyelenggara VARCHAR(100),
usia_min INT DEFAULT 15, usia_maks INT DEFAULT 60,
gender ENUM('L','P','semua') DEFAULT 'semua',
status_ekonomi_sasaran ENUM('miskin','rentan','semua') DEFAULT 'semua',
prasyarat TEXT, durasi VARCHAR(50), biaya DECIMAL(10,2) DEFAULT 0,
aktif TINYINT(1) DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE TABLE penduduk_pelatihan (
id INT AUTO_INCREMENT PRIMARY KEY,
penduduk_id INT NOT NULL, rekomendasi_pelatihan_id INT NOT NULL,
status ENUM('direkomendasikan','mendaftar','sedang_berjalan','selesai','batal') DEFAULT 'direkomendasikan',
tanggal_rekomendasi DATE NOT NULL, tanggal_mulai DATE NULL, tanggal_selesai DATE NULL,
sertifikat VARCHAR(255) NULL, catatan TEXT, direkomendasikan_oleh INT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (penduduk_id) REFERENCES penduduk(id) ON DELETE CASCADE,
FOREIGN KEY (rekomendasi_pelatihan_id) REFERENCES rekomendasi_pelatihan(id) ON DELETE CASCADE,
FOREIGN KEY (direkomendasikan_oleh) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE jadwal_piket (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL, rumah_ibadah_id INT NOT NULL,
tanggal DATE NOT NULL, shift ENUM('pagi','siang','sore','malam','full_day') DEFAULT 'full_day',
jam_mulai TIME DEFAULT '08:00:00', jam_selesai TIME DEFAULT '17:00:00',
keterangan TEXT,
status ENUM('terjadwal','hadir','tidak_hadir','digantikan') DEFAULT 'terjadwal',
digantikan_oleh INT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (rumah_ibadah_id) REFERENCES rumah_ibadah(id) ON DELETE CASCADE,
FOREIGN KEY (digantikan_oleh) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY unique_piket (user_id, tanggal, shift)
);
-- ============================================================
-- LANGKAH 3: VIEWS
-- ============================================================
CREATE OR REPLACE VIEW v_penduduk AS
SELECT p.*, TIMESTAMPDIFF(YEAR, p.tanggal_lahir, CURDATE()) AS umur,
k.no_kk, k.alamat AS alamat_kk, k.rt, k.rw, k.kelurahan, k.kecamatan,
k.lat AS lat_kk, k.lng AS lng_kk, k.status_ekonomi AS status_ekonomi_kk,
ri.nama AS nama_ibadah, ri.jenis AS jenis_ibadah,
(SELECT COUNT(*) FROM histori_bantuan hb WHERE hb.penduduk_id=p.id AND hb.status_penyaluran='disalurkan') AS total_bantuan_diterima,
(SELECT MAX(hb2.tanggal_penyaluran) FROM histori_bantuan hb2 WHERE hb2.penduduk_id=p.id) AS terakhir_dibantu
FROM penduduk p
LEFT JOIN keluarga k ON p.keluarga_id=k.id
LEFT JOIN rumah_ibadah ri ON k.rumah_ibadah_id=ri.id;
CREATE OR REPLACE VIEW v_prioritas_bantuan AS
SELECT p.id, p.nik, p.nama, TIMESTAMPDIFF(YEAR,p.tanggal_lahir,CURDATE()) AS umur,
p.status_ekonomi, p.kondisi_kesehatan, k.alamat, k.kelurahan, k.kecamatan, k.lat, k.lng,
ri.nama AS nama_ibadah,
(SELECT COUNT(*) FROM histori_bantuan hb WHERE hb.penduduk_id=p.id AND hb.status_penyaluran='disalurkan') AS total_bantuan,
CASE
WHEN p.status_ekonomi='miskin' AND p.kondisi_kesehatan IN ('sakit_berat','disabilitas') THEN 'DARURAT'
WHEN p.status_ekonomi='miskin' THEN 'TINGGI'
WHEN p.status_ekonomi='rentan' AND p.kondisi_kesehatan IN ('sakit_berat','disabilitas') THEN 'TINGGI'
WHEN p.status_ekonomi='rentan' THEN 'SEDANG'
ELSE 'RENDAH'
END AS prioritas
FROM penduduk p
LEFT JOIN keluarga k ON p.keluarga_id=k.id
LEFT JOIN rumah_ibadah ri ON k.rumah_ibadah_id=ri.id
WHERE p.status_hidup='hidup'
ORDER BY FIELD(p.status_ekonomi,'miskin','rentan','mampu'), FIELD(p.kondisi_kesehatan,'sakit_berat','disabilitas','sakit_ringan','sehat');
CREATE OR REPLACE VIEW v_laporan_aktif AS
SELECT l.*, p.nama AS nama_penduduk_terdaftar, p.kondisi_kesehatan,
TIMESTAMPDIFF(YEAR,p.tanggal_lahir,CURDATE()) AS umur_penduduk,
k.alamat AS alamat_kk, ri.nama AS nama_ibadah_terdekat,
TIMESTAMPDIFF(HOUR,l.created_at,NOW()) AS jam_sejak_laporan
FROM laporan l
LEFT JOIN penduduk p ON l.penduduk_id=p.id
LEFT JOIN keluarga k ON p.keluarga_id=k.id
LEFT JOIN rumah_ibadah ri ON k.rumah_ibadah_id=ri.id
WHERE l.status NOT IN ('selesai','ditolak')
ORDER BY FIELD(l.urgensi,'darurat','tinggi','sedang','rendah'), l.created_at ASC;
CREATE OR REPLACE VIEW v_rekap_bantuan AS
SELECT p.id AS penduduk_id, p.nik, p.nama,
TIMESTAMPDIFF(YEAR,p.tanggal_lahir,CURDATE()) AS umur,
p.status_ekonomi, p.prioritas_bantuan, p.kondisi_kesehatan,
k.no_kk, k.alamat, k.kelurahan, k.kecamatan, ri.nama AS nama_ibadah,
COUNT(hb.id) AS total_transaksi_bantuan,
SUM(CASE WHEN hb.status_penyaluran='disalurkan' THEN 1 ELSE 0 END) AS total_tersalurkan,
SUM(CASE WHEN hb.status_penyaluran='disalurkan' THEN hb.nilai_rupiah ELSE 0 END) AS total_nilai_rupiah,
MAX(hb.tanggal_penyaluran) AS terakhir_dibantu,
MAX(CASE WHEN hb.status_penyaluran='disalurkan' AND YEAR(hb.tanggal_penyaluran)=YEAR(CURDATE()) AND MONTH(hb.tanggal_penyaluran)=MONTH(CURDATE()) THEN 1 ELSE 0 END) AS sudah_dibantu_bulan_ini,
GROUP_CONCAT(DISTINCT CASE WHEN hb.status_penyaluran='disalurkan' AND YEAR(hb.tanggal_penyaluran)=YEAR(CURDATE()) AND MONTH(hb.tanggal_penyaluran)=MONTH(CURDATE()) THEN jb.nama ELSE NULL END ORDER BY jb.nama SEPARATOR ', ') AS bantuan_bulan_ini
FROM penduduk p
LEFT JOIN keluarga k ON p.keluarga_id=k.id
LEFT JOIN rumah_ibadah ri ON k.rumah_ibadah_id=ri.id
LEFT JOIN histori_bantuan hb ON hb.penduduk_id=p.id
LEFT JOIN jenis_bantuan jb ON hb.jenis_bantuan_id=jb.id
WHERE p.status_hidup='hidup'
GROUP BY p.id,p.nik,p.nama,p.tanggal_lahir,p.status_ekonomi,p.prioritas_bantuan,p.kondisi_kesehatan,k.no_kk,k.alamat,k.kelurahan,k.kecamatan,ri.nama
ORDER BY FIELD(p.prioritas_bantuan,'darurat','tinggi','sedang','rendah'), terakhir_dibantu ASC;
CREATE OR REPLACE VIEW v_validasi_pendidikan AS
SELECT p.id, p.nik, p.nama, TIMESTAMPDIFF(YEAR,p.tanggal_lahir,CURDATE()) AS umur,
p.pendidikan_terakhir, p.status_pendidikan,
CASE
WHEN TIMESTAMPDIFF(YEAR,p.tanggal_lahir,CURDATE()) < 6 AND p.status_pendidikan='masih_sekolah' THEN 'Terlalu muda untuk sekolah (< 6 tahun)'
WHEN TIMESTAMPDIFF(YEAR,p.tanggal_lahir,CURDATE()) BETWEEN 6 AND 12 AND p.pendidikan_terakhir NOT IN ('tidak_sekolah','sd') THEN 'Umur 6-12 tahun seharusnya max SD'
WHEN TIMESTAMPDIFF(YEAR,p.tanggal_lahir,CURDATE()) BETWEEN 13 AND 15 AND p.pendidikan_terakhir IN ('sma','smk','d1','d2','d3','s1','s2','s3') THEN 'Umur 13-15 tahun tidak mungkin SMA/kuliah'
WHEN TIMESTAMPDIFF(YEAR,p.tanggal_lahir,CURDATE()) < 18 AND p.pendidikan_terakhir IN ('d1','d2','d3','s1','s2','s3') THEN 'Di bawah 18 tahun tidak mungkin kuliah'
ELSE NULL
END AS peringatan_inkonsistensi,
CASE
WHEN TIMESTAMPDIFF(YEAR,p.tanggal_lahir,CURDATE()) BETWEEN 18 AND 55 AND p.status_pendidikan IN ('tidak_sekolah','putus_sekolah') AND p.status_ekonomi IN ('miskin','rentan') THEN 'Rekomendasikan pelatihan vokasi atau kejar paket'
WHEN TIMESTAMPDIFF(YEAR,p.tanggal_lahir,CURDATE()) < 18 AND p.status_pendidikan IN ('tidak_sekolah','putus_sekolah') AND p.status_ekonomi IN ('miskin','rentan') THEN 'Rekomendasikan beasiswa / program kembali sekolah'
ELSE NULL
END AS saran_tindak_lanjut
FROM penduduk p WHERE p.status_hidup='hidup'
ORDER BY peringatan_inkonsistensi IS NULL, p.id;
CREATE OR REPLACE VIEW v_pengurus_piket_hari_ini AS
SELECT jp.id AS jadwal_id, u.id AS user_id, u.nama AS nama_pengurus, u.email,
ri.id AS rumah_ibadah_id, ri.nama AS nama_ibadah, ri.jenis AS jenis_ibadah,
jp.shift, jp.jam_mulai, jp.jam_selesai, jp.status AS status_piket
FROM jadwal_piket jp
JOIN users u ON jp.user_id=u.id
JOIN rumah_ibadah ri ON jp.rumah_ibadah_id=ri.id
WHERE jp.tanggal=CURDATE() AND jp.status IN ('terjadwal','hadir') AND u.aktif=1;
-- ============================================================
-- LANGKAH 4: TRIGGERS
-- ============================================================
DELIMITER $$
CREATE TRIGGER trg_penduduk_insert BEFORE INSERT ON penduduk FOR EACH ROW BEGIN
SET NEW.prioritas_bantuan = CASE WHEN NEW.status_ekonomi='miskin' AND NEW.kondisi_kesehatan IN ('sakit_berat','disabilitas') THEN 'darurat' WHEN NEW.status_ekonomi='miskin' THEN 'tinggi' WHEN NEW.status_ekonomi='rentan' AND NEW.kondisi_kesehatan IN ('sakit_berat','disabilitas') THEN 'tinggi' WHEN NEW.status_ekonomi='rentan' THEN 'sedang' ELSE 'rendah' END;
SET @umur = TIMESTAMPDIFF(YEAR, NEW.tanggal_lahir, CURDATE());
SET NEW.kebutuhan_pelatihan = CASE WHEN @umur < 18 AND NEW.status_ekonomi IN ('miskin','rentan') THEN JSON_ARRAY('Beasiswa pendidikan','Bantuan seragam/buku') WHEN @umur BETWEEN 18 AND 35 AND NEW.status_ekonomi='miskin' THEN JSON_ARRAY('Pelatihan vokasi (jahit/las/bengkel)','Modal usaha UMKM','Literasi digital') WHEN @umur BETWEEN 18 AND 35 AND NEW.status_ekonomi='rentan' THEN JSON_ARRAY('Modal usaha UMKM','Pelatihan kewirausahaan') WHEN @umur BETWEEN 36 AND 55 AND NEW.status_ekonomi='miskin' AND NEW.kondisi_kesehatan NOT IN ('sakit_berat','disabilitas') THEN JSON_ARRAY('Pelatihan pertanian/budidaya','Modal usaha UMKM') WHEN NEW.kondisi_kesehatan IN ('sakit_berat','disabilitas') THEN JSON_ARRAY('Bantuan kesehatan gratis','Alat bantu disabilitas','Pendampingan sosial') WHEN @umur > 55 AND NEW.status_ekonomi IN ('miskin','rentan') THEN JSON_ARRAY('Bantuan sembako rutin','Pendampingan lansia','Bantuan kesehatan') ELSE NULL END;
END$$
CREATE TRIGGER trg_penduduk_update BEFORE UPDATE ON penduduk FOR EACH ROW BEGIN
SET NEW.prioritas_bantuan = CASE WHEN NEW.status_ekonomi='miskin' AND NEW.kondisi_kesehatan IN ('sakit_berat','disabilitas') THEN 'darurat' WHEN NEW.status_ekonomi='miskin' THEN 'tinggi' WHEN NEW.status_ekonomi='rentan' AND NEW.kondisi_kesehatan IN ('sakit_berat','disabilitas') THEN 'tinggi' WHEN NEW.status_ekonomi='rentan' THEN 'sedang' ELSE 'rendah' END;
SET @umur = TIMESTAMPDIFF(YEAR, NEW.tanggal_lahir, CURDATE());
SET NEW.kebutuhan_pelatihan = CASE WHEN @umur < 18 AND NEW.status_ekonomi IN ('miskin','rentan') THEN JSON_ARRAY('Beasiswa pendidikan','Bantuan seragam/buku') WHEN @umur BETWEEN 18 AND 35 AND NEW.status_ekonomi='miskin' THEN JSON_ARRAY('Pelatihan vokasi (jahit/las/bengkel)','Modal usaha UMKM','Literasi digital') WHEN @umur BETWEEN 18 AND 35 AND NEW.status_ekonomi='rentan' THEN JSON_ARRAY('Modal usaha UMKM','Pelatihan kewirausahaan') WHEN @umur BETWEEN 36 AND 55 AND NEW.status_ekonomi='miskin' AND NEW.kondisi_kesehatan NOT IN ('sakit_berat','disabilitas') THEN JSON_ARRAY('Pelatihan pertanian/budidaya','Modal usaha UMKM') WHEN NEW.kondisi_kesehatan IN ('sakit_berat','disabilitas') THEN JSON_ARRAY('Bantuan kesehatan gratis','Alat bantu disabilitas','Pendampingan sosial') WHEN @umur > 55 AND NEW.status_ekonomi IN ('miskin','rentan') THEN JSON_ARRAY('Bantuan sembako rutin','Pendampingan lansia','Bantuan kesehatan') ELSE NULL END;
END$$
CREATE TRIGGER trg_penduduk_meninggal AFTER UPDATE ON penduduk FOR EACH ROW BEGIN
IF OLD.status_hidup='hidup' AND NEW.status_hidup='meninggal' THEN
INSERT INTO peristiwa_kependudukan (penduduk_id,keluarga_id,jenis,tanggal,keterangan,dicatat_oleh) VALUES (NEW.id,NEW.keluarga_id,'meninggal',IFNULL(NEW.tanggal_meninggal,CURDATE()),'Dicatat otomatis dari update status_hidup',NEW.updated_by);
END IF;
END$$
CREATE TRIGGER trg_penduduk_kawin AFTER UPDATE ON penduduk FOR EACH ROW BEGIN
IF OLD.status_kawin != 'kawin' AND NEW.status_kawin = 'kawin' THEN
INSERT INTO peristiwa_kependudukan (penduduk_id,keluarga_id,jenis,tanggal,keterangan,dicatat_oleh) VALUES (NEW.id,NEW.keluarga_id,'menikah',CURDATE(),'Dicatat otomatis dari update status_kawin',NEW.updated_by);
END IF;
IF OLD.status_kawin='kawin' AND NEW.status_kawin IN ('cerai_hidup','cerai_mati') THEN
INSERT INTO peristiwa_kependudukan (penduduk_id,keluarga_id,jenis,tanggal,keterangan,dicatat_oleh) VALUES (NEW.id,NEW.keluarga_id,'cerai',CURDATE(),CONCAT('Status kawin berubah ke: ',NEW.status_kawin),NEW.updated_by);
END IF;
END$$
CREATE TRIGGER trg_laporan_notifikasi AFTER INSERT ON laporan FOR EACH ROW BEGIN
DECLARE done INT DEFAULT FALSE; DECLARE v_user_id INT;
DECLARE cur CURSOR FOR SELECT u.id FROM users u WHERE u.role_id=2 AND u.aktif=1;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done=TRUE;
IF NEW.urgensi IN ('darurat','tinggi') THEN
OPEN cur; read_loop: LOOP FETCH cur INTO v_user_id; IF done THEN LEAVE read_loop; END IF;
INSERT INTO notifikasi (user_id,judul,isi,tipe,url_target) VALUES (v_user_id,CONCAT('[',UPPER(NEW.urgensi),'] Laporan baru masuk'),CONCAT('Kode: ',NEW.kode_laporan,' | Kategori: ',NEW.kategori,' | Alamat: ',IFNULL(NEW.alamat_kejadian,'-')),CASE WHEN NEW.urgensi='darurat' THEN 'danger' ELSE 'warning' END,CONCAT('/laporan.php?id=',NEW.id));
END LOOP; CLOSE cur;
END IF;
END$$
DELIMITER ;
-- ============================================================
-- LANGKAH 5: DATA AWAL (roles + users + data dummy)
-- ============================================================
INSERT IGNORE INTO roles (nama, slug, deskripsi) VALUES
('Administrator','admin','Akses penuh ke seluruh sistem'),
('Pengurus Rumah Ibadah','pengurus','Input data, verifikasi laporan, kelola bantuan'),
('Pimpinan Daerah','pimpinan','Monitoring & laporan (read-only)'),
('Masyarakat','masyarakat','Lihat data terbatas & buat laporan');
INSERT IGNORE INTO rumah_ibadah (nama,jenis,kontak,alamat,kelurahan,kecamatan,lat,lng,radius) VALUES
('Masjid Raya Mujahiddin','Masjid','0561-748551','Jl. Ahmad Yani, Pontianak Selatan','Akcaya','Pontianak Selatan',-0.026300,109.342500,600),
('Masjid Jami Pontianak','Masjid','0561-734567','Jl. Tanjungpura No. 1','Dalam Bugis','Pontianak Timur',-0.018500,109.331500,500),
('Gereja Katedral St. Yosef','Gereja Katolik','0561-712233','Jl. Rahadi Usman No. 3','Tengah','Pontianak Kota',-0.021400,109.329800,400),
('Vihara Tri Dharma Bumi Raya','Vihara','0561-745566','Jl. Diponegoro No. 45','Sungai Bangkong','Pontianak Kota',-0.030200,109.335800,350),
('Masjid Al-Falah','Masjid','0561-891234','Jl. Prof. M. Yamin No. 7','Sungai Jawi','Pontianak Barat',-0.011200,109.320500,450),
('Gereja Bethel Indonesia','Gereja Protestan','0561-723456','Jl. Imam Bonjol No. 15','Benua Melayu Darat','Pontianak Selatan',-0.033100,109.337200,380);
-- Password semua akun: password123
INSERT IGNORE INTO users (nama,username,email,password,role_id,rumah_ibadah_id) VALUES
('Administrator Sistem','admin','admin@webgis.id','$2y$10$TKh8H1.PpuAuGAQ1qwLSJ.Q7hV5jB87dJ3y/N6OvHT9Lq.LdLFOiO',1,NULL),
('H. Ruslan Pengurus','pengurus1','ruslan@webgis.id','$2y$10$TKh8H1.PpuAuGAQ1qwLSJ.Q7hV5jB87dJ3y/N6OvHT9Lq.LdLFOiO',2,1),
('Pdt. Stefanus','pengurus2','stefanus@webgis.id','$2y$10$TKh8H1.PpuAuGAQ1qwLSJ.Q7hV5jB87dJ3y/N6OvHT9Lq.LdLFOiO',2,3),
('Bapak Walikota','pimpinan','walikota@pontianak.go.id','$2y$10$TKh8H1.PpuAuGAQ1qwLSJ.Q7hV5jB87dJ3y/N6OvHT9Lq.LdLFOiO',3,NULL),
('Warga Biasa','warga','warga@gmail.com','$2y$10$TKh8H1.PpuAuGAQ1qwLSJ.Q7hV5jB87dJ3y/N6OvHT9Lq.LdLFOiO',4,NULL);
INSERT IGNORE INTO jenis_bantuan (kode,nama,kategori,satuan,deskripsi,sumber_dana) VALUES
('SMKO-001','Paket Sembako Bulanan','sembako','paket','Beras 5kg, minyak, gula, telur','APBD Kota'),
('PKSH-001','Bantuan Kesehatan Gratis','kesehatan','kartu','Kartu berobat gratis Puskesmas','APBN (JKN)'),
('PDDK-001','Beasiswa Pendidikan SD-SMA','pendidikan','tahun','Bantuan biaya pendidikan Rp 300ribu/bulan','APBD Provinsi'),
('EKON-001','Modal Usaha UMKM','ekonomi','paket','Modal awal usaha kecil Rp 1-5 juta','Dana CSR'),
('SMKO-002','Paket Lebaran','sembako','paket','Paket sembako + busana hari raya','Donasi Masyarakat');
INSERT IGNORE INTO rekomendasi_pelatihan (nama,kategori,deskripsi,penyelenggara,kontak_penyelenggara,usia_min,usia_maks,gender,status_ekonomi_sasaran,prasyarat,durasi,biaya) VALUES
('Pelatihan Ojek Online','vokasi','Cara daftar & operasikan aplikasi ojek online','Balai Latihan Kerja Pontianak','0561-891234',18,50,'L','semua','Punya motor & SIM C','3 hari',0),
('Menjahit & Konveksi','vokasi','Menjahit pakaian dasar hingga konveksi sederhana','Dinas Koperasi & UKM Kota Pontianak','0561-737777',17,55,'P','miskin',NULL,'2 minggu',0),
('Modal Usaha Warung/UMKM','kewirausahaan','Bantuan modal Rp1-5 juta + pelatihan manajemen keuangan','Baznas Kota Pontianak','0561-745678',20,60,'semua','miskin',NULL,'1 hari orientasi',0),
('Literasi Digital & E-commerce','digital','Jualan online lewat Shopee/Tokopedia','Relawan TIK Kalimantan Barat','081234500000',17,40,'semua','semua','Punya smartphone','2 hari',0),
('Kejar Paket B/C (setara SMP/SMA)','pendidikan','Pendidikan kesetaraan bagi yang tidak sempat selesaikan SMP/SMA','PKBM Harapan Bangsa Pontianak','081211223344',18,45,'semua','semua',NULL,'6 bulan',0);
SET FOREIGN_KEY_CHECKS = 1;
-- ============================================================
-- SELESAI! Login dengan: admin / password123
-- ============================================================
-- ============================================================
-- DATA DUMMY LENGKAP — Keluarga, Penduduk, Bantuan, Laporan
-- ============================================================
USE webgisuas;
INSERT IGNORE INTO keluarga (no_kk,alamat,rt,rw,kelurahan,kecamatan,lat,lng,rumah_ibadah_id,status_ekonomi,status_verifikasi,verified_by) VALUES
('6171012501800001','Jl. Sei Raya Dalam No. 5','002','003','Akcaya','Pontianak Selatan',-0.027100,109.344000,1,'miskin','terverifikasi',2),
('6171012501850002','Jl. Gusti Hamzah No. 12','001','001','Dalam Bugis','Pontianak Timur',-0.019500,109.332000,2,'miskin','terverifikasi',2),
('6171012501900003','Jl. Purnama No. 8','003','002','Sungai Bangkong','Pontianak Kota',-0.028000,109.337000,1,'rentan','terverifikasi',2),
('6171012501750004','Jl. Putri Candramidi No. 3','001','001','Tengah','Pontianak Kota',-0.022000,109.330500,3,'miskin','terverifikasi',3),
('6171012501820005','Jl. Gajah Mada No. 22','004','002','Sungai Bangkong','Pontianak Kota',-0.031000,109.336500,4,'rentan','terverifikasi',2),
('6171012501960006','Jl. Tanjung Pura Gang IV','002','001','Sungai Jawi','Pontianak Barat',-0.012000,109.321000,5,'mampu','terverifikasi',2),
('6171012501700007','Jl. Imam Bonjol Gg. Kenari No. 4','003','003','Benua Melayu Darat','Pontianak Selatan',-0.034000,109.338000,6,'miskin','belum',NULL);
INSERT IGNORE INTO penduduk (nik,nama,jenis_kelamin,tanggal_lahir,tempat_lahir,status_keluarga,status_kawin,keluarga_id,pendidikan_terakhir,status_pendidikan,pekerjaan,penghasilan,status_ekonomi,kondisi_kesehatan,created_by) VALUES
('6171010101800001','Budi Santoso','L','1980-03-15','Pontianak','kepala_keluarga','kawin',1,'sd','lulus','Buruh Harian',800000,'miskin','sehat',2),
('6171010101850002','Dewi Santoso','P','1985-07-22','Sambas','istri','kawin',1,'sd','lulus','Ibu Rumah Tangga',0,'miskin','sehat',2),
('6171010101100003','Rian Santoso','L','2010-01-10','Pontianak','anak','belum_kawin',1,'sd','masih_sekolah','Pelajar',0,'miskin','sehat',2),
('6171010101850004','Siti Rahayu','P','1985-11-30','Mempawah','kepala_keluarga','cerai_mati',2,'smp','lulus','Pedagang Kecil',600000,'miskin','sakit_ringan',2),
('6171010101120005','Anis Rahayu','P','2012-06-18','Pontianak','anak','belum_kawin',2,'sd','masih_sekolah','Pelajar',0,'miskin','sehat',2),
('6171010101900006','Ahmad Fauzi','L','1990-09-05','Ketapang','kepala_keluarga','kawin',3,'sma','lulus','Ojek Online',1200000,'rentan','sehat',2),
('6171010101920007','Nurhasanah','P','1992-04-12','Pontianak','istri','kawin',3,'smp','lulus','Ibu Rumah Tangga',0,'rentan','sehat',2),
('6171010101750008','Maria Yuliani','P','1975-08-20','Sintang','kepala_keluarga','cerai_hidup',4,'sd','lulus','Pembantu Rumah Tangga',700000,'miskin','sakit_berat',3),
('6171010101960009','Kevin Yuliani','L','2005-12-03','Pontianak','anak','belum_kawin',4,'smp','masih_sekolah','Pelajar',0,'miskin','sehat',3),
('6171010101820010','Hendra Wijaya','L','1982-02-28','Pontianak','kepala_keluarga','kawin',5,'sma','lulus','Nelayan',1500000,'rentan','sehat',2),
('6171010101960011','Putri Lestari','P','1996-05-14','Pontianak','kepala_keluarga','belum_kawin',6,'s1','lulus','Guru Honor',2500000,'mampu','sehat',2),
('6171010101700012','Pak Slamet Riyadi','L','1970-11-11','Jogja','kepala_keluarga','kawin',7,'sd','lulus','Pengangguran',0,'miskin','disabilitas',NULL);
INSERT IGNORE INTO program_bantuan (nama_program,jenis_bantuan_id,anggaran,target_penerima,tanggal_mulai,tanggal_selesai,status,penanggungjawab) VALUES
('Bantuan Sembako Ramadan 2025',1,50000000,200,'2025-03-01','2025-04-30','selesai',2),
('JKN Gratis Warga Miskin Q1 2025',2,0,500,'2025-01-01','2025-03-31','selesai',2),
('Beasiswa 2025',3,30000000,50,'2025-01-01','2025-12-31','berjalan',2);
INSERT IGNORE INTO histori_bantuan (penduduk_id,keluarga_id,program_bantuan_id,jenis_bantuan_id,jumlah,satuan,nilai_rupiah,tanggal_penyaluran,status_penyaluran,disalurkan_oleh,rumah_ibadah_id) VALUES
(1,1,1,1,1,'paket',250000,'2025-03-20','disalurkan',2,1),
(2,1,1,1,1,'paket',250000,'2025-03-20','disalurkan',2,1),
(4,2,1,1,1,'paket',250000,'2025-03-21','disalurkan',2,2),
(8,4,1,1,1,'paket',250000,'2025-03-22','disalurkan',3,3),
(10,5,1,1,1,'paket',250000,'2025-03-20','disalurkan',2,4),
(1,1,2,2,1,'kartu',0,'2025-01-15','disalurkan',2,1),
(4,2,2,2,1,'kartu',0,'2025-01-15','disalurkan',2,2),
(8,4,2,2,1,'kartu',0,'2025-01-16','disalurkan',3,3),
(3,1,3,3,1,'tahun',3600000,'2025-01-10','disalurkan',2,1),
(5,2,3,3,1,'tahun',3600000,'2025-01-10','disalurkan',2,2);
INSERT IGNORE INTO laporan (kode_laporan,nama_pelapor,no_hp_pelapor,penduduk_id,deskripsi,kategori,urgensi,alamat_kejadian,lat,lng,status) VALUES
('LPR-2025-001','Anonim',NULL,8,'Ibu Maria sedang sakit keras sudah 2 minggu tidak bisa berobat karena tidak punya biaya. Mohon segera dibantu.','kesehatan','darurat','Jl. Putri Candramidi No. 3',-0.022000,109.330500,'diproses'),
('LPR-2025-002','Tetangga Pak Slamet','081234567890',12,'Pak Slamet penyandang disabilitas, tinggal sendirian, tidak ada yang urus dan butuh kursi roda.','kemiskinan','tinggi','Jl. Imam Bonjol Gg. Kenari No. 4',-0.034000,109.338000,'diverifikasi'),
('LPR-2025-003',NULL,NULL,NULL,'Ada keluarga baru pindah ke gang belakang masjid, kondisinya sangat memprihatinkan, anak-anak tidak sekolah.','pendidikan','tinggi','Gang Belakang Masjid Al-Falah',-0.011500,109.321200,'pending');
INSERT IGNORE INTO riwayat_pendidikan (penduduk_id,jenis,nama_institusi,jenjang,tahun_masuk,tahun_lulus,status) VALUES
(1,'formal','SDN 5 Pontianak','SD',1987,1993,'lulus'),
(4,'formal','SDN 3 Mempawah','SD',1992,1998,'lulus'),
(4,'formal','SMPN 2 Mempawah','SMP',1998,2001,'lulus'),
(6,'formal','SDN 7 Ketapang','SD',1997,2003,'lulus'),
(6,'formal','SMPN 1 Ketapang','SMP',2003,2006,'lulus'),
(6,'formal','SMAN 1 Ketapang','SMA',2006,2009,'lulus'),
(6,'pelatihan','Balai Latihan Kerja Pontianak','Pelatihan Ojek Online',2019,2019,'lulus'),
(11,'formal','UNTAN Pontianak','S1',2014,2018,'lulus');
INSERT IGNORE INTO peristiwa_kependudukan (penduduk_id,keluarga_id,jenis,tanggal,keterangan,dicatat_oleh,diverifikasi) VALUES
(1,1,'lahir','1980-03-15','Data historis dari KK',2,1),
(4,2,'lahir','1985-11-30','Data historis dari KK',2,1),
(4,2,'menikah','2008-06-01','Menikah, suami meninggal 2020',2,1),
(8,4,'lahir','1975-08-20','Data historis dari KK',3,1),
(8,4,'menikah','2000-03-10','Menikah',3,1),
(8,4,'cerai','2015-07-22','Cerai hidup',3,1),
(12,7,'pindah_masuk','2022-01-15','Pindah dari Yogyakarta',NULL,0);
INSERT IGNORE INTO jadwal_piket (user_id,rumah_ibadah_id,tanggal,shift,jam_mulai,jam_selesai) VALUES
(2,1,CURDATE(),'pagi','08:00:00','12:00:00'),
(2,1,DATE_ADD(CURDATE(),INTERVAL 1 DAY),'pagi','08:00:00','12:00:00'),
(3,3,CURDATE(),'full_day','08:00:00','17:00:00');
INSERT IGNORE INTO log_aktivitas (user_id,aksi,tabel_target,id_target,ip_address) VALUES
(2,'CREATE','penduduk',1,'127.0.0.1'),
(2,'UPDATE_BANTUAN','histori_bantuan',1,'127.0.0.1'),
(3,'VERIFY_LAPORAN','laporan',2,'127.0.0.1');
-- Update trigger untuk data lama
UPDATE penduduk SET kondisi_kesehatan=kondisi_kesehatan WHERE id > 0;
-- ============================================================
-- SELESAI! Login: admin / password123
-- ============================================================
@@ -0,0 +1,32 @@
<?php
// fix_password.php
// Letakkan file ini di folder webgis_v2/, lalu akses sekali di browser
// Setelah berhasil, HAPUS file ini!
require_once 'koneksi.php';
$newHash = password_hash('password123', PASSWORD_DEFAULT);
$users = [
['username' => 'admin', 'hash' => $newHash],
['username' => 'pengurus1', 'hash' => $newHash],
['username' => 'pengurus2', 'hash' => $newHash],
['username' => 'pimpinan', 'hash' => $newHash],
['username' => 'warga', 'hash' => $newHash],
];
echo "<h2>Fix Password Users</h2><pre>";
foreach ($users as $u) {
$hash = $conn->real_escape_string($u['hash']);
$uname = $u['username'];
$res = $conn->query("UPDATE users SET password='$hash' WHERE username='$uname'");
if ($res) {
echo "{$uname} — password berhasil direset ke: password123\n";
} else {
echo "{$uname} — gagal: " . $conn->error . "\n";
}
}
echo "</pre>";
echo "<p style='color:red'><strong>⚠️ HAPUS file fix_password.php ini sekarang setelah berhasil!</strong></p>";
echo "<p><a href='login.php'>→ Coba Login Sekarang</a></p>";
?>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,582 @@
<?php
// ============================================================
// jadwal_piket.php — Atur jadwal pengurus harian per masjid
// Akses: admin, pengurus
// ============================================================
require_once 'koneksi.php';
requireRole('admin','pengurus');
$msg = ''; $err = '';
$role = getRole();
$myUid = currentUserId();
$myRumahIbadahId = $_SESSION['rumah_ibadah_id'] ?? null;
// Jika session kosong, ambil dari database
if (!$myRumahIbadahId && $role === 'pengurus') {
$uRow = $conn->query("SELECT rumah_ibadah_id FROM users WHERE id=$myUid")->fetch_assoc();
$myRumahIbadahId = $uRow['rumah_ibadah_id'] ?? null;
if ($myRumahIbadahId) $_SESSION['rumah_ibadah_id'] = $myRumahIbadahId; // cache ke session
}
// Pengurus hanya lihat & kelola jadwal di rumah ibadahnya sendiri
$filterRumah = ($role === 'pengurus' && $myRumahIbadahId)
? "AND jp.rumah_ibadah_id = $myRumahIbadahId" : '';
$filterRumahMaster = ($role === 'pengurus' && $myRumahIbadahId)
? "WHERE id = $myRumahIbadahId" : '';
// ---- HANDLE POST --------------------------------------------
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$aksi = $_POST['aksi'] ?? '';
// Tambah jadwal baru
if ($aksi === 'tambah') {
$uid = intval($_POST['user_id']);
$riId = intval($_POST['rumah_ibadah_id']);
$tgl = sanitize($conn, $_POST['tanggal']);
$shift = $_POST['shift'];
$jMul = sanitize($conn, $_POST['jam_mulai']);
$jSls = sanitize($conn, $_POST['jam_selesai']);
$ket = sanitize($conn, trim($_POST['keterangan'] ?? ''));
// Jika pengurus tidak punya rumah_ibadah_id (dari session), ambil dari user record
if (!$riId && $role === 'pengurus') {
$uRow = $conn->query("SELECT rumah_ibadah_id FROM users WHERE id=$myUid")->fetch_assoc();
$riId = intval($uRow['rumah_ibadah_id'] ?? 0);
}
if (!$riId) {
$err = 'Rumah ibadah tidak ditemukan. Pastikan akun pengurus sudah ditetapkan ke rumah ibadah.';
} else {
// Cek konflik jadwal (user + tanggal + shift sudah ada)
$cek = $conn->query("SELECT id FROM jadwal_piket
WHERE user_id=$uid AND tanggal='$tgl' AND shift='$shift'")->fetch_assoc();
if ($cek) {
$err = 'Pengurus tersebut sudah terjadwal di shift & tanggal yang sama.';
} else {
$conn->query("INSERT INTO jadwal_piket
(user_id, rumah_ibadah_id, tanggal, shift, jam_mulai, jam_selesai, keterangan)
VALUES ($uid, $riId, '$tgl', '$shift', '$jMul', '$jSls', '$ket')");
logAktivitas($conn, 'CREATE_JADWAL_PIKET', 'jadwal_piket', $conn->insert_id);
$msg = 'Jadwal piket berhasil ditambahkan.';
}
}
// Bulk: tambah jadwal seminggu sekaligus
} elseif ($aksi === 'bulk') {
$uid = intval($_POST['user_id']);
$riId = intval($_POST['rumah_ibadah_id']);
// Jika pengurus tidak punya rumah_ibadah_id, ambil dari user record
if (!$riId && $role === 'pengurus') {
$uRow = $conn->query("SELECT rumah_ibadah_id FROM users WHERE id=$myUid")->fetch_assoc();
$riId = intval($uRow['rumah_ibadah_id'] ?? 0);
}
$tglMul = $_POST['tanggal_mulai'];
$tglSls = $_POST['tanggal_selesai'];
$shift = $_POST['shift'];
$jMul = sanitize($conn, $_POST['jam_mulai']);
$jSls = sanitize($conn, $_POST['jam_selesai']);
$hariAktif = $_POST['hari_aktif'] ?? [];
$cur = new DateTime($tglMul);
$end = new DateTime($tglSls);
$added = 0;
$namaHari = ['0'=>'Minggu','1'=>'Senin','2'=>'Selasa','3'=>'Rabu','4'=>'Kamis','5'=>'Jumat','6'=>'Sabtu'];
while ($cur <= $end) {
$dow = $cur->format('w'); // 0=Minggu
if (in_array($dow, $hariAktif)) {
$tgl = $cur->format('Y-m-d');
$cek = $conn->query("SELECT id FROM jadwal_piket WHERE user_id=$uid AND tanggal='$tgl' AND shift='$shift'")->fetch_assoc();
if (!$cek) {
$conn->query("INSERT INTO jadwal_piket (user_id,rumah_ibadah_id,tanggal,shift,jam_mulai,jam_selesai)
VALUES ($uid,$riId,'$tgl','$shift','$jMul','$jSls')");
$added++;
}
}
$cur->modify('+1 day');
}
$msg = "$added jadwal berhasil dibuat.";
// Update status (hadir / tidak_hadir / digantikan)
} elseif ($aksi === 'update_status') {
$id = intval($_POST['id']);
$status = $_POST['status'];
$gantiId= intval($_POST['digantikan_oleh'] ?? 0) ?: 'NULL';
$conn->query("UPDATE jadwal_piket SET status='$status', digantikan_oleh=$gantiId WHERE id=$id");
logAktivitas($conn, 'UPDATE_JADWAL_PIKET', 'jadwal_piket', $id);
$msg = 'Status piket diperbarui.';
// Hapus jadwal
} elseif ($aksi === 'hapus') {
$id = intval($_POST['id']);
$conn->query("DELETE FROM jadwal_piket WHERE id=$id");
logAktivitas($conn, 'DELETE_JADWAL_PIKET', 'jadwal_piket', $id);
$msg = 'Jadwal berhasil dihapus.';
}
}
// ---- Load data ----------------------------------------------
// Minggu yang ditampilkan
$weekOffset = intval($_GET['week'] ?? 0);
$senin = new DateTime();
$senin->modify('monday this week');
$senin->modify("$weekOffset weeks");
$minggu = clone $senin;
$minggu->modify('+6 days');
$tglMulai = $senin->format('Y-m-d');
$tglAkhir = $minggu->format('Y-m-d');
// Jadwal minggu ini
$jadwalMinggu = $conn->query("
SELECT jp.*,
u.nama AS nama_pengurus, u.email,
ri.nama AS nama_ibadah, ri.jenis AS jenis_ibadah,
ug.nama AS nama_pengganti
FROM jadwal_piket jp
JOIN users u ON jp.user_id = u.id
JOIN rumah_ibadah ri ON jp.rumah_ibadah_id = ri.id
LEFT JOIN users ug ON jp.digantikan_oleh = ug.id
WHERE jp.tanggal BETWEEN '$tglMulai' AND '$tglAkhir'
$filterRumah
ORDER BY jp.tanggal, FIELD(jp.shift,'pagi','siang','sore','malam','full_day'), u.nama
")->fetch_all(MYSQLI_ASSOC);
// Hari ini: siapa yang piket
$piketHariIni = $conn->query("
SELECT jp.*, u.nama AS nama_pengurus, ri.nama AS nama_ibadah
FROM jadwal_piket jp
JOIN users u ON jp.user_id = u.id
JOIN rumah_ibadah ri ON jp.rumah_ibadah_id = ri.id
WHERE jp.tanggal = CURDATE() AND jp.status IN ('terjadwal','hadir')
$filterRumah
ORDER BY FIELD(jp.shift,'pagi','siang','sore','malam','full_day')
")->fetch_all(MYSQLI_ASSOC);
// Daftar pengurus untuk dropdown
$pengurusList = $conn->query("
SELECT u.id, u.nama, ri.nama AS ibadah, u.rumah_ibadah_id
FROM users u
LEFT JOIN rumah_ibadah ri ON u.rumah_ibadah_id = ri.id
WHERE u.role_id = 2 AND u.aktif = 1
" . ($role==='pengurus' && $myRumahIbadahId ? "AND u.rumah_ibadah_id=$myRumahIbadahId" : "")
. " ORDER BY u.nama")->fetch_all(MYSQLI_ASSOC);
// Daftar rumah ibadah
$rumahIbadahList = $conn->query("SELECT id, nama, jenis FROM rumah_ibadah $filterRumahMaster ORDER BY nama")->fetch_all(MYSQLI_ASSOC);
$shiftLabel = ['pagi'=>'🌅 Pagi','siang'=>'☀️ Siang','sore'=>'🌆 Sore','malam'=>'🌙 Malam','full_day'=>'🕐 Full Day'];
$statusLabel = ['terjadwal'=>'Terjadwal','hadir'=>'Hadir','tidak_hadir'=>'Tidak Hadir','digantikan'=>'Digantikan'];
$statusColor = ['terjadwal'=>'var(--blue)','hadir'=>'var(--green)','tidak_hadir'=>'var(--red)','digantikan'=>'var(--yellow)'];
// Kelompokkan jadwal per tanggal
$byDate = [];
foreach ($jadwalMinggu as $j) {
$byDate[$j['tanggal']][] = $j;
}
$namaHariArr = ['Senin','Selasa','Rabu','Kamis','Jumat','Sabtu','Minggu'];
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Jadwal Piket — WebGIS Kemiskinan</title>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{--bg:#080c11;--surface:#0f1620;--surface2:#161f2c;--surface3:#1e2a3a;--border:#243044;--border2:#2f3f57;--text:#e8edf3;--muted:#6b7e96;--red:#e85050;--green:#27ae72;--yellow:#f0b429;--blue:#4a9eff;--font-head:'Plus Jakarta Sans',sans-serif;--font-body:'Inter',sans-serif;}
body{font-family:var(--font-body);background:var(--bg);color:var(--text);min-height:100vh;}
.topnav{display:flex;align-items:center;gap:14px;padding:14px 24px;background:var(--surface);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:100;}
.nav-logo{font-family:var(--font-head);font-weight:800;font-size:15px;}
.nav-links{display:flex;gap:6px;flex:1;}
.nav-link{padding:7px 14px;border-radius:8px;color:var(--muted);font-size:13px;font-weight:500;text-decoration:none;transition:background .15s;}
.nav-link:hover,.nav-link.active{background:rgba(240,180,41,.12);color:var(--yellow);}
.container{max-width:1100px;margin:0 auto;padding:28px 24px;}
h2{font-family:var(--font-head);font-size:22px;font-weight:800;margin-bottom:6px;}
/* Banner piket hari ini */
.today-banner{background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:18px 22px;margin-bottom:24px;}
.today-title{font-family:var(--font-head);font-size:13px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:12px;}
.piket-cards{display:flex;gap:10px;flex-wrap:wrap;}
.piket-card{background:var(--surface2);border:1px solid var(--border);border-radius:12px;padding:12px 16px;min-width:180px;}
.piket-nama{font-weight:600;font-size:14px;margin-bottom:3px;}
.piket-ibadah{font-size:11px;color:var(--muted);}
.piket-shift{font-size:11px;margin-top:5px;}
.empty-today{color:var(--muted);font-size:13px;padding:8px 0;}
/* Nav minggu */
.week-nav{display:flex;align-items:center;gap:12px;margin-bottom:20px;}
.week-label{font-family:var(--font-head);font-weight:700;font-size:16px;flex:1;text-align:center;}
.btn{padding:9px 18px;border:none;border-radius:10px;font-family:var(--font-body);font-size:13px;font-weight:600;cursor:pointer;transition:opacity .2s;}
.btn:hover{opacity:.88;}
.btn-primary{background:var(--yellow);color:#111;}
.btn-outline{background:transparent;border:1px solid var(--border);color:var(--text);}
.btn-sm{padding:5px 12px;font-size:12px;border-radius:8px;}
.btn-success{background:var(--green);color:#fff;}
.btn-danger{background:var(--red);color:#fff;}
.btn-info{background:var(--blue);color:#fff;}
/* Grid kalender */
.week-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:8px;margin-bottom:28px;}
.day-col{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:12px;min-height:160px;}
.day-col.today{border-color:var(--yellow);background:rgba(240,180,41,.05);}
.day-header{font-family:var(--font-head);font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;margin-bottom:4px;}
.day-date{font-family:var(--font-head);font-size:20px;font-weight:800;margin-bottom:10px;}
.day-col.today .day-date{color:var(--yellow);}
.piket-item{background:var(--surface2);border:1px solid var(--border2);border-radius:8px;padding:7px 9px;margin-bottom:6px;font-size:11px;}
.piket-item-name{font-weight:600;margin-bottom:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.piket-item-meta{color:var(--muted);font-size:10px;}
.piket-item-actions{display:flex;gap:4px;margin-top:5px;}
.dot{display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:4px;vertical-align:middle;}
/* Modal */
.modal-bg{display:none;position:fixed;inset:0;background:rgba(0,0,0,.65);backdrop-filter:blur(4px);z-index:500;align-items:center;justify-content:center;padding:20px;}
.modal-bg.open{display:flex;}
.modal{background:var(--surface);border:1px solid var(--border);border-radius:20px;width:100%;max-width:560px;max-height:90vh;overflow-y:auto;}
.modal-header{display:flex;align-items:center;gap:12px;padding:18px 22px;border-bottom:1px solid var(--border);position:sticky;top:0;background:var(--surface);}
.modal-title{font-family:var(--font-head);font-size:16px;font-weight:700;flex:1;}
.modal-close{width:30px;height:30px;background:var(--surface2);border:1px solid var(--border);border-radius:8px;display:flex;align-items:center;justify-content:center;cursor:pointer;color:var(--muted);font-size:16px;}
.modal-body{padding:22px;}
.tabs-modal{display:flex;gap:4px;margin-bottom:20px;background:var(--surface2);border-radius:10px;padding:4px;}
.tab-modal-btn{flex:1;padding:8px;border:none;background:transparent;color:var(--muted);font-family:var(--font-body);font-size:12px;font-weight:600;cursor:pointer;border-radius:8px;transition:all .15s;}
.tab-modal-btn.active{background:var(--surface);color:var(--text);box-shadow:0 1px 4px rgba(0,0,0,.3);}
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px;}
.form-full{grid-column:1/-1;}
.form-group{display:flex;flex-direction:column;gap:6px;}
.form-group label{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;font-weight:500;}
.form-group input,.form-group select{background:var(--surface2);border:1px solid var(--border);border-radius:10px;padding:10px 13px;color:var(--text);font-family:var(--font-body);font-size:13.5px;outline:none;transition:border-color .2s;width:100%;}
.form-group input:focus,.form-group select:focus{border-color:var(--yellow);}
.form-group select option{background:var(--surface2);}
.hari-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:6px;}
.hari-opt{display:none;}
.hari-label{display:flex;align-items:center;justify-content:center;padding:8px 4px;border:1px solid var(--border);border-radius:8px;font-size:11px;font-weight:600;cursor:pointer;transition:all .15s;text-align:center;}
.hari-opt:checked + .hari-label{border-color:var(--yellow);background:rgba(240,180,41,.15);color:var(--yellow);}
.form-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:20px;}
.toast-container{position:fixed;top:20px;right:20px;z-index:9999;display:flex;flex-direction:column;gap:8px;}
.toast{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:10px 16px;font-size:13px;box-shadow:0 8px 24px rgba(0,0,0,.4);animation:slideIn .2s ease;min-width:260px;}
.toast.success{border-left:3px solid var(--green);}.toast.error{border-left:3px solid var(--red);}
@keyframes slideIn{from{transform:translateX(20px);opacity:0}to{transform:translateX(0);opacity:1}}
.alert{padding:12px 16px;border-radius:10px;font-size:13px;margin-bottom:16px;}
.alert-ok{background:rgba(39,174,114,.12);border:1px solid rgba(39,174,114,.3);color:var(--green);}
.alert-err{background:rgba(232,80,80,.15);border:1px solid rgba(232,80,80,.3);color:var(--red);}
@media(max-width:700px){.week-grid{grid-template-columns:1fr;}}
</style>
</head>
<body>
<div class="toast-container" id="toastCont"></div>
<nav class="topnav">
<div class="nav-logo">🗺️ WebGIS Kemiskinan</div>
<div class="nav-links">
<a href="index.php" class="nav-link">Peta</a>
<a href="dashboard.php" class="nav-link">Dashboard</a>
<a href="laporan.php" class="nav-link">Laporan</a>
<a href="jadwal_piket.php" class="nav-link active">Jadwal Piket</a>
</div>
<div style="font-size:12px;color:var(--muted)">👤 <?= htmlspecialchars($_SESSION['nama']) ?></div>
<a href="logout.php" style="color:var(--red);font-size:12px;text-decoration:none">Logout</a>
</nav>
<div class="container">
<h2>📅 Jadwal Piket Pengurus</h2>
<p style="color:var(--muted);font-size:13px;margin-bottom:20px;">Atur pengurus yang bertugas setiap hari agar laporan warga selalu ada yang menangani.</p>
<?php if ($msg): ?><div class="alert alert-ok">✅ <?= htmlspecialchars($msg) ?></div><?php endif; ?>
<?php if ($err): ?><div class="alert alert-err">⚠️ <?= htmlspecialchars($err) ?></div><?php endif; ?>
<!-- Banner: Piket Hari Ini -->
<div class="today-banner">
<div class="today-title">🟢 Pengurus Piket Hari Ini — <?= date('l, d F Y') ?></div>
<?php if (empty($piketHariIni)): ?>
<div class="empty-today">⚠️ Belum ada pengurus terjadwal hari ini. Segera tambahkan jadwal!</div>
<?php else: ?>
<div class="piket-cards">
<?php foreach ($piketHariIni as $p): ?>
<div class="piket-card">
<div class="piket-nama"><?= htmlspecialchars($p['nama_pengurus']) ?></div>
<div class="piket-ibadah">🕌 <?= htmlspecialchars($p['nama_ibadah']) ?></div>
<div class="piket-shift">
<span class="dot" style="background:var(--green)"></span>
<?= $shiftLabel[$p['shift']] ?> · <?= substr($p['jam_mulai'],0,5) ?><?= substr($p['jam_selesai'],0,5) ?>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<!-- Navigasi Minggu -->
<div class="week-nav">
<a href="?week=<?= $weekOffset-1 ?>" class="btn btn-outline">← Minggu Lalu</a>
<div class="week-label">
<?= $senin->format('d M') ?> <?= $minggu->format('d M Y') ?>
<?php if ($weekOffset===0): ?> <span style="font-size:12px;color:var(--yellow)">(Minggu ini)</span><?php endif; ?>
</div>
<a href="?week=<?= $weekOffset+1 ?>" class="btn btn-outline">Minggu Depan →</a>
<button class="btn btn-primary" onclick="openModalTambah()">+ Tambah Jadwal</button>
</div>
<!-- Grid Kalender Mingguan -->
<div class="week-grid">
<?php
$curDay = clone $senin;
$today = date('Y-m-d');
for ($d = 0; $d < 7; $d++):
$tglStr = $curDay->format('Y-m-d');
$isToday = $tglStr === $today;
$items = $byDate[$tglStr] ?? [];
?>
<div class="day-col <?= $isToday?'today':'' ?>">
<div class="day-header"><?= $namaHariArr[$d] ?></div>
<div class="day-date"><?= $curDay->format('d') ?></div>
<?php if (empty($items)): ?>
<div style="font-size:10px;color:var(--border2);text-align:center;padding-top:12px;">Kosong</div>
<?php else: foreach ($items as $item): ?>
<div class="piket-item">
<div class="piket-item-name" title="<?= htmlspecialchars($item['nama_pengurus']) ?>">
<span class="dot" style="background:<?= $statusColor[$item['status']] ?>"></span>
<?= htmlspecialchars($item['nama_pengurus']) ?>
</div>
<div class="piket-item-meta">
<?= $shiftLabel[$item['shift']] ?><br>
<?= substr($item['jam_mulai'],0,5) ?><?= substr($item['jam_selesai'],0,5) ?><br>
🕌 <?= htmlspecialchars($item['nama_ibadah']) ?>
</div>
<?php if ($item['digantikan_oleh']): ?>
<div style="font-size:10px;color:var(--yellow);margin-top:3px">→ Diganti: <?= htmlspecialchars($item['nama_pengganti']) ?></div>
<?php endif; ?>
<?php if (hasRole('admin','pengurus')): ?>
<div class="piket-item-actions">
<button class="btn btn-sm btn-outline" style="padding:3px 7px;font-size:10px"
onclick="openModalUpdate(<?= $item['id'] ?>,'<?= $item['status'] ?>')">✏️</button>
<form method="POST" style="display:inline" onsubmit="return confirm('Hapus jadwal ini?')">
<input type="hidden" name="aksi" value="hapus">
<input type="hidden" name="id" value="<?= $item['id'] ?>">
<button type="submit" class="btn btn-sm btn-danger" style="padding:3px 7px;font-size:10px">🗑</button>
</form>
</div>
<?php endif; ?>
</div>
<?php endforeach; endif; ?>
</div>
<?php $curDay->modify('+1 day'); endfor; ?>
</div>
</div>
<!-- Modal Tambah Jadwal -->
<div class="modal-bg" id="modalTambah">
<div class="modal">
<div class="modal-header">
<span class="modal-title">📅 Tambah Jadwal Piket</span>
<div class="modal-close" onclick="closeModalTambah()">✕</div>
</div>
<div class="modal-body">
<!-- Tab: Single vs Bulk -->
<div class="tabs-modal">
<button class="tab-modal-btn active" id="tabSingleBtn" onclick="switchModalTab('single')">Satu Hari</button>
<button class="tab-modal-btn" id="tabBulkBtn" onclick="switchModalTab('bulk')">Sekaligus (Range)</button>
</div>
<!-- Form: Single -->
<form method="POST" id="formSingle">
<input type="hidden" name="aksi" value="tambah">
<div class="form-grid">
<div class="form-full form-group">
<label>Pengurus <span style="color:var(--red)">*</span></label>
<select name="user_id" required>
<option value="">-- Pilih Pengurus --</option>
<?php foreach ($pengurusList as $pg): ?>
<option value="<?=$pg['id']?>"><?= htmlspecialchars($pg['nama']) ?> — <?= htmlspecialchars($pg['ibadah']??'-') ?></option>
<?php endforeach; ?>
</select>
</div>
<?php if ($role === 'admin'): ?>
<div class="form-full form-group">
<label>Rumah Ibadah <span style="color:var(--red)">*</span></label>
<select name="rumah_ibadah_id" required>
<option value="">-- Pilih --</option>
<?php foreach ($rumahIbadahList as $ri): ?>
<option value="<?=$ri['id']?>"><?= htmlspecialchars($ri['nama']) ?> (<?= $ri['jenis'] ?>)</option>
<?php endforeach; ?>
</select>
</div>
<?php else: ?>
<input type="hidden" name="rumah_ibadah_id" value="<?= $myRumahIbadahId ?>">
<?php endif; ?>
<div class="form-group">
<label>Tanggal <span style="color:var(--red)">*</span></label>
<input type="date" name="tanggal" required value="<?= date('Y-m-d') ?>">
</div>
<div class="form-group">
<label>Shift <span style="color:var(--red)">*</span></label>
<select name="shift" required>
<?php foreach ($shiftLabel as $v=>$l): ?>
<option value="<?=$v?>"><?=$l?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label>Jam Mulai</label>
<input type="time" name="jam_mulai" value="08:00">
</div>
<div class="form-group">
<label>Jam Selesai</label>
<input type="time" name="jam_selesai" value="17:00">
</div>
<div class="form-full form-group">
<label>Keterangan</label>
<input type="text" name="keterangan" placeholder="Opsional — misal: tugas khusus verifikasi laporan">
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-outline" onclick="closeModalTambah()">Batal</button>
<button type="submit" class="btn btn-primary">💾 Simpan</button>
</div>
</form>
<!-- Form: Bulk -->
<form method="POST" id="formBulk" style="display:none">
<input type="hidden" name="aksi" value="bulk">
<div class="form-grid">
<div class="form-full form-group">
<label>Pengurus <span style="color:var(--red)">*</span></label>
<select name="user_id" required>
<option value="">-- Pilih Pengurus --</option>
<?php foreach ($pengurusList as $pg): ?>
<option value="<?=$pg['id']?>"><?= htmlspecialchars($pg['nama']) ?> — <?= htmlspecialchars($pg['ibadah']??'-') ?></option>
<?php endforeach; ?>
</select>
</div>
<?php if ($role === 'admin'): ?>
<div class="form-full form-group">
<label>Rumah Ibadah</label>
<select name="rumah_ibadah_id" required>
<option value="">-- Pilih --</option>
<?php foreach ($rumahIbadahList as $ri): ?>
<option value="<?=$ri['id']?>"><?= htmlspecialchars($ri['nama']) ?></option>
<?php endforeach; ?>
</select>
</div>
<?php else: ?>
<input type="hidden" name="rumah_ibadah_id" value="<?= $myRumahIbadahId ?>">
<?php endif; ?>
<div class="form-group">
<label>Dari Tanggal</label>
<input type="date" name="tanggal_mulai" value="<?= date('Y-m-d') ?>">
</div>
<div class="form-group">
<label>Sampai Tanggal</label>
<input type="date" name="tanggal_selesai" value="<?= date('Y-m-d', strtotime('+1 month')) ?>">
</div>
<div class="form-group">
<label>Shift</label>
<select name="shift">
<?php foreach ($shiftLabel as $v=>$l): ?>
<option value="<?=$v?>"><?=$l?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label>Jam Mulai</label>
<input type="time" name="jam_mulai" value="08:00">
</div>
<div class="form-full form-group" style="grid-column:2">
<label>Jam Selesai</label>
<input type="time" name="jam_selesai" value="17:00">
</div>
<div class="form-full form-group">
<label>Aktif Hari <span style="color:var(--red)">*</span></label>
<div class="hari-grid">
<?php
$hariNama = ['0'=>'Min','1'=>'Sen','2'=>'Sel','3'=>'Rab','4'=>'Kam','5'=>'Jum','6'=>'Sab'];
foreach ($hariNama as $v=>$l): ?>
<div>
<input type="checkbox" name="hari_aktif[]" id="h<?=$v?>" value="<?=$v?>" class="hari-opt"
<?= in_array($v,['1','2','3','4','5'])?'checked':'' ?>>
<label for="h<?=$v?>" class="hari-label"><?=$l?></label>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-outline" onclick="closeModalTambah()">Batal</button>
<button type="submit" class="btn btn-primary">📅 Buat Jadwal Sekaligus</button>
</div>
</form>
</div>
</div>
</div>
<!-- Modal Update Status -->
<div class="modal-bg" id="modalUpdate">
<div class="modal" style="max-width:400px">
<div class="modal-header">
<span class="modal-title">✏️ Update Status Piket</span>
<div class="modal-close" onclick="closeModalUpdate()">✕</div>
</div>
<div class="modal-body">
<form method="POST">
<input type="hidden" name="aksi" value="update_status">
<input type="hidden" name="id" id="updateId">
<div class="form-grid">
<div class="form-full form-group">
<label>Status</label>
<select name="status" id="updateStatus">
<?php foreach ($statusLabel as $v=>$l): ?>
<option value="<?=$v?>"><?=$l?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-full form-group" id="gantiWrap" style="display:none">
<label>Pengurus Pengganti</label>
<select name="digantikan_oleh">
<option value="">-- Pilih --</option>
<?php foreach ($pengurusList as $pg): ?>
<option value="<?=$pg['id']?>"><?= htmlspecialchars($pg['nama']) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-outline" onclick="closeModalUpdate()">Batal</button>
<button type="submit" class="btn btn-success">💾 Simpan</button>
</div>
</form>
</div>
</div>
</div>
<script>
function openModalTambah(){ document.getElementById('modalTambah').classList.add('open'); }
function closeModalTambah(){ document.getElementById('modalTambah').classList.remove('open'); }
function openModalUpdate(id, status) {
document.getElementById('updateId').value = id;
document.getElementById('updateStatus').value = status;
toggleGantiField(status);
document.getElementById('modalUpdate').classList.add('open');
}
function closeModalUpdate(){ document.getElementById('modalUpdate').classList.remove('open'); }
document.querySelectorAll('.modal-bg').forEach(m => m.addEventListener('click', function(e){ if(e.target===this) this.classList.remove('open'); }));
function switchModalTab(tab) {
document.getElementById('formSingle').style.display = tab==='single'?'block':'none';
document.getElementById('formBulk').style.display = tab==='bulk'?'block':'none';
document.getElementById('tabSingleBtn').classList.toggle('active', tab==='single');
document.getElementById('tabBulkBtn').classList.toggle('active', tab==='bulk');
}
function toggleGantiField(val) {
document.getElementById('gantiWrap').style.display = val==='digantikan'?'flex':'none';
}
document.getElementById('updateStatus').addEventListener('change', function(){ toggleGantiField(this.value); });
<?php if ($msg): ?>
(function(){ var t=document.createElement('div'); t.className='toast success'; t.textContent='✅ <?= addslashes($msg) ?>'; document.getElementById('toastCont').appendChild(t); setTimeout(()=>t.remove(),3500); })();
<?php endif; if ($err): ?>
(function(){ var t=document.createElement('div'); t.className='toast error'; t.textContent='⚠️ <?= addslashes($err) ?>'; document.getElementById('toastCont').appendChild(t); setTimeout(()=>t.remove(),4000); })();
<?php endif; ?>
</script>
</body>
</html>
@@ -0,0 +1,404 @@
<?php
require_once 'koneksi.php';
requireLogin();
$msg = '';
$err = '';
$role = getRole();
// Load rumah ibadah untuk dropdown
$ibadahList = $conn->query("SELECT id, nama, jenis FROM rumah_ibadah WHERE aktif=1 ORDER BY nama")->fetch_all(MYSQLI_ASSOC);
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Manajemen Keluarga (KK) — WebGIS Kemiskinan</title>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#080c11;--surface:#0f1620;--surface2:#161f2c;--surface3:#1e2a3a;
--border:#243044;--border2:#2f3f57;
--text:#e8edf3;--muted:#6b7e96;
--red:#e85050;--green:#27ae72;--yellow:#f0b429;--blue:#4a9eff;--purple:#9b59b6;
--font-head:'Plus Jakarta Sans',sans-serif;--font-body:'Inter',sans-serif;
}
body{font-family:var(--font-body);background:var(--bg);color:var(--text);min-height:100vh;}
.topnav{display:flex;align-items:center;gap:14px;padding:14px 24px;background:var(--surface);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:100;}
.nav-logo{font-family:var(--font-head);font-weight:800;font-size:15px;}
.nav-links{display:flex;gap:6px;flex:1;}
.nav-link{padding:7px 14px;border-radius:8px;color:var(--muted);font-size:13px;font-weight:500;text-decoration:none;transition:background .15s,color .15s;}
.nav-link:hover,.nav-link.active{background:rgba(232,80,80,.12);color:var(--red);}
.container{max-width:1300px;margin:0 auto;padding:28px 24px;}
h2{font-family:var(--font-head);font-size:22px;font-weight:800;margin-bottom:22px;}
.toolbar{display:flex;align-items:center;gap:10px;margin-bottom:18px;flex-wrap:wrap;}
.search-box{flex:1;min-width:200px;background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:9px 14px;color:var(--text);font-family:var(--font-body);font-size:13px;outline:none;}
.search-box:focus{border-color:var(--red);}
.btn{padding:9px 18px;border:none;border-radius:10px;font-family:var(--font-body);font-size:13px;font-weight:600;cursor:pointer;transition:opacity .2s;}
.btn:hover{opacity:.88;}
.btn-primary{background:var(--red);color:#fff;box-shadow:0 3px 12px rgba(232,80,80,.3);}
.btn-success{background:var(--green);color:#fff;}
.btn-outline{background:transparent;border:1px solid var(--border);color:var(--text);}
.btn-sm{padding:5px 12px;font-size:12px;border-radius:8px;}
.card{background:var(--surface);border:1px solid var(--border);border-radius:16px;overflow:hidden;margin-bottom:28px;}
.tbl-wrap{overflow-x:auto;}
table{width:100%;border-collapse:collapse;font-size:13px;}
th{text-align:left;padding:10px 14px;background:var(--surface2);color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.5px;font-weight:600;border-bottom:1px solid var(--border);}
td{padding:10px 14px;border-bottom:1px solid var(--border2);vertical-align:middle;}
tr:hover td{background:rgba(255,255,255,.02);}
.badge{display:inline-block;padding:2px 8px;border-radius:20px;font-size:10px;font-weight:600;text-transform:uppercase;}
.b-miskin{background:rgba(232,80,80,.2);color:var(--red);}
.b-rentan{background:rgba(240,180,41,.2);color:var(--yellow);}
.b-mampu{background:rgba(39,174,114,.2);color:var(--green);}
.b-belum{background:rgba(107,126,150,.15);color:var(--muted);}
.b-terv{background:rgba(39,174,114,.2);color:var(--green);}
.act{display:flex;gap:5px;}
/* MODAL */
.modal-bg{display:none;position:fixed;inset:0;background:rgba(0,0,0,.65);backdrop-filter:blur(4px);z-index:500;align-items:center;justify-content:center;padding:20px;}
.modal-bg.open{display:flex;}
.modal{background:var(--surface);border:1px solid var(--border);border-radius:20px;width:100%;max-width:640px;max-height:90vh;overflow-y:auto;box-shadow:0 24px 80px rgba(0,0,0,.5);}
.modal-header{display:flex;align-items:center;gap:12px;padding:18px 22px;border-bottom:1px solid var(--border);position:sticky;top:0;background:var(--surface);z-index:1;}
.modal-title{font-family:var(--font-head);font-size:16px;font-weight:700;flex:1;}
.modal-close{width:30px;height:30px;background:var(--surface2);border:1px solid var(--border);border-radius:8px;display:flex;align-items:center;justify-content:center;cursor:pointer;color:var(--muted);font-size:16px;}
.modal-body{padding:22px;}
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px;}
.form-full{grid-column:1/-1;}
.form-group{display:flex;flex-direction:column;gap:6px;}
.form-group label{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;font-weight:500;}
.form-group input,.form-group select,.form-group textarea{background:var(--surface2);border:1px solid var(--border);border-radius:10px;padding:10px 13px;color:var(--text);font-family:var(--font-body);font-size:13.5px;outline:none;transition:border-color .2s;}
.form-group input:focus,.form-group select:focus{border-color:var(--red);}
.form-group select option{background:var(--surface2);}
.form-section{font-family:var(--font-head);font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.8px;padding-bottom:8px;border-bottom:1px solid var(--border);grid-column:1/-1;margin-top:6px;}
.form-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:20px;}
/* DETAIL SIDE PANEL */
.side-panel{position:fixed;right:0;top:0;bottom:0;width:400px;background:var(--surface);border-left:1px solid var(--border);z-index:400;transform:translateX(100%);transition:transform .25s ease;display:flex;flex-direction:column;}
.side-panel.open{transform:translateX(0);}
.sp-header{padding:18px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;}
.sp-title{font-family:var(--font-head);font-size:15px;font-weight:700;flex:1;}
.sp-body{flex:1;overflow-y:auto;padding:18px 20px;}
.sp-section{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.6px;margin:14px 0 8px;font-weight:600;}
.sp-row{display:flex;justify-content:space-between;padding:6px 0;border-bottom:1px solid var(--border2);font-size:13px;}
.sp-row span:first-child{color:var(--muted);}
.anggota-card{background:var(--surface2);border:1px solid var(--border);border-radius:10px;padding:10px 12px;margin-bottom:8px;}
.anggota-name{font-weight:600;font-size:13px;margin-bottom:4px;}
.anggota-meta{font-size:11px;color:var(--muted);}
.toast-container{position:fixed;top:20px;right:20px;z-index:9999;display:flex;flex-direction:column;gap:8px;}
.toast{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:10px 16px;font-size:13px;box-shadow:0 8px 24px rgba(0,0,0,.4);animation:slideIn .2s ease;min-width:260px;}
.toast.success{border-left:3px solid var(--green);}
.toast.error{border-left:3px solid var(--red);}
@keyframes slideIn{from{transform:translateX(20px);opacity:0}to{transform:translateX(0);opacity:1}}
.empty{text-align:center;color:var(--muted);padding:40px;font-size:14px;}
</style>
</head>
<body>
<div class="toast-container" id="toastContainer"></div>
<nav class="topnav">
<div class="nav-logo">🗺️ WebGIS Kemiskinan</div>
<div class="nav-links">
<a href="index.php" class="nav-link">Peta</a>
<a href="dashboard.php" class="nav-link">Dashboard</a>
<a href="keluarga.php" class="nav-link active">Keluarga</a>
</div>
<div style="font-size:12px;color:var(--muted)">👤 <?= htmlspecialchars($_SESSION['nama']) ?></div>
<a href="logout.php" style="color:var(--red);font-size:12px;text-decoration:none">Logout</a>
</nav>
<div class="container">
<h2>🏠 Manajemen Data Keluarga (Kartu Keluarga)</h2>
<div class="toolbar">
<input type="text" class="search-box" placeholder="🔍 Cari No. KK / Alamat..." oninput="loadTable(this.value)">
<select id="filterVer" onchange="loadTable()" style="background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:9px 12px;color:var(--text);font-size:13px;outline:none;">
<option value="">Semua Verifikasi</option>
<option value="belum">Belum Terverifikasi</option>
<option value="terverifikasi">Terverifikasi</option>
</select>
<?php if(hasRole('admin','pengurus')): ?>
<button class="btn btn-primary" onclick="openModal()">+ Tambah KK</button>
<?php endif; ?>
</div>
<div class="card">
<div class="tbl-wrap">
<table>
<thead>
<tr>
<th>No. KK</th>
<th>Alamat</th>
<th>RT/RW</th>
<th>Kecamatan</th>
<th>Anggota</th>
<th>Miskin</th>
<th>Status Ekonomi</th>
<th>Verifikasi</th>
<th>Rumah Ibadah</th>
<th>Aksi</th>
</tr>
</thead>
<tbody id="tableBody">
<tr><td colspan="10" class="empty">Memuat data...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- MODAL FORM -->
<div class="modal-bg" id="formModal" onclick="if(event.target.id==='formModal')closeModal()">
<div class="modal">
<div class="modal-header">
<div class="modal-title" id="modalTitle">Tambah Kartu Keluarga</div>
<div class="modal-close" onclick="closeModal()">✕</div>
</div>
<div class="modal-body">
<form id="formKK" onsubmit="submitKK(event)">
<input type="hidden" id="fk_id" name="id">
<div class="form-grid">
<div class="form-section">📋 Identitas Keluarga</div>
<div class="form-group form-full">
<label>No. KK (16 digit) *</label>
<input type="text" name="no_kk" id="fk_nokk" maxlength="16" minlength="16" pattern="\d{16}" placeholder="6171xxxxxxxxxxxx" required>
</div>
<div class="form-group form-full">
<label>Alamat Lengkap *</label>
<input type="text" name="alamat" id="fk_alamat" required placeholder="Jl. Contoh No. 1">
</div>
<div class="form-group">
<label>RT</label>
<input type="text" name="rt" id="fk_rt" maxlength="5" placeholder="001">
</div>
<div class="form-group">
<label>RW</label>
<input type="text" name="rw" id="fk_rw" maxlength="5" placeholder="001">
</div>
<div class="form-group">
<label>Kelurahan</label>
<input type="text" name="kelurahan" id="fk_kel" placeholder="Nama kelurahan">
</div>
<div class="form-group">
<label>Kecamatan</label>
<input type="text" name="kecamatan" id="fk_kec" placeholder="Nama kecamatan">
</div>
<div class="form-group">
<label>Kota</label>
<input type="text" name="kota" id="fk_kota" value="Pontianak">
</div>
<div class="form-group">
<label>Status Ekonomi *</label>
<select name="status_ekonomi" id="fk_status" required>
<option value="miskin">Miskin</option>
<option value="rentan" selected>Rentan</option>
<option value="mampu">Mampu</option>
</select>
</div>
<div class="form-section">🕌 Rumah Ibadah & Lokasi</div>
<div class="form-group form-full">
<label>Rumah Ibadah Terdekat</label>
<select name="rumah_ibadah_id" id="fk_ibadah">
<option value="">-- Pilih (opsional, auto-detect dari koordinat) --</option>
<?php foreach($ibadahList as $ri): ?>
<option value="<?= $ri['id'] ?>">[<?= $ri['jenis'] ?>] <?= htmlspecialchars($ri['nama']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label>Latitude</label>
<input type="number" name="lat" id="fk_lat" step="0.000001" placeholder="-0.026300">
</div>
<div class="form-group">
<label>Longitude</label>
<input type="number" name="lng" id="fk_lng" step="0.000001" placeholder="109.342500">
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-outline" onclick="closeModal()">Batal</button>
<button type="submit" class="btn btn-primary">💾 Simpan</button>
</div>
</form>
</div>
</div>
</div>
<!-- SIDE PANEL DETAIL -->
<div class="side-panel" id="sidePanel">
<div class="sp-header">
<div class="sp-title" id="spTitle">Detail Keluarga</div>
<div class="modal-close" onclick="closeSide()">✕</div>
</div>
<div class="sp-body" id="spBody">
<div class="empty">Pilih keluarga untuk melihat detail</div>
</div>
</div>
<script>
// Load tabel
function loadTable(search = '') {
const ver = document.getElementById('filterVer')?.value || '';
fetch(`api/keluarga.php?action=list&search=${encodeURIComponent(search)}&verifikasi=${ver}`)
.then(r => r.json())
.then(data => {
const tbody = document.getElementById('tableBody');
if (!data.success || !data.data.length) {
tbody.innerHTML = '<tr><td colspan="10" class="empty">Tidak ada data keluarga</td></tr>';
return;
}
tbody.innerHTML = data.data.map(k => `
<tr>
<td style="font-family:monospace;font-size:11px">${k.no_kk}</td>
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${k.alamat}">${k.alamat}</td>
<td style="font-size:11px">${k.rt||'-'}/${k.rw||'-'}</td>
<td style="font-size:12px">${k.kecamatan||'-'}</td>
<td style="text-align:center">${k.jumlah_anggota||0} org</td>
<td style="text-align:center">${k.jumlah_miskin > 0 ? `<span class="badge b-miskin">${k.jumlah_miskin}</span>` : '<span style="color:var(--muted)">-</span>'}</td>
<td><span class="badge b-${k.status_ekonomi}">${k.status_ekonomi}</span></td>
<td><span class="badge ${k.status_verifikasi==='terverifikasi'?'b-terv':'b-belum'}">${k.status_verifikasi}</span></td>
<td style="font-size:11px;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${k.nama_ibadah||'<span style="color:var(--muted)">-</span>'}</td>
<td>
<div class="act">
<button class="btn btn-outline btn-sm" onclick="lihatDetail(${k.id})" title="Detail">👁</button>
<?php if(hasRole('admin','pengurus')): ?>
<button class="btn btn-outline btn-sm" onclick="editKK(${k.id})" title="Edit">✏️</button>
${k.status_verifikasi !== 'terverifikasi' ? `<button class="btn btn-success btn-sm" onclick="verifikasi(${k.id})" title="Verifikasi">✓</button>` : ''}
<button class="btn btn-outline btn-sm" onclick="hapusKK(${k.id},'${k.no_kk}')" title="Hapus" style="color:var(--red)">🗑</button>
<?php endif; ?>
</div>
</td>
</tr>
`).join('');
});
}
// Modal
function openModal() {
document.getElementById('modalTitle').textContent = 'Tambah Kartu Keluarga';
document.getElementById('formKK').reset();
document.getElementById('fk_id').value = '';
document.getElementById('formModal').classList.add('open');
}
function closeModal() {
document.getElementById('formModal').classList.remove('open');
}
function editKK(id) {
fetch(`api/keluarga.php?action=get&id=${id}`)
.then(r => r.json())
.then(data => {
if (!data.success) return;
const k = data.data;
document.getElementById('modalTitle').textContent = 'Edit KK: ' + k.no_kk;
document.getElementById('fk_id').value = k.id;
document.getElementById('fk_nokk').value = k.no_kk;
document.getElementById('fk_alamat').value = k.alamat;
document.getElementById('fk_rt').value = k.rt || '';
document.getElementById('fk_rw').value = k.rw || '';
document.getElementById('fk_kel').value = k.kelurahan || '';
document.getElementById('fk_kec').value = k.kecamatan || '';
document.getElementById('fk_kota').value = k.kota || 'Pontianak';
document.getElementById('fk_status').value = k.status_ekonomi;
document.getElementById('fk_ibadah').value = k.rumah_ibadah_id || '';
document.getElementById('fk_lat').value = k.lat || '';
document.getElementById('fk_lng').value = k.lng || '';
document.getElementById('formModal').classList.add('open');
});
}
function submitKK(e) {
e.preventDefault();
const fd = new FormData(e.target);
fetch('api/keluarga.php?action=save', { method: 'POST', body: fd })
.then(r => r.json())
.then(data => {
if (data.success) { toast('✅ ' + data.message, 'success'); closeModal(); loadTable(); }
else toast('❌ ' + data.error, 'error');
});
}
function verifikasi(id) {
if (!confirm('Verifikasi keluarga ini?')) return;
fetch('api/keluarga.php?action=verifikasi', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `id=${id}`
})
.then(r => r.json())
.then(data => {
if (data.success) { toast('✅ Keluarga terverifikasi', 'success'); loadTable(); }
else toast('❌ ' + data.error, 'error');
});
}
function hapusKK(id, nokk) {
if (!confirm(`Hapus KK ${nokk}? Pastikan tidak ada anggota aktif.`)) return;
fetch('api/keluarga.php?action=delete', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `id=${id}`
})
.then(r => r.json())
.then(data => {
if (data.success) { toast('🗑 KK dihapus', 'success'); loadTable(); }
else toast('❌ ' + data.error, 'error');
});
}
// DETAIL SIDE PANEL
function lihatDetail(id) {
fetch(`api/keluarga.php?action=get&id=${id}`)
.then(r => r.json())
.then(data => {
if (!data.success) return;
const k = data.data;
document.getElementById('spTitle').textContent = 'KK: ' + k.no_kk;
const anggotaHtml = (k.anggota || []).map(a => `
<div class="anggota-card">
<div class="anggota-name">${a.nama} <span class="badge b-${a.status_ekonomi}" style="font-size:9px">${a.status_ekonomi}</span></div>
<div class="anggota-meta">${a.status_keluarga.replace('_',' ')} · ${a.umur} thn · ${a.pekerjaan||'Tidak bekerja'}</div>
</div>
`).join('');
document.getElementById('spBody').innerHTML = `
<div class="sp-section">Identitas</div>
<div class="sp-row"><span>No. KK</span><span style="font-family:monospace;font-size:11px">${k.no_kk}</span></div>
<div class="sp-row"><span>Alamat</span><span>${k.alamat}</span></div>
<div class="sp-row"><span>RT/RW</span><span>${k.rt||'-'}/${k.rw||'-'}</span></div>
<div class="sp-row"><span>Kelurahan</span><span>${k.kelurahan||'-'}</span></div>
<div class="sp-row"><span>Kecamatan</span><span>${k.kecamatan||'-'}</span></div>
<div class="sp-row"><span>Status Ekonomi</span><span><span class="badge b-${k.status_ekonomi}">${k.status_ekonomi}</span></span></div>
<div class="sp-row"><span>Verifikasi</span><span>${k.status_verifikasi}</span></div>
<div class="sp-row"><span>Rumah Ibadah</span><span>${k.nama_ibadah||'-'}</span></div>
<div class="sp-row"><span>Koordinat</span><span style="font-size:11px">${k.lat||'?'}, ${k.lng||'?'}</span></div>
<div class="sp-section">Anggota Keluarga (${(k.anggota||[]).length} orang)</div>
${anggotaHtml || '<div style="color:var(--muted);font-size:13px;padding:10px 0">Belum ada anggota terdaftar</div>'}
<div style="margin-top:14px">
<a href="index.php" style="color:var(--blue);font-size:13px;text-decoration:none">→ Lihat di Peta</a>
</div>
`;
document.getElementById('sidePanel').classList.add('open');
});
}
function closeSide() {
document.getElementById('sidePanel').classList.remove('open');
}
function toast(msg, type = 'info') {
const c = document.getElementById('toastContainer');
const t = document.createElement('div');
t.className = `toast ${type}`;
t.textContent = msg;
c.appendChild(t);
setTimeout(() => t.remove(), 3500);
}
loadTable();
</script>
</body>
</html>
@@ -0,0 +1,221 @@
<?php
// ============================================================
// koneksi.php — Database connection, session, helpers
// ============================================================
define('DB_HOST', getenv('DB_HOST'));
define('DB_USER', getenv('DB_USER'));
define('DB_PASS', getenv('DB_PASSWORD'));
define('DB_NAME', getenv('DB_NAME'));
define('APP_NAME', 'WebGIS Kemiskinan');
define('APP_VERSION', '2.0');
define('UPLOAD_DIR', __DIR__ . '/uploads/');
define('UPLOAD_URL', 'uploads/');
// -- Database
$conn = new mysqli(DB_HOST, DB_USER, DB_PASS, DB_NAME);
$conn->set_charset('utf8mb4');
if ($conn->connect_error) {
http_response_code(500);
die(json_encode(['success' => false, 'error' => 'Koneksi database gagal: ' . $conn->connect_error]));
}
// -- Session
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
// ============================================================
// AUTH HELPERS
// ============================================================
function isLoggedIn(): bool {
return isset($_SESSION['user_id']);
}
function requireLogin(): void {
if (!isLoggedIn()) {
if (isApiRequest()) {
http_response_code(401);
die(json_encode(['success' => false, 'error' => 'Unauthorized']));
}
header('Location: login.php');
exit;
}
}
function getRole(): string {
return $_SESSION['role_slug'] ?? 'masyarakat';
}
function hasRole(string ...$roles): bool {
return in_array(getRole(), $roles);
}
function requireRole(string ...$roles): void {
requireLogin();
if (!hasRole(...$roles)) {
if (isApiRequest()) {
http_response_code(403);
die(json_encode(['success' => false, 'error' => 'Akses ditolak']));
}
header('Location: index.php?err=forbidden');
exit;
}
}
function isApiRequest(): bool {
return isset($_SERVER['HTTP_X_REQUESTED_WITH']) ||
(isset($_SERVER['HTTP_ACCEPT']) && str_contains($_SERVER['HTTP_ACCEPT'], 'application/json'));
}
function currentUserId(): int {
return (int)($_SESSION['user_id'] ?? 0);
}
// ============================================================
// DB HELPERS
// ============================================================
function sanitize($conn, $val): string {
return $conn->real_escape_string(trim((string)$val));
}
function dbQuery($conn, string $sql, array $params = [], string $types = ''): ?array {
if (empty($params)) {
$res = $conn->query($sql);
if (!$res) return null;
return $res->fetch_all(MYSQLI_ASSOC);
}
$stmt = $conn->prepare($sql);
if (!$stmt) return null;
if ($types && $params) {
$stmt->bind_param($types, ...$params);
}
$stmt->execute();
$res = $stmt->get_result();
return $res ? $res->fetch_all(MYSQLI_ASSOC) : [];
}
function dbFirst($conn, string $sql, array $params = [], string $types = ''): ?array {
$rows = dbQuery($conn, $sql, $params, $types);
return $rows[0] ?? null;
}
// ============================================================
// RESPONSE HELPERS
// ============================================================
function jsonSuccess($data = null, string $msg = 'OK'): void {
header('Content-Type: application/json');
echo json_encode(['success' => true, 'message' => $msg, 'data' => $data]);
exit;
}
function jsonError(string $msg, int $code = 400): void {
header('Content-Type: application/json');
http_response_code($code);
echo json_encode(['success' => false, 'error' => $msg]);
exit;
}
// ============================================================
// VALIDASI PENDIDIKAN vs UMUR
// ============================================================
function validasiPendidikan(int $umur, string $status_pendidikan, string $pendidikan_terakhir): array {
$warnings = [];
if ($umur >= 7 && $umur <= 12) {
if ($status_pendidikan === 'tidak_sekolah') {
$warnings[] = "⚠️ Usia $umur tahun seharusnya masih SD. Status tidak sekolah perlu verifikasi.";
}
}
if ($umur >= 13 && $umur <= 15) {
if ($status_pendidikan === 'tidak_sekolah' && !in_array($pendidikan_terakhir, ['sd','smp'])) {
$warnings[] = "⚠️ Usia $umur tahun seharusnya SMP. Mohon verifikasi status pendidikan.";
}
}
if ($umur >= 16 && $umur <= 18) {
if ($status_pendidikan === 'tidak_sekolah' && !in_array($pendidikan_terakhir, ['sd','smp','sma','smk'])) {
$warnings[] = "⚠️ Usia $umur tahun seharusnya SMA/SMK.";
}
}
if ($umur < 7 && $status_pendidikan === 'masih_sekolah' && in_array($pendidikan_terakhir, ['sma','smk','s1','s2','s3'])) {
$warnings[] = "❌ Data tidak valid: usia $umur tahun tidak mungkin sudah $pendidikan_terakhir.";
}
return $warnings;
}
// ============================================================
// GEOGRAPHIC HELPERS
// ============================================================
function hitungJarak(float $lat1, float $lng1, float $lat2, float $lng2): float {
$R = 6371000;
$phi1 = deg2rad($lat1); $phi2 = deg2rad($lat2);
$dphi = deg2rad($lat2 - $lat1);
$dlam = deg2rad($lng2 - $lng1);
$a = sin($dphi/2)**2 + cos($phi1)*cos($phi2)*sin($dlam/2)**2;
return $R * 2 * atan2(sqrt($a), sqrt(1-$a));
}
function cariRumahIbadahTerdekat($conn, float $lat, float $lng): ?array {
$rows = $conn->query("SELECT id, lat, lng, radius FROM rumah_ibadah WHERE aktif=1")->fetch_all(MYSQLI_ASSOC);
$terdekat = null; $minJarak = PHP_INT_MAX;
foreach ($rows as $ri) {
$d = hitungJarak($lat, $lng, (float)$ri['lat'], (float)$ri['lng']);
if ($d < $minJarak) {
$minJarak = $d;
$terdekat = $ri;
$terdekat['jarak'] = $d;
}
}
return $terdekat;
}
// ============================================================
// LOG AKTIVITAS
// ============================================================
function logAktivitas($conn, string $aksi, string $tabel = '', int $id = 0, $dataLama = null, $dataBaru = null): void {
$userId = currentUserId() ?: 'NULL';
$ip = sanitize($conn, $_SERVER['REMOTE_ADDR'] ?? '');
$ua = sanitize($conn, substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255));
$aksiSan = sanitize($conn, $aksi);
$tabelSan = sanitize($conn, $tabel);
$lamaJson = $dataLama ? "'" . sanitize($conn, json_encode($dataLama, JSON_UNESCAPED_UNICODE)) . "'" : 'NULL';
$baruJson = $dataBaru ? "'" . sanitize($conn, json_encode($dataBaru, JSON_UNESCAPED_UNICODE)) . "'" : 'NULL';
$conn->query("INSERT INTO log_aktivitas (user_id, aksi, tabel_target, id_target, data_lama, data_baru, ip_address, user_agent)
VALUES ($userId, '$aksiSan', '$tabelSan', $id, $lamaJson, $baruJson, '$ip', '$ua')");
}
// ============================================================
// UPLOAD FILE
// ============================================================
function uploadFile(array $file, string $prefix = 'file', array $allowed = ['jpg','jpeg','png','pdf']): ?string {
if (empty($file['name'])) return null;
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($ext, $allowed)) return null;
if ($file['size'] > 5 * 1024 * 1024) return null; // 5MB max
$fname = $prefix . '_' . time() . '_' . rand(100, 999) . '.' . $ext;
if (move_uploaded_file($file['tmp_name'], UPLOAD_DIR . $fname)) return $fname;
return null;
}
// ============================================================
// NOTIFIKASI
// ============================================================
function kirimNotifikasi($conn, int $userId, string $judul, string $isi, string $tipe = 'info', string $url = ''): void {
$judulSan = sanitize($conn, $judul);
$isiSan = sanitize($conn, $isi);
$tipeSan = sanitize($conn, $tipe);
$urlSan = sanitize($conn, $url);
$conn->query("INSERT INTO notifikasi (user_id, judul, isi, tipe, url_target) VALUES ($userId, '$judulSan', '$isiSan', '$tipeSan', '$urlSan')");
}
function jumlahNotifBelumDibaca($conn): int {
$uid = currentUserId();
if (!$uid) return 0;
$r = $conn->query("SELECT COUNT(*) as n FROM notifikasi WHERE user_id=$uid AND dibaca=0");
return (int)($r->fetch_assoc()['n'] ?? 0);
}
@@ -0,0 +1,238 @@
<?php
// ============================================================
// lapor_publik.php — Form laporan TANPA LOGIN
// Akses publik: warga bisa lapor via QR code di masjid
// ============================================================
require_once 'koneksi.php';
$msg = ''; $err = ''; $kode = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$nama = trim($_POST['nama_pelapor'] ?? '');
$no_hp = trim($_POST['no_hp_pelapor'] ?? '');
$nama_lapor = trim($_POST['nama_terlapor'] ?? '');
$alamat = trim($_POST['alamat_kejadian'] ?? '');
$deskripsi = trim($_POST['deskripsi'] ?? '');
$kategori = $_POST['kategori'] ?? 'kemiskinan';
$urgensi = $_POST['urgensi'] ?? 'sedang';
$lat = floatval($_POST['lat'] ?? 0);
$lng = floatval($_POST['lng'] ?? 0);
if (strlen($deskripsi) < 20) {
$err = 'Deskripsi terlalu singkat. Mohon jelaskan kondisi lebih detail (min. 20 karakter).';
} else {
// Generate kode laporan unik
$kode = 'LPR-' . date('Y') . '-' . strtoupper(substr(uniqid(), -6));
// Upload foto jika ada
$foto = null;
if (!empty($_FILES['foto']['name'])) {
$foto = uploadFile($_FILES['foto'], 'laporan', ['jpg','jpeg','png']);
}
$stmt = $conn->prepare("INSERT INTO laporan
(kode_laporan, nama_pelapor, no_hp_pelapor, nama_terlapor, alamat_kejadian,
lat, lng, deskripsi, kategori, urgensi, foto, status)
VALUES (?,?,?,?,?,?,?,?,?,?,?,'pending')");
$namaVal = $nama ?: null;
$hpVal = $no_hp ?: null;
$laporVal = $nama_lapor ?: null;
$latVal = $lat ?: null;
$lngVal = $lng ?: null;
$stmt->bind_param('sssssddssss',
$kode, $namaVal, $hpVal, $laporVal, $alamat,
$latVal, $lngVal, $deskripsi, $kategori, $urgensi, $foto);
if ($stmt->execute()) {
logAktivitas($conn, 'LAPORAN_PUBLIK', 'laporan', $conn->insert_id);
$msg = $kode;
} else {
$err = 'Gagal menyimpan laporan. Silakan coba lagi.';
$kode = '';
}
$stmt->close();
}
}
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Lapor Warga — WebGIS Kemiskinan</title>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#080c11;--surface:#0f1620;--surface2:#161f2c;--surface3:#1e2a3a;
--border:#243044;--border2:#2f3f57;
--text:#e8edf3;--muted:#6b7e96;
--red:#e85050;--green:#27ae72;--yellow:#f0b429;--blue:#4a9eff;
--font-head:'Plus Jakarta Sans',sans-serif;--font-body:'Inter',sans-serif;
}
body{font-family:var(--font-body);background:var(--bg);color:var(--text);min-height:100vh;display:flex;flex-direction:column;align-items:center;padding:24px 16px;}
.header{text-align:center;margin-bottom:28px;}
.header h1{font-family:var(--font-head);font-size:22px;font-weight:800;margin-bottom:6px;}
.header p{font-size:13px;color:var(--muted);}
.card{background:var(--surface);border:1px solid var(--border);border-radius:20px;width:100%;max-width:540px;padding:28px;}
.form-group{display:flex;flex-direction:column;gap:6px;margin-bottom:16px;}
.form-group label{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;font-weight:600;}
.form-group input,.form-group select,.form-group textarea{background:var(--surface2);border:1px solid var(--border);border-radius:10px;padding:11px 14px;color:var(--text);font-family:var(--font-body);font-size:14px;outline:none;transition:border-color .2s;width:100%;}
.form-group input:focus,.form-group select:focus,.form-group textarea:focus{border-color:var(--red);}
.form-group select option{background:var(--surface2);}
.form-group textarea{resize:vertical;min-height:100px;}
.form-section{font-family:var(--font-head);font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.8px;padding:10px 0 8px;border-bottom:1px solid var(--border);margin-bottom:16px;}
.hint{font-size:11px;color:var(--muted);margin-top:2px;}
.badge-anonim{background:rgba(107,126,150,.15);color:var(--muted);font-size:11px;padding:3px 10px;border-radius:20px;display:inline-block;margin-bottom:6px;}
.urgensi-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:8px;}
.urgensi-opt{display:none;}
.urgensi-label{display:flex;flex-direction:column;align-items:center;gap:5px;padding:12px;border:1px solid var(--border);border-radius:12px;cursor:pointer;font-size:12px;text-align:center;transition:all .15s;}
.urgensi-opt:checked + .urgensi-label{border-color:var(--red);background:rgba(232,80,80,.1);color:var(--red);}
.urgensi-label .icon{font-size:22px;}
.btn{width:100%;padding:13px;background:var(--red);color:#fff;border:none;border-radius:12px;font-family:var(--font-body);font-size:15px;font-weight:600;cursor:pointer;box-shadow:0 4px 16px rgba(232,80,80,.3);transition:opacity .2s;}
.btn:hover{opacity:.88;}
.btn:disabled{opacity:.5;cursor:not-allowed;}
.alert{padding:14px 16px;border-radius:12px;font-size:13px;margin-bottom:20px;}
.alert-err{background:rgba(232,80,80,.15);border:1px solid rgba(232,80,80,.3);color:var(--red);}
.alert-ok{background:rgba(39,174,114,.12);border:1px solid rgba(39,174,114,.3);color:var(--green);text-align:center;}
.kode-laporan{font-family:var(--font-head);font-size:26px;font-weight:800;letter-spacing:2px;margin:8px 0;}
.loc-btn{background:var(--surface2);border:1px solid var(--border);border-radius:10px;padding:10px 14px;color:var(--muted);font-size:13px;cursor:pointer;width:100%;text-align:left;transition:border-color .2s;}
.loc-btn:hover{border-color:var(--blue);}
.loc-ok{color:var(--green);font-size:11px;margin-top:4px;}
footer{margin-top:28px;font-size:11px;color:var(--muted);text-align:center;}
</style>
</head>
<body>
<div class="header">
<h1>📢 Lapor Kondisi Warga</h1>
<p>WebGIS Pengentasan Kemiskinan — Kota Pontianak</p>
<p style="margin-top:6px;font-size:12px;">Laporan anonim diperbolehkan. Tidak perlu login.</p>
</div>
<?php if ($msg): ?>
<div class="card" style="max-width:540px;text-align:center;">
<div style="font-size:48px;margin-bottom:12px;">✅</div>
<div class="alert alert-ok">
<div style="margin-bottom:8px;">Laporan berhasil dikirim!</div>
<div>Kode laporan kamu:</div>
<div class="kode-laporan"><?= htmlspecialchars($msg) ?></div>
<div style="font-size:12px;color:var(--muted);">Simpan kode ini untuk cek status laporan.</div>
</div>
<p style="font-size:13px;color:var(--muted);margin-bottom:20px;">
Pengurus rumah ibadah terdekat sudah mendapat notifikasi dan akan segera menindaklanjuti.
</p>
<a href="lapor_publik.php" style="display:block;padding:12px;background:var(--surface2);border:1px solid var(--border);border-radius:12px;color:var(--text);text-decoration:none;font-size:13px;">
+ Buat Laporan Baru
</a>
</div>
<?php else: ?>
<div class="card">
<?php if ($err): ?>
<div class="alert alert-err">⚠️ <?= htmlspecialchars($err) ?></div>
<?php endif; ?>
<form method="POST" enctype="multipart/form-data" id="formLapor">
<div class="form-section">Identitas Pelapor (opsional)</div>
<span class="badge-anonim">🔒 Boleh anonim — tidak wajib diisi</span>
<div class="form-group">
<label>Nama Pelapor</label>
<input type="text" name="nama_pelapor" placeholder="Anonim" value="<?= htmlspecialchars($_POST['nama_pelapor'] ?? '') ?>">
</div>
<div class="form-group">
<label>No. HP / WhatsApp</label>
<input type="tel" name="no_hp_pelapor" placeholder="Untuk konfirmasi tindak lanjut (opsional)" value="<?= htmlspecialchars($_POST['no_hp_pelapor'] ?? '') ?>">
</div>
<div class="form-section">Data yang Dilaporkan</div>
<div class="form-group">
<label>Nama Warga yang Perlu Dibantu</label>
<input type="text" name="nama_terlapor" placeholder="Nama lengkap / panggilan" value="<?= htmlspecialchars($_POST['nama_terlapor'] ?? '') ?>">
</div>
<div class="form-group">
<label>Alamat Kejadian <span style="color:var(--red)">*</span></label>
<input type="text" name="alamat_kejadian" required placeholder="Gang, RT/RW, Jalan..." value="<?= htmlspecialchars($_POST['alamat_kejadian'] ?? '') ?>">
</div>
<div class="form-group">
<button type="button" class="loc-btn" onclick="ambilLokasi()">📍 Gunakan Lokasi GPS Saya (opsional)</button>
<span class="loc-ok" id="locOk" style="display:none">✅ Lokasi berhasil diambil</span>
<input type="hidden" name="lat" id="inp_lat">
<input type="hidden" name="lng" id="inp_lng">
</div>
<div class="form-section">Detail Laporan</div>
<div class="form-group">
<label>Kategori</label>
<select name="kategori">
<?php foreach(['kemiskinan'=>'Kemiskinan','kesehatan'=>'Kesehatan','pendidikan'=>'Pendidikan','bencana'=>'Bencana','kekerasan'=>'Kekerasan','lainnya'=>'Lainnya'] as $v=>$l): ?>
<option value="<?=$v?>" <?= ($_POST['kategori']??'kemiskinan')===$v?'selected':'' ?>><?=$l?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label>Tingkat Urgensi <span style="color:var(--red)">*</span></label>
<div class="urgensi-grid">
<?php
$urgensiOpt = [
'rendah' => ['🟢','Rendah','Tidak mendesak'],
'sedang' => ['🟡','Sedang','Perlu diperhatikan'],
'tinggi' => ['🟠','Tinggi','Segera ditangani'],
'darurat' => ['🔴','Darurat','Bahaya / kritis'],
];
$selUrg = $_POST['urgensi'] ?? 'sedang';
foreach ($urgensiOpt as $val => [$icon, $label, $sub]): ?>
<div>
<input type="radio" name="urgensi" id="urg_<?=$val?>" value="<?=$val?>" class="urgensi-opt" <?=$selUrg===$val?'checked':''?>>
<label for="urg_<?=$val?>" class="urgensi-label">
<span class="icon"><?=$icon?></span>
<strong><?=$label?></strong>
<span style="font-size:10px;color:var(--muted)"><?=$sub?></span>
</label>
</div>
<?php endforeach; ?>
</div>
</div>
<div class="form-group">
<label>Deskripsi Kondisi <span style="color:var(--red)">*</span></label>
<textarea name="deskripsi" required placeholder="Ceritakan kondisi warga tersebut secara detail. Misal: sudah berapa lama sakit, apakah punya penghasilan, kondisi tempat tinggal, dsb."><?= htmlspecialchars($_POST['deskripsi'] ?? '') ?></textarea>
<span class="hint">Minimal 20 karakter. Semakin detail, semakin mudah pengurus menindaklanjuti.</span>
</div>
<div class="form-group">
<label>Foto Pendukung (opsional)</label>
<input type="file" name="foto" accept="image/*" style="padding:8px;">
<span class="hint">Max 5MB. Format: JPG, PNG.</span>
</div>
<button type="submit" class="btn" id="btnKirim">📤 Kirim Laporan</button>
<p style="text-align:center;font-size:11px;color:var(--muted);margin-top:12px;">
Data laporan hanya digunakan untuk keperluan sosial dan tidak disebarluaskan.
</p>
</form>
</div>
<?php endif; ?>
<footer>
WebGIS Pengentasan Kemiskinan — Kota Pontianak<br>
Didukung oleh Rumah Ibadah & Dinas Sosial
</footer>
<script>
function ambilLokasi() {
if (!navigator.geolocation) { alert('Browser tidak mendukung GPS'); return; }
navigator.geolocation.getCurrentPosition(function(pos) {
document.getElementById('inp_lat').value = pos.coords.latitude;
document.getElementById('inp_lng').value = pos.coords.longitude;
document.getElementById('locOk').style.display = 'block';
}, function() {
alert('Gagal mengambil lokasi. Pastikan izin lokasi diaktifkan.');
});
}
document.getElementById('formLapor')?.addEventListener('submit', function() {
document.getElementById('btnKirim').disabled = true;
document.getElementById('btnKirim').textContent = '⏳ Mengirim...';
});
</script>
</body>
</html>
@@ -0,0 +1,362 @@
<?php
// ============================================================
// laporan.php — Manajemen Laporan Masyarakat
// Akses: admin, pengurus
// ============================================================
require_once 'koneksi.php';
requireRole('admin','pengurus');
$role = getRole();
$myUid = currentUserId();
// Filter
$filterStatus = $_GET['status'] ?? '';
$filterKategori = $_GET['kategori'] ?? '';
$filterUrgensi = $_GET['urgensi'] ?? '';
$q = trim($_GET['q'] ?? '');
$where = [];
if ($filterStatus) $where[] = "l.status = '" . sanitize($conn, $filterStatus) . "'";
if ($filterKategori) $where[] = "l.kategori = '" . sanitize($conn, $filterKategori) . "'";
if ($filterUrgensi) $where[] = "l.urgensi = '" . sanitize($conn, $filterUrgensi) . "'";
if ($q) {
$qSan = sanitize($conn, $q);
$where[] = "(l.kode_laporan LIKE '%$qSan%' OR l.nama_pelapor LIKE '%$qSan%' OR l.deskripsi LIKE '%$qSan%' OR l.alamat_kejadian LIKE '%$qSan%')";
}
$whereStr = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$laporanList = $conn->query("
SELECT l.*, p.nama AS nama_penduduk,
u1.nama AS nama_verifikator, u2.nama AS nama_prosesor
FROM laporan l
LEFT JOIN penduduk p ON l.penduduk_id = p.id
LEFT JOIN users u1 ON l.diverifikasi_oleh = u1.id
LEFT JOIN users u2 ON l.diproses_oleh = u2.id
$whereStr
ORDER BY FIELD(l.urgensi,'darurat','tinggi','sedang','rendah'), l.created_at DESC
LIMIT 200
")->fetch_all(MYSQLI_ASSOC);
// Stats ringkas
$stats = [
'pending' => $conn->query("SELECT COUNT(*) c FROM laporan WHERE status='pending'")->fetch_assoc()['c'],
'diverifikasi' => $conn->query("SELECT COUNT(*) c FROM laporan WHERE status='diverifikasi'")->fetch_assoc()['c'],
'diproses' => $conn->query("SELECT COUNT(*) c FROM laporan WHERE status='diproses'")->fetch_assoc()['c'],
'selesai' => $conn->query("SELECT COUNT(*) c FROM laporan WHERE status='selesai'")->fetch_assoc()['c'],
'ditolak' => $conn->query("SELECT COUNT(*) c FROM laporan WHERE status='ditolak'")->fetch_assoc()['c'],
];
$statusLabel = [
'pending' => ['label'=>'Pending', 'color'=>'#f0b429'],
'diverifikasi' => ['label'=>'Diverifikasi', 'color'=>'#4a9eff'],
'diproses' => ['label'=>'Diproses', 'color'=>'#9b59b6'],
'selesai' => ['label'=>'Selesai', 'color'=>'#27ae72'],
'ditolak' => ['label'=>'Ditolak', 'color'=>'#e85050'],
];
$urgensiLabel = [
'darurat' => ['label'=>'🚨 Darurat', 'color'=>'#e85050'],
'tinggi' => ['label'=>'⚠️ Tinggi', 'color'=>'#f0b429'],
'sedang' => ['label'=>'🔵 Sedang', 'color'=>'#4a9eff'],
'rendah' => ['label'=>'⬇️ Rendah', 'color'=>'#6b7e96'],
];
$kategoriLabel = [
'kemiskinan' => 'Kemiskinan',
'kesehatan' => 'Kesehatan',
'pendidikan' => 'Pendidikan',
'infrastruktur'=> 'Infrastruktur',
'keamanan' => 'Keamanan',
'lainnya' => 'Lainnya',
];
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Laporan Masyarakat — WebGIS Kemiskinan</title>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#080c11;--surface:#0f1620;--surface2:#161f2c;--surface3:#1e2a3a;
--border:#243044;--border2:#2f3f57;
--text:#e8edf3;--muted:#6b7e96;
--red:#e85050;--green:#27ae72;--yellow:#f0b429;--blue:#4a9eff;--purple:#9b59b6;
--font-head:'Plus Jakarta Sans',sans-serif;--font-body:'Inter',sans-serif;
}
body{font-family:var(--font-body);background:var(--bg);color:var(--text);min-height:100vh;}
.topnav{display:flex;align-items:center;gap:14px;padding:14px 24px;background:var(--surface);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:100;}
.nav-logo{font-family:var(--font-head);font-weight:800;font-size:15px;}
.nav-links{display:flex;gap:6px;flex:1;}
.nav-link{padding:7px 14px;border-radius:8px;color:var(--muted);font-size:13px;font-weight:500;text-decoration:none;transition:background .15s,color .15s;}
.nav-link:hover,.nav-link.active{background:rgba(232,80,80,.12);color:var(--red);}
.container{max-width:1400px;margin:0 auto;padding:28px 24px;}
h2{font-family:var(--font-head);font-size:22px;font-weight:800;margin-bottom:6px;}
/* KPI */
.kpi-row{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:24px;}
.kpi{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:14px 18px;flex:1;min-width:120px;cursor:pointer;transition:border-color .15s;}
.kpi:hover{border-color:var(--border2);}
.kpi.active{border-color:currentColor;}
.kpi-num{font-family:var(--font-head);font-size:26px;font-weight:800;}
.kpi-lbl{font-size:11px;color:var(--muted);margin-top:2px;}
/* Toolbar */
.toolbar{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:16px;align-items:center;}
.toolbar input[type=text]{background:var(--surface);border:1px solid var(--border);color:var(--text);padding:8px 12px;border-radius:8px;font-size:13px;width:220px;}
.toolbar input[type=text]:focus{outline:none;border-color:var(--border2);}
.toolbar select{background:var(--surface);border:1px solid var(--border);color:var(--text);padding:8px 10px;border-radius:8px;font-size:13px;}
.btn{padding:8px 16px;border-radius:8px;border:none;cursor:pointer;font-size:13px;font-weight:500;transition:opacity .15s;}
.btn-primary{background:var(--blue);color:#fff;}
.btn-sm{padding:5px 10px;font-size:12px;border-radius:6px;border:none;cursor:pointer;font-weight:500;}
.btn-sm:hover{opacity:.85;}
/* Table */
.table-wrap{background:var(--surface);border:1px solid var(--border);border-radius:12px;overflow:hidden;}
table{width:100%;border-collapse:collapse;font-size:13px;}
thead tr{background:var(--surface2);}
th{padding:11px 14px;text-align:left;font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;white-space:nowrap;}
td{padding:11px 14px;border-top:1px solid var(--border);vertical-align:top;}
tr:hover td{background:rgba(255,255,255,.02);}
.badge{display:inline-block;padding:3px 9px;border-radius:20px;font-size:11px;font-weight:600;}
.desc-cell{max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted);}
.kode{font-family:monospace;font-size:12px;color:var(--blue);}
/* Modal */
.modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.65);z-index:200;align-items:center;justify-content:center;}
.modal-overlay.open{display:flex;}
.modal{background:var(--surface);border:1px solid var(--border);border-radius:16px;width:560px;max-width:95vw;max-height:90vh;overflow-y:auto;}
.modal-head{display:flex;align-items:center;justify-content:space-between;padding:18px 20px;border-bottom:1px solid var(--border);}
.modal-head h3{font-family:var(--font-head);font-size:16px;font-weight:700;}
.modal-close{background:none;border:none;color:var(--muted);font-size:18px;cursor:pointer;line-height:1;}
.modal-body{padding:20px;}
.detail-row{display:flex;gap:8px;margin-bottom:12px;font-size:13px;}
.detail-key{color:var(--muted);min-width:140px;flex-shrink:0;}
.detail-val{color:var(--text);word-break:break-word;}
.section-title{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin:16px 0 8px;}
.status-form{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;margin-top:12px;}
.status-form select,.status-form textarea{background:var(--surface2);border:1px solid var(--border);color:var(--text);padding:8px 10px;border-radius:8px;font-size:13px;}
.status-form textarea{width:100%;min-height:60px;resize:vertical;}
.foto-preview{width:100%;max-height:260px;object-fit:contain;border-radius:8px;border:1px solid var(--border);margin-bottom:10px;}
/* Empty */
.empty{text-align:center;padding:48px 20px;color:var(--muted);}
.empty-icon{font-size:40px;margin-bottom:10px;}
</style>
</head>
<body>
<nav class="topnav">
<div class="nav-logo">🗺️ WebGIS Kemiskinan</div>
<div class="nav-links">
<a href="index.php" class="nav-link">Peta</a>
<a href="dashboard.php" class="nav-link">Dashboard</a>
<a href="laporan.php" class="nav-link active">Laporan</a>
<a href="jadwal_piket.php" class="nav-link">Jadwal Piket</a>
</div>
<div style="font-size:12px;color:var(--muted)">👤 <?= htmlspecialchars($_SESSION['nama']) ?></div>
<a href="logout.php" style="color:var(--red);font-size:12px;text-decoration:none">Logout</a>
</nav>
<div class="container">
<h2>📢 Laporan Masyarakat</h2>
<p style="color:var(--muted);font-size:13px;margin-bottom:20px;">Kelola dan tindak lanjuti laporan yang masuk dari warga.</p>
<!-- KPI -->
<div class="kpi-row">
<?php
$kpiData = [
'' => ['label'=>'Semua', 'color'=>'var(--text)'],
'pending' => ['label'=>'Pending', 'color'=>'#f0b429'],
'diverifikasi'=>['label'=>'Diverifikasi', 'color'=>'#4a9eff'],
'diproses' => ['label'=>'Diproses', 'color'=>'#9b59b6'],
'selesai' => ['label'=>'Selesai', 'color'=>'#27ae72'],
'ditolak' => ['label'=>'Ditolak', 'color'=>'#e85050'],
];
$total = array_sum($stats);
foreach ($kpiData as $k => $v):
$n = $k ? ($stats[$k] ?? 0) : $total;
$active = $filterStatus === $k ? 'active' : '';
?>
<div class="kpi <?= $active ?>" style="color:<?= $v['color'] ?>" onclick="filterByStatus('<?= $k ?>')">
<div class="kpi-num"><?= $n ?></div>
<div class="kpi-lbl"><?= $v['label'] ?></div>
</div>
<?php endforeach; ?>
</div>
<!-- Toolbar -->
<form method="GET" class="toolbar">
<input type="hidden" name="status" id="inp-status" value="<?= htmlspecialchars($filterStatus) ?>">
<input type="text" name="q" placeholder="🔍 Cari kode / nama / deskripsi…" value="<?= htmlspecialchars($q) ?>">
<select name="kategori">
<option value="">Semua Kategori</option>
<?php foreach ($kategoriLabel as $k=>$l): ?>
<option value="<?=$k?>" <?= $filterKategori===$k?'selected':'' ?>><?= $l ?></option>
<?php endforeach; ?>
</select>
<select name="urgensi">
<option value="">Semua Urgensi</option>
<?php foreach ($urgensiLabel as $k=>$v): ?>
<option value="<?=$k?>" <?= $filterUrgensi===$k?'selected':'' ?>><?= $v['label'] ?></option>
<?php endforeach; ?>
</select>
<button type="submit" class="btn btn-primary">Terapkan</button>
<a href="laporan.php" style="font-size:13px;color:var(--muted);text-decoration:none;padding:8px 4px;">Reset</a>
</form>
<!-- Table -->
<div class="table-wrap">
<?php if (empty($laporanList)): ?>
<div class="empty">
<div class="empty-icon">📭</div>
<div>Belum ada laporan yang sesuai filter.</div>
</div>
<?php else: ?>
<table>
<thead>
<tr>
<th>Kode</th>
<th>Pelapor</th>
<th>Kategori</th>
<th>Urgensi</th>
<th>Deskripsi</th>
<th>Status</th>
<th>Tanggal</th>
<th>Aksi</th>
</tr>
</thead>
<tbody>
<?php foreach ($laporanList as $row):
$stInfo = $statusLabel[$row['status']] ?? ['label'=>$row['status'],'color'=>'#aaa'];
$urInfo = $urgensiLabel[$row['urgensi']] ?? ['label'=>$row['urgensi'],'color'=>'#aaa'];
$rowJson = htmlspecialchars(json_encode($row, JSON_UNESCAPED_UNICODE), ENT_QUOTES);
?>
<tr>
<td><span class="kode"><?= htmlspecialchars($row['kode_laporan']) ?></span></td>
<td><?= htmlspecialchars($row['nama_pelapor'] ?? $row['nama_penduduk'] ?? '—') ?></td>
<td><?= htmlspecialchars($kategoriLabel[$row['kategori']] ?? $row['kategori']) ?></td>
<td><span class="badge" style="background:<?= $urInfo['color'] ?>22;color:<?= $urInfo['color'] ?>"><?= $urInfo['label'] ?></span></td>
<td class="desc-cell" title="<?= htmlspecialchars($row['deskripsi']) ?>"><?= htmlspecialchars($row['deskripsi']) ?></td>
<td><span class="badge" style="background:<?= $stInfo['color'] ?>22;color:<?= $stInfo['color'] ?>"><?= $stInfo['label'] ?></span></td>
<td style="font-size:12px;color:var(--muted)"><?= date('d/m/Y', strtotime($row['created_at'])) ?></td>
<td>
<button class="btn-sm" style="background:var(--surface3);color:var(--text)" onclick='openDetail(<?= $rowJson ?>)'>Detail</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<p style="font-size:12px;color:var(--muted);margin-top:10px;">Menampilkan <?= count($laporanList) ?> laporan</p>
</div>
<!-- Modal Detail -->
<div class="modal-overlay" id="modal" onclick="closeModalOut(event)">
<div class="modal" id="modal-box">
<div class="modal-head">
<h3 id="modal-title">Detail Laporan</h3>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<div class="modal-body" id="modal-body"></div>
</div>
</div>
<script>
let currentId = null;
const statusLabel = <?= json_encode($statusLabel) ?>;
const urgensiLabel = <?= json_encode($urgensiLabel) ?>;
const kategoriLabel = <?= json_encode($kategoriLabel) ?>;
function filterByStatus(s) {
document.getElementById('inp-status').value = s;
document.querySelector('form.toolbar').submit();
}
function closeModal() {
document.getElementById('modal').classList.remove('open');
}
function closeModalOut(e) {
if (e.target === document.getElementById('modal')) closeModal();
}
function openDetail(row) {
currentId = row.id;
document.getElementById('modal-title').textContent = 'Laporan ' + row.kode_laporan;
const ur = urgensiLabel[row.urgensi] || {label: row.urgensi, color: '#aaa'};
const st = statusLabel[row.status] || {label: row.status, color: '#aaa'};
const kat = kategoriLabel[row.kategori] || row.kategori;
let html = '';
if (row.foto) {
html += `<img class="foto-preview" src="uploads/${row.foto}" alt="Foto laporan">`;
}
const fields = [
['Kode Laporan', `<span style="font-family:monospace;color:#4a9eff">${row.kode_laporan}</span>`],
['Nama Pelapor', esc(row.nama_pelapor || row.nama_penduduk || '—')],
['No. HP', esc(row.no_hp_pelapor || '—')],
['Kategori', esc(kat)],
['Urgensi', `<span class="badge" style="background:${ur.color}22;color:${ur.color}">${ur.label}</span>`],
['Status', `<span class="badge" style="background:${st.color}22;color:${st.color}">${st.label}</span>`],
['Alamat', esc(row.alamat_kejadian || '—')],
['Deskripsi', `<span style="white-space:pre-wrap">${esc(row.deskripsi)}</span>`],
['Tanggal Masuk', row.created_at ? row.created_at.substring(0,16) : '—'],
];
if (row.catatan_verifikasi) fields.push(['Catatan Verifikasi', esc(row.catatan_verifikasi)]);
if (row.nama_verifikator) fields.push(['Diverifikasi oleh', esc(row.nama_verifikator)]);
if (row.nama_prosesor) fields.push(['Diproses oleh', esc(row.nama_prosesor)]);
fields.forEach(([k,v]) => {
html += `<div class="detail-row"><div class="detail-key">${k}</div><div class="detail-val">${v}</div></div>`;
});
<?php if (hasRole('admin','pengurus')): ?>
html += `<div class="section-title">Update Status</div>
<div class="status-form" id="status-form">
<div style="display:flex;flex-direction:column;gap:6px;width:100%">
<select id="new-status">
${Object.entries(statusLabel).map(([k,v])=>`<option value="${k}" ${row.status===k?'selected':''}>${v.label}</option>`).join('')}
</select>
<textarea id="new-catatan" placeholder="Catatan (opsional)…">${esc(row.catatan_verifikasi || '')}</textarea>
<button class="btn btn-primary" onclick="updateStatus()">Simpan Perubahan</button>
</div>
</div>`;
<?php endif; ?>
document.getElementById('modal-body').innerHTML = html;
document.getElementById('modal').classList.add('open');
}
function esc(s) {
if (!s) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function updateStatus() {
const status = document.getElementById('new-status').value;
const catatan = document.getElementById('new-catatan').value;
const fd = new FormData();
fd.append('id', currentId);
fd.append('status', status);
fd.append('catatan', catatan);
fetch('api/laporan.php?action=update_status', { method:'POST', body:fd })
.then(r => r.json())
.then(d => {
if (d.success) {
closeModal();
location.reload();
} else {
alert('Gagal: ' + (d.error || 'unknown'));
}
})
.catch(() => alert('Terjadi kesalahan jaringan.'));
}
</script>
</body>
</html>
@@ -0,0 +1,402 @@
<?php
require_once 'koneksi.php';
if (isLoggedIn()) {
header('Location: index.php');
exit;
}
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = sanitize($conn, $_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
$user = dbFirst($conn, "SELECT u.*, r.slug as role_slug, r.nama as role_nama FROM users u JOIN roles r ON u.role_id=r.id WHERE u.username=? AND u.aktif=1", [$username], 's');
if ($user && password_verify($password, $user['password'])) {
$_SESSION['user_id'] = $user['id'];
$_SESSION['nama'] = $user['nama'];
$_SESSION['username'] = $user['username'];
$_SESSION['role_id'] = $user['role_id'];
$_SESSION['role_slug'] = $user['role_slug'];
$_SESSION['role_nama'] = $user['role_nama'];
$_SESSION['ibadah_id'] = $user['rumah_ibadah_id'];
$conn->query("UPDATE users SET last_login=NOW() WHERE id={$user['id']}");
logAktivitas($conn, 'LOGIN', 'users', $user['id']);
header('Location: index.php');
exit;
} else {
$error = 'Username atau password salah.';
}
}
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login — WebGIS Pengentasan Kemiskinan</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#080c12;
--surface:#0f1520;
--surface2:#16202e;
--surface3:#1d2b3e;
--border:#253347;
--border2:#2e3f57;
--text:#edf2f9;
--text2:#c4cfdf;
--muted:#6b7e96;
--muted2:#4e6278;
--red:#e8455a;
--red2:#ff6b7d;
--green:#22c97d;
--blue:#3d8ef0;
--yellow:#f0b429;
--purple:#9d6ef5;
--font-head:'Plus Jakarta Sans',sans-serif;
--font-body:'Inter',sans-serif;
}
body{
font-family:var(--font-body);
background:var(--bg);
min-height:100vh;
display:flex;
align-items:center;
justify-content:center;
padding:20px;
position:relative;
overflow:hidden;
}
/* Background effects */
.bg-grid{
position:fixed;inset:0;
background-image:
linear-gradient(rgba(61,142,240,0.04) 1px,transparent 1px),
linear-gradient(90deg,rgba(61,142,240,0.04) 1px,transparent 1px);
background-size:52px 52px;
pointer-events:none;
}
.bg-glow{
position:fixed;pointer-events:none;border-radius:50%;filter:blur(100px);
}
.glow1{width:500px;height:500px;background:radial-gradient(circle,rgba(232,69,90,0.18),transparent 70%);top:-150px;left:-150px;}
.glow2{width:450px;height:450px;background:radial-gradient(circle,rgba(61,142,240,0.15),transparent 70%);bottom:-100px;right:-100px;}
.glow3{width:300px;height:300px;background:radial-gradient(circle,rgba(157,110,245,0.1),transparent 70%);top:50%;left:50%;transform:translate(-50%,-50%);}
.wrapper{
width:100%;max-width:480px;
position:relative;z-index:1;
}
/* Card */
.card{
background:var(--surface);
border:1px solid var(--border);
border-radius:28px;
padding:44px 40px;
box-shadow:0 40px 100px rgba(0,0,0,0.6),0 0 0 1px rgba(255,255,255,0.03) inset;
}
/* Header */
.logo{display:flex;align-items:center;gap:14px;margin-bottom:36px;}
.logo-icon{
width:50px;height:50px;
background:linear-gradient(135deg,var(--red),var(--red2));
border-radius:16px;
display:flex;align-items:center;justify-content:center;
font-size:24px;
box-shadow:0 8px 28px rgba(232,69,90,0.45);
flex-shrink:0;
}
.logo-text{
font-family:var(--font-head);
font-size:15px;font-weight:800;
color:var(--text);
line-height:1.2;
letter-spacing:-0.2px;
}
.logo-sub{font-size:11px;color:var(--muted);margin-top:4px;line-height:1.4;}
.heading{
font-family:var(--font-head);
font-size:30px;font-weight:800;
color:var(--text);
margin-bottom:6px;
letter-spacing:-0.6px;
line-height:1.2;
}
.subtitle{
font-size:14px;
color:var(--muted);
margin-bottom:32px;
font-weight:400;
line-height:1.5;
}
/* Form */
.form-group{margin-bottom:18px;}
label{
display:block;
font-size:11.5px;
font-weight:600;
color:var(--muted);
text-transform:uppercase;
letter-spacing:0.8px;
margin-bottom:8px;
}
input{
width:100%;
background:var(--surface2);
border:1px solid var(--border);
border-radius:12px;
padding:13px 16px;
color:var(--text);
font-family:var(--font-body);
font-size:14.5px;
outline:none;
transition:border-color .2s,background .2s,box-shadow .2s;
font-weight:400;
}
input:focus{
border-color:var(--red);
background:var(--surface3);
box-shadow:0 0 0 3px rgba(232,69,90,0.12);
}
input::placeholder{color:var(--muted2);font-size:14px;}
.error-box{
background:rgba(232,69,90,.1);
border:1px solid rgba(232,69,90,.25);
border-radius:10px;
padding:11px 15px;
color:#ff7a8a;
font-size:13px;
margin-bottom:20px;
display:flex;align-items:center;gap:8px;
}
.btn-login{
width:100%;
padding:14px;
background:linear-gradient(135deg,var(--red) 0%,#f05068 100%);
color:white;border:none;
border-radius:12px;
font-family:var(--font-head);
font-size:15px;font-weight:700;
cursor:pointer;
transition:opacity .2s,transform .15s,box-shadow .2s;
box-shadow:0 6px 24px rgba(232,69,90,0.45);
letter-spacing:-0.1px;
}
.btn-login:hover{opacity:.92;transform:translateY(-1px);box-shadow:0 8px 28px rgba(232,69,90,0.55);}
.btn-login:active{transform:translateY(0);}
/* Divider */
.divider{
display:flex;align-items:center;gap:12px;
margin:28px 0 18px;
color:var(--muted2);
font-size:11.5px;
font-weight:500;
text-transform:uppercase;
letter-spacing:0.6px;
}
.divider::before,.divider::after{content:'';flex:1;height:1px;background:var(--border);}
/* Demo section label */
.demo-label{
font-family:var(--font-head);
font-size:12px;font-weight:700;
color:var(--muted);
text-transform:uppercase;
letter-spacing:0.8px;
margin-bottom:12px;
}
.demo-sublabel{
font-size:11.5px;color:var(--muted2);
margin-bottom:14px;
font-style:italic;
}
/* Demo accounts grid */
.demo-grid{
display:grid;
grid-template-columns:1fr 1fr;
gap:8px;
}
.demo-btn{
display:flex;align-items:center;gap:10px;
background:var(--surface2);
border:1px solid var(--border);
border-radius:12px;
padding:10px 12px;
cursor:pointer;
transition:border-color .2s,background .2s,transform .15s;
text-align:left;
color:var(--text);
width:100%;
}
.demo-btn:hover{
border-color:var(--border2);
background:var(--surface3);
transform:translateY(-1px);
}
.demo-btn:active{transform:translateY(0);}
.demo-icon{
width:34px;height:34px;
border-radius:10px;
display:flex;align-items:center;justify-content:center;
font-size:16px;
flex-shrink:0;
}
.demo-info{flex:1;min-width:0;}
.demo-name{
font-family:var(--font-head);
font-size:12.5px;font-weight:700;
color:var(--text);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
line-height:1.3;
}
.demo-role{font-size:10.5px;color:var(--muted);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.demo-user{
font-size:10px;
color:var(--muted2);
font-family:'Courier New',monospace;
background:rgba(255,255,255,0.04);
padding:2px 6px;border-radius:5px;
margin-top:3px;
display:inline-block;
letter-spacing:0.2px;
}
.pass-info{
margin-top:14px;
text-align:center;
font-size:11.5px;
color:var(--muted2);
}
.pass-info span{
color:var(--text2);
font-family:'Courier New',monospace;
background:rgba(255,255,255,0.06);
padding:2px 8px;border-radius:5px;
font-size:12px;
}
@media(max-width:420px){
.card{padding:32px 24px;}
.demo-grid{grid-template-columns:1fr;}
}
</style>
</head>
<body>
<div class="bg-grid"></div>
<div class="bg-glow glow1"></div>
<div class="bg-glow glow2"></div>
<div class="bg-glow glow3"></div>
<div class="wrapper">
<div class="card">
<!-- Logo -->
<div class="logo">
<div class="logo-icon">🗺️</div>
<div>
<div class="logo-text">WebGIS Kemiskinan</div>
<div class="logo-sub">Sistem Pengentasan Kemiskinan Berbasis Masyarakat</div>
</div>
</div>
<h1 class="heading">Masuk</h1>
<p class="subtitle">Sistem Monitoring &amp; Distribusi Bantuan Sosial</p>
<?php if ($error): ?>
<div class="error-box">⚠️ <?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<form method="POST" id="loginForm">
<div class="form-group">
<label>Username</label>
<input type="text" name="username" id="uname" placeholder="Masukkan username" required autocomplete="username">
</div>
<div class="form-group">
<label>Password</label>
<input type="password" name="password" id="upass" placeholder="Masukkan password" required autocomplete="current-password">
</div>
<button type="submit" class="btn-login">Masuk ke Sistem →</button>
</form>
<div class="divider">Demo Akun</div>
<div class="demo-grid">
<!-- Pimpinan -->
<button class="demo-btn" onclick="fillLogin('pimpinan','password123')">
<div class="demo-icon" style="background:rgba(61,142,240,0.18);">🏛️</div>
<div class="demo-info">
<div class="demo-name">Pimpinan</div>
<div class="demo-role">Monitoring</div>
<div class="demo-user">pimpinan</div>
</div>
</button>
<!-- Warga -->
<button class="demo-btn" onclick="fillLogin('warga','password123')">
<div class="demo-icon" style="background:rgba(240,180,41,0.18);">👤</div>
<div class="demo-info">
<div class="demo-name">Warga</div>
<div class="demo-role">Masyarakat</div>
<div class="demo-user">warga</div>
</div>
</button>
<!-- Pengurus 1 -->
<button class="demo-btn" onclick="fillLogin('pengurus1','password123')">
<div class="demo-icon" style="background:rgba(34,201,125,0.18);">🕌</div>
<div class="demo-info">
<div class="demo-name">Pengurus 1</div>
<div class="demo-role">Input & Verifikasi</div>
<div class="demo-user">pengurus1</div>
</div>
</button>
<!-- Pengurus 2 -->
<button class="demo-btn" onclick="fillLogin('pengurus2','password123')">
<div class="demo-icon" style="background:rgba(157,110,245,0.18);">🏠</div>
<div class="demo-info">
<div class="demo-name">Pengurus 2</div>
<div class="demo-role">Input & Verifikasi</div>
<div class="demo-user">pengurus2</div>
</div>
</button>
<!-- Admin (full width) -->
<button class="demo-btn" onclick="fillLogin('admin','password123')" style="grid-column:1/-1;">
<div class="demo-icon" style="background:rgba(232,69,90,0.18);">👑</div>
<div class="demo-info">
<div class="demo-name">Administrator</div>
<div class="demo-role">Akses penuh sistem</div>
<div class="demo-user">admin</div>
</div>
</button>
</div>
<p class="pass-info">Password semua akun: <span>password123</span></p>
</div>
</div>
<script>
function fillLogin(u, p) {
document.getElementById('uname').value = u;
document.getElementById('upass').value = p;
document.getElementById('loginForm').submit();
}
</script>
</body>
</html>
@@ -0,0 +1,8 @@
<?php
require_once 'koneksi.php';
if (isLoggedIn()) {
logAktivitas($conn, 'LOGOUT', 'users', currentUserId());
session_destroy();
}
header('Location: login.php');
exit;
@@ -0,0 +1,369 @@
<?php
// ============================================================
// peristiwa_kependudukan.php — Kelola histori vital penduduk
// Akses: pengurus, admin
// ============================================================
require_once 'koneksi.php';
requireRole('admin','pengurus');
$msg = ''; $err = '';
$role = getRole();
$myRumahIbadahId = $_SESSION['rumah_ibadah_id'] ?? null;
// Filter: pengurus hanya lihat wilayahnya
$filterRumah = ($role === 'pengurus' && $myRumahIbadahId)
? "AND k.rumah_ibadah_id = $myRumahIbadahId"
: '';
// ---- HANDLE POST (simpan peristiwa baru) --------------------
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['aksi'])) {
if ($_POST['aksi'] === 'simpan') {
$pid = intval($_POST['penduduk_id']);
$jenis = $_POST['jenis'];
$tgl = $_POST['tanggal'];
$ket = trim($_POST['keterangan'] ?? '');
$alamatAs = trim($_POST['alamat_asal'] ?? '');
$alamatTj = trim($_POST['alamat_tujuan'] ?? '');
$sebab = trim($_POST['sebab_meninggal'] ?? '');
$dicatatOleh = currentUserId();
// Upload dokumen
$dok = null;
if (!empty($_FILES['dokumen']['name']))
$dok = uploadFile($_FILES['dokumen'], 'peristiwa', ['jpg','jpeg','png','pdf']);
// Ambil keluarga_id dari penduduk
$r = $conn->query("SELECT keluarga_id FROM penduduk WHERE id=$pid");
$kidRow = $r->fetch_assoc();
$kid = $kidRow['keluarga_id'] ?? 'NULL';
$stmt = $conn->prepare("INSERT INTO peristiwa_kependudukan
(penduduk_id, keluarga_id, jenis, tanggal, keterangan,
alamat_asal, alamat_tujuan, sebab_meninggal, dokumen, dicatat_oleh)
VALUES (?,?,?,?,?,?,?,?,?,?)");
$kidBind = $kidRow['keluarga_id'] ?: null;
$sebabBind = $sebab ?: null;
$asBind = $alamatAs ?: null;
$tjBind = $alamatTj ?: null;
$dokBind = $dok ?: null;
$ketBind = $ket ?: null;
$stmt->bind_param('iisssssssi',
$pid, $kidBind, $jenis, $tgl, $ketBind,
$asBind, $tjBind, $sebabBind, $dokBind, $dicatatOleh);
if ($stmt->execute()) {
$newId = $conn->insert_id;
logAktivitas($conn, 'CREATE_PERISTIWA', 'peristiwa_kependudukan', $newId);
// Sinkronisasi otomatis ke tabel penduduk sesuai jenis
if ($jenis === 'meninggal') {
$tglSan = sanitize($conn, $tgl);
$conn->query("UPDATE penduduk SET status_hidup='meninggal', tanggal_meninggal='$tglSan', updated_by=$dicatatOleh WHERE id=$pid");
} elseif ($jenis === 'menikah') {
$conn->query("UPDATE penduduk SET status_kawin='kawin', updated_by=$dicatatOleh WHERE id=$pid");
} elseif ($jenis === 'cerai') {
$conn->query("UPDATE penduduk SET status_kawin='cerai_hidup', updated_by=$dicatatOleh WHERE id=$pid");
} elseif ($jenis === 'pindah_keluar') {
$conn->query("UPDATE penduduk SET keluarga_id=NULL, domisili_sama_kk=0, updated_by=$dicatatOleh WHERE id=$pid");
}
$msg = 'Peristiwa berhasil dicatat.';
} else {
$err = 'Gagal menyimpan: ' . $conn->error;
}
$stmt->close();
} elseif ($_POST['aksi'] === 'verifikasi') {
$id = intval($_POST['id']);
$uid = currentUserId();
$conn->query("UPDATE peristiwa_kependudukan SET diverifikasi=1, diverifikasi_oleh=$uid, diverifikasi_at=NOW() WHERE id=$id");
logAktivitas($conn, 'VERIFIKASI_PERISTIWA', 'peristiwa_kependudukan', $id);
$msg = 'Peristiwa berhasil diverifikasi.';
}
}
// ---- Load penduduk untuk dropdown -------------------------
$pendudukList = $conn->query("
SELECT p.id, p.nama, p.nik, k.alamat, ri.nama AS ibadah
FROM penduduk p
LEFT JOIN keluarga k ON p.keluarga_id = k.id
LEFT JOIN rumah_ibadah ri ON k.rumah_ibadah_id = ri.id
WHERE p.status_hidup='hidup' $filterRumah
ORDER BY p.nama")->fetch_all(MYSQLI_ASSOC);
// ---- Load histori peristiwa --------------------------------
$filter = $_GET['jenis'] ?? '';
$search = sanitize($conn, $_GET['q'] ?? '');
$wJenis = $filter ? "AND pk.jenis='$filter'" : '';
$wSearch = $search ? "AND (p.nama LIKE '%$search%' OR p.nik LIKE '%$search%')" : '';
$peristiwa = $conn->query("
SELECT pk.*,
p.nama AS nama_penduduk, p.nik,
TIMESTAMPDIFF(YEAR, p.tanggal_lahir, CURDATE()) AS umur,
u.nama AS dicatat_nama,
uv.nama AS diverifikasi_nama
FROM peristiwa_kependudukan pk
JOIN penduduk p ON pk.penduduk_id = p.id
LEFT JOIN keluarga k ON p.keluarga_id = k.id
LEFT JOIN users u ON pk.dicatat_oleh = u.id
LEFT JOIN users uv ON pk.diverifikasi_oleh = uv.id
WHERE 1=1 $filterRumah $wJenis $wSearch
ORDER BY pk.tanggal DESC, pk.created_at DESC
LIMIT 200")->fetch_all(MYSQLI_ASSOC);
$jenisLabel = [
'lahir'=>'Kelahiran','meninggal'=>'Kematian','menikah'=>'Pernikahan',
'cerai'=>'Perceraian','pindah_masuk'=>'Pindah Masuk','pindah_keluar'=>'Pindah Keluar',
'ganti_nama'=>'Ganti Nama','perubahan_status_ekonomi'=>'Perubahan Status Ekonomi'
];
$jenisIcon = [
'lahir'=>'👶','meninggal'=>'🕊️','menikah'=>'💍','cerai'=>'💔',
'pindah_masuk'=>'🏠','pindah_keluar'=>'🚚','ganti_nama'=>'📝','perubahan_status_ekonomi'=>'📊'
];
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Peristiwa Kependudukan — WebGIS Kemiskinan</title>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{--bg:#080c11;--surface:#0f1620;--surface2:#161f2c;--surface3:#1e2a3a;--border:#243044;--border2:#2f3f57;--text:#e8edf3;--muted:#6b7e96;--red:#e85050;--green:#27ae72;--yellow:#f0b429;--blue:#4a9eff;--font-head:'Plus Jakarta Sans',sans-serif;--font-body:'Inter',sans-serif;}
body{font-family:var(--font-body);background:var(--bg);color:var(--text);min-height:100vh;}
.topnav{display:flex;align-items:center;gap:14px;padding:14px 24px;background:var(--surface);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:100;}
.nav-logo{font-family:var(--font-head);font-weight:800;font-size:15px;}
.nav-links{display:flex;gap:6px;flex:1;}
.nav-link{padding:7px 14px;border-radius:8px;color:var(--muted);font-size:13px;font-weight:500;text-decoration:none;transition:background .15s,color .15s;}
.nav-link:hover,.nav-link.active{background:rgba(232,80,80,.12);color:var(--red);}
.container{max-width:1200px;margin:0 auto;padding:28px 24px;}
h2{font-family:var(--font-head);font-size:22px;font-weight:800;margin-bottom:22px;}
.toolbar{display:flex;align-items:center;gap:10px;margin-bottom:18px;flex-wrap:wrap;}
.search-box,.filter-sel{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:9px 14px;color:var(--text);font-family:var(--font-body);font-size:13px;outline:none;}
.search-box{flex:1;min-width:200px;}.search-box:focus{border-color:var(--red);}
.filter-sel option{background:var(--surface2);}
.btn{padding:9px 18px;border:none;border-radius:10px;font-family:var(--font-body);font-size:13px;font-weight:600;cursor:pointer;transition:opacity .2s;}
.btn:hover{opacity:.88;}.btn-primary{background:var(--red);color:#fff;box-shadow:0 3px 12px rgba(232,80,80,.3);}
.btn-success{background:var(--green);color:#fff;}.btn-sm{padding:5px 12px;font-size:12px;border-radius:8px;}
.btn-outline{background:transparent;border:1px solid var(--border);color:var(--text);}
.card{background:var(--surface);border:1px solid var(--border);border-radius:16px;overflow:hidden;margin-bottom:28px;}
.tbl-wrap{overflow-x:auto;}
table{width:100%;border-collapse:collapse;font-size:13px;}
th{text-align:left;padding:10px 14px;background:var(--surface2);color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.5px;font-weight:600;border-bottom:1px solid var(--border);}
td{padding:10px 14px;border-bottom:1px solid var(--border2);vertical-align:middle;}
tr:hover td{background:rgba(255,255,255,.02);}
.badge{display:inline-block;padding:2px 8px;border-radius:20px;font-size:10px;font-weight:600;}
.b-verif{background:rgba(39,174,114,.2);color:var(--green);}
.b-belum{background:rgba(107,126,150,.15);color:var(--muted);}
.jenis-badge{padding:3px 10px;border-radius:20px;font-size:11px;font-weight:600;background:var(--surface2);border:1px solid var(--border);}
.modal-bg{display:none;position:fixed;inset:0;background:rgba(0,0,0,.65);backdrop-filter:blur(4px);z-index:500;align-items:center;justify-content:center;padding:20px;}
.modal-bg.open{display:flex;}
.modal{background:var(--surface);border:1px solid var(--border);border-radius:20px;width:100%;max-width:580px;max-height:90vh;overflow-y:auto;}
.modal-header{display:flex;align-items:center;gap:12px;padding:18px 22px;border-bottom:1px solid var(--border);position:sticky;top:0;background:var(--surface);}
.modal-title{font-family:var(--font-head);font-size:16px;font-weight:700;flex:1;}
.modal-close{width:30px;height:30px;background:var(--surface2);border:1px solid var(--border);border-radius:8px;display:flex;align-items:center;justify-content:center;cursor:pointer;color:var(--muted);font-size:16px;}
.modal-body{padding:22px;}
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px;}
.form-full{grid-column:1/-1;}
.form-group{display:flex;flex-direction:column;gap:6px;}
.form-group label{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;font-weight:500;}
.form-group input,.form-group select,.form-group textarea{background:var(--surface2);border:1px solid var(--border);border-radius:10px;padding:10px 13px;color:var(--text);font-family:var(--font-body);font-size:13.5px;outline:none;transition:border-color .2s;}
.form-group input:focus,.form-group select:focus,.form-group textarea:focus{border-color:var(--red);}
.form-group select option{background:var(--surface2);}
.form-section{font-family:var(--font-head);font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.8px;padding-bottom:8px;border-bottom:1px solid var(--border);grid-column:1/-1;margin-top:6px;}
.form-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:20px;}
.extra-field{display:none;}
.toast-container{position:fixed;top:20px;right:20px;z-index:9999;display:flex;flex-direction:column;gap:8px;}
.toast{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:10px 16px;font-size:13px;box-shadow:0 8px 24px rgba(0,0,0,.4);animation:slideIn .2s ease;min-width:260px;}
.toast.success{border-left:3px solid var(--green);}.toast.error{border-left:3px solid var(--red);}
@keyframes slideIn{from{transform:translateX(20px);opacity:0}to{transform:translateX(0);opacity:1}}
.empty{text-align:center;color:var(--muted);padding:40px;font-size:14px;}
.alert{padding:12px 16px;border-radius:10px;font-size:13px;margin-bottom:16px;}
.alert-ok{background:rgba(39,174,114,.12);border:1px solid rgba(39,174,114,.3);color:var(--green);}
.alert-err{background:rgba(232,80,80,.15);border:1px solid rgba(232,80,80,.3);color:var(--red);}
</style>
</head>
<body>
<div class="toast-container" id="toastCont"></div>
<nav class="topnav">
<div class="nav-logo">🗺️ WebGIS Kemiskinan</div>
<div class="nav-links">
<a href="index.php" class="nav-link">Peta</a>
<a href="dashboard.php" class="nav-link">Dashboard</a>
<a href="keluarga.php" class="nav-link">Keluarga</a>
<a href="peristiwa_kependudukan.php" class="nav-link active">Peristiwa</a>
</div>
<div style="font-size:12px;color:var(--muted)">👤 <?= htmlspecialchars($_SESSION['nama']) ?></div>
<a href="logout.php" style="color:var(--red);font-size:12px;text-decoration:none">Logout</a>
</nav>
<div class="container">
<h2>📋 Peristiwa Kependudukan</h2>
<?php if ($msg): ?><div class="alert alert-ok">✅ <?= htmlspecialchars($msg) ?></div><?php endif; ?>
<?php if ($err): ?><div class="alert alert-err">⚠️ <?= htmlspecialchars($err) ?></div><?php endif; ?>
<div class="toolbar">
<form method="GET" style="display:contents">
<input type="text" name="q" class="search-box" placeholder="🔍 Cari nama / NIK..." value="<?= htmlspecialchars($_GET['q']??'') ?>">
<select name="jenis" class="filter-sel" onchange="this.form.submit()">
<option value="">Semua Jenis</option>
<?php foreach($jenisLabel as $v=>$l): ?>
<option value="<?=$v?>" <?= ($filter===$v)?'selected':'' ?>><?=$jenisIcon[$v]?> <?=$l?></option>
<?php endforeach; ?>
</select>
<button type="submit" class="btn btn-outline">Cari</button>
</form>
<button class="btn btn-primary" onclick="openModal()">+ Catat Peristiwa</button>
</div>
<div class="card">
<div class="tbl-wrap">
<table>
<thead>
<tr>
<th>Tanggal</th><th>Penduduk</th><th>Jenis Peristiwa</th>
<th>Keterangan</th><th>Dicatat Oleh</th><th>Status</th><th>Aksi</th>
</tr>
</thead>
<tbody>
<?php if (empty($peristiwa)): ?>
<tr><td colspan="7" class="empty">Belum ada data peristiwa.</td></tr>
<?php else: foreach ($peristiwa as $p): ?>
<tr>
<td><?= date('d/m/Y', strtotime($p['tanggal'])) ?></td>
<td>
<div style="font-weight:600"><?= htmlspecialchars($p['nama_penduduk']) ?></div>
<div style="font-size:11px;color:var(--muted)"><?= htmlspecialchars($p['nik']) ?> · <?= $p['umur'] ?> th</div>
</td>
<td><span class="jenis-badge"><?= $jenisIcon[$p['jenis']]??'' ?> <?= $jenisLabel[$p['jenis']]??$p['jenis'] ?></span></td>
<td style="max-width:240px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
<?= htmlspecialchars($p['keterangan'] ?: '-') ?>
<?php if ($p['sebab_meninggal']): ?>
<div style="font-size:11px;color:var(--muted)">Sebab: <?= htmlspecialchars($p['sebab_meninggal']) ?></div>
<?php endif; ?>
</td>
<td style="font-size:12px;color:var(--muted)"><?= htmlspecialchars($p['dicatat_nama'] ?: 'Sistem') ?></td>
<td>
<?php if ($p['diverifikasi']): ?>
<span class="badge b-verif">✓ Terverifikasi</span>
<div style="font-size:10px;color:var(--muted)"><?= htmlspecialchars($p['diverifikasi_nama']) ?></div>
<?php else: ?>
<span class="badge b-belum">Belum diverifikasi</span>
<?php endif; ?>
</td>
<td>
<?php if (!$p['diverifikasi'] && hasRole('admin','pengurus')): ?>
<form method="POST" style="display:inline">
<input type="hidden" name="aksi" value="verifikasi">
<input type="hidden" name="id" value="<?= $p['id'] ?>">
<button type="submit" class="btn btn-success btn-sm" onclick="return confirm('Verifikasi peristiwa ini?')">✓ Verifikasi</button>
</form>
<?php endif; ?>
<?php if ($p['dokumen']): ?>
<a href="uploads/<?= htmlspecialchars($p['dokumen']) ?>" target="_blank" class="btn btn-outline btn-sm">📎 Dok</a>
<?php endif; ?>
</td>
</tr>
<?php endforeach; endif; ?>
</tbody>
</table>
</div>
</div>
</div>
<!-- Modal Catat Peristiwa -->
<div class="modal-bg" id="modalBg">
<div class="modal">
<div class="modal-header">
<span class="modal-title">📋 Catat Peristiwa Kependudukan</span>
<div class="modal-close" onclick="closeModal()">✕</div>
</div>
<div class="modal-body">
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="aksi" value="simpan">
<div class="form-grid">
<div class="form-full form-group">
<label>Penduduk <span style="color:var(--red)">*</span></label>
<select name="penduduk_id" required id="selPenduduk">
<option value="">-- Pilih Penduduk --</option>
<?php foreach ($pendudukList as $pd): ?>
<option value="<?= $pd['id'] ?>"><?= htmlspecialchars($pd['nama']) ?> — <?= htmlspecialchars($pd['nik']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label>Jenis Peristiwa <span style="color:var(--red)">*</span></label>
<select name="jenis" required id="selJenis" onchange="toggleExtra(this.value)">
<option value="">-- Pilih --</option>
<?php foreach ($jenisLabel as $v=>$l): ?>
<option value="<?=$v?>"><?=$jenisIcon[$v]?> <?=$l?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label>Tanggal <span style="color:var(--red)">*</span></label>
<input type="date" name="tanggal" required value="<?= date('Y-m-d') ?>">
</div>
<div class="form-full form-group">
<label>Keterangan</label>
<textarea name="keterangan" rows="3" placeholder="Detail tambahan peristiwa..."></textarea>
</div>
<!-- Field khusus meninggal -->
<div class="form-full form-group extra-field" id="ex_meninggal">
<label>Sebab Meninggal</label>
<input type="text" name="sebab_meninggal" placeholder="Sakit, kecelakaan, usia tua, dll">
</div>
<!-- Field khusus pindah -->
<div class="form-group extra-field" id="ex_pindah_as">
<label>Alamat Asal</label>
<input type="text" name="alamat_asal" placeholder="Alamat sebelum pindah">
</div>
<div class="form-group extra-field" id="ex_pindah_tj">
<label>Alamat Tujuan</label>
<input type="text" name="alamat_tujuan" placeholder="Alamat tujuan pindah">
</div>
<div class="form-full form-group">
<label>Dokumen Pendukung (opsional)</label>
<input type="file" name="dokumen" accept=".jpg,.jpeg,.png,.pdf" style="padding:8px">
<span style="font-size:11px;color:var(--muted)">Scan surat keterangan RT, akta, dll. Max 5MB.</span>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-outline" onclick="closeModal()">Batal</button>
<button type="submit" class="btn btn-primary">💾 Simpan Peristiwa</button>
</div>
</form>
</div>
</div>
</div>
<script>
function openModal(){ document.getElementById('modalBg').classList.add('open'); }
function closeModal(){ document.getElementById('modalBg').classList.remove('open'); }
document.getElementById('modalBg').addEventListener('click', function(e){ if(e.target===this) closeModal(); });
function toggleExtra(jenis) {
['ex_meninggal','ex_pindah_as','ex_pindah_tj'].forEach(id => {
document.getElementById(id).style.display = 'none';
});
if (jenis === 'meninggal') {
document.getElementById('ex_meninggal').style.display = 'flex';
} else if (jenis === 'pindah_masuk' || jenis === 'pindah_keluar') {
document.getElementById('ex_pindah_as').style.display = 'flex';
document.getElementById('ex_pindah_tj').style.display = 'flex';
}
}
<?php if ($msg): ?>
(function(){ var t=document.createElement('div'); t.className='toast success'; t.textContent='✅ <?= addslashes($msg) ?>'; document.getElementById('toastCont').appendChild(t); setTimeout(()=>t.remove(),3500); })();
<?php endif; ?>
</script>
</body>
</html>
@@ -0,0 +1,579 @@
<?php
// ============================================================
// rekomendasi_pelatihan.php — Kelola & tampilkan rekomendasi
// pelatihan per penduduk berdasarkan umur & status ekonomi
// Akses: admin, pengurus
// ============================================================
require_once 'koneksi.php';
requireRole('admin','pengurus');
$msg = ''; $err = '';
$role = getRole();
$myRumahIbadahId = $_SESSION['rumah_ibadah_id'] ?? null;
$filterRumah = ($role === 'pengurus' && $myRumahIbadahId)
? "AND k.rumah_ibadah_id = $myRumahIbadahId" : '';
// ---- HANDLE POST --------------------------------------------
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$aksi = $_POST['aksi'] ?? '';
// Tambah master pelatihan baru
if ($aksi === 'tambah_master') {
$nama = sanitize($conn, trim($_POST['nama']));
$kat = $_POST['kategori'];
$desk = sanitize($conn, trim($_POST['deskripsi'] ?? ''));
$penye = sanitize($conn, trim($_POST['penyelenggara'] ?? ''));
$kontak = sanitize($conn, trim($_POST['kontak_penyelenggara'] ?? ''));
$umin = intval($_POST['usia_min']);
$umaks = intval($_POST['usia_maks']);
$gender = $_POST['gender'];
$sasaran = $_POST['status_ekonomi_sasaran'];
$prsyrt = sanitize($conn, trim($_POST['prasyarat'] ?? ''));
$durasi = sanitize($conn, trim($_POST['durasi'] ?? ''));
$biaya = floatval($_POST['biaya'] ?? 0);
$conn->query("INSERT INTO rekomendasi_pelatihan
(nama,kategori,deskripsi,penyelenggara,kontak_penyelenggara,
usia_min,usia_maks,gender,status_ekonomi_sasaran,prasyarat,durasi,biaya)
VALUES ('$nama','$kat','$desk','$penye','$kontak',
$umin,$umaks,'$gender','$sasaran','$prsyrt','$durasi',$biaya)");
logAktivitas($conn,'CREATE_REKOMENDASI_PELATIHAN','rekomendasi_pelatihan',$conn->insert_id);
$msg = "Pelatihan '$nama' berhasil ditambahkan.";
// Rekomendasikan pelatihan ke penduduk tertentu
} elseif ($aksi === 'rekomendasikan') {
$pid = intval($_POST['penduduk_id']);
$rekId = intval($_POST['rekomendasi_pelatihan_id']);
$catatan= sanitize($conn, trim($_POST['catatan'] ?? ''));
$oleh = currentUserId();
$tgl = date('Y-m-d');
// Cek duplikat
$cek = $conn->query("SELECT id FROM penduduk_pelatihan
WHERE penduduk_id=$pid AND rekomendasi_pelatihan_id=$rekId
AND status NOT IN ('selesai','batal')")->fetch_assoc();
if ($cek) {
$err = 'Pelatihan ini sudah pernah direkomendasikan ke penduduk tersebut dan masih aktif.';
} else {
$conn->query("INSERT INTO penduduk_pelatihan
(penduduk_id,rekomendasi_pelatihan_id,status,tanggal_rekomendasi,catatan,direkomendasikan_oleh)
VALUES ($pid,$rekId,'direkomendasikan','$tgl','$catatan',$oleh)");
// Kirim notifikasi ke pengurus lain
logAktivitas($conn,'REKOMENDASIKAN_PELATIHAN','penduduk_pelatihan',$conn->insert_id);
$msg = 'Rekomendasi berhasil dikirim.';
}
// Update status pelatihan (mendaftar, berjalan, selesai, batal)
} elseif ($aksi === 'update_status') {
$id = intval($_POST['id']);
$status = $_POST['status'];
$tMulai = sanitize($conn, $_POST['tanggal_mulai'] ?? '');
$tSls = sanitize($conn, $_POST['tanggal_selesai'] ?? '');
$sertif = null;
if (!empty($_FILES['sertifikat']['name']))
$sertif = uploadFile($_FILES['sertifikat'], 'sertif', ['jpg','jpeg','png','pdf']);
$setMulai = $tMulai ? ", tanggal_mulai='$tMulai'" : '';
$setSelesai = $tSls ? ", tanggal_selesai='$tSls'" : '';
$setSertif = $sertif ? ", sertifikat='$sertif'" : '';
$conn->query("UPDATE penduduk_pelatihan
SET status='$status' $setMulai $setSelesai $setSertif
WHERE id=$id");
logAktivitas($conn,'UPDATE_STATUS_PELATIHAN','penduduk_pelatihan',$id);
$msg = 'Status pelatihan diperbarui.';
// Toggle aktif/nonaktif master pelatihan
} elseif ($aksi === 'toggle_aktif') {
$id = intval($_POST['id']);
$val = intval($_POST['aktif']);
$conn->query("UPDATE rekomendasi_pelatihan SET aktif=$val WHERE id=$id");
$msg = $val ? 'Pelatihan diaktifkan.' : 'Pelatihan dinonaktifkan.';
}
}
// ---- Load data ----------------------------------------------
$tab = $_GET['tab'] ?? 'penduduk'; // tab: penduduk | master
// Master pelatihan
$masterList = $conn->query("SELECT * FROM rekomendasi_pelatihan ORDER BY kategori,nama")->fetch_all(MYSQLI_ASSOC);
// Rekap per penduduk: semua yang punya rekomendasi / otomatis kebutuhan
$search = sanitize($conn, $_GET['q'] ?? '');
$wSearch = $search ? "AND (p.nama LIKE '%$search%' OR p.nik LIKE '%$search%')" : '';
$filterStatus = sanitize($conn, $_GET['status'] ?? '');
$wStatus = $filterStatus ? "AND pp.status='$filterStatus'" : '';
$rekPerPenduduk = $conn->query("
SELECT p.id AS pid, p.nama, p.nik,
TIMESTAMPDIFF(YEAR,p.tanggal_lahir,CURDATE()) AS umur,
p.jenis_kelamin, p.status_ekonomi, p.kondisi_kesehatan,
p.kebutuhan_pelatihan,
k.alamat, ri.nama AS ibadah,
pp.id AS pp_id, pp.status AS pp_status,
pp.tanggal_rekomendasi, pp.tanggal_mulai, pp.tanggal_selesai,
pp.sertifikat, pp.catatan,
rp.nama AS nama_pelatihan, rp.kategori, rp.penyelenggara,
rp.durasi, rp.biaya,
u.nama AS direkomendasikan_oleh
FROM penduduk p
LEFT JOIN keluarga k ON p.keluarga_id=k.id
LEFT JOIN rumah_ibadah ri ON k.rumah_ibadah_id=ri.id
LEFT JOIN penduduk_pelatihan pp ON pp.penduduk_id=p.id
LEFT JOIN rekomendasi_pelatihan rp ON pp.rekomendasi_pelatihan_id=rp.id
LEFT JOIN users u ON pp.direkomendasikan_oleh=u.id
WHERE p.status_hidup='hidup' $filterRumah $wSearch $wStatus
ORDER BY p.nama, pp.tanggal_rekomendasi DESC
LIMIT 300")->fetch_all(MYSQLI_ASSOC);
// Penduduk untuk dropdown rekomendasikan
$pendudukDrop = $conn->query("
SELECT p.id, p.nama, p.nik,
TIMESTAMPDIFF(YEAR,p.tanggal_lahir,CURDATE()) AS umur,
p.status_ekonomi, p.jenis_kelamin
FROM penduduk p
LEFT JOIN keluarga k ON p.keluarga_id=k.id
WHERE p.status_hidup='hidup' $filterRumah
ORDER BY p.nama")->fetch_all(MYSQLI_ASSOC);
$katLabel = ['vokasi'=>'Vokasi','kewirausahaan'=>'Kewirausahaan','digital'=>'Digital',
'pertanian'=>'Pertanian','kesehatan'=>'Kesehatan','pendidikan'=>'Pendidikan','lainnya'=>'Lainnya'];
$katColor = ['vokasi'=>'var(--blue)','kewirausahaan'=>'var(--green)','digital'=>'var(--purple,#9b59b6)',
'pertanian'=>'#27ae72','kesehatan'=>'var(--red)','pendidikan'=>'var(--yellow)','lainnya'=>'var(--muted)'];
$statusLabel = ['direkomendasikan'=>'Direkomendasikan','mendaftar'=>'Mendaftar',
'sedang_berjalan'=>'Sedang Berjalan','selesai'=>'Selesai','batal'=>'Batal'];
$statusColor = ['direkomendasikan'=>'var(--yellow)','mendaftar'=>'var(--blue)',
'sedang_berjalan'=>'var(--green)','selesai'=>'#27ae72','batal'=>'var(--muted)'];
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Rekomendasi Pelatihan — WebGIS Kemiskinan</title>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{--bg:#080c11;--surface:#0f1620;--surface2:#161f2c;--surface3:#1e2a3a;--border:#243044;--border2:#2f3f57;--text:#e8edf3;--muted:#6b7e96;--red:#e85050;--green:#27ae72;--yellow:#f0b429;--blue:#4a9eff;--purple:#9b59b6;--font-head:'Plus Jakarta Sans',sans-serif;--font-body:'Inter',sans-serif;}
body{font-family:var(--font-body);background:var(--bg);color:var(--text);min-height:100vh;}
.topnav{display:flex;align-items:center;gap:14px;padding:14px 24px;background:var(--surface);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:100;}
.nav-logo{font-family:var(--font-head);font-weight:800;font-size:15px;}
.nav-links{display:flex;gap:6px;flex:1;}
.nav-link{padding:7px 14px;border-radius:8px;color:var(--muted);font-size:13px;font-weight:500;text-decoration:none;transition:background .15s;}
.nav-link:hover,.nav-link.active{background:rgba(74,158,255,.12);color:var(--blue);}
.container{max-width:1200px;margin:0 auto;padding:28px 24px;}
h2{font-family:var(--font-head);font-size:22px;font-weight:800;margin-bottom:6px;}
.tabs{display:flex;gap:6px;margin-bottom:22px;border-bottom:1px solid var(--border);padding-bottom:0;}
.tab-btn{padding:9px 18px;border:none;background:transparent;color:var(--muted);font-family:var(--font-body);font-size:13px;font-weight:600;cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;transition:color .15s;}
.tab-btn.active{color:var(--blue);border-bottom-color:var(--blue);}
.toolbar{display:flex;align-items:center;gap:10px;margin-bottom:18px;flex-wrap:wrap;}
.search-box,.filter-sel{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:9px 14px;color:var(--text);font-family:var(--font-body);font-size:13px;outline:none;}
.search-box{flex:1;min-width:180px;}.search-box:focus{border-color:var(--blue);}
.filter-sel option{background:var(--surface2);}
.btn{padding:9px 18px;border:none;border-radius:10px;font-family:var(--font-body);font-size:13px;font-weight:600;cursor:pointer;transition:opacity .2s;}
.btn:hover{opacity:.88;}.btn-primary{background:var(--blue);color:#fff;}
.btn-success{background:var(--green);color:#fff;}.btn-sm{padding:5px 12px;font-size:12px;border-radius:8px;}
.btn-outline{background:transparent;border:1px solid var(--border);color:var(--text);}
.btn-danger{background:var(--red);color:#fff;}
.card{background:var(--surface);border:1px solid var(--border);border-radius:16px;overflow:hidden;margin-bottom:24px;}
.tbl-wrap{overflow-x:auto;}
table{width:100%;border-collapse:collapse;font-size:13px;}
th{text-align:left;padding:10px 14px;background:var(--surface2);color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.5px;font-weight:600;border-bottom:1px solid var(--border);}
td{padding:10px 14px;border-bottom:1px solid var(--border2);vertical-align:middle;}
tr:hover td{background:rgba(255,255,255,.02);}
.badge{display:inline-block;padding:2px 9px;border-radius:20px;font-size:10px;font-weight:600;}
.kebutuhan-tag{display:inline-block;background:var(--surface2);border:1px solid var(--border);border-radius:20px;padding:2px 8px;font-size:10px;margin:2px 2px 2px 0;color:var(--muted);}
.modal-bg{display:none;position:fixed;inset:0;background:rgba(0,0,0,.65);backdrop-filter:blur(4px);z-index:500;align-items:center;justify-content:center;padding:20px;}
.modal-bg.open{display:flex;}
.modal{background:var(--surface);border:1px solid var(--border);border-radius:20px;width:100%;max-width:580px;max-height:90vh;overflow-y:auto;}
.modal-header{display:flex;align-items:center;gap:12px;padding:18px 22px;border-bottom:1px solid var(--border);position:sticky;top:0;background:var(--surface);}
.modal-title{font-family:var(--font-head);font-size:16px;font-weight:700;flex:1;}
.modal-close{width:30px;height:30px;background:var(--surface2);border:1px solid var(--border);border-radius:8px;display:flex;align-items:center;justify-content:center;cursor:pointer;color:var(--muted);font-size:16px;}
.modal-body{padding:22px;}
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px;}
.form-full{grid-column:1/-1;}
.form-group{display:flex;flex-direction:column;gap:6px;}
.form-group label{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;font-weight:500;}
.form-group input,.form-group select,.form-group textarea{background:var(--surface2);border:1px solid var(--border);border-radius:10px;padding:10px 13px;color:var(--text);font-family:var(--font-body);font-size:13.5px;outline:none;transition:border-color .2s;}
.form-group input:focus,.form-group select:focus{border-color:var(--blue);}
.form-group select option{background:var(--surface2);}
.form-section{font-family:var(--font-head);font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.8px;padding-bottom:8px;border-bottom:1px solid var(--border);grid-column:1/-1;margin-top:6px;}
.form-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:20px;}
.toast-container{position:fixed;top:20px;right:20px;z-index:9999;display:flex;flex-direction:column;gap:8px;}
.toast{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:10px 16px;font-size:13px;box-shadow:0 8px 24px rgba(0,0,0,.4);animation:slideIn .2s ease;min-width:260px;}
.toast.success{border-left:3px solid var(--green);}.toast.error{border-left:3px solid var(--red);}
@keyframes slideIn{from{transform:translateX(20px);opacity:0}to{transform:translateX(0);opacity:1}}
.empty{text-align:center;color:var(--muted);padding:40px;font-size:14px;}
.alert{padding:12px 16px;border-radius:10px;font-size:13px;margin-bottom:16px;}
.alert-ok{background:rgba(39,174,114,.12);border:1px solid rgba(39,174,114,.3);color:var(--green);}
.alert-err{background:rgba(232,80,80,.15);border:1px solid rgba(232,80,80,.3);color:var(--red);}
.master-card{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:16px;display:flex;flex-direction:column;gap:8px;}
.master-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:14px;}
.mc-head{display:flex;align-items:flex-start;gap:10px;}
.mc-title{font-weight:600;font-size:14px;flex:1;}
.mc-meta{font-size:11px;color:var(--muted);display:flex;gap:10px;flex-wrap:wrap;}
.mc-actions{display:flex;gap:6px;margin-top:4px;}
</style>
</head>
<body>
<div class="toast-container" id="toastCont"></div>
<nav class="topnav">
<div class="nav-logo">🗺️ WebGIS Kemiskinan</div>
<div class="nav-links">
<a href="index.php" class="nav-link">Peta</a>
<a href="dashboard.php" class="nav-link">Dashboard</a>
<a href="keluarga.php" class="nav-link">Keluarga</a>
<a href="rekomendasi_pelatihan.php" class="nav-link active">Pelatihan</a>
</div>
<div style="font-size:12px;color:var(--muted)">👤 <?= htmlspecialchars($_SESSION['nama']) ?></div>
<a href="logout.php" style="color:var(--red);font-size:12px;text-decoration:none">Logout</a>
</nav>
<div class="container">
<h2>🎓 Rekomendasi Pelatihan</h2>
<p style="color:var(--muted);font-size:13px;margin-bottom:18px;">Kelola program pelatihan & rekomendasikan ke penduduk berdasarkan umur dan kondisi ekonomi.</p>
<?php if ($msg): ?><div class="alert alert-ok">✅ <?= htmlspecialchars($msg) ?></div><?php endif; ?>
<?php if ($err): ?><div class="alert alert-err">⚠️ <?= htmlspecialchars($err) ?></div><?php endif; ?>
<div class="tabs">
<button class="tab-btn <?= $tab==='penduduk'?'active':'' ?>" onclick="switchTab('penduduk')">👥 Per Penduduk</button>
<button class="tab-btn <?= $tab==='master'?'active':'' ?>" onclick="switchTab('master')">📚 Master Pelatihan</button>
</div>
<!-- TAB: PER PENDUDUK -->
<div id="tabPenduduk" style="display:<?= $tab==='penduduk'?'block':'none' ?>">
<div class="toolbar">
<form method="GET" style="display:contents">
<input type="hidden" name="tab" value="penduduk">
<input type="text" name="q" class="search-box" placeholder="🔍 Cari nama / NIK..." value="<?= htmlspecialchars($_GET['q']??'') ?>">
<select name="status" class="filter-sel" onchange="this.form.submit()">
<option value="">Semua Status</option>
<?php foreach ($statusLabel as $v=>$l): ?>
<option value="<?=$v?>" <?= ($filterStatus===$v)?'selected':'' ?>><?=$l?></option>
<?php endforeach; ?>
</select>
<button type="submit" class="btn btn-outline">Cari</button>
</form>
<button class="btn btn-primary" onclick="openModalRek()">+ Rekomendasikan</button>
</div>
<div class="card">
<div class="tbl-wrap">
<table>
<thead>
<tr>
<th>Penduduk</th><th>Umur / Status</th><th>Kebutuhan Otomatis</th>
<th>Pelatihan Direkomendasikan</th><th>Status</th><th>Aksi</th>
</tr>
</thead>
<tbody>
<?php
// Group by penduduk
$grouped = [];
foreach ($rekPerPenduduk as $r) {
$grouped[$r['pid']] = $grouped[$r['pid']] ?? ['info'=>$r, 'pelatihan'=>[]];
if ($r['pp_id']) $grouped[$r['pid']]['pelatihan'][] = $r;
}
if (empty($grouped)): ?>
<tr><td colspan="6" class="empty">Belum ada data. Klik "+ Rekomendasikan" untuk mulai.</td></tr>
<?php else: foreach ($grouped as $gd):
$info = $gd['info'];
$pelatihan = $gd['pelatihan'];
$kebutuhan = $info['kebutuhan_pelatihan'] ? json_decode($info['kebutuhan_pelatihan'], true) : [];
?>
<tr>
<td>
<div style="font-weight:600"><?= htmlspecialchars($info['nama']) ?></div>
<div style="font-size:11px;color:var(--muted)"><?= htmlspecialchars($info['nik']) ?></div>
<div style="font-size:11px;color:var(--muted)"><?= htmlspecialchars($info['ibadah']??'-') ?></div>
</td>
<td>
<div><?= $info['umur'] ?> tahun · <?= $info['jenis_kelamin']==='L'?'Laki-laki':'Perempuan' ?></div>
<div>
<span class="badge" style="background:rgba(232,80,80,.15);color:var(--red)"><?= ucfirst($info['status_ekonomi']) ?></span>
<?php if ($info['kondisi_kesehatan'] !== 'sehat'): ?>
<span class="badge" style="background:rgba(240,180,41,.15);color:var(--yellow)"><?= ucfirst(str_replace('_',' ',$info['kondisi_kesehatan'])) ?></span>
<?php endif; ?>
</div>
</td>
<td>
<?php if ($kebutuhan): foreach ($kebutuhan as $kb): ?>
<span class="kebutuhan-tag">💡 <?= htmlspecialchars($kb) ?></span>
<?php endforeach; else: ?>
<span style="font-size:11px;color:var(--muted)">—</span>
<?php endif; ?>
</td>
<td>
<?php if (empty($pelatihan)): ?>
<span style="font-size:12px;color:var(--muted)">Belum ada rekomendasi</span>
<?php else: foreach ($pelatihan as $pt): ?>
<div style="margin-bottom:5px;">
<div style="font-size:12px;font-weight:600"><?= htmlspecialchars($pt['nama_pelatihan']) ?></div>
<div style="font-size:10px;color:var(--muted)"><?= htmlspecialchars($pt['penyelenggara']??'-') ?> · <?= htmlspecialchars($pt['durasi']??'-') ?></div>
</div>
<?php endforeach; endif; ?>
</td>
<td>
<?php if (empty($pelatihan)): ?>
<span class="badge" style="background:rgba(107,126,150,.15);color:var(--muted)">Belum</span>
<?php else: foreach ($pelatihan as $pt): ?>
<span class="badge" style="background:rgba(255,255,255,.06);color:<?= $statusColor[$pt['pp_status']]??'var(--muted)' ?>">
<?= $statusLabel[$pt['pp_status']]??$pt['pp_status'] ?>
</span><br>
<?php endforeach; endif; ?>
</td>
<td>
<?php if (!empty($pelatihan)): foreach ($pelatihan as $pt): ?>
<?php if ($pt['pp_status'] !== 'selesai' && $pt['pp_status'] !== 'batal'): ?>
<button class="btn btn-success btn-sm" style="margin-bottom:3px"
onclick="openModalStatus(<?= $pt['pp_id'] ?>,'<?= $pt['pp_status'] ?>')">
✏️ Update
</button>
<?php endif; ?>
<?php if ($pt['sertifikat']): ?>
<a href="uploads/<?= htmlspecialchars($pt['sertifikat']) ?>" target="_blank" class="btn btn-outline btn-sm">🏆 Sertifikat</a>
<?php endif; ?>
<?php endforeach; endif; ?>
</td>
</tr>
<?php endforeach; endif; ?>
</tbody>
</table>
</div>
</div>
</div>
<!-- TAB: MASTER PELATIHAN -->
<div id="tabMaster" style="display:<?= $tab==='master'?'block':'none' ?>">
<div class="toolbar">
<span style="font-size:13px;color:var(--muted)"><?= count($masterList) ?> program terdaftar</span>
<button class="btn btn-primary" onclick="openModalMaster()">+ Tambah Program</button>
</div>
<div class="master-grid">
<?php foreach ($masterList as $m): ?>
<div class="master-card" style="opacity:<?= $m['aktif']?'1':'.5' ?>">
<div class="mc-head">
<div>
<span class="badge" style="background:rgba(74,158,255,.15);color:var(--blue);margin-bottom:6px"><?= ucfirst($m['kategori']) ?></span>
<div class="mc-title"><?= htmlspecialchars($m['nama']) ?></div>
</div>
<?php if (!$m['aktif']): ?><span class="badge" style="background:rgba(107,126,150,.15);color:var(--muted)">Nonaktif</span><?php endif; ?>
</div>
<div class="mc-meta">
<span>👥 <?= $m['usia_min'] ?><?= $m['usia_maks'] ?> th</span>
<span>⚥ <?= ucfirst($m['gender']) ?></span>
<span>⏱ <?= htmlspecialchars($m['durasi']??'-') ?></span>
<span>💰 <?= $m['biaya']>0?'Rp '.number_format($m['biaya'],0,',','.'):'Gratis' ?></span>
</div>
<?php if ($m['penyelenggara']): ?>
<div style="font-size:12px;color:var(--muted)">🏛 <?= htmlspecialchars($m['penyelenggara']) ?></div>
<?php endif; ?>
<?php if ($m['prasyarat']): ?>
<div style="font-size:11px;color:var(--yellow)">⚠️ <?= htmlspecialchars($m['prasyarat']) ?></div>
<?php endif; ?>
<div class="mc-actions">
<form method="POST" style="display:inline">
<input type="hidden" name="aksi" value="toggle_aktif">
<input type="hidden" name="id" value="<?= $m['id'] ?>">
<input type="hidden" name="aktif" value="<?= $m['aktif']?0:1 ?>">
<button type="submit" class="btn btn-outline btn-sm"><?= $m['aktif']?'⏸ Nonaktifkan':'▶️ Aktifkan' ?></button>
</form>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<!-- Modal: Rekomendasikan ke Penduduk -->
<div class="modal-bg" id="modalRek">
<div class="modal">
<div class="modal-header">
<span class="modal-title">🎯 Rekomendasikan Pelatihan</span>
<div class="modal-close" onclick="closeModalRek()">✕</div>
</div>
<div class="modal-body">
<form method="POST">
<input type="hidden" name="aksi" value="rekomendasikan">
<div class="form-grid">
<div class="form-full form-group">
<label>Penduduk <span style="color:var(--red)">*</span></label>
<select name="penduduk_id" required id="selPendudukRek">
<option value="">-- Pilih Penduduk --</option>
<?php foreach ($pendudukDrop as $pd): ?>
<option value="<?=$pd['id']?>"><?= htmlspecialchars($pd['nama']) ?> (<?=$pd['umur']?> th · <?= ucfirst($pd['status_ekonomi']) ?>)</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-full form-group">
<label>Program Pelatihan <span style="color:var(--red)">*</span></label>
<select name="rekomendasi_pelatihan_id" required>
<option value="">-- Pilih Program --</option>
<?php foreach ($masterList as $m): if (!$m['aktif']) continue; ?>
<option value="<?=$m['id']?>">[<?= ucfirst($m['kategori']) ?>] <?= htmlspecialchars($m['nama']) ?> — <?= htmlspecialchars($m['durasi']??'-') ?> <?= $m['biaya']>0?'(Rp '.number_format($m['biaya'],0,',','.').')':'(Gratis)' ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-full form-group">
<label>Catatan</label>
<textarea name="catatan" rows="2" placeholder="Alasan rekomendasi, kebutuhan khusus, dll"></textarea>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-outline" onclick="closeModalRek()">Batal</button>
<button type="submit" class="btn btn-primary">✅ Rekomendasikan</button>
</div>
</form>
</div>
</div>
</div>
<!-- Modal: Update Status -->
<div class="modal-bg" id="modalStatus">
<div class="modal">
<div class="modal-header">
<span class="modal-title">✏️ Update Status Pelatihan</span>
<div class="modal-close" onclick="closeModalStatus()">✕</div>
</div>
<div class="modal-body">
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="aksi" value="update_status">
<input type="hidden" name="id" id="statusId">
<div class="form-grid">
<div class="form-full form-group">
<label>Status Baru <span style="color:var(--red)">*</span></label>
<select name="status" id="statusSel" required>
<?php foreach ($statusLabel as $v=>$l): ?>
<option value="<?=$v?>"><?=$l?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label>Tanggal Mulai</label>
<input type="date" name="tanggal_mulai">
</div>
<div class="form-group">
<label>Tanggal Selesai</label>
<input type="date" name="tanggal_selesai">
</div>
<div class="form-full form-group">
<label>Upload Sertifikat (jika sudah selesai)</label>
<input type="file" name="sertifikat" accept=".jpg,.jpeg,.png,.pdf" style="padding:8px">
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-outline" onclick="closeModalStatus()">Batal</button>
<button type="submit" class="btn btn-success">💾 Simpan</button>
</div>
</form>
</div>
</div>
</div>
<!-- Modal: Tambah Master -->
<div class="modal-bg" id="modalMaster">
<div class="modal">
<div class="modal-header">
<span class="modal-title">📚 Tambah Program Pelatihan</span>
<div class="modal-close" onclick="closeModalMaster()">✕</div>
</div>
<div class="modal-body">
<form method="POST">
<input type="hidden" name="aksi" value="tambah_master">
<div class="form-grid">
<div class="form-full form-group">
<label>Nama Program <span style="color:var(--red)">*</span></label>
<input type="text" name="nama" required placeholder="Misal: Pelatihan Sablon Kaos">
</div>
<div class="form-group">
<label>Kategori <span style="color:var(--red)">*</span></label>
<select name="kategori" required>
<?php foreach ($katLabel as $v=>$l): ?>
<option value="<?=$v?>"><?=$l?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label>Gender Sasaran</label>
<select name="gender">
<option value="semua">Semua</option>
<option value="L">Laki-laki</option>
<option value="P">Perempuan</option>
</select>
</div>
<div class="form-group">
<label>Usia Min</label>
<input type="number" name="usia_min" value="15" min="5" max="99">
</div>
<div class="form-group">
<label>Usia Maks</label>
<input type="number" name="usia_maks" value="60" min="5" max="99">
</div>
<div class="form-group">
<label>Sasaran Ekonomi</label>
<select name="status_ekonomi_sasaran">
<option value="semua">Semua</option>
<option value="miskin">Miskin</option>
<option value="rentan">Rentan</option>
</select>
</div>
<div class="form-group">
<label>Durasi</label>
<input type="text" name="durasi" placeholder="3 hari, 2 minggu...">
</div>
<div class="form-group">
<label>Biaya (Rp, 0=gratis)</label>
<input type="number" name="biaya" value="0" min="0">
</div>
<div class="form-full form-group">
<label>Penyelenggara</label>
<input type="text" name="penyelenggara" placeholder="BLK, Dinas Sosial, NGO, Masjid...">
</div>
<div class="form-full form-group">
<label>Kontak Penyelenggara</label>
<input type="text" name="kontak_penyelenggara" placeholder="No. HP / email">
</div>
<div class="form-full form-group">
<label>Prasyarat</label>
<input type="text" name="prasyarat" placeholder="Punya motor & SIM C, punya smartphone, dll">
</div>
<div class="form-full form-group">
<label>Deskripsi</label>
<textarea name="deskripsi" rows="3" placeholder="Deskripsi singkat program pelatihan..."></textarea>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-outline" onclick="closeModalMaster()">Batal</button>
<button type="submit" class="btn btn-primary">💾 Tambah Program</button>
</div>
</form>
</div>
</div>
</div>
<script>
function switchTab(t) {
document.getElementById('tabPenduduk').style.display = t==='penduduk'?'block':'none';
document.getElementById('tabMaster').style.display = t==='master'?'block':'none';
document.querySelectorAll('.tab-btn').forEach((b,i)=>b.classList.toggle('active',i===(t==='penduduk'?0:1)));
}
function openModalRek(){ document.getElementById('modalRek').classList.add('open'); }
function closeModalRek(){ document.getElementById('modalRek').classList.remove('open'); }
function openModalMaster(){ document.getElementById('modalMaster').classList.add('open'); }
function closeModalMaster(){ document.getElementById('modalMaster').classList.remove('open'); }
function openModalStatus(id, status) {
document.getElementById('statusId').value = id;
document.getElementById('statusSel').value = status;
document.getElementById('modalStatus').classList.add('open');
}
function closeModalStatus(){ document.getElementById('modalStatus').classList.remove('open'); }
document.querySelectorAll('.modal-bg').forEach(m => m.addEventListener('click', function(e){ if(e.target===this) this.classList.remove('open'); }));
<?php if ($msg): ?>
(function(){ var t=document.createElement('div'); t.className='toast success'; t.textContent='✅ <?= addslashes($msg) ?>'; document.getElementById('toastCont').appendChild(t); setTimeout(()=>t.remove(),3500); })();
<?php endif; if ($err): ?>
(function(){ var t=document.createElement('div'); t.className='toast error'; t.textContent='⚠️ <?= addslashes($err) ?>'; document.getElementById('toastCont').appendChild(t); setTimeout(()=>t.remove(),4000); })();
<?php endif; ?>
</script>
</body>
</html>
@@ -0,0 +1,341 @@
<?php
require_once 'koneksi.php';
requireLogin();
requireRole('admin','pengurus');
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Riwayat Pendidikan & Pelatihan — WebGIS Kemiskinan</title>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#080c11;--surface:#0f1620;--surface2:#161f2c;--border:#243044;--border2:#2f3f57;
--text:#e8edf3;--muted:#6b7e96;
--red:#e85050;--green:#27ae72;--yellow:#f0b429;--blue:#4a9eff;
--font-head:'Plus Jakarta Sans',sans-serif;--font-body:'Inter',sans-serif;
}
body{font-family:var(--font-body);background:var(--bg);color:var(--text);}
.topnav{display:flex;align-items:center;gap:14px;padding:14px 24px;background:var(--surface);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:100;}
.nav-logo{font-family:var(--font-head);font-weight:800;font-size:15px;}
.nav-links{display:flex;gap:6px;flex:1;}
.nav-link{padding:7px 14px;border-radius:8px;color:var(--muted);font-size:13px;text-decoration:none;transition:background .15s,color .15s;}
.nav-link:hover,.nav-link.active{background:rgba(232,80,80,.12);color:var(--red);}
.container{max-width:1200px;margin:0 auto;padding:28px 24px;}
h2{font-family:var(--font-head);font-size:22px;font-weight:800;margin-bottom:6px;}
.subtitle{color:var(--muted);font-size:14px;margin-bottom:24px;}
.alert{background:rgba(240,180,41,.08);border:1px solid rgba(240,180,41,.25);border-radius:12px;padding:14px 18px;color:var(--yellow);font-size:13px;margin-bottom:24px;display:flex;align-items:center;gap:10px;}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:start;}
.card{background:var(--surface);border:1px solid var(--border);border-radius:16px;overflow:hidden;}
.card-header{padding:16px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;}
.card-title{font-family:var(--font-head);font-size:14px;font-weight:700;}
.card-body{padding:18px 20px;}
table{width:100%;border-collapse:collapse;font-size:13px;}
th{text-align:left;padding:8px 12px;background:var(--surface2);color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.5px;font-weight:600;border-bottom:1px solid var(--border);}
td{padding:8px 12px;border-bottom:1px solid var(--border2);}
tr:hover td{background:rgba(255,255,255,.02);}
.badge{display:inline-block;padding:2px 8px;border-radius:20px;font-size:10px;font-weight:600;}
.b-lulus{background:rgba(39,174,114,.2);color:var(--green);}
.b-berjalan{background:rgba(74,158,255,.2);color:var(--blue);}
.b-putus{background:rgba(232,80,80,.2);color:var(--red);}
.b-formal{background:rgba(74,158,255,.12);color:var(--blue);}
.b-pelatihan{background:rgba(155,89,182,.12);color:#9b59b6;}
.btn{padding:8px 16px;border:none;border-radius:9px;font-family:var(--font-body);font-size:12.5px;font-weight:600;cursor:pointer;transition:opacity .2s;}
.btn:hover{opacity:.85;}
.btn-primary{background:var(--red);color:#fff;}
.btn-outline{background:transparent;border:1px solid var(--border);color:var(--text);}
.btn-sm{padding:5px 10px;font-size:11px;border-radius:7px;}
.form-group{margin-bottom:14px;}
.form-group label{display:block;font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:5px;font-weight:500;}
.form-group input,.form-group select,.form-group textarea{width:100%;background:var(--surface2);border:1px solid var(--border);border-radius:9px;padding:9px 12px;color:var(--text);font-family:var(--font-body);font-size:13px;outline:none;transition:border-color .2s;}
.form-group input:focus,.form-group select:focus{border-color:var(--red);}
.form-group select option{background:var(--surface2);}
.warn-box{background:rgba(240,180,41,.08);border:1px solid rgba(240,180,41,.25);border-radius:8px;padding:10px 12px;color:var(--yellow);font-size:12px;margin-top:8px;display:none;}
.toast-container{position:fixed;top:20px;right:20px;z-index:9999;display:flex;flex-direction:column;gap:8px;}
.toast{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:10px 16px;font-size:13px;box-shadow:0 8px 24px rgba(0,0,0,.4);animation:slideIn .2s ease;min-width:260px;}
.toast.success{border-left:3px solid var(--green);}
.toast.error{border-left:3px solid var(--red);}
@keyframes slideIn{from{transform:translateX(20px);opacity:0}to{transform:translateX(0);opacity:1}}
</style>
</head>
<body>
<div class="toast-container" id="toastContainer"></div>
<nav class="topnav">
<div class="nav-logo">🗺️ WebGIS Kemiskinan</div>
<div class="nav-links">
<a href="index.php" class="nav-link">Peta</a>
<a href="dashboard.php" class="nav-link">Dashboard</a>
<a href="keluarga.php" class="nav-link">Keluarga</a>
<a href="riwayat_pendidikan.php" class="nav-link active">Pendidikan</a>
</div>
<a href="logout.php" style="color:var(--red);font-size:12px;text-decoration:none">Logout</a>
</nav>
<div class="container">
<h2>🎓 Riwayat Pendidikan & Pelatihan</h2>
<p class="subtitle">Kelola data pendidikan formal dan pelatihan kerja penduduk, lengkap dengan validasi logika usia.</p>
<div class="alert">
⚠️ <strong>Validasi Aktif:</strong> Sistem otomatis memvalidasi kesesuaian usia dengan jenjang pendidikan. Usia 712 tahun wajib SD, 1315 tahun wajib SMP, 1618 tahun wajib SMA/SMK.
</div>
<div class="grid2">
<!-- FORM TAMBAH -->
<div class="card">
<div class="card-header">
<div class="card-title"> Tambah Riwayat Pendidikan / Pelatihan</div>
</div>
<div class="card-body">
<form id="formPddk" onsubmit="submitPddk(event)">
<input type="hidden" id="p_id" name="id">
<div class="form-group">
<label>Cari Penduduk *</label>
<input type="text" id="cariPenduduk" placeholder="Ketik nama atau NIK..." oninput="cariPendudukFn(this.value)">
<div id="hasilCari" style="background:var(--surface2);border:1px solid var(--border);border-radius:8px;margin-top:4px;display:none;max-height:160px;overflow-y:auto;font-size:13px;"></div>
<input type="hidden" name="penduduk_id" id="p_penduduk_id">
<div id="selectedPenduduk" style="font-size:12px;color:var(--blue);margin-top:5px;"></div>
</div>
<div class="form-group">
<label>Jenis *</label>
<select name="jenis" id="p_jenis" required>
<option value="formal">Pendidikan Formal</option>
<option value="pelatihan">Pelatihan Kerja</option>
<option value="kursus">Kursus / Non-formal</option>
</select>
</div>
<div class="form-group">
<label>Nama Institusi</label>
<input type="text" name="nama_institusi" placeholder="SDN 1 Pontianak / BLK Pontianak">
</div>
<div class="form-group">
<label>Jenjang / Nama Program</label>
<input type="text" name="jenjang" id="p_jenjang" placeholder="SD / SMP / Menjahit / Las">
</div>
<div class="form-group">
<label>Jurusan (jika ada)</label>
<input type="text" name="jurusan" placeholder="IPA / Teknik / dll">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div class="form-group">
<label>Tahun Masuk</label>
<input type="number" name="tahun_masuk" min="1950" max="2030" placeholder="2015">
</div>
<div class="form-group">
<label>Tahun Lulus</label>
<input type="number" name="tahun_lulus" min="1950" max="2030" placeholder="2021 (kosong jika berjalan)">
</div>
</div>
<div class="form-group">
<label>Status</label>
<select name="status" required>
<option value="lulus">Lulus</option>
<option value="sedang_berjalan">Sedang Berjalan</option>
<option value="putus">Putus / Tidak Selesai</option>
</select>
</div>
<div class="warn-box" id="warnPddk"></div>
<button type="submit" class="btn btn-primary" style="width:100%;margin-top:4px">💾 Simpan</button>
</form>
</div>
</div>
<!-- TABEL RIWAYAT -->
<div class="card">
<div class="card-header">
<div class="card-title">📋 Semua Riwayat Pendidikan</div>
<input type="text" placeholder="Cari nama..." style="background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:6px 12px;color:var(--text);font-size:12px;outline:none;width:150px" oninput="loadRiwayat(this.value)">
</div>
<div style="overflow-x:auto;max-height:620px;overflow-y:auto;">
<table>
<thead>
<tr><th>Penduduk</th><th>Jenis</th><th>Institusi</th><th>Jenjang</th><th>Thn</th><th>Status</th><th>Aksi</th></tr>
</thead>
<tbody id="riwayatBody">
<tr><td colspan="7" style="text-align:center;color:var(--muted);padding:24px">Memuat...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- WARGA POTENSI BUTUH PELATIHAN -->
<div class="card" style="margin-top:18px">
<div class="card-header">
<div class="card-title">🎯 Deteksi Potensi Butuh Intervensi Pendidikan</div>
</div>
<div style="overflow-x:auto">
<table>
<thead>
<tr><th>Nama</th><th>Umur</th><th>Status Ekonomi</th><th>Pendidikan Terakhir</th><th>Status Pendidikan</th><th>Pekerjaan</th><th>Rekomendasi</th></tr>
</thead>
<tbody id="intervensiBody">
<tr><td colspan="7" style="text-align:center;color:var(--muted);padding:24px">Memuat...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<script>
// ---- SEARCH PENDUDUK ----
let cariTimer;
function cariPendudukFn(val) {
clearTimeout(cariTimer);
if (val.length < 2) { document.getElementById('hasilCari').style.display = 'none'; return; }
cariTimer = setTimeout(() => {
fetch(`api/penduduk.php?action=list&search=${encodeURIComponent(val)}`)
.then(r => r.json())
.then(data => {
const box = document.getElementById('hasilCari');
if (!data.success || !data.data.length) { box.style.display = 'none'; return; }
box.style.display = '';
box.innerHTML = data.data.slice(0, 8).map(p =>
`<div onclick="pilihPenduduk(${p.id},'${p.nama}',${p.umur || 0})" style="padding:8px 12px;cursor:pointer;border-bottom:1px solid var(--border);transition:background .1s" onmouseover="this.style.background='var(--surface3)'" onmouseout="this.style.background=''">${p.nama} <span style="color:var(--muted);font-size:11px">— ${p.nik} — ${p.umur || '?'}th</span></div>`
).join('');
});
}, 300);
}
function pilihPenduduk(id, nama, umur) {
document.getElementById('p_penduduk_id').value = id;
document.getElementById('cariPenduduk').value = nama;
document.getElementById('selectedPenduduk').textContent = `✓ Dipilih: ${nama} (${umur} tahun)`;
document.getElementById('hasilCari').style.display = 'none';
cekWarnPddk(umur);
}
function cekWarnPddk(umur) {
const jenjang = document.getElementById('p_jenjang').value.toLowerCase();
const warn = document.getElementById('warnPddk');
let msg = '';
if (umur >= 7 && umur <= 12 && !['sd'].some(j => jenjang.includes(j))) {
msg = `⚠️ Usia ${umur} tahun seharusnya masih SD.`;
} else if (umur >= 13 && umur <= 15 && !['smp','sd'].some(j => jenjang.includes(j))) {
msg = `⚠️ Usia ${umur} tahun seharusnya SMP.`;
} else if (umur >= 16 && umur <= 18 && !['sma','smk','smp','sd'].some(j => jenjang.includes(j))) {
msg = `⚠️ Usia ${umur} tahun seharusnya SMA/SMK.`;
}
warn.textContent = msg;
warn.style.display = msg ? '' : 'none';
}
function submitPddk(e) {
e.preventDefault();
const fd = new FormData(e.target);
if (!fd.get('penduduk_id')) { toast('❌ Pilih penduduk terlebih dahulu', 'error'); return; }
fetch('api/riwayat_pendidikan.php?action=save', { method: 'POST', body: fd })
.then(r => r.json())
.then(data => {
if (data.success) {
toast('✅ Riwayat pendidikan disimpan', 'success');
e.target.reset();
document.getElementById('selectedPenduduk').textContent = '';
document.getElementById('p_penduduk_id').value = '';
loadRiwayat();
} else {
toast('❌ ' + data.error, 'error');
}
});
}
// ---- LOAD RIWAYAT ----
function loadRiwayat(search = '') {
fetch(`api/riwayat_pendidikan.php?action=list&search=${encodeURIComponent(search)}`)
.then(r => r.json())
.then(data => {
const tbody = document.getElementById('riwayatBody');
if (!data.success || !data.data.length) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:var(--muted);padding:24px">Tidak ada data</td></tr>';
return;
}
tbody.innerHTML = data.data.map(r => `
<tr>
<td><strong>${r.nama_penduduk}</strong><div style="font-size:10px;color:var(--muted)">${r.nik}</div></td>
<td><span class="badge b-${r.jenis}">${r.jenis}</span></td>
<td style="font-size:12px">${r.nama_institusi || '-'}</td>
<td>${r.jenjang || '-'}</td>
<td style="font-size:11px">${r.tahun_masuk || '-'} ${r.tahun_lulus || '…'}</td>
<td><span class="badge b-${r.status}">${r.status.replace('_',' ')}</span></td>
<td>
<button class="btn btn-outline btn-sm" onclick="hapusRiwayat(${r.id})" style="color:var(--red)">🗑</button>
</td>
</tr>
`).join('');
});
}
function hapusRiwayat(id) {
if (!confirm('Hapus riwayat ini?')) return;
fetch('api/riwayat_pendidikan.php?action=delete', {
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: `id=${id}`
})
.then(r => r.json())
.then(data => {
if (data.success) { toast('🗑 Dihapus', 'success'); loadRiwayat(); }
else toast('❌ ' + data.error, 'error');
});
}
// ---- DETEKSI INTERVENSI ----
function loadIntervensi() {
fetch('api/penduduk.php?action=list')
.then(r => r.json())
.then(data => {
const tbody = document.getElementById('intervensiBody');
if (!data.success) return;
const perlu = data.data.filter(p => {
const u = p.umur;
if (u >= 7 && u <= 15 && p.status_pendidikan !== 'masih_sekolah') return true;
if (u >= 16 && u <= 25 && p.status_ekonomi === 'miskin' && p.status_pendidikan === 'tidak_sekolah') return true;
return false;
}).slice(0, 30);
if (!perlu.length) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:var(--green);padding:24px">✅ Tidak ada warga yang terdeteksi butuh intervensi pendidikan mendesak.</td></tr>';
return;
}
tbody.innerHTML = perlu.map(p => {
let rek = '';
if (p.umur >= 7 && p.umur <= 12) rek = '📚 Daftarkan ke SD terdekat';
else if (p.umur >= 13 && p.umur <= 15) rek = '📚 Daftarkan ke SMP';
else if (p.umur >= 16 && p.umur <= 18) rek = '📚 Daftarkan ke SMA/SMK';
else rek = '🔧 Ikutkan pelatihan kerja BLK';
return `<tr>
<td><strong>${p.nama}</strong></td>
<td>${p.umur} tahun</td>
<td><span class="badge" style="background:rgba(232,80,80,.2);color:var(--red)">${p.status_ekonomi}</span></td>
<td>${p.pendidikan_terakhir.replace('_',' ').toUpperCase()}</td>
<td style="color:var(--red)">${p.status_pendidikan.replace('_',' ')}</td>
<td>${p.pekerjaan || '-'}</td>
<td style="color:var(--yellow);font-size:12px">${rek}</td>
</tr>`;
}).join('');
});
}
function toast(msg, type='info') {
const c = document.getElementById('toastContainer');
const t = document.createElement('div');
t.className = `toast ${type}`;
t.textContent = msg;
c.appendChild(t);
setTimeout(() => t.remove(), 3500);
}
loadRiwayat();
loadIntervensi();
</script>
</body>
</html>
@@ -0,0 +1,5 @@
php_flag engine off
<FilesMatch "\.(php|phtml|pl|py|cgi)$">
Order Allow,Deny
Deny from all
</FilesMatch>
+1128
View File
File diff suppressed because it is too large Load Diff
+295
View File
@@ -0,0 +1,295 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Manual Book WebGIS Project</title>
<style>
:root {
--bg: #07101f;
--panel: #0d1a2e;
--panel-soft: #10213a;
--line: rgba(116, 213, 255, .18);
--text: #eef7ff;
--muted: #a8bbcc;
--cyan: #38e8ff;
--mint: #53f2b7;
--amber: #ffbd59;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
color: var(--text);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background:
radial-gradient(circle at 12% 0%, rgba(56,232,255,.24), transparent 32%),
radial-gradient(circle at 88% 12%, rgba(83,242,183,.16), transparent 30%),
linear-gradient(145deg, #050914, var(--bg));
min-height: 100vh;
}
.shell {
width: min(1080px, calc(100% - 36px));
margin: 0 auto;
padding: 28px 0 44px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 34px;
}
.brand {
display: grid;
gap: 5px;
}
.brand strong {
font-size: 18px;
line-height: 1.1;
}
.brand span {
color: var(--muted);
font-size: 13px;
font-weight: 700;
}
a {
color: inherit;
}
.back {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 42px;
padding: 0 15px;
border: 1px solid var(--line);
border-radius: 10px;
background: rgba(255,255,255,.06);
text-decoration: none;
font-size: 13px;
font-weight: 850;
}
.hero {
padding: 30px;
border: 1px solid var(--line);
border-radius: 16px;
background:
linear-gradient(135deg, rgba(56,232,255,.13), transparent 40%),
rgba(13, 26, 46, .84);
box-shadow: 0 24px 80px rgba(0,0,0,.36);
}
.eyebrow {
color: var(--mint);
font-size: 13px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: .08em;
}
h1 {
max-width: 800px;
margin: 14px 0 0;
font-size: clamp(36px, 6vw, 72px);
line-height: .95;
letter-spacing: 0;
}
.lead {
max-width: 780px;
margin: 20px 0 0;
color: var(--muted);
font-size: 17px;
line-height: 1.75;
font-weight: 600;
}
.section {
margin-top: 26px;
padding: 24px;
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(13, 26, 46, .72);
}
.section h2 {
margin: 0 0 16px;
font-size: 22px;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.card {
padding: 18px;
border: 1px solid rgba(116, 213, 255, .14);
border-radius: 12px;
background: var(--panel-soft);
}
.card h3 {
margin: 0;
font-size: 17px;
}
.card p,
.card li {
color: var(--muted);
font-size: 14px;
line-height: 1.65;
font-weight: 620;
}
.card p {
margin: 9px 0 0;
}
ul,
ol {
margin: 10px 0 0;
padding-left: 20px;
}
.project-link {
display: inline-flex;
margin-top: 14px;
padding: 9px 12px;
border: 1px solid rgba(56,232,255,.32);
border-radius: 10px;
color: var(--cyan);
background: rgba(56,232,255,.08);
text-decoration: none;
font-size: 13px;
font-weight: 900;
}
.note {
color: var(--amber);
}
@media (max-width: 760px) {
.shell {
width: min(100% - 22px, 560px);
padding-top: 18px;
}
.topbar {
align-items: flex-start;
flex-direction: column;
}
.hero,
.section {
padding: 18px;
}
.grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<main class="shell">
<header class="topbar">
<div class="brand">
<strong>Manual Book WebGIS Project</strong>
<span>Panduan penggunaan folder gabungan 01 sampai 05</span>
</div>
<a class="back" href="index.html">Kembali ke Landing</a>
</header>
<section class="hero">
<div class="eyebrow">Dokumentasi akses</div>
<h1>Panduan cepat menjalankan semua project.</h1>
<p class="lead">
Folder utama berisi lima project WebGIS. Project 01 sampai 04 dapat dibuka langsung tanpa login, sedangkan project 05 adalah sistem administrasi yang harus masuk melalui halaman login.
</p>
</section>
<section class="section">
<h2>Alur Penggunaan</h2>
<div class="grid">
<div class="card">
<h3>1. Buka landing utama</h3>
<p>Akses folder melalui XAMPP di <strong>http://localhost/websig/WebGIS_Project/</strong>. Landing menjadi pusat navigasi semua project.</p>
</div>
<div class="card">
<h3>2. Pilih project sesuai kebutuhan</h3>
<p>Gunakan launchpad di landing untuk membuka modul peta titik, digitasi, choropleth, atau dashboard admin.</p>
</div>
<div class="card">
<h3>3. Gunakan project publik</h3>
<p>Project publik langsung menampilkan peta. Beberapa fitur membutuhkan database lokal sesuai file koneksi masing-masing project.</p>
</div>
<div class="card">
<h3>4. Login untuk project 05</h3>
<p>Project 05 berisi modul internal seperti dashboard, laporan, keluarga, pendidikan, dan administrasi lain sehingga aksesnya memakai autentikasi.</p>
</div>
</div>
</section>
<section class="section">
<h2>Daftar Project</h2>
<div class="grid">
<div class="card">
<h3>Project 01 - GIS SPBU Dasar</h3>
<p>Pemetaan titik SPBU berbasis Leaflet. Cocok untuk melihat data lokasi dan status marker sederhana.</p>
<a class="project-link" href="01/index.php">Buka Project 01</a>
</div>
<div class="card">
<h3>Project 02 - Digitasi Objek Spasial</h3>
<p>Digunakan untuk menggambar dan mengelola objek jalan, parsil, dan kerusakan pada peta.</p>
<a class="project-link" href="02/index.html">Buka Project 02</a>
</div>
<div class="card">
<h3>Project 03 - Choropleth Pontianak</h3>
<p>Menampilkan peta tematik Kota Pontianak dengan pewarnaan wilayah berdasarkan nilai atau kategori.</p>
<a class="project-link" href="03/Choropleth.html">Buka Project 03</a>
</div>
<div class="card">
<h3>Project 04 - GIS SPBU Pro</h3>
<p>Versi lanjutan project SPBU dengan tampilan popup dan pengalaman penggunaan yang lebih rapi.</p>
<a class="project-link" href="04/index.php">Buka Project 04</a>
</div>
<div class="card">
<h3>Project 05 - Sistem WebGIS UAS</h3>
<p>Dashboard administrasi dengan modul data internal. Akses utama diarahkan ke halaman login.</p>
<a class="project-link" href="05/webgis_uas_FIXED_v2/webgis_uas_FIXED/login.php">Login Project 05</a>
</div>
<div class="card">
<h3>Catatan Database</h3>
<p class="note">Jika data tidak tampil, pastikan database SQL sudah diimport dan konfigurasi koneksi pada masing-masing folder sudah sesuai XAMPP lokal.</p>
</div>
</div>
</section>
<section class="section">
<h2>Struktur Folder Setelah Dirapikan</h2>
<div class="card">
<ul>
<li><strong>01</strong> - GIS SPBU dasar.</li>
<li><strong>02</strong> - Digitasi jalan, parsil, dan kerusakan.</li>
<li><strong>03</strong> - Choropleth Kota Pontianak.</li>
<li><strong>04</strong> - GIS SPBU Pro.</li>
<li><strong>05</strong> - Sistem WebGIS UAS dengan login.</li>
</ul>
</div>
</section>
</main>
</body>
</html>