diff --git a/api/config.ts b/api/config.ts index f905f8a..f9a0bdc 100644 --- a/api/config.ts +++ b/api/config.ts @@ -5,10 +5,9 @@ export const API_BASE_URL = export const API_ENDPOINTS = { geometries: `${API_BASE_URL}/geometries`, - geometriesBatch: `${API_BASE_URL}/geometries/batch`, - geometriesBatchCombined: `${API_BASE_URL}/geometries/batch/combined`, entities: `${API_BASE_URL}/entities`, - entitiesBatch: `${API_BASE_URL}/entities/batch`, + sections: `${API_BASE_URL}/sections`, + submissions: `${API_BASE_URL}/submissions`, vectorTiles: `${API_BASE_URL}/tiles/{z}/{x}/{y}`, rasterTiles: `${API_BASE_URL}/raster-tiles/{z}/{x}/{y}`, vectorTilesMetadata: `${API_BASE_URL}/tiles/metadata/info`, diff --git a/api/entities.ts b/api/entities.ts index fca6fdc..3eb6c1b 100644 --- a/api/entities.ts +++ b/api/entities.ts @@ -13,51 +13,6 @@ export type Entity = { updated_at?: string; }; -export type CreateEntityPayload = { - name: string; - slug?: string | null; - description?: string | null; - type_id?: string | null; - status?: number | null; -}; - -export type EntityBatchCreateChange = - | ({ - action: "create"; - entity: CreateEntityPayload & { id?: string }; - }) - | ({ - action: "create"; - id?: string; - } & CreateEntityPayload); - -export type EntityBatchUpdateChange = - | ({ - action: "update"; - id: string; - entity: Partial & { id?: string }; - }) - | ({ - action: "update"; - id: string; - } & Partial); - -export type EntityBatchDeleteChange = { - action: "delete"; - id: string; -}; - -export type EntityBatchChange = - | EntityBatchCreateChange - | EntityBatchUpdateChange - | EntityBatchDeleteChange; - -export type EntityBatchSaveResponse = { - success: boolean; - applied: number; - created_entity_ids: string[]; -}; - export async function fetchEntities(query?: { q?: string }): Promise { const params = new URLSearchParams(); if (query?.q) { @@ -82,27 +37,3 @@ export async function searchEntitiesByName( return requestJson(`${API_ENDPOINTS.entities}/search?${params.toString()}`); } - -export async function createEntity(payload: CreateEntityPayload): Promise { - return requestJson(API_ENDPOINTS.entities, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); -} - -export async function updateEntity(id: string, payload: CreateEntityPayload): Promise { - return requestJson(`${API_ENDPOINTS.entities}/${id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); -} - -export async function saveEntityBatchChanges(changes: EntityBatchChange[]): Promise { - return requestJson(API_ENDPOINTS.entitiesBatch, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ changes }), - }); -} diff --git a/api/geometries.ts b/api/geometries.ts index d560caa..d668925 100644 --- a/api/geometries.ts +++ b/api/geometries.ts @@ -1,7 +1,6 @@ import { API_ENDPOINTS } from "@/api/config"; -import { EntityBatchChange } from "@/api/entities"; import { requestJson } from "@/api/http"; -import { Change, FeatureCollection, Geometry } from "@/lib/useEditorState"; +import { FeatureCollection } from "@/lib/useEditorState"; export type GeometriesBBoxQuery = { minLng: number; @@ -12,43 +11,6 @@ export type GeometriesBBoxQuery = { entity_id?: string; }; -export type GeometryCreatePayload = { - geometry: Geometry; - type?: string | null; - time_start?: number | null; - time_end?: number | null; - binding?: string[]; - entity_id?: string | null; - entity_ids?: string[]; -}; - -export type GeometryUpdatePayload = { - geometry: Geometry; - type?: string | null; - time_start?: number | null; - time_end?: number | null; - binding?: string[]; - entity_id?: string | null; - entity_ids?: string[]; -}; - -export type GeometryCreateResponse = { - id: string; -}; - -export type BatchSaveResponse = { - success: boolean; - applied: number; -}; - -export type CombinedBatchSaveResponse = { - success: boolean; - applied: number; - entity_applied: number; - geometry_applied: number; - created_entity_ids: string[]; -}; - function buildBBoxQueryString(params: GeometriesBBoxQuery): string { const query = new URLSearchParams({ minLng: String(params.minLng), @@ -72,47 +34,3 @@ export async function fetchGeometriesByBBox(params: GeometriesBBoxQuery): Promis const url = `${API_ENDPOINTS.geometries}?${buildBBoxQueryString(params)}`; return requestJson(url); } - -export async function saveGeometryBatchChanges(changes: Change[]): Promise { - return requestJson(API_ENDPOINTS.geometriesBatch, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ changes }), - }); -} - -export async function saveCombinedGeometryEntityBatchChanges( - entityChanges: EntityBatchChange[], - geometryChanges: Change[] -): Promise { - return requestJson(API_ENDPOINTS.geometriesBatchCombined, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - entity_changes: entityChanges, - geometry_changes: geometryChanges, - }), - }); -} - -export async function createGeometry(payload: GeometryCreatePayload): Promise { - return requestJson(API_ENDPOINTS.geometries, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); -} - -export async function updateGeometry(id: string | number, payload: GeometryUpdatePayload): Promise<{ success: boolean }> { - return requestJson<{ success: boolean }>(`${API_ENDPOINTS.geometries}/${id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); -} - -export async function deleteGeometry(id: string | number): Promise<{ success: boolean }> { - return requestJson<{ success: boolean }>(`${API_ENDPOINTS.geometries}/${id}`, { - method: "DELETE", - }); -} diff --git a/api/http.ts b/api/http.ts index ecb6650..b409b12 100644 --- a/api/http.ts +++ b/api/http.ts @@ -20,3 +20,11 @@ export async function requestJson(input: RequestInfo | URL, init?: RequestIni return (await res.json()) as T; } + +export function jsonRequestInit(method: string, body: unknown): RequestInit { + return { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }; +} diff --git a/api/sections.ts b/api/sections.ts new file mode 100644 index 0000000..bbb8820 --- /dev/null +++ b/api/sections.ts @@ -0,0 +1,195 @@ +import { API_ENDPOINTS } from "@/api/config"; +import { jsonRequestInit, requestJson } from "@/api/http"; + +export type SectionState = { + section_id?: string; + status: "editing" | "submitted" | "approved" | "rejected"; + head_commit_id: string | null; + version: number; + locked_by: string | null; + locked_at: string | null; + lock_expires_at: string | null; + updated_at?: string; +}; + +export type Section = { + id: string; + title: string; + description: string | null; + user_id: string | null; + created_by: string | null; + created_at: string; + updated_at: string; + state: Omit; +}; + +export type SectionCommit = { + id: string; + section_id: string; + parent_commit_id: string | null; + commit_no: number; + kind: "manual" | "restore"; + restored_from_commit_id: string | null; + created_by: string; + created_at: string; + title: string | null; + note: string | null; + snapshot_hash: string | null; + snapshot?: unknown; +}; + +export type SectionSubmission = { + id: string; + section_id: string; + commit_id: string; + submitted_by: string; + submitted_at: string; + status: "pending" | "approved" | "rejected" | "conflicted"; + reviewed_by: string | null; + reviewed_at: string | null; + review_note: string | null; + snapshot_hash: string | null; + snapshot?: unknown; +}; + +export type EditorLoadResponse = { + section: Section; + state: SectionState; + commit: SectionCommit | null; + snapshot: unknown; +}; + +export type CreateSectionInput = { + id?: string; + title: string; + description?: string | null; + user_id?: string; + created_by?: string; +}; + +export type CreateCommitInput = { + snapshot: unknown; + created_by?: string; + user_id?: string; + expected_version?: number; + expected_head_commit_id?: string | null; + title?: string | null; + note?: string | null; +}; + +export async function fetchSections(): Promise { + return requestJson(API_ENDPOINTS.sections); +} + +export async function createSection(input: CreateSectionInput): Promise
{ + return requestJson
(API_ENDPOINTS.sections, jsonRequestInit("POST", input)); +} + +export async function openSectionEditor(sectionId: string, userId?: string): Promise { + const params = new URLSearchParams(); + if (userId) params.set("user_id", userId); + return requestJson(sectionUrl(sectionId, "editor", params)); +} + +export async function lockSection(sectionId: string, userId: string): Promise<{ state: SectionState }> { + return requestJson<{ state: SectionState }>( + sectionUrl(sectionId, "lock"), + jsonRequestInit("POST", { user_id: userId }) + ); +} + +export async function unlockSection(sectionId: string, userId: string): Promise<{ success: boolean }> { + return requestJson<{ success: boolean }>( + sectionUrl(sectionId, "unlock"), + jsonRequestInit("POST", { user_id: userId }) + ); +} + +export async function createSectionCommit( + sectionId: string, + input: CreateCommitInput +): Promise<{ commit: SectionCommit; state: SectionState }> { + return requestJson<{ commit: SectionCommit; state: SectionState }>( + sectionUrl(sectionId, "commits"), + jsonRequestInit("POST", input) + ); +} + +export async function fetchSectionCommits( + sectionId: string, + options?: { includeSnapshot?: boolean } +): Promise { + const params = new URLSearchParams(); + if (options?.includeSnapshot) params.set("include_snapshot", "1"); + return requestJson(sectionUrl(sectionId, "commits", params)); +} + +export async function restoreSectionCommit( + sectionId: string, + input: { + commit_id: string; + created_by?: string; + user_id?: string; + expected_version?: number; + expected_head_commit_id?: string | null; + title?: string | null; + note?: string | null; + } +): Promise<{ commit: SectionCommit; state: SectionState }> { + return requestJson<{ commit: SectionCommit; state: SectionState }>( + sectionUrl(sectionId, "restore"), + jsonRequestInit("POST", input) + ); +} + +export async function submitSection( + sectionId: string, + input: { commit_id?: string; submitted_by?: string; user_id?: string } +): Promise { + return requestJson(sectionUrl(sectionId, "submit"), jsonRequestInit("POST", input)); +} + +export async function fetchSectionSubmissions( + sectionId: string, + options?: { includeSnapshot?: boolean } +): Promise { + const params = new URLSearchParams(); + if (options?.includeSnapshot) params.set("include_snapshot", "1"); + return requestJson(sectionUrl(sectionId, "submissions", params)); +} + +export async function approveSubmission( + submissionId: string, + input: { reviewed_by?: string; user_id?: string; review_note?: string | null } +): Promise { + return requestJson( + submissionUrl(submissionId, "approve"), + jsonRequestInit("POST", input) + ); +} + +export async function rejectSubmission( + submissionId: string, + input: { reviewed_by?: string; user_id?: string; review_note?: string | null } +): Promise { + return requestJson( + submissionUrl(submissionId, "reject"), + jsonRequestInit("POST", input) + ); +} + +function sectionUrl(sectionId: string, path?: string, params?: URLSearchParams): string { + return appendQuery( + `${API_ENDPOINTS.sections}/${encodeURIComponent(sectionId)}${path ? `/${path}` : ""}`, + params + ); +} + +function submissionUrl(submissionId: string, path: "approve" | "reject"): string { + return `${API_ENDPOINTS.submissions}/${encodeURIComponent(submissionId)}/${path}`; +} + +function appendQuery(url: string, params?: URLSearchParams): string { + const suffix = params?.toString(); + return suffix ? `${url}?${suffix}` : url; +} diff --git a/app/editor/page.tsx b/app/editor/page.tsx index 5343675..f714084 100644 --- a/app/editor/page.tsx +++ b/app/editor/page.tsx @@ -1,15 +1,28 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Map from "@/components/Map"; import Editor from "@/components/Editor"; import BackgroundLayersPanel from "@/components/BackgroundLayersPanel"; import TimelineBar from "@/components/TimelineBar"; import SelectedGeometryPanel from "@/components/SelectedGeometryPanel"; -import { Entity, EntityBatchChange, fetchEntities, searchEntitiesByName } from "@/api/entities"; +import { Entity, fetchEntities, searchEntitiesByName } from "@/api/entities"; import { ApiError } from "@/api/http"; -import { fetchGeometriesByBBox, saveCombinedGeometryEntityBatchChanges, updateGeometry } from "@/api/geometries"; +import { fetchGeometriesByBBox } from "@/api/geometries"; import { + createSection, + createSectionCommit, + fetchSections, + fetchSectionCommits, + openSectionEditor, + restoreSectionCommit, + Section, + SectionCommit, + SectionState, + submitSection, +} from "@/api/sections"; +import { + Change, Feature, FeatureCollection, useEditorState, @@ -42,6 +55,8 @@ const FALLBACK_TIMELINE_RANGE: TimelineRange = { }; const TIMELINE_DEBOUNCE_MS = 180; const BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY = "uhm.backgroundLayerVisibility.v1"; +const DEFAULT_SECTION_TITLE = "Main editor section"; +const DEFAULT_EDITOR_USER_ID = "local-editor"; type TimelineRange = { min: number; @@ -60,11 +75,6 @@ type GeometryMetaFormState = { binding: string; }; -type BindingGeometrySearchOption = { - id: string; - label: string; -}; - type PendingEntityCreate = { id: string; name: string; @@ -73,10 +83,34 @@ type PendingEntityCreate = { status: number; }; +type EditorSnapshot = { + schema_version: number; + section: { + id: string; + title: string; + }; + editor_feature_collection?: FeatureCollection; + entities?: Array>; + geometries?: Array>; + link_scopes?: Array>; +}; + export default function Page() { const [mode, setMode] = useState<"idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle">("idle"); const [initialData, setInitialData] = useState(EMPTY_FC); const [isSaving, setIsSaving] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isOpeningSection, setIsOpeningSection] = useState(false); + const [availableSections, setAvailableSections] = useState([]); + const [selectedSectionId, setSelectedSectionId] = useState(""); + const [newSectionTitle, setNewSectionTitle] = useState(""); + const [commitTitle, setCommitTitle] = useState(""); + const [commitNote, setCommitNote] = useState(""); + const [editorUserIdInput, setEditorUserIdInput] = useState(DEFAULT_EDITOR_USER_ID); + const [activeSection, setActiveSection] = useState
(null); + const [sectionState, setSectionState] = useState(null); + const [sectionCommits, setSectionCommits] = useState([]); + const [lastSectionSnapshot, setLastSectionSnapshot] = useState(null); const [persistedEntities, setPersistedEntities] = useState([]); const [pendingEntityCreates, setPendingEntityCreates] = useState([]); const [createdEntities, setCreatedEntities] = useState([]); const [selectedSearchEntityId, setSelectedSearchEntityId] = useState(null); const [isEntitySearchLoading, setIsEntitySearchLoading] = useState(false); - const [bindingGeometrySearchQuery, setBindingGeometrySearchQuery] = useState(""); - const [bindingGeometrySearchResults, setBindingGeometrySearchResults] = useState([]); - const [selectedBindingGeometryId, setSelectedBindingGeometryId] = useState(null); const [timelineRange, setTimelineRange] = useState(FALLBACK_TIMELINE_RANGE); const [timelineWindowStart, setTimelineWindowStart] = useState(FALLBACK_TIMELINE_RANGE.min); const [timelineWindowEnd, setTimelineWindowEnd] = useState(FALLBACK_TIMELINE_RANGE.max); @@ -124,8 +155,11 @@ export default function Page() { const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false); const timelineFetchRequestRef = useRef(0); const entitySearchRequestRef = useRef(0); + const timelineYearRef = useRef(timelineYear); + const editorUserIdRef = useRef(DEFAULT_EDITOR_USER_ID); const editor = useEditorState(initialData); + const editorUserId = normalizeEditorUserId(editorUserIdInput); const entities = useMemo( () => mergeEntitiesWithPending(persistedEntities, pendingEntityCreates), [persistedEntities, pendingEntityCreates] @@ -162,6 +196,102 @@ export default function Page() { return rows; }, [editor.changes, entities]); + useEffect(() => { + timelineYearRef.current = timelineYear; + }, [timelineYear]); + + useEffect(() => { + editorUserIdRef.current = editorUserId; + }, [editorUserId]); + + const openSectionForEditing = useCallback(async (sectionId: string) => { + const editorPayload = await openSectionEditor(sectionId, editorUserIdRef.current); + const snapshot = normalizeEditorSnapshot(editorPayload.snapshot); + const commits = await fetchSectionCommits(sectionId); + + let nextInitialData = snapshot?.editor_feature_collection || null; + if (!nextInitialData) { + nextInitialData = await fetchGeometriesByBBox({ + ...WORLD_BBOX, + time: timelineYearRef.current, + }); + } + + setActiveSection(editorPayload.section); + setSelectedSectionId(editorPayload.section.id); + setSectionState(editorPayload.state); + setLastSectionSnapshot(snapshot); + setInitialData(nextInitialData); + setSectionCommits(commits); + setPendingEntityCreates([]); + setCreatedEntities([]); + setSelectedFeatureId(null); + setEntityFormStatus(null); + }, []); + + useEffect(() => { + let disposed = false; + + async function loadSection() { + try { + setIsOpeningSection(true); + const sections = await fetchSections(); + if (disposed) return; + setAvailableSections(sections); + + const section = await resolveDefaultSection(sections, editorUserIdRef.current); + if (disposed) return; + setAvailableSections((prev) => + prev.some((item) => item.id === section.id) + ? prev + : [section, ...prev] + ); + + try { + await openSectionForEditing(section.id); + } catch (err) { + if (!(err instanceof ApiError) || err.status !== 409) { + throw err; + } + + const fallbackSection = await resolveOpenableSectionAfterConflict( + sections, + section.id, + editorUserIdRef.current + ); + if (disposed) return; + setAvailableSections((prev) => + prev.some((item) => item.id === fallbackSection.id) + ? prev + : [fallbackSection, ...prev] + ); + await openSectionForEditing(fallbackSection.id); + if (!disposed) { + setEntityStatus("Section mặc định đang bị lock, đã mở section khác để chỉnh sửa."); + } + } + } catch (err) { + if (disposed) return; + if (err instanceof ApiError) { + setEntityStatus(`Không tải được section editor: ${err.body || err.message}`); + } else { + console.error("Load section editor failed", err); + setEntityStatus("Không tải được section editor."); + } + } finally { + if (!disposed) { + setIsOpeningSection(false); + } + } + } + + loadSection(); + + return () => { + disposed = true; + }; + }, [openSectionForEditing]); + useEffect(() => { let disposed = false; @@ -171,9 +301,7 @@ export default function Page() { if (disposed) return; setPersistedEntities(rows); - setEntityStatus(rows.length - ? null - : "Chưa có entity. Cần tạo entity trước khi Save geometry."); + setEntityStatus(null); } catch (err) { if (disposed) return; console.error("Load entities failed", err); @@ -264,51 +392,6 @@ export default function Page() { }; }, [entitySearchQuery, selectedFeature, pendingEntityCreates]); - useEffect(() => { - if (!selectedFeature) { - setBindingGeometrySearchResults([]); - setSelectedBindingGeometryId(null); - return; - } - - const keyword = bindingGeometrySearchQuery.trim().toLowerCase(); - if (!keyword.length) { - setBindingGeometrySearchResults([]); - setSelectedBindingGeometryId(null); - return; - } - - const currentBindingIds = new Set(parseBindingInput(geometryMetaForm.binding)); - const rows = editor.draft.features - .filter((feature) => String(feature.properties.id) !== String(selectedFeature.properties.id)) - .map((feature) => { - const id = String(feature.properties.id); - const typeLabel = feature.properties.type || feature.geometry.type; - const entityLabel = Array.isArray(feature.properties.entity_names) && feature.properties.entity_names.length - ? feature.properties.entity_names.join(", ") - : (feature.properties.entity_name || ""); - const label = `#${id} [${feature.geometry.type}] ${typeLabel}${entityLabel ? ` - ${entityLabel}` : ""}`; - const searchable = `${id} ${feature.geometry.type} ${typeLabel} ${entityLabel}`.toLowerCase(); - return { id, label, searchable }; - }) - .filter((item) => !currentBindingIds.has(item.id)) - .filter((item) => item.searchable.includes(keyword)) - .slice(0, 40) - .map((item) => ({ id: item.id, label: item.label })); - - setBindingGeometrySearchResults(rows); - setSelectedBindingGeometryId((prev) => - prev && rows.some((item) => item.id === prev) - ? prev - : rows[0]?.id || null - ); - }, [ - bindingGeometrySearchQuery, - geometryMetaForm.binding, - editor.draft, - selectedFeature, - ]); - useEffect(() => { if (selectedFeatureId === null) return; const stillExists = editor.draft.features.some((feature) => @@ -330,9 +413,6 @@ export default function Page() { setEntitySearchQuery(""); setEntitySearchResults([]); setSelectedSearchEntityId(null); - setBindingGeometrySearchQuery(""); - setBindingGeometrySearchResults([]); - setSelectedBindingGeometryId(null); setEntityFormStatus(null); return; } @@ -351,14 +431,7 @@ export default function Page() { setEntitySearchQuery(""); setEntitySearchResults([]); setSelectedSearchEntityId(null); - setBindingGeometrySearchQuery(""); - setBindingGeometrySearchResults([]); - setSelectedBindingGeometryId(null); - if (!featureEntityIds.length) { - setEntityFormStatus("Geometry mới phải được gắn ít nhất 1 entity trước khi Save."); - } else { - setEntityFormStatus(null); - } + setEntityFormStatus(null); }, [selectedFeature]); useEffect(() => { @@ -470,7 +543,9 @@ export default function Page() { }); if (disposed || requestId !== timelineFetchRequestRef.current) return; - setInitialData(data); + if (!lastSectionSnapshot?.editor_feature_collection) { + setInitialData(data); + } } catch (err) { if (err instanceof ApiError) { console.error("Load timeline data failed", err.body); @@ -493,65 +568,54 @@ export default function Page() { return () => { disposed = true; }; - }, [timelineYear, isTimelineReady]); + }, [timelineYear, isTimelineReady, lastSectionSnapshot]); - const handleSave = async () => { - const geometryChanges = editor.buildPayload(); - const entityChanges: EntityBatchChange[] = pendingEntityCreates.map((entity) => ({ - action: "create", - entity: { - id: entity.id, - name: entity.name, - slug: entity.slug, - type_id: entity.type_id, - status: entity.status, - }, - })); - - if (!geometryChanges.length && !entityChanges.length) return; - - const invalid = geometryChanges.find((change) => { - if (change.action === "delete") return false; - const draftFeature = change.action === "create" - ? change.feature - : editor.draft.features.find((feature) => - String(feature.properties.id) === String(change.id) - ); - if (!draftFeature) return false; - const entityIds = normalizeFeatureEntityIds(draftFeature); - return entityIds.length === 0; - }); - - if (invalid) { - const invalidId = invalid.action === "create" - ? invalid.feature.properties.id - : invalid.id; - setSelectedFeatureId(invalidId); - setEntityStatus("Không thể Save: mỗi geometry phải có ít nhất 1 entity."); + const handleCommitSection = async () => { + if (!activeSection || !sectionState) { + setEntityStatus("Chưa mở được section editor."); return; } + const geometryChanges = editor.buildPayload(); + setIsSaving(true); setEntityStatus(null); try { - await saveCombinedGeometryEntityBatchChanges(entityChanges, geometryChanges); - if (geometryChanges.length) { - editor.clearChanges(); - await reloadCurrentTimelineData(); - } - if (entityChanges.length) { - setPendingEntityCreates([]); - await reloadEntities(); - setEntityFormStatus("Đã lưu batch entities + geometries."); - } + const snapshot = buildEditorSnapshot({ + section: activeSection, + draft: editor.draft, + changes: geometryChanges, + pendingEntities: pendingEntityCreates, + previousSnapshot: lastSectionSnapshot, + hasPersistedFeature: editor.hasPersistedFeature, + }); + const result = await createSectionCommit(activeSection.id, { + snapshot, + created_by: editorUserId, + expected_version: sectionState.version, + expected_head_commit_id: sectionState.head_commit_id, + title: commitTitle.trim() || `Commit ${new Date().toLocaleString()}`, + note: commitNote.trim() || null, + }); + + setSectionState(result.state); + setLastSectionSnapshot(snapshot); + setInitialData(editor.draft); + editor.clearChanges(); + setPendingEntityCreates([]); + setCreatedEntities([]); + setCommitTitle(""); + setCommitNote(""); + setSectionCommits(await fetchSectionCommits(activeSection.id)); + setEntityFormStatus(`Đã tạo commit #${result.commit.commit_no}.`); } catch (err) { if (err instanceof ApiError) { - console.error("Save failed", err.body); - setEntityStatus(`Save thất bại: ${err.body}`); + console.error("Commit failed", err.body); + setEntityStatus(`Commit thất bại: ${err.body}`); return; } - console.error("Save error", err); - setEntityStatus("Save thất bại."); + console.error("Commit error", err); + setEntityStatus("Commit thất bại."); } finally { setIsSaving(false); } @@ -623,42 +687,6 @@ export default function Page() { setEntityFormStatus(null); }; - const handleAddSelectedBindingGeometry = () => { - const geometryId = selectedBindingGeometryId ? selectedBindingGeometryId.trim() : ""; - if (!geometryId.length) { - setEntityFormStatus("Hãy chọn một geometry từ kết quả search binding trước."); - return; - } - - if (selectedFeature && String(selectedFeature.properties.id) === geometryId) { - setEntityFormStatus("Không thể tự bind geometry với chính nó."); - return; - } - - const currentBindingIds = parseBindingInput(geometryMetaForm.binding); - const nextBindingIds = uniqueEntityIds([...currentBindingIds, geometryId]); - setGeometryMetaForm((prev) => ({ - ...prev, - binding: nextBindingIds.join(", "), - })); - setSelectedBindingGeometryId(null); - setEntityFormStatus(null); - }; - - const reloadEntities = async () => { - const rows = await fetchEntities(); - setPersistedEntities(rows); - return rows; - }; - - const reloadCurrentTimelineData = async () => { - const data = await fetchGeometriesByBBox({ - ...WORLD_BBOX, - time: timelineYear, - }); - setInitialData(data); - }; - const parseGeometryMetaFormRange = () => { const timeStart = parseOptionalYearInput(geometryMetaForm.time_start, "time_start"); const timeEnd = parseOptionalYearInput(geometryMetaForm.time_end, "time_end"); @@ -678,12 +706,28 @@ export default function Page() { return primaryEntity?.type_id || null; }; - const patchSelectedFeatureLocally = ( + const patchSelectedGeometryMetadataLocally = ( feature: Feature, - entityIds: string[], timeStart: number | null, timeEnd: number | null, - bindingIds: string[], + bindingIds: string[] + ) => { + editor.patchFeatureProperties(feature.properties.id, { + time_start: timeStart, + time_end: timeEnd, + binding: bindingIds, + }); + + setGeometryMetaForm({ + time_start: timeStart != null ? String(timeStart) : "", + time_end: timeEnd != null ? String(timeEnd) : "", + binding: bindingIds.join(", "), + }); + }; + + const patchSelectedFeatureEntitiesLocally = ( + feature: Feature, + entityIds: string[], entityRows: Entity[] = entities ) => { const primaryEntityId = entityIds[0] || null; @@ -702,31 +746,17 @@ export default function Page() { entity_name: primaryEntity?.name || null, entity_names: entityNames, entity_type_id: primaryEntity?.type_id || null, - time_start: timeStart, - time_end: timeEnd, - binding: bindingIds, }); setSelectedGeometryEntityIds(entityIds); - setGeometryMetaForm({ - time_start: timeStart != null ? String(timeStart) : "", - time_end: timeEnd != null ? String(timeEnd) : "", - binding: bindingIds.join(", "), - }); }; - const handleApplyEntitiesForSelectedGeometry = async () => { + const handleApplyGeometryMetadata = async () => { if (!selectedFeature) { setEntityFormStatus("Hãy chọn một geometry trước."); return; } - const entityIds = uniqueEntityIds(selectedGeometryEntityIds); - if (!entityIds.length) { - setEntityFormStatus("Geometry phải có ít nhất 1 entity."); - return; - } - let timeStart: number | null; let timeEnd: number | null; let bindingIds: string[]; @@ -741,34 +771,25 @@ export default function Page() { setIsEntitySubmitting(true); setEntityFormStatus(null); try { - const nextGeometryType = resolveGeometryTypeFromEntityIds(entityIds) || selectedFeature.properties.type || null; - if (editor.hasPersistedFeature(selectedFeature.properties.id)) { - const pendingEntityIdSet = new Set(pendingEntityCreates.map((entity) => entity.id)); - const hasPendingEntity = entityIds.some((entityId) => pendingEntityIdSet.has(entityId)); - if (hasPendingEntity) { - setEntityFormStatus("Danh sách gắn có entity chưa lưu. Hãy bấm Save trước, rồi áp dụng lại."); - return; - } - if (editor.changeCount > 0) { - setEntityFormStatus("Hãy Save/Undo hết thay đổi bản đồ trước khi cập nhật geometry đã tồn tại."); - return; - } + patchSelectedGeometryMetadataLocally(selectedFeature, timeStart, timeEnd, bindingIds); + setEntityFormStatus("Đã cập nhật thuộc tính GEO. Commit khi sẵn sàng."); + } finally { + setIsEntitySubmitting(false); + } + }; - await updateGeometry(selectedFeature.properties.id, { - geometry: selectedFeature.geometry, - type: nextGeometryType ?? undefined, - time_start: timeStart, - time_end: timeEnd, - binding: bindingIds, - entity_ids: entityIds, - }); + const handleApplyEntitiesForSelectedGeometry = async () => { + if (!selectedFeature) { + setEntityFormStatus("Hãy chọn một geometry trước."); + return; + } - await reloadCurrentTimelineData(); - setEntityFormStatus("Đã cập nhật entities + metadata geometry."); - } else { - patchSelectedFeatureLocally(selectedFeature, entityIds, timeStart, timeEnd, bindingIds); - setEntityFormStatus("Đã cập nhật local. Bấm Save để lưu geometry mới."); - } + const entityIds = uniqueEntityIds(selectedGeometryEntityIds); + setIsEntitySubmitting(true); + setEntityFormStatus(null); + try { + patchSelectedFeatureEntitiesLocally(selectedFeature, entityIds); + setEntityFormStatus("Đã cập nhật danh sách entity. Commit khi sẵn sàng."); } catch (err) { if (err instanceof ApiError) { setEntityFormStatus(`Lưu thất bại: ${err.body}`); @@ -837,7 +858,7 @@ export default function Page() { slug: "", })); setEntityStatus(null); - setEntityFormStatus("Đã thêm entity mới vào danh sách chờ Save."); + setEntityFormStatus("Đã thêm entity mới vào danh sách chờ Commit."); if (selectedFeature) { setEntitySearchQuery(pendingCreate.name); @@ -848,11 +869,142 @@ export default function Page() { } }; + const handleOpenSelectedSection = async () => { + const sectionId = selectedSectionId.trim(); + if (!sectionId) { + setEntityStatus("Hãy chọn section để mở."); + return; + } + if (pendingSaveCount > 0) { + const confirmed = window.confirm("Section hiện tại có thay đổi chưa Commit. Mở section khác sẽ bỏ các thay đổi này. Tiếp tục?"); + if (!confirmed) return; + } + + setIsOpeningSection(true); + setEntityStatus(null); + try { + await openSectionForEditing(sectionId); + setEntityStatus("Đã mở section để chỉnh sửa."); + } catch (err) { + if (err instanceof ApiError) { + setEntityStatus(`Mở section thất bại: ${err.body}`); + } else { + setEntityStatus("Mở section thất bại."); + } + } finally { + setIsOpeningSection(false); + } + }; + + const handleCreateAndOpenSection = async () => { + const title = newSectionTitle.trim(); + if (!title) { + setEntityStatus("Tên section là bắt buộc."); + return; + } + if (pendingSaveCount > 0) { + const confirmed = window.confirm("Section hiện tại có thay đổi chưa Commit. Tạo section mới sẽ bỏ các thay đổi này. Tiếp tục?"); + if (!confirmed) return; + } + + setIsOpeningSection(true); + setEntityStatus(null); + try { + const section = await createSection({ + title, + user_id: editorUserId, + created_by: editorUserId, + }); + const sections = await fetchSections(); + setAvailableSections(sections); + setNewSectionTitle(""); + await openSectionForEditing(section.id); + setEntityStatus("Đã tạo và mở section mới."); + } catch (err) { + if (err instanceof ApiError) { + setEntityStatus(`Tạo section thất bại: ${err.body}`); + } else { + setEntityStatus("Tạo section thất bại."); + } + } finally { + setIsOpeningSection(false); + } + }; + + const handleSubmitSection = async () => { + if (!activeSection || !sectionState?.head_commit_id) { + setEntityStatus("Chưa có commit để submit."); + return; + } + if (pendingSaveCount > 0) { + setEntityStatus("Hãy Commit các thay đổi trước khi Submit."); + return; + } + + setIsSubmitting(true); + setEntityStatus(null); + try { + const submission = await submitSection(activeSection.id, { + submitted_by: editorUserId, + commit_id: sectionState.head_commit_id, + }); + setSectionState((prev) => prev ? { ...prev, status: "submitted" } : prev); + setEntityStatus(`Đã submit section, submission ${submission.id}.`); + } catch (err) { + if (err instanceof ApiError) { + setEntityStatus(`Submit thất bại: ${err.body}`); + } else { + setEntityStatus("Submit thất bại."); + } + } finally { + setIsSubmitting(false); + } + }; + + const handleRestoreCommit = async (commitId: string) => { + if (!activeSection || !sectionState) { + setEntityStatus("Chưa mở được section editor."); + return; + } + if (pendingSaveCount > 0) { + setEntityStatus("Hãy Commit hoặc Undo thay đổi hiện tại trước khi Restore."); + return; + } + + setIsSaving(true); + setEntityStatus(null); + try { + const result = await restoreSectionCommit(activeSection.id, { + commit_id: commitId, + created_by: editorUserId, + expected_version: sectionState.version, + expected_head_commit_id: sectionState.head_commit_id, + }); + const editorPayload = await openSectionEditor(activeSection.id, editorUserId); + const snapshot = normalizeEditorSnapshot(editorPayload.snapshot); + setSectionState(result.state); + setLastSectionSnapshot(snapshot); + if (snapshot?.editor_feature_collection) { + setInitialData(snapshot.editor_feature_collection); + } + setSectionCommits(await fetchSectionCommits(activeSection.id)); + setEntityFormStatus(`Đã restore thành commit #${result.commit.commit_no}.`); + } catch (err) { + if (err instanceof ApiError) { + setEntityStatus(`Restore thất bại: ${err.body}`); + } else { + setEntityStatus("Restore thất bại."); + } + } finally { + setIsSaving(false); + } + }; + const pendingSaveCount = editor.changeCount + pendingEntityCreates.length; const timelineDisabled = !isTimelineReady || isSaving || pendingSaveCount > 0; const timelineStatusText = pendingSaveCount > 0 - ? "Lưu hết thay đổi trước khi đổi mốc thời gian." + ? "Commit hoặc Undo hết thay đổi trước khi đổi mốc thời gian." : isSaving ? "Đang lưu thay đổi..." : timelineStatus; @@ -869,8 +1021,30 @@ export default function Page() { setMode={setMode} entityStatus={entityStatus} onUndo={editor.undo} - onSave={handleSave} + onCommit={handleCommitSection} + onSubmit={handleSubmitSection} + onRestoreCommit={handleRestoreCommit} isSaving={isSaving} + isSubmitting={isSubmitting} + isOpeningSection={isOpeningSection} + sectionTitle={activeSection?.title || "Đang tải section"} + sectionStatus={sectionState?.status || "editing"} + selectedSectionId={selectedSectionId} + editorUserId={editorUserIdInput} + sectionOptions={availableSections} + newSectionTitle={newSectionTitle} + commitTitle={commitTitle} + commitNote={commitNote} + onEditorUserIdChange={setEditorUserIdInput} + onSelectedSectionIdChange={setSelectedSectionId} + onNewSectionTitleChange={setNewSectionTitle} + onCommitTitleChange={setCommitTitle} + onCommitNoteChange={setCommitNote} + onOpenSection={handleOpenSelectedSection} + onCreateSection={handleCreateAndOpenSection} + commitCount={sectionCommits.length} + latestCommitLabel={sectionCommits[0] ? `Head #${sectionCommits[0].commit_no}` : null} + commits={sectionCommits} changesCount={pendingSaveCount} undoStack={editor.undoStack} createdEntities={createdEntities} @@ -940,14 +1114,9 @@ export default function Page() { entityTypeOptions={ENTITY_TYPE_OPTIONS} geometryMetaForm={geometryMetaForm} onGeometryMetaFormChange={handleGeometryMetaFormChange} - bindingGeometrySearchQuery={bindingGeometrySearchQuery} - onBindingGeometrySearchQueryChange={setBindingGeometrySearchQuery} - bindingGeometrySearchResults={bindingGeometrySearchResults} - selectedBindingGeometryId={selectedBindingGeometryId} - onSelectBindingGeometryId={setSelectedBindingGeometryId} - onAddSelectedBindingGeometry={handleAddSelectedBindingGeometry} isEntitySubmitting={isEntitySubmitting} onCreateEntityOnly={handleCreateEntityOnly} + onApplyGeometryMetadata={handleApplyGeometryMetadata} onApplyEntitiesForSelectedGeometry={handleApplyEntitiesForSelectedGeometry} changeCount={editor.changeCount} entityFormStatus={entityFormStatus} @@ -958,6 +1127,233 @@ export default function Page() { ); } +async function resolveDefaultSection(existingSections?: Section[], userId = DEFAULT_EDITOR_USER_ID): Promise
{ + const sections = existingSections || await fetchSections(); + + const existingDefault = sections.find((section) => section.title === DEFAULT_SECTION_TITLE); + if (existingDefault) { + return existingDefault; + } + + return createSection({ + title: DEFAULT_SECTION_TITLE, + user_id: userId, + created_by: userId, + description: "Default section used by the map editor UI.", + }); +} + +async function resolveOpenableSectionAfterConflict( + sections: Section[], + blockedSectionId: string, + userId: string +): Promise
{ + const now = Date.now(); + const fallback = sections.find((section) => + section.id !== blockedSectionId && + section.state.status === "editing" && + ( + !section.state.locked_by || + section.state.locked_by === userId || + ( + section.state.lock_expires_at !== null && + Date.parse(section.state.lock_expires_at) <= now + ) + ) + ); + if (fallback) return fallback; + + return createSection({ + title: `${DEFAULT_SECTION_TITLE} (${userId})`, + user_id: userId, + created_by: userId, + description: "Fallback section created because the default section is locked.", + }); +} + +function normalizeEditorUserId(value: string): string { + const normalized = value.trim(); + return normalized || DEFAULT_EDITOR_USER_ID; +} + +function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; + const snapshot = raw as EditorSnapshot; + if ( + snapshot.editor_feature_collection && + snapshot.editor_feature_collection.type === "FeatureCollection" && + Array.isArray(snapshot.editor_feature_collection.features) + ) { + return snapshot; + } + return { + ...snapshot, + editor_feature_collection: undefined, + }; +} + +function buildEditorSnapshot(options: { + section: Section; + draft: FeatureCollection; + changes: Change[]; + pendingEntities: PendingEntityCreate[]; + previousSnapshot: EditorSnapshot | null; + hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean; +}): EditorSnapshot { + const changedIds = new Set(options.changes.map((change) => String(change.action === "create" ? change.feature.properties.id : change.id))); + const deletedIds = new Set( + options.changes + .filter((change): change is Extract => change.action === "delete") + .map((change) => String(change.id)) + ); + const currentDraftIds = new Set(options.draft.features.map((feature) => String(feature.properties.id))); + const previousFeatures = new globalThis.Map(); + for (const feature of options.previousSnapshot?.editor_feature_collection?.features || []) { + previousFeatures.set(String(feature.properties.id), feature); + if (!currentDraftIds.has(String(feature.properties.id))) { + deletedIds.add(String(feature.properties.id)); + } + } + const previousGeometryOps = new globalThis.Map(); + for (const item of options.previousSnapshot?.geometries || []) { + const id = typeof item.id === "string" || typeof item.id === "number" ? String(item.id) : ""; + const operation = typeof item.operation === "string" ? item.operation : ""; + if (id && operation) previousGeometryOps.set(id, operation); + } + + const pendingEntityIds = new Set(options.pendingEntities.map((entity) => entity.id)); + const entityRows = new globalThis.Map>(); + for (const item of options.previousSnapshot?.entities || []) { + const id = typeof item.id === "string" || typeof item.id === "number" ? String(item.id) : ""; + if (id) entityRows.set(id, { ...item }); + } + for (const entity of options.pendingEntities) { + entityRows.set(entity.id, { + id: entity.id, + operation: "create", + name: entity.name, + slug: entity.slug, + description: null, + type_id: entity.type_id, + status: entity.status, + is_deleted: 0, + }); + } + + for (const feature of options.draft.features) { + for (const entityId of normalizeFeatureEntityIds(feature)) { + if (entityRows.has(entityId)) continue; + entityRows.set(entityId, { + id: entityId, + operation: "reference", + name: feature.properties.entity_names?.[0] || feature.properties.entity_name || entityId, + slug: null, + description: null, + type_id: feature.properties.entity_type_id || feature.properties.type || DEFAULT_ENTITY_TYPE_ID, + status: 1, + is_deleted: 0, + }); + } + } + + const geometries: Array> = options.draft.features.map((feature) => { + const id = String(feature.properties.id); + const previousOperation = previousGeometryOps.get(id); + const previousFeature = previousFeatures.get(id); + const changedFromPreviousSnapshot = previousFeature + ? JSON.stringify(previousFeature) !== JSON.stringify(feature) + : false; + const operation = previousOperation === "create" + ? "create" + : !previousFeature && (options.previousSnapshot || !options.hasPersistedFeature(feature.properties.id)) + ? "create" + : changedIds.has(id) || changedFromPreviousSnapshot + ? "update" + : "reference"; + const bbox = getFeatureBBox(feature); + return { + id, + operation, + type: feature.properties.type || getDefaultTypeIdForFeature(feature), + draw_geometry: feature.geometry, + binding: normalizeFeatureBindingIds(feature), + time_start: feature.properties.time_start ?? null, + time_end: feature.properties.time_end ?? null, + bbox: bbox + ? { + min_lng: bbox.minLng, + min_lat: bbox.minLat, + max_lng: bbox.maxLng, + max_lat: bbox.maxLat, + } + : null, + is_deleted: 0, + }; + }); + + for (const id of deletedIds) { + geometries.push({ + id, + operation: "delete", + is_deleted: 1, + }); + } + + const linkScopes = options.draft.features + .map((feature) => ({ + geometry_id: String(feature.properties.id), + operation: "replace", + entity_ids: normalizeFeatureEntityIds(feature), + })) + .filter((scope) => scope.entity_ids.length > 0); + + return { + schema_version: 1, + section: { + id: options.section.id, + title: options.section.title, + }, + editor_feature_collection: JSON.parse(JSON.stringify(options.draft)) as FeatureCollection, + entities: Array.from(entityRows.values()).map((entity) => { + const id = String(entity.id || ""); + if (pendingEntityIds.has(id)) return entity; + return entity; + }), + geometries, + link_scopes: linkScopes, + }; +} + +function getFeatureBBox(feature: Feature): { minLng: number; minLat: number; maxLng: number; maxLat: number } | null { + const points = collectCoordinatePairs(feature.geometry.coordinates); + if (!points.length) return null; + let minLng = Number.POSITIVE_INFINITY; + let minLat = Number.POSITIVE_INFINITY; + let maxLng = Number.NEGATIVE_INFINITY; + let maxLat = Number.NEGATIVE_INFINITY; + for (const [lng, lat] of points) { + minLng = Math.min(minLng, lng); + minLat = Math.min(minLat, lat); + maxLng = Math.max(maxLng, lng); + maxLat = Math.max(maxLat, lat); + } + return { minLng, minLat, maxLng, maxLat }; +} + +function collectCoordinatePairs(value: unknown): Array<[number, number]> { + if (!Array.isArray(value)) return []; + if ( + value.length >= 2 && + typeof value[0] === "number" && + typeof value[1] === "number" && + Number.isFinite(value[0]) && + Number.isFinite(value[1]) + ) { + return [[value[0], value[1]]]; + } + return value.flatMap((item) => collectCoordinatePairs(item)); +} + function deriveTimelineRange(collection: FeatureCollection): TimelineRange { let min = Number.POSITIVE_INFINITY; let max = Number.NEGATIVE_INFINITY; diff --git a/components/Editor.tsx b/components/Editor.tsx index f86ff5d..deb63de 100644 --- a/components/Editor.tsx +++ b/components/Editor.tsx @@ -9,8 +9,42 @@ type Props = { setMode: (mode: Mode) => void; entityStatus?: string | null; onUndo: () => void; - onSave: () => void; + onCommit: () => void; + onSubmit: () => void; + onRestoreCommit: (commitId: string) => void; isSaving: boolean; + isSubmitting: boolean; + isOpeningSection: boolean; + sectionTitle: string; + sectionStatus: string; + selectedSectionId: string; + editorUserId: string; + sectionOptions: Array<{ + id: string; + title: string; + state?: { + status?: string; + }; + }>; + newSectionTitle: string; + commitTitle: string; + commitNote: string; + onEditorUserIdChange: (userId: string) => void; + onSelectedSectionIdChange: (sectionId: string) => void; + onNewSectionTitleChange: (title: string) => void; + onCommitTitleChange: (title: string) => void; + onCommitNoteChange: (note: string) => void; + onOpenSection: () => void; + onCreateSection: () => void; + commitCount: number; + latestCommitLabel: string | null; + commits: Array<{ + id: string; + commit_no: number; + kind: string; + created_at: string; + title: string | null; + }>; changesCount: number; undoStack: UndoAction[]; createdEntities: Array<{ @@ -31,8 +65,30 @@ export default function Editor({ setMode, entityStatus, onUndo, - onSave, + onCommit, + onSubmit, + onRestoreCommit, isSaving, + isSubmitting, + isOpeningSection, + sectionTitle, + sectionStatus, + selectedSectionId, + editorUserId, + sectionOptions, + newSectionTitle, + commitTitle, + commitNote, + onEditorUserIdChange, + onSelectedSectionIdChange, + onNewSectionTitleChange, + onCommitTitleChange, + onCommitNoteChange, + onOpenSection, + onCreateSection, + commitCount, + latestCommitLabel, + commits, changesCount, undoStack, createdEntities, @@ -83,6 +139,124 @@ export default function Editor({ }} >

Editor

+
+
{sectionTitle}
+
Status: {sectionStatus}
+
Commits: {commitCount}
+
{latestCommitLabel || "Chưa có commit"}
+
+ +
+
+ Section +
+ onEditorUserIdChange(event.target.value)} + placeholder="User ID" + style={{ + width: "100%", + marginBottom: "8px", + padding: "7px", + borderRadius: "4px", + border: "1px solid #334155", + background: "#111827", + color: "white", + boxSizing: "border-box", + }} + disabled={isOpeningSection} + /> + + + onNewSectionTitleChange(event.target.value)} + placeholder="Tên section mới" + style={{ + width: "100%", + marginTop: "10px", + padding: "7px", + borderRadius: "4px", + border: "1px solid #334155", + background: "#111827", + color: "white", + boxSizing: "border-box", + }} + disabled={isOpeningSection} + /> + +
- + + onCommitTitleChange(event.target.value)} + placeholder="Commit title" + disabled={isSaving || isSubmitting || sectionStatus === "submitted"} + style={{ + width: "100%", + marginTop: "8px", + padding: "7px", + borderRadius: "4px", + border: "1px solid #334155", + background: "#111827", + color: "white", + boxSizing: "border-box", + }} + /> +