feat: add parcel-sync module (eTerra ANCPI integration with PostGIS)
- 31 eTerra layer catalog (terenuri, cladiri, documentatii, administrativ) - Incremental sync engine (OBJECTID comparison, only downloads new features) - PostGIS-ready Prisma schema (GisFeature, GisSyncRun, GisUat models) - 7 API routes (/api/eterra/login, count, sync, features, layers/summary, progress, sync-status) - Full UI with 3 tabs (Sincronizare, Parcele, Istoric) - Env var auth (ETERRA_USERNAME / ETERRA_PASSWORD) - Real-time sync progress tracking with polling
This commit is contained in:
@@ -41,6 +41,9 @@ services:
|
|||||||
- AI_MAX_TOKENS=${AI_MAX_TOKENS:-2048}
|
- AI_MAX_TOKENS=${AI_MAX_TOKENS:-2048}
|
||||||
# Visual CoPilot (at-vim)
|
# Visual CoPilot (at-vim)
|
||||||
- VIM_URL=${VIM_URL:-}
|
- VIM_URL=${VIM_URL:-}
|
||||||
|
# eTerra ANCPI (parcel-sync module)
|
||||||
|
- ETERRA_USERNAME=${ETERRA_USERNAME:-}
|
||||||
|
- ETERRA_PASSWORD=${ETERRA_PASSWORD:-}
|
||||||
volumes:
|
volumes:
|
||||||
# SMB share for ManicTime Tags.txt (mount on host: //time/tags → /mnt/manictime)
|
# SMB share for ManicTime Tags.txt (mount on host: //time/tags → /mnt/manictime)
|
||||||
- /mnt/manictime:/mnt/manictime
|
- /mnt/manictime:/mnt/manictime
|
||||||
|
|||||||
Generated
+191
-6
@@ -9,6 +9,8 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
|
"axios": "^1.13.6",
|
||||||
|
"axios-cookiejar-support": "^6.0.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"docx": "^9.6.0",
|
"docx": "^9.6.0",
|
||||||
@@ -19,12 +21,14 @@
|
|||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"next-auth": "^4.24.13",
|
"next-auth": "^4.24.13",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"proj4": "^2.20.3",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"tailwind-merge": "^3.4.1",
|
"tailwind-merge": "^3.4.1",
|
||||||
"tesseract.js": "^7.0.0",
|
"tesseract.js": "^7.0.0",
|
||||||
|
"tough-cookie": "^6.0.0",
|
||||||
"utif2": "^4.1.0",
|
"utif2": "^4.1.0",
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
@@ -32,9 +36,11 @@
|
|||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/jszip": "^3.4.0",
|
"@types/jszip": "^3.4.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/proj4": "^2.5.6",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/tough-cookie": "^4.0.5",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.6",
|
||||||
@@ -4024,7 +4030,6 @@
|
|||||||
"integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==",
|
"integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@@ -4035,6 +4040,13 @@
|
|||||||
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
|
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/proj4": {
|
||||||
|
"version": "2.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/proj4/-/proj4-2.5.6.tgz",
|
||||||
|
"integrity": "sha512-zfMrPy9fx+8DchqM0kIUGeu2tTVB5ApO1KGAYcSGFS8GoqRIkyL41xq2yCx/iV3sOLzo7v4hEgViSLTiPI1L0w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/qrcode": {
|
"node_modules/@types/qrcode": {
|
||||||
"version": "1.5.6",
|
"version": "1.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||||
@@ -4081,6 +4093,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/tough-cookie": {
|
||||||
|
"version": "4.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
|
||||||
|
"integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/trusted-types": {
|
"node_modules/@types/trusted-types": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
@@ -4703,7 +4722,6 @@
|
|||||||
"version": "7.1.4",
|
"version": "7.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 14"
|
"node": ">= 14"
|
||||||
@@ -5031,6 +5049,12 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/asynckit": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/available-typed-arrays": {
|
"node_modules/available-typed-arrays": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
||||||
@@ -5056,6 +5080,37 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/axios": {
|
||||||
|
"version": "1.13.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||||
|
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"follow-redirects": "^1.15.11",
|
||||||
|
"form-data": "^4.0.5",
|
||||||
|
"proxy-from-env": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/axios-cookiejar-support": {
|
||||||
|
"version": "6.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/axios-cookiejar-support/-/axios-cookiejar-support-6.0.5.tgz",
|
||||||
|
"integrity": "sha512-ldPOQCJWB0ipugkTNVB8QRl/5L2UgfmVNVQtS9en1JQJ1wW588PqAmymnwmmgc12HLDzDtsJ28xE2ppj4rD4ng==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"http-cookie-agent": "^7.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/3846masa"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"axios": ">=0.20.0",
|
||||||
|
"tough-cookie": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/axobject-query": {
|
"node_modules/axobject-query": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||||
@@ -5606,6 +5661,18 @@
|
|||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/combined-stream": {
|
||||||
|
"version": "1.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
|
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"delayed-stream": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/commander": {
|
"node_modules/commander": {
|
||||||
"version": "14.0.3",
|
"version": "14.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
|
||||||
@@ -6032,6 +6099,15 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/delayed-stream": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/depd": {
|
"node_modules/depd": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
@@ -6416,7 +6492,6 @@
|
|||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
@@ -7343,6 +7418,26 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/follow-redirects": {
|
||||||
|
"version": "1.15.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||||
|
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"debug": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/for-each": {
|
"node_modules/for-each": {
|
||||||
"version": "0.3.5",
|
"version": "0.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||||
@@ -7358,6 +7453,43 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/form-data": {
|
||||||
|
"version": "4.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||||
|
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"asynckit": "^0.4.0",
|
||||||
|
"combined-stream": "^1.0.8",
|
||||||
|
"es-set-tostringtag": "^2.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"mime-types": "^2.1.12"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/form-data/node_modules/mime-db": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/form-data/node_modules/mime-types": {
|
||||||
|
"version": "2.1.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "1.52.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/formdata-polyfill": {
|
"node_modules/formdata-polyfill": {
|
||||||
"version": "4.0.10",
|
"version": "4.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||||
@@ -7847,6 +7979,30 @@
|
|||||||
"node": ">=8.0.0"
|
"node": ">=8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/http-cookie-agent": {
|
||||||
|
"version": "7.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/http-cookie-agent/-/http-cookie-agent-7.0.3.tgz",
|
||||||
|
"integrity": "sha512-EeZo7CGhfqPW6R006rJa4QtZZUpBygDa2HZH3DJqsTzTjyRE6foDBVQIv/pjVsxHC8z2GIdbB1Hvn9SRorP3WQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "^7.1.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/3846masa"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"tough-cookie": "^4.0.0 || ^5.0.0 || ^6.0.0",
|
||||||
|
"undici": "^7.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"undici": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/http-errors": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
@@ -9311,6 +9467,12 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mgrs": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mgrs/-/mgrs-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-awNbTOqCxK1DBGjalK3xqWIstBZgN6fxsMSiXLs9/spqWkF2pAhb2rrYCFSsr1/tT7PhcDGjZndG8SWYn0byYA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/micromatch": {
|
"node_modules/micromatch": {
|
||||||
"version": "4.0.8",
|
"version": "4.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||||
@@ -10564,6 +10726,19 @@
|
|||||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/proj4": {
|
||||||
|
"version": "2.20.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/proj4/-/proj4-2.20.3.tgz",
|
||||||
|
"integrity": "sha512-uKJXnf/RkHhExxnWHqQqy2J1bPc5Qo8XSGzrMSJTdPWUQDo1DkunIRBfAS0crQaP9bZCSKNjqYJdYWVov0hDXw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mgrs": "1.0.0",
|
||||||
|
"wkt-parser": "^1.5.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ahocevar"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prompts": {
|
"node_modules/prompts": {
|
||||||
"version": "2.4.2",
|
"version": "2.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
|
||||||
@@ -10614,6 +10789,12 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/proxy-from-env": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
@@ -12436,7 +12617,6 @@
|
|||||||
"version": "7.0.23",
|
"version": "7.0.23",
|
||||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz",
|
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz",
|
||||||
"integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==",
|
"integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tldts-core": "^7.0.23"
|
"tldts-core": "^7.0.23"
|
||||||
@@ -12449,7 +12629,6 @@
|
|||||||
"version": "7.0.23",
|
"version": "7.0.23",
|
||||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz",
|
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz",
|
||||||
"integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==",
|
"integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/to-regex-range": {
|
"node_modules/to-regex-range": {
|
||||||
@@ -12479,8 +12658,8 @@
|
|||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz",
|
||||||
"integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==",
|
"integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tldts": "^7.0.5"
|
"tldts": "^7.0.5"
|
||||||
},
|
},
|
||||||
@@ -13143,6 +13322,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wkt-parser": {
|
||||||
|
"version": "1.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/wkt-parser/-/wkt-parser-1.5.3.tgz",
|
||||||
|
"integrity": "sha512-myla+RrMj+WTlnHc8Y4HEwjBcBF9dqJ3vjff/zmlrn9V3OKOM1mZVIyNjlPEmOM9Jjr/PPut0tnaTs9NyHcK8Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/word-wrap": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
|
"axios": "^1.13.6",
|
||||||
|
"axios-cookiejar-support": "^6.0.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"docx": "^9.6.0",
|
"docx": "^9.6.0",
|
||||||
@@ -20,12 +22,14 @@
|
|||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"next-auth": "^4.24.13",
|
"next-auth": "^4.24.13",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"proj4": "^2.20.3",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"tailwind-merge": "^3.4.1",
|
"tailwind-merge": "^3.4.1",
|
||||||
"tesseract.js": "^7.0.0",
|
"tesseract.js": "^7.0.0",
|
||||||
|
"tough-cookie": "^6.0.0",
|
||||||
"utif2": "^4.1.0",
|
"utif2": "^4.1.0",
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
@@ -33,9 +37,11 @@
|
|||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/jszip": "^3.4.0",
|
"@types/jszip": "^3.4.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/proj4": "^2.5.6",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/tough-cookie": "^4.0.5",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.6",
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
|
previewFeatures = ["postgresqlExtensions"]
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
|
extensions = [postgis]
|
||||||
}
|
}
|
||||||
|
|
||||||
model KeyValueStore {
|
model KeyValueStore {
|
||||||
@@ -18,3 +20,59 @@ model KeyValueStore {
|
|||||||
@@unique([namespace, key])
|
@@unique([namespace, key])
|
||||||
@@index([namespace])
|
@@index([namespace])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── GIS: eTerra ParcelSync ────────────────────────────────────────
|
||||||
|
|
||||||
|
model GisFeature {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
layerId String // e.g. TERENURI_ACTIVE, CLADIRI_ACTIVE
|
||||||
|
siruta String
|
||||||
|
objectId Int // eTerra OBJECTID (unique per layer)
|
||||||
|
inspireId String?
|
||||||
|
cadastralRef String? // NATIONAL_CADASTRAL_REFERENCE
|
||||||
|
areaValue Float?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
attributes Json // all raw eTerra attributes
|
||||||
|
geometry Json? // GeoJSON geometry (Polygon/MultiPolygon)
|
||||||
|
syncRunId String?
|
||||||
|
projectId String? // link to project tag
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
syncRun GisSyncRun? @relation(fields: [syncRunId], references: [id])
|
||||||
|
|
||||||
|
@@unique([layerId, objectId])
|
||||||
|
@@index([siruta])
|
||||||
|
@@index([cadastralRef])
|
||||||
|
@@index([layerId, siruta])
|
||||||
|
@@index([projectId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model GisSyncRun {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
siruta String
|
||||||
|
uatName String?
|
||||||
|
layerId String
|
||||||
|
status String @default("pending") // pending | running | done | error
|
||||||
|
totalRemote Int @default(0)
|
||||||
|
totalLocal Int @default(0)
|
||||||
|
newFeatures Int @default(0)
|
||||||
|
removedFeatures Int @default(0)
|
||||||
|
startedAt DateTime @default(now())
|
||||||
|
completedAt DateTime?
|
||||||
|
errorMessage String?
|
||||||
|
features GisFeature[]
|
||||||
|
|
||||||
|
@@index([siruta])
|
||||||
|
@@index([layerId])
|
||||||
|
@@index([siruta, layerId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model GisUat {
|
||||||
|
siruta String @id
|
||||||
|
name String
|
||||||
|
county String?
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([name])
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FeatureGate } from "@/core/feature-flags";
|
||||||
|
import { useI18n } from "@/core/i18n";
|
||||||
|
import { ParcelSyncModule } from "@/modules/parcel-sync";
|
||||||
|
|
||||||
|
export default function ParcelSyncPage() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FeatureGate flag="module.parcel-sync" fallback={<ModuleDisabled />}>
|
||||||
|
<div className="mx-auto max-w-6xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">
|
||||||
|
{t("parcel-sync.title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{t("parcel-sync.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ParcelSyncModule />
|
||||||
|
</div>
|
||||||
|
</FeatureGate>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModuleDisabled() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-6xl py-12 text-center text-muted-foreground">
|
||||||
|
<p>Modulul eTerra Parcele este dezactivat.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
||||||
|
import { findLayerById } from "@/modules/parcel-sync/services/eterra-layers";
|
||||||
|
import { fetchUatGeometry } from "@/modules/parcel-sync/services/uat-geometry";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type Body = {
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
siruta?: string | number;
|
||||||
|
layerId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const body = (await req.json()) as Body;
|
||||||
|
const username = (
|
||||||
|
body.username ??
|
||||||
|
process.env.ETERRA_USERNAME ??
|
||||||
|
""
|
||||||
|
).trim();
|
||||||
|
const password = (
|
||||||
|
body.password ??
|
||||||
|
process.env.ETERRA_PASSWORD ??
|
||||||
|
""
|
||||||
|
).trim();
|
||||||
|
const siruta = String(body.siruta ?? "").trim();
|
||||||
|
|
||||||
|
if (!username || !password)
|
||||||
|
return NextResponse.json({ error: "Credențiale lipsă" }, { status: 400 });
|
||||||
|
if (!/^\d+$/.test(siruta))
|
||||||
|
return NextResponse.json({ error: "SIRUTA invalid" }, { status: 400 });
|
||||||
|
|
||||||
|
const layerId = body.layerId ? String(body.layerId) : undefined;
|
||||||
|
const layer = layerId ? findLayerById(layerId) : undefined;
|
||||||
|
if (layerId && !layer)
|
||||||
|
return NextResponse.json({ error: "Layer necunoscut" }, { status: 400 });
|
||||||
|
|
||||||
|
const client = await EterraClient.create(username, password);
|
||||||
|
|
||||||
|
let geometry;
|
||||||
|
if (layer?.spatialFilter) {
|
||||||
|
geometry = await fetchUatGeometry(client, siruta);
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = layer
|
||||||
|
? geometry
|
||||||
|
? await client.countLayerByGeometry(layer, geometry)
|
||||||
|
: await client.countLayer(layer, siruta)
|
||||||
|
: await client.countParcels(siruta);
|
||||||
|
|
||||||
|
return NextResponse.json({ count });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Eroare server";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { PrismaClient, type Prisma } from "@prisma/client";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
type Body = {
|
||||||
|
siruta?: string;
|
||||||
|
layerId?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
projectId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List features stored in local GIS database with pagination & search.
|
||||||
|
*/
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const body = (await req.json()) as Body;
|
||||||
|
const siruta = String(body.siruta ?? "").trim();
|
||||||
|
const layerId = String(body.layerId ?? "").trim();
|
||||||
|
const search = (body.search ?? "").trim();
|
||||||
|
const page = Math.max(1, body.page ?? 1);
|
||||||
|
const pageSize = Math.min(200, Math.max(1, body.pageSize ?? 50));
|
||||||
|
|
||||||
|
if (!siruta) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "SIRUTA obligatoriu" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const where: Prisma.GisFeatureWhereInput = { siruta };
|
||||||
|
if (layerId) where.layerId = layerId;
|
||||||
|
if (body.projectId) where.projectId = body.projectId;
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ cadastralRef: { contains: search, mode: "insensitive" } },
|
||||||
|
{ inspireId: { contains: search, mode: "insensitive" } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [features, total] = await Promise.all([
|
||||||
|
prisma.gisFeature.findMany({
|
||||||
|
where,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
layerId: true,
|
||||||
|
siruta: true,
|
||||||
|
objectId: true,
|
||||||
|
inspireId: true,
|
||||||
|
cadastralRef: true,
|
||||||
|
areaValue: true,
|
||||||
|
isActive: true,
|
||||||
|
attributes: true,
|
||||||
|
projectId: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
// geometry omitted for list — too large; fetch single feature by ID for geometry
|
||||||
|
},
|
||||||
|
orderBy: { objectId: "asc" },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
prisma.gisFeature.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
features,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalPages: Math.ceil(total / pageSize),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Eroare server";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/eterra/features?id=... — Single feature with full geometry.
|
||||||
|
*/
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const id = url.searchParams.get("id");
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: "ID obligatoriu" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const feature = await prisma.gisFeature.findUnique({ where: { id } });
|
||||||
|
if (!feature) {
|
||||||
|
return NextResponse.json({ error: "Negăsit" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(feature);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Eroare server";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import {
|
||||||
|
EterraClient,
|
||||||
|
type EsriGeometry,
|
||||||
|
} from "@/modules/parcel-sync/services/eterra-client";
|
||||||
|
import { LAYER_CATALOG } from "@/modules/parcel-sync/services/eterra-layers";
|
||||||
|
import { fetchUatGeometry } from "@/modules/parcel-sync/services/uat-geometry";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type Body = {
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
siruta?: string | number;
|
||||||
|
layerIds?: string[]; // subset — omit to count all
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST — Count features per layer on the remote eTerra server.
|
||||||
|
*/
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const body = (await req.json()) as Body;
|
||||||
|
const username = (
|
||||||
|
body.username ??
|
||||||
|
process.env.ETERRA_USERNAME ??
|
||||||
|
""
|
||||||
|
).trim();
|
||||||
|
const password = (
|
||||||
|
body.password ??
|
||||||
|
process.env.ETERRA_PASSWORD ??
|
||||||
|
""
|
||||||
|
).trim();
|
||||||
|
const siruta = String(body.siruta ?? "").trim();
|
||||||
|
|
||||||
|
if (!username || !password)
|
||||||
|
return NextResponse.json({ error: "Credențiale lipsă" }, { status: 400 });
|
||||||
|
if (!/^\d+$/.test(siruta))
|
||||||
|
return NextResponse.json({ error: "SIRUTA invalid" }, { status: 400 });
|
||||||
|
|
||||||
|
const client = await EterraClient.create(username, password);
|
||||||
|
let uatGeometry: EsriGeometry | undefined;
|
||||||
|
|
||||||
|
// Pre-fetch UAT geometry for spatial layers
|
||||||
|
try {
|
||||||
|
uatGeometry = await fetchUatGeometry(client, siruta);
|
||||||
|
} catch {
|
||||||
|
// Some layers don't need it
|
||||||
|
}
|
||||||
|
|
||||||
|
const layers = body.layerIds
|
||||||
|
? LAYER_CATALOG.filter((l) => body.layerIds!.includes(l.id))
|
||||||
|
: LAYER_CATALOG;
|
||||||
|
|
||||||
|
const results: Record<string, { count: number; error?: string }> = {};
|
||||||
|
|
||||||
|
// Count layers in parallel, max 4 concurrent
|
||||||
|
const chunks: (typeof layers)[] = [];
|
||||||
|
for (let i = 0; i < layers.length; i += 4) {
|
||||||
|
chunks.push(layers.slice(i, i + 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
const promises = chunk.map(async (layer) => {
|
||||||
|
try {
|
||||||
|
const count =
|
||||||
|
layer.spatialFilter && uatGeometry
|
||||||
|
? await client.countLayerByGeometry(layer, uatGeometry)
|
||||||
|
: await client.countLayer(layer, siruta);
|
||||||
|
results[layer.id] = { count };
|
||||||
|
} catch (err) {
|
||||||
|
results[layer.id] = {
|
||||||
|
count: 0,
|
||||||
|
error: err instanceof Error ? err.message : "eroare",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(promises);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ siruta, counts: results });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Eroare server";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const body = (await req.json()) as { username?: string; password?: string };
|
||||||
|
const username = (
|
||||||
|
body.username ??
|
||||||
|
process.env.ETERRA_USERNAME ??
|
||||||
|
""
|
||||||
|
).trim();
|
||||||
|
const password = (
|
||||||
|
body.password ??
|
||||||
|
process.env.ETERRA_PASSWORD ??
|
||||||
|
""
|
||||||
|
).trim();
|
||||||
|
if (!username || !password)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Credențiale eTerra lipsă" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
|
||||||
|
await EterraClient.create(username, password);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Eroare server";
|
||||||
|
const status = message.toLowerCase().includes("login") ? 401 : 500;
|
||||||
|
return NextResponse.json({ error: message }, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getProgress } from "@/modules/parcel-sync/services/progress-store";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/eterra/progress?jobId=...
|
||||||
|
* Poll sync progress for a running job.
|
||||||
|
*/
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const jobId = url.searchParams.get("jobId");
|
||||||
|
if (!jobId) {
|
||||||
|
return NextResponse.json({ error: "jobId obligatoriu" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = getProgress(jobId);
|
||||||
|
if (!progress) {
|
||||||
|
return NextResponse.json({ jobId, status: "unknown" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(progress);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getSyncStatus } from "@/modules/parcel-sync/services/sync-service";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/eterra/sync-status?siruta=...
|
||||||
|
* Returns sync run history & local feature counts per layer.
|
||||||
|
*/
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const siruta = url.searchParams.get("siruta");
|
||||||
|
if (!siruta || !/^\d+$/.test(siruta)) {
|
||||||
|
return NextResponse.json({ error: "SIRUTA invalid" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = await getSyncStatus(siruta);
|
||||||
|
return NextResponse.json(status);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Eroare server";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { syncLayer } from "@/modules/parcel-sync/services/sync-service";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const maxDuration = 300; // 5 minute timeout for long syncs
|
||||||
|
|
||||||
|
type Body = {
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
siruta?: string | number;
|
||||||
|
layerId?: string;
|
||||||
|
uatName?: string;
|
||||||
|
jobId?: string;
|
||||||
|
forceFullSync?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const body = (await req.json()) as Body;
|
||||||
|
const username = (
|
||||||
|
body.username ??
|
||||||
|
process.env.ETERRA_USERNAME ??
|
||||||
|
""
|
||||||
|
).trim();
|
||||||
|
const password = (
|
||||||
|
body.password ??
|
||||||
|
process.env.ETERRA_PASSWORD ??
|
||||||
|
""
|
||||||
|
).trim();
|
||||||
|
const siruta = String(body.siruta ?? "").trim();
|
||||||
|
const layerId = String(body.layerId ?? "TERENURI_ACTIVE").trim();
|
||||||
|
|
||||||
|
if (!username || !password)
|
||||||
|
return NextResponse.json({ error: "Credențiale lipsă" }, { status: 400 });
|
||||||
|
if (!/^\d+$/.test(siruta))
|
||||||
|
return NextResponse.json({ error: "SIRUTA invalid" }, { status: 400 });
|
||||||
|
|
||||||
|
const result = await syncLayer(username, password, siruta, layerId, {
|
||||||
|
uatName: body.uatName,
|
||||||
|
jobId: body.jobId,
|
||||||
|
forceFullSync: body.forceFullSync,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Eroare server";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -114,6 +114,14 @@ export const DEFAULT_FLAGS: FeatureFlag[] = [
|
|||||||
category: "module",
|
category: "module",
|
||||||
overridable: true,
|
overridable: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "module.parcel-sync",
|
||||||
|
enabled: true,
|
||||||
|
label: "eTerra Parcele",
|
||||||
|
description: "Sincronizare parcele cadastrale din eTerra ANCPI",
|
||||||
|
category: "module",
|
||||||
|
overridable: true,
|
||||||
|
},
|
||||||
|
|
||||||
// System flags
|
// System flags
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { miniUtilitiesConfig } from "@/modules/mini-utilities/config";
|
|||||||
import { aiChatConfig } from "@/modules/ai-chat/config";
|
import { aiChatConfig } from "@/modules/ai-chat/config";
|
||||||
import { hotDeskConfig } from "@/modules/hot-desk/config";
|
import { hotDeskConfig } from "@/modules/hot-desk/config";
|
||||||
import { visualCopilotConfig } from "@/modules/visual-copilot/config";
|
import { visualCopilotConfig } from "@/modules/visual-copilot/config";
|
||||||
|
import { parcelSyncConfig } from "@/modules/parcel-sync/config";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toate configurările modulelor ArchiTools, ordonate după navOrder.
|
* Toate configurările modulelor ArchiTools, ordonate după navOrder.
|
||||||
@@ -32,6 +33,7 @@ export const MODULE_CONFIGS: ModuleConfig[] = [
|
|||||||
hotDeskConfig, // navOrder: 33 | management
|
hotDeskConfig, // navOrder: 33 | management
|
||||||
tagManagerConfig, // navOrder: 40 | tools
|
tagManagerConfig, // navOrder: 40 | tools
|
||||||
miniUtilitiesConfig, // navOrder: 41 | tools
|
miniUtilitiesConfig, // navOrder: 41 | tools
|
||||||
|
parcelSyncConfig, // navOrder: 42 | tools
|
||||||
promptGeneratorConfig, // navOrder: 50 | ai
|
promptGeneratorConfig, // navOrder: 50 | ai
|
||||||
aiChatConfig, // navOrder: 51 | ai
|
aiChatConfig, // navOrder: 51 | ai
|
||||||
visualCopilotConfig, // navOrder: 52 | ai
|
visualCopilotConfig, // navOrder: 52 | ai
|
||||||
|
|||||||
@@ -111,4 +111,9 @@ export const ro: Labels = {
|
|||||||
title: "Birouri Partajate",
|
title: "Birouri Partajate",
|
||||||
description: "Rezervare birouri în camera partajată",
|
description: "Rezervare birouri în camera partajată",
|
||||||
},
|
},
|
||||||
|
"parcel-sync": {
|
||||||
|
title: "eTerra Parcele",
|
||||||
|
description:
|
||||||
|
"Sincronizare parcele cadastrale ANCPI cu bază de date GIS locală",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,928 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
RefreshCw,
|
||||||
|
Database,
|
||||||
|
Cloud,
|
||||||
|
Download,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Loader2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
MapPin,
|
||||||
|
History,
|
||||||
|
Layers,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { Input } from "@/shared/components/ui/input";
|
||||||
|
import { Label } from "@/shared/components/ui/label";
|
||||||
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/shared/components/ui/card";
|
||||||
|
import {
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from "@/shared/components/ui/tabs";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import {
|
||||||
|
LAYER_CATALOG,
|
||||||
|
LAYER_CATEGORY_LABELS,
|
||||||
|
type LayerCategory,
|
||||||
|
type LayerCatalogItem,
|
||||||
|
} from "../services/eterra-layers";
|
||||||
|
import type { SyncProgress, ParcelFeature } from "../types";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Helpers */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function formatDate(iso?: string | null) {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleDateString("ro-RO", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatArea(val?: number | null) {
|
||||||
|
if (val == null) return "—";
|
||||||
|
return val.toLocaleString("ro-RO", { maximumFractionDigits: 2 }) + " mp";
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: string }) {
|
||||||
|
switch (status) {
|
||||||
|
case "done":
|
||||||
|
return (
|
||||||
|
<Badge className="bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400 hover:bg-emerald-100">
|
||||||
|
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||||
|
Finalizat
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
case "running":
|
||||||
|
return (
|
||||||
|
<Badge className="bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400 hover:bg-blue-100 animate-pulse">
|
||||||
|
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||||
|
În curs
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
case "error":
|
||||||
|
return (
|
||||||
|
<Badge variant="destructive">
|
||||||
|
<XCircle className="mr-1 h-3 w-3" />
|
||||||
|
Eroare
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return <Badge variant="outline">{status}</Badge>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Main Component */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export function ParcelSyncModule() {
|
||||||
|
/* ---- Connection ---- */
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [connecting, setConnecting] = useState(false);
|
||||||
|
const [connectionError, setConnectionError] = useState("");
|
||||||
|
|
||||||
|
/* ---- UAT selection ---- */
|
||||||
|
const [siruta, setSiruta] = useState("");
|
||||||
|
const [uatName, setUatName] = useState("");
|
||||||
|
|
||||||
|
/* ---- Remote / Local counts ---- */
|
||||||
|
const [remoteCounts, setRemoteCounts] = useState<
|
||||||
|
Record<string, { count: number; error?: string }>
|
||||||
|
>({});
|
||||||
|
const [localCounts, setLocalCounts] = useState<Record<string, number>>({});
|
||||||
|
const [loadingCounts, setLoadingCounts] = useState(false);
|
||||||
|
|
||||||
|
/* ---- Sync ---- */
|
||||||
|
const [syncingLayer, setSyncingLayer] = useState<string | null>(null);
|
||||||
|
const [syncProgress, setSyncProgress] = useState<SyncProgress | null>(null);
|
||||||
|
type SyncRun = {
|
||||||
|
id: string;
|
||||||
|
siruta: string;
|
||||||
|
uatName?: string;
|
||||||
|
layerId: string;
|
||||||
|
status: string;
|
||||||
|
totalRemote: number;
|
||||||
|
totalLocal: number;
|
||||||
|
newFeatures: number;
|
||||||
|
removedFeatures: number;
|
||||||
|
startedAt: string;
|
||||||
|
completedAt?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
};
|
||||||
|
const [syncHistory, setSyncHistory] = useState<SyncRun[]>([]);
|
||||||
|
|
||||||
|
/* ---- Features browser ---- */
|
||||||
|
const [features, setFeatures] = useState<ParcelFeature[]>([]);
|
||||||
|
const [featuresTotal, setFeaturesTotal] = useState(0);
|
||||||
|
const [featuresPage, setFeaturesPage] = useState(1);
|
||||||
|
const [featuresSearch, setFeaturesSearch] = useState("");
|
||||||
|
const [featuresLayerFilter, setFeaturesLayerFilter] = useState("");
|
||||||
|
const [loadingFeatures, setLoadingFeatures] = useState(false);
|
||||||
|
|
||||||
|
const progressRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
/* ================================================================ */
|
||||||
|
/* Connection */
|
||||||
|
/* ================================================================ */
|
||||||
|
|
||||||
|
const handleConnect = useCallback(async () => {
|
||||||
|
setConnecting(true);
|
||||||
|
setConnectionError("");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/eterra/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const data = (await res.json()) as { success?: boolean; error?: string };
|
||||||
|
if (data.success) setConnected(true);
|
||||||
|
else setConnectionError(data.error ?? "Eroare conectare");
|
||||||
|
} catch {
|
||||||
|
setConnectionError("Eroare rețea");
|
||||||
|
}
|
||||||
|
setConnecting(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* ================================================================ */
|
||||||
|
/* Load remote counts */
|
||||||
|
/* ================================================================ */
|
||||||
|
|
||||||
|
const loadRemoteCounts = useCallback(async () => {
|
||||||
|
if (!siruta || !/^\d+$/.test(siruta)) return;
|
||||||
|
setLoadingCounts(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/eterra/layers/summary", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ siruta }),
|
||||||
|
});
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
counts?: Record<string, { count: number; error?: string }>;
|
||||||
|
};
|
||||||
|
if (data.counts) setRemoteCounts(data.counts);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
setLoadingCounts(false);
|
||||||
|
}, [siruta]);
|
||||||
|
|
||||||
|
/* ================================================================ */
|
||||||
|
/* Load sync status (local counts + run history) */
|
||||||
|
/* ================================================================ */
|
||||||
|
|
||||||
|
const loadSyncStatus = useCallback(async () => {
|
||||||
|
if (!siruta || !/^\d+$/.test(siruta)) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/eterra/sync-status?siruta=${siruta}`);
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
localCounts?: Record<string, number>;
|
||||||
|
runs?: SyncRun[];
|
||||||
|
};
|
||||||
|
if (data.localCounts) setLocalCounts(data.localCounts);
|
||||||
|
if (data.runs) setSyncHistory(data.runs.slice(0, 30));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}, [siruta]);
|
||||||
|
|
||||||
|
/* ================================================================ */
|
||||||
|
/* Sync a layer */
|
||||||
|
/* ================================================================ */
|
||||||
|
|
||||||
|
const handleSync = useCallback(
|
||||||
|
async (layerId: string, forceFullSync = false) => {
|
||||||
|
if (!siruta || syncingLayer) return;
|
||||||
|
const jobId = `sync-${Date.now()}-${layerId}`;
|
||||||
|
setSyncingLayer(layerId);
|
||||||
|
setSyncProgress({
|
||||||
|
jobId,
|
||||||
|
downloaded: 0,
|
||||||
|
status: "running",
|
||||||
|
phase: "Pornire...",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Poll progress
|
||||||
|
progressRef.current = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/eterra/progress?jobId=${jobId}`);
|
||||||
|
const data = (await res.json()) as SyncProgress;
|
||||||
|
if (data.status === "done" || data.status === "error") {
|
||||||
|
setSyncProgress(data);
|
||||||
|
if (progressRef.current) clearInterval(progressRef.current);
|
||||||
|
progressRef.current = null;
|
||||||
|
setTimeout(() => {
|
||||||
|
setSyncingLayer(null);
|
||||||
|
setSyncProgress(null);
|
||||||
|
void loadSyncStatus();
|
||||||
|
}, 2500);
|
||||||
|
} else if (data.status === "running") {
|
||||||
|
setSyncProgress(data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore polling errors */
|
||||||
|
}
|
||||||
|
}, 1200);
|
||||||
|
|
||||||
|
// Fire sync (may take minutes — progress comes via polling)
|
||||||
|
try {
|
||||||
|
await fetch("/api/eterra/sync", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
siruta,
|
||||||
|
layerId,
|
||||||
|
uatName,
|
||||||
|
jobId,
|
||||||
|
forceFullSync,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* error is reported via sync run DB record */
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup polling if still active
|
||||||
|
if (progressRef.current) {
|
||||||
|
clearInterval(progressRef.current);
|
||||||
|
progressRef.current = null;
|
||||||
|
}
|
||||||
|
setSyncingLayer(null);
|
||||||
|
setSyncProgress(null);
|
||||||
|
void loadSyncStatus();
|
||||||
|
},
|
||||||
|
[siruta, syncingLayer, uatName, loadSyncStatus],
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ================================================================ */
|
||||||
|
/* Load features from local DB */
|
||||||
|
/* ================================================================ */
|
||||||
|
|
||||||
|
const loadFeatures = useCallback(async () => {
|
||||||
|
if (!siruta || !/^\d+$/.test(siruta)) return;
|
||||||
|
setLoadingFeatures(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/eterra/features", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
siruta,
|
||||||
|
layerId: featuresLayerFilter || undefined,
|
||||||
|
search: featuresSearch || undefined,
|
||||||
|
page: featuresPage,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
features?: ParcelFeature[];
|
||||||
|
total?: number;
|
||||||
|
};
|
||||||
|
if (data.features) setFeatures(data.features);
|
||||||
|
if (data.total != null) setFeaturesTotal(data.total);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
setLoadingFeatures(false);
|
||||||
|
}, [siruta, featuresLayerFilter, featuresSearch, featuresPage]);
|
||||||
|
|
||||||
|
/* ================================================================ */
|
||||||
|
/* Effects */
|
||||||
|
/* ================================================================ */
|
||||||
|
|
||||||
|
// Load sync status on siruta change
|
||||||
|
useEffect(() => {
|
||||||
|
if (siruta && /^\d+$/.test(siruta)) {
|
||||||
|
void loadSyncStatus();
|
||||||
|
} else {
|
||||||
|
setLocalCounts({});
|
||||||
|
setSyncHistory([]);
|
||||||
|
}
|
||||||
|
}, [siruta, loadSyncStatus]);
|
||||||
|
|
||||||
|
// Load features on filter/page change (only when on Data tab, but keep it simple)
|
||||||
|
useEffect(() => {
|
||||||
|
if (siruta && /^\d+$/.test(siruta)) void loadFeatures();
|
||||||
|
}, [siruta, featuresLayerFilter, featuresSearch, featuresPage, loadFeatures]);
|
||||||
|
|
||||||
|
// Cleanup interval on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (progressRef.current) clearInterval(progressRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* ================================================================ */
|
||||||
|
/* Derived data */
|
||||||
|
/* ================================================================ */
|
||||||
|
|
||||||
|
const layersByCategory = useMemo(() => {
|
||||||
|
const grouped: Record<string, LayerCatalogItem[]> = {};
|
||||||
|
for (const layer of LAYER_CATALOG) {
|
||||||
|
if (!grouped[layer.category]) grouped[layer.category] = [];
|
||||||
|
grouped[layer.category]!.push(layer);
|
||||||
|
}
|
||||||
|
return grouped;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const totalLocal = Object.values(localCounts).reduce((s, c) => s + c, 0);
|
||||||
|
const totalRemote = Object.values(remoteCounts).reduce(
|
||||||
|
(s, c) => s + c.count,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const totalPages = Math.ceil(featuresTotal / PAGE_SIZE);
|
||||||
|
const sirutaValid = siruta.length > 0 && /^\d+$/.test(siruta);
|
||||||
|
|
||||||
|
/* ================================================================ */
|
||||||
|
/* Render helpers */
|
||||||
|
/* ================================================================ */
|
||||||
|
|
||||||
|
function renderSyncProgress() {
|
||||||
|
if (!syncProgress || !syncingLayer) return null;
|
||||||
|
const layer = LAYER_CATALOG.find((l) => l.id === syncingLayer);
|
||||||
|
const pct =
|
||||||
|
syncProgress.total && syncProgress.total > 0
|
||||||
|
? Math.round((syncProgress.downloaded / syncProgress.total) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-blue-200 bg-blue-50/50 dark:border-blue-800 dark:bg-blue-950/20">
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<Loader2 className="h-5 w-5 text-blue-600 animate-spin" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">
|
||||||
|
Sincronizare: {layer?.label ?? syncingLayer}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{syncProgress.phase}
|
||||||
|
{syncProgress.total
|
||||||
|
? ` — ${syncProgress.downloaded} / ${syncProgress.total}`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 w-full rounded-full bg-blue-200/50 dark:bg-blue-800/30">
|
||||||
|
<div
|
||||||
|
className="h-2 rounded-full bg-blue-600 transition-all duration-300"
|
||||||
|
style={{ width: `${Math.max(2, pct)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLayerRow(layer: LayerCatalogItem) {
|
||||||
|
const remote = remoteCounts[layer.id];
|
||||||
|
const local = localCounts[layer.id] ?? 0;
|
||||||
|
const isSyncing = syncingLayer === layer.id;
|
||||||
|
const lastRun = syncHistory.find((r) => r.layerId === layer.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={layer.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between gap-3 rounded-lg border px-4 py-3 transition-colors",
|
||||||
|
isSyncing
|
||||||
|
? "border-blue-300 bg-blue-50/50 dark:border-blue-700 dark:bg-blue-950/20"
|
||||||
|
: "hover:bg-muted/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium truncate">{layer.label}</p>
|
||||||
|
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Database className="h-3 w-3" />
|
||||||
|
{local.toLocaleString("ro-RO")}
|
||||||
|
</span>
|
||||||
|
{remote != null && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Cloud className="h-3 w-3" />
|
||||||
|
{remote.error ? (
|
||||||
|
<span className="text-destructive">eroare</span>
|
||||||
|
) : (
|
||||||
|
remote.count.toLocaleString("ro-RO")
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{lastRun && (
|
||||||
|
<span className="hidden sm:inline">
|
||||||
|
Ultima: {formatDate(lastRun.completedAt ?? lastRun.startedAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{local > 0 && remote && remote.count > 0 && local >= remote.count && (
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!sirutaValid || !!syncingLayer}
|
||||||
|
onClick={() => void handleSync(layer.id)}
|
||||||
|
>
|
||||||
|
{isSyncing ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
<span className="ml-1.5 hidden sm:inline">Sync</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================ */
|
||||||
|
/* JSX */
|
||||||
|
/* ================================================================ */
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs defaultValue="sync" className="space-y-4">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="sync" className="gap-1.5">
|
||||||
|
<Layers className="h-4 w-4" />
|
||||||
|
Sincronizare
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="data" className="gap-1.5">
|
||||||
|
<Database className="h-4 w-4" />
|
||||||
|
Parcele
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="history" className="gap-1.5">
|
||||||
|
<History className="h-4 w-4" />
|
||||||
|
Istoric
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* ─── Tab: Sincronizare ─────────────────────────────────── */}
|
||||||
|
<TabsContent value="sync" className="space-y-4">
|
||||||
|
{/* Connection + UAT */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<MapPin className="h-4 w-4" />
|
||||||
|
Conexiune & UAT
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Connection row */}
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={connected ? "outline" : "default"}
|
||||||
|
disabled={connecting}
|
||||||
|
onClick={handleConnect}
|
||||||
|
>
|
||||||
|
{connecting && (
|
||||||
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||||
|
)}
|
||||||
|
{connected ? "Reconectare" : "Conectare eTerra"}
|
||||||
|
</Button>
|
||||||
|
{connected && (
|
||||||
|
<Badge className="bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400 hover:bg-emerald-100">
|
||||||
|
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||||
|
Conectat
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{connectionError && (
|
||||||
|
<span className="text-sm text-destructive">
|
||||||
|
{connectionError}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* UAT input */}
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="siruta" className="text-xs">
|
||||||
|
Cod SIRUTA
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="siruta"
|
||||||
|
placeholder="ex: 179141"
|
||||||
|
value={siruta}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSiruta(e.target.value.replace(/\D/g, ""));
|
||||||
|
setFeaturesPage(1);
|
||||||
|
}}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="uatName" className="text-xs">
|
||||||
|
Nume UAT (opțional)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="uatName"
|
||||||
|
placeholder="ex: Cluj-Napoca"
|
||||||
|
value={uatName}
|
||||||
|
onChange={(e) => setUatName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!sirutaValid || loadingCounts}
|
||||||
|
onClick={loadRemoteCounts}
|
||||||
|
>
|
||||||
|
{loadingCounts ? (
|
||||||
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Cloud className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
Numără remote
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!sirutaValid}
|
||||||
|
onClick={loadSyncStatus}
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick stats */}
|
||||||
|
{sirutaValid && (
|
||||||
|
<div className="flex gap-4 text-sm text-muted-foreground pt-1">
|
||||||
|
<span>
|
||||||
|
<Database className="inline h-3.5 w-3.5 mr-1" />
|
||||||
|
Local:{" "}
|
||||||
|
<strong className="text-foreground">
|
||||||
|
{totalLocal.toLocaleString("ro-RO")}
|
||||||
|
</strong>{" "}
|
||||||
|
features
|
||||||
|
</span>
|
||||||
|
{totalRemote > 0 && (
|
||||||
|
<span>
|
||||||
|
<Cloud className="inline h-3.5 w-3.5 mr-1" />
|
||||||
|
Remote:{" "}
|
||||||
|
<strong className="text-foreground">
|
||||||
|
{totalRemote.toLocaleString("ro-RO")}
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
Layere sincronizate:{" "}
|
||||||
|
<strong className="text-foreground">
|
||||||
|
{Object.keys(localCounts).length}
|
||||||
|
</strong>{" "}
|
||||||
|
/ {LAYER_CATALOG.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Sync progress banner */}
|
||||||
|
{renderSyncProgress()}
|
||||||
|
|
||||||
|
{/* Layer grid */}
|
||||||
|
{sirutaValid && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{(Object.keys(LAYER_CATEGORY_LABELS) as LayerCategory[]).map(
|
||||||
|
(cat) => {
|
||||||
|
const layers = layersByCategory[cat];
|
||||||
|
if (!layers || layers.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<Card key={cat}>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-semibold">
|
||||||
|
{LAYER_CATEGORY_LABELS[cat]}
|
||||||
|
<Badge variant="outline" className="ml-2 font-normal">
|
||||||
|
{layers.length}
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{layers.map(renderLayerRow)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ─── Tab: Parcele ──────────────────────────────────────── */}
|
||||||
|
<TabsContent value="data" className="space-y-4">
|
||||||
|
{!sirutaValid ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center text-muted-foreground">
|
||||||
|
<Database className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||||
|
<p>
|
||||||
|
Introduceți un cod SIRUTA în tabul Sincronizare pentru a vedea
|
||||||
|
datele locale.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Filters */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<div className="flex gap-3 flex-wrap items-end">
|
||||||
|
<div className="space-y-1 flex-1 min-w-[200px]">
|
||||||
|
<Label className="text-xs">Căutare</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Ref. cadastrală sau INSPIRE ID..."
|
||||||
|
className="pl-9"
|
||||||
|
value={featuresSearch}
|
||||||
|
onChange={(e) => {
|
||||||
|
setFeaturesSearch(e.target.value);
|
||||||
|
setFeaturesPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 min-w-[200px]">
|
||||||
|
<Label className="text-xs">Layer</Label>
|
||||||
|
<select
|
||||||
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
value={featuresLayerFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setFeaturesLayerFilter(e.target.value);
|
||||||
|
setFeaturesPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Toate layerele</option>
|
||||||
|
{LAYER_CATALOG.map((l) => (
|
||||||
|
<option key={l.id} value={l.id}>
|
||||||
|
{l.label}
|
||||||
|
{localCounts[l.id] != null
|
||||||
|
? ` (${localCounts[l.id]})`
|
||||||
|
: ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={loadFeatures}
|
||||||
|
disabled={loadingFeatures}
|
||||||
|
>
|
||||||
|
{loadingFeatures ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/40">
|
||||||
|
<th className="px-4 py-2.5 text-left font-medium">
|
||||||
|
OBJECTID
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2.5 text-left font-medium">
|
||||||
|
Ref. cadastrală
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2.5 text-left font-medium hidden md:table-cell">
|
||||||
|
INSPIRE ID
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2.5 text-right font-medium hidden sm:table-cell">
|
||||||
|
Suprafață
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2.5 text-left font-medium hidden lg:table-cell">
|
||||||
|
Layer
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2.5 text-left font-medium hidden lg:table-cell">
|
||||||
|
Actualizat
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{features.length === 0 && !loadingFeatures ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={6}
|
||||||
|
className="px-4 py-8 text-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
{featuresSearch
|
||||||
|
? "Nicio parcela găsită pentru căutarea curentă."
|
||||||
|
: "Nicio parcelă sincronizată. Folosiți tabul Sincronizare."}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
features.map((f) => {
|
||||||
|
const layerLabel =
|
||||||
|
LAYER_CATALOG.find((l) => l.id === f.layerId)
|
||||||
|
?.label ?? f.layerId;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={f.id}
|
||||||
|
className="border-b hover:bg-muted/30 transition-colors cursor-default"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2 font-mono text-xs">
|
||||||
|
{f.objectId}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
{f.cadastralRef ?? "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 hidden md:table-cell text-xs text-muted-foreground">
|
||||||
|
{f.inspireId ?? "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right hidden sm:table-cell tabular-nums">
|
||||||
|
{formatArea(f.areaValue)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 hidden lg:table-cell">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[11px] font-normal"
|
||||||
|
>
|
||||||
|
{layerLabel}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 hidden lg:table-cell text-xs text-muted-foreground">
|
||||||
|
{formatDate(f.updatedAt)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{featuresTotal > PAGE_SIZE && (
|
||||||
|
<div className="flex items-center justify-between border-t px-4 py-3">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{featuresTotal.toLocaleString("ro-RO")} total — pagina{" "}
|
||||||
|
{featuresPage} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={featuresPage <= 1}
|
||||||
|
onClick={() =>
|
||||||
|
setFeaturesPage((p) => Math.max(1, p - 1))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={featuresPage >= totalPages}
|
||||||
|
onClick={() => setFeaturesPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ─── Tab: Istoric ──────────────────────────────────────── */}
|
||||||
|
<TabsContent value="history" className="space-y-4">
|
||||||
|
{!sirutaValid ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center text-muted-foreground">
|
||||||
|
<History className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||||
|
<p>
|
||||||
|
Introduceți un cod SIRUTA pentru a vedea istoricul
|
||||||
|
sincronizărilor.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : syncHistory.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center text-muted-foreground">
|
||||||
|
<History className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||||
|
<p>Nicio sincronizare efectuată pentru SIRUTA {siruta}.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
Istoric sincronizări
|
||||||
|
<Badge variant="outline" className="ml-2 font-normal">
|
||||||
|
{syncHistory.length}
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/40">
|
||||||
|
<th className="px-4 py-2.5 text-left font-medium">
|
||||||
|
Layer
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2.5 text-left font-medium">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2.5 text-right font-medium hidden sm:table-cell">
|
||||||
|
Noi
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2.5 text-right font-medium hidden sm:table-cell">
|
||||||
|
Șterse
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2.5 text-right font-medium hidden md:table-cell">
|
||||||
|
Total Remote
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2.5 text-left font-medium">
|
||||||
|
Data
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{syncHistory.map((run) => {
|
||||||
|
const layerLabel =
|
||||||
|
LAYER_CATALOG.find((l) => l.id === run.layerId)
|
||||||
|
?.label ?? run.layerId;
|
||||||
|
return (
|
||||||
|
<tr key={run.id} className="border-b hover:bg-muted/30">
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[11px] font-normal"
|
||||||
|
>
|
||||||
|
{layerLabel}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<StatusBadge status={run.status} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right tabular-nums hidden sm:table-cell">
|
||||||
|
{run.newFeatures > 0 ? (
|
||||||
|
<span className="text-emerald-600">
|
||||||
|
+{run.newFeatures}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"0"
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right tabular-nums hidden sm:table-cell">
|
||||||
|
{run.removedFeatures > 0 ? (
|
||||||
|
<span className="text-destructive">
|
||||||
|
-{run.removedFeatures}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"0"
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right tabular-nums hidden md:table-cell">
|
||||||
|
{run.totalRemote.toLocaleString("ro-RO")}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-xs text-muted-foreground">
|
||||||
|
{formatDate(run.completedAt ?? run.startedAt)}
|
||||||
|
{run.errorMessage && (
|
||||||
|
<p className="text-destructive mt-0.5 truncate max-w-[200px]">
|
||||||
|
{run.errorMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { ModuleConfig } from "@/core/module-registry/types";
|
||||||
|
|
||||||
|
export const parcelSyncConfig: ModuleConfig = {
|
||||||
|
id: "parcel-sync",
|
||||||
|
name: "eTerra Parcele",
|
||||||
|
description:
|
||||||
|
"Sincronizare parcele cadastrale din eTerra ANCPI cu bază de date GIS locală",
|
||||||
|
icon: "map-pin",
|
||||||
|
route: "/parcel-sync",
|
||||||
|
category: "tools",
|
||||||
|
featureFlag: "module.parcel-sync",
|
||||||
|
visibility: "all",
|
||||||
|
version: "0.1.0",
|
||||||
|
dependencies: [],
|
||||||
|
storageNamespace: "parcel-sync",
|
||||||
|
navOrder: 42,
|
||||||
|
tags: ["gis", "eterra", "parcele", "cadastru", "sincronizare", "shapefile"],
|
||||||
|
};
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { ParcelSyncModule } from "./components/parcel-sync-module";
|
||||||
|
export { parcelSyncConfig } from "./config";
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* ESRI → GeoJSON conversion for eTerra features.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { EsriFeature } from "./eterra-client";
|
||||||
|
|
||||||
|
export type GeoJsonPolygon = { type: "Polygon"; coordinates: number[][][] };
|
||||||
|
export type GeoJsonMultiPolygon = {
|
||||||
|
type: "MultiPolygon";
|
||||||
|
coordinates: number[][][][];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GeoJsonFeature = {
|
||||||
|
type: "Feature";
|
||||||
|
properties: Record<string, unknown>;
|
||||||
|
geometry: GeoJsonPolygon | GeoJsonMultiPolygon;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GeoJsonFeatureCollection = {
|
||||||
|
type: "FeatureCollection";
|
||||||
|
features: GeoJsonFeature[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const ringArea = (ring: number[][]) => {
|
||||||
|
let area = 0;
|
||||||
|
for (let i = 0; i < ring.length - 1; i++) {
|
||||||
|
const curr = ring[i]!;
|
||||||
|
const next = ring[i + 1]!;
|
||||||
|
area += curr[0]! * next[1]! - next[0]! * curr[1]!;
|
||||||
|
}
|
||||||
|
return area / 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isClockwise = (ring: number[][]) => ringArea(ring) < 0;
|
||||||
|
|
||||||
|
const closeRing = (ring: number[][]) => {
|
||||||
|
if (ring.length === 0) return ring;
|
||||||
|
const first = ring[0]!;
|
||||||
|
const last = ring[ring.length - 1]!;
|
||||||
|
return first[0] !== last[0] || first[1] !== last[1]
|
||||||
|
? [...ring, [first[0]!, first[1]!]]
|
||||||
|
: ring;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ringsToPolygons = (rings: number[][][]) => {
|
||||||
|
const polygons: number[][][][] = [];
|
||||||
|
let current: number[][][] | null = null;
|
||||||
|
for (const ring of rings) {
|
||||||
|
const closed = closeRing(ring);
|
||||||
|
if (closed.length < 4) continue;
|
||||||
|
if (isClockwise(closed) || !current) {
|
||||||
|
if (current) polygons.push(current);
|
||||||
|
current = [closed];
|
||||||
|
} else {
|
||||||
|
current.push(closed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current) polygons.push(current);
|
||||||
|
return polygons;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const esriToGeojson = (
|
||||||
|
features: EsriFeature[],
|
||||||
|
): GeoJsonFeatureCollection => {
|
||||||
|
const geoFeatures: GeoJsonFeature[] = [];
|
||||||
|
for (const feature of features) {
|
||||||
|
const rings = feature.geometry?.rings;
|
||||||
|
if (!rings?.length) continue;
|
||||||
|
const polygons = ringsToPolygons(rings);
|
||||||
|
if (!polygons.length) continue;
|
||||||
|
const geometry: GeoJsonPolygon | GeoJsonMultiPolygon =
|
||||||
|
polygons.length === 1
|
||||||
|
? { type: "Polygon", coordinates: polygons[0]! }
|
||||||
|
: { type: "MultiPolygon", coordinates: polygons };
|
||||||
|
geoFeatures.push({
|
||||||
|
type: "Feature",
|
||||||
|
properties: feature.attributes ?? {},
|
||||||
|
geometry,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { type: "FeatureCollection", features: geoFeatures };
|
||||||
|
};
|
||||||
@@ -0,0 +1,608 @@
|
|||||||
|
/**
|
||||||
|
* eTerra ANCPI client — server-side only.
|
||||||
|
*
|
||||||
|
* Authenticates via form-post, keeps a JSESSIONID cookie jar,
|
||||||
|
* and exposes typed wrappers for count / list / fetchAll / parcel-detail
|
||||||
|
* queries against the eTerra ArcGIS-REST API.
|
||||||
|
*
|
||||||
|
* Ported from the standalone ParcelSync project and adapted for
|
||||||
|
* ArchiTools module architecture.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import axios, { type AxiosError, type AxiosInstance } from "axios";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import { wrapper } from "axios-cookiejar-support";
|
||||||
|
import { CookieJar } from "tough-cookie";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Types */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export type EsriGeometry = { rings: number[][][] };
|
||||||
|
|
||||||
|
export type EsriFeature = {
|
||||||
|
attributes: Record<string, unknown>;
|
||||||
|
geometry?: EsriGeometry;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LayerEndpoint = "aut" | "all";
|
||||||
|
|
||||||
|
export type LayerConfig = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
endpoint: LayerEndpoint;
|
||||||
|
subLayerId?: number;
|
||||||
|
whereTemplate?: string;
|
||||||
|
spatialFilter?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EsriQueryResponse = {
|
||||||
|
error?: { message?: string; details?: string[] };
|
||||||
|
count?: number;
|
||||||
|
objectIds?: number[];
|
||||||
|
objectIdFieldName?: string;
|
||||||
|
features?: EsriFeature[];
|
||||||
|
exceededTransferLimit?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EsriLayerInfo = {
|
||||||
|
fields?: { name: string }[];
|
||||||
|
error?: { message?: string; details?: string[] };
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProgressCallback = (downloaded: number, total?: number) => void;
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Constants */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const BASE_URL = "https://eterra.ancpi.ro/eterra";
|
||||||
|
const LOGIN_URL = `${BASE_URL}/api/authentication`;
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MS = 40_000;
|
||||||
|
const DEFAULT_PAGE_SIZE = 2000;
|
||||||
|
const FALLBACK_PAGE_SIZE = 1000;
|
||||||
|
const MAX_RETRIES = 2;
|
||||||
|
const SESSION_TTL_MS = 9 * 60 * 1000;
|
||||||
|
const MAX_URL_LENGTH = 1500;
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Session cache (global singleton) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
type SessionEntry = {
|
||||||
|
jar: CookieJar;
|
||||||
|
client: AxiosInstance;
|
||||||
|
createdAt: number;
|
||||||
|
lastUsed: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const globalStore = globalThis as {
|
||||||
|
__eterraSessionStore?: Map<string, SessionEntry>;
|
||||||
|
};
|
||||||
|
const sessionStore =
|
||||||
|
globalStore.__eterraSessionStore ?? new Map<string, SessionEntry>();
|
||||||
|
globalStore.__eterraSessionStore = sessionStore;
|
||||||
|
|
||||||
|
const makeCacheKey = (u: string, p: string) =>
|
||||||
|
crypto.createHash("sha256").update(`${u}:${p}`).digest("hex");
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Helpers */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const isTransient = (error: unknown) => {
|
||||||
|
const err = error as AxiosError;
|
||||||
|
if (!err) return false;
|
||||||
|
if (
|
||||||
|
err.code === "ECONNRESET" ||
|
||||||
|
err.code === "ETIMEDOUT" ||
|
||||||
|
err.code === "ECONNABORTED"
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
const status = err.response?.status ?? 0;
|
||||||
|
return status >= 500;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Client */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export class EterraClient {
|
||||||
|
private client: AxiosInstance;
|
||||||
|
private jar: CookieJar;
|
||||||
|
private timeoutMs: number;
|
||||||
|
private maxRetries: number;
|
||||||
|
private username: string;
|
||||||
|
private password: string;
|
||||||
|
private reloginAttempted = false;
|
||||||
|
private layerFieldsCache = new Map<string, string[]>();
|
||||||
|
|
||||||
|
private constructor(
|
||||||
|
client: AxiosInstance,
|
||||||
|
jar: CookieJar,
|
||||||
|
timeoutMs: number,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
maxRetries: number,
|
||||||
|
) {
|
||||||
|
this.client = client;
|
||||||
|
this.jar = jar;
|
||||||
|
this.timeoutMs = timeoutMs;
|
||||||
|
this.username = username;
|
||||||
|
this.password = password;
|
||||||
|
this.maxRetries = maxRetries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Factory --------------------------------------------------- */
|
||||||
|
|
||||||
|
static async create(
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
options?: { timeoutMs?: number; maxRetries?: number; useCache?: boolean },
|
||||||
|
) {
|
||||||
|
const useCache = options?.useCache !== false;
|
||||||
|
const now = Date.now();
|
||||||
|
const cacheKey = makeCacheKey(username, password);
|
||||||
|
const cached = sessionStore.get(cacheKey);
|
||||||
|
|
||||||
|
if (useCache && cached && now - cached.lastUsed < SESSION_TTL_MS) {
|
||||||
|
cached.lastUsed = now;
|
||||||
|
return new EterraClient(
|
||||||
|
cached.client,
|
||||||
|
cached.jar,
|
||||||
|
options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
options?.maxRetries ?? MAX_RETRIES,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jar = new CookieJar();
|
||||||
|
const client = wrapper(
|
||||||
|
axios.create({
|
||||||
|
jar,
|
||||||
|
withCredentials: true,
|
||||||
|
maxContentLength: Infinity,
|
||||||
|
maxBodyLength: Infinity,
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json, text/plain, */*",
|
||||||
|
"User-Agent":
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||||
|
Referer: `${BASE_URL}/`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const instance = new EterraClient(
|
||||||
|
client,
|
||||||
|
jar,
|
||||||
|
options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
options?.maxRetries ?? MAX_RETRIES,
|
||||||
|
);
|
||||||
|
await instance.login(username, password);
|
||||||
|
sessionStore.set(cacheKey, { jar, client, createdAt: now, lastUsed: now });
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Auth ------------------------------------------------------ */
|
||||||
|
|
||||||
|
private async login(u: string, p: string) {
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
body.set("j_username", u);
|
||||||
|
body.set("j_password", p);
|
||||||
|
body.set("j_uuid", "undefined");
|
||||||
|
body.set("j_isRevoked", "undefined");
|
||||||
|
body.set("_spring_security_remember_me", "true");
|
||||||
|
body.set("submit", "Login");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.requestWithRetry(() =>
|
||||||
|
this.client.post(LOGIN_URL, body.toString(), {
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
timeout: this.timeoutMs,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as AxiosError;
|
||||||
|
if (err?.response?.status === 401)
|
||||||
|
throw new Error("Login failed (invalid credentials)");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookies = await this.jar.getCookies(LOGIN_URL);
|
||||||
|
if (!cookies.some((c) => c.key === "JSESSIONID"))
|
||||||
|
throw new Error("Login failed / session not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- High-level parcels --------------------------------------- */
|
||||||
|
|
||||||
|
async countParcels(siruta: string) {
|
||||||
|
return this.countLayer(
|
||||||
|
{
|
||||||
|
id: "TERENURI_ACTIVE",
|
||||||
|
name: "TERENURI_ACTIVE",
|
||||||
|
endpoint: "aut",
|
||||||
|
whereTemplate: "{{adminField}}={{siruta}} AND IS_ACTIVE=1",
|
||||||
|
},
|
||||||
|
siruta,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchAllParcels(
|
||||||
|
siruta: string,
|
||||||
|
options?: {
|
||||||
|
pageSize?: number;
|
||||||
|
total?: number;
|
||||||
|
onProgress?: ProgressCallback;
|
||||||
|
delayMs?: number;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.fetchAllLayer(
|
||||||
|
{
|
||||||
|
id: "TERENURI_ACTIVE",
|
||||||
|
name: "TERENURI_ACTIVE",
|
||||||
|
endpoint: "aut",
|
||||||
|
whereTemplate: "{{adminField}}={{siruta}} AND IS_ACTIVE=1",
|
||||||
|
},
|
||||||
|
siruta,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Generic layer methods ------------------------------------ */
|
||||||
|
|
||||||
|
async countLayer(layer: LayerConfig, siruta: string) {
|
||||||
|
const where = await this.buildWhere(layer, siruta);
|
||||||
|
return this.countLayerByWhere(layer, where);
|
||||||
|
}
|
||||||
|
|
||||||
|
async countLayerByWhere(layer: LayerConfig, where: string) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("f", "json");
|
||||||
|
params.set("where", where);
|
||||||
|
return this.countLayerWithParams(layer, params, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async countLayerByGeometry(layer: LayerConfig, geometry: EsriGeometry) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("f", "json");
|
||||||
|
params.set("where", "1=1");
|
||||||
|
this.applyGeometryParams(params, geometry);
|
||||||
|
return this.countLayerWithParams(layer, params, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listLayer(
|
||||||
|
layer: LayerConfig,
|
||||||
|
siruta: string,
|
||||||
|
options?: { limit?: number; outFields?: string },
|
||||||
|
) {
|
||||||
|
const where = await this.buildWhere(layer, siruta);
|
||||||
|
return this.listLayerByWhere(layer, where, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listLayerByWhere(
|
||||||
|
layer: LayerConfig,
|
||||||
|
where: string,
|
||||||
|
options?: { limit?: number; outFields?: string },
|
||||||
|
) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("f", "json");
|
||||||
|
params.set("where", where);
|
||||||
|
params.set("outFields", options?.outFields ?? "*");
|
||||||
|
params.set("returnGeometry", "false");
|
||||||
|
params.set("resultRecordCount", String(options?.limit ?? 200));
|
||||||
|
params.set("resultOffset", "0");
|
||||||
|
const data = await this.queryLayer(layer, params, false);
|
||||||
|
return data.features ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchAllLayer(
|
||||||
|
layer: LayerConfig,
|
||||||
|
siruta: string,
|
||||||
|
options?: {
|
||||||
|
pageSize?: number;
|
||||||
|
total?: number;
|
||||||
|
onProgress?: ProgressCallback;
|
||||||
|
delayMs?: number;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const where = await this.buildWhere(layer, siruta);
|
||||||
|
return this.fetchAllLayerByWhere(layer, where, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchAllLayerByWhere(
|
||||||
|
layer: LayerConfig,
|
||||||
|
where: string,
|
||||||
|
options?: {
|
||||||
|
pageSize?: number;
|
||||||
|
total?: number;
|
||||||
|
onProgress?: ProgressCallback;
|
||||||
|
delayMs?: number;
|
||||||
|
outFields?: string;
|
||||||
|
returnGeometry?: boolean;
|
||||||
|
geometry?: EsriGeometry;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
let pageSize = options?.pageSize ?? DEFAULT_PAGE_SIZE;
|
||||||
|
const total = options?.total;
|
||||||
|
const onProgress = options?.onProgress;
|
||||||
|
const delayMs = options?.delayMs ?? 0;
|
||||||
|
const outFields = options?.outFields ?? "*";
|
||||||
|
const returnGeometry = options?.returnGeometry ?? true;
|
||||||
|
let offset = 0;
|
||||||
|
const all: EsriFeature[] = [];
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("f", "json");
|
||||||
|
params.set("where", where);
|
||||||
|
if (options?.geometry) this.applyGeometryParams(params, options.geometry);
|
||||||
|
params.set("outFields", outFields);
|
||||||
|
params.set("returnGeometry", returnGeometry ? "true" : "false");
|
||||||
|
if (returnGeometry) params.set("outSR", "3844");
|
||||||
|
params.set("resultRecordCount", String(pageSize));
|
||||||
|
params.set("resultOffset", String(offset));
|
||||||
|
|
||||||
|
let data: EsriQueryResponse;
|
||||||
|
try {
|
||||||
|
data = await this.queryLayer(layer, params, Boolean(options?.geometry));
|
||||||
|
} catch {
|
||||||
|
if (pageSize > FALLBACK_PAGE_SIZE) {
|
||||||
|
pageSize = FALLBACK_PAGE_SIZE;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to fetch layer ${layer.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const features = data.features ?? [];
|
||||||
|
if (features.length === 0) {
|
||||||
|
if (total && all.length < total && pageSize > FALLBACK_PAGE_SIZE) {
|
||||||
|
pageSize = FALLBACK_PAGE_SIZE;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
all.push(...features);
|
||||||
|
offset += features.length;
|
||||||
|
if (onProgress) onProgress(all.length, total);
|
||||||
|
if (total && all.length >= total) break;
|
||||||
|
if (features.length < pageSize) {
|
||||||
|
if (total && all.length < total && pageSize > FALLBACK_PAGE_SIZE) {
|
||||||
|
pageSize = FALLBACK_PAGE_SIZE;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delayMs > 0) await sleep(delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchAllLayerByGeometry(
|
||||||
|
layer: LayerConfig,
|
||||||
|
geometry: EsriGeometry,
|
||||||
|
options?: {
|
||||||
|
pageSize?: number;
|
||||||
|
total?: number;
|
||||||
|
onProgress?: ProgressCallback;
|
||||||
|
delayMs?: number;
|
||||||
|
outFields?: string;
|
||||||
|
returnGeometry?: boolean;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.fetchAllLayerByWhere(layer, "1=1", { ...options, geometry });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLayerFieldNames(layer: LayerConfig) {
|
||||||
|
return this.getLayerFields(layer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Internals ------------------------------------------------ */
|
||||||
|
|
||||||
|
private layerQueryUrl(layer: LayerConfig) {
|
||||||
|
const suffix =
|
||||||
|
typeof layer.subLayerId === "number" ? `/${layer.subLayerId}` : "";
|
||||||
|
return `${BASE_URL}/api/map/rest/${layer.endpoint}/layer/${layer.name}${suffix}/query`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyGeometryParams(params: URLSearchParams, geometry: EsriGeometry) {
|
||||||
|
params.set("geometry", JSON.stringify(geometry));
|
||||||
|
params.set("geometryType", "esriGeometryPolygon");
|
||||||
|
params.set("spatialRel", "esriSpatialRelIntersects");
|
||||||
|
params.set("inSR", "3844");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildWhere(layer: LayerConfig, siruta: string) {
|
||||||
|
const fields = await this.getLayerFields(layer);
|
||||||
|
const adminField = this.findAdminField(fields);
|
||||||
|
if (!adminField)
|
||||||
|
throw new Error(`Layer ${layer.name} has no SIRUTA/ADMIN_UNIT field`);
|
||||||
|
|
||||||
|
if (!layer.whereTemplate) return `${adminField}=${siruta}`;
|
||||||
|
|
||||||
|
const hasIsActive = fields.some((f) => f.toUpperCase() === "IS_ACTIVE");
|
||||||
|
if (layer.whereTemplate.includes("IS_ACTIVE") && !hasIsActive)
|
||||||
|
return `${adminField}=${siruta}`;
|
||||||
|
|
||||||
|
return layer.whereTemplate
|
||||||
|
.replace(/\{\{adminField\}\}/g, adminField)
|
||||||
|
.replace(/\{\{siruta\}\}/g, siruta);
|
||||||
|
}
|
||||||
|
|
||||||
|
private findAdminField(fields: string[]) {
|
||||||
|
const preferred = [
|
||||||
|
"ADMIN_UNIT_ID",
|
||||||
|
"SIRUTA",
|
||||||
|
"UAT_ID",
|
||||||
|
"SIRUTA_UAT",
|
||||||
|
"UAT_SIRUTA",
|
||||||
|
];
|
||||||
|
const upper = fields.map((f) => f.toUpperCase());
|
||||||
|
for (const key of preferred) {
|
||||||
|
const idx = upper.indexOf(key);
|
||||||
|
if (idx >= 0) return fields[idx];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getLayerFields(layer: LayerConfig) {
|
||||||
|
const cacheKey = `${layer.endpoint}:${layer.name}`;
|
||||||
|
const cached = this.layerFieldsCache.get(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const suffix =
|
||||||
|
typeof layer.subLayerId === "number" ? `/${layer.subLayerId}` : "";
|
||||||
|
const url = `${BASE_URL}/api/map/rest/${layer.endpoint}/layer/${layer.name}${suffix}?f=json`;
|
||||||
|
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await this.requestWithRetry(() =>
|
||||||
|
this.client.get(url, { timeout: this.timeoutMs }),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as AxiosError;
|
||||||
|
if (err?.response?.status === 401 && !this.reloginAttempted) {
|
||||||
|
this.reloginAttempted = true;
|
||||||
|
await this.login(this.username, this.password);
|
||||||
|
response = await this.requestWithRetry(() =>
|
||||||
|
this.client.get(url, { timeout: this.timeoutMs }),
|
||||||
|
);
|
||||||
|
} else throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = response.data as EsriLayerInfo | string;
|
||||||
|
if (typeof data === "string")
|
||||||
|
throw new Error(`Layer info invalid for ${layer.name}`);
|
||||||
|
if (data.error) {
|
||||||
|
const details = data.error.details?.join(" ") ?? "";
|
||||||
|
throw new Error(
|
||||||
|
`${data.error.message ?? "Layer error"}${details ? ` (${details})` : ""}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let fields = data.fields?.map((f) => f.name) ?? [];
|
||||||
|
if (!fields.length) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("f", "json");
|
||||||
|
params.set("where", "1=1");
|
||||||
|
params.set("outFields", "*");
|
||||||
|
params.set("returnGeometry", "false");
|
||||||
|
params.set("resultRecordCount", "1");
|
||||||
|
params.set("resultOffset", "0");
|
||||||
|
const sample = await this.getJson(
|
||||||
|
`${this.layerQueryUrl(layer)}?${params}`,
|
||||||
|
);
|
||||||
|
fields = Object.keys(sample.features?.[0]?.attributes ?? {});
|
||||||
|
}
|
||||||
|
if (!fields.length)
|
||||||
|
throw new Error(`Layer ${layer.name} returned no fields`);
|
||||||
|
|
||||||
|
this.layerFieldsCache.set(cacheKey, fields);
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getJson(url: string): Promise<EsriQueryResponse> {
|
||||||
|
return this.requestJson(() =>
|
||||||
|
this.client.get(url, { timeout: this.timeoutMs }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async postJson(
|
||||||
|
url: string,
|
||||||
|
body: URLSearchParams,
|
||||||
|
): Promise<EsriQueryResponse> {
|
||||||
|
return this.requestJson(() =>
|
||||||
|
this.client.post(url, body.toString(), {
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
timeout: this.timeoutMs,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestJson(
|
||||||
|
request: () => Promise<{
|
||||||
|
data: EsriQueryResponse | string;
|
||||||
|
status: number;
|
||||||
|
}>,
|
||||||
|
): Promise<EsriQueryResponse> {
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await this.requestWithRetry(request);
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as AxiosError;
|
||||||
|
if (err?.response?.status === 401 && !this.reloginAttempted) {
|
||||||
|
this.reloginAttempted = true;
|
||||||
|
await this.login(this.username, this.password);
|
||||||
|
response = await this.requestWithRetry(request);
|
||||||
|
} else if (err?.response?.status === 401) {
|
||||||
|
throw new Error("Session expired (401)");
|
||||||
|
} else throw error;
|
||||||
|
}
|
||||||
|
const data = response.data as EsriQueryResponse | string;
|
||||||
|
if (typeof data === "string")
|
||||||
|
throw new Error("Session expired or invalid response from eTerra");
|
||||||
|
if (data.error) {
|
||||||
|
const details = data.error.details?.join(" ") ?? "";
|
||||||
|
throw new Error(
|
||||||
|
`${data.error.message ?? "eTerra query error"}${details ? ` (${details})` : ""}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async queryLayer(
|
||||||
|
layer: LayerConfig,
|
||||||
|
params: URLSearchParams,
|
||||||
|
usePost: boolean,
|
||||||
|
): Promise<EsriQueryResponse> {
|
||||||
|
const url = this.layerQueryUrl(layer);
|
||||||
|
const qs = params.toString();
|
||||||
|
const shouldPost = usePost || qs.length > MAX_URL_LENGTH;
|
||||||
|
if (shouldPost) return this.postJson(url, params);
|
||||||
|
try {
|
||||||
|
return await this.getJson(`${url}?${qs}`);
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as AxiosError;
|
||||||
|
if (err?.response?.status === 414) return this.postJson(url, params);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async countLayerWithParams(
|
||||||
|
layer: LayerConfig,
|
||||||
|
params: URLSearchParams,
|
||||||
|
usePost: boolean,
|
||||||
|
) {
|
||||||
|
const cp = new URLSearchParams(params);
|
||||||
|
cp.set("returnCountOnly", "true");
|
||||||
|
const data = await this.queryLayer(layer, cp, usePost);
|
||||||
|
if (typeof data.count === "number") return data.count;
|
||||||
|
const message = data.error?.message;
|
||||||
|
throw new Error(
|
||||||
|
message
|
||||||
|
? `Count unavailable (${layer.name}): ${message}`
|
||||||
|
: `Count unavailable (${layer.name})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestWithRetry<T>(request: () => Promise<T>) {
|
||||||
|
let attempt = 0;
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
return await request();
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt >= this.maxRetries || !isTransient(error)) throw error;
|
||||||
|
attempt += 1;
|
||||||
|
await sleep(300 * attempt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
/**
|
||||||
|
* eTerra layer catalog — all known layers grouped by category.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { LayerConfig } from "./eterra-client";
|
||||||
|
|
||||||
|
export type LayerCategory =
|
||||||
|
| "terenuri"
|
||||||
|
| "documentatii"
|
||||||
|
| "cladiri"
|
||||||
|
| "administrativ";
|
||||||
|
|
||||||
|
export type LayerCatalogItem = LayerConfig & {
|
||||||
|
label: string;
|
||||||
|
category: LayerCategory;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LAYER_CATEGORY_LABELS: Record<LayerCategory, string> = {
|
||||||
|
terenuri: "Terenuri",
|
||||||
|
documentatii: "Documentații",
|
||||||
|
cladiri: "Clădiri",
|
||||||
|
administrativ: "Administrativ",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LAYER_CATALOG: LayerCatalogItem[] = [
|
||||||
|
// ── Terenuri ──
|
||||||
|
{
|
||||||
|
id: "TERENURI_ACTIVE",
|
||||||
|
name: "TERENURI_ACTIVE",
|
||||||
|
label: "Terenuri active",
|
||||||
|
category: "terenuri",
|
||||||
|
endpoint: "aut",
|
||||||
|
whereTemplate: "{{adminField}}={{siruta}} AND IS_ACTIVE=1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "TERENURI_NEINREGISTRATE",
|
||||||
|
name: "TERENURI_NEINREGISTRATE",
|
||||||
|
label: "Terenuri neînregistrate",
|
||||||
|
category: "terenuri",
|
||||||
|
endpoint: "aut",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "TERENURI_IN_LUCRU",
|
||||||
|
name: "TERENURI_IN_LUCRU",
|
||||||
|
label: "Terenuri în lucru",
|
||||||
|
category: "terenuri",
|
||||||
|
endpoint: "aut",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "TERENURI_RECEPTIONATE",
|
||||||
|
name: "TERENURI_RECEPTIONATE",
|
||||||
|
label: "Terenuri recepționate",
|
||||||
|
category: "terenuri",
|
||||||
|
endpoint: "aut",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "TERENURI_RESPINSE",
|
||||||
|
name: "TERENURI_RESPINSE",
|
||||||
|
label: "Terenuri respinse",
|
||||||
|
category: "terenuri",
|
||||||
|
endpoint: "aut",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "TERENURI_PE_CERERE",
|
||||||
|
name: "TERENURI_PE_CERERE",
|
||||||
|
label: "Terenuri pe cerere",
|
||||||
|
category: "terenuri",
|
||||||
|
endpoint: "aut",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "CADGEN_LAND_ACTIVE",
|
||||||
|
name: "CADGEN_LAND_ACTIVE",
|
||||||
|
label: "Cadgen land active",
|
||||||
|
category: "terenuri",
|
||||||
|
endpoint: "aut",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Clădiri ──
|
||||||
|
{
|
||||||
|
id: "CLADIRI_ACTIVE",
|
||||||
|
name: "CLADIRI_ACTIVE",
|
||||||
|
label: "Clădiri active",
|
||||||
|
category: "cladiri",
|
||||||
|
endpoint: "aut",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "CLADIRI_RECEPTIONATE",
|
||||||
|
name: "CLADIRI_RECEPTIONATE",
|
||||||
|
label: "Clădiri recepționate",
|
||||||
|
category: "cladiri",
|
||||||
|
endpoint: "aut",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "CLADIRI_RESPINSE",
|
||||||
|
name: "CLADIRI_RESPINSE",
|
||||||
|
label: "Clădiri respinse",
|
||||||
|
category: "cladiri",
|
||||||
|
endpoint: "aut",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "CLADIRI_PE_CERERE",
|
||||||
|
name: "CLADIRI_PE_CERERE",
|
||||||
|
label: "Clădiri pe cerere",
|
||||||
|
category: "cladiri",
|
||||||
|
endpoint: "aut",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "CLADIRI_IN_LUCRU",
|
||||||
|
name: "CLADIRI_IN_LUCRU",
|
||||||
|
label: "Clădiri în lucru",
|
||||||
|
category: "cladiri",
|
||||||
|
endpoint: "aut",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "CLADIRI_NEINREGISTRATE",
|
||||||
|
name: "CLADIRI_NEINREGISTRATE",
|
||||||
|
label: "Clădiri neînregistrate",
|
||||||
|
category: "cladiri",
|
||||||
|
endpoint: "aut",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "CADGEN_BUILDING_ACTIVE",
|
||||||
|
name: "CADGEN_BUILDING_ACTIVE",
|
||||||
|
label: "Cadgen building active",
|
||||||
|
category: "cladiri",
|
||||||
|
endpoint: "aut",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Documentații ──
|
||||||
|
{
|
||||||
|
id: "EXPERTIZA_CLADIRI_ACTIVE_DYNAMIC",
|
||||||
|
name: "EXPERTIZA_CLADIRI_ACTIVE_DYNAMIC",
|
||||||
|
label: "Expertiză clădiri active",
|
||||||
|
category: "documentatii",
|
||||||
|
endpoint: "aut",
|
||||||
|
spatialFilter: true,
|
||||||
|
subLayerId: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "EXPERTIZA_CLADIRI_INACTIVE_DYNAMIC",
|
||||||
|
name: "EXPERTIZA_CLADIRI_INACTIVE_DYNAMIC",
|
||||||
|
label: "Expertiză clădiri inactive",
|
||||||
|
category: "documentatii",
|
||||||
|
endpoint: "aut",
|
||||||
|
spatialFilter: true,
|
||||||
|
subLayerId: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "EXPERTIZA_TERENURI_ACTIVE_DYNAMIC",
|
||||||
|
name: "EXPERTIZA_TERENURI_ACTIVE_DYNAMIC",
|
||||||
|
label: "Expertiză terenuri active",
|
||||||
|
category: "documentatii",
|
||||||
|
endpoint: "aut",
|
||||||
|
spatialFilter: true,
|
||||||
|
subLayerId: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "EXPERTIZA_TERENURI_INACTIVE_DYNAMIC",
|
||||||
|
name: "EXPERTIZA_TERENURI_INACTIVE_DYNAMIC",
|
||||||
|
label: "Expertiză terenuri inactive",
|
||||||
|
category: "documentatii",
|
||||||
|
endpoint: "aut",
|
||||||
|
spatialFilter: true,
|
||||||
|
subLayerId: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "EXPERTIZA_JUDICIARA_ADMISE_DYNAMIC",
|
||||||
|
name: "EXPERTIZA_JUDICIARA_ADMISE_DYNAMIC",
|
||||||
|
label: "Expertiză judiciară admise",
|
||||||
|
category: "documentatii",
|
||||||
|
endpoint: "aut",
|
||||||
|
spatialFilter: true,
|
||||||
|
subLayerId: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "EXPERTIZA_JUDICIARA_RESPINSE_DYNAMIC",
|
||||||
|
name: "EXPERTIZA_JUDICIARA_RESPINSE_DYNAMIC",
|
||||||
|
label: "Expertiză judiciară respinse",
|
||||||
|
category: "documentatii",
|
||||||
|
endpoint: "aut",
|
||||||
|
spatialFilter: true,
|
||||||
|
subLayerId: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "EXPERTIZA_JUDICIARA_NEINREGISTRATE_DYNAMIC",
|
||||||
|
name: "EXPERTIZA_JUDICIARA_NEINREGISTRATE_DYNAMIC",
|
||||||
|
label: "Expertiză judiciară neînreg.",
|
||||||
|
category: "documentatii",
|
||||||
|
endpoint: "aut",
|
||||||
|
spatialFilter: true,
|
||||||
|
subLayerId: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ZONE_INTERES_ACTIVE_DYNAMIC",
|
||||||
|
name: "ZONE_INTERES_ACTIVE_DYNAMIC",
|
||||||
|
label: "Zone interes active",
|
||||||
|
category: "documentatii",
|
||||||
|
endpoint: "aut",
|
||||||
|
spatialFilter: true,
|
||||||
|
subLayerId: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ZONE_INTERES_INACTIVE_DYNAMIC",
|
||||||
|
name: "ZONE_INTERES_INACTIVE_DYNAMIC",
|
||||||
|
label: "Zone interes inactive",
|
||||||
|
category: "documentatii",
|
||||||
|
endpoint: "aut",
|
||||||
|
spatialFilter: true,
|
||||||
|
subLayerId: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ZONE_INTERES_PROPUSE_DYNAMIC",
|
||||||
|
name: "ZONE_INTERES_PROPUSE_DYNAMIC",
|
||||||
|
label: "Zone interes propuse",
|
||||||
|
category: "documentatii",
|
||||||
|
endpoint: "aut",
|
||||||
|
spatialFilter: true,
|
||||||
|
subLayerId: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "RECEPTII_TEHNICE_ADMISE_DYNAMIC",
|
||||||
|
name: "RECEPTII_TEHNICE_ADMISE_DYNAMIC",
|
||||||
|
label: "Recepții tehnice admise",
|
||||||
|
category: "documentatii",
|
||||||
|
endpoint: "aut",
|
||||||
|
spatialFilter: true,
|
||||||
|
subLayerId: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "RECEPTII_TEHNICE_RESPINSE_DYNAMIC",
|
||||||
|
name: "RECEPTII_TEHNICE_RESPINSE_DYNAMIC",
|
||||||
|
label: "Recepții tehnice respinse",
|
||||||
|
category: "documentatii",
|
||||||
|
endpoint: "aut",
|
||||||
|
spatialFilter: true,
|
||||||
|
subLayerId: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "RECEPTII_TEHNICE_NEINREGISTRATE_DYNAMIC",
|
||||||
|
name: "RECEPTII_TEHNICE_NEINREGISTRATE_DYNAMIC",
|
||||||
|
label: "Recepții tehnice neînreg.",
|
||||||
|
category: "documentatii",
|
||||||
|
endpoint: "aut",
|
||||||
|
spatialFilter: true,
|
||||||
|
subLayerId: 0,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Administrativ ──
|
||||||
|
{
|
||||||
|
id: "LIMITE_UAT",
|
||||||
|
name: "LIMITE_UAT",
|
||||||
|
label: "Limite UAT",
|
||||||
|
category: "administrativ",
|
||||||
|
endpoint: "all",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "LIMITE_INTRAV_DYNAMIC",
|
||||||
|
name: "LIMITE_INTRAV_DYNAMIC",
|
||||||
|
label: "Limite intravilan",
|
||||||
|
category: "administrativ",
|
||||||
|
endpoint: "aut",
|
||||||
|
spatialFilter: true,
|
||||||
|
subLayerId: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "SPECIAL_AREAS_ACTIVE_DYNAMIC",
|
||||||
|
name: "SPECIAL_AREAS_ACTIVE_DYNAMIC",
|
||||||
|
label: "Arii speciale active",
|
||||||
|
category: "administrativ",
|
||||||
|
endpoint: "aut",
|
||||||
|
spatialFilter: true,
|
||||||
|
subLayerId: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "SPECIAL_AREAS_INACTIVE_DYNAMIC",
|
||||||
|
name: "SPECIAL_AREAS_INACTIVE_DYNAMIC",
|
||||||
|
label: "Arii speciale inactive",
|
||||||
|
category: "administrativ",
|
||||||
|
endpoint: "aut",
|
||||||
|
spatialFilter: true,
|
||||||
|
subLayerId: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const findLayerById = (id?: string) =>
|
||||||
|
LAYER_CATALOG.find((l) => l.id === id);
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* In-memory progress store for long-running sync/export jobs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SyncProgress = {
|
||||||
|
jobId: string;
|
||||||
|
downloaded: number;
|
||||||
|
total?: number;
|
||||||
|
status: "running" | "done" | "error";
|
||||||
|
phase?: string;
|
||||||
|
message?: string;
|
||||||
|
note?: string;
|
||||||
|
phaseCurrent?: number;
|
||||||
|
phaseTotal?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProgressStore = Map<string, SyncProgress>;
|
||||||
|
|
||||||
|
const g = globalThis as { __parcelSyncProgressStore?: ProgressStore };
|
||||||
|
const store: ProgressStore = g.__parcelSyncProgressStore ?? new Map();
|
||||||
|
g.__parcelSyncProgressStore = store;
|
||||||
|
|
||||||
|
export const setProgress = (p: SyncProgress) => store.set(p.jobId, p);
|
||||||
|
export const getProgress = (jobId: string) => store.get(jobId);
|
||||||
|
export const clearProgress = (jobId: string) => store.delete(jobId);
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
/**
|
||||||
|
* Sync engine — downloads eTerra features and stores them in PostgreSQL.
|
||||||
|
*
|
||||||
|
* Supports incremental sync: compares remote OBJECTIDs with local DB,
|
||||||
|
* only downloads new features, marks removed ones.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Prisma, PrismaClient } from "@prisma/client";
|
||||||
|
import { EterraClient } from "./eterra-client";
|
||||||
|
import type { LayerConfig } from "./eterra-client";
|
||||||
|
import { esriToGeojson } from "./esri-geojson";
|
||||||
|
import { findLayerById, type LayerCatalogItem } from "./eterra-layers";
|
||||||
|
import { fetchUatGeometry } from "./uat-geometry";
|
||||||
|
import {
|
||||||
|
setProgress,
|
||||||
|
clearProgress,
|
||||||
|
type SyncProgress,
|
||||||
|
} from "./progress-store";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
export type SyncResult = {
|
||||||
|
layerId: string;
|
||||||
|
siruta: string;
|
||||||
|
totalRemote: number;
|
||||||
|
totalLocal: number;
|
||||||
|
newFeatures: number;
|
||||||
|
removedFeatures: number;
|
||||||
|
status: "done" | "error";
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync a single layer for a UAT into the local GIS database.
|
||||||
|
*
|
||||||
|
* 1. Count remote features
|
||||||
|
* 2. Get local OBJECTIDs already stored
|
||||||
|
* 3. Download only new OBJECTIDs (incremental)
|
||||||
|
* 4. Mark removed ones (present local, absent remote)
|
||||||
|
* 5. Store results + sync run metadata
|
||||||
|
*/
|
||||||
|
export async function syncLayer(
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
siruta: string,
|
||||||
|
layerId: string,
|
||||||
|
options?: {
|
||||||
|
uatName?: string;
|
||||||
|
jobId?: string;
|
||||||
|
forceFullSync?: boolean;
|
||||||
|
},
|
||||||
|
): Promise<SyncResult> {
|
||||||
|
const jobId = options?.jobId;
|
||||||
|
const layer = findLayerById(layerId);
|
||||||
|
if (!layer) throw new Error(`Layer ${layerId} not found`);
|
||||||
|
|
||||||
|
const push = (partial: Partial<SyncProgress>) => {
|
||||||
|
if (!jobId) return;
|
||||||
|
setProgress({
|
||||||
|
jobId,
|
||||||
|
downloaded: 0,
|
||||||
|
status: "running",
|
||||||
|
...partial,
|
||||||
|
} as SyncProgress);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create sync run record
|
||||||
|
const syncRun = await prisma.gisSyncRun.create({
|
||||||
|
data: {
|
||||||
|
siruta,
|
||||||
|
uatName: options?.uatName,
|
||||||
|
layerId,
|
||||||
|
status: "running",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
push({ phase: "Conectare eTerra", downloaded: 0 });
|
||||||
|
const client = await EterraClient.create(username, password);
|
||||||
|
|
||||||
|
// Get UAT geometry for spatial-filtered layers
|
||||||
|
let uatGeometry;
|
||||||
|
if (layer.spatialFilter) {
|
||||||
|
push({ phase: "Obținere geometrie UAT" });
|
||||||
|
uatGeometry = await fetchUatGeometry(client, siruta);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count remote features
|
||||||
|
push({ phase: "Numărare remote" });
|
||||||
|
let remoteCount: number;
|
||||||
|
try {
|
||||||
|
remoteCount = uatGeometry
|
||||||
|
? await client.countLayerByGeometry(layer, uatGeometry)
|
||||||
|
: await client.countLayer(layer, siruta);
|
||||||
|
} catch {
|
||||||
|
remoteCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
push({ phase: "Verificare locală", total: remoteCount });
|
||||||
|
|
||||||
|
// Get local OBJECTIDs for this layer+siruta
|
||||||
|
const localFeatures = await prisma.gisFeature.findMany({
|
||||||
|
where: { layerId, siruta },
|
||||||
|
select: { objectId: true },
|
||||||
|
});
|
||||||
|
const localObjIds = new Set(localFeatures.map((f) => f.objectId));
|
||||||
|
|
||||||
|
// Fetch all remote features
|
||||||
|
push({ phase: "Descărcare features", downloaded: 0, total: remoteCount });
|
||||||
|
|
||||||
|
const allRemote = uatGeometry
|
||||||
|
? await client.fetchAllLayerByGeometry(layer, uatGeometry, {
|
||||||
|
total: remoteCount > 0 ? remoteCount : undefined,
|
||||||
|
onProgress: (dl, tot) =>
|
||||||
|
push({ phase: "Descărcare features", downloaded: dl, total: tot }),
|
||||||
|
delayMs: 200,
|
||||||
|
})
|
||||||
|
: await client.fetchAllLayerByWhere(
|
||||||
|
layer,
|
||||||
|
await buildWhere(client, layer, siruta),
|
||||||
|
{
|
||||||
|
total: remoteCount > 0 ? remoteCount : undefined,
|
||||||
|
onProgress: (dl, tot) =>
|
||||||
|
push({
|
||||||
|
phase: "Descărcare features",
|
||||||
|
downloaded: dl,
|
||||||
|
total: tot,
|
||||||
|
}),
|
||||||
|
delayMs: 200,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert to GeoJSON for geometry storage
|
||||||
|
const geojson = esriToGeojson(allRemote);
|
||||||
|
const geojsonByObjId = new Map<number, (typeof geojson.features)[0]>();
|
||||||
|
for (const f of geojson.features) {
|
||||||
|
const objId = f.properties.OBJECTID as number | undefined;
|
||||||
|
if (objId != null) geojsonByObjId.set(objId, f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which OBJECTIDs are new
|
||||||
|
const remoteObjIds = new Set<number>();
|
||||||
|
for (const f of allRemote) {
|
||||||
|
const objId = f.attributes.OBJECTID as number | undefined;
|
||||||
|
if (objId != null) remoteObjIds.add(objId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newObjIds = options?.forceFullSync
|
||||||
|
? remoteObjIds
|
||||||
|
: new Set([...remoteObjIds].filter((id) => !localObjIds.has(id)));
|
||||||
|
const removedObjIds = [...localObjIds].filter(
|
||||||
|
(id) => !remoteObjIds.has(id),
|
||||||
|
);
|
||||||
|
|
||||||
|
push({
|
||||||
|
phase: "Salvare în baza de date",
|
||||||
|
downloaded: 0,
|
||||||
|
total: newObjIds.size,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Insert new features in batches
|
||||||
|
let saved = 0;
|
||||||
|
const BATCH_SIZE = 100;
|
||||||
|
const newArray = [...newObjIds];
|
||||||
|
for (let i = 0; i < newArray.length; i += BATCH_SIZE) {
|
||||||
|
const batch = newArray.slice(i, i + BATCH_SIZE);
|
||||||
|
const creates = batch
|
||||||
|
.map((objId) => {
|
||||||
|
const feature = allRemote.find(
|
||||||
|
(f) => (f.attributes.OBJECTID as number) === objId,
|
||||||
|
);
|
||||||
|
if (!feature) return null;
|
||||||
|
const geoFeature = geojsonByObjId.get(objId);
|
||||||
|
const geom = geoFeature?.geometry;
|
||||||
|
return {
|
||||||
|
layerId,
|
||||||
|
siruta,
|
||||||
|
objectId: objId,
|
||||||
|
inspireId:
|
||||||
|
(feature.attributes.INSPIRE_ID as string | undefined) ?? null,
|
||||||
|
cadastralRef:
|
||||||
|
(feature.attributes.NATIONAL_CADASTRAL_REFERENCE as
|
||||||
|
| string
|
||||||
|
| undefined) ?? null,
|
||||||
|
areaValue:
|
||||||
|
typeof feature.attributes.AREA_VALUE === "number"
|
||||||
|
? feature.attributes.AREA_VALUE
|
||||||
|
: null,
|
||||||
|
isActive: feature.attributes.IS_ACTIVE !== 0,
|
||||||
|
attributes: feature.attributes as Prisma.InputJsonValue,
|
||||||
|
geometry: geom ? (geom as Prisma.InputJsonValue) : Prisma.JsonNull,
|
||||||
|
syncRunId: syncRun.id,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
// Use upsert to handle potential conflicts (force sync)
|
||||||
|
for (const item of creates) {
|
||||||
|
if (!item) continue;
|
||||||
|
await prisma.gisFeature.upsert({
|
||||||
|
where: {
|
||||||
|
layerId_objectId: {
|
||||||
|
layerId: item.layerId,
|
||||||
|
objectId: item.objectId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: item,
|
||||||
|
update: {
|
||||||
|
...item,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
saved += creates.length;
|
||||||
|
push({
|
||||||
|
phase: "Salvare în baza de date",
|
||||||
|
downloaded: saved,
|
||||||
|
total: newObjIds.size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark removed features
|
||||||
|
if (removedObjIds.length > 0) {
|
||||||
|
push({ phase: "Marcare șterse" });
|
||||||
|
await prisma.gisFeature.deleteMany({
|
||||||
|
where: {
|
||||||
|
layerId,
|
||||||
|
siruta,
|
||||||
|
objectId: { in: removedObjIds },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update sync run
|
||||||
|
const localCount = await prisma.gisFeature.count({
|
||||||
|
where: { layerId, siruta },
|
||||||
|
});
|
||||||
|
await prisma.gisSyncRun.update({
|
||||||
|
where: { id: syncRun.id },
|
||||||
|
data: {
|
||||||
|
status: "done",
|
||||||
|
totalRemote: remoteCount,
|
||||||
|
totalLocal: localCount,
|
||||||
|
newFeatures: newObjIds.size,
|
||||||
|
removedFeatures: removedObjIds.length,
|
||||||
|
completedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
push({
|
||||||
|
phase: "Finalizat",
|
||||||
|
status: "done",
|
||||||
|
downloaded: remoteCount,
|
||||||
|
total: remoteCount,
|
||||||
|
});
|
||||||
|
if (jobId) setTimeout(() => clearProgress(jobId), 60_000);
|
||||||
|
|
||||||
|
return {
|
||||||
|
layerId,
|
||||||
|
siruta,
|
||||||
|
totalRemote: remoteCount,
|
||||||
|
totalLocal: localCount,
|
||||||
|
newFeatures: newObjIds.size,
|
||||||
|
removedFeatures: removedObjIds.length,
|
||||||
|
status: "done",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
await prisma.gisSyncRun.update({
|
||||||
|
where: { id: syncRun.id },
|
||||||
|
data: { status: "error", errorMessage: msg, completedAt: new Date() },
|
||||||
|
});
|
||||||
|
push({ phase: "Eroare", status: "error", message: msg });
|
||||||
|
if (jobId) setTimeout(() => clearProgress(jobId), 60_000);
|
||||||
|
return {
|
||||||
|
layerId,
|
||||||
|
siruta,
|
||||||
|
totalRemote: 0,
|
||||||
|
totalLocal: 0,
|
||||||
|
newFeatures: 0,
|
||||||
|
removedFeatures: 0,
|
||||||
|
status: "error",
|
||||||
|
error: msg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper to build where clause outside the client */
|
||||||
|
async function buildWhere(
|
||||||
|
client: EterraClient,
|
||||||
|
layer: LayerConfig,
|
||||||
|
siruta: string,
|
||||||
|
) {
|
||||||
|
const fields = await client.getLayerFieldNames(layer);
|
||||||
|
const preferred = [
|
||||||
|
"ADMIN_UNIT_ID",
|
||||||
|
"SIRUTA",
|
||||||
|
"UAT_ID",
|
||||||
|
"SIRUTA_UAT",
|
||||||
|
"UAT_SIRUTA",
|
||||||
|
];
|
||||||
|
const upper = fields.map((f) => f.toUpperCase());
|
||||||
|
let adminField: string | null = null;
|
||||||
|
for (const key of preferred) {
|
||||||
|
const idx = upper.indexOf(key);
|
||||||
|
if (idx >= 0) {
|
||||||
|
adminField = fields[idx] ?? null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!adminField) return "1=1";
|
||||||
|
if (!layer.whereTemplate) return `${adminField}=${siruta}`;
|
||||||
|
const hasIsActive = fields.some((f) => f.toUpperCase() === "IS_ACTIVE");
|
||||||
|
if (layer.whereTemplate.includes("IS_ACTIVE") && !hasIsActive)
|
||||||
|
return `${adminField}=${siruta}`;
|
||||||
|
return layer.whereTemplate
|
||||||
|
.replace(/\{\{adminField\}\}/g, adminField)
|
||||||
|
.replace(/\{\{siruta\}\}/g, siruta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get sync status for all layers for a given UAT.
|
||||||
|
*/
|
||||||
|
export async function getSyncStatus(siruta: string) {
|
||||||
|
const runs = await prisma.gisSyncRun.findMany({
|
||||||
|
where: { siruta },
|
||||||
|
orderBy: { startedAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const counts = await prisma.gisFeature.groupBy({
|
||||||
|
by: ["layerId"],
|
||||||
|
where: { siruta },
|
||||||
|
_count: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const countMap: Record<string, number> = {};
|
||||||
|
for (const c of counts) {
|
||||||
|
countMap[c.layerId] = c._count.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { runs, localCounts: countMap };
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* UAT geometry fetcher — retrieves the boundary polygon for a SIRUTA
|
||||||
|
* from the LIMITE_UAT layer. Used as spatial filter for dynamic layers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { EsriGeometry } from "./eterra-client";
|
||||||
|
import { EterraClient } from "./eterra-client";
|
||||||
|
import { findLayerById } from "./eterra-layers";
|
||||||
|
|
||||||
|
const findAdminField = (fields: string[]) => {
|
||||||
|
const preferred = [
|
||||||
|
"ADMIN_UNIT_ID",
|
||||||
|
"SIRUTA",
|
||||||
|
"UAT_ID",
|
||||||
|
"SIRUTA_UAT",
|
||||||
|
"UAT_SIRUTA",
|
||||||
|
];
|
||||||
|
const upper = fields.map((f) => f.toUpperCase());
|
||||||
|
for (const key of preferred) {
|
||||||
|
const idx = upper.indexOf(key);
|
||||||
|
if (idx >= 0) return fields[idx];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchUatGeometry = async (
|
||||||
|
client: EterraClient,
|
||||||
|
siruta: string,
|
||||||
|
): Promise<EsriGeometry> => {
|
||||||
|
const layer = findLayerById("LIMITE_UAT");
|
||||||
|
if (!layer) throw new Error("LIMITE_UAT not configured");
|
||||||
|
|
||||||
|
const fields = await client.getLayerFieldNames(layer);
|
||||||
|
const adminField = findAdminField(fields);
|
||||||
|
if (!adminField) throw new Error("LIMITE_UAT missing admin field");
|
||||||
|
|
||||||
|
const where = `${adminField}=${siruta}`;
|
||||||
|
const features = await client.fetchAllLayerByWhere(layer, where, {
|
||||||
|
outFields: adminField,
|
||||||
|
returnGeometry: true,
|
||||||
|
pageSize: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const geometry = features[0]?.geometry as EsriGeometry | undefined;
|
||||||
|
if (!geometry?.rings?.length) throw new Error("UAT geometry not found");
|
||||||
|
return geometry;
|
||||||
|
};
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* ParcelSync module types.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type UatEntry = {
|
||||||
|
siruta: string;
|
||||||
|
name: string;
|
||||||
|
county?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LayerSyncStatus = {
|
||||||
|
layerId: string;
|
||||||
|
localCount: number;
|
||||||
|
lastSync?: string; // ISO date
|
||||||
|
lastSyncStatus?: string; // done | error
|
||||||
|
lastSyncNew?: number;
|
||||||
|
lastSyncRemoved?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SyncRunEntry = {
|
||||||
|
id: string;
|
||||||
|
siruta: string;
|
||||||
|
uatName?: string;
|
||||||
|
layerId: string;
|
||||||
|
status: string;
|
||||||
|
totalRemote: number;
|
||||||
|
totalLocal: number;
|
||||||
|
newFeatures: number;
|
||||||
|
removedFeatures: number;
|
||||||
|
startedAt: string;
|
||||||
|
completedAt?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ParcelFeature = {
|
||||||
|
id: string;
|
||||||
|
layerId: string;
|
||||||
|
siruta: string;
|
||||||
|
objectId: number;
|
||||||
|
inspireId?: string;
|
||||||
|
cadastralRef?: string;
|
||||||
|
areaValue?: number;
|
||||||
|
isActive: boolean;
|
||||||
|
attributes: Record<string, unknown>;
|
||||||
|
geometry?: unknown;
|
||||||
|
projectId?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SyncProgress = {
|
||||||
|
jobId: string;
|
||||||
|
downloaded: number;
|
||||||
|
total?: number;
|
||||||
|
status: "running" | "done" | "error";
|
||||||
|
phase?: string;
|
||||||
|
message?: string;
|
||||||
|
note?: string;
|
||||||
|
phaseCurrent?: number;
|
||||||
|
phaseTotal?: number;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user