diff --git a/admin/auth_action.php b/admin/auth_action.php new file mode 100644 index 0000000..ab1f257 --- /dev/null +++ b/admin/auth_action.php @@ -0,0 +1,36 @@ +prepare("SELECT * FROM users WHERE email = ? LIMIT 1"); +$stmt->execute([$email]); +$user = $stmt->fetch(); + +if (!$user || !password_verify($password, $user['password'])) { + // Tambah sedikit delay untuk mencegah brute force + sleep(1); + header('Location: ' . BASE_URL . '/admin/login.php?error=invalid&email=' . urlencode($email)); + exit; +} + +loginUser($user); +header('Location: ' . BASE_URL . '/admin/'); +exit; diff --git a/admin/export.php b/admin/export.php new file mode 100644 index 0000000..c0a53c9 --- /dev/null +++ b/admin/export.php @@ -0,0 +1,194 @@ +query("SELECT DISTINCT kecamatan FROM penduduk_miskin WHERE kecamatan IS NOT NULL AND kecamatan != '' ORDER BY kecamatan")->fetchAll(PDO::FETCH_COLUMN); +$ibadahList = $db->query("SELECT id, nama, jenis FROM rumah_ibadah ORDER BY nama")->fetchAll(); +?> + + + + + +
+ + +
+
+
👤 Export Penduduk Miskin
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
🏛️ Export Rumah Ibadah
+
+
+
+ + +
+
+ + +
+
+
+ Export akan mencakup: Nama, Jenis, Kontak, Alamat, Koordinat, Radius, Jumlah Binaan +
+
+
+ + +
+
+
+
+ + +
+
+
📊 Ringkasan Data
+
+
+
+ $db->query("SELECT COUNT(*) FROM penduduk_miskin")->fetchColumn(), + 'Sudah Dibantu' => $db->query("SELECT COUNT(*) FROM penduduk_miskin WHERE status_bantuan='sudah'")->fetchColumn(), + 'Belum Dibantu' => $db->query("SELECT COUNT(*) FROM penduduk_miskin WHERE status_bantuan='belum'")->fetchColumn(), + 'Menunggu' => $db->query("SELECT COUNT(*) FROM penduduk_miskin WHERE status_bantuan='menunggu'")->fetchColumn(), + 'Total Jiwa' => $db->query("SELECT COALESCE(SUM(jumlah_anggota),0) FROM penduduk_miskin")->fetchColumn(), + 'Rumah Ibadah' => $db->query("SELECT COUNT(*) FROM rumah_ibadah")->fetchColumn(), + ]; + $colors = ['#5b7fff','#22c55e','#ef4444','#f59e0b','#a78bfa','#34d399']; + $i = 0; + foreach ($counts as $label => $val): + $c = $colors[$i++]; + ?> +
+
+
+
+ +
+
+
+ + + + diff --git a/admin/import.php b/admin/import.php new file mode 100644 index 0000000..0bebecf --- /dev/null +++ b/admin/import.php @@ -0,0 +1,364 @@ +query("SELECT id, nama, jenis FROM rumah_ibadah ORDER BY nama")->fetchAll(); +?> + + + + + +
+
+
👤
+
Penduduk Miskin
+
Import data KK
+
+
+
🏛️
+
Rumah Ibadah
+
Import data ibadah
+
+
+ + +
+
+
📤 Pilih Jenis Import
+
+
+ + +
+ + +
+ + +
+
+
+
+ Format CSV Penduduk Miskin +
+
+ Kolom (urutan wajib): kk_nama, nik, jumlah_anggota, alamat, kelurahan, kecamatan, lat, lng, status_bantuan, jenis_bantuan, tanggal_bantuan, nominal_bantuan +
+
+ Contoh baris:
+ + Ahmad Sulaiman,6171012345678901,4,Jl. Veteran No. 12,Akcaya,Pontianak Selatan,-0.0430,109.3340,belum, + +
+
+ ⚠️ status_bantuan: belum / sudah / menunggu  |  Baris pertama bisa berupa header (akan dideteksi otomatis) +
+ + Download Template CSV + +
+
+
+ + + + +
+
+ +
+
📂
+
Drag & Drop file CSV di sini
+
atau klik untuk browse file
+ + +
+ +
+ +
+ + +
+
+
+
+ + + + + + + + + + diff --git a/admin/index.php b/admin/index.php new file mode 100644 index 0000000..01ff5ae --- /dev/null +++ b/admin/index.php @@ -0,0 +1,288 @@ +'; +require_once __DIR__ . '/../includes/header.php'; + +$db = getDB(); + +// Statistik utama +$stats = [ + 'ibadah' => (int)$db->query("SELECT COUNT(*) FROM rumah_ibadah")->fetchColumn(), + 'miskin' => (int)$db->query("SELECT COUNT(*) FROM penduduk_miskin")->fetchColumn(), + 'sudah' => (int)$db->query("SELECT COUNT(*) FROM penduduk_miskin WHERE status_bantuan='sudah'")->fetchColumn(), + 'belum' => (int)$db->query("SELECT COUNT(*) FROM penduduk_miskin WHERE status_bantuan='belum'")->fetchColumn(), + 'menunggu' => (int)$db->query("SELECT COUNT(*) FROM penduduk_miskin WHERE status_bantuan='menunggu'")->fetchColumn(), + 'anggota' => (int)$db->query("SELECT SUM(jumlah_anggota) FROM penduduk_miskin")->fetchColumn(), + 'nominal' => (float)$db->query("SELECT COALESCE(SUM(nominal_bantuan),0) FROM penduduk_miskin WHERE status_bantuan='sudah' AND nominal_bantuan IS NOT NULL")->fetchColumn(), +]; + +// Per jenis ibadah +$jenisStats = $db->query("SELECT jenis, COUNT(*) AS total FROM rumah_ibadah GROUP BY jenis ORDER BY total DESC")->fetchAll(); + +// Per kecamatan (top 7) +$kecamatanStats = $db->query(" + SELECT kecamatan, COUNT(*) AS total, + SUM(CASE WHEN status_bantuan='sudah' THEN 1 ELSE 0 END) AS sudah, + SUM(CASE WHEN status_bantuan='belum' THEN 1 ELSE 0 END) AS belum + FROM penduduk_miskin WHERE kecamatan IS NOT NULL AND kecamatan != '' + GROUP BY kecamatan ORDER BY total DESC LIMIT 7 +")->fetchAll(); + +// Data terbaru 6 baris +$recentMiskin = $db->query(" + SELECT pm.id, pm.kk_nama, pm.jumlah_anggota, pm.status_bantuan, + pm.kecamatan, pm.created_at, ri.nama AS ibadah_nama + FROM penduduk_miskin pm + LEFT JOIN rumah_ibadah ri ON pm.rumah_ibadah_id = ri.id + ORDER BY pm.created_at DESC LIMIT 6 +")->fetchAll(); + +// Tren bulanan (6 bulan terakhir) +$trenBulan = $db->query(" + SELECT DATE_FORMAT(created_at,'%Y-%m') AS bulan, + COUNT(*) AS total, + SUM(CASE WHEN status_bantuan='sudah' THEN 1 ELSE 0 END) AS sudah + FROM penduduk_miskin + WHERE created_at >= DATE_SUB(NOW(), INTERVAL 6 MONTH) + GROUP BY bulan ORDER BY bulan ASC +")->fetchAll(); +?> + + + + + +
+
+
🏛️
+
+
Rumah Ibadah
+
+
+
👥
+
+
KK Terdaftar
+
+
+
+
+
Sudah Dibantu
+
+
+
+
+
Belum Dibantu
+
+
+
+
+
Menunggu Verif.
+
+
+
👤
+
+
Total Jiwa
+
+
+
💰
+
+
Rp
+
Total Bantuan Disalurkan
+
+
+
+ + +
+ + +
+
+
📊 Status Bantuan
+
+
+
+ +
+
+ ● Sudah: + ● Belum: + ● Menunggu: +
+
+
+ + +
+
+
🕌 Jenis Ibadah
+
+
+
+ +
+
+
+ + +
+
+
📈 Tren 6 Bulan
+
+
+
+ +
+
+
+ +
+ + +
+ + +
+
+
📍 Top Kecamatan
+
+
+
+ +
+
+
+ + +
+
+
🕐 Data Terbaru
+ Lihat Semua +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
Nama KKKecamatanStatusIbadahTanggal
Belum ada data
+
+
+
+ + + + + diff --git a/admin/login.php b/admin/login.php new file mode 100644 index 0000000..d30cfa2 --- /dev/null +++ b/admin/login.php @@ -0,0 +1,91 @@ + 'Email dan password tidak boleh kosong.', + 'invalid' => 'Email atau password salah.', + default => '' +}; +?> + + + + + + Login — <?= APP_NAME ?> + + + + + + + +
+
+ + + + + + +
+ +
+ + + +
+
+ + +
+
+ + +
+ +
+ + + +
+
+ + + + diff --git a/admin/logout.php b/admin/logout.php new file mode 100644 index 0000000..2e398c8 --- /dev/null +++ b/admin/logout.php @@ -0,0 +1,9 @@ +prepare("SELECT COUNT(*) FROM penduduk_miskin pm WHERE $ws"); +$stmtCnt->execute($params); +$total = (int)$stmtCnt->fetchColumn(); +$totalPages = max(1, ceil($total / $perPage)); + +$stmt = $db->prepare(" + SELECT pm.*, ri.nama AS ibadah_nama, ri.jenis AS ibadah_jenis + FROM penduduk_miskin pm + LEFT JOIN rumah_ibadah ri ON pm.rumah_ibadah_id = ri.id + WHERE $ws ORDER BY pm.created_at DESC LIMIT $perPage OFFSET $offset +"); +$stmt->execute($params); +$rows = $stmt->fetchAll(); + +$kecList = $db->query("SELECT DISTINCT kecamatan FROM penduduk_miskin WHERE kecamatan IS NOT NULL AND kecamatan != '' ORDER BY kecamatan")->fetchAll(PDO::FETCH_COLUMN); +$ibadahList = $db->query("SELECT id, nama, jenis FROM rumah_ibadah ORDER BY nama")->fetchAll(); +?> + + + + + +
+
+
+ +
+ +
+
+ +
+ + Reset +
+
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + $row): ?> + + + + + + + + + + + + + + + + + + + +
#Nama KKNIKAnggotaKecamatanStatusRumah IbadahJarakBantuanNominalTerdaftarAksi
+ + + + + + + +
+
👤
Tidak ada data ditemukan +
+
+ + + 1): ?> +
+
Menampilkan dari data
+ +
+ +
+ + + + + + + diff --git a/admin/rumah_ibadah.php b/admin/rumah_ibadah.php new file mode 100644 index 0000000..0923684 --- /dev/null +++ b/admin/rumah_ibadah.php @@ -0,0 +1,328 @@ +prepare("SELECT COUNT(*) FROM rumah_ibadah WHERE $whereStr")->execute($params) ? $db->prepare("SELECT COUNT(*) FROM rumah_ibadah WHERE $whereStr")->execute($params) : 0; + +// Re-execute for count +$stmtCount = $db->prepare("SELECT COUNT(*) FROM rumah_ibadah WHERE $whereStr"); +$stmtCount->execute($params); +$total = (int)$stmtCount->fetchColumn(); +$totalPages = max(1, ceil($total / $perPage)); + +$stmt = $db->prepare(" + SELECT ri.*, + (SELECT COUNT(*) FROM penduduk_miskin pm WHERE pm.rumah_ibadah_id = ri.id) AS jumlah_binaan + FROM rumah_ibadah ri + WHERE $whereStr ORDER BY ri.created_at DESC LIMIT $perPage OFFSET $offset +"); +$stmt->execute($params); +$rows = $stmt->fetchAll(); + +// Kecamatan list for filter +$kecamatanList = $db->query("SELECT DISTINCT kecamatan FROM rumah_ibadah WHERE kecamatan IS NOT NULL AND kecamatan != '' ORDER BY kecamatan")->fetchAll(PDO::FETCH_COLUMN); +?> + + + + + +
+
+
+ +
+ +
+
+ +
+ + Reset +
+
+
+ + +
+
+ + + + + + + + + + + + + + + + + $row): ?> + + + + + + + + + + + + + + + + + +
#NamaJenisKontakKecamatanRadiusBinaanKoordinatDitambahkanAksi
+ + + + + 0): ?> + warga + + 0 + + , + + + + + +
+
🏛️
+ Tidak ada data ditemukan +
+
+ + + 1): ?> +
+
+ Menampilkan dari data +
+ +
+ +
+ + + + + + + diff --git a/admin/users.php b/admin/users.php new file mode 100644 index 0000000..a8ffa19 --- /dev/null +++ b/admin/users.php @@ -0,0 +1,311 @@ +query("SELECT id, nama, email, role, created_at FROM users ORDER BY role DESC, nama ASC")->fetchAll(); +?> + + + + + +
+
+ + + + + + + + + + + + + $u): ?> + + + + + + + + + + +
#NamaEmailRoleTerdaftarAksi
+
+
+ +
+
+
+ +
Anda
+ +
+
+
+ + + + + + + + + + + + +
+
+
+ + +
+
+
+
+ + Admin: Akses penuh ke semua fitur termasuk manajemen user, import/export +
+
+ + Operator: Akses CRUD rumah ibadah & penduduk miskin +
+
+
+
+ + + + + + + + + + diff --git a/api/anggota_keluarga.php b/api/anggota_keluarga.php new file mode 100644 index 0000000..61d2843 --- /dev/null +++ b/api/anggota_keluarga.php @@ -0,0 +1,47 @@ + false, 'message' => 'penduduk_id wajib'], 400); + $stmt = $db->prepare("SELECT * FROM anggota_keluarga WHERE penduduk_id = ? ORDER BY id"); + $stmt->execute([$pid]); + jsonResponse(['success' => true, 'data' => $stmt->fetchAll()]); +} + +if ($method === 'POST') { + $body = json_decode(file_get_contents('php://input'), true) ?? []; + if (empty($body['penduduk_id']) || empty($body['nama'])) { + jsonResponse(['success' => false, 'message' => 'penduduk_id dan nama wajib'], 422); + } + $stmt = $db->prepare("INSERT INTO anggota_keluarga (penduduk_id,nama,hubungan,umur,pekerjaan,keterangan) VALUES (?,?,?,?,?,?)"); + $stmt->execute([ + (int)$body['penduduk_id'], + sanitize($body['nama'] ?? ''), + sanitize($body['hubungan'] ?? ''), + !empty($body['umur']) ? (int)$body['umur'] : null, + sanitize($body['pekerjaan'] ?? ''), + sanitize($body['keterangan'] ?? ''), + ]); + jsonResponse(['success' => true, 'id' => $db->lastInsertId()], 201); +} + +if ($method === 'DELETE') { + if (!$id) jsonResponse(['success' => false, 'message' => 'id wajib'], 400); + $stmt = $db->prepare("DELETE FROM anggota_keluarga WHERE id = ?"); + $stmt->execute([$id]); + jsonResponse(['success' => true]); +} + +jsonResponse(['success' => false, 'message' => 'Method tidak didukung'], 405); diff --git a/api/export.php b/api/export.php new file mode 100644 index 0000000..f9d30cf --- /dev/null +++ b/api/export.php @@ -0,0 +1,211 @@ + false, 'message' => 'Unauthorized']); + exit; +} + +$db = getDB(); +$type = $_GET['type'] ?? 'penduduk'; // penduduk | ibadah +$format = $_GET['format'] ?? 'csv'; // csv | print + +// ── Query Penduduk Miskin ────────────────────────────────────── +if ($type === 'penduduk') { + $where = ['1=1']; + $params = []; + + if (!empty($_GET['status'])) { $where[] = 'pm.status_bantuan = ?'; $params[] = $_GET['status']; } + if (!empty($_GET['kecamatan'])) { $where[] = 'pm.kecamatan = ?'; $params[] = $_GET['kecamatan']; } + if (!empty($_GET['ibadah_id'])) { $where[] = 'pm.rumah_ibadah_id = ?'; $params[] = (int)$_GET['ibadah_id']; } + if (!empty($_GET['dari'])) { $where[] = 'DATE(pm.created_at) >= ?'; $params[] = $_GET['dari']; } + if (!empty($_GET['sampai'])) { $where[] = 'DATE(pm.created_at) <= ?'; $params[] = $_GET['sampai']; } + + $ws = implode(' AND ', $where); + $stmt = $db->prepare(" + SELECT pm.id, pm.kk_nama, pm.nik, pm.jumlah_anggota, pm.alamat, + pm.kelurahan, pm.kecamatan, pm.lat, pm.lng, + pm.status_bantuan, pm.jenis_bantuan, pm.tanggal_bantuan, pm.nominal_bantuan, pm.jarak_ke_ibadah, + pm.created_at, ri.nama AS ibadah_nama, ri.jenis AS ibadah_jenis + FROM penduduk_miskin pm + LEFT JOIN rumah_ibadah ri ON pm.rumah_ibadah_id = ri.id + WHERE $ws ORDER BY pm.kecamatan, pm.kk_nama + "); + $stmt->execute($params); + $rows = $stmt->fetchAll(); + + if ($format === 'csv') { + header('Content-Type: text/csv; charset=utf-8'); + header('Content-Disposition: attachment; filename="penduduk_miskin_' . date('Ymd_His') . '.csv"'); + $out = fopen('php://output', 'w'); + // BOM for Excel UTF-8 + fputs($out, "\xEF\xBB\xBF"); + fputcsv($out, ['No','Nama KK','NIK','Jumlah Anggota','Alamat','Kelurahan','Kecamatan', + 'Latitude','Longitude','Status Bantuan','Jenis Bantuan','Tanggal Bantuan','Nominal Bantuan', + 'Jarak ke Ibadah (m)','Rumah Ibadah','Jenis Ibadah','Terdaftar']); + foreach ($rows as $i => $row) { + fputcsv($out, [ + $i + 1, + $row['kk_nama'], + $row['nik'] ?: '', + $row['jumlah_anggota'], + $row['alamat'] ?: '', + $row['kelurahan'] ?: '', + $row['kecamatan'] ?: '', + $row['lat'], + $row['lng'], + $row['status_bantuan'], + $row['jenis_bantuan'] ?: '', + $row['tanggal_bantuan'] ?: '', + $row['nominal_bantuan'] ? $row['nominal_bantuan'] : '', + $row['jarak_ke_ibadah'] ? round($row['jarak_ke_ibadah']) : '', + $row['ibadah_nama'] ?: '', + $row['ibadah_jenis'] ?: '', + $row['created_at'], + ]); + } + fclose($out); + exit; + } + + // Print / PDF view + $title = 'Data Penduduk Miskin'; + $subtitle = 'WebGIS Sebaran Penduduk Miskin — ' . APP_NAME; + $cols = ['No', 'Nama KK', 'NIK', 'Anggota', 'Kecamatan', 'Status', 'Nominal Bantuan', 'Rumah Ibadah', 'Jarak']; + $tableRows = array_map(function($r, $i) { + $sc = ['sudah' => '#22c55e', 'menunggu' => '#f59e0b', 'belum' => '#ef4444'][$r['status_bantuan']] ?? '#ef4444'; + return [$i + 1, $r['kk_nama'], $r['nik'] ?: '—', $r['jumlah_anggota'], + $r['kecamatan'] ?: '—', + "".ucfirst($r['status_bantuan']).'', + $r['nominal_bantuan'] ? 'Rp '.number_format($r['nominal_bantuan'],0,',','.') : '—', + $r['ibadah_nama'] ?: '—', + $r['jarak_ke_ibadah'] ? round($r['jarak_ke_ibadah']).' m' : '—']; + }, $rows, array_keys($rows)); +} + +// ── Query Rumah Ibadah ──────────────────────────────────────── +if ($type === 'ibadah') { + $where = ['1=1']; + $params = []; + if (!empty($_GET['jenis'])) { $where[] = 'jenis = ?'; $params[] = $_GET['jenis']; } + if (!empty($_GET['kecamatan'])) { $where[] = 'kecamatan = ?'; $params[] = $_GET['kecamatan']; } + + $ws = implode(' AND ', $where); + $stmt = $db->prepare(" + SELECT ri.*, + (SELECT COUNT(*) FROM penduduk_miskin pm WHERE pm.rumah_ibadah_id = ri.id) AS jumlah_binaan + FROM rumah_ibadah ri WHERE $ws ORDER BY jenis, nama + "); + $stmt->execute($params); + $rows = $stmt->fetchAll(); + + if ($format === 'csv') { + header('Content-Type: text/csv; charset=utf-8'); + header('Content-Disposition: attachment; filename="rumah_ibadah_' . date('Ymd_His') . '.csv"'); + $out = fopen('php://output', 'w'); + fputs($out, "\xEF\xBB\xBF"); + fputcsv($out, ['No','Nama','Jenis','Kontak','Alamat','Kelurahan','Kecamatan', + 'Latitude','Longitude','Radius (m)','Jumlah Binaan','Terdaftar']); + foreach ($rows as $i => $row) { + fputcsv($out, [ + $i + 1, $row['nama'], $row['jenis'], $row['kontak'] ?: '', + $row['alamat'] ?: '', $row['kelurahan'] ?: '', $row['kecamatan'] ?: '', + $row['lat'], $row['lng'], $row['radius'], $row['jumlah_binaan'], $row['created_at'] + ]); + } + fclose($out); + exit; + } + + $title = 'Data Rumah Ibadah'; + $subtitle = 'WebGIS Sebaran Penduduk Miskin — ' . APP_NAME; + $cols = ['No', 'Nama', 'Jenis', 'Kontak', 'Kecamatan', 'Radius', 'Binaan']; + $tableRows = array_map(function($r, $i) { + return [$i + 1, $r['nama'], $r['jenis'], $r['kontak'] ?: '—', + $r['kecamatan'] ?: '—', formatRadius($r['radius']), $r['jumlah_binaan']]; + }, $rows, array_keys($rows)); +} + +// ── HTML Print View ─────────────────────────────────────────── +$total = count($rows); +?> + + + + + + <?= $title ?> + + + + +
+ + +
+ + + + + + $c"; ?> + + + + $td"; ?> + + + + + +
Tidak ada data
+ + + + + diff --git a/api/geocode.php b/api/geocode.php new file mode 100644 index 0000000..546fa88 --- /dev/null +++ b/api/geocode.php @@ -0,0 +1,44 @@ + 'lat dan lng wajib']); + exit; +} + +$url = "https://nominatim.openstreetmap.org/reverse?format=json&lat={$lat}&lon={$lng}&addressdetails=1&accept-language=id"; + +$opts = ['http' => ['header' => "User-Agent: WebGIS-PovertyMap/2.0 (https://github.com)\r\n", 'timeout' => 10]]; +$context = stream_context_create($opts); +$result = @file_get_contents($url, false, $context); + +if ($result === false) { + echo json_encode(['display_name' => "{$lat}, {$lng}", 'fallback' => true]); + exit; +} + +$data = json_decode($result, true); +$addr = $data['display_name'] ?? "{$lat}, {$lng}"; + +// Ekstrak komponen alamat +$components = $data['address'] ?? []; +$kelurahan = $components['suburb'] ?? $components['village'] ?? $components['neighbourhood'] ?? ''; +$kecamatan = $components['city_district'] ?? $components['district'] ?? ''; +$kota = $components['city'] ?? $components['town'] ?? $components['county'] ?? ''; + +echo json_encode([ + 'display_name' => $addr, + 'kelurahan' => $kelurahan, + 'kecamatan' => $kecamatan, + 'kota' => $kota, + 'lat' => $lat, + 'lng' => $lng, +]); diff --git a/api/import.php b/api/import.php new file mode 100644 index 0000000..484e6f7 --- /dev/null +++ b/api/import.php @@ -0,0 +1,184 @@ + false, 'message' => 'Method tidak didukung']); + exit; +} + +$db = getDB(); +$type = $_POST['type'] ?? ''; // 'penduduk' | 'ibadah' + +if (!in_array($type, ['penduduk', 'ibadah'])) { + echo json_encode(['success' => false, 'message' => 'Tipe import tidak valid']); exit; +} + +if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) { + echo json_encode(['success' => false, 'message' => 'File tidak valid atau tidak ada']); exit; +} + +// Baca isi file +$content = file_get_contents($_FILES['file']['tmp_name']); +if ($content === false) { + echo json_encode(['success' => false, 'message' => 'Gagal membaca file']); exit; +} + +// Normalize line endings & parse CSV +$content = str_replace("\r\n", "\n", $content); +$content = str_replace("\r", "\n", $content); +$lines = array_filter(explode("\n", $content), 'strlen'); +$lines = array_values($lines); + +if (empty($lines)) { + echo json_encode(['success' => false, 'message' => 'File kosong']); exit; +} + +// Parse CSV baris per baris +function parseCSVLine(string $line): array { + $cols = []; + $cur = ''; + $inQ = false; + for ($i = 0; $i < strlen($line); $i++) { + $ch = $line[$i]; + if ($ch === '"') { $inQ = !$inQ; } + elseif ($ch === ',' && !$inQ) { $cols[] = trim($cur, " \t\""); $cur = ''; } + else { $cur .= $ch; } + } + $cols[] = trim($cur, " \t\""); + return $cols; +} + +// Deteksi header row +$firstLine = strtolower($lines[0]); +$hasHeader = str_contains($firstLine, 'nama') || str_contains($firstLine, 'lat') || str_contains($firstLine, 'kk_nama'); +$dataLines = $hasHeader ? array_slice($lines, 1) : $lines; + +$imported = 0; +$failed = 0; +$skipped = 0; +$errors = []; + +// ── Import Penduduk Miskin ───────────────────────────────────── +if ($type === 'penduduk') { + $stmt = $db->prepare(" + INSERT INTO penduduk_miskin + (kk_nama, nik, jumlah_anggota, alamat, kelurahan, kecamatan, lat, lng, + rumah_ibadah_id, jarak_ke_ibadah, status_bantuan, jenis_bantuan, tanggal_bantuan, nominal_bantuan) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + "); + + foreach ($dataLines as $lineNum => $line) { + if (!trim($line)) continue; + $row = parseCSVLine($line); + + // kk_nama (0), nik (1), jumlah_anggota (2), alamat (3), kelurahan (4), + // kecamatan (5), lat (6), lng (7), status_bantuan (8), jenis_bantuan (9), + // tanggal_bantuan (10), nominal_bantuan (11) + if (count($row) < 8 || empty($row[0]) || empty($row[6]) || empty($row[7])) { + $failed++; + $errors[] = "Baris " . ($lineNum + ($hasHeader ? 2 : 1)) . ": kolom tidak lengkap atau koordinat kosong"; + if (count($errors) > 10) { $errors[] = '... dan lebih banyak error'; break; } + continue; + } + + $lat = (float)$row[6]; + $lng = (float)$row[7]; + if ($lat === 0.0 && $lng === 0.0) { $skipped++; continue; } + + $status = in_array($row[8] ?? '', ['sudah','belum','menunggu']) ? $row[8] : 'belum'; + $tanggal_bantuan = !empty($row[10]) ? sanitize($row[10]) : null; + $nominal_bantuan = isset($row[11]) && $row[11] !== '' ? (float)str_replace(['.', ','], ['', '.'], $row[11]) : null; + + $nearest = findNearestIbadah($lat, $lng); + $ibadahId = $nearest ? (int)$nearest['id'] : null; + $jarakIbadah = $nearest ? (float)$nearest['jarak'] : null; + + try { + $stmt->execute([ + sanitize($row[0]), + sanitize($row[1] ?? ''), + max(1, (int)($row[2] ?? 1)), + sanitize($row[3] ?? ''), + sanitize($row[4] ?? ''), + sanitize($row[5] ?? ''), + $lat, $lng, + $ibadahId, + $jarakIbadah, + $status, + sanitize($row[9] ?? ''), + $tanggal_bantuan, + $nominal_bantuan, + ]); + $imported++; + } catch (PDOException $e) { + $failed++; + $errors[] = "Baris " . ($lineNum + ($hasHeader ? 2 : 1)) . ": " . $e->getMessage(); + } + } +} + +// ── Import Rumah Ibadah ─────────────────────────────────────── +if ($type === 'ibadah') { + $validJenis = ['Masjid','Musholla','Gereja','Katolik','Pura','Vihara','Klenteng','Lainnya']; + + $stmt = $db->prepare(" + INSERT INTO rumah_ibadah (nama, jenis, kontak, alamat, kelurahan, kecamatan, lat, lng, radius) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + "); + + foreach ($dataLines as $lineNum => $line) { + if (!trim($line)) continue; + $row = parseCSVLine($line); + + // nama (0), jenis (1), kontak (2), alamat (3), kelurahan (4), + // kecamatan (5), lat (6), lng (7), radius (8) + if (count($row) < 8 || empty($row[0]) || empty($row[6]) || empty($row[7])) { + $failed++; + $errors[] = "Baris " . ($lineNum + ($hasHeader ? 2 : 1)) . ": kolom tidak lengkap"; + if (count($errors) > 10) { $errors[] = '... dan lebih banyak error'; break; } + continue; + } + + $lat = (float)$row[6]; + $lng = (float)$row[7]; + if ($lat === 0.0 && $lng === 0.0) { $skipped++; continue; } + + $jenis = in_array($row[1] ?? '', $validJenis) ? $row[1] : 'Lainnya'; + $radius = max(100, min(5000, (int)($row[8] ?? RADIUS_DEFAULT))); + + try { + $stmt->execute([ + sanitize($row[0]), + $jenis, + sanitize($row[2] ?? ''), + sanitize($row[3] ?? ''), + sanitize($row[4] ?? ''), + sanitize($row[5] ?? ''), + $lat, $lng, $radius, + ]); + $imported++; + } catch (PDOException $e) { + $failed++; + $errors[] = "Baris " . ($lineNum + ($hasHeader ? 2 : 1)) . ": " . $e->getMessage(); + } + } +} + +echo json_encode([ + 'success' => true, + 'imported' => $imported, + 'failed' => $failed, + 'skipped' => $skipped, + 'errors' => array_slice($errors, 0, 15), + 'message' => "Import selesai: $imported berhasil, $failed gagal, $skipped dilewati", +], JSON_UNESCAPED_UNICODE); diff --git a/api/notify.php b/api/notify.php new file mode 100644 index 0000000..9b97b97 --- /dev/null +++ b/api/notify.php @@ -0,0 +1,36 @@ +prepare("SELECT COUNT(*) FROM penduduk_miskin WHERE created_at > ?")->execute([$since]) ? 0 : 0; +$stmtM = $db->prepare("SELECT COUNT(*) FROM penduduk_miskin WHERE created_at > ?"); +$stmtM->execute([$since]); +$newMiskin = (int)$stmtM->fetchColumn(); + +$stmtI = $db->prepare("SELECT COUNT(*) FROM rumah_ibadah WHERE created_at > ?"); +$stmtI->execute([$since]); +$newIbadah = (int)$stmtI->fetchColumn(); + +$stmtTotal = $db->query("SELECT COUNT(*) FROM penduduk_miskin WHERE status_bantuan='belum'"); +$belumDibantu = (int)$stmtTotal->fetchColumn(); + +jsonResponse([ + 'success' => true, + 'new_miskin' => $newMiskin, + 'new_ibadah' => $newIbadah, + 'belum_dibantu' => $belumDibantu, + 'has_new' => ($newMiskin + $newIbadah) > 0, + 'server_time' => date('Y-m-d H:i:s'), +]); diff --git a/api/penduduk_miskin.php b/api/penduduk_miskin.php new file mode 100644 index 0000000..d16760a --- /dev/null +++ b/api/penduduk_miskin.php @@ -0,0 +1,275 @@ +prepare(" + SELECT pm.*, + ri.nama AS ibadah_nama, ri.jenis AS ibadah_jenis, + ri.kontak AS ibadah_kontak, ri.lat AS ibadah_lat, ri.lng AS ibadah_lng + FROM penduduk_miskin pm + LEFT JOIN rumah_ibadah ri ON pm.rumah_ibadah_id = ri.id + WHERE pm.id = ? + "); + $stmt->execute([$id]); + $row = $stmt->fetch(); + if (!$row) jsonResponse(['success' => false, 'message' => 'Data tidak ditemukan'], 404); + + // Anggota keluarga + $stmtAk = $db->prepare("SELECT * FROM anggota_keluarga WHERE penduduk_id = ? ORDER BY id"); + $stmtAk->execute([$id]); + $row['anggota'] = $stmtAk->fetchAll(); + + jsonResponse(['success' => true, 'data' => $row]); + } + + // List dengan filter + search + $where = ['1=1']; + $params = []; + + if (!empty($_GET['status'])) { + $where[] = 'pm.status_bantuan = ?'; + $params[] = $_GET['status']; + } + if (!empty($_GET['kecamatan'])) { + $where[] = 'pm.kecamatan = ?'; + $params[] = $_GET['kecamatan']; + } + if (!empty($_GET['kelurahan'])) { + $where[] = 'pm.kelurahan = ?'; + $params[] = $_GET['kelurahan']; + } + if (!empty($_GET['ibadah_id'])) { + $where[] = 'pm.rumah_ibadah_id = ?'; + $params[] = (int)$_GET['ibadah_id']; + } + if (!empty($_GET['search'])) { + $where[] = '(pm.kk_nama LIKE ? OR pm.alamat LIKE ? OR pm.nik LIKE ?)'; + $q = '%' . $_GET['search'] . '%'; + $params = array_merge($params, [$q, $q, $q]); + } + + $sql = "SELECT pm.*, + ri.nama AS ibadah_nama, + ri.jenis AS ibadah_jenis + FROM penduduk_miskin pm + LEFT JOIN rumah_ibadah ri ON pm.rumah_ibadah_id = ri.id + WHERE " . implode(' AND ', $where) . " + ORDER BY pm.created_at DESC"; + $stmt = $db->prepare($sql); + $stmt->execute($params); + $rows = $stmt->fetchAll(); + + jsonResponse(['success' => true, 'data' => $rows, 'total' => count($rows)]); +} + +// ── POST ─────────────────────────────────────────────────────── +if ($method === 'POST') { + $isMultipart = str_contains($_SERVER['CONTENT_TYPE'] ?? '', 'multipart/form-data'); + $body = $isMultipart ? $_POST : (json_decode(file_get_contents('php://input'), true) ?? []); + + $required = ['kk_nama', 'lat', 'lng']; + foreach ($required as $f) { + if (empty($body[$f])) jsonResponse(['success' => false, 'message' => "Field '$f' wajib diisi"], 422); + } + + $lat = (float)$body['lat']; + $lng = (float)$body['lng']; + + // Auto-cari rumah ibadah terdekat + $nearest = findNearestIbadah($lat, $lng); + $ibadahId = $nearest ? (int)$nearest['id'] : null; + $jarakIbadah = $nearest ? (float)$nearest['jarak'] : null; + + // Handle upload bukti + $buktiFile = null; + if (!empty($_FILES['bukti_file']) && $_FILES['bukti_file']['error'] === UPLOAD_ERR_OK) { + $buktiFile = handleUpload($_FILES['bukti_file'], 'bukti'); + } + + $stmt = $db->prepare(" + INSERT INTO penduduk_miskin + (kk_nama, nik, jumlah_anggota, alamat, kelurahan, kecamatan, lat, lng, + rumah_ibadah_id, jarak_ke_ibadah, status_bantuan, jenis_bantuan, + tanggal_bantuan, nominal_bantuan, bukti_file) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + "); + $stmt->execute([ + sanitize($body['kk_nama']), + sanitize($body['nik'] ?? ''), + (int)($body['jumlah_anggota'] ?? 1), + sanitize($body['alamat'] ?? ''), + sanitize($body['kelurahan'] ?? ''), + sanitize($body['kecamatan'] ?? ''), + $lat, $lng, + $ibadahId, + $jarakIbadah, + $body['status_bantuan'] ?? 'belum', + sanitize($body['jenis_bantuan'] ?? ''), + !empty($body['tanggal_bantuan']) ? $body['tanggal_bantuan'] : null, + !empty($body['nominal_bantuan']) ? (float)str_replace(['.', ','], ['', '.'], $body['nominal_bantuan']) : null, + $buktiFile, + ]); + $newId = $db->lastInsertId(); + + // Simpan anggota keluarga jika ada (anggota bisa berupa JSON string dari FormData) + $anggotaData = $body['anggota'] ?? null; + if (is_string($anggotaData)) $anggotaData = json_decode($anggotaData, true); + if (!empty($anggotaData) && is_array($anggotaData)) { + saveAnggota($db, $newId, $anggotaData); + } + + // Fetch full data + $new = $db->query(" + SELECT pm.*, ri.nama AS ibadah_nama, ri.jenis AS ibadah_jenis + FROM penduduk_miskin pm + LEFT JOIN rumah_ibadah ri ON pm.rumah_ibadah_id = ri.id + WHERE pm.id = $newId + ")->fetch(); + + jsonResponse(['success' => true, 'message' => 'Data penduduk berhasil ditambahkan', 'data' => $new], 201); +} + +// ── PUT ──────────────────────────────────────────────────────── +if ($method === 'PUT') { + if (!$id) jsonResponse(['success' => false, 'message' => 'ID wajib disertakan'], 400); + + // Cek apakah multipart (ada file) atau JSON + $isMultipart = str_contains($_SERVER['CONTENT_TYPE'] ?? '', 'multipart/form-data'); + $body = $isMultipart ? $_POST : (json_decode(file_get_contents('php://input'), true) ?? []); + + $current = $db->prepare("SELECT * FROM penduduk_miskin WHERE id = ?"); + $current->execute([$id]); + $existing = $current->fetch(); + if (!$existing) jsonResponse(['success' => false, 'message' => 'Data tidak ditemukan'], 404); + + $lat = (float)($body['lat'] ?? $existing['lat']); + $lng = (float)($body['lng'] ?? $existing['lng']); + + // Re-assign ibadah terdekat jika koordinat berubah + $coordChanged = ($lat != (float)$existing['lat'] || $lng != (float)$existing['lng']); + if ($coordChanged || isset($body['force_reassign'])) { + $nearest = findNearestIbadah($lat, $lng); + $ibadahId = $nearest ? (int)$nearest['id'] : null; + $jarakIbadah = $nearest ? (float)$nearest['jarak'] : null; + } else { + $ibadahId = (int)($body['rumah_ibadah_id'] ?? $existing['rumah_ibadah_id']); + $jarakIbadah = (float)($existing['jarak_ke_ibadah'] ?? 0); + } + + // Handle upload bukti baru + $buktiFile = $existing['bukti_file']; + if (!empty($_FILES['bukti_file']) && $_FILES['bukti_file']['error'] === UPLOAD_ERR_OK) { + $newFile = handleUpload($_FILES['bukti_file'], 'bukti'); + if ($newFile) $buktiFile = $newFile; + } + + $status_bantuan = $body['status_bantuan'] ?? $existing['status_bantuan']; + if ($status_bantuan === 'belum') { + $jenis_bantuan = null; + $tanggal_bantuan = null; + $nominal_bantuan = null; + } else { + $jenis_bantuan = isset($body['jenis_bantuan']) ? sanitize($body['jenis_bantuan']) : $existing['jenis_bantuan']; + $tanggal_bantuan = isset($body['tanggal_bantuan']) ? (!empty($body['tanggal_bantuan']) ? $body['tanggal_bantuan'] : null) : $existing['tanggal_bantuan']; + if (isset($body['nominal_bantuan'])) { + $nominal_bantuan = $body['nominal_bantuan'] !== '' ? (float)str_replace(['.', ','], ['', '.'], $body['nominal_bantuan']) : null; + } else { + $nominal_bantuan = $existing['nominal_bantuan']; + } + } + + $stmt = $db->prepare(" + UPDATE penduduk_miskin + SET kk_nama=?, nik=?, jumlah_anggota=?, alamat=?, kelurahan=?, kecamatan=?, + lat=?, lng=?, rumah_ibadah_id=?, jarak_ke_ibadah=?, + status_bantuan=?, jenis_bantuan=?, tanggal_bantuan=?, nominal_bantuan=?, bukti_file=? + WHERE id=? + "); + $stmt->execute([ + sanitize($body['kk_nama'] ?? $existing['kk_nama']), + sanitize($body['nik'] ?? $existing['nik'] ?? ''), + (int)($body['jumlah_anggota'] ?? $existing['jumlah_anggota']), + sanitize($body['alamat'] ?? $existing['alamat'] ?? ''), + sanitize($body['kelurahan'] ?? $existing['kelurahan'] ?? ''), + sanitize($body['kecamatan'] ?? $existing['kecamatan'] ?? ''), + $lat, $lng, + $ibadahId, $jarakIbadah, + $status_bantuan, + $jenis_bantuan, + $tanggal_bantuan, + $nominal_bantuan, + $buktiFile, + $id, + ]); + + // Update anggota keluarga (anggota bisa berupa JSON string dari FormData) + if (isset($body['anggota'])) { + $anggotaDataPut = $body['anggota']; + if (is_string($anggotaDataPut)) $anggotaDataPut = json_decode($anggotaDataPut, true); + if (is_array($anggotaDataPut)) { + $db->prepare("DELETE FROM anggota_keluarga WHERE penduduk_id = ?")->execute([$id]); + saveAnggota($db, $id, $anggotaDataPut); + } + } + + $updated = $db->query(" + SELECT pm.*, ri.nama AS ibadah_nama, ri.jenis AS ibadah_jenis + FROM penduduk_miskin pm + LEFT JOIN rumah_ibadah ri ON pm.rumah_ibadah_id = ri.id + WHERE pm.id = $id + ")->fetch(); + + jsonResponse(['success' => true, 'message' => 'Data berhasil diperbarui', 'data' => $updated]); +} + +// ── DELETE ───────────────────────────────────────────────────── +if ($method === 'DELETE') { + if (!$id) jsonResponse(['success' => false, 'message' => 'ID wajib disertakan'], 400); + $stmt = $db->prepare("DELETE FROM penduduk_miskin WHERE id = ?"); + $stmt->execute([$id]); + if ($stmt->rowCount() === 0) jsonResponse(['success' => false, 'message' => 'Data tidak ditemukan'], 404); + jsonResponse(['success' => true, 'message' => 'Data berhasil dihapus']); +} + +// ── Helper ───────────────────────────────────────────────────── +function saveAnggota(PDO $db, int $pendudukId, array $list): void { + $stmt = $db->prepare(" + INSERT INTO anggota_keluarga (penduduk_id, nama, hubungan, umur, pekerjaan, keterangan) + VALUES (?,?,?,?,?,?) + "); + foreach ($list as $a) { + if (empty($a['nama'])) continue; + $stmt->execute([ + $pendudukId, + sanitize($a['nama'] ?? ''), + sanitize($a['hubungan'] ?? ''), + !empty($a['umur']) ? (int)$a['umur'] : null, + sanitize($a['pekerjaan'] ?? ''), + sanitize($a['keterangan'] ?? ''), + ]); + } +} + +jsonResponse(['success' => false, 'message' => 'Method tidak didukung'], 405); diff --git a/api/rumah_ibadah.php b/api/rumah_ibadah.php new file mode 100644 index 0000000..ceb84b3 --- /dev/null +++ b/api/rumah_ibadah.php @@ -0,0 +1,148 @@ +prepare(" + SELECT ri.*, + (SELECT COUNT(*) FROM penduduk_miskin pm WHERE pm.rumah_ibadah_id = ri.id) AS jumlah_binaan + FROM rumah_ibadah ri WHERE ri.id = ? + "); + $stmt->execute([$id]); + $row = $stmt->fetch(); + if (!$row) jsonResponse(['success' => false, 'message' => 'Data tidak ditemukan'], 404); + jsonResponse(['success' => true, 'data' => $row]); + } + + // List dengan filter + $where = ['1=1']; + $params = []; + + if (!empty($_GET['jenis'])) { + $where[] = 'jenis = ?'; + $params[] = $_GET['jenis']; + } + if (!empty($_GET['kecamatan'])) { + $where[] = 'kecamatan = ?'; + $params[] = $_GET['kecamatan']; + } + if (!empty($_GET['search'])) { + $where[] = 'nama LIKE ?'; + $params[] = '%' . $_GET['search'] . '%'; + } + + $sql = "SELECT ri.*, + (SELECT COUNT(*) FROM penduduk_miskin pm WHERE pm.rumah_ibadah_id = ri.id) AS jumlah_binaan + FROM rumah_ibadah ri + WHERE " . implode(' AND ', $where) . " + ORDER BY ri.nama ASC"; + $stmt = $db->prepare($sql); + $stmt->execute($params); + $rows = $stmt->fetchAll(); + + jsonResponse(['success' => true, 'data' => $rows, 'total' => count($rows)]); +} + +// ── POST ─────────────────────────────────────────────────────── +if ($method === 'POST') { + $body = json_decode(file_get_contents('php://input'), true) ?? $_POST; + + $required = ['nama', 'jenis', 'lat', 'lng']; + foreach ($required as $f) { + if (empty($body[$f])) jsonResponse(['success' => false, 'message' => "Field '$f' wajib diisi"], 422); + } + + $stmt = $db->prepare(" + INSERT INTO rumah_ibadah (nama, jenis, kontak, alamat, kelurahan, kecamatan, lat, lng, radius) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + "); + $stmt->execute([ + sanitize($body['nama']), + $body['jenis'], + sanitize($body['kontak'] ?? ''), + sanitize($body['alamat'] ?? ''), + sanitize($body['kelurahan'] ?? ''), + sanitize($body['kecamatan'] ?? ''), + (float)$body['lat'], + (float)$body['lng'], + (int)($body['radius'] ?? RADIUS_DEFAULT), + ]); + $newId = $db->lastInsertId(); + $new = $db->query("SELECT * FROM rumah_ibadah WHERE id = $newId")->fetch(); + jsonResponse(['success' => true, 'message' => 'Rumah ibadah berhasil ditambahkan', 'data' => $new], 201); +} + +// ── PUT ──────────────────────────────────────────────────────── +if ($method === 'PUT') { + if (!$id) jsonResponse(['success' => false, 'message' => 'ID wajib disertakan'], 400); + $body = json_decode(file_get_contents('php://input'), true) ?? []; + + $stmt = $db->prepare(" + UPDATE rumah_ibadah + SET nama=?, jenis=?, kontak=?, alamat=?, kelurahan=?, kecamatan=?, lat=?, lng=?, radius=? + WHERE id=? + "); + $stmt->execute([ + sanitize($body['nama'] ?? ''), + $body['jenis'] ?? 'Masjid', + sanitize($body['kontak'] ?? ''), + sanitize($body['alamat'] ?? ''), + sanitize($body['kelurahan'] ?? ''), + sanitize($body['kecamatan'] ?? ''), + (float)($body['lat'] ?? 0), + (float)($body['lng'] ?? 0), + (int)($body['radius'] ?? RADIUS_DEFAULT), + $id, + ]); + + // Re-assign penduduk yang terhubung jika radius berubah + reassignPendudukAfterIbadahUpdate(); + + $updated = $db->query("SELECT * FROM rumah_ibadah WHERE id = $id")->fetch(); + jsonResponse(['success' => true, 'message' => 'Data berhasil diperbarui', 'data' => $updated]); +} + +// ── DELETE ───────────────────────────────────────────────────── +if ($method === 'DELETE') { + if (!$id) jsonResponse(['success' => false, 'message' => 'ID wajib disertakan'], 400); + $stmt = $db->prepare("DELETE FROM rumah_ibadah WHERE id = ?"); + $stmt->execute([$id]); + if ($stmt->rowCount() === 0) jsonResponse(['success' => false, 'message' => 'Data tidak ditemukan'], 404); + jsonResponse(['success' => true, 'message' => 'Data berhasil dihapus']); +} + +function reassignPendudukAfterIbadahUpdate(): void { + $db = getDB(); + $penduduk = $db->query("SELECT id, lat, lng FROM penduduk_miskin")->fetchAll(); + foreach ($penduduk as $p) { + $nearest = findNearestIbadah((float)$p['lat'], (float)$p['lng']); + if ($nearest) { + $stmt = $db->prepare("UPDATE penduduk_miskin SET rumah_ibadah_id=?, jarak_ke_ibadah=? WHERE id=?"); + $stmt->execute([$nearest['id'], $nearest['jarak'], $p['id']]); + } + } +} + +jsonResponse(['success' => false, 'message' => 'Method tidak didukung'], 405); diff --git a/api/stats.php b/api/stats.php new file mode 100644 index 0000000..9d118d2 --- /dev/null +++ b/api/stats.php @@ -0,0 +1,63 @@ +query("SELECT COUNT(*) FROM rumah_ibadah")->fetchColumn(); +$totalMiskin = $db->query("SELECT COUNT(*) FROM penduduk_miskin")->fetchColumn(); +$sudahDibantu = $db->query("SELECT COUNT(*) FROM penduduk_miskin WHERE status_bantuan='sudah'")->fetchColumn(); +$belumDibantu = $db->query("SELECT COUNT(*) FROM penduduk_miskin WHERE status_bantuan='belum'")->fetchColumn(); +$menunggu = $db->query("SELECT COUNT(*) FROM penduduk_miskin WHERE status_bantuan='menunggu'")->fetchColumn(); +$totalNominal = $db->query("SELECT COALESCE(SUM(nominal_bantuan),0) FROM penduduk_miskin WHERE status_bantuan='sudah' AND nominal_bantuan IS NOT NULL")->fetchColumn(); +$totalNominalAll = $db->query("SELECT COALESCE(SUM(nominal_bantuan),0) FROM penduduk_miskin WHERE nominal_bantuan IS NOT NULL")->fetchColumn(); + +// Per jenis ibadah +$jenisStats = $db->query(" + SELECT jenis, COUNT(*) as total, + (SELECT COUNT(*) FROM penduduk_miskin pm WHERE pm.rumah_ibadah_id = ri.id) AS binaan + FROM rumah_ibadah ri + GROUP BY jenis ORDER BY total DESC +")->fetchAll(); + +// Per kecamatan (top 5) +$kecamatanStats = $db->query(" + SELECT kecamatan, COUNT(*) as total, + SUM(CASE WHEN status_bantuan='sudah' THEN 1 ELSE 0 END) AS sudah, + SUM(CASE WHEN status_bantuan='belum' THEN 1 ELSE 0 END) AS belum + FROM penduduk_miskin + WHERE kecamatan != '' AND kecamatan IS NOT NULL + GROUP BY kecamatan ORDER BY total DESC LIMIT 5 +")->fetchAll(); + +// Data terbaru (5 data) +$recentMiskin = $db->query(" + SELECT pm.id, pm.kk_nama, pm.status_bantuan, pm.created_at, + ri.nama AS ibadah_nama + FROM penduduk_miskin pm + LEFT JOIN rumah_ibadah ri ON pm.rumah_ibadah_id = ri.id + ORDER BY pm.created_at DESC LIMIT 5 +")->fetchAll(); + +jsonResponse([ + 'success' => true, + 'data' => [ + 'total_ibadah' => (int)$totalIbadah, + 'total_miskin' => (int)$totalMiskin, + 'sudah_dibantu' => (int)$sudahDibantu, + 'belum_dibantu' => (int)$belumDibantu, + 'menunggu' => (int)$menunggu, + 'total_nominal' => (float)$totalNominal, + 'total_nominal_all' => (float)$totalNominalAll, + 'jenis_stats' => $jenisStats, + 'kecamatan_stats' => $kecamatanStats, + 'recent_miskin' => $recentMiskin, + ] +]); diff --git a/api/upload.php b/api/upload.php new file mode 100644 index 0000000..783fff2 --- /dev/null +++ b/api/upload.php @@ -0,0 +1,31 @@ + false, 'message' => 'Method harus POST'], 405); +} + +if (empty($_FILES['file'])) { + jsonResponse(['success' => false, 'message' => 'Tidak ada file yang diupload'], 400); +} + +$file = $_FILES['file']; +$prefix = sanitize($_POST['prefix'] ?? 'upload'); +$filename = handleUpload($file, $prefix); + +if (!$filename) { + jsonResponse(['success' => false, 'message' => 'Upload gagal. Pastikan tipe file dan ukuran sesuai (maks 10MB, tipe: jpg/jpeg/png/gif/pdf/doc/docx)'], 422); +} + +jsonResponse([ + 'success' => true, + 'filename' => $filename, + 'url' => UPLOAD_URL . $filename, + 'message' => 'File berhasil diupload', +]); diff --git a/api/users.php b/api/users.php new file mode 100644 index 0000000..16fa1ba --- /dev/null +++ b/api/users.php @@ -0,0 +1,132 @@ + false, 'message' => 'Akses ditolak: hanya admin'], 403); +} + +$db = getDB(); +$method = $_SERVER['REQUEST_METHOD']; +$id = isset($_GET['id']) ? (int)$_GET['id'] : null; + +// ── GET ──────────────────────────────────────────────────────── +if ($method === 'GET') { + if ($id) { + $stmt = $db->prepare("SELECT id, nama, email, role, created_at FROM users WHERE id = ?"); + $stmt->execute([$id]); + $row = $stmt->fetch(); + if (!$row) jsonResponse(['success' => false, 'message' => 'User tidak ditemukan'], 404); + jsonResponse(['success' => true, 'data' => $row]); + } + $rows = $db->query("SELECT id, nama, email, role, created_at FROM users ORDER BY role DESC, nama")->fetchAll(); + jsonResponse(['success' => true, 'data' => $rows, 'total' => count($rows)]); +} + +// ── POST (Tambah user baru) ──────────────────────────────────── +if ($method === 'POST') { + $body = json_decode(file_get_contents('php://input'), true) ?? []; + $nama = trim($body['nama'] ?? ''); + $email = trim($body['email'] ?? ''); + $pwd = $body['password'] ?? ''; + $role = in_array($body['role'] ?? '', ['admin','operator']) ? $body['role'] : 'operator'; + + if (!$nama || !$email || !$pwd) { + jsonResponse(['success' => false, 'message' => 'Nama, email, dan password wajib diisi'], 422); + } + if (strlen($pwd) < 6) { + jsonResponse(['success' => false, 'message' => 'Password minimal 6 karakter'], 422); + } + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + jsonResponse(['success' => false, 'message' => 'Format email tidak valid'], 422); + } + + // Cek duplikat email + $check = $db->prepare("SELECT id FROM users WHERE email = ?"); + $check->execute([$email]); + if ($check->fetch()) { + jsonResponse(['success' => false, 'message' => 'Email sudah terdaftar'], 409); + } + + $hash = password_hash($pwd, PASSWORD_BCRYPT); + $stmt = $db->prepare("INSERT INTO users (nama, email, password, role) VALUES (?, ?, ?, ?)"); + $stmt->execute([$nama, $email, $hash, $role]); + $newId = $db->lastInsertId(); + $new = $db->query("SELECT id, nama, email, role, created_at FROM users WHERE id = $newId")->fetch(); + jsonResponse(['success' => true, 'message' => 'Pengguna berhasil ditambahkan', 'data' => $new], 201); +} + +// ── PUT (Edit user) ──────────────────────────────────────────── +if ($method === 'PUT') { + if (!$id) jsonResponse(['success' => false, 'message' => 'ID wajib disertakan'], 400); + $body = json_decode(file_get_contents('php://input'), true) ?? []; + + $existing = $db->prepare("SELECT * FROM users WHERE id = ?"); + $existing->execute([$id]); + $user = $existing->fetch(); + if (!$user) jsonResponse(['success' => false, 'message' => 'User tidak ditemukan'], 404); + + $nama = trim($body['nama'] ?? $user['nama']); + $email = trim($body['email'] ?? $user['email']); + $role = in_array($body['role'] ?? '', ['admin','operator']) ? $body['role'] : $user['role']; + $pwd = $body['password'] ?? ''; + + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + jsonResponse(['success' => false, 'message' => 'Format email tidak valid'], 422); + } + + // Cek duplikat email (kecuali user ini sendiri) + $checkEmail = $db->prepare("SELECT id FROM users WHERE email = ? AND id != ?"); + $checkEmail->execute([$email, $id]); + if ($checkEmail->fetch()) { + jsonResponse(['success' => false, 'message' => 'Email sudah digunakan oleh user lain'], 409); + } + + if ($pwd) { + if (strlen($pwd) < 6) jsonResponse(['success' => false, 'message' => 'Password minimal 6 karakter'], 422); + $hash = password_hash($pwd, PASSWORD_BCRYPT); + $stmt = $db->prepare("UPDATE users SET nama=?, email=?, role=?, password=? WHERE id=?"); + $stmt->execute([$nama, $email, $role, $hash, $id]); + } else { + $stmt = $db->prepare("UPDATE users SET nama=?, email=?, role=? WHERE id=?"); + $stmt->execute([$nama, $email, $role, $id]); + } + + $updated = $db->query("SELECT id, nama, email, role, created_at FROM users WHERE id = $id")->fetch(); + jsonResponse(['success' => true, 'message' => 'Data pengguna berhasil diperbarui', 'data' => $updated]); +} + +// ── DELETE ───────────────────────────────────────────────────── +if ($method === 'DELETE') { + if (!$id) jsonResponse(['success' => false, 'message' => 'ID wajib disertakan'], 400); + + // Tidak boleh hapus diri sendiri + if ($id == $sessionUserId) { + jsonResponse(['success' => false, 'message' => 'Tidak bisa menghapus akun sendiri'], 403); + } + + $stmt = $db->prepare("DELETE FROM users WHERE id = ?"); + $stmt->execute([$id]); + if ($stmt->rowCount() === 0) jsonResponse(['success' => false, 'message' => 'User tidak ditemukan'], 404); + jsonResponse(['success' => true, 'message' => 'Pengguna berhasil dihapus']); +} + +jsonResponse(['success' => false, 'message' => 'Method tidak didukung'], 405); diff --git a/app.js b/app.js new file mode 100644 index 0000000..e875697 --- /dev/null +++ b/app.js @@ -0,0 +1,647 @@ +// ═══════════════════════════════════════════════════════════════ +// MAP SETUP +// ═══════════════════════════════════════════════════════════════ +const map = L.map('map', { preferCanvas: false }).setView([-0.0263, 109.3425], 13); +L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + maxZoom: 19 +}).addTo(map); +setTimeout(() => map.invalidateSize(), 150); + +// ═══════════════════════════════════════════════════════════════ +// STATE +// ═══════════════════════════════════════════════════════════════ +let mode = null; // 'ibadah' | 'miskin' | null +let editMode = null; // { type:'ibadah'|'miskin', id } — saat edit +let ibadahList = []; +let miskinList = []; +let myMarker = null; +let pendingLatLng = null; +let pendingAddr = null; +let tempMarker = null; + +// ═══════════════════════════════════════════════════════════════ +// TOAST +// ═══════════════════════════════════════════════════════════════ +function showToast(msg, color = '#5b7fff') { + const t = document.getElementById('toast'); + t.innerHTML = msg; + t.style.display = 'block'; + t.style.borderLeft = `3px solid ${color}`; + clearTimeout(t._timer); + t._timer = setTimeout(() => t.style.display = 'none', 3500); +} + +// ═══════════════════════════════════════════════════════════════ +// TOGGLE MODE + HIGHLIGHT +// ═══════════════════════════════════════════════════════════════ +function setMode(m) { + if (mode === m) { + mode = null; clearHighlights(); updateToolbar(); resetMapCursor(); return; + } + mode = m; updateToolbar(); resetMapCursor(); + if (m === 'ibadah') { + document.getElementById('map').classList.add('adding-ibadah'); + highlightAll('ibadah'); + showToast('🏛️ Klik peta untuk menentukan lokasi Rumah Ibadah', '#5b7fff'); + } else { + document.getElementById('map').classList.add('adding-miskin'); + highlightAll('miskin'); + showToast('⛽ Klik peta untuk menentukan lokasi SPBU / Pangkalan BBM', '#f59e0b'); + } +} + +function resetMapCursor() { + document.getElementById('map').classList.remove('adding-ibadah', 'adding-miskin'); +} + +function updateToolbar() { + document.getElementById('toggle-ibadah').className = 'toggle-btn' + (mode === 'ibadah' ? ' active-ibadah' : ''); + document.getElementById('toggle-miskin').className = 'toggle-btn' + (mode === 'miskin' ? ' active-miskin' : ''); +} + +function highlightAll(type) { + clearHighlights(); + if (type === 'ibadah') { + ibadahList.forEach(ib => { + if (ib.circle) ib.circle.setStyle({ fillOpacity: 0.18, weight: 3, opacity: 1 }); + pulseMarker(ib.marker); + const c = document.getElementById('card-ibadah-' + ib.id); + if (c) c.classList.add('highlighted'); + }); + } else { + miskinList.forEach(m => { + pulseMarker(m.marker); + const c = document.getElementById('card-miskin-' + m.id); + if (c) c.classList.add('highlighted'); + }); + } +} + +function clearHighlights() { + ibadahList.forEach(ib => { + if (ib.circle) ib.circle.setStyle({ fillOpacity: 0.08, weight: 2, opacity: 0.8 }); + const c = document.getElementById('card-ibadah-' + ib.id); + if (c) c.classList.remove('highlighted'); + }); + miskinList.forEach(m => { + const c = document.getElementById('card-miskin-' + m.id); + if (c) c.classList.remove('highlighted'); + }); +} + +function pulseMarker(marker) { + if (!marker) return; + const el = marker.getElement(); if (!el) return; + el.classList.remove('leaflet-marker-pulsing'); + void el.offsetWidth; + el.classList.add('leaflet-marker-pulsing'); + setTimeout(() => el.classList.remove('leaflet-marker-pulsing'), 3000); +} + +// ═══════════════════════════════════════════════════════════════ +// REVERSE GEOCODING +// ═══════════════════════════════════════════════════════════════ +async function reverseGeocode(lat, lng) { + try { + const r = await fetch( + `https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&addressdetails=1`, + { headers: { 'Accept-Language': 'id' } } + ); + const d = await r.json(); + return d.display_name || `${lat.toFixed(5)}, ${lng.toFixed(5)}`; + } catch { return `${lat.toFixed(5)}, ${lng.toFixed(5)}`; } +} + +// ═══════════════════════════════════════════════════════════════ +// RADIUS: cari rumah ibadah TERDEKAT yang masih dalam radius +// ═══════════════════════════════════════════════════════════════ +function getNearestIbadah(lat, lng) { + let nearest = null, minDist = Infinity; + for (const ib of ibadahList) { + const dist = map.distance([lat, lng], [ib.lat, ib.lng]); + if (dist <= ib.radius && dist < minDist) { + minDist = dist; nearest = ib; + } + } + return nearest; // null = di luar semua radius +} + +function updateAllMiskinColors() { + miskinList.forEach(m => { + const nearest = getNearestIbadah(m.lat, m.lng); + m.inRadius = nearest !== null; + m.nearestIbadah = nearest ? nearest.id : null; + if (m.marker) m.marker.setIcon(createMiskinIcon(m.inRadius)); + if (m.marker) m.marker.setPopupContent(buildMiskinPopup(m)); + }); + updateStats(); + renderMiskinList(); +} + +// ═══════════════════════════════════════════════════════════════ +// ICONS +// ═══════════════════════════════════════════════════════════════ +function createIbadahIcon(emoji) { + return L.divIcon({ + html: `
+ ${emoji}
`, + iconSize: [36,36], iconAnchor: [18,36], popupAnchor: [0,-40], className: '' + }); +} + +function createMiskinIcon(inRadius) { + // Icon SPBU — fuel pump style + const c = inRadius ? '#f59e0b' : '#ef4444'; + const s = inRadius ? 'rgba(245,158,11,0.5)' : 'rgba(239,68,68,0.5)'; + return L.divIcon({ + html: `
`, + iconSize: [22,22], iconAnchor: [11,11], popupAnchor: [0,-14], className: '' + }); +} + +function createTempIcon(type) { + if (type === 'ibadah') return L.divIcon({ + html: `
+
`, + iconSize: [36,36], iconAnchor: [18,36], className: '' + }); + return L.divIcon({ + html: `
`, + iconSize: [20,20], iconAnchor: [10,10], className: '' + }); +} + +function createMyIcon() { + return L.divIcon({ + html: `
+ `, + iconSize: [24,24], iconAnchor: [12,12], popupAnchor: [0,-14], className: '' + }); +} + +// ═══════════════════════════════════════════════════════════════ +// MAP CLICK → FORM POPUP +// ═══════════════════════════════════════════════════════════════ +map.on('click', async (e) => { + if (!mode) return; + const { lat, lng } = e.latlng; + pendingLatLng = { lat, lng }; + pendingAddr = null; + editMode = null; // ini bukan edit + + if (tempMarker) map.removeLayer(tempMarker); + tempMarker = L.marker([lat, lng], { icon: createTempIcon(mode) }).addTo(map); + + openFormOverlay(mode, lat, lng, null); // null = mode tambah baru + + reverseGeocode(lat, lng).then(addr => { + pendingAddr = addr; + const el = document.getElementById(mode === 'ibadah' ? 'f-ibadah-addr' : 'f-miskin-addr'); + if (el) el.innerHTML = `📍 ${addr}`; + }); +}); + +// ═══════════════════════════════════════════════════════════════ +// FORM OVERLAY — bisa untuk tambah BARU atau EDIT +// ═══════════════════════════════════════════════════════════════ +function openFormOverlay(type, lat, lng, existingData) { + const isEdit = existingData !== null; + editMode = isEdit ? { type, id: existingData.id } : null; + + const header = document.getElementById('form-header'); + const title = document.getElementById('form-title'); + const fI = document.getElementById('form-ibadah-fields'); + const fM = document.getElementById('form-miskin-fields'); + const saveBtn = document.getElementById('btn-form-save'); + + const coordText = `Lat: ${lat.toFixed(6)} Lng: ${lng.toFixed(6)}`; + + if (type === 'ibadah') { + title.textContent = isEdit ? '✏️ Edit Rumah Ibadah' : '🏛️ Tambah Rumah Ibadah'; + header.className = 'form-header ibadah-header'; + fI.style.display = 'block'; + fM.style.display = 'none'; + + document.getElementById('f-ibadah-coord').textContent = coordText; + document.getElementById('f-ibadah-name').value = isEdit ? existingData.name : ''; + document.getElementById('f-ibadah-type').value = isEdit ? existingData.type : '🕌'; + + const rad = isEdit ? existingData.radius : 500; + document.getElementById('f-radius-slider').value = rad; + document.getElementById('f-radius-val').textContent = fmtRadius(rad); + + if (isEdit) { + document.getElementById('f-ibadah-addr').innerHTML = `📍 ${existingData.addr}`; + pendingAddr = existingData.addr; + } else { + document.getElementById('f-ibadah-addr').innerHTML = '🔄 Memuat alamat...'; + } + + saveBtn.onclick = isEdit ? applyEditIbadah : saveIbadah; + + } else { + title.textContent = isEdit ? '✏️ Edit Penduduk Miskin' : '👤 Tambah Penduduk Miskin'; + header.className = 'form-header miskin-header'; + fI.style.display = 'none'; + fM.style.display = 'block'; + + document.getElementById('f-miskin-coord').textContent = coordText; + document.getElementById('f-miskin-name').value = isEdit ? existingData.name : ''; + + if (isEdit) { + document.getElementById('f-miskin-addr').innerHTML = `📍 ${existingData.addr}`; + pendingAddr = existingData.addr; + } else { + document.getElementById('f-miskin-addr').innerHTML = '🔄 Memuat alamat...'; + } + + saveBtn.onclick = isEdit ? applyEditMiskin : saveMiskin; + } + + document.getElementById('form-overlay').classList.remove('hidden'); + setTimeout(() => { + const inp = document.getElementById(type === 'ibadah' ? 'f-ibadah-name' : 'f-miskin-name'); + if (inp) inp.focus(); + }, 100); +} + +window.closeFormOverlay = function () { + document.getElementById('form-overlay').classList.add('hidden'); + if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; } + pendingLatLng = null; pendingAddr = null; editMode = null; +}; + +// radius slider in form +document.getElementById('f-radius-slider').addEventListener('input', function () { + document.getElementById('f-radius-val').textContent = fmtRadius(parseInt(this.value)); +}); +// SPBU radius slider +document.getElementById('f-radius-slider-spbu').addEventListener('input', function () { + document.getElementById('f-radius-val-spbu').textContent = fmtRadius(parseInt(this.value)); +}); + +function fmtRadius(v) { return v >= 1000 ? (v/1000).toFixed(1)+' km' : v+' m'; } + +// ═══════════════════════════════════════════════════════════════ +// SAVE — TAMBAH IBADAH BARU +// ═══════════════════════════════════════════════════════════════ +function saveIbadah() { + if (!pendingLatLng) return; + const { lat, lng } = pendingLatLng; + const name = document.getElementById('f-ibadah-name').value.trim() || 'Rumah Ibadah'; + const type = document.getElementById('f-ibadah-type').value; + const radius = parseInt(document.getElementById('f-radius-slider').value); + const addr = pendingAddr || `${lat.toFixed(5)}, ${lng.toFixed(5)}`; + + if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; } + + // also get spbu radius slider for form that was open + const id = Date.now(); + const marker = L.marker([lat, lng], { icon: createIbadahIcon(type) }).addTo(map); + const circle = L.circle([lat, lng], { + radius, color: '#5b7fff', fillColor: '#5b7fff', + fillOpacity: 0.08, weight: 2, dashArray: '6,4', opacity: 0.8 + }).addTo(map); + + const obj = { id, name, type, lat, lng, radius, addr, marker, circle }; + marker.bindPopup(() => buildIbadahPopup(obj)); + ibadahList.push(obj); + + closeFormOverlay(); + updateAllMiskinColors(); + renderIbadahList(); + updateStats(); + + setTimeout(() => { + pulseMarker(marker); + const c = document.getElementById('card-ibadah-' + id); + if (c) { c.classList.add('highlighted'); c.scrollIntoView({ behavior:'smooth', block:'nearest' }); } + }, 100); + + showToast(`✅ ${type} ${name} ditambahkan`, '#5b7fff'); +} + +// ═══════════════════════════════════════════════════════════════ +// EDIT — TERAPKAN EDIT IBADAH +// ═══════════════════════════════════════════════════════════════ +function applyEditIbadah() { + if (!editMode) return; + const ib = ibadahList.find(x => x.id === editMode.id); + if (!ib) return; + + ib.name = document.getElementById('f-ibadah-name').value.trim() || ib.name; + ib.type = document.getElementById('f-ibadah-type').value; + ib.radius = parseInt(document.getElementById('f-radius-slider').value); + // koordinat & addr tidak berubah saat edit (hanya metadata) + + // Update marker icon & circle + ib.marker.setIcon(createIbadahIcon(ib.type)); + ib.circle.setRadius(ib.radius); + ib.marker.setPopupContent(buildIbadahPopup(ib)); + + closeFormOverlay(); + updateAllMiskinColors(); + renderIbadahList(); + updateStats(); + showToast(`✏️ ${ib.type} ${ib.name} diperbarui`, '#5b7fff'); +} + +// ═══════════════════════════════════════════════════════════════ +// SAVE — TAMBAH MISKIN BARU +// ═══════════════════════════════════════════════════════════════ +function saveMiskin() { + if (!pendingLatLng) return; + const { lat, lng } = pendingLatLng; + const name = document.getElementById('f-miskin-name').value.trim() || 'SPBU'; + const spbuType = document.getElementById('f-spbu-type') ? document.getElementById('f-spbu-type').value : '⛽'; + const radius = parseInt(document.getElementById('f-radius-slider-spbu').value) || 1000; + const addr = pendingAddr || `${lat.toFixed(5)}, ${lng.toFixed(5)}`; + const nearest = getNearestIbadah(lat, lng); + + if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; } + + const id = Date.now(); + const inRadius = nearest !== null; + const marker = L.marker([lat, lng], { icon: createMiskinIcon(true) }).addTo(map); + const circle = L.circle([lat, lng], { + radius, color: '#f59e0b', fillColor: '#f59e0b', + fillOpacity: 0.06, weight: 1.5, dashArray: '4,4', opacity: 0.7 + }).addTo(map); + const obj = { id, name, type: spbuType, lat, lng, inRadius, nearestIbadah: nearest ? nearest.id : null, addr, marker, circle, radius }; + marker.bindPopup(() => buildMiskinPopup(obj)); + miskinList.push(obj); + + closeFormOverlay(); + updateStats(); + renderMiskinList(); + + setTimeout(() => { + pulseMarker(marker); + const c = document.getElementById('card-miskin-' + id); + if (c) { c.classList.add('highlighted'); c.scrollIntoView({ behavior:'smooth', block:'nearest' }); } + }, 100); + + showToast(`⛽ ${spbuType} ${name} ditambahkan`, '#f59e0b'); +} + +// ═══════════════════════════════════════════════════════════════ +// EDIT — TERAPKAN EDIT MISKIN +// ═══════════════════════════════════════════════════════════════ +function applyEditMiskin() { + if (!editMode) return; + const m = miskinList.find(x => x.id === editMode.id); + if (!m) return; + + m.name = document.getElementById('f-miskin-name').value.trim() || m.name; + m.marker.setPopupContent(buildMiskinPopup(m)); + + closeFormOverlay(); + updateStats(); + renderMiskinList(); + showToast(`✏️ Data ${m.name} diperbarui`, '#22c55e'); +} + +// ═══════════════════════════════════════════════════════════════ +// POPUP BUILDERS +// ═══════════════════════════════════════════════════════════════ +function buildIbadahPopup(ib) { + return `${ib.type} ${ib.name} + +
+ Radius: ${fmtRadius(ib.radius)} +  ·  ${ib.lat.toFixed(5)}, ${ib.lng.toFixed(5)} +
+
+ + ✏️ Edit + +
`; +} + +function buildMiskinPopup(m) { + const nearest = ibadahList.find(x => x.id === m.nearestIbadah); + const nearestLabel = nearest + ? `
+ 📌 Terdekat: ${nearest.type} ${nearest.name} + (${Math.round(map.distance([m.lat,m.lng],[nearest.lat,nearest.lng]))}m) +
` + : ''; + return `⛽ ${m.type || ''} ${m.name} +
+ + ⛽ SPBU / Pangkalan BBM + +
+ ${nearestLabel} + +
${m.lat.toFixed(5)}, ${m.lng.toFixed(5)}
+
+ + ✏️ Edit + +
`; +} + +// ═══════════════════════════════════════════════════════════════ +// OPEN EDIT (dipanggil dari popup peta ATAU tombol edit di sidebar) +// ═══════════════════════════════════════════════════════════════ +window.openEditIbadah = (id) => { + const ib = ibadahList.find(x => x.id === id); if (!ib) return; + map.closePopup(); + pendingLatLng = { lat: ib.lat, lng: ib.lng }; + openFormOverlay('ibadah', ib.lat, ib.lng, ib); +}; + +window.openEditMiskin = (id) => { + const m = miskinList.find(x => x.id === id); if (!m) return; + map.closePopup(); + pendingLatLng = { lat: m.lat, lng: m.lng }; + openFormOverlay('miskin', m.lat, m.lng, m); +}; + +// ═══════════════════════════════════════════════════════════════ +// RENDER SIDEBAR +// ═══════════════════════════════════════════════════════════════ +function renderIbadahList() { + const el = document.getElementById('ibadah-list'); + document.getElementById('count-ibadah').textContent = ibadahList.length; + if (!ibadahList.length) { el.innerHTML = '
Belum ada rumah ibadah
'; return; } + + el.innerHTML = ibadahList.map(ib => ` +
+
${ib.type}
+
+
${ib.name}
+
${truncate(ib.addr, 52)}
+
${ib.lat.toFixed(4)}, ${ib.lng.toFixed(4)}
+ +
+ + ${fmtRadius(ib.radius)} +
+
+
+ + +
+
` + ).join(''); +} + +function renderMiskinList() { + const el = document.getElementById('miskin-list'); + document.getElementById('count-miskin').textContent = miskinList.length; + if (!miskinList.length) { el.innerHTML = '
Belum ada data SPBU
'; return; } + + el.innerHTML = miskinList.map(m => { + const nearest = ibadahList.find(x => x.id === m.nearestIbadah); + const nearestHtml = nearest + ? `
📌 ${nearest.type} ${truncate(nearest.name, 20)}
` + : ''; + return ` +
+
${m.type || '⛽'}
+
+
${m.name}
+
${truncate(m.addr, 52)}
+
${m.lat.toFixed(4)}, ${m.lng.toFixed(4)}
+ ${nearestHtml} +
+
+ + + +
+
`; + }).join(''); +} + +// ─── Live radius update dari slider sidebar ─────────────────── +window.liveRadius = (id, val) => { + const ib = ibadahList.find(x => x.id === id); if (!ib) return; + ib.radius = parseInt(val); + if (ib.circle) ib.circle.setRadius(ib.radius); + const disp = document.getElementById('rdisplay-' + id); + if (disp) disp.textContent = fmtRadius(ib.radius); + ib.marker.setPopupContent(buildIbadahPopup(ib)); + updateAllMiskinColors(); // recalculate nearest +}; + +// ═══════════════════════════════════════════════════════════════ +// FOCUS (klik card → pan + popup) +// ═══════════════════════════════════════════════════════════════ +window.focusIbadah = (id) => { + const ib = ibadahList.find(x => x.id === id); if (!ib) return; + map.setView([ib.lat, ib.lng], Math.max(map.getZoom(), 15)); + ib.marker.openPopup(); pulseMarker(ib.marker); +}; +window.focusMiskin = (id) => { + const m = miskinList.find(x => x.id === id); if (!m) return; + map.setView([m.lat, m.lng], Math.max(map.getZoom(), 16)); + m.marker.openPopup(); pulseMarker(m.marker); +}; + +// ═══════════════════════════════════════════════════════════════ +// STATS +// ═══════════════════════════════════════════════════════════════ +function updateStats() { + document.getElementById('stat-ibadah').textContent = ibadahList.length; + document.getElementById('stat-in').textContent = miskinList.length; + document.getElementById('stat-out').textContent = 0; // not used for SPBU +} +function truncate(s, n) { return s.length > n ? s.slice(0,n)+'…' : s; } + +// ═══════════════════════════════════════════════════════════════ +// REMOVE +// ═══════════════════════════════════════════════════════════════ +window.removeIbadah = (id) => { + const i = ibadahList.findIndex(x => x.id === id); if (i < 0) return; + map.removeLayer(ibadahList[i].marker); + map.removeLayer(ibadahList[i].circle); + ibadahList.splice(i, 1); + updateAllMiskinColors(); renderIbadahList(); updateStats(); +}; +window.removeMiskin = (id) => { + const i = miskinList.findIndex(x => x.id === id); if (i < 0) return; + map.removeLayer(miskinList[i].marker); + if (miskinList[i].circle) map.removeLayer(miskinList[i].circle); + miskinList.splice(i, 1); + updateStats(); renderMiskinList(); +}; + +// ═══════════════════════════════════════════════════════════════ +// RESET +// ═══════════════════════════════════════════════════════════════ +document.getElementById('btn-reset').addEventListener('click', () => { + ibadahList.forEach(ib => { map.removeLayer(ib.marker); map.removeLayer(ib.circle); }); + miskinList.forEach(m => { map.removeLayer(m.marker); if (m.circle) map.removeLayer(m.circle); }); + if (myMarker) { map.removeLayer(myMarker); myMarker = null; } + if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; } + ibadahList = []; miskinList = []; + mode = null; editMode = null; updateToolbar(); resetMapCursor(); + document.getElementById('form-overlay').classList.add('hidden'); + document.getElementById('my-location-info').style.display = 'none'; + document.getElementById('loc-bar').style.display = 'none'; + renderIbadahList(); renderMiskinList(); updateStats(); + showToast('🗑️ Semua data dihapus', '#ef4444'); +}); + +// ═══════════════════════════════════════════════════════════════ +// GEOLOCATION +// ═══════════════════════════════════════════════════════════════ +function doLocate() { + if (!navigator.geolocation) { showToast('❌ Browser tidak mendukung Geolocation', '#ef4444'); return; } + showToast('📡 Mendeteksi lokasi...', '#f59e0b'); + navigator.geolocation.getCurrentPosition(async (pos) => { + const lat = pos.coords.latitude, lng = pos.coords.longitude; + if (myMarker) map.removeLayer(myMarker); + myMarker = L.marker([lat, lng], { icon: createMyIcon() }).addTo(map); + const addr = await reverseGeocode(lat, lng); + myMarker.bindPopup(`💻 Lokasi Saya + +
+ ±${Math.round(pos.coords.accuracy)}m · ${lat.toFixed(6)}, ${lng.toFixed(6)} +
`).openPopup(); + map.setView([lat, lng], 15); + document.getElementById('my-location-info').style.display = 'block'; + document.getElementById('my-addr').textContent = truncate(addr, 80); + document.getElementById('my-coord').textContent = `${lat.toFixed(6)}, ${lng.toFixed(6)} · ±${Math.round(pos.coords.accuracy)}m`; + const lb = document.getElementById('loc-bar'); + lb.style.display = 'flex'; + document.getElementById('loc-bar-addr').textContent = truncate(addr, 60); + document.getElementById('loc-bar-coord').textContent = `${lat.toFixed(5)}, ${lng.toFixed(5)}`; + showToast('✅ Lokasi berhasil ditemukan!', '#f59e0b'); + }, (err) => { + const msgs = { 1:'Izin lokasi ditolak', 2:'Posisi tidak tersedia', 3:'Timeout' }; + showToast('❌ ' + (msgs[err.code] || 'Gagal mendapatkan lokasi'), '#ef4444'); + }, { enableHighAccuracy: false, timeout: 10000 }); +} +document.getElementById('btn-locate').addEventListener('click', doLocate); +document.getElementById('btn-locate-map').addEventListener('click', doLocate); + +// ═══════════════════════════════════════════════════════════════ +// KEYBOARD +// ═══════════════════════════════════════════════════════════════ +document.addEventListener('keydown', e => { + if (e.key === 'Escape') { + if (!document.getElementById('form-overlay').classList.contains('hidden')) { + closeFormOverlay(); + } else { + mode = null; updateToolbar(); resetMapCursor(); clearHighlights(); + } + } +}); \ No newline at end of file diff --git a/assets/css/admin.css b/assets/css/admin.css new file mode 100644 index 0000000..370b393 --- /dev/null +++ b/assets/css/admin.css @@ -0,0 +1,491 @@ +/* ================================================================ + Admin Panel CSS — WebGIS Sebaran Penduduk Miskin + Dark theme, Bootstrap 5 override, glassmorphism + ================================================================ */ + +@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap'); + +:root { + --bg: #0f1117; + --bg2: #161b27; + --bg3: #1e2538; + --bg4: #242c42; + --border: rgba(255,255,255,0.07); + --text: #e8ecf4; + --text2: #8890b0; + --text3: #565e80; + --accent: #5b7fff; + --accent2: #7c5bff; + --green: #22c55e; + --red: #ef4444; + --yellow: #f59e0b; + --glass: rgba(255,255,255,0.04); + --glass-hover: rgba(255,255,255,0.07); + --radius: 12px; + --shadow: 0 8px 32px rgba(0,0,0,0.45); + --navbar-h: 60px; + --sidebar-w: 240px; + --transition: 0.2s cubic-bezier(0.4,0,0.2,1); +} + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html, body { + height: 100%; + font-family: 'Plus Jakarta Sans', sans-serif; + background: var(--bg); + color: var(--text); + font-size: 14px; +} + +/* ── NAVBAR ────────────────────────────────────────────────────── */ +.admin-navbar { + position: fixed; top: 0; left: 0; right: 0; + height: var(--navbar-h); z-index: 1000; + background: rgba(22,27,39,0.95); backdrop-filter: blur(14px); + border-bottom: 1px solid var(--border); + display: flex; align-items: center; + padding: 0 24px; gap: 16px; + box-shadow: 0 2px 20px rgba(0,0,0,0.3); +} + +.navbar-brand { + display: flex; align-items: center; gap: 10px; + text-decoration: none; font-weight: 800; font-size: 14px; + color: var(--text); flex-shrink: 0; +} + +.navbar-brand .brand-icon { + width: 34px; height: 34px; + background: linear-gradient(135deg, var(--accent), var(--accent2)); + border-radius: 9px; display: flex; align-items: center; justify-content: center; + font-size: 16px; +} + +.navbar-right { margin-left: auto; display: flex; align-items: center; gap: 10px; } + +.btn-nav-link { + display: flex; align-items: center; gap: 6px; + padding: 6px 14px; border-radius: 8px; + background: var(--glass); border: 1px solid var(--border); + color: var(--text2); text-decoration: none; + font-size: 12px; font-weight: 600; + transition: all var(--transition); white-space: nowrap; +} +.btn-nav-link:hover { background: var(--glass-hover); color: var(--text); border-color: rgba(255,255,255,0.12); } +.btn-nav-link.danger { color: var(--red); border-color: rgba(239,68,68,0.2); background: rgba(239,68,68,0.05); } +.btn-nav-link.danger:hover { background: rgba(239,68,68,0.12); } + +.user-badge { + display: flex; align-items: center; gap: 7px; + padding: 5px 12px; background: var(--glass); + border: 1px solid var(--border); border-radius: 20px; + font-size: 12px; font-weight: 600; color: var(--text2); +} +.user-role-dot { width: 8px; height: 8px; border-radius: 50%; } +.user-role-dot.admin { background: var(--accent); box-shadow: 0 0 6px var(--accent); } +.user-role-dot.operator { background: var(--green); box-shadow: 0 0 6px var(--green); } +.role-pill { + font-size: 9px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.5px; + padding: 2px 7px; border-radius: 4px; + background: rgba(91,127,255,0.15); color: var(--accent); +} + +/* ── LAYOUT ─────────────────────────────────────────────────────── */ +.admin-layout { + display: flex; padding-top: var(--navbar-h); + min-height: 100vh; +} + +/* ── SIDEBAR ────────────────────────────────────────────────────── */ +.admin-sidebar { + width: var(--sidebar-w); min-width: var(--sidebar-w); + position: fixed; top: var(--navbar-h); bottom: 0; left: 0; + background: var(--bg2); border-right: 1px solid var(--border); + overflow-y: auto; z-index: 100; + scrollbar-width: thin; scrollbar-color: var(--bg3) transparent; + padding: 16px 0 24px; +} + +.sidebar-nav { display: flex; flex-direction: column; gap: 2px; padding: 0 10px; } + +.nav-section-label { + font-size: 9px; font-weight: 800; text-transform: uppercase; + letter-spacing: 0.8px; color: var(--text3); + padding: 10px 8px 4px; margin-top: 6px; +} + +.nav-item { + display: flex; align-items: center; gap: 9px; + padding: 9px 12px; border-radius: 9px; + text-decoration: none; color: var(--text2); + font-size: 13px; font-weight: 600; + transition: all var(--transition); position: relative; +} +.nav-item:hover { background: var(--glass-hover); color: var(--text); } +.nav-item.active { background: rgba(91,127,255,0.15); color: var(--accent); } +.nav-item.active::before { + content: ''; position: absolute; left: 0; top: 8px; bottom: 8px; + width: 3px; border-radius: 0 3px 3px 0; background: var(--accent); +} +.nav-item.danger { color: var(--red); } +.nav-item.danger:hover { background: rgba(239,68,68,0.1); } +.nav-item i { width: 16px; text-align: center; flex-shrink: 0; } + +/* ── MAIN CONTENT ───────────────────────────────────────────────── */ +.admin-content { + flex: 1; margin-left: var(--sidebar-w); + padding: 28px 28px 40px; + min-height: calc(100vh - var(--navbar-h)); +} + +/* ── PAGE HEADER ────────────────────────────────────────────────── */ +.page-header { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 24px; flex-wrap: wrap; gap: 12px; +} +.page-title-block h1 { + font-size: 20px; font-weight: 800; margin-bottom: 2px; +} +.page-subtitle { font-size: 12px; color: var(--text2); } +.page-actions { display: flex; gap: 8px; flex-wrap: wrap; } + +/* ── STAT CARDS ─────────────────────────────────────────────────── */ +.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px,1fr)); gap: 14px; margin-bottom: 24px; } + +.stat-card { + background: var(--bg2); border: 1px solid var(--border); + border-radius: var(--radius); padding: 16px 18px; + transition: all var(--transition); cursor: default; + position: relative; overflow: hidden; +} +.stat-card::before { + content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px; + border-radius: var(--radius) var(--radius) 0 0; +} +.stat-card.blue::before { background: linear-gradient(90deg, var(--accent), var(--accent2)); } +.stat-card.green::before { background: linear-gradient(90deg, var(--green), #16a34a); } +.stat-card.red::before { background: linear-gradient(90deg, var(--red), #b91c1c); } +.stat-card.yellow::before { background: linear-gradient(90deg, var(--yellow), #d97706); } +.stat-card:hover { transform: translateY(-2px); box-shadow: var(--shadow); } + +.stat-icon { font-size: 26px; margin-bottom: 8px; } +.stat-value { font-size: 28px; font-weight: 800; line-height: 1; } +.stat-label { font-size: 11px; color: var(--text2); margin-top: 4px; font-weight: 500; } +.stat-card.blue .stat-value { color: var(--accent); } +.stat-card.green .stat-value { color: var(--green); } +.stat-card.red .stat-value { color: var(--red); } +.stat-card.yellow .stat-value { color: var(--yellow); } + +/* ── CARDS ──────────────────────────────────────────────────────── */ +.card { + background: var(--bg2); border: 1px solid var(--border); + border-radius: var(--radius); overflow: hidden; + box-shadow: 0 2px 12px rgba(0,0,0,0.2); +} + +.card-header { + padding: 14px 18px; border-bottom: 1px solid var(--border); + display: flex; align-items: center; justify-content: space-between; + background: rgba(255,255,255,0.02); +} +.card-title { font-size: 14px; font-weight: 700; } +.card-body { padding: 18px; } + +/* Chart container */ +.chart-container { position: relative; width: 100%; } + +/* ── TABLE ──────────────────────────────────────────────────────── */ +.table-responsive { overflow-x: auto; } + +.admin-table { + width: 100%; border-collapse: collapse; + font-size: 13px; +} +.admin-table th { + padding: 10px 14px; + background: var(--bg3); color: var(--text2); + font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.6px; + border-bottom: 1px solid var(--border); + white-space: nowrap; text-align: left; +} +.admin-table td { + padding: 11px 14px; + border-bottom: 1px solid var(--border); + color: var(--text); vertical-align: middle; +} +.admin-table tr:last-child td { border-bottom: none; } +.admin-table tbody tr:hover td { background: var(--glass); } +.admin-table .td-actions { white-space: nowrap; } + +/* ── BADGES ─────────────────────────────────────────────────────── */ +.badge-status { + display: inline-flex; align-items: center; gap: 4px; + padding: 3px 9px; border-radius: 6px; + font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.4px; +} +.badge-sudah { background: rgba(34,197,94,0.15); color: var(--green); } +.badge-belum { background: rgba(239,68,68,0.14); color: var(--red); } +.badge-menunggu { background: rgba(245,158,11,0.15); color: var(--yellow); } +.badge-admin { background: rgba(91,127,255,0.15); color: var(--accent); } +.badge-operator { background: rgba(34,197,94,0.12); color: var(--green); } + +/* ── BUTTONS ─────────────────────────────────────────────────────── */ +.btn { + display: inline-flex; align-items: center; gap: 6px; + padding: 8px 16px; border-radius: 8px; + font-size: 13px; font-weight: 600; font-family: inherit; + border: none; cursor: pointer; + transition: all var(--transition); white-space: nowrap; + text-decoration: none; +} +.btn-sm { padding: 5px 10px; font-size: 12px; border-radius: 6px; } + +.btn-primary { background: var(--accent); color: #fff; } +.btn-primary:hover { background: #4a6ef5; transform: translateY(-1px); box-shadow: 0 4px 14px rgba(91,127,255,0.4); } + +.btn-success { background: var(--green); color: #fff; } +.btn-success:hover { filter: brightness(1.1); transform: translateY(-1px); } + +.btn-danger { background: rgba(239,68,68,0.12); color: var(--red); border: 1px solid rgba(239,68,68,0.2); } +.btn-danger:hover { background: rgba(239,68,68,0.2); } + +.btn-warning { background: rgba(245,158,11,0.12); color: var(--yellow); border: 1px solid rgba(245,158,11,0.2); } +.btn-warning:hover { background: rgba(245,158,11,0.22); } + +.btn-ghost { background: var(--glass); color: var(--text2); border: 1px solid var(--border); } +.btn-ghost:hover { background: var(--glass-hover); color: var(--text); } + +.btn-outline-accent { background: transparent; color: var(--accent); border: 1px solid rgba(91,127,255,0.35); } +.btn-outline-accent:hover { background: rgba(91,127,255,0.1); } + +/* ── FORM ELEMENTS ──────────────────────────────────────────────── */ +.form-group { margin-bottom: 14px; } +.form-label { + display: block; font-size: 11px; font-weight: 700; + color: var(--text2); margin-bottom: 5px; + text-transform: uppercase; letter-spacing: 0.5px; +} +.form-control { + width: 100%; padding: 9px 12px; + background: var(--bg3); border: 1px solid var(--border); + border-radius: 8px; color: var(--text); + font-size: 13px; font-family: inherit; + transition: border-color var(--transition); outline: none; +} +.form-control:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(91,127,255,0.12); } +.form-control::placeholder { color: var(--text3); } +select.form-control { cursor: pointer; } + +.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } + +.form-hint { font-size: 10px; color: var(--text3); margin-top: 4px; } + +/* Input group */ +.input-group { display: flex; align-items: stretch; } +.input-group .form-control { border-radius: 8px 0 0 8px; } +.input-group .input-group-btn { + padding: 9px 12px; background: var(--bg3); border: 1px solid var(--border); + border-left: none; border-radius: 0 8px 8px 0; cursor: pointer; color: var(--text2); + font-size: 13px; transition: all var(--transition); +} +.input-group .input-group-btn:hover { background: var(--bg4); color: var(--text); } + +/* ── SEARCH BAR ─────────────────────────────────────────────────── */ +.search-bar { position: relative; } +.search-bar input { padding-left: 36px; } +.search-bar .search-ico { + position: absolute; left: 12px; top: 50%; transform: translateY(-50%); + color: var(--text3); font-size: 13px; pointer-events: none; +} + +/* ── MODAL (Bootstrap override) ─────────────────────────────────── */ +.modal-content { + background: var(--bg2) !important; + border: 1px solid var(--border) !important; + border-radius: 16px !important; + color: var(--text) !important; +} +.modal-header { + border-bottom: 1px solid var(--border) !important; + padding: 16px 20px !important; +} +.modal-title { font-weight: 700 !important; font-size: 15px !important; } +.btn-close { filter: invert(1) !important; opacity: 0.5 !important; } +.btn-close:hover { opacity: 1 !important; } +.modal-body { padding: 18px 20px !important; } +.modal-footer { + border-top: 1px solid var(--border) !important; + padding: 12px 20px !important; +} + +/* ── ALERT ──────────────────────────────────────────────────────── */ +.alert { border-radius: 10px; padding: 12px 16px; font-size: 13px; border: none; } +.alert-danger { background: rgba(239,68,68,0.1); color: var(--red); border-left: 3px solid var(--red); } +.alert-success { background: rgba(34,197,94,0.1); color: var(--green); border-left: 3px solid var(--green); } +.alert-warning { background: rgba(245,158,11,0.1); color: var(--yellow); border-left: 3px solid var(--yellow); } +.alert-info { background: rgba(91,127,255,0.1); color: var(--accent); border-left: 3px solid var(--accent); } + +/* ── SECTION LABEL ──────────────────────────────────────────────── */ +.section-label { + font-size: 10px; font-weight: 800; color: var(--text2); + text-transform: uppercase; letter-spacing: 0.7px; + margin: 16px 0 8px; padding-bottom: 6px; + border-bottom: 1px solid var(--border); + display: flex; align-items: center; gap: 6px; +} +.section-label::before { + content: ''; display: block; width: 3px; height: 12px; + background: linear-gradient(var(--accent), var(--accent2)); border-radius: 2px; +} + +/* ── ANGGOTA ROWS ───────────────────────────────────────────────── */ +.anggota-row { + background: var(--bg3); border: 1px solid var(--border); + border-radius: 9px; padding: 12px; margin-bottom: 10px; +} +.anggota-row-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; } +.anggota-num { font-size: 11px; font-weight: 700; color: var(--text2); } +.btn-remove-anggota { + width: 22px; height: 22px; border: none; border-radius: 5px; + background: rgba(239,68,68,0.12); color: var(--red); + cursor: pointer; font-size: 12px; display: flex; align-items: center; justify-content: center; +} + +/* ── UPLOAD AREA ─────────────────────────────────────────────────── */ +.upload-area { + border: 2px dashed var(--border); border-radius: 10px; + padding: 20px; text-align: center; cursor: pointer; + transition: all var(--transition); position: relative; + display: block; +} +.upload-area:hover, .upload-area.dragover { + border-color: var(--accent); background: rgba(91,127,255,0.05); +} +.upload-text { font-size: 12px; color: var(--text2); } +.upload-preview { margin-top: 8px; } +.upload-preview img { max-width: 100%; max-height: 120px; border-radius: 8px; } + +/* ── IMPORT/EXPORT PAGE ─────────────────────────────────────────── */ +.import-zone { + border: 2px dashed rgba(91,127,255,0.3); border-radius: 14px; + padding: 36px; text-align: center; cursor: pointer; + transition: all var(--transition); position: relative; background: rgba(91,127,255,0.03); +} +.import-zone:hover, .import-zone.dragover { + border-color: var(--accent); background: rgba(91,127,255,0.07); +} +.import-zone-icon { font-size: 40px; margin-bottom: 10px; } +.import-zone-text { font-size: 14px; font-weight: 600; color: var(--text2); margin-bottom: 4px; } +.import-zone-sub { font-size: 11px; color: var(--text3); } + +.export-card { + background: var(--bg2); border: 1px solid var(--border); + border-radius: 12px; padding: 20px; text-align: center; + transition: all var(--transition); +} +.export-card:hover { border-color: rgba(91,127,255,0.3); transform: translateY(-2px); box-shadow: var(--shadow); } +.export-icon { font-size: 36px; margin-bottom: 10px; } +.export-title { font-size: 14px; font-weight: 700; margin-bottom: 4px; } +.export-sub { font-size: 11px; color: var(--text2); margin-bottom: 14px; } + +/* ── LOGIN PAGE ──────────────────────────────────────────────────── */ +.login-page { + min-height: 100vh; display: flex; align-items: center; justify-content: center; + background: var(--bg); + background-image: radial-gradient(ellipse at 20% 50%, rgba(91,127,255,0.08) 0%, transparent 50%), + radial-gradient(ellipse at 80% 20%, rgba(124,91,255,0.06) 0%, transparent 40%); + padding: 20px; +} + +.login-card { + width: 100%; max-width: 420px; + background: var(--bg2); border: 1px solid var(--border); + border-radius: 20px; padding: 36px; + box-shadow: 0 20px 60px rgba(0,0,0,0.5); +} + +.login-logo { + text-align: center; margin-bottom: 28px; +} +.login-logo-icon { + width: 56px; height: 56px; margin: 0 auto 10px; + background: linear-gradient(135deg, var(--accent), var(--accent2)); + border-radius: 16px; display: flex; align-items: center; + justify-content: center; font-size: 24px; + box-shadow: 0 8px 24px rgba(91,127,255,0.4); +} +.login-logo h1 { font-size: 16px; font-weight: 800; margin-bottom: 3px; } +.login-logo p { font-size: 11px; color: var(--text2); } + +.login-input { + position: relative; margin-bottom: 14px; +} +.login-input i { + position: absolute; left: 13px; top: 50%; transform: translateY(-50%); + color: var(--text3); font-size: 15px; pointer-events: none; +} +.login-input input { + width: 100%; padding: 11px 12px 11px 40px; + background: var(--bg3); border: 1px solid var(--border); + border-radius: 10px; color: var(--text); + font-size: 14px; font-family: inherit; + transition: all var(--transition); outline: none; +} +.login-input input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(91,127,255,0.15); } +.login-input input::placeholder { color: var(--text3); } + +.login-btn { + width: 100%; padding: 12px; + background: linear-gradient(135deg, var(--accent), var(--accent2)); + border: none; border-radius: 10px; color: #fff; + font-size: 14px; font-weight: 700; font-family: inherit; + cursor: pointer; transition: all var(--transition); + box-shadow: 0 4px 14px rgba(91,127,255,0.4); +} +.login-btn:hover { transform: translateY(-1px); box-shadow: 0 6px 20px rgba(91,127,255,0.5); } + +.login-footer { + text-align: center; margin-top: 16px; + font-size: 11px; color: var(--text3); +} + +/* ── PAGINATION ──────────────────────────────────────────────────── */ +.pagination { display: flex; gap: 4px; align-items: center; flex-wrap: wrap; } +.page-btn { + padding: 5px 10px; border-radius: 6px; + background: var(--glass); border: 1px solid var(--border); + color: var(--text2); font-size: 12px; cursor: pointer; + transition: all var(--transition); +} +.page-btn:hover, .page-btn.active { background: rgba(91,127,255,0.15); color: var(--accent); border-color: rgba(91,127,255,0.3); } +.page-btn:disabled { opacity: 0.4; cursor: not-allowed; } + +/* ── RANGE ───────────────────────────────────────────────────────── */ +.range-wrapper { display: flex; align-items: center; gap: 10px; } +.range-wrapper input[type=range] { flex: 1; accent-color: var(--accent); } +.range-val { font-weight: 700; color: var(--accent); font-size: 13px; min-width: 50px; text-align: right; } + +/* ── PREVIEW IMAGE ────────────────────────────────────────────────── */ +.img-preview { + max-width: 100%; max-height: 140px; object-fit: contain; + border-radius: 8px; border: 1px solid var(--border); +} + +/* ── RESPONSIVE ─────────────────────────────────────────────────── */ +@media (max-width: 900px) { + .admin-sidebar { transform: translateX(-100%); transition: transform 0.3s; } + .admin-sidebar.open { transform: translateX(0); } + .admin-content { margin-left: 0; padding: 20px 16px; } + .form-row { grid-template-columns: 1fr; } + .stats-row { grid-template-columns: 1fr 1fr; } +} +@media (max-width: 480px) { + .stats-row { grid-template-columns: 1fr; } + .page-header { flex-direction: column; align-items: flex-start; } +} + +/* ── SCROLLBAR ───────────────────────────────────────────────────── */ +::-webkit-scrollbar { width: 5px; height: 5px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--bg3); border-radius: 3px; } diff --git a/assets/css/app.css b/assets/css/app.css new file mode 100644 index 0000000..530481d --- /dev/null +++ b/assets/css/app.css @@ -0,0 +1,463 @@ +/* ================================================================ + WebGIS Sebaran Penduduk Miskin — Dashboard CSS + ================================================================ */ + +@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap'); + +/* ── Reset & Variables ──────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #0f1117; + --bg2: #161b27; + --bg3: #1e2538; + --sidebar-w: 340px; + --border: rgba(255,255,255,0.07); + --text: #e8ecf4; + --text2: #8890b0; + --text3: #565e80; + --accent: #5b7fff; + --accent2: #7c5bff; + --green: #22c55e; + --red: #ef4444; + --yellow: #f59e0b; + --glass: rgba(255,255,255,0.04); + --glass-hover: rgba(255,255,255,0.07); + --radius: 12px; + --shadow: 0 8px 32px rgba(0,0,0,0.4); + --transition: 0.2s cubic-bezier(0.4,0,0.2,1); +} + +html, body { height: 100%; overflow: hidden; font-family: 'Plus Jakarta Sans', sans-serif; background: var(--bg); color: var(--text); } + +/* ── Layout ─────────────────────────────────────────────────── */ +.app-layout { display: flex; height: 100vh; overflow: hidden; } + +/* ── SIDEBAR ─────────────────────────────────────────────────── */ +.sidebar { + width: var(--sidebar-w); + min-width: var(--sidebar-w); + background: var(--bg2); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + overflow: hidden; + z-index: 10; +} + +.sidebar-header { + padding: 18px 20px 14px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.sidebar-brand { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 14px; +} + +.brand-icon { + width: 38px; height: 38px; + background: linear-gradient(135deg, var(--accent), var(--accent2)); + border-radius: 10px; + display: flex; align-items: center; justify-content: center; + font-size: 18px; + flex-shrink: 0; +} + +.brand-title { font-size: 13px; font-weight: 700; line-height: 1.3; color: var(--text); } +.brand-sub { font-size: 10px; color: var(--text2); font-weight: 500; } + +/* Search box */ +.search-box { + position: relative; + margin-bottom: 8px; +} +.search-box input { + width: 100%; padding: 8px 12px 8px 34px; + background: var(--bg3); border: 1px solid var(--border); + border-radius: 8px; color: var(--text); + font-size: 12px; font-family: inherit; + transition: border-color var(--transition); + outline: none; +} +.search-box input:focus { border-color: var(--accent); } +.search-box input::placeholder { color: var(--text3); } +.search-box .search-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); font-size: 13px; pointer-events: none; } + +/* Filter row */ +.filter-row { display: flex; gap: 6px; flex-wrap: wrap; } +.filter-select { + flex: 1; min-width: 80px; + padding: 6px 8px; background: var(--bg3); + border: 1px solid var(--border); border-radius: 7px; + color: var(--text); font-size: 11px; font-family: inherit; + outline: none; cursor: pointer; +} +.filter-select:focus { border-color: var(--accent); } + +/* Stats */ +.stats-grid { + display: grid; grid-template-columns: 1fr 1fr; + gap: 8px; padding: 14px 20px; + border-bottom: 1px solid var(--border); flex-shrink: 0; +} +.stat-card { + background: var(--bg3); border-radius: 10px; + padding: 10px 12px; text-align: center; + border: 1px solid var(--border); + transition: transform var(--transition); +} +.stat-card:hover { transform: translateY(-2px); } +.stat-card.full { grid-column: 1/-1; } +.stat-num { font-size: 22px; font-weight: 800; line-height: 1; } +.stat-label{ font-size: 10px; color: var(--text2); margin-top: 3px; font-weight: 500; } +.stat-card.blue .stat-num { color: var(--accent); } +.stat-card.green .stat-num { color: var(--green); } +.stat-card.red .stat-num { color: var(--red); } +.stat-card.yellow .stat-num { color: var(--yellow); } + +/* Tabs */ +.sidebar-tabs { + display: flex; border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.tab-btn { + flex: 1; padding: 10px; + background: none; border: none; + color: var(--text2); font-size: 12px; font-weight: 600; + cursor: pointer; font-family: inherit; + transition: all var(--transition); + border-bottom: 2px solid transparent; + display: flex; align-items: center; justify-content: center; gap: 5px; +} +.tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); background: var(--glass); } +.tab-btn:hover:not(.active) { color: var(--text); background: var(--glass); } + +/* List container */ +.sidebar-body { flex: 1; overflow-y: auto; padding: 10px 12px; scrollbar-width: thin; scrollbar-color: var(--bg3) transparent; } +.sidebar-body::-webkit-scrollbar { width: 4px; } +.sidebar-body::-webkit-scrollbar-track { background: transparent; } +.sidebar-body::-webkit-scrollbar-thumb { background: var(--bg3); border-radius: 2px; } + +/* Item cards */ +.item-card { + background: var(--glass); border: 1px solid var(--border); + border-radius: var(--radius); padding: 10px 12px; + margin-bottom: 7px; cursor: pointer; + transition: all var(--transition); + display: flex; align-items: flex-start; gap: 10px; +} +.item-card:hover { background: var(--glass-hover); border-color: rgba(91,127,255,0.3); transform: translateX(2px); } +.item-card.highlighted { border-color: var(--accent); background: rgba(91,127,255,0.1); } + +.item-icon { font-size: 22px; flex-shrink: 0; width: 34px; text-align: center; } +.item-info { flex: 1; min-width: 0; } +.item-name { font-size: 12px; font-weight: 700; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.item-addr { font-size: 10px; color: var(--text2); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.item-coord{ font-size: 9px; color: var(--text3); margin-top: 2px; font-family: 'JetBrains Mono', monospace; } +.item-meta { display: flex; gap: 5px; align-items: center; margin-top: 4px; flex-wrap: wrap; } + +.item-actions { display: flex; gap: 4px; flex-shrink: 0; flex-direction: column; align-items: center; } +.btn-icon { + width: 26px; height: 26px; border: none; border-radius: 6px; + cursor: pointer; font-size: 12px; display: flex; align-items: center; justify-content: center; + transition: all var(--transition); +} +.btn-icon.edit { background: rgba(91,127,255,0.15); color: var(--accent); } +.btn-icon.delete { background: rgba(239,68,68,0.12); color: var(--red); } +.btn-icon:hover { filter: brightness(1.3); transform: scale(1.1); } + +/* Radius slider in card */ +.item-radius-row { display: flex; align-items: center; gap: 6px; margin-top: 5px; } +.item-radius-row input[type=range] { flex: 1; height: 3px; accent-color: var(--accent); cursor: pointer; } +.radius-val { font-size: 10px; color: var(--accent); font-weight: 700; min-width: 38px; } + +/* Badge */ +.badge-status { + display: inline-block; padding: 2px 7px; border-radius: 4px; + font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; +} +.badge-belum { background: rgba(239,68,68,0.15); color: var(--red); } +.badge-sudah { background: rgba(34,197,94,0.15); color: var(--green); } +.badge-menunggu { background: rgba(245,158,11,0.15); color: var(--yellow); } + +/* Nearest tag */ +.nearest-tag { font-size: 9px; color: var(--accent); background: rgba(91,127,255,0.12); padding: 2px 6px; border-radius: 4px; } + +/* Empty state */ +.empty-state { text-align: center; padding: 24px 12px; color: var(--text3); font-size: 12px; } +.empty-state .empty-icon { font-size: 28px; margin-bottom: 6px; display: block; opacity: 0.5; } + +/* Sidebar footer */ +.sidebar-footer { padding: 12px; border-top: 1px solid var(--border); flex-shrink: 0; } +.sidebar-btn-row { display: flex; gap: 7px; } +.btn-sm-action { + flex: 1; padding: 8px; border: 1px solid var(--border); border-radius: 8px; + background: var(--glass); color: var(--text2); + font-size: 11px; font-weight: 600; font-family: inherit; + cursor: pointer; transition: all var(--transition); + display: flex; align-items: center; justify-content: center; gap: 4px; +} +.btn-sm-action:hover { background: var(--glass-hover); color: var(--text); border-color: rgba(255,255,255,0.15); } +.btn-sm-action.accent { border-color: rgba(91,127,255,0.3); color: var(--accent); background: rgba(91,127,255,0.08); } +.btn-sm-action.accent:hover { background: rgba(91,127,255,0.15); } + +/* ── MAP CONTAINER ────────────────────────────────────────────── */ +.map-wrap { flex: 1; position: relative; overflow: hidden; } +#map { width: 100%; height: 100%; } +#map.adding-ibadah { cursor: crosshair !important; } +#map.adding-miskin { cursor: cell !important; } + +/* ── TOOLBAR (floating) ───────────────────────────────────────── */ +.map-toolbar { + position: absolute; top: 16px; left: 50%; transform: translateX(-50%); + z-index: 900; display: flex; align-items: center; gap: 6px; + background: rgba(15,17,23,0.92); backdrop-filter: blur(12px); + border: 1px solid var(--border); border-radius: 50px; + padding: 6px 12px; box-shadow: var(--shadow); +} +.toolbar-label { font-size: 11px; color: var(--text2); font-weight: 600; margin-right: 4px; } +.toggle-btn { + padding: 6px 14px; border: 1px solid var(--border); border-radius: 50px; + background: var(--glass); color: var(--text2); + font-size: 11px; font-weight: 600; font-family: inherit; + cursor: pointer; transition: all var(--transition); + white-space: nowrap; +} +.toggle-btn:hover { background: var(--glass-hover); color: var(--text); } +.toggle-btn.active-ibadah { background: rgba(91,127,255,0.2); color: var(--accent); border-color: rgba(91,127,255,0.4); } +.toggle-btn.active-miskin { background: rgba(34,197,94,0.15); color: var(--green); border-color: rgba(34,197,94,0.35); } +.toolbar-divider { width: 1px; height: 20px; background: var(--border); margin: 0 4px; } +.toolbar-icon-btn { + width: 32px; height: 32px; border: 1px solid var(--border); border-radius: 50%; + background: var(--glass); color: var(--text2); + font-size: 14px; cursor: pointer; display: flex; align-items: center; justify-content: center; + transition: all var(--transition); +} +.toolbar-icon-btn:hover { background: var(--glass-hover); color: var(--text); } + +/* ── MAP CONTROLS (bottom-right) ──────────────────────────────── */ +.map-controls { + position: absolute; bottom: 30px; right: 14px; + z-index: 900; display: flex; flex-direction: column; gap: 8px; +} +.map-ctrl-btn { + width: 42px; height: 42px; + background: rgba(15,17,23,0.92); backdrop-filter: blur(12px); + border: 1px solid var(--border); border-radius: 10px; + color: var(--text); font-size: 16px; cursor: pointer; + display: flex; align-items: center; justify-content: center; + transition: all var(--transition); box-shadow: var(--shadow); +} +.map-ctrl-btn:hover { background: var(--bg3); border-color: var(--accent); color: var(--accent); } +.map-ctrl-btn.active { background: rgba(91,127,255,0.2); border-color: var(--accent); color: var(--accent); } + +/* ── LOC BAR ─────────────────────────────────────────────────── */ +.loc-bar { + position: absolute; bottom: 10px; left: 50%; transform: translateX(-50%); + z-index: 900; background: rgba(15,17,23,0.9); backdrop-filter: blur(10px); + border: 1px solid var(--border); border-radius: 50px; + padding: 6px 16px; display: none; align-items: center; gap: 8px; + font-size: 11px; color: var(--text2); box-shadow: var(--shadow); + max-width: 600px; white-space: nowrap; overflow: hidden; + animation: fadeIn 0.3s ease; +} +.loc-bar strong { color: var(--text); font-size: 11px; } +@keyframes fadeIn { from { opacity:0; transform: translateX(-50%) translateY(6px); } to { opacity:1; transform: translateX(-50%) translateY(0); } } + +/* ── TOAST ─────────────────────────────────────────────────────── */ +#toast { + position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); + background: rgba(22,27,39,0.95); backdrop-filter: blur(10px); + border-radius: 10px; padding: 12px 20px; + font-size: 13px; font-weight: 600; + z-index: 9999; box-shadow: var(--shadow); + border-left: 3px solid var(--accent); + display: none; max-width: 420px; text-align: center; + animation: toastIn 0.3s ease; +} +@keyframes toastIn { from { opacity:0; transform: translateX(-50%) translateY(10px); } to { opacity:1; transform: translateX(-50%) translateY(0); } } + +/* ── MODAL ─────────────────────────────────────────────────────── */ +.modal-overlay { + position: fixed; inset: 0; z-index: 2000; + background: rgba(0,0,0,0.7); backdrop-filter: blur(6px); + display: flex; align-items: center; justify-content: center; + padding: 20px; + opacity: 0; pointer-events: none; + transition: opacity 0.25s; +} +.modal-overlay.open { opacity: 1; pointer-events: all; } + +.modal-card { + background: var(--bg2); border: 1px solid var(--border); + border-radius: 18px; width: 100%; max-width: 540px; + max-height: 90vh; display: flex; flex-direction: column; + box-shadow: 0 20px 60px rgba(0,0,0,0.6); + transform: scale(0.95) translateY(10px); + transition: transform 0.25s; + overflow: hidden; +} +.modal-overlay.open .modal-card { transform: scale(1) translateY(0); } + +.modal-header { + padding: 16px 20px; border-bottom: 1px solid var(--border); + display: flex; align-items: center; justify-content: space-between; + flex-shrink: 0; +} +.modal-title { font-size: 15px; font-weight: 700; } +.modal-close { + width: 28px; height: 28px; border: none; border-radius: 50%; + background: var(--glass); color: var(--text2); cursor: pointer; + font-size: 14px; display: flex; align-items: center; justify-content: center; + transition: all var(--transition); +} +.modal-close:hover { background: rgba(239,68,68,0.15); color: var(--red); } + +.modal-body { flex: 1; overflow-y: auto; padding: 18px 20px; scrollbar-width: thin; scrollbar-color: var(--bg3) transparent; } +.modal-footer { padding: 14px 20px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 8px; flex-shrink: 0; } + +/* Form elements */ +.form-group { margin-bottom: 14px; } +.form-label { display: block; font-size: 11px; font-weight: 700; color: var(--text2); margin-bottom: 5px; text-transform: uppercase; letter-spacing: 0.5px; } +.form-control { + width: 100%; padding: 9px 12px; + background: var(--bg3); border: 1px solid var(--border); + border-radius: 8px; color: var(--text); + font-size: 13px; font-family: inherit; + transition: border-color var(--transition); + outline: none; +} +.form-control:focus { border-color: var(--accent); } +.form-control::placeholder { color: var(--text3); } +select.form-control { cursor: pointer; } +.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } + +.addr-preview { + padding: 8px 10px; background: rgba(91,127,255,0.06); + border: 1px solid rgba(91,127,255,0.15); border-radius: 7px; + font-size: 11px; color: var(--text2); margin-top: 5px; min-height: 32px; + display: flex; align-items: center; gap: 6px; +} +.addr-loading { color: var(--text3); } + +/* Range slider */ +.range-wrapper { display: flex; align-items: center; gap: 10px; } +.range-wrapper input[type=range] { flex: 1; accent-color: var(--accent); } +.range-val { font-weight: 700; color: var(--accent); font-size: 13px; min-width: 50px; text-align: right; } + +/* Anggota keluarga dynamic rows */ +.anggota-row { + background: var(--bg3); border: 1px solid var(--border); + border-radius: 8px; padding: 10px; margin-bottom: 8px; + position: relative; +} +.anggota-row-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } +.anggota-num { font-size: 11px; font-weight: 700; color: var(--text2); } +.btn-remove-anggota { + width: 22px; height: 22px; border: none; border-radius: 5px; + background: rgba(239,68,68,0.12); color: var(--red); + cursor: pointer; font-size: 12px; display: flex; align-items: center; justify-content: center; +} + +.section-label { + font-size: 11px; font-weight: 700; color: var(--text2); + text-transform: uppercase; letter-spacing: 0.6px; + margin: 14px 0 8px; padding-bottom: 5px; border-bottom: 1px solid var(--border); + display: flex; align-items: center; gap: 6px; +} +.section-label::before { content: ''; display: block; width: 3px; height: 13px; background: var(--accent); border-radius: 2px; } + +/* ── BUTTONS ───────────────────────────────────────────────────── */ +.btn { + padding: 9px 18px; border-radius: 8px; + font-size: 13px; font-weight: 600; font-family: inherit; + border: none; cursor: pointer; + transition: all var(--transition); + display: inline-flex; align-items: center; justify-content: center; gap: 6px; +} +.btn-primary { background: var(--accent); color: #fff; } +.btn-primary:hover { background: #4a6ef5; transform: translateY(-1px); } +.btn-success { background: var(--green); color: #fff; } +.btn-success:hover { filter: brightness(1.1); transform: translateY(-1px); } +.btn-danger { background: rgba(239,68,68,0.12); color: var(--red); border: 1px solid rgba(239,68,68,0.2); } +.btn-danger:hover { background: rgba(239,68,68,0.2); } +.btn-ghost { background: var(--glass); color: var(--text2); border: 1px solid var(--border); } +.btn-ghost:hover { background: var(--glass-hover); color: var(--text); } +.btn-outline-accent { background: transparent; color: var(--accent); border: 1px solid rgba(91,127,255,0.35); } +.btn-outline-accent:hover { background: rgba(91,127,255,0.1); } + +/* ── POPUP (Leaflet) ────────────────────────────────────────────── */ +.leaflet-popup-content-wrapper { + background: var(--bg2) !important; + border: 1px solid var(--border) !important; + border-radius: 12px !important; + box-shadow: 0 8px 32px rgba(0,0,0,0.5) !important; + color: var(--text) !important; +} +.leaflet-popup-tip { background: var(--bg2) !important; } +.leaflet-popup-content { margin: 14px 16px !important; font-family: 'Plus Jakarta Sans', sans-serif !important; font-size: 13px !important; line-height: 1.5; min-width: 200px; } + +.popup-title { font-size: 14px; font-weight: 700; margin-bottom: 8px; } +.popup-addr { font-size: 11px; color: var(--text2); margin-top: 4px; } +.popup-coord { font-size: 10px; color: var(--text3); font-family: monospace; } +.popup-divider { height: 1px; background: var(--border); margin: 8px 0; } +.popup-row { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--text2); margin: 3px 0; } +.popup-row strong { color: var(--text); } +.popup-actions { display: flex; gap: 6px; margin-top: 10px; } +.popup-btn { + flex: 1; padding: 5px 10px; border-radius: 6px; border: none; + font-size: 11px; font-weight: 600; font-family: inherit; cursor: pointer; + transition: all var(--transition); +} +.popup-btn.edit { background: rgba(91,127,255,0.15); color: var(--accent); } +.popup-btn.danger { background: rgba(239,68,68,0.12); color: var(--red); } +.popup-btn:hover { filter: brightness(1.3); } + +/* ── MARKER ANIMATIONS ───────────────────────────────────────────── */ +@keyframes markerPulse { + 0%,100% { transform: scale(1); } + 50% { transform: scale(1.25); } +} +.marker-pulsing { animation: markerPulse 0.6s ease 3; } + +/* ── UPLOAD PREVIEW ───────────────────────────────────────────────── */ +.upload-area { + border: 2px dashed var(--border); border-radius: 10px; + padding: 16px; text-align: center; cursor: pointer; + transition: all var(--transition); position: relative; +} +.upload-area:hover, .upload-area.dragover { border-color: var(--accent); background: rgba(91,127,255,0.05); } +.upload-area input[type=file] { position: absolute; inset: 0; opacity: 0; cursor: pointer; } +.upload-text { font-size: 12px; color: var(--text2); } +.upload-preview { margin-top: 8px; } +.upload-preview img { max-width: 100%; max-height: 120px; border-radius: 6px; } + +/* ── Legend ───────────────────────────────────────────────────────── */ +.map-legend { + position: absolute; bottom: 80px; right: 14px; z-index: 800; + background: rgba(15,17,23,0.9); backdrop-filter: blur(10px); + border: 1px solid var(--border); border-radius: 10px; + padding: 10px 14px; font-size: 11px; color: var(--text2); + box-shadow: var(--shadow); +} +.legend-title { font-weight: 700; color: var(--text); margin-bottom: 7px; font-size: 11px; } +.legend-item { display: flex; align-items: center; gap: 7px; margin-bottom: 4px; } +.legend-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } +.legend-rect { width: 20px; height: 10px; border-radius: 3px; flex-shrink: 0; } + +/* ── Leaflet custom zoom ─────────────────────────────────────────── */ +.leaflet-control-zoom { display: none !important; } +.leaflet-control-attribution { font-size: 9px !important; background: rgba(0,0,0,0.5) !important; color: #666 !important; } +.leaflet-control-attribution a { color: #8890b0 !important; } + +/* ── Responsive ──────────────────────────────────────────────────── */ +@media (max-width: 768px) { + .sidebar { position: absolute; left: -100%; z-index: 500; transition: left 0.3s; width: 85vw; height: 100%; } + .sidebar.mobile-open { left: 0; } + .map-toolbar { top: 10px; padding: 5px 8px; gap: 4px; } + .toggle-btn { padding: 5px 10px; } +} diff --git a/assets/js/map.js b/assets/js/map.js new file mode 100644 index 0000000..8079a60 --- /dev/null +++ b/assets/js/map.js @@ -0,0 +1,943 @@ +// ================================================================ +// MAP.JS — WebGIS Peta Interaktif (terhubung ke PHP/MySQL API) +// Variabel global BASE_URL, MAP_LAT, MAP_LNG, MAP_ZOOM +// diinjeksikan dari index.php via PHP +// ================================================================ + +// ── INIT MAP ──────────────────────────────────────────────────── +const map = L.map('map', { preferCanvas: false, zoomControl: false }) + .setView([MAP_LAT, MAP_LNG], MAP_ZOOM); + +// Basemaps +const BASEMAPS = { + osm: L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap', + maxZoom: 19 + }), + satellite: L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { + attribution: '© Esri World Imagery', maxZoom: 19 + }), + dark: L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { + attribution: '© CartoDB', maxZoom: 19 + }) +}; +const BASEMAP_KEYS = ['osm', 'satellite', 'dark']; +const BASEMAP_ICONS = { osm: '🗺️', satellite: '🛰️', dark: '🌑' }; +let basemapIdx = 0; +BASEMAPS.osm.addTo(map); + +// Layer groups +const ibadahLayer = L.layerGroup().addTo(map); +const miskinLayer = L.layerGroup().addTo(map); +let heatLayer = null; +let routeControl = null; +let myMarker = null; + +// State +let mode = null; // 'ibadah' | 'miskin' | null +let tempMarker = null; +let pendingLatLng = null; +let pendingGeo = null; // reverse geocode result +let allIbadah = []; // Array of ibadah objects with marker & circle +let allMiskin = []; // Array of miskin objects with marker +let anggotaCount = 0; + +// ── JENIS METADATA ────────────────────────────────────────────── +const JENIS_COLOR = { + Masjid: '#10b981', Musholla: '#34d399', + Gereja: '#3b82f6', Katolik: '#60a5fa', + Pura: '#f59e0b', Vihara: '#fbbf24', + Klenteng: '#ef4444', Lainnya: '#8b5cf6' +}; +const JENIS_EMOJI = { + Masjid: '🕌', Musholla: '🕌', + Gereja: '⛪', Katolik: '⛪', + Pura: '🛕', Vihara: '🛕', + Klenteng: '⛩️', Lainnya: '🙏' +}; +const STATUS_COLOR = { sudah: '#22c55e', menunggu: '#f59e0b', belum: '#ef4444' }; +const STATUS_LABEL = { sudah: 'Sudah Dibantu', menunggu: 'Menunggu Verif.', belum: 'Belum Dibantu' }; + +// ── HELPERS ────────────────────────────────────────────────────── +function fmtRadius(v) { v = parseInt(v); return v >= 1000 ? (v/1000).toFixed(1)+' km' : v+' m'; } +function truncate(s, n) { s = (s || ''); return s.length > n ? s.slice(0,n)+'\u2026' : s; } +function fmtRupiah(n) { + if (!n && n !== 0) return '\u2014'; + return 'Rp\u00a0' + parseFloat(n).toLocaleString('id-ID', { minimumFractionDigits: 0, maximumFractionDigits: 0 }); +} +window.formatNominalInput = function(el) { + let raw = el.value.replace(/[^0-9]/g, ''); + el.value = raw.replace(/\B(?=(\d{3})+(?!\d))/g, '.'); +}; + +// ── TOAST ──────────────────────────────────────────────────────── +function showToast(msg, color = '#5b7fff') { + const t = document.getElementById('toast'); + t.innerHTML = msg; + t.style.display = 'block'; + t.style.borderLeftColor = color; + clearTimeout(t._tid); + t._tid = setTimeout(() => t.style.display = 'none', 3500); +} + +// ── ICONS ──────────────────────────────────────────────────────── +function createIbadahIcon(jenis) { + const c = JENIS_COLOR[jenis] || '#5b7fff'; + const em = JENIS_EMOJI[jenis] || '🏛️'; + return L.divIcon({ + html: `
+ ${em}
`, + iconSize:[36,36], iconAnchor:[18,36], popupAnchor:[0,-42], className:'' + }); +} + +function createMiskinIcon(status) { + const c = STATUS_COLOR[status] || '#ef4444'; + const glow = c.replace('#','') === '22c55e' + ? 'rgba(34,197,94,0.55)' : c === '#f59e0b' + ? 'rgba(245,158,11,0.55)' : 'rgba(239,68,68,0.55)'; + return L.divIcon({ + html: `
`, + iconSize:[20,20], iconAnchor:[10,10], popupAnchor:[0,-13], className:'' + }); +} + +function createTempIcon(type) { + if (type === 'ibadah') return L.divIcon({ + html: `
+ 📍
`, + iconSize:[36,36], iconAnchor:[18,36], className:'' + }); + return L.divIcon({ + html: `
`, + iconSize:[20,20], iconAnchor:[10,10], className:'' + }); +} + +function createMyIcon() { + return L.divIcon({ + html: `
+ `, + iconSize:[24,24], iconAnchor:[12,12], popupAnchor:[0,-16], className:'' + }); +} + +// ── LOAD DATA ──────────────────────────────────────────────────── +async function loadIbadah() { + try { + const q = buildIbadahQuery(); + const r = await fetch(`${BASE_URL}/api/rumah_ibadah.php${q}`); + const js = await r.json(); + if (!js.success) return; + ibadahLayer.clearLayers(); + allIbadah = []; + js.data.forEach(d => addIbadahToMap(d)); + updateStats(); + renderIbadahList(); + } catch(e) { console.error('loadIbadah:', e); } +} + +async function loadMiskin() { + try { + const q = buildMiskinQuery(); + const r = await fetch(`${BASE_URL}/api/penduduk_miskin.php${q}`); + const js = await r.json(); + if (!js.success) return; + miskinLayer.clearLayers(); + allMiskin = []; + js.data.forEach(d => addMiskinToMap(d)); + updateStats(); + renderMiskinList(); + rebuildHeatmap(); + } catch(e) { console.error('loadMiskin:', e); } +} + +function buildIbadahQuery() { + const jenis = document.getElementById('filter-jenis').value; + const kec = document.getElementById('filter-kecamatan')?.value || ''; + const kel = document.getElementById('filter-kelurahan')?.value || ''; + const search = document.getElementById('global-search').value.trim(); + const p = []; + if (jenis) p.push('jenis=' + encodeURIComponent(jenis)); + if (kec) p.push('kecamatan=' + encodeURIComponent(kec)); + if (kel) p.push('kelurahan=' + encodeURIComponent(kel)); + if (search) p.push('search=' + encodeURIComponent(search)); + return p.length ? '?' + p.join('&') : ''; +} + +function buildMiskinQuery() { + const status = document.getElementById('filter-status').value; + const kec = document.getElementById('filter-kecamatan')?.value || ''; + const kel = document.getElementById('filter-kelurahan')?.value || ''; + const search = document.getElementById('global-search').value.trim(); + const p = []; + if (status) p.push('status=' + encodeURIComponent(status)); + if (kec) p.push('kecamatan=' + encodeURIComponent(kec)); + if (kel) p.push('kelurahan=' + encodeURIComponent(kel)); + if (search) p.push('search=' + encodeURIComponent(search)); + return p.length ? '?' + p.join('&') : ''; +} + +// ── LOAD KECAMATAN & KELURAHAN OPTIONS ───────────────────────────────── +let _allWilayah = []; // cache [{kecamatan, kelurahan}] + +async function loadKecamatanOptions() { + try { + const r = await fetch(`${BASE_URL}/api/penduduk_miskin.php`); + const js = await r.json(); + if (!js.success) return; + + // Cache semua data wilayah + _allWilayah = js.data.map(d => ({ kecamatan: d.kecamatan || '', kelurahan: d.kelurahan || '' })); + + const selKec = document.getElementById('filter-kecamatan'); + if (!selKec) return; + + // Populate kecamatan + const kecs = [...new Set(_allWilayah.map(d => d.kecamatan).filter(Boolean))].sort(); + const curKec = selKec.value; + selKec.innerHTML = ''; + kecs.forEach(k => { + const opt = document.createElement('option'); + opt.value = k; opt.textContent = k; + if (k === curKec) opt.selected = true; + selKec.appendChild(opt); + }); + + // Populate kelurahan (cascade dari kecamatan terpilih) + _updateKelurahanOptions(); + } catch(e) { /* silent */ } +} + +function _updateKelurahanOptions() { + const selKel = document.getElementById('filter-kelurahan'); + if (!selKel) return; + const kec = document.getElementById('filter-kecamatan')?.value || ''; + const kels = [...new Set( + _allWilayah + .filter(d => !kec || d.kecamatan === kec) + .map(d => d.kelurahan) + .filter(Boolean) + )].sort(); + const cur = selKel.value; + selKel.innerHTML = ''; + kels.forEach(k => { + const opt = document.createElement('option'); + opt.value = k; opt.textContent = k; + if (k === cur) opt.selected = true; + selKel.appendChild(opt); + }); +} + + +function addIbadahToMap(d) { + const lat = parseFloat(d.lat); + const lng = parseFloat(d.lng); + const r = parseInt(d.radius); + const c = JENIS_COLOR[d.jenis] || '#5b7fff'; + + const marker = L.marker([lat, lng], { icon: createIbadahIcon(d.jenis) }); + const circle = L.circle([lat, lng], { + radius: r, color: c, fillColor: c, + fillOpacity: 0.07, weight: 2, dashArray: '6,4', opacity: 0.8 + }); + + const obj = { ...d, lat, lng, radius: r, marker, circle }; + marker.bindPopup(() => buildIbadahPopup(obj)); + marker.addTo(ibadahLayer); + circle.addTo(ibadahLayer); + allIbadah.push(obj); +} + +function addMiskinToMap(d) { + const lat = parseFloat(d.lat); + const lng = parseFloat(d.lng); + const marker = L.marker([lat, lng], { icon: createMiskinIcon(d.status_bantuan) }); + const obj = { ...d, lat, lng, marker }; + marker.bindPopup(() => buildMiskinPopup(obj)); + marker.addTo(miskinLayer); + allMiskin.push(obj); +} + +// ── POPUPS ─────────────────────────────────────────────────────── +function buildIbadahPopup(d) { + const em = JENIS_EMOJI[d.jenis] || '🏛️'; + return ` + + + + ${d.kontak ? `` : ''} + + + ${d.alamat ? `` : ''} + + `; +} + +function buildMiskinPopup(d) { + const sc = STATUS_COLOR[d.status_bantuan] || '#ef4444'; + const sl = STATUS_LABEL[d.status_bantuan] || d.status_bantuan; + const ib = allIbadah.find(x => x.id == d.rumah_ibadah_id); + return ` + + + + ${d.jumlah_anggota ? `` : ''} + ${ib ? `` : ''} + ${d.jarak_ke_ibadah ? `` : ''} + ${d.jenis_bantuan ? `` : ''} + ${d.nominal_bantuan ? `` : ''} + ${d.bukti_file ? `` : ''} + ${d.alamat ? `` : ''} + + `; +} + +// ── STATS ──────────────────────────────────────────────────────── +function updateStats() { + document.getElementById('stat-ibadah').textContent = allIbadah.length; + document.getElementById('stat-miskin').textContent = allMiskin.length; + document.getElementById('stat-sudah').textContent = allMiskin.filter(m => m.status_bantuan === 'sudah').length; + document.getElementById('stat-belum').textContent = allMiskin.filter(m => m.status_bantuan === 'belum').length; + document.getElementById('count-ibadah').textContent = allIbadah.length; + document.getElementById('count-miskin').textContent = allMiskin.length; + + const totalNominal = allMiskin.reduce((acc, m) => { + return acc + (m.status_bantuan === 'sudah' && m.nominal_bantuan ? parseFloat(m.nominal_bantuan) : 0); + }, 0); + const elNominal = document.getElementById('stat-nominal'); + if (elNominal) elNominal.textContent = fmtRupiah(totalNominal); +} + +// ── HEATMAP ────────────────────────────────────────────────────── +function rebuildHeatmap() { + if (!heatLayer || !map.hasLayer(heatLayer)) return; + heatLayer.setLatLngs(allMiskin.map(m => [m.lat, m.lng, 0.9])); +} + +function toggleHeatmap() { + const btn = document.getElementById('btn-heatmap'); + if (heatLayer && map.hasLayer(heatLayer)) { + map.removeLayer(heatLayer); + btn.classList.remove('active'); + showToast('🔥 Heatmap dinonaktifkan', '#f59e0b'); + } else { + const pts = allMiskin.map(m => [m.lat, m.lng, 0.9]); + if (!heatLayer) heatLayer = L.heatLayer(pts, { radius:32, blur:22, maxZoom:17 }); + else heatLayer.setLatLngs(pts); + heatLayer.addTo(map); + btn.classList.add('active'); + showToast('🔥 Heatmap aktif', '#f59e0b'); + } +} + +// ── BASEMAP ────────────────────────────────────────────────────── +function toggleBasemap() { + map.removeLayer(BASEMAPS[BASEMAP_KEYS[basemapIdx]]); + basemapIdx = (basemapIdx + 1) % BASEMAP_KEYS.length; + BASEMAPS[BASEMAP_KEYS[basemapIdx]].addTo(map); + document.getElementById('btn-layers').textContent = BASEMAP_ICONS[BASEMAP_KEYS[basemapIdx]]; + showToast(`🗺️ Basemap: ${BASEMAP_KEYS[basemapIdx].toUpperCase()}`, '#5b7fff'); +} + +// ── SIDEBAR & TABS ─────────────────────────────────────────────── +function switchTab(tab) { + document.getElementById('list-ibadah').style.display = tab === 'ibadah' ? 'block' : 'none'; + document.getElementById('list-miskin').style.display = tab === 'miskin' ? 'block' : 'none'; + document.getElementById('tab-ibadah').className = 'tab-btn' + (tab === 'ibadah' ? ' active' : ''); + document.getElementById('tab-miskin').className = 'tab-btn' + (tab === 'miskin' ? ' active' : ''); +} + +function toggleSidebar() { + document.getElementById('sidebar').classList.toggle('mobile-open'); +} + +// ── SEARCH & FILTER ────────────────────────────────────────────── +let searchTimer = null; +document.getElementById('global-search').addEventListener('input', function () { + clearTimeout(searchTimer); + searchTimer = setTimeout(() => { loadIbadah(); loadMiskin(); }, 350); +}); +document.getElementById('filter-status').addEventListener('change', loadMiskin); +document.getElementById('filter-jenis').addEventListener('change', loadIbadah); +document.getElementById('filter-kecamatan')?.addEventListener('change', () => { + _updateKelurahanOptions(); // cascade: update kelurahan sesuai kecamatan + loadIbadah(); loadMiskin(); +}); +document.getElementById('filter-kelurahan')?.addEventListener('change', () => { loadIbadah(); loadMiskin(); }); + + +// ── RENDER LISTS ───────────────────────────────────────────────── +function renderIbadahList() { + const el = document.getElementById('list-ibadah'); + if (!allIbadah.length) { + el.innerHTML = `
🏛️
Belum ada data rumah ibadah
`; + return; + } + el.innerHTML = allIbadah.map(ib => ` +
+
${JENIS_EMOJI[ib.jenis] || '🏛️'}
+
+
${ib.nama}
+
${ib.jenis}${ib.kecamatan ? ' · '+ib.kecamatan : ''}
+
+ 📏 ${fmtRadius(ib.radius)} + 👥 ${ib.jumlah_binaan || 0} binaan +
+
+ + ${fmtRadius(ib.radius)} +
+
+
+ + +
+
`).join(''); +} + +function renderMiskinList() { + const el = document.getElementById('list-miskin'); + if (!allMiskin.length) { + el.innerHTML = `
👤
Belum ada data penduduk
`; + return; + } + el.innerHTML = allMiskin.map(m => { + const ib = allIbadah.find(x => x.id == m.rumah_ibadah_id); + const sc = STATUS_COLOR[m.status_bantuan] || '#ef4444'; + return ` +
+
👤
+
+
${m.kk_nama}
+
${m.kecamatan || m.kelurahan || truncate(m.alamat||'',40) || '—'}
+
+ ${STATUS_LABEL[m.status_bantuan]||m.status_bantuan} + ${ib ? `📌 ${truncate(ib.nama,18)}` : ''} +
+
+
+ + +
+
`; + }).join(''); +} + +// ── FOCUS MARKER ───────────────────────────────────────────────── +function focusMarker(type, id) { + if (type === 'ibadah') { + const ib = allIbadah.find(x => x.id == id); if (!ib) return; + map.setView([ib.lat, ib.lng], Math.max(map.getZoom(), 16)); + ib.marker.openPopup(); + } else { + const m = allMiskin.find(x => x.id == id); if (!m) return; + map.setView([m.lat, m.lng], Math.max(map.getZoom(), 17)); + m.marker.openPopup(); + } +} + +// ── LIVE RADIUS ────────────────────────────────────────────────── +window.liveRadius = function (id, val) { + const ib = allIbadah.find(x => x.id == id); if (!ib) return; + ib.radius = parseInt(val); + if (ib.circle) ib.circle.setRadius(ib.radius); + const disp = document.getElementById('rdisplay-' + id); + if (disp) disp.textContent = fmtRadius(ib.radius); +}; + +// ── MODE / MAP CLICK ───────────────────────────────────────────── +function setMode(m) { + if (mode === m) { mode = null; _resetMode(); return; } + mode = m; _resetMode(); + if (m === 'ibadah') { + document.getElementById('map').classList.add('adding-ibadah'); + showToast('🏛️ Klik peta untuk menentukan lokasi Rumah Ibadah', '#5b7fff'); + } else { + document.getElementById('map').classList.add('adding-miskin'); + showToast('👤 Klik peta untuk menentukan lokasi Penduduk Miskin', '#22c55e'); + } + _updateToolbar(); +} + +function _resetMode() { + document.getElementById('map').classList.remove('adding-ibadah', 'adding-miskin'); +} +function _updateToolbar() { + document.getElementById('toggle-ibadah').className = 'toggle-btn' + (mode === 'ibadah' ? ' active-ibadah' : ''); + document.getElementById('toggle-miskin').className = 'toggle-btn' + (mode === 'miskin' ? ' active-miskin' : ''); +} + +map.on('click', async (e) => { + if (!mode) return; + const { lat, lng } = e.latlng; + pendingLatLng = { lat, lng }; + pendingGeo = null; + + if (tempMarker) map.removeLayer(tempMarker); + tempMarker = L.marker([lat, lng], { icon: createTempIcon(mode) }).addTo(map); + + if (mode === 'ibadah') _openModalIbadah(lat, lng, null); + else _openModalMiskin(lat, lng, null); + + // Reverse geocode async + try { + const gr = await fetch(`${BASE_URL}/api/geocode.php?lat=${lat}&lng=${lng}`); + pendingGeo = await gr.json(); + _fillGeoFields(mode); + } catch { /* no-op */ } +}); + +function _fillGeoFields(type) { + if (!pendingGeo) return; + const addr = pendingGeo.display_name || ''; + if (type === 'ibadah') { + const el = document.getElementById('ibadah-addr-display'); + if (el) el.textContent = addr ? '📍 ' + addr : '—'; + const kel = document.getElementById('ibadah-kelurahan'); + const kec = document.getElementById('ibadah-kecamatan'); + if (kel && !kel.value && pendingGeo.kelurahan) kel.value = pendingGeo.kelurahan; + if (kec && !kec.value && pendingGeo.kecamatan) kec.value = pendingGeo.kecamatan; + } else { + const el = document.getElementById('miskin-addr-display'); + if (el) el.textContent = addr ? '📍 ' + addr : '—'; + const kel = document.getElementById('miskin-kelurahan'); + const kec = document.getElementById('miskin-kecamatan'); + if (kel && !kel.value && pendingGeo.kelurahan) kel.value = pendingGeo.kelurahan; + if (kec && !kec.value && pendingGeo.kecamatan) kec.value = pendingGeo.kecamatan; + } +} + +// ── MODAL IBADAH ───────────────────────────────────────────────── +function _openModalIbadah(lat, lng, data) { + const isEdit = data !== null; + document.getElementById('modal-ibadah-title').textContent = isEdit ? '✏️ Edit Rumah Ibadah' : '🏛️ Tambah Rumah Ibadah'; + document.getElementById('ibadah-edit-id').value = isEdit ? data.id : ''; + document.getElementById('ibadah-nama').value = isEdit ? data.nama : ''; + document.getElementById('ibadah-jenis').value = isEdit ? data.jenis : 'Masjid'; + document.getElementById('ibadah-kontak').value = isEdit ? (data.kontak||'') : ''; + document.getElementById('ibadah-kelurahan').value = isEdit ? (data.kelurahan||'') : ''; + document.getElementById('ibadah-kecamatan').value = isEdit ? (data.kecamatan||'') : ''; + const rad = isEdit ? data.radius : 300; + document.getElementById('ibadah-radius').value = rad; + document.getElementById('ibadah-radius-val').textContent = fmtRadius(rad); + document.getElementById('ibadah-coord-display').textContent = `Lat: ${lat.toFixed(6)} Lng: ${lng.toFixed(6)}`; + const addrEl = document.getElementById('ibadah-addr-display'); + if (isEdit) addrEl.textContent = '📍 ' + (data.alamat || `${lat.toFixed(5)}, ${lng.toFixed(5)}`); + else addrEl.innerHTML = '🔄 Memuat alamat...'; + document.getElementById('modal-ibadah').classList.add('open'); + setTimeout(() => document.getElementById('ibadah-nama').focus(), 120); +} + +window.openEditIbadah = function (id) { + const ib = allIbadah.find(x => x.id == id); if (!ib) return; + map.closePopup(); + pendingLatLng = { lat: ib.lat, lng: ib.lng }; + _openModalIbadah(ib.lat, ib.lng, ib); +}; + +window.closeModalIbadah = function () { + document.getElementById('modal-ibadah').classList.remove('open'); + if (tempMarker && !document.getElementById('modal-miskin').classList.contains('open')) { + map.removeLayer(tempMarker); tempMarker = null; + } + if (!document.getElementById('modal-miskin').classList.contains('open')) { + pendingLatLng = null; pendingGeo = null; + mode = null; _resetMode(); _updateToolbar(); + } +}; + +document.getElementById('ibadah-radius').addEventListener('input', function () { + document.getElementById('ibadah-radius-val').textContent = fmtRadius(parseInt(this.value)); + // Live preview on existing circle + const eid = document.getElementById('ibadah-edit-id').value; + if (eid) { const ib = allIbadah.find(x => x.id == eid); if (ib?.circle) ib.circle.setRadius(parseInt(this.value)); } +}); + +document.getElementById('btn-save-ibadah').addEventListener('click', _saveIbadah); + +async function _saveIbadah() { + if (!pendingLatLng) { showToast('❌ Pilih lokasi di peta terlebih dahulu', '#ef4444'); return; } + const { lat, lng } = pendingLatLng; + const editId = document.getElementById('ibadah-edit-id').value; + const nama = document.getElementById('ibadah-nama').value.trim(); + if (!nama) { showToast('❌ Nama rumah ibadah wajib diisi!', '#ef4444'); return; } + + const payload = { + nama, jenis: document.getElementById('ibadah-jenis').value, + kontak: document.getElementById('ibadah-kontak').value, + kelurahan: document.getElementById('ibadah-kelurahan').value, + kecamatan: document.getElementById('ibadah-kecamatan').value, + alamat: pendingGeo?.display_name || '', + lat, lng, radius: parseInt(document.getElementById('ibadah-radius').value) + }; + + const btn = document.getElementById('btn-save-ibadah'); + btn.disabled = true; btn.textContent = '⏳ Menyimpan...'; + try { + const url = editId ? `${BASE_URL}/api/rumah_ibadah.php?id=${editId}` : `${BASE_URL}/api/rumah_ibadah.php`; + const resp = await fetch(url, { method: editId?'PUT':'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) }); + const json = await resp.json(); + if (json.success) { + window.closeModalIbadah(); + await Promise.all([loadIbadah(), loadMiskin()]); + showToast(`✅ ${json.message}`, '#22c55e'); + } else { showToast('❌ ' + json.message, '#ef4444'); } + } catch { showToast('❌ Gagal menyimpan data', '#ef4444'); } + finally { btn.disabled = false; btn.innerHTML = ' Simpan'; } +} + +// ── MODAL MISKIN ───────────────────────────────────────────────── +function _openModalMiskin(lat, lng, data) { + const isEdit = data !== null; + document.getElementById('modal-miskin-title').textContent = isEdit ? '✏️ Edit Penduduk Miskin' : '👤 Tambah Penduduk Miskin'; + document.getElementById('miskin-edit-id').value = isEdit ? data.id : ''; + document.getElementById('miskin-kk-nama').value = isEdit ? data.kk_nama : ''; + document.getElementById('miskin-nik').value = isEdit ? (data.nik||'') : ''; + document.getElementById('miskin-jumlah').value = isEdit ? (data.jumlah_anggota||1) : 1; + document.getElementById('miskin-kelurahan').value = isEdit ? (data.kelurahan||'') : ''; + document.getElementById('miskin-kecamatan').value = isEdit ? (data.kecamatan||'') : ''; + document.getElementById('miskin-status').value = isEdit ? data.status_bantuan : 'belum'; + document.getElementById('miskin-jenis-bantuan').value = isEdit ? (data.jenis_bantuan||'') : ''; + document.getElementById('miskin-tanggal').value = isEdit ? (data.tanggal_bantuan||'') : ''; + // Nominal bantuan + const nomEl = document.getElementById('miskin-nominal'); + if (nomEl) { + const rawNom = isEdit && data.nominal_bantuan ? parseFloat(data.nominal_bantuan) : ''; + nomEl.value = rawNom !== '' ? rawNom.toLocaleString('id-ID', {maximumFractionDigits:0}) : ''; + } + // Keterangan menunggu + const ketEl = document.getElementById('miskin-keterangan'); + if (ketEl) ketEl.value = isEdit ? (data.keterangan||'') : ''; + document.getElementById('miskin-coord-display').textContent = `Lat: ${lat.toFixed(6)} Lng: ${lng.toFixed(6)}`; + const addrEl = document.getElementById('miskin-addr-display'); + if (isEdit) addrEl.textContent = '📍 ' + (data.alamat || `${lat.toFixed(5)}, ${lng.toFixed(5)}`); + else addrEl.innerHTML = '🔄 Memuat alamat...'; + + _toggleBantuanFields(); + + // Reset anggota + anggotaCount = 0; + document.getElementById('anggota-container').innerHTML = ''; + if (isEdit && data.id) _loadAnggota(data.id); + + // Bukti preview + const prev = document.getElementById('miskin-bukti-preview'); + prev.innerHTML = isEdit && data.bukti_file + ? `
📎 File: ${data.bukti_file}
` + : ''; + document.getElementById('miskin-bukti').value = ''; + + document.getElementById('modal-miskin').classList.add('open'); + setTimeout(() => document.getElementById('miskin-kk-nama').focus(), 120); +} + +async function _loadAnggota(pid) { + try { + const r = await fetch(`${BASE_URL}/api/anggota_keluarga.php?penduduk_id=${pid}`); + const js = await r.json(); + if (js.success) js.data.forEach(a => _addAnggotaRow(a)); + } catch { /* silent */ } +} + +window.closeModalMiskin = function () { + document.getElementById('modal-miskin').classList.remove('open'); + if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; } + pendingLatLng = null; pendingGeo = null; + mode = null; _resetMode(); _updateToolbar(); +}; + +window.openEditMiskin = function (id) { + const m = allMiskin.find(x => x.id == id); if (!m) return; + map.closePopup(); + pendingLatLng = { lat: m.lat, lng: m.lng }; + _openModalMiskin(m.lat, m.lng, m); +}; + +function _toggleBantuanFields() { + const st = document.getElementById('miskin-status').value; + const showBantuan = (st === 'sudah' || st === 'menunggu'); + document.getElementById('bantuan-fields').style.display = showBantuan ? 'block' : 'none'; + const ketWrap = document.getElementById('keterangan-menunggu-wrap'); + if (ketWrap) ketWrap.style.display = (st === 'menunggu') ? 'block' : 'none'; +} +document.getElementById('miskin-status').addEventListener('change', _toggleBantuanFields); + +// Anggota rows +function _addAnggotaRow(data) { + anggotaCount++; + const idx = anggotaCount; + const container = document.getElementById('anggota-container'); + const div = document.createElement('div'); + div.className = 'anggota-row'; div.id = `anggota-row-${idx}`; + div.innerHTML = ` +
+ 👤 Anggota ${idx} + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
`; + container.appendChild(div); +} +window.addAnggotaRow = () => _addAnggotaRow(null); +window.removeAnggotaRow = (idx) => { const r = document.getElementById(`anggota-row-${idx}`); if (r) r.remove(); }; +document.getElementById('btn-add-anggota').addEventListener('click', () => _addAnggotaRow(null)); + +function _collectAnggota() { + const rows = document.querySelectorAll('.anggota-row'); + const res = []; + rows.forEach(row => { + const idx = row.id.replace('anggota-row-', ''); + const nama = document.getElementById(`ak-nama-${idx}`)?.value?.trim(); + if (!nama) return; + res.push({ + nama, + hubungan: document.getElementById(`ak-hub-${idx}`)?.value || '', + umur: document.getElementById(`ak-umur-${idx}`)?.value || '', + pekerjaan: document.getElementById(`ak-pkj-${idx}`)?.value || '', + keterangan: document.getElementById(`ak-ket-${idx}`)?.value || '' + }); + }); + return res; +} + + +document.getElementById('btn-save-miskin').addEventListener('click', _saveMiskin); + +async function _saveMiskin() { + if (!pendingLatLng) { showToast('❌ Pilih lokasi di peta terlebih dahulu', '#ef4444'); return; } + const { lat, lng } = pendingLatLng; + const editId = document.getElementById('miskin-edit-id').value; + const nama = document.getElementById('miskin-kk-nama').value.trim(); + if (!nama) { showToast('❌ Nama Kepala Keluarga wajib diisi!', '#ef4444'); return; } + + const btn = document.getElementById('btn-save-miskin'); + btn.disabled = true; btn.textContent = '⏳ Menyimpan...'; + + const fd = new FormData(); + fd.append('kk_nama', nama); + fd.append('nik', document.getElementById('miskin-nik').value); + fd.append('jumlah_anggota', document.getElementById('miskin-jumlah').value || 1); + fd.append('kelurahan', document.getElementById('miskin-kelurahan').value); + fd.append('kecamatan', document.getElementById('miskin-kecamatan').value); + fd.append('alamat', pendingGeo?.display_name || ''); + fd.append('lat', lat); fd.append('lng', lng); + fd.append('status_bantuan', document.getElementById('miskin-status').value); + fd.append('jenis_bantuan', document.getElementById('miskin-jenis-bantuan').value); + fd.append('tanggal_bantuan', document.getElementById('miskin-tanggal').value); + // Nominal bantuan: hapus titik pemisah ribuan sebelum kirim + const nomRaw = (document.getElementById('miskin-nominal')?.value || '').replace(/\./g,''); + fd.append('nominal_bantuan', nomRaw); + fd.append('anggota', JSON.stringify(_collectAnggota())); + + const buktiInput = document.getElementById('miskin-bukti'); + if (buktiInput.files[0]) fd.append('bukti_file', buktiInput.files[0]); + + try { + const url = editId ? `${BASE_URL}/api/penduduk_miskin.php?id=${editId}` : `${BASE_URL}/api/penduduk_miskin.php`; + const resp = await fetch(url, { method: editId?'PUT':'POST', body: fd }); + const json = await resp.json(); + if (json.success) { + window.closeModalMiskin(); + await loadMiskin(); + showToast(`✅ ${json.message}`, '#22c55e'); + } else { showToast('❌ ' + json.message, '#ef4444'); } + } catch { showToast('❌ Gagal menyimpan data', '#ef4444'); } + finally { btn.disabled = false; btn.innerHTML = ' Simpan'; } +} + +// ── DELETE ─────────────────────────────────────────────────────── +window.confirmDeleteIbadah = async function (id) { + if (!confirm('Hapus rumah ibadah ini? Warga yang terkait akan di-reset.')) return; + try { + const r = await fetch(`${BASE_URL}/api/rumah_ibadah.php?id=${id}`, { method:'DELETE' }); + const js = await r.json(); + if (js.success) { map.closePopup(); await Promise.all([loadIbadah(), loadMiskin()]); showToast('🗑️ Data dihapus', '#f59e0b'); } + else showToast('❌ ' + js.message, '#ef4444'); + } catch { showToast('❌ Gagal menghapus', '#ef4444'); } +}; + +window.confirmDeleteMiskin = async function (id) { + if (!confirm('Hapus data penduduk ini?')) return; + try { + const r = await fetch(`${BASE_URL}/api/penduduk_miskin.php?id=${id}`, { method:'DELETE' }); + const js = await r.json(); + if (js.success) { map.closePopup(); await loadMiskin(); showToast('🗑️ Data dihapus', '#f59e0b'); } + else showToast('❌ ' + js.message, '#ef4444'); + } catch { showToast('❌ Gagal menghapus', '#ef4444'); } +}; + +// ── GEOLOCATION ────────────────────────────────────────────────── +function doLocate() { + if (!navigator.geolocation) { showToast('❌ Browser tidak mendukung Geolocation', '#ef4444'); return; } + showToast('📡 Mendeteksi lokasi...', '#f59e0b'); + navigator.geolocation.getCurrentPosition(async (pos) => { + const lat = pos.coords.latitude, lng = pos.coords.longitude; + if (myMarker) map.removeLayer(myMarker); + myMarker = L.marker([lat, lng], { icon: createMyIcon() }).addTo(map); + + let addr = `${lat.toFixed(5)}, ${lng.toFixed(5)}`; + try { + const gr = await fetch(`${BASE_URL}/api/geocode.php?lat=${lat}&lng=${lng}`); + const gj = await gr.json(); addr = gj.display_name || addr; + } catch { /* silent */ } + + myMarker.bindPopup(`📍 Lokasi Saya + `).openPopup(); + map.setView([lat, lng], 16); + + const lb = document.getElementById('loc-bar'); + lb.style.display = 'flex'; + document.getElementById('loc-bar-addr').textContent = truncate(addr, 60); + document.getElementById('loc-bar-coord').textContent = `${lat.toFixed(5)}, ${lng.toFixed(5)} · ±${Math.round(pos.coords.accuracy)}m`; + showToast('✅ Lokasi ditemukan!', '#22c55e'); + }, (err) => { + const msgs = {1:'Izin ditolak',2:'Posisi tidak tersedia',3:'Timeout'}; + showToast('❌ ' + (msgs[err.code]||'Gagal mendapatkan lokasi'), '#ef4444'); + }, { enableHighAccuracy: true, timeout: 10000 }); +} + +// ── ROUTING ────────────────────────────────────────────────────── +window.routeTo = function (fromLat, fromLng, destLat, destLng) { + if (routeControl) { try { map.removeControl(routeControl); } catch{} routeControl = null; } + map.closePopup(); + routeControl = L.Routing.control({ + waypoints: [L.latLng(fromLat, fromLng), L.latLng(destLat, destLng)], + lineOptions: { styles: [{ color: '#5b7fff', weight: 4, opacity: 0.85 }] }, + createMarker: () => null, show: false, + addWaypoints: false, draggableWaypoints: false + }).addTo(map); + showToast('🗺️ Rute ditampilkan', '#5b7fff'); +}; + +// ── REFRESH ────────────────────────────────────────────────────── +async function refreshAll() { + showToast('🔄 Memperbarui data...', '#f59e0b'); + await Promise.all([loadIbadah(), loadMiskin()]); + showToast('✅ Data diperbarui', '#22c55e'); +} + +// ── KEYBOARD ───────────────────────────────────────────────────── +document.addEventListener('keydown', e => { + if (e.key === 'Escape') { + if (document.getElementById('modal-ibadah').classList.contains('open')) window.closeModalIbadah(); + else if (document.getElementById('modal-miskin').classList.contains('open')) window.closeModalMiskin(); + else { mode = null; _resetMode(); _updateToolbar(); } + } +}); + +// ── UPLOAD PREVIEW ─────────────────────────────────────────────── +document.getElementById('miskin-bukti')?.addEventListener('change', function () { + const prev = document.getElementById('miskin-bukti-preview'); + if (!this.files[0]) { prev.innerHTML = ''; return; } + const f = this.files[0]; + if (f.type.startsWith('image/')) { + const reader = new FileReader(); + reader.onload = (ev) => { + prev.innerHTML = `
`; + }; + reader.readAsDataURL(f); + } else { + prev.innerHTML = `
📎 ${f.name} (${(f.size/1024).toFixed(1)} KB)
`; + } +}); + +// ── NOTIFICATION SYSTEM ───────────────────────────────────────── +let notifLastCheck = new Date().toISOString().replace('T',' ').substring(0,19); +let notifCount = 0; +let notifPanelOpen = false; + +function toggleNotifPanel() { + notifPanelOpen = !notifPanelOpen; + const panel = document.getElementById('notif-panel'); + if (panel) panel.style.display = notifPanelOpen ? 'block' : 'none'; + if (notifPanelOpen) { + // Clear badge when panel opened + notifCount = 0; + const badge = document.getElementById('notif-badge'); + if (badge) badge.style.display = 'none'; + } +} + +async function checkNotifications() { + try { + const r = await fetch(`${BASE_URL}/api/notify.php?since=${encodeURIComponent(notifLastCheck)}`); + const js = await r.json(); + if (!js.success) return; + notifLastCheck = js.server_time; + + const list = document.getElementById('notif-list'); + const badge = document.getElementById('notif-badge'); + let html = ''; + + if (js.new_miskin > 0 || js.new_ibadah > 0) { + notifCount += (js.new_miskin + js.new_ibadah); + if (js.new_miskin > 0) { + html += `
👤 +${js.new_miskin} data penduduk baru
`; + showToast(`🔔 ${js.new_miskin} data penduduk baru ditambahkan`, '#22c55e'); + await loadMiskin(); + } + if (js.new_ibadah > 0) { + html += `
🏛️ +${js.new_ibadah} rumah ibadah baru
`; + showToast(`🔔 ${js.new_ibadah} rumah ibadah baru ditambahkan`, '#5b7fff'); + await loadIbadah(); + } + if (list) list.innerHTML = html + (list.innerHTML || ''); + if (badge) { badge.style.display = 'flex'; badge.textContent = notifCount > 9 ? '9+' : notifCount; } + } + + if (js.belum_dibantu > 0 && list && !list.innerHTML.includes('belum-info')) { + list.innerHTML += `
📊 ${js.belum_dibantu} warga belum dibantu
`; + } + if (!list?.innerHTML.trim()) { + if (list) list.innerHTML = '
Tidak ada notifikasi baru
'; + } + } catch(e) { /* silent */ } +} + +// Poll setiap 60 detik +setInterval(checkNotifications, 60000); + +// ── INIT ───────────────────────────────────────────────────────── +setTimeout(() => map.invalidateSize(), 150); +refreshAll().then(() => { loadKecamatanOptions(); }); diff --git a/config/config.php b/config/config.php new file mode 100644 index 0000000..07e1e8f --- /dev/null +++ b/config/config.php @@ -0,0 +1,62 @@ + '🕌', + 'Musholla' => '🕌', + 'Gereja' => '⛪', + 'Katolik' => '⛪', + 'Pura' => '🛕', + 'Vihara' => '🛕', + 'Klenteng' => '⛩️', + 'Lainnya' => '🙏', +]); + +// Jenis rumah ibadah dengan emoji +define('JENIS_IBADAH', [ + 'Masjid' => ['emoji' => '🕌', 'color' => '#10b981'], + 'Musholla' => ['emoji' => '🕌', 'color' => '#34d399'], + 'Gereja' => ['emoji' => '⛪', 'color' => '#3b82f6'], + 'Katolik' => ['emoji' => '⛪', 'color' => '#60a5fa'], + 'Pura' => ['emoji' => '🛕', 'color' => '#f59e0b'], + 'Vihara' => ['emoji' => '🛕', 'color' => '#fbbf24'], + 'Klenteng' => ['emoji' => '⛩️', 'color' => '#ef4444'], + 'Lainnya' => ['emoji' => '🙏', 'color' => '#8b5cf6'], +]); + +// Status bantuan +define('STATUS_BANTUAN', [ + 'belum' => ['label' => 'Belum Dibantu', 'color' => '#ef4444', 'badge' => 'danger'], + 'sudah' => ['label' => 'Sudah Dibantu', 'color' => '#22c55e', 'badge' => 'success'], + 'menunggu' => ['label' => 'Menunggu Verif', 'color' => '#f59e0b', 'badge' => 'warning'], +]); diff --git a/config/database.php b/config/database.php new file mode 100644 index 0000000..0bb4cdd --- /dev/null +++ b/config/database.php @@ -0,0 +1,27 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + } catch (PDOException $e) { + http_response_code(500); + die(json_encode(['success' => false, 'message' => 'Database connection failed: ' . $e->getMessage()])); + } + } + return $pdo; +} diff --git a/database.sql b/database.sql new file mode 100644 index 0000000..6282a22 --- /dev/null +++ b/database.sql @@ -0,0 +1,134 @@ +-- ================================================================ +-- WebGIS Sebaran Penduduk Miskin +-- Database: poverty_map +-- ================================================================ + +CREATE DATABASE IF NOT EXISTS poverty_map CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE poverty_map; + +-- ---------------------------------------------------------------- +-- Tabel: users +-- ---------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS users ( + id INT NOT NULL AUTO_INCREMENT, + nama VARCHAR(100) NOT NULL, + email VARCHAR(100) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + role ENUM('admin','operator') NOT NULL DEFAULT 'operator', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ---------------------------------------------------------------- +-- Tabel: rumah_ibadah +-- ---------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS rumah_ibadah ( + id INT NOT NULL AUTO_INCREMENT, + nama VARCHAR(150) NOT NULL, + jenis ENUM('Masjid','Musholla','Gereja','Katolik','Pura','Vihara','Klenteng','Lainnya') NOT NULL DEFAULT 'Masjid', + kontak VARCHAR(100) DEFAULT NULL, + alamat TEXT DEFAULT NULL, + kelurahan VARCHAR(100) DEFAULT NULL, + kecamatan VARCHAR(100) DEFAULT NULL, + lat DECIMAL(10,7) NOT NULL, + lng DECIMAL(10,7) NOT NULL, + radius INT NOT NULL DEFAULT 300, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ---------------------------------------------------------------- +-- Tabel: penduduk_miskin +-- ---------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS penduduk_miskin ( + id INT NOT NULL AUTO_INCREMENT, + kk_nama VARCHAR(150) NOT NULL, + nik VARCHAR(20) DEFAULT NULL, + jumlah_anggota INT NOT NULL DEFAULT 1, + alamat TEXT DEFAULT NULL, + kelurahan VARCHAR(100) DEFAULT NULL, + kecamatan VARCHAR(100) DEFAULT NULL, + lat DECIMAL(10,7) NOT NULL, + lng DECIMAL(10,7) NOT NULL, + rumah_ibadah_id INT DEFAULT NULL, + jarak_ke_ibadah DECIMAL(10,2) DEFAULT NULL, + status_bantuan ENUM('belum','sudah','menunggu') NOT NULL DEFAULT 'belum', + jenis_bantuan VARCHAR(200) DEFAULT NULL, + tanggal_bantuan DATE DEFAULT NULL, + nominal_bantuan DECIMAL(15,2) DEFAULT NULL COMMENT 'Nominal bantuan dalam Rupiah', + bukti_file VARCHAR(255) 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), + FOREIGN KEY (rumah_ibadah_id) REFERENCES rumah_ibadah(id) ON DELETE SET NULL ON UPDATE CASCADE, + INDEX idx_status (status_bantuan), + INDEX idx_ibadah (rumah_ibadah_id), + INDEX idx_kecamatan (kecamatan) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ---------------------------------------------------------------- +-- Tabel: anggota_keluarga +-- ---------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS anggota_keluarga ( + id INT NOT NULL AUTO_INCREMENT, + penduduk_id INT NOT NULL, + nama VARCHAR(150) NOT NULL, + hubungan VARCHAR(50) DEFAULT NULL, + umur INT DEFAULT NULL, + pekerjaan VARCHAR(100) DEFAULT NULL, + keterangan TEXT DEFAULT NULL, + PRIMARY KEY (id), + FOREIGN KEY (penduduk_id) REFERENCES penduduk_miskin(id) ON DELETE CASCADE ON UPDATE CASCADE, + INDEX idx_penduduk (penduduk_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ---------------------------------------------------------------- +-- Tabel: bantuan (riwayat bantuan) +-- ---------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS bantuan ( + id INT NOT NULL AUTO_INCREMENT, + penduduk_id INT NOT NULL, + jenis_bantuan VARCHAR(200) NOT NULL, + tanggal_bantuan DATE NOT NULL, + keterangan TEXT DEFAULT NULL, + bukti_file VARCHAR(255) DEFAULT NULL, + created_by INT DEFAULT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + FOREIGN KEY (penduduk_id) REFERENCES penduduk_miskin(id) ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ---------------------------------------------------------------- +-- Default admin user: admin@povertymap.id / admin123 +-- ---------------------------------------------------------------- +INSERT IGNORE INTO users (nama, email, password, role) VALUES +('Administrator', 'admin@povertymap.id', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'admin'), +('Operator', 'operator@povertymap.id', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'operator'); + +-- ---------------------------------------------------------------- +-- Sample Data Rumah Ibadah (Pontianak) +-- ---------------------------------------------------------------- +INSERT IGNORE INTO rumah_ibadah (nama, jenis, kontak, alamat, kelurahan, kecamatan, lat, lng, radius) VALUES +('Masjid Raya Mujahidin', 'Masjid', '0561-123456', 'Jl. Jend. Ahmad Yani, Pontianak', 'Akcaya', 'Pontianak Selatan', -0.0412, 109.3325, 500), +('Masjid Sultan Syarif Abdurrahman', 'Masjid', '0561-123457', 'Jl. Tanjungpura, Pontianak', 'Dalam Bugis', 'Pontianak Kota', -0.0263, 109.3425, 400), +('Gereja Katedral Santo Yosef', 'Gereja', '0561-123458', 'Jl. Pattimura, Pontianak', 'Mariana', 'Pontianak Kota', -0.0290, 109.3380, 350), +('Vihara Bodhisattva Karaniya', 'Vihara', '0561-123459', 'Jl. Diponegoro, Pontianak', 'Sungai Bangkong', 'Pontianak Kota', -0.0320, 109.3350, 300); + +-- ---------------------------------------------------------------- +-- Sample Data Penduduk Miskin +-- ---------------------------------------------------------------- +INSERT IGNORE INTO penduduk_miskin (kk_nama, nik, jumlah_anggota, alamat, kelurahan, kecamatan, lat, lng, rumah_ibadah_id, jarak_ke_ibadah, status_bantuan, jenis_bantuan, tanggal_bantuan) VALUES +('Ahmad Sulaiman', '6171012345678901', 4, 'Jl. Veteran No. 12, Pontianak', 'Akcaya', 'Pontianak Selatan', -0.0430, 109.3340, 1, 210.5, 'sudah', 'Sembako + Uang Tunai', '2024-01-15'), +('Budi Santoso', '6171023456789012', 3, 'Jl. Imam Bonjol No. 5, Pontianak', 'Dalam Bugis', 'Pontianak Kota', -0.0270, 109.3440, 2, 180.2, 'belum', NULL, NULL), +('Siti Rahayu', '6171034567890123', 5, 'Jl. Gajahmada No. 8, Pontianak', 'Mariana', 'Pontianak Kota', -0.0300, 109.3400, 3, 320.8, 'menunggu', NULL, NULL), +('Wati Susanti', '6171045678901234', 2, 'Jl. Diponegoro No. 20, Pontianak', 'Sungai Bangkong', 'Pontianak Kota', -0.0350, 109.3370, 4, 180.0, 'belum', NULL, NULL); + +-- Sample anggota keluarga +INSERT IGNORE INTO anggota_keluarga (penduduk_id, nama, hubungan, umur, pekerjaan, keterangan) VALUES +(1, 'Fatimah', 'Istri', 35, 'Ibu Rumah Tangga', NULL), +(1, 'Ali', 'Anak', 12, 'Pelajar', 'Kelas 6 SD'), +(1, 'Sari', 'Anak', 8, 'Pelajar', 'Kelas 2 SD'), +(2, 'Dewi Santoso', 'Istri', 30, 'Pedagang Kecil', NULL), +(2, 'Rizki', 'Anak', 10, 'Pelajar', NULL); diff --git a/includes/auth.php b/includes/auth.php new file mode 100644 index 0000000..8ce3546 --- /dev/null +++ b/includes/auth.php @@ -0,0 +1,62 @@ +Akses Ditolak

Anda tidak memiliki izin untuk mengakses halaman ini.

'); + } +} + +function getCurrentUser(): ?array { + if (!isLoggedIn()) return null; + return [ + 'id' => $_SESSION['user_id'], + 'nama' => $_SESSION['user_nama'] ?? '', + 'email' => $_SESSION['user_email'] ?? '', + 'role' => $_SESSION['user_role'] ?? 'operator', + ]; +} + +function loginUser(array $user): void { + $_SESSION['user_id'] = $user['id']; + $_SESSION['user_nama'] = $user['nama']; + $_SESSION['user_email'] = $user['email']; + $_SESSION['user_role'] = $user['role']; + // Also store as array for API compatibility + $_SESSION['user'] = [ + 'id' => $user['id'], + 'nama' => $user['nama'], + 'email' => $user['email'], + 'role' => $user['role'], + ]; +} + +function logoutUser(): void { + $_SESSION = []; + if (ini_get('session.use_cookies')) { + $p = session_get_cookie_params(); + setcookie(session_name(), '', time() - 42000, $p['path'], $p['domain'], $p['secure'], $p['httponly']); + } + session_destroy(); +} diff --git a/includes/footer.php b/includes/footer.php new file mode 100644 index 0000000..56cb1b4 --- /dev/null +++ b/includes/footer.php @@ -0,0 +1,5 @@ + + + + + diff --git a/includes/functions.php b/includes/functions.php new file mode 100644 index 0000000..1bd1a54 --- /dev/null +++ b/includes/functions.php @@ -0,0 +1,128 @@ +query("SELECT id, nama, jenis, lat, lng, radius FROM rumah_ibadah")->fetchAll(); + $best = null; + $minDist = PHP_FLOAT_MAX; + foreach ($rows as $row) { + $dist = haversineDistance($lat, $lng, (float)$row['lat'], (float)$row['lng']); + if ($dist <= (float)$row['radius'] && $dist < $minDist) { + $minDist = $dist; + $best = array_merge($row, ['jarak' => round($dist, 2)]); + } + } + // Jika tidak ada dalam radius, cari yang paling dekat saja + if ($best === null && !empty($rows)) { + foreach ($rows as $row) { + $dist = haversineDistance($lat, $lng, (float)$row['lat'], (float)$row['lng']); + if ($dist < $minDist) { + $minDist = $dist; + $best = array_merge($row, ['jarak' => round($dist, 2), 'di_luar_radius' => true]); + } + } + } + return $best; +} + +/** + * Sanitize string input + */ +function sanitize(string $val): string { + return trim(htmlspecialchars($val, ENT_QUOTES, 'UTF-8')); +} + +/** + * Format radius display + */ +function formatRadius(int $meter): string { + return $meter >= 1000 ? number_format($meter / 1000, 1) . ' km' : $meter . ' m'; +} + +/** + * Format tanggal Indonesia + */ +function formatTanggal(?string $date): string { + if (!$date) return '-'; + $ts = strtotime($date); + $bulan = ['','Jan','Feb','Mar','Apr','Mei','Jun','Jul','Agt','Sep','Okt','Nov','Des']; + return date('d', $ts) . ' ' . $bulan[(int)date('n', $ts)] . ' ' . date('Y', $ts); +} + +/** + * Handle file upload + */ +function handleUpload(array $file, string $prefix = 'bukti'): ?string { + if ($file['error'] !== UPLOAD_ERR_OK) return null; + if ($file['size'] > UPLOAD_MAX_SIZE) return null; + + $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); + if (!in_array($ext, UPLOAD_ALLOWED)) return null; + + if (!is_dir(UPLOAD_DIR)) mkdir(UPLOAD_DIR, 0755, true); + + $filename = $prefix . '_' . date('Ymd_His') . '_' . uniqid() . '.' . $ext; + $dest = UPLOAD_DIR . $filename; + if (move_uploaded_file($file['tmp_name'], $dest)) return $filename; + return null; +} + +/** + * Emoji dan label untuk jenis ibadah + */ +function ibadahEmoji(string $jenis): string { + $map = [ + 'Masjid' => '🕌', + 'Musholla' => '🕌', + 'Gereja' => '⛪', + 'Katolik' => '⛪', + 'Pura' => '🛕', + 'Vihara' => '🛕', + 'Klenteng' => '⛩️', + 'Lainnya' => '🙏', + ]; + return $map[$jenis] ?? '🏛️'; +} + +/** + * Color untuk marker berdasarkan status + */ +function statusColor(string $status): string { + return match($status) { + 'sudah' => '#22c55e', + 'menunggu' => '#f59e0b', + default => '#ef4444', + }; +} diff --git a/includes/header.php b/includes/header.php new file mode 100644 index 0000000..7c01dd9 --- /dev/null +++ b/includes/header.php @@ -0,0 +1,94 @@ + + + + + + + <?= isset($pageTitle) ? htmlspecialchars($pageTitle) . ' — ' : '' ?><?= APP_NAME ?> + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
diff --git a/includes/modal_ibadah.php b/includes/modal_ibadah.php new file mode 100644 index 0000000..cbb69a9 --- /dev/null +++ b/includes/modal_ibadah.php @@ -0,0 +1,86 @@ + + + diff --git a/includes/modal_miskin.php b/includes/modal_miskin.php new file mode 100644 index 0000000..ce0baf7 --- /dev/null +++ b/includes/modal_miskin.php @@ -0,0 +1,132 @@ + + + diff --git a/index.html b/index.html new file mode 100644 index 0000000..f3acb77 --- /dev/null +++ b/index.html @@ -0,0 +1,204 @@ + + + + + + ⛽ Input SPBU & Rumah Ibadah — WebGIS Kalbar + + + + + + + +
+ +
+

Input Manual SPBU & Rumah Ibadah

+

Mode 2 — OpenStreetMap · Geolocation · Reverse Geocoding

+
+ + ← Pilih Mode + +
+ +
+ + + + + +
+
+ + +
+
Tambah:
+
+ + +
+ +
+ +
+ 📍 +
+ Mendapatkan alamat... + +
+
+
+ +
+ +
+ + + + + + + + + + \ No newline at end of file diff --git a/index.php b/index.php new file mode 100644 index 0000000..3cbe0d4 --- /dev/null +++ b/index.php @@ -0,0 +1,248 @@ + + + + + + + <?= APP_NAME ?> + + + + + + + + + + + + + + + + + +
+ + + + + +
+
+ + +
+ Tambah: + + +
+ + + +
+ + + + + +
+ + + + +
+ + +
+
Legenda
+
Sudah Dibantu
+
Belum Dibantu
+
Menunggu Verif.
+
+
Radius Layanan +
+
+ + +
+ 📍 + +
+ +
+ +
+ + +
+ + + + + + + + + + + + + + + + + + + diff --git a/landing.php b/landing.php new file mode 100644 index 0000000..76546b5 --- /dev/null +++ b/landing.php @@ -0,0 +1,435 @@ + + + + + + + WebGIS Kalimantan Barat — Pilih Mode + + + + + + + + + + + + +
+
+
+
+
+
+
+ + + + + + diff --git a/style.css b/style.css new file mode 100644 index 0000000..db0c145 --- /dev/null +++ b/style.css @@ -0,0 +1,307 @@ +/* ─── VARIABLES ──────────────────────────────────────────────── */ +:root { + --bg: #0f1117; + --surface: #1a1d27; + --surface2: #22263a; + --surface3: #2a2f45; + --border: #2e3350; + --accent: #5b7fff; + --accent2: #7c5bff; + --green: #22c55e; + --red: #ef4444; + --yellow: #f59e0b; + --orange: #f97316; + --text: #e8eaf6; + --text2: #8890b0; + --header-h: 61px; + --sidebar-w: 300px; +} + +/* ─── RESET ──────────────────────────────────────────────────── */ +* { box-sizing: border-box; margin: 0; padding: 0; } +html { height: 100%; } +body { + font-family: 'Plus Jakarta Sans', sans-serif; + background: var(--bg); color: var(--text); + height: 100%; overflow: hidden; +} + +/* ─── HEADER ─────────────────────────────────────────────────── */ +header { + position: fixed; top: 0; left: 0; right: 0; + height: var(--header-h); + background: var(--surface); border-bottom: 1px solid var(--border); + padding: 0 20px; display: flex; align-items: center; gap: 12px; z-index: 800; +} +header .logo { + width: 36px; height: 36px; flex-shrink: 0; + background: linear-gradient(135deg, var(--accent), var(--accent2)); + border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 18px; +} +header h1 { + font-size: 15px; font-weight: 700; letter-spacing: -0.3px; + background: linear-gradient(90deg, var(--text), var(--accent)); + -webkit-background-clip: text; -webkit-text-fill-color: transparent; +} +header p { font-size: 11px; color: var(--text2); margin-top: 1px; } + +/* ─── LAYOUT ─────────────────────────────────────────────────── */ +.main { position: fixed; top: var(--header-h); left: 0; right: 0; bottom: 0; } + +/* ─── SIDEBAR ────────────────────────────────────────────────── */ +.sidebar { + position: absolute; top: 0; left: 0; width: var(--sidebar-w); bottom: 0; + background: var(--surface); border-right: 1px solid var(--border); + overflow-y: auto; display: flex; flex-direction: column; z-index: 500; +} +.sidebar::-webkit-scrollbar { width: 4px; } +.sidebar::-webkit-scrollbar-track { background: transparent; } +.sidebar::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } + +/* ─── SECTION ────────────────────────────────────────────────── */ +.section { padding: 14px 16px; border-bottom: 1px solid var(--border); flex-shrink: 0; } +.section-title { + font-size: 10px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; + color: var(--text2); margin-bottom: 10px; display: flex; align-items: center; gap: 6px; +} +.section-title .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); flex-shrink: 0; } +.count-badge { + margin-left: auto; background: var(--surface3); border: 1px solid var(--border); + border-radius: 10px; padding: 1px 7px; font-size: 10px; color: var(--text2); + font-family: 'JetBrains Mono', monospace; +} + +/* ─── BUTTONS ────────────────────────────────────────────────── */ +.btn { + width: 100%; padding: 9px 14px; border-radius: 8px; border: none; + font-family: inherit; font-size: 13px; font-weight: 600; cursor: pointer; + transition: all 0.15s; display: flex; align-items: center; justify-content: center; gap: 6px; +} +.btn-primary { background: linear-gradient(135deg, var(--accent), var(--accent2)); color: white; flex: 2; } +.btn-primary:hover { opacity: 0.88; transform: translateY(-1px); } +.btn-danger { background: rgba(239,68,68,0.12); color: var(--red); border: 1px solid rgba(239,68,68,0.25); } +.btn-danger:hover { background: rgba(239,68,68,0.22); } +.btn-warning { background: rgba(249,115,22,0.12); color: var(--orange); border: 1px solid rgba(249,115,22,0.25); } +.btn-warning:hover { background: rgba(249,115,22,0.22); } +.btn-loc { background: rgba(245,158,11,0.12); color: var(--yellow); border: 1px solid rgba(245,158,11,0.25); } +.btn-loc:hover { background: rgba(245,158,11,0.22); } +.btn-ghost { background: var(--surface3); color: var(--text2); border: 1px solid var(--border); flex: 1; } +.btn-ghost:hover { background: var(--border); color: var(--text); } + +/* ─── STATS ──────────────────────────────────────────────────── */ +.stats-row { display: flex; gap: 6px; } +.stat-box { flex: 1; background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; padding: 8px; text-align: center; } +.stat-box .stat-num { font-family: 'JetBrains Mono', monospace; font-size: 20px; font-weight: 700; } +.stat-box .stat-label { font-size: 10px; color: var(--text2); margin-top: 2px; } +.stat-box.green .stat-num { color: var(--green); } +.stat-box.red .stat-num { color: var(--red); } +.stat-box.blue .stat-num { color: var(--accent); } +.stat-box.yellow .stat-num { color: var(--yellow); } + +/* ─── ITEM LIST ──────────────────────────────────────────────── */ +.item-list { display: flex; flex-direction: column; gap: 5px; } +.item-card { + background: var(--surface2); border: 1px solid var(--border); + border-radius: 8px; padding: 8px 10px; display: flex; align-items: flex-start; + gap: 8px; font-size: 12px; cursor: pointer; transition: border-color 0.15s, background 0.15s; +} +.item-card:hover { border-color: var(--accent); background: var(--surface3); } +.item-card.highlighted { border-color: var(--accent) !important; background: rgba(91,127,255,0.1) !important; box-shadow: 0 0 0 1px var(--accent); } +.item-card .item-icon { font-size: 15px; flex-shrink: 0; margin-top: 1px; } +.item-card .item-info { flex: 1; min-width: 0; } +.item-card .item-name { font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.item-card .item-addr { color: var(--text2); font-size: 10px; margin-top: 2px; line-height: 1.3; } +.item-card .item-coord { font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--text2); margin-top: 2px; } + +/* Radius live-edit di sidebar */ +.item-radius-row { + display: flex; align-items: center; gap: 6px; margin-top: 5px; +} +.item-radius-row input[type="range"] { + flex: 1; height: 3px; margin-bottom: 0; background: var(--border); +} +.item-radius-row input[type="range"]::-webkit-slider-thumb { + width: 13px; height: 13px; background: var(--accent); +} +.radius-display { + font-family: 'JetBrains Mono', monospace; font-size: 9px; + color: var(--accent); background: var(--surface3); + padding: 1px 5px; border-radius: 4px; border: 1px solid var(--border); + white-space: nowrap; flex-shrink: 0; +} + +/* Nearest ibadah tag */ +.nearest-tag { + font-size: 9px; padding: 1px 5px; border-radius: 4px; + background: rgba(91,127,255,0.15); color: var(--accent); + border: 1px solid rgba(91,127,255,0.3); margin-top: 3px; + display: inline-block; font-weight: 600; +} + +.badge { font-size: 10px; padding: 2px 5px; border-radius: 4px; font-weight: 700; flex-shrink: 0; } +.badge-green { background: rgba(34,197,94,0.15); color: var(--green); } +.badge-red { background: rgba(239,68,68,0.12); color: var(--red); } +.badge-yellow { background: rgba(245,158,11,0.15); color: var(--yellow); } + +.item-actions { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; } +.btn-remove { + background: transparent; border: none; cursor: pointer; color: var(--text2); + font-size: 13px; padding: 2px 4px; border-radius: 4px; transition: color 0.15s, background 0.15s; line-height: 1; +} +.btn-remove:hover { color: var(--red); background: rgba(239,68,68,0.1); } +.btn-edit { + background: transparent; border: none; cursor: pointer; color: var(--text2); + font-size: 12px; padding: 2px 4px; border-radius: 4px; transition: color 0.15s, background 0.15s; line-height: 1; +} +.btn-edit:hover { color: var(--accent); background: rgba(91,127,255,0.1); } + +.empty-state { text-align: center; color: var(--text2); font-size: 11px; padding: 14px 0; opacity: 0.5; } + +label { + display: block; font-size: 11px; color: var(--text2); + margin-bottom: 5px; font-weight: 500; +} + +/* ─── MAP CONTAINER ──────────────────────────────────────────── */ +.map-container { position: absolute; top: 0; left: var(--sidebar-w); right: 0; bottom: 0; z-index: 1; } +#map { width: 100%; height: 100%; } + +/* ─── TOOLBAR ────────────────────────────────────────────────── */ +.toolbar { + position: absolute; top: 14px; left: 50%; transform: translateX(-50%); + z-index: 400; background: var(--surface); border: 1px solid var(--border); + border-radius: 50px; padding: 6px 8px; display: flex; align-items: center; gap: 8px; + box-shadow: 0 4px 20px rgba(0,0,0,0.4); white-space: nowrap; +} +.toolbar-label { font-size: 11px; color: var(--text2); font-weight: 600; padding: 0 4px; } +.toggle-group { + display: flex; gap: 3px; background: var(--surface2); + border: 1px solid var(--border); border-radius: 40px; padding: 3px; +} +.toggle-btn { + padding: 6px 14px; border-radius: 40px; border: none; + font-family: inherit; font-size: 12px; font-weight: 600; + cursor: pointer; transition: all 0.18s; color: var(--text2); background: transparent; +} +.toggle-btn:hover { color: var(--text); background: var(--surface3); } +.toggle-btn.active-ibadah { background: linear-gradient(135deg, var(--accent), var(--accent2)); color: white; box-shadow: 0 2px 10px rgba(91,127,255,0.4); } +.toggle-btn.active-miskin { background: linear-gradient(135deg, #16a34a, var(--green)); color: white; box-shadow: 0 2px 10px rgba(34,197,94,0.4); } +.toolbar-loc-btn { + width: 32px; height: 32px; border-radius: 50%; border: 1px solid var(--border); + background: var(--surface2); cursor: pointer; font-size: 14px; transition: all 0.15s; + display: flex; align-items: center; justify-content: center; +} +.toolbar-loc-btn:hover { background: rgba(245,158,11,0.15); border-color: var(--yellow); } + +#map.adding-ibadah { cursor: crosshair !important; } +#map.adding-miskin { cursor: cell !important; } + +/* ─── LOC BAR ────────────────────────────────────────────────── */ +#loc-bar { + position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%); + background: var(--surface); border: 1px solid var(--border); border-radius: 10px; + padding: 10px 16px; z-index: 400; font-size: 12px; color: var(--text2); + display: none; gap: 8px; align-items: center; max-width: 90%; + box-shadow: 0 4px 24px rgba(0,0,0,0.4); +} +#loc-bar .loc-icon { font-size: 16px; } +#loc-bar .loc-text strong { color: var(--yellow); display: block; font-size: 13px; } + +/* ─── PULSE ──────────────────────────────────────────────────── */ +@keyframes markerPulse { + 0% { box-shadow: 0 0 0 0 rgba(91,127,255,0.8); } + 70% { box-shadow: 0 0 0 12px rgba(91,127,255,0); } + 100% { box-shadow: 0 0 0 0 rgba(91,127,255,0); } +} +.leaflet-marker-pulsing div { animation: markerPulse 1s ease-out 3 !important; } + +/* ─── FORM OVERLAY ───────────────────────────────────────────── */ +.form-overlay { + position: fixed; inset: 0; z-index: 2000; + display: flex; align-items: center; justify-content: center; + background: rgba(0,0,0,0.5); backdrop-filter: blur(3px); transition: opacity 0.2s; +} +.form-overlay.hidden { display: none; } +.form-card { + background: var(--surface); border: 1px solid var(--border); + border-radius: 16px; width: 380px; max-width: 95vw; + box-shadow: 0 20px 60px rgba(0,0,0,0.5); overflow: hidden; + animation: formIn 0.2s cubic-bezier(0.34,1.56,0.64,1); +} +@keyframes formIn { + from { opacity: 0; transform: scale(0.92) translateY(10px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} +.form-header { + padding: 14px 18px; display: flex; align-items: center; justify-content: space-between; + border-bottom: 1px solid var(--border); font-weight: 700; font-size: 14px; +} +.form-header.ibadah-header { background: linear-gradient(135deg, rgba(91,127,255,0.15), rgba(124,91,255,0.1)); } +.form-header.miskin-header { background: linear-gradient(135deg, rgba(34,197,94,0.12), rgba(22,163,74,0.08)); } +.form-close { + background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; + cursor: pointer; color: var(--text2); width: 26px; height: 26px; font-size: 12px; + display: flex; align-items: center; justify-content: center; transition: all 0.15s; +} +.form-close:hover { color: var(--red); border-color: var(--red); } +.form-body { padding: 16px 18px; display: flex; flex-direction: column; } +.form-body label { + font-size: 11px; color: var(--text2); margin-bottom: 5px; + font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; +} +.form-body input[type="text"], +.form-body select { + width: 100%; background: var(--surface2); border: 1px solid var(--border); + border-radius: 8px; color: var(--text); padding: 8px 12px; + font-size: 13px; font-family: inherit; outline: none; + transition: border-color 0.2s; margin-bottom: 12px; +} +.form-body input:focus, .form-body select:focus { border-color: var(--accent); } +.range-label { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; } +.range-val { + font-family: 'JetBrains Mono', monospace; font-size: 12px; + background: var(--surface2); padding: 2px 8px; border-radius: 4px; + color: var(--accent); border: 1px solid var(--border); +} +input[type="range"] { + -webkit-appearance: none; width: 100%; height: 4px; + background: var(--border); border-radius: 2px; outline: none; margin-bottom: 12px; +} +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; width: 16px; height: 16px; + border-radius: 50%; background: var(--accent); cursor: pointer; border: 2px solid var(--bg); +} +.form-coord { + font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); + margin-bottom: 8px; padding: 5px 8px; background: var(--surface2); + border-radius: 6px; border: 1px solid var(--border); +} +.form-addr-preview { + font-size: 11px; color: var(--text2); line-height: 1.4; padding: 8px 10px; + background: var(--surface2); border-radius: 8px; border: 1px solid var(--border); margin-bottom: 4px; +} +.addr-loading { color: var(--text2); font-style: italic; } +.form-footer { padding: 12px 18px; border-top: 1px solid var(--border); display: flex; gap: 8px; } + +/* ─── TOAST ──────────────────────────────────────────────────── */ +#toast { + position: fixed; top: 76px; right: 16px; + background: var(--surface2); border: 1px solid var(--border); + border-radius: 10px; padding: 11px 15px; font-size: 13px; + z-index: 9999; display: none; animation: slideIn 0.2s ease; + max-width: 280px; box-shadow: 0 4px 20px rgba(0,0,0,0.3); +} +@keyframes slideIn { from { opacity:0; transform: translateX(16px); } to { opacity:1; transform: translateX(0); } } + +/* ─── LEAFLET POPUP ──────────────────────────────────────────── */ +.leaflet-popup-content-wrapper { + background: var(--surface2) !important; color: var(--text) !important; + border: 1px solid var(--border) !important; border-radius: 10px !important; + box-shadow: 0 4px 20px rgba(0,0,0,0.4) !important; + font-family: 'Plus Jakarta Sans', sans-serif !important; +} +.leaflet-popup-tip { background: var(--surface2) !important; } +.leaflet-popup-content { margin: 12px 14px !important; font-size: 13px !important; line-height: 1.6 !important; } +.leaflet-popup-content b { color: var(--accent); } +.leaflet-popup-content .popup-addr { color: var(--text2); font-size: 11px; margin-top: 4px; } +.leaflet-popup-close-button { color: var(--text2) !important; } \ No newline at end of file