357 lines
12 KiB
PHP
357 lines
12 KiB
PHP
<?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;
|
||
}
|