From 8b1df737971d1ed4ab1c9a1934490a15c45d7b35 Mon Sep 17 00:00:00 2001 From: taDuc Date: Thu, 7 May 2026 13:38:52 +0700 Subject: [PATCH] refactor state storge, UI editor --- EDITOR_LOCAL_STORAGE.md | 312 +++++ commit_snapshot.md | 49 +- next.config.ts | 11 +- package-lock.json | 293 +--- package.json | 3 +- scripts/dev.mjs | 26 + src/app/editor/[id]/featureCommands.ts | 20 +- src/app/editor/[id]/page.tsx | 1217 +++++++++++++---- src/app/page.tsx | 185 ++- src/app/user/submissions/[id]/page.tsx | 330 ----- src/app/user/submissions/page.tsx | 326 ----- src/components/auth/SignInForm.tsx | 8 +- src/components/auth/SignUpForm.tsx | 12 +- src/interface/project.ts | 3 + src/interface/submission.ts | 50 - src/service/submissionService.ts | 37 - src/uhm/api/geometries.ts | 47 +- src/uhm/api/http.ts | 14 +- src/uhm/api/sections.ts | 68 +- src/uhm/components/AuthPanel.tsx | 2 +- src/uhm/components/BackgroundLayersPanel.tsx | 83 +- src/uhm/components/CommitTreePopup.tsx | 349 ----- src/uhm/components/Editor.tsx | 721 +++++----- .../components/EntityWikiBindingsPanel.tsx | 176 ++- src/uhm/components/Map.tsx | 40 +- src/uhm/components/ProjectEntityRefsPanel.tsx | 356 +++-- src/uhm/components/SelectedGeometryPanel.tsx | 429 ++---- src/uhm/components/TimelineBar.tsx | 105 +- src/uhm/components/UnifiedSearchBar.tsx | 86 ++ src/uhm/components/WikiSidebarPanel.tsx | 533 +++----- src/uhm/lib/editor/entity/entityBinding.ts | 47 +- .../lib/editor/geometry/geometryMetadata.ts | 4 +- .../lib/editor/section/useSectionCommands.ts | 190 ++- src/uhm/lib/editor/session/sessionTypes.ts | 9 +- .../editor/session/useEntitySessionState.ts | 31 +- .../editor/session/useSectionSessionState.ts | 8 +- .../lib/editor/session/useWikiSessionState.ts | 6 +- src/uhm/lib/editor/snapshot/editorSnapshot.ts | 175 ++- src/uhm/lib/geoTypeMap.json | 33 + src/uhm/lib/geoTypeMap.ts | 34 + src/uhm/lib/id.ts | 2 +- src/uhm/lib/map/constants.ts | 2 +- src/uhm/lib/useEditorSessionState.ts | 2 - src/uhm/types/entities.ts | 4 +- src/uhm/types/sections.ts | 14 +- src/uhm/types/wiki.ts | 5 +- 46 files changed, 3345 insertions(+), 3112 deletions(-) create mode 100644 EDITOR_LOCAL_STORAGE.md create mode 100644 scripts/dev.mjs delete mode 100644 src/app/user/submissions/[id]/page.tsx delete mode 100644 src/app/user/submissions/page.tsx delete mode 100644 src/interface/submission.ts delete mode 100644 src/service/submissionService.ts delete mode 100644 src/uhm/components/CommitTreePopup.tsx create mode 100644 src/uhm/components/UnifiedSearchBar.tsx create mode 100644 src/uhm/lib/geoTypeMap.json create mode 100644 src/uhm/lib/geoTypeMap.ts diff --git a/EDITOR_LOCAL_STORAGE.md b/EDITOR_LOCAL_STORAGE.md new file mode 100644 index 0000000..c54eb00 --- /dev/null +++ b/EDITOR_LOCAL_STORAGE.md @@ -0,0 +1,312 @@ +# Editor (/editor) - Local Store & Snapshot Conversion + +Tài liệu này mô tả chi tiết **các nơi lưu trữ state (store) ở phía FrontEndUser** trong `/editor/[id]`, ý nghĩa từng biến state, state nào là “single source of truth”, state nào chỉ là cache/UI, và cách chuyển đổi qua lại giữa: + +1. **Local session state** (React state trong phiên làm việc) +2. **Commit snapshot** (`commits.snapshot_json`) +3. **Reload trang** (mất state local, load lại từ commit snapshot) + +Mục tiêu: dễ debug, nhất quán dữ liệu, tránh sai semantics `"reference"`/`"binding"`. + +--- + +## 0) 5 Dataset Quan Trọng Nhất (GEO/ENT/WIKI/ENT_WIKI/GEO_ENT) + +Trong `/editor`, 5 nhóm dữ liệu quan trọng nhất tương ứng trực tiếp với snapshot: + +1. **GEO**: `snapshot_json.geometries[]` + `snapshot_json.editor_feature_collection` +2. **ENT**: `snapshot_json.entities[]` +3. **WIKI**: `snapshot_json.wikis[]` +4. **ENT_WIKI** (entity ↔ wiki): `snapshot_json.entity_wiki[]` +5. **GEO_ENT** (geometry ↔ entity): `snapshot_json.geometry_entity[]` + +Điểm quan trọng về “store”: + +- **ENT/WIKI/ENT_WIKI** có store snapshot riêng trong React session: + - `snapshotEntities` -> `entities[]` + - `snapshotWikis` -> `wikis[]` + - `snapshotEntityWikiLinks` -> `entity_wiki[]` + +- **GEO/GEO_ENT không có store snapshot riêng theo kiểu `snapshotGeometries` / `snapshotGeometryEntity`**. + - Trong session, GEO sống ở **`editor.draft`** (GeoJSON FeatureCollection). + - Khi commit, FE **build ra**: + - `geometries[]` từ `editor.draft + editor.changes + baselineSnapshot.geometries` + - `geometry_entity[]` từ `editor.draft.features[].properties.entity_ids` + +Vì vậy, nếu bạn “tìm store của geo trong React state” thì bạn sẽ thấy nó nằm ở `useEditorState()` chứ không nằm trong `useEditorSessionState()`. + +--- + +## 1) Nguyên tắc chung + +### 1.1 Single source of truth theo lớp + +- **Geometry (map/editor):** `useEditorState(initialData)` là state trung tâm cho `draft/changes/undo`. +- **Snapshot stores (phần sẽ đi vào commit snapshot):** + - `snapshotEntities` -> `snapshot_json.entities` + - `snapshotWikis` -> `snapshot_json.wikis` + - `snapshotEntityWikiLinks` -> `snapshot_json.entity_wiki` +- **Catalog/cache để tìm kiếm & hiển thị:** + - `entityCatalog` là danh sách entity “global” trong RAM (fetch + search merge). Không phải snapshot. + +### 1.2 “reference” vs “binding” + +- `"reference"` (entities/wikis/geometries.operation) nghĩa là **không sửa record** trong commit đó. +- `"binding"` (chỉ áp dụng cho `entity_wiki.operation`) nghĩa là **link entity ↔ wiki đang tồn tại** trong snapshot. +- `"delete"` nghĩa là xóa record (entities/wikis/geometries) hoặc unlink (entity_wiki). + +Khi **mở 1 phiên editor mới từ commit**, mọi operation local đều bị “reset về baseline”: + +- `entities[].operation` và `wikis[].operation` trong session -> `"reference"` +- `entity_wiki[].operation` trong session -> `"binding"` (nếu link còn active) + +--- + +## 2) Local state: danh sách đầy đủ và ý nghĩa + +Các state này được tạo từ `useEditorSessionState()` và `useEditorState()` trong: + +- `FrontEndUser/src/app/editor/[id]/page.tsx` +- `FrontEndUser/src/uhm/lib/useEditorSessionState.ts` +- `FrontEndUser/src/uhm/lib/useEditorState.ts` + +### 2.1 Geometry editor state (core) + +Nguồn: `const editor = useEditorState(initialData)` + +- `initialData: FeatureCollection` + - Là **baseline** của session hiện tại để render Map ban đầu. + - Được set khi: + - mở project (load snapshot head), + - restore FE-only từ 1 commit, + - hoặc import/replace dữ liệu session. + +- `editor.draft: FeatureCollection` + - **Single source of truth** cho geometry đang hiển thị + chỉnh sửa. + - Map render trực tiếp từ `draft` (hoặc bản “visibleDraft” đã filter theo timeline/binding). + - Đây chính là **store runtime của GEO** trong session. + +- `editor.changes: Map` + - Diff giữa `draft` và baseline map nội bộ (initialMapRef). + - Dùng để tính `pendingSaveCount` và để build snapshot geometries/update/delete. + +- `editor.undoStack` + - Danh sách thao tác gần nhất (create/update/properties/delete). + +- `editor.changeCount` + - Số lượng changes (để chặn commit khi không đổi gì). + +- `editor.hasPersistedFeature(id)` + - `true` nếu feature đã tồn tại trong baseline map nội bộ. + - Dùng cho timeline filter: feature mới tạo trong session vẫn luôn visible. + +### 2.2 Snapshot stores (persisted on commit) + +Các state này là “source of truth” cho những phần non-geometry trong commit snapshot. + +#### a) `snapshotEntities: EntitySnapshot[]` + +- Dùng để build `snapshot_json.entities`. +- Bao gồm: + - entity “pin” vào project (`source:"ref"`, `operation:"reference"`), + - entity tạo mới local (`source:"inline"`, `operation:"create"`), + - entity bị xóa (nếu có) (`operation:"delete"`). + +Lưu ý quan trọng: + +- `snapshotEntities` là nơi “giữ entity” **qua các commit**, kể cả entity tạo mới chưa bind geometry. +- `buildEditorSnapshot()` có logic carry-forward inline entity từ `previousSnapshot` để tránh mất entity sau commit/reload. + +#### b) `snapshotWikis: WikiSnapshot[]` + +- Dùng để build `snapshot_json.wikis`. +- Wiki hiện lưu `doc` là **string (HTML)** (Quill) hoặc `null` với ref wiki. +- Tiptap JSON cũ: được normalize sang HTML để hiển thị. + +#### c) `snapshotEntityWikiLinks: EntityWikiLinkSnapshot[]` + +- Dùng để build `snapshot_json.entity_wiki`. +- `operation`: + - `"binding"`: link đang tồn tại + - `"delete"`: unlink trong snapshot + - (compat) `"reference"` từ snapshot cũ được normalize thành `"binding"` khi load. + +### 2.3 Catalog/cache state (không persist) + +#### `entityCatalog: Entity[]` + +Đây là **RAM cache** để: + +- hiển thị tên/description/status của entity, +- merge kết quả fetch + search, +- giảm tình trạng UI “cùng 1 entity nhưng 2 object khác nhau”. + +Không ghi thẳng vào snapshot. Snapshot vẫn lấy từ `snapshotEntities`. + +Trong page, danh sách `entities` dùng cho UI được merge: + +`entities = mergeEntitySearchResults(entityCatalog, snapshotEntitiesAsEntities)` + +Nghĩa là: snapshot entities (local) luôn được ưu tiên hiển thị trong UI. + +### 2.4 UI-only state (không persist) + +Các state sau chỉ phục vụ UX, mất khi reload: + +- `mode` (idle/select/add-*) +- `selectedFeatureId` +- `selectedGeometryEntityIds` (list bind tạm thời cho UI, map patch sẽ sync vào feature properties) +- `geometryMetaForm` +- `entityForm` (tạo entity mới) +- `entityFormStatus` (toast/status 3s) +- `searchKind`, `searchQuery` +- `entitySearchResults`, `wikiSearchResults`, `geoSearchResults` +- `timelineDraftYear`, `timelineFilterEnabled` +- panel widths (`leftPanelWidth`, `rightPanelWidth`) + +### 2.5 LocalStorage (trên browser) + +Hiện tại chỉ có **1 thứ** persist sang LocalStorage: + +- `backgroundVisibility` (ẩn/hiện layer nền) + +Các snapshot stores (`snapshotEntities`, `snapshotWikis`, `snapshotEntityWikiLinks`, `draft`) **không** lưu LocalStorage; chúng được persist qua commit snapshot (backend). + +--- + +## 3) Chuyển đổi giữa local session ↔ snapshot + +### 3.1 Load snapshot -> mở session + +Luồng: `openSectionEditor()` -> `normalizeEditorSnapshot()` -> `toEditorSessionSnapshot()` + +Khi mở session mới: + +1. `baselineSnapshot = toEditorSessionSnapshot(snapshot)` +2. `initialData = baselineSnapshot.editor_feature_collection || EMPTY_FEATURE_COLLECTION` +3. `snapshotEntities = baselineSnapshot.entities || []` +4. `snapshotWikis = baselineSnapshot.wikis || []` +5. `snapshotEntityWikiLinks = baselineSnapshot.entity_wiki || []` + +Riêng về GEO/GEO_ENT khi load: + +- `baselineSnapshot.editor_feature_collection` là dữ liệu map gốc đưa vào `initialData`. +- `normalizeEditorSnapshot()` sẽ **rehydrate** `feature.properties.entity_ids/entity_id` từ `snapshot.geometry_entity[]` (hoặc legacy `link_scopes`) để UI bind entity hoạt động. + - Lưu ý: đây là rehydrate phục vụ editor UX, **không phải** dữ liệu persist chính thức trên `feature.properties` trong snapshot. + +Điểm mấu chốt: **toEditorSessionSnapshot() reset operation** để snapshot trở thành “baseline state”: + +- entities/wikis -> `"reference"` +- entity_wiki active -> `"binding"` + +### 3.2 Commit session -> snapshot_json + +Luồng: `commitSection()` -> `buildEditorSnapshot({ draft, changes, snapshotEntities, snapshotWikis, snapshotEntityWikiLinks, previousSnapshot: baselineSnapshot })` + +`buildEditorSnapshot()` sẽ tạo: + +- `editor_feature_collection` (draft đã strip các field denormalized) +- `geometries[]` (create/update/delete dựa trên changes + previousSnapshot) +- `geometry_entity[]` (join table từ feature.properties.entity_ids) +- `entities[]` (từ snapshotEntities + carry-forward inline + ensure entities referenced by joins) +- `wikis[]` (từ snapshotWikis, tương tự) +- `entity_wiki[]` (từ snapshotEntityWikiLinks, đã dedupe/sort) + +Sau khi commit thành công: + +- `baselineSnapshot` cập nhật = `toEditorSessionSnapshot(snapshot)` của commit mới +- snapshot stores cập nhật theo baseline mới (operation reset về `"reference"/"binding"`) + +### 3.3 Reload trang -> mất local state + +Khi reload: + +- Toàn bộ React state reset +- App sẽ load lại snapshot từ backend (head commit) +- Các thứ bạn “tạo/sửa” chỉ còn lại nếu đã nằm trong commit snapshot + +Vì vậy: + +- Entity/Wiki/Link/Geometry muốn “không mất” phải đi qua **Commit**. +- Các state UI (selected geo, search results, form đang nhập) sẽ mất. + +--- + +## 4) GEO Search (`/geometries/entity`) và tác động lên local store + +Search GEO gọi: + +`GET /geometries/entity?name=&limit=` + +Khi bấm **Import** một geometry từ kết quả search: + +1. Tắt `timelineFilterEnabled` để geometry luôn nhìn thấy (không bị filter theo năm). +2. Add entity tương ứng vào: + - `snapshotEntities` (source:"ref", operation:"reference") + - `entityCatalog` (để UI có name/description) +3. Nếu geometry chưa có trong `editor.draft`: + - tạo `Feature` mới với `id = geometry.id` + - set `properties.type` từ `geo_type` (map qua `geoTypeCodeToTypeKey`) + - set `time_start/time_end/binding` + - set denormalized `entity_id/entity_ids/entity_name/entity_names` để UI/joins hoạt động +4. `editor.createFeature(feature)` và auto select feature đó. + +Lưu ý: Import geo tạo ra “create change” trong editor session, nên sẽ đi vào commit snapshot. + +--- + +## 4.1 Nhìn nhanh “5 dataset nằm ở đâu” trong session + +- GEO: + - Runtime store: `editor.draft.features[]` + - Persisted on commit: `snapshot_json.geometries[]` (build khi commit) + +- ENT: + - Runtime store (snapshot): `snapshotEntities` + - Persisted on commit: `snapshot_json.entities[]` + +- WIKI: + - Runtime store (snapshot): `snapshotWikis` + - Persisted on commit: `snapshot_json.wikis[]` + +- ENT_WIKI: + - Runtime store (snapshot): `snapshotEntityWikiLinks` + - Persisted on commit: `snapshot_json.entity_wiki[]` + +- GEO_ENT: + - Runtime store: denormalized tạm thời trên `editor.draft.features[].properties.entity_ids` (để UI chạy) + - Persisted on commit: `snapshot_json.geometry_entity[]` (build khi commit) + +--- + +## 5) Checklist khi debug “mất dữ liệu” + +1. Dữ liệu có nằm trong `snapshotEntities/snapshotWikis/snapshotEntityWikiLinks/editor.draft` không? +2. Có bấm **Commit** chưa? +3. `pendingSaveCount` có > 0 không (Commit button có enable không)? +4. Khi reload, snapshot head commit load lên có chứa các rows đó không? +5. Nếu entity tạo mới bị mất: + - kiểm tra commit snapshot có `entities[].source:"inline"` không + - nếu có mà reload vẫn mất, kiểm tra `normalizeEditorSnapshot()` có parse đúng không + +--- + +## 6) File/entrypoints liên quan + +- Session stores: + - `FrontEndUser/src/uhm/lib/useEditorSessionState.ts` + - `FrontEndUser/src/uhm/lib/editor/session/useEntitySessionState.ts` + - `FrontEndUser/src/uhm/lib/editor/session/useWikiSessionState.ts` + - `FrontEndUser/src/uhm/lib/editor/session/useSectionSessionState.ts` + +- Geometry editor core: + - `FrontEndUser/src/uhm/lib/useEditorState.ts` + +- Snapshot normalization + build snapshot: + - `FrontEndUser/src/uhm/lib/editor/snapshot/editorSnapshot.ts` + +- Open/commit/restore commands: + - `FrontEndUser/src/uhm/lib/editor/section/useSectionCommands.ts` + +- Page wiring / UI state: + - `FrontEndUser/src/app/editor/[id]/page.tsx` diff --git a/commit_snapshot.md b/commit_snapshot.md index ce7a91d..0178bfd 100644 --- a/commit_snapshot.md +++ b/commit_snapshot.md @@ -88,7 +88,6 @@ export type EntitySnapshot = { name?: string; slug?: string | null; description?: string | null; - type_id?: string | null; status?: number | null; base_updated_at?: string; base_hash?: string; @@ -178,7 +177,7 @@ FE hiện tại luôn ghi `source` cho `entities[]`, `geometries[]`, `wikis[]`. `geometry_entity[]` không có `operation` (join table state). -`entity_wiki[]` dùng `operation:"reference"|"delete"` để biểu diễn link/unlink **trong snapshot** (không phải delete trong DB). +`entity_wiki[]` dùng `operation:"binding"|"delete"` để biểu diễn link/unlink **trong snapshot** (không phải delete trong DB). ## 3) Ý Nghĩa Từng Phần @@ -254,8 +253,8 @@ Join table many-to-many giữa geometry và entity. Mỗi cặp geometry↔entit Danh sách wiki của project tại thời điểm commit: -- Wiki tạo mới: `source:"inline"`, `operation:"create"`, `doc` là tiptap JSON. -- Wiki sửa: `source:"inline"`, `operation:"update"`, `doc` là tiptap JSON. +- Wiki tạo mới: `source:"inline"`, `operation:"create"`, `doc` là HTML string (Quill). +- Wiki sửa: `source:"inline"`, `operation:"update"`, `doc` là HTML string (Quill). - Wiki không đổi: thường không có `operation`. - Wiki add từ search (wiki đã có trong DB): `source:"ref"`, `operation:"reference"`, `doc` có thể là `null`. @@ -265,13 +264,17 @@ Danh sách wiki của project tại thời điểm commit: export type EntityWikiLinkSnapshot = { entity_id: string; wiki_id: string; - operation?: "reference" | "delete"; + // New semantics: + // - binding: link active + // - delete: link removed in this snapshot + // Backwards-compat: older snapshots may use "reference" meaning link active. + operation?: "binding" | "delete" | "reference"; }; ``` Toggle link trong UI: -- Tick checkbox: `{ operation: "reference" }` +- Toggle ON (bind): `{ operation: "binding" }` (or legacy `"reference"`) - Untick checkbox: `{ operation: "delete" }` ## 4) Ví Dụ JSON (rút gọn) @@ -296,7 +299,7 @@ Toggle link trong UI: }, "entities": [ { "id": "e_2", "source": "ref", "name": "Pinned Entity" }, - { "id": "e_1", "source": "ref", "operation": "reference", "name": "Ha Noi", "type_id": "city", "status": 1 } + { "id": "e_1", "source": "ref", "operation": "reference", "name": "Ha Noi", "status": 1 } ], "geometries": [ { @@ -314,14 +317,14 @@ Toggle link trong UI: "geometry_entity": [ { "geometry_id": "g_1", "entity_id": "e_1" } ], - "wikis": [ - { - "id": "w_inline_1", - "source": "inline", - "operation": "create", - "title": "Overview", - "doc": { "type": "doc", "content": [{ "type": "paragraph" }] } - }, + "wikis": [ + { + "id": "w_inline_1", + "source": "inline", + "operation": "create", + "title": "Overview", + "doc": "

Overview

" + }, { "id": "019d...wiki_from_db", "source": "ref", @@ -330,11 +333,11 @@ Toggle link trong UI: "doc": null } ], - "entity_wiki": [ - { "entity_id": "e_1", "wiki_id": "w_inline_1", "operation": "reference" } - ] -} -``` + "entity_wiki": [ + { "entity_id": "e_1", "wiki_id": "w_inline_1", "operation": "binding" } + ] + } + ``` ## 5) Notes Cho BackEnd (Normalize + Compat) @@ -342,9 +345,9 @@ BE nên normalize trước khi convert snapshot → DB: - Ignore toàn bộ field entity denormalize trên `feature.properties` (nếu có): `entity_id/entity_ids/entity_name/entity_names/entity_type_id`. Quan hệ geometry↔entity lấy từ `geometry_entity[]`. - `entity_wiki[].operation`: - - `"reference"`: link active + - `"binding"` (or legacy `"reference"`): link active - `"delete"`: link removed trong snapshot - - missing: treat as `"reference"` (compat) + - missing: treat as `"binding"` (compat) ## 6) Legacy Compatibility (nếu gặp snapshot cũ) @@ -352,4 +355,4 @@ FE đã từng gửi các field legacy; BE có thể gặp nếu đang xử lý - `entity_wikis` (plural) thay vì `entity_wiki` (singular): treat như nhau. - `ref:{id}` trong `entities/geometries/wikis`: ignore (id canonical). -- `is_deleted` trong join table entity↔wiki: map sang `operation:"delete"` khi `is_deleted==1`, ngược lại `"reference"`. +- `is_deleted` trong join table entity↔wiki: map sang `operation:"delete"` khi `is_deleted==1`, ngược lại `"binding"` (or legacy `"reference"`). diff --git a/next.config.ts b/next.config.ts index 532af9f..916db6c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,4 +1,12 @@ import type { NextConfig } from "next"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +// Turbopack uses its "root" directory for module resolution. In a repo that +// contains multiple projects (without a package.json at the repo root), +// Turbopack can accidentally pick the repo root and then fail to resolve +// dependencies like `tailwindcss`. Force Turbopack root to this app directory. +const turbopackRoot = path.dirname(fileURLToPath(import.meta.url)); const nextConfig: NextConfig = { /* config options here */ @@ -28,6 +36,7 @@ const nextConfig: NextConfig = { }, turbopack: { + root: turbopackRoot, rules: { '*.svg': { loaders: ['@svgr/webpack'], @@ -37,4 +46,4 @@ const nextConfig: NextConfig = { }, }; -export default nextConfig; \ No newline at end of file +export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 31751da..883a642 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,6 @@ "next": "^16.1.6", "react": "^19.2.0", "react-apexcharts": "^1.8.0", - "react-d3-tree": "^3.6.6", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "^19.2.0", @@ -1772,28 +1771,11 @@ "node": ">=6.9.0" } }, - "node_modules/@bkrem/react-transition-group": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@bkrem/react-transition-group/-/react-transition-group-1.3.5.tgz", - "integrity": "sha512-lbBYhC42sxAeFEopxzd9oWdkkV0zirO5E9WyeOBxOrpXsf7m30Aj8vnbayZxFOwD9pvUQ2Pheb1gO79s0Qap3Q==", - "license": "BSD-3-Clause", - "dependencies": { - "chain-function": "^1.0.0", - "dom-helpers": "^3.3.1", - "loose-envify": "^1.3.1", - "prop-types": "^15.5.6", - "react-lifecycles-compat": "^3.0.4", - "warning": "^3.0.0" - }, - "peerDependencies": { - "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/@emnapi/core": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1815,6 +1797,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2697,6 +2680,7 @@ "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -3577,6 +3561,64 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.8.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.8.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", @@ -4030,18 +4072,13 @@ "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { "tslib": "^2.4.0" } }, - "node_modules/@types/d3-hierarchy": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-1.1.11.tgz", - "integrity": "sha512-lnQiU7jV+Gyk9oQYk0GGYccuexmQPTp08E0+4BidgFdiJivjEvf+esPSdZqCZ2C7UwTWejWpqetVaU8A+eX3FA==", - "license": "MIT" - }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -5301,12 +5338,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/chain-function": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/chain-function/-/chain-function-1.0.1.tgz", - "integrity": "sha512-SxltgMwL9uCko5/ZCLiyG2B7R9fY4pDZUw7hJ4MhirdjBLosoDqkWABi3XMucddHdLiFJMb7PD2MZifZriuMTg==", - "license": "MIT" - }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -5336,15 +5367,6 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, - "node_modules/clone": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", - "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -5550,133 +5572,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-dispatch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", - "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-drag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", - "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-selection": "3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-hierarchy": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz", - "integrity": "sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-path": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", - "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-selection": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", - "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", - "license": "ISC", - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-shape": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", - "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-path": "1" - } - }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-transition": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", - "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3", - "d3-dispatch": "1 - 3", - "d3-ease": "1 - 3", - "d3-interpolate": "1 - 3", - "d3-timer": "1 - 3" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "d3-selection": "2 - 3" - } - }, - "node_modules/d3-zoom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", - "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-drag": "2 - 3", - "d3-interpolate": "1 - 3", - "d3-selection": "2 - 3", - "d3-transition": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -5818,15 +5713,6 @@ "node": ">=0.4.0" } }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -5869,15 +5755,6 @@ "node": ">=0.10.0" } }, - "node_modules/dom-helpers": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz", - "integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.1.2" - } - }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -9241,37 +9118,6 @@ "react": ">=16.8.0" } }, - "node_modules/react-d3-tree": { - "version": "3.6.6", - "resolved": "https://registry.npmjs.org/react-d3-tree/-/react-d3-tree-3.6.6.tgz", - "integrity": "sha512-E9ByUdeqvlxLlF9BSL7KWQH3ikYHtHO+g1rAPcVgj6mu92tjRUCan2AWxoD4eTSzzAATf8BZtf+CXGSoSd6ioQ==", - "license": "MIT", - "dependencies": { - "@bkrem/react-transition-group": "^1.3.5", - "@types/d3-hierarchy": "^1.1.8", - "clone": "^2.1.1", - "d3-hierarchy": "^1.1.9", - "d3-selection": "^3.0.0", - "d3-shape": "^1.3.7", - "d3-zoom": "^3.0.0", - "dequal": "^2.0.2", - "uuid": "^8.3.1" - }, - "peerDependencies": { - "react": "16.x || 17.x || 18.x || 19.x", - "react-dom": "16.x || 17.x || 18.x || 19.x" - } - }, - "node_modules/react-d3-tree/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/react-dnd": { "version": "16.0.1", "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz", @@ -9347,12 +9193,6 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, - "node_modules/react-lifecycles-compat": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", - "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", - "license": "MIT" - }, "node_modules/react-quill-new": { "version": "3.8.3", "resolved": "https://registry.npmjs.org/react-quill-new/-/react-quill-new-3.8.3.tgz", @@ -10668,15 +10508,6 @@ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, - "node_modules/warning": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/warning/-/warning-3.0.0.tgz", - "integrity": "sha512-jMBt6pUrKn5I+OGgtQ4YZLdhIeJmObddh6CsibPxyQ5yPZm1XExSyzC1LCNX7BzhxWgiHmizBWJTHJIjMjTQYQ==", - "license": "BSD-3-Clause", - "dependencies": { - "loose-envify": "^1.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index ec109c7..a6314bc 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "2.2.3", "private": true, "scripts": { - "dev": "next dev", + "dev": "node ./scripts/dev.mjs", "build": "next build", "start": "next start", "lint": "eslint ." @@ -31,7 +31,6 @@ "next": "^16.1.6", "react": "^19.2.0", "react-apexcharts": "^1.8.0", - "react-d3-tree": "^3.6.6", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "^19.2.0", diff --git a/scripts/dev.mjs b/scripts/dev.mjs new file mode 100644 index 0000000..8d6a69f --- /dev/null +++ b/scripts/dev.mjs @@ -0,0 +1,26 @@ +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +// WebStorm sometimes runs npm scripts with a different working directory (repo root), +// which breaks module resolution for PostCSS/Tailwind. Force cwd to this package. +const here = path.dirname(fileURLToPath(import.meta.url)); +const pkgRoot = path.resolve(here, ".."); + +// Ensure this process (and any child tools that read process.cwd()) runs from package root, +// even if the caller started in a different working directory (e.g. IDE run configs). +process.chdir(pkgRoot); + +const nextBin = path.join(pkgRoot, "node_modules", "next", "dist", "bin", "next"); + +// Forward any args passed after `--` from npm, e.g. `npm run dev -- --port 3005`. +const extraArgs = process.argv.slice(2); +const child = spawn(process.execPath, [nextBin, "dev", ...extraArgs], { + cwd: pkgRoot, + stdio: "inherit", + env: process.env, +}); + +child.on("exit", (code) => { + process.exit(code ?? 0); +}); diff --git a/src/app/editor/[id]/featureCommands.ts b/src/app/editor/[id]/featureCommands.ts index 92c7829..023cb3d 100644 --- a/src/app/editor/[id]/featureCommands.ts +++ b/src/app/editor/[id]/featureCommands.ts @@ -39,18 +39,26 @@ export function useFeatureCommands(options: Options) { setEntityFormStatus, } = options; - const applyGeometryMetadata = useCallback(async () => { + const applyGeometryMetadata = useCallback(async (): Promise<{ ok: boolean; error?: string }> => { if (!selectedFeature) { - setEntityFormStatus("Hãy chọn một geometry trước."); - return; + const msg = "Hãy chọn một geometry trước."; + setEntityFormStatus(msg); + return { ok: false, error: msg }; + } + + if (!geometryMetaForm.time_start.trim() || !geometryMetaForm.time_end.trim()) { + const msg = "time_start và time_end là bắt buộc."; + setEntityFormStatus(msg); + return { ok: false, error: msg }; } let metadata; try { metadata = buildGeometryMetadataPatch(geometryMetaForm); } catch (err) { - setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ."); - return; + const msg = err instanceof Error ? err.message : "Thời gian không hợp lệ."; + setEntityFormStatus(msg); + return { ok: false, error: msg }; } setIsEntitySubmitting(true); @@ -59,6 +67,7 @@ export function useFeatureCommands(options: Options) { editor.patchFeatureProperties(selectedFeature.properties.id, metadata.patch); setGeometryMetaForm(metadata.formState); setEntityFormStatus("Đã cập nhật thuộc tính GEO. Commit khi sẵn sàng."); + return { ok: true }; } finally { setIsEntitySubmitting(false); } @@ -111,4 +120,3 @@ export function useFeatureCommands(options: Options) { applyEntitiesToSelectedGeometry, }; } - diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx index c01f488..ab4ad7b 100644 --- a/src/app/editor/[id]/page.tsx +++ b/src/app/editor/[id]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import Map from "@/uhm/components/Map"; import Editor from "@/uhm/components/Editor"; @@ -13,12 +13,16 @@ import EntityWikiBindingsPanel from "@/uhm/components/EntityWikiBindingsPanel"; import { Entity, fetchEntities, searchEntitiesByName } from "@/uhm/api/entities"; import { ApiError } from "@/uhm/api/http"; import { fetchCurrentUser } from "@/uhm/api/auth"; -import { fetchGeometriesByBBox } from "@/uhm/api/geometries"; import { SectionCommit } from "@/uhm/api/sections"; +import { searchWikisByTitle, type Wiki } from "@/uhm/api/wikis"; +import { searchGeometriesByEntityName, type EntityGeometriesSearchItem, type EntityGeometrySearchGeo } from "@/uhm/api/geometries"; +import type { EntitySnapshot } from "@/uhm/types/entities"; import { Feature, + Geometry, useEditorState, } from "@/uhm/lib/useEditorState"; +import { geoTypeCodeToTypeKey } from "@/uhm/lib/geoTypeMap"; import { BackgroundLayerId, BackgroundLayerVisibility, @@ -26,14 +30,11 @@ import { HIDDEN_BACKGROUND_LAYER_VISIBILITY, } from "@/uhm/lib/backgroundLayers"; import { - DEFAULT_ENTITY_TYPE_ID, ENTITY_TYPE_OPTIONS, - EntityTypeGroupId, - findEntityTypeOption, } from "@/uhm/lib/entityTypeOptions"; import { EntityFormState, - PendingEntityCreate, + GeometryMetaFormState, useEditorSessionState, } from "@/uhm/lib/useEditorSessionState"; import { @@ -45,9 +46,9 @@ import { import { buildClientEntityId, formatEntityNamesForDisplay, - mergeEntitiesWithPending, mergeEntitySearchResults, } from "@/uhm/lib/editor/entity/entityBinding"; +import { buildFeatureEntityPatch } from "@/uhm/lib/editor/entity/entityBinding"; import { formatBindingIdsForDisplay, } from "@/uhm/lib/editor/geometry/geometryMetadata"; @@ -56,9 +57,12 @@ import { persistBackgroundLayerVisibility, } from "@/uhm/lib/editor/background/backgroundVisibilityStorage"; import { useSectionCommands } from "@/uhm/lib/editor/section/useSectionCommands"; -import { EMPTY_FEATURE_COLLECTION, WORLD_BBOX } from "@/uhm/lib/geo/constants"; -import { FIXED_TIMELINE_RANGE, clampYearToFixedRange, TIMELINE_DEBOUNCE_MS } from "@/uhm/lib/timeline"; +import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/geo/constants"; +import { FIXED_TIMELINE_RANGE, clampYearToFixedRange } from "@/uhm/lib/timeline"; import { useFeatureCommands } from "./featureCommands"; +import { deleteSubmission } from "@/uhm/api/sections"; +import type { WikiSnapshot } from "@/uhm/types/wiki"; +import UnifiedSearchBar, { type UnifiedSearchKind } from "@/uhm/components/UnifiedSearchBar"; const CURRENT_YEAR = new Date().getUTCFullYear(); const DEFAULT_EDITOR_USER_ID = "local-editor"; @@ -71,6 +75,19 @@ export default function Page() { const openedProjectIdRef = useRef(null); const autoOpenWiki = searchParams.get("only") === "wiki"; const wikiOnly = autoOpenWiki; + const [blockedPendingSubmissionId, setBlockedPendingSubmissionId] = useState(null); + const [searchKind, setSearchKind] = useState("entity"); + const [searchQuery, setSearchQuery] = useState(""); + const [wikiSearchResults, setWikiSearchResults] = useState([]); + const [isWikiSearching, setIsWikiSearching] = useState(false); + const [geoSearchResults, setGeoSearchResults] = useState([]); + const [isGeoSearching, setIsGeoSearching] = useState(false); + const [requestedActiveWikiId, setRequestedActiveWikiId] = useState(null); + const [leftPanelWidth, setLeftPanelWidth] = useState(280); + const [rightPanelWidth, setRightPanelWidth] = useState(420); + const [timelineFilterEnabled, setTimelineFilterEnabled] = useState(true); + const entityFormStatusTimeoutRef = useRef(null); + const lastSelectedFeatureIdRef = useRef(null); const { mode, @@ -83,7 +100,6 @@ export default function Page() { setIsSubmitting, isOpeningSection, setIsOpeningSection, - availableSections, setAvailableSections, selectedSectionId, setSelectedSectionId, @@ -94,23 +110,18 @@ export default function Page() { commitNote, setCommitNote, editorUserIdInput, - setEditorUserIdInput, activeSection, setActiveSection, sectionState, setSectionState, sectionCommits, setSectionCommits, - lastSectionSnapshot, - setLastSectionSnapshot, - persistedEntities, - setPersistedEntities, - projectEntityRefs, - setProjectEntityRefs, - pendingEntityCreates, - setPendingEntityCreates, - createdEntities, - setCreatedEntities, + baselineSnapshot, + setBaselineSnapshot, + entityCatalog, + setEntityCatalog, + snapshotEntities, + setSnapshotEntities, entityStatus, setEntityStatus, selectedFeatureId, @@ -125,61 +136,87 @@ export default function Page() { setIsEntitySubmitting, entityFormStatus, setEntityFormStatus, - entitySearchQuery, - setEntitySearchQuery, entitySearchResults, setEntitySearchResults, - selectedSearchEntityId, - setSelectedSearchEntityId, isEntitySearchLoading, setIsEntitySearchLoading, - timelineYear, - setTimelineYear, timelineDraftYear, setTimelineDraftYear, - isTimelineLoading, - setIsTimelineLoading, - timelineStatus, - setTimelineStatus, backgroundVisibility, setBackgroundVisibility, isBackgroundVisibilityReady, setIsBackgroundVisibilityReady, - wikis, - setWikis, - entityWikiLinks, - setEntityWikiLinks, + snapshotWikis, + setSnapshotWikis, + snapshotEntityWikiLinks, + setSnapshotEntityWikiLinks, } = useEditorSessionState({ emptyFeatureCollection: EMPTY_FEATURE_COLLECTION, defaultEditorUserId: DEFAULT_EDITOR_USER_ID, fallbackTimelineRange: FIXED_TIMELINE_RANGE, currentYear: CURRENT_YEAR, }); - // Counter để bỏ qua response cũ khi user đổi timeline/section liên tục. - const timelineFetchRequestRef = useRef(0); // Counter để bỏ qua response cũ khi user gõ search entity liên tục. const entitySearchRequestRef = useRef(0); + const wikiSearchRequestRef = useRef(0); + const geoSearchRequestRef = useRef(0); const editor = useEditorState(initialData); const editorUserId = normalizeEditorUserId(editorUserIdInput); + const snapshotEntitiesAsEntities = useMemo(() => { + const rows = snapshotEntities || []; + return rows + .filter((e) => e && e.operation !== "delete") + .map((e) => ({ + id: String(e.id || ""), + name: String(e.name || "").trim() || String(e.id || ""), + description: e.description ?? null, + status: typeof e.status === "number" ? e.status : 1, + geometry_count: 0, + })) + .filter((e) => e.id.length > 0 && e.name.length > 0); + }, [snapshotEntities]); + const entities = useMemo( - () => mergeEntitiesWithPending(persistedEntities, pendingEntityCreates), - [persistedEntities, pendingEntityCreates] + () => mergeEntitySearchResults(entityCatalog, snapshotEntitiesAsEntities), + [entityCatalog, snapshotEntitiesAsEntities] ); + const snapshotEntitiesVisible = useMemo(() => { + const byId = new globalThis.Map(); + for (const ref of snapshotEntities || []) { + const id = String(ref?.id || "").trim(); + if (!id || byId.has(id)) continue; + if (ref.operation === "delete") continue; + byId.set(id, ref); + } + return Array.from(byId.values()); + }, [snapshotEntities]); + + // Timeline filter: only affects persisted snapshot features. + // New features created in the current session remain visible regardless of time range. + const timelineVisibleDraft = useMemo(() => { + if (!timelineFilterEnabled) return editor.draft; + const year = clampYearToFixedRange(Math.trunc(timelineDraftYear)); + return { + ...editor.draft, + features: editor.draft.features.filter((feature) => { + if (!editor.hasPersistedFeature(feature.properties.id)) return true; + return isFeatureVisibleAtYear(feature, year); + }), + }; + }, [editor, timelineDraftYear, timelineFilterEnabled]); + const projectEntityChoices = useMemo(() => { const ids = new Set(); - for (const ref of projectEntityRefs) ids.add(String(ref.id)); - for (const feature of editor.draft.features) { - for (const id of normalizeFeatureEntityIds(feature)) ids.add(id); - } + for (const ref of snapshotEntitiesVisible) ids.add(String(ref.id)); const rows = Array.from(ids).map((id) => { const found = entities.find((e) => e.id === id) || null; return { id, name: found?.name || id }; }); rows.sort((a, b) => a.name.localeCompare(b.name)); return rows; - }, [editor.draft.features, entities, projectEntityRefs]); + }, [entities, snapshotEntitiesVisible]); const selectedFeature = selectedFeatureId === null ? null @@ -187,6 +224,16 @@ export default function Page() { String(feature.properties.id) === String(selectedFeatureId) ) || null; + const createdEntities = useMemo(() => { + return (snapshotEntities || []) + .filter((e) => e && e.source === "inline" && e.operation === "create") + .map((e) => ({ + id: String(e.id || ""), + name: String(e.name || "").trim() || String(e.id || ""), + })) + .filter((e) => e.id.length > 0 && e.name.length > 0); + }, [snapshotEntities]); + const createdGeometries = useMemo(() => { const rows: Array<{ id: string | number; @@ -213,15 +260,40 @@ export default function Page() { }, [editor.changes, entities]); const wikiDirty = useMemo(() => { - const prev = lastSectionSnapshot?.wikis || []; + const prev = normalizeWikisForCompare(baselineSnapshot?.wikis); + const next = normalizeWikisForCompare(snapshotWikis); try { - return JSON.stringify(prev) !== JSON.stringify(wikis); + return JSON.stringify(prev) !== JSON.stringify(next); } catch { return true; } - }, [lastSectionSnapshot?.wikis, wikis]); + }, [baselineSnapshot?.wikis, snapshotWikis]); - const pendingSaveCount = editor.changeCount + pendingEntityCreates.length + (wikiDirty ? 1 : 0); + const entitiesDirty = useMemo(() => { + const prev = normalizeEntitiesForCompare(baselineSnapshot?.entities); + const next = normalizeEntitiesForCompare(snapshotEntities); + try { + return JSON.stringify(prev) !== JSON.stringify(next); + } catch { + return true; + } + }, [baselineSnapshot?.entities, snapshotEntities]); + + const entityWikiDirty = useMemo(() => { + const prev = normalizeEntityWikiLinksForCompare(baselineSnapshot?.entity_wiki); + const next = normalizeEntityWikiLinksForCompare(snapshotEntityWikiLinks); + try { + return JSON.stringify(prev) !== JSON.stringify(next); + } catch { + return true; + } + }, [snapshotEntityWikiLinks, baselineSnapshot?.entity_wiki]); + + const pendingSaveCount = + editor.changeCount + + (wikiDirty ? 1 : 0) + + (entitiesDirty ? 1 : 0) + + (entityWikiDirty ? 1 : 0); const sectionCommands = useSectionCommands({ editor, @@ -232,24 +304,21 @@ export default function Page() { selectedSectionId, newSectionTitle, pendingSaveCount, - pendingEntityCreates, - projectEntityRefs, - wikis, - entityWikiLinks, - lastSectionSnapshot, + snapshotEntities, + snapshotWikis, + snapshotEntityWikiLinks, + baselineSnapshot, commitTitle, commitNote, setActiveSection, setSelectedSectionId, setSectionState, - setLastSectionSnapshot, + setBaselineSnapshot, setInitialData, setSectionCommits, - setPendingEntityCreates, - setProjectEntityRefs, - setCreatedEntities, - setWikis, - setEntityWikiLinks, + setSnapshotEntities, + setSnapshotWikis, + setSnapshotEntityWikiLinks, setEntityFormStatus, setSelectedFeatureId, setEntityStatus, @@ -273,6 +342,7 @@ export default function Page() { try { setIsOpeningSection(true); setEntityStatus(null); + setBlockedPendingSubmissionId(null); await openSectionForEditing(projectId); setEntityStatus(null); } catch (err) { @@ -281,6 +351,19 @@ export default function Page() { router.replace("/signin"); return; } + // Pending submission blocks editor in BE. We parse the pending id to offer delete/unlock. + if (err.status === 409) { + try { + const payload = JSON.parse(err.body || "{}"); + if (payload?.pending_submission_id) { + setBlockedPendingSubmissionId(String(payload.pending_submission_id)); + setEntityStatus("Project đang có submission PENDING. Hãy xoa submission đó để unlock editor."); + return; + } + } catch { + // fallthrough + } + } setEntityStatus(`Mở project thất bại: ${err.body || err.message}`); } else { console.error("Open project failed", err); @@ -291,15 +374,36 @@ export default function Page() { } }, [openSectionForEditing, projectId, router, setEntityStatus, setIsOpeningSection]); + const unlockByDeletingPendingSubmission = useCallback(async () => { + if (!blockedPendingSubmissionId) return; + const confirmed = window.confirm("Xoa submission PENDING de unlock editor? Hanh dong nay khong the hoan tac."); + if (!confirmed) return; + try { + setIsOpeningSection(true); + setEntityStatus(null); + await deleteSubmission(blockedPendingSubmissionId); + setBlockedPendingSubmissionId(null); + await openProject(); + } catch (err) { + if (err instanceof ApiError) { + setEntityStatus(`Khong the xoa submission: ${err.body || err.message}`); + } else { + setEntityStatus("Khong the xoa submission."); + } + } finally { + setIsOpeningSection(false); + } + }, [blockedPendingSubmissionId, openProject, setEntityStatus, setIsOpeningSection]); + useEffect(() => { let disposed = false; async function ensureAuthenticated() { try { await fetchCurrentUser(); - } catch (err) { + } catch { if (disposed) return; - // Follow the same behavior as the rest of FrontEndAdmin: unauthenticated -> /signin. + // Follow the same behavior as the rest of FrontEndUser: unauthenticated -> /signin. router.replace("/signin"); } } @@ -322,7 +426,7 @@ export default function Page() { // allow retry if openProject threw outside its try/catch (should be rare) openedProjectIdRef.current = null; }); - }, [openProject]); + }, [openProject, projectId]); useEffect(() => { let disposed = false; @@ -332,7 +436,19 @@ export default function Page() { const rows = await fetchEntities(); if (disposed) return; - setPersistedEntities(rows); + setEntityCatalog((prev) => { + const byId = new globalThis.Map(); + for (const row of prev || []) { + if (!row?.id) continue; + byId.set(String(row.id), row); + } + for (const row of rows || []) { + if (!row?.id) continue; + // Prefer the freshest backend payload on conflicts. + byId.set(String(row.id), row); + } + return Array.from(byId.values()); + }); setEntityStatus(null); } catch (err) { if (disposed) return; @@ -346,20 +462,18 @@ export default function Page() { return () => { disposed = true; }; - }, [setEntityStatus, setPersistedEntities]); + }, [setEntityCatalog, setEntityStatus]); useEffect(() => { - if (!selectedFeature) { + if (searchKind !== "entity") { setEntitySearchResults([]); - setSelectedSearchEntityId(null); setIsEntitySearchLoading(false); return; } - const keyword = entitySearchQuery.trim(); + const keyword = searchQuery.trim(); if (!keyword.length) { setEntitySearchResults([]); - setSelectedSearchEntityId(null); setIsEntitySearchLoading(false); return; } @@ -367,50 +481,41 @@ export default function Page() { let disposed = false; const requestId = ++entitySearchRequestRef.current; const timeoutId = window.setTimeout(async () => { + const keywordLower = keyword.toLowerCase(); + const localMatches = entities + .filter((entity) => + entity.name.toLowerCase().includes(keywordLower) || + (entity.description || "").toLowerCase().includes(keywordLower) + ) + .map((entity) => ({ + ...entity, + geometry_count: typeof entity.geometry_count === "number" ? entity.geometry_count : 0, + })); + setIsEntitySearchLoading(true); try { const rows = await searchEntitiesByName(keyword, { limit: 30 }); if (disposed || requestId !== entitySearchRequestRef.current) return; + // Centralize: merge search results into the shared entity catalog so UI stays consistent. + setEntityCatalog((prev) => { + const byId = new globalThis.Map(); + for (const row of prev || []) { + if (!row?.id) continue; + byId.set(String(row.id), row); + } + for (const row of rows || []) { + if (!row?.id) continue; + byId.set(String(row.id), row); + } + return Array.from(byId.values()); + }); - 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); + const mergedRows = mergeEntitySearchResults(rows, localMatches); setEntitySearchResults(mergedRows); - setSelectedSearchEntityId((prev) => - prev && mergedRows.some((entity) => entity.id === prev) - ? prev - : mergedRows[0]?.id || null - ); } catch (err) { if (disposed || requestId !== entitySearchRequestRef.current) return; console.error("Search entity by name failed", err); - 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); + setEntitySearchResults(localMatches); } finally { if (!disposed && requestId === entitySearchRequestRef.current) { setIsEntitySearchLoading(false); @@ -423,42 +528,124 @@ export default function Page() { window.clearTimeout(timeoutId); }; }, [ - entitySearchQuery, - selectedFeature, - pendingEntityCreates, + searchKind, + searchQuery, + entities, + setEntityCatalog, setEntitySearchResults, setIsEntitySearchLoading, - setSelectedSearchEntityId, ]); + useEffect(() => { + if (searchKind !== "wiki") { + setWikiSearchResults([]); + setIsWikiSearching(false); + return; + } + + const keyword = searchQuery.trim(); + if (!keyword.length) { + setWikiSearchResults([]); + setIsWikiSearching(false); + return; + } + + let disposed = false; + const requestId = ++wikiSearchRequestRef.current; + const timeoutId = window.setTimeout(async () => { + setIsWikiSearching(true); + try { + const rows = await searchWikisByTitle(keyword, { limit: 12 }); + if (disposed || requestId !== wikiSearchRequestRef.current) return; + setWikiSearchResults(rows); + } catch (err) { + if (disposed || requestId !== wikiSearchRequestRef.current) return; + console.error("Search wikis failed", err); + setWikiSearchResults([]); + } finally { + if (!disposed && requestId === wikiSearchRequestRef.current) { + setIsWikiSearching(false); + } + } + }, 250); + + return () => { + disposed = true; + window.clearTimeout(timeoutId); + }; + }, [searchKind, searchQuery]); + + useEffect(() => { + if (searchKind !== "geo") { + setGeoSearchResults([]); + setIsGeoSearching(false); + return; + } + + const keyword = searchQuery.trim(); + if (!keyword.length) { + setGeoSearchResults([]); + setIsGeoSearching(false); + return; + } + + let disposed = false; + const requestId = ++geoSearchRequestRef.current; + const timeoutId = window.setTimeout(async () => { + setIsGeoSearching(true); + try { + const res = await searchGeometriesByEntityName(keyword, { limit: 24 }); + if (disposed || requestId !== geoSearchRequestRef.current) return; + setGeoSearchResults(res.items || []); + } catch (err) { + if (disposed || requestId !== geoSearchRequestRef.current) return; + console.error("Search geometries by entity name failed", err); + setGeoSearchResults([]); + } finally { + if (!disposed && requestId === geoSearchRequestRef.current) { + setIsGeoSearching(false); + } + } + }, 260); + + return () => { + disposed = true; + window.clearTimeout(timeoutId); + }; + }, [geoSearchRequestRef, searchKind, searchQuery]); + useEffect(() => { if (selectedFeatureId === null) return; - const stillExists = editor.draft.features.some((feature) => + const stillExists = timelineVisibleDraft.features.some((feature) => String(feature.properties.id) === String(selectedFeatureId) ); if (!stillExists) { setSelectedFeatureId(null); } - }, [editor.draft, selectedFeatureId, setSelectedFeatureId]); + }, [timelineVisibleDraft, selectedFeatureId, setSelectedFeatureId]); useEffect(() => { if (!selectedFeature) { setSelectedGeometryEntityIds([]); setGeometryMetaForm({ + type_key: "", time_start: "", time_end: "", binding: "", }); - setEntitySearchQuery(""); - setEntitySearchResults([]); - setSelectedSearchEntityId(null); setEntityFormStatus(null); + lastSelectedFeatureIdRef.current = null; return; } const featureEntityIds = normalizeFeatureEntityIds(selectedFeature); + const nextTypeKey = typeof selectedFeature.properties.type === "string" && selectedFeature.properties.type.trim().length + ? selectedFeature.properties.type + : getDefaultTypeIdForFeature(selectedFeature); + const currentId = String(selectedFeature.properties.id); setSelectedGeometryEntityIds(featureEntityIds); setGeometryMetaForm({ + type_key: nextTypeKey, time_start: selectedFeature.properties.time_start != null ? String(selectedFeature.properties.time_start) : "", @@ -467,107 +654,38 @@ export default function Page() { : "", binding: normalizeFeatureBindingIds(selectedFeature).join(", "), }); - setEntitySearchQuery(""); - setEntitySearchResults([]); - setSelectedSearchEntityId(null); - setEntityFormStatus(null); + // Only clear status when switching to a different geometry, not when patching metadata/bindings + // on the same selected geometry (otherwise messages will blink). + if (lastSelectedFeatureIdRef.current !== currentId) { + setEntityFormStatus(null); + } + lastSelectedFeatureIdRef.current = currentId; }, [ selectedFeature, setEntityFormStatus, - setEntitySearchQuery, - setEntitySearchResults, setGeometryMetaForm, setSelectedGeometryEntityIds, - setSelectedSearchEntityId, ]); - 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, setEntityForm]); - - useEffect(() => { - const timeoutId = window.setTimeout(() => { - if (timelineDraftYear !== timelineYear) { - setTimelineYear(timelineDraftYear); - } - }, TIMELINE_DEBOUNCE_MS); - - return () => window.clearTimeout(timeoutId); - }, [timelineDraftYear, timelineYear, setTimelineYear]); + const flashEntityFormStatus = useCallback((msg: string | null, timeoutMs = 3000) => { + if (entityFormStatusTimeoutRef.current) { + window.clearTimeout(entityFormStatusTimeoutRef.current); + entityFormStatusTimeoutRef.current = null; + } + setEntityFormStatus(msg); + if (msg && timeoutMs > 0) { + entityFormStatusTimeoutRef.current = window.setTimeout(() => { + setEntityFormStatus(null); + entityFormStatusTimeoutRef.current = null; + }, timeoutMs); + } + }, [setEntityFormStatus]); useEffect(() => { setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage()); setIsBackgroundVisibilityReady(true); }, [setBackgroundVisibility, setIsBackgroundVisibilityReady]); - useEffect(() => { - if (activeSection) return; - - let disposed = false; - const requestId = ++timelineFetchRequestRef.current; - - async function loadGlobalByTimeline() { - setIsTimelineLoading(true); - setTimelineStatus(null); - - try { - const data = await fetchGeometriesByBBox({ - ...WORLD_BBOX, - time: timelineYear, - }); - - if (disposed || requestId !== timelineFetchRequestRef.current) return; - setInitialData(data); - } catch (err) { - if (err instanceof ApiError) { - console.error("Load global timeline data failed", err.body); - } else { - console.error("Load global timeline data failed", err); - } - - if (!disposed && requestId === timelineFetchRequestRef.current) { - setTimelineStatus("Không tải được geometry global tại mốc thời gian đã chọn."); - } - } finally { - if (!disposed && requestId === timelineFetchRequestRef.current) { - setIsTimelineLoading(false); - } - } - } - - loadGlobalByTimeline(); - - return () => { - disposed = true; - }; - }, [ - timelineYear, - activeSection, - setInitialData, - setIsTimelineLoading, - setTimelineStatus, - ]); - const updateBackgroundVisibility = ( updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility ) => { @@ -601,7 +719,7 @@ export default function Page() { setEntityForm((prev) => ({ ...prev, [key]: value })); }; - const handleGeometryMetaFormChange = (key: "time_start" | "time_end" | "binding", value: string) => { + const handleGeometryMetaFormChange = (key: keyof GeometryMetaFormState, value: string) => { setGeometryMetaForm((prev) => ({ ...prev, [key]: value })); }; @@ -609,18 +727,161 @@ export default function Page() { setSelectedGeometryEntityIds(uniqueEntityIds(values)); }; - const handleAddSelectedSearchEntity = () => { - const entityId = selectedSearchEntityId ? selectedSearchEntityId.trim() : ""; - if (!entityId.length) { - setEntityFormStatus("Hãy chọn một entity từ kết quả search trước."); + const handleAddEntityRefToProject = useCallback((entity: Entity) => { + const id = String(entity.id || "").trim(); + if (!id) return; + setSnapshotEntities((prev) => { + if (prev.some((e) => String(e.id) === id)) return prev; + return [ + { + id, + source: "ref", + operation: "reference", + name: entity.name, + description: entity.description ?? null, + }, + ...prev, + ]; + }); + // Keep entity catalog centralized as a single in-memory list. + setEntityCatalog((prev) => { + const byId = new globalThis.Map(); + for (const row of prev || []) { + if (!row?.id) continue; + byId.set(String(row.id), row); + } + byId.set(id, entity); + return Array.from(byId.values()); + }); + }, [setEntityCatalog, setSnapshotEntities]); + + const handleToggleBindEntityForSelectedGeometry = useCallback((entityId: string, nextChecked: boolean) => { + if (!selectedFeature) { + flashEntityFormStatus("Chưa chọn geometry để bind entity."); + return; + } + const id = String(entityId || "").trim(); + if (!id) return; + const nextEntityIds = (() => { + const prev = selectedGeometryEntityIds; + const has = prev.includes(id); + if (nextChecked) { + if (has) return prev; + return uniqueEntityIds([...prev, id]); + } + if (!has) return prev; + return prev.filter((x) => x !== id); + })(); + + setIsEntitySubmitting(true); + flashEntityFormStatus(null, 0); + try { + editor.patchFeatureProperties( + selectedFeature.properties.id, + buildFeatureEntityPatch(selectedFeature, nextEntityIds, entities) + ); + setSelectedGeometryEntityIds(nextEntityIds); + flashEntityFormStatus( + nextChecked + ? "Đã bind entity vào geometry. Commit khi sẵn sàng." + : "Đã unbind entity khỏi geometry. Commit khi sẵn sàng.", + 3000 + ); + } finally { + setIsEntitySubmitting(false); + } + }, [ + editor, + entities, + flashEntityFormStatus, + selectedFeature, + selectedGeometryEntityIds, + setIsEntitySubmitting, + setSelectedGeometryEntityIds, + ]); + + const handleAddWikiRefToProject = useCallback((wiki: Wiki) => { + const id = String(wiki.id || "").trim(); + if (!id) return; + const title = (wiki.title || "").trim() || "Untitled wiki"; + setSnapshotWikis((prev) => { + if (prev.some((w) => w.id === id)) return prev; + return [ + { + id, + source: "ref", + operation: "reference", + title, + doc: null, + updated_at: wiki.updated_at, + }, + ...prev, + ]; + }); + setRequestedActiveWikiId(id); + }, [setSnapshotWikis]); + + const handleImportGeoFromSearch = useCallback(( + entityItem: EntityGeometriesSearchItem, + geo: EntityGeometrySearchGeo + ) => { + const geoId = String(geo?.id || "").trim(); + if (!geoId) return; + + // Ensure the geometry stays selectable even if it doesn't match the current timeline year. + setTimelineFilterEnabled(false); + + // Keep entity store consistent: importing a geo implies the entity should exist in snapshot + catalog. + handleAddEntityRefToProject({ + id: entityItem.entity_id, + name: (entityItem.name || "").trim() || entityItem.entity_id, + description: (entityItem.description || "").trim() || null, + status: 1, + geometry_count: 0, + }); + + const existing = editor.draft.features.find((f) => String(f.properties.id) === geoId) || null; + if (existing) { + setSelectedFeatureId(existing.properties.id); + flashEntityFormStatus("Đã chọn geometry từ kết quả search.", 3000); return; } - const next = uniqueEntityIds([...selectedGeometryEntityIds, entityId]); - setSelectedGeometryEntityIds(next); - setSelectedSearchEntityId(null); - setEntityFormStatus(null); - }; + const geometry = normalizeGeoSearchGeometry(geo.draw_geometry); + if (!geometry) { + flashEntityFormStatus("Không import được: draw_geometry không hợp lệ.", 3000); + return; + } + + const bindingIds = normalizeGeoSearchBindingIds(geo.binding); + const typeKey = geoTypeCodeToTypeKey(Number(geo.geo_type)) || null; + + const feature: Feature = { + type: "Feature", + properties: { + id: geoId, + type: typeKey, + time_start: typeof geo.time_start === "number" ? geo.time_start : null, + time_end: typeof geo.time_end === "number" ? geo.time_end : null, + binding: bindingIds.length ? bindingIds : undefined, + entity_id: entityItem.entity_id, + entity_ids: [entityItem.entity_id], + entity_name: (entityItem.name || "").trim() || entityItem.entity_id, + entity_names: [(entityItem.name || "").trim() || entityItem.entity_id], + }, + geometry, + }; + + editor.createFeature(feature); + setSelectedFeatureId(feature.properties.id); + flashEntityFormStatus("Đã import geometry từ search GEO. Commit khi sẵn sàng.", 3000); + }, [ + editor, + flashEntityFormStatus, + handleAddEntityRefToProject, + setSelectedFeatureId, + setTimelineFilterEnabled, + ]); const featureCommands = useFeatureCommands({ editor, @@ -641,61 +902,62 @@ export default function Page() { return; } - const slug = entityForm.slug.trim() || null; - const typeId = entityForm.type_id || DEFAULT_ENTITY_TYPE_ID; + const description = entityForm.description.trim() || null; 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 = { + const createdEntity: Entity = { id: entityId, name, - slug, - type_id: typeId, + description, status: 1, + geometry_count: 0, }; setIsEntitySubmitting(true); setEntityFormStatus(null); try { - setPendingEntityCreates((prev) => [pendingCreate, ...prev]); - setCreatedEntities((prev) => { - if (prev.some((item) => item.id === pendingCreate.id)) return prev; + setSnapshotEntities((prev) => { + if (prev.some((e) => String(e.id) === entityId)) return prev; return [ { - id: pendingCreate.id, - name: pendingCreate.name, - type_id: pendingCreate.type_id || null, + id: entityId, + source: "inline", + operation: "create", + name, + slug: null, + description, + status: 1, }, ...prev, ]; }); + setEntityCatalog((prev) => { + const byId = new globalThis.Map(); + for (const row of prev || []) { + if (!row?.id) continue; + byId.set(String(row.id), row); + } + byId.set(entityId, createdEntity); + return Array.from(byId.values()); + }); setEntityForm((prev) => ({ ...prev, name: "", - slug: "", + description: "", })); setEntityStatus(null); - setEntityFormStatus("Đã thêm entity mới vào danh sách chờ Commit."); + setEntityFormStatus("Đã tạo entity mới (local). Commit khi sẵn sàng."); if (selectedFeature) { - setEntitySearchQuery(pendingCreate.name); - setSelectedSearchEntityId(pendingCreate.id); + setSearchKind("entity"); + setSearchQuery(name); } } finally { setIsEntitySubmitting(false); @@ -705,13 +967,6 @@ export default function Page() { const headCommit = sectionState?.head_commit_id ? sectionCommits.find((commit) => commit.id === sectionState.head_commit_id) || null : null; - const timelineDisabled = isSaving || pendingSaveCount > 0; - const timelineStatusText = - pendingSaveCount > 0 - ? "Commit hoặc Undo hết thay đổi trước khi đổi mốc thời gian." - : isSaving - ? "Đang lưu thay đổi..." - : timelineStatus; const handleCreateFeature = (feature: Feature) => { editor.createFeature(feature); @@ -745,14 +1000,64 @@ export default function Page() { undoStack={editor.undoStack} createdEntities={createdEntities} createdGeometries={createdGeometries} + width={leftPanelWidth} /> - {!wikiOnly ? ( + { + setLeftPanelWidth((prev) => clampNumber(prev + deltaX, 220, 520)); + }} + /> + + {blockedPendingSubmissionId ? ( +
+
+

Editor dang bi khoa

+
+ Project nay dang co submission o trang thai PENDING (id:{" "} + {blockedPendingSubmissionId}). Theo BE moi, khi + submission dang pending thi khong duoc tao submission/commit moi va khong duoc vao editor. +
+
+ + +
+
+
+ ) : null} + + {!wikiOnly && !blockedPendingSubmissionId ? (
{isBackgroundVisibilityReady ? (
- ) : ( + ) : blockedPendingSubmissionId ? null : ( // Wiki-only mode: avoid mounting Map/Timeline (WebGL + geometry fetching) to reduce lag.
)} + { + // dragging handle (between map and right panel): moving right increases right panel width + setRightPanelWidth((prev) => clampNumber(prev - deltaX, 260, 720)); + }} + /> + + { + setSearchKind(next); + setSearchQuery(""); + }} + query={searchQuery} + onQueryChange={setSearchQuery} + /> + + {searchKind === "entity" && searchQuery.trim().length > 0 ? ( +
+
+
Entity Results
+
+ {isEntitySearchLoading ? "Searching…" : `${entitySearchResults.length} results`} +
+
+
+ {entitySearchResults.slice(0, 8).map((e) => ( +
+
+
+ {e.name} +
+
+ {e.id} +
+
+ +
+ ))} + {!isEntitySearchLoading && entitySearchResults.length === 0 ? ( +
No results.
+ ) : null} +
+
+ ) : null} + + {searchKind === "wiki" && searchQuery.trim().length > 0 ? ( +
+
+
Wiki Results
+
+ {isWikiSearching ? "Searching…" : `${wikiSearchResults.length} results`} +
+
+
+ {wikiSearchResults.slice(0, 8).map((w) => ( +
+
+
+ {(w.title || "").trim() || "Untitled wiki"} +
+
+ {w.id} +
+
+ +
+ ))} + {!isWikiSearching && wikiSearchResults.length === 0 ? ( +
No results.
+ ) : null} +
+
+ ) : null} + + {searchKind === "geo" && searchQuery.trim().length > 0 ? ( +
+
+
Geo Results
+
+ {isGeoSearching ? "Searching…" : `${geoSearchResults.length} entities`} +
+
+
+ {geoSearchResults.slice(0, 6).map((item) => ( +
+
+
+
+ {item.name?.trim() || item.entity_id} +
+
+ {item.entity_id} +
+
+
+ {Array.isArray(item.geometries) ? item.geometries.length : 0} geos +
+
+ {item.description?.trim() ? ( +
+ {item.description.trim()} +
+ ) : null} + {Array.isArray(item.geometries) && item.geometries.length ? ( +
+ {item.geometries.slice(0, 4).map((geo) => ( +
+
+
+ #{geo.id} +
+
+ type: {String(geo.geo_type)}{" "} + {geo.time_start != null || geo.time_end != null + ? `| time: ${geo.time_start ?? "?"} → ${geo.time_end ?? "?"}` + : ""} +
+
+ +
+ ))} + {item.geometries.length > 4 ? ( +
+ +{item.geometries.length - 4} more… +
+ ) : null} +
+ ) : ( +
+ No geometry linked. +
+ )} +
+ ))} + {!isGeoSearching && geoSearchResults.length === 0 ? ( +
No results.
+ ) : null} +
+
+ ) : null} + - - - {!wikiOnly ? ( + + + {!wikiOnly && selectedFeature ? ( ) : null}
@@ -834,6 +1382,59 @@ export default function Page() { ); } +function ResizeHandle({ + onDrag, + title, +}: { + onDrag: (deltaX: number) => void; + title: string; +}) { + const handlePointerDown = (event: ReactPointerEvent) => { + // Only horizontal resize + event.preventDefault(); + const startX = event.clientX; + let lastX = startX; + + const onMove = (e: PointerEvent) => { + const dx = e.clientX - lastX; + if (dx !== 0) { + onDrag(dx); + lastX = e.clientX; + } + }; + const onUp = () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + }; + + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + }; + + return ( +
+ ); +} + +function clampNumber(value: number, min: number, max: number): number { + if (value < min) return min; + if (value > max) return max; + return value; +} + function normalizeEditorUserId(value: string): string { const normalized = value.trim(); return normalized || DEFAULT_EDITOR_USER_ID; @@ -843,11 +1444,83 @@ function formatCommitTitle(commit: SectionCommit): string { return commit.edit_summary?.trim() || `Commit ${commit.id.slice(0, 8)}`; } -function getAllowedEntityTypeGroupIdsForFeature(feature: Feature): EntityTypeGroupId[] { - const defaultTypeId = getDefaultTypeIdForFeature(feature); - const defaultTypeOption = findEntityTypeOption(defaultTypeId); - if (defaultTypeOption) { - return [defaultTypeOption.groupId]; - } - return ["polygon"]; +function isFeatureVisibleAtYear(feature: Feature, year: number): boolean { + const start = feature.properties.time_start; + const end = feature.properties.time_end; + if (typeof start === "number" && Number.isFinite(start) && year < start) return false; + if (typeof end === "number" && Number.isFinite(end) && year > end) return false; + return true; +} + +function normalizeWikisForCompare(input: WikiSnapshot[] | null | undefined) { + const list = Array.isArray(input) ? input : []; + const normalized = list + .filter((w) => w && typeof w.id === "string" && w.id.trim().length > 0) + .filter((w) => { + if (w.source === "ref") return true; + if (w.operation === "create" || w.operation === "update" || w.operation === "delete") return true; + const title = typeof w.title === "string" ? w.title.trim() : ""; + const doc = typeof w.doc === "string" ? w.doc.trim() : ""; + return title.length > 0 || (w.doc !== null && doc.length > 0); + }) + .map((w) => ({ + id: w.id, + source: w.source, + title: typeof w.title === "string" ? w.title.trim() : "", + slug: typeof w.slug === "string" ? w.slug : null, + doc: w.doc === null ? null : typeof w.doc === "string" ? w.doc.trim() : null, + })) + .sort((a, b) => a.id.localeCompare(b.id)); + return normalized; +} + +function normalizeEntitiesForCompare(input: EntitySnapshot[] | null | undefined) { + const list = Array.isArray(input) ? input : []; + const normalized = list + .filter((e) => e && (typeof e.id === "string" || typeof e.id === "number")) + .map((e) => ({ + id: String(e.id), + source: e.source, + name: typeof e.name === "string" ? e.name.trim() : "", + slug: typeof e.slug === "string" ? e.slug : null, + description: e.description == null ? null : String(e.description), + status: typeof e.status === "number" ? e.status : null, + })) + .sort((a, b) => a.id.localeCompare(b.id)); + return normalized; +} + +function normalizeEntityWikiLinksForCompare(input: Array<{ entity_id: string; wiki_id: string; operation?: string }> | null | undefined) { + const list = Array.isArray(input) ? input : []; + const normalized = list + .filter((l) => l && typeof l.entity_id === "string" && typeof l.wiki_id === "string") + .map((l) => ({ + entity_id: l.entity_id, + wiki_id: l.wiki_id, + operation: l.operation === "delete" ? "delete" : "binding", + })) + .sort((a, b) => (a.entity_id + a.wiki_id).localeCompare(b.entity_id + b.wiki_id)); + return normalized; +} + +function normalizeGeoSearchGeometry(value: unknown): Geometry | null { + if (!value || typeof value !== "object") return null; + const g = value as Record; + if (typeof g.type !== "string") return null; + if (!("coordinates" in g)) return null; + return value as Geometry; +} + +function normalizeGeoSearchBindingIds(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const deduped: string[] = []; + const seen = new Set(); + for (const rawId of value) { + if (typeof rawId !== "string" && typeof rawId !== "number") continue; + const id = String(rawId).trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + deduped.push(id); + } + return deduped; } diff --git a/src/app/page.tsx b/src/app/page.tsx index ce17266..bcca9c4 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,9 +1,186 @@ -import AdminLayout from "./user/layout"; +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import Map from "@/uhm/components/Map"; +import BackgroundLayersPanel from "@/uhm/components/BackgroundLayersPanel"; +import TimelineBar from "@/uhm/components/TimelineBar"; +import { fetchGeometriesByBBox } from "@/uhm/api/geometries"; +import { ApiError } from "@/uhm/api/http"; +import { API_BASE_URL } from "@/uhm/api/config"; +import { + BackgroundLayerId, + BackgroundLayerVisibility, + DEFAULT_BACKGROUND_LAYER_VISIBILITY, + HIDDEN_BACKGROUND_LAYER_VISIBILITY, +} from "@/uhm/lib/backgroundLayers"; +import { + loadBackgroundLayerVisibilityFromStorage, + persistBackgroundLayerVisibility, +} from "@/uhm/lib/editor/background/backgroundVisibilityStorage"; +import { EMPTY_FEATURE_COLLECTION, WORLD_BBOX } from "@/uhm/lib/geo/constants"; +import { clampYearToFixedRange, TIMELINE_DEBOUNCE_MS } from "@/uhm/lib/timeline"; +import type { Feature, FeatureCollection } from "@/uhm/types/geo"; + +const CURRENT_YEAR = new Date().getUTCFullYear(); export default function Page() { + const [data, setData] = useState(EMPTY_FEATURE_COLLECTION); + const [selectedFeatureId, setSelectedFeatureId] = useState(null); + const [timelineYear, setTimelineYear] = useState(() => clampYearToFixedRange(CURRENT_YEAR)); + const [timelineDraftYear, setTimelineDraftYear] = useState(() => clampYearToFixedRange(CURRENT_YEAR)); + const [isTimelineLoading, setIsTimelineLoading] = useState(false); + const [timelineStatus, setTimelineStatus] = useState(null); + const [backgroundVisibility, setBackgroundVisibility] = useState( + () => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }) + ); + const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false); + const timelineFetchRequestRef = useRef(0); + const [lastLoadedAt, setLastLoadedAt] = useState(null); + + const selectedFeature: Feature | null = useMemo(() => { + if (selectedFeatureId === null) return null; + return ( + data.features.find((feature) => String(feature.properties.id) === String(selectedFeatureId)) || null + ); + }, [data.features, selectedFeatureId]); + + useEffect(() => { + if (selectedFeatureId === null) return; + const stillExists = data.features.some((feature) => String(feature.properties.id) === String(selectedFeatureId)); + if (!stillExists) setSelectedFeatureId(null); + }, [data.features, selectedFeatureId]); + + useEffect(() => { + const timeoutId = window.setTimeout(() => { + if (timelineDraftYear !== timelineYear) setTimelineYear(timelineDraftYear); + }, TIMELINE_DEBOUNCE_MS); + return () => window.clearTimeout(timeoutId); + }, [timelineDraftYear, timelineYear]); + + useEffect(() => { + setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage()); + setIsBackgroundVisibilityReady(true); + }, []); + + useEffect(() => { + let disposed = false; + const requestId = ++timelineFetchRequestRef.current; + + async function loadByTimeline() { + setIsTimelineLoading(true); + setTimelineStatus(null); + try { + const next = await fetchGeometriesByBBox({ ...WORLD_BBOX, time: timelineYear }); + if (disposed || requestId !== timelineFetchRequestRef.current) return; + setData(next); + setLastLoadedAt(new Date().toISOString()); + } catch (err) { + if (err instanceof ApiError) { + console.error("Load timeline data failed", err.body); + } else { + console.error("Load timeline data failed", err); + } + if (!disposed && requestId === timelineFetchRequestRef.current) { + setTimelineStatus("Không tải được geometry tại mốc thời gian đã chọn."); + } + } finally { + if (!disposed && requestId === timelineFetchRequestRef.current) { + setIsTimelineLoading(false); + } + } + } + + loadByTimeline(); + return () => { + disposed = true; + }; + }, [timelineYear]); + + const updateBackgroundVisibility = (updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility) => { + setBackgroundVisibility((prev) => { + const next = updater(prev); + persistBackgroundLayerVisibility(next); + return next; + }); + }; + + const handleToggleBackgroundLayer = (id: BackgroundLayerId) => { + updateBackgroundVisibility((prev) => ({ ...prev, [id]: !prev[id] })); + }; + + const handleShowAllBackgroundLayers = () => { + updateBackgroundVisibility(() => ({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY })); + }; + + const handleHideAllBackgroundLayers = () => { + updateBackgroundVisibility(() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })); + }; + + const handleTimelineYearChange = (nextYear: number) => { + setTimelineDraftYear(clampYearToFixedRange(Math.trunc(nextYear))); + }; + return ( - -
Page
-
+
+
+ {isBackgroundVisibilityReady ? ( + + ) : ( +
+ )} + + +
+ + +
Viewer
+
+ API: {API_BASE_URL} +
+
+ Year: {timelineYear} | Features: {data.features.length} +
+
+ {isTimelineLoading ? "Loading geometries..." : lastLoadedAt ? `Loaded: ${lastLoadedAt}` : "Not loaded yet"} +
+
+ {selectedFeature ? `ID: ${String(selectedFeature.properties.id)}` : "Chưa chọn geometry"} +
+
+ {selectedFeature?.properties?.type ? `Type: ${String(selectedFeature.properties.type)}` : "Type: -"} +
+
+ } + /> +
); } diff --git a/src/app/user/submissions/[id]/page.tsx b/src/app/user/submissions/[id]/page.tsx deleted file mode 100644 index eb67587..0000000 --- a/src/app/user/submissions/[id]/page.tsx +++ /dev/null @@ -1,330 +0,0 @@ -"use client"; - -import { useEffect, useMemo, useState } from "react"; -import { useParams } from "next/navigation"; -import { toast } from "sonner"; - -import PageBreadcrumb from "@/components/common/PageBreadCrumb"; -import ComponentCard from "@/components/common/ComponentCard"; -import Badge from "@/components/ui/badge/Badge"; -import Button from "@/components/ui/button/Button"; - -import Map from "@/uhm/components/Map"; -import { DEFAULT_BACKGROUND_LAYER_VISIBILITY } from "@/uhm/lib/backgroundLayers"; -import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/geo/constants"; -import { fetchSectionCommits } from "@/uhm/api/sections"; -import { normalizeEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot"; -import type { EditorSnapshot, SectionCommit } from "@/uhm/types/sections"; -import type { EntitySnapshot } from "@/uhm/types/entities"; - -import type { Submission } from "@/interface/submission"; -import { apiGetSubmissionById } from "@/service/submissionService"; -import type { Project } from "@/interface/project"; -import { apiGetProjectDetail } from "@/service/projectService"; - -function formatTime(value?: string | null) { - if (!value) return "-"; - const d = new Date(value); - if (Number.isNaN(d.getTime())) return String(value); - return d.toLocaleString("vi-VN"); -} - -export default function SubmissionDetailPage() { - const params = useParams(); - const id = String(params.id || ""); - const [row, setRow] = useState(null); - const [project, setProject] = useState(null); - const [commits, setCommits] = useState([]); - const [snapshot, setSnapshot] = useState(null); - const [snapshotEntities, setSnapshotEntities] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [isLoadingExtras, setIsLoadingExtras] = useState(false); - - const headCommitSnapshotJson = useMemo(() => { - const headId = project?.latest_commit_id || null; - if (!headId) return null; - const head = commits.find((c) => c.id === headId) || null; - return (head as any)?.snapshot_json ?? null; - }, [commits, project?.latest_commit_id]); - - const draft = useMemo( - () => snapshot?.editor_feature_collection || EMPTY_FEATURE_COLLECTION, - [snapshot] - ); - - useEffect(() => { - let disposed = false; - async function load() { - if (!id) return; - try { - setIsLoading(true); - const res = await apiGetSubmissionById(id); - if (!disposed) setRow(res?.data || null); - } catch (err) { - console.error(err); - toast.error("Khong the tai submission."); - } finally { - if (!disposed) setIsLoading(false); - } - } - load(); - return () => { - disposed = true; - }; - }, [id]); - - useEffect(() => { - let disposed = false; - async function loadExtras() { - if (!row?.project_id) return; - try { - setIsLoadingExtras(true); - - const [projectRes, commitRows] = await Promise.all([ - apiGetProjectDetail(row.project_id), - fetchSectionCommits(row.project_id), - ]); - - if (disposed) return; - setProject(projectRes?.data || null); - setCommits(commitRows || []); - - const commit = (commitRows || []).find((c) => c.id === row.commit_id) || null; - const snap = normalizeEditorSnapshot(commit?.snapshot_json || null); - setSnapshot(snap); - setSnapshotEntities((snap?.entities || []) as EntitySnapshot[]); - } catch (err) { - console.error(err); - toast.error("Khong the tai thong tin project/commit."); - } finally { - if (!disposed) setIsLoadingExtras(false); - } - } - loadExtras(); - return () => { - disposed = true; - }; - }, [row?.commit_id, row?.project_id]); - - return ( -
- - -
- - {isLoading ? ( -
Dang tai...
- ) : row ? ( -
-
-
ID
-
{row.id}
-
-
-
Status
-
- - {row.status} - -
-
-
-
Project
-
{row.project_title || "-"}
-
{row.project_id}
-
-
-
Commit
-
{row.commit_id}
-
-
-
User
-
{row.user_id}
-
-
-
Created
-
{formatTime(row.created_at)}
-
-
-
Reviewed by
-
{row.reviewed_by || "-"}
-
-
-
Reviewed at
-
{formatTime(row.reviewed_at)}
-
-
-
Review note
-
{row.review_note || "-"}
-
-
-
Content
-
{row.content || "-"}
-
- -
- -
-
- ) : ( -
Khong tim thay submission.
- )} -
-
- - {row ? ( -
- -
-
- {}} - backgroundVisibility={DEFAULT_BACKGROUND_LAYER_VISIBILITY} - allowGeometryEditing={false} - respectBindingFilter={false} - height="320px" - fitToDraftBounds - fitBoundsKey={row.id} - /> -
- {isLoadingExtras ? ( -
Dang tai snapshot/commits...
- ) : snapshot ? ( -
Da tai snapshot.
- ) : ( -
- Khong tim thay snapshot cho commit nay. -
- )} -
-
- - -
- {snapshotEntities.length === 0 ? ( -
Khong co entities trong snapshot.
- ) : ( -
-
-
-
Op
-
Name
-
Entity ID
-
-
- {snapshotEntities.map((e) => ( -
-
- - {e.operation} - -
-
{e.name || "-"}
-
{e.id}
-
- ))} -
-
-
- )} -
-
-
- ) : null} - - {row ? ( -
- -
-
-                {JSON.stringify(headCommitSnapshotJson, null, 2)}
-              
-
-
- - -
- {!project ? ( -
Khong co du lieu project.
- ) : (project.members || []).length === 0 ? ( -
Khong co thanh vien.
- ) : ( -
-
-
-
Member
-
Role
-
User ID
-
-
- {(project.members || []).map((m) => ( -
-
{m.display_name || "-"}
-
- - {m.role} - -
-
{m.user_id}
-
- ))} -
-
-
- )} -
-
-
- ) : null} - - {row ? ( -
- -
- {commits.length === 0 ? ( -
Khong co commits.
- ) : ( -
-
-
-
Commit
-
Title
-
Created
-
User
-
-
- {commits.map((c) => { - const isTarget = c.id === row.commit_id; - return ( -
-
- {isTarget ? {c.id} : c.id} -
-
{c.edit_summary || "-"}
-
{formatTime(c.created_at)}
-
{c.user_id}
-
- ); - })} -
-
-
- )} -
-
-
- ) : null} -
- ); -} diff --git a/src/app/user/submissions/page.tsx b/src/app/user/submissions/page.tsx deleted file mode 100644 index d6ac461..0000000 --- a/src/app/user/submissions/page.tsx +++ /dev/null @@ -1,326 +0,0 @@ -"use client"; - -import { useEffect, useMemo, useState } from "react"; -import Link from "next/link"; -import { toast } from "sonner"; - -import PageBreadcrumb from "@/components/common/PageBreadCrumb"; -import ComponentCard from "@/components/common/ComponentCard"; -import Badge from "@/components/ui/badge/Badge"; -import Button from "@/components/ui/button/Button"; -import Label from "@/components/form/Label"; -import { Modal } from "@/components/ui/modal"; -import { useModal } from "@/hooks/useModal"; - -import type { Submission, SubmissionStatus } from "@/interface/submission"; -import { apiSearchSubmissions, apiUpdateSubmissionStatus } from "@/service/submissionService"; - -type Decision = "APPROVED" | "REJECTED"; - -function statusBadge(status: SubmissionStatus) { - switch (status) { - case "PENDING": - return ( - - PENDING - - ); - case "APPROVED": - return ( - - APPROVED - - ); - case "REJECTED": - return ( - - REJECTED - - ); - default: - return ( - - {String(status || "UNKNOWN")} - - ); - } -} - -function formatTime(value?: string | null) { - if (!value) return "-"; - const d = new Date(value); - if (Number.isNaN(d.getTime())) return String(value); - return d.toLocaleString("vi-VN"); -} - -export default function SubmissionsPage() { - const [items, setItems] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [page, setPage] = useState(1); - const limit = 20; - const [totalPages, setTotalPages] = useState(1); - - const [search, setSearch] = useState(""); - const [projectId, setProjectId] = useState(""); - const [status, setStatus] = useState<"ALL" | "PENDING" | "APPROVED" | "REJECTED">("PENDING"); - - const { isOpen, openModal, closeModal } = useModal(); - const [active, setActive] = useState(null); - const [decision, setDecision] = useState("APPROVED"); - const [reviewNote, setReviewNote] = useState(""); - const [isSubmitting, setIsSubmitting] = useState(false); - - const query = useMemo(() => { - const trimmedSearch = search.trim(); - const trimmedProject = projectId.trim(); - return { - page, - limit, - project_id: trimmedProject.length ? trimmedProject : undefined, - search: trimmedSearch.length ? trimmedSearch : undefined, - statuses: status === "ALL" ? undefined : ([status] as any), - sort: "created_at" as const, - }; - }, [limit, page, projectId, search, status]); - - const fetchList = async () => { - try { - setIsLoading(true); - const res = await apiSearchSubmissions(query); - const payload = res?.data; - const rows = payload?.data || []; - setItems(rows); - setTotalPages(payload?.pagination?.total_pages || 1); - } catch (err) { - console.error(err); - toast.error("Khong the tai danh sach submissions."); - } finally { - setIsLoading(false); - } - }; - - useEffect(() => { - fetchList(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [query]); - - const openReview = (row: Submission, nextDecision: Decision) => { - setActive(row); - setDecision(nextDecision); - setReviewNote(""); - openModal(); - }; - - const submitDecision = async () => { - if (!active) return; - const note = reviewNote.trim(); - if (note.length < 10) { - toast.error("Review note toi thieu 10 ky tu."); - return; - } - - try { - setIsSubmitting(true); - await apiUpdateSubmissionStatus(active.id, { status: decision, review_note: note }); - toast.success(decision === "APPROVED" ? "Da duyet submission." : "Da tu choi submission."); - closeModal(); - await fetchList(); - } catch (err: any) { - console.error(err); - toast.error(err?.response?.data?.message || "Cap nhat trang thai that bai."); - } finally { - setIsSubmitting(false); - } - }; - - return ( -
- - -
- -
-
- - { - setPage(1); - setSearch(e.target.value); - }} - placeholder="Tim theo keyword (>= 2 ky tu)" - className="h-11 w-full rounded-xl border border-gray-200 bg-transparent px-4 py-2.5 text-sm text-gray-800 outline-none focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:text-white/90 dark:focus:border-brand-800" - /> -
-
- - { - setPage(1); - setProjectId(e.target.value); - }} - placeholder="UUID" - className="h-11 w-full rounded-xl border border-gray-200 bg-transparent px-4 py-2.5 text-sm text-gray-800 outline-none focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:text-white/90 dark:focus:border-brand-800" - /> -
-
- - -
-
- -
- {isLoading ? ( -
-
-
- ) : null} - -
-
-
-
Project
-
Submitter
-
Status
-
Created
-
Actions
-
- -
- {items.length === 0 ? ( -
Khong co submissions.
- ) : null} - {items.map((row) => ( -
{ - const target = e.target as HTMLElement | null; - if (target && target.closest("button")) return; - window.location.href = `/user/submissions/${row.id}`; - }} - role="link" - tabIndex={0} - onKeyDown={(e) => { - if (e.key === "Enter") window.location.href = `/user/submissions/${row.id}`; - }} - > -
-
- {row.project_title || row.project_id} -
-
- Submission:{" "} - - {row.id} - -
-
- Commit: {row.commit_id} -
-
-
- {row.user?.display_name || row.user?.email || row.user_id} -
-
{statusBadge(row.status)}
-
{formatTime(row.created_at)}
-
- - {row.status === "PENDING" ? ( - <> - - - - ) : ( - - )} -
-
- ))} -
-
-
- -
-
- Page {page} / {totalPages} -
-
- - -
-
-
- -
- - -
-

- {decision === "APPROVED" ? "Duyet submission" : "Tu choi submission"} -

-
- {active?.id} -
- -
-
- -