From 7e9aadc6f45ac249e1023642e1927fecedec204e Mon Sep 17 00:00:00 2001 From: Udin Voldigoad Date: Thu, 23 Jul 2026 23:32:37 +0700 Subject: [PATCH] feat(operator): rapikan dashboard operator & detail laporan untuk mobile Dashboard operator (OperatorDashboardPage): - KPI jadi 2 kolom di mobile (bukan 1) & angka berukuran standar/modern (1.8rem/700, bukan 3rem tipis), kartu lebih ringkas. Di-scope ke .operator-kpis agar dashboard lain tak berubah. - Switcher "Laporan Masuk" / "Riwayat Validasi" jadi segmented control modern dengan badge jumlah laporan baru; "Antrean Laporan Masuk" -> "Laporan Masuk". - Filter jadi 3 kolom satu baris (keparahan, SLA, Export); tombol Export CSV hijau; label diringkas ("SLA"/"Export") di mobile agar muat. - Baris antrean proporsional di mobile: badge turun jadi chip, deskripsi tak overflow, aksi Validasi full-width lalu Tolak & Detail 2 kolom. - Banner: tombol "Ringkasan Provinsi" -> "Lihat laporan" yang scroll ke daftar laporan (bukan pindah halaman). Detail laporan (ReportDetailPage): - Skeleton shimmer saat memuat (hilangkan kedip "tidak ditemukan"). - Indikator status jadi 3 kolom di mobile. - Form alasan penolakan pakai modal in-app (ganti window.prompt bawaan browser). - Saat ditolak: tampilkan toast lalu kembali ke dashboard operator. --- .../dashboards/OperatorDashboardPage.tsx | 155 +++++++++++----- .../src/features/reports/ReportDetailPage.tsx | 171 ++++++++++++++++-- 2 files changed, 262 insertions(+), 64 deletions(-) diff --git a/frontend/src/features/dashboards/OperatorDashboardPage.tsx b/frontend/src/features/dashboards/OperatorDashboardPage.tsx index c7bd308..f12c3eb 100644 --- a/frontend/src/features/dashboards/OperatorDashboardPage.tsx +++ b/frontend/src/features/dashboards/OperatorDashboardPage.tsx @@ -55,6 +55,8 @@ export function OperatorDashboardPage() { const [activeTab, setActiveTab] = useState<"antrean" | "riwayat">("antrean"); // Untuk mendeteksi kedatangan laporan baru antar polling (null = belum ada baseline). const prevPendingRef = useRef(null); + // Panel antrean — target scroll dari tombol "Lihat laporan" di banner. + const queueRef = useRef(null); const toast = useToast(); const loadReports = async (targetPage = page) => { @@ -161,6 +163,64 @@ export function OperatorDashboardPage() { .operator-page-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 800; } .operator-page-btn:disabled { cursor: not-allowed; opacity: .45; } .dashboard-split-layout { display: grid; gap: 24px; } + + /* KPI operator: kartu lebih ringkas & angka berukuran standar/modern + (bukan 3rem tipis). Di-scope ke .operator-kpis agar dashboard lain + tak ikut berubah. */ + .metric-grid.operator-kpis .metric-card { min-height: 0; padding: 18px 20px; gap: 6px; } + .metric-grid.operator-kpis .metric-card span { font-size: 12px; font-weight: 600; } + .metric-grid.operator-kpis .metric-card strong { font-size: 1.8rem; font-weight: 700; } + .metric-grid.operator-kpis .metric-card small { font-size: 11px; } + /* 2 kolom di mobile (override aturan global 1fr !important di ≤768px); + spesifisitas .metric-grid.operator-kpis + !important memenangkannya. */ + @media (max-width: 768px) { + .metric-grid.operator-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; gap: 12px; } + } + + /* Header panel antrean: switcher + subjudul + filter, ditumpuk rapi. */ + .op-panel-head { padding: 18px 20px; border-bottom: 1px solid var(--line); display: grid; gap: 14px; } + + /* Switcher tab modern (segmented control). */ + .op-tabs { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; background: var(--surface-soft); border: 1px solid var(--line); border-radius: 12px; padding: 4px; width: 100%; max-width: 400px; } + .op-tab { display: inline-flex; align-items: center; justify-content: center; gap: 7px; border: 0; background: transparent; color: var(--ink-soft); font: inherit; font-size: 0.9rem; font-weight: 600; padding: 9px 10px; border-radius: 9px; cursor: pointer; transition: background .15s, color .15s, box-shadow .15s; } + .op-tab:hover { color: var(--ink); } + .op-tab.active { background: var(--surface); color: var(--accent); box-shadow: 0 1px 3px rgba(15, 23, 42, .12); } + .op-tab-count { font-size: 11px; font-weight: 800; line-height: 1; background: var(--critical); color: #fff; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; } + + .op-subtitle { color: var(--ink-soft); font-size: 12px; } + + /* Filter 3 kolom satu baris; Export CSV hijau. */ + .op-filter-row { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; max-width: 520px; } + .op-filter-select, .op-filter-btn { min-width: 0; width: 100%; height: 40px; border-radius: 9px; border: 1px solid var(--line); background: var(--surface); color: var(--ink); font-size: 13px; font-weight: 600; padding: 0 10px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; cursor: pointer; } + .op-filter-btn { white-space: nowrap; overflow: hidden; } + .op-filter-btn .op-btn-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .op-filter-btn:hover { border-color: var(--accent); } + .op-filter-btn.active { background: var(--critical); border-color: var(--critical); color: #fff; } + .op-filter-btn.export { background: var(--low); border-color: var(--low); color: #fff; } + .op-filter-btn.export:hover { border-color: var(--low); filter: brightness(0.94); } + + /* Baris laporan: rapi & proporsional. */ + .op-report-row { padding: 18px 20px; } + .op-report-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; } + .op-report-head > div:first-child { min-width: 0; } + .op-report-badges { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; flex-shrink: 0; } + .op-report-desc { font-size: 14px; color: var(--ink); margin: 12px 0 0; padding: 14px 16px; background: var(--surface-soft); border-radius: var(--radius); } + .op-report-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 16px; } + + @media (max-width: 768px) { + .op-panel-head { padding: 16px; } + .op-tabs { max-width: none; } + /* Label tombol filter diringkas ("SLA"/"Export") agar muat 3 kolom. */ + .op-btn-suffix { display: none; } + .op-report-row { padding: 16px; } + /* Badge severity/SLA turun ke bawah judul (chip) agar tak menekan judul. */ + .op-report-head { flex-direction: column; } + .op-report-badges { flex-direction: row; flex-wrap: wrap; align-items: flex-start; } + /* Aksi proporsional: Validasi full-width, Tolak & Detail berbagi baris. */ + .op-report-actions { flex-wrap: wrap; justify-content: stretch; } + .op-report-actions .btn { flex: 1 1 calc(50% - 5px); min-width: 0; justify-content: center; padding: 11px 12px; } + .op-report-actions .op-act-primary { flex-basis: 100%; } + } `} - queueRef.current?.scrollIntoView({ behavior: "smooth", block: "start" })} className="btn primary" style={{ background: "#7a1a13", borderColor: "#7a1a13", fontSize: "13px", whiteSpace: "nowrap" }} > - Ringkasan Provinsi - + Lihat laporan + {/* KPI Grid */} - + {[ { title: "Kelurahan pantau aktif", val: summary.monitored_villages, sub: "wilayah pesisir", cls: "" }, { title: "Bahaya Sangat Tinggi", val: summary.critical_villages, sub: "kelurahan hari ini", cls: "critical" }, @@ -229,34 +290,35 @@ export function OperatorDashboardPage() { {/* Main Content Layout */} {/* Antrean Moderasi */} -
-
-
-
-

setActiveTab("antrean")} - > - Antrean Laporan Masuk -

-

setActiveTab("riwayat")} - > - Riwayat Validasi -

-
- - {operatorArea} · {pageMeta.total === 0 ? "tidak ada laporan pada filter ini" : `menampilkan ${pageMeta.from}-${pageMeta.to} dari ${pageMeta.total} laporan`} - {(severityFilter || slaOverdue) && <> · } - +
+
+
+ +
-
+ + {operatorArea} · {pageMeta.total === 0 ? "tidak ada laporan pada filter ini" : `menampilkan ${pageMeta.from}-${pageMeta.to} dari ${pageMeta.total} laporan`} + {(severityFilter || slaOverdue) && <> · } + +
- - Export CSV + + Export CSV - - {pendingCount} baru -
@@ -297,23 +352,23 @@ export function OperatorDashboardPage() { ) : ( {reports.map((report) => ( - -
+
Kel. {report.village} • Kec. {report.district}

Dilaporkan: {report.submittedAt} • oleh warga ({report.waterHeightCm ? `${report.waterHeightCm} cm` : "-"})

{!report.isWithinMonitoringArea &&

Triase: lokasi di luar wilayah pantauan rob, tetapi masih dalam {report.regency}.

}
-
+
{severityLabels[report.severity]} @@ -324,21 +379,21 @@ export function OperatorDashboardPage() { )}
-
+
"{report.description}"
-
+
{activeTab === "antrean" && ( <> - handleValidate(report)}> + handleValidate(report)}> Validasi Laporan - handleReject(report)}> + handleReject(report)}> Tolak )} - + Detail
diff --git a/frontend/src/features/reports/ReportDetailPage.tsx b/frontend/src/features/reports/ReportDetailPage.tsx index 643c2b8..8cc739a 100644 --- a/frontend/src/features/reports/ReportDetailPage.tsx +++ b/frontend/src/features/reports/ReportDetailPage.tsx @@ -1,32 +1,46 @@ import { useEffect, useState, useRef } from "react"; +import { createPortal } from "react-dom"; import maplibregl from "maplibre-gl"; import "maplibre-gl/dist/maplibre-gl.css"; import { AppShell } from "../../shared/components/AppShell"; import { Icon } from "../../shared/components/Icon"; +import { Skeleton } from "../../shared/components/Skeleton"; +import { useToast } from "../../shared/components/Toast"; import { fetchOperatorReport, findOperatorReport, severityLabels, statusLabels, updateOperatorReportStatus, type OperatorReport, type ReportStatus } from "./reportData"; export function ReportDetailPage({ reportId }: { reportId: string }) { const [report, setReport] = useState(() => findOperatorReport(reportId)); const [error, setError] = useState(""); const [isSaving, setIsSaving] = useState(false); + // Skeleton hanya saat belum ada data lokal (laporan asli dari API) agar tak + // ada kedip "tidak ditemukan" sebelum fetch selesai. + const [isLoading, setIsLoading] = useState(() => !findOperatorReport(reportId)); + const [rejectOpen, setRejectOpen] = useState(false); + const [rejectReason, setRejectReason] = useState(""); + const toast = useToast(); useEffect(() => { - setReport(findOperatorReport(reportId)); + const local = findOperatorReport(reportId); + setReport(local); + setIsLoading(!local); fetchOperatorReport(reportId) - .then((data) => { - setReport(data); - setError(""); - }) - .catch(() => setError("Backend belum tersedia. Menampilkan data contoh jika ada.")); + .then((data) => { setReport(data); setError(""); }) + .catch(() => setError("Backend belum tersedia. Menampilkan data contoh jika ada.")) + .finally(() => setIsLoading(false)); }, [reportId]); - async function saveStatus(status: ReportStatus) { - const rejectionReason = status === "ditolak" ? window.prompt("Alasan penolakan laporan")?.trim() : undefined; - if (status === "ditolak" && !rejectionReason) return; + // Tutup modal penolakan dengan Escape. + useEffect(() => { + if (!rejectOpen) return; + const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") setRejectOpen(false); }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [rejectOpen]); + async function saveStatus(status: ReportStatus, reason?: string) { try { setIsSaving(true); - setReport(await updateOperatorReportStatus(reportId, status, rejectionReason)); + setReport(await updateOperatorReportStatus(reportId, status, reason)); setError(""); } catch { setError("Status laporan belum tersimpan. Cek koneksi backend."); @@ -35,6 +49,27 @@ export function ReportDetailPage({ reportId }: { reportId: string }) { } } + async function confirmReject() { + const reason = rejectReason.trim(); + if (!reason) return; + const code = report?.code ?? ""; + try { + setIsSaving(true); + await updateOperatorReportStatus(reportId, "ditolak", reason); + setRejectOpen(false); + setRejectReason(""); + toast.info(`Laporan ${code} telah ditolak.`); + // Kembali ke dashboard operator setelah laporan ditolak. + window.location.hash = "#/operator"; + } catch { + setError("Status laporan belum tersimpan. Cek koneksi backend."); + } finally { + setIsSaving(false); + } + } + + if (isLoading) return ; + if (!report) { return ( @@ -55,6 +90,31 @@ export function ReportDetailPage({ reportId }: { reportId: string }) { return ( +
{error &&
{error}
} @@ -68,7 +128,7 @@ export function ReportDetailPage({ reportId }: { reportId: string }) {

Ringkasan kejadian

{report.description}

-
+
{report.isWithinMonitoringArea ? "Wilayah pantauan" : "Di luar cakupan prediksi"} {severityLabels[report.severity]} {statusLabels[report.status]} @@ -100,7 +160,7 @@ export function ReportDetailPage({ reportId }: { reportId: string }) {
- + Kembali
@@ -128,6 +188,89 @@ export function ReportDetailPage({ reportId }: { reportId: string }) {
+ + {rejectOpen && createPortal( +
setRejectOpen(false)}> +
event.stopPropagation()}> +
+ +

Tolak laporan {report.code}

+
+

Tuliskan alasan penolakan agar tercatat bersama laporan dan bisa ditinjau kembali.

+