"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { useShallow } from "zustand/react/shallow"; import { toast } from "sonner"; import Map from "@/uhm/components/Map"; import BackgroundLayersPanel from "@/uhm/components/editor/BackgroundLayersPanel"; import TimelineBar from "@/uhm/components/ui/TimelineBar"; import ProjectEntityRefsPanel from "@/uhm/components/editor/ProjectEntityRefsPanel"; import EntityWikiBindingsPanel from "@/uhm/components/editor/EntityWikiBindingsPanel"; import GeometryBindingPanel from "@/uhm/components/editor/GeometryBindingPanel"; import SelectedGeometryPanel from "@/uhm/components/editor/SelectedGeometryPanel"; import WikiSidebarPanel from "@/uhm/components/wiki/WikiSidebarPanel"; import { fetchProjectCommits } from "@/uhm/api/projects"; import { requestJson } from "@/uhm/api/http"; import { API_ENDPOINTS } from "@/uhm/api/config"; import { normalizeEditorSnapshot, getDefaultTypeIdForFeature, normalizeFeatureEntityIds } from "@/uhm/lib/editor/snapshot/editorSnapshot"; import { normalizeTimelineYearValue } from "@/uhm/lib/utils/timeline"; import { isFeatureVisibleAtYear } from "@/uhm/lib/editor/editorPageUtils"; import { buildEntityLabelContextDraft as buildPreviewEntityLabelContextDraft } from "@/uhm/lib/preview/relationIndex"; import { getDirectGeometryChildIds } from "@/uhm/lib/editor/geometry/geometryBinding"; import { ResizeHandle } from "@/uhm/components/ui/ResizeHandle"; import { EMPTY_FEATURE_COLLECTION, WORLD_BBOX } from "@/uhm/lib/map/geo/constants"; import { FIXED_TIMELINE_RANGE, clampYearToFixedRange } from "@/uhm/lib/utils/timeline"; import { loadBackgroundLayerVisibilityFromStorage } from "@/uhm/lib/editor/background/backgroundVisibilityStorage"; import { apiGetSubmissionDetail, updateSubmission } from "@/service/submisisonService"; import { SubmissionItem } from "@/components/tables/SubmissionsTable"; import { EditorStoreProvider, useEditorStore, useEditorStoreApi, } from "@/uhm/store/editorStore"; import type { Feature, FeatureCollection, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo"; import type { EditorSnapshot, ProjectCommit, EntityWikiLinkSnapshot, Project } from "@/uhm/types/projects"; import type { EntitySnapshot } from "@/uhm/types/entities"; import type { WikiSnapshot } from "@/uhm/types/wiki"; const CURRENT_YEAR = new Date().getUTCFullYear(); const DEFAULT_EDITOR_USER_ID = "admin-viewer"; // Helper functions copied from useProjectCommands.ts to build read-only session snapshot. function toEditorSessionSnapshot(snapshot: EditorSnapshot): EditorSnapshot { return { ...snapshot, entities: toEditorSessionEntities(snapshot.entities), geometries: toEditorSessionGeometries(snapshot.geometries), geometry_entity: toEditorSessionGeometryEntity(snapshot.geometry_entity), wikis: toEditorSessionWikis(snapshot.wikis), entity_wiki: toEditorSessionEntityWikiLinks(snapshot.entity_wiki), }; } function toEditorSessionEntities(input: EditorSnapshot["entities"]): EntitySnapshot[] { const rows = Array.isArray(input) ? input : []; return rows .filter((e): e is any => Boolean(e) && (typeof e.id === "string" || typeof e.id === "number")) .filter((e) => e.operation !== "delete") .map((e) => { const id = String(e.id); const source: EntitySnapshot["source"] = e.source === "inline" ? "inline" : "ref"; return { id, source, operation: "reference", name: typeof e.name === "string" ? e.name : undefined, description: typeof e.description === "string" ? e.description : e.description ?? null, time_start: normalizeTimelineYearValue(e.time_start) ?? undefined, time_end: normalizeTimelineYearValue(e.time_end) ?? undefined, }; }); } function toEditorSessionGeometries(input: EditorSnapshot["geometries"]): GeometrySnapshot[] { const rows = Array.isArray(input) ? input : []; return rows .filter((g): g is any => Boolean(g) && (typeof g.id === "string" || typeof g.id === "number")) .filter((g) => g.operation !== "delete") .map((g) => { const id = String(g.id); const source: GeometrySnapshot["source"] = g.source === "inline" ? "inline" : "ref"; return { id, source, operation: "reference", type: g.type ?? undefined, draw_geometry: g.draw_geometry, geometry: g.geometry, bound_with: g.bound_with ?? null, time_start: normalizeTimelineYearValue(g.time_start) ?? undefined, time_end: normalizeTimelineYearValue(g.time_end) ?? undefined, bbox: g.bbox ? { min_lng: g.bbox.min_lng, min_lat: g.bbox.min_lat, max_lng: g.bbox.max_lng, max_lat: g.bbox.max_lat, } : g.bbox ?? undefined, }; }); } function toEditorSessionGeometryEntity(input: EditorSnapshot["geometry_entity"]): GeometryEntitySnapshot[] { const rows = Array.isArray(input) ? input : []; const deduped = new globalThis.Map(); for (const row of rows) { if (!row) continue; const safeRow = row as any; if (safeRow.operation === "delete") continue; const geometry_id = typeof safeRow.geometry_id === "string" || typeof safeRow.geometry_id === "number" ? String(safeRow.geometry_id).trim() : ""; const entity_id = typeof safeRow.entity_id === "string" || typeof safeRow.entity_id === "number" ? String(safeRow.entity_id).trim() : ""; if (!geometry_id || !entity_id) continue; const key = `${geometry_id}::${entity_id}`; deduped.set(key, { geometry_id, entity_id, operation: "reference", }); } return Array.from(deduped.values()).sort((a, b) => { const g = a.geometry_id.localeCompare(b.geometry_id); if (g !== 0) return g; return a.entity_id.localeCompare(b.entity_id); }); } function toEditorSessionWikis(input: EditorSnapshot["wikis"]): WikiSnapshot[] { const rows = Array.isArray(input) ? input : []; return rows .filter((w): w is any => Boolean(w) && typeof w.id === "string" && w.id.trim().length > 0) .filter((w) => w.operation !== "delete") .map((w) => { const source: WikiSnapshot["source"] = w.source === "inline" ? "inline" : "ref"; return { id: w.id, source, operation: "reference", title: typeof w.title === "string" ? w.title : "", slug: w.slug ?? null, doc: w.doc ?? null, }; }); } function toEditorSessionEntityWikiLinks(input: EditorSnapshot["entity_wiki"]): EntityWikiLinkSnapshot[] { const rows = Array.isArray(input) ? input : []; const deduped = new globalThis.Map(); for (const row of rows) { if (!row) continue; const safeRow = row as any; if (safeRow.operation === "delete") continue; const entity_id = typeof safeRow.entity_id === "string" || typeof safeRow.entity_id === "number" ? String(safeRow.entity_id).trim() : ""; const wiki_id = typeof safeRow.wiki_id === "string" || typeof safeRow.wiki_id === "number" ? String(safeRow.wiki_id).trim() : ""; if (!entity_id || !wiki_id) continue; const key = `${entity_id}::${wiki_id}`; deduped.set(key, { entity_id, wiki_id, operation: "reference", }); } return Array.from(deduped.values()).sort((a, b) => { const e = a.entity_id.localeCompare(b.entity_id); if (e !== 0) return e; return a.wiki_id.localeCompare(b.wiki_id); }); } export default function SubmissionDetailPage() { return ( ); } // Side Component to set the Zustand store state based on loaded data. function StoreInitializer({ project, sessionSnapshot, }: { project: Project; sessionSnapshot: EditorSnapshot; }) { const store = useEditorStoreApi(); useEffect(() => { if (!project || !sessionSnapshot) return; const state = store.getState(); state.setActiveSection(project); state.setSelectedProjectId(project.id); state.setBaselineSnapshot(sessionSnapshot); state.setBaselineFeatureCollection(sessionSnapshot?.editor_feature_collection || EMPTY_FEATURE_COLLECTION); state.setSnapshotEntityRows(sessionSnapshot?.entities || []); state.setSnapshotWikis(sessionSnapshot?.wikis || []); state.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []); state.setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage()); state.setIsBackgroundVisibilityReady(true); // Auto-detect the earliest year from geometries or entities to center the timeline. let minYear: number | null = null; if (sessionSnapshot?.geometries && sessionSnapshot.geometries.length > 0) { for (const g of sessionSnapshot.geometries) { if (g.time_start !== undefined && g.time_start !== null) { const y = Number(g.time_start); if (!Number.isNaN(y)) { if (minYear === null || y < minYear) { minYear = y; } } } } } if (minYear === null && sessionSnapshot?.entities && sessionSnapshot.entities.length > 0) { for (const e of sessionSnapshot.entities) { if (e.time_start !== undefined && e.time_start !== null) { const y = Number(e.time_start); if (!Number.isNaN(y)) { if (minYear === null || y < minYear) { minYear = y; } } } } } if (minYear !== null) { state.setTimelineDraftYear(clampYearToFixedRange(minYear)); } }, [project, sessionSnapshot, store]); return null; } function clampNumber(value: number, min: number, max: number): number { if (value < min) return min; if (value > max) return max; return value; } function SubmissionDetailPageContent() { const params = useParams(); const router = useRouter(); const id = String(params.id || ""); const [submission, setSubmission] = useState(null); const [project, setProject] = useState(null); const [sessionSnapshot, setSessionSnapshot] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [reviewNote, setReviewNote] = useState(""); const [submitting, setSubmitting] = useState(false); // Retrieve Zustand state const { baselineFeatureCollection, selectedFeatureIds, setSelectedFeatureIds, timelineDraftYear, setTimelineDraftYear, backgroundVisibility, geometryVisibility, snapshotEntityRows, snapshotWikis, snapshotEntityWikiLinks, leftPanelWidth, setLeftPanelWidth, rightPanelWidth, setRightPanelWidth, timelineFilterEnabled, setTimelineFilterEnabled, } = useEditorStore(useShallow((state) => ({ baselineFeatureCollection: state.baselineFeatureCollection, selectedFeatureIds: state.selectedFeatureIds, setSelectedFeatureIds: state.setSelectedFeatureIds, timelineDraftYear: state.timelineDraftYear, setTimelineDraftYear: state.setTimelineDraftYear, backgroundVisibility: state.backgroundVisibility, geometryVisibility: state.geometryVisibility, snapshotEntityRows: state.snapshotEntityRows, snapshotWikis: state.snapshotWikis, snapshotEntityWikiLinks: state.snapshotEntityWikiLinks, leftPanelWidth: state.leftPanelWidth, setLeftPanelWidth: state.setLeftPanelWidth, rightPanelWidth: state.rightPanelWidth, setRightPanelWidth: state.setRightPanelWidth, timelineFilterEnabled: state.timelineFilterEnabled, setTimelineFilterEnabled: state.setTimelineFilterEnabled, }))); // Fetch submission details and project commit snapshot useEffect(() => { async function loadData() { try { setLoading(true); setError(null); const subRes = await apiGetSubmissionDetail(id); if (!subRes || !subRes.status || !subRes.data) { throw new Error("Không thể tải thông tin submission"); } const subData = subRes.data as SubmissionItem; setSubmission(subData); setReviewNote(subData.review_note || ""); const projectId = subData.project_id; const commits = await fetchProjectCommits(projectId); const projRes = await requestJson(`${API_ENDPOINTS.projects}/${encodeURIComponent(projectId)}`); setProject(projRes); const targetCommit = commits.find((c) => String(c.id) === String(subData.commit_id)); if (!targetCommit) { throw new Error(`Không tìm thấy commit #${subData.commit_id} của project`); } const snapshot = normalizeEditorSnapshot(targetCommit.snapshot_json); const session = snapshot ? toEditorSessionSnapshot(snapshot) : null; setSessionSnapshot(session); } catch (err: any) { console.error("Error loading submission details:", err); setError(err.message || "Lỗi hệ thống"); } finally { setLoading(false); } } if (id) { loadData(); } }, [id]); // Apply filters based on timeline year const activeTimelineYear = timelineDraftYear; const activeTimelineFilterEnabled = timelineFilterEnabled; const activeMapDraft = useMemo(() => { const draft = baselineFeatureCollection || EMPTY_FEATURE_COLLECTION; if (!activeTimelineFilterEnabled) return draft; return { type: "FeatureCollection", features: draft.features.filter((f) => isFeatureVisibleAtYear(f, activeTimelineYear)), }; }, [baselineFeatureCollection, activeTimelineFilterEnabled, activeTimelineYear]); const mapLabelContextDraft = useMemo(() => { return baselineFeatureCollection || EMPTY_FEATURE_COLLECTION; }, [baselineFeatureCollection]); // Handle review (approve / reject) const handleReview = async (status: "APPROVED" | "REJECTED") => { if (!submission) return; setSubmitting(true); try { const res = await updateSubmission(submission.id, { status, review_note: reviewNote, }); if (res?.status) { toast.success("Cập nhật trạng thái submission thành công!"); setSubmission((prev) => prev ? { ...prev, status, review_note: reviewNote } : null); } else { toast.error(res?.message || "Cập nhật thất bại"); } } catch (err: any) { console.error(err); toast.error("Lỗi khi cập nhật trạng thái submission"); } finally { setSubmitting(false); } }; // Right Sidebar Geometry Choices const geometryChoices = useMemo(() => { const draft = baselineFeatureCollection || EMPTY_FEATURE_COLLECTION; const mapRenderGeometryIds = new Set( activeMapDraft.features.map((feature) => String(feature.properties.id)) ); const rows = draft.features .filter((f) => f && f.properties && (typeof f.properties.id === "string" || typeof f.properties.id === "number")) .map((f) => { const id = String(f.properties.id); const semantic = String(f.properties.type || getDefaultTypeIdForFeature(f) || "").trim(); const label = semantic.length ? `${semantic} (${f.geometry.type})` : "Geometry"; const timeStart = normalizeTimelineYearValue(f.properties.time_start); const timeEnd = normalizeTimelineYearValue(f.properties.time_end); const hasStart = timeStart !== null; const hasEnd = timeEnd !== null; const timeStatus: "missing" | "partial" | "complete" = !hasStart && !hasEnd ? "missing" : !hasStart || !hasEnd ? "partial" : "complete"; const isTimelineVisible = mapRenderGeometryIds.has(id); const timelineStatus: "off" | "visible" | "filteredOut" = !activeTimelineFilterEnabled ? "off" : isTimelineVisible ? "visible" : "filteredOut"; return { id, label, time_start: timeStart, time_end: timeEnd, isTimelineVisible, isOrphan: normalizeFeatureEntityIds(f).length === 0, timeStatus, timelineStatus, isNew: false, }; }); rows.sort((a, b) => { const na = String(a.label || a.id); const nb = String(b.label || b.id); return na.localeCompare(nb); }); return rows; }, [baselineFeatureCollection, activeMapDraft, activeTimelineFilterEnabled]); // Selected features state resolution const selectedFeatures = useMemo(() => { const draft = baselineFeatureCollection || EMPTY_FEATURE_COLLECTION; return selectedFeatureIds .map((fid) => draft.features.find((f) => f.properties.id === fid) || null) .filter((f): f is Feature => Boolean(f)); }, [baselineFeatureCollection, selectedFeatureIds]); const selectedFeature = selectedFeatures[0] || null; const selectedGeometryChildIds = useMemo(() => { if (!selectedFeature) return []; return getDirectGeometryChildIds( baselineFeatureCollection || EMPTY_FEATURE_COLLECTION, String(selectedFeature.properties.id) ); }, [baselineFeatureCollection, selectedFeature]); const selectedGeometryTime = useMemo(() => { if (!selectedFeature) return null; const start = normalizeTimelineYearValue(selectedFeature.properties.time_start); const end = normalizeTimelineYearValue(selectedFeature.properties.time_end); return { time_start: start, time_end: end }; }, [selectedFeature]); if (loading) { return (
Đang tải dữ liệu bản đồ...
); } if (error || !submission || !project || !sessionSnapshot) { return (
{error || "Không thể tìm thấy submission"}
); } return (
{/* Initialize store with project & snapshot data */} {/* Left Sidebar showing submission details and review form */}
{/* Header */}
Chi tiết yêu cầu duyệt
#{submission.id.slice(0, 8)}
{/* Info scroll area */}
{/* Project Title */}
Dự án
{submission.project_title}
{submission.project_description || "Không có mô tả dự án."}
{/* Submitter */}
Người gửi
{submission.user?.avatar_url ? ( avatar ) : (
{submission.user?.display_name?.charAt(0).toUpperCase() || "U"}
)}
{submission.user?.display_name || "N/A"}
{submission.user?.email || ""}
{/* Meta grid */}
Ngày tạo
{new Date(submission.created_at).toLocaleString("vi-VN")}
Trạng thái
{submission.status}
{submission.content && (
Nội dung ghi chú
{submission.content}
)} {/* Review Forms */}
Đánh giá của Admin
{submission.status === "PENDING" ? (