Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions backend/app/Http/Controllers/Api/ResearchController.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,17 @@ public function datasets(Request $request): JsonResponse

$paginator = $query->paginate($filters['per_page'] ?? 10);

// REKAMAN dihitung langsung dari query data nyata (sama dengan yang
// diekspor saat download), bukan kolom seeder statis — agar angka selalu
// cocok dengan isi CSV/JSON yang bisa diunduh.
$items = array_map(function (Dataset $dataset): Dataset {
$dataset->record_count = $this->queryForDataset($dataset)[0]->count();

return $dataset;
}, $paginator->items());

return response()->json([
'data' => $paginator->items(),
'data' => $items,
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
Expand Down Expand Up @@ -100,7 +109,7 @@ public function downloadDataset(Request $request, Dataset $dataset): JsonRespons
'dataset_type' => $dataset->dataset_type,
]);

return $this->exportOrPaginate($query, $data, $filename);
return $this->exportOrPaginate($query, $data, $filename, fullExport: true);
}

public function apiKeys(Request $request)
Expand Down Expand Up @@ -156,7 +165,9 @@ public function stats(Request $request): JsonResponse

return response()->json(['data' => [
'dataset_count' => Dataset::count(),
'total_records' => (int) Dataset::sum('record_count'),
// Jumlah rekaman nyata lintas dataset (count dari query data asli),
// bukan penjumlahan kolom seeder statis.
'total_records' => (int) Dataset::all()->sum(fn (Dataset $dataset): int => $this->queryForDataset($dataset)[0]->count()),
'downloads_this_month' => DB::table('audit_logs')
->where('action', 'download_research_dataset')
->where('outcome', 'success')
Expand Down Expand Up @@ -438,8 +449,25 @@ private function tidalQuery(): Builder
]);
}

private function exportOrPaginate(Builder $query, array $data, string $filename): JsonResponse|StreamedResponse
private function exportOrPaginate(Builder $query, array $data, string $filename, bool $fullExport = false): JsonResponse|StreamedResponse
{
// Unduhan JSON (fullExport): file lengkap & rapi — pretty-print, semua baris
// via cursor (hemat memori), sekelas CSV. API v1 tetap paginated/compact.
if ($fullExport && ($data['format'] ?? 'json') === 'json') {
$jsonFilename = str_replace('.csv', '.json', $filename);

return response()->streamDownload(function () use ($query): void {
$first = true;
foreach ($query->cursor() as $row) {
echo $first ? "[\n" : ",\n";
$encoded = json_encode((array) $row, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
echo ' '.str_replace("\n", "\n ", (string) $encoded);
$first = false;
}
echo $first ? "[]\n" : "\n]\n";
}, $jsonFilename, ['Content-Type' => 'application/json; charset=UTF-8']);
}

if (($data['format'] ?? 'json') === 'csv') {
return response()->streamDownload(function () use ($query): void {
$output = fopen('php://output', 'wb');
Expand Down
16 changes: 10 additions & 6 deletions backend/tests/Feature/ResearchDownloadTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public function test_v1_predictions_supports_csv_and_json_with_date_filter(): vo
->assertJsonMissing(['risk_class' => 'tinggi']);
}

public function test_dataset_download_streams_csv_paginates_json_and_writes_audit(): void
public function test_dataset_download_streams_csv_and_full_json_and_writes_audit(): void
{
$region = $this->insertRegion('Kabupaten Dataset Unduh');
$this->makePrediction($region, CarbonImmutable::today()->toDateString(), 'tinggi');
Expand All @@ -74,12 +74,16 @@ public function test_dataset_download_streams_csv_paginates_json_and_writes_audi
->assertHeader('content-type', 'text/csv; charset=UTF-8');
$this->assertStringContainsString('Kabupaten Dataset Unduh', $csv->streamedContent());

// JSON dipaginasi urut tanggal terbaru; DB dev berisi prediksi nyata
// sehingga baris seed tak pasti di halaman 1 — cukup uji struktur.
$json = $this->getJson("/api/research/datasets/{$dataset->id}/download?per_page=5")
// Unduhan JSON: file lengkap (bukan paginated), pretty-print, streamed.
$jsonResponse = $this->get("/api/research/datasets/{$dataset->id}/download?format=json")
->assertOk()
->assertJsonStructure(['data', 'meta' => ['current_page', 'total']]);
$this->assertGreaterThanOrEqual(1, $json->json('meta.total'));
->assertHeader('content-type', 'application/json; charset=UTF-8');
$jsonBody = $jsonResponse->streamedContent();
$this->assertStringContainsString('Kabupaten Dataset Unduh', $jsonBody);
$decoded = json_decode($jsonBody, true);
$this->assertIsArray($decoded);
$this->assertGreaterThanOrEqual(1, count($decoded));
$this->assertArrayHasKey('regency', $decoded[0]);

$this->assertSame(
2,
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const navItems: NavItem[] = [
{ href: "#/history", icon: "history", label: "Riwayat Laporan", roles: ["warga", "admin"] },
{ href: "#/operator", icon: "assignment_turned_in", label: "Operator", roles: ["bpbd_operator", "admin"] },
{ href: "#/province", icon: "monitoring", label: "Provinsi", roles: ["bpbd_provinsi", "admin"] },
{ href: "#/research", icon: "database", label: "Arsip Data", roles: ["peneliti", "bpbd_provinsi", "admin"] },
{ href: "#/research", icon: "database", label: "Arsip Data", roles: ["peneliti", "admin"] },
{ href: "#/admin", icon: "manage_accounts", label: "Pengguna & Perizinan", roles: ["admin"] },
{ href: "#/audit", icon: "policy", label: "Audit", roles: ["admin"] }
];
48 changes: 41 additions & 7 deletions frontend/src/features/dashboards/ProvinceDashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,41 @@ export function ProvinceDashboardPage() {

return (
<AppShell active="province" title="Dashboard BPBD Provinsi Lampung">
<motion.div
<style>{`
/* Filter banner provinsi: kontrol modern seragam + 3 kolom di mobile. */
.prov-filter { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; justify-content: flex-end; }
.prov-control, .prov-reset {
height: 42px; border-radius: 10px; border: 1px solid var(--line);
background: var(--surface); color: var(--ink);
font: inherit; font-size: 13px; font-weight: 500; padding: 0 12px;
box-sizing: border-box;
}
.prov-control:focus, .prov-reset:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(37, 99, 235, .15); }
input[type="month"].prov-control { min-width: 160px; }
input[type="month"].prov-control::-webkit-calendar-picker-indicator { cursor: pointer; opacity: .55; }
select.prov-control { min-width: 210px; cursor: pointer; }
.prov-reset {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
border-color: var(--critical); color: var(--critical); background: transparent;
font-weight: 600; cursor: pointer; white-space: nowrap; padding: 0 16px;
}
.prov-reset:hover { background: rgba(220, 38, 38, .08); }

@media (max-width: 768px) {
.prov-filter { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; width: 100%; }
.prov-control, .prov-reset { width: 100%; min-width: 0 !important; padding: 0 8px; font-size: 12.5px; }
.prov-reset-suffix { display: none; }

/* KPI jadi 2 kolom di mobile (override aturan global 1fr !important),
padding & angka diringkas agar proporsional. */
.metric-grid.province-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; gap: 12px !important; }
.metric-grid.province-kpis .metric-card { padding: 18px !important; }
.metric-grid.province-kpis .metric-card span { font-size: 12px !important; }
.metric-grid.province-kpis .metric-card strong { font-size: 28px !important; }
.metric-grid.province-kpis .metric-card small { font-size: 11px !important; }
}
`}</style>
<motion.div
className="content"
variants={containerVariants}
initial="hidden"
Expand All @@ -292,20 +326,20 @@ export function ProvinceDashboardPage() {
</div>
</div>
</div>
<div style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap", justifyContent: "flex-end" }}>
<input type="month" value={selectedMonth} onChange={(event) => setSelectedMonth(event.target.value)} style={{ minWidth: 150 }} />
<select value={selectedRegency} onChange={(event) => setSelectedRegency(event.target.value)} style={{ minWidth: 220 }}>
<div className="prov-filter">
<input type="month" className="prov-control" value={selectedMonth} onChange={(event) => setSelectedMonth(event.target.value)} aria-label="Filter bulan prediksi" />
<select className="prov-control" value={selectedRegency} onChange={(event) => setSelectedRegency(event.target.value)} aria-label="Filter kabupaten/kota">
<option value="all">Semua kabupaten/kota</option>
{(summary.available_regencies ?? []).map((regency) => <option key={regency} value={regency}>{regency}</option>)}
</select>
<button className="btn secondary" style={{ background: "transparent", color: "var(--critical)", borderColor: "var(--critical)" }} onClick={() => { setSelectedMonth(""); setSelectedRegency("all"); }}>
Reset Filter
<button type="button" className="prov-reset" onClick={() => { setSelectedMonth(""); setSelectedRegency("all"); }}>
<Icon name="restart_alt" style={{ fontSize: 17 }} /> <span>Reset<span className="prov-reset-suffix"> Filter</span></span>
</button>
</div>
</motion.div>

{/* KPI Grid */}
<motion.div variants={containerVariants} className="metric-grid" style={{ marginBottom: "32px", gap: "24px" }}>
<motion.div variants={containerVariants} className="metric-grid province-kpis" style={{ marginBottom: "32px", gap: "24px" }}>
<motion.div variants={itemVariants} className="metric-card" style={{ padding: "28px", borderRadius: 8 }}>
<span style={{ fontSize: "14px", color: "var(--ink-soft)", fontWeight: 600, display: "block", marginBottom: "12px", textTransform: "uppercase", letterSpacing: "0.5px" }}>Wilayah Pantau Aktif</span>
<strong style={{ color: "var(--accent)", fontSize: "36px", fontWeight: 900, display: "block", lineHeight: 1 }}>{summary.monitored_regencies}</strong>
Expand Down
Loading
Loading