"use client"; import { type CSSProperties, useEffect, useMemo, useState } from "react"; import Map from "@/components/Map"; import { ApiError } from "@/api/http"; import { approveSubmission, fetchSections, fetchSectionSubmissions, rejectSubmission, Section, SectionSubmission, } from "@/api/sections"; import { DEFAULT_BACKGROUND_LAYER_VISIBILITY } from "@/lib/backgroundLayers"; import { Feature, FeatureCollection, Geometry } from "@/lib/useEditorState"; type SubmissionStatusFilter = "pending" | "all"; type ReviewAction = "approve" | "reject"; type SubmissionRow = { section: Section; submission: SectionSubmission; }; type SnapshotSummary = { featureCount: number; entityCount: number; geometryCount: number; linkScopeCount: number; entityOperations: Record; geometryOperations: Record; linkOperations: Record; }; const DEFAULT_REVIEWER_ID = "admin"; const EMPTY_FEATURE_COLLECTION: FeatureCollection = { type: "FeatureCollection", features: [], }; const STATUS_LABELS: Record = { pending: "Chờ duyệt", approved: "Đã duyệt", rejected: "Từ chối", conflicted: "Conflict", }; export default function SubmittedPage() { const [rows, setRows] = useState([]); const [selectedSubmissionId, setSelectedSubmissionId] = useState(null); const [statusFilter, setStatusFilter] = useState("pending"); const [reviewerId, setReviewerId] = useState(DEFAULT_REVIEWER_ID); const [reviewNote, setReviewNote] = useState(""); const [isLoading, setIsLoading] = useState(true); const [reviewingSubmissionId, setReviewingSubmissionId] = useState(null); const [selectedPreviewFeatureId, setSelectedPreviewFeatureId] = useState(null); const [statusMessage, setStatusMessage] = useState(null); const [errorMessage, setErrorMessage] = useState(null); const loadSubmissions = async () => { setIsLoading(true); setErrorMessage(null); setStatusMessage(null); try { const sections = await fetchSections(); const submissionGroups = await Promise.all( sections.map(async (section) => { const submissions = await fetchSectionSubmissions(section.id, { includeSnapshot: true, }); return submissions.map((submission) => ({ section, submission, })); }) ); const nextRows = submissionGroups .flat() .sort((a, b) => Date.parse(b.submission.submitted_at) - Date.parse(a.submission.submitted_at) ); setRows(nextRows); setSelectedSubmissionId((current) => { if (current && nextRows.some((row) => row.submission.id === current)) { return current; } return nextRows.find((row) => row.submission.status === "pending")?.submission.id || nextRows[0]?.submission.id || null; }); } catch (err) { setErrorMessage(getErrorMessage(err, "Không tải được danh sách section đã submit.")); } finally { setIsLoading(false); } }; useEffect(() => { loadSubmissions(); }, []); const visibleRows = useMemo(() => { if (statusFilter === "all") return rows; return rows.filter((row) => row.submission.status === "pending"); }, [rows, statusFilter]); const selectedRow = useMemo(() => { if (!selectedSubmissionId) return visibleRows[0] || null; return ( visibleRows.find((row) => row.submission.id === selectedSubmissionId) || rows.find((row) => row.submission.id === selectedSubmissionId) || visibleRows[0] || null ); }, [rows, selectedSubmissionId, visibleRows]); const pendingCount = rows.filter((row) => row.submission.status === "pending").length; const reviewedCount = rows.length - pendingCount; const selectedSummary = selectedRow ? summarizeSnapshot(selectedRow.submission.snapshot) : null; const selectedFeatureCollection = selectedRow ? extractSnapshotFeatureCollection(selectedRow.submission.snapshot) : EMPTY_FEATURE_COLLECTION; const selectedPreviewFeature = selectedFeatureCollection.features.find((feature) => String(feature.properties.id) === String(selectedPreviewFeatureId) ) || null; useEffect(() => { setSelectedPreviewFeatureId(null); }, [selectedRow?.submission.id]); const handleReview = async (action: ReviewAction) => { if (!selectedRow) return; const actor = reviewerId.trim(); if (!actor) { setErrorMessage("Reviewer là bắt buộc."); return; } if (selectedRow.submission.status !== "pending") { setErrorMessage("Submission này không còn ở trạng thái chờ duyệt."); return; } const label = action === "approve" ? "duyệt" : "từ chối"; const confirmed = window.confirm(`Xác nhận ${label} section "${selectedRow.section.title}"?`); if (!confirmed) return; setReviewingSubmissionId(selectedRow.submission.id); setErrorMessage(null); setStatusMessage(null); try { const input = { reviewed_by: actor, review_note: reviewNote.trim() || null, }; const updatedSubmission = action === "approve" ? await approveSubmission(selectedRow.submission.id, input) : await rejectSubmission(selectedRow.submission.id, input); setRows((currentRows) => currentRows.map((row) => row.submission.id === updatedSubmission.id ? { ...row, section: { ...row.section, state: { ...row.section.state, status: updatedSubmission.status === "approved" ? "approved" : updatedSubmission.status === "rejected" ? "rejected" : row.section.state.status, }, }, submission: updatedSubmission, } : row ) ); setReviewNote(""); setStatusMessage( action === "approve" ? "Đã duyệt submission và apply vào published data." : "Đã từ chối submission." ); } catch (err) { setErrorMessage(getErrorMessage(err, `Không thể ${label} submission.`)); } finally { setReviewingSubmissionId(null); } }; const isReviewingSelected = reviewingSubmissionId === selectedRow?.submission.id; const canReviewSelected = Boolean( selectedRow && selectedRow.submission.status === "pending" && !isReviewingSelected ); return (

Review sections

Submitted sections

Xem các section đã submit, kiểm tra snapshot và duyệt dữ liệu vào published map.

Mở editor
{statusMessage ?
{statusMessage}
: null} {errorMessage ?
{errorMessage}
: null}

Sections

{visibleRows.length} item
{isLoading ? (
Đang tải submissions...
) : visibleRows.length === 0 ? (
Không có submission nào trong bộ lọc hiện tại.
) : (
{visibleRows.map((row) => { const selected = selectedRow?.submission.id === row.submission.id; return ( ); })}
)}
{!selectedRow ? (
Chọn một section để xem chi tiết.
) : ( <>

{selectedRow.section.title}

Section status: {selectedRow.section.state.status} · Version {selectedRow.section.state.version}

{selectedRow.submission.review_note ? (
Review note

{selectedRow.submission.review_note}

) : null} {selectedSummary ? ( <>

Map preview

{selectedFeatureCollection.features.length > 0 ? (
Selected geometry {selectedPreviewFeature ? (
) : (

Chọn một geometry trên preview để xem metadata.

)}
) : (
Snapshot này không có editor_feature_collection để render bản đồ.
)}

Snapshot

) : (
Submission này không có snapshot để hiển thị.
)}
)}
); } function Metric({ label, value }: { label: string; value: number }) { return (
{value} {label}
); } function Info({ label, value }: { label: string; value: string }) { return (
{label} {value}
); } function StatusBadge({ status }: { status: SectionSubmission["status"] }) { return ( {STATUS_LABELS[status] || status} ); } function OperationBlock({ title, operations, }: { title: string; operations: Record; }) { const entries = Object.entries(operations); return (
{title} {entries.length === 0 ? (

Không có operation.

) : (
{entries.map(([operation, count]) => ( {operation}: {count} ))}
)}
); } function extractSnapshotFeatureCollection(rawSnapshot: unknown): FeatureCollection { if (!rawSnapshot || typeof rawSnapshot !== "object" || Array.isArray(rawSnapshot)) { return EMPTY_FEATURE_COLLECTION; } const snapshot = rawSnapshot as Record; const editorCollection = snapshot.editor_feature_collection; if (isFeatureCollection(editorCollection)) { return { type: "FeatureCollection", features: editorCollection.features.map(cloneFeature), }; } const geometries = Array.isArray(snapshot.geometries) ? snapshot.geometries : []; const features = geometries .map((item): Feature | null => { if (!item || typeof item !== "object" || Array.isArray(item)) return null; const source = item as Record; if (source.operation === "delete") return null; const id = source.id; const geometry = source.draw_geometry || source.geometry; if ((typeof id !== "string" && typeof id !== "number") || !isGeometry(geometry)) { return null; } return { type: "Feature", properties: { id, type: typeof source.type === "string" ? source.type : null, geometry_preset: null, entity_id: null, entity_ids: [], entity_name: null, entity_type_id: null, binding: normalizeStringArray(source.binding), time_start: normalizeOptionalNumber(source.time_start), time_end: normalizeOptionalNumber(source.time_end), }, geometry: cloneGeometry(geometry), }; }) .filter((feature): feature is Feature => feature !== null); return { type: "FeatureCollection", features, }; } function summarizeSnapshot(rawSnapshot: unknown): SnapshotSummary | null { if (!rawSnapshot || typeof rawSnapshot !== "object" || Array.isArray(rawSnapshot)) { return null; } const snapshot = rawSnapshot as Record; const featureCollection = snapshot.editor_feature_collection as Record | undefined; const features = Array.isArray(featureCollection?.features) ? featureCollection.features : []; const entities = Array.isArray(snapshot.entities) ? snapshot.entities : []; const geometries = Array.isArray(snapshot.geometries) ? snapshot.geometries : []; const linkScopes = Array.isArray(snapshot.link_scopes) ? snapshot.link_scopes : []; return { featureCount: features.length, entityCount: entities.length, geometryCount: geometries.length, linkScopeCount: linkScopes.length, entityOperations: countOperations(entities), geometryOperations: countOperations(geometries), linkOperations: countOperations(linkScopes), }; } function isFeatureCollection(value: unknown): value is FeatureCollection { if (!value || typeof value !== "object" || Array.isArray(value)) return false; const source = value as Record; return source.type === "FeatureCollection" && Array.isArray(source.features) && source.features.every(isFeature); } function isFeature(value: unknown): value is Feature { if (!value || typeof value !== "object" || Array.isArray(value)) return false; const source = value as Record; const properties = source.properties; if (source.type !== "Feature" || !isGeometry(source.geometry)) return false; if (!properties || typeof properties !== "object" || Array.isArray(properties)) return false; const id = (properties as Record).id; return typeof id === "string" || typeof id === "number"; } function isGeometry(value: unknown): value is Geometry { if (!value || typeof value !== "object" || Array.isArray(value)) return false; const source = value as Record; return typeof source.type === "string" && Array.isArray(source.coordinates); } function cloneFeature(feature: Feature): Feature { return JSON.parse(JSON.stringify(feature)) as Feature; } function cloneGeometry(geometry: Geometry): Geometry { return JSON.parse(JSON.stringify(geometry)) as Geometry; } function normalizeStringArray(value: unknown): string[] { if (!Array.isArray(value)) return []; return value .map((item) => { if (typeof item !== "string" && typeof item !== "number") return ""; return String(item).trim(); }) .filter((item) => item.length > 0); } function normalizeOptionalNumber(value: unknown): number | null { if (value === null || value === undefined || value === "") return null; const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } function formatFeatureEntities(feature: Feature): string { const named = normalizeStringArray(feature.properties.entity_names); if (named.length > 0) return named.join(", "); if (feature.properties.entity_name) return feature.properties.entity_name; const ids = normalizeStringArray(feature.properties.entity_ids); if (ids.length > 0) return ids.join(", "); return feature.properties.entity_id || "Chưa gắn"; } function formatFeatureTimeRange(feature: Feature): string { const start = feature.properties.time_start; const end = feature.properties.time_end; if (start == null && end == null) return "Không giới hạn"; if (start != null && end != null) return `${start} - ${end}`; if (start != null) return `Từ ${start}`; return `Đến ${end}`; } function formatFeatureBinding(feature: Feature): string { const binding = normalizeStringArray(feature.properties.binding); return binding.length > 0 ? binding.join(", ") : "Không có"; } function countOperations(items: unknown[]): Record { return items.reduce>((acc, item) => { const source = item && typeof item === "object" && !Array.isArray(item) ? item as Record : {}; const operation = typeof source.operation === "string" && source.operation.trim() ? source.operation.trim() : "unknown"; acc[operation] = (acc[operation] || 0) + 1; return acc; }, {}); } function formatDateTime(value: string | null | undefined): string { if (!value) return "Chưa có"; const date = new Date(value); if (Number.isNaN(date.getTime())) return value; return new Intl.DateTimeFormat("vi-VN", { dateStyle: "medium", timeStyle: "short", }).format(date); } function getErrorMessage(err: unknown, fallback: string): string { if (err instanceof ApiError) { const parsed = parseErrorBody(err.body); return parsed || err.body || fallback; } if (err instanceof Error) return err.message; return fallback; } function parseErrorBody(body: string): string | null { try { const parsed = JSON.parse(body) as unknown; if (parsed && typeof parsed === "object" && "error" in parsed) { const error = (parsed as { error?: unknown }).error; return typeof error === "string" ? error : null; } } catch { return null; } return null; } function getStatusStyle(status: SectionSubmission["status"]): CSSProperties { if (status === "approved") { return { background: "#d9f5df", color: "#17652a", borderColor: "#8bd19a", }; } if (status === "rejected") { return { background: "#ffe2dd", color: "#8a1f12", borderColor: "#ef9a8d", }; } if (status === "conflicted") { return { background: "#fff1c7", color: "#755400", borderColor: "#dfbf59", }; } return { background: "#e2f1ff", color: "#0c4f85", borderColor: "#8bbde4", }; } const styles: Record = { page: { minHeight: "100vh", padding: "32px", background: "#f7f8f3", color: "#1f241f", }, header: { display: "flex", justifyContent: "space-between", gap: "20px", alignItems: "flex-start", flexWrap: "wrap", marginBottom: "22px", }, kicker: { margin: "0 0 8px", color: "#4f6f39", fontSize: "13px", fontWeight: 700, textTransform: "uppercase", }, title: { margin: 0, fontSize: "36px", lineHeight: 1.15, letterSpacing: 0, }, subtitle: { maxWidth: "680px", margin: "10px 0 0", color: "#596055", lineHeight: 1.5, }, headerActions: { display: "flex", gap: "10px", flexWrap: "wrap", justifyContent: "flex-end", }, primaryButton: { minHeight: "38px", border: "1px solid #284f2f", borderRadius: "8px", padding: "8px 14px", background: "#2f6d3a", color: "#ffffff", fontWeight: 700, cursor: "pointer", }, secondaryLink: { minHeight: "38px", border: "1px solid #c7cebc", borderRadius: "8px", padding: "8px 14px", background: "#ffffff", color: "#2a3326", fontWeight: 700, textDecoration: "none", }, statsGrid: { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", gap: "12px", marginBottom: "16px", }, metric: { border: "1px solid #d5dbc8", borderRadius: "8px", padding: "14px", background: "#ffffff", }, metricValue: { display: "block", fontSize: "28px", lineHeight: 1.1, fontWeight: 800, color: "#254b2b", }, metricLabel: { display: "block", marginTop: "6px", color: "#5a6356", fontSize: "13px", }, successBanner: { border: "1px solid #9dcc9c", borderRadius: "8px", padding: "12px 14px", marginBottom: "12px", background: "#e6f6e5", color: "#1d5b25", }, errorBanner: { border: "1px solid #ef9a8d", borderRadius: "8px", padding: "12px 14px", marginBottom: "12px", background: "#ffe2dd", color: "#8a1f12", }, toolbar: { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 220px), 1fr))", gap: "12px", marginBottom: "16px", alignItems: "end", }, fieldLabel: { display: "grid", gap: "6px", color: "#424a3f", fontSize: "13px", fontWeight: 700, }, fieldLabelWide: { display: "grid", gap: "6px", color: "#424a3f", fontSize: "13px", fontWeight: 700, }, input: { width: "100%", minHeight: "38px", border: "1px solid #c9d0bd", borderRadius: "8px", padding: "8px 10px", background: "#ffffff", color: "#202820", outline: "none", }, contentGrid: { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 360px), 1fr))", gap: "16px", alignItems: "start", }, listPanel: { border: "1px solid #d5dbc8", borderRadius: "8px", background: "#ffffff", minHeight: "520px", overflow: "hidden", }, detailPanel: { border: "1px solid #d5dbc8", borderRadius: "8px", background: "#ffffff", minHeight: "520px", padding: "18px", }, panelHeader: { display: "flex", justifyContent: "space-between", alignItems: "center", padding: "14px", borderBottom: "1px solid #e3e7da", }, panelTitle: { margin: 0, fontSize: "18px", }, mutedText: { margin: 0, color: "#66705f", fontSize: "13px", lineHeight: 1.45, }, emptyState: { padding: "18px", color: "#66705f", lineHeight: 1.5, }, submissionList: { display: "grid", gap: "8px", padding: "10px", maxHeight: "calc(100vh - 310px)", overflow: "auto", }, submissionItem: { width: "100%", display: "grid", gap: "6px", textAlign: "left", border: "1px solid #e1e5d9", borderRadius: "8px", padding: "12px", background: "#ffffff", color: "#202820", cursor: "pointer", }, submissionItemSelected: { borderColor: "#71a66a", background: "#f0f8ee", }, itemTopLine: { display: "flex", gap: "8px", justifyContent: "space-between", alignItems: "center", }, itemTitle: { minWidth: 0, overflowWrap: "anywhere", }, itemMeta: { color: "#5f695a", fontSize: "13px", overflowWrap: "anywhere", }, statusBadge: { flex: "0 0 auto", display: "inline-flex", alignItems: "center", minHeight: "24px", border: "1px solid", borderRadius: "8px", padding: "2px 8px", fontSize: "12px", fontWeight: 800, whiteSpace: "nowrap", }, detailHeader: { display: "flex", justifyContent: "space-between", gap: "16px", alignItems: "flex-start", marginBottom: "16px", }, detailTitle: { margin: "0 0 6px", fontSize: "24px", lineHeight: 1.25, overflowWrap: "anywhere", }, metaGrid: { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: "10px", marginBottom: "16px", }, infoCell: { border: "1px solid #e1e5d9", borderRadius: "8px", padding: "10px", background: "#fbfcf8", minWidth: 0, }, infoLabel: { display: "block", color: "#66705f", fontSize: "12px", fontWeight: 700, marginBottom: "5px", }, infoValue: { display: "block", color: "#202820", fontSize: "13px", overflowWrap: "anywhere", }, noteBox: { border: "1px solid #e1e5d9", borderRadius: "8px", padding: "12px", marginBottom: "16px", background: "#fbfcf8", }, noteText: { margin: "6px 0 0", color: "#40483c", lineHeight: 1.5, overflowWrap: "anywhere", }, sectionHeading: { margin: "18px 0 10px", fontSize: "18px", }, summaryGrid: { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(150px, 1fr))", gap: "10px", marginBottom: "12px", }, operationGrid: { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: "10px", }, operationBlock: { border: "1px solid #e1e5d9", borderRadius: "8px", padding: "12px", background: "#fbfcf8", }, operationRows: { display: "flex", flexWrap: "wrap", gap: "8px", marginTop: "10px", }, operationPill: { border: "1px solid #cfd8c2", borderRadius: "8px", padding: "4px 8px", background: "#ffffff", color: "#394136", fontSize: "13px", }, previewGrid: { display: "grid", gridTemplateColumns: "minmax(min(100%, 420px), 1fr) minmax(min(100%, 260px), 320px)", gap: "12px", marginBottom: "16px", alignItems: "stretch", }, mapPreview: { height: "420px", minHeight: "320px", border: "1px solid #d5dbc8", borderRadius: "8px", overflow: "hidden", background: "#0b1220", }, featurePanel: { border: "1px solid #e1e5d9", borderRadius: "8px", padding: "12px", background: "#fbfcf8", minWidth: 0, }, featureDetails: { display: "grid", gap: "8px", marginTop: "10px", }, reviewActions: { display: "flex", justifyContent: "flex-end", gap: "10px", marginTop: "22px", paddingTop: "16px", borderTop: "1px solid #e3e7da", }, dangerButton: { minHeight: "40px", border: "1px solid #a93322", borderRadius: "8px", padding: "8px 16px", background: "#b94330", color: "#ffffff", fontWeight: 800, cursor: "pointer", }, approveButton: { minHeight: "40px", border: "1px solid #276131", borderRadius: "8px", padding: "8px 16px", background: "#2f7a3b", color: "#ffffff", fontWeight: 800, cursor: "pointer", }, };