Initial commit
This commit is contained in:
+149
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
// core/Auth.php
|
||||
|
||||
class Auth {
|
||||
private static ?array $user = null;
|
||||
|
||||
public static function init(): void {
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_name(SESSION_NAME);
|
||||
session_set_cookie_params([
|
||||
'lifetime' => SESSION_LIFETIME,
|
||||
'path' => '/',
|
||||
'secure' => false,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax'
|
||||
]);
|
||||
session_start();
|
||||
}
|
||||
}
|
||||
|
||||
public static function attempt(string $username, string $password): bool {
|
||||
$db = Database::getInstance();
|
||||
$user = $db->fetchOne(
|
||||
"SELECT u.*, r.slug as role_slug, r.nama as role_nama
|
||||
FROM users u
|
||||
JOIN roles r ON u.id_role = r.id
|
||||
WHERE (u.username = ? OR u.email = ?) AND u.is_active = 1",
|
||||
[$username, $username]
|
||||
);
|
||||
|
||||
if ($user && password_verify($password, $user['password'])) {
|
||||
// Update last login
|
||||
$db->update('users', ['last_login' => date('Y-m-d H:i:s')], 'id = ?', [$user['id']]);
|
||||
|
||||
// Store in session
|
||||
$_SESSION['user_id'] = $user['id'];
|
||||
$_SESSION['user_name'] = $user['nama'];
|
||||
$_SESSION['user_role'] = $user['role_slug'];
|
||||
$_SESSION['user_email'] = $user['email'];
|
||||
$_SESSION['id_kelurahan'] = $user['id_kelurahan'];
|
||||
$_SESSION['login_time'] = time();
|
||||
|
||||
// Log activity
|
||||
self::logActivity($user['id'], $user['nama'], $user['role_slug'], 'Login', 'auth', 'Login ke sistem');
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function check(): bool {
|
||||
return isset($_SESSION['user_id']) && !empty($_SESSION['user_id']);
|
||||
}
|
||||
|
||||
public static function user(): ?array {
|
||||
if (!self::check()) return null;
|
||||
if (self::$user !== null) return self::$user;
|
||||
|
||||
$db = Database::getInstance();
|
||||
self::$user = $db->fetchOne(
|
||||
"SELECT u.*, r.slug as role_slug, r.nama as role_nama,
|
||||
k.nama as kelurahan_nama, kec.nama as kecamatan_nama,
|
||||
ri.id as rumah_ibadah_id, ri.nama as rumah_ibadah_nama,
|
||||
ri.lat as ri_lat, ri.lng as ri_lng, ri.radius_meter as ri_radius
|
||||
FROM users u
|
||||
JOIN roles r ON u.id_role = r.id
|
||||
LEFT JOIN kelurahan k ON u.id_kelurahan = k.id
|
||||
LEFT JOIN kecamatan kec ON k.id_kecamatan = kec.id
|
||||
LEFT JOIN rumah_ibadah ri ON ri.id_user = u.id
|
||||
WHERE u.id = ?",
|
||||
[$_SESSION['user_id']]
|
||||
);
|
||||
return self::$user;
|
||||
}
|
||||
|
||||
public static function id(): ?int {
|
||||
return self::check() ? (int)$_SESSION['user_id'] : null;
|
||||
}
|
||||
|
||||
public static function role(): ?string {
|
||||
return $_SESSION['user_role'] ?? null;
|
||||
}
|
||||
|
||||
public static function is(string ...$roles): bool {
|
||||
return in_array(self::role(), $roles);
|
||||
}
|
||||
|
||||
public static function logout(): void {
|
||||
if (self::check()) {
|
||||
self::logActivity(
|
||||
$_SESSION['user_id'],
|
||||
$_SESSION['user_name'],
|
||||
$_SESSION['user_role'],
|
||||
'Logout', 'auth', 'Logout dari sistem'
|
||||
);
|
||||
}
|
||||
$_SESSION = [];
|
||||
session_destroy();
|
||||
self::$user = null;
|
||||
}
|
||||
|
||||
public static function require(string ...$roles): void {
|
||||
self::init();
|
||||
if (!self::check()) {
|
||||
header('Location: ' . APP_URL . '/login');
|
||||
exit;
|
||||
}
|
||||
if (!empty($roles) && !self::is(...$roles)) {
|
||||
header('Location: ' . APP_URL . '/forbidden');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
public static function redirectIfLoggedIn(): void {
|
||||
if (self::check()) {
|
||||
header('Location: ' . self::getDashboardUrl());
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
public static function getDashboardUrl(): string {
|
||||
return match(self::role()) {
|
||||
'superadmin' => APP_URL . '/admin/dashboard',
|
||||
'verifikator' => APP_URL . '/verifikator/dashboard',
|
||||
'rumah_ibadah' => APP_URL . '/rumah-ibadah/dashboard',
|
||||
'dinsos' => APP_URL . '/dinsos/dashboard',
|
||||
'walikota' => APP_URL . '/walikota/dashboard',
|
||||
default => APP_URL . '/'
|
||||
};
|
||||
}
|
||||
|
||||
public static function logActivity(int $userId, string $namaUser, string $role, string $aksi, string $modul, string $deskripsi = ''): void {
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
$db->insert('aktivitas_sistem', [
|
||||
'id_user' => $userId,
|
||||
'nama_user' => $namaUser,
|
||||
'role' => $role,
|
||||
'aksi' => $aksi,
|
||||
'modul' => $modul,
|
||||
'deskripsi' => $deskripsi,
|
||||
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? '',
|
||||
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? ''
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
// Silent fail for logging
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
// core/Controller.php
|
||||
|
||||
class Controller {
|
||||
protected Database $db;
|
||||
|
||||
public function __construct() {
|
||||
$this->db = Database::getInstance();
|
||||
}
|
||||
|
||||
protected function view(string $view, array $data = [], ?string $layout = 'main'): void {
|
||||
extract($data);
|
||||
$viewFile = APP_ROOT . '/app/views/' . str_replace('.', '/', $view) . '.php';
|
||||
|
||||
if (!file_exists($viewFile)) {
|
||||
die("View not found: $viewFile");
|
||||
}
|
||||
|
||||
if ($layout) {
|
||||
$layoutFile = APP_ROOT . '/app/views/layouts/' . $layout . '.php';
|
||||
if (file_exists($layoutFile)) {
|
||||
// Capture view content
|
||||
ob_start();
|
||||
require $viewFile;
|
||||
$content = ob_get_clean();
|
||||
require $layoutFile;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
require $viewFile;
|
||||
}
|
||||
|
||||
protected function json(mixed $data, int $code = 200): void {
|
||||
http_response_code($code);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
protected function redirect(string $url): void {
|
||||
// Flush session ke disk sebelum redirect agar data tidak hilang
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
session_write_close();
|
||||
}
|
||||
header("Location: $url");
|
||||
exit;
|
||||
}
|
||||
|
||||
protected function back(): void {
|
||||
$referer = $_SERVER['HTTP_REFERER'] ?? APP_URL;
|
||||
$this->redirect($referer);
|
||||
}
|
||||
|
||||
protected function csrf(): string {
|
||||
if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
return $_SESSION['csrf_token'];
|
||||
}
|
||||
|
||||
protected function verifyCsrf(): bool {
|
||||
// Pastikan session aktif sebelum membaca token
|
||||
Auth::init();
|
||||
$token = $_POST['_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
return hash_equals($_SESSION['csrf_token'] ?? '', $token);
|
||||
}
|
||||
|
||||
protected function input(string $key, mixed $default = null): mixed {
|
||||
return $_POST[$key] ?? $_GET[$key] ?? $default;
|
||||
}
|
||||
|
||||
protected function sanitize(string $value): string {
|
||||
return htmlspecialchars(trim($value), ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
protected function flash(string $type, string $message, array $extra = []): void {
|
||||
$_SESSION['flash'] = ['type' => $type, 'message' => $message] + $extra;
|
||||
}
|
||||
|
||||
protected function getFlash(): ?array {
|
||||
if (isset($_SESSION['flash'])) {
|
||||
$flash = $_SESSION['flash'];
|
||||
unset($_SESSION['flash']);
|
||||
return $flash;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function uploadFile(array $file, string $dir = 'uploads'): ?string {
|
||||
if ($file['error'] !== UPLOAD_ERR_OK) return null;
|
||||
|
||||
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
||||
$allow = ['jpg', 'jpeg', 'png', 'webp'];
|
||||
if (!in_array($ext, $allow)) return null;
|
||||
|
||||
$maxSize = 5 * 1024 * 1024; // 5MB
|
||||
if ($file['size'] > $maxSize) return null;
|
||||
|
||||
$filename = uniqid('img_', true) . '.' . $ext;
|
||||
$destDir = APP_ROOT . '/public/images/' . $dir . '/';
|
||||
if (!is_dir($destDir)) mkdir($destDir, 0755, true);
|
||||
|
||||
if (move_uploaded_file($file['tmp_name'], $destDir . $filename)) {
|
||||
return $filename;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function paginate(int $total, int $perPage = 20): array {
|
||||
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||
$offset = ($page - 1) * $perPage;
|
||||
$pages = (int)ceil($total / $perPage);
|
||||
return compact('page', 'offset', 'perPage', 'pages', 'total');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
// core/Database.php
|
||||
|
||||
class Database {
|
||||
private static ?Database $instance = null;
|
||||
private PDO $pdo;
|
||||
|
||||
private function __construct() {
|
||||
$dsn = 'mysql:host=' . DB_HOST . ';dbname=' . DB_NAME . ';charset=' . DB_CHARSET;
|
||||
$options = [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4"
|
||||
];
|
||||
try {
|
||||
$this->pdo = new PDO($dsn, DB_USER, DB_PASS, $options);
|
||||
} catch (PDOException $e) {
|
||||
die(json_encode(['error' => 'Database connection failed: ' . $e->getMessage()]));
|
||||
}
|
||||
}
|
||||
|
||||
public static function getInstance(): Database {
|
||||
if (self::$instance === null) {
|
||||
self::$instance = new Database();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function getConnection(): PDO {
|
||||
return $this->pdo;
|
||||
}
|
||||
|
||||
// Shorthand query
|
||||
public function query(string $sql, array $params = []): PDOStatement {
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
return $stmt;
|
||||
}
|
||||
|
||||
public function fetchAll(string $sql, array $params = []): array {
|
||||
return $this->query($sql, $params)->fetchAll();
|
||||
}
|
||||
|
||||
public function fetchOne(string $sql, array $params = []): ?array {
|
||||
$row = $this->query($sql, $params)->fetch();
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
public function insert(string $table, array $data): int {
|
||||
$cols = implode(', ', array_keys($data));
|
||||
$placeholders = implode(', ', array_fill(0, count($data), '?'));
|
||||
$sql = "INSERT INTO `$table` ($cols) VALUES ($placeholders)";
|
||||
$this->query($sql, array_values($data));
|
||||
return (int)$this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
public function update(string $table, array $data, string $where, array $whereParams = []): int {
|
||||
$sets = implode(', ', array_map(fn($k) => "`$k` = ?", array_keys($data)));
|
||||
$sql = "UPDATE `$table` SET $sets WHERE $where";
|
||||
$stmt = $this->query($sql, array_merge(array_values($data), $whereParams));
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
|
||||
public function delete(string $table, string $where, array $params = []): int {
|
||||
$stmt = $this->query("DELETE FROM `$table` WHERE $where", $params);
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
|
||||
public function lastInsertId(): string {
|
||||
return $this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
// Prevent cloning
|
||||
private function __clone() {}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
// core/Router.php
|
||||
|
||||
class Router {
|
||||
private array $routes = [];
|
||||
|
||||
public function get(string $path, callable|array $handler): void {
|
||||
$this->routes['GET'][$path] = $handler;
|
||||
}
|
||||
|
||||
public function post(string $path, callable|array $handler): void {
|
||||
$this->routes['POST'][$path] = $handler;
|
||||
}
|
||||
|
||||
public function dispatch(): void {
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||
|
||||
// Strip base path
|
||||
$basePath = parse_url(APP_URL, PHP_URL_PATH);
|
||||
if ($basePath && str_starts_with($uri, $basePath)) {
|
||||
$uri = substr($uri, strlen($basePath));
|
||||
}
|
||||
$uri = '/' . ltrim($uri, '/');
|
||||
if ($uri !== '/' && str_ends_with($uri, '/')) {
|
||||
$uri = rtrim($uri, '/');
|
||||
}
|
||||
|
||||
$routes = $this->routes[$method] ?? [];
|
||||
|
||||
// Two-pass: static routes (no {param}) matched first, dynamic routes second.
|
||||
// This ensures /verifikator/warga/tambah is never swallowed by /verifikator/warga/{id}.
|
||||
$staticRoutes = array_filter($routes, fn($p) => !str_contains($p, '{'), ARRAY_FILTER_USE_KEY);
|
||||
$dynamicRoutes = array_filter($routes, fn($p) => str_contains($p, '{'), ARRAY_FILTER_USE_KEY);
|
||||
|
||||
foreach ([$staticRoutes, $dynamicRoutes] as $group) {
|
||||
foreach ($group as $pattern => $handler) {
|
||||
$regex = $this->buildRegex($pattern);
|
||||
if (preg_match($regex, $uri, $matches)) {
|
||||
array_shift($matches);
|
||||
$params = array_values($matches);
|
||||
|
||||
if (is_array($handler)) {
|
||||
[$controllerName, $action] = $handler;
|
||||
$controller = new $controllerName();
|
||||
$controller->$action(...$params);
|
||||
} else {
|
||||
$handler(...$params);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 404
|
||||
http_response_code(404);
|
||||
require APP_ROOT . '/app/views/public/404.php';
|
||||
}
|
||||
|
||||
private function buildRegex(string $pattern): string {
|
||||
$pattern = preg_replace('/\{(\w+)\}/', '([^/]+)', $pattern);
|
||||
return '#^' . $pattern . '$#';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
<?php
|
||||
// core/helpers.php
|
||||
|
||||
/**
|
||||
* Haversine Formula - menghitung jarak antara dua koordinat (meter)
|
||||
*/
|
||||
function haversine(float $lat1, float $lng1, float $lat2, float $lng2): float {
|
||||
$R = 6371000; // radius bumi meter
|
||||
$phi1 = deg2rad($lat1);
|
||||
$phi2 = deg2rad($lat2);
|
||||
$dphi = deg2rad($lat2 - $lat1);
|
||||
$dlam = deg2rad($lng2 - $lng1);
|
||||
|
||||
$a = sin($dphi / 2) ** 2 + cos($phi1) * cos($phi2) * sin($dlam / 2) ** 2;
|
||||
$c = 2 * atan2(sqrt($a), sqrt(1 - $a));
|
||||
|
||||
return $R * $c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Algoritma Skor Prioritas Bantuan (0-100)
|
||||
* Semakin tinggi skor = semakin prioritas
|
||||
*/
|
||||
function hitungSkorPrioritas(array $warga): array {
|
||||
$skor = 0;
|
||||
|
||||
// 1. Penghasilan (30 poin)
|
||||
$penghasilan = (int)($warga['penghasilan_bulanan'] ?? 0);
|
||||
if ($penghasilan === 0) $skor += 30;
|
||||
elseif ($penghasilan <= 300000) $skor += 25;
|
||||
elseif ($penghasilan <= 600000) $skor += 18;
|
||||
elseif ($penghasilan <= 1000000) $skor += 10;
|
||||
else $skor += 3;
|
||||
|
||||
// 2. Jumlah tanggungan (20 poin)
|
||||
$tanggungan = (int)($warga['jumlah_tanggungan'] ?? 0);
|
||||
if ($tanggungan >= 6) $skor += 20;
|
||||
elseif ($tanggungan >= 4) $skor += 15;
|
||||
elseif ($tanggungan >= 2) $skor += 10;
|
||||
else $skor += 4;
|
||||
|
||||
// 3. Kondisi rumah (15 poin)
|
||||
$skor += match($warga['kondisi_rumah'] ?? '') {
|
||||
'Sangat Tidak Layak' => 15,
|
||||
'Tidak Layak' => 10,
|
||||
'Cukup Layak' => 5,
|
||||
'Layak' => 1,
|
||||
default => 5
|
||||
};
|
||||
|
||||
// 4. Pendidikan (10 poin)
|
||||
$skor += match($warga['pendidikan_terakhir'] ?? '') {
|
||||
'Tidak Sekolah' => 10,
|
||||
'SD' => 8,
|
||||
'SMP' => 6,
|
||||
'SMA' => 3,
|
||||
default => 1
|
||||
};
|
||||
|
||||
// 5. Riwayat penyakit (10 poin)
|
||||
if (!empty($warga['riwayat_penyakit'])) {
|
||||
$penyakit = strtolower($warga['riwayat_penyakit']);
|
||||
if (str_contains($penyakit, 'kanker') || str_contains($penyakit, 'gagal ginjal') || str_contains($penyakit, 'stroke')) {
|
||||
$skor += 10;
|
||||
} elseif (str_contains($penyakit, 'diabetes') || str_contains($penyakit, 'jantung') || str_contains($penyakit, 'tbc')) {
|
||||
$skor += 8;
|
||||
} else {
|
||||
$skor += 5;
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Lansia (8 poin)
|
||||
if (!empty($warga['is_lansia'])) $skor += 8;
|
||||
|
||||
// 7. Disabilitas (7 poin)
|
||||
if (!empty($warga['is_disabilitas'])) $skor += 7;
|
||||
|
||||
$skor = min(100, $skor);
|
||||
|
||||
$level = match(true) {
|
||||
$skor >= 70 => 'Tinggi',
|
||||
$skor >= 40 => 'Sedang',
|
||||
default => 'Rendah'
|
||||
};
|
||||
|
||||
return ['skor' => $skor, 'level' => $level];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tentukan status kemiskinan berdasarkan penghasilan dan tanggungan
|
||||
*/
|
||||
function tentukanStatusKemiskinan(int $penghasilan, int $tanggungan): string {
|
||||
$kapita = $tanggungan > 0 ? $penghasilan / $tanggungan : $penghasilan;
|
||||
if ($kapita <= 350000) return 'Miskin Ekstrem';
|
||||
if ($kapita <= 550000) return 'Miskin';
|
||||
return 'Rentan Miskin';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate kode laporan: LPR-YYYYMMDD-XXXX
|
||||
*/
|
||||
function generateKodeLaporan(): string {
|
||||
$db = Database::getInstance();
|
||||
$today = date('Ymd');
|
||||
$count = $db->fetchOne("SELECT COUNT(*)+1 as n FROM laporan_masyarakat WHERE DATE(created_at) = CURDATE()")['n'];
|
||||
return 'LPR-' . $today . '-' . str_pad($count, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate kode bantuan: BNT-YYYYMMDD-XXXX
|
||||
*/
|
||||
function generateKodeBantuan(): string {
|
||||
$db = Database::getInstance();
|
||||
$today = date('Ymd');
|
||||
$count = $db->fetchOne("SELECT COUNT(*)+1 as n FROM histori_bantuan WHERE DATE(created_at) = CURDATE()")['n'];
|
||||
return 'BNT-' . $today . '-' . str_pad($count, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate password awal/reset yang mudah diketik tetapi tetap acak.
|
||||
*/
|
||||
function generatePasswordAkun(): string {
|
||||
return 'Sinergi@' . random_int(100000, 999999);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kirim kredensial akun via email jika mail server tersedia.
|
||||
* Mengembalikan false di instalasi lokal yang belum punya konfigurasi SMTP/sendmail.
|
||||
*/
|
||||
function kirimEmailAkun(string $email, string $nama, string $username, string $password, string $aksi = 'dibuat'): bool {
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$subject = 'Informasi Akun SINERGI';
|
||||
$message = "Yth. {$nama},\n\n"
|
||||
. "Akun SINERGI Anda telah {$aksi}.\n\n"
|
||||
. "URL Login: " . APP_URL . "/login\n"
|
||||
. "Username: {$username}\n"
|
||||
. "Password: {$password}\n\n"
|
||||
. "Silakan masuk ke sistem dan simpan kredensial ini dengan aman.\n\n"
|
||||
. "Salam,\nAdmin SINERGI";
|
||||
|
||||
$headers = [
|
||||
'From: SINERGI <noreply@sinergi.local>',
|
||||
'Content-Type: text/plain; charset=UTF-8'
|
||||
];
|
||||
|
||||
return @mail($email, $subject, $message, implode("\r\n", $headers));
|
||||
}
|
||||
|
||||
/**
|
||||
* Temukan rumah ibadah terdekat berdasarkan Haversine
|
||||
*/
|
||||
function findRumahIbadahTerdekat(float $lat, float $lng): ?array {
|
||||
$db = Database::getInstance();
|
||||
$rumahIbadahs = $db->fetchAll(
|
||||
"SELECT ri.*, k.nama as kelurahan FROM rumah_ibadah ri
|
||||
JOIN kelurahan k ON ri.id_kelurahan = k.id
|
||||
WHERE ri.is_active = 1"
|
||||
);
|
||||
|
||||
$nearest = null;
|
||||
$minDist = PHP_FLOAT_MAX;
|
||||
|
||||
foreach ($rumahIbadahs as $ri) {
|
||||
$dist = haversine($lat, $lng, (float)$ri['lat'], (float)$ri['lng']);
|
||||
if ($dist < $minDist) {
|
||||
$minDist = $dist;
|
||||
$nearest = $ri;
|
||||
$nearest['jarak_meter'] = round($dist);
|
||||
}
|
||||
}
|
||||
return $nearest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cek apakah titik berada dalam radius salah satu rumah ibadah
|
||||
*/
|
||||
function isInBlindSpot(float $lat, float $lng): bool {
|
||||
$db = Database::getInstance();
|
||||
$rumahIbadahs = $db->fetchAll("SELECT lat, lng, radius_meter FROM rumah_ibadah WHERE is_active = 1");
|
||||
foreach ($rumahIbadahs as $ri) {
|
||||
$dist = haversine($lat, $lng, (float)$ri['lat'], (float)$ri['lng']);
|
||||
if ($dist <= (float)$ri['radius_meter']) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format rupiah
|
||||
*/
|
||||
function formatRupiah(int $amount): string {
|
||||
return 'Rp ' . number_format($amount, 0, ',', '.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format tanggal Indonesia
|
||||
*/
|
||||
function formatTanggal(string $date, bool $withTime = false): string {
|
||||
if (empty($date) || $date === '0000-00-00') return '-';
|
||||
$bulan = ['','Januari','Februari','Maret','April','Mei','Juni','Juli','Agustus','September','Oktober','November','Desember'];
|
||||
$d = new DateTime($date);
|
||||
$str = $d->format('j') . ' ' . $bulan[(int)$d->format('n')] . ' ' . $d->format('Y');
|
||||
if ($withTime) $str .= ' ' . $d->format('H:i');
|
||||
return $str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hitung umur
|
||||
*/
|
||||
function hitungUmur(string $tanggalLahir): int {
|
||||
if (empty($tanggalLahir)) return 0;
|
||||
return (new DateTime($tanggalLahir))->diff(new DateTime())->y;
|
||||
}
|
||||
|
||||
/**
|
||||
* Warna marker berdasarkan status kemiskinan
|
||||
*/
|
||||
function warnaMiskin(string $status): string {
|
||||
return match($status) {
|
||||
'Miskin Ekstrem' => '#dc2626',
|
||||
'Miskin' => '#ea580c',
|
||||
'Rentan Miskin' => '#ca8a04',
|
||||
default => '#6b7280'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Badge HTML status
|
||||
*/
|
||||
function statusBadge(string $status): string {
|
||||
$map = [
|
||||
'menunggu' => ['bg-yellow-100 text-yellow-800', 'Menunggu'],
|
||||
'diproses' => ['bg-blue-100 text-blue-800', 'Diproses'],
|
||||
'terverifikasi'=> ['bg-green-100 text-green-800', 'Terverifikasi'],
|
||||
'ditolak' => ['bg-red-100 text-red-800', 'Ditolak'],
|
||||
'aktif' => ['bg-green-100 text-green-800', 'Aktif'],
|
||||
'nonaktif' => ['bg-gray-100 text-gray-600', 'Nonaktif'],
|
||||
'Tinggi' => ['bg-red-100 text-red-800', 'Tinggi'],
|
||||
'Sedang' => ['bg-yellow-100 text-yellow-800', 'Sedang'],
|
||||
'Rendah' => ['bg-green-100 text-green-800', 'Rendah'],
|
||||
'disalurkan' => ['bg-green-100 text-green-800', 'Disalurkan'],
|
||||
'rencana' => ['bg-blue-100 text-blue-800', 'Rencana'],
|
||||
'dibatalkan' => ['bg-red-100 text-red-800', 'Dibatalkan'],
|
||||
];
|
||||
[$cls, $label] = $map[$status] ?? ['bg-gray-100 text-gray-600', $status];
|
||||
return "<span class=\"badge $cls\">$label</span>";
|
||||
}
|
||||
|
||||
/**
|
||||
* CSRF hidden input
|
||||
*/
|
||||
function csrfField(): string {
|
||||
if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
return '<input type="hidden" name="_token" value="' . htmlspecialchars($_SESSION['csrf_token']) . '">';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set flash message ke session.
|
||||
* Gunakan fungsi ini (bukan tulis langsung ke $_SESSION['flash']) agar
|
||||
* kode_laporan / kode_bantuan disimpan sebagai key terpisah — bukan
|
||||
* ditempel ke dalam string pesan — sehingga tidak ikut terhapus saat
|
||||
* flashMessage() dipanggil.
|
||||
*
|
||||
* Contoh penggunaan:
|
||||
* setFlash('success', 'Laporan berhasil disimpan.', ['kode_laporan' => $kode]);
|
||||
* setFlash('error', 'Terjadi kesalahan.');
|
||||
*/
|
||||
function setFlash(string $type, string $message, array $extra = []): void {
|
||||
$_SESSION['flash'] = array_merge(['type' => $type, 'message' => $message], $extra);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flash message display.
|
||||
*
|
||||
* FIX Bug 1 — unset dilakukan SETELAH semua data flash dibaca, bukan di
|
||||
* tengah-tengah, sehingga kode_laporan/kode_bantuan yang
|
||||
* mungkin dipakai lapor.php masih tersedia selama request ini.
|
||||
*
|
||||
* FIX Bug 2 — pesan flash boleh mengandung <strong> dan <br> (supaya kode
|
||||
* laporan bisa ditebalkan), tapi semua tag lain tetap di-escape
|
||||
* agar aman dari XSS.
|
||||
*
|
||||
* Jika flash mengandung 'kode_laporan' atau 'kode_bantuan', nilainya
|
||||
* otomatis disisipkan ke baris kedua pesan dalam tag <strong>.
|
||||
*/
|
||||
function flashMessage(): string {
|
||||
if (empty($_SESSION['flash'])) return '';
|
||||
|
||||
// Baca dulu seluruh data — JANGAN unset sebelum semua field diambil
|
||||
$flash = $_SESSION['flash'];
|
||||
unset($_SESSION['flash']); // hapus sekarang, setelah semua field tersalin ke $flash
|
||||
|
||||
$type = $flash['type'] ?? 'info';
|
||||
$message = $flash['message'] ?? '';
|
||||
|
||||
// Sisipkan kode laporan / bantuan sebagai baris ke-2 jika ada
|
||||
if (!empty($flash['kode_laporan'])) {
|
||||
$kode = htmlspecialchars($flash['kode_laporan'], ENT_QUOTES, 'UTF-8');
|
||||
$message .= '<br>Kode Laporan: <strong>' . $kode . '</strong>';
|
||||
} elseif (!empty($flash['kode_bantuan'])) {
|
||||
$kode = htmlspecialchars($flash['kode_bantuan'], ENT_QUOTES, 'UTF-8');
|
||||
$message .= '<br>Kode Bantuan: <strong>' . $kode . '</strong>';
|
||||
}
|
||||
|
||||
// Escape pesan utama — lalu kembalikan <strong> dan <br> yang sudah kita sisipkan
|
||||
// Alur: escape semua → unescape hanya tag yang kita izinkan
|
||||
$safe = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
|
||||
$safe = str_replace(
|
||||
['<strong>', '</strong>', '<br>'],
|
||||
['<strong>', '</strong>', '<br>'],
|
||||
$safe
|
||||
);
|
||||
|
||||
$icon = match($type) {
|
||||
'success' => '✓',
|
||||
'error' => '✕',
|
||||
'warning' => '⚠',
|
||||
default => 'ℹ',
|
||||
};
|
||||
$cls = match($type) {
|
||||
'success' => 'flash-success',
|
||||
'error' => 'flash-error',
|
||||
'warning' => 'flash-warning',
|
||||
default => 'flash-info',
|
||||
};
|
||||
|
||||
return "<div class=\"flash-message $cls\"><span>$icon</span><span>$safe</span></div>";
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML
|
||||
*/
|
||||
function e(string $str): string {
|
||||
return htmlspecialchars($str, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset URL
|
||||
*/
|
||||
function asset(string $path): string {
|
||||
return APP_URL . '/public/' . ltrim($path, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload URL
|
||||
*/
|
||||
function uploadUrl(string $filename): string {
|
||||
if (empty($filename)) return asset('images/placeholder.png');
|
||||
return APP_URL . '/public/images/uploads/' . $filename;
|
||||
}
|
||||
Reference in New Issue
Block a user