diff --git a/backend/app/Http/Controllers/Api/ResearchController.php b/backend/app/Http/Controllers/Api/ResearchController.php index ddef1a9..ecb6069 100644 --- a/backend/app/Http/Controllers/Api/ResearchController.php +++ b/backend/app/Http/Controllers/Api/ResearchController.php @@ -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(), @@ -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) @@ -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') @@ -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'); diff --git a/backend/tests/Feature/ResearchDownloadTest.php b/backend/tests/Feature/ResearchDownloadTest.php index 2b41d67..8374dc9 100644 --- a/backend/tests/Feature/ResearchDownloadTest.php +++ b/backend/tests/Feature/ResearchDownloadTest.php @@ -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'); @@ -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, diff --git a/frontend/src/app/navigation.ts b/frontend/src/app/navigation.ts index bfbae1d..9e5857c 100644 --- a/frontend/src/app/navigation.ts +++ b/frontend/src/app/navigation.ts @@ -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"] } ]; diff --git a/frontend/src/features/dashboards/ProvinceDashboardPage.tsx b/frontend/src/features/dashboards/ProvinceDashboardPage.tsx index 7afd8ae..af360a4 100644 --- a/frontend/src/features/dashboards/ProvinceDashboardPage.tsx +++ b/frontend/src/features/dashboards/ProvinceDashboardPage.tsx @@ -267,7 +267,41 @@ export function ProvinceDashboardPage() { return ( - {` + /* 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; } + } + `} + -
- setSelectedMonth(event.target.value)} style={{ minWidth: 150 }} /> - setSelectedMonth(event.target.value)} aria-label="Filter bulan prediksi" /> + -
{/* KPI Grid */} - + Wilayah Pantau Aktif {summary.monitored_regencies} diff --git a/frontend/src/features/research/ResearchPortalPage.tsx b/frontend/src/features/research/ResearchPortalPage.tsx index 06b2cae..9eaa965 100644 --- a/frontend/src/features/research/ResearchPortalPage.tsx +++ b/frontend/src/features/research/ResearchPortalPage.tsx @@ -105,6 +105,14 @@ interface ApiReferenceResponse { }; } +// Periode dataset disimpan sebagai ISO UTC (mis. 2020-01-01T00:00:00Z). Ditampilkan +// ringkas "1 Jan 2020 – 31 Des 2025"; timeZone UTC agar tanggal tak bergeser. +const periodDateFormatter = new Intl.DateTimeFormat("id-ID", { day: "numeric", month: "short", year: "numeric", timeZone: "UTC" }); +function formatPeriodDate(value: string): string { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : periodDateFormatter.format(date); +} + export function ResearchPortalPage() { const toast = useToast(); const currentUser = (() => { try { return JSON.parse(localStorage.getItem("siperah-user") || "null") as { role?: string } | null; } catch { return null; } })(); @@ -152,14 +160,14 @@ export function ResearchPortalPage() { } }; - const loadResearchData = async (page = datasetPage) => { + const loadResearchData = async (page = datasetPage, overrideFilters = datasetFilters) => { setIsLoading(true); try { const params = new URLSearchParams({ per_page: "10", page: String(page) }); - if (datasetFilters.year) params.set("year", datasetFilters.year); - if (datasetFilters.type) params.set("type", datasetFilters.type); - if (datasetFilters.regency) params.set("regency", datasetFilters.regency); - if (datasetFilters.search.trim()) params.set("search", datasetFilters.search.trim()); + if (overrideFilters.year) params.set("year", overrideFilters.year); + if (overrideFilters.type) params.set("type", overrideFilters.type); + if (overrideFilters.regency) params.set("regency", overrideFilters.regency); + if (overrideFilters.search.trim()) params.set("search", overrideFilters.search.trim()); const dsRes = await api(`/research/datasets?${params.toString()}`); setDatasets(dsRes.data); @@ -242,6 +250,20 @@ export function ResearchPortalPage() { setDatasetPage(1); loadResearchData(1); }; + // Ubah satu filter select lalu langsung terapkan (pakai nilai baru, bukan state basi). + const changeDatasetFilter = (patch: Partial) => { + const next = { ...datasetFilters, ...patch }; + setDatasetFilters(next); + setDatasetPage(1); + loadResearchData(1, next); + }; + const resetDatasetFilters = () => { + const cleared = { year: "", type: "", search: "", regency: "" }; + setDatasetFilters(cleared); + setDatasetPage(1); + loadResearchData(1, cleared); + }; + const hasDatasetFilters = Boolean(datasetFilters.year || datasetFilters.type || datasetFilters.regency || datasetFilters.search.trim()); const changeDatasetPage = (nextPage: number) => { if (datasetMeta && (nextPage < 1 || nextPage > datasetMeta.last_page)) return; setDatasetPage(nextPage); @@ -289,7 +311,42 @@ export function ResearchPortalPage() { } {/* KPI Grid */} -
+ +
{[ { title: "Dataset Tersedia", val: stats.dataset_count || datasets.length, sub: "set data tervalidasi" }, { title: "Total Rekaman", val: stats.total_records.toLocaleString("id-ID"), sub: "baris data terdokumentasi" }, @@ -335,24 +392,47 @@ export function ResearchPortalPage() { {activeTab === 0 && ( {/* Filters */} -
- - - -
- - setDatasetFilters((current) => ({ ...current, search: event.target.value }))} onKeyDown={(event) => { if (event.key === "Enter") applyDatasetFilters(); }} placeholder="Cari dataset..." style={{ width: "100%", padding: "10px 14px 10px 38px", borderRadius: "8px", border: "1px solid var(--line)", fontSize: "13px", background: "var(--surface)", color: "var(--ink)", boxSizing: "border-box" }} /> +
+
+
+ + setDatasetFilters((current) => ({ ...current, search: event.target.value }))} + onKeyDown={(event) => { if (event.key === "Enter") applyDatasetFilters(); }} + placeholder="Cari nama dataset…" + aria-label="Cari dataset" + /> + {datasetFilters.search && ( + + )} +
+ +
+
+ + +
- + {hasDatasetFilters && ( + + )}
{/* Table */} @@ -379,8 +459,8 @@ export function ResearchPortalPage() { {ds.dataset_type} - - {ds.period_start} — {ds.period_end} + + {formatPeriodDate(ds.period_start)} – {formatPeriodDate(ds.period_end)} {ds.resolution}