pre updating version control
This commit is contained in:
@@ -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`,
|
||||
|
||||
@@ -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<CreateEntityPayload> & { id?: string };
|
||||
})
|
||||
| ({
|
||||
action: "update";
|
||||
id: string;
|
||||
} & Partial<CreateEntityPayload>);
|
||||
|
||||
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<Entity[]> {
|
||||
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<EntityBatchSaveResponse> {
|
||||
return requestJson<EntityBatchSaveResponse>(API_ENDPOINTS.entitiesBatch, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ changes }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<Batch
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveCombinedGeometryEntityBatchChanges(
|
||||
entityChanges: EntityBatchChange[],
|
||||
geometryChanges: Change[]
|
||||
): Promise<CombinedBatchSaveResponse> {
|
||||
return requestJson<CombinedBatchSaveResponse>(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<GeometryCreateResponse> {
|
||||
return requestJson<GeometryCreateResponse>(API_ENDPOINTS.geometries, {
|
||||
method: "POST",
|
||||
|
||||
@@ -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);
|
||||
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 };
|
||||
|
||||
@@ -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({
|
||||
<div
|
||||
style={{
|
||||
width: "220px",
|
||||
height: "100vh",
|
||||
overflowY: "auto",
|
||||
background: "#111",
|
||||
color: "white",
|
||||
padding: "12px",
|
||||
@@ -210,6 +225,70 @@ export default function Editor({
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: "16px",
|
||||
padding: "10px",
|
||||
background: "#0b1220",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #1f2937",
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: "8px", fontWeight: 600, fontSize: "14px" }}>
|
||||
Mới tạo trong phiên
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: "13px", color: "#cbd5e1", marginBottom: "6px" }}>
|
||||
Entities ({createdEntities.length})
|
||||
</div>
|
||||
{createdEntities.length === 0 ? (
|
||||
<div style={{ color: "#64748b", fontSize: "12px", marginBottom: "10px" }}>
|
||||
Chưa tạo entity mới
|
||||
</div>
|
||||
) : (
|
||||
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: "12px", marginBottom: "10px" }}>
|
||||
{createdEntities.map((entity) => (
|
||||
<li
|
||||
key={entity.id}
|
||||
style={{
|
||||
padding: "4px 0",
|
||||
borderBottom: "1px solid #1f2937",
|
||||
color: "#e2e8f0",
|
||||
}}
|
||||
title={entity.id}
|
||||
>
|
||||
{entity.name} ({entity.type_id || "country"})
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div style={{ fontSize: "13px", color: "#cbd5e1", marginBottom: "6px" }}>
|
||||
Geometries mới chưa lưu ({createdGeometries.length})
|
||||
</div>
|
||||
{createdGeometries.length === 0 ? (
|
||||
<div style={{ color: "#64748b", fontSize: "12px" }}>
|
||||
Chưa có geometry mới chờ save
|
||||
</div>
|
||||
) : (
|
||||
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: "12px" }}>
|
||||
{createdGeometries.map((geometry) => (
|
||||
<li
|
||||
key={String(geometry.id)}
|
||||
style={{
|
||||
padding: "4px 0",
|
||||
borderBottom: "1px solid #1f2937",
|
||||
color: "#e2e8f0",
|
||||
}}
|
||||
>
|
||||
#{geometry.id} [{geometry.geometryType}] {geometry.semanticType ? `- ${geometry.semanticType}` : ""}
|
||||
{geometry.entityNames.length ? ` | ${geometry.entityNames.join(", ")}` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
@@ -102,12 +117,12 @@ export default function SelectedGeometryPanel({
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 700, marginBottom: "8px", fontSize: "14px" }}>
|
||||
Selected Geometry
|
||||
Entity & Geometry
|
||||
</div>
|
||||
|
||||
{!selectedFeature ? (
|
||||
<div style={{ color: "#94a3b8", fontSize: "13px" }}>
|
||||
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.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "grid", gap: "8px", fontSize: "13px" }}>
|
||||
@@ -187,11 +202,10 @@ export default function SelectedGeometryPanel({
|
||||
}}
|
||||
>
|
||||
<div style={{ color: "#e2e8f0", fontWeight: 700, fontSize: "12px" }}>
|
||||
Metadata geometry (áp dụng cho cả 2 luồng bên dưới)
|
||||
Metadata geometry (chỉ áp dụng khi bind entity)
|
||||
</div>
|
||||
<div style={{ color: "#94a3b8", fontSize: "11px" }}>
|
||||
`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.
|
||||
</div>
|
||||
<input
|
||||
value={geometryMetaForm.time_start}
|
||||
@@ -214,6 +228,38 @@ export default function SelectedGeometryPanel({
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
<input
|
||||
value={bindingGeometrySearchQuery}
|
||||
onChange={(event) =>
|
||||
onBindingGeometrySearchQueryChange(event.target.value)
|
||||
}
|
||||
placeholder="Search geometry để thêm vào binding..."
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
<select
|
||||
value={selectedBindingGeometryId || ""}
|
||||
onChange={(event) =>
|
||||
onSelectBindingGeometryId(event.target.value ? event.target.value : null)
|
||||
}
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
>
|
||||
<option value="">-- Chọn geometry từ kết quả search binding --</option>
|
||||
{bindingGeometrySearchResults.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddSelectedBindingGeometry}
|
||||
disabled={isEntitySubmitting}
|
||||
style={secondaryActionButtonStyle}
|
||||
>
|
||||
Thêm geometry đã chọn vào binding
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -227,7 +273,7 @@ export default function SelectedGeometryPanel({
|
||||
}}
|
||||
>
|
||||
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
|
||||
Luồng 1: Gắn entity có sẵn
|
||||
Bind entity có sẵn
|
||||
</div>
|
||||
<div style={{ color: "#93c5fd", fontSize: "11px" }}>
|
||||
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,6 +331,14 @@ export default function SelectedGeometryPanel({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{changeCount > 0 ? (
|
||||
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
|
||||
Geometry mới sẽ lưu entity khi bấm Save.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
@@ -293,14 +347,20 @@ export default function SelectedGeometryPanel({
|
||||
borderRadius: "8px",
|
||||
padding: "8px",
|
||||
background: "#0f172a",
|
||||
marginTop: "10px",
|
||||
}}
|
||||
>
|
||||
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
|
||||
Luồng 2: Tạo entity mới rồi gắn
|
||||
Tạo entity mới (độc lập)
|
||||
</div>
|
||||
<div style={{ color: "#93c5fd", fontSize: "11px" }}>
|
||||
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.
|
||||
Chỉ tạo entity, không tự bind vào geometry.
|
||||
</div>
|
||||
{selectedFeature ? (
|
||||
<div style={{ color: "#93c5fd", fontSize: "11px" }}>
|
||||
Type đang bị giới hạn theo geometry: <b>{formatGeometryPresetLabel(featureGeometryPreset)}</b>.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<input
|
||||
value={entityForm.name}
|
||||
@@ -325,12 +385,12 @@ export default function SelectedGeometryPanel({
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
>
|
||||
{!hasCurrentVisibleTypeOption && entityForm.type_id ? (
|
||||
{!selectedFeature && !hasCurrentVisibleTypeOption && entityForm.type_id ? (
|
||||
<option value={entityForm.type_id}>
|
||||
Custom Type ({entityForm.type_id})
|
||||
</option>
|
||||
) : null}
|
||||
{visibleGroupedEntityTypeOptions.map((group) => (
|
||||
{groupedEntityTypeOptionsForCreate.map((group) => (
|
||||
<optgroup
|
||||
key={group.id}
|
||||
label={`${group.label} (${group.geometryLabel})`}
|
||||
@@ -354,14 +414,8 @@ export default function SelectedGeometryPanel({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isGeometryCompatible && selectedTypeOption ? (
|
||||
<div style={{ color: "#fbbf24", fontSize: "12px" }}>
|
||||
Type <b>{selectedTypeOption.label}</b> thuộc nhóm <b>{selectedTypeOption.groupLabel}</b>, nhưng geometry hiện tại là <b>{formatGeometryPresetLabel(featureGeometryPreset)}</b>.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
onClick={onCreateEntityAndAttach}
|
||||
onClick={onCreateEntityOnly}
|
||||
disabled={isEntitySubmitting}
|
||||
style={{
|
||||
border: "none",
|
||||
@@ -374,24 +428,16 @@ export default function SelectedGeometryPanel({
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Tạo entity mới + gắn + áp metadata
|
||||
Tạo entity mới
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{changeCount > 0 ? (
|
||||
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
|
||||
Geometry mới sẽ lưu entity khi bấm Save.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{entityFormStatus ? (
|
||||
<div style={{ color: "#93c5fd", fontSize: "12px" }}>
|
||||
<div style={{ color: "#93c5fd", fontSize: "12px", marginTop: "8px" }}>
|
||||
{entityFormStatus}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user