feat: Plan 5 — Ekspor PDF + Ekspor per Radius + Cetak Laporan

- 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 <noreply@anthropic.com>
This commit is contained in:
GuavaPopper
2026-06-03 20:44:10 +07:00
parent 4649a983ef
commit 1fcad7d9b2
9 changed files with 327 additions and 9 deletions
+3
View File
@@ -99,6 +99,9 @@
<button onclick="exportCSV()" class="btn btn-md btn-outline border-base-300 gap-2">
<i class="fa-solid fa-file-csv"></i> Ekspor CSV
</button>
<a href="{{ route('export.pdf.all') }}" target="_blank" class="btn btn-md btn-outline border-base-300 gap-2">
<i class="fa-solid fa-file-pdf text-error"></i> Ekspor PDF
</a>
@endif
<a href="{{ url('/map') }}" class="btn btn-md btn-primary shadow-lg gap-2">
<i class="fa-solid fa-plus"></i> Tambah Data
@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Laporan Keluarga Miskin</title>
<style>
body { font-family: DejaVu Sans, sans-serif; font-size: 9px; color: #1f2937; margin: 20px; }
h1 { font-size: 15px; text-align: center; margin: 0 0 2px 0; }
.subtitle { text-align: center; font-size: 9px; color: #6b7280; margin: 0 0 12px 0; }
.summary { background: #f3f4f6; padding: 6px 10px; margin-bottom: 10px; font-size: 9px; }
table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
th { background: #1e40af; color: #fff; padding: 5px 6px; text-align: left; font-size: 8px; text-transform: uppercase; }
td { padding: 4px 6px; border-bottom: 1px solid #e5e7eb; font-size: 8px; vertical-align: top; }
tr:nth-child(even) td { background: #f9fafb; }
.footer { font-size: 7px; color: #9ca3af; text-align: right; border-top: 1px solid #e5e7eb; padding-top: 4px; }
</style>
</head>
<body>
<h1>Laporan Data Keluarga Miskin</h1>
<p class="subtitle">WebGIS Pemetaan Kemiskinan &mdash; Dicetak: {{ now()->format('d/m/Y H:i') }} WIB</p>
<div class="summary">
<strong>Total Keluarga:</strong> {{ $miskin->count() }} &nbsp;&nbsp;
<strong>Total KK:</strong> {{ $totalKK }} &nbsp;&nbsp;
<strong>Total Jiwa:</strong> {{ $totalJiwa }} &nbsp;&nbsp;
<strong>Total Rumah Ibadah:</strong> {{ $ibadahCount }}
</div>
<table>
<thead>
<tr>
<th style="width:20px">#</th>
<th style="width:60px">ID Rumah</th>
<th style="width:80px">Nama KK</th>
<th>Alamat</th>
<th style="width:20px">KK</th>
<th style="width:20px">Jiwa</th>
<th style="width:60px">Pekerjaan</th>
<th style="width:50px">Kondisi</th>
</tr>
</thead>
<tbody>
@forelse($miskin as $i => $m)
<tr>
<td>{{ $i + 1 }}</td>
<td>{{ $m->id_rumah }}</td>
<td>{{ $m->nama_kk ?: '-' }}</td>
<td>{{ Str::limit($m->alamat, 45) }}</td>
<td style="text-align:center">{{ $m->jumlah_kk }}</td>
<td style="text-align:center">{{ $m->jumlah_orang }}</td>
<td>{{ $m->pekerjaan ?: '-' }}</td>
<td>{{ str_replace('_', ' ', $m->kondisi_rumah ?: '-') }}</td>
</tr>
@empty
<tr><td colspan="8" style="text-align:center;padding:12px;">Tidak ada data</td></tr>
@endforelse
</tbody>
</table>
<p class="footer">Dokumen ini dibuat otomatis oleh sistem WebGIS Pemetaan Kemiskinan.</p>
</body>
</html>
@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Laporan Radius {{ $ibadah->nama }}</title>
<style>
body { font-family: DejaVu Sans, sans-serif; font-size: 9px; color: #1f2937; margin: 20px; }
h1 { font-size: 15px; margin: 0 0 2px 0; }
.subtitle { font-size: 9px; color: #6b7280; margin: 0 0 12px 0; }
.info-box { border: 1px solid #d1d5db; padding: 7px 10px; margin-bottom: 10px; font-size: 9px; }
.info-row { margin-bottom: 2px; }
.info-label { display: inline-block; width: 100px; font-weight: bold; }
table { width: 100%; border-collapse: collapse; }
th { background: #1e40af; color: #fff; padding: 5px 6px; text-align: left; font-size: 8px; text-transform: uppercase; }
td { padding: 4px 6px; border-bottom: 1px solid #e5e7eb; font-size: 8px; vertical-align: top; }
tr:nth-child(even) td { background: #f9fafb; }
.footer { font-size: 7px; color: #9ca3af; text-align: right; border-top: 1px solid #e5e7eb; padding-top: 4px; margin-top: 10px; }
</style>
</head>
<body>
<h1>Laporan Radius: {{ $ibadah->nama }}</h1>
<p class="subtitle">WebGIS Pemetaan Kemiskinan &mdash; Dicetak: {{ now()->format('d/m/Y H:i') }} WIB</p>
<div class="info-box">
<div class="info-row"><span class="info-label">Rumah Ibadah:</span> {{ $ibadah->nama }} ({{ ucfirst($ibadah->jenis ?: '-') }})</div>
<div class="info-row"><span class="info-label">Alamat:</span> {{ $ibadah->alamat ?: '-' }}</div>
<div class="info-row"><span class="info-label">Pengurus:</span> {{ $ibadah->pengurus ?: '-' }}</div>
<div class="info-row"><span class="info-label">Radius Analisis:</span> {{ number_format($radius) }} meter</div>
</div>
<table style="width:auto; border-collapse:collapse; margin-bottom:12px;">
<tr>
<td style="border:1px solid #d1d5db; padding:6px 20px; text-align:center;">
<span style="font-size:18px; font-weight:bold; color:#1e40af; display:block;">{{ $miskin->count() }}</span>
<span style="font-size:7px; text-transform:uppercase; color:#6b7280;">Rumah</span>
</td>
<td style="border:1px solid #d1d5db; padding:6px 20px; text-align:center;">
<span style="font-size:18px; font-weight:bold; color:#dc2626; display:block;">{{ $totalKK }}</span>
<span style="font-size:7px; text-transform:uppercase; color:#6b7280;">KK</span>
</td>
<td style="border:1px solid #d1d5db; padding:6px 20px; text-align:center;">
<span style="font-size:18px; font-weight:bold; color:#d97706; display:block;">{{ $totalJiwa }}</span>
<span style="font-size:7px; text-transform:uppercase; color:#6b7280;">Jiwa</span>
</td>
</tr>
</table>
<table>
<thead>
<tr>
<th style="width:20px">#</th>
<th style="width:70px">ID Rumah</th>
<th style="width:90px">Nama KK</th>
<th>Alamat</th>
<th style="width:20px">KK</th>
<th style="width:20px">Jiwa</th>
<th style="width:70px">Pekerjaan</th>
<th style="width:55px">Kondisi</th>
</tr>
</thead>
<tbody>
@forelse($miskin as $i => $m)
<tr>
<td>{{ $i + 1 }}</td>
<td>{{ $m->id_rumah }}</td>
<td>{{ $m->nama_kk ?: '-' }}</td>
<td>{{ Str::limit($m->alamat, 40) }}</td>
<td style="text-align:center">{{ $m->jumlah_kk }}</td>
<td style="text-align:center">{{ $m->jumlah_orang }}</td>
<td>{{ $m->pekerjaan ?: '-' }}</td>
<td>{{ str_replace('_', ' ', $m->kondisi_rumah ?: '-') }}</td>
</tr>
@empty
<tr><td colspan="8" style="text-align:center;padding:12px;">Tidak ada keluarga miskin dalam radius ini.</td></tr>
@endforelse
</tbody>
</table>
<p class="footer">Dokumen ini dibuat otomatis oleh sistem WebGIS Pemetaan Kemiskinan.</p>
</body>
</html>
+37
View File
@@ -191,6 +191,14 @@
<div class="text-[8px] uppercase tracking-wider font-bold opacity-50">Jiwa</div>
</div>
</div>
<div class="border-t border-base-200 pt-3 mt-3 flex flex-col gap-2">
<button onclick="exportRadiusCSV()" class="btn btn-xs btn-outline gap-1 w-full">
<i class="fa-solid fa-file-csv text-success"></i> Export CSV Radius
</button>
<a id="btnExportRadiusPDF" href="#" target="_blank" class="btn btn-xs btn-error btn-outline gap-1 w-full">
<i class="fa-solid fa-file-pdf"></i> Export PDF Radius
</a>
</div>
</div>
</div>
</div>
@@ -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');