Initial commit

This commit is contained in:
dena27
2026-06-10 18:48:37 +07:00
commit 084b171dbd
44 changed files with 35487 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
<?php
require_once 'config/db.php';
try {
$pdo->exec("ALTER TABLE laporan_warga ADD COLUMN dokumentasi TEXT NULL DEFAULT NULL AFTER catatan_admin");
echo "Column dokumentasi added";
} catch (Exception $e) {
echo "Error: " . $e->getMessage();
}
+163
View File
@@ -0,0 +1,163 @@
<?php
// api/anggota_keluarga.php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
require_once '../config/db.php';
$method = $_SERVER['REQUEST_METHOD'];
switch ($method) {
case 'GET':
require_once 'middleware.php';
$currentUser = requireAuth(['admin', 'petugas', 'koordinator']);
if ($currentUser['role'] === 'koordinator') {
echo json_encode(['success' => true, 'data' => [], 'total' => 0]);
break;
}
$wargaId = (int)($_GET['rumah_warga_id'] ?? 0);
if (!$wargaId) { echo json_encode(['success' => false, 'message' => 'rumah_warga_id diperlukan']); break; }
$res = $conn->query("SELECT * FROM anggota_keluarga WHERE rumah_warga_id=$wargaId ORDER BY tanggal_lahir ASC");
$data = [];
$today = new DateTime();
while ($row = $res->fetch_assoc()) {
// Hitung usia
if ($row['tanggal_lahir']) {
$lahir = new DateTime($row['tanggal_lahir']);
$row['usia'] = (int)$today->diff($lahir)->y;
} else {
$row['usia'] = null;
}
$data[] = $row;
}
echo json_encode(['success' => true, 'data' => $data, 'total' => count($data)]);
break;
case 'POST':
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
$wargaId = (int)($input['rumah_warga_id'] ?? 0);
$nama = $conn->real_escape_string(trim($input['nama'] ?? ''));
$nik = $conn->real_escape_string($input['nik'] ?? '');
$tl = !empty($input['tanggal_lahir']) ? "'" . $conn->real_escape_string($input['tanggal_lahir']) . "'" : 'NULL';
$jk = $conn->real_escape_string($input['jenis_kelamin'] ?? 'laki_laki');
$pend = $conn->real_escape_string($input['pendidikan'] ?? 'sd');
$pek = $conn->real_escape_string($input['pekerjaan'] ?? '');
$stsBk = $conn->real_escape_string($input['status_bekerja'] ?? 'tidak_bekerja');
$kes = $conn->real_escape_string($input['kondisi_kesehatan'] ?? 'sehat');
$bisa = (int)($input['dapat_bekerja'] ?? 1);
if (!$wargaId || !$nama) { echo json_encode(['success' => false, 'message' => 'ID warga dan nama wajib diisi']); break; }
$sql = "INSERT INTO anggota_keluarga (rumah_warga_id,nama,nik,tanggal_lahir,jenis_kelamin,pendidikan,pekerjaan,status_bekerja,kondisi_kesehatan,dapat_bekerja)
VALUES ($wargaId,'$nama','$nik',$tl,'$jk','$pend','$pek','$stsBk','$kes',$bisa)";
if ($conn->query($sql)) {
// Re-hitung rekomendasi induk
rehitungRekomendasi($conn, $wargaId);
echo json_encode(['success' => true, 'message' => 'Anggota berhasil ditambahkan', 'id' => $conn->insert_id]);
} else {
echo json_encode(['success' => false, 'message' => 'Gagal: ' . $conn->error]);
}
break;
case 'PUT':
$input = json_decode(file_get_contents('php://input'), true);
$id = (int)($input['id'] ?? 0);
$nama = $conn->real_escape_string(trim($input['nama'] ?? ''));
$nik = $conn->real_escape_string($input['nik'] ?? '');
$tl = !empty($input['tanggal_lahir']) ? "'" . $conn->real_escape_string($input['tanggal_lahir']) . "'" : 'NULL';
$jk = $conn->real_escape_string($input['jenis_kelamin'] ?? 'laki_laki');
$pend = $conn->real_escape_string($input['pendidikan'] ?? 'sd');
$pek = $conn->real_escape_string($input['pekerjaan'] ?? '');
$stsBk = $conn->real_escape_string($input['status_bekerja'] ?? 'tidak_bekerja');
$kes = $conn->real_escape_string($input['kondisi_kesehatan'] ?? 'sehat');
$bisa = (int)($input['dapat_bekerja'] ?? 1);
if (!$id) { echo json_encode(['success' => false, 'message' => 'ID tidak valid']); break; }
$sql = "UPDATE anggota_keluarga SET
nama='$nama', nik='$nik', tanggal_lahir=$tl, jenis_kelamin='$jk',
pendidikan='$pend', pekerjaan='$pek', status_bekerja='$stsBk',
kondisi_kesehatan='$kes', dapat_bekerja=$bisa
WHERE id=$id";
if ($conn->query($sql)) {
// Ambil warga_id lalu re-hitung rekomendasi
$r = $conn->query("SELECT rumah_warga_id FROM anggota_keluarga WHERE id=$id");
if ($row = $r->fetch_assoc()) rehitungRekomendasi($conn, (int)$row['rumah_warga_id']);
echo json_encode(['success' => true, 'message' => 'Anggota berhasil diperbarui']);
} else {
echo json_encode(['success' => false, 'message' => 'Gagal: ' . $conn->error]);
}
break;
case 'DELETE':
$id = (int)($_GET['id'] ?? 0);
if (!$id) { $input = json_decode(file_get_contents('php://input'), true); $id = (int)($input['id'] ?? 0); }
// Ambil warga_id sebelum hapus
$r = $conn->query("SELECT rumah_warga_id FROM anggota_keluarga WHERE id=$id");
$wargaId = ($row = $r->fetch_assoc()) ? (int)$row['rumah_warga_id'] : 0;
if ($conn->query("DELETE FROM anggota_keluarga WHERE id=$id")) {
if ($wargaId) rehitungRekomendasi($conn, $wargaId);
echo json_encode(['success' => true, 'message' => 'Anggota berhasil dihapus']);
} else {
echo json_encode(['success' => false, 'message' => 'Gagal: ' . $conn->error]);
}
break;
default:
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
}
$conn->close();
// ── Helper: re-hitung & simpan rekomendasi ────────────────────────────────
function rehitungRekomendasi(mysqli $conn, int $wargaId): void
{
$res = $conn->query("SELECT penghasilan_bulanan, status_pekerjaan, is_rekomendasi_manual FROM rumah_warga WHERE id=$wargaId");
if (!($row = $res->fetch_assoc())) return;
if (isset($row['is_rekomendasi_manual']) && (int)$row['is_rekomendasi_manual'] === 1) return;
$penghasilan = (float)$row['penghasilan_bulanan'];
$stsPekerjaan = $row['status_pekerjaan'];
$today = new DateTime();
$resA = $conn->query("SELECT tanggal_lahir, status_bekerja, dapat_bekerja, kondisi_kesehatan FROM anggota_keluarga WHERE rumah_warga_id=$wargaId");
$anggota = [];
while ($a = $resA->fetch_assoc()) $anggota[] = $a;
$adaProduktif = false;
$adaProduktifBisaKerja = false;
$semuaProduktifTakBisa = true;
foreach ($anggota as $a) {
$usia = null;
if ($a['tanggal_lahir']) {
$lahir = new DateTime($a['tanggal_lahir']);
$usia = (int)$today->diff($lahir)->y;
}
if ($usia !== null && $usia >= 18 && $usia <= 55) {
$adaProduktif = true;
$bisaKerja = (int)$a['dapat_bekerja'] === 1 && in_array($a['kondisi_kesehatan'], ['sehat', 'sakit_ringan']);
if ($bisaKerja) $semuaProduktifTakBisa = false;
if ($bisaKerja && $a['status_bekerja'] !== 'bekerja') $adaProduktifBisaKerja = true;
}
}
$perluRutin = !$adaProduktif || ($adaProduktif && $semuaProduktifTakBisa) || $penghasilan < 500000 || $stsPekerjaan === 'tidak_bekerja';
$perluPelatihan = $adaProduktifBisaKerja;
$rek = 'tidak_ada';
if ($perluRutin && $perluPelatihan) $rek = 'keduanya';
elseif ($perluRutin) $rek = 'bantuan_rutin';
elseif ($perluPelatihan) $rek = 'bantuan_pelatihan';
$conn->query("UPDATE rumah_warga SET rekomendasi_bantuan='$rek' WHERE id=$wargaId");
}
+216
View File
@@ -0,0 +1,216 @@
<?php
// api/auth.php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
require_once '../config/db.php';
require_once 'middleware.php';
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? 'status';
try {
switch ($method) {
case 'GET':
if ($action === 'status') {
// Cek status login (Guest support)
$user = optionalAuth();
if ($user) {
echo json_encode([
'success' => true,
'logged_in' => true,
'user' => [
'id' => $user['id'],
'username' => $user['username'],
'role' => $user['role'],
'rumah_ibadah_id' => $user['rumah_ibadah_id'],
'no_telp' => $user['no_telp'] ?? ''
]
]);
} else {
echo json_encode([
'success' => true,
'logged_in' => false,
'user' => null,
'message' => 'Guest Mode: Pengguna belum login.'
]);
}
} else {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Action GET tidak dikenal.']);
}
break;
case 'POST':
if ($action === 'login') {
// Proses Login
$body = json_decode(file_get_contents('php://input'), true) ?: $_POST;
$username = trim($body['username'] ?? '');
$password = trim($body['password'] ?? '');
if (!$username || !$password) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Username dan Password wajib diisi.']);
exit;
}
// Cari user berdasarkan username
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = ?");
$stmt->execute([$username]);
$user = $stmt->fetch();
if ($user && password_verify($password, $user['password'])) {
// Simpan data penting ke session
$_SESSION['user'] = [
'id' => (int)$user['id'],
'username' => $user['username'],
'role' => $user['role'],
'rumah_ibadah_id' => $user['rumah_ibadah_id'] ? (int)$user['rumah_ibadah_id'] : null,
'no_telp' => $user['no_telp'] ?? ''
];
echo json_encode([
'success' => true,
'message' => 'Login berhasil! Selamat datang, ' . $user['username'],
'user' => $_SESSION['user']
]);
} else {
http_response_code(401);
echo json_encode(['success' => false, 'message' => 'Username atau Password salah.']);
}
} elseif ($action === 'register') {
// Proses Registrasi Mandiri (hanya untuk kontributor & koordinator)
$body = json_decode(file_get_contents('php://input'), true) ?: $_POST;
$username = trim($body['username'] ?? '');
$email = trim($body['email'] ?? '');
$password = trim($body['password'] ?? '');
$noTelp = trim($body['no_telp'] ?? '');
$role = trim($body['role'] ?? '');
$rumahIbadahId = isset($body['rumah_ibadah_id']) && $body['rumah_ibadah_id'] !== '' ? (int)$body['rumah_ibadah_id'] : null;
// Validasi field wajib
if (!$username || !$email || !$password || !$noTelp || !$role) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Semua field wajib diisi (username, email, password, no_telp, role).']);
exit;
}
// Validasi panjang & kekuatan password
if (strlen($password) < 6) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Password minimal 6 karakter.']);
exit;
}
// Validasi format email
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Format email tidak valid.']);
exit;
}
// Whitelist role — HANYA kontributor dan koordinator yang boleh mendaftar sendiri
$allowedRoles = ['kontributor', 'koordinator'];
if (!in_array($role, $allowedRoles)) {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'Peran "' . $role . '" tidak diizinkan untuk registrasi mandiri.']);
exit;
}
// Koordinator wajib memilih rumah ibadah
if ($role === 'koordinator' && !$rumahIbadahId) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Koordinator Rumah Ibadah wajib memilih rumah ibadah yang dikelola.']);
exit;
}
// Kontributor tidak boleh terikat ke rumah ibadah
if ($role === 'kontributor') {
$rumahIbadahId = null;
}
// Cek duplikasi username
$stmtCheck = $pdo->prepare("SELECT id FROM users WHERE username = ? LIMIT 1");
$stmtCheck->execute([$username]);
if ($stmtCheck->fetch()) {
http_response_code(409);
echo json_encode(['success' => false, 'message' => 'Username "' . $username . '" sudah terdaftar. Gunakan username lain.']);
exit;
}
// Cek duplikasi email
$stmtCheck2 = $pdo->prepare("SELECT id FROM users WHERE email = ? LIMIT 1");
$stmtCheck2->execute([$email]);
if ($stmtCheck2->fetch()) {
http_response_code(409);
echo json_encode(['success' => false, 'message' => 'Email "' . $email . '" sudah terdaftar. Gunakan email lain.']);
exit;
}
// Validasi rumah ibadah ada di database (untuk koordinator)
if ($role === 'koordinator' && $rumahIbadahId) {
$stmtIbadah = $pdo->prepare("SELECT id FROM rumah_ibadah WHERE id = ? LIMIT 1");
$stmtIbadah->execute([$rumahIbadahId]);
if (!$stmtIbadah->fetch()) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Rumah ibadah yang dipilih tidak valid atau tidak ditemukan.']);
exit;
}
}
// Hash password dengan bcrypt
$hashedPassword = password_hash($password, PASSWORD_BCRYPT);
// Simpan user baru ke database
$stmtInsert = $pdo->prepare("INSERT INTO users (username, email, password, no_telp, role, rumah_ibadah_id) VALUES (?, ?, ?, ?, ?, ?)");
$stmtInsert->execute([$username, $email, $hashedPassword, $noTelp, $role, $rumahIbadahId]);
$roleLabel = $role === 'koordinator' ? 'Koordinator Rumah Ibadah' : 'Kontributor';
echo json_encode([
'success' => true,
'message' => 'Registrasi berhasil! Selamat bergabung sebagai ' . $roleLabel . '. Silakan login dengan akun Anda.',
'username' => $username
]);
} elseif ($action === 'logout') {
// Proses Logout
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
// Hapus semua data session dan hancurkan
$_SESSION = [];
if (ini_get("session.use_cookies")) {
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$params["path"], $params["domain"],
$params["secure"], $params["httponly"]
);
}
session_destroy();
echo json_encode([
'success' => true,
'message' => 'Logout berhasil. Anda sekarang masuk sebagai Guest.'
]);
} else {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Action POST tidak dikenal.']);
}
break;
default:
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
}
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Terjadi kesalahan sistem: ' . $e->getMessage()]);
}
+177
View File
@@ -0,0 +1,177 @@
<?php
// api/kebutuhan.php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
require_once '../config/db.php';
require_once 'middleware.php';
$method = $_SERVER['REQUEST_METHOD'];
try {
switch ($method) {
case 'GET':
$currentUser = optionalAuth();
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
if ($id) {
$stmt = $pdo->prepare(
"SELECT k.*, ri.nama AS nama_rumah_ibadah, ri.alamat, ri.latitude, ri.longitude,
COALESCE(SUM(CASE WHEN ko.status = 'confirmed' THEN ko.jumlah ELSE 0 END), 0) AS jumlah_terkumpul
FROM kebutuhan k
JOIN rumah_ibadah ri ON k.rumah_ibadah_id = ri.id
LEFT JOIN kontribusi ko ON k.id = ko.kebutuhan_id
WHERE k.id = ?
GROUP BY k.id"
);
$stmt->execute([$id]);
$row = $stmt->fetch();
if ($row) {
$row['sisa_kebutuhan'] = max(0, (float)$row['jumlah_dibutuhkan'] - (float)$row['jumlah_terkumpul']);
echo json_encode(['success' => true, 'data' => $row, 'viewer_role' => $currentUser ? $currentUser['role'] : 'guest']);
} else {
echo json_encode(['success' => false, 'message' => 'Kebutuhan tidak ditemukan']);
}
} else {
$sql = "SELECT k.*, ri.nama AS nama_rumah_ibadah, ri.alamat, ri.latitude, ri.longitude,
COALESCE(SUM(CASE WHEN ko.status = 'confirmed' THEN ko.jumlah ELSE 0 END), 0) AS jumlah_terkumpul
FROM kebutuhan k
JOIN rumah_ibadah ri ON k.rumah_ibadah_id = ri.id
LEFT JOIN kontribusi ko ON k.id = ko.kebutuhan_id
GROUP BY k.id
ORDER BY k.created_at DESC";
$rows = $pdo->query($sql)->fetchAll();
foreach ($rows as &$r) {
$r['sisa_kebutuhan'] = max(0, (float)$r['jumlah_dibutuhkan'] - (float)$r['jumlah_terkumpul']);
}
echo json_encode(['success' => true, 'data' => $rows, 'viewer_role' => $currentUser ? $currentUser['role'] : 'guest']);
}
break;
case 'POST':
$currentUser = requireAuth(['admin', 'koordinator']);
$body = json_decode(file_get_contents('php://input'), true) ?: $_POST;
$rumahIbadahId = (int)($body['rumah_ibadah_id'] ?? 0);
$jenisBantuan = trim($body['jenis_bantuan'] ?? '');
$kategori = trim($body['kategori'] ?? 'barang');
$satuan = trim($body['satuan'] ?? 'unit');
$jumlahReq = (float)($body['jumlah_dibutuhkan'] ?? 0);
if ($currentUser['role'] === 'koordinator' && $currentUser['rumah_ibadah_id'] != $rumahIbadahId) {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'Koordinator hanya dapat mengelola kebutuhan rumah ibadah mereka sendiri.']);
exit;
}
$allowedSatuan = ['unit','orang','jam','shift','nominal'];
if (!in_array($satuan, $allowedSatuan)) $satuan = 'unit';
if (!$rumahIbadahId || !$jenisBantuan || $jumlahReq <= 0) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Parameter input tidak valid atau jumlah harus lebih besar dari 0']);
exit;
}
try {
$stmt = $pdo->prepare("INSERT INTO kebutuhan (rumah_ibadah_id, jenis_bantuan, kategori, jumlah_dibutuhkan, satuan) VALUES (?, ?, ?, ?, ?)");
$stmt->execute([$rumahIbadahId, $jenisBantuan, $kategori, $jumlahReq, $satuan]);
} catch (PDOException $e) {
$stmt = $pdo->prepare("INSERT INTO kebutuhan (rumah_ibadah_id, jenis_bantuan, kategori, jumlah_dibutuhkan) VALUES (?, ?, ?, ?)");
$stmt->execute([$rumahIbadahId, $jenisBantuan, $kategori, $jumlahReq]);
}
echo json_encode(['success' => true, 'message' => 'Kebutuhan bantuan berhasil ditambahkan', 'id' => $pdo->lastInsertId()]);
break;
case 'PUT':
$currentUser = requireAuth(['admin', 'koordinator']);
$body = json_decode(file_get_contents('php://input'), true) ?: $_POST;
$id = (int)($body['id'] ?? 0);
$stmt = $pdo->prepare("SELECT rumah_ibadah_id FROM kebutuhan WHERE id = ?");
$stmt->execute([$id]);
$kebutuhan = $stmt->fetch();
if (!$kebutuhan) {
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'Kebutuhan tidak ditemukan']);
exit;
}
if ($currentUser['role'] === 'koordinator' && $currentUser['rumah_ibadah_id'] != $kebutuhan['rumah_ibadah_id']) {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'Koordinator tidak berhak merubah kebutuhan rumah ibadah lain.']);
exit;
}
$satuanIn = trim($body['satuan'] ?? 'unit');
$allowedSatuan = ['unit','orang','jam','shift','nominal'];
if (!in_array($satuanIn, $allowedSatuan)) $satuanIn = 'unit';
try {
$stmt = $pdo->prepare("UPDATE kebutuhan SET jenis_bantuan = ?, kategori = ?, jumlah_dibutuhkan = ?, satuan = ? WHERE id = ?");
$stmt->execute([
trim($body['jenis_bantuan']),
trim($body['kategori']),
(float)$body['jumlah_dibutuhkan'],
$satuanIn,
$id
]);
} catch (PDOException $e) {
$stmt = $pdo->prepare("UPDATE kebutuhan SET jenis_bantuan = ?, kategori = ?, jumlah_dibutuhkan = ? WHERE id = ?");
$stmt->execute([
trim($body['jenis_bantuan']),
trim($body['kategori']),
(float)$body['jumlah_dibutuhkan'],
$id
]);
}
echo json_encode(['success' => true, 'message' => 'Kebutuhan berhasil diperbarui']);
break;
case 'DELETE':
$currentUser = requireAuth(['admin', 'koordinator']);
$id = (int)($_GET['id'] ?? 0);
$stmt = $pdo->prepare("SELECT rumah_ibadah_id FROM kebutuhan WHERE id = ?");
$stmt->execute([$id]);
$kebutuhan = $stmt->fetch();
if (!$kebutuhan) {
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'Kebutuhan tidak ditemukan']);
exit;
}
if ($currentUser['role'] === 'koordinator' && $currentUser['rumah_ibadah_id'] != $kebutuhan['rumah_ibadah_id']) {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'Koordinator tidak berhak menghapus kebutuhan rumah ibadah lain.']);
exit;
}
$stmt = $pdo->prepare("DELETE FROM kebutuhan WHERE id = ?");
$stmt->execute([$id]);
echo json_encode(['success' => true, 'message' => 'Kebutuhan berhasil dihapus']);
break;
default:
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
}
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
+445
View File
@@ -0,0 +1,445 @@
<?php
// api/kemandirian.php
// Fitur 3: Potensi Kemandirian Warga
// Hitung skor kemandirian berdasarkan: penghasilan, status kerja anggota, jumlah tanggungan
// Method: GET (list/detail/statistik), POST (catat perkembangan manual), PUT (set exit plan)
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once '../config/db.php';
require_once 'middleware.php';
/* ─────────────────────────────────────────────
HELPER
───────────────────────────────────────────── */
function getKonfig(PDO $pdo, string $kunci, $default = null) {
$stmt = $pdo->prepare("SELECT nilai, tipe FROM konfigurasi_sistem WHERE kunci = ?");
$stmt->execute([$kunci]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) return $default;
return match($row['tipe']) {
'integer' => (int)$row['nilai'],
'decimal' => (float)$row['nilai'],
'boolean' => filter_var($row['nilai'], FILTER_VALIDATE_BOOLEAN),
default => $row['nilai'],
};
}
function hitungUsia(?string $tanggalLahir): ?int {
if (!$tanggalLahir) return null;
$lahir = new DateTime($tanggalLahir);
$today = new DateTime();
return (int)$today->diff($lahir)->y;
}
/* ─────────────────────────────────────────────
CORE: Hitung Skor Kemandirian
Komponen:
A) Skor Penghasilan (bobot configurable, default 40%)
- 0% : penghasilan = 0
- 50% : penghasilan = 50% threshold
- 100%: penghasilan >= threshold UMR
B) Skor Status Kerja (default 35%)
- Dihitung dari anggota usia produktif (18-55)
- Poin per anggota: bekerja_tetap=100, tidak_tetap=60, tidak_bekerja=0, pelajar=30
- Bonus: sudah bekerja tetap setelah ikut pelatihan
C) Skor Tanggungan (default 25%)
- Tanggungan sedikit (1-2) = skor tinggi
- Tanggungan banyak (> 6) = skor rendah
Status kemandirian:
- 0-20 : sangat_bergantung
- 21-40 : bergantung
- 41-60 : berkembang
- 61-79 : berpotensi_mandiri
- 80+ : mandiri (siap exit)
───────────────────────────────────────────── */
function hitungSkorKemandirian(
PDO $pdo,
array $warga,
array $anggotaList,
float $thresholdPenghasilan,
int $bobotPenghasilan,
int $bobotStatusKerja,
int $bobotTanggungan
): array {
$penghasilan = (float)$warga['penghasilan_bulanan'];
$tanggungan = (int)$warga['jumlah_tanggungan'];
$statusKerja = $warga['status_pekerjaan'];
/* ── A: Skor Penghasilan ── */
$skorPenghasilan = 0;
if ($thresholdPenghasilan > 0) {
$skorPenghasilan = (int)min(100, round($penghasilan / $thresholdPenghasilan * 100));
}
/* ── B: Skor Status Kerja ── */
$bobotStatusKerjaMap = [
'tetap' => 100,
'tidak_tetap' => 60,
'pensiun' => 50,
'tidak_bekerja'=> 0,
];
// Skor kepala keluarga
$poinKK = $bobotStatusKerjaMap[$statusKerja] ?? 0;
$anggotaProd = array_filter($anggotaList, function($a) {
$usia = hitungUsia($a['tanggal_lahir']);
return $usia !== null && $usia >= 18 && $usia <= 55;
});
if (count($anggotaProd) > 0) {
$totalPoin = $poinKK;
foreach ($anggotaProd as $a) {
$stsBekMap = [
'bekerja' => 100,
'pensiunan' => 50,
'pelajar' => 30,
'tidak_bekerja' => 0,
];
$poin = $stsBekMap[$a['status_bekerja']] ?? 0;
// Diskon: tidak bisa bekerja / disabilitas
if (!$a['dapat_bekerja'] || in_array($a['kondisi_kesehatan'], ['sakit_berat','disabilitas'])) {
$poin = (int)($poin * 0.3);
}
$totalPoin += $poin;
}
$jumlahDitimbang = count($anggotaProd) + 1; // +1 untuk KK
$skorStatusKerja = (int)round($totalPoin / ($jumlahDitimbang * 100) * 100);
} else {
// Tidak ada anggota produktif, skor bergantung murni dari KK
$skorStatusKerja = $poinKK;
}
/* ── C: Skor Tanggungan ── */
// Makin sedikit tanggungan relatif terhadap yang bekerja = makin mandiri
$anggotaBekerja = count(array_filter($anggotaList, fn($a) => $a['status_bekerja'] === 'bekerja'));
$pencariNafkah = $anggotaBekerja + ($statusKerja !== 'tidak_bekerja' ? 1 : 0);
if ($tanggungan <= 0) {
$skorTanggungan = 100;
} elseif ($pencariNafkah >= $tanggungan) {
$skorTanggungan = 100; // semua tanggungan punya pemasukan
} else {
$rasio = $pencariNafkah / $tanggungan;
$skorTanggungan = (int)min(100, round($rasio * 100));
}
/* ── Skor Total (weighted) ── */
$totalBobot = $bobotPenghasilan + $bobotStatusKerja + $bobotTanggungan;
if ($totalBobot <= 0) $totalBobot = 100;
$skorTotal = (int)round(
($skorPenghasilan * $bobotPenghasilan
+ $skorStatusKerja * $bobotStatusKerja
+ $skorTanggungan * $bobotTanggungan) / $totalBobot
);
/* ── Status ── */
$status = match(true) {
$skorTotal >= 80 => 'mandiri',
$skorTotal >= 61 => 'berpotensi_mandiri',
$skorTotal >= 41 => 'berkembang',
$skorTotal >= 21 => 'bergantung',
default => 'sangat_bergantung',
};
/* ── Estimasi waktu menuju mandiri ── */
$estimasiKenaikan = 5; // asumsi naik 5 poin/bulan dengan pelatihan
$bulanMenujuMandiri = $skorTotal >= 80 ? 0 : (int)ceil((80 - $skorTotal) / $estimasiKenaikan);
/* ── Rekomendasi aksi ── */
$rekomendasi = [];
if ($skorPenghasilan < 50) $rekomendasi[] = 'Tingkatkan penghasilan melalui pelatihan vokasional';
if ($skorStatusKerja < 50) $rekomendasi[] = 'Dukung anggota produktif untuk mendapat pekerjaan tetap';
if ($skorTanggungan < 50) $rekomendasi[] = 'Pertimbangkan program KB/pemberdayaan tanggungan';
if (count($rekomendasi) === 0) $rekomendasi[] = 'Pertahankan perkembangan yang ada, pantau secara berkala';
return [
'skor_total' => $skorTotal,
'skor_penghasilan' => $skorPenghasilan,
'skor_status_kerja' => $skorStatusKerja,
'skor_tanggungan' => $skorTanggungan,
'status' => $status,
'estimasi_bulan_mandiri' => $bulanMenujuMandiri,
'rekomendasi' => $rekomendasi,
'detail' => [
'penghasilan' => $penghasilan,
'threshold' => $thresholdPenghasilan,
'jumlah_tanggungan' => $tanggungan,
'pencari_nafkah' => $pencariNafkah,
'anggota_produktif' => count($anggotaProd),
],
];
}
/* ─────────────────────────────────────────────
UPDATE skor kemandirian batch + simpan riwayat
───────────────────────────────────────────── */
function updateKemandirianBatch(PDO $pdo, bool $simpanRiwayat = false): int {
$thres = getKonfig($pdo, 'threshold_penghasilan_mandiri', 3500000);
$bPeng = getKonfig($pdo, 'bobot_penghasilan', 40);
$bKerja = getKonfig($pdo, 'bobot_status_kerja', 35);
$bTangg = getKonfig($pdo, 'bobot_tanggungan', 25);
$wargaList = $pdo->query("
SELECT id, penghasilan_bulanan, jumlah_tanggungan, status_pekerjaan,
skor_kemandirian as skor_lama
FROM rumah_warga
")->fetchAll(PDO::FETCH_ASSOC);
$stmtAnggota = $pdo->prepare("
SELECT nama, tanggal_lahir, status_bekerja, kondisi_kesehatan, dapat_bekerja
FROM anggota_keluarga WHERE rumah_warga_id = ?
");
$stmtUpdate = $pdo->prepare("
UPDATE rumah_warga SET skor_kemandirian = ?, status_kemandirian = ? WHERE id = ?
");
$stmtRiwayat = $pdo->prepare("
INSERT INTO riwayat_kemandirian
(rumah_warga_id, tanggal, skor, status, penghasilan_saat_ini)
VALUES (?, CURDATE(), ?, ?, ?)
");
$updated = 0;
foreach ($wargaList as $w) {
$stmtAnggota->execute([(int)$w['id']]);
$anggota = $stmtAnggota->fetchAll(PDO::FETCH_ASSOC);
$calc = hitungSkorKemandirian($pdo, $w, $anggota, $thres, $bPeng, $bKerja, $bTangg);
$stmtUpdate->execute([$calc['skor_total'], $calc['status'], (int)$w['id']]);
if ($simpanRiwayat && $calc['skor_total'] !== (int)$w['skor_lama']) {
$stmtRiwayat->execute([
(int)$w['id'],
$calc['skor_total'],
$calc['status'],
(float)$w['penghasilan_bulanan'],
]);
}
$updated++;
}
return $updated;
}
/* ─────────────────────────────────────────────
ROUTING
───────────────────────────────────────────── */
try {
// Hanya Admin dan Petugas Lapangan yang memiliki akses ke modul kemandirian warga
requireAuth(['admin', 'petugas']);
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? 'list';
if ($method === 'GET') {
if ($action === 'list') {
// Recalculate dulu
updateKemandirianBatch($pdo, true);
$statusFilter = $_GET['status'] ?? null;
$minSkor = (int)($_GET['min_skor'] ?? 0);
$limit = (int)($_GET['limit'] ?? 50);
$thresholdSk = getKonfig($pdo, 'threshold_skor_mandiri', 70);
$where = [];
if ($statusFilter) $where[] = "rw.status_kemandirian = " . $pdo->quote($statusFilter);
if ($minSkor > 0) $where[] = "rw.skor_kemandirian >= $minSkor";
$whereStr = $where ? "WHERE " . implode(" AND ", $where) : "";
$rows = $pdo->query("
SELECT rw.id, rw.nama_kepala_keluarga, rw.no_kk,
rw.penghasilan_bulanan, rw.jumlah_tanggungan,
rw.status_pekerjaan, rw.kategori_kemiskinan,
rw.skor_kemandirian, rw.status_kemandirian,
rw.catatan_kemandirian, rw.tanggal_exit_plan,
rw.rekomendasi_bantuan, rw.status_bantuan,
rw.kelurahan, rw.kecamatan,
rw.latitude, rw.longitude
FROM rumah_warga rw
$whereStr
ORDER BY rw.skor_kemandirian DESC
LIMIT $limit
")->fetchAll(PDO::FETCH_ASSOC);
// Tambah detail skor per row
$thres = getKonfig($pdo, 'threshold_penghasilan_mandiri', 3500000);
$bPeng = getKonfig($pdo, 'bobot_penghasilan', 40);
$bKerja = getKonfig($pdo, 'bobot_status_kerja', 35);
$bTangg = getKonfig($pdo, 'bobot_tanggungan', 25);
$stmtA = $pdo->prepare("SELECT * FROM anggota_keluarga WHERE rumah_warga_id = ?");
foreach ($rows as &$r) {
$stmtA->execute([(int)$r['id']]);
$anggota = $stmtA->fetchAll(PDO::FETCH_ASSOC);
$r['detail_skor'] = hitungSkorKemandirian($pdo, $r, $anggota, $thres, $bPeng, $bKerja, $bTangg);
}
$summary = $pdo->query("
SELECT status_kemandirian, COUNT(*) as jumlah, ROUND(AVG(skor_kemandirian)) as avg_skor
FROM rumah_warga GROUP BY status_kemandirian
")->fetchAll(PDO::FETCH_ASSOC);
$siapExit = $pdo->query("
SELECT COUNT(*) FROM rumah_warga
WHERE skor_kemandirian >= $thresholdSk
AND status_bantuan != 'sudah_terima'
")->fetchColumn();
echo json_encode([
'success' => true,
'data' => $rows,
'summary' => $summary,
'siap_exit' => (int)$siapExit,
]);
} elseif ($action === 'detail') {
$wargaId = (int)($_GET['id'] ?? 0);
$warga = $pdo->prepare("SELECT * FROM rumah_warga WHERE id = ?");
$warga->execute([$wargaId]);
$w = $warga->fetch(PDO::FETCH_ASSOC);
if (!$w) { echo json_encode(['success' => false, 'message' => 'Warga tidak ditemukan']); exit; }
$stmtA = $pdo->prepare("SELECT * FROM anggota_keluarga WHERE rumah_warga_id = ?");
$stmtA->execute([$wargaId]);
$anggota = $stmtA->fetchAll(PDO::FETCH_ASSOC);
$thres = getKonfig($pdo, 'threshold_penghasilan_mandiri', 3500000);
$bPeng = getKonfig($pdo, 'bobot_penghasilan', 40);
$bKerja = getKonfig($pdo, 'bobot_status_kerja', 35);
$bTangg = getKonfig($pdo, 'bobot_tanggungan', 25);
$skor = hitungSkorKemandirian($pdo, $w, $anggota, $thres, $bPeng, $bKerja, $bTangg);
// Riwayat
$riwayat = $pdo->prepare("
SELECT * FROM riwayat_kemandirian WHERE rumah_warga_id = ?
ORDER BY tanggal DESC LIMIT 12
");
$riwayat->execute([$wargaId]);
echo json_encode([
'success' => true,
'data' => $w,
'skor' => $skor,
'anggota' => $anggota,
'riwayat' => $riwayat->fetchAll(PDO::FETCH_ASSOC),
]);
} elseif ($action === 'statistik') {
updateKemandirianBatch($pdo, false);
$thresholdSk = getKonfig($pdo, 'threshold_skor_mandiri', 70);
echo json_encode([
'success' => true,
'data' => [
'distribusi_status' => $pdo->query("
SELECT status_kemandirian as status, COUNT(*) as jumlah,
ROUND(AVG(skor_kemandirian)) as avg_skor,
ROUND(AVG(penghasilan_bulanan)) as avg_penghasilan
FROM rumah_warga GROUP BY status_kemandirian ORDER BY avg_skor DESC
")->fetchAll(PDO::FETCH_ASSOC),
'siap_exit_plan' => (int)$pdo->query("
SELECT COUNT(*) FROM rumah_warga WHERE skor_kemandirian >= $thresholdSk
")->fetchColumn(),
'sudah_punya_exit_plan' => (int)$pdo->query("
SELECT COUNT(*) FROM rumah_warga WHERE tanggal_exit_plan IS NOT NULL
")->fetchColumn(),
'avg_skor_global' => (float)$pdo->query("
SELECT ROUND(AVG(skor_kemandirian),1) FROM rumah_warga
")->fetchColumn(),
'trend_bulan_ini' => $pdo->query("
SELECT DATE(tanggal) as tgl, ROUND(AVG(skor)) as avg_skor, COUNT(*) as jumlah
FROM riwayat_kemandirian
WHERE tanggal >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY DATE(tanggal) ORDER BY tgl
")->fetchAll(PDO::FETCH_ASSOC),
],
]);
}
} elseif ($method === 'POST') {
// Catat perkembangan/catatan manual dari petugas
$body = json_decode(file_get_contents('php://input'), true);
$wargaId = (int)($body['warga_id'] ?? 0);
if (!$wargaId) { echo json_encode(['success' => false, 'message' => 'warga_id wajib']); exit; }
$stmt = $pdo->prepare("
UPDATE rumah_warga
SET catatan_kemandirian = ?, tanggal_exit_plan = ?, updated_at = NOW()
WHERE id = ?
");
$stmt->execute([
$body['catatan'] ?? null,
$body['tanggal_exit_plan'] ?? null,
$wargaId,
]);
// Hitung ulang dan simpan ke riwayat
$warga = $pdo->prepare("SELECT * FROM rumah_warga WHERE id = ?");
$warga->execute([$wargaId]);
$w = $warga->fetch(PDO::FETCH_ASSOC);
$stmtA = $pdo->prepare("SELECT * FROM anggota_keluarga WHERE rumah_warga_id = ?");
$stmtA->execute([$wargaId]);
$anggota = $stmtA->fetchAll(PDO::FETCH_ASSOC);
$thres = getKonfig($pdo, 'threshold_penghasilan_mandiri', 3500000);
$bPeng = getKonfig($pdo, 'bobot_penghasilan', 40);
$bKerja = getKonfig($pdo, 'bobot_status_kerja', 35);
$bTangg = getKonfig($pdo, 'bobot_tanggungan', 25);
$calc = hitungSkorKemandirian($pdo, $w, $anggota, $thres, $bPeng, $bKerja, $bTangg);
$pdo->prepare("
INSERT INTO riwayat_kemandirian
(rumah_warga_id, tanggal, skor, status, penghasilan_saat_ini, catatan)
VALUES (?, CURDATE(), ?, ?, ?, ?)
")->execute([
$wargaId, $calc['skor_total'], $calc['status'],
(float)$w['penghasilan_bulanan'], $body['catatan'] ?? null
]);
echo json_encode([
'success' => true,
'message' => 'Catatan kemandirian berhasil disimpan',
'skor' => $calc,
]);
} elseif ($method === 'PUT') {
// Update status kemandirian manual oleh admin (override sistem)
$body = json_decode(file_get_contents('php://input'), true);
$wargaId = (int)($body['warga_id'] ?? 0);
$stmt = $pdo->prepare("
UPDATE rumah_warga
SET status_kemandirian = ?,
catatan_kemandirian = ?,
tanggal_exit_plan = ?,
updated_at = NOW()
WHERE id = ?
");
$stmt->execute([
$body['status_kemandirian'] ?? null,
$body['catatan'] ?? null,
$body['tanggal_exit_plan'] ?? null,
$wargaId,
]);
echo json_encode(['success' => true, 'message' => 'Status kemandirian diperbarui']);
}
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
+216
View File
@@ -0,0 +1,216 @@
<?php
// api/kontribusi.php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
require_once '../config/db.php';
require_once 'middleware.php';
$method = $_SERVER['REQUEST_METHOD'];
try {
switch ($method) {
case 'GET':
// Wajib login untuk melihat riwayat kontribusi
$currentUser = requireAuth(['admin', 'koordinator', 'kontributor']);
if ($currentUser['role'] === 'kontributor') {
// Hanya riwayat kontribusi milik sendiri
$stmt = $pdo->prepare("
SELECT ko.id, ko.user_id, ko.kebutuhan_id, ko.status, ko.created_at, ko.updated_at,
ko.jumlah,
ko.jenis_kontribusi AS tipe_kontribusi,
ko.detail_barang_jasa AS catatan,
k.jenis_bantuan,
ri.nama AS nama_rumah_ibadah,
ri.nama AS nama_ibadah
FROM kontribusi ko
JOIN kebutuhan k ON ko.kebutuhan_id = k.id
JOIN rumah_ibadah ri ON k.rumah_ibadah_id = ri.id
WHERE ko.user_id = ?
ORDER BY ko.created_at DESC
");
$stmt->execute([$currentUser['id']]);
$rows = $stmt->fetchAll();
} else {
// Admin atau Koordinator melihat kontribusi
$where = "";
$params = [];
if ($currentUser['role'] === 'koordinator') {
$where = "WHERE k.rumah_ibadah_id = ?";
$params[] = $currentUser['rumah_ibadah_id'];
}
$stmt = $pdo->prepare("
SELECT ko.id, ko.user_id, ko.kebutuhan_id, ko.status, ko.created_at, ko.updated_at,
ko.jumlah,
ko.jenis_kontribusi AS tipe_kontribusi,
ko.detail_barang_jasa AS catatan,
k.jenis_bantuan,
ri.nama AS nama_rumah_ibadah,
ri.nama AS nama_ibadah,
u.username AS nama_kontributor,
u.email AS kontak_kontributor
FROM kontribusi ko
JOIN kebutuhan k ON ko.kebutuhan_id = k.id
JOIN rumah_ibadah ri ON k.rumah_ibadah_id = ri.id
JOIN users u ON ko.user_id = u.id
$where
ORDER BY ko.created_at DESC
");
$stmt->execute($params);
$rows = $stmt->fetchAll();
}
echo json_encode(['success' => true, 'data' => $rows]);
break;
case 'POST':
// Wajib login sebagai Kontributor untuk berdonasi
$currentUser = requireAuth(['kontributor']);
$body = json_decode(file_get_contents('php://input'), true) ?: $_POST;
$kebutuhanId = (int)($body['kebutuhan_id'] ?? 0);
$jenisKontribusi = trim($body['jenis_kontribusi'] ?? $body['tipe_kontribusi'] ?? ''); // uang/barang/jasa
$jumlahDonasi = (float)($body['jumlah'] ?? 0);
$detail = trim($body['detail_barang_jasa'] ?? $body['catatan'] ?? '');
if (!$kebutuhanId || !$jenisKontribusi || $jumlahDonasi <= 0) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Input data donasi tidak lengkap/valid.']);
exit;
}
// ════════════════════════════════════════════════════════════════════════════
// VALIDASI SISA KEBUTUHAN
// ════════════════════════════════════════════════════════════════════════════
$stmt = $pdo->prepare("
SELECT k.jumlah_dibutuhkan, k.kategori,
COALESCE(SUM(CASE WHEN ko.status = 'confirmed' THEN ko.jumlah ELSE 0 END), 0) AS jumlah_terkumpul
FROM kebutuhan k
LEFT JOIN kontribusi ko ON k.id = ko.kebutuhan_id
WHERE k.id = ?
GROUP BY k.id
");
$stmt->execute([$kebutuhanId]);
$kebutuhan = $stmt->fetch();
if (!$kebutuhan) {
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'Data kebutuhan bantuan tidak ditemukan.']);
exit;
}
// Hitung sisa kebutuhan secara dinamis
$sisaKebutuhan = (float)$kebutuhan['jumlah_dibutuhkan'] - (float)$kebutuhan['jumlah_terkumpul'];
if ($jumlahDonasi > $sisaKebutuhan) {
http_response_code(400);
echo json_encode([
'success' => false,
'message' => sprintf(
'Donasi ditolak! Jumlah kontribusi (%.2f) melebihi sisa kebutuhan bantuan saat ini (%.2f).',
$jumlahDonasi,
$sisaKebutuhan
)
]);
exit;
}
// Simpan kontribusi (default status = pending)
$stmt = $pdo->prepare("
INSERT INTO kontribusi (user_id, kebutuhan_id, jenis_kontribusi, jumlah, detail_barang_jasa, status)
VALUES (?, ?, ?, ?, ?, 'pending')
");
$stmt->execute([$currentUser['id'], $kebutuhanId, $jenisKontribusi, $jumlahDonasi, $detail]);
echo json_encode([
'success' => true,
'message' => 'Terima kasih atas kontribusi Anda! Status kontribusi saat ini menunggu verifikasi (pending).',
'id' => $pdo->lastInsertId()
]);
break;
case 'PUT':
// Konfirmasi kontribusi (pending -> confirmed)
// Hanya Admin atau Koordinator terkait
$currentUser = requireAuth(['admin', 'koordinator']);
$body = json_decode(file_get_contents('php://input'), true) ?: $_POST;
$kontribusiId = (int)($body['id'] ?? 0);
$newStatus = trim($body['status'] ?? 'pending'); // pending/confirmed
if (!$kontribusiId || !in_array($newStatus, ['pending', 'confirmed'])) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Parameter input status tidak valid.']);
exit;
}
// Ambil detail kontribusi
$stmt = $pdo->prepare("
SELECT ko.jumlah, ko.status AS status_sebelumnya, ko.kebutuhan_id, k.rumah_ibadah_id
FROM kontribusi ko
JOIN kebutuhan k ON ko.kebutuhan_id = k.id
WHERE ko.id = ?
");
$stmt->execute([$kontribusiId]);
$kontribusi = $stmt->fetch();
if (!$kontribusi) {
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'Data kontribusi tidak ditemukan.']);
exit;
}
// Batasan wilayah untuk Koordinator
if ($currentUser['role'] === 'koordinator' && $currentUser['rumah_ibadah_id'] != $kontribusi['rumah_ibadah_id']) {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'Koordinator hanya berhak mengonfirmasi donasi untuk rumah ibadahnya sendiri.']);
exit;
}
// Jika status dirubah ke 'confirmed' dari 'pending'
if ($newStatus === 'confirmed' && $kontribusi['status_sebelumnya'] !== 'confirmed') {
$stmt = $pdo->prepare("
SELECT k.jumlah_dibutuhkan,
COALESCE(SUM(CASE WHEN ko.status = 'confirmed' THEN ko.jumlah ELSE 0 END), 0) AS jumlah_terkumpul
FROM kebutuhan k
LEFT JOIN kontribusi ko ON k.id = ko.kebutuhan_id
WHERE k.id = ?
GROUP BY k.id
");
$stmt->execute([$kontribusi['kebutuhan_id']]);
$kebutuhan = $stmt->fetch();
$sisaKebutuhan = (float)$kebutuhan['jumlah_dibutuhkan'] - (float)$kebutuhan['jumlah_terkumpul'];
if ($kontribusi['jumlah'] > $sisaKebutuhan) {
http_response_code(400);
echo json_encode([
'success' => false,
'message' => 'Gagal konfirmasi! Total confirmed donasi melebihi kapasitas kebutuhan.'
]);
exit;
}
}
$stmt = $pdo->prepare("UPDATE kontribusi SET status = ? WHERE id = ?");
$stmt->execute([$newStatus, $kontribusiId]);
echo json_encode(['success' => true, 'message' => 'Status kontribusi berhasil diperbarui ke ' . $newStatus]);
break;
default:
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
}
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
+305
View File
@@ -0,0 +1,305 @@
<?php
// api/laporan.php — Pelaporan Warga oleh Kontributor & Koordinator
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
require_once '../config/db.php';
require_once 'middleware.php';
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'POST' && isset($_POST['_method'])) {
$method = strtoupper($_POST['_method']);
}
$action = $_GET['action'] ?? '';
try {
switch ($method) {
// ── GET: List laporan ──────────────────────────────────────────────────
case 'GET':
$currentUser = requireAuth(['admin', 'petugas', 'koordinator', 'kontributor']);
$role = $currentUser['role'];
$userId = (int)$currentUser['id'];
// Jika ada action=stats untuk admin dashboard
if ($action === 'stats') {
if (!in_array($role, ['admin', 'petugas'])) {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'Forbidden']);
exit;
}
$stmtStats = $pdo->query("
SELECT status, COUNT(*) as jumlah
FROM laporan_warga
GROUP BY status
");
$stats = [];
while ($r = $stmtStats->fetch(PDO::FETCH_ASSOC)) {
$stats[$r['status']] = (int)$r['jumlah'];
}
$stmtTotal = $pdo->query("SELECT COUNT(*) FROM laporan_warga");
echo json_encode(['success' => true, 'stats' => $stats, 'total' => (int)$stmtTotal->fetchColumn()]);
exit;
}
// Filter berdasarkan role
$where = [];
$params = [];
// Jika ada action=get_petugas
if ($action === 'get_petugas') {
if (!in_array($role, ['admin', 'petugas'])) {
http_response_code(403); echo json_encode(['success' => false, 'message' => 'Forbidden']); exit;
}
$stmt = $pdo->query("SELECT username FROM users WHERE role = 'petugas' ORDER BY username ASC");
$petugas = $stmt->fetchAll(PDO::FETCH_COLUMN);
echo json_encode(['success' => true, 'petugas' => $petugas]);
exit;
}
if ($role === 'kontributor') {
// Kontributor hanya lihat laporan milik sendiri
$where[] = 'lw.pelapor_id = ?';
$params[] = $userId;
} elseif ($role === 'koordinator') {
// Koordinator lihat laporan dari wilayahnya + laporan milik sendiri
$where[] = '(lw.pelapor_id = ? OR lw.koordinator_ibadah_id = ?)';
$params[] = $userId;
$params[] = !empty($currentUser['rumah_ibadah_id']) ? (int)$currentUser['rumah_ibadah_id'] : 0;
} elseif ($role === 'petugas') {
// Petugas hanya lihat laporan yang ditugaskan ke mereka
$where[] = 'lw.ditugaskan_ke = ?';
$params[] = $currentUser['username'];
}
// Admin: lihat semua
// Filter status
if (!empty($_GET['status'])) {
$where[] = 'lw.status = ?';
$params[] = $_GET['status'];
}
// Filter prioritas
if (!empty($_GET['prioritas'])) {
$where[] = 'lw.prioritas = ?';
$params[] = $_GET['prioritas'];
}
$whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$sql = "
SELECT
lw.*,
rw.nama_kepala_keluarga,
rw.kelurahan,
rw.kecamatan,
rw.kategori_kemiskinan,
rw.status_bantuan,
u.username AS pelapor_nama,
u.role AS pelapor_role
FROM laporan_warga lw
JOIN rumah_warga rw ON rw.id = lw.rumah_warga_id
JOIN users u ON u.id = lw.pelapor_id
$whereSql
ORDER BY lw.created_at DESC
LIMIT 100
";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$data = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Summary counts for filter badges
$stmtSummary = $pdo->query("SELECT status, COUNT(*) as n FROM laporan_warga GROUP BY status");
$summary = [];
while ($r = $stmtSummary->fetch(PDO::FETCH_ASSOC)) {
$summary[$r['status']] = (int)$r['n'];
}
$response = ['success' => true, 'data' => $data, 'summary' => $summary, 'total' => count($data)];
// Sertakan list petugas jika user admin
if ($role === 'admin' || $role === 'petugas') {
$stmtP = $pdo->query("SELECT username FROM users WHERE role = 'petugas' ORDER BY username ASC");
$response['petugas'] = $stmtP->fetchAll(PDO::FETCH_COLUMN);
}
echo json_encode($response);
break;
// ── POST: Buat laporan baru ──────────────────────────────────────────
case 'POST':
$currentUser = requireAuth(['koordinator', 'kontributor']);
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
$rumahWargaId = (int)($input['rumah_warga_id'] ?? 0);
$jenisLaporan = trim($input['jenis_laporan'] ?? '');
$deskripsi = trim($input['deskripsi'] ?? '');
// Penentuan prioritas otomatis berdasarkan jenis laporan
$prioritasMap = [
'warga_meninggal' => 'tinggi',
'warga_pindah' => 'tinggi',
'warga_mampu' => 'tinggi',
'data_ganda' => 'sedang',
'data_tidak_sesuai'=> 'sedang',
'lainnya' => 'rendah'
];
$prioritas = $prioritasMap[$jenisLaporan] ?? 'sedang';
if (!$rumahWargaId || !$jenisLaporan || !$deskripsi) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Data tidak lengkap: rumah_warga_id, jenis_laporan, dan deskripsi wajib diisi.']);
exit;
}
// Cek warga ada
$stmtCek = $pdo->prepare("SELECT id, nama_kepala_keluarga FROM rumah_warga WHERE id = ?");
$stmtCek->execute([$rumahWargaId]);
$warga = $stmtCek->fetch();
if (!$warga) {
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'Data warga tidak ditemukan.']);
exit;
}
// Cek apakah laporan duplikat dalam 7 hari terakhir
$stmtDupCheck = $pdo->prepare("
SELECT id FROM laporan_warga
WHERE rumah_warga_id = ? AND pelapor_id = ?
AND status NOT IN ('selesai', 'ditolak')
AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
");
$stmtDupCheck->execute([$rumahWargaId, $currentUser['id']]);
if ($stmtDupCheck->fetch()) {
http_response_code(409);
echo json_encode(['success' => false, 'message' => 'Anda sudah membuat laporan untuk warga ini dalam 7 hari terakhir. Harap tunggu laporan sebelumnya diproses.']);
exit;
}
$koordinatorIbadahId = !empty($currentUser['rumah_ibadah_id']) ? (int)$currentUser['rumah_ibadah_id'] : null;
$stmtInsert = $pdo->prepare("
INSERT INTO laporan_warga
(rumah_warga_id, pelapor_id, koordinator_ibadah_id, jenis_laporan, deskripsi, prioritas, status)
VALUES (?, ?, ?, ?, ?, ?, 'pending')
");
$stmtInsert->execute([
$rumahWargaId,
$currentUser['id'],
$koordinatorIbadahId,
$jenisLaporan,
$deskripsi,
$prioritas,
]);
echo json_encode([
'success' => true,
'message' => 'Laporan berhasil dikirim. Admin akan meninjau laporan Anda segera.',
'id' => $pdo->lastInsertId()
]);
break;
case 'PUT':
$currentUser = requireAuth(['admin', 'petugas']);
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) $input = $_POST;
$id = (int)($input['id'] ?? 0);
$status = $input['status'] ?? '';
$catatanAdmin = trim($input['catatan_admin'] ?? '');
$ditugaskanKe = trim($input['ditugaskan_ke'] ?? '');
// Ambil dari old state
$stmtDok = $pdo->prepare("SELECT dokumentasi FROM laporan_warga WHERE id = ?");
$stmtDok->execute([$id]);
$oldDok = $stmtDok->fetchColumn();
$dokumentasi = trim($input['dokumentasi'] ?? $oldDok ?? '');
// Handle file upload
if (isset($_FILES['dokumentasi_file']) && $_FILES['dokumentasi_file']['error'] === UPLOAD_ERR_OK) {
$uploadDir = '../uploads/dokumentasi/';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
$ext = strtolower(pathinfo($_FILES['dokumentasi_file']['name'], PATHINFO_EXTENSION));
$allowedExts = ['jpg', 'jpeg', 'png', 'pdf'];
if (in_array($ext, $allowedExts)) {
$filename = 'dok_' . $id . '_' . time() . '.' . $ext;
$dest = $uploadDir . $filename;
if (move_uploaded_file($_FILES['dokumentasi_file']['tmp_name'], $dest)) {
$dokumentasi = 'uploads/dokumentasi/' . $filename;
}
}
}
$allowedStatus = ['pending', 'ditinjau', 'diproses', 'selesai', 'ditolak'];
if (!$id || !in_array($status, $allowedStatus)) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'ID dan status valid wajib diisi.']);
exit;
}
// Jika petugas, pastikan hanya bisa update dokumentasi, status, catatan. (ditugaskan_ke biarkan tetap)
if ($currentUser['role'] === 'petugas') {
$stmt = $pdo->prepare("
UPDATE laporan_warga
SET status = ?, catatan_admin = ?, dokumentasi = ?, updated_at = NOW()
WHERE id = ? AND ditugaskan_ke = ?
");
$stmt->execute([$status, $catatanAdmin, $dokumentasi, $id, $currentUser['username']]);
} else {
// Admin bisa update semua
$stmt = $pdo->prepare("
UPDATE laporan_warga
SET status = ?, catatan_admin = ?, ditugaskan_ke = ?, dokumentasi = ?, updated_at = NOW()
WHERE id = ?
");
$stmt->execute([$status, $catatanAdmin, $ditugaskanKe ?: null, $dokumentasi, $id]);
}
$statusLabel = [
'pending' => 'Menunggu',
'ditinjau' => 'Sedang Ditinjau',
'diproses' => 'Sedang Diproses',
'selesai' => 'Selesai',
'ditolak' => 'Ditolak',
];
echo json_encode([
'success' => true,
'message' => 'Laporan berhasil diperbarui ke status: ' . ($statusLabel[$status] ?? $status)
]);
break;
// ── DELETE: Hapus laporan (admin only) ──────────────────────────────
case 'DELETE':
$currentUser = requireAuth(['admin']);
$id = (int)($_GET['id'] ?? 0);
if (!$id) {
$input = json_decode(file_get_contents('php://input'), true);
$id = (int)($input['id'] ?? 0);
}
if (!$id) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'ID laporan wajib diisi.']);
exit;
}
$stmt = $pdo->prepare("DELETE FROM laporan_warga WHERE id = ?");
$stmt->execute([$id]);
echo json_encode(['success' => true, 'message' => 'Laporan berhasil dihapus.']);
break;
default:
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
}
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Kesalahan sistem: ' . $e->getMessage()]);
}
+59
View File
@@ -0,0 +1,59 @@
<?php
// api/middleware.php
// Memulai session jika belum dimulai
if (session_status() === PHP_SESSION_NONE) {
// Pastikan session cookie aman dan hanya terakses lewat HTTP (mencegah XSS hijack)
ini_set('session.cookie_httponly', 1);
session_start();
}
/**
* Middleware untuk memvalidasi user yang login dan mencocokkan perannya (RBAC).
* Jika user belum login, akan mengembalikan 401 Unauthorized.
* Jika role user tidak ada dalam daftar role yang diizinkan, mengembalikan 403 Forbidden.
*
* @param array $allowedRoles Daftar role yang diizinkan (contoh: ['admin', 'petugas'])
* @return array Data user yang saat ini sedang login dari Session
*/
function requireAuth(array $allowedRoles = []): array
{
// Cek apakah ada data user di Session
if (!isset($_SESSION['user']) || !is_array($_SESSION['user'])) {
http_response_code(401);
echo json_encode([
'success' => false,
'message' => 'Unauthorized: Silakan login terlebih dahulu untuk mengakses fitur ini.'
]);
exit;
}
$user = $_SESSION['user'];
// Cek kecocokan role (RBAC) jika list allowedRoles tidak kosong
if (!empty($allowedRoles) && !in_array($user['role'], $allowedRoles)) {
http_response_code(403);
echo json_encode([
'success' => false,
'message' => 'Forbidden: Peran Anda (' . $user['role'] . ') tidak memiliki hak akses ke fitur ini.'
]);
exit;
}
return $user;
}
/**
* Middleware Opsional untuk mendukung Guest Mode.
* Jika user login, akan mengembalikan data user.
* Jika belum login, akan mengembalikan null (sebagai Guest) dan proses tetap berlanjut.
*
* @return array|null Data user jika login, atau null jika guest
*/
function optionalAuth(): ?array
{
if (isset($_SESSION['user']) && is_array($_SESSION['user'])) {
return $_SESSION['user'];
}
return null;
}
+42
View File
@@ -0,0 +1,42 @@
<?php
require_once '../config/db.php';
$sql = "
CREATE TABLE IF NOT EXISTS `laporan_warga` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`rumah_warga_id` INT(11) NOT NULL,
`pelapor_id` INT(11) NOT NULL,
`koordinator_ibadah_id` INT(11) NULL DEFAULT NULL,
`jenis_laporan` ENUM(
'data_tidak_sesuai',
'warga_mampu',
'warga_pindah',
'warga_meninggal',
'data_ganda',
'lainnya'
) NOT NULL DEFAULT 'data_tidak_sesuai',
`deskripsi` TEXT NOT NULL,
`prioritas` ENUM('rendah', 'sedang', 'tinggi') NOT NULL DEFAULT 'sedang',
`status` ENUM(
'pending',
'ditinjau',
'diproses',
'selesai',
'ditolak'
) NOT NULL DEFAULT 'pending',
`catatan_admin` TEXT NULL DEFAULT NULL,
`ditugaskan_ke` VARCHAR(150) NULL DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP(),
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP() ON UPDATE CURRENT_TIMESTAMP(),
PRIMARY KEY (`id`),
KEY `fk_laporan_warga` (`rumah_warga_id`),
KEY `fk_laporan_pelapor` (`pelapor_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
";
try {
$pdo->exec($sql);
echo "Tabel laporan_warga berhasil dibuat!";
} catch (Exception $e) {
echo "Error: " . $e->getMessage();
}
+401
View File
@@ -0,0 +1,401 @@
<?php
// api/redistribusi.php
// Fitur 1: Redistribusi Bantuan antar Rumah Ibadah
// Method: GET (analisis), POST (buat saran manual), PUT (update status), DELETE
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once '../config/db.php';
require_once 'middleware.php';
/* ─────────────────────────────────────────────
HELPER: ambil konfigurasi dari DB
───────────────────────────────────────────── */
function getKonfig(PDO $pdo, string $kunci, $default = null) {
$stmt = $pdo->prepare("SELECT nilai, tipe FROM konfigurasi_sistem WHERE kunci = ?");
$stmt->execute([$kunci]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) return $default;
return match($row['tipe']) {
'integer' => (int)$row['nilai'],
'decimal' => (float)$row['nilai'],
'boolean' => filter_var($row['nilai'], FILTER_VALIDATE_BOOLEAN),
default => $row['nilai'],
};
}
/* ─────────────────────────────────────────────
CORE: Hitung beban tiap rumah ibadah
berdasarkan radius dan jumlah warga miskin
───────────────────────────────────────────── */
function hitungBebanIbadah(PDO $pdo, float $radius): array {
// Ambil semua rumah ibadah
$ibadahList = $pdo->query("SELECT * FROM rumah_ibadah ORDER BY id")->fetchAll(PDO::FETCH_ASSOC);
// Ambil semua rumah warga dengan status relevan
$wargaList = $pdo->query("
SELECT id, latitude, longitude, kategori_kemiskinan,
status_bantuan, rekomendasi_bantuan, jumlah_tanggungan,
penghasilan_bulanan, status_kemandirian, skor_kemandirian,
dikelola_oleh_ibadah_id
FROM rumah_warga
WHERE status_kemandirian NOT IN ('mandiri')
AND kategori_kemiskinan IN ('sangat_miskin','miskin','rentan_miskin')
")->fetchAll(PDO::FETCH_ASSOC);
$result = [];
foreach ($ibadahList as $ibadah) {
$lat1 = (float)$ibadah['latitude'];
$lng1 = (float)$ibadah['longitude'];
$rad = $ibadah['radius_primer'] ?? $radius;
$kap = (int)($ibadah['kapasitas_kk'] ?? 20);
$wargaDalam = [];
foreach ($wargaList as $warga) {
$kelolaId = $warga['dikelola_oleh_ibadah_id'];
if ($kelolaId !== null) {
if ((int)$kelolaId === (int)$ibadah['id']) {
// Explicitly assigned to this ibadah
$d = haversine($lat1, $lng1, (float)$warga['latitude'], (float)$warga['longitude']);
$wargaDalam[] = array_merge($warga, ['jarak_meter' => round($d)]);
}
} else {
// Not assigned explicitly, check radius
$d = haversine($lat1, $lng1, (float)$warga['latitude'], (float)$warga['longitude']);
if ($d <= $rad) {
$wargaDalam[] = array_merge($warga, ['jarak_meter' => round($d)]);
}
}
}
// Hitung kategori
$sangat = count(array_filter($wargaDalam, fn($w) => $w['kategori_kemiskinan'] === 'sangat_miskin'));
$miskin = count(array_filter($wargaDalam, fn($w) => $w['kategori_kemiskinan'] === 'miskin'));
$rentan = count(array_filter($wargaDalam, fn($w) => $w['kategori_kemiskinan'] === 'rentan_miskin'));
$total = count($wargaDalam);
// Indeks beban: (sangat_miskin*3 + miskin*2 + rentan*1) / kapasitas
// Skor 0-100, > 100 = overload
$bobotBeban = ($sangat * 3 + $miskin * 2 + $rentan * 1);
$maxBobot = $kap * 3; // kalau semua sangat miskin
$pctBeban = $maxBobot > 0 ? round(min($bobotBeban / $maxBobot * 100, 200)) : 0;
// Rasio kemiskinan: berapa % warga dalam radius yang masuk kategori miskin
$rasioMiskin = $total > 0 ? round(($sangat + $miskin) / $total * 100) : 0;
// Status beban
$statusBeban = match(true) {
$total === 0 => 'kosong',
$total < $kap * 0.5 => 'ringan',
$total <= $kap => 'normal',
$total <= $kap * 1.3 => 'padat',
default => 'overload',
};
$result[$ibadah['id']] = [
'ibadah' => $ibadah,
'radius_dipakai' => $rad,
'kapasitas' => $kap,
'total_warga' => $total,
'sangat_miskin' => $sangat,
'miskin' => $miskin,
'rentan_miskin' => $rentan,
'rasio_miskin' => $rasioMiskin,
'bobot_beban' => $bobotBeban,
'pct_beban' => $pctBeban,
'status_beban' => $statusBeban,
'surplus_kapasitas' => max(0, $kap - $total),
'defisit_kapasitas' => max(0, $total - $kap),
'warga_list' => $wargaDalam,
];
}
return $result;
}
/* ─────────────────────────────────────────────
CORE: Generate saran redistribusi otomatis
───────────────────────────────────────────── */
function generateSaranRedistribusi(array $bebanData): array {
$saranList = [];
// Pisahkan ibadah surplus vs defisit
$surplus = array_filter($bebanData, fn($d) => $d['status_beban'] === 'ringan' || $d['status_beban'] === 'kosong');
$defisit = array_filter($bebanData, fn($d) => in_array($d['status_beban'], ['padat','overload']));
// Sort: defisit paling parah dulu, surplus paling longgar dulu
uasort($surplus, fn($a,$b) => $a['pct_beban'] <=> $b['pct_beban']);
uasort($defisit, fn($a,$b) => $b['pct_beban'] <=> $a['pct_beban']);
foreach ($defisit as $def) {
foreach ($surplus as &$sur) {
if ($sur['surplus_kapasitas'] <= 0) continue;
$ibadahDef = $def['ibadah'];
$ibadahSur = $sur['ibadah'];
// Hitung jarak antar kedua ibadah
$jarakKm = round(haversine(
(float)$ibadahDef['latitude'], (float)$ibadahDef['longitude'],
(float)$ibadahSur['latitude'], (float)$ibadahSur['longitude']
) / 1000, 2);
// Hanya sarankan jika jarak tidak terlalu jauh (< 5km)
if ($jarakKm > 5) continue;
$jumlahPindah = min($def['defisit_kapasitas'], $sur['surplus_kapasitas']);
if ($jumlahPindah <= 0) continue;
// Estimasi dana: rata-rata penghasilan warga * faktor bantuan
$estDana = $jumlahPindah * 200000; // estimasi Rp200rb/KK/bulan
// Tingkat prioritas
$prioritas = match(true) {
$def['rasio_miskin'] >= 80 => 'tinggi',
$def['rasio_miskin'] >= 50 => 'sedang',
default => 'rendah',
};
$saranList[] = [
'pengirim_id' => (int)$ibadahSur['id'],
'pengirim_nama' => $ibadahSur['nama'],
'penerima_id' => (int)$ibadahDef['id'],
'penerima_nama' => $ibadahDef['nama'],
'jumlah_kk' => $jumlahPindah,
'estimasi_dana' => $estDana,
'jarak_km' => $jarakKm,
'prioritas' => $prioritas,
'alasan' => sprintf(
'%s memiliki surplus %d kapasitas (beban %d%%), sementara %s kelebihan %d KK (beban %d%%) dengan rasio kemiskinan %d%%.',
$ibadahSur['nama'],
$sur['surplus_kapasitas'],
$sur['pct_beban'],
$ibadahDef['nama'],
$def['defisit_kapasitas'],
$def['pct_beban'],
$def['rasio_miskin']
),
];
// Kurangi surplus yang sudah dipakai
$sur['surplus_kapasitas'] -= $jumlahPindah;
break;
}
}
// Sort hasil saran berdasarkan prioritas
usort($saranList, function($a, $b) {
$p = ['tinggi' => 0, 'sedang' => 1, 'rendah' => 2];
return ($p[$a['prioritas']] ?? 9) <=> ($p[$b['prioritas']] ?? 9);
});
return $saranList;
}
function haversine(float $lat1, float $lng1, float $lat2, float $lng2): float {
$R = 6371000;
$dLat = deg2rad($lat2 - $lat1);
$dLng = deg2rad($lng2 - $lng1);
$a = sin($dLat/2)**2 + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLng/2)**2;
return $R * 2 * atan2(sqrt($a), sqrt(1-$a));
}
/* ─────────────────────────────────────────────
ROUTING
───────────────────────────────────────────── */
try {
// Hanya Admin yang bisa mengakses fitur redistribusi bantuan / analisis spasial
requireAuth(['admin']);
// Guard: PDO connection must be available
if (!$pdo) {
throw new RuntimeException('Koneksi database (PDO) tidak tersedia. Pastikan XAMPP MySQL aktif.');
}
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET') {
$action = $_GET['action'] ?? 'analisis';
if ($action === 'analisis') {
// Analisis beban semua rumah ibadah
$radius = (float)($_GET['radius'] ?? getKonfig($pdo, 'kapasitas_default_ibadah') ?? 500);
$beban = hitungBebanIbadah($pdo, $radius);
// Hapus warga_list dari response utama (terlalu besar)
$ringkasan = array_map(function($d) {
$r = $d;
unset($r['warga_list']);
return $r;
}, $beban);
echo json_encode([
'success' => true,
'data' => array_values($ringkasan),
'saran' => generateSaranRedistribusi($beban),
'summary' => [
'total_ibadah' => count($beban),
'overload' => count(array_filter($beban, fn($d) => $d['status_beban'] === 'overload')),
'padat' => count(array_filter($beban, fn($d) => $d['status_beban'] === 'padat')),
'normal' => count(array_filter($beban, fn($d) => $d['status_beban'] === 'normal')),
'ringan' => count(array_filter($beban, fn($d) => $d['status_beban'] === 'ringan')),
'kosong' => count(array_filter($beban, fn($d) => $d['status_beban'] === 'kosong')),
],
]);
} elseif ($action === 'riwayat') {
// Riwayat keputusan redistribusi
$limit = (int)($_GET['limit'] ?? 20);
$status = $_GET['status'] ?? null;
$sql = "SELECT r.*,
ip.nama as pengirim_nama, ip.jenis as pengirim_jenis,
ir.nama as penerima_nama, ir.jenis as penerima_jenis
FROM redistribusi_bantuan r
JOIN rumah_ibadah ip ON r.ibadah_pengirim_id = ip.id
JOIN rumah_ibadah ir ON r.ibadah_penerima_id = ir.id";
if ($status) $sql .= " WHERE r.status = " . $pdo->quote($status);
$sql .= " ORDER BY r.tanggal_saran DESC LIMIT $limit";
$rows = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
echo json_encode(['success' => true, 'data' => $rows]);
} elseif ($action === 'detail') {
// Detail beban 1 ibadah
$id = (int)($_GET['id'] ?? 0);
$radius = (float)($_GET['radius'] ?? 500);
$beban = hitungBebanIbadah($pdo, $radius);
if (!isset($beban[$id])) {
echo json_encode(['success' => false, 'message' => 'Rumah ibadah tidak ditemukan']);
exit;
}
echo json_encode(['success' => true, 'data' => $beban[$id]]);
}
} elseif ($method === 'POST') {
// Simpan saran redistribusi (dari sistem atau manual admin)
$body = json_decode(file_get_contents('php://input'), true);
$stmt = $pdo->prepare("
INSERT INTO redistribusi_bantuan
(ibadah_pengirim_id, ibadah_penerima_id, jumlah_kk, estimasi_dana,
status, catatan_sistem, catatan_admin, periode_bulan)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
");
$stmt->execute([
(int)$body['pengirim_id'],
(int)$body['penerima_id'],
(int)($body['jumlah_kk'] ?? 0),
(float)($body['estimasi_dana'] ?? 0),
$body['status'] ?? 'saran_sistem',
$body['catatan_sistem'] ?? null,
$body['catatan_admin'] ?? null,
$body['periode_bulan'] ?? date('Y-m'),
]);
$redistId = (int)$pdo->lastInsertId();
// Jika disetujui, alihkan warga dari penerima (defisit) ke pengirim (surplus)
if (($body['status'] ?? '') === 'disetujui') {
$penerimaId = (int)$body['penerima_id'];
$pengirimId = (int)$body['pengirim_id'];
$limit = (int)($body['jumlah_kk'] ?? 0);
if (isset($body['warga_ids']) && is_array($body['warga_ids']) && count($body['warga_ids']) > 0) {
$ids = array_map('intval', $body['warga_ids']);
$placeholders = str_repeat('?,', count($ids) - 1) . '?';
$updateStmt = $pdo->prepare("UPDATE rumah_warga SET dikelola_oleh_ibadah_id = ? WHERE id IN ($placeholders)");
$params = array_merge([$pengirimId], $ids);
$updateStmt->execute($params);
} elseif ($limit > 0) {
// Ambil radius penerima (untuk mencari warga yang saat ini masuk radiusnya)
$stmtPen = $pdo->prepare("SELECT latitude, longitude, radius_primer FROM rumah_ibadah WHERE id = ?");
$stmtPen->execute([$penerimaId]);
$penerima = $stmtPen->fetch(PDO::FETCH_ASSOC);
// Ambil koordinat pengirim (untuk mencari warga terdekat ke pengirim)
$stmtPeng = $pdo->prepare("SELECT latitude, longitude FROM rumah_ibadah WHERE id = ?");
$stmtPeng->execute([$pengirimId]);
$pengirim = $stmtPeng->fetch(PDO::FETCH_ASSOC);
if ($penerima && $pengirim) {
$radPenerima = (float)($penerima['radius_primer'] ?? 500);
// Ambil warga yang dikelola oleh penerima atau belum dikelola siapapun
$stmtWarga = $pdo->prepare("SELECT id, latitude, longitude, dikelola_oleh_ibadah_id FROM rumah_warga WHERE (dikelola_oleh_ibadah_id = ? OR dikelola_oleh_ibadah_id IS NULL) AND kategori_kemiskinan IN ('sangat_miskin', 'miskin', 'rentan_miskin') AND status_kemandirian != 'mandiri'");
$stmtWarga->execute([$penerimaId]);
$wargaList = $stmtWarga->fetchAll(PDO::FETCH_ASSOC);
$eligible = [];
foreach ($wargaList as $w) {
// Jika sudah secara eksplisit dikelola oleh penerima, atau jika NULL dan masih dalam radius penerima
$isManaged = ($w['dikelola_oleh_ibadah_id'] == $penerimaId);
$distPenerima = haversine((float)$w['latitude'], (float)$w['longitude'], (float)$penerima['latitude'], (float)$penerima['longitude']);
if ($isManaged || $distPenerima <= $radPenerima) {
$distPengirim = haversine((float)$w['latitude'], (float)$w['longitude'], (float)$pengirim['latitude'], (float)$pengirim['longitude']);
$w['dist_pengirim'] = $distPengirim;
$eligible[] = $w;
}
}
// Urutkan warga berdasarkan jarak terdekat ke rumah ibadah pengirim (surplus)
usort($eligible, fn($a, $b) => $a['dist_pengirim'] <=> $b['dist_pengirim']);
// Ambil sejumlah $limit
$toTransfer = array_slice($eligible, 0, $limit);
if (!empty($toTransfer)) {
$ids = array_column($toTransfer, 'id');
$placeholders = str_repeat('?,', count($ids) - 1) . '?';
$updateStmt = $pdo->prepare("UPDATE rumah_warga SET dikelola_oleh_ibadah_id = ? WHERE id IN ($placeholders)");
$params = array_merge([$pengirimId], $ids);
$updateStmt->execute($params);
}
}
}
}
echo json_encode([
'success' => true,
'message' => 'Saran redistribusi berhasil disimpan',
'id' => $redistId,
]);
} elseif ($method === 'PUT') {
// Update status keputusan admin
$body = json_decode(file_get_contents('php://input'), true);
$id = (int)($body['id'] ?? 0);
$stmt = $pdo->prepare("
UPDATE redistribusi_bantuan
SET status = ?,
catatan_admin = ?,
disetujui_oleh = ?,
tanggal_keputusan = NOW()
WHERE id = ?
");
$stmt->execute([
$body['status'],
$body['catatan_admin'] ?? null,
$body['disetujui_oleh'] ?? null,
$id,
]);
echo json_encode(['success' => true, 'message' => 'Status redistribusi diperbarui']);
} elseif ($method === 'DELETE') {
$id = (int)($_GET['id'] ?? 0);
$stmt = $pdo->prepare("DELETE FROM redistribusi_bantuan WHERE id = ?");
$stmt->execute([$id]);
echo json_encode(['success' => true, 'message' => 'Data redistribusi dihapus']);
}
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
+136
View File
@@ -0,0 +1,136 @@
<?php
// api/rumah_ibadah.php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
require_once '../config/db.php';
$method = $_SERVER['REQUEST_METHOD'];
switch ($method) {
// ── GET ──────────────────────────────────────────────────────────────────
case 'GET':
if (isset($_GET['id'])) {
$id = (int)$_GET['id'];
$result = $conn->query("SELECT * FROM rumah_ibadah WHERE id = $id");
if ($row = $result->fetch_assoc()) {
echo json_encode(['success' => true, 'data' => $row]);
} else {
echo json_encode(['success' => false, 'message' => 'Data tidak ditemukan']);
}
} else {
// Filter opsional
$where = [];
if (!empty($_GET['jenis'])) $where[] = "jenis = '" . $conn->real_escape_string($_GET['jenis']) . "'";
if (!empty($_GET['kecamatan'])) $where[] = "kecamatan LIKE '%" . $conn->real_escape_string($_GET['kecamatan']) . "%'";
if (!empty($_GET['search'])) $where[] = "(nama LIKE '%" . $conn->real_escape_string($_GET['search']) . "%' OR alamat LIKE '%" . $conn->real_escape_string($_GET['search']) . "%')";
$sql = "SELECT * FROM rumah_ibadah" . ($where ? " WHERE " . implode(' AND ', $where) : "") . " ORDER BY nama ASC";
$result = $conn->query($sql);
if ($result === false) {
echo json_encode([
'success' => false,
'message' => 'Gagal mengambil data dari tabel "rumah_ibadah": ' . $conn->error . '. Pastikan Anda telah mengimpor berkas database "database/bansos_gis.sql" di phpMyAdmin Anda!'
]);
break;
}
$data = [];
while ($row = $result->fetch_assoc()) $data[] = $row;
echo json_encode(['success' => true, 'data' => $data, 'total' => count($data)]);
}
break;
// ── POST ─────────────────────────────────────────────────────────────────
case 'POST':
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
$nama = $conn->real_escape_string(trim($input['nama'] ?? ''));
$jenis = $conn->real_escape_string($input['jenis'] ?? 'masjid');
$alamat = $conn->real_escape_string($input['alamat'] ?? '');
$kelurahan = $conn->real_escape_string($input['kelurahan'] ?? '');
$kecamatan = $conn->real_escape_string($input['kecamatan'] ?? '');
$kota = $conn->real_escape_string($input['kota'] ?? '');
$lat = (float)($input['latitude'] ?? 0);
$lng = (float)($input['longitude'] ?? 0);
$pj = $conn->real_escape_string($input['penanggung_jawab'] ?? '');
$kontak = $conn->real_escape_string($input['kontak'] ?? '');
$ket = $conn->real_escape_string($input['keterangan'] ?? '');
$kap = (int)($input['kapasitas_kk'] ?? 20);
$rad = (int)($input['radius_primer'] ?? 500);
$anggaran = (float)($input['anggaran_bulanan'] ?? 0);
if (!$nama || !$lat || !$lng) {
echo json_encode(['success' => false, 'message' => 'Nama, latitude, dan longitude wajib diisi']);
break;
}
$sql = "INSERT INTO rumah_ibadah (nama,jenis,alamat,kelurahan,kecamatan,kota,latitude,longitude,penanggung_jawab,kontak,keterangan,kapasitas_kk,radius_primer,anggaran_bulanan)
VALUES ('$nama','$jenis','$alamat','$kelurahan','$kecamatan','$kota',$lat,$lng,'$pj','$kontak','$ket',$kap,$rad,$anggaran)";
if ($conn->query($sql)) {
echo json_encode(['success' => true, 'message' => 'Data berhasil disimpan', 'id' => $conn->insert_id]);
} else {
echo json_encode(['success' => false, 'message' => 'Gagal menyimpan: ' . $conn->error]);
}
break;
// ── PUT ──────────────────────────────────────────────────────────────────
case 'PUT':
$input = json_decode(file_get_contents('php://input'), true);
$id = (int)($input['id'] ?? 0);
$nama = $conn->real_escape_string(trim($input['nama'] ?? ''));
$jenis = $conn->real_escape_string($input['jenis'] ?? 'masjid');
$alamat = $conn->real_escape_string($input['alamat'] ?? '');
$kelurahan = $conn->real_escape_string($input['kelurahan'] ?? '');
$kecamatan = $conn->real_escape_string($input['kecamatan'] ?? '');
$kota = $conn->real_escape_string($input['kota'] ?? '');
$lat = (float)($input['latitude'] ?? 0);
$lng = (float)($input['longitude'] ?? 0);
$pj = $conn->real_escape_string($input['penanggung_jawab'] ?? '');
$kontak = $conn->real_escape_string($input['kontak'] ?? '');
$ket = $conn->real_escape_string($input['keterangan'] ?? '');
$kap = (int)($input['kapasitas_kk'] ?? 20);
$rad = (int)($input['radius_primer'] ?? 500);
$anggaran = (float)($input['anggaran_bulanan'] ?? 0);
if (!$id) { echo json_encode(['success' => false, 'message' => 'ID tidak valid']); break; }
$sql = "UPDATE rumah_ibadah SET
nama='$nama', jenis='$jenis', alamat='$alamat',
kelurahan='$kelurahan', kecamatan='$kecamatan', kota='$kota',
latitude=$lat, longitude=$lng,
penanggung_jawab='$pj', kontak='$kontak', keterangan='$ket',
kapasitas_kk=$kap, radius_primer=$rad, anggaran_bulanan=$anggaran
WHERE id=$id";
if ($conn->query($sql)) {
echo json_encode(['success' => true, 'message' => 'Data berhasil diperbarui']);
} else {
echo json_encode(['success' => false, 'message' => 'Gagal memperbarui: ' . $conn->error]);
}
break;
// ── DELETE ───────────────────────────────────────────────────────────────
case 'DELETE':
$id = (int)($_GET['id'] ?? 0);
if (!$id) {
$input = json_decode(file_get_contents('php://input'), true);
$id = (int)($input['id'] ?? 0);
}
if ($conn->query("DELETE FROM rumah_ibadah WHERE id=$id")) {
echo json_encode(['success' => true, 'message' => 'Data berhasil dihapus']);
} else {
echo json_encode(['success' => false, 'message' => 'Gagal menghapus: ' . $conn->error]);
}
break;
default:
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
}
$conn->close();
+511
View File
@@ -0,0 +1,511 @@
<?php
// api/rumah_warga.php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
require_once '../config/db.php';
require_once 'middleware.php';
$method = $_SERVER['REQUEST_METHOD'];
// ════════════════════════════════════════════════════════════════════════════
// FUNGSI: Hitung rekomendasi bantuan otomatis berdasarkan data anggota
// ════════════════════════════════════════════════════════════════════════════
function hitungRekomendasi(mysqli $conn, int $wargaId, float $penghasilan, string $statusPekerjaan): string
{
$today = new DateTime();
// Ambil semua anggota keluarga
$res = $conn->query("SELECT tanggal_lahir, status_bekerja, dapat_bekerja, kondisi_kesehatan FROM anggota_keluarga WHERE rumah_warga_id = $wargaId");
$anggota = [];
while ($r = $res->fetch_assoc()) $anggota[] = $r;
$adaProduktif = false; // usia 18-55 th
$adaProduktifBisaKerja = false; // produktif & bisa kerja & belum bekerja tetap
$semuaProduktifTakBisa = true; // semua yg produktif tidak bisa kerja
foreach ($anggota as $a) {
$usia = null;
if ($a['tanggal_lahir']) {
$lahir = new DateTime($a['tanggal_lahir']);
$usia = (int)$today->diff($lahir)->y;
}
if ($usia !== null && $usia >= 18 && $usia <= 55) {
$adaProduktif = true;
$bisaKerja = (int)$a['dapat_bekerja'] === 1
&& in_array($a['kondisi_kesehatan'], ['sehat', 'sakit_ringan']);
if ($bisaKerja) $semuaProduktifTakBisa = false;
// Produktif, bisa kerja, belum bekerja tetap → kandidat pelatihan
if ($bisaKerja && $a['status_bekerja'] !== 'bekerja') {
$adaProduktifBisaKerja = true;
}
}
}
// Kriteria bantuan rutin
$perluRutin = !$adaProduktif
|| ($adaProduktif && $semuaProduktifTakBisa)
|| $penghasilan < 500000
|| $statusPekerjaan === 'tidak_bekerja';
// Kriteria bantuan pelatihan
$perluPelatihan = $adaProduktifBisaKerja;
if ($perluRutin && $perluPelatihan) return 'keduanya';
if ($perluRutin) return 'bantuan_rutin';
if ($perluPelatihan) return 'bantuan_pelatihan';
return 'tidak_ada';
}
// ════════════════════════════════════════════════════════════════════════════
// FUNGSI: Hitung kategori kesejahteraan otomatis berdasarkan data warga
// ════════════════════════════════════════════════════════════════════════════
function hitungKategoriKesejahteraan(float $penghasilan, int $tanggungan, string $statusTempatTinggal, string $sumberAir): string
{
// Logika sederhana:
// Jika penghasilan sangat rendah dan tanggungan banyak
$pendapatanPerKapita = $tanggungan > 0 ? $penghasilan / $tanggungan : $penghasilan;
$skor = 0;
if ($pendapatanPerKapita < 300000) {
$skor += 40;
} elseif ($pendapatanPerKapita < 600000) {
$skor += 20;
}
if ($statusTempatTinggal === 'numpang' || $statusTempatTinggal === 'sewa') {
$skor += 20;
}
if ($sumberAir === 'sungai' || $sumberAir === 'hujan') {
$skor += 10;
}
if ($skor >= 50) return 'sangat_miskin';
if ($skor >= 20) return 'miskin';
return 'rentan_miskin';
}
// ════════════════════════════════════════════════════════════════════════════
// FUNGSI: Memeriksa akses koordinator berdasarkan kelurahan/kecamatan ATAU radius geografis <= 5km
// ════════════════════════════════════════════════════════════════════════════
function checkCoordinatorAccess(mysqli $conn, int $koorIbadahId, float $lat, float $lng, string $kelurahan, string $kecamatan): bool
{
$wilRes = $conn->query("SELECT latitude, longitude, kelurahan, kecamatan FROM rumah_ibadah WHERE id = $koorIbadahId");
if ($wil = $wilRes->fetch_assoc()) {
// Cek 1: Pencocokan nama kelurahan/kecamatan (sebagai fallback tangguh)
$kel = strtolower($wil['kelurahan']);
$kec = strtolower($wil['kecamatan']);
$wKel = strtolower(trim($kelurahan));
$wKec = strtolower(trim($kecamatan));
if (($kel !== '' && strpos($wKel, $kel) !== false) || ($kec !== '' && strpos($wKec, $kec) !== false)) {
return true;
}
// Cek 2: Jarak radius geografis <= 5000m (karena batas slider maksimal 5km)
$lat1 = (float)$wil['latitude'];
$lng1 = (float)$wil['longitude'];
if ($lat1 !== 0.0 && $lng1 !== 0.0 && $lat !== 0.0 && $lng !== 0.0) {
$earthRadius = 6371000;
$dLat = deg2rad($lat - $lat1);
$dLng = deg2rad($lng - $lng1);
$a = sin($dLat/2) * sin($dLat/2) + cos(deg2rad($lat1)) * cos(deg2rad($lat)) * sin($dLng/2) * sin($dLng/2);
$c = 2 * atan2(sqrt($a), sqrt(1-$a));
$dist = $earthRadius * $c;
if ($dist <= 5000.0) {
return true;
}
}
}
return false;
}
// ════════════════════════════════════════════════════════════════════════════
switch ($method) {
// ── GET ──────────────────────────────────────────────────────────────────
case 'GET':
// Izinkan guest mode (opsional) untuk melihat data rumah warga
$currentUser = optionalAuth();
$isGuest = $currentUser === null;
// Jika tidak login (guest), validasi akses
if ($isGuest) {
$currentUser = ['role' => 'guest'];
} else {
// Validasi role jika sudah login
if (!in_array($currentUser['role'], ['admin', 'petugas', 'koordinator', 'kontributor'])) {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'Forbidden: Role Anda tidak memiliki akses.']);
exit;
}
}
if (isset($_GET['id'])) {
$id = (int)$_GET['id'];
$res = $conn->query("SELECT * FROM rumah_warga WHERE id=$id");
if ($row = $res->fetch_assoc()) {
// Batasi Koordinator agar hanya bisa melihat detail warga di wilayah tugasnya sendiri (radius / kelurahan / kecamatan)
if ($currentUser['role'] === 'koordinator') {
$koorIbadahId = (int)$currentUser['rumah_ibadah_id'];
$lat = (float)($row['latitude'] ?? 0);
$lng = (float)($row['longitude'] ?? 0);
$kelurahan = $row['kelurahan'] ?? '';
$kecamatan = $row['kecamatan'] ?? '';
if (!checkCoordinatorAccess($conn, $koorIbadahId, $lat, $lng, $kelurahan, $kecamatan)) {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'Forbidden: Detail warga di luar wilayah tugas Anda.']);
exit;
}
}
// Sertakan anggota keluarga jika bukan koordinator / kontributor / guest
if ($currentUser['role'] === 'koordinator') {
$row['anggota'] = [];
unset($row['no_kk']);
unset($row['nik']);
unset($row['penghasilan_bulanan']);
unset($row['pekerjaan']);
unset($row['status_pekerjaan']);
unset($row['luas_rumah']);
unset($row['jenis_lantai']);
unset($row['jenis_dinding']);
unset($row['sumber_air']);
unset($row['status_tempat_tinggal']);
unset($row['keterangan']);
unset($row['skor_kepercayaan']);
unset($row['is_rekomendasi_manual']);
unset($row['is_kategori_manual']);
// KEEP: kategori_kemiskinan, jumlah_tanggungan (needed for display)
} elseif ($currentUser['role'] === 'kontributor' || $currentUser['role'] === 'guest') {
$row['anggota'] = [];
unset($row['no_kk']);
unset($row['nik']);
unset($row['penghasilan_bulanan']);
unset($row['pekerjaan']);
unset($row['status_pekerjaan']);
unset($row['luas_rumah']);
unset($row['jenis_lantai']);
unset($row['jenis_dinding']);
unset($row['sumber_air']);
unset($row['status_tempat_tinggal']);
unset($row['keterangan']);
unset($row['skor_kepercayaan']);
unset($row['is_rekomendasi_manual']);
unset($row['is_kategori_manual']);
unset($row['created_at']);
unset($row['updated_at']);
// KEEP: kategori_kemiskinan, jumlah_tanggungan, nama_kepala_keluarga (needed for display)
} else {
$resA = $conn->query("SELECT * FROM anggota_keluarga WHERE rumah_warga_id=$id ORDER BY tanggal_lahir ASC");
$anggota = [];
while ($a = $resA->fetch_assoc()) $anggota[] = $a;
$row['anggota'] = $anggota;
}
echo json_encode(['success' => true, 'data' => $row]);
} else {
echo json_encode(['success' => false, 'message' => 'Data tidak ditemukan']);
}
break;
}
// Filter pencarian
$where = [];
if (!empty($_GET['kategori'])) $where[] = "kategori_kemiskinan = '" . $conn->real_escape_string($_GET['kategori']) . "'";
if (!empty($_GET['status'])) $where[] = "status_bantuan = '" . $conn->real_escape_string($_GET['status']) . "'";
if (!empty($_GET['rekomendasi'])) $where[] = "rekomendasi_bantuan = '" . $conn->real_escape_string($_GET['rekomendasi']) . "'";
if (!empty($_GET['kecamatan'])) $where[] = "kecamatan LIKE '%" . $conn->real_escape_string($_GET['kecamatan']) . "%'";
if (!empty($_GET['kelurahan'])) $where[] = "kelurahan LIKE '%" . $conn->real_escape_string($_GET['kelurahan']) . "%'";
if (!empty($_GET['search'])) $where[] = "(nama_kepala_keluarga LIKE '%" . $conn->real_escape_string($_GET['search']) . "%' OR no_kk LIKE '%" . $conn->real_escape_string($_GET['search']) . "%' OR nik LIKE '%" . $conn->real_escape_string($_GET['search']) . "%')";
// Filter wilayah untuk koordinator tidak lagi dibatasi per kelurahan/kecamatan di SQL,
// melainkan akan difilter secara dinamis berdasarkan radius geografis di frontend.
$sql = "SELECT * FROM rumah_warga" . ($where ? " WHERE " . implode(' AND ', $where) : "") . " ORDER BY nama_kepala_keluarga ASC";
$result = $conn->query($sql);
if ($result === false) {
echo json_encode([
'success' => false,
'message' => 'Gagal mengambil data dari tabel "rumah_warga": ' . $conn->error
]);
break;
}
$data = [];
while ($row = $result->fetch_assoc()) {
if ($currentUser['role'] === 'koordinator') {
unset($row['no_kk']);
unset($row['nik']);
unset($row['penghasilan_bulanan']);
unset($row['pekerjaan']);
unset($row['status_pekerjaan']);
unset($row['luas_rumah']);
unset($row['jenis_lantai']);
unset($row['jenis_dinding']);
unset($row['sumber_air']);
unset($row['status_tempat_tinggal']);
unset($row['keterangan']);
unset($row['skor_kepercayaan']);
unset($row['is_rekomendasi_manual']);
// KEEP: kategori_kemiskinan, jumlah_tanggungan (needed for frontend display)
} elseif ($currentUser['role'] === 'kontributor' || $currentUser['role'] === 'guest') {
unset($row['no_kk']);
unset($row['nik']);
unset($row['penghasilan_bulanan']);
unset($row['pekerjaan']);
unset($row['status_pekerjaan']);
unset($row['luas_rumah']);
unset($row['jenis_lantai']);
unset($row['jenis_dinding']);
unset($row['sumber_air']);
unset($row['status_tempat_tinggal']);
unset($row['keterangan']);
unset($row['skor_kepercayaan']);
unset($row['is_rekomendasi_manual']);
unset($row['created_at']);
unset($row['updated_at']);
// KEEP: kategori_kemiskinan, jumlah_tanggungan, nama_kepala_keluarga (needed for frontend display)
}
$data[] = $row;
}
echo json_encode(['success' => true, 'data' => $data, 'total' => count($data)]);
break;
// ── POST ─────────────────────────────────────────────────────────────────
case 'POST':
// Membatasi akses: Hanya Admin dan Petugas Lapangan yang bisa menginput
requireAuth(['admin', 'petugas']);
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
$no_kk = $conn->real_escape_string(trim($input['no_kk'] ?? ''));
$nama = $conn->real_escape_string(trim($input['nama_kepala_keluarga'] ?? ''));
$nik = $conn->real_escape_string($input['nik'] ?? '');
$alamat = $conn->real_escape_string($input['alamat'] ?? '');
$kelurahan = $conn->real_escape_string($input['kelurahan'] ?? '');
$kecamatan = $conn->real_escape_string($input['kecamatan'] ?? '');
$kota = $conn->real_escape_string($input['kota'] ?? '');
$lat = (float)($input['latitude'] ?? 0);
$lng = (float)($input['longitude'] ?? 0);
$tanggungan = (int)($input['jumlah_tanggungan'] ?? 1);
$penghasilan= (float)($input['penghasilan_bulanan'] ?? 0);
$pekerjaan = $conn->real_escape_string($input['pekerjaan'] ?? '');
$stsPekerjaan = $conn->real_escape_string($input['status_pekerjaan'] ?? 'tidak_bekerja');
$luas = !empty($input['luas_rumah']) ? (float)$input['luas_rumah'] : 'NULL';
$lantai = $conn->real_escape_string($input['jenis_lantai'] ?? 'semen');
$dinding = $conn->real_escape_string($input['jenis_dinding'] ?? 'tembok');
$air = $conn->real_escape_string($input['sumber_air'] ?? 'sumur');
$tinggal = $conn->real_escape_string($input['status_tempat_tinggal'] ?? 'milik_sendiri');
$kategoriInput = $conn->real_escape_string($input['kategori_kemiskinan'] ?? 'miskin');
$isKatManual = ($kategoriInput !== 'sistem') ? 1 : 0;
$kategori = ($isKatManual === 1) ? $kategoriInput : hitungKategoriKesejahteraan($penghasilan, $tanggungan, $tinggal, $air);
$stsbansos = $conn->real_escape_string($input['status_bantuan'] ?? 'belum_terima');
$ket = $conn->real_escape_string($input['keterangan'] ?? '');
$dikelola = !empty($input['dikelola_oleh_ibadah_id']) ? (int)$input['dikelola_oleh_ibadah_id'] : 'NULL';
// Validasi
if (!$nama || !$lat || !$lng) {
echo json_encode(['success' => false, 'message' => 'Nama KK, latitude, dan longitude wajib diisi']); break;
}
if (!$no_kk || strlen(preg_replace('/\D/', '', $no_kk)) !== 16) {
echo json_encode(['success' => false, 'message' => 'Nomor KK harus 16 digit angka']); break;
}
// Cek duplikat KK
$cek = $conn->query("SELECT id FROM rumah_warga WHERE no_kk='$no_kk'");
if ($cek->num_rows > 0) {
echo json_encode(['success' => false, 'message' => 'Nomor KK sudah terdaftar']); break;
}
$luasVal = is_numeric($luas) ? $luas : 'NULL';
$rekInput = $conn->real_escape_string($input['rekomendasi_bantuan'] ?? 'sistem');
$isManual = ($rekInput !== 'sistem') ? 1 : 0;
$sql = "INSERT INTO rumah_warga
(no_kk,nama_kepala_keluarga,nik,alamat,kelurahan,kecamatan,kota,latitude,longitude,
jumlah_tanggungan,penghasilan_bulanan,pekerjaan,status_pekerjaan,
luas_rumah,jenis_lantai,jenis_dinding,sumber_air,status_tempat_tinggal,
kategori_kemiskinan,is_kategori_manual,status_bantuan,rekomendasi_bantuan,is_rekomendasi_manual,keterangan,
dikelola_oleh_ibadah_id, tanggal_verifikasi, skor_kepercayaan, status_verifikasi)
VALUES
('$no_kk','$nama','$nik','$alamat','$kelurahan','$kecamatan','$kota',$lat,$lng,
$tanggungan,$penghasilan,'$pekerjaan','$stsPekerjaan',
$luasVal,'$lantai','$dinding','$air','$tinggal',
'$kategori',$isKatManual,'$stsbansos','bantuan_rutin',$isManual,'$ket',
$dikelola, CURRENT_DATE(), 100, 'terverifikasi')";
if ($conn->query($sql)) {
$newId = $conn->insert_id;
// Simpan anggota jika ada
if (!empty($input['anggota']) && is_array($input['anggota'])) {
simpanAnggota($conn, $newId, $input['anggota']);
}
// Hitung rekomendasi otomatis
if ($isManual === 1) {
$rek = $rekInput;
} else {
$rek = hitungRekomendasi($conn, $newId, $penghasilan, $stsPekerjaan);
}
$conn->query("UPDATE rumah_warga SET rekomendasi_bantuan='$rek' WHERE id=$newId");
echo json_encode(['success' => true, 'message' => 'Data berhasil disimpan', 'id' => $newId, 'rekomendasi' => $rek]);
} else {
echo json_encode(['success' => false, 'message' => 'Gagal menyimpan: ' . $conn->error]);
}
break;
// ── PUT ──────────────────────────────────────────────────────────────────
case 'PUT':
// Membatasi akses: Admin, Petugas Lapangan, dan Koordinator Rumah Ibadah
$currentUser = requireAuth(['admin', 'petugas', 'koordinator']);
$input = json_decode(file_get_contents('php://input'), true);
$id = (int)($input['id'] ?? 0);
if (!$id) { echo json_encode(['success' => false, 'message' => 'ID tidak valid']); break; }
if ($currentUser['role'] === 'koordinator') {
$stsbansos = $conn->real_escape_string($input['status_bantuan'] ?? 'belum_terima');
// Cek apakah warga berada di wilayah tugas koordinator (radius / kelurahan / kecamatan)
$koorIbadahId = (int)$currentUser['rumah_ibadah_id'];
$wargaRes = $conn->query("SELECT latitude, longitude, kelurahan, kecamatan FROM rumah_warga WHERE id = $id");
if ($wargaRow = $wargaRes->fetch_assoc()) {
$lat = (float)($wargaRow['latitude'] ?? 0);
$lng = (float)($wargaRow['longitude'] ?? 0);
$kelurahan = $wargaRow['kelurahan'] ?? '';
$kecamatan = $wargaRow['kecamatan'] ?? '';
if (!checkCoordinatorAccess($conn, $koorIbadahId, $lat, $lng, $kelurahan, $kecamatan)) {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'Forbidden: Warga di luar wilayah tugas Anda.']);
exit;
}
} else {
echo json_encode(['success' => false, 'message' => 'Warga tidak ditemukan.']);
exit;
}
$sql = "UPDATE rumah_warga SET status_bantuan='$stsbansos' WHERE id=$id";
if ($conn->query($sql)) {
echo json_encode(['success' => true, 'message' => 'Status bantuan warga berhasil diperbarui']);
} else {
echo json_encode(['success' => false, 'message' => 'Gagal memperbarui status bantuan: ' . $conn->error]);
}
break;
}
$no_kk = $conn->real_escape_string(trim($input['no_kk'] ?? ''));
$nama = $conn->real_escape_string(trim($input['nama_kepala_keluarga'] ?? ''));
$nik = $conn->real_escape_string($input['nik'] ?? '');
$alamat = $conn->real_escape_string($input['alamat'] ?? '');
$kelurahan = $conn->real_escape_string($input['kelurahan'] ?? '');
$kecamatan = $conn->real_escape_string($input['kecamatan'] ?? '');
$kota = $conn->real_escape_string($input['kota'] ?? '');
$lat = (float)($input['latitude'] ?? 0);
$lng = (float)($input['longitude'] ?? 0);
$tanggungan = (int)($input['jumlah_tanggungan'] ?? 1);
$penghasilan= (float)($input['penghasilan_bulanan'] ?? 0);
$pekerjaan = $conn->real_escape_string($input['pekerjaan'] ?? '');
$stsPekerjaan = $conn->real_escape_string($input['status_pekerjaan'] ?? 'tidak_bekerja');
$luasVal = !empty($input['luas_rumah']) ? (float)$input['luas_rumah'] : 'NULL';
$lantai = $conn->real_escape_string($input['jenis_lantai'] ?? 'semen');
$dinding = $conn->real_escape_string($input['jenis_dinding'] ?? 'tembok');
$air = $conn->real_escape_string($input['sumber_air'] ?? 'sumur');
$tinggal = $conn->real_escape_string($input['status_tempat_tinggal'] ?? 'milik_sendiri');
$kategoriInput = $conn->real_escape_string($input['kategori_kemiskinan'] ?? 'miskin');
$isKatManual = ($kategoriInput !== 'sistem') ? 1 : 0;
$kategori = ($isKatManual === 1) ? $kategoriInput : hitungKategoriKesejahteraan($penghasilan, $tanggungan, $tinggal, $air);
$stsbansos = $conn->real_escape_string($input['status_bantuan'] ?? 'belum_terima');
$rekInput = $conn->real_escape_string($input['rekomendasi_bantuan'] ?? 'sistem');
$isManual = ($rekInput !== 'sistem') ? 1 : 0;
$ket = $conn->real_escape_string($input['keterangan'] ?? '');
$dikelola = isset($input['dikelola_oleh_ibadah_id']) && $input['dikelola_oleh_ibadah_id'] !== '' ? (int)$input['dikelola_oleh_ibadah_id'] : 'NULL';
// Cek duplikat KK (kecuali diri sendiri)
$cek = $conn->query("SELECT id FROM rumah_warga WHERE no_kk='$no_kk' AND id != $id");
if ($cek->num_rows > 0) {
echo json_encode(['success' => false, 'message' => 'Nomor KK sudah digunakan data lain']); break;
}
// Update anggota jika ada
if (!empty($input['anggota']) && is_array($input['anggota'])) {
$conn->query("DELETE FROM anggota_keluarga WHERE rumah_warga_id=$id");
simpanAnggota($conn, $id, $input['anggota']);
}
// Hitung rekomendasi otomatis
if ($isManual === 1) {
$rek = $rekInput;
} else {
$rek = hitungRekomendasi($conn, $id, $penghasilan, $stsPekerjaan);
}
$sql = "UPDATE rumah_warga SET
no_kk='$no_kk', nama_kepala_keluarga='$nama', nik='$nik',
alamat='$alamat', kelurahan='$kelurahan', kecamatan='$kecamatan', kota='$kota',
latitude=$lat, longitude=$lng,
jumlah_tanggungan=$tanggungan, penghasilan_bulanan=$penghasilan,
pekerjaan='$pekerjaan', status_pekerjaan='$stsPekerjaan',
luas_rumah=$luasVal, jenis_lantai='$lantai', jenis_dinding='$dinding',
sumber_air='$air', status_tempat_tinggal='$tinggal',
kategori_kemiskinan='$kategori', is_kategori_manual=$isKatManual, status_bantuan='$stsbansos',
rekomendasi_bantuan='$rek', is_rekomendasi_manual=$isManual, keterangan='$ket',
dikelola_oleh_ibadah_id=$dikelola
WHERE id=$id";
if ($conn->query($sql)) {
echo json_encode(['success' => true, 'message' => 'Data berhasil diperbarui', 'rekomendasi' => $rek]);
} else {
echo json_encode(['success' => false, 'message' => 'Gagal memperbarui: ' . $conn->error]);
}
break;
// ── DELETE ───────────────────────────────────────────────────────────────
case 'DELETE':
// Membatasi akses: HANYA Admin yang boleh menghapus data warga
requireAuth(['admin']);
$id = (int)($_GET['id'] ?? 0);
if (!$id) {
$input = json_decode(file_get_contents('php://input'), true);
$id = (int)($input['id'] ?? 0);
}
if ($conn->query("DELETE FROM rumah_warga WHERE id=$id")) {
echo json_encode(['success' => true, 'message' => 'Data berhasil dihapus']);
} else {
echo json_encode(['success' => false, 'message' => 'Gagal menghapus: ' . $conn->error]);
}
break;
default:
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
}
$conn->close();
// ════════════════════════════════════════════════════════════════════════════
function simpanAnggota(mysqli $conn, int $wargaId, array $list): void
{
foreach ($list as $a) {
$nama = $conn->real_escape_string(trim($a['nama'] ?? ''));
if (!$nama) continue;
$nik = $conn->real_escape_string($a['nik'] ?? '');
$tl = !empty($a['tanggal_lahir']) ? "'" . $conn->real_escape_string($a['tanggal_lahir']) . "'" : 'NULL';
$jk = $conn->real_escape_string($a['jenis_kelamin'] ?? 'laki_laki');
$pend = $conn->real_escape_string($a['pendidikan'] ?? 'sd');
$pek = $conn->real_escape_string($a['pekerjaan'] ?? '');
$stsBk = $conn->real_escape_string($a['status_bekerja'] ?? 'tidak_bekerja');
$kes = $conn->real_escape_string($a['kondisi_kesehatan'] ?? 'sehat');
$bisa = (int)($a['dapat_bekerja'] ?? 1);
$conn->query("INSERT INTO anggota_keluarga (rumah_warga_id,nama,nik,tanggal_lahir,jenis_kelamin,pendidikan,pekerjaan,status_bekerja,kondisi_kesehatan,dapat_bekerja)
VALUES ($wargaId,'$nama','$nik',$tl,'$jk','$pend','$pek','$stsBk','$kes',$bisa)");
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
// api/statistik.php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
require_once '../config/db.php';
$stats = [];
// Total rumah ibadah per jenis
$result = $conn->query("SELECT jenis, COUNT(*) as total FROM rumah_ibadah GROUP BY jenis");
if ($result === false) {
http_response_code(500);
echo json_encode([
'success' => false,
'message' => 'Gagal memuat statistik. Tabel "rumah_ibadah" tidak ditemukan. Pastikan Anda telah mengimpor berkas database "database/bansos_gis.sql" di phpMyAdmin Anda!'
]);
exit;
}
$ibadah = [];
while ($row = $result->fetch_assoc()) $ibadah[$row['jenis']] = (int)$row['total'];
$stats['rumah_ibadah'] = $ibadah;
$stats['total_ibadah'] = array_sum($ibadah);
// Total rumah warga per kategori
$result = $conn->query("SELECT kategori_kemiskinan, COUNT(*) as total FROM rumah_warga GROUP BY kategori_kemiskinan");
$warga = [];
while ($row = $result->fetch_assoc()) $warga[$row['kategori_kemiskinan']] = (int)$row['total'];
$stats['kategori_kemiskinan'] = $warga;
$stats['total_warga'] = array_sum($warga);
// Status bantuan
$result = $conn->query("SELECT status_bantuan, COUNT(*) as total FROM rumah_warga GROUP BY status_bantuan");
$bansos = [];
while ($row = $result->fetch_assoc()) $bansos[$row['status_bantuan']] = (int)$row['total'];
$stats['status_bansos'] = $bansos;
// Rekomendasi bantuan
$result = $conn->query("SELECT rekomendasi_bantuan, COUNT(*) as total FROM rumah_warga GROUP BY rekomendasi_bantuan");
$rek = [];
while ($row = $result->fetch_assoc()) $rek[$row['rekomendasi_bantuan']] = (int)$row['total'];
$stats['rekomendasi_bantuan'] = $rek;
// Total jiwa
$result = $conn->query("SELECT SUM(jumlah_tanggungan) as total FROM rumah_warga");
$row = $result->fetch_assoc();
$stats['total_jiwa'] = (int)$row['total'];
// Total anggota di DB
$result = $conn->query("SELECT COUNT(*) as total FROM anggota_keluarga");
$row = $result->fetch_assoc();
$stats['total_anggota_db'] = (int)$row['total'];
// Rata-rata penghasilan
$result = $conn->query("SELECT AVG(penghasilan_bulanan) as avg_ph FROM rumah_warga");
$row = $result->fetch_assoc();
$stats['rata_penghasilan'] = round((float)$row['avg_ph'], 0);
echo json_encode(['success' => true, 'data' => $stats]);
$conn->close();
+316
View File
@@ -0,0 +1,316 @@
<?php
// api/verifikasi.php
// Fitur 2: Keterbaruan Data & Verifikasi Berkala
// Method: GET (list warga perlu update), POST (catat verifikasi), GET?action=config, PUT (update config)
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once '../config/db.php';
require_once 'middleware.php';
/* ─────────────────────────────────────────────
HELPER
───────────────────────────────────────────── */
function getKonfig(PDO $pdo, string $kunci, $default = null) {
$stmt = $pdo->prepare("SELECT nilai, tipe FROM konfigurasi_sistem WHERE kunci = ?");
$stmt->execute([$kunci]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) return $default;
return match($row['tipe']) {
'integer' => (int)$row['nilai'],
'decimal' => (float)$row['nilai'],
'boolean' => filter_var($row['nilai'], FILTER_VALIDATE_BOOLEAN),
default => $row['nilai'],
};
}
/* ─────────────────────────────────────────────
CORE: Hitung skor kepercayaan data
Formula: mulai 100, kurangi seiring waktu
Decay rate: linear -100/interval_bulan per bulan
───────────────────────────────────────────── */
function hitungSkorKepercayaan(
?string $tanggalVerifikasi,
int $intervalBulan,
int $minSkor = 40
): array {
if (!$tanggalVerifikasi) {
return ['skor' => 0, 'hari_sejak' => null, 'status' => 'kadaluarsa'];
}
$tglVerif = new DateTime($tanggalVerifikasi);
$today = new DateTime();
// Jika tanggal verifikasi di masa depan (karena beda timezone / lag server), anggap 0 hari
if ($tglVerif > $today) {
$hariSejak = 0;
} else {
$diff = $today->diff($tglVerif);
$hariSejak = $diff->days;
}
$intervalHari = $intervalBulan * 30;
// Skor linear: 100 di hari 0, turun ke 0 di hari intervalHari
$skor = max(0, (int)round(100 - ($hariSejak / $intervalHari * 100)));
$status = match(true) {
$skor >= 80 => 'terverifikasi',
$skor >= $minSkor => 'perlu_update',
default => 'kadaluarsa',
};
// Berapa hari lagi sampai perlu_update dan kadaluarsa
$hariMenujuPerluUpdate = max(0, (int)($intervalHari * 0.2) - $hariSejak);
$hariMenujuKadaluarsa = max(0, (int)($intervalHari * ((100 - $minSkor) / 100)) - $hariSejak);
return [
'skor' => $skor,
'hari_sejak_verifikasi' => $hariSejak,
'interval_hari' => $intervalHari,
'status' => $status,
'hari_menuju_perlu_update' => $hariMenujuPerluUpdate,
'hari_menuju_kadaluarsa' => $hariMenujuKadaluarsa,
'tanggal_kadaluarsa' => (clone $tglVerif)->modify("+{$intervalHari} days")->format('Y-m-d'),
];
}
/* ─────────────────────────────────────────────
UPDATE skor semua warga secara batch
Formula: mulai 100, kurangi seiring waktu
───────────────────────────────────────────── */
function updateSkopBatch(PDO $pdo): int {
$interval = getKonfig($pdo, 'interval_verifikasi_bulan', 6);
$minSkor = getKonfig($pdo, 'skor_data_kadaluarsa', 40);
$wargaList = $pdo->query("SELECT id, tanggal_verifikasi FROM rumah_warga")->fetchAll(PDO::FETCH_ASSOC);
$updated = 0;
$stmt = $pdo->prepare("
UPDATE rumah_warga SET skor_kepercayaan = ?, status_verifikasi = ? WHERE id = ?
");
foreach ($wargaList as $w) {
$calc = hitungSkorKepercayaan($w['tanggal_verifikasi'], $interval, $minSkor);
$stmt->execute([$calc['skor'], $calc['status'], (int)$w['id']]);
$updated++;
}
return $updated;
}
/* ─────────────────────────────────────────────
ROUTING
───────────────────────────────────────────── */
try {
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? 'list';
if ($method === 'GET') {
// Hanya Admin dan Petugas Lapangan yang boleh melihat data verifikasi
requireAuth(['admin', 'petugas']);
if ($action === 'list') {
// Daftar warga yang perlu diverifikasi
$statusFilter = $_GET['status'] ?? null; // terverifikasi|perlu_update|kadaluarsa
$limit = (int)($_GET['limit'] ?? 50);
$offset = (int)($_GET['offset'] ?? 0);
// Update skor dulu secara batch
updateSkopBatch($pdo);
if ($statusFilter === 'semua') {
$where = "";
} elseif ($statusFilter === 'terverifikasi' || $statusFilter === 'perlu_update' || $statusFilter === 'kadaluarsa') {
$where = "WHERE rw.status_verifikasi = " . $pdo->quote($statusFilter);
} else {
// Default: Hanya warga yang perlu verifikasi (perlu_update atau kadaluarsa)
$where = "WHERE rw.status_verifikasi IN ('perlu_update', 'kadaluarsa')";
}
$sql = "
SELECT rw.id, rw.nama_kepala_keluarga, rw.no_kk,
rw.kelurahan, rw.kecamatan,
rw.tanggal_verifikasi, rw.petugas_verifikasi,
rw.skor_kepercayaan, rw.status_verifikasi,
rw.kategori_kemiskinan, rw.status_bantuan,
rw.latitude, rw.longitude
FROM rumah_warga rw
$where
ORDER BY rw.skor_kepercayaan ASC, rw.tanggal_verifikasi ASC
LIMIT $limit OFFSET $offset
";
$rows = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
// Enrichment: hitung detail decay per row
$interval = getKonfig($pdo, 'interval_verifikasi_bulan', 6);
$minSkor = getKonfig($pdo, 'skor_data_kadaluarsa', 40);
foreach ($rows as &$r) {
$r['detail_skor'] = hitungSkorKepercayaan(
$r['tanggal_verifikasi'], $interval, $minSkor
);
}
// Summary count
$summary = $pdo->query("
SELECT status_verifikasi, COUNT(*) as jumlah
FROM rumah_warga
GROUP BY status_verifikasi
")->fetchAll(PDO::FETCH_KEY_PAIR);
echo json_encode([
'success' => true,
'data' => $rows,
'summary' => $summary,
'total' => count($rows),
]);
} elseif ($action === 'config') {
// Ambil semua konfigurasi
$rows = $pdo->query("SELECT * FROM konfigurasi_sistem ORDER BY id")->fetchAll(PDO::FETCH_ASSOC);
echo json_encode(['success' => true, 'data' => $rows]);
} elseif ($action === 'riwayat') {
// Riwayat verifikasi untuk 1 warga
$wargaId = (int)($_GET['warga_id'] ?? 0);
$rows = $pdo->prepare("
SELECT * FROM riwayat_verifikasi
WHERE rumah_warga_id = ?
ORDER BY tanggal DESC LIMIT 20
");
$rows->execute([$wargaId]);
echo json_encode(['success' => true, 'data' => $rows->fetchAll(PDO::FETCH_ASSOC)]);
} elseif ($action === 'statistik') {
updateSkopBatch($pdo);
$data = [
'per_status' => $pdo->query("
SELECT status_verifikasi, COUNT(*) as jumlah,
ROUND(AVG(skor_kepercayaan)) as avg_skor
FROM rumah_warga GROUP BY status_verifikasi
")->fetchAll(PDO::FETCH_ASSOC),
'avg_skor_global' => (float)$pdo->query("SELECT ROUND(AVG(skor_kepercayaan),1) FROM rumah_warga")->fetchColumn(),
'paling_lama' => $pdo->query("
SELECT nama_kepala_keluarga, tanggal_verifikasi, skor_kepercayaan
FROM rumah_warga ORDER BY skor_kepercayaan ASC LIMIT 5
")->fetchAll(PDO::FETCH_ASSOC),
];
echo json_encode(['success' => true, 'data' => $data]);
}
} elseif ($method === 'POST') {
// Catat verifikasi baru untuk 1 warga
// Hanya Admin, Petugas Lapangan, dan Koordinator
$currentUser = requireAuth(['admin', 'petugas', 'koordinator']);
$body = json_decode(file_get_contents('php://input'), true);
$wargaId = (int)($body['warga_id'] ?? 0);
$petugas = trim($body['petugas'] ?? '');
$catatan = $body['catatan'] ?? null;
$tanggal = $body['tanggal'] ?? date('Y-m-d');
if (!$wargaId) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'warga_id wajib diisi']);
exit;
}
// Jika koordinator, lakukan pengecekan wilayah
if ($currentUser['role'] === 'koordinator') {
$koorIbadahId = (int)$currentUser['rumah_ibadah_id'];
$wilayahRes = $pdo->prepare("SELECT kelurahan, kecamatan FROM rumah_ibadah WHERE id = ?");
$wilayahRes->execute([$koorIbadahId]);
if ($wil = $wilayahRes->fetch(PDO::FETCH_ASSOC)) {
$kel = $wil['kelurahan'];
$kec = $wil['kecamatan'];
$wargaRes = $pdo->prepare("SELECT kelurahan, kecamatan FROM rumah_warga WHERE id = ?");
$wargaRes->execute([$wargaId]);
if ($wargaRow = $wargaRes->fetch(PDO::FETCH_ASSOC)) {
$wKel = strtolower(trim($wargaRow['kelurahan'] ?? ''));
$wKec = strtolower(trim($wargaRow['kecamatan'] ?? ''));
if (strpos($wKel, strtolower($kel)) === false && strpos($wKec, strtolower($kec)) === false) {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'Forbidden: Warga di luar wilayah tugas Anda.']);
exit;
}
} else {
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'Warga tidak ditemukan.']);
exit;
}
} else {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'Forbidden: Wilayah tugas tidak terkonfigurasi.']);
exit;
}
}
// Ambil skor lama
$oldRow = $pdo->prepare("SELECT skor_kepercayaan, status_verifikasi FROM rumah_warga WHERE id = ?");
$oldRow->execute([$wargaId]);
$old = $oldRow->fetch(PDO::FETCH_ASSOC);
$skorLama = $old ? (int)$old['skor_kepercayaan'] : null;
// Reset skor ke 100 & update tanggal verifikasi
$stmt = $pdo->prepare("
UPDATE rumah_warga
SET tanggal_verifikasi = ?,
petugas_verifikasi = ?,
skor_kepercayaan = 100,
status_verifikasi = 'terverifikasi',
updated_at = NOW()
WHERE id = ?
");
$stmt->execute([$tanggal, $petugas ?: null, $wargaId]);
// Simpan riwayat
$riwayat = $pdo->prepare("
INSERT INTO riwayat_verifikasi
(rumah_warga_id, tanggal, petugas, skor_sebelum, skor_sesudah, catatan)
VALUES (?, ?, ?, ?, 100, ?)
");
$riwayat->execute([$wargaId, $tanggal, $petugas ?: null, $skorLama, $catatan]);
echo json_encode([
'success' => true,
'message' => 'Verifikasi berhasil dicatat. Skor kepercayaan data dikembalikan ke 100.',
'skor_lama' => $skorLama,
'skor_baru' => 100,
]);
} elseif ($method === 'PUT') {
// Update konfigurasi sistem
// HANYA Admin yang boleh merubah konfigurasi sistem
requireAuth(['admin']);
$body = json_decode(file_get_contents('php://input'), true);
if (!is_array($body)) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Body tidak valid']);
exit;
}
$stmt = $pdo->prepare("UPDATE konfigurasi_sistem SET nilai = ? WHERE kunci = ?");
$updated = 0;
foreach ($body as $kunci => $nilai) {
$stmt->execute([(string)$nilai, $kunci]);
$updated += $stmt->rowCount();
}
// Setelah konfigurasi diupdate, recalculate semua skor
updateSkopBatch($pdo);
echo json_encode([
'success' => true,
'message' => "$updated konfigurasi berhasil diperbarui. Skor kepercayaan seluruh data telah dihitung ulang.",
]);
}
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
+943
View File
@@ -0,0 +1,943 @@
/* ================================================================
TAMBAHAN CSS — 3 Fitur Baru WebGIS Bansos
Append ke bagian bawah assets/css/style.css yang sudah ada
================================================================ */
/* ─── Panel Header ─── */
.fb-panel-header {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
padding: 10px 0 8px;
border-bottom: 1px solid var(--border);
margin-bottom: 10px;
}
/* ─── Sub Tabs ─── */
.fb-sub-tabs {
display: flex;
gap: 2px;
background: var(--bg-tertiary);
border-radius: 8px;
padding: 3px;
margin-bottom: 10px;
}
.fb-sub-tab {
flex: 1;
padding: 5px 4px;
font-size: 10px;
font-weight: 500;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s;
text-align: center;
}
.fb-sub-tab.active {
background: var(--bg-primary);
color: var(--text-primary);
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.fb-sub-panel { display: none; }
.fb-sub-panel.active { display: block; }
/* ─── States ─── */
.fb-loading {
display: flex;
align-items: center;
gap: 8px;
padding: 20px;
color: var(--text-muted);
font-size: 11px;
justify-content: center;
animation: pulse 1.5s infinite;
}
.fb-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 24px 16px;
color: var(--text-muted);
font-size: 11px;
text-align: center;
}
.fb-error {
display: flex;
align-items: center;
gap: 6px;
padding: 12px;
background: rgba(239,68,68,0.08);
border-radius: 8px;
color: var(--danger);
font-size: 11px;
}
.fb-badge {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 2px 8px;
border-radius: 20px;
font-size: 10px;
font-weight: 600;
white-space: nowrap;
}
/* ================================================================
FITUR 1: REDISTRIBUSI
================================================================ */
.redistribusi-summary {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 4px;
margin-bottom: 10px;
}
.redist-sum-item {
border-radius: 6px;
border: 1px solid transparent;
padding: 6px 4px;
text-align: center;
}
.redist-sum-val {
display: block;
font-size: 16px;
font-weight: 700;
line-height: 1;
}
.redist-sum-key {
display: block;
font-size: 9px;
color: var(--text-muted);
margin-top: 2px;
}
/* ── Beban Card ── */
.redist-card {
background: var(--bg-secondary);
border-radius: 10px;
padding: 10px;
margin-bottom: 8px;
border: 1px solid var(--border);
transition: box-shadow 0.2s;
}
.redist-card:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.redist-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 6px;
margin-bottom: 8px;
}
.redist-card-nama {
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
}
.redist-card-sub {
font-size: 10px;
color: var(--text-muted);
margin-top: 1px;
}
.redist-progress-wrap {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
}
.redist-progress-bar {
flex: 1;
height: 5px;
background: var(--border);
border-radius: 3px;
overflow: hidden;
}
.redist-progress-fill {
height: 100%;
border-radius: 3px;
transition: width 0.4s ease;
}
.redist-progress-pct {
font-size: 10px;
font-weight: 600;
color: var(--text-secondary);
min-width: 32px;
text-align: right;
}
.redist-stat-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
}
.redist-stat-item {
text-align: center;
padding: 4px 2px;
background: var(--bg-tertiary);
border-radius: 6px;
}
.redist-stat-val {
display: block;
font-size: 13px;
font-weight: 700;
line-height: 1;
}
.redist-stat-key {
display: block;
font-size: 9px;
color: var(--text-muted);
margin-top: 2px;
}
/* ── Saran Card ── */
.saran-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px;
margin-bottom: 8px;
}
.saran-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.saran-flow {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 6px;
flex-wrap: wrap;
}
.saran-ibadah {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 3px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
}
.saran-ibadah.surplus {
background: rgba(16,185,129,0.12);
color: #10b981;
}
.saran-ibadah.defisit {
background: rgba(239,68,68,0.12);
color: #ef4444;
}
.saran-arrow {
font-size: 10px;
color: var(--text-muted);
font-weight: 600;
padding: 0 2px;
}
.saran-alasan {
font-size: 10px;
color: var(--text-muted);
line-height: 1.5;
margin-bottom: 4px;
}
.saran-actions {
display: flex;
gap: 6px;
margin-top: 8px;
}
/* ── Riwayat Item ── */
.riwayat-item {
padding: 8px 10px;
background: var(--bg-secondary);
border-radius: 8px;
border: 1px solid var(--border);
margin-bottom: 6px;
}
/* ================================================================
FITUR 2: VERIFIKASI / KETERBARUAN DATA
================================================================ */
.verif-summary-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
margin-bottom: 10px;
}
.verif-sum-card {
border-radius: 8px;
padding: 8px 6px;
text-align: center;
border: 1px solid transparent;
}
.verif-sum-card.ok { background: rgba(16,185,129,0.1); border-color: rgba(16,185,129,0.3); }
.verif-sum-card.warn { background: rgba(245,158,11,0.1); border-color: rgba(245,158,11,0.3); }
.verif-sum-card.err { background: rgba(239,68,68,0.1); border-color: rgba(239,68,68,0.3); }
.verif-sum-card span {
display: block;
font-size: 18px;
font-weight: 700;
line-height: 1.1;
margin-bottom: 2px;
}
.verif-sum-card.ok span { color: #10b981; }
.verif-sum-card.warn span { color: #f59e0b; }
.verif-sum-card.err span { color: #ef4444; }
.verif-sum-card small {
display: block;
font-size: 9px;
color: var(--text-muted);
line-height: 1.1;
}
/* ── Verif Item ── */
.verif-item {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px;
margin-bottom: 8px;
}
.verif-item-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 6px;
margin-bottom: 8px;
}
.verif-item-nama {
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
}
.verif-item-sub {
font-size: 10px;
color: var(--text-muted);
margin-top: 1px;
}
.skor-bar-wrap {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.skor-bar {
flex: 1;
height: 6px;
background: var(--border);
border-radius: 3px;
overflow: hidden;
}
.skor-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.4s ease;
}
.skor-pct {
font-size: 10px;
font-weight: 700;
min-width: 28px;
text-align: right;
}
.verif-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
font-size: 10px;
color: var(--text-muted);
align-items: center;
}
.verif-info-box {
display: flex;
align-items: center;
gap: 6px;
background: rgba(99,102,241,0.08);
border: 1px solid rgba(99,102,241,0.2);
border-radius: 8px;
padding: 8px 10px;
font-size: 10px;
color: var(--text-secondary);
margin-top: 8px;
}
/* ── Konfigurasi ── */
.konfig-intro {
font-size: 11px;
color: var(--text-muted);
margin-bottom: 10px;
line-height: 1.5;
}
.konfig-item {
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border);
}
.konfig-item:last-child { border-bottom: none; }
.konfig-label {
display: block;
font-size: 11px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 3px;
}
.konfig-desc {
font-size: 10px;
color: var(--text-muted);
margin-bottom: 5px;
line-height: 1.4;
}
/* ================================================================
FITUR 3: KEMANDIRIAN
================================================================ */
.kemandirian-siap-badge {
margin-left: auto;
background: rgba(16,185,129,0.12);
color: #10b981;
border-radius: 20px;
padding: 2px 8px;
font-size: 10px;
font-weight: 600;
}
.kemandirian-item {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px;
margin-bottom: 8px;
transition: box-shadow 0.2s;
}
.kemandirian-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.kemandirian-item-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 6px;
margin-bottom: 8px;
}
.kemandirian-nama {
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
}
.kemandirian-sub {
font-size: 10px;
color: var(--text-muted);
margin-top: 1px;
}
/* ── Gauge Grid ── */
.kemandirian-gauges {
display: flex;
gap: 4px;
justify-content: space-around;
flex-wrap: wrap;
margin-bottom: 6px;
padding: 8px;
background: var(--bg-tertiary);
border-radius: 8px;
}
.kemandirian-gauge {
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
position: relative;
}
.gauge-val {
font-size: 13px;
font-weight: 700;
line-height: 1;
margin-top: -4px;
}
.gauge-label {
font-size: 9px;
color: var(--text-muted);
margin-top: 1px;
}
.kemandirian-est {
display: flex;
align-items: center;
gap: 4px;
font-size: 10px;
color: var(--text-secondary);
margin-bottom: 4px;
}
.kemandirian-rek {
background: var(--bg-tertiary);
border-radius: 6px;
padding: 6px 8px;
margin-top: 4px;
}
.kemandirian-rek-item {
display: flex;
align-items: flex-start;
gap: 4px;
font-size: 10px;
color: var(--text-muted);
line-height: 1.4;
padding: 2px 0;
}
/* ── Animation ── */
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* ── Responsive fine-tuning ── */
@media (max-width: 320px) {
.redistribusi-summary { grid-template-columns: repeat(3, 1fr); }
.redist-stat-row { grid-template-columns: repeat(2, 1fr); }
.kemandirian-gauges { gap: 2px; }
}
/* ─── Side by Side Modals ─── */
@media (min-width: 1100px) {
body.side-by-side-modals #modal-premium-verifikasi {
justify-content: center;
padding-right: 560px;
transition: all 0.3s ease;
}
body.side-by-side-modals #modal-warga {
justify-content: center;
padding-left: 540px;
background: transparent;
backdrop-filter: none;
pointer-events: none;
transition: all 0.3s ease;
}
body.side-by-side-modals #modal-warga .modal {
pointer-events: auto;
}
}
/* ================================================================
RBAC & LOGIN & DONASI FRONTEND STYLES
================================================================ */
.user-profile-panel {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
transition: all 0.25s ease;
}
.user-avatar-wrap {
position: relative;
}
.user-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: linear-gradient(135deg, #6366f1, #4338ca);
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 14px;
box-shadow: 0 2px 5px rgba(99, 102, 241, 0.3);
}
.user-avatar.avatar-guest {
background: linear-gradient(135deg, #94a3b8, #64748b);
box-shadow: 0 2px 5px rgba(100, 116, 139, 0.3);
}
.user-info {
flex: 1;
min-width: 0;
}
.user-name {
font-size: 11px;
font-weight: 700;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-role-badge {
display: inline-block;
font-size: 9px;
font-weight: 600;
padding: 1px 6px;
border-radius: 4px;
margin-top: 2px;
}
.badge-guest { background: rgba(148, 163, 184, 0.12); color: #64748b; }
.badge-admin { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
.badge-petugas { background: rgba(245, 158, 11, 0.12); color: #d97706; }
.badge-koordinator { background: rgba(99, 102, 241, 0.12); color: #6366f1; }
.badge-kontributor { background: rgba(16, 185, 129, 0.12); color: #10b981; }
.btn-auth-action {
font-size: 10px;
font-weight: 600;
padding: 5px 10px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-primary);
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s ease;
}
.btn-auth-action:hover {
background: var(--bg-tertiary);
border-color: var(--text-muted);
}
/* Kebutuhan & Donasi Cards */
.kebutuhan-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px;
margin-bottom: 10px;
transition: all 0.2s ease;
}
.kebutuhan-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
border-color: var(--accent-light);
}
.kebutuhan-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
margin-bottom: 8px;
}
.kebutuhan-ibadah-nama {
font-size: 11px;
font-weight: 700;
color: var(--text-primary);
}
.kebutuhan-bantuan-jenis {
font-size: 10px;
color: var(--text-muted);
margin-top: 1px;
}
.kebutuhan-progress-section {
margin: 10px 0;
}
.kebutuhan-progress-text {
display: flex;
justify-content: space-between;
font-size: 10px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 4px;
}
.kebutuhan-progress-bar {
height: 6px;
background: var(--bg-tertiary);
border-radius: 3px;
overflow: hidden;
}
.kebutuhan-progress-fill {
height: 100%;
background: linear-gradient(90deg, #10b981, #34d399);
border-radius: 3px;
transition: width 0.4s ease;
}
.kebutuhan-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
gap: 8px;
}
.kebutuhan-sisa {
font-size: 10px;
font-weight: 600;
color: #10b981;
}
.kebutuhan-sisa.kebutuhan-penuh {
color: var(--text-muted);
}
/* Riwayat Kontribusi Item */
.kontribusi-item {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px;
margin-bottom: 8px;
}
.kontribusi-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
/* ================================================================
PREMIUM DISTINCT LOGIN CARD REDESIGN (LIGHT CLASSIC PREMIUM)
================================================================ */
.login-premium-modal {
border-radius: 16px !important;
overflow: hidden !important;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.02), 0 0 0 1px rgba(99, 102, 241, 0.15) !important;
border: none !important;
background: #ffffff !important;
animation: modalSlideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes modalSlideUp {
from { transform: translateY(12px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.login-premium-header {
background: #ffffff !important;
padding: 26px 20px 18px 20px !important;
text-align: center;
position: relative;
border-bottom: 1px solid #f1f5f9 !important;
}
.login-premium-header .modal-title {
color: #1e293b !important;
font-size: 16px !important;
font-weight: 800 !important;
text-transform: uppercase;
letter-spacing: 0.8px;
width: 100%;
}
.login-premium-header .login-sub-title {
color: #64748b;
font-size: 11px;
margin-top: 5px;
}
.login-premium-header .modal-close {
color: #64748b !important;
background: #f1f5f9 !important;
border-radius: 50% !important;
width: 26px !important;
height: 26px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
transition: all 0.2s !important;
position: absolute !important;
top: 15px !important;
right: 15px !important;
border: none !important;
}
.login-premium-header .modal-close:hover {
background: #e2e8f0 !important;
color: #0f172a !important;
}
.login-premium-logo-wrap {
width: 50px;
height: 50px;
background: rgba(99, 102, 241, 0.08) !important;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 12px auto;
border: 1px solid rgba(99, 102, 241, 0.15);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.08) !important;
}
.login-premium-logo-wrap svg {
stroke: #4f46e5;
width: 24px;
height: 24px;
}
.login-premium-body {
padding: 24px 22px !important;
background: #ffffff !important;
}
.login-premium-input-group {
position: relative;
margin-bottom: 18px;
}
.login-premium-input-group label {
display: block;
font-size: 10px;
font-weight: 700;
color: #475569;
text-transform: uppercase;
margin-bottom: 6px;
letter-spacing: 0.5px;
}
.login-premium-input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.login-premium-input-wrapper svg {
position: absolute;
left: 12px;
color: #94a3b8;
pointer-events: none;
z-index: 10;
transition: color 0.2s;
}
.login-premium-input-wrapper .form-control {
padding-left: 38px !important;
height: 42px !important;
border-radius: 8px !important;
border: 1px solid #cbd5e1 !important;
background: #f8fafc !important;
color: #0f172a !important;
font-size: 12px !important;
font-weight: 500 !important;
transition: all 0.2s ease-in-out !important;
width: 100% !important;
}
.login-premium-input-wrapper .form-control::placeholder {
color: #94a3b8 !important;
}
.login-premium-input-wrapper .form-control:focus {
border-color: #6366f1 !important;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15) !important;
background: #ffffff !important;
}
.login-premium-input-wrapper .form-control:focus + svg {
color: #6366f1 !important;
}
.login-premium-footer {
padding: 0 22px 26px 22px !important;
border-top: none !important;
display: flex;
gap: 12px;
background: #ffffff !important;
}
.login-premium-footer .btn {
height: 42px !important;
border-radius: 8px !important;
font-size: 12px !important;
font-weight: 700 !important;
flex: 1;
}
.login-premium-footer .btn-primary {
background: linear-gradient(135deg, #6366f1, #4f46e5) !important;
border: none !important;
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.15) !important;
transition: all 0.2s;
color: #ffffff !important;
}
.login-premium-footer .btn-primary:hover {
background: linear-gradient(135deg, #7073ff, #6366f1) !important;
box-shadow: 0 6px 16px rgba(79, 70, 229, 0.25) !important;
transform: translateY(-1.5px);
}
.login-premium-footer .btn-ghost {
border: 1px solid #cbd5e1 !important;
background: #ffffff !important;
color: #475569 !important;
}
.login-premium-footer .btn-ghost:hover {
background: #f1f5f9 !important;
border-color: #cbd5e1 !important;
}
.login-hint-premium {
margin-top: 18px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 12px;
}
+847
View File
@@ -0,0 +1,847 @@
/* assets/css/style.css */
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600;700&family=DM+Mono:wght@400;500&display=swap');
:root {
--bg-deep: #f8fafc;
--bg-panel: #ffffff;
--bg-card: #ffffff;
--bg-hover: #f1f5f9;
--border: #e2e8f0;
--border-light: #cbd5e1;
--border-subtle: #f1f5f9;
--accent: #4f46e5;
--accent-light: #6366f1;
--accent-glow: rgba(99, 102, 241, 0.1);
--accent-2: #8b5cf6;
--text-primary: #1e293b;
--text-secondary:#475569;
--text-muted: #64748b;
--text-hint: #94a3b8;
--success: #10b981;
--success-bg: #ecfdf5;
--success-text: #065f46;
--warning: #f59e0b;
--warning-bg: #fffbeb;
--warning-text: #92400e;
--danger: #ef4444;
--danger-bg: #fef2f2;
--danger-text: #991b1b;
--info: #3b82f6;
--info-bg: #eff6ff;
--info-text: #1e40af;
--icon-tint: #64748b;
--icon-active: #4f46e5;
--radius-xs: 4px;
--radius-sm: 8px;
--radius: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.05);
--shadow: 0 4px 6px -1px rgba(0,0,0,0.05), 0 2px 4px -1px rgba(0,0,0,0.03);
--shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.08);
--font: 'DM Sans', sans-serif;
--mono: 'DM Mono', monospace;
--sidebar-w: 300px;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
font-family: var(--font);
background: var(--bg-deep);
color: var(--text-primary);
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
/* ─────────────────────────────────────────────
LAYOUT
───────────────────────────────────────────── */
#app { display: flex; height: 100vh; width: 100vw; }
/* ─────────────────────────────────────────────
SIDEBAR
───────────────────────────────────────────── */
#sidebar {
width: var(--sidebar-w);
min-width: var(--sidebar-w);
background: var(--bg-panel);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
z-index: 100;
overflow: hidden;
}
.sidebar-header {
padding: 18px 16px 14px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
background: var(--bg-panel);
}
.logo { display: flex; align-items: center; gap: 10px; margin-bottom: 3px; }
.logo-icon {
width: 32px; height: 32px;
background: var(--accent);
border-radius: var(--radius-sm);
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.logo-icon svg { width: 17px; height: 17px; }
.logo-text { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; color: var(--text-primary); }
.logo-sub { font-size: 10px; color: var(--text-muted); font-weight: 400; letter-spacing: 0.2px; margin-left: 42px; line-height: 1.4; }
/* Tabs */
.sidebar-tabs { display: flex; border-bottom: 1px solid var(--border); flex-shrink: 0; padding: 0 4px; }
.tab-btn {
flex: 1; padding: 10px 4px;
background: transparent; border: none;
border-bottom: 2px solid transparent;
color: var(--text-muted);
font-family: var(--font); font-size: 11px; font-weight: 600;
cursor: pointer; transition: all 0.2s; letter-spacing: 0.3px;
display: flex; flex-direction: column; align-items: center; gap: 5px;
margin-bottom: -1px;
}
.tab-btn svg { width: 16px; height: 16px; stroke: currentColor; fill: none; }
.tab-btn:hover { color: var(--text-secondary); }
.tab-btn.active { color: var(--accent-light); border-bottom-color: var(--accent-light); }
.sidebar-content { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 14px 12px; }
.sidebar-content::-webkit-scrollbar { width: 3px; }
.sidebar-content::-webkit-scrollbar-track { background: transparent; }
.sidebar-content::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 2px; }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* ─────────────────────────────────────────────
STATS
───────────────────────────────────────────── */
.stats-grid {
display: grid; grid-template-columns: 1fr 1fr;
gap: 6px; margin-bottom: 14px;
}
.stat-card {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius); padding: 12px 10px;
text-align: center; transition: border-color 0.2s;
position: relative; overflow: hidden;
}
.stat-card::before {
content: ''; position: absolute; top: 0; left: 0; right: 0;
height: 2px; background: var(--accent); opacity: 0.4;
}
.stat-card--rutin::before { background: var(--danger); opacity: 0.5; }
.stat-card--pelatihan::before { background: var(--success); opacity: 0.5; }
.stat-card:hover { border-color: var(--border-light); }
.stat-number {
font-size: 24px; font-weight: 700; font-family: var(--font);
color: var(--accent-light); letter-spacing: -0.5px; line-height: 1.1;
}
.stat-card--rutin .stat-number { color: var(--danger); }
.stat-card--pelatihan .stat-number { color: var(--success); }
.stat-label { font-size: 9px; color: var(--text-muted); font-weight: 500; text-transform: uppercase; letter-spacing: 0.6px; margin-top: 3px; }
/* ─────────────────────────────────────────────
EKONOMI GRID (stat avg penghasilan & anggota)
───────────────────────────────────────────── */
.ekon-grid { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
.ekon-card {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius); padding: 10px 12px;
display: flex; justify-content: space-between; align-items: center;
}
.ekon-label { font-size: 11px; color: var(--text-muted); font-weight: 500; }
.ekon-value { font-size: 13px; font-weight: 700; font-family: var(--font); color: var(--text-primary); }
/* ─────────────────────────────────────────────
SECTION TITLE
───────────────────────────────────────────── */
.section-title {
font-size: 9px; font-weight: 600; color: var(--text-muted);
text-transform: uppercase; letter-spacing: 1px;
margin: 14px 0 8px;
display: flex; align-items: center; gap: 8px;
}
.section-title svg { width: 12px; height: 12px; stroke: var(--text-muted); fill: none; flex-shrink: 0; }
.section-title::after { content: ''; flex: 1; height: 1px; background: var(--border); }
/* ─────────────────────────────────────────────
LEGEND
───────────────────────────────────────────── */
.legend-group-label {
font-size: 9px; color: var(--text-hint); font-weight: 600;
text-transform: uppercase; letter-spacing: 0.8px;
margin-bottom: 5px; padding-left: 2px;
}
.legend-item {
display: flex; align-items: center; gap: 8px;
padding: 5px 6px; border-radius: var(--radius-sm); margin-bottom: 2px;
font-size: 12px; font-weight: 400; color: var(--text-secondary);
transition: background 0.15s; cursor: default;
}
.legend-item:hover { background: var(--bg-card); }
.legend-icon {
width: 22px; height: 22px; border-radius: 6px;
background: var(--bg-card); border: 1px solid var(--border);
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.legend-icon svg { width: 13px; height: 13px; stroke: var(--icon-tint); fill: none; }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
/* ─────────────────────────────────────────────
RADIUS CONTROL
───────────────────────────────────────────── */
.radius-control {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius); padding: 12px; margin-bottom: 10px;
}
.radius-label {
font-size: 11px; font-weight: 500; color: var(--text-secondary);
margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center;
}
.radius-value { font-family: var(--font); font-size: 13px; font-weight: 600; color: var(--accent-light); }
.radius-slider {
width: 100%; -webkit-appearance: none; appearance: none;
height: 3px; border-radius: 2px;
background: linear-gradient(to right, var(--accent) 0%, var(--accent) 50%, var(--border-light) 50%);
outline: none; cursor: pointer; margin-bottom: 10px;
}
.radius-slider::-webkit-slider-thumb {
-webkit-appearance: none; width: 15px; height: 15px; border-radius: 50%;
background: var(--accent-light); border: 2px solid var(--bg-panel);
cursor: pointer; transition: transform 0.15s; box-shadow: var(--shadow-sm);
}
.radius-slider::-webkit-slider-thumb:hover { transform: scale(1.15); }
.radius-presets { display: flex; gap: 5px; }
.preset-btn {
flex: 1; padding: 5px; background: var(--bg-deep);
border: 1px solid var(--border); border-radius: var(--radius-sm);
color: var(--text-muted); font-size: 10px; font-weight: 600;
font-family: var(--font); cursor: pointer; transition: all 0.15s; letter-spacing: 0.2px;
}
.preset-btn:hover { border-color: var(--accent); color: var(--accent-light); }
.preset-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
/* ─────────────────────────────────────────────
SEARCH & DATA LIST
───────────────────────────────────────────── */
.search-box { position: relative; margin-bottom: 8px; }
.search-box input {
width: 100%; padding: 8px 10px 8px 32px;
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-sm); color: var(--text-primary);
font-family: var(--font); font-size: 12px; outline: none; transition: border-color 0.2s;
}
.search-box input::placeholder { color: var(--text-muted); }
.search-box input:focus { border-color: var(--accent); }
.search-icon { position: absolute; left: 9px; top: 50%; transform: translateY(-50%); pointer-events: none; }
.search-icon svg { width: 14px; height: 14px; stroke: var(--text-muted); fill: none; }
.data-list { display: flex; flex-direction: column; gap: 4px; }
.data-item {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius); padding: 10px;
cursor: pointer; transition: all 0.18s;
display: flex; gap: 10px; align-items: flex-start;
}
.data-item:hover { border-color: var(--accent); background: var(--bg-hover); }
.item-icon {
width: 28px; height: 28px; border-radius: 7px;
background: var(--bg-deep); border: 1px solid var(--border);
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.item-icon svg { width: 14px; height: 14px; stroke: var(--icon-tint); fill: none; }
.item-info { flex: 1; min-width: 0; }
.item-name { font-size: 12px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 2px; color: var(--text-primary); }
.item-sub { font-size: 10px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.item-badge { font-size: 9px; font-weight: 600; padding: 2px 7px; border-radius: 20px; flex-shrink: 0; align-self: flex-start; margin-top: 2px; letter-spacing: 0.3px; }
/* ─────────────────────────────────────────────
BUTTONS
───────────────────────────────────────────── */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
padding: 8px 14px; border: none; border-radius: var(--radius-sm);
font-family: var(--font); font-size: 12px; font-weight: 600;
cursor: pointer; transition: all 0.18s; text-decoration: none;
}
.btn svg { width: 14px; height: 14px; stroke: currentColor; fill: none; }
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { background: #4338ca; }
.btn-success { background: #059669; color: #fff; }
.btn-success:hover { background: #047857; }
.btn-danger { background: #dc2626; color: #fff; }
.btn-danger:hover { background: #b91c1c; }
.btn-ghost { background: var(--bg-card); color: var(--text-secondary); border: 1px solid var(--border); }
.btn-ghost:hover { border-color: var(--border-light); color: var(--text-primary); }
.btn-sm { padding: 5px 10px; font-size: 11px; }
.btn-xs { padding: 3px 8px; font-size: 10px; }
.btn-block { width: 100%; }
/* ─────────────────────────────────────────────
MAP AREA
───────────────────────────────────────────── */
#map-container { flex: 1; position: relative; overflow: hidden; }
#map { width: 100%; height: 100%; }
/* ─────────────────────────────────────────────
TOOLBAR
───────────────────────────────────────────── */
#map-toolbar {
position: absolute; bottom: 20px; right: 16px;
z-index: 1000; display: flex; flex-direction: column; gap: 6px;
}
.tool-btn {
width: 38px; height: 38px; border-radius: var(--radius-sm);
background: var(--bg-panel); border: 1px solid var(--border);
color: var(--text-muted); cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: all 0.18s; box-shadow: var(--shadow-sm);
}
.tool-btn svg { width: 16px; height: 16px; stroke: currentColor; fill: none; }
.tool-btn:hover { background: var(--bg-hover); color: var(--text-primary); border-color: var(--accent); }
/* ─────────────────────────────────────────────
INFO PANEL
───────────────────────────────────────────── */
#info-panel {
position: absolute; top: 16px; right: 16px;
width: 280px; background: var(--bg-panel);
border: 1px solid var(--border); border-radius: var(--radius-lg);
z-index: 1000; display: none; box-shadow: var(--shadow-lg); overflow: hidden;
}
#info-panel.show { display: block; animation: slideIn 0.22s ease; }
@keyframes slideIn {
from { opacity: 0; transform: translateY(-8px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.info-header {
padding: 13px 14px 11px; border-bottom: 1px solid var(--border);
display: flex; justify-content: space-between; align-items: center;
background: var(--bg-card);
}
.info-title { font-size: 13px; font-weight: 700; display: flex; align-items: center; gap: 8px; color: var(--text-primary); }
.info-title-icon {
width: 26px; height: 26px; border-radius: 6px;
background: var(--bg-panel); border: 1px solid var(--border);
display: flex; align-items: center; justify-content: center;
}
.info-title-icon svg { width: 14px; height: 14px; stroke: var(--icon-tint); fill: none; }
.info-close {
width: 24px; height: 24px; border-radius: 6px;
background: transparent; border: 1px solid transparent;
color: var(--text-muted); font-size: 14px; cursor: pointer;
display: flex; align-items: center; justify-content: center; transition: all 0.18s;
}
.info-close:hover { background: var(--danger-bg); color: var(--danger); }
.info-body { padding: 12px 14px; max-height: calc(100vh - 200px); overflow-y: auto; }
.info-body::-webkit-scrollbar { width: 3px; }
.info-body::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 2px; }
.info-row { display: flex; gap: 8px; margin-bottom: 7px; font-size: 12px; align-items: flex-start; }
.info-key { color: var(--text-muted); font-weight: 500; min-width: 82px; flex-shrink: 0; padding-top: 1px; }
.info-val { color: var(--text-secondary); word-break: break-word; line-height: 1.5; }
.address-box {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-sm); padding: 8px 10px;
font-size: 11px; color: var(--text-muted); line-height: 1.5;
margin: 0 0 10px; display: flex; gap: 6px; align-items: flex-start;
}
.addr-icon svg { width: 12px; height: 12px; stroke: var(--text-muted); fill: none; flex-shrink: 0; margin-top: 1px; }
.info-coords {
font-family: var(--mono); font-size: 10px; color: var(--text-muted);
text-align: center; padding: 6px;
background: var(--bg-card); border-radius: var(--radius-sm);
border: 1px solid var(--border); margin-top: 8px;
}
.info-section-title {
font-size: 9px; font-weight: 600; color: var(--text-muted);
text-transform: uppercase; letter-spacing: 0.8px;
margin: 12px 0 6px; display: flex; align-items: center; gap: 6px;
}
.info-section-title svg { width: 11px; height: 11px; stroke: currentColor; fill: none; }
/* Anggota list in info panel */
.anggota-list { display: flex; flex-direction: column; gap: 4px; }
.anggota-item { display: flex; gap: 8px; align-items: flex-start; padding: 6px 8px; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-sm); }
.anggota-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; margin-top: 4px; }
.anggota-info { flex: 1; min-width: 0; }
.anggota-name { font-size: 11px; font-weight: 600; color: var(--text-primary); }
.anggota-meta { font-size: 10px; color: var(--text-muted); line-height: 1.5; margin-top: 1px; }
/* Rekomendasi box in info panel */
.rek-box {
border-radius: var(--radius-sm); padding: 8px 10px; margin: 8px 0;
border-left: 3px solid;
}
.rek-box.rutin { background: var(--danger-bg); border-color: var(--danger); }
.rek-box.pelatihan { background: var(--success-bg); border-color: var(--success); }
.rek-box.keduanya { background: var(--warning-bg); border-color: var(--warning); }
.rek-box.tidak-ada { background: var(--bg-hover); border-color: var(--border-light); }
.rek-box-title { font-size: 10px; font-weight: 700; color: var(--text-primary); margin-bottom: 3px; }
.rek-box-desc { font-size: 10px; color: var(--text-muted); line-height: 1.5; }
/* ─────────────────────────────────────────────
BADGES
───────────────────────────────────────────── */
.badge { display: inline-block; padding: 2px 8px; border-radius: 20px; font-size: 10px; font-weight: 600; letter-spacing: 0.2px; }
.badge-sangat-miskin { background: var(--danger-bg); color: var(--danger-text); }
.badge-miskin { background: var(--info-bg); color: var(--info-text); }
.badge-rentan-miskin { background: var(--warning-bg); color: var(--warning-text); }
.badge-belum_terima { background: var(--danger-bg); color: var(--danger-text); }
.badge-sudah_terima { background: var(--success-bg); color: var(--success-text); }
.badge-sedang_proses { background: var(--warning-bg); color: var(--warning-text); }
/* STATUS_CONFIG cls — harus cocok dengan app.js */
.badge-belum-terima { background: var(--danger-bg); color: var(--danger-text); }
.badge-sudah-terima { background: var(--success-bg); color: var(--success-text); }
.badge-sedang-proses { background: var(--warning-bg); color: var(--warning-text); }
/* REK_CONFIG cls */
.badge-bantuan-rutin { background: var(--danger-bg); color: var(--danger-text); }
.badge-bantuan-pelatihan { background: var(--success-bg); color: var(--success-text); }
.badge-keduanya { background: var(--warning-bg); color: var(--warning-text); }
.badge-tidak-ada { background: var(--bg-hover); color: var(--text-muted); }
/* ─────────────────────────────────────────────
MODAL
───────────────────────────────────────────── */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
z-index: 9000; display: none; align-items: center; justify-content: center;
backdrop-filter: blur(4px);
}
.modal-overlay.show { display: flex; }
#modal-warga, #modal-ibadah { z-index: 9100; }
.modal {
background: var(--bg-panel); border: 1px solid var(--border);
border-radius: var(--radius-xl); width: 480px;
max-width: 95vw; max-height: 90vh; overflow-y: auto;
box-shadow: var(--shadow-lg); animation: modalIn 0.22s ease;
}
@keyframes modalIn {
from { opacity: 0; transform: scale(0.96) translateY(-16px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.modal::-webkit-scrollbar { width: 3px; }
.modal::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 2px; }
.modal-header {
padding: 16px 18px; border-bottom: 1px solid var(--border);
display: flex; justify-content: space-between; align-items: center;
position: sticky; top: 0; background: var(--bg-panel); z-index: 1;
}
.modal-title { font-size: 14px; font-weight: 700; color: var(--text-primary); }
.modal-close {
width: 26px; height: 26px; border-radius: var(--radius-sm);
background: var(--bg-card); border: 1px solid var(--border);
color: var(--text-muted); font-size: 13px; cursor: pointer;
display: flex; align-items: center; justify-content: center; transition: all 0.18s;
}
.modal-close:hover { background: var(--danger-bg); color: var(--danger); border-color: transparent; }
/* Modal sub-tabs */
.modal-tabs {
display: flex; border-bottom: 1px solid var(--border);
background: var(--bg-card); flex-shrink: 0;
}
.modal-tab-btn {
flex: 1; padding: 9px 6px;
background: transparent; border: none;
border-bottom: 2px solid transparent;
color: var(--text-muted); font-family: var(--font);
font-size: 11px; font-weight: 600; cursor: pointer;
transition: all 0.2s; margin-bottom: -1px;
}
.modal-tab-btn:hover { color: var(--text-secondary); }
.modal-tab-btn.active { color: var(--accent-light); border-bottom-color: var(--accent-light); }
.modal-tab-panel { display: none; }
.modal-tab-panel.active { display: block; }
.modal-body { padding: 18px; }
.modal-footer {
padding: 12px 18px; border-top: 1px solid var(--border);
display: flex; gap: 8px; justify-content: flex-end;
position: sticky; bottom: 0; background: var(--bg-panel); z-index: 1;
}
/* ─────────────────────────────────────────────
FORM
───────────────────────────────────────────── */
.form-group { margin-bottom: 13px; }
.form-label { display: block; font-size: 10px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.6px; margin-bottom: 5px; }
.form-label span { color: var(--danger); }
.form-control {
width: 100%; padding: 8px 11px;
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-sm); color: var(--text-primary);
font-family: var(--font); font-size: 13px; outline: none;
transition: border-color 0.18s, box-shadow 0.18s;
}
.form-control::placeholder { color: var(--text-muted); }
.form-control:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
select.form-control {
cursor: pointer; -webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 10px center; padding-right: 32px;
}
textarea.form-control { resize: vertical; min-height: 68px; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.form-hint { font-size: 10px; color: var(--text-muted); margin-top: 4px; }
.coord-display {
font-family: var(--mono); font-size: 11px; color: var(--accent-light);
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-sm); padding: 8px 10px;
}
/* Kondisi rumah preview */
.kondisi-rumah-preview {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius); padding: 12px; margin-top: 4px;
}
.kpr-title { font-size: 10px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.6px; margin-bottom: 10px; }
.kpr-grid { display: flex; gap: 6px; }
.kpr-item {
flex: 1; display: flex; flex-direction: column; align-items: center; gap: 5px;
background: var(--bg-deep); border: 1px solid var(--border);
border-radius: var(--radius-sm); padding: 8px 6px;
font-size: 10px; color: var(--text-secondary); text-align: center;
}
.kpr-item svg { color: var(--icon-tint); }
/* Anggota form items */
.anggota-form-item {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius); overflow: hidden;
}
.anggota-form-header {
display: flex; justify-content: space-between; align-items: center;
padding: 8px 12px; background: var(--bg-hover);
border-bottom: 1px solid var(--border);
}
.anggota-form-title { font-size: 11px; font-weight: 600; color: var(--text-secondary); }
.anggota-form-body {
padding: 12px; display: grid; grid-template-columns: 1fr 1fr; gap: 8px;
}
.anggota-form-body .form-group { margin-bottom: 0; }
/* Rekomendasi preview box */
.rek-preview {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius); padding: 10px 12px;
}
/* ─────────────────────────────────────────────
TOAST
───────────────────────────────────────────── */
#toast-container { position: fixed; bottom: 80px; right: 20px; z-index: 9999; display: flex; flex-direction: column; gap: 6px; }
.toast {
background: var(--bg-panel); border: 1px solid var(--border);
border-radius: var(--radius); padding: 10px 14px;
display: flex; align-items: center; gap: 10px; min-width: 240px;
box-shadow: var(--shadow-lg); animation: toastIn 0.25s ease;
font-size: 12px; color: var(--text-secondary);
}
@keyframes toastIn { from { opacity: 0; transform: translateX(16px); } to { opacity: 1; transform: translateX(0); } }
.toast.success { border-left: 3px solid var(--success); }
.toast.error { border-left: 3px solid var(--danger); }
.toast.info { border-left: 3px solid var(--accent-light); }
.toast-icon svg { width: 15px; height: 15px; stroke: currentColor; fill: none; }
.toast.success .toast-icon { color: var(--success); }
.toast.error .toast-icon { color: var(--danger); }
.toast.info .toast-icon { color: var(--accent-light); }
/* ─────────────────────────────────────────────
RADIUS INFO BAR
───────────────────────────────────────────── */
#radius-info {
position: absolute; bottom: 72px; left: 50%; transform: translateX(-50%);
z-index: 1000; background: var(--bg-panel);
border: 1px solid var(--border-light); border-radius: var(--radius);
padding: 9px 14px; display: none; gap: 10px; align-items: center;
box-shadow: var(--shadow); font-size: 12px; white-space: nowrap;
}
#radius-info.show { display: flex; }
.ri-label { color: var(--text-muted); }
.ri-val { color: var(--accent-light); font-weight: 600; font-family: var(--font); }
.ri-count { background: var(--danger-bg); color: var(--danger-text); padding: 2px 8px; border-radius: 20px; font-weight: 700; font-size: 11px; }
.ri-close { cursor: pointer; color: var(--text-muted); display: flex; align-items: center; justify-content: center; transition: color 0.18s; }
.ri-close:hover { color: var(--danger); }
.ri-close svg { width: 12px; height: 12px; stroke: currentColor; fill: none; }
/* ─────────────────────────────────────────────
MODE INDICATOR
───────────────────────────────────────────── */
#mode-indicator {
position: absolute; top: 16px; left: 16px; z-index: 1000;
background: var(--bg-panel); border: 1px solid var(--border);
border-radius: var(--radius); padding: 8px 12px;
display: none; align-items: center; gap: 8px;
font-size: 12px; font-weight: 500; color: var(--text-secondary);
box-shadow: var(--shadow-sm);
}
#mode-indicator.show { display: flex; }
.mode-dot { width: 7px; height: 7px; border-radius: 50%; animation: pulse 1.2s infinite; }
.mode-dot.ibadah { background: var(--success); }
.mode-dot.warga { background: var(--accent-light); }
@keyframes pulse { 0%, 100% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.5); opacity: 0.5; } }
/* ─────────────────────────────────────────────
LEAFLET OVERRIDES
───────────────────────────────────────────── */
.leaflet-popup-content-wrapper {
background: var(--bg-panel) !important; border: 1px solid var(--border) !important;
border-radius: var(--radius) !important; box-shadow: var(--shadow-lg) !important; color: var(--text-primary) !important;
}
.leaflet-popup-content { color: var(--text-primary) !important; font-family: var(--font) !important; }
.leaflet-popup-tip { background: var(--bg-panel) !important; }
.leaflet-popup-close-button { color: var(--text-muted) !important; }
.leaflet-control-zoom a {
background: var(--bg-panel) !important; color: var(--text-secondary) !important;
border-color: var(--border) !important; font-family: var(--font) !important;
}
.leaflet-control-zoom a:hover { background: var(--bg-hover) !important; }
.leaflet-control-attribution { display: none !important; }
/* ─────────────────────────────────────────────
SCROLLBAR
───────────────────────────────────────────── */
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
/* ─────────────────────────────────────────────
LOADING / EMPTY
───────────────────────────────────────────── */
.loading { display: flex; align-items: center; justify-content: center; padding: 28px; color: var(--text-muted); font-size: 12px; gap: 8px; }
.spinner { width: 16px; height: 16px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.empty-state { text-align: center; padding: 24px 16px; color: var(--text-muted); font-size: 12px; }
.empty-state svg { width: 28px; height: 28px; stroke: var(--text-hint); fill: none; margin: 0 auto 8px; display: block; }
/* ─────────────────────────────────────────────
HOW TO USE BOX
───────────────────────────────────────────── */
.howto-box {
font-size: 11px; color: var(--text-muted); line-height: 1.8;
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-sm); padding: 10px 12px;
}
.howto-box b { color: var(--text-secondary); font-weight: 600; }
/* ─────────────────────────────────────────────
LAPORAN WARGA
───────────────────────────────────────────── */
/* Tab badge counter */
.tab-badge {
display: inline-flex; align-items: center; justify-content: center;
min-width: 16px; height: 16px; padding: 0 4px;
background: var(--danger); color: #fff;
border-radius: 10px; font-size: 9px; font-weight: 700;
margin-left: 3px; line-height: 1;
}
/* Laporan card */
.laporan-card {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius); padding: 11px 12px;
margin-bottom: 6px; transition: border-color 0.18s;
border-left: 3px solid var(--border);
}
.laporan-card:hover { border-color: var(--accent); }
.laporan-card.prioritas-tinggi { border-left-color: var(--danger); }
.laporan-card.prioritas-sedang { border-left-color: var(--warning); }
.laporan-card.prioritas-rendah { border-left-color: var(--success); }
.laporan-card-header {
display: flex; justify-content: space-between; align-items: flex-start;
gap: 8px; margin-bottom: 6px;
}
.laporan-card-nama {
font-size: 12px; font-weight: 600; color: var(--text-primary);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.laporan-card-meta {
font-size: 10px; color: var(--text-muted); margin-top: 2px;
}
.laporan-card-jenis {
font-size: 11px; color: var(--text-secondary);
margin-bottom: 5px; font-weight: 500;
}
.laporan-card-desc {
font-size: 11px; color: var(--text-muted); line-height: 1.5;
background: var(--bg-deep); border-radius: var(--radius-sm);
padding: 6px 8px; margin-bottom: 6px;
border-left: 2px solid var(--border-light);
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
overflow: hidden;
}
.laporan-card-footer {
display: flex; justify-content: space-between; align-items: center;
gap: 6px; flex-wrap: wrap;
}
.laporan-card-petugas {
font-size: 10px; color: var(--success); font-weight: 600;
display: flex; align-items: center; gap: 4px;
}
.laporan-catatan-admin {
font-size: 10px; color: var(--text-muted);
background: var(--info-bg); border: 1px solid rgba(59,130,246,0.15);
border-radius: var(--radius-sm); padding: 5px 8px;
margin-top: 5px; line-height: 1.5;
}
/* Status badges laporan */
.badge-laporan-pending { background: rgba(245,158,11,0.12); color: #92400e; }
.badge-laporan-ditinjau { background: rgba(59,130,246,0.12); color: #1e40af; }
.badge-laporan-diproses { background: rgba(139,92,246,0.12); color: #5b21b6; }
.badge-laporan-selesai { background: rgba(16,185,129,0.12); color: #065f46; }
.badge-laporan-ditolak { background: rgba(239,68,68,0.12); color: #991b1b; }
/* Prioritas badges */
.badge-prioritas-tinggi { background: rgba(239,68,68,0.12); color: var(--danger-text); }
.badge-prioritas-sedang { background: rgba(245,158,11,0.12); color: var(--warning-text); }
.badge-prioritas-rendah { background: rgba(16,185,129,0.12); color: var(--success-text); }
/* Admin action area in laporan card */
.laporan-admin-actions {
display: flex; gap: 5px; flex-wrap: wrap; margin-top: 8px;
padding-top: 8px; border-top: 1px dashed var(--border);
}
.laporan-admin-actions select {
flex: 1; min-width: 110px; font-size: 10px; padding: 4px 8px;
border-radius: var(--radius-sm); border: 1px solid var(--border);
background: var(--bg-deep); color: var(--text-secondary);
font-family: var(--font); cursor: pointer;
}
.laporan-admin-actions input[type="text"] {
flex: 1.5; min-width: 100px; font-size: 10px; padding: 4px 8px;
border-radius: var(--radius-sm); border: 1px solid var(--border);
background: var(--bg-deep); color: var(--text-secondary);
font-family: var(--font);
}
/* Laporan filter tabs */
.laporan-filter-tabs {
display: flex; gap: 4px; flex-wrap: wrap;
margin-bottom: 10px;
}
.laporan-filter-tab {
padding: 4px 10px; border-radius: 20px; font-size: 10px; font-weight: 600;
border: 1px solid var(--border); background: var(--bg-card);
color: var(--text-muted); cursor: pointer; transition: all 0.18s;
font-family: var(--font);
}
.laporan-filter-tab:hover { border-color: var(--accent); color: var(--accent-light); }
.laporan-filter-tab.active { background: var(--accent); border-color: var(--accent); color: #fff; }
/* Laporkan button in info panel */
.btn-laporkan {
display: flex; align-items: center; gap: 6px;
width: 100%; margin-top: 8px; padding: 7px 12px;
background: rgba(239,68,68,0.08); border: 1px solid rgba(239,68,68,0.25);
border-radius: var(--radius-sm); color: var(--danger);
font-family: var(--font); font-size: 11px; font-weight: 600;
cursor: pointer; transition: all 0.18s;
}
.btn-laporkan:hover {
background: rgba(239,68,68,0.15); border-color: var(--danger);
}
.btn-laporkan svg { width: 13px; height: 13px; stroke: currentColor; fill: none; flex-shrink: 0; }
/* ─────────────────────────────────────────────
MOBILE STYLES & HAMBURGER MENU
───────────────────────────────────────────── */
.mobile-menu-btn {
display: none;
position: absolute;
top: 16px;
left: 16px;
z-index: 2000;
width: 42px;
height: 42px;
border-radius: var(--radius-sm);
background: var(--bg-panel);
border: 1px solid var(--border);
color: var(--text-primary);
cursor: pointer;
align-items: center;
justify-content: center;
box-shadow: var(--shadow-sm);
transition: all 0.2s;
}
.mobile-menu-btn svg { width: 22px; height: 22px; }
.mobile-menu-btn:hover { background: var(--bg-hover); color: var(--accent); border-color: var(--accent); }
/* Overlay for mobile sidebar */
.sidebar-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.4);
z-index: 2500;
backdrop-filter: blur(2px);
opacity: 0;
transition: opacity 0.3s;
}
.sidebar-overlay.show {
display: block;
opacity: 1;
}
@media (max-width: 768px) {
.mobile-menu-btn {
display: flex;
}
#sidebar {
position: absolute;
top: 0;
left: -100%;
height: 100%;
z-index: 3000;
transition: left 0.3s ease;
box-shadow: var(--shadow-lg);
width: 85vw; /* Adjust width for mobile */
max-width: 340px;
}
#sidebar.show {
left: 0;
}
#mode-indicator {
top: 16px;
left: 70px; /* Make space for hamburger button */
}
.radius-control {
padding: 10px;
}
}
+2061
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+74
View File
@@ -0,0 +1,74 @@
<?php
$host = 'localhost';
$dbname = 'bansos_gis';
$user = 'root';
$pass = '';
// Legacy MySQLi connection used by older APIs
try {
$conn = new mysqli($host, $user, $pass, $dbname);
$conn->set_charset('utf8mb4');
if ($conn->connect_error) {
throw new Exception($conn->connect_error);
}
// Self-healing migration for manual recommendation column
try {
$checkCol = $conn->query("SHOW COLUMNS FROM `rumah_warga` LIKE 'is_rekomendasi_manual'");
if ($checkCol && $checkCol->num_rows === 0) {
$conn->query("ALTER TABLE `rumah_warga` ADD COLUMN `is_rekomendasi_manual` TINYINT(1) NOT NULL DEFAULT 0 AFTER `rekomendasi_bantuan`");
}
$checkColKat = $conn->query("SHOW COLUMNS FROM `rumah_warga` LIKE 'is_kategori_manual'");
if ($checkColKat && $checkColKat->num_rows === 0) {
$conn->query("ALTER TABLE `rumah_warga` ADD COLUMN `is_kategori_manual` TINYINT(1) NOT NULL DEFAULT 0 AFTER `kategori_kemiskinan`");
}
$checkColDikelola = $conn->query("SHOW COLUMNS FROM `rumah_warga` LIKE 'dikelola_oleh_ibadah_id'");
if ($checkColDikelola && $checkColDikelola->num_rows === 0) {
$conn->query("ALTER TABLE `rumah_warga` ADD COLUMN `dikelola_oleh_ibadah_id` INT NULL DEFAULT NULL AFTER `keterangan`");
}
} catch (Exception $ex) {}
// Self-healing migration for no_telp column in users table
try {
$checkColUsers = $conn->query("SHOW COLUMNS FROM `users` LIKE 'no_telp'");
if ($checkColUsers && $checkColUsers->num_rows === 0) {
$conn->query("ALTER TABLE `users` ADD COLUMN `no_telp` VARCHAR(20) NULL DEFAULT NULL AFTER `email`");
}
} catch (Exception $ex) {}
// Self-healing migration for RBAC & Kebutuhan tables
try {
$checkUsers = $conn->query("SHOW TABLES LIKE 'users'");
if ($checkUsers && $checkUsers->num_rows === 0) {
$sqlFile = dirname(__DIR__) . '/database/migrations.sql';
if (file_exists($sqlFile)) {
$sqlContent = file_get_contents($sqlFile);
if ($conn->multi_query($sqlContent)) {
do {
if ($result = $conn->store_result()) {
$result->free();
}
} while ($conn->more_results() && $conn->next_result());
}
}
}
} catch (Exception $ex) {}
} catch (Exception $e) {
http_response_code(500);
die(json_encode([
'success' => false,
'message' => 'Koneksi database MySQLi gagal: ' . $e->getMessage() . '. Pastikan XAMPP Apache dan MySQL Anda aktif, serta database "bansos_gis" telah dibuat di phpMyAdmin.'
]));
}
// New PDO connection used by new APIs (redistribusi, etc.)
try {
$pdo = new PDO("mysql:host=$host;dbname=$dbname;charset=utf8mb4", $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
]);
} catch (PDOException $e) {
// Set to null — APIs that require PDO must check and throw themselves
$pdo = null;
}
+364
View File
@@ -0,0 +1,364 @@
-- phpMyAdmin SQL Dump
-- version 5.2.1
-- https://www.phpmyadmin.net/
--
-- Host: 127.0.0.1
-- Generation Time: May 17, 2026 at 01:04 PM
-- Server version: 10.4.32-MariaDB
-- PHP Version: 8.2.12
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
START TRANSACTION;
SET time_zone = "+00:00";
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!40101 SET NAMES utf8mb4 */;
--
-- Database: `bansos_gis`
--
-- --------------------------------------------------------
--
-- Table structure for table `anggota_keluarga`
--
CREATE TABLE `anggota_keluarga` (
`id` int(11) NOT NULL,
`rumah_warga_id` int(11) NOT NULL,
`nama` varchar(200) NOT NULL,
`nik` varchar(16) DEFAULT NULL,
`tanggal_lahir` date DEFAULT NULL,
`jenis_kelamin` enum('laki_laki','perempuan') DEFAULT 'laki_laki',
`pendidikan` enum('tidak_sekolah','sd','smp','sma','d3','s1','s2','s3') DEFAULT 'sd',
`pekerjaan` varchar(100) DEFAULT NULL,
`status_bekerja` enum('bekerja','tidak_bekerja','pelajar','pensiunan') DEFAULT 'tidak_bekerja',
`kondisi_kesehatan` enum('sehat','sakit_ringan','sakit_berat','disabilitas') DEFAULT 'sehat',
`dapat_bekerja` tinyint(1) DEFAULT 1,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
--
-- Dumping data for table `anggota_keluarga`
--
INSERT INTO `anggota_keluarga` (`id`, `rumah_warga_id`, `nama`, `nik`, `tanggal_lahir`, `jenis_kelamin`, `pendidikan`, `pekerjaan`, `status_bekerja`, `kondisi_kesehatan`, `dapat_bekerja`, `created_at`, `updated_at`) VALUES
(1, 1, 'Budi Santoso', '1234567890123456', '1985-06-15', 'laki_laki', 'smp', 'Buruh Harian', 'bekerja', 'sehat', 1, '2026-05-17 06:06:37', '2026-05-17 06:06:37'),
(2, 1, 'Rina Santoso', '1234567890123457', '1988-03-20', 'perempuan', 'sd', 'Ibu Rumah Tangga', 'tidak_bekerja', 'sehat', 1, '2026-05-17 06:06:37', '2026-05-17 06:06:37'),
(3, 1, 'Doni Santoso', '1234567890123458', '2008-11-10', 'laki_laki', 'smp', NULL, 'pelajar', 'sehat', 0, '2026-05-17 06:06:37', '2026-05-17 06:06:37'),
(4, 1, 'Dewi Santoso', '1234567890123459', '2012-07-25', 'perempuan', 'sd', NULL, 'pelajar', 'sehat', 0, '2026-05-17 06:06:37', '2026-05-17 06:06:37'),
(5, 2, 'Siti Aminah', '9876543210987654', '1970-01-12', 'perempuan', 'sd', NULL, 'tidak_bekerja', 'sakit_ringan', 0, '2026-05-17 06:06:37', '2026-05-17 06:06:37'),
(6, 2, 'Ahmad Firdaus', '9876543210987655', '2005-09-30', 'laki_laki', 'sma', NULL, 'pelajar', 'sehat', 1, '2026-05-17 06:06:37', '2026-05-17 06:06:37');
-- --------------------------------------------------------
--
-- Table structure for table `konfigurasi_sistem`
--
CREATE TABLE `konfigurasi_sistem` (
`id` int(11) NOT NULL,
`kunci` varchar(100) NOT NULL COMMENT 'key konfigurasi',
`nilai` text NOT NULL COMMENT 'value konfigurasi',
`label` varchar(200) DEFAULT NULL COMMENT 'label tampil di UI',
`deskripsi` text DEFAULT NULL,
`tipe` enum('integer','decimal','string','boolean','json') DEFAULT 'string',
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
--
-- Dumping data for table `konfigurasi_sistem`
--
INSERT INTO `konfigurasi_sistem` (`id`, `kunci`, `nilai`, `label`, `deskripsi`, `tipe`, `updated_at`) VALUES
(1, 'interval_verifikasi_bulan', '6', 'Interval Verifikasi Data (Bulan)', 'Berapa bulan sekali data rumah warga harus diverifikasi ulang', 'integer', '2026-05-17 10:41:31'),
(2, 'skor_data_kadaluarsa', '40', 'Skor Minimum Data Valid (%)', 'Data dengan skor kepercayaan di bawah nilai ini dianggap perlu reverifikasi', 'integer', '2026-05-17 10:41:31'),
(3, 'threshold_penghasilan_mandiri', '3500000', 'Threshold Penghasilan Mandiri (Rp)', 'Penghasilan bulanan minimum agar warga dianggap berpotensi mandiri (default = UMR)', 'decimal', '2026-05-17 10:41:31'),
(4, 'bobot_penghasilan', '40', 'Bobot Skor: Penghasilan (%)', 'Kontribusi penghasilan terhadap skor kemandirian (total 3 bobot harus = 100)', 'integer', '2026-05-17 10:41:31'),
(5, 'bobot_status_kerja', '35', 'Bobot Skor: Status Kerja (%)', 'Kontribusi status pekerjaan anggota terhadap skor kemandirian', 'integer', '2026-05-17 10:41:31'),
(6, 'bobot_tanggungan', '25', 'Bobot Skor: Tanggungan (%)', 'Kontribusi jumlah tanggungan terhadap skor kemandirian', 'integer', '2026-05-17 10:41:31'),
(7, 'threshold_skor_mandiri', '70', 'Threshold Skor Kemandirian (%)', 'Skor minimum agar warga masuk kategori \"Berpotensi Mandiri\"', 'integer', '2026-05-17 10:41:31'),
(8, 'kapasitas_default_ibadah', '20', 'Kapasitas Default per Rumah Ibadah (KK)', 'Jumlah KK yang idealnya ditangani 1 rumah ibadah jika tidak diset manual', 'integer', '2026-05-17 10:41:31');
-- --------------------------------------------------------
--
-- Table structure for table `redistribusi_bantuan`
--
CREATE TABLE `redistribusi_bantuan` (
`id` int(11) NOT NULL,
`ibadah_pengirim_id` int(11) NOT NULL COMMENT 'Rumah ibadah yang punya surplus',
`ibadah_penerima_id` int(11) NOT NULL COMMENT 'Rumah ibadah yang butuh tambahan',
`jumlah_kk` smallint(5) UNSIGNED DEFAULT 0 COMMENT 'Jumlah KK yang dialihkan',
`estimasi_dana` decimal(15,2) DEFAULT 0.00 COMMENT 'Estimasi dana yang ikut dialihkan',
`status` enum('saran_sistem','disetujui','ditolak','selesai') DEFAULT 'saran_sistem',
`catatan_sistem` text DEFAULT NULL COMMENT 'Alasan/penjelasan dari sistem',
`catatan_admin` text DEFAULT NULL COMMENT 'Catatan keputusan admin',
`disetujui_oleh` varchar(150) DEFAULT NULL,
`tanggal_saran` timestamp NOT NULL DEFAULT current_timestamp(),
`tanggal_keputusan` timestamp NULL DEFAULT NULL,
`periode_bulan` char(7) DEFAULT NULL COMMENT 'Format YYYY-MM, periode redistribusi'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- --------------------------------------------------------
--
-- Table structure for table `riwayat_kemandirian`
--
CREATE TABLE `riwayat_kemandirian` (
`id` int(11) NOT NULL,
`rumah_warga_id` int(11) NOT NULL,
`tanggal` date NOT NULL,
`skor` tinyint(3) UNSIGNED DEFAULT 0,
`status` enum('sangat_bergantung','bergantung','berkembang','berpotensi_mandiri','mandiri') DEFAULT 'bergantung',
`penghasilan_saat_ini` decimal(15,2) DEFAULT 0.00,
`catatan` text DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- --------------------------------------------------------
--
-- Table structure for table `riwayat_verifikasi`
--
CREATE TABLE `riwayat_verifikasi` (
`id` int(11) NOT NULL,
`rumah_warga_id` int(11) NOT NULL,
`tanggal` date NOT NULL,
`petugas` varchar(150) DEFAULT NULL,
`skor_sebelum` tinyint(3) UNSIGNED DEFAULT NULL,
`skor_sesudah` tinyint(3) UNSIGNED DEFAULT NULL,
`perubahan` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL COMMENT 'Field yang berubah saat verifikasi' CHECK (json_valid(`perubahan`)),
`catatan` text DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- --------------------------------------------------------
--
-- Table structure for table `rumah_ibadah`
--
CREATE TABLE `rumah_ibadah` (
`id` int(11) NOT NULL,
`nama` varchar(200) NOT NULL,
`jenis` enum('masjid','gereja','klenteng','pura','vihara','lainnya') NOT NULL DEFAULT 'masjid',
`alamat` text DEFAULT NULL,
`kelurahan` varchar(100) DEFAULT NULL,
`kecamatan` varchar(100) DEFAULT NULL,
`kota` varchar(100) DEFAULT NULL,
`latitude` decimal(10,8) NOT NULL,
`longitude` decimal(11,8) NOT NULL,
`penanggung_jawab` varchar(150) DEFAULT NULL,
`kontak` varchar(50) DEFAULT NULL,
`keterangan` text DEFAULT NULL,
`kapasitas_kk` smallint(5) UNSIGNED DEFAULT 20 COMMENT 'Kapasitas maksimal KK yang dapat ditangani',
`anggaran_bulanan` decimal(15,2) DEFAULT 0.00 COMMENT 'Estimasi anggaran bantuan tersedia per bulan (Rp)',
`radius_primer` smallint(5) UNSIGNED DEFAULT 500 COMMENT 'Radius cakupan utama dalam meter',
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
--
-- Dumping data for table `rumah_ibadah`
--
INSERT INTO `rumah_ibadah` (`id`, `nama`, `jenis`, `alamat`, `kelurahan`, `kecamatan`, `kota`, `latitude`, `longitude`, `penanggung_jawab`, `kontak`, `keterangan`, `kapasitas_kk`, `anggaran_bulanan`, `radius_primer`, `created_at`, `updated_at`) VALUES
(1, 'Masjid Al-Ikhlas', 'masjid', 'Jl. Merdeka No. 1', 'Sungai Jawi', 'Pontianak Kota', 'Pontianak', -0.02436519, 109.32911396, 'H. Ahmad Fauzi', '08123456789', 'Masjid utama kecamatan', 20, 0.00, 500, '2026-05-17 06:06:37', '2026-05-17 07:29:38'),
(2, 'Paroki Santo Yoseph Katedral', 'gereja', 'Jl. Katedral No. 5', 'Mariana', 'Pontianak Kota', 'Pontianak', -0.02727270, 109.33816910, 'Pastor Yohanes', '08234567890', 'Gereja Katedral', 20, 0.00, 500, '2026-05-17 06:06:37', '2026-05-17 06:06:37');
-- --------------------------------------------------------
--
-- Table structure for table `rumah_warga`
--
CREATE TABLE `rumah_warga` (
`id` int(11) NOT NULL,
`no_kk` varchar(16) NOT NULL,
`nama_kepala_keluarga` varchar(200) NOT NULL,
`nik` varchar(16) DEFAULT NULL,
`alamat` text DEFAULT NULL,
`kelurahan` varchar(100) DEFAULT NULL,
`kecamatan` varchar(100) DEFAULT NULL,
`kota` varchar(100) DEFAULT NULL,
`latitude` decimal(10,8) NOT NULL,
`longitude` decimal(11,8) NOT NULL,
`jumlah_tanggungan` int(11) DEFAULT 1,
`penghasilan_bulanan` decimal(15,2) DEFAULT 0.00,
`pekerjaan` varchar(100) DEFAULT NULL,
`status_pekerjaan` enum('tetap','tidak_tetap','tidak_bekerja','pensiun') DEFAULT 'tidak_bekerja',
`luas_rumah` decimal(8,2) DEFAULT NULL COMMENT 'm2',
`jenis_lantai` enum('tanah','semen','keramik','kayu','lainnya') DEFAULT 'semen',
`jenis_dinding` enum('tembok','kayu','bambu','lainnya') DEFAULT 'tembok',
`sumber_air` enum('pdam','sumur','sungai','hujan','lainnya') DEFAULT 'sumur',
`status_tempat_tinggal` enum('milik_sendiri','sewa','numpang','dinas') DEFAULT 'milik_sendiri',
`kategori_kemiskinan` enum('sangat_miskin','miskin','rentan_miskin') DEFAULT 'miskin',
`status_bantuan` enum('belum_terima','sudah_terima','sedang_proses') DEFAULT 'belum_terima',
`rekomendasi_bantuan` enum('bantuan_rutin','bantuan_pelatihan','keduanya','tidak_ada') DEFAULT 'bantuan_rutin',
`keterangan` text DEFAULT NULL,
`tanggal_verifikasi` date DEFAULT NULL COMMENT 'Tanggal terakhir data diverifikasi petugas',
`petugas_verifikasi` varchar(150) DEFAULT NULL COMMENT 'Nama petugas yang melakukan verifikasi',
`skor_kepercayaan` tinyint(3) UNSIGNED DEFAULT 100 COMMENT 'Skor kepercayaan data 0-100 (100=baru diverifikasi)',
`status_verifikasi` enum('terverifikasi','perlu_update','kadaluarsa') DEFAULT 'terverifikasi' COMMENT 'Status keterbaruan data',
`skor_kemandirian` tinyint(3) UNSIGNED DEFAULT 0 COMMENT 'Skor potensi kemandirian 0-100 (dihitung otomatis)',
`status_kemandirian` enum('sangat_bergantung','bergantung','berkembang','berpotensi_mandiri','mandiri') DEFAULT 'bergantung' COMMENT 'Status kemandirian warga',
`catatan_kemandirian` text DEFAULT NULL COMMENT 'Catatan perkembangan kemandirian dari petugas',
`tanggal_exit_plan` date DEFAULT NULL COMMENT 'Target tanggal warga keluar dari daftar bantuan',
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
--
-- Dumping data for table `rumah_warga`
--
INSERT INTO `rumah_warga` (`id`, `no_kk`, `nama_kepala_keluarga`, `nik`, `alamat`, `kelurahan`, `kecamatan`, `kota`, `latitude`, `longitude`, `jumlah_tanggungan`, `penghasilan_bulanan`, `pekerjaan`, `status_pekerjaan`, `luas_rumah`, `jenis_lantai`, `jenis_dinding`, `sumber_air`, `status_tempat_tinggal`, `kategori_kemiskinan`, `status_bantuan`, `rekomendasi_bantuan`, `keterangan`, `tanggal_verifikasi`, `petugas_verifikasi`, `skor_kepercayaan`, `status_verifikasi`, `skor_kemandirian`, `status_kemandirian`, `catatan_kemandirian`, `tanggal_exit_plan`, `created_at`, `updated_at`) VALUES
(1, '1234567890987654', 'Budi Santoso', '1234567890123456', 'Jl. Gang Melati No. 3', 'Sungai Jawi', 'Pontianak Kota', 'Pontianak', -0.02399236, 109.32659805, 4, 800000.00, 'Buruh Harian', 'tidak_tetap', 36.00, 'semen', 'kayu', 'sumur', 'sewa', 'miskin', 'belum_terima', 'bantuan_pelatihan', 'Kepala keluarga masih produktif', '2026-05-17', NULL, 100, 'terverifikasi', 0, 'bergantung', NULL, NULL, '2026-05-17 06:06:37', '2026-05-17 10:41:31'),
(2, '0987654321234567', 'Siti Aminah', '9876543210987654', 'Jl. Flamboyan No. 12', 'Sungai Jawi', 'Pontianak Kota', 'Pontianak', -0.02216577, 109.32456493, 2, 0.00, 'Ibu Rumah Tangga', 'tidak_bekerja', 24.00, 'tanah', 'bambu', 'sungai', 'numpang', 'sangat_miskin', 'belum_terima', 'bantuan_rutin', 'Suami meninggal, tidak ada penghasilan', '2026-05-17', NULL, 100, 'terverifikasi', 0, 'bergantung', NULL, NULL, '2026-05-17 06:06:37', '2026-05-17 10:41:31'),
(3, '1111111111111111', 'Test User', '1111111111111111', 'Test Alamat', 'Test', 'Test', 'Pontianak', -0.02300000, 109.32600000, 1, 500000.00, 'Test', 'tidak_bekerja', 30.00, 'semen', 'tembok', 'sumur', 'milik_sendiri', 'miskin', 'belum_terima', 'bantuan_rutin', 'Test data', '2026-05-17', NULL, 100, 'terverifikasi', 0, 'bergantung', NULL, NULL, '2026-05-17 08:08:57', '2026-05-17 10:41:31'),
(4, '9999999999999999', 'Ahmad Wijaya', '', '', '', '', '', -0.02300000, 109.32600000, 1, 0.00, '', 'tidak_bekerja', NULL, 'semen', 'tembok', 'sumur', 'milik_sendiri', 'miskin', 'belum_terima', 'bantuan_rutin', '', '2026-05-17', NULL, 100, 'terverifikasi', 0, 'bergantung', NULL, NULL, '2026-05-17 08:13:55', '2026-05-17 10:41:31');
--
-- Indexes for dumped tables
--
--
-- Indexes for table `anggota_keluarga`
--
ALTER TABLE `anggota_keluarga`
ADD PRIMARY KEY (`id`),
ADD KEY `fk_anggota_warga` (`rumah_warga_id`);
--
-- Indexes for table `konfigurasi_sistem`
--
ALTER TABLE `konfigurasi_sistem`
ADD PRIMARY KEY (`id`),
ADD UNIQUE KEY `uk_kunci` (`kunci`);
--
-- Indexes for table `redistribusi_bantuan`
--
ALTER TABLE `redistribusi_bantuan`
ADD PRIMARY KEY (`id`),
ADD KEY `fk_redist_pengirim` (`ibadah_pengirim_id`),
ADD KEY `fk_redist_penerima` (`ibadah_penerima_id`),
ADD KEY `idx_periode` (`periode_bulan`),
ADD KEY `idx_status` (`status`);
--
-- Indexes for table `riwayat_kemandirian`
--
ALTER TABLE `riwayat_kemandirian`
ADD PRIMARY KEY (`id`),
ADD KEY `fk_kemandirian_warga` (`rumah_warga_id`);
--
-- Indexes for table `riwayat_verifikasi`
--
ALTER TABLE `riwayat_verifikasi`
ADD PRIMARY KEY (`id`),
ADD KEY `fk_riwayat_warga` (`rumah_warga_id`);
--
-- Indexes for table `rumah_ibadah`
--
ALTER TABLE `rumah_ibadah`
ADD PRIMARY KEY (`id`);
--
-- Indexes for table `rumah_warga`
--
ALTER TABLE `rumah_warga`
ADD PRIMARY KEY (`id`),
ADD UNIQUE KEY `no_kk` (`no_kk`),
ADD UNIQUE KEY `uk_no_kk` (`no_kk`);
--
-- AUTO_INCREMENT for dumped tables
--
--
-- AUTO_INCREMENT for table `anggota_keluarga`
--
ALTER TABLE `anggota_keluarga`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=7;
--
-- AUTO_INCREMENT for table `konfigurasi_sistem`
--
ALTER TABLE `konfigurasi_sistem`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=9;
--
-- AUTO_INCREMENT for table `redistribusi_bantuan`
--
ALTER TABLE `redistribusi_bantuan`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
--
-- AUTO_INCREMENT for table `riwayat_kemandirian`
--
ALTER TABLE `riwayat_kemandirian`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
--
-- AUTO_INCREMENT for table `riwayat_verifikasi`
--
ALTER TABLE `riwayat_verifikasi`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
--
-- AUTO_INCREMENT for table `rumah_ibadah`
--
ALTER TABLE `rumah_ibadah`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=3;
--
-- AUTO_INCREMENT for table `rumah_warga`
--
ALTER TABLE `rumah_warga`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=5;
--
-- Constraints for dumped tables
--
--
-- Constraints for table `anggota_keluarga`
--
ALTER TABLE `anggota_keluarga`
ADD CONSTRAINT `fk_anggota_warga` FOREIGN KEY (`rumah_warga_id`) REFERENCES `rumah_warga` (`id`) ON DELETE CASCADE;
--
-- Constraints for table `redistribusi_bantuan`
--
ALTER TABLE `redistribusi_bantuan`
ADD CONSTRAINT `fk_redist_penerima` FOREIGN KEY (`ibadah_penerima_id`) REFERENCES `rumah_ibadah` (`id`) ON DELETE CASCADE,
ADD CONSTRAINT `fk_redist_pengirim` FOREIGN KEY (`ibadah_pengirim_id`) REFERENCES `rumah_ibadah` (`id`) ON DELETE CASCADE;
--
-- Constraints for table `riwayat_kemandirian`
--
ALTER TABLE `riwayat_kemandirian`
ADD CONSTRAINT `fk_kemandirian_warga` FOREIGN KEY (`rumah_warga_id`) REFERENCES `rumah_warga` (`id`) ON DELETE CASCADE;
--
-- Constraints for table `riwayat_verifikasi`
--
ALTER TABLE `riwayat_verifikasi`
ADD CONSTRAINT `fk_riwayat_warga` FOREIGN KEY (`rumah_warga_id`) REFERENCES `rumah_warga` (`id`) ON DELETE CASCADE;
COMMIT;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
+44
View File
@@ -0,0 +1,44 @@
-- laporan_migration.sql
-- Jalankan script ini di phpMyAdmin untuk database "bansos_gis"
-- Fitur: Pelaporan Warga oleh Kontributor & Koordinator
-- Tambah kolom no_telp ke tabel users jika belum ada
ALTER TABLE `users`
ADD COLUMN IF NOT EXISTS `no_telp` VARCHAR(20) NULL DEFAULT NULL
COMMENT 'Nomor telepon/WA pengguna';
-- Buat tabel laporan_warga
CREATE TABLE IF NOT EXISTS `laporan_warga` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`rumah_warga_id` INT(11) NOT NULL COMMENT 'Warga yang dilaporkan',
`pelapor_id` INT(11) NOT NULL COMMENT 'User yang membuat laporan',
`koordinator_ibadah_id` INT(11) NULL DEFAULT NULL COMMENT 'Rumah ibadah pelapor (jika koordinator)',
`jenis_laporan` ENUM(
'data_tidak_sesuai',
'warga_mampu',
'warga_pindah',
'warga_meninggal',
'data_ganda',
'lainnya'
) NOT NULL DEFAULT 'data_tidak_sesuai' COMMENT 'Jenis ketidaksesuaian yang dilaporkan',
`deskripsi` TEXT NOT NULL COMMENT 'Penjelasan detail dari pelapor',
`prioritas` ENUM('rendah', 'sedang', 'tinggi') NOT NULL DEFAULT 'sedang',
`status` ENUM(
'pending',
'ditinjau',
'diproses',
'selesai',
'ditolak'
) NOT NULL DEFAULT 'pending' COMMENT 'Status penanganan laporan',
`catatan_admin` TEXT NULL DEFAULT NULL COMMENT 'Catatan atau keputusan admin',
`ditugaskan_ke` VARCHAR(150) NULL DEFAULT NULL COMMENT 'Nama petugas yang ditugaskan cek lapangan',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP(),
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP() ON UPDATE CURRENT_TIMESTAMP(),
PRIMARY KEY (`id`),
KEY `fk_laporan_warga` (`rumah_warga_id`),
KEY `fk_laporan_pelapor` (`pelapor_id`),
KEY `idx_laporan_status` (`status`),
KEY `idx_laporan_prioritas` (`prioritas`),
CONSTRAINT `fk_laporan_warga` FOREIGN KEY (`rumah_warga_id`) REFERENCES `rumah_warga` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_laporan_pelapor` FOREIGN KEY (`pelapor_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
+79
View File
@@ -0,0 +1,79 @@
-- migrations.sql
-- Run this SQL script in phpMyAdmin or your MySQL client for database "bansos_gis"
-- 1. Membuat tabel 'users' untuk multi-role
CREATE TABLE IF NOT EXISTS `users` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`username` VARCHAR(100) NOT NULL UNIQUE,
`email` VARCHAR(150) NOT NULL UNIQUE,
`password` VARCHAR(255) NOT NULL,
`role` ENUM('admin', 'petugas', 'koordinator', 'kontributor') NOT NULL DEFAULT 'kontributor',
`rumah_ibadah_id` INT(11) NULL DEFAULT NULL COMMENT 'Terhubung ke rumah ibadah tertentu untuk Petugas & Koordinator',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP(),
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP() ON UPDATE CURRENT_TIMESTAMP(),
PRIMARY KEY (`id`),
CONSTRAINT `fk_users_rumah_ibadah` FOREIGN KEY (`rumah_ibadah_id`) REFERENCES `rumah_ibadah` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- 2. Membuat tabel 'kebutuhan' rumah ibadah
CREATE TABLE IF NOT EXISTS `kebutuhan` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`rumah_ibadah_id` INT(11) NOT NULL,
`jenis_bantuan` VARCHAR(150) NOT NULL COMMENT 'Contoh: Beras, Uang Operasional, Jasa Renovasi, dll',
`kategori` ENUM('uang', 'barang', 'jasa') NOT NULL DEFAULT 'barang',
`satuan` VARCHAR(32) NULL DEFAULT 'unit' COMMENT 'satuan untuk jumlah_dibutuhkan (orang/jam/shift/unit/nominal)',
`jumlah_dibutuhkan` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP(),
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP() ON UPDATE CURRENT_TIMESTAMP(),
PRIMARY KEY (`id`),
CONSTRAINT `fk_kebutuhan_rumah_ibadah` FOREIGN KEY (`rumah_ibadah_id`) REFERENCES `rumah_ibadah` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- 3. Membuat tabel 'kontribusi' oleh kontributor
CREATE TABLE IF NOT EXISTS `kontribusi` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`user_id` INT(11) NOT NULL,
`kebutuhan_id` INT(11) NOT NULL,
`jenis_kontribusi` ENUM('uang', 'barang', 'jasa') NOT NULL,
`jumlah` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
`detail_barang_jasa` TEXT NULL COMMENT 'Deskripsi detail barang/jasa yang disumbangkan',
`status` ENUM('pending', 'confirmed') NOT NULL DEFAULT 'pending',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP(),
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP() ON UPDATE CURRENT_TIMESTAMP(),
PRIMARY KEY (`id`),
CONSTRAINT `fk_kontribusi_users` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_kontribusi_kebutuhan` FOREIGN KEY (`kebutuhan_id`) REFERENCES `kebutuhan` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- 4. Seed User Awal (Password: password123)
-- Hash bcrypt untuk password123: $2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi
INSERT INTO `users` (`username`, `email`, `password`, `role`, `rumah_ibadah_id`)
VALUES
('admin', 'admin@webgis.com', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'admin', NULL)
ON DUPLICATE KEY UPDATE `username`=`username`;
INSERT INTO `users` (`username`, `email`, `password`, `role`, `rumah_ibadah_id`)
VALUES
('petugas', 'petugas@webgis.com', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'petugas', 1)
ON DUPLICATE KEY UPDATE `username`=`username`;
INSERT INTO `users` (`username`, `email`, `password`, `role`, `rumah_ibadah_id`)
VALUES
('koordinator', 'koordinator@webgis.com', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'koordinator', 1)
ON DUPLICATE KEY UPDATE `username`=`username`;
INSERT INTO `users` (`username`, `email`, `password`, `role`, `rumah_ibadah_id`)
VALUES
('kontributor', 'kontributor@webgis.com', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'kontributor', NULL)
ON DUPLICATE KEY UPDATE `username`=`username`;
-- 5. Seed Kebutuhan Awal untuk Demonstrasi
INSERT INTO `kebutuhan` (`id`, `rumah_ibadah_id`, `jenis_bantuan`, `kategori`, `jumlah_dibutuhkan`)
VALUES
(1, 1, 'Beras Sembako (KK)', 'barang', 10.00),
(2, 1, 'Dana Operasional Santunan', 'uang', 5000000.00),
(3, 2, 'Relawan Pembagi Paket Beras', 'jasa', 5.00)
ON DUPLICATE KEY UPDATE `id`=`id`;
-- Backfill/ensure `satuan` exists for existing installations
ALTER TABLE `kebutuhan` ADD COLUMN IF NOT EXISTS `satuan` VARCHAR(32) NULL DEFAULT 'unit' COMMENT 'satuan untuk jumlah_dibutuhkan (orang/jam/shift/unit/nominal)';
+1226
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB