pre updating version control
This commit is contained in:
@@ -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<FeatureCollection>(EMPTY_FC);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [entities, setEntities] = useState<Entity[]>([]);
|
||||
const [persistedEntities, setPersistedEntities] = useState<Entity[]>([]);
|
||||
const [pendingEntityCreates, setPendingEntityCreates] = useState<PendingEntityCreate[]>([]);
|
||||
const [createdEntities, setCreatedEntities] = useState<Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type_id?: string | null;
|
||||
}>>([]);
|
||||
const [entityStatus, setEntityStatus] = useState<string | null>(null);
|
||||
const [selectedFeatureId, setSelectedFeatureId] = useState<string | number | null>(null);
|
||||
const [entityForm, setEntityForm] = useState<EntityFormState>({
|
||||
@@ -79,6 +103,9 @@ export default function Page() {
|
||||
const [entitySearchResults, setEntitySearchResults] = useState<Entity[]>([]);
|
||||
const [selectedSearchEntityId, setSelectedSearchEntityId] = useState<string | null>(null);
|
||||
const [isEntitySearchLoading, setIsEntitySearchLoading] = useState(false);
|
||||
const [bindingGeometrySearchQuery, setBindingGeometrySearchQuery] = useState("");
|
||||
const [bindingGeometrySearchResults, setBindingGeometrySearchResults] = useState<BindingGeometrySearchOption[]>([]);
|
||||
const [selectedBindingGeometryId, setSelectedBindingGeometryId] = useState<string | null>(null);
|
||||
const [timelineRange, setTimelineRange] = useState<TimelineRange>(FALLBACK_TIMELINE_RANGE);
|
||||
const [timelineWindowStart, setTimelineWindowStart] = useState<number>(FALLBACK_TIMELINE_RANGE.min);
|
||||
const [timelineWindowEnd, setTimelineWindowEnd] = useState<number>(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>((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>((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}
|
||||
/>
|
||||
|
||||
<div style={{ flex: 1, position: "relative", minHeight: "100vh" }}>
|
||||
@@ -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<string>();
|
||||
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<string>();
|
||||
|
||||
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 };
|
||||
|
||||
Reference in New Issue
Block a user