diff --git a/api.ts b/api.ts index d3069b2..ee28e72 100644 --- a/api.ts +++ b/api.ts @@ -58,4 +58,10 @@ export const API = { GET_COMMITS: (id: number | string) => `${API_URL_ROOT}/projects/${id}/commits`, RESTORE_COMMIT: (id: number | string) => `${API_URL_ROOT}/projects/${id}/commits/restore`, }, -} \ No newline at end of file + Submission: { + SEARCH: `${API_URL_ROOT}/submissions`, + GET_BY_ID: (id: number | string) => `${API_URL_ROOT}/submissions/${id}`, + UPDATE_STATUS: (id: number | string) => `${API_URL_ROOT}/submissions/${id}/status`, + DELETE: (id: number | string) => `${API_URL_ROOT}/submissions/${id}`, + }, +} diff --git a/package-lock.json b/package-lock.json index a135486..ff183d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,9 +23,11 @@ "autoprefixer": "^10.4.22", "axios": "^1.14.0", "flatpickr": "^4.6.13", + "maplibre-gl": "^5.20.2", "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", @@ -95,6 +97,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1765,6 +1768,24 @@ "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", @@ -1945,6 +1966,7 @@ "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz", "integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==", "license": "MIT", + "peer": true, "dependencies": { "preact": "~10.12.1" } @@ -2562,6 +2584,111 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", + "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.1.0.tgz", + "integrity": "sha512-uFJhNh36BR4OCuWIEiWaEix9CA2WzT6CAIcqVjWYpnx8+QDtS+oC4QehRrx5cX4mgWs37MmKnwUejeHxVymzNg==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz", + "integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.1" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/geojson-vt": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.0.tgz", + "integrity": "sha512-2eIY4gZxeKIVOZVNkAMb+5NgXhgsMQpOveTQAvnp53LYqHGJZDidk7Ew0Tged9PThidpbS+NFTh0g4zivhPDzQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "24.8.1", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.8.1.tgz", + "integrity": "sha512-zxa92qF96ZNojLxeAjnaRpjVCy+swoUNJvDhtpC90k7u5F0TMr4GmvNqMKvYrMoPB8d7gRSXbMG1hBbmgESIsw==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "rw": "^1.3.3", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/mlt": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.9.tgz", + "integrity": "sha512-g/tD8EYJB97udq33ipuJ9a4Q7fcbZnTEnUrgnEc/tLMmEL+zaCbR+X5fkDBO2dgpaAMsLH179qE3UXg2N0Nc/g==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0" + } + }, + "node_modules/@maplibre/vt-pbf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.0.tgz", + "integrity": "sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/vector-tile": "^2.0.4", + "@maplibre/geojson-vt": "^5.0.4", + "@types/geojson": "^7946.0.16", + "@types/supercluster": "^7.1.3", + "pbf": "^4.0.1", + "supercluster": "^8.0.1" + } + }, + "node_modules/@maplibre/vt-pbf/node_modules/@maplibre/geojson-vt": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz", + "integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==", + "license": "ISC" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -2894,6 +3021,7 @@ "resolved": "https://registry.npmjs.org/@svgdotjs/svg.js/-/svg.js-3.2.5.tgz", "integrity": "sha512-/VNHWYhNu+BS7ktbYoVGrCmsXDh+chFMaONMwGNdIBcFHrWqk2jY8fNyr3DLdtQUIalvkPfM554ZSFa3dm3nxQ==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/Fuzzyma" @@ -2917,6 +3045,7 @@ "resolved": "https://registry.npmjs.org/@svgdotjs/svg.select.js/-/svg.select.js-4.0.3.tgz", "integrity": "sha512-qkMgso1sd2hXKd1FZ1weO7ANq12sNmQJeGDjs46QwDVsxSRcHmvWKL2NDF7Yimpwf3sl5esOLkPqtV2bQ3v/Jg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 14.18" }, @@ -3093,6 +3222,7 @@ "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -3482,6 +3612,12 @@ "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", @@ -3489,6 +3625,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -3509,6 +3651,7 @@ "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3519,6 +3662,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3529,6 +3673,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3543,6 +3688,15 @@ "@types/react": "*" } }, + "node_modules/@types/supercluster": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", + "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -3594,6 +3748,7 @@ "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.0", "@typescript-eslint/types": "8.58.0", @@ -4125,6 +4280,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4180,6 +4336,7 @@ "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-4.7.0.tgz", "integrity": "sha512-iZSrrBGvVlL+nt2B1NpqfDuBZ9jX61X9I2+XV0hlYXHtTwhwLTHDKGXjNXAgFBDLuvSYCB/rq2nPWVPRv2DrGA==", "license": "MIT", + "peer": true, "dependencies": { "@svgdotjs/svg.draggable.js": "^3.0.4", "@svgdotjs/svg.filter.js": "^3.0.8", @@ -4592,6 +4749,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -4698,6 +4856,12 @@ ], "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", @@ -4727,6 +4891,15 @@ "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", @@ -4926,6 +5099,133 @@ "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", @@ -5067,6 +5367,15 @@ "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", @@ -5109,6 +5418,15 @@ "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", @@ -5193,6 +5511,12 @@ "node": ">= 0.4" } }, + "node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "license": "ISC" + }, "node_modules/electron-to-chromium": { "version": "1.5.329", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.329.tgz", @@ -5444,6 +5768,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5629,6 +5954,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -6215,6 +6541,12 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -6922,7 +7254,8 @@ "version": "3.7.1", "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/js-tokens": { "version": "4.0.0", @@ -6984,6 +7317,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -7013,6 +7352,12 @@ "node": ">=4.0" } }, + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", + "license": "ISC" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -7403,6 +7748,40 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/maplibre-gl": { + "version": "5.24.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz", + "integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.1.0", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^2.0.4", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/geojson-vt": "^6.1.0", + "@maplibre/maplibre-gl-style-spec": "^24.8.1", + "@maplibre/mlt": "^1.1.8", + "@maplibre/vt-pbf": "^4.3.0", + "@types/geojson": "^7946.0.16", + "earcut": "^3.0.2", + "gl-matrix": "^3.4.4", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^4.0.1", + "potpack": "^2.1.0", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -7490,7 +7869,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -7503,6 +7881,12 @@ "dev": true, "license": "MIT" }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -7939,6 +8323,18 @@ "node": ">=8" } }, + "node_modules/pbf": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", + "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7987,6 +8383,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -8002,6 +8399,12 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, "node_modules/preact": { "version": "10.12.1", "resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz", @@ -8033,6 +8436,12 @@ "react-is": "^16.13.1" } }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", + "license": "MIT" + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -8073,6 +8482,12 @@ ], "license": "MIT" }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, "node_modules/quill": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz", @@ -8093,6 +8508,7 @@ "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz", "integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==", "license": "MIT", + "peer": true, "dependencies": { "fast-diff": "^1.3.0", "lodash.clonedeep": "^4.5.0", @@ -8107,6 +8523,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -8124,6 +8541,27 @@ "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-dnd": { "version": "16.0.1", "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz", @@ -8168,6 +8606,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -8198,6 +8637,12 @@ "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", @@ -8218,6 +8663,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -8240,7 +8686,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -8400,6 +8847,15 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -8435,6 +8891,12 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -8932,6 +9394,15 @@ } } }, + "node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -9034,7 +9505,8 @@ "version": "4.2.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.2", @@ -9090,6 +9562,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9097,6 +9570,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -9252,6 +9731,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9438,6 +9918,25 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "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/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", @@ -9605,6 +10104,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 1903a1c..950118c 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,11 @@ "autoprefixer": "^10.4.22", "axios": "^1.14.0", "flatpickr": "^4.6.13", + "maplibre-gl": "^5.20.2", "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/public/point.png b/public/point.png new file mode 100644 index 0000000..99b5c6e Binary files /dev/null and b/public/point.png differ diff --git a/src/app/editor/[id]/featureCommands.ts b/src/app/editor/[id]/featureCommands.ts new file mode 100644 index 0000000..92c7829 --- /dev/null +++ b/src/app/editor/[id]/featureCommands.ts @@ -0,0 +1,114 @@ +"use client"; + +import { useCallback } from "react"; +import type { Dispatch, SetStateAction } from "react"; +import type { Entity } from "@/uhm/types/entities"; +import type { Feature, FeatureProperties } from "@/uhm/types/geo"; +import { ApiError } from "@/uhm/api/http"; +import { buildFeatureEntityPatch } from "@/uhm/lib/editor/entity/entityBinding"; +import { buildGeometryMetadataPatch } from "@/uhm/lib/editor/geometry/geometryMetadata"; +import { uniqueEntityIds } from "@/uhm/lib/editor/snapshot/editorSnapshot"; +import type { GeometryMetaFormState } from "@/uhm/lib/editor/session/sessionTypes"; + +type EditorDraftApi = { + patchFeatureProperties: (id: FeatureProperties["id"], patch: Partial) => void; +}; + +type Options = { + editor: EditorDraftApi; + selectedFeature: Feature | null; + geometryMetaForm: GeometryMetaFormState; + setGeometryMetaForm: Dispatch>; + selectedGeometryEntityIds: string[]; + setSelectedGeometryEntityIds: Dispatch>; + entities: Entity[]; + setIsEntitySubmitting: Dispatch>; + setEntityFormStatus: Dispatch>; +}; + +export function useFeatureCommands(options: Options) { + const { + editor, + selectedFeature, + geometryMetaForm, + setGeometryMetaForm, + selectedGeometryEntityIds, + setSelectedGeometryEntityIds, + entities, + setIsEntitySubmitting, + setEntityFormStatus, + } = options; + + const applyGeometryMetadata = useCallback(async () => { + if (!selectedFeature) { + setEntityFormStatus("Hãy chọn một geometry trước."); + return; + } + + let metadata; + try { + metadata = buildGeometryMetadataPatch(geometryMetaForm); + } catch (err) { + setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ."); + return; + } + + setIsEntitySubmitting(true); + setEntityFormStatus(null); + try { + 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."); + } finally { + setIsEntitySubmitting(false); + } + }, [ + editor, + geometryMetaForm, + selectedFeature, + setEntityFormStatus, + setGeometryMetaForm, + setIsEntitySubmitting, + ]); + + const applyEntitiesToSelectedGeometry = useCallback(async () => { + if (!selectedFeature) { + setEntityFormStatus("Hãy chọn một geometry trước."); + return; + } + + const entityIds = uniqueEntityIds(selectedGeometryEntityIds); + setIsEntitySubmitting(true); + setEntityFormStatus(null); + try { + editor.patchFeatureProperties( + selectedFeature.properties.id, + buildFeatureEntityPatch(selectedFeature, entityIds, entities) + ); + setSelectedGeometryEntityIds(entityIds); + setEntityFormStatus("Đã cập nhật danh sách entity. Commit khi sẵn sàng."); + } catch (err) { + if (err instanceof ApiError) { + setEntityFormStatus(`Lưu thất bại: ${err.body}`); + } else { + setEntityFormStatus("Lưu thất bại."); + } + } finally { + setIsEntitySubmitting(false); + } + }, [ + editor, + entities, + selectedFeature, + selectedGeometryEntityIds, + setEntityFormStatus, + setIsEntitySubmitting, + setSelectedGeometryEntityIds, + ]); + + return { + applyGeometryMetadata, + applyEntitiesToSelectedGeometry, + }; +} + diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx new file mode 100644 index 0000000..bf861d8 --- /dev/null +++ b/src/app/editor/[id]/page.tsx @@ -0,0 +1,794 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useParams, useRouter } from "next/navigation"; +import Map from "@/uhm/components/Map"; +import Editor from "@/uhm/components/Editor"; +import BackgroundLayersPanel from "@/uhm/components/BackgroundLayersPanel"; +import TimelineBar from "@/uhm/components/TimelineBar"; +import SelectedGeometryPanel from "@/uhm/components/SelectedGeometryPanel"; +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 { + Feature, + useEditorState, +} from "@/uhm/lib/useEditorState"; +import { + BackgroundLayerId, + BackgroundLayerVisibility, + DEFAULT_BACKGROUND_LAYER_VISIBILITY, + 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, + useEditorSessionState, +} from "@/uhm/lib/useEditorSessionState"; +import { + getDefaultTypeIdForFeature, + normalizeFeatureBindingIds, + normalizeFeatureEntityIds, + uniqueEntityIds, +} from "@/uhm/lib/editor/snapshot/editorSnapshot"; +import { + buildClientEntityId, + formatEntityNamesForDisplay, + mergeEntitiesWithPending, + mergeEntitySearchResults, +} from "@/uhm/lib/editor/entity/entityBinding"; +import { + formatBindingIdsForDisplay, +} from "@/uhm/lib/editor/geometry/geometryMetadata"; +import { + loadBackgroundLayerVisibilityFromStorage, + 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 { useFeatureCommands } from "./featureCommands"; + +const CURRENT_YEAR = new Date().getUTCFullYear(); +const DEFAULT_EDITOR_USER_ID = "local-editor"; + +export default function Page() { + const params = useParams(); + const router = useRouter(); + const projectId = String(params.id || ""); + const openedProjectIdRef = useRef(null); + + const { + mode, + setMode, + initialData, + setInitialData, + isSaving, + setIsSaving, + isSubmitting, + setIsSubmitting, + isOpeningSection, + setIsOpeningSection, + availableSections, + setAvailableSections, + selectedSectionId, + setSelectedSectionId, + newSectionTitle, + setNewSectionTitle, + commitTitle, + setCommitTitle, + commitNote, + setCommitNote, + editorUserIdInput, + setEditorUserIdInput, + activeSection, + setActiveSection, + sectionState, + setSectionState, + sectionCommits, + setSectionCommits, + lastSectionSnapshot, + setLastSectionSnapshot, + persistedEntities, + setPersistedEntities, + pendingEntityCreates, + setPendingEntityCreates, + createdEntities, + setCreatedEntities, + entityStatus, + setEntityStatus, + selectedFeatureId, + setSelectedFeatureId, + entityForm, + setEntityForm, + selectedGeometryEntityIds, + setSelectedGeometryEntityIds, + geometryMetaForm, + setGeometryMetaForm, + isEntitySubmitting, + setIsEntitySubmitting, + entityFormStatus, + setEntityFormStatus, + entitySearchQuery, + setEntitySearchQuery, + entitySearchResults, + setEntitySearchResults, + selectedSearchEntityId, + setSelectedSearchEntityId, + isEntitySearchLoading, + setIsEntitySearchLoading, + timelineYear, + setTimelineYear, + timelineDraftYear, + setTimelineDraftYear, + isTimelineLoading, + setIsTimelineLoading, + timelineStatus, + setTimelineStatus, + backgroundVisibility, + setBackgroundVisibility, + isBackgroundVisibilityReady, + setIsBackgroundVisibilityReady, + } = 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 editor = useEditorState(initialData); + const editorUserId = normalizeEditorUserId(editorUserIdInput); + const entities = useMemo( + () => mergeEntitiesWithPending(persistedEntities, pendingEntityCreates), + [persistedEntities, pendingEntityCreates] + ); + const selectedFeature = + selectedFeatureId === null + ? null + : editor.draft.features.find((feature) => + String(feature.properties.id) === String(selectedFeatureId) + ) || null; + + const createdGeometries = useMemo(() => { + const rows: Array<{ + id: string | number; + geometryType: string; + semanticType?: string | null; + entityNames: string[]; + }> = []; + + for (const change of editor.changes.values()) { + if (change.action !== "create") continue; + const feature = change.feature; + const entityNames = normalizeFeatureEntityIds(feature) + .map((entityId) => entities.find((entity) => entity.id === entityId)?.name || entityId); + + rows.push({ + id: feature.properties.id, + geometryType: feature.geometry.type, + semanticType: feature.properties.type || getDefaultTypeIdForFeature(feature), + entityNames, + }); + } + + return rows; + }, [editor.changes, entities]); + + const sectionCommands = useSectionCommands({ + editor, + editorUserId, + emptyFeatureCollection: EMPTY_FEATURE_COLLECTION, + activeSection, + sectionState, + selectedSectionId, + newSectionTitle, + pendingSaveCount: editor.changeCount + pendingEntityCreates.length, + pendingEntityCreates, + lastSectionSnapshot, + commitTitle, + commitNote, + setActiveSection, + setSelectedSectionId, + setSectionState, + setLastSectionSnapshot, + setInitialData, + setSectionCommits, + setPendingEntityCreates, + setCreatedEntities, + setEntityFormStatus, + setSelectedFeatureId, + setEntityStatus, + setIsSaving, + setIsSubmitting, + setIsOpeningSection, + setAvailableSections, + setNewSectionTitle, + setCommitTitle, + setCommitNote, + }); + const { + openSectionForEditing, + commitSection, + submitCurrentSection, + restoreCommit, + } = sectionCommands; + + const openProject = useCallback(async () => { + if (!projectId) return; + try { + setIsOpeningSection(true); + setEntityStatus(null); + await openSectionForEditing(projectId); + setEntityStatus(null); + } catch (err) { + if (err instanceof ApiError) { + if (err.status === 401 || err.status === 400) { + router.replace("/signin"); + return; + } + setEntityStatus(`Mở project thất bại: ${err.body || err.message}`); + } else { + console.error("Open project failed", err); + setEntityStatus("Mở project thất bại."); + } + } finally { + setIsOpeningSection(false); + } + }, [openSectionForEditing, projectId, router, setEntityStatus, setIsOpeningSection]); + + useEffect(() => { + let disposed = false; + + async function ensureAuthenticated() { + try { + await fetchCurrentUser(); + } catch (err) { + if (disposed) return; + // Follow the same behavior as the rest of FrontEndAdmin: unauthenticated -> /signin. + router.replace("/signin"); + } + } + + ensureAuthenticated(); + return () => { + disposed = true; + }; + }, [router]); + + useEffect(() => { + if (!projectId) return; + if (openedProjectIdRef.current === projectId) return; + + openProject() + .then(() => { + openedProjectIdRef.current = projectId; + }) + .catch(() => { + // allow retry if openProject threw outside its try/catch (should be rare) + openedProjectIdRef.current = null; + }); + }, [openProject]); + + useEffect(() => { + let disposed = false; + + async function loadEntities() { + try { + const rows = await fetchEntities(); + if (disposed) return; + + setPersistedEntities(rows); + setEntityStatus(null); + } catch (err) { + if (disposed) return; + console.error("Load entities failed", err); + setEntityStatus("Không tải được danh sách entity."); + } + } + + loadEntities(); + + return () => { + disposed = true; + }; + }, [setEntityStatus, setPersistedEntities]); + + useEffect(() => { + if (!selectedFeature) { + setEntitySearchResults([]); + setSelectedSearchEntityId(null); + setIsEntitySearchLoading(false); + return; + } + + const keyword = entitySearchQuery.trim(); + if (!keyword.length) { + setEntitySearchResults([]); + setSelectedSearchEntityId(null); + setIsEntitySearchLoading(false); + return; + } + + let disposed = false; + const requestId = ++entitySearchRequestRef.current; + const timeoutId = window.setTimeout(async () => { + setIsEntitySearchLoading(true); + try { + const rows = await searchEntitiesByName(keyword, { limit: 30 }); + if (disposed || requestId !== entitySearchRequestRef.current) return; + + const pendingMatches = pendingEntityCreates + .filter((entity) => + entity.name.toLowerCase().includes(keyword.toLowerCase()) || + (entity.slug || "").toLowerCase().includes(keyword.toLowerCase()) + ) + .map((entity) => ({ + id: entity.id, + name: entity.name, + slug: entity.slug, + type_id: entity.type_id, + status: entity.status, + geometry_count: 0, + })); + + const mergedRows = mergeEntitySearchResults(rows, pendingMatches); + setEntitySearchResults(mergedRows); + setSelectedSearchEntityId((prev) => + prev && 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); + } finally { + if (!disposed && requestId === entitySearchRequestRef.current) { + setIsEntitySearchLoading(false); + } + } + }, 220); + + return () => { + disposed = true; + window.clearTimeout(timeoutId); + }; + }, [ + entitySearchQuery, + selectedFeature, + pendingEntityCreates, + setEntitySearchResults, + setIsEntitySearchLoading, + setSelectedSearchEntityId, + ]); + + useEffect(() => { + if (selectedFeatureId === null) return; + const stillExists = editor.draft.features.some((feature) => + String(feature.properties.id) === String(selectedFeatureId) + ); + if (!stillExists) { + setSelectedFeatureId(null); + } + }, [editor.draft, selectedFeatureId, setSelectedFeatureId]); + + useEffect(() => { + if (!selectedFeature) { + setSelectedGeometryEntityIds([]); + setGeometryMetaForm({ + time_start: "", + time_end: "", + binding: "", + }); + setEntitySearchQuery(""); + setEntitySearchResults([]); + setSelectedSearchEntityId(null); + setEntityFormStatus(null); + return; + } + + const featureEntityIds = normalizeFeatureEntityIds(selectedFeature); + setSelectedGeometryEntityIds(featureEntityIds); + setGeometryMetaForm({ + time_start: selectedFeature.properties.time_start != null + ? String(selectedFeature.properties.time_start) + : "", + time_end: selectedFeature.properties.time_end != null + ? String(selectedFeature.properties.time_end) + : "", + binding: normalizeFeatureBindingIds(selectedFeature).join(", "), + }); + setEntitySearchQuery(""); + setEntitySearchResults([]); + setSelectedSearchEntityId(null); + setEntityFormStatus(null); + }, [ + 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]); + + 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 + ) => { + 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))); + }; + + const handleEntityFormChange = (key: keyof EntityFormState, value: string) => { + setEntityForm((prev) => ({ ...prev, [key]: value })); + }; + + const handleGeometryMetaFormChange = (key: "time_start" | "time_end" | "binding", value: string) => { + setGeometryMetaForm((prev) => ({ ...prev, [key]: value })); + }; + + const handleEntityIdsChange = (values: string[]) => { + 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."); + return; + } + + const next = uniqueEntityIds([...selectedGeometryEntityIds, entityId]); + setSelectedGeometryEntityIds(next); + setSelectedSearchEntityId(null); + setEntityFormStatus(null); + }; + + const featureCommands = useFeatureCommands({ + editor, + selectedFeature, + geometryMetaForm, + setGeometryMetaForm, + selectedGeometryEntityIds, + setSelectedGeometryEntityIds, + entities, + setIsEntitySubmitting, + setEntityFormStatus, + }); + + const handleCreateEntityOnly = async () => { + const name = entityForm.name.trim(); + if (!name) { + setEntityFormStatus("Tên entity là bắt buộc."); + return; + } + + const slug = entityForm.slug.trim() || null; + const typeId = entityForm.type_id || DEFAULT_ENTITY_TYPE_ID; + const normalizedName = name.toLowerCase(); + const duplicatedName = entities.some((entity) => entity.name.trim().toLowerCase() === normalizedName); + if (duplicatedName) { + setEntityFormStatus("Tên entity đã tồn tại."); + return; + } + if (slug) { + const normalizedSlug = slug.toLowerCase(); + const duplicatedSlug = entities.some((entity) => + (entity.slug || "").trim().toLowerCase() === normalizedSlug + ); + if (duplicatedSlug) { + setEntityFormStatus("Slug entity đã tồn tại."); + return; + } + } + + const entityId = buildClientEntityId(); + const pendingCreate: PendingEntityCreate = { + id: entityId, + name, + slug, + type_id: typeId, + status: 1, + }; + + setIsEntitySubmitting(true); + setEntityFormStatus(null); + try { + setPendingEntityCreates((prev) => [pendingCreate, ...prev]); + setCreatedEntities((prev) => { + if (prev.some((item) => item.id === pendingCreate.id)) return prev; + return [ + { + id: pendingCreate.id, + name: pendingCreate.name, + type_id: pendingCreate.type_id || null, + }, + ...prev, + ]; + }); + + setEntityForm((prev) => ({ + ...prev, + name: "", + slug: "", + })); + setEntityStatus(null); + setEntityFormStatus("Đã thêm entity mới vào danh sách chờ Commit."); + + if (selectedFeature) { + setEntitySearchQuery(pendingCreate.name); + setSelectedSearchEntityId(pendingCreate.id); + } + } finally { + setIsEntitySubmitting(false); + } + }; + + const pendingSaveCount = editor.changeCount + pendingEntityCreates.length; + 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); + setSelectedFeatureId(feature.properties.id); + }; + + return ( +
+ + +
+ {isBackgroundVisibilityReady ? ( + + ) : ( +
+ )} + +
+ + + } + /> +
+ ); +} + +function normalizeEditorUserId(value: string): string { + const normalized = value.trim(); + return normalized || DEFAULT_EDITOR_USER_ID; +} + +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"]; +} diff --git a/src/app/editor/page.tsx b/src/app/editor/page.tsx new file mode 100644 index 0000000..cff2e76 --- /dev/null +++ b/src/app/editor/page.tsx @@ -0,0 +1,7 @@ +import { redirect } from "next/navigation"; + +export default function EditorIndexPage() { + // Editor must be opened from a specific project (see /user/projects). + redirect("/user/projects"); +} + diff --git a/src/app/user/projects/[id]/page.tsx b/src/app/user/projects/[id]/page.tsx index 365de43..5c2bc7d 100644 --- a/src/app/user/projects/[id]/page.tsx +++ b/src/app/user/projects/[id]/page.tsx @@ -10,6 +10,7 @@ import Swal from "sweetalert2"; import PageBreadcrumb from "@/components/common/PageBreadCrumb"; import { apiAddProjectMember, apiChangeProjectOwner, apiDeleteProject, apiGetProjectDetail, apiRemoveProjectMember, apiUpdateProject, apiUpdateProjectMemberRole } from "@/service/projectService"; import Loading from "@/app/loading"; +import Button from "@/components/ui/button/Button"; type TabType = "overview" | "members" | "settings"; @@ -256,7 +257,7 @@ export default function ProjectDetailsPage() {
-
+
{[ { id: "overview", @@ -305,6 +306,11 @@ export default function ProjectDetailsPage() { )} ))} + +
+
diff --git a/src/app/user/projects/page.tsx b/src/app/user/projects/page.tsx index 7ee2f43..2f4553e 100644 --- a/src/app/user/projects/page.tsx +++ b/src/app/user/projects/page.tsx @@ -217,7 +217,15 @@ export default function ProjectsPage() { -
+
+ +
{project.members && project.members.length > 0 ? ( <> @@ -330,4 +338,4 @@ export default function ProjectsPage() {
); -} \ No newline at end of file +} diff --git a/src/app/user/submissions/[id]/page.tsx b/src/app/user/submissions/[id]/page.tsx new file mode 100644 index 0000000..776afd4 --- /dev/null +++ b/src/app/user/submissions/[id]/page.tsx @@ -0,0 +1,331 @@ +"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 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 = (commit?.snapshot_json || null) as EditorSnapshot | 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 ? ( +
+ Snapshot schema_version: {snapshot.schema_version} +
+ ) : ( +
+ 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 new file mode 100644 index 0000000..d6ac461 --- /dev/null +++ b/src/app/user/submissions/page.tsx @@ -0,0 +1,326 @@ +"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} +
+ +
+
+ +