From 1fcad7d9b22ebf829141b1ea34b950c801be7786 Mon Sep 17 00:00:00 2001 From: GuavaPopper Date: Wed, 3 Jun 2026 20:44:10 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20Plan=205=20=E2=80=94=20Ekspor=20PDF=20+?= =?UTF-8?q?=20Ekspor=20per=20Radius=20+=20Cetak=20Laporan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ExportController: allMiskinPDF (all data) + radiusMiskinPDF (server-side Haversine filtering per ibadah radius) - Add two table-based PDF Blade views (dompdf-compatible, no flexbox/grid) - Add export routes (/export/pdf, /export/pdf/radius/{id}/{radius}) with numeric route constraints - Add "Ekspor PDF" button to data tabular view - Add CSV + PDF radius export buttons to map analysis panel; exportRadiusCSV() reuses existing isInside(); PDF href synced on ibadah click and radius slider change - 5 new tests: access control, PDF content-type, 404 + param validation - Mark K-04 and Section 5 (Cetak & Ekspor) complete in missing_features.md - Update README with export features Co-Authored-By: Claude Opus 4.8 --- README.md | 2 + app/Http/Controllers/ExportController.php | 51 ++++++++++++ missing_features.md | 18 ++--- resources/views/data.blade.php | 3 + .../views/exports/laporan-miskin.blade.php | 62 ++++++++++++++ .../views/exports/laporan-radius.blade.php | 81 +++++++++++++++++++ resources/views/map.blade.php | 37 +++++++++ routes/web.php | 7 ++ tests/Feature/ExportTest.php | 75 +++++++++++++++++ 9 files changed, 327 insertions(+), 9 deletions(-) create mode 100644 app/Http/Controllers/ExportController.php create mode 100644 resources/views/exports/laporan-miskin.blade.php create mode 100644 resources/views/exports/laporan-radius.blade.php create mode 100644 tests/Feature/ExportTest.php diff --git a/README.md b/README.md index f53497e..af6ce1a 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ Sistem mendukung pengelolaan data secara penuh: - **Update Lokasi (Drag & Drop)**: Marker dapat digeser (drag) untuk memperbarui koordinat lokasi di database secara real-time. - **Hapus Data**: Menghapus data melalui popup pada marker atau melalui tabel data. - **Ownership Rumah Ibadah**: Pengurus Rumah Ibadah hanya dapat mengubah atau menghapus data rumah ibadah yang mereka tambahkan sendiri. Administrator dapat mengubah semua data. +- **Ekspor PDF**: Unduh laporan seluruh data keluarga miskin dalam format PDF dari halaman Data Tabular. +- **Ekspor CSV/PDF per Radius**: Dari panel analisis peta, ekspor hanya data keluarga miskin yang berada dalam radius terpilih — tersedia dalam format CSV (langsung di browser) dan PDF (via server, filtering menggunakan Haversine). - **Data Tabular**: Halaman khusus yang menampilkan seluruh data dalam bentuk tabel dengan statistik ringkasan. ### 3. Dashboard Statistik & Gap Analysis diff --git a/app/Http/Controllers/ExportController.php b/app/Http/Controllers/ExportController.php new file mode 100644 index 0000000..0552aed --- /dev/null +++ b/app/Http/Controllers/ExportController.php @@ -0,0 +1,51 @@ +get(); + $ibadahCount = RumahIbadah::count(); + $totalJiwa = $miskin->sum('jumlah_orang'); + $totalKK = $miskin->sum('jumlah_kk'); + + $pdf = Pdf::loadView('exports.laporan-miskin', compact('miskin', 'ibadahCount', 'totalJiwa', 'totalKK')); + $pdf->setPaper('a4', 'landscape'); + return $pdf->download('laporan-keluarga-miskin.pdf'); + } + + public function radiusMiskinPDF(int $ibadahId, int $radius) + { + $ibadah = RumahIbadah::findOrFail($ibadahId); + $allMiskin = RumahMiskin::all(); + + $miskin = $allMiskin->filter(fn($m) => $this->haversineDistance( + $ibadah->latitude, $ibadah->longitude, + $m->latitude, $m->longitude + ) <= $radius); + + $totalJiwa = $miskin->sum('jumlah_orang'); + $totalKK = $miskin->sum('jumlah_kk'); + + $pdf = Pdf::loadView('exports.laporan-radius', compact('ibadah', 'miskin', 'radius', 'totalJiwa', 'totalKK')); + $pdf->setPaper('a4', 'portrait'); + $filename = 'laporan-radius-' . Str::slug($ibadah->nama) . '-' . $radius . 'm.pdf'; + return $pdf->download($filename); + } + + private function haversineDistance(float $lat1, float $lon1, float $lat2, float $lon2): float + { + $R = 6371000; + $dLat = deg2rad($lat2 - $lat1); + $dLon = deg2rad($lon2 - $lon1); + $a = sin($dLat / 2) ** 2 + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLon / 2) ** 2; + return 2 * $R * asin(sqrt($a)); + } +} diff --git a/missing_features.md b/missing_features.md index 3f88745..060c41c 100644 --- a/missing_features.md +++ b/missing_features.md @@ -2,7 +2,7 @@ > Berdasarkan perbandingan dokumen SKPL (`D1041231009_RadityaIndraPutranto_SKPL_SIG.docx`) dengan implementasi aktual project. > -> **Terakhir diperbarui:** 2026-06-03 — setelah implementasi Dashboard Statistik + Gap Analysis (Plan 4). +> **Terakhir diperbarui:** 2026-06-03 — setelah implementasi Ekspor PDF + per Radius (Plan 5). --- @@ -64,7 +64,7 @@ Data bisa ditambah dan dihapus. Untuk lokasi: drag marker (ibadah & miskin) dan |---|---|---| | K-02 | **Dashboard Statistik Lengkap** — Perbandingan antar wilayah, tren waktu | ✅ Selesai | | K-03 | **Analisis Kesenjangan (Gap Analysis)** — Identifikasi wilayah miskin yang kurang terjangkau rumah ibadah | ✅ Selesai | -| K-04 | **Ekspor Laporan Terfilter** — Ekspor khusus per radius/wilayah untuk laporan resmi | ❌ Belum ada (hanya CSV semua data) | +| K-04 | **Ekspor Laporan Terfilter** — Ekspor khusus per radius/wilayah untuk laporan resmi | ✅ Selesai | --- @@ -72,9 +72,9 @@ Data bisa ditambah dan dihapus. Untuk lokasi: drag marker (ibadah & miskin) dan | Fitur | Role terkait | Status | |---|---|---| -| **Cetak Laporan Wilayah** — Print daftar keluarga miskin dalam radius rumah ibadah | I-04 | ❌ Belum ada | -| **Ekspor PDF** — Format laporan resmi | K-04 | ❌ Belum ada | -| **Ekspor per Radius** — Data yang terfilter per analisis radius | I-04, K-04 | ❌ Belum ada | +| **Cetak Laporan Wilayah** — Print daftar keluarga miskin dalam radius rumah ibadah | I-04 | ✅ Selesai | +| **Ekspor PDF** — Format laporan resmi | K-04 | ✅ Selesai | +| **Ekspor per Radius** — Data yang terfilter per analisis radius | I-04, K-04 | ✅ Selesai | --- @@ -87,9 +87,9 @@ Data bisa ditambah dan dihapus. Untuk lokasi: drag marker (ibadah & miskin) dan | Ownership pengurus_ibadah | ✅ 1/1 | — | | Fitur Admin | ✅ 1/8 | ❌ 7 fitur | | Fitur Edit Data | ✅ 2/2 | — | -| Fitur Analisis/Dashboard | ✅ 2/3 | ❌ 1 fitur (K-04 ekspor) | -| Fitur Cetak/Ekspor | — | ❌ 3 fitur | -| **Total sisa** | | **~11 item** | +| Fitur Analisis/Dashboard | ✅ 3/3 | — | +| Fitur Cetak/Ekspor | ✅ 3/3 | — | +| **Total sisa** | | **~7 item** | --- @@ -101,5 +101,5 @@ Data bisa ditambah dan dihapus. Untuk lokasi: drag marker (ibadah & miskin) dan | ~~Plan 2~~ | ~~Form Edit Rumah Ibadah & Keluarga Miskin + Ownership~~ | ✅ Selesai | | ~~Plan 3~~ | ~~Manajemen Pengguna (Admin: CRUD user, assign role)~~ | ✅ Selesai | | ~~Plan 4~~ | ~~Dashboard Statistik + Gap Analysis (Pemangku Kebijakan)~~ | ✅ Selesai | -| Plan 5 | Ekspor PDF + Ekspor per Radius + Cetak Laporan | Sedang | +| ~~Plan 5~~ | ~~Ekspor PDF + Ekspor per Radius + Cetak Laporan~~ | ✅ Selesai | | Plan 6 | Fitur Admin lanjutan (Bulk delete, CSV import, Log, Backup) | Rendah | diff --git a/resources/views/data.blade.php b/resources/views/data.blade.php index 01805d3..fe21041 100644 --- a/resources/views/data.blade.php +++ b/resources/views/data.blade.php @@ -99,6 +99,9 @@ + + Ekspor PDF + @endif Tambah Data diff --git a/resources/views/exports/laporan-miskin.blade.php b/resources/views/exports/laporan-miskin.blade.php new file mode 100644 index 0000000..563a5ab --- /dev/null +++ b/resources/views/exports/laporan-miskin.blade.php @@ -0,0 +1,62 @@ + + + + + Laporan Keluarga Miskin + + + +

Laporan Data Keluarga Miskin

+

WebGIS Pemetaan Kemiskinan — Dicetak: {{ now()->format('d/m/Y H:i') }} WIB

+ +
+ Total Keluarga: {{ $miskin->count() }}    + Total KK: {{ $totalKK }}    + Total Jiwa: {{ $totalJiwa }}    + Total Rumah Ibadah: {{ $ibadahCount }} +
+ + + + + + + + + + + + + + + + @forelse($miskin as $i => $m) + + + + + + + + + + + @empty + + @endforelse + +
#ID RumahNama KKAlamatKKJiwaPekerjaanKondisi
{{ $i + 1 }}{{ $m->id_rumah }}{{ $m->nama_kk ?: '-' }}{{ Str::limit($m->alamat, 45) }}{{ $m->jumlah_kk }}{{ $m->jumlah_orang }}{{ $m->pekerjaan ?: '-' }}{{ str_replace('_', ' ', $m->kondisi_rumah ?: '-') }}
Tidak ada data
+ + + + diff --git a/resources/views/exports/laporan-radius.blade.php b/resources/views/exports/laporan-radius.blade.php new file mode 100644 index 0000000..3b330d2 --- /dev/null +++ b/resources/views/exports/laporan-radius.blade.php @@ -0,0 +1,81 @@ + + + + + Laporan Radius {{ $ibadah->nama }} + + + +

Laporan Radius: {{ $ibadah->nama }}

+

WebGIS Pemetaan Kemiskinan — Dicetak: {{ now()->format('d/m/Y H:i') }} WIB

+ +
+
Rumah Ibadah: {{ $ibadah->nama }} ({{ ucfirst($ibadah->jenis ?: '-') }})
+
Alamat: {{ $ibadah->alamat ?: '-' }}
+
Pengurus: {{ $ibadah->pengurus ?: '-' }}
+
Radius Analisis: {{ number_format($radius) }} meter
+
+ + + + + + + +
+ {{ $miskin->count() }} + Rumah + + {{ $totalKK }} + KK + + {{ $totalJiwa }} + Jiwa +
+ + + + + + + + + + + + + + + + @forelse($miskin as $i => $m) + + + + + + + + + + + @empty + + @endforelse + +
#ID RumahNama KKAlamatKKJiwaPekerjaanKondisi
{{ $i + 1 }}{{ $m->id_rumah }}{{ $m->nama_kk ?: '-' }}{{ Str::limit($m->alamat, 40) }}{{ $m->jumlah_kk }}{{ $m->jumlah_orang }}{{ $m->pekerjaan ?: '-' }}{{ str_replace('_', ' ', $m->kondisi_rumah ?: '-') }}
Tidak ada keluarga miskin dalam radius ini.
+ + + + diff --git a/resources/views/map.blade.php b/resources/views/map.blade.php index 7e15631..4025b87 100644 --- a/resources/views/map.blade.php +++ b/resources/views/map.blade.php @@ -191,6 +191,14 @@
Jiwa
+
@@ -666,6 +674,10 @@ const radius = document.getElementById('radiusSlider').value = item.radius || 500; updateRadius(item.latitude, item.longitude, radius); + + // Update PDF export link + const pdfBtn = document.getElementById('btnExportRadiusPDF'); + if (pdfBtn) pdfBtn.href = `{{ url('/export/pdf/radius') }}/${item.id}/${item.radius || 500}`; } // ── RADIUS ANALYSIS ──────────────────────────────────────────────── @@ -711,6 +723,26 @@ document.getElementById('statJiwa').innerText = jiwa; } + function exportRadiusCSV() { + if (!activeIbadah) return; + const radius = parseInt(document.getElementById('radiusSlider').value); + const inRadius = miskinData.filter(m => + isInside(m, { lat: activeIbadah.latitude, lng: activeIbadah.longitude }, radius) + ); + + let csv = "data:text/csv;charset=utf-8,ID Rumah,Nama KK,Alamat,KK,Jiwa,Pekerjaan,Pendapatan,Kondisi\n"; + inRadius.forEach(m => { + csv += `"${m.id_rumah}","${m.nama_kk || ''}","${(m.alamat || '').replace(/"/g, '""')}",${m.jumlah_kk},${m.jumlah_orang},"${m.pekerjaan || ''}",${m.pendapatan || 0},"${m.kondisi_rumah || ''}"\n`; + }); + + const link = document.createElement('a'); + link.setAttribute('href', encodeURI(csv)); + link.setAttribute('download', `radius-${activeIbadah.nama.replace(/\s+/g, '-')}-${radius}m.csv`); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + function isInside(point, center, radius) { // center can be [lat, lng] or {lat, lng} const cLat = center.lat !== undefined ? center.lat : center[0]; @@ -752,6 +784,11 @@ // Update the local data copy so it persists in memory const localItem = ibadahData.find(i => i.id == activeIbadah.id); if (localItem) localItem.radius = newRadius; + // Update PDF export link with new radius + const pdfBtn = document.getElementById('btnExportRadiusPDF'); + if (pdfBtn && activeIbadah) { + pdfBtn.href = `{{ url('/export/pdf/radius') }}/${activeIbadah.id}/${newRadius}`; + } } } catch (e) { showToast('Gagal menyimpan radius ke database', 'error'); diff --git a/routes/web.php b/routes/web.php index de23ccc..4553b2d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,6 +2,7 @@ use App\Http\Controllers\AdminUserController; use App\Http\Controllers\AuthController; +use App\Http\Controllers\ExportController; use App\Http\Controllers\IbadahController; use App\Http\Controllers\MiskinController; use Illuminate\Support\Facades\Route; @@ -20,6 +21,12 @@ Route::middleware('auth')->group(function () { Route::get('/data', fn() => view('data')); Route::get('/dashboard', fn() => view('dashboard'))->name('dashboard'); + // Export routes — semua role authenticated bisa ekspor + Route::get('/export/pdf', [ExportController::class, 'allMiskinPDF'])->name('export.pdf.all'); + Route::get('/export/pdf/radius/{ibadahId}/{radius}', [ExportController::class, 'radiusMiskinPDF']) + ->name('export.pdf.radius') + ->where(['ibadahId' => '[0-9]+', 'radius' => '[0-9]+']); + // API routes moved here for session + CSRF middleware Route::prefix('api')->group(function () { diff --git a/tests/Feature/ExportTest.php b/tests/Feature/ExportTest.php new file mode 100644 index 0000000..dcf9323 --- /dev/null +++ b/tests/Feature/ExportTest.php @@ -0,0 +1,75 @@ + $role, 'guard_name' => 'web']); + } + } + + private function createUser(string $role): User + { + $user = User::factory()->create(); + $user->assignRole($role); + return $user; + } + + public function test_unauthenticated_cannot_export_pdf(): void + { + $this->get('/export/pdf')->assertRedirect('/login'); + } + + public function test_authenticated_can_export_all_miskin_pdf(): void + { + $this->actingAs($this->createUser('pemangku_kebijakan')); + + $response = $this->get('/export/pdf'); + $response->assertOk(); + $response->assertHeader('content-type', 'application/pdf'); + } + + public function test_radius_pdf_returns_404_for_invalid_ibadah(): void + { + $this->actingAs($this->createUser('administrator')); + + $this->get('/export/pdf/radius/99999/500')->assertNotFound(); + } + + public function test_radius_pdf_returns_pdf_for_valid_ibadah(): void + { + $admin = $this->createUser('administrator'); + $this->actingAs($admin); + + $ibadah = RumahIbadah::create([ + 'user_id' => $admin->id, + 'nama' => 'Test Masjid', + 'jenis' => 'masjid', + 'latitude' => -7.0, + 'longitude' => 110.0, + 'radius' => 500, + ]); + + $response = $this->get("/export/pdf/radius/{$ibadah->id}/500"); + $response->assertOk(); + $response->assertHeader('content-type', 'application/pdf'); + } + + public function test_radius_pdf_rejects_non_numeric_params(): void + { + $this->actingAs($this->createUser('administrator')); + + $this->get('/export/pdf/radius/abc/def')->assertNotFound(); + } +}