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 }}
+
+
+
+
+
+ | # |
+ ID Rumah |
+ Nama KK |
+ Alamat |
+ KK |
+ Jiwa |
+ Pekerjaan |
+ Kondisi |
+
+
+
+ @forelse($miskin as $i => $m)
+
+ | {{ $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 ?: '-') }} |
+
+ @empty
+ | Tidak ada data |
+ @endforelse
+
+
+
+
+
+
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
+ |
+
+
+
+
+
+
+ | # |
+ ID Rumah |
+ Nama KK |
+ Alamat |
+ KK |
+ Jiwa |
+ Pekerjaan |
+ Kondisi |
+
+
+
+ @forelse($miskin as $i => $m)
+
+ | {{ $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 ?: '-') }} |
+
+ @empty
+ | Tidak ada keluarga miskin dalam radius ini. |
+ @endforelse
+
+
+
+
+
+
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();
+ }
+}