diff --git a/api/config.ts b/api/config.ts index fc1380e..f905f8a 100644 --- a/api/config.ts +++ b/api/config.ts @@ -6,7 +6,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`, 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 696359a..fca6fdc 100644 --- a/api/entities.ts +++ b/api/entities.ts @@ -21,6 +21,43 @@ export type CreateEntityPayload = { 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) { @@ -61,3 +98,11 @@ export async function updateEntity(id: string, payload: CreateEntityPayload): Pr 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 1ff9be4..d560caa 100644 --- a/api/geometries.ts +++ b/api/geometries.ts @@ -1,4 +1,5 @@ import { API_ENDPOINTS } from "@/api/config"; +import { EntityBatchChange } from "@/api/entities"; import { requestJson } from "@/api/http"; import { Change, FeatureCollection, Geometry } from "@/lib/useEditorState"; @@ -40,6 +41,14 @@ export type BatchSaveResponse = { 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,6 +81,20 @@ export async function saveGeometryBatchChanges(changes: 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", diff --git a/app/editor/page.tsx b/app/editor/page.tsx index 429e361..5343675 100644 --- a/app/editor/page.tsx +++ b/app/editor/page.tsx @@ -1,14 +1,14 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { 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 { createEntity, Entity, fetchEntities, searchEntitiesByName } from "@/api/entities"; +import { Entity, EntityBatchChange, fetchEntities, searchEntitiesByName } from "@/api/entities"; import { ApiError } from "@/api/http"; -import { fetchGeometriesByBBox, saveGeometryBatchChanges, updateGeometry } from "@/api/geometries"; +import { fetchGeometriesByBBox, saveCombinedGeometryEntityBatchChanges, updateGeometry } from "@/api/geometries"; import { Feature, FeatureCollection, @@ -21,7 +21,12 @@ import { DEFAULT_BACKGROUND_LAYER_VISIBILITY, HIDDEN_BACKGROUND_LAYER_VISIBILITY, } from "@/lib/backgroundLayers"; -import { DEFAULT_ENTITY_TYPE_ID, ENTITY_TYPE_OPTIONS } from "@/lib/entityTypeOptions"; +import { + DEFAULT_ENTITY_TYPE_ID, + ENTITY_TYPE_OPTIONS, + EntityTypeGroupId, + findEntityTypeOption, +} from "@/lib/entityTypeOptions"; const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] }; const WORLD_BBOX = { @@ -55,11 +60,30 @@ type GeometryMetaFormState = { binding: string; }; +type BindingGeometrySearchOption = { + id: string; + label: string; +}; + +type PendingEntityCreate = { + id: string; + name: string; + slug: string | null; + type_id: string; + status: number; +}; + 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 [entities, setEntities] = useState([]); + const [persistedEntities, setPersistedEntities] = useState([]); + const [pendingEntityCreates, setPendingEntityCreates] = useState([]); + const [createdEntities, setCreatedEntities] = useState>([]); const [entityStatus, setEntityStatus] = useState(null); const [selectedFeatureId, setSelectedFeatureId] = useState(null); const [entityForm, setEntityForm] = useState({ @@ -79,6 +103,9 @@ export default function Page() { const [entitySearchResults, setEntitySearchResults] = 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); @@ -97,9 +124,12 @@ export default function Page() { const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false); const timelineFetchRequestRef = useRef(0); const entitySearchRequestRef = useRef(0); - const skipSelectedFeatureSyncRef = useRef(false); const editor = useEditorState(initialData); + const entities = useMemo( + () => mergeEntitiesWithPending(persistedEntities, pendingEntityCreates), + [persistedEntities, pendingEntityCreates] + ); const selectedFeature = selectedFeatureId === null ? null @@ -107,6 +137,31 @@ export default function Page() { String(feature.properties.id) === String(selectedFeatureId) ) || null; + const createdGeometries = useMemo(() => { + const rows: Array<{ + id: string | number; + geometryType: string; + semanticType?: string | null; + entityNames: string[]; + }> = []; + + for (const change of editor.changes.values()) { + if (change.action !== "create") continue; + const feature = change.feature; + const entityNames = normalizeFeatureEntityIds(feature) + .map((entityId) => entities.find((entity) => entity.id === entityId)?.name || entityId); + + rows.push({ + id: feature.properties.id, + geometryType: feature.geometry.type, + semanticType: feature.properties.type || getDefaultTypeIdForFeature(feature), + entityNames, + }); + } + + return rows; + }, [editor.changes, entities]); + useEffect(() => { let disposed = false; @@ -115,8 +170,10 @@ export default function Page() { const rows = await fetchEntities(); if (disposed) return; - setEntities(rows); - setEntityStatus(rows.length ? null : "Chưa có entity. Cần tạo entity trước khi Save geometry."); + setPersistedEntities(rows); + setEntityStatus(rows.length + ? null + : "Chưa có entity. Cần tạo entity trước khi Save geometry."); } catch (err) { if (disposed) return; console.error("Load entities failed", err); @@ -155,17 +212,45 @@ export default function Page() { const rows = await searchEntitiesByName(keyword, { limit: 30 }); if (disposed || requestId !== entitySearchRequestRef.current) return; - setEntitySearchResults(rows); + const pendingMatches = pendingEntityCreates + .filter((entity) => + entity.name.toLowerCase().includes(keyword.toLowerCase()) || + (entity.slug || "").toLowerCase().includes(keyword.toLowerCase()) + ) + .map((entity) => ({ + id: entity.id, + name: entity.name, + slug: entity.slug, + type_id: entity.type_id, + status: entity.status, + geometry_count: 0, + })); + + const mergedRows = mergeEntitySearchResults(rows, pendingMatches); + setEntitySearchResults(mergedRows); setSelectedSearchEntityId((prev) => - prev && rows.some((entity) => entity.id === prev) + prev && mergedRows.some((entity) => entity.id === prev) ? prev - : rows[0]?.id || null + : mergedRows[0]?.id || null ); } catch (err) { if (disposed || requestId !== entitySearchRequestRef.current) return; console.error("Search entity by name failed", err); - setEntitySearchResults([]); - setSelectedSearchEntityId(null); + const pendingMatches = pendingEntityCreates + .filter((entity) => + entity.name.toLowerCase().includes(keyword.toLowerCase()) || + (entity.slug || "").toLowerCase().includes(keyword.toLowerCase()) + ) + .map((entity) => ({ + id: entity.id, + name: entity.name, + slug: entity.slug, + type_id: entity.type_id, + status: entity.status, + geometry_count: 0, + })); + setEntitySearchResults(pendingMatches); + setSelectedSearchEntityId(pendingMatches[0]?.id || null); } finally { if (!disposed && requestId === entitySearchRequestRef.current) { setIsEntitySearchLoading(false); @@ -177,7 +262,52 @@ export default function Page() { disposed = true; window.clearTimeout(timeoutId); }; - }, [entitySearchQuery, selectedFeature]); + }, [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; @@ -190,17 +320,7 @@ export default function Page() { }, [editor.draft, selectedFeatureId]); useEffect(() => { - if (skipSelectedFeatureSyncRef.current) { - skipSelectedFeatureSyncRef.current = false; - return; - } - if (!selectedFeature) { - setEntityForm({ - name: "", - slug: "", - type_id: DEFAULT_ENTITY_TYPE_ID, - }); setSelectedGeometryEntityIds([]); setGeometryMetaForm({ time_start: "", @@ -210,26 +330,14 @@ export default function Page() { setEntitySearchQuery(""); setEntitySearchResults([]); setSelectedSearchEntityId(null); + setBindingGeometrySearchQuery(""); + setBindingGeometrySearchResults([]); + setSelectedBindingGeometryId(null); setEntityFormStatus(null); return; } const featureEntityIds = normalizeFeatureEntityIds(selectedFeature); - const primaryEntityId = featureEntityIds[0] || null; - const linkedEntity = primaryEntityId - ? entities.find((entity) => entity.id === primaryEntityId) || null - : null; - const nextTypeId = - linkedEntity?.type_id || - selectedFeature.properties.type || - selectedFeature.properties.entity_type_id || - getDefaultTypeIdForFeature(selectedFeature); - - setEntityForm({ - name: "", - slug: linkedEntity?.slug || "", - type_id: nextTypeId, - }); setSelectedGeometryEntityIds(featureEntityIds); setGeometryMetaForm({ time_start: selectedFeature.properties.time_start != null @@ -243,12 +351,39 @@ 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); } - }, [selectedFeature, entities]); + }, [selectedFeature]); + + useEffect(() => { + if (!selectedFeature) return; + + const allowedGroupIds = getAllowedEntityTypeGroupIdsForFeature(selectedFeature); + const fallbackOption = ENTITY_TYPE_OPTIONS.find((option) => + allowedGroupIds.includes(option.groupId) + ); + if (!fallbackOption) return; + + setEntityForm((prev) => { + const currentOption = findEntityTypeOption(prev.type_id); + const isCurrentAllowed = currentOption + ? allowedGroupIds.includes(currentOption.groupId) + : false; + if (isCurrentAllowed || prev.type_id === fallbackOption.value) { + return prev; + } + return { + ...prev, + type_id: fallbackOption.value, + }; + }); + }, [selectedFeature]); useEffect(() => { let disposed = false; @@ -361,10 +496,21 @@ export default function Page() { }, [timelineYear, isTimelineReady]); const handleSave = async () => { - const payload = editor.buildPayload(); - if (!payload.length) return; + 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, + }, + })); - const invalid = payload.find((change) => { + if (!geometryChanges.length && !entityChanges.length) return; + + const invalid = geometryChanges.find((change) => { if (change.action === "delete") return false; const draftFeature = change.action === "create" ? change.feature @@ -388,9 +534,16 @@ export default function Page() { setIsSaving(true); setEntityStatus(null); try { - await saveGeometryBatchChanges(payload); - editor.clearChanges(); - await reloadCurrentTimelineData(); + await saveCombinedGeometryEntityBatchChanges(entityChanges, geometryChanges); + if (geometryChanges.length) { + editor.clearChanges(); + await reloadCurrentTimelineData(); + } + if (entityChanges.length) { + setPendingEntityCreates([]); + await reloadEntities(); + setEntityFormStatus("Đã lưu batch entities + geometries."); + } } catch (err) { if (err instanceof ApiError) { console.error("Save failed", err.body); @@ -470,9 +623,31 @@ 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(); - setEntities(rows); + setPersistedEntities(rows); return rows; }; @@ -484,24 +659,6 @@ export default function Page() { setInitialData(data); }; - const resetSelectedGeometryInputsAfterCreate = () => { - skipSelectedFeatureSyncRef.current = true; - setEntitySearchQuery(""); - setEntitySearchResults([]); - setSelectedSearchEntityId(null); - setSelectedGeometryEntityIds([]); - setGeometryMetaForm({ - time_start: "", - time_end: "", - binding: "", - }); - setEntityForm({ - name: "", - slug: "", - type_id: DEFAULT_ENTITY_TYPE_ID, - }); - }; - const parseGeometryMetaFormRange = () => { const timeStart = parseOptionalYearInput(geometryMetaForm.time_start, "time_start"); const timeEnd = parseOptionalYearInput(geometryMetaForm.time_end, "time_end"); @@ -586,6 +743,12 @@ export default function Page() { 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; @@ -617,89 +780,79 @@ export default function Page() { } }; - const handleCreateEntityAndAttach = async () => { - if (!selectedFeature) { - setEntityFormStatus("Hãy chọn một geometry trước."); - return; - } - - if (editor.hasPersistedFeature(selectedFeature.properties.id) && editor.changeCount > 0) { - setEntityFormStatus("Hãy Save/Undo hết thay đổi bản đồ trước khi gắn entity cho geometry đã tồn tại."); - return; - } - + const handleCreateEntityOnly = async () => { const name = entityForm.name.trim(); if (!name) { setEntityFormStatus("Tên entity là bắt buộc."); return; } - let timeStart: number | null; - let timeEnd: number | null; - let bindingIds: string[]; - try { - ({ timeStart, timeEnd } = parseGeometryMetaFormRange()); - bindingIds = parseBindingInput(geometryMetaForm.binding); - } catch (err) { - setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ."); + const slug = entityForm.slug.trim() || null; + const typeId = entityForm.type_id || DEFAULT_ENTITY_TYPE_ID; + const normalizedName = name.toLowerCase(); + const duplicatedName = entities.some((entity) => entity.name.trim().toLowerCase() === normalizedName); + if (duplicatedName) { + setEntityFormStatus("Tên entity đã tồn tại."); return; } + if (slug) { + const normalizedSlug = slug.toLowerCase(); + const duplicatedSlug = entities.some((entity) => + (entity.slug || "").trim().toLowerCase() === normalizedSlug + ); + if (duplicatedSlug) { + setEntityFormStatus("Slug entity đã tồn tại."); + return; + } + } + + const entityId = buildClientEntityId(); + const pendingCreate: PendingEntityCreate = { + id: entityId, + name, + slug, + type_id: typeId, + status: 1, + }; setIsEntitySubmitting(true); setEntityFormStatus(null); try { - const created = await createEntity({ - name, - slug: entityForm.slug.trim() || null, - type_id: entityForm.type_id || DEFAULT_ENTITY_TYPE_ID, + setPendingEntityCreates((prev) => [pendingCreate, ...prev]); + setCreatedEntities((prev) => { + if (prev.some((item) => item.id === pendingCreate.id)) return prev; + return [ + { + id: pendingCreate.id, + name: pendingCreate.name, + type_id: pendingCreate.type_id || null, + }, + ...prev, + ]; }); - const rows = await reloadEntities(); - const nextEntityIds = uniqueEntityIds([ - ...selectedGeometryEntityIds, - created.id, - ]); - const nextGeometryType = resolveGeometryTypeFromEntityIds(nextEntityIds, rows) || selectedFeature.properties.type || null; + setEntityForm((prev) => ({ + ...prev, + name: "", + slug: "", + })); + setEntityStatus(null); + setEntityFormStatus("Đã thêm entity mới vào danh sách chờ Save."); - if (editor.hasPersistedFeature(selectedFeature.properties.id)) { - await updateGeometry(selectedFeature.properties.id, { - geometry: selectedFeature.geometry, - type: nextGeometryType ?? undefined, - time_start: timeStart, - time_end: timeEnd, - binding: bindingIds, - entity_ids: nextEntityIds, - }); - await reloadCurrentTimelineData(); - setEntityFormStatus("Đã tạo entity và gắn vào geometry."); - } else { - patchSelectedFeatureLocally( - selectedFeature, - nextEntityIds, - timeStart, - timeEnd, - bindingIds, - rows - ); - setEntityFormStatus("Đã tạo entity và gắn local. Bấm Save để lưu geometry mới."); - } - - resetSelectedGeometryInputsAfterCreate(); - } catch (err) { - if (err instanceof ApiError) { - setEntityFormStatus(`Tạo/gắn entity thất bại: ${err.body}`); - } else { - setEntityFormStatus("Tạo/gắn entity thất bại."); + if (selectedFeature) { + setEntitySearchQuery(pendingCreate.name); + setSelectedSearchEntityId(pendingCreate.id); } } finally { setIsEntitySubmitting(false); } }; - const timelineDisabled = !isTimelineReady || isSaving || editor.changeCount > 0; + const pendingSaveCount = editor.changeCount + pendingEntityCreates.length; + const timelineDisabled = !isTimelineReady || isSaving || pendingSaveCount > 0; const timelineStatusText = - editor.changeCount > 0 - ? "Lưu hoặc undo hết thay đổi trước khi đổi mốc thời gian." + pendingSaveCount > 0 + ? "Lưu hết thay đổi trước khi đổi mốc thời gian." : isSaving ? "Đang lưu thay đổi..." : timelineStatus; @@ -718,8 +871,10 @@ export default function Page() { onUndo={editor.undo} onSave={handleSave} isSaving={isSaving} - changesCount={editor.changeCount} + changesCount={pendingSaveCount} undoStack={editor.undoStack} + createdEntities={createdEntities} + createdGeometries={createdGeometries} />
@@ -785,8 +940,14 @@ 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} - onCreateEntityAndAttach={handleCreateEntityAndAttach} + onCreateEntityOnly={handleCreateEntityOnly} onApplyEntitiesForSelectedGeometry={handleApplyEntitiesForSelectedGeometry} changeCount={editor.changeCount} entityFormStatus={entityFormStatus} @@ -858,6 +1019,15 @@ function getDefaultTypeIdForFeature(feature: Feature): string { return DEFAULT_ENTITY_TYPE_ID; } +function getAllowedEntityTypeGroupIdsForFeature(feature: Feature): EntityTypeGroupId[] { + const defaultTypeId = getDefaultTypeIdForFeature(feature); + const defaultTypeOption = findEntityTypeOption(defaultTypeId); + if (defaultTypeOption) { + return [defaultTypeOption.groupId]; + } + return ["polygon"]; +} + function parseOptionalYearInput(raw: string, fieldName: string): number | null { const value = raw.trim(); if (!value.length) return null; @@ -934,6 +1104,64 @@ function formatEntityNamesForDisplay(feature: Feature, entities: Entity[]): stri return names.join(", "); } +function mergeEntitiesWithPending( + persistedEntities: Entity[], + pendingCreates: PendingEntityCreate[] +): Entity[] { + if (!pendingCreates.length) { + return persistedEntities; + } + + const seen = new Set(); + const pendingAsEntities: Entity[] = []; + for (const pending of pendingCreates) { + if (seen.has(pending.id)) continue; + seen.add(pending.id); + pendingAsEntities.push({ + id: pending.id, + name: pending.name, + slug: pending.slug, + type_id: pending.type_id, + status: pending.status, + geometry_count: 0, + created_at: undefined, + updated_at: undefined, + }); + } + + const nextPersisted = persistedEntities.filter((entity) => !seen.has(entity.id)); + return [...pendingAsEntities, ...nextPersisted]; +} + +function mergeEntitySearchResults( + remoteRows: Entity[], + localRows: Entity[] +): Entity[] { + const merged: Entity[] = []; + const seen = new Set(); + + for (const row of localRows) { + if (!row.id || seen.has(row.id)) continue; + seen.add(row.id); + merged.push(row); + } + + for (const row of remoteRows) { + if (!row.id || seen.has(row.id)) continue; + seen.add(row.id); + merged.push(row); + } + + return merged; +} + +function buildClientEntityId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `entity-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`; +} + function loadBackgroundLayerVisibilityFromStorage(): BackgroundLayerVisibility { if (typeof window === "undefined") { return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }; diff --git a/components/Editor.tsx b/components/Editor.tsx index e709d7e..f86ff5d 100644 --- a/components/Editor.tsx +++ b/components/Editor.tsx @@ -13,6 +13,17 @@ type Props = { isSaving: boolean; changesCount: number; undoStack: UndoAction[]; + createdEntities: Array<{ + id: string; + name: string; + type_id?: string | null; + }>; + createdGeometries: Array<{ + id: string | number; + geometryType: string; + semanticType?: string | null; + entityNames: string[]; + }>; }; export default function Editor({ @@ -24,6 +35,8 @@ export default function Editor({ isSaving, changesCount, undoStack, + createdEntities, + createdGeometries, }: Props) { const toggleMode = (newMode: Mode) => { if (mode === newMode) { @@ -61,6 +74,8 @@ export default function Editor({
)}
+ +
+
+ Mới tạo trong phiên +
+ +
+ Entities ({createdEntities.length}) +
+ {createdEntities.length === 0 ? ( +
+ Chưa tạo entity mới +
+ ) : ( +
    + {createdEntities.map((entity) => ( +
  • + {entity.name} ({entity.type_id || "country"}) +
  • + ))} +
+ )} + +
+ Geometries mới chưa lưu ({createdGeometries.length}) +
+ {createdGeometries.length === 0 ? ( +
+ Chưa có geometry mới chờ save +
+ ) : ( +
    + {createdGeometries.map((geometry) => ( +
  • + #{geometry.id} [{geometry.geometryType}] {geometry.semanticType ? `- ${geometry.semanticType}` : ""} + {geometry.entityNames.length ? ` | ${geometry.entityNames.join(", ")}` : ""} +
  • + ))} +
+ )} +
); } diff --git a/components/SelectedGeometryPanel.tsx b/components/SelectedGeometryPanel.tsx index 5c08d16..e3eca86 100644 --- a/components/SelectedGeometryPanel.tsx +++ b/components/SelectedGeometryPanel.tsx @@ -42,8 +42,17 @@ type Props = { entityTypeOptions: EntityTypeOption[]; geometryMetaForm: GeometryMetaFormState; onGeometryMetaFormChange: (key: keyof GeometryMetaFormState, value: string) => void; + bindingGeometrySearchQuery: string; + onBindingGeometrySearchQueryChange: (value: string) => void; + bindingGeometrySearchResults: Array<{ + id: string; + label: string; + }>; + selectedBindingGeometryId: string | null; + onSelectBindingGeometryId: (value: string | null) => void; + onAddSelectedBindingGeometry: () => void; isEntitySubmitting: boolean; - onCreateEntityAndAttach: () => void; + onCreateEntityOnly: () => void; onApplyEntitiesForSelectedGeometry: () => void; changeCount: number; entityFormStatus: string | null; @@ -68,8 +77,14 @@ export default function SelectedGeometryPanel({ entityTypeOptions, geometryMetaForm, onGeometryMetaFormChange, + bindingGeometrySearchQuery, + onBindingGeometrySearchQueryChange, + bindingGeometrySearchResults, + selectedBindingGeometryId, + onSelectBindingGeometryId, + onAddSelectedBindingGeometry, isEntitySubmitting, - onCreateEntityAndAttach, + onCreateEntityOnly, onApplyEntitiesForSelectedGeometry, changeCount, entityFormStatus, @@ -84,13 +99,13 @@ export default function SelectedGeometryPanel({ const visibleGroupedEntityTypeOptions = groupedEntityTypeOptions.filter((group) => allowedGroupIds.includes(group.id) ); + const groupedEntityTypeOptionsForCreate = selectedFeature + ? visibleGroupedEntityTypeOptions + : groupedEntityTypeOptions; const selectedTypeOption = findEntityTypeOption(entityForm.type_id); - const hasCurrentVisibleTypeOption = visibleGroupedEntityTypeOptions.some((group) => + const hasCurrentVisibleTypeOption = groupedEntityTypeOptionsForCreate.some((group) => group.options.some((option) => option.value === entityForm.type_id) ); - const isGeometryCompatible = selectedTypeOption - ? allowedGroupIds.includes(selectedTypeOption.groupId) - : true; return (
- Selected Geometry + Entity & Geometry
{!selectedFeature ? (
- Vào mode Select và chọn 1 geometry để điền entity. + Chưa chọn geometry. Tạo entity mới ở khối bên dưới, hoặc vào mode Select để bind entity cho geometry.
) : (
@@ -187,11 +202,10 @@ export default function SelectedGeometryPanel({ }} >
- Metadata geometry (áp dụng cho cả 2 luồng bên dưới) + Metadata geometry (chỉ áp dụng khi bind entity)
- `time_start`, `time_end`, `binding` sẽ được dùng cho cả luồng gắn entity có sẵn - và luồng tạo entity mới. + `time_start`, `time_end`, `binding` chỉ được áp dụng khi bấm nút bind entity cho geometry.
+ + onBindingGeometrySearchQueryChange(event.target.value) + } + placeholder="Search geometry để thêm vào binding..." + disabled={isEntitySubmitting} + style={entityInputStyle} + /> + +
- Luồng 1: Gắn entity có sẵn + Bind entity có sẵn
Dùng khi entity đã tồn tại. Tìm kiếm, thêm vào danh sách rồi bấm nút áp dụng. @@ -285,112 +331,112 @@ export default function SelectedGeometryPanel({
-
-
- Luồng 2: Tạo entity mới rồi gắn -
-
- Dùng khi chưa có entity phù hợp. Điền form bên dưới rồi bấm nút tạo + gắn. -
- - onEntityFormChange("name", event.target.value)} - placeholder="Tên entity mới" - disabled={isEntitySubmitting} - style={entityInputStyle} - /> - onEntityFormChange("slug", event.target.value)} - placeholder="Slug" - disabled={isEntitySubmitting} - style={entityInputStyle} - /> -
- Chọn loại entity -
- - - {selectedTypeOption ? ( -
- Type đang chọn: {selectedTypeOption.label} ({selectedTypeOption.groupLabel}) -
- ) : entityForm.type_id ? ( -
- Type đang chọn: {entityForm.type_id} -
- ) : null} - - {!isGeometryCompatible && selectedTypeOption ? ( -
- Type {selectedTypeOption.label} thuộc nhóm {selectedTypeOption.groupLabel}, nhưng geometry hiện tại là {formatGeometryPresetLabel(featureGeometryPreset)}. -
- ) : null} - - -
- {changeCount > 0 ? (
Geometry mới sẽ lưu entity khi bấm Save.
) : null} - - {entityFormStatus ? ( -
- {entityFormStatus} -
- ) : null}
)} + +
+
+ Tạo entity mới (độc lập) +
+
+ Chỉ tạo entity, không tự bind vào geometry. +
+ {selectedFeature ? ( +
+ Type đang bị giới hạn theo geometry: {formatGeometryPresetLabel(featureGeometryPreset)}. +
+ ) : null} + + onEntityFormChange("name", event.target.value)} + placeholder="Tên entity mới" + disabled={isEntitySubmitting} + style={entityInputStyle} + /> + onEntityFormChange("slug", event.target.value)} + placeholder="Slug" + disabled={isEntitySubmitting} + style={entityInputStyle} + /> +
+ Chọn loại entity +
+ + + {selectedTypeOption ? ( +
+ Type đang chọn: {selectedTypeOption.label} ({selectedTypeOption.groupLabel}) +
+ ) : entityForm.type_id ? ( +
+ Type đang chọn: {entityForm.type_id} +
+ ) : null} + + +
+ + {entityFormStatus ? ( +
+ {entityFormStatus} +
+ ) : null}
); }