Compare commits

10 Commits

157 changed files with 17086 additions and 668 deletions

View File

@@ -3,6 +3,7 @@ import {
alarmsRoute, alarmsRoute,
commonManagerRoutes, commonManagerRoutes,
loginRoute, loginRoute,
managerCameraRoute,
managerRouteBase, managerRouteBase,
notFoundRoute, notFoundRoute,
profileRoute, profileRoute,
@@ -25,7 +26,7 @@ export default defineConfig({
}, },
{ {
...managerRouteBase, ...managerRouteBase,
routes: [...commonManagerRoutes], routes: [...commonManagerRoutes, managerCameraRoute],
}, },
{ {
path: '/', path: '/',

View File

@@ -23,6 +23,13 @@ export default defineConfig({
path: '/map', path: '/map',
component: './Slave/SGW/Map', component: './Slave/SGW/Map',
}, },
{
name: 'sgw.ships',
icon: 'icon-ship',
path: '/ships',
component: './Slave/SGW/Ship',
access: 'canEndUser_User',
},
{ {
name: 'sgw.trips', name: 'sgw.trips',
icon: 'icon-trip', icon: 'icon-trip',

View File

@@ -80,6 +80,11 @@ export const commonManagerRoutes = [
}, },
]; ];
export const managerCameraRoute = {
path: '/manager/devices/:thingId/camera',
component: './Manager/Device/Camera',
};
export const managerRouteBase = { export const managerRouteBase = {
name: 'manager', name: 'manager',
icon: 'icon-setting', icon: 'icon-setting',

2
package-lock.json generated
View File

@@ -1,5 +1,5 @@
{ {
"name": "smatec-frontend", "name": "SMATEC-FRONTEND",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {

123
pnpm-lock.yaml generated
View File

@@ -26,6 +26,15 @@ importers:
dayjs: dayjs:
specifier: ^1.11.19 specifier: ^1.11.19
version: 1.11.19 version: 1.11.19
moment:
specifier: ^2.30.1
version: 2.30.1
ol:
specifier: ^10.6.1
version: 10.7.0
reconnecting-websocket:
specifier: ^4.4.0
version: 4.4.0
devDependencies: devDependencies:
'@types/react': '@types/react':
specifier: ^18.0.33 specifier: ^18.0.33
@@ -1091,6 +1100,9 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==, tarball: https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz} resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==, tarball: https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz}
engines: {node: '>= 8'} engines: {node: '>= 8'}
'@petamoriken/float16@3.9.3':
resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==, tarball: https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz}
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==, tarball: https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz} resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==, tarball: https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz}
engines: {node: '>=14'} engines: {node: '>=14'}
@@ -1356,6 +1368,9 @@ packages:
'@types/prop-types@15.7.15': '@types/prop-types@15.7.15':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==, tarball: https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz} resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==, tarball: https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz}
'@types/rbush@4.0.0':
resolution: {integrity: sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==, tarball: https://registry.npmjs.org/@types/rbush/-/rbush-4.0.0.tgz}
'@types/react-dom@18.3.7': '@types/react-dom@18.3.7':
resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==, tarball: https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz} resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==, tarball: https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz}
peerDependencies: peerDependencies:
@@ -2746,6 +2761,9 @@ packages:
react: 15.x || ^16.0.0-0 react: 15.x || ^16.0.0-0
react-dom: 15.x || ^16.0.0-0 react-dom: 15.x || ^16.0.0-0
earcut@3.0.2:
resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==, tarball: https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz}
eastasianwidth@0.2.0: eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==, tarball: https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz} resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==, tarball: https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz}
@@ -3179,6 +3197,10 @@ packages:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==, tarball: https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz} resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==, tarball: https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
geotiff@2.1.3:
resolution: {integrity: sha512-PT6uoF5a1+kbC3tHmZSUsLHBp2QJlHasxxxxPW47QIY1VBKpFB+FcDvX+MxER6UzgLQZ0xDzJ9s48B9JbOCTqA==, tarball: https://registry.npmjs.org/geotiff/-/geotiff-2.1.3.tgz}
engines: {node: '>=10.19'}
get-caller-file@2.0.5: get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==, tarball: https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz} resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==, tarball: https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz}
engines: {node: 6.* || 8.* || >= 10.*} engines: {node: 6.* || 8.* || >= 10.*}
@@ -3840,6 +3862,9 @@ packages:
kolorist@1.8.0: kolorist@1.8.0:
resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==, tarball: https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz} resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==, tarball: https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz}
lerc@3.0.0:
resolution: {integrity: sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==, tarball: https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz}
less-loader@12.3.0: less-loader@12.3.0:
resolution: {integrity: sha512-0M6+uYulvYIWs52y0LqN4+QM9TqWAohYSNTo4htE8Z7Cn3G/qQMEmktfHmyJT23k+20kU9zHH2wrfFXkxNLtVw==, tarball: https://registry.npmjs.org/less-loader/-/less-loader-12.3.0.tgz} resolution: {integrity: sha512-0M6+uYulvYIWs52y0LqN4+QM9TqWAohYSNTo4htE8Z7Cn3G/qQMEmktfHmyJT23k+20kU9zHH2wrfFXkxNLtVw==, tarball: https://registry.npmjs.org/less-loader/-/less-loader-12.3.0.tgz}
engines: {node: '>= 18.12.0'} engines: {node: '>= 18.12.0'}
@@ -4281,6 +4306,9 @@ packages:
obuf@1.1.2: obuf@1.1.2:
resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==, tarball: https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz} resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==, tarball: https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz}
ol@10.7.0:
resolution: {integrity: sha512-122U5gamPqNgLpLOkogFJhgpywvd/5en2kETIDW+Ubfi9lPnZ0G9HWRdG+CX0oP8od2d6u6ky3eewIYYlrVczw==, tarball: https://registry.npmjs.org/ol/-/ol-10.7.0.tgz}
on-exit-leak-free@0.2.0: on-exit-leak-free@0.2.0:
resolution: {integrity: sha512-dqaz3u44QbRXQooZLTUKU41ZrzYrcvLISVgbrzbyCMxpmSLJvZ3ZamIJIZ29P6OhZIkNIQKosdeM6t1LYbA9hg==, tarball: https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-0.2.0.tgz} resolution: {integrity: sha512-dqaz3u44QbRXQooZLTUKU41ZrzYrcvLISVgbrzbyCMxpmSLJvZ3ZamIJIZ29P6OhZIkNIQKosdeM6t1LYbA9hg==, tarball: https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-0.2.0.tgz}
@@ -4352,6 +4380,9 @@ packages:
pako@1.0.11: pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==, tarball: https://registry.npmjs.org/pako/-/pako-1.0.11.tgz} resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==, tarball: https://registry.npmjs.org/pako/-/pako-1.0.11.tgz}
pako@2.1.0:
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==, tarball: https://registry.npmjs.org/pako/-/pako-2.1.0.tgz}
param-case@3.0.4: param-case@3.0.4:
resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==, tarball: https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz} resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==, tarball: https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz}
@@ -4363,6 +4394,9 @@ packages:
resolution: {integrity: sha512-fIYNuZ/HastSb80baGOuPRo1O9cf4baWw5WsAp7dBuUzeTD/BoaG8sVTdlPFksBE2lF21dN+A1AnrpIjSWqHHg==, tarball: https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.9.tgz} resolution: {integrity: sha512-fIYNuZ/HastSb80baGOuPRo1O9cf4baWw5WsAp7dBuUzeTD/BoaG8sVTdlPFksBE2lF21dN+A1AnrpIjSWqHHg==, tarball: https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.9.tgz}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
parse-headers@2.0.6:
resolution: {integrity: sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==, tarball: https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz}
parse-json@5.2.0: parse-json@5.2.0:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==, tarball: https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz} resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==, tarball: https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -4421,6 +4455,10 @@ packages:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==, tarball: https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz} resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==, tarball: https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz}
engines: {node: '>=8'} engines: {node: '>=8'}
pbf@4.0.1:
resolution: {integrity: sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==, tarball: https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz}
hasBin: true
pbkdf2@3.1.5: pbkdf2@3.1.5:
resolution: {integrity: sha512-Q3CG/cYvCO1ye4QKkuH7EXxs3VC/rI1/trd+qX2+PolbaKG0H+bgcZzrTt96mMyRtejk+JMCiLUn3y29W8qmFQ==, tarball: https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.5.tgz} resolution: {integrity: sha512-Q3CG/cYvCO1ye4QKkuH7EXxs3VC/rI1/trd+qX2+PolbaKG0H+bgcZzrTt96mMyRtejk+JMCiLUn3y29W8qmFQ==, tarball: https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.5.tgz}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
@@ -4835,6 +4873,9 @@ packages:
prop-types@15.8.1: prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==, tarball: https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz} resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==, tarball: https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz}
protocol-buffers-schema@3.6.0:
resolution: {integrity: sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==, tarball: https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz}
proxy-addr@2.0.7: proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==, tarball: https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz} resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==, tarball: https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
@@ -4883,6 +4924,13 @@ packages:
resolution: {integrity: sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==, tarball: https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz} resolution: {integrity: sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==, tarball: https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz}
engines: {node: '>=8'} engines: {node: '>=8'}
quick-lru@6.1.2:
resolution: {integrity: sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==, tarball: https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz}
engines: {node: '>=12'}
quickselect@3.0.0:
resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==, tarball: https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz}
randombytes@2.1.0: randombytes@2.1.0:
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==, tarball: https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz} resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==, tarball: https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz}
@@ -4897,6 +4945,9 @@ packages:
resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==, tarball: https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz} resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==, tarball: https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
rbush@4.0.1:
resolution: {integrity: sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==, tarball: https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz}
rc-align@4.0.15: rc-align@4.0.15:
resolution: {integrity: sha512-wqJtVH60pka/nOX7/IspElA8gjPNQKIx/ZqJ6heATCkXpe1Zg4cPVrMD2vC96wjsFFL8WsmhPbx9tdMo1qqlIA==, tarball: https://registry.npmjs.org/rc-align/-/rc-align-4.0.15.tgz} resolution: {integrity: sha512-wqJtVH60pka/nOX7/IspElA8gjPNQKIx/ZqJ6heATCkXpe1Zg4cPVrMD2vC96wjsFFL8WsmhPbx9tdMo1qqlIA==, tarball: https://registry.npmjs.org/rc-align/-/rc-align-4.0.15.tgz}
peerDependencies: peerDependencies:
@@ -5462,6 +5513,9 @@ packages:
resolution: {integrity: sha512-r/H9MzAWtrv8aSVjPCMFpDMl5q66GqtmmRkRjpHTsp4zBAa+snZyiQNlMONiUmEJcsnaw0wCauJ2GWODr/aFkg==, tarball: https://registry.npmjs.org/real-require/-/real-require-0.1.0.tgz} resolution: {integrity: sha512-r/H9MzAWtrv8aSVjPCMFpDMl5q66GqtmmRkRjpHTsp4zBAa+snZyiQNlMONiUmEJcsnaw0wCauJ2GWODr/aFkg==, tarball: https://registry.npmjs.org/real-require/-/real-require-0.1.0.tgz}
engines: {node: '>= 12.13.0'} engines: {node: '>= 12.13.0'}
reconnecting-websocket@4.4.0:
resolution: {integrity: sha512-D2E33ceRPga0NvTDhJmphEgJ7FUYF0v4lr1ki0csq06OdlxKfugGzN0dSkxM/NfqCxYELK4KcaTOUOjTV6Dcng==, tarball: https://registry.npmjs.org/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz}
redent@3.0.0: redent@3.0.0:
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==, tarball: https://registry.npmjs.org/redent/-/redent-3.0.0.tgz} resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==, tarball: https://registry.npmjs.org/redent/-/redent-3.0.0.tgz}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -5538,6 +5592,9 @@ packages:
resolve-pkg-maps@1.0.0: resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==, tarball: https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz} resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==, tarball: https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz}
resolve-protobuf-schema@2.1.0:
resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==, tarball: https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz}
resolve-url-loader@5.0.0: resolve-url-loader@5.0.0:
resolution: {integrity: sha512-uZtduh8/8srhBoMx//5bwqjQ+rfYOUq8zC9NrMUGtjBiGTtFJM42s58/36+hTqeqINcnYe08Nj3LkK9lW4N8Xg==, tarball: https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-5.0.0.tgz} resolution: {integrity: sha512-uZtduh8/8srhBoMx//5bwqjQ+rfYOUq8zC9NrMUGtjBiGTtFJM42s58/36+hTqeqINcnYe08Nj3LkK9lW4N8Xg==, tarball: https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-5.0.0.tgz}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -6377,6 +6434,9 @@ packages:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==, tarball: https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz} resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==, tarball: https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz}
engines: {node: '>= 8'} engines: {node: '>= 8'}
web-worker@1.5.0:
resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==, tarball: https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz}
webpack-5-chain@8.0.1: webpack-5-chain@8.0.1:
resolution: {integrity: sha512-Tu1w80WA2Z+X6e7KzGy+cc0A0z+npVJA/fh55q2azMJ030gqz343Kx+yNAstDCeugsepmtDWY2J2IBRW/O+DEA==, tarball: https://registry.npmjs.org/webpack-5-chain/-/webpack-5-chain-8.0.1.tgz} resolution: {integrity: sha512-Tu1w80WA2Z+X6e7KzGy+cc0A0z+npVJA/fh55q2azMJ030gqz343Kx+yNAstDCeugsepmtDWY2J2IBRW/O+DEA==, tarball: https://registry.npmjs.org/webpack-5-chain/-/webpack-5-chain-8.0.1.tgz}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -6454,6 +6514,9 @@ packages:
utf-8-validate: utf-8-validate:
optional: true optional: true
xml-utils@1.10.2:
resolution: {integrity: sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==, tarball: https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz}
xtend@4.0.2: xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==, tarball: https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz} resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==, tarball: https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz}
engines: {node: '>=0.4'} engines: {node: '>=0.4'}
@@ -6501,6 +6564,9 @@ packages:
zod@3.25.76: zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==, tarball: https://registry.npmjs.org/zod/-/zod-3.25.76.tgz} resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==, tarball: https://registry.npmjs.org/zod/-/zod-3.25.76.tgz}
zstddec@0.1.0:
resolution: {integrity: sha512-w2NTI8+3l3eeltKAdK8QpiLo/flRAr2p8AGeakfMZOXBxOg9HIu4LVDxBi81sYgVhFhdJjv1OrB5ssI8uFPoLg==, tarball: https://registry.npmjs.org/zstddec/-/zstddec-0.1.0.tgz}
snapshots: snapshots:
'@ahooksjs/use-request@2.8.15(react@18.3.1)': '@ahooksjs/use-request@2.8.15(react@18.3.1)':
@@ -7810,6 +7876,8 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5 '@nodelib/fs.scandir': 2.1.5
fastq: 1.20.1 fastq: 1.20.1
'@petamoriken/float16@3.9.3': {}
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
optional: true optional: true
@@ -8095,6 +8163,8 @@ snapshots:
'@types/prop-types@15.7.15': {} '@types/prop-types@15.7.15': {}
'@types/rbush@4.0.0': {}
'@types/react-dom@18.3.7(@types/react@18.3.27)': '@types/react-dom@18.3.7(@types/react@18.3.27)':
dependencies: dependencies:
'@types/react': 18.3.27 '@types/react': 18.3.27
@@ -10061,6 +10131,8 @@ snapshots:
react-router-redux: 5.0.0-alpha.9(react@18.3.1) react-router-redux: 5.0.0-alpha.9(react@18.3.1)
redux: 3.7.2 redux: 3.7.2
earcut@3.0.2: {}
eastasianwidth@0.2.0: {} eastasianwidth@0.2.0: {}
ee-first@1.1.1: {} ee-first@1.1.1: {}
@@ -10704,6 +10776,17 @@ snapshots:
gensync@1.0.0-beta.2: {} gensync@1.0.0-beta.2: {}
geotiff@2.1.3:
dependencies:
'@petamoriken/float16': 3.9.3
lerc: 3.0.0
pako: 2.1.0
parse-headers: 2.0.6
quick-lru: 6.1.2
web-worker: 1.5.0
xml-utils: 1.10.2
zstddec: 0.1.0
get-caller-file@2.0.5: {} get-caller-file@2.0.5: {}
get-intrinsic@1.3.0: get-intrinsic@1.3.0:
@@ -11398,6 +11481,8 @@ snapshots:
kolorist@1.8.0: {} kolorist@1.8.0: {}
lerc@3.0.0: {}
less-loader@12.3.0(less@4.5.1)(webpack@5.104.1): less-loader@12.3.0(less@4.5.1)(webpack@5.104.1):
dependencies: dependencies:
less: 4.5.1 less: 4.5.1
@@ -11877,6 +11962,14 @@ snapshots:
obuf@1.1.2: {} obuf@1.1.2: {}
ol@10.7.0:
dependencies:
'@types/rbush': 4.0.0
earcut: 3.0.2
geotiff: 2.1.3
pbf: 4.0.1
rbush: 4.0.1
on-exit-leak-free@0.2.0: {} on-exit-leak-free@0.2.0: {}
on-finished@2.3.0: on-finished@2.3.0:
@@ -11953,6 +12046,8 @@ snapshots:
pako@1.0.11: {} pako@1.0.11: {}
pako@2.1.0: {}
param-case@3.0.4: param-case@3.0.4:
dependencies: dependencies:
dot-case: 3.0.4 dot-case: 3.0.4
@@ -11970,6 +12065,8 @@ snapshots:
pbkdf2: 3.1.5 pbkdf2: 3.1.5
safe-buffer: 5.2.1 safe-buffer: 5.2.1
parse-headers@2.0.6: {}
parse-json@5.2.0: parse-json@5.2.0:
dependencies: dependencies:
'@babel/code-frame': 7.28.6 '@babel/code-frame': 7.28.6
@@ -12017,6 +12114,10 @@ snapshots:
path-type@4.0.0: {} path-type@4.0.0: {}
pbf@4.0.1:
dependencies:
resolve-protobuf-schema: 2.1.0
pbkdf2@3.1.5: pbkdf2@3.1.5:
dependencies: dependencies:
create-hash: 1.2.0 create-hash: 1.2.0
@@ -12396,6 +12497,8 @@ snapshots:
object-assign: 4.1.1 object-assign: 4.1.1
react-is: 16.13.1 react-is: 16.13.1
protocol-buffers-schema@3.6.0: {}
proxy-addr@2.0.7: proxy-addr@2.0.7:
dependencies: dependencies:
forwarded: 0.2.0 forwarded: 0.2.0
@@ -12447,6 +12550,10 @@ snapshots:
quick-lru@4.0.1: {} quick-lru@4.0.1: {}
quick-lru@6.1.2: {}
quickselect@3.0.0: {}
randombytes@2.1.0: randombytes@2.1.0:
dependencies: dependencies:
safe-buffer: 5.2.1 safe-buffer: 5.2.1
@@ -12465,6 +12572,10 @@ snapshots:
iconv-lite: 0.4.24 iconv-lite: 0.4.24
unpipe: 1.0.0 unpipe: 1.0.0
rbush@4.0.1:
dependencies:
quickselect: 3.0.0
rc-align@4.0.15(react-dom@18.3.1(react@18.3.1))(react@18.3.1): rc-align@4.0.15(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.28.6
@@ -13262,6 +13373,8 @@ snapshots:
real-require@0.1.0: {} real-require@0.1.0: {}
reconnecting-websocket@4.4.0: {}
redent@3.0.0: redent@3.0.0:
dependencies: dependencies:
indent-string: 4.0.0 indent-string: 4.0.0
@@ -13340,6 +13453,10 @@ snapshots:
resolve-pkg-maps@1.0.0: {} resolve-pkg-maps@1.0.0: {}
resolve-protobuf-schema@2.1.0:
dependencies:
protocol-buffers-schema: 3.6.0
resolve-url-loader@5.0.0: resolve-url-loader@5.0.0:
dependencies: dependencies:
adjust-sourcemap-loader: 4.0.0 adjust-sourcemap-loader: 4.0.0
@@ -14334,6 +14451,8 @@ snapshots:
web-streams-polyfill@3.3.3: {} web-streams-polyfill@3.3.3: {}
web-worker@1.5.0: {}
webpack-5-chain@8.0.1: webpack-5-chain@8.0.1:
dependencies: dependencies:
deepmerge: 1.5.2 deepmerge: 1.5.2
@@ -14447,6 +14566,8 @@ snapshots:
ws@8.19.0: {} ws@8.19.0: {}
xml-utils@1.10.2: {}
xtend@4.0.2: {} xtend@4.0.2: {}
y18n@5.0.8: {} y18n@5.0.8: {}
@@ -14480,3 +14601,5 @@ snapshots:
zod: 3.25.76 zod: 3.25.76
zod@3.25.76: {} zod@3.25.76: {}
zstddec@0.1.0: {}

View File

@@ -25,8 +25,8 @@ import {
} from './utils/storage'; } from './utils/storage';
const isProdBuild = process.env.NODE_ENV === 'production'; const isProdBuild = process.env.NODE_ENV === 'production';
export type InitialStateResponse = { export type InitialStateResponse = {
getUserProfile?: () => Promise<MasterModel.ProfileResponse | undefined>; getUserProfile?: () => Promise<MasterModel.UserResponse | undefined>;
currentUserProfile?: MasterModel.ProfileResponse; currentUserProfile?: MasterModel.UserResponse;
theme?: 'light' | 'dark'; theme?: 'light' | 'dark';
}; };
@@ -86,7 +86,7 @@ export const layout: RunTimeLayoutConfig = ({ initialState }) => {
contentWidth: 'Fluid', contentWidth: 'Fluid',
navTheme: isDark ? 'realDark' : 'light', navTheme: isDark ? 'realDark' : 'light',
splitMenus: true, splitMenus: true,
iconfontUrl: '//at.alicdn.com/t/c/font_5096559_fvnh1x2eqer.js', iconfontUrl: '//at.alicdn.com/t/c/font_5096559_pwy498d2aw.js',
contentStyle: { contentStyle: {
padding: 0, padding: 0,
margin: 0, margin: 0,

BIN
src/assets/alarm_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

BIN
src/assets/exclamation.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

BIN
src/assets/marker.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
src/assets/ship_alarm.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
src/assets/ship_alarm_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

BIN
src/assets/ship_online.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

BIN
src/assets/ship_warning.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

BIN
src/assets/sos_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

BIN
src/assets/warning_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@@ -13,7 +13,7 @@ import { Dropdown } from 'antd';
export const AvatarDropdown = ({ export const AvatarDropdown = ({
currentUserProfile, currentUserProfile,
}: { }: {
currentUserProfile?: MasterModel.ProfileResponse; currentUserProfile?: MasterModel.UserResponse;
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
return ( return (

View File

@@ -1,7 +1,7 @@
import { createFromIconfontCN } from '@ant-design/icons'; import { createFromIconfontCN } from '@ant-design/icons';
const IconFont = createFromIconfontCN({ const IconFont = createFromIconfontCN({
scriptUrl: '//at.alicdn.com/t/c/font_5096559_fvnh1x2eqer.js', scriptUrl: '//at.alicdn.com/t/c/font_5096559_pwy498d2aw.js',
}); });
export default IconFont; export default IconFont;

View File

@@ -0,0 +1,62 @@
import { DeleteOutlined, EditOutlined } from '@ant-design/icons';
import { Button, Popconfirm, Tooltip } from 'antd';
import { useState } from 'react';
/* =======================
DeleteButton
======================= */
interface DeleteButtonProps {
title: string;
text: string;
onOk: () => void | Promise<void>;
}
export const DeleteButton: React.FC<DeleteButtonProps> = ({
title,
text,
onOk,
}) => {
const [visible, setVisible] = useState<boolean>(false);
const handleConfirm = async () => {
await onOk();
setVisible(false);
};
return (
<Popconfirm
title={title}
open={visible}
onConfirm={handleConfirm}
onCancel={() => setVisible(false)}
>
<Tooltip title={text}>
<Button
size="small"
danger
type="primary"
icon={<DeleteOutlined />}
onClick={() => setVisible(true)}
/>
</Tooltip>
</Popconfirm>
);
};
/* =======================
EditButton
======================= */
interface EditButtonProps {
text: string;
onClick: () => void;
}
export const EditButton: React.FC<EditButtonProps> = ({ text, onClick }) => {
return (
<Tooltip title={text}>
<Button size="small" icon={<EditOutlined />} onClick={onClick} />
</Tooltip>
);
};

View File

@@ -0,0 +1,298 @@
import { HTTPSTATUS } from '@/const';
import {
apiDeletePhoto,
apiGetPhoto,
apiGetTagsPhoto,
apiUploadPhoto,
} from '@/services/slave/sgw/PhotoController';
import { ModalForm, ProFormUploadButton } from '@ant-design/pro-components';
import { useIntl } from '@umijs/max';
import { Divider, message } from 'antd';
import { UploadFile } from 'antd/lib';
import { useState } from 'react';
type PhotoActionModalProps = {
isOpen?: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
type: SgwModel.PhotoGetParams['type'];
id: string | number;
hasSubPhotos?: boolean;
};
const PhotoActionModal = ({
isOpen,
setIsOpen,
type,
id,
hasSubPhotos = true,
}: PhotoActionModalProps) => {
const [imageMain, setImageMain] = useState<UploadFile[]>([]);
const [imageSubs, setImageSubs] = useState<UploadFile[]>([]);
const [messageApi, contextHolder] = message.useMessage();
const intl = useIntl();
const fetchImageByTag = async (tag: string): Promise<UploadFile | null> => {
try {
const resp = await apiGetPhoto(type, id, tag);
if (resp.status !== HTTPSTATUS.HTTP_SUCCESS) {
return null;
}
const objectUrl = URL.createObjectURL(
new Blob([resp.data], { type: 'image/jpeg' }),
);
return {
uid: `-${tag}`,
name: `${tag}.jpg`,
status: 'done',
url: objectUrl,
};
} catch {
return null;
}
};
const handleUploadPhoto = async (
file: UploadFile,
tag: string,
): Promise<boolean> => {
try {
const resp = await apiUploadPhoto(
type,
String(id),
file.originFileObj as File,
tag,
);
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
return true;
}
throw new Error('Upload photo failed');
} catch (error) {
console.error('Error when upload image: ', error);
return false;
}
};
const handleDeletePhoto = async (tag: string): Promise<boolean> => {
try {
const resp = await apiDeletePhoto(type, String(id), tag);
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
return true;
}
throw new Error('Delete photo failed');
} catch (error) {
console.error('Error when delete image: ', error);
return false;
}
};
return (
<ModalForm
open={isOpen}
submitter={false}
onOpenChange={setIsOpen}
layout="vertical"
request={async () => {
// 1. Lấy ảnh chính (tag 'main')
const mainImage = await fetchImageByTag('main');
setImageMain(mainImage ? [mainImage] : []);
if (hasSubPhotos) {
// 2. Lấy danh sách tags
try {
const tagsResp = await apiGetTagsPhoto(type, id);
if (tagsResp?.tags && Array.isArray(tagsResp.tags)) {
// Lọc bỏ tag 'main' và lấy các tag còn lại
const subTags = tagsResp.tags.filter(
(tag: string) => tag !== 'main',
);
// 3. Lấy ảnh cho từng tag phụ
const subImages: UploadFile[] = [];
for (const tag of subTags) {
const img = await fetchImageByTag(tag);
if (img) {
subImages.push(img);
}
}
setImageSubs(subImages);
}
} catch {
// Không có tags hoặc lỗi
}
}
return {};
}}
>
{contextHolder}
<Divider>
{intl.formatMessage({ id: 'photo.main', defaultMessage: 'Ảnh chính' })}
</Divider>
<div
style={{
display: 'flex',
justifyContent: 'center',
marginBottom: 16,
}}
>
<ProFormUploadButton
name="main-picture"
label={null}
title={intl.formatMessage({
id: 'photo.upload',
defaultMessage: 'Chọn ảnh',
})}
accept="image/*"
max={1}
transform={(value) => ({ upload: value })}
fieldProps={{
onChange: async (info) => {
if (info.file.status !== 'removed') {
const isSuccess = await handleUploadPhoto(info.file, 'main');
if (!isSuccess) {
messageApi.error(
intl.formatMessage({
id: 'photo.update_fail',
defaultMessage: 'Cập nhật ảnh thất bại',
}),
);
setImageMain([]);
} else {
messageApi.success(
intl.formatMessage({
id: 'photo.update_success',
defaultMessage: 'Cập nhật ảnh thành công',
}),
);
// Dùng luôn file local để tạo URL, đỡ gọi API
const objectUrl = URL.createObjectURL(
info.file.originFileObj as File,
);
setImageMain([
{
uid: info.file.uid,
name: info.file.name,
status: 'done',
url: objectUrl,
},
]);
}
}
},
listType: 'picture-card',
fileList: imageMain,
maxCount: 1,
onRemove: async () => {
const isSuccess = await handleDeletePhoto('main');
if (!isSuccess) {
messageApi.error(
intl.formatMessage({
id: 'photo.delete_fail',
defaultMessage: 'Xóa ảnh thất bại',
}),
);
} else {
messageApi.success(
intl.formatMessage({
id: 'photo.delete_success',
defaultMessage: 'Xóa ảnh thành công',
}),
);
setImageMain([]);
}
},
}}
/>
</div>
{hasSubPhotos && (
<>
<Divider>
{intl.formatMessage({ id: 'photo.sub', defaultMessage: 'Ảnh phụ' })}
</Divider>
<div
style={{
display: 'flex',
justifyContent: imageSubs.length > 0 ? 'flex-start' : 'center',
marginBottom: 16,
}}
>
<ProFormUploadButton
name="sub-picture"
label={null}
title={intl.formatMessage({
id: 'photo.upload',
defaultMessage: 'Chọn ảnh',
})}
accept="image/*"
max={10}
transform={(value) => ({ upload: value })}
fieldProps={{
onChange: async (info) => {
// Tìm file mới được thêm (so sánh với imageSubs hiện tại)
const existingUids = new Set(imageSubs.map((f) => f.uid));
const newFiles = info.fileList.filter(
(f) => !existingUids.has(f.uid) && f.originFileObj,
);
for (const file of newFiles) {
// Tạo tag random
const randomTag = `sub_${Date.now()}_${Math.random()
.toString(36)
.substring(2, 9)}`;
const isSuccess = await handleUploadPhoto(file, randomTag);
if (!isSuccess) {
messageApi.error(
intl.formatMessage({
id: 'photo.update_fail',
defaultMessage: 'Cập nhật ảnh thất bại',
}),
);
setImageSubs(imageSubs); // Revert về state cũ
return;
}
messageApi.success(
intl.formatMessage({
id: 'photo.update_success',
defaultMessage: 'Thêm ảnh thành công',
}),
);
// Cập nhật file với tag
(file as any).tag = randomTag;
file.url = URL.createObjectURL(file.originFileObj as File);
file.status = 'done';
}
setImageSubs(info.fileList);
},
listType: 'picture-card',
fileList: imageSubs,
onRemove: async (file) => {
// Lấy tag từ file (được lưu khi fetch hoặc upload)
const tag = (file as any).tag || file.uid?.replace(/^-/, '');
const isSuccess = await handleDeletePhoto(tag);
if (!isSuccess) {
messageApi.error(
intl.formatMessage({
id: 'photo.delete_fail',
defaultMessage: 'Xóa ảnh thất bại',
}),
);
return false;
}
messageApi.success(
intl.formatMessage({
id: 'photo.delete_success',
defaultMessage: 'Xóa ảnh thành công',
}),
);
setImageSubs((prev) =>
prev.filter((f) => f.uid !== file.uid),
);
return true;
},
}}
/>
</div>
</>
)}
</ModalForm>
);
};
export default PhotoActionModal;

View File

@@ -0,0 +1,205 @@
import {
AlertOutlined,
CheckOutlined,
DisconnectOutlined,
ExclamationOutlined,
WarningOutlined,
} from '@ant-design/icons';
import { useIntl } from '@umijs/max';
import { Flex, Tag, theme, Tooltip } from 'antd';
import { useState } from 'react';
import { TagStateCallbackPayload } from '../../pages/Slave/SGW/Map/type';
type TagStateProps = {
normalCount?: number;
warningCount?: number;
criticalCount?: number;
sosCount?: number;
disconnectedCount?: number;
onTagPress?: (selection: TagStateCallbackPayload) => void;
};
const TagState = ({
normalCount = 0,
warningCount = 0,
criticalCount = 0,
sosCount,
disconnectedCount = 0,
onTagPress,
}: TagStateProps) => {
const [activeStates, setActiveStates] = useState({
normal: false,
warning: false,
critical: false,
sos: false,
disconnected: false,
});
const { token } = theme.useToken();
const intl = useIntl();
// Style variants using antd theme tokens for dark mode support
const getTagStyle = (
type: 'normal' | 'warning' | 'critical' | 'offline',
isActive: boolean,
) => {
const baseStyle = {
borderRadius: token.borderRadiusSM,
borderWidth: 1,
borderStyle: 'solid' as const,
};
if (type === 'normal') {
return {
...baseStyle,
color: isActive ? token.colorSuccess : token.colorSuccess,
backgroundColor: isActive
? token.colorSuccessBg
: token.colorBgContainer,
borderColor: token.colorSuccessBorder,
};
}
if (type === 'warning') {
return {
...baseStyle,
color: isActive ? token.colorWarning : token.colorWarning,
backgroundColor: isActive
? token.colorWarningBg
: token.colorBgContainer,
borderColor: token.colorWarningBorder,
};
}
if (type === 'critical') {
return {
...baseStyle,
color: isActive ? token.colorError : token.colorError,
backgroundColor: isActive ? token.colorErrorBg : token.colorBgContainer,
borderColor: token.colorErrorBorder,
};
}
// offline
return {
...baseStyle,
color: token.colorTextSecondary,
backgroundColor: isActive
? token.colorFillSecondary
: token.colorBgContainer,
borderColor: token.colorBorder,
};
};
const handleTagClick = (key: keyof typeof activeStates) => {
const newStates = { ...activeStates, [key]: !activeStates[key] };
setActiveStates(newStates);
if (onTagPress) {
onTagPress({
isNormal: newStates.normal,
isWarning: newStates.warning,
isCritical: newStates.critical,
isSos: newStates.sos,
isDisconnected: newStates.disconnected,
});
}
};
return (
<Flex
gap={1}
style={{
overflowX: 'auto',
overflowY: 'hidden',
whiteSpace: 'nowrap',
minWidth: 0,
zIndex: 20,
flexWrap: 'nowrap',
}}
>
{/* Only show SOS tag if sosCount is provided (SGW environment) */}
{sosCount !== undefined && (
<Tooltip
title={intl.formatMessage({
id: 'common.level.sos',
defaultMessage: 'SOS',
})}
>
<Tag.CheckableTag
style={getTagStyle('critical', activeStates.sos)}
icon={<AlertOutlined />}
checked={activeStates.sos}
onChange={() => handleTagClick('sos')}
>
{`${sosCount}`}
</Tag.CheckableTag>
</Tooltip>
)}
{/* {normalCount > 0 && ( */}
<Tooltip
title={intl.formatMessage({
id: 'common.level.normal',
defaultMessage: 'Normal',
})}
>
<Tag.CheckableTag
style={getTagStyle('normal', activeStates.normal)}
icon={<CheckOutlined />}
checked={activeStates.normal}
onChange={() => handleTagClick('normal')}
>
{`${normalCount}`}
</Tag.CheckableTag>
</Tooltip>
{/* {warningCount > 0 && ( */}
<Tooltip
title={intl.formatMessage({
id: 'common.level.warning',
defaultMessage: 'Warning',
})}
>
<Tag.CheckableTag
style={getTagStyle('warning', activeStates.warning)}
icon={<WarningOutlined />}
checked={activeStates.warning}
onChange={() => handleTagClick('warning')}
>
{`${warningCount}`}
</Tag.CheckableTag>
</Tooltip>
{/* {criticalCount > 0 && ( */}
<Tooltip
title={intl.formatMessage({
id: 'common.level.critical',
defaultMessage: 'Critical',
})}
>
<Tag.CheckableTag
style={getTagStyle('critical', activeStates.critical)}
icon={<ExclamationOutlined />}
checked={activeStates.critical}
onChange={() => handleTagClick('critical')}
>
{`${criticalCount}`}
</Tag.CheckableTag>
</Tooltip>
{/* {disconnectedCount > 0 && ( */}
<Tooltip
title={intl.formatMessage({
id: 'common.level.disconnected',
defaultMessage: 'Disconnected',
})}
>
<Tag.CheckableTag
style={getTagStyle('offline', activeStates.disconnected)}
icon={<DisconnectOutlined />}
checked={activeStates.disconnected}
onChange={() => handleTagClick('disconnected')}
>
{`${disconnectedCount}`}
</Tag.CheckableTag>
</Tooltip>
</Flex>
);
};
export default TagState;

View File

@@ -13,17 +13,23 @@ import TreeGroup from './TreeGroup';
const { Paragraph } = Typography; const { Paragraph } = Typography;
type ThingsFilterProps = { type ThingsFilterProps = {
title?: string;
isOpen?: boolean; isOpen?: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>; setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
thingIds?: string | string[] | null; thingIds?: string | string[] | null;
extra?: React.ReactNode;
onSubmit?: (thingIds: string[]) => void; onSubmit?: (thingIds: string[]) => void;
disabled?: boolean;
}; };
const ThingsFilter = ({ const ThingsFilter = ({
title,
isOpen, isOpen,
setIsOpen, setIsOpen,
thingIds, thingIds,
extra,
onSubmit, onSubmit,
disabled = false,
}: ThingsFilterProps) => { }: ThingsFilterProps) => {
const intl = useIntl(); const intl = useIntl();
const { useBreakpoint } = Grid; const { useBreakpoint } = Grid;
@@ -93,6 +99,7 @@ const ThingsFilter = ({
]; ];
return ( return (
<Modal <Modal
title={title ? title : null}
open={isOpen} open={isOpen}
centered centered
width="80%" width="80%"
@@ -104,7 +111,10 @@ const ThingsFilter = ({
<FormattedMessage id="common.cancel" defaultMessage="Cancel" /> <FormattedMessage id="common.cancel" defaultMessage="Cancel" />
} }
> >
<ProCard split={screens.md ? 'vertical' : 'horizontal'}> <ProCard
split={screens.md ? 'vertical' : 'horizontal'}
extra={extra ? extra : null}
>
<ProCard colSpan={{ xs: 24, sm: 8, md: 8, lg: 6, xl: 6 }}> <ProCard colSpan={{ xs: 24, sm: 8, md: 8, lg: 6, xl: 6 }}>
<TreeGroup <TreeGroup
disable={isLoading} disable={isLoading}
@@ -128,6 +138,15 @@ const ThingsFilter = ({
alwaysShowAlert: true, alwaysShowAlert: true,
selectedRowKeys, selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys), onChange: (keys) => setSelectedRowKeys(keys),
getCheckboxProps: (record) => ({
disabled:
disabled && thingIds && record.id
? (Array.isArray(thingIds)
? thingIds
: [thingIds]
).includes(record.id)
: false,
}),
}} }}
pagination={{ pagination={{
size: 'small', size: 'small',

View File

@@ -0,0 +1,32 @@
import {
STATUS_DANGEROUS,
STATUS_NORMAL,
STATUS_SOS,
STATUS_WARNING,
} from '@/constants';
import { Badge } from 'antd';
import IconFont from '../IconFont';
export const getBadgeStatus = (status: number) => {
switch (status) {
case STATUS_NORMAL:
return <Badge size="default" status="success" />;
case STATUS_WARNING:
return <Badge size="default" status="warning" />;
case STATUS_DANGEROUS:
return <Badge size="default" status="error" />;
case STATUS_SOS:
return <Badge size="default" status="error" />;
default:
return <Badge size="default" status="default" />;
}
};
export const getBadgeConnection = (online: boolean) => {
switch (online) {
case true:
return <Badge status="processing" />;
default:
return <IconFont type="icon-cloud-disconnect" />;
}
};

View File

@@ -0,0 +1,78 @@
.italic {
//font-style: italic;
margin-left: 4px;
}
.disconnected {
color: rgb(146, 143, 143);
//background-color: rgb(219, 220, 222);
cursor: pointer;
}
:global(.cursor-pointer-row .ant-table-tbody > tr) {
cursor: pointer;
}
.normalActive {
color: #52c41a;
background: #e0fec3;
border-color: #b7eb8f;
}
.warningActive {
color: #faad14;
background: #f8ebaa;
border-color: #ffe58f;
}
.criticalActive {
color: #ff4d4f;
background: #f9b9b0;
border-color: #ffccc7;
}
.normal {
color: #52c41a;
background: #fff;
border-color: #b7eb8f;
}
.warning {
color: #faad14;
background: #fff;
border-color: #ffe58f;
}
.critical {
color: #ff4d4f;
background: #fff;
border-color: #ffccc7;
}
.offline {
background: #fff;
color: rgba(0, 0, 0, 88%);
border-color: #d9d9d9;
}
.offlineActive {
background: rgb(190, 190, 190);
color: rgba(0, 0, 0, 88%);
border-color: #d9d9d9;
}
.online {
background: #fff;
color: #1677ff;
border-color: #91caff;
}
.onlineActive {
background: #c6e1f5;
color: #1677ff;
border-color: #91caff;
}
.table-row-select tbody tr:hover {
cursor: pointer;
}

2
src/const.ts Normal file
View File

@@ -0,0 +1,2 @@
// Re-export from constants for backward compatibility
export * from './constants';

View File

@@ -8,7 +8,8 @@ export const API_ALARMS_CONFIRM = '/api/alarms/confirm';
// Thing API Constants // Thing API Constants
export const API_THINGS_SEARCH = '/api/things/search'; export const API_THINGS_SEARCH = '/api/things/search';
export const API_THINGS_POLICY = '/api/things/policy'; export const API_THING_POLICY = '/api/things/policy2';
export const API_SHARE_THING = '/api/things';
// Group API Constants // Group API Constants
export const API_GROUPS = '/api/groups'; export const API_GROUPS = '/api/groups';

View File

@@ -5,6 +5,9 @@ export const DATE_TIME_FORMAT = 'DD/MM/YYYY HH:mm:ss';
export const TIME_FORMAT = 'HH:mm:ss'; export const TIME_FORMAT = 'HH:mm:ss';
export const DATE_FORMAT = 'DD/MM/YYYY'; export const DATE_FORMAT = 'DD/MM/YYYY';
export const DURATION_DISCONNECTED = 300; //seconds
export const DURATION_POLLING_PRESENTATIONS = 120000; //milliseconds
export const STATUS_NORMAL = 0; export const STATUS_NORMAL = 0;
export const STATUS_WARNING = 1; export const STATUS_WARNING = 1;
export const STATUS_DANGEROUS = 2; export const STATUS_DANGEROUS = 2;

View File

@@ -4,3 +4,12 @@ export enum SGW_ROLE {
USERS = 'users', USERS = 'users',
ENDUSER = 'enduser', ENDUSER = 'enduser',
} }
export enum SGW_STATUS {
CREATE_FISHING_LOG_SUCCESS = 'CREATE_FISHING_LOG_SUCCESS',
CREATE_FISHING_LOG_FAIL = 'CREATE_FISHING_LOG_FAIL',
START_TRIP_SUCCESS = 'START_TRIP_SUCCESS',
START_TRIP_FAIL = 'START_TRIP_FAIL',
UPDATE_FISHING_LOG_SUCCESS = 'UPDATE_FISHING_LOG_SUCCESS',
UPDATE_FISHING_LOG_FAIL = 'UPDATE_FISHING_LOG_FAIL',
}

View File

@@ -2,3 +2,36 @@ export const SGW_ROUTE_HOME = '/maps';
export const SGW_ROUTE_TRIP = '/trip'; export const SGW_ROUTE_TRIP = '/trip';
export const SGW_ROUTE_CREATE_BANZONE = '/manager/banzones/create'; export const SGW_ROUTE_CREATE_BANZONE = '/manager/banzones/create';
export const SGW_ROUTE_MANAGER_BANZONE = '/manager/banzones'; export const SGW_ROUTE_MANAGER_BANZONE = '/manager/banzones';
// API Routes
export const SGW_ROUTE_PORTS = '/api/sgw/ports';
export const SGW_ROUTE_SHIPS = '/api/sgw/ships';
export const SGW_ROUTE_SHIP_TYPES = '/api/sgw/ships/types';
export const SGW_ROUTE_SHIP_GROUPS = '/api/sgw/shipsgroup';
// Trip API Routes
export const SGW_ROUTE_TRIPS = '/api/sgw/trips';
export const SGW_ROUTE_TRIPS_LIST = '/api/sgw/tripslist';
export const SGW_ROUTE_TRIPS_LAST = '/api/sgw/trips/last';
export const SGW_ROUTE_TRIPS_BY_ID = '/api/sgw/trips-by-id';
export const SGW_ROUTE_UPDATE_TRIP_STATUS = '/api/sgw/update-trip-status';
export const SGW_ROUTE_HAUL_HANDLE = '/api/sgw/haul-handle';
export const SGW_ROUTE_GET_FISH = '/api/sgw/fishspecies-list';
export const SGW_ROUTE_UPDATE_FISHING_LOGS = '/api/sgw/update-fishing-logs';
// Crew API Routes
export const SGW_ROUTE_CREW = '/api/sgw/crew';
export const SGW_ROUTE_TRIP_CREW = '/api/sgw/tripcrew';
export const SGW_ROUTE_TRIPS_CREWS = '/api/sgw/trips/crews';
// Photo API Routes
export const SGW_ROUTE_PHOTO = '/api/sgw/photo';
export const SGW_ROUTE_PHOTO_TAGS = '/api/sgw/list-photo';
// Banzone API Routes
export const SGW_ROUTE_BANZONES = '/api/sgw/banzones';
export const SGW_ROUTE_BANZONES_LIST = '/api/sgw/banzones/list';
// Fish API Routes
export const SGW_ROUTE_CREATE_OR_UPDATE_FISH = '/api/sgw/fishspecies';
export const SGW_ROUTE_CREATE_OR_UPDATE_FISHING_LOGS = '/api/sgw/fishingLog';

View File

@@ -0,0 +1 @@
export const SHIP_SOS_WS_URL = 'wss://sgw.gms.vn/thingscache';

View File

@@ -33,11 +33,15 @@ export default {
'common.theme.dark': 'Dark Theme', 'common.theme.dark': 'Dark Theme',
'common.paginations.things': 'things', 'common.paginations.things': 'things',
'common.paginations.of': 'of', 'common.paginations.of': 'of',
'common.of': 'of',
'common.name': 'Name', 'common.name': 'Name',
'common.name.required': 'Name is required', 'common.name.required': 'Name is required',
'common.note': 'Note',
'common.image': 'Image',
'common.type': 'Type', 'common.type': 'Type',
'common.type.placeholder': 'Select Type', 'common.type.placeholder': 'Select Type',
'common.status': 'Status', 'common.status': 'Status',
'common.connect': 'Connection',
'common.province': 'Province', 'common.province': 'Province',
'common.description': 'Description', 'common.description': 'Description',
'common.description.required': 'Description is required', 'common.description.required': 'Description is required',
@@ -48,6 +52,7 @@ export default {
'common.updated_at': 'Updated At', 'common.updated_at': 'Updated At',
'common.undefined': 'Undefined', 'common.undefined': 'Undefined',
'common.not_empty': 'Cannot be empty!', 'common.not_empty': 'Cannot be empty!',
'common.level.disconnected': 'Disconnected',
'common.level.normal': 'Normal', 'common.level.normal': 'Normal',
'common.level.warning': 'Warning', 'common.level.warning': 'Warning',
'common.level.critical': 'Critical', 'common.level.critical': 'Critical',

View File

@@ -3,4 +3,45 @@ export default {
'master.thing.external_id': 'External ID', 'master.thing.external_id': 'External ID',
'master.thing.group': 'Group', 'master.thing.group': 'Group',
'master.thing.address': 'Address', 'master.thing.address': 'Address',
// Device translations
'master.devices.title': 'Devices',
'master.devices.name': 'Name',
'master.devices.name.tip': 'The device name',
'master.devices.external_id': 'External ID',
'master.devices.external_id.tip': 'The external identifier',
'master.devices.type': 'Type',
'master.devices.type.tip': 'The device type',
'master.devices.online': 'Online',
'master.devices.offline': 'Offline',
'master.devices.table.pagination': 'devices',
'master.devices.register': 'Add Device',
'master.devices.register.title': 'Add new device',
'master.devices.create.success': 'Device created successfully',
'master.devices.create.error': 'Device creation failed',
'master.devices.groups': 'Groups',
'master.devices.groups.required': 'Please select groups',
// Update info device
'master.devices.update.success': 'Updated successfully',
'master.devices.update.error': 'Update failed',
// Edit device modal
'master.devices.update.title': 'Update device',
'master.devices.ok': 'OK',
'master.devices.cancel': 'Cancel',
'master.devices.name.placeholder': 'Enter device name',
'master.devices.name.required': 'Please enter device name',
'master.devices.external_id.placeholder': 'Enter external ID',
'master.devices.external_id.required': 'Please enter external ID',
'master.devices.address': 'Address',
'master.devices.address.placeholder': 'Enter address',
'master.devices.address.required': 'Please enter address',
// Location modal
'master.devices.location.title': 'Update location',
'master.devices.location.latitude': 'Latitude',
'master.devices.location.latitude.required': 'Please enter latitude',
'master.devices.location.longitude': 'Longitude',
'master.devices.location.longitude.required': 'Please enter longitude',
'master.devices.location.placeholder': 'Enter data',
'master.devices.location.update.success': 'Location updated successfully',
'master.devices.location.update.error': 'Location update failed',
}; };

View File

@@ -48,7 +48,22 @@ export default {
'master.users.unassign.fail': 'Unassign failed', 'master.users.unassign.fail': 'Unassign failed',
'master.users.assign.success': 'Assign group successful', 'master.users.assign.success': 'Assign group successful',
'master.users.assign.fail': 'Assign group failed', 'master.users.assign.fail': 'Assign group failed',
'master.users.deletion.title': 'Are you sure to delete this selected items?', 'master.users.delete.title': 'Are you sure to delete this selected items?',
'master.users.delete.success': 'User deleted successfully', 'master.users.delete.success': 'User deleted successfully',
'master.users.delete.fail': 'User deletion failed', 'master.users.delete.fail': 'User deletion failed',
'master.users.things.list': 'List of devices',
'master.users.things.relations.text': 'Relations',
'master.users.things.relation.write': 'Control',
'master.users.things.relation.read': 'View',
'master.users.things.relation.delete': 'Config',
'master.users.thing.unshare.confirm':
'Are you sure you want to stop sharing these devices?',
'master.users.thing.unshare.title': 'Stop Sharing',
'master.users.things.unsharing': 'Unsharing devices...',
'master.users.thing.unshare.success': 'Stop sharing successful',
'master.users.thing.unshare.fail': 'Stop sharing failed',
'master.users.thing.share.title': 'Share things',
'master.users.things.sharing': 'Sharing devices...',
'master.users.thing.share.success': 'Device sharing successful',
'master.users.thing.share.fail': 'Device sharing failed',
}; };

View File

@@ -1,6 +1,19 @@
import sgwFish from './sgw-fish-en';
import sgwMap from './sgw-map-en';
import sgwMenu from './sgw-menu-en'; import sgwMenu from './sgw-menu-en';
import sgwPhoto from './sgw-photo-en';
import sgwShip from './sgw-ship-en';
import sgwTrip from './sgw-trip-en';
import sgwZone from './sgw-zone-en';
export default { export default {
'sgw.title': 'Sea Gateway', 'sgw.title': 'Sea Gateway',
'sgw.ship': 'Ship', 'sgw.ship': 'Ship',
...sgwMenu, ...sgwMenu,
...sgwTrip,
...sgwMap,
...sgwShip,
...sgwFish,
...sgwPhoto,
...sgwZone,
}; };

View File

@@ -0,0 +1,53 @@
export default {
// Fish group
'fish.fish_group': 'Fish Group',
'fish.fish_group.tooltip': 'Enter fish group name',
'fish.fish_group.placeholder': 'Enter fish group',
// Rarity
'fish.rarity': 'Rarity Level',
'fish.rarity.placeholder': 'Select rarity level',
'fish.rarity.normal': 'Normal',
'fish.rarity.sensitive': 'Sensitive',
'fish.rarity.near_threatened': 'Near Threatened',
'fish.rarity.vulnerable': 'Vulnerable',
'fish.rarity.endangered': 'Endangered',
'fish.rarity.critically_endangered': 'Critically Endangered',
'fish.rarity.extinct_in_the_wild': 'Extinct in the Wild',
'fish.rarity.data_deficient': 'Data Deficient',
// Fish name
'fish.name': 'Fish Name',
'fish.name.tooltip': 'Enter fish name',
'fish.name.placeholder': 'Enter fish name',
'fish.name.required': 'Please enter fish name',
// Specific name
'fish.specific_name': 'Scientific Name',
'fish.specific_name.placeholder': 'Enter scientific name',
// Actions
'fish.create.title': 'Add New Fish Species',
'fish.edit.title': 'Edit Fish Species',
'fish.delete.title': 'Delete Fish Species',
'fish.delete.confirm': 'Are you sure you want to delete this fish species?',
'fish.delete_confirm': 'Are you sure you want to delete this fish species?',
'fish.delete.success': 'Fish species deleted successfully',
'fish.delete.fail': 'Failed to delete fish species',
'fish.create.success': 'Fish species created successfully',
'fish.create.fail': 'Failed to create fish species',
'fish.update.success': 'Fish species updated successfully',
'fish.update.fail': 'Failed to update fish species',
// Table columns
'fish.table.name': 'Fish Name',
'fish.table.specific_name': 'Scientific Name',
'fish.table.fish_group': 'Fish Group',
'fish.table.rarity': 'Rarity Level',
'fish.table.actions': 'Actions',
// Search & Filter
'fish.search.placeholder': 'Search fish species...',
'fish.filter.group': 'Filter by group',
'fish.filter.rarity': 'Filter by rarity',
};

View File

@@ -0,0 +1,37 @@
export default {
// Map
'home.mapError': 'Map Error',
'map.ship_detail.heading': 'Heading',
'map.ship_detail.speed': 'Speed',
'map.ship_detail.name': 'Ship Information',
// Thing status
'thing.name': 'Device Name',
'thing.status': 'Status',
'thing.status.normal': 'Normal',
'thing.status.warning': 'Warning',
'thing.status.critical': 'Critical',
'thing.status.sos': 'SOS',
// Map layers
'map.layer.list': 'Map Layers',
'map.layer.fishing_ban_zone': 'Fishing Ban Zone',
'map.layer.entry_ban_zone': 'Entry Ban Zone',
'map.layer.boundary_lines': 'Boundary Lines',
'map.layer.ports': 'Ports',
// Map filters
'map.filter.name': 'Filter',
'map.filter.ship_name': 'Ship Name',
'map.filter.ship_name_tooltip': 'Enter ship name to search',
'map.filter.ship_reg_number': 'Registration Number',
'map.filter.ship_reg_number_tooltip':
'Enter ship registration number to search',
'map.filter.ship_length': 'Ship Length (m)',
'map.filter.ship_power': 'Power (HP)',
'map.filter.ship_type': 'Ship Type',
'map.filter.ship_type_placeholder': 'Select ship type',
'map.filter.ship_warning': 'Warning',
'map.filter.ship_warning_placeholder': 'Select warning type',
'map.filter.area_type': 'Area Type',
};

View File

@@ -1,6 +1,11 @@
export default { export default {
'menu.sgw.map': 'Maps', 'menu.sgw.map': 'Maps',
'menu.sgw.trips': 'Trips', 'menu.sgw.trips': 'Trips',
'menu.manager.sgw.fishes': 'Fishes', 'menu.sgw.ships': 'Ships',
'menu.manager.sgw.zones': 'Zones', 'menu.sgw.fishes': 'Fishes',
'menu.sgw.zones': 'Prohibited Zones',
// Manager menu
'menu.manager.sgw.fishes': 'Fish Management',
'menu.manager.sgw.zones': 'Zone Management',
}; };

View File

@@ -0,0 +1,16 @@
export default {
'photo.main': 'Main Photo',
'photo.sub': 'Sub Photos',
'photo.upload': 'Upload Photo',
'photo.delete': 'Delete Photo',
'photo.delete.confirm': 'Are you sure you want to delete this photo?',
'photo.delete.success': 'Photo deleted successfully',
'photo.delete.fail': 'Failed to delete photo',
'photo.upload.success': 'Photo uploaded successfully',
'photo.upload.fail': 'Failed to upload photo',
'photo.upload.limit': 'Photo size must not exceed 5MB',
'photo.upload.format': 'Only JPG/PNG format supported',
'photo.manage': 'Manage Photos',
'photo.change': 'Change Photo',
'photo.add': 'Add Photo',
};

View File

@@ -0,0 +1,16 @@
export default {
// Pages - Ship List
'pages.ships.reg_number': 'Registration Number',
'pages.ships.name': 'Ship Name',
'pages.ships.type': 'Ship Type',
'pages.ships.home_port': 'Home Port',
'pages.ships.option': 'Options',
// Pages - Ship Create/Edit
'pages.ship.create.text': 'Create New Ship',
'pages.ships.create.title': 'Add New Ship',
'pages.ships.edit.title': 'Edit Ship',
// Pages - Things (Ship related)
'pages.things.fishing_license_expiry_date': 'Fishing License Expiry Date',
};

View File

@@ -0,0 +1,64 @@
export default {
// Pages - Trip List
'pages.trips.name': 'Trip Name',
'pages.trips.ship_id': 'Ship',
'pages.trips.departure_time': 'Departure Time',
'pages.trips.arrival_time': 'Arrival Time',
'pages.trips.status': 'Status',
'pages.trips.status.created': 'Created',
'pages.trips.status.pending_approval': 'Pending Approval',
'pages.trips.status.approved': 'Approved',
'pages.trips.status.active': 'Active',
'pages.trips.status.completed': 'Completed',
'pages.trips.status.cancelled': 'Cancelled',
// Pages - Date filters
'pages.date.yesterday': 'Yesterday',
'pages.date.lastweek': 'Last Week',
'pages.date.lastmonth': 'Last Month',
// Pages - Things/Ship
'pages.things.createTrip.text': 'Create Trip',
'pages.things.option': 'Options',
// Trip badges
'trip.badge.active': 'Active',
'trip.badge.approved': 'Approved',
'trip.badge.cancelled': 'Cancelled',
'trip.badge.completed': 'Completed',
'trip.badge.notApproved': 'Not Approved',
'trip.badge.unknown': 'Unknown',
'trip.badge.waitingApproval': 'Waiting Approval',
// Cancel trip
'trip.cancelTrip.button': 'Cancel Trip',
'trip.cancelTrip.placeholder': 'Enter reason for cancellation',
'trip.cancelTrip.reason': 'Reason',
'trip.cancelTrip.title': 'Cancel Trip',
'trip.cancelTrip.validation': 'Please enter a reason',
// Trip cost
'trip.cost.amount': 'Amount',
'trip.cost.crewSalary': 'Crew Salary',
'trip.cost.food': 'Food',
'trip.cost.fuel': 'Fuel',
'trip.cost.grandTotal': 'Grand Total',
'trip.cost.iceSalt': 'Ice & Salt',
'trip.cost.price': 'Price',
'trip.cost.total': 'Total',
'trip.cost.type': 'Type',
'trip.cost.unit': 'Unit',
// Trip gear
'trip.gear.name': 'Gear Name',
'trip.gear.quantity': 'Quantity',
// Haul fish list
'trip.haulFishList.fishCondition': 'Condition',
'trip.haulFishList.fishName': 'Fish Name',
'trip.haulFishList.fishRarity': 'Rarity',
'trip.haulFishList.fishSize': 'Size',
'trip.haulFishList.gearUsage': 'Gear Usage',
'trip.haulFishList.title': 'Haul Fish List',
'trip.haulFishList.weight': 'Weight',
};

View File

@@ -0,0 +1,32 @@
export default {
// Table columns
'banzones.name': 'Zone Name',
'banzones.area': 'Province/City',
'banzones.description': 'Description',
'banzones.type': 'Type',
'banzones.conditions': 'Conditions',
'banzones.state': 'Status',
'banzones.action': 'Actions',
'banzones.title': 'zones',
'banzones.create': 'Create Zone',
// Zone types
'banzone.area.fishing_ban': 'Fishing Ban',
'banzone.area.move_ban': 'Movement Ban',
'banzone.area.safe': 'Safe Area',
// Status
'banzone.is_enable': 'Enabled',
'banzone.is_unenabled': 'Disabled',
// Shape types
'banzone.polygon': 'Polygon',
'banzone.polyline': 'Polyline',
'banzone.circle': 'Circle',
// Notifications
'banzone.notify.delete_zone_success': 'Zone deleted successfully',
'banzone.notify.delete_zone_confirm':
'Are you sure you want to delete this zone',
'banzone.notify.fail': 'Operation failed!',
};

View File

@@ -32,6 +32,7 @@ export default {
'common.theme.dark': 'Tối', 'common.theme.dark': 'Tối',
'common.paginations.things': 'thiết bị', 'common.paginations.things': 'thiết bị',
'common.paginations.of': 'trên', 'common.paginations.of': 'trên',
'common.of': 'trên',
'common.name': 'Tên', 'common.name': 'Tên',
'common.name.required': 'Tên không được để trống', 'common.name.required': 'Tên không được để trống',
'common.note': 'Ghi chú', 'common.note': 'Ghi chú',
@@ -39,6 +40,7 @@ export default {
'common.type': 'Loại', 'common.type': 'Loại',
'common.type.placeholder': 'Chọn loại', 'common.type.placeholder': 'Chọn loại',
'common.status': 'Trạng thái', 'common.status': 'Trạng thái',
'common.connect': 'Kết nối',
'common.province': 'Tỉnh', 'common.province': 'Tỉnh',
'common.description': 'Mô tả', 'common.description': 'Mô tả',
'common.description.required': 'Mô tả không được để trống', 'common.description.required': 'Mô tả không được để trống',
@@ -49,6 +51,7 @@ export default {
'common.updated_at': 'Ngày cập nhật', 'common.updated_at': 'Ngày cập nhật',
'common.undefined': 'Chưa xác định', 'common.undefined': 'Chưa xác định',
'common.not_empty': 'Không được để trống!', 'common.not_empty': 'Không được để trống!',
'common.level.disconnected': 'Mất kết nối',
'common.level.normal': 'Bình thường', 'common.level.normal': 'Bình thường',
'common.level.warning': 'Cảnh báo', 'common.level.warning': 'Cảnh báo',
'common.level.critical': 'Nguy hiểm', 'common.level.critical': 'Nguy hiểm',

View File

@@ -4,7 +4,7 @@ export default {
'Không thể tạo đơn vị con khi gốc đã có thiết bị', 'Không thể tạo đơn vị con khi gốc đã có thiết bị',
'master.groups.add': 'Tạo đơn vị cấp dưới', 'master.groups.add': 'Tạo đơn vị cấp dưới',
'master.groups.delete.confirm': 'Bạn có chắc muốn xóa nhóm này không?', 'master.groups.delete.confirm': 'Bạn có chắc muốn xóa nhóm này không?',
'master.groups.code': 'Mã đ vị', 'master.groups.code': 'Mã đơn vị',
'master.groups.code.exists': 'Mã đã tồn tại', 'master.groups.code.exists': 'Mã đã tồn tại',
'master.groups.short_name': 'Tên viết tắt', 'master.groups.short_name': 'Tên viết tắt',
'master.groups.short_name.exists': 'Tên viết tắt đã tồn tại', 'master.groups.short_name.exists': 'Tên viết tắt đã tồn tại',

View File

@@ -3,4 +3,45 @@ export default {
'master.thing.external_id': 'External ID', 'master.thing.external_id': 'External ID',
'master.thing.group': 'Nhóm', 'master.thing.group': 'Nhóm',
'master.thing.address': 'Địa chỉ', 'master.thing.address': 'Địa chỉ',
// Device translations
'master.devices.title': 'Quản lý thiết bị',
'master.devices.name': 'Tên',
'master.devices.name.tip': 'Tên thiết bị',
'master.devices.external_id': 'External ID',
'master.devices.external_id.tip': 'Mã định danh bên ngoài',
'master.devices.type': 'Loại',
'master.devices.type.tip': 'Loại thiết bị',
'master.devices.online': 'Trực tuyến',
'master.devices.offline': 'Ngoại tuyến',
'master.devices.table.pagination': 'thiết bị',
'master.devices.register': 'Thêm thiết bị',
'master.devices.register.title': 'Thêm thiết bị mới',
'master.devices.create.success': 'Tạo thiết bị thành công',
'master.devices.create.error': 'Tạo thiết bị lỗi',
'master.devices.groups': 'Đơn vị',
'master.devices.groups.required': 'Vui lòng chọn đơn vị',
// Update info device
'master.devices.update.success': 'Cập nhật thành công',
'master.devices.update.error': 'Cập nhật thất bại',
// Edit device modal
'master.devices.update.title': 'Cập nhật thiết bị',
'master.devices.ok': 'Đồng ý',
'master.devices.cancel': 'Hủy',
'master.devices.name.placeholder': 'Nhập tên thiết bị',
'master.devices.name.required': 'Vui lòng nhập tên thiết bị',
'master.devices.external_id.placeholder': 'Nhập mã định danh bên ngoài',
'master.devices.external_id.required': 'Vui lòng nhập mã định danh bên ngoài',
'master.devices.address': 'Địa chỉ',
'master.devices.address.placeholder': 'Nhập địa chỉ',
'master.devices.address.required': 'Vui lòng nhập địa chỉ',
// Location modal
'master.devices.location.title': 'Cập nhật vị trí',
'master.devices.location.latitude': 'Vị độ',
'master.devices.location.latitude.required': 'Vui lòng nhập vị độ',
'master.devices.location.longitude': 'Kinh độ',
'master.devices.location.longitude.required': 'Vui lòng nhập kinh độ',
'master.devices.location.placeholder': 'Nhập dữ liệu',
'master.devices.location.update.success': 'Cập nhật vị trí thành công',
'master.devices.location.update.error': 'Cập nhật vị trí thất bại',
}; };

View File

@@ -47,7 +47,22 @@ export default {
'master.users.unassign.fail': 'Ngừng phân quyền thất bại', 'master.users.unassign.fail': 'Ngừng phân quyền thất bại',
'master.users.assign.success': 'Phân quyền đơn vị thành công', 'master.users.assign.success': 'Phân quyền đơn vị thành công',
'master.users.assign.fail': 'Phân quyền đơn vị thất bại', 'master.users.assign.fail': 'Phân quyền đơn vị thất bại',
'master.users.deletion.title': 'Chắc chắn xoá các tài khoản đã chọn?', 'master.users.delete.title': 'Chắc chắn xoá các tài khoản đã chọn?',
'master.users.delete.success': 'Xoá người dùng thành công', 'master.users.delete.success': 'Xoá người dùng thành công',
'master.users.delete.fail': 'Xoá người dùng thất bại', 'master.users.delete.fail': 'Xoá người dùng thất bại',
'master.users.things.list': 'Danh sách thiết bị',
'master.users.things.relations.text': 'Hành động',
'master.users.things.relation.write': 'Điều khiển',
'master.users.things.relation.read': 'Giám sát',
'master.users.things.relation.delete': 'Quản lí',
'master.users.thing.unshare.confirm':
'Chắc chắn muốn ngừng chia sẻ các thiết bị này?',
'master.users.thing.unshare.title': 'Ngừng chia sẻ',
'master.users.thing.unshare.success': 'Ngừng chia sẻ thành công',
'master.users.thing.unshare.fail': 'Ngừng chia sẻ thất bại',
'master.users.things.unsharing': 'Đang ngừng chia sẻ thiết bị...',
'master.users.thing.share.title': 'Chia sẻ thiết bị',
'master.users.things.sharing': 'Đang chia sẻ thiết bị...',
'master.users.thing.share.success': 'Chia sẻ thiết bị thành công',
'master.users.thing.share.fail': 'Chia sẻ thiết bị thất bại',
}; };

View File

@@ -0,0 +1,53 @@
export default {
// Fish group
'fish.fish_group': 'Nhóm loài cá',
'fish.fish_group.tooltip': 'Nhập tên nhóm loài cá',
'fish.fish_group.placeholder': 'Nhập nhóm loài cá',
// Rarity
'fish.rarity': 'Mức độ quý hiếm',
'fish.rarity.placeholder': 'Chọn mức độ quý hiếm',
'fish.rarity.normal': 'Bình thường',
'fish.rarity.sensitive': 'Nhạy cảm',
'fish.rarity.near_threatened': 'Gần nguy cấp',
'fish.rarity.vulnerable': 'Sắp nguy cấp',
'fish.rarity.endangered': 'Nguy cấp',
'fish.rarity.critically_endangered': 'Cực kỳ nguy cấp',
'fish.rarity.extinct_in_the_wild': 'Tuyệt chủng trong tự nhiên',
'fish.rarity.data_deficient': 'Thiếu dữ liệu',
// Fish name
'fish.name': 'Tên loài cá',
'fish.name.tooltip': 'Nhập tên loài cá',
'fish.name.placeholder': 'Nhập tên loài cá',
'fish.name.required': 'Vui lòng nhập tên loài cá',
// Specific name
'fish.specific_name': 'Tên khoa học',
'fish.specific_name.placeholder': 'Nhập tên khoa học',
// Actions
'fish.create.title': 'Thêm loài cá mới',
'fish.edit.title': 'Chỉnh sửa loài cá',
'fish.delete.title': 'Xóa loài cá',
'fish.delete.confirm': 'Bạn có chắc chắn muốn xóa loài cá này không?',
'fish.delete_confirm': 'Bạn có chắc chắn muốn xóa loài cá này không?',
'fish.delete.success': 'Xóa loài cá thành công',
'fish.delete.fail': 'Xóa loài cá thất bại',
'fish.create.success': 'Thêm loài cá thành công',
'fish.create.fail': 'Thêm loài cá thất bại',
'fish.update.success': 'Cập nhật loài cá thành công',
'fish.update.fail': 'Cập nhật loài cá thất bại',
// Table columns
'fish.table.name': 'Tên loài cá',
'fish.table.specific_name': 'Tên khoa học',
'fish.table.fish_group': 'Nhóm loài',
'fish.table.rarity': 'Mức độ quý hiếm',
'fish.table.actions': 'Hành động',
// Search & Filter
'fish.search.placeholder': 'Tìm kiếm loài cá...',
'fish.filter.group': 'Lọc theo nhóm',
'fish.filter.rarity': 'Lọc theo mức độ quý hiếm',
};

View File

@@ -0,0 +1,36 @@
export default {
// Map
'home.mapError': 'Lỗi bản đồ',
'map.ship_detail.heading': 'Hướng di chuyển',
'map.ship_detail.speed': 'Tốc độ',
'map.ship_detail.name': 'Thông tin tàu',
// Thing status
'thing.name': 'Tên thiết bị',
'thing.status': 'Trạng thái',
'thing.status.normal': 'Bình thường',
'thing.status.warning': 'Cảnh báo',
'thing.status.critical': 'Nghiêm trọng',
'thing.status.sos': 'Khẩn cấp',
// Map layers
'map.layer.list': 'Danh sách lớp bản đồ',
'map.layer.fishing_ban_zone': 'Khu vực cấm đánh bắt',
'map.layer.entry_ban_zone': 'Khu vực cấm vào',
'map.layer.boundary_lines': 'Đường biên giới',
'map.layer.ports': 'Cảng',
// Map filters
'map.filter.name': 'Bộ lọc',
'map.filter.ship_name': 'Tên tàu',
'map.filter.ship_name_tooltip': 'Nhập tên tàu để tìm kiếm',
'map.filter.ship_reg_number': 'Số đăng ký',
'map.filter.ship_reg_number_tooltip': 'Nhập số đăng ký tàu để tìm kiếm',
'map.filter.ship_length': 'Chiều dài tàu (m)',
'map.filter.ship_power': 'Công suất (HP)',
'map.filter.ship_type': 'Loại tàu',
'map.filter.ship_type_placeholder': 'Chọn loại tàu',
'map.filter.ship_warning': 'Cảnh báo',
'map.filter.ship_warning_placeholder': 'Chọn loại cảnh báo',
'map.filter.area_type': 'Loại khu vực',
};

View File

@@ -1,6 +1,11 @@
export default { export default {
'menu.sgw.map': 'Bản đồ', 'menu.sgw.map': 'Bản đồ',
'menu.sgw.trips': 'Chuyến đi', 'menu.sgw.trips': 'Chuyến đi',
'menu.manager.sgw.fishes': 'Loài cá', 'menu.sgw.ships': 'Quản lý tàu',
'menu.manager.sgw.zones': 'Khu vực', 'menu.sgw.fishes': 'Loài cá',
'menu.sgw.zones': 'Khu vực cấm',
// Manager menu
'menu.manager.sgw.fishes': 'Quản lý loài cá',
'menu.manager.sgw.zones': 'Quản lý khu vực cấm',
}; };

View File

@@ -0,0 +1,16 @@
export default {
'photo.main': 'Ảnh chính',
'photo.sub': 'Ảnh phụ',
'photo.upload': 'Tải ảnh lên',
'photo.delete': 'Xóa ảnh',
'photo.delete.confirm': 'Bạn có chắc chắn muốn xóa ảnh này không?',
'photo.delete.success': 'Xóa ảnh thành công',
'photo.delete.fail': 'Xóa ảnh thất bại',
'photo.upload.success': 'Tải ảnh lên thành công',
'photo.upload.fail': 'Tải ảnh lên thất bại',
'photo.upload.limit': 'Kích thước ảnh không được vượt quá 5MB',
'photo.upload.format': 'Chỉ hỗ trợ định dạng JPG/PNG',
'photo.manage': 'Quản lý ảnh',
'photo.change': 'Thay đổi ảnh',
'photo.add': 'Thêm ảnh',
};

View File

@@ -0,0 +1,16 @@
export default {
// Pages - Ship List
'pages.ships.reg_number': 'Số đăng ký',
'pages.ships.name': 'Tên tàu',
'pages.ships.type': 'Loại tàu',
'pages.ships.home_port': 'Cảng đăng ký',
'pages.ships.option': 'Tùy chọn',
// Pages - Ship Create/Edit
'pages.ship.create.text': 'Tạo tàu mới',
'pages.ships.create.title': 'Thêm tàu mới',
'pages.ships.edit.title': 'Chỉnh sửa tàu',
// Pages - Things (Ship related)
'pages.things.fishing_license_expiry_date': 'Ngày hết hạn giấy phép',
};

View File

@@ -0,0 +1,64 @@
export default {
// Pages - Trip List
'pages.trips.name': 'Tên chuyến đi',
'pages.trips.ship_id': 'Tàu',
'pages.trips.departure_time': 'Thời gian khởi hành',
'pages.trips.arrival_time': 'Thời gian về',
'pages.trips.status': 'Trạng thái',
'pages.trips.status.created': 'Đã tạo',
'pages.trips.status.pending_approval': 'Chờ phê duyệt',
'pages.trips.status.approved': 'Đã phê duyệt',
'pages.trips.status.active': 'Đang hoạt động',
'pages.trips.status.completed': 'Hoàn thành',
'pages.trips.status.cancelled': 'Đã hủy',
// Pages - Date filters
'pages.date.yesterday': 'Hôm qua',
'pages.date.lastweek': 'Tuần trước',
'pages.date.lastmonth': 'Tháng trước',
// Pages - Things/Ship
'pages.things.createTrip.text': 'Tạo chuyến đi',
'pages.things.option': 'Tùy chọn',
// Trip badges
'trip.badge.active': 'Đang hoạt động',
'trip.badge.approved': 'Đã duyệt',
'trip.badge.cancelled': 'Đã hủy',
'trip.badge.completed': 'Hoàn thành',
'trip.badge.notApproved': 'Chưa duyệt',
'trip.badge.unknown': 'Không xác định',
'trip.badge.waitingApproval': 'Chờ duyệt',
// Cancel trip
'trip.cancelTrip.button': 'Hủy chuyến',
'trip.cancelTrip.placeholder': 'Nhập lý do hủy chuyến',
'trip.cancelTrip.reason': 'Lý do',
'trip.cancelTrip.title': 'Hủy chuyến đi',
'trip.cancelTrip.validation': 'Vui lòng nhập lý do',
// Trip cost
'trip.cost.amount': 'Số lượng',
'trip.cost.crewSalary': 'Lương thuyền viên',
'trip.cost.food': 'Thực phẩm',
'trip.cost.fuel': 'Nhiên liệu',
'trip.cost.grandTotal': 'Tổng cộng',
'trip.cost.iceSalt': 'Đá & Muối',
'trip.cost.price': 'Giá',
'trip.cost.total': 'Tổng',
'trip.cost.type': 'Loại',
'trip.cost.unit': 'Đơn vị',
// Trip gear
'trip.gear.name': 'Tên ngư cụ',
'trip.gear.quantity': 'Số lượng',
// Haul fish list
'trip.haulFishList.fishCondition': 'Tình trạng',
'trip.haulFishList.fishName': 'Tên cá',
'trip.haulFishList.fishRarity': 'Độ hiếm',
'trip.haulFishList.fishSize': 'Kích thước',
'trip.haulFishList.gearUsage': 'Ngư cụ sử dụng',
'trip.haulFishList.title': 'Danh sách đánh bắt',
'trip.haulFishList.weight': 'Trọng lượng',
};

View File

@@ -1,6 +1,19 @@
import sgwFish from './sgw-fish-vi';
import sgwMap from './sgw-map-vi';
import sgwMenu from './sgw-menu-vi'; import sgwMenu from './sgw-menu-vi';
import sgwPhoto from './sgw-photo-vi';
import sgwShip from './sgw-ship-vi';
import sgwTrip from './sgw-trip-vi';
import sgwZone from './sgw-zone-vi';
export default { export default {
'sgw.title': 'Hệ thống giám sát tàu cá', 'sgw.title': 'Hệ thống giám sát tàu cá',
'sgw.ship': 'Tàu', 'sgw.ship': 'Tàu',
...sgwMenu, ...sgwMenu,
...sgwTrip,
...sgwMap,
...sgwShip,
...sgwFish,
...sgwPhoto,
...sgwZone,
}; };

View File

@@ -0,0 +1,31 @@
export default {
// Table columns
'banzones.name': 'Tên khu vực',
'banzones.area': 'Tỉnh/Thành phố',
'banzones.description': 'Mô tả',
'banzones.type': 'Loại',
'banzones.conditions': 'Điều kiện',
'banzones.state': 'Trạng thái',
'banzones.action': 'Hành động',
'banzones.title': 'khu vực',
'banzones.create': 'Tạo khu vực',
// Zone types
'banzone.area.fishing_ban': 'Cấm khai thác',
'banzone.area.move_ban': 'Cấm di chuyển',
'banzone.area.safe': 'Khu vực an toàn',
// Status
'banzone.is_enable': 'Kích hoạt',
'banzone.is_unenabled': 'Vô hiệu hóa',
// Shape types
'banzone.polygon': 'Đa giác',
'banzone.polyline': 'Đường kẻ',
'banzone.circle': 'Hình tròn',
// Notifications
'banzone.notify.delete_zone_success': 'Xóa khu vực thành công',
'banzone.notify.delete_zone_confirm': 'Bạn có chắc chắn muốn xóa khu vực',
'banzone.notify.fail': 'Thao tác thất bại!',
};

View File

@@ -0,0 +1,36 @@
import { apiQueryPorts } from '@/services/slave/sgw/ShipController';
import { useCallback, useState } from 'react';
export default function useHomeport() {
const [homeports, setHomeports] = useState<SgwModel.Port[]>([]);
const [loading, setLoading] = useState(false);
const getHomeportsByProvinceCode = useCallback(async () => {
setLoading(true);
try {
const params: SgwModel.PortQueryParams = {
name: '',
order: 'name',
dir: 'asc',
limit: 100,
offset: 0,
};
console.log('Calling apiQueryPorts with params:', params);
const res = await apiQueryPorts(params);
console.log('apiQueryPorts response:', res);
setHomeports(res?.ports || []);
} catch (err) {
console.error('Fetch Homeports failed:', err);
setHomeports([]);
} finally {
setLoading(false);
}
}, []);
return {
homeports,
loading,
getHomeportsByProvinceCode,
};
}

View File

@@ -0,0 +1,30 @@
import { SHIP_SOS_WS_URL } from '@/constants/slave/sgw/websocket';
import { wsClient } from '@/utils/wsClient';
import { useCallback, useState } from 'react';
export default function useGetShipSos() {
const [shipSos, setShipSos] = useState<WsTypes.WsThingResponse | null>(null);
const [loading, setLoading] = useState(false);
const getShipSosWs = useCallback(async () => {
setLoading(true);
try {
wsClient.connect(SHIP_SOS_WS_URL, true);
const unsubscribe = wsClient.subscribe(
(data: WsTypes.WsThingResponse) => {
setShipSos((pre) => {
if (pre?.time && data.time && pre.time > data.time) {
return pre;
}
return data;
});
setLoading(false);
},
);
return unsubscribe;
} catch (error) {
console.error('Error when get Ship SOS: ', error);
}
}, []);
return { shipSos, getShipSosWs, loading };
}

View File

@@ -0,0 +1,25 @@
import { apiGetShipTypes } from '@/services/slave/sgw/ShipController';
import { useCallback, useState } from 'react';
export default function useShipTypes() {
const [shipTypes, setShipTypes] = useState<SgwModel.ShipType[] | null>(null);
const [loading, setLoading] = useState(false);
const getShipTypes = useCallback(async () => {
setLoading(true);
try {
const res = await apiGetShipTypes(); // đổi URL cho phù hợp
setShipTypes(res || null);
} catch (err) {
console.error('Fetch ShipTypes failed', err);
} finally {
setLoading(false);
}
}, []);
return {
shipTypes,
loading,
getShipTypes,
};
}

View File

@@ -10,7 +10,7 @@ import {
FilterOutlined, FilterOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components'; import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max'; import { FormattedMessage, useIntl, useModel } from '@umijs/max';
import { Button, Flex, message, Popconfirm, Progress, Tooltip } from 'antd'; import { Button, Flex, message, Popconfirm, Progress, Tooltip } from 'antd';
import moment from 'moment'; import moment from 'moment';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
@@ -24,6 +24,9 @@ const AlarmPage = () => {
const [thingFilterDatas, setThingFilterDatas] = useState<string[]>([]); const [thingFilterDatas, setThingFilterDatas] = useState<string[]>([]);
const intl = useIntl(); const intl = useIntl();
const [messageApi, contextHolder] = message.useMessage(); const [messageApi, contextHolder] = message.useMessage();
const { initialState } = useModel('@@initialState');
const { currentUserProfile } = initialState || {};
const columns: ProColumns<MasterModel.Alarm>[] = [ const columns: ProColumns<MasterModel.Alarm>[] = [
{ {
title: intl.formatMessage({ title: intl.formatMessage({

View File

@@ -0,0 +1,505 @@
import { apiSearchThings } from '@/services/master/ThingController';
import { wsClient } from '@/utils/wsClient';
import {
ArrowLeftOutlined,
DeleteOutlined,
EditOutlined,
PlusOutlined,
ReloadOutlined,
SettingOutlined,
} from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-components';
import { history, useParams } from '@umijs/max';
import {
Button,
Card,
Checkbox,
Col,
Form,
Input,
InputNumber,
Modal,
Row,
Select,
Space,
Spin,
Table,
theme,
Typography,
} from 'antd';
import { useEffect, useState } from 'react';
const { Text } = Typography;
// Camera types
const CAMERA_TYPES = [
{ label: 'HIKVISION', value: 'HIKVISION' },
{ label: 'DAHUA', value: 'DAHUA' },
{ label: 'GENERIC', value: 'GENERIC' },
];
// Recording modes
const RECORDING_MODES = [
{ label: 'Theo cảnh báo', value: 'alarm' },
{ label: 'Liên tục', value: 'continuous' },
{ label: 'Thủ công', value: 'manual' },
];
// Alert types for configuration
const ALERT_TYPES = [
{ id: 'motion', name: 'Chuyển Động có cảnh báo' },
{ id: 'smoke', name: 'Khói có cảnh báo' },
{ id: 'door', name: 'Cửa có cảnh báo' },
{ id: 'ac1_high', name: 'Điện AC 1 cao' },
{ id: 'ac1_low', name: 'Điện AC 1 thấp' },
{ id: 'ac1_lost', name: 'Điện AC 1 mất' },
{ id: 'load_high', name: 'Điện tải cao' },
{ id: 'load_low', name: 'Điện tải thấp' },
{ id: 'load_lost', name: 'Điện tải mất' },
{ id: 'grid_high', name: 'Điện lưới cao' },
{ id: 'grid_low', name: 'Điện lưới thấp' },
{ id: 'grid_lost', name: 'Điện lưới mất' },
{ id: 'ac1_on_error', name: 'Điều hòa 1 bật lỗi' },
{ id: 'ac1_off_error', name: 'Điều hòa 1 tắt lỗi' },
{ id: 'ac1_has_error', name: 'Điều hòa 1 có thể lỗi' },
{ id: 'ac2_on_error', name: 'Điều hòa 2 bật lỗi' },
{ id: 'ac2_off_error', name: 'Điều hòa 2 tắt lỗi' },
{ id: 'ac2_has_error', name: 'Điều hòa 2 điều hòa có thể lỗi' },
{ id: 'room_temp_high', name: 'Nhiệt độ phòng máy nhiệt độ phòng máy cao' },
{ id: 'rectifier_error', name: 'Rectifier bật lỗi' },
{ id: 'meter_volt_high', name: 'Công tơ điện điện áp cao' },
{ id: 'meter_volt_low', name: 'Công tơ điện điện áp thấp' },
{ id: 'meter_lost', name: 'Công tơ điện mất điện áp' },
{ id: 'lithium_volt_low', name: 'Pin lithium điện áp thấp' },
{ id: 'lithium_temp_high', name: 'Pin lithium nhiệt độ cao' },
{ id: 'lithium_capacity_low', name: 'Pin lithium dung lượng thấp' },
];
// Camera interface
interface Camera {
id: string;
name: string;
type: string;
ipAddress: string;
}
interface CameraFormValues {
name: string;
type: string;
account: string;
password: string;
ipAddress: string;
rtspPort: number;
httpPort: number;
stream: number;
channel: number;
}
const CameraConfigPage = () => {
const { thingId } = useParams<{ thingId: string }>();
const { token } = theme.useToken();
const [form] = Form.useForm<CameraFormValues>();
const [isModalVisible, setIsModalVisible] = useState(false);
const [cameras, setCameras] = useState<Camera[]>([]);
const [selectedAlerts, setSelectedAlerts] = useState<string[]>([
'motion',
'smoke',
'door',
]);
const [recordingMode, setRecordingMode] = useState('alarm');
const [thingName, setThingName] = useState<string>('');
const [loading, setLoading] = useState(true);
useEffect(() => {
wsClient.connect('wss://gms.smatec.com.vn/mqtt', false);
const unsubscribe = wsClient.subscribe((data: any) => {
console.log('Received WS data:', data);
});
return () => {
unsubscribe();
};
}, []);
// Fetch thing info on mount
useEffect(() => {
const fetchThingInfo = async () => {
if (!thingId) return;
try {
setLoading(true);
const response = await apiSearchThings({
offset: 0,
limit: 1,
id: thingId,
});
if (response?.things && response.things.length > 0) {
setThingName(response.things[0].name || thingId);
} else {
setThingName(thingId);
}
} catch (error) {
console.error('Failed to fetch thing info:', error);
setThingName(thingId);
} finally {
setLoading(false);
}
};
fetchThingInfo();
}, [thingId]);
const handleBack = () => {
history.push('/manager/devices');
};
const handleOpenModal = () => {
form.resetFields();
form.setFieldsValue({
type: 'HIKVISION',
rtspPort: 554,
httpPort: 80,
stream: 0,
channel: 0,
});
setIsModalVisible(true);
};
const handleCloseModal = () => {
setIsModalVisible(false);
form.resetFields();
};
const handleSubmitCamera = async () => {
try {
const values = await form.validateFields();
console.log('Camera values:', values);
// TODO: Call API to create camera
setCameras([
...cameras,
{
id: String(cameras.length + 1),
name: values.name,
type: values.type,
ipAddress: values.ipAddress,
},
]);
handleCloseModal();
} catch (error) {
console.error('Validation failed:', error);
}
};
const handleAlertToggle = (alertId: string) => {
if (selectedAlerts.includes(alertId)) {
setSelectedAlerts(selectedAlerts.filter((id) => id !== alertId));
} else {
setSelectedAlerts([...selectedAlerts, alertId]);
}
};
const handleClearAlerts = () => {
setSelectedAlerts([]);
};
const handleSubmitAlerts = () => {
console.log('Submit alerts:', selectedAlerts);
// TODO: Call API to save alert configuration
};
const columns = [
{
title: '',
dataIndex: 'checkbox',
width: 50,
render: () => <Checkbox />,
},
{
title: 'Tên',
dataIndex: 'name',
key: 'name',
render: (text: string) => (
<a style={{ color: token.colorPrimary }}>{text}</a>
),
},
{
title: 'Loại',
dataIndex: 'type',
key: 'type',
},
{
title: 'Địa chỉ IP',
dataIndex: 'ipAddress',
key: 'ipAddress',
},
{
title: 'Thao tác',
key: 'action',
render: () => <Button size="small" icon={<EditOutlined />} />,
},
];
return (
<Spin spinning={loading}>
<PageContainer
header={{
title: (
<Space>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={handleBack}
/>
<span>{thingName || 'Loading...'}</span>
</Space>
),
}}
>
<Row gutter={24}>
{/* Left Column - Camera Table */}
<Col xs={24} md={10} lg={8}>
<Card bodyStyle={{ padding: 16 }}>
<Space style={{ marginBottom: 16 }}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleOpenModal}
>
Tạo mới camera
</Button>
<Button icon={<ReloadOutlined />} />
<Button icon={<SettingOutlined />} />
<Button icon={<DeleteOutlined />} />
</Space>
<Table
dataSource={cameras}
columns={columns}
rowKey="id"
size="small"
pagination={{
size: 'small',
showTotal: (total, range) =>
`${range[0]}-${range[1]} trên ${total} mặt hàng`,
pageSize: 10,
}}
/>
</Card>
</Col>
{/* Right Column - Alert Configuration */}
<Col xs={24} md={14} lg={16}>
<Card bodyStyle={{ padding: 16 }}>
{/* Recording Mode */}
<div style={{ marginBottom: 24 }}>
<Text strong style={{ display: 'block', marginBottom: 8 }}>
Ghi dữ liệu camera
</Text>
<Select
value={recordingMode}
onChange={setRecordingMode}
options={RECORDING_MODES}
style={{ width: 200 }}
/>
</div>
{/* Alert List */}
<div>
<Text strong style={{ display: 'block', marginBottom: 8 }}>
Danh sách cảnh báo
</Text>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
padding: '8px 12px',
background: token.colorBgContainer,
borderRadius: token.borderRadius,
border: `1px solid ${token.colorBorder}`,
}}
>
<Text type="secondary">
đã chọn {selectedAlerts.length} mục
</Text>
<Button type="link" onClick={handleClearAlerts}>
Xóa
</Button>
</div>
{/* Alert Cards Grid */}
<Row gutter={[12, 12]}>
{ALERT_TYPES.map((alert) => {
const isSelected = selectedAlerts.includes(alert.id);
return (
<Col xs={12} sm={8} md={6} lg={4} xl={4} key={alert.id}>
<Card
size="small"
hoverable
onClick={() => handleAlertToggle(alert.id)}
style={{
cursor: 'pointer',
borderColor: isSelected
? token.colorPrimary
: token.colorBorder,
borderWidth: isSelected ? 2 : 1,
background: isSelected
? token.colorPrimaryBg
: token.colorBgContainer,
height: 80,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
bodyStyle={{
padding: 8,
textAlign: 'center',
width: '100%',
}}
>
<Text
style={{
fontSize: 12,
color: isSelected
? token.colorPrimary
: token.colorText,
wordBreak: 'break-word',
}}
>
{alert.name}
</Text>
</Card>
</Col>
);
})}
</Row>
{/* Submit Button */}
<div style={{ marginTop: 24, textAlign: 'center' }}>
<Button type="primary" onClick={handleSubmitAlerts}>
Gửi đi
</Button>
</div>
</div>
</Card>
</Col>
</Row>
{/* Create Camera Modal */}
<Modal
title="Tạo mới"
open={isModalVisible}
onCancel={handleCloseModal}
footer={[
<Button key="cancel" onClick={handleCloseModal}>
Hủy
</Button>,
<Button key="submit" type="primary" onClick={handleSubmitCamera}>
Đng ý
</Button>,
]}
width={500}
>
<Form
form={form}
layout="vertical"
initialValues={{
type: 'HIKVISION',
rtspPort: 554,
httpPort: 80,
stream: 0,
channel: 0,
}}
>
<Form.Item
label="Tên"
name="name"
rules={[{ required: true, message: 'Vui lòng nhập tên' }]}
>
<Input placeholder="nhập dữ liệu" />
</Form.Item>
<Form.Item
label="Loại"
name="type"
rules={[{ required: true, message: 'Vui lòng chọn loại' }]}
>
<Select options={CAMERA_TYPES} />
</Form.Item>
<Form.Item
label="Tài khoản"
name="account"
rules={[{ required: true, message: 'Vui lòng nhập tài khoản' }]}
>
<Input placeholder="nhập tài khoản" />
</Form.Item>
<Form.Item
label="Mật khẩu"
name="password"
rules={[{ required: true, message: 'Vui lòng nhập mật khẩu' }]}
>
<Input.Password placeholder="nhập mật khẩu" />
</Form.Item>
<Form.Item
label="Địa chỉ IP"
name="ipAddress"
rules={[{ required: true, message: 'Vui lòng nhập địa chỉ IP' }]}
>
<Input placeholder="192.168.1.10" />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item
label="Cổng RTSP"
name="rtspPort"
rules={[
{ required: true, message: 'Vui lòng nhập cổng RTSP' },
]}
>
<InputNumber style={{ width: '100%' }} min={0} max={65535} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="Cổng HTTP"
name="httpPort"
rules={[
{ required: true, message: 'Vui lòng nhập cổng HTTP' },
]}
>
<InputNumber style={{ width: '100%' }} min={0} max={65535} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item
label="Luồng"
name="stream"
rules={[{ required: true, message: 'Vui lòng nhập luồng' }]}
>
<InputNumber style={{ width: '100%' }} min={0} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="Kênh"
name="channel"
rules={[{ required: true, message: 'Vui lòng nhập kênh' }]}
>
<InputNumber style={{ width: '100%' }} min={0} />
</Form.Item>
</Col>
</Row>
</Form>
</Modal>
</PageContainer>
</Spin>
);
};
export default CameraConfigPage;

View File

@@ -0,0 +1,210 @@
import TreeSelectedGroup from '@/components/shared/TreeSelectedGroup';
import { PlusOutlined } from '@ant-design/icons';
import {
ModalForm,
ProForm,
ProFormInstance,
ProFormSelect,
ProFormText,
} from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max';
import { Button } from 'antd';
import { MessageInstance } from 'antd/es/message/interface';
import { useRef, useState } from 'react';
type CreateDeviceProps = {
message: MessageInstance;
onSuccess?: (isSuccess: boolean) => void;
};
type CreateDeviceFormValues = {
name: string;
external_id: string;
type: string;
address?: string;
group_id?: string;
};
const CreateDevice = ({ message, onSuccess }: CreateDeviceProps) => {
const formRef = useRef<ProFormInstance<CreateDeviceFormValues>>();
const intl = useIntl();
const [group_id, setGroupId] = useState<string | string[] | null>(null);
const handleGroupSelect = (group: string | string[] | null) => {
setGroupId(group);
formRef.current?.setFieldsValue({
group_id: Array.isArray(group) ? group.join(',') : group || undefined,
});
};
return (
<ModalForm<CreateDeviceFormValues>
title={intl.formatMessage({
id: 'master.devices.register.title',
defaultMessage: 'Register New Device',
})}
formRef={formRef}
trigger={
<Button type="primary" key="primary" icon={<PlusOutlined />}>
<FormattedMessage
id="master.devices.register"
defaultMessage="Register"
/>
</Button>
}
autoFocusFirstInput
onFinish={async (values: CreateDeviceFormValues) => {
// TODO: Implement API call to create device
console.log('Create device with values:', values);
try {
// Placeholder for API call
// const body = {
// name: values.name,
// metadata: {
// external_id: values.external_id,
// type: values.type,
// address: values.address,
// group_id: values.group_id,
// },
// };
// const resp = await apiCreateDevice(body);
message.success(
intl.formatMessage({
id: 'master.devices.create.success',
defaultMessage: 'Create device successfully',
}),
);
formRef.current?.resetFields();
onSuccess?.(true);
return true;
} catch (error) {
message.error(
intl.formatMessage({
id: 'master.devices.create.error',
defaultMessage: 'Failed to create device',
}),
);
onSuccess?.(false);
return false;
}
}}
>
<ProFormText
name={'name'}
label={intl.formatMessage({
id: 'master.devices.name',
defaultMessage: 'Name',
})}
placeholder={intl.formatMessage({
id: 'master.devices.name.placeholder',
defaultMessage: 'Enter device name',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.devices.name.required',
defaultMessage: 'The device name is required',
}),
},
]}
/>
<ProFormText
name={'external_id'}
label={intl.formatMessage({
id: 'master.devices.external_id',
defaultMessage: 'External ID',
})}
placeholder={intl.formatMessage({
id: 'master.devices.external_id.placeholder',
defaultMessage: 'Enter external ID',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.devices.external_id.required',
defaultMessage: 'The external ID is required',
}),
},
]}
/>
<ProFormSelect
name="type"
label={intl.formatMessage({
id: 'master.devices.type',
defaultMessage: 'Type',
})}
options={[
{
label: intl.formatMessage({
id: 'master.devices.type.gms',
defaultMessage: 'GMS',
}),
value: 'gms',
},
{
label: intl.formatMessage({
id: 'master.devices.type.sgw',
defaultMessage: 'SGW',
}),
value: 'sgw',
},
{
label: intl.formatMessage({
id: 'master.devices.type.spole',
defaultMessage: 'SPOLE',
}),
value: 'spole',
},
]}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.devices.type.required',
defaultMessage: 'Please select a device type',
}),
},
]}
/>
<ProFormText
name={'address'}
label={intl.formatMessage({
id: 'master.devices.address',
defaultMessage: 'Address',
})}
placeholder={intl.formatMessage({
id: 'master.devices.address.placeholder',
defaultMessage: 'Enter device address',
})}
/>
<ProForm.Item
name="group_id"
label={intl.formatMessage({
id: 'master.devices.groups',
defaultMessage: 'Groups',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.devices.groups.required',
defaultMessage: 'Please select groups!',
}),
},
]}
>
<TreeSelectedGroup groupIds={group_id} onSelected={handleGroupSelect} />
</ProForm.Item>
</ModalForm>
);
};
export default CreateDevice;

View File

@@ -0,0 +1,152 @@
import { useIntl } from '@umijs/max';
import { Form, Input, Modal } from 'antd';
import React, { useEffect } from 'react';
interface Props {
visible: boolean;
device: MasterModel.Thing | null;
onCancel: () => void;
onSubmit: (values: MasterModel.Thing) => void;
}
const EditDeviceModal: React.FC<Props> = ({
visible,
device,
onCancel,
onSubmit,
}) => {
const [form] = Form.useForm();
const intl = useIntl();
useEffect(() => {
if (device) {
form.setFieldsValue({
name: device.name,
external_id: device?.metadata?.external_id,
address: device?.metadata?.address,
});
} else {
form.resetFields();
}
}, [device, form]);
const handleFinish = (values: {
name: string;
external_id: string;
address?: string;
}) => {
const payload: MasterModel.Thing = {
...device,
name: values.name,
metadata: {
...(device?.metadata || {}),
external_id: values.external_id,
address: values.address,
},
};
onSubmit(payload);
};
return (
<Modal
title={intl.formatMessage({
id: 'master.devices.update.title',
defaultMessage: 'Update device',
})}
open={visible}
onCancel={onCancel}
onOk={() => form.submit()}
okText={intl.formatMessage({
id: 'master.devices.ok',
defaultMessage: 'OK',
})}
cancelText={intl.formatMessage({
id: 'master.devices.cancel',
defaultMessage: 'Cancel',
})}
destroyOnClose
>
<Form
form={form}
layout="vertical"
onFinish={handleFinish}
preserve={false}
>
<Form.Item
name="name"
label={intl.formatMessage({
id: 'master.devices.name',
defaultMessage: 'Name',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.devices.name.required',
defaultMessage: 'Please enter device name',
}),
},
]}
>
<Input
placeholder={intl.formatMessage({
id: 'master.devices.name.placeholder',
defaultMessage: 'Enter device name',
})}
/>
</Form.Item>
<Form.Item
name="external_id"
label={intl.formatMessage({
id: 'master.devices.external_id',
defaultMessage: 'External ID',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.devices.external_id.required',
defaultMessage: 'Please enter external ID',
}),
},
]}
>
<Input
placeholder={intl.formatMessage({
id: 'master.devices.external_id.placeholder',
defaultMessage: 'Enter external ID',
})}
/>
</Form.Item>
<Form.Item
name="address"
label={intl.formatMessage({
id: 'master.devices.address',
defaultMessage: 'Address',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.devices.address.required',
defaultMessage: 'Please enter address',
}),
},
]}
>
<Input
placeholder={intl.formatMessage({
id: 'master.devices.address.placeholder',
defaultMessage: 'Enter address',
})}
/>
</Form.Item>
</Form>
</Modal>
);
};
export default EditDeviceModal;

View File

@@ -0,0 +1,121 @@
import { useIntl } from '@umijs/max';
import { Form, Input, Modal } from 'antd';
import React, { useEffect } from 'react';
interface Props {
visible: boolean;
device: MasterModel.Thing | null;
onCancel: () => void;
onSubmit: (values: MasterModel.Thing) => void;
}
const LocationModal: React.FC<Props> = ({
visible,
device,
onCancel,
onSubmit,
}) => {
const [form] = Form.useForm();
const intl = useIntl();
useEffect(() => {
if (device) {
form.setFieldsValue({
lat: device?.metadata?.lat || '',
lng: device?.metadata?.lng || '',
});
} else {
form.resetFields();
}
}, [device, form]);
return (
<Modal
title={intl.formatMessage({
id: 'master.devices.location.title',
defaultMessage: 'Update location',
})}
open={visible}
onCancel={onCancel}
onOk={() => form.submit()}
okText={intl.formatMessage({
id: 'master.devices.ok',
defaultMessage: 'OK',
})}
cancelText={intl.formatMessage({
id: 'master.devices.cancel',
defaultMessage: 'Cancel',
})}
destroyOnClose
>
<Form
form={form}
layout="vertical"
onFinish={(values) => {
const payload: MasterModel.Thing = {
id: device?.id,
name: device?.name,
key: device?.key,
metadata: {
...device?.metadata,
lat: values.lat,
lng: values.lng,
},
};
onSubmit(payload);
}}
preserve={false}
>
<Form.Item
name="lat"
label={intl.formatMessage({
id: 'master.devices.location.latitude',
defaultMessage: 'Latitude',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.devices.location.latitude.required',
defaultMessage: 'Please enter latitude',
}),
},
]}
>
<Input
placeholder={intl.formatMessage({
id: 'master.devices.location.placeholder',
defaultMessage: 'Enter data',
})}
/>
</Form.Item>
<Form.Item
name="lng"
label={intl.formatMessage({
id: 'master.devices.location.longitude',
defaultMessage: 'Longitude',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.devices.location.longitude.required',
defaultMessage: 'Please enter longitude',
}),
},
]}
>
<Input
placeholder={intl.formatMessage({
id: 'master.devices.location.placeholder',
defaultMessage: 'Enter data',
})}
/>
</Form.Item>
</Form>
</Modal>
);
};
export default LocationModal;

View File

@@ -1,5 +1,375 @@
import IconFont from '@/components/IconFont';
import TreeGroup from '@/components/shared/TreeGroup';
import { DEFAULT_PAGE_SIZE } from '@/constants';
import {
apiSearchThings,
apiUpdateThing,
} from '@/services/master/ThingController';
import { EditOutlined, EnvironmentOutlined } from '@ant-design/icons';
import {
ActionType,
ProCard,
ProColumns,
ProTable,
} from '@ant-design/pro-components';
import { FormattedMessage, history, useIntl } from '@umijs/max';
import { Button, Divider, Grid, Space, Tag, theme } from 'antd';
import message from 'antd/es/message';
import Paragraph from 'antd/lib/typography/Paragraph';
import { useRef, useState } from 'react';
import CreateDevice from './components/CreateDevice';
import EditDeviceModal from './components/EditDeviceModal';
import LocationModal from './components/LocationModal';
const ManagerDevicePage = () => { const ManagerDevicePage = () => {
return <div>ManagerDevicePage</div>; const { useBreakpoint } = Grid;
const intl = useIntl();
const screens = useBreakpoint();
const { token } = theme.useToken();
const actionRef = useRef<ActionType | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [messageApi, contextHolder] = message.useMessage();
const [selectedRowsState, setSelectedRowsState] = useState<
MasterModel.Thing[]
>([]);
const [groupCheckedKeys, setGroupCheckedKeys] = useState<
string | string[] | null
>(null);
const [isEditModalVisible, setIsEditModalVisible] = useState<boolean>(false);
const [editingDevice, setEditingDevice] = useState<MasterModel.Thing | null>(
null,
);
const [isLocationModalVisible, setIsLocationModalVisible] =
useState<boolean>(false);
const [locationDevice, setLocationDevice] =
useState<MasterModel.Thing | null>(null);
const handleLocation = (device: MasterModel.Thing) => {
setLocationDevice(device);
setIsLocationModalVisible(true);
};
const handleEdit = (device: MasterModel.Thing) => {
setEditingDevice(device);
setIsEditModalVisible(true);
};
const handleEditCancel = () => {
setIsEditModalVisible(false);
setEditingDevice(null);
};
const handleLocationCancel = () => {
setIsLocationModalVisible(false);
setLocationDevice(null);
};
const handleLocationSubmit = async (values: MasterModel.Thing) => {
try {
await apiUpdateThing(values);
messageApi.success(
intl.formatMessage({
id: 'master.devices.location.update.success',
defaultMessage: 'Location updated successfully',
}),
);
setIsLocationModalVisible(false);
setLocationDevice(null);
actionRef.current?.reload();
} catch (error) {
messageApi.error(
intl.formatMessage({
id: 'master.devices.location.update.error',
defaultMessage: 'Location update failed',
}),
);
}
};
const handleEditSubmit = async (values: MasterModel.Thing) => {
try {
await apiUpdateThing(values);
messageApi.success(
intl.formatMessage({
id: 'master.devices.update.success',
defaultMessage: 'Updated successfully',
}),
);
setIsEditModalVisible(false);
setEditingDevice(null);
actionRef.current?.reload();
} catch (error) {
messageApi.error(
intl.formatMessage({
id: 'master.devices.update.error',
defaultMessage: 'Update failed',
}),
);
}
};
const columns: ProColumns<MasterModel.Thing>[] = [
{
key: 'name',
title: (
<FormattedMessage id="master.devices.name" defaultMessage="Name" />
),
tip: intl.formatMessage({
id: 'master.devices.name.tip',
defaultMessage: 'The device name',
}),
dataIndex: 'name',
render: (_, record) => (
<Paragraph
style={{
marginBottom: 0,
verticalAlign: 'middle',
display: 'inline-block',
color: token.colorText,
}}
copyable
>
{record?.name}
</Paragraph>
),
},
{
key: 'external_id',
title: (
<FormattedMessage
id="master.devices.external_id"
defaultMessage="External ID"
/>
),
tip: intl.formatMessage({
id: 'master.devices.external_id.tip',
defaultMessage: 'The external identifier',
}),
responsive: ['lg', 'md'],
dataIndex: ['metadata', 'external_id'],
render: (_, record) =>
record?.metadata?.external_id ? (
<Paragraph
style={{
marginBottom: 0,
verticalAlign: 'middle',
display: 'inline-block',
color: token.colorText,
}}
copyable
>
{record?.metadata?.external_id}
</Paragraph>
) : (
'-'
),
},
{
key: 'type',
hideInSearch: true,
title: (
<FormattedMessage id="master.devices.type" defaultMessage="Type" />
),
tip: intl.formatMessage({
id: 'master.devices.type.tip',
defaultMessage: 'The device type',
}),
dataIndex: ['metadata', 'type'],
render: (_, record) => record?.metadata?.type || '...',
},
{
key: 'connected',
hideInSearch: true,
title: <FormattedMessage id="common.status" defaultMessage="Status" />,
dataIndex: ['metadata', 'connected'],
render: (_, record) => (
<Tag color={record?.metadata?.connected ? 'green' : 'red'}>
{record?.metadata?.connected
? intl.formatMessage({
id: 'master.devices.online',
defaultMessage: 'Online',
})
: intl.formatMessage({
id: 'master.devices.offline',
defaultMessage: 'Offline',
})}
</Tag>
),
},
{
title: (
<FormattedMessage id="common.actions" defaultMessage="Operating" />
),
hideInSearch: true,
render: (_, device) => {
return (
<Space
size={5}
split={<Divider type="vertical" style={{ margin: '0 4px' }} />}
>
<Button
shape="default"
size="small"
icon={<EditOutlined />}
onClick={() => handleEdit(device)}
/>
<Button
shape="default"
size="small"
icon={<EnvironmentOutlined />}
onClick={() => handleLocation(device)}
/>
<Button
shape="default"
size="small"
icon={<IconFont type="icon-camera" />}
onClick={() => {
history.push(`/manager/devices/${device.id}/camera`);
}}
/>
{device?.metadata?.type === 'gmsv6' && (
<Button
shape="default"
size="small"
icon={<IconFont type="icon-terminal" />}
/>
)}
</Space>
);
},
},
];
return (
<>
<EditDeviceModal
visible={isEditModalVisible}
device={editingDevice}
onCancel={handleEditCancel}
onSubmit={handleEditSubmit}
/>
<LocationModal
visible={isLocationModalVisible}
device={locationDevice}
onCancel={handleLocationCancel}
onSubmit={handleLocationSubmit}
/>
{contextHolder}
<ProCard split={screens.md ? 'vertical' : 'horizontal'}>
<ProCard colSpan={{ xs: 24, sm: 24, md: 6, lg: 6, xl: 6 }}>
<TreeGroup
disable={isLoading}
multiple={true}
groupIds={groupCheckedKeys}
onSelected={(value: string | string[] | null) => {
setGroupCheckedKeys(value);
if (actionRef.current) {
actionRef.current.reload();
}
}}
/>
</ProCard>
<ProCard colSpan={{ xs: 24, sm: 24, md: 18, lg: 18, xl: 18 }}>
<ProTable<MasterModel.Thing>
columns={columns}
tableLayout="auto"
actionRef={actionRef}
rowKey="id"
search={{
layout: 'vertical',
defaultCollapsed: false,
}}
dateFormatter="string"
rowSelection={{
selectedRowKeys: selectedRowsState.map((row) => row.id!),
onChange: (_: React.Key[], selectedRows: MasterModel.Thing[]) => {
setSelectedRowsState(selectedRows);
},
}}
pagination={{
defaultPageSize: DEFAULT_PAGE_SIZE * 2,
showSizeChanger: true,
pageSizeOptions: ['10', '15', '20'],
showTotal: (total, range) =>
`${range[0]}-${range[1]}
${intl.formatMessage({
id: 'common.paginations.of',
defaultMessage: 'of',
})}
${total} ${intl.formatMessage({
id: 'master.devices.table.pagination',
defaultMessage: 'devices',
})}`,
}}
request={async (params = {}) => {
const { current = 1, pageSize, name, external_id } = params;
const size = pageSize || DEFAULT_PAGE_SIZE * 2;
const offset = current === 1 ? 0 : (current - 1) * size;
setIsLoading(true);
const metadata: Partial<MasterModel.SearchThingMetadata> = {};
if (external_id) metadata.external_id = external_id;
// Add group filter if groups are selected
if (groupCheckedKeys && groupCheckedKeys.length > 0) {
const groupId = Array.isArray(groupCheckedKeys)
? groupCheckedKeys.join(',')
: groupCheckedKeys;
metadata.group_id = groupId;
}
const query: MasterModel.SearchThingPaginationBody = {
offset: offset,
limit: size,
order: 'name',
dir: 'asc',
};
if (name) query.name = name;
if (Object.keys(metadata).length > 0) query.metadata = metadata;
try {
const response = await apiSearchThings(query);
setIsLoading(false);
return {
data: response.things || [],
success: true,
total: response.total || 0,
};
} catch (error) {
setIsLoading(false);
return {
data: [],
success: false,
total: 0,
};
}
}}
options={{
search: false,
setting: false,
density: false,
reload: true,
}}
toolBarRender={() => [
<CreateDevice
message={messageApi}
onSuccess={(isSuccess) => {
if (isSuccess) {
actionRef.current?.reload();
}
}}
key="create-device"
/>,
]}
/>
</ProCard>
</ProCard>
</>
);
}; };
export default ManagerDevicePage; export default ManagerDevicePage;

View File

@@ -0,0 +1,303 @@
const LogActions = (intl: any) => {
return [
//Alarm
{
title: intl.formatMessage({
id: 'master.logs.things.alarm.confirm',
defaultMessage: 'Alarm confirm',
}),
value: '0-0',
selectable: false,
children: [
{
value: 'things.alarm_confirm',
title: intl.formatMessage({
id: 'master.logs.things.confirm',
defaultMessage: 'Confirm',
}),
},
{
value: 'things.alarm_unconfirm',
title: intl.formatMessage({
id: 'master.logs.things.unconfirm',
defaultMessage: 'Unconfirm',
}),
},
],
},
//Things
{
title: intl.formatMessage({
id: 'master.logs.things',
defaultMessage: 'Things',
}),
value: '0-1',
selectable: false,
children: [
{
value: 'things.create',
title: intl.formatMessage({
id: 'master.logs.things.create',
defaultMessage: 'Create new thing',
}),
},
{
value: 'things.update',
title: intl.formatMessage({
id: 'master.logs.things.update',
defaultMessage: 'Update thing',
}),
},
{
value: 'things.remove',
title: intl.formatMessage({
id: 'master.logs.things.remove',
defaultMessage: 'Remove thing',
}),
},
{
value: 'things.share',
title: intl.formatMessage({
id: 'master.logs.things.share',
defaultMessage: 'Share thing',
}),
},
{
value: 'things.unshare',
title: intl.formatMessage({
id: 'master.logs.things.unshare',
defaultMessage: 'Unshare thing',
}),
},
{
value: 'things.update_key',
title: intl.formatMessage({
id: 'master.logs.things.update_key',
defaultMessage: 'Update key thing',
}),
},
],
},
// Users
{
title: intl.formatMessage({
id: 'master.logs.users',
defaultMessage: 'Users',
}),
value: '0-2',
selectable: false,
children: [
{
value: 'users.create',
title: intl.formatMessage({
id: 'master.logs.users.create',
defaultMessage: 'Register user',
}),
},
{
value: 'users.update',
title: intl.formatMessage({
id: 'master.logs.users.update',
defaultMessage: 'Update user',
}),
},
{
value: 'users.remove',
title: intl.formatMessage({
id: 'master.logs.users.remove',
defaultMessage: 'Remove user',
}),
},
{
value: 'users.login',
title: intl.formatMessage({
id: 'master.logs.users.login',
defaultMessage: 'User login',
}),
},
],
},
// Groups
{
title: intl.formatMessage({
id: 'master.logs.groups',
defaultMessage: 'Groups',
}),
value: '0-3',
selectable: false,
children: [
{
value: 'group.create',
title: intl.formatMessage({
id: 'master.logs.groups.create',
defaultMessage: 'Create new group',
}),
},
{
value: 'group.update',
title: intl.formatMessage({
id: 'master.logs.groups.update',
defaultMessage: 'Update group',
}),
},
{
value: 'group.remove',
title: intl.formatMessage({
id: 'master.logs.groups.remove',
defaultMessage: 'Remove group',
}),
},
{
value: 'group.assign_thing',
title: intl.formatMessage({
id: 'master.logs.groups.assign_thing',
defaultMessage: 'Assign thing to group',
}),
},
{
value: 'group.assign_user',
title: intl.formatMessage({
id: 'master.logs.groups.assign_user',
defaultMessage: 'Assign user to group',
}),
},
{
value: 'group.unassign_thing',
title: intl.formatMessage({
id: 'master.logs.groups.unassign_thing',
defaultMessage: 'Remove thing from group',
}),
},
{
value: 'group.unassign_user',
title: intl.formatMessage({
id: 'master.logs.groups.unassign_user',
defaultMessage: 'Remove user from group',
}),
},
],
},
// Ships
{
title: intl.formatMessage({
id: 'master.logs.ships',
defaultMessage: 'Ships',
}),
value: '0-4',
selectable: false,
children: [
{
value: 'ships.create',
title: intl.formatMessage({
id: 'master.logs.ships.create',
defaultMessage: 'Create new ship',
}),
},
{
value: 'ships.update',
title: intl.formatMessage({
id: 'master.logs.ships.update',
defaultMessage: 'Update ship',
}),
},
{
value: 'ships.remove',
title: intl.formatMessage({
id: 'master.logs.ships.remove',
defaultMessage: 'Remove ship',
}),
},
{
value: 'ships.assign_thing',
title: intl.formatMessage({
id: 'master.logs.ships.assign_thing',
defaultMessage: 'Assign thing to ship',
}),
},
{
value: 'ships.assign_user',
title: intl.formatMessage({
id: 'master.logs.ships.assign_user',
defaultMessage: 'Assign user to ship',
}),
},
{
value: 'ships.unassign_thing',
title: intl.formatMessage({
id: 'master.logs.ships.unassign_thing',
defaultMessage: 'Remove thing from ship',
}),
},
{
value: 'ships.unassign_user',
title: intl.formatMessage({
id: 'master.logs.ships.unassign_user',
defaultMessage: 'Remove user from ship',
}),
},
],
},
// Trips
{
title: intl.formatMessage({
id: 'master.logs.trips',
defaultMessage: 'Trips',
}),
value: '0-5',
selectable: false,
children: [
{
value: 'trips.create',
title: intl.formatMessage({
id: 'master.logs.trips.create',
defaultMessage: 'Create new ship',
}),
},
{
value: 'trips.update',
title: intl.formatMessage({
id: 'master.logs.trips.update',
defaultMessage: 'Update ship',
}),
},
{
value: 'trips.remove',
title: intl.formatMessage({
id: 'master.logs.trips.remove',
defaultMessage: 'Remove ship',
}),
},
{
value: 'trips.approve',
title: intl.formatMessage({
id: 'master.logs.trips.approve',
defaultMessage: 'Approve trip',
}),
},
{
value: 'trips.request_approve',
title: intl.formatMessage({
id: 'master.logs.trips.request_approve',
defaultMessage: 'Request approval for trip',
}),
},
{
value: 'trips.unassign_thing',
title: intl.formatMessage({
id: 'master.logs.trips.unassign_thing',
defaultMessage: 'Remove thing from ship',
}),
},
{
value: 'trips.unassign_user',
title: intl.formatMessage({
id: 'master.logs.trips.unassign_user',
defaultMessage: 'Remove user from ship',
}),
},
],
},
];
};
export default LogActions;

View File

@@ -3,314 +3,18 @@ import { apiQueryLogs } from '@/services/master/LogController';
import { apiQueryUsers } from '@/services/master/UserController'; import { apiQueryUsers } from '@/services/master/UserController';
import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components'; import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { theme } from 'antd';
import { DatePicker } from 'antd/lib'; import { DatePicker } from 'antd/lib';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useRef } from 'react'; import { useRef } from 'react';
import LogActions from './components/LogActions';
const SystemLogs = () => { const SystemLogs = () => {
const intl = useIntl(); const intl = useIntl();
const tableRef = useRef<ActionType>(); const tableRef = useRef<ActionType>();
const actions = [ const { token } = theme.useToken();
//Alarm
{
title: intl.formatMessage({
id: 'master.logs.things.alarm.confirm',
defaultMessage: 'Alarm confirm',
}),
value: '0-0',
selectable: false,
children: [
{
value: 'things.alarm_confirm',
title: intl.formatMessage({
id: 'master.logs.things.confirm',
defaultMessage: 'Confirm',
}),
},
{
value: 'things.alarm_unconfirm',
title: intl.formatMessage({
id: 'master.logs.things.unconfirm',
defaultMessage: 'Unconfirm',
}),
},
],
},
//Things
{
title: intl.formatMessage({
id: 'master.logs.things',
defaultMessage: 'Things',
}),
value: '0-1',
selectable: false,
children: [
{
value: 'things.create',
title: intl.formatMessage({
id: 'master.logs.things.create',
defaultMessage: 'Create new thing',
}),
},
{
value: 'things.update',
title: intl.formatMessage({
id: 'master.logs.things.update',
defaultMessage: 'Update thing',
}),
},
{
value: 'things.remove',
title: intl.formatMessage({
id: 'master.logs.things.remove',
defaultMessage: 'Remove thing',
}),
},
{
value: 'things.share',
title: intl.formatMessage({
id: 'master.logs.things.share',
defaultMessage: 'Share thing',
}),
},
{
value: 'things.unshare',
title: intl.formatMessage({
id: 'master.logs.things.unshare',
defaultMessage: 'Unshare thing',
}),
},
{
value: 'things.update_key',
title: intl.formatMessage({
id: 'master.logs.things.update_key',
defaultMessage: 'Update key thing',
}),
},
],
},
// Users
{
title: intl.formatMessage({
id: 'master.logs.users',
defaultMessage: 'Users',
}),
value: '0-2',
selectable: false,
children: [
{
value: 'users.create',
title: intl.formatMessage({
id: 'master.logs.users.create',
defaultMessage: 'Register user',
}),
},
{
value: 'users.update',
title: intl.formatMessage({
id: 'master.logs.users.update',
defaultMessage: 'Update user',
}),
},
{
value: 'users.remove',
title: intl.formatMessage({
id: 'master.logs.users.remove',
defaultMessage: 'Remove user',
}),
},
{
value: 'users.login',
title: intl.formatMessage({
id: 'master.logs.users.login',
defaultMessage: 'User login',
}),
},
],
},
// Groups
{
title: intl.formatMessage({
id: 'master.logs.groups',
defaultMessage: 'Groups',
}),
value: '0-3',
selectable: false,
children: [
{
value: 'group.create',
title: intl.formatMessage({
id: 'master.logs.groups.create',
defaultMessage: 'Create new group',
}),
},
{
value: 'group.update',
title: intl.formatMessage({
id: 'master.logs.groups.update',
defaultMessage: 'Update group',
}),
},
{
value: 'group.remove',
title: intl.formatMessage({
id: 'master.logs.groups.remove',
defaultMessage: 'Remove group',
}),
},
{
value: 'group.assign_thing',
title: intl.formatMessage({
id: 'master.logs.groups.assign_thing',
defaultMessage: 'Assign thing to group',
}),
},
{
value: 'group.assign_user',
title: intl.formatMessage({
id: 'master.logs.groups.assign_user',
defaultMessage: 'Assign user to group',
}),
},
{
value: 'group.unassign_thing',
title: intl.formatMessage({
id: 'master.logs.groups.unassign_thing',
defaultMessage: 'Remove thing from group',
}),
},
{
value: 'group.unassign_user',
title: intl.formatMessage({
id: 'master.logs.groups.unassign_user',
defaultMessage: 'Remove user from group',
}),
},
],
},
// Ships
{
title: intl.formatMessage({
id: 'master.logs.ships',
defaultMessage: 'Ships',
}),
value: '0-4',
selectable: false,
children: [
{
value: 'ships.create',
title: intl.formatMessage({
id: 'master.logs.ships.create',
defaultMessage: 'Create new ship',
}),
},
{
value: 'ships.update',
title: intl.formatMessage({
id: 'master.logs.ships.update',
defaultMessage: 'Update ship',
}),
},
{
value: 'ships.remove',
title: intl.formatMessage({
id: 'master.logs.ships.remove',
defaultMessage: 'Remove ship',
}),
},
{
value: 'ships.assign_thing',
title: intl.formatMessage({
id: 'master.logs.ships.assign_thing',
defaultMessage: 'Assign thing to ship',
}),
},
{
value: 'ships.assign_user',
title: intl.formatMessage({
id: 'master.logs.ships.assign_user',
defaultMessage: 'Assign user to ship',
}),
},
{
value: 'ships.unassign_thing',
title: intl.formatMessage({
id: 'master.logs.ships.unassign_thing',
defaultMessage: 'Remove thing from ship',
}),
},
{
value: 'ships.unassign_user',
title: intl.formatMessage({
id: 'master.logs.ships.unassign_user',
defaultMessage: 'Remove user from ship',
}),
},
],
},
// Trips
{
title: intl.formatMessage({
id: 'master.logs.trips',
defaultMessage: 'Trips',
}),
value: '0-5',
selectable: false,
children: [
{
value: 'trips.create',
title: intl.formatMessage({
id: 'master.logs.trips.create',
defaultMessage: 'Create new ship',
}),
},
{
value: 'trips.update',
title: intl.formatMessage({
id: 'master.logs.trips.update',
defaultMessage: 'Update ship',
}),
},
{
value: 'trips.remove',
title: intl.formatMessage({
id: 'master.logs.trips.remove',
defaultMessage: 'Remove ship',
}),
},
{
value: 'trips.approve',
title: intl.formatMessage({
id: 'master.logs.trips.approve',
defaultMessage: 'Approve trip',
}),
},
{
value: 'trips.request_approve',
title: intl.formatMessage({
id: 'master.logs.trips.request_approve',
defaultMessage: 'Request approval for trip',
}),
},
{
value: 'trips.unassign_thing',
title: intl.formatMessage({
id: 'master.logs.trips.unassign_thing',
defaultMessage: 'Remove thing from ship',
}),
},
{
value: 'trips.unassign_user',
title: intl.formatMessage({
id: 'master.logs.trips.unassign_user',
defaultMessage: 'Remove user from ship',
}),
},
],
},
];
const queryUserSource = async (): Promise<MasterModel.ProfileResponse[]> => { const queryUserSource = async (): Promise<MasterModel.UserResponse[]> => {
try { try {
const body: MasterModel.SearchUserPaginationBody = { const body: MasterModel.SearchUserPaginationBody = {
offset: 0, offset: 0,
@@ -345,6 +49,10 @@ const SystemLogs = () => {
return ( return (
<DatePicker.RangePicker <DatePicker.RangePicker
width="50%" width="50%"
style={{
backgroundColor: token.colorBgContainer,
color: token.colorText,
}}
presets={[ presets={[
{ {
label: intl.formatMessage({ label: intl.formatMessage({
@@ -404,9 +112,9 @@ const SystemLogs = () => {
treeCheckable: true, treeCheckable: true,
multiple: true, multiple: true,
}, },
request: async () => actions, request: async () => LogActions(intl),
render: (_, item) => { render: (_, item) => {
const childs = actions.flatMap((a) => a.children || []); const childs = LogActions(intl).flatMap((a) => a.children || []);
const action = childs.find((a) => a?.value === item.subtopic); const action = childs.find((a) => a?.value === item.subtopic);
return action?.title ?? '...'; return action?.title ?? '...';
}, },

View File

@@ -14,7 +14,7 @@ enum AssignTabsKey {
const AssignUserPage = () => { const AssignUserPage = () => {
const { userId } = useParams<{ userId: string }>(); const { userId } = useParams<{ userId: string }>();
const [userProfile, setUserProfile] = const [userProfile, setUserProfile] =
useState<MasterModel.ProfileResponse | null>(null); useState<MasterModel.UserResponse | null>(null);
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [tabSelected, setTabSelected] = useState<AssignTabsKey>( const [tabSelected, setTabSelected] = useState<AssignTabsKey>(
AssignTabsKey.group, AssignTabsKey.group,

View File

@@ -24,7 +24,7 @@ import {
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
const { Text } = Typography; const { Text } = Typography;
type AssignGroupProps = { type AssignGroupProps = {
user: MasterModel.ProfileResponse | null; user: MasterModel.UserResponse | null;
}; };
const AssignGroup = ({ user }: AssignGroupProps) => { const AssignGroup = ({ user }: AssignGroupProps) => {
const groupActionRef = useRef<ActionType>(); const groupActionRef = useRef<ActionType>();

View File

@@ -1,64 +1,323 @@
import { ActionType, ProList } from '@ant-design/pro-components'; import ThingsFilter from '@/components/shared/ThingFilterModal';
import { useIntl } from '@umijs/max'; import { DEFAULT_PAGE_SIZE } from '@/constants';
import { message } from 'antd'; import {
import { useRef } from 'react'; apiDeleteUserThingPolicy,
apiGetThingPolicyByUser,
apiShareThingToUser,
} from '@/services/master/ThingController';
import { DeleteOutlined, ShareAltOutlined } from '@ant-design/icons';
import {
ActionType,
FooterToolbar,
ProList,
ProListMetas,
} from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max';
import {
Button,
Checkbox,
GetProp,
message,
Popconfirm,
Tag,
Typography,
} from 'antd';
import { useRef, useState } from 'react';
const { Paragraph } = Typography;
type PolicyShareDefault = {
read: boolean;
write: boolean;
delete: boolean;
};
type ShareThingProps = { type ShareThingProps = {
user: MasterModel.ProfileResponse | null; user: MasterModel.UserResponse | null;
}; };
const ShareThing = ({ user }: ShareThingProps) => { const ShareThing = ({ user }: ShareThingProps) => {
const listActionRef = useRef<ActionType>(); const listActionRef = useRef<ActionType>();
const intl = useIntl(); const intl = useIntl();
const [messageAPI, contextHolder] = message.useMessage(); const [messageApi, contextHolder] = message.useMessage();
const [selectedRowsState, setSelectedRows] = useState<
MasterModel.ThingPolicy[]
>([]);
const [thingPolicy, setThingPolicy] = useState<MasterModel.ThingPolicy[]>([]);
const [shareThingModalVisible, setShareThingModalVisible] =
useState<boolean>(false);
const [policyShare, setPolicyShare] = useState<PolicyShareDefault>({
read: true,
write: true,
delete: true,
});
const getPolicyInfo = (
policy: MasterModel.Policy,
): { color: string; text: string } => {
switch (policy) {
case 'read':
return {
color: 'blue',
text: intl.formatMessage({
id: 'master.users.things.relation.read',
defaultMessage: 'Read',
}),
};
case 'write':
return {
color: 'gold',
text: intl.formatMessage({
id: 'master.users.things.relation.write',
defaultMessage: 'Write',
}),
};
case 'delete':
return {
color: 'red',
text: intl.formatMessage({
id: 'master.users.things.relation.delete',
defaultMessage: 'Delete',
}),
};
default:
return { color: 'default', text: policy };
}
};
const columns: ProListMetas<MasterModel.ThingPolicy> = {
title: {
dataIndex: 'name',
render: (_, record: MasterModel.ThingPolicy) => (
<Paragraph copyable>{record?.thing_name}</Paragraph>
),
},
subTitle: {
dataIndex: 'metadata.external_id',
render: (_, record: MasterModel.ThingPolicy) => (
<Paragraph copyable>{record?.external_id}</Paragraph>
),
},
description: {
dataIndex: 'policies',
render: (_, record: MasterModel.ThingPolicy) => {
return record?.policies?.map((policy) => {
const info = getPolicyInfo(policy);
return (
<Tag key={policy} color={info.color}>
{info.text}
</Tag>
);
});
},
},
};
const handleShareThings = async (thingIds: string[]) => {
const thingsFiltered = thingIds.filter((thingId) => {
return !thingPolicy.find((thing) => thing.thing_id === thingId);
});
if (thingsFiltered.length === 0) return;
const key = 'share';
try {
messageApi.open({
type: 'loading',
content: intl.formatMessage({
id: 'master.users.things.sharing',
defaultMessage: 'Sharing devices...',
}),
key,
});
const allShare = thingsFiltered.map(async (thingId) => {
const resp = await apiShareThingToUser(
thingId,
user?.id || '',
Object.keys(policyShare).filter(
(key) => policyShare[key as keyof PolicyShareDefault],
),
);
});
await Promise.all(allShare);
messageApi.open({
type: 'success',
content: intl.formatMessage({
id: 'master.users.thing.share.success',
defaultMessage: 'Share successfully and will refresh soon',
}),
key,
});
await new Promise((resolve) => {
setTimeout(resolve, 500);
});
if (listActionRef.current) {
listActionRef.current.reload();
}
} catch (error) {
console.error('Error when share thing: ', error);
messageApi.open({
type: 'error',
content: intl.formatMessage({
id: 'master.users.thing.share.fail',
defaultMessage: 'Share failed, please try again!',
}),
key,
});
}
};
const onChange: GetProp<typeof Checkbox.Group, 'onChange'> = (
checkedValues,
) => {
setPolicyShare({
read: checkedValues.includes('read'),
write: checkedValues.includes('write'),
delete: checkedValues.includes('delete'),
});
};
const handleUnshare = async (selectedRows: MasterModel.ThingPolicy[]) => {
if (!selectedRows) return true;
const key = 'unshare';
try {
messageApi.open({
type: 'loading',
content: intl.formatMessage({
id: 'master.users.things.unsharing',
defaultMessage: 'Unsharing devices...',
}),
key,
});
const allUnshare = selectedRows.map(async (row) => {
const resp = await apiDeleteUserThingPolicy(
row?.thing_id || '',
user?.id || '',
);
});
await Promise.all(allUnshare);
messageApi.open({
type: 'success',
content: intl.formatMessage({
id: 'master.users.thing.unshare.success',
defaultMessage: 'Unshare successfully and will refresh soon',
}),
key,
});
await new Promise((resolve) => {
setTimeout(resolve, 500);
});
return true;
} catch (error) {
console.error('Error when unshare thing: ', error);
messageApi.open({
type: 'error',
content: intl.formatMessage({
id: 'master.users.thing.unshare.fail',
defaultMessage: 'Unshare failed, please try again!',
}),
key,
});
return false;
}
};
return ( return (
<> <>
{contextHolder} {contextHolder}
<ProList <ThingsFilter
isOpen={shareThingModalVisible}
setIsOpen={setShareThingModalVisible}
thingIds={thingPolicy.map((thing) => thing.thing_id!)}
disabled
extra={
<Checkbox.Group
options={[
{
label: intl.formatMessage({
id: 'master.users.things.relation.read',
defaultMessage: 'Read',
}),
value: 'read',
disabled: true,
},
{
label: intl.formatMessage({
id: 'master.users.things.relation.write',
defaultMessage: 'Write',
}),
value: 'write',
},
{
label: intl.formatMessage({
id: 'master.users.things.relation.delete',
defaultMessage: 'Delete',
}),
value: 'delete',
},
]}
value={Object.keys(policyShare).filter(
(key) => policyShare[key as keyof PolicyShareDefault],
)}
onChange={onChange}
/>
}
onSubmit={handleShareThings}
/>
<ProList<MasterModel.ThingPolicy>
headerTitle={intl.formatMessage({ headerTitle={intl.formatMessage({
id: 'pages.users.things.list', id: 'master.users.things.list',
defaultMessage: 'List things', defaultMessage: 'List things',
})} })}
actionRef={listActionRef} actionRef={listActionRef}
toolBarRender={() => [ toolBarRender={() => [
// <Button <Button
// type="primary" type="primary"
// key="primary" key="primary"
// onClick={() => { icon={<ShareAltOutlined />}
// handleShareModalVisible(true); onClick={() => {
// }} setShareThingModalVisible(true);
// > }}
// <PlusOutlined />{" "} >
// <FormattedMessage <FormattedMessage
// id="pages.things.share.text" id="master.users.thing.share.title"
// defaultMessage="Share" defaultMessage="Share"
// /> />
// </Button>, </Button>,
]} ]}
pagination={{
pageSize: DEFAULT_PAGE_SIZE,
}}
metas={columns} metas={columns}
request={async () => { request={async (params) => {
const { current, pageSize } = params;
const query = { const query = {
type: 'sub', type: 'sub',
id: user?.id || '', id: user?.id || '',
}; };
if (user?.id) { if (user?.id) {
const resp = (await apiQueryThingsByPolicy( const offset = current === 1 ? 0 : (current! - 1) * pageSize!;
query, const policyBody: Partial<MasterModel.SearchPaginationBody> = {
)) as PolicyResponse; offset: offset,
const { relations } = resp; limit: pageSize,
if (relations) { };
const queries = relations.map(async (rel: PolicyRelation) => { const resp = await apiGetThingPolicyByUser(policyBody, user.id);
const thg = await apiQueryThing(rel.id);
return { if (resp.things) {
...thg, setThingPolicy(resp.things);
relations: rel?.actions,
};
});
const policies = await Promise.all(queries);
return Promise.resolve({ return Promise.resolve({
success: true, success: true,
data: policies, data: resp.things,
total: policies.length, total: resp.total,
}); });
} else {
return {
success: false,
data: [],
total: 0,
};
} }
} }
return Promise.resolve({ return Promise.resolve({
@@ -67,32 +326,67 @@ const ShareThing = ({ user }: ShareThingProps) => {
total: 0, total: 0,
}); });
}} }}
rowKey="id" rowKey="external_id"
search={false} search={false}
// rowSelection={{ rowSelection={{
// selectedRowKeys: selectedRowsState.map((row) => row.id).filter((id): id is string => id !== undefined), selectedRowKeys: selectedRowsState
// onChange: (_: React.Key[], selectedRows: API.Thing[]) => { .map((row) => row.external_id)
// setSelectedRows(selectedRows); .filter((id): id is string => id !== undefined),
// }, onChange: (_, selectedRows: MasterModel.ThingPolicy[]) => {
// }} setSelectedRows(selectedRows);
/> },
{/* <FormShareVms
visible={shareModalVisibale}
onVisibleChange={handleShareModalVisible}
user={user}
onSubmit={async (values: ShareFormValues) => {
console.log(values);
const success = await handleShare(values);
if (success) {
handleShareModalVisible(false);
onReload();
if (actionRef.current) {
//await delay(1000);
actionRef.current.reload();
}
}
}} }}
/> */} />
{selectedRowsState?.length > 0 && (
<FooterToolbar
extra={
<div>
<FormattedMessage
id="master.footer.chosen"
defaultMessage="Chosen"
/>{' '}
<a
style={{
fontWeight: 600,
}}
>
{selectedRowsState.length}
</a>{' '}
<FormattedMessage
id="common.paginations.things"
defaultMessage="item"
/>
</div>
}
>
<Popconfirm
title={intl.formatMessage({
id: 'master.users.thing.unshare.confirm',
defaultMessage: 'Are you sure to stop sharing these devices?',
})}
okText={intl.formatMessage({
id: 'common.sure',
defaultMessage: 'Sure',
})}
onConfirm={async () => {
const success = await handleUnshare(selectedRowsState);
if (success) {
setSelectedRows([]);
if (listActionRef.current) {
listActionRef.current.reload();
}
}
}}
>
<Button type="primary" danger icon={<DeleteOutlined />}>
<FormattedMessage
id="master.users.thing.unshare.title"
defaultMessage="Sure"
/>
</Button>
</Popconfirm>
</FooterToolbar>
)}
</> </>
); );
}; };

View File

@@ -34,19 +34,19 @@ const ManagerUserPage = () => {
const [isLoading, setIsLoading] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false);
const [messageApi, contextHolder] = message.useMessage(); const [messageApi, contextHolder] = message.useMessage();
const [selectedRowsState, setSelectedRowsState] = useState< const [selectedRowsState, setSelectedRowsState] = useState<
MasterModel.ProfileResponse[] MasterModel.UserResponse[]
>([]); >([]);
const [groupCheckedKeys, setGroupCheckedKeys] = useState< const [groupCheckedKeys, setGroupCheckedKeys] = useState<
string | string[] | null string | string[] | null
>(null); >(null);
const handleClickAssign = (user: MasterModel.ProfileResponse) => { const handleClickAssign = (user: MasterModel.UserResponse) => {
const path = `${ROUTE_MANAGER_USERS}/${user.id}/${ROUTE_MANAGER_USERS_PERMISSIONS}`; const path = `${ROUTE_MANAGER_USERS}/${user.id}/${ROUTE_MANAGER_USERS_PERMISSIONS}`;
history.push(path); history.push(path);
}; };
const columns: ProColumns<MasterModel.ProfileResponse>[] = [ const columns: ProColumns<MasterModel.UserResponse>[] = [
{ {
key: 'email', key: 'email',
title: ( title: (
@@ -136,7 +136,7 @@ const ManagerUserPage = () => {
}, },
]; ];
const handleRemove = async (selectedRows: MasterModel.ProfileResponse[]) => { const handleRemove = async (selectedRows: MasterModel.UserResponse[]) => {
const key = 'remove_user'; const key = 'remove_user';
if (!selectedRows) return true; if (!selectedRows) return true;
@@ -151,7 +151,7 @@ const ManagerUserPage = () => {
key, key,
}); });
const allDelete = selectedRows.map( const allDelete = selectedRows.map(
async (row: MasterModel.ProfileResponse) => { async (row: MasterModel.UserResponse) => {
await apiDeleteUser(row?.id || ''); await apiDeleteUser(row?.id || '');
}, },
); );
@@ -196,7 +196,7 @@ const ManagerUserPage = () => {
/> />
</ProCard> </ProCard>
<ProCard colSpan={{ xs: 24, sm: 24, md: 18, lg: 18, xl: 18 }}> <ProCard colSpan={{ xs: 24, sm: 24, md: 18, lg: 18, xl: 18 }}>
<ProTable<MasterModel.ProfileResponse> <ProTable<MasterModel.UserResponse>
columns={columns} columns={columns}
tableLayout="auto" tableLayout="auto"
actionRef={actionRef} actionRef={actionRef}
@@ -210,7 +210,7 @@ const ManagerUserPage = () => {
selectedRowKeys: selectedRowsState.map((row) => row.id!), selectedRowKeys: selectedRowsState.map((row) => row.id!),
onChange: ( onChange: (
_: React.Key[], _: React.Key[],
selectedRows: MasterModel.ProfileResponse[], selectedRows: MasterModel.UserResponse[],
) => { ) => {
setSelectedRowsState(selectedRows); setSelectedRowsState(selectedRows);
}, },
@@ -249,12 +249,12 @@ const ManagerUserPage = () => {
let users = userByGroupResponses.users || []; let users = userByGroupResponses.users || [];
// Apply filters // Apply filters
if (email) { if (email) {
users = users.filter((user: MasterModel.ProfileResponse) => users = users.filter((user: MasterModel.UserResponse) =>
user.email?.includes(email), user.email?.includes(email),
); );
} }
if (phone_number) { if (phone_number) {
users = users.filter((user: MasterModel.ProfileResponse) => users = users.filter((user: MasterModel.UserResponse) =>
user.metadata?.phone_number?.includes(phone_number), user.metadata?.phone_number?.includes(phone_number),
); );
} }
@@ -269,7 +269,7 @@ const ManagerUserPage = () => {
}; };
} else { } else {
// Use regular queryUsers API // Use regular queryUsers API
const metadata: Partial<MasterModel.ProfileMetadata> = {}; const metadata: Partial<MasterModel.UserMetadata> = {};
if (phone_number) metadata.phone_number = phone_number; if (phone_number) metadata.phone_number = phone_number;
const query: MasterModel.SearchUserPaginationBody = { const query: MasterModel.SearchUserPaginationBody = {
@@ -343,7 +343,7 @@ const ManagerUserPage = () => {
> >
<Popconfirm <Popconfirm
title={intl.formatMessage({ title={intl.formatMessage({
id: 'master.users.deletion.title', id: 'master.users.delete.title',
defaultMessage: 'Are you sure to delete this selected items', defaultMessage: 'Are you sure to delete this selected items',
})} })}
okText={intl.formatMessage({ okText={intl.formatMessage({

View File

@@ -32,7 +32,7 @@ const ChangeProfile = () => {
}} }}
onFinish={async (values) => { onFinish={async (values) => {
try { try {
const body: Partial<MasterModel.ProfileMetadata> = { const body: Partial<MasterModel.UserMetadata> = {
full_name: values.full_name, full_name: values.full_name,
phone_number: values.phone_number, phone_number: values.phone_number,
}; };

View File

@@ -0,0 +1,317 @@
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import { ModalForm } from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max';
import { Button, DatePicker, Flex, Form, Input, Select, Space } from 'antd';
import { FormListFieldData } from 'antd/lib';
import dayjs from 'dayjs';
import { useEffect } from 'react';
import { AreaCondition, ZoneFormField } from '../type';
const { RangePicker } = DatePicker;
// Transform form values to match AreaCondition type
const transformToAreaCondition = (values: any[]): AreaCondition[] => {
return values.map((item) => {
const { type } = item;
switch (type) {
case 'month_range': {
// RangePicker for month returns [Dayjs, Dayjs]
const [from, to] = item.from ?? [];
return {
type: 'month_range',
from: from?.month() ?? 0, // 0-11
to: to?.month() ?? 0,
};
}
case 'date_range': {
// RangePicker for date returns [Dayjs, Dayjs]
const [from, to] = item.from ?? [];
return {
type: 'date_range',
from: from?.format('YYYY-MM-DD') ?? '',
to: to?.format('YYYY-MM-DD') ?? '',
};
}
case 'length_limit': {
return {
type: 'length_limit',
min: Number(item.min) ?? 0,
max: Number(item.max) ?? 0,
};
}
default:
return item;
}
});
};
// Transform AreaCondition to form values
const transformToFormValues = (conditions?: AreaCondition[]): any[] => {
if (!conditions || conditions.length === 0) return [{}];
return conditions.map((condition) => {
switch (condition.type) {
case 'month_range': {
return {
type: 'month_range',
from: [dayjs().month(condition.from), dayjs().month(condition.to)],
};
}
case 'date_range': {
return {
type: 'date_range',
from: [dayjs(condition.from), dayjs(condition.to)],
};
}
case 'length_limit': {
return condition;
}
default:
return {};
}
});
};
const ConditionRow = ({
field,
remove,
}: {
field: FormListFieldData;
remove: (name: number) => void;
}) => {
const selectedType = Form.useWatch([
ZoneFormField.AreaConditions,
field.name,
'type',
]);
const intl = useIntl();
return (
<Flex gap="middle" style={{ marginBottom: 16 }}>
<Space align="baseline">
<Form.Item
name={[field.name, 'type']}
label={intl.formatMessage({
id: 'banzone.category.name',
defaultMessage: 'Danh mục',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'banzone.category.not_empty',
defaultMessage: 'Category cannot be empty!',
}),
},
]}
>
<Select
placeholder={intl.formatMessage({
id: 'banzone.category.add',
defaultMessage: 'Add category',
})}
>
<Select.Option value="month_range">
<FormattedMessage
id="banzone.condition.yearly_select"
defaultMessage="Yearly"
/>
</Select.Option>
<Select.Option value="date_range">
<FormattedMessage
id="banzone.condition.specific_time_select"
defaultMessage="Specific date"
/>
</Select.Option>
<Select.Option value="length_limit">
<FormattedMessage
id="banzone.condition.length_limit"
defaultMessage="Length limit"
/>
</Select.Option>
</Select>
</Form.Item>
{selectedType !== undefined &&
(selectedType === 'length_limit' ? (
<Form.Item
label={intl.formatMessage({
id: 'banzone.condition.length_limit',
defaultMessage: 'Length limit',
})}
required
>
<Space>
<Form.Item
name={[field.name, 'min']}
noStyle
rules={[
{
required: true,
message: intl.formatMessage({
id: 'common.not_empty',
defaultMessage: 'Cannot be empty!',
}),
},
]}
>
<Input
placeholder={intl.formatMessage({
id: 'banzone.condition.length_limit_min',
defaultMessage: 'Minimum',
})}
type="number"
/>
</Form.Item>
<Form.Item
name={[field.name, 'max']}
noStyle
rules={[
{
required: true,
message: intl.formatMessage({
id: 'common.not_empty',
defaultMessage: 'Cannot be empty!',
}),
},
]}
>
<Input
placeholder={intl.formatMessage({
id: 'banzone.condition.length_limit_max',
defaultMessage: 'Maximum',
})}
type="number"
/>
</Form.Item>
</Space>
</Form.Item>
) : (
<Form.Item
name={[field.name, 'from']}
label={intl.formatMessage({
id: 'banzone.condition.ban_time',
defaultMessage: 'Time',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'common.not_empty',
defaultMessage: 'Cannot be empty!',
}),
},
]}
>
<RangePicker
picker={selectedType === 'month_range' ? 'month' : 'date'}
format={
selectedType === 'month_range' ? 'MM/YYYY' : 'DD/MM/YYYY'
}
/>
</Form.Item>
))}
<MinusCircleOutlined onClick={() => remove(field.name)} />
</Space>
</Flex>
);
};
interface AddConditionFormProps {
initialData?: AreaCondition[];
isVisible: boolean;
setVisible: (visible: boolean) => void;
onFinish?: (values: AreaCondition[]) => void;
}
const AddConditionForm = ({
initialData,
isVisible,
setVisible,
onFinish,
}: AddConditionFormProps) => {
const [form] = Form.useForm();
useEffect(() => {
if (isVisible) {
form.resetFields();
if (initialData && initialData.length > 0) {
form.setFieldsValue({ conditions: transformToFormValues(initialData) });
} else {
form.setFieldsValue({ conditions: [{}] });
}
}
}, [isVisible, initialData]);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
const transformedConditions = transformToAreaCondition(values.conditions);
onFinish?.(transformedConditions);
setVisible(false);
form.resetFields();
} catch (err) {
console.error('Validation failed', err);
}
};
return (
<ModalForm
form={form}
open={isVisible}
onOpenChange={(open) => {
if (!open) form.resetFields();
setVisible(open);
}}
title={
<FormattedMessage
id="banzone.category.add"
defaultMessage="Add category"
/>
}
width="600px"
submitter={{
searchConfig: { submitText: 'Lưu' },
render: (_, doms) => (
<Flex justify="center" gap={16}>
{doms}
</Flex>
),
}}
onFinish={handleSubmit}
>
<Form.List name={ZoneFormField.AreaConditions}>
{(fields, { add, remove }) => (
<>
{fields.map((field) => (
<ConditionRow
key={field.key}
field={field}
remove={remove}
// isFirst={index === 0}
/>
))}
<Form.Item>
<Flex justify="center">
<Button
type="dashed"
onClick={() => add()}
icon={<PlusOutlined />}
style={{ width: 300 }}
>
<FormattedMessage
id="banzone.category.add"
defaultMessage="Add category"
/>
</Button>
</Flex>
</Form.Item>
</>
)}
</Form.List>
</ModalForm>
);
};
export default AddConditionForm;

View File

@@ -0,0 +1,334 @@
import { getCircleRadius } from '@/utils/slave/sgw/geomUtils';
import { PlusOutlined } from '@ant-design/icons';
import {
ProFormDigit,
ProFormInstance,
ProFormItem,
ProFormText,
} from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max';
import { Button, Col, Flex, Form, Input, Row, Tag, Tooltip } from 'antd';
import { MutableRefObject, useMemo, useState } from 'react';
import {
CircleGeometry,
GeometryType,
PolygonGeometry,
tagPlusStyle,
validateGeometry,
ZoneFormData,
ZoneFormField,
} from '../type';
import PolygonModal from './PolygonModal';
type GeometryFormProps = {
shape?: number;
form: MutableRefObject<ProFormInstance<ZoneFormData> | null | undefined>;
zoneData?: SgwModel.Geom;
};
const GeometryForm = ({ shape, form }: GeometryFormProps) => {
const [isPolygonModalOpen, setIsPolygonModalOpen] = useState<boolean>(false);
const [indexTag, setIndexTag] = useState<number>(-1);
const intl = useIntl();
const polygonGeometry =
(Form.useWatch(
ZoneFormField.PolygonGeometry,
form.current || undefined,
) as PolygonGeometry[]) || [];
// Circle area calculation (subscribe to Radius so it updates)
const area = Form.useWatch(
[ZoneFormField.CircleData, 'area'],
form.current || undefined,
) as number | undefined;
const radiusArea = useMemo(() => {
if (shape === GeometryType.CIRCLE) {
if (area && area > 0) {
return getCircleRadius(area);
}
}
return 0;
}, [shape, area]);
const handleRemovePolygon = (index: number) => {
const newPolygons = polygonGeometry.filter(
(_, i) => i !== index,
) as PolygonGeometry[];
form.current?.setFieldsValue({
[ZoneFormField.PolygonGeometry]: newPolygons,
});
};
const handleEditPolygon = (index: number) => {
setIsPolygonModalOpen(true);
setIndexTag(index);
};
// Format coordinates for display
const formatCoords = (coords: number[][]) => {
return coords.map((c) => `[${c[0]}, ${c[1]}]`).join(', ');
};
switch (shape) {
case GeometryType.POLYGON:
return (
<>
<ProFormItem
name={ZoneFormField.PolygonGeometry}
label={intl.formatMessage({
id: 'banzone.geometry.coordinates',
defaultMessage: 'Tọa độ',
})}
required
>
<Flex gap="10px 4px" wrap>
{polygonGeometry.map((polygon, index) => (
<Tooltip
key={index}
title={formatCoords(polygon.geometry) || ''}
>
<Tag
closable
onClose={(e) => {
e.preventDefault();
handleRemovePolygon(index);
}}
onClick={() => handleEditPolygon(index)}
color="blue"
style={{ cursor: 'pointer' }}
>
{intl.formatMessage({
id: 'banzones.title',
defaultMessage: 'Khu vực',
})}{' '}
{index + 1}
</Tag>
</Tooltip>
))}
<Button
style={tagPlusStyle}
icon={<PlusOutlined />}
onClick={() => {
setIndexTag(-1);
setIsPolygonModalOpen(true);
}}
>
<FormattedMessage
id="banzone.geometry.add_zone"
defaultMessage="Thêm vùng"
/>
</Button>
</Flex>
</ProFormItem>
<PolygonModal
isVisible={isPolygonModalOpen}
setVisible={setIsPolygonModalOpen}
initialData={polygonGeometry}
index={indexTag}
handleSubmit={async (values) => {
form.current?.setFieldsValue({
[ZoneFormField.PolygonGeometry]: values,
});
setIndexTag(-1);
setIsPolygonModalOpen(false);
}}
/>
</>
);
case GeometryType.LINESTRING:
return (
<ProFormItem
required
rules={[
{
required: true,
message: intl.formatMessage({
id: 'banzone.geometry.coordinates.not_empty',
defaultMessage: 'Tọa độ không được để trống!',
}),
},
{
validator: (_: any, value: string) => {
return validateGeometry(value);
},
},
]}
name={ZoneFormField.PolylineData}
label={intl.formatMessage({
id: 'banzone.geometry.coordinates',
defaultMessage: 'Tọa độ',
})}
tooltip={intl.formatMessage({
id: 'banzone.geometry.coordinates.tooltip',
defaultMessage:
'Danh sách các toạ độ theo định dạng: [[longitude,latitude],[longitude,latitude],...]',
})}
>
<Input.TextArea
autoSize={{ minRows: 5, maxRows: 10 }}
placeholder={intl.formatMessage({
id: 'banzone.geometry.coordinates.placeholder',
defaultMessage:
'Ví dụ: [[109.5, 12.3], [109.6, 12.4], [109.7, 12.5]]',
})}
/>
</ProFormItem>
);
case GeometryType.CIRCLE:
return (
<>
<Row gutter={16}>
<Col span={12}>
<ProFormText
name={[ZoneFormField.CircleData, 'center', 0]}
label={intl.formatMessage({
id: 'banzone.geometry.longitude',
defaultMessage: 'Kinh độ',
})}
placeholder={intl.formatMessage({
id: 'banzone.geometry.longitude.placeholder',
defaultMessage: 'Nhập kinh độ',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'banzone.geometry.longitude.required',
defaultMessage: 'Kinh độ không được để trống!',
}),
},
]}
fieldProps={{
type: 'number',
step: '0.000001',
onChange: (e) => {
const lng = parseFloat(e.target.value);
const currentCircle = (form.current?.getFieldValue(
ZoneFormField.CircleData,
) as CircleGeometry) || { center: [0, 0], area: 0 };
form.current?.setFieldsValue({
[ZoneFormField.CircleData]: {
...currentCircle,
center: [lng, currentCircle.center?.[1] || 0],
},
});
},
}}
/>
</Col>
<Col span={12}>
<ProFormText
name={[ZoneFormField.CircleData, 'center', 1]}
label={intl.formatMessage({
id: 'banzone.geometry.latitude',
defaultMessage: 'Vĩ độ',
})}
placeholder={intl.formatMessage({
id: 'banzone.geometry.latitude.placeholder',
defaultMessage: 'Nhập vĩ độ',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'banzone.geometry.latitude.required',
defaultMessage: 'Vĩ độ không được để trống!',
}),
},
]}
fieldProps={{
type: 'number',
step: '0.000001',
onChange: (e) => {
const lat = parseFloat(e.target.value);
const currentCircle = (form.current?.getFieldValue(
ZoneFormField.CircleData,
) as CircleGeometry) || { center: [0, 0], radius: 0 };
form.current?.setFieldsValue({
[ZoneFormField.CircleData]: {
...currentCircle,
center: [currentCircle.center?.[0] || 0, lat],
},
});
},
}}
/>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<ProFormDigit
name={[ZoneFormField.CircleData, 'area']}
label={intl.formatMessage({
id: 'banzone.geometry.area',
defaultMessage: 'Diện tích (Hecta)',
})}
placeholder={intl.formatMessage({
id: 'banzone.geometry.area.placeholder',
defaultMessage: 'Nhập diện tích',
})}
min={1}
fieldProps={{
precision: 0,
onChange: (value) => {
const currentCircle = (form.current?.getFieldValue(
ZoneFormField.CircleData,
) as CircleGeometry) || { center: [0, 0], radius: 0 };
form.current?.setFieldsValue({
[ZoneFormField.CircleData]: {
center: currentCircle.center || [0, 0],
area: value || 0,
},
});
},
}}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'banzone.geometry.area.required',
defaultMessage: 'Diện tích không được để trống!',
}),
},
]}
/>
</Col>
<Col span={12}>
<ProFormItem
name={[ZoneFormField.CircleData, 'radius']}
label={intl.formatMessage({
id: 'banzone.geometry.radius',
defaultMessage: 'Bán kính (m)',
})}
>
<Input
disabled
readOnly
value={
radiusArea > 0
? `${radiusArea} ${intl.formatMessage({
id: 'banzone.geometry.metrics',
defaultMessage: 'mét',
})}`
: ''
}
addonAfter={intl.formatMessage({
id: 'banzone.geometry.auto_calculate',
defaultMessage: 'Tự động tính',
})}
/>
</ProFormItem>
</Col>
</Row>
</>
);
default:
return <div>Vui lòng chọn loại hình học đ nhập toạ đ.</div>;
}
};
export default GeometryForm;

View File

@@ -0,0 +1,175 @@
import {
ModalForm,
ProFormItem,
ProFormList,
} from '@ant-design/pro-components';
import { useIntl } from '@umijs/max';
import { Flex, Input, theme } from 'antd';
import { useEffect, useMemo, useRef } from 'react';
import { PolygonGeometry, validateGeometry } from '../type';
interface PolygonModalProps {
initialData?: PolygonGeometry[];
index?: number;
isVisible: boolean;
setVisible: (visible: boolean) => void;
handleSubmit: (values: PolygonGeometry[]) => Promise<void>;
}
const PolygonModal = ({
isVisible,
setVisible,
handleSubmit,
initialData,
index,
}: PolygonModalProps) => {
const formRef = useRef<any>();
const { token } = theme.useToken();
const intl = useIntl();
// Counter to track item index during render
let itemIndex = 0;
// Convert initialData to form format
const initialValues = useMemo(() => {
if (!initialData || initialData.length === 0) {
return { conditions: [] };
}
return {
conditions: initialData.map((item) => ({
geometry: JSON.stringify(item.geometry),
id: item.id || undefined,
})),
};
}, [initialData]);
useEffect(() => {
if (isVisible) {
formRef.current?.setFieldsValue(initialValues);
}
}, [isVisible, initialValues]);
// Handle form submit - convert back to AreaGeometry format
const handleFinish = async (values: any) => {
const conditions = values.conditions || [];
const result: PolygonGeometry[] = conditions.map((item: any) => ({
geometry: JSON.parse(item.geometry),
id: item.id,
}));
await handleSubmit(result);
};
return (
<>
{/* Global style for highlighting TextArea border */}
<style>{`
.highlighted-item .ant-input {
border-color: ${token.colorWarningActive} !important;
}
`}</style>
<ModalForm
formRef={formRef}
open={isVisible}
onOpenChange={(open) => {
if (!open) formRef.current?.resetFields();
setVisible(open);
}}
title={intl.formatMessage({
id: 'banzone.polygon_modal.title',
defaultMessage: 'Thêm toạ độ',
})}
width="40%"
initialValues={initialValues}
submitter={{
searchConfig: {
submitText: intl.formatMessage({
id: 'common.save',
defaultMessage: 'Lưu',
}),
},
render: (_, doms) => (
<Flex justify="center" gap={16}>
{doms}
</Flex>
),
}}
onFinish={handleFinish}
>
<ProFormList
required
name="conditions"
creatorButtonProps={{
position: 'bottom',
creatorButtonText: intl.formatMessage({
id: 'banzone.geometry.add_zone',
defaultMessage: 'Thêm toạ độ',
}),
}}
copyIconProps={false}
deleteIconProps={{
tooltipText: intl.formatMessage({
id: 'common.delete',
defaultMessage: 'Xoá',
}),
}}
itemRender={({ listDom, action }) => {
const currentIndex = itemIndex++;
const isHighlighted = index !== undefined && currentIndex === index;
return (
<div
className={isHighlighted ? 'highlighted-item' : ''}
data-item-index={currentIndex}
>
{listDom}
<Flex gap={8} justify="flex-end">
{action}
</Flex>
</div>
);
}}
>
{/* Hidden field for id - needed to preserve id when editing */}
<ProFormItem name="id" hidden>
<input type="hidden" />
</ProFormItem>
<ProFormItem
name="geometry"
required
rules={[
{
required: true,
message: intl.formatMessage({
id: 'banzone.geometry.coordinates.not_empty',
defaultMessage: 'Coordinates cannot be empty!',
}),
},
{
validator: (_rule: any, value: any) => {
return validateGeometry(value);
},
},
]}
label={intl.formatMessage({
id: 'banzone.geometry.coordinates',
defaultMessage: 'Coordinates',
})}
tooltip={intl.formatMessage({
id: 'banzone.geometry.coordinates.tooltip',
defaultMessage:
'Danh sách các toạ độ theo định dạng: [[longitude,latitude],[longitude,latitude],...]',
})}
>
<Input.TextArea
autoSize={{ minRows: 5, maxRows: 10 }}
placeholder={intl.formatMessage({
id: 'banzone.geometry.coordinates.placeholder',
defaultMessage:
'Example: [[109.5, 12.3], [109.6, 12.4], [109.7, 12.5]]',
})}
/>
</ProFormItem>
</ProFormList>
</ModalForm>
</>
);
};
export default PolygonModal;

View File

@@ -0,0 +1,273 @@
import TreeSelectedGroup from '@/components/shared/TreeSelectedGroup';
import { PlusOutlined } from '@ant-design/icons';
import {
ProForm,
ProFormInstance,
ProFormSelect,
ProFormSwitch,
ProFormText,
ProFormTextArea,
} from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max';
import { Button, Col, Flex, Form, Row, Tag, Tooltip } from 'antd';
import dayjs from 'dayjs';
import { MutableRefObject, useState } from 'react';
import { tagPlusStyle, ZoneFormData, ZoneFormField } from '../type';
import AddConditionForm from './AddConditionForm';
import GeometryForm from './GeometryForm';
interface ZoneFormProps {
shape?: number;
formRef: MutableRefObject<ProFormInstance<ZoneFormData> | null | undefined>;
}
const ZoneForm = ({ formRef, shape }: ZoneFormProps) => {
const intl = useIntl();
const [isConditionModalOpen, setIsConditionModalOpen] = useState(false);
const handleGroupSelect = (groupId: string | string[] | null) => {
formRef.current?.setFieldsValue({ [ZoneFormField.AreaId]: groupId });
};
const selectedGroupIds = Form.useWatch(
ZoneFormField.AreaId,
formRef.current || undefined,
) as string | string[] | null | undefined;
const conditionData = Form.useWatch(
ZoneFormField.AreaConditions,
formRef.current || undefined,
);
const handleConditionsClose = (indexToRemove: number) => {
formRef.current?.setFieldValue(
ZoneFormField.AreaConditions,
conditionData?.filter((_, index) => index !== indexToRemove),
);
};
return (
<>
<Row gutter={16}>
{/* Tên */}
<Col xs={24} md={12}>
<ProFormText
name={ZoneFormField.AreaName}
label={intl.formatMessage({
id: 'common.name',
defaultMessage: 'Tên',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'banzone.form.name.required.message',
defaultMessage: 'Tên khu vực không được để trống!',
}),
},
]}
required
/>
</Col>
{/* Loại */}
<Col xs={24} md={12}>
<ProFormSelect
name={ZoneFormField.AreaType}
label={intl.formatMessage({
id: 'common.type',
defaultMessage: 'Loại',
})}
required
rules={[
{
required: true,
message: intl.formatMessage({
id: 'banzone.form.area_type.required.message',
defaultMessage: 'Loại khu vực không được để trống',
}),
},
]}
placeholder={intl.formatMessage({
id: 'banzone.form.area_type.placeholder',
defaultMessage: 'Chọn loại khu vực',
})}
options={[
{
label: intl.formatMessage({
id: 'banzone.area.fishing_ban',
defaultMessage: 'Cấm đánh bắt',
}),
value: 1,
},
{
label: intl.formatMessage({
id: 'banzone.area.move_ban',
defaultMessage: 'Cấm di chuyển',
}),
value: 2,
},
{
label: intl.formatMessage({
id: 'banzone.area.safe',
defaultMessage: 'Vùng an toàn',
}),
value: 3,
},
]}
/>
</Col>
</Row>
<Row gutter={16}>
{/* Tỉnh */}
<Col xs={24} md={12}>
<ProForm.Item
name={ZoneFormField.AreaId}
label={intl.formatMessage({
id: 'common.province',
defaultMessage: 'Tỉnh',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'banzone.form.province.required.message',
defaultMessage: 'Tỉnh quản lý không được để trống!',
}),
},
]}
>
<TreeSelectedGroup
groupIds={selectedGroupIds ?? ''}
onSelected={handleGroupSelect}
/>
</ProForm.Item>
</Col>
{/* Có hiệu lực */}
<Col xs={24} md={12}>
<ProFormSwitch
name={ZoneFormField.AreaEnabled}
label={intl.formatMessage({
id: 'banzone.is_enable',
defaultMessage: 'Có hiệu lực',
})}
valuePropName="checked"
/>
</Col>
</Row>
<ProFormTextArea
name={ZoneFormField.AreaDescription}
label={intl.formatMessage({
id: 'common.description',
defaultMessage: 'Mô tả',
})}
placeholder={intl.formatMessage({
id: 'banzone.form.description.placeholder',
defaultMessage: 'Nhập mô tả khu vực',
})}
autoSize={{ minRows: 3, maxRows: 5 }}
/>
<Form.Item
name={ZoneFormField.AreaConditions}
label={intl.formatMessage({
id: 'banzone.condition',
defaultMessage: 'Điều kiện',
})}
>
<Flex gap="10px 4px" wrap>
{(conditionData || []).map((condition, index) => {
// console.log("Condition: ", condition);
let tootip = '';
let label = '';
const { type } = condition;
switch (type) {
case 'month_range': {
label = intl.formatMessage({
id: 'banzone.condition.yearly',
defaultMessage: 'Hàng năm',
});
const fromMonth = condition.from + 1;
const toMonth = condition.to + 1;
tootip = `Tháng từ ${fromMonth} đến tháng ${toMonth}`;
break;
}
case 'date_range': {
label = intl.formatMessage({
id: 'banzone.condition.specific_time',
defaultMessage: 'Thời gian cụ thể',
});
const fromDate = dayjs(condition.from).format('DD/MM/YYYY');
const toDate = dayjs(condition.to).format('DD/MM/YYYY');
tootip = `Từ ${fromDate} đến ${toDate}`;
break;
}
case 'length_limit':
label = intl.formatMessage({
id: 'banzone.condition.length_limit',
defaultMessage: 'Chiều dài cho phép',
});
tootip = `Chiều dài tàu từ ${condition.min} đến ${condition.max} mét`;
break;
default:
label = intl.formatMessage({
id: 'common.undefined',
defaultMessage: 'Không xác định',
});
}
return (
<Tooltip title={tootip} key={index}>
<Tag
closable
onClick={() => setIsConditionModalOpen(true)}
onClose={(e) => {
e.preventDefault();
handleConditionsClose(index);
}}
color={
type === 'month_range'
? 'blue'
: type === 'date_range'
? 'green'
: 'volcano'
}
>
{label}
</Tag>
</Tooltip>
);
})}
<Button
style={tagPlusStyle}
icon={<PlusOutlined />}
onClick={() => setIsConditionModalOpen(true)}
>
<FormattedMessage
id="banzone.condition.add"
defaultMessage="Thêm điều kiện"
/>
</Button>
</Flex>
</Form.Item>
<GeometryForm shape={shape} form={formRef} />
<AddConditionForm
isVisible={isConditionModalOpen}
setVisible={setIsConditionModalOpen}
initialData={conditionData}
onFinish={(newConditions) => {
try {
formRef.current?.setFieldValue(
ZoneFormField.AreaConditions,
newConditions,
);
} catch (e) {
console.error('Error setting form value:', e);
}
}}
/>
</>
);
};
export default ZoneForm;

View File

@@ -0,0 +1 @@
export { useMapGeometrySync } from './useMapGeometrySync';

View File

@@ -0,0 +1,304 @@
import { BaseMap, ZoneData } from '@/pages/Slave/SGW/Map/type';
import {
getAreaFromRadius,
getCircleRadius,
} from '@/utils/slave/sgw/geomUtils';
import { type ProFormInstance } from '@ant-design/pro-components';
import { Feature } from 'ol';
import { MutableRefObject, useEffect, useRef } from 'react';
import type { ZoneFormData } from '../type';
import { GeometryType, ZoneFormField } from '../type';
// Default zone data for drawing features
const DEFAULT_ZONE_DATA: ZoneData = { type: 'default' };
interface DrawnFeatureData {
type: string;
coordinates: any;
feature: any;
}
interface MapGeometrySyncOptions {
baseMap: MutableRefObject<BaseMap | null>;
dataLayerId: string;
form: MutableRefObject<ProFormInstance<ZoneFormData> | undefined>;
shape?: number;
enabled?: boolean;
}
/**
* Custom hook to sync geometry between Map and Form
* - Draw on Map -> Update Form
* - Modify on Map -> Update Form
* - Form changes -> Update Map (optional)
*/
export const useMapGeometrySync = (options: MapGeometrySyncOptions) => {
const { baseMap, dataLayerId, form, enabled = true } = options;
const isHandlingUpdateRef = useRef(false);
useEffect(() => {
if (!baseMap || !enabled) return;
// Handle feature drawn event
const handleFeatureDrawn = (data: DrawnFeatureData) => {
if (isHandlingUpdateRef.current) return;
isHandlingUpdateRef.current = true;
switch (data.type) {
case 'Polygon': {
const currentGeometry =
form.current?.getFieldValue(ZoneFormField.PolygonGeometry) || [];
const polygonId = `polygon_${Date.now()}`; // Tạo unique ID
data.feature.set('polygonId', polygonId); // Sửa: set trực tiếp key-value
const polygonCoords = data.coordinates;
form.current?.setFieldsValue({
[ZoneFormField.PolygonGeometry]: [
...currentGeometry,
{
geometry: polygonCoords[0],
id: polygonId,
},
],
});
break;
}
case 'LineString': {
baseMap.current?.clearFeatures(dataLayerId);
const lineCoords = data.coordinates;
form.current?.setFieldsValue({
[ZoneFormField.PolylineData]: JSON.stringify(lineCoords),
});
break;
}
case 'Circle': {
baseMap.current?.clearFeatures(dataLayerId);
const circleData = data.coordinates;
console.log('Circle Data: ', circleData);
form.current?.setFieldsValue({
[ZoneFormField.CircleData]: {
center: circleData.center,
area: getAreaFromRadius(circleData.radius),
},
});
break;
}
case 'Point':
// Point is stored as circle with small radius
// form?.setFieldsValue({
// [ZoneFormField.CircleData]: {
// center: data.coordinates,
// radius: 100, // Default radius for point
// },
// [ZoneFormField.Radius]: 100,
// });
break;
}
setTimeout(() => {
isHandlingUpdateRef.current = false;
}, 100);
};
// Handle feature modified event
const handleFeatureModified = (data: {
feature: any;
coordinates: any;
}) => {
if (isHandlingUpdateRef.current) return;
isHandlingUpdateRef.current = true;
const geometry = data.feature.getGeometry();
if (!geometry) {
isHandlingUpdateRef.current = false;
return;
}
const geomType = geometry.getType();
switch (geomType) {
case 'Polygon': {
const polygonId = data.feature.get('polygonId');
if (polygonId) {
const currentGeometry =
form.current?.getFieldValue(ZoneFormField.PolygonGeometry) || [];
const modifiedCoords = data.coordinates[0];
const updatedGeometry = currentGeometry.map((item: any) =>
item.id === polygonId
? { ...item, geometry: modifiedCoords }
: item,
);
form.current?.setFieldsValue({
[ZoneFormField.PolygonGeometry]: updatedGeometry,
});
}
break;
}
case 'LineString': {
form.current?.setFieldsValue({
[ZoneFormField.PolylineData]: JSON.stringify(data.coordinates),
});
break;
}
case 'Circle': {
form.current?.setFieldsValue({
[ZoneFormField.CircleData]: {
center: data.coordinates.center,
// radius: data.coordinates.radius,
area: getAreaFromRadius(data.coordinates.radius),
},
});
break;
}
}
setTimeout(() => {
isHandlingUpdateRef.current = false;
}, 100);
};
// Register event listeners
baseMap.current?.onFeatureDrawn(handleFeatureDrawn);
baseMap.current?.onFeatureModified(handleFeatureModified);
// Cleanup
return () => {
// Note: BaseMap doesn't have off method, so events will remain
// This is acceptable as the component unmounts
};
}, [baseMap, enabled]);
/**
* Update map display based on current form data
*/
const updateMapFromForm = (type: GeometryType) => {
if (!baseMap || !form) return;
const formCurrent = form.current;
let geometryData;
if (type === GeometryType.POLYGON) {
geometryData =
formCurrent?.getFieldValue(ZoneFormField.PolygonGeometry) || [];
} else if (type === GeometryType.LINESTRING) {
const polylineString =
formCurrent?.getFieldValue(ZoneFormField.PolylineData) || [];
geometryData = JSON.parse(polylineString);
} else if (type === GeometryType.CIRCLE) {
geometryData = formCurrent?.getFieldValue(ZoneFormField.CircleData)
? [formCurrent?.getFieldValue(ZoneFormField.CircleData)]
: [];
}
if (!geometryData || geometryData.length === 0) {
baseMap.current?.clearFeatures(dataLayerId);
return;
}
// Nếu đang xử lý event từ Map (vẽ/sửa), KHÔNG clear và vẽ lại
// vì Draw interaction đã add feature vào map sẵn rồi
if (isHandlingUpdateRef.current) {
return;
}
// Clear existing features trước khi vẽ lại (chỉ khi không đang handle map event)
baseMap.current?.clearFeatures(dataLayerId);
switch (type) {
case GeometryType.POLYGON:
{
const features: Feature[] = [];
geometryData.forEach((geom: any) => {
const feature = baseMap.current?.addPolygon(
dataLayerId,
[geom.geometry],
DEFAULT_ZONE_DATA,
geom.id,
);
features.push(feature!);
});
baseMap.current?.zoomToFeatures(features);
}
break;
case GeometryType.LINESTRING:
{
const feature = baseMap.current?.addPolyline(
dataLayerId,
geometryData,
DEFAULT_ZONE_DATA,
);
baseMap.current?.zoomToFeatures([feature!]);
}
break;
case GeometryType.CIRCLE:
{
const feature = baseMap.current?.addCircle(
dataLayerId,
geometryData[0].center,
getCircleRadius(geometryData[0].area),
DEFAULT_ZONE_DATA,
);
baseMap.current?.zoomToFeatures([feature!]);
}
break;
}
};
/**
* Clear all drawn features from map
*/
const clearMapFeatures = () => {
if (!baseMap) return;
baseMap.current?.clearFeatures(dataLayerId);
};
/**
* Draw existing geometry on map (for update mode)
*/
const drawExistingGeometry = (geometry: any) => {
if (!baseMap || !geometry) return;
clearMapFeatures();
if (geometry.geom_type === GeometryType.POLYGON && geometry.polygons) {
geometry.polygons.forEach((polygonCoords: number[][]) => {
baseMap.current?.addPolygon(
dataLayerId,
[polygonCoords],
DEFAULT_ZONE_DATA,
);
});
} else if (
geometry.geom_type === GeometryType.LINESTRING &&
geometry.coordinates
) {
baseMap.current?.addPolyline(
dataLayerId,
geometry.coordinates,
DEFAULT_ZONE_DATA,
);
} else if (
geometry.geom_type === GeometryType.CIRCLE &&
geometry.center &&
geometry.radius
) {
baseMap.current?.addCircle(
dataLayerId,
geometry.center,
geometry.radius,
DEFAULT_ZONE_DATA,
);
}
};
return {
updateMapFromForm,
clearMapFeatures,
drawExistingGeometry,
};
};
export default useMapGeometrySync;

View File

@@ -0,0 +1,520 @@
import { SGW_ROUTE_BANZONES_LIST } from '@/constants/slave/sgw/routes';
import VietNamMap, {
VietNamMapRef,
} from '@/pages/Slave/SGW/Map/components/VietNamMap';
import { BaseMap, DATA_LAYER } from '@/pages/Slave/SGW/Map/type';
import {
apiCreateBanzone,
apiGetZoneById,
apiUpdateBanzone,
} from '@/services/slave/sgw/ZoneController';
import {
getAreaFromRadius,
getCircleRadius,
} from '@/utils/slave/sgw/geomUtils';
import { DeleteOutlined, EditFilled, GatewayOutlined } from '@ant-design/icons';
import {
PageContainer,
ProCard,
ProForm,
ProFormInstance,
} from '@ant-design/pro-components';
import {
FormattedMessage,
history,
useIntl,
useLocation,
useModel,
useParams,
} from '@umijs/max';
import { Button, Flex, Form, Grid, message } from 'antd';
import VectorLayer from 'ol/layer/Vector';
import VectorSource from 'ol/source/Vector';
import { useCallback, useEffect, useRef, useState } from 'react';
import ZoneForm from './components/ZoneForm';
import { useMapGeometrySync } from './hooks';
import {
AreaCondition,
checkValidateGeometry,
DrawActionType,
formatLineStringWKT,
formatPolygonGeometryToWKT,
GeometryType,
parseLineStringWKT,
parseMultiPolygonWKT,
parsePointWKT,
PolygonGeometry,
ZoneFormData,
ZoneFormField,
ZoneLocationState,
} from './type';
const CreateOrUpdateBanzone = () => {
const location = useLocation() as { state: ZoneLocationState };
const shape = location.state?.shape;
const type = location.state?.type;
const { id: zoneId } = useParams();
const intl = useIntl();
const { useBreakpoint } = Grid;
const screens = useBreakpoint();
const formRef = useRef<ProFormInstance<ZoneFormData>>();
const vietNamMapRef = useRef<VietNamMapRef>(null);
const baseMap = useRef<BaseMap | null>(null);
const drawController = useRef<any>(null);
const [loading, setLoading] = useState(false);
const [formReady, setFormReady] = useState(false);
const { groupMap } = useModel('master.useGroups');
const [mapActions, setMapActions] = useState<DrawActionType>({
isDrawing: false,
isModifying: false,
});
const { clearMapFeatures, updateMapFromForm } = useMapGeometrySync({
baseMap: baseMap,
dataLayerId: DATA_LAYER,
form: formRef,
shape,
enabled: !!baseMap.current,
});
// Handler for back navigation
const handleBack = () => {
history.push(SGW_ROUTE_BANZONES_LIST);
formRef.current?.resetFields();
};
const handleMapReady = useCallback((baseMapInstance: BaseMap) => {
baseMap.current = baseMapInstance;
// Create a vector layer for dynamic features
const vectorDataLayer = new VectorLayer({
source: new VectorSource(),
});
vectorDataLayer.set('id', DATA_LAYER);
baseMapInstance.addLayer(vectorDataLayer);
baseMapInstance.setView([116.152685, 15.70581], 5);
// Initialize draw controller
drawController.current = baseMapInstance.DrawAndModifyFeature(DATA_LAYER);
}, []);
const handleEnableModify = () => {
if (drawController.current) {
setMapActions({
isDrawing: false,
isModifying: true,
});
drawController.current.removeInteractions();
drawController.current.enableModify();
}
};
const handleSubmit = async (values?: ZoneFormData) => {
if (!values) return;
// Validate required fields
if (!values[ZoneFormField.AreaName]) {
message.error(
intl.formatMessage({ id: 'banzone.form.name.error.required' }),
);
return;
}
if (!values[ZoneFormField.AreaType]) {
message.error(
intl.formatMessage({ id: 'banzone.form.name.area_type.required' }),
);
return;
}
if (!values[ZoneFormField.AreaId]) {
message.error(
intl.formatMessage({ id: 'banzone.form.name.province.required' }),
);
return;
}
const lineStringData = values[ZoneFormField.PolylineData];
const polygonData = values[ZoneFormField.PolygonGeometry];
const circleData = values[ZoneFormField.CircleData];
if (!lineStringData && !polygonData && !circleData) {
message.error(
intl.formatMessage({ id: 'banzone.form.name.geometry.required' }),
);
return;
}
let geometryJson = '';
// // Convert geometry to API format
switch (shape) {
case GeometryType.POLYGON: {
const polygonWKT: string = formatPolygonGeometryToWKT(
polygonData || [],
);
geometryJson = JSON.stringify({
geom_type: GeometryType.POLYGON,
geom_poly: polygonWKT,
geom_lines: '',
geom_point: '',
geom_radius: 0,
});
break;
}
case GeometryType.LINESTRING: {
const polylineData = JSON.parse(lineStringData || '[]');
const polylineWKT: string = formatLineStringWKT(polylineData);
geometryJson = JSON.stringify({
geom_type: GeometryType.LINESTRING,
geom_poly: '',
geom_lines: polylineWKT,
geom_point: '',
geom_radius: 0,
});
break;
}
case GeometryType.CIRCLE: {
// For circle, API expects radius in hecta, convert from meters
const radiusInMetter = getCircleRadius(circleData?.area || 0);
const pointWKT = `POINT(${circleData?.center[0] || 0} ${
circleData?.center[1] || 0
})`;
geometryJson = JSON.stringify({
geom_type: GeometryType.CIRCLE,
geom_poly: '',
geom_lines: '',
geom_point: pointWKT,
geom_radius: radiusInMetter,
});
break;
}
default:
message.error('Loại hình học không hợp lệ!');
return;
}
const groupId = values[ZoneFormField.AreaId];
const provinceCode = Array.isArray(groupId)
? groupId.map((id) => groupMap[id]?.code || '').join(',')
: groupMap[groupId as string]?.code || '';
// // Prepare request body
const requestBody: SgwModel.ZoneBodyRequest = {
name: values[ZoneFormField.AreaName],
type: values[ZoneFormField.AreaType],
group_id: groupId as string,
province_code: provinceCode,
enabled: values[ZoneFormField.AreaEnabled] ?? true,
description: values[ZoneFormField.AreaDescription],
conditions: values[ZoneFormField.AreaConditions] as SgwModel.Condition[],
geom: geometryJson,
};
console.log('Submit body:', requestBody);
try {
setLoading(true);
if (type === 'create') {
const key = 'create';
message.open({
key,
type: 'loading',
content: intl.formatMessage({ id: 'banzone.creating' }),
});
await apiCreateBanzone(requestBody);
message.open({
key,
type: 'success',
content: intl.formatMessage({ id: 'banzone.creating_success' }),
});
} else if (type === 'update' && zoneId) {
const key = 'update';
message.open({
key,
type: 'loading',
content: intl.formatMessage({ id: 'banzone.updating' }),
});
await apiUpdateBanzone(zoneId, requestBody);
message.open({
key,
type: 'success',
content: intl.formatMessage({ id: 'banzone.updating_success' }),
});
} else {
message.error(
type === 'update'
? intl.formatMessage({ id: 'banzone.updating_fail' })
: intl.formatMessage({ id: 'banzone.creating_fail' }),
);
return;
}
setTimeout(() => {
handleBack();
}, 1000);
} catch (error) {
console.error('Failed to save zone:', error);
message.error(intl.formatMessage({ id: 'banzone.fail.save' }));
} finally {
setLoading(false);
}
};
const handleOnClickDraw = () => {
drawController.current = baseMap.current?.DrawAndModifyFeature(DATA_LAYER);
setMapActions({
isDrawing: true,
isModifying: false,
});
drawController.current.removeInteractions();
switch (shape) {
case 1:
drawController.current.drawPolygon();
break;
case 2:
drawController.current.drawLineString();
break;
case 3:
drawController.current.drawCircle();
break;
case 4:
drawController.current.drawPoint();
break;
default:
break;
}
};
const polygonGeometry =
(Form.useWatch(
ZoneFormField.PolygonGeometry,
formReady && formRef.current ? formRef.current : undefined,
) as PolygonGeometry[]) || [];
const polylineData = Form.useWatch(
ZoneFormField.PolylineData,
formReady && formRef.current ? formRef.current : undefined,
);
const circleData = Form.useWatch(
ZoneFormField.CircleData,
formReady && formRef.current ? formRef.current : undefined,
);
useEffect(() => {
if (polygonGeometry && polygonGeometry.length > 0) {
updateMapFromForm(GeometryType.POLYGON);
}
}, [polygonGeometry, formRef]);
useEffect(() => {
if (polylineData && checkValidateGeometry(polylineData)) {
updateMapFromForm(GeometryType.LINESTRING);
}
}, [polylineData, formRef]);
useEffect(() => {
if (circleData) {
updateMapFromForm(GeometryType.CIRCLE);
}
}, [circleData, formRef]);
return (
<PageContainer
onBack={handleBack}
title={
type === 'create' ? (
<FormattedMessage id="banzones.create" />
) : (
<FormattedMessage id="banzones.update" />
)
}
style={{
padding: 10,
}}
>
<ProCard split={screens.md ? 'vertical' : 'horizontal'}>
<ProCard colSpan={{ xs: 24, sm: 24, md: 10, lg: 10, xl: 10 }}>
<ProForm<ZoneFormData>
formRef={formRef}
layout="vertical"
onFinish={handleSubmit}
onInit={(_, form) => {
formRef.current = form;
setFormReady(true);
}}
initialValues={{
[ZoneFormField.AreaEnabled]: true,
}}
request={async () => {
if (type === 'update' && zoneId) {
try {
const zone: SgwModel.Banzone = await apiGetZoneById(zoneId);
if (!zone) return {} as ZoneFormData;
// Parse geometry (API may use `geom` or `geometry` field)
let parsedGeometry: SgwModel.Geom | undefined;
const geomRaw = (zone as any).geom ?? (zone as any).geometry;
if (geomRaw) {
try {
parsedGeometry =
typeof geomRaw === 'string'
? JSON.parse(geomRaw)
: geomRaw;
} catch (e) {
console.warn('Failed to parse geometry', e);
}
}
const groupId = Object.entries(groupMap).find(
([, value]) => value.code === zone.province_code,
)?.[0] as string | undefined;
// Build base form data
const formData: Partial<ZoneFormData> = {
[ZoneFormField.AreaName]: zone.name || '',
[ZoneFormField.AreaType]: zone.type ?? 1,
[ZoneFormField.AreaEnabled]: zone.enabled ?? true,
[ZoneFormField.AreaDescription]: zone.description || '',
[ZoneFormField.AreaId]: groupId || '',
[ZoneFormField.AreaConditions]:
zone.conditions as AreaCondition[],
};
// Map geometry to form fields depending on geometry type
if (parsedGeometry) {
switch (parsedGeometry.geom_type) {
case GeometryType.POLYGON: {
const polygons = parseMultiPolygonWKT(
parsedGeometry.geom_poly || '',
);
formData[ZoneFormField.PolygonGeometry] = polygons.map(
(polygon) => ({
geometry: polygon,
id: Math.random().toString(36).substring(2, 9),
}),
);
break;
}
case GeometryType.LINESTRING: {
const polyline = parseLineStringWKT(
parsedGeometry.geom_lines || '',
);
formData[ZoneFormField.PolylineData] =
JSON.stringify(polyline);
break;
}
case GeometryType.CIRCLE: {
const center = parsePointWKT(
parsedGeometry.geom_point || '',
);
formData[ZoneFormField.CircleData] = {
center: center || [0, 0],
radius: parsedGeometry.geom_radius || 0,
area: getAreaFromRadius(
parsedGeometry.geom_radius || 0,
),
};
break;
}
default:
break;
}
}
return formData as ZoneFormData;
} catch (error) {
console.error('Failed to load zone for request:', error);
return {} as ZoneFormData;
}
}
return {} as ZoneFormData;
}}
submitter={{
searchConfig: {
submitText:
type === 'create'
? intl.formatMessage({ id: 'banzone.create.button.title' })
: intl.formatMessage({ id: 'banzone.update.button.title' }),
},
submitButtonProps: {
loading: loading,
},
render: (_, dom) => {
return (
<Flex
gap={8}
justify="center"
style={{ marginTop: 24, marginBottom: 24 }}
>
{dom}
</Flex>
);
},
}}
>
<ZoneForm formRef={formRef} shape={shape} />
<Flex align="center" gap={8} justify="end">
<Button
onClick={handleOnClickDraw}
variant={`${mapActions.isDrawing ? 'solid' : 'dashed'}`}
color={`${mapActions.isDrawing ? 'green' : 'default'}`}
icon={<GatewayOutlined />}
>
<FormattedMessage
id="banzone.map_action.draw"
defaultMessage="Draw"
/>
</Button>
<Button
onClick={handleEnableModify}
variant={`${mapActions.isModifying ? 'solid' : 'dashed'}`}
color={`${mapActions.isModifying ? 'green' : 'default'}`}
icon={<EditFilled />}
>
<FormattedMessage
id="banzone.map_action.modify"
defaultMessage="Modify"
/>
</Button>
<Button
danger
type="primary"
icon={<DeleteOutlined />}
onClick={() => {
clearMapFeatures();
setMapActions({
isDrawing: false,
isModifying: false,
});
switch (shape) {
case GeometryType.POLYGON:
formRef.current?.setFieldValue(
ZoneFormField.PolygonGeometry,
[],
);
break;
case GeometryType.LINESTRING:
formRef.current?.setFieldValue(
ZoneFormField.PolylineData,
'',
);
break;
case GeometryType.CIRCLE:
formRef.current?.setFieldValue(
ZoneFormField.CircleData,
undefined,
);
break;
default:
break;
}
}}
></Button>
</Flex>
</ProForm>
</ProCard>
<ProCard ghost colSpan={{ xs: 24, sm: 24, md: 14, lg: 14, xl: 14 }}>
<VietNamMap
ref={vietNamMapRef}
style={{
maxWidth: '100%',
height: '100%',
width: '100%',
}}
onMapReady={handleMapReady}
/>
</ProCard>
</ProCard>
</PageContainer>
);
};
export default CreateOrUpdateBanzone;

View File

@@ -0,0 +1,216 @@
export enum ZoneFormField {
AreaName = 'name',
AreaType = 'type',
AreaId = 'id',
AreaEnabled = 'enabled',
AreaDescription = 'description',
AreaConditions = 'conditions',
AreaProvinceCode = 'province_code',
PolygonGeometry = 'geometry',
PolylineData = 'polyline_data',
CircleData = 'circle_data',
}
export interface ZoneFormData {
[ZoneFormField.AreaName]: string;
[ZoneFormField.AreaType]: number; // API returns number
[ZoneFormField.AreaId]: string | string[] | null;
[ZoneFormField.AreaEnabled]: boolean;
[ZoneFormField.AreaDescription]?: string;
[ZoneFormField.AreaConditions]?: AreaCondition[];
[ZoneFormField.AreaProvinceCode]: string | number; // API returns string
[ZoneFormField.PolygonGeometry]: PolygonGeometry[];
[ZoneFormField.PolylineData]?: string;
[ZoneFormField.CircleData]?: CircleGeometry;
}
type MonthRangeCondition = {
type: 'month_range';
from: number;
to: number;
};
type DateRangeCondition = {
type: 'date_range';
from: string;
to: string;
};
type LengthLimitCondition = {
type: 'length_limit';
min: number;
max: number;
};
export type AreaCondition =
| MonthRangeCondition
| DateRangeCondition
| LengthLimitCondition;
export interface ZoneLocationState {
shape?: number;
type: 'create' | 'update';
}
export const tagPlusStyle = {
height: 22,
// background: "blue",
borderStyle: 'dashed',
};
export type PolygonGeometry = {
geometry: number[][];
id?: string;
};
export type CircleGeometry = {
center: [number, number];
radius: number;
area?: number;
};
// Geometry type mapping with API
export enum GeometryType {
POLYGON = 1,
LINESTRING = 2,
CIRCLE = 3,
}
export interface DrawActionType {
isDrawing: boolean;
isModifying: boolean;
}
// Parse MULTIPOINT/WKT for polygon
export const parseMultiPolygonWKT = (wktString: string): number[][][] => {
if (
!wktString ||
typeof wktString !== 'string' ||
!wktString.startsWith('MULTIPOLYGON')
) {
return [];
}
const matched = wktString.match(/MULTIPOLYGON\s*\(\(\((.*)\)\)\)/);
if (!matched) return [];
const polygons = matched[1]
.split(')),((') // chia các polygon
.map((polygonStr) =>
polygonStr
.trim()
.split(',')
.map((coordStr) => {
const [x, y] = coordStr.trim().split(' ').map(Number);
return [x, y];
}),
);
return polygons;
};
// // Parse LINESTRING WKT
export const parseLineStringWKT = (wkt: string): number[][] => {
if (!wkt || !wkt.startsWith('LINESTRING')) return [];
const match = wkt.match(/LINESTRING\s*\((.*)\)/);
if (!match) return [];
return match[1].split(',').map((coordStr) => {
const [x, y] = coordStr.trim().split(' ').map(Number);
return [x, y]; // [lng, lat]
});
};
// // Parse POINT WKT
export const parsePointWKT = (wkt: string): [number, number] | null => {
if (!wkt || !wkt.startsWith('POINT')) return null;
const match = wkt.match(/POINT\s*\(([-\d.]+)\s+([-\d.]+)\)/);
if (!match) return null;
return [parseFloat(match[1]), parseFloat(match[2])]; // [lng, lat]
};
// Format coordinates array to WKT LINESTRING
export const formatLineStringWKT = (coordinates: number[][]): string => {
const coordStr = coordinates.map((c) => `${c[0]} ${c[1]}`).join(',');
return `LINESTRING(${coordStr})`;
};
// Format AreaGeometry[] to WKT MULTIPOLYGON string
export const formatPolygonGeometryToWKT = (
geometries: PolygonGeometry[],
): string => {
const polygons = geometries.map((g) => g.geometry as number[][]);
if (polygons.length === 0) return '';
const polygonStrs = polygons.map((polygon) => {
const coordStr = polygon.map((c) => `${c[0]} ${c[1]}`).join(',');
return `(${coordStr})`;
});
return `MULTIPOLYGON((${polygonStrs.join('),(')}))`;
};
export const validateGeometry = (value: any) => {
if (!value || typeof value !== 'string') {
return Promise.reject('Dữ liệu không hợp lệ!');
}
try {
const text = value.trim();
const formattedText = text.startsWith('[[') ? text : `[${text}]`;
const data = JSON.parse(formattedText);
if (!Array.isArray(data)) {
return Promise.reject('Dữ liệu không phải mảng!');
}
for (const item of data) {
if (
!Array.isArray(item) ||
item.length !== 2 ||
typeof item[0] !== 'number' ||
typeof item[1] !== 'number'
) {
return Promise.reject(
'Mỗi dòng phải là [longitude, latitude] với số thực!',
);
}
}
return Promise.resolve();
} catch (e) {
return Promise.reject('Định dạng JSON không hợp lệ!');
}
};
export const checkValidateGeometry = (value: string) => {
if (!value || typeof value !== 'string') {
return false;
}
try {
const text = value.trim();
const formattedText = text.startsWith('[[') ? text : `[${text}]`;
const data = JSON.parse(formattedText);
if (!Array.isArray(data)) {
return false;
}
for (const item of data) {
if (
!Array.isArray(item) ||
item.length !== 2 ||
typeof item[0] !== 'number' ||
typeof item[1] !== 'number'
) {
return false;
}
}
return true;
} catch (e) {
return false;
}
};

View File

@@ -1,11 +1,644 @@
import React from 'react'; import IconFont from '@/components/IconFont';
import TreeGroup from '@/components/shared/TreeGroup';
import { DEFAULT_PAGE_SIZE } from '@/const';
import {
SGW_ROUTE_BANZONES,
SGW_ROUTE_BANZONES_LIST,
} from '@/constants/slave/sgw/routes';
import {
apiGetAllBanzones,
apiRemoveBanzone,
} from '@/services/slave/sgw/ZoneController';
import { flattenGroupNodes } from '@/utils/slave/sgw/groupUtils';
import { formatDate } from '@/utils/slave/sgw/timeUtils';
import { DeleteOutlined, DownOutlined, EditOutlined } from '@ant-design/icons';
import {
ActionType,
ProCard,
ProColumns,
ProTable,
} from '@ant-design/pro-components';
import { FormattedMessage, history, useIntl, useModel } from '@umijs/max';
import {
Button,
Dropdown,
Flex,
Grid,
message,
Popconfirm,
Space,
Tag,
Tooltip,
Typography,
} from 'antd';
import { MenuProps } from 'antd/lib';
import { useEffect, useRef, useState } from 'react';
const { Paragraph, Text } = Typography;
const BanZoneList = () => {
const { useBreakpoint } = Grid;
const intl = useIntl();
const screens = useBreakpoint();
const tableRef = useRef<ActionType>();
const [groupCheckedKeys, setGroupCheckedKeys] = useState<string>('');
const { groups, getGroups } = useModel('master.useGroups');
const groupFlattened = flattenGroupNodes(groups || []);
const [messageApi, contextHolder] = message.useMessage();
const [selectedRowsState, setSelectedRows] = useState<SgwModel.Banzone[]>([]);
useEffect(() => {
if (groups === null) {
getGroups();
}
}, [groups]);
// Reload table khi groups được load
useEffect(() => {
if (groups && groups.length > 0 && tableRef.current) {
tableRef.current.reload();
}
}, [groups]);
const handleEdit = (record: SgwModel.Banzone) => {
console.log('record: ', record);
let geomType = 1; // Default: Polygon
try {
if (record.geometry) {
const geometry: SgwModel.Geom = JSON.parse(record.geometry);
geomType = geometry.geom_type || 1;
}
} catch (e) {
console.error('Failed to parse geometry:', e);
}
history.push(`${SGW_ROUTE_BANZONES_LIST}/${record.id}`, {
type: 'update',
shape: geomType,
});
};
const handleDelete = async (record: SgwModel.Banzone) => {
try {
const groupID = groupFlattened.find(
(m) => m.metadata.code === record.province_code,
)?.id;
await apiRemoveBanzone(record.id || '', groupID || '');
messageApi.success(
intl.formatMessage({
id: 'banzone.notify.delete_zone_success',
defaultMessage: 'Zone deleted successfully',
}),
);
// Reload lại bảng
if (tableRef.current) {
tableRef.current.reload();
}
} catch (error) {
console.error('Error deleting area:', error);
messageApi.error(
intl.formatMessage({
id: 'banzone.notify.fail',
defaultMessage: 'Delete zone failed!',
}),
);
}
};
const columns: ProColumns<SgwModel.Banzone>[] = [
{
key: 'name',
title: <FormattedMessage id="banzones.name" defaultMessage="Name" />,
dataIndex: 'name',
render: (_, record) => (
<div
style={{
whiteSpace: 'normal',
wordBreak: 'break-word',
}}
>
<Paragraph
copyable
style={{ margin: 0 }}
ellipsis={{ rows: 999, tooltip: record?.name }}
>
{record?.name}
</Paragraph>
</div>
),
width: '15%',
},
{
key: 'group',
title: <FormattedMessage id="banzones.area" defaultMessage="Province" />,
dataIndex: 'province_code',
hideInSearch: true,
responsive: ['lg', 'md'],
ellipsis: true,
render: (_, record) => {
const matchedMember =
groupFlattened.find(
(group) => group.metadata.code === record.province_code,
) ?? null;
return (
<Text ellipsis={{ tooltip: matchedMember?.name || '-' }}>
{matchedMember?.name || '-'}
</Text>
);
},
width: '15%',
},
{
key: 'description',
title: (
<FormattedMessage id="banzones.description" defaultMessage="Mô tả" />
),
dataIndex: 'description',
hideInSearch: true,
render: (_, record) => (
<Paragraph
ellipsis={{ rows: 2, tooltip: record?.description }}
style={{
margin: 0,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{record?.description || '-'}
</Paragraph>
),
width: '15%',
},
{
key: 'type',
title: <FormattedMessage id="banzones.type" defaultMessage="Loại" />,
dataIndex: 'type',
valueType: 'select',
fieldProps: {
options: [
{
label: intl.formatMessage({
id: 'banzone.area.fishing_ban',
defaultMessage: 'Fishing Ban',
}),
value: 1,
},
{
label: intl.formatMessage({
id: 'banzone.area.move_ban',
defaultMessage: 'Movement Ban',
}),
value: 2,
},
{
label: intl.formatMessage({
id: 'banzone.area.safe',
defaultMessage: 'Safe Area',
}),
value: 3,
},
],
},
render: (_, record) => (
<Tag color={record.type === 1 ? '#f50' : 'orange'}>
{record.type === 1
? intl.formatMessage({
id: 'banzone.area.fishing_ban',
defaultMessage: 'Fishing Ban',
})
: intl.formatMessage({
id: 'banzone.area.move_ban',
defaultMessage: 'Movement Ban',
})}
</Tag>
),
width: 120,
},
{
key: 'conditions',
title: (
<FormattedMessage id="banzones.conditions" defaultMessage="Điều kiện" />
),
dataIndex: 'conditions',
hideInSearch: true,
render: (conditions) => {
if (!Array.isArray(conditions)) return null;
return (
<Space direction="vertical" size={4}>
{conditions.map((cond, index) => {
switch (cond.type) {
case 'month_range':
return (
<Tooltip
key={index}
title={`Áp dụng từ tháng ${cond.from} đến tháng ${cond.to} hàng năm`}
>
<Tag
color="geekblue"
style={{ borderRadius: 8, margin: 0 }}
>
Th.{cond.from} - Th.{cond.to}
</Tag>
</Tooltip>
);
case 'date_range':
return (
<Tooltip
key={index}
title={`Áp dụng từ ${formatDate(
cond.from,
)} đến ${formatDate(cond.to)}`}
>
<Tag color="green" style={{ borderRadius: 8, margin: 0 }}>
{formatDate(cond.from)} {formatDate(cond.to)}
</Tag>
</Tooltip>
);
case 'length_limit':
return (
<Tooltip
key={index}
title={`Tàu từ ${cond.min} đến ${cond.max} mét`}
>
<Tag color="cyan" style={{ borderRadius: 8, margin: 0 }}>
{cond.min}-{cond.max}m
</Tag>
</Tooltip>
);
default:
return null;
}
})}
</Space>
);
},
width: 180,
},
{
key: 'enabled',
title: (
<FormattedMessage id="banzones.state" defaultMessage="Trạng thái" />
),
dataIndex: 'enabled',
valueType: 'select',
fieldProps: {
options: [
{
label: intl.formatMessage({
id: 'banzone.is_enable',
defaultMessage: 'Enabled',
}),
value: true,
},
{
label: intl.formatMessage({
id: 'banzone.is_unenabled',
defaultMessage: 'Disabled',
}),
value: false,
},
],
},
hideInSearch: false,
responsive: ['lg', 'md'],
render: (_, record) => {
return (
<Tag color={record.enabled === true ? '#08CB00' : '#DCDCDC'}>
{record.enabled === true
? intl.formatMessage({
id: 'banzone.is_enable',
defaultMessage: 'Enabled',
})
: intl.formatMessage({
id: 'banzone.is_unenabled',
defaultMessage: 'Disabled',
})}
</Tag>
);
},
width: 120,
},
{
title: <FormattedMessage id="banzones.action" defaultMessage="Action" />,
hideInSearch: true,
width: 120,
fixed: 'right',
render: (_, record) => [
<Space key="actions">
<Button
key="edit"
type="primary"
icon={<EditOutlined />}
size="small"
onClick={() => handleEdit(record)}
></Button>
<Popconfirm
key="delete"
title={intl.formatMessage({
id: 'common.delete_confirm',
defaultMessage: 'Confirm delete?',
})}
description={`${intl.formatMessage({
id: 'banzone.notify.delete_zone_confirm',
defaultMessage: 'Are you sure you want to delete this zone',
})} "${record.name}"?`}
onConfirm={() => handleDelete(record)}
okText={intl.formatMessage({
id: 'common.delete',
defaultMessage: 'Delete',
})}
cancelText={intl.formatMessage({
id: 'common.cancel',
defaultMessage: 'Cancel',
})}
okType="danger"
>
<Button
type="primary"
danger
icon={<DeleteOutlined />}
size="small"
></Button>
</Popconfirm>
</Space>,
],
},
];
const items: Required<MenuProps>['items'] = [
{
label: intl.formatMessage({
id: 'banzone.polygon',
defaultMessage: 'Polygon',
}),
onClick: () => {
history.push(SGW_ROUTE_BANZONES, {
shape: 1,
type: 'create',
});
},
key: '0',
icon: <IconFont type="icon-polygon" />,
},
{
label: intl.formatMessage({
id: 'banzone.polyline',
defaultMessage: 'Polyline',
}),
key: '1',
onClick: () => {
history.push(SGW_ROUTE_BANZONES, {
shape: 2,
type: 'create',
});
},
icon: <IconFont type="icon-polyline" />,
},
{
label: intl.formatMessage({
id: 'banzone.circle',
defaultMessage: 'Circle',
}),
key: '3',
onClick: () => {
history.push(SGW_ROUTE_BANZONES, {
shape: 3,
type: 'create',
});
},
icon: <IconFont type="icon-circle" />,
},
];
const deleteMultipleBanzones = async (records: SgwModel.Banzone[]) => {
const key = 'deleteMultiple';
messageApi.open({
key,
type: 'loading',
content: intl.formatMessage({
id: 'common.deleting',
defaultMessage: 'Deleting...',
}),
duration: 0,
});
try {
for (const record of records) {
const groupID = groupFlattened.find(
(m) => m.metadata.code === record.province_code,
)?.id;
await apiRemoveBanzone(record.id || '', groupID || '');
}
messageApi.open({
key,
type: 'success',
content: `Đã xoá thành công ${records.length} khu vực`,
duration: 2,
});
tableRef.current?.reload();
} catch (error) {
console.error('Error deleting area:', error);
messageApi.open({
key,
type: 'error',
content: intl.formatMessage({
id: 'banzone.notify.fail',
defaultMessage: 'Delete zone failed!',
}),
duration: 2,
});
}
};
const SGWArea: React.FC = () => {
return ( return (
<div> <>
<h1>Khu vực (SGW Manager)</h1> {contextHolder}
</div> <ProCard split={screens.md ? 'vertical' : 'horizontal'}>
<ProCard colSpan={{ xs: 24, sm: 24, md: 4, lg: 4, xl: 4 }}>
<TreeGroup
multiple
onSelected={(value) => {
// Convert group IDs to province codes string
const selectedIds = Array.isArray(value)
? value
: value
? [value]
: [];
const provinceCodes =
selectedIds.length > 0
? selectedIds
.reduce((codes: string[], id) => {
const group = groupFlattened.find((g) => g.id === id);
if (group?.metadata?.code) {
codes.push(group.metadata.code);
}
return codes;
}, [])
.join(',')
: '';
setGroupCheckedKeys(provinceCodes);
tableRef.current?.reload();
}}
/>
</ProCard>
<ProCard colSpan={{ xs: 24, sm: 24, md: 20, lg: 20, xl: 20 }}>
<ProTable<SgwModel.Banzone>
tableLayout="fixed"
scroll={{ x: 1000 }}
actionRef={tableRef}
columns={columns}
pagination={{
defaultPageSize: DEFAULT_PAGE_SIZE,
showSizeChanger: true,
pageSizeOptions: ['5', '10', '15', '20'],
showTotal: (total, range) =>
`${range[0]}-${range[1]}
${intl.formatMessage({
id: 'common.of',
defaultMessage: 'of',
})}
${total} ${intl.formatMessage({
id: 'banzones.title',
defaultMessage: 'zones',
})}`,
}}
request={async (params) => {
const { current, pageSize, name, type, enabled } = params;
// Nếu chưa có groups, đợi
if (!groups || groups.length === 0) {
return {
success: true,
data: [],
total: 0,
};
}
const offset = current === 1 ? 0 : (current! - 1) * pageSize!;
const groupFalttened = flattenGroupNodes(groups || []);
const groupId =
groupCheckedKeys ||
groupFalttened
.map((group) => group.metadata.code)
.filter(Boolean)
.join(',') + ',';
if (!groupId || groupId === ',') {
return {
success: true,
data: [],
total: 0,
};
}
const body: SgwModel.SearchZonePaginationBody = {
name: name || '',
order: 'name',
dir: 'asc',
limit: pageSize,
offset: offset,
metadata: {
province_code: groupId,
...(type ? { type: Number(type) } : {}), // nếu có type thì thêm vào
...(enabled !== undefined ? { enabled } : {}),
},
};
try {
const resp = await apiGetAllBanzones(body);
return {
success: true,
data: resp?.banzones || [],
total: resp?.total || 0,
};
} catch (error) {
console.error('Query banzones failed:', error);
return {
success: true,
data: [],
total: 0,
};
}
}}
rowKey="id"
options={{
search: false,
setting: false,
density: false,
reload: true,
}}
search={{
layout: 'vertical',
defaultCollapsed: false,
}}
dateFormatter="string"
rowSelection={{
selectedRowKeys: selectedRowsState?.map((row) => row?.id ?? ''),
onChange: (_, selectedRows) => {
setSelectedRows(selectedRows);
},
}}
tableAlertRender={({ selectedRowKeys }) => (
<div>Đã chọn {selectedRowKeys.length} mục</div>
)}
tableAlertOptionRender={({ selectedRows, onCleanSelected }) => {
return (
<Flex gap={5}>
<Popconfirm
title={intl.formatMessage({
id: 'common.notification',
defaultMessage: 'Thông báo',
})}
description={`Bạn muốn xoá hết ${selectedRows.length} khu vực này?`}
onConfirm={() => {
deleteMultipleBanzones(selectedRows);
}}
okText={intl.formatMessage({
id: 'common.sure',
defaultMessage: 'Chắc chắn',
})}
cancelText={intl.formatMessage({
id: 'common.no',
defaultMessage: 'Không',
})}
>
<Button type="primary" danger>
{intl.formatMessage({
id: 'common.delete',
defaultMessage: 'Xóa',
})}
</Button>
</Popconfirm>
<Button color="cyan" variant="text" onClick={onCleanSelected}>
{intl.formatMessage({
id: 'common.cancel',
defaultMessage: 'Bỏ chọn',
})}
</Button>
</Flex>
);
}}
toolBarRender={() => [
<Dropdown
menu={{ items }}
trigger={['click']}
key="toolbar-dropdown"
>
<Button type="primary">
<Space>
{intl.formatMessage({
id: 'banzones.create',
defaultMessage: 'Tạo khu vực',
})}
<DownOutlined />
</Space>
</Button>
</Dropdown>,
]}
/>
</ProCard>
</ProCard>
</>
); );
}; };
export default SGWArea; export default BanZoneList;

View File

@@ -0,0 +1,399 @@
import { HTTPSTATUS } from '@/constants';
import {
apiCreateFishSpecies,
apiUpdateFishSpecies,
} from '@/services/slave/sgw/FishController';
import {
apiDeletePhoto,
apiUploadPhoto,
} from '@/services/slave/sgw/PhotoController';
import {
ModalForm,
ProForm,
ProFormInstance,
ProFormSelect,
ProFormText,
ProFormTextArea,
ProFormUploadButton,
} from '@ant-design/pro-components';
import { useIntl } from '@umijs/max';
import { MessageInstance } from 'antd/es/message/interface';
import type { UploadFile } from 'antd/es/upload/interface';
import { useRef, useState } from 'react';
export type AddOrUpdateFishProps = {
type: 'create' | 'update';
fish?: SgwModel.Fish;
isOpen?: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
message: MessageInstance;
onReload?: (isSuccess: boolean) => void;
};
const AddOrUpdateFish = ({
type,
fish,
isOpen,
setIsOpen,
message,
onReload,
}: AddOrUpdateFishProps) => {
const formRef = useRef<ProFormInstance<SgwModel.Fish>>();
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [originalFileList, setOriginalFileList] = useState<UploadFile[]>([]);
const intl = useIntl();
// Check ảnh có thay đổi so với ban đầu không
const hasImageChanged = () => {
const currentHasImage = fileList.length > 0;
const originalHasImage = originalFileList.length > 0;
// Nếu số lượng ảnh khác nhau → có thay đổi
if (currentHasImage !== originalHasImage) {
return true;
}
// Nếu cả 2 đều rỗng → không thay đổi
if (!currentHasImage) {
return false;
}
// Nếu có ảnh, check xem file có phải là file mới upload không
// (file gốc có uid = '-1', file mới upload có uid khác)
const currentFile = fileList[0];
const isOriginalImage =
currentFile.uid === '-1' && currentFile.status === 'done';
return !isOriginalImage;
};
return (
<ModalForm<SgwModel.Fish>
key={fish?.id || 'new'}
open={isOpen}
formRef={formRef}
title={
type === 'create'
? intl.formatMessage({
id: 'fish.create.title',
defaultMessage: 'Thêm cá mới',
})
: intl.formatMessage({
id: 'fish.update.title',
defaultMessage: 'Cập nhật cá',
})
}
onOpenChange={setIsOpen}
layout="vertical"
modalProps={{
destroyOnHidden: true,
}}
request={async () => {
if (type === 'update' && fish) {
return fish;
}
setFileList([]);
setOriginalFileList([]);
return {};
}}
onFinish={async (values) => {
// 1. Cập nhật thông tin cá
if (type === 'create') {
// TODO: Gọi API tạo cá mới
// const result = await apiCreateFish(values);
console.log('Create fish:', values);
try {
const resp = await apiCreateFishSpecies(values);
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
message.success(
intl.formatMessage({
id: 'fish.create.success',
defaultMessage: 'Tạo cá thành công',
}),
);
onReload?.(true);
const id = resp.data.name_ids![0];
if (fileList.length > 0 && fileList[0].originFileObj && id) {
// TODO: Sau khi có result.id từ API create
// await apiUploadPhoto('fish', result.id, fileList[0].originFileObj);
console.log('Upload photo for new fish');
try {
const resp = await apiUploadPhoto(
'fish',
id.toString(),
fileList[0].originFileObj,
);
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
message.success(
intl.formatMessage({
id: 'fish.create.image.success',
defaultMessage: 'Thêm ảnh cá thành công',
}),
);
onReload?.(true);
} else {
throw new Error('Thêm ảnh thất bại');
}
} catch (error) {
message.error(
intl.formatMessage({
id: 'fish.create.image.fail',
defaultMessage: 'Thêm ảnh cá thất bại',
}),
);
}
}
} else {
throw new Error('Tạo cá thất bại');
}
} catch (error) {
message.error(
intl.formatMessage({
id: 'fish.create.fail',
defaultMessage: 'Tạo cá thất bại',
}),
);
}
// 2. Upload ảnh (nếu có chọn ảnh)
onReload?.(true);
} else {
// TODO: Gọi API cập nhật cá
// await apiUpdateFish(fish!.id!, values);
console.log('Update fish:', fish?.id, values);
// Check nếu dữ liệu có thay đổi so với ban đầu
const hasDataChanged =
fish!.name !== values.name ||
fish!.scientific_name !== values.scientific_name ||
fish!.group_name !== values.group_name ||
fish!.rarity_level !== values.rarity_level ||
fish!.note !== values.note;
if (hasDataChanged) {
try {
const body = { ...values, id: fish!.id! };
const resp = await apiUpdateFishSpecies(body);
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
message.success(
intl.formatMessage({
id: 'fish.update.success',
defaultMessage: 'Cập nhật cá thành công',
}),
);
onReload?.(true);
} else {
throw new Error('Cập nhật cá thất bại');
}
} catch (error) {
message.error(
intl.formatMessage({
id: 'fish.update.fail',
defaultMessage: 'Cập nhật cá thất bại',
}),
);
return true;
}
} else {
console.log('Dữ liệu không thay đổi, bỏ qua API update');
}
// 2. Upload ảnh (chỉ khi ảnh có thay đổi)
if (hasImageChanged()) {
if (fileList.length > 0 && fileList[0].originFileObj) {
// TODO: Upload ảnh mới
// await apiUploadPhoto('fish', fish!.id!, fileList[0].originFileObj);
try {
const resp = await apiUploadPhoto(
'fish',
fish!.id!.toString(),
fileList[0].originFileObj,
);
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
message.success(
intl.formatMessage({
id: 'fish.update.image.success',
defaultMessage: 'Cập nhật ảnh cá thành công',
}),
);
onReload?.(true);
} else {
throw new Error('Cập nhật ảnh thất bại');
}
} catch (error) {
message.error(
intl.formatMessage({
id: 'fish.update.image.fail',
defaultMessage: 'Cập nhật ảnh cá thất bại',
}),
);
return true;
}
} else {
// TODO: Xóa ảnh (nếu có API delete)
console.log('Remove photo');
const resp = await apiDeletePhoto('fish', fish!.id!);
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
message.success(
intl.formatMessage({
id: 'fish.delete.image.success',
defaultMessage: 'Xóa ảnh cá thành công',
}),
);
onReload?.(true);
} else {
message.error(
intl.formatMessage({
id: 'fish.delete.image.fail',
defaultMessage: 'Xóa ảnh cá thất bại',
}),
);
return true;
}
}
}
}
return true;
}}
>
{type === 'create' && (
<div
style={{
display: 'flex',
justifyContent: 'center',
marginBottom: 16,
}}
>
<ProFormUploadButton
name="upload"
label={null}
title="Chọn ảnh"
accept="image/*"
max={1}
transform={(value) => ({ upload: value })}
fieldProps={{
onChange(info) {
setFileList(info.fileList);
},
listType: 'picture-card',
fileList: fileList,
onRemove: () => {
setFileList([]);
},
}}
/>
</div>
)}
<ProForm.Group>
<ProFormText
name="name"
width="md"
label={intl.formatMessage({
id: 'common.name',
defaultMessage: 'Tên',
})}
tooltip={intl.formatMessage({
id: 'fish.name.tooltip',
defaultMessage: 'Tên loài cá',
})}
placeholder={intl.formatMessage({
id: 'fish.name.placeholder',
defaultMessage: 'Nhập tên cá',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'fish.name.required',
defaultMessage: 'Tên cá không được để trống',
}),
},
]}
/>
<ProFormText
name="scientific_name"
width="md"
label={intl.formatMessage({
id: 'fish.specific_name',
defaultMessage: 'Tên khoa học',
})}
placeholder={intl.formatMessage({
id: 'fish.specific_name.placeholder',
defaultMessage: 'Nhập tên khoa học',
})}
/>
</ProForm.Group>
<ProForm.Group>
<ProFormText
name="group_name"
label={intl.formatMessage({
id: 'fish.fish_group',
defaultMessage: 'Nhóm',
})}
width="md"
tooltip={intl.formatMessage({
id: 'fish.fish_group.tooltip',
defaultMessage: 'Nhóm cá',
})}
placeholder={intl.formatMessage({
id: 'fish.fish_group.placeholder',
defaultMessage: 'Nhập nhóm cá',
})}
/>
<ProFormSelect
name="rarity_level"
label={intl.formatMessage({
id: 'fish.rarity',
defaultMessage: 'Độ hiếm',
})}
width="md"
placeholder={intl.formatMessage({
id: 'fish.rarity.placeholder',
defaultMessage: 'Chọn độ hiếm',
})}
options={[
{
label: intl.formatMessage({
id: 'fish.rarity.normal',
defaultMessage: 'Phổ biến',
}),
value: 1,
},
{
label: intl.formatMessage({
id: 'fish.rarity.sensitive',
defaultMessage: 'Dễ bị tổn thương',
}),
value: 2,
},
{
label: intl.formatMessage({
id: 'fish.rarity.near_threatened',
defaultMessage: 'Gần bị đe dọa',
}),
value: 3,
},
{
label: intl.formatMessage({
id: 'fish.rarity.endangered',
defaultMessage: 'Nguy cấp',
}),
value: 4,
},
]}
/>
</ProForm.Group>
<ProFormTextArea
name="note"
label={intl.formatMessage({
id: 'common.description',
defaultMessage: 'Ghi chú',
})}
placeholder={intl.formatMessage({
id: 'common.description.placeholder',
defaultMessage: 'Nhập ghi chú',
})}
/>
</ModalForm>
);
};
export default AddOrUpdateFish;

View File

@@ -0,0 +1,66 @@
import { HTTPSTATUS } from '@/constants';
import { apiGetPhoto } from '@/services/slave/sgw/PhotoController';
import { Image, Spin } from 'antd';
import { useEffect, useRef, useState } from 'react';
export const FishImage = ({
fishId,
alt,
isReload,
}: {
fishId: string;
alt: string;
isReload: boolean;
}) => {
const [url, setUrl] = useState<string>('');
const [loading, setLoading] = useState(true);
const objectUrlRef = useRef<string>('');
useEffect(() => {
let isMounted = true;
const fetchImage = async () => {
try {
const resp = await apiGetPhoto('fish', fishId);
let objectUrl = '';
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
const blob = new Blob([resp.data], { type: 'image/jpeg' });
objectUrl = URL.createObjectURL(blob);
objectUrlRef.current = objectUrl;
} else {
throw new Error('Failed to fetch image');
}
if (isMounted) {
setUrl(objectUrl);
setLoading(false);
}
} catch (error) {
// console.log('Error: ', error);
setUrl('');
if (isMounted) {
setLoading(false);
}
}
};
fetchImage();
return () => {
isMounted = false;
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current);
}
};
}, [fishId, isReload]);
if (loading) {
return <Spin size="small" />;
}
if (!url) {
return <span>-</span>;
}
return <Image height={50} width={50} src={url} alt={alt} />;
};

View File

@@ -1,11 +1,350 @@
import React from 'react'; import PhotoActionModal from '@/components/shared/PhotoActionModal';
import { DEFAULT_PAGE_SIZE, HTTPSTATUS } from '@/constants';
import {
apiDeleteFishSpecies,
apiGetFishSpecies,
} from '@/services/slave/sgw/FishController';
import { getRarityById } from '@/utils/slave/sgw/fishRarity';
import {
DeleteOutlined,
EditOutlined,
PictureOutlined,
PlusOutlined,
} from '@ant-design/icons';
import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max';
import { Button, Flex, message, Popconfirm, Tag, theme, Tooltip } from 'antd';
import { useRef, useState } from 'react';
import AddOrUpdateFish from './component/AddOrUpdateFish';
import { FishImage } from './component/FishImage';
const FishList = () => {
const tableRef = useRef<ActionType>();
const intl = useIntl();
const [messageApi, contextHolder] = message.useMessage();
const [showAddOrUpdateModal, setShowAddOrUpdateModal] =
useState<boolean>(false);
const [fishSelected, setFishSelected] = useState<SgwModel.Fish | undefined>(
undefined,
);
const [fishPhotoModalOpen, setFishPhotoModalOpen] = useState<boolean>(false);
const [fishID, setFishID] = useState<number | undefined>(undefined);
const [isReloadImage, setIsReloadImage] = useState<boolean>(false);
const token = theme.useToken();
const getColorByRarityLevel = (level: number) => {
switch (level) {
case 2:
return token.token.yellow;
case 3:
return token.token.orange;
case 4:
return token.token.colorError;
case 5:
return '#FF6347';
case 6:
return '#FF4500';
case 7:
return '#FF0000';
case 8:
return '#8B0000';
default:
return token.token.green;
}
};
const handleDeleteFish = async (id: string) => {
try {
const resp = await apiDeleteFishSpecies(id);
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
messageApi.success(
intl.formatMessage({
id: 'fish.delete.success',
defaultMessage: 'Successfully deleted fish',
}),
);
tableRef.current?.reload();
} else {
throw new Error('Xóa cá thất bại');
}
} catch (error) {
messageApi.error(
intl.formatMessage({
id: 'fish.delete.fail',
defaultMessage: 'Failed to delete fish',
}),
);
}
};
const columns: ProColumns<SgwModel.Fish>[] = [
{
title: intl.formatMessage({
id: 'common.name',
defaultMessage: 'Name',
}),
dataIndex: 'name',
key: 'name',
copyable: true,
render: (dom, entity) => {
return (
<Tooltip
title={
intl.formatMessage({
id: 'fish.specific_name',
defaultMessage: 'Scientific Name',
}) +
': ' +
entity.scientific_name
}
>
{dom}
</Tooltip>
);
},
},
{
title: intl.formatMessage({
id: 'common.image',
defaultMessage: 'Image',
}),
dataIndex: 'id',
key: 'id',
hideInSearch: true,
// valueType: 'image',
render: (_, entity) => {
return (
<FishImage
fishId={String(entity.id || '')}
alt={entity.name || ''}
isReload={isReloadImage}
/>
);
},
},
{
title: intl.formatMessage({
id: 'fish.fish_group',
defaultMessage: 'Group',
}),
dataIndex: 'group_name',
key: 'group_name',
},
{
title: intl.formatMessage({
id: 'fish.rarity',
defaultMessage: 'Rarity',
}),
dataIndex: 'rarity_level',
key: 'rarity_level',
valueType: 'select',
valueEnum: {
1: {
text: intl.formatMessage({
id: 'fish.rarity.normal',
defaultMessage: 'Common',
}),
},
2: {
text: intl.formatMessage({
id: 'fish.rarity.sensitive',
defaultMessage: 'Sensitive',
}),
},
3: {
text: intl.formatMessage({
id: 'fish.rarity.near_threatened',
defaultMessage: 'Near Threatened',
}),
},
4: {
text: intl.formatMessage({
id: 'fish.rarity.endangered',
defaultMessage: 'Endangered',
}),
},
},
render: (_, entity) => {
const rarity = getRarityById(entity.rarity_level || 1);
return (
<Tag color={getColorByRarityLevel(entity.rarity_level || 1)}>
{rarity ||
intl.formatMessage({
id: 'common.undefined',
defaultMessage: 'Undefined',
})}
</Tag>
);
},
},
{
title: intl.formatMessage({
id: 'common.note',
defaultMessage: 'Note',
}),
dataIndex: 'note',
hideInSearch: true,
key: 'note',
ellipsis: true,
},
{
title: intl.formatMessage({
id: 'common.actions',
defaultMessage: 'Actions',
}),
dataIndex: 'actions',
key: 'actions',
hideInSearch: true,
align: 'center',
render: (_, entity) => {
return (
<Flex align="center" justify="center" gap={8}>
<Button
type="text"
onClick={() => {
setFishSelected(entity);
setShowAddOrUpdateModal(true);
}}
icon={<EditOutlined />}
></Button>
<Button
type="text"
onClick={async () => {
setFishID(entity.id);
setFishPhotoModalOpen(true);
}}
icon={<PictureOutlined />}
></Button>
<Popconfirm
title={intl.formatMessage({
id: 'fish.delete_confirm',
defaultMessage:
'Are you sure you want to delete this fish species?',
})}
onConfirm={() => handleDeleteFish(entity.id?.toString() || '')}
okText={intl.formatMessage({
id: 'common.yes',
defaultMessage: 'Yes',
})}
cancelText={intl.formatMessage({
id: 'common.no',
defaultMessage: 'No',
})}
>
<Button type="text" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Flex>
);
},
},
];
const SGWFish: React.FC = () => {
return ( return (
<div> <div>
<h1> (SGW Manager)</h1> {contextHolder}
<AddOrUpdateFish
type={fishSelected ? 'update' : 'create'}
isOpen={showAddOrUpdateModal}
setIsOpen={setShowAddOrUpdateModal}
fish={fishSelected}
message={messageApi}
onReload={(isSuccess) => {
if (isSuccess) {
tableRef.current?.reload();
setIsReloadImage((prev) => !prev);
}
}}
/>
<PhotoActionModal
key={fishID ?? 'none'}
isOpen={fishPhotoModalOpen}
setIsOpen={setFishPhotoModalOpen}
type={'fish'}
id={fishID!}
hasSubPhotos={true}
/>
<ProTable<SgwModel.Fish>
actionRef={tableRef}
rowKey="id"
size="large"
columns={columns}
search={{
defaultCollapsed: false,
}}
columnEmptyText="-"
pagination={{
defaultPageSize: DEFAULT_PAGE_SIZE * 2,
showSizeChanger: true,
pageSizeOptions: ['5', '10', '15', '20'],
showTotal: (total, range) =>
`${range[0]}-${range[1]}
${intl.formatMessage({
id: 'common.of',
defaultMessage: 'of',
})}
${total} ${intl.formatMessage({
id: 'fish.name',
defaultMessage: 'fishes',
})}`,
}}
options={{
search: false,
setting: false,
density: false,
reload: true,
}}
toolBarRender={() => [
<Button
color="cyan"
variant="outlined"
key="add-fish"
icon={<PlusOutlined />}
size="middle"
onClick={() => {
setFishSelected(undefined);
setShowAddOrUpdateModal(true);
}}
>
<FormattedMessage
id="fish.create.title"
defaultMessage="Add Fish Species"
/>
</Button>,
]}
request={async (params) => {
const { current, pageSize, name, group_name, rarity_level } = params;
const offset = current === 1 ? 0 : (current! - 1) * pageSize!;
const body: SgwModel.SearchFishPaginationBody = {
name: name,
order: 'name',
limit: pageSize,
offset: offset,
dir: 'desc',
};
if (group_name || rarity_level) body.metadata = {};
if (group_name && body.metadata) {
body.metadata.group_name = group_name;
}
if (rarity_level && body.metadata) {
body.metadata.rarity_level = Number(rarity_level);
}
try {
const res = await apiGetFishSpecies(body);
return {
data: res.fishes,
total: res.total,
success: true,
};
} catch (error) {
return {
data: [],
total: 0,
success: false,
};
}
}}
/>
</div> </div>
); );
}; };
export default SGWFish; export default FishList;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,74 @@
import { FeatureLike } from 'ol/Feature';
import { Coordinate } from 'ol/coordinate';
import { Circle, Point } from 'ol/geom';
import { Fill, Stroke, Style, Text } from 'ol/style';
import { ZoneData } from '../type';
const getCircleStyleFromData = ({ zoneData }: { zoneData: ZoneData }) => {
// Base style configuration for each zone type
console.log('Type: ', zoneData.type);
const circleStyles = {
warning: {
fill: new Fill({ color: 'rgba(250, 206, 104, 0.5)' }), // Yellow with transparency
stroke: new Stroke({ color: '#FFC107', width: 2 }),
textColor: '#856404',
},
alarm: {
fill: new Fill({ color: 'rgba(220, 53, 69, 0.8)' }), // Red with transparency
stroke: new Stroke({ color: '#DC3545', width: 3 }),
textColor: '#721C24',
},
default: {
fill: new Fill({ color: 'rgba(108, 117, 125, 0.2)' }), // Gray with transparency
stroke: new Stroke({ color: '#6C757D', width: 1 }),
textColor: '#383D41',
},
};
const styleConfig = circleStyles[zoneData.type] || circleStyles.default;
// Create styles array - we might need multiple styles for proper rendering
const styles: Style[] = [];
// Main style for Circle geometry (uses fill/stroke directly, not image)
const mainStyle = new Style({
fill: styleConfig.fill,
stroke: styleConfig.stroke,
});
styles.push(mainStyle);
// Add text style if message exists
if (zoneData.message && zoneData.message.trim()) {
const textStyle = new Style({
geometry: (feature: FeatureLike) => {
// Get the center of the circle for text placement
const geometry = feature.getGeometry();
if (geometry && geometry.getType() === 'Circle') {
// For Circle geometry, get the center and return it as a Point geometry
const circle = geometry as Circle;
const center: Coordinate = circle.getCenter();
return new Point(center);
}
return geometry;
},
text: new Text({
font: 'bold 14px Arial, sans-serif',
text: zoneData.message,
fill: new Fill({ color: styleConfig.textColor }),
stroke: new Stroke({
color: 'white',
width: 3,
}),
textAlign: 'center',
textBaseline: 'middle',
}),
});
styles.push(textStyle);
}
// Return single style if no text, or array if text exists
return styles.length === 1 ? styles[0] : styles;
};
export default getCircleStyleFromData;

View File

@@ -0,0 +1,64 @@
import { useIntl } from '@umijs/max';
import { Collapse, Flex, Typography } from 'antd';
import { MessageInstance } from 'antd/es/message/interface';
import { GPSParseResult } from '../type';
import { BaseMap } from './BaseMap';
import ShipDetail from './ShipDetail';
const MultipleShips = ({
things,
messageApi,
mapController,
}: {
things: SgwModel.SgwThing[];
messageApi: MessageInstance;
mapController: BaseMap | null;
}) => {
const intl = useIntl();
return (
<Collapse
bordered={false}
style={{
background: 'white',
}}
// onChange={handleCollapseChange}
items={things.map((thing, index) => {
const gpsData: GPSParseResult = JSON.parse(thing.metadata?.gps || '{}');
return {
key: index,
label: (
<Flex
justify="space-between"
align="center"
style={{ width: '100%', marginBottom: 10 }}
>
<Typography.Text strong>
{thing.metadata?.ship_name || thing.name}
</Typography.Text>
<div style={{ display: 'flex', gap: '10px' }}>
<Typography.Text strong>
${intl.formatMessage({ id: 'map.ship_detail.speed' })}:{' '}
{gpsData.s} km/h
</Typography.Text>
<Typography.Text>-</Typography.Text>
<Typography.Text strong>
{intl.formatMessage({ id: 'map.ship_detail.heading' })}:{' '}
{gpsData.h}°
</Typography.Text>
</div>
</Flex>
),
children: (
<ShipDetail
thing={thing}
messageApi={messageApi}
mapController={mapController}
/>
),
};
})}
></Collapse>
);
};
export default MultipleShips;

View File

@@ -0,0 +1,56 @@
import { Fill, Stroke, Style, Text } from 'ol/style';
import { ZoneData } from '../type';
const getZoneStyleFromData = ({ zoneData }: { zoneData: ZoneData }) => {
// Base style configuration for each zone type
const zoneStyles = {
warning: {
fill: new Fill({ color: 'rgba(255, 193, 7, 0.3)' }), // Yellow with transparency
stroke: new Stroke({ color: '#FFC107', width: 2 }),
textColor: '#856404',
},
alarm: {
fill: new Fill({ color: 'rgba(220, 53, 69, 0.3)' }), // Red with transparency
stroke: new Stroke({ color: '#DC3545', width: 3 }),
textColor: '#721C24',
},
default: {
fill: new Fill({ color: 'rgba(108, 117, 125, 0.2)' }), // Gray with transparency
stroke: new Stroke({ color: '#6C757D', width: 1 }),
textColor: '#383D41',
},
};
const styleConfig = zoneStyles[zoneData.type] || zoneStyles.default;
// Create the base style
const baseStyle = {
fill: styleConfig.fill,
stroke: styleConfig.stroke,
};
// Add text style if message exists
if (zoneData.message && zoneData.message.trim()) {
return new Style({
...baseStyle,
text: new Text({
font: 'bold 14px Arial, sans-serif',
text: zoneData.message,
fill: new Fill({ color: styleConfig.textColor }),
stroke: new Stroke({
color: 'white',
width: 3,
}),
placement: 'point', // Center text in polygon
overflow: true, // Allow text to overflow if needed
textAlign: 'center',
textBaseline: 'middle',
}),
});
}
// Return style without text if no message
return new Style(baseStyle);
};
export default getZoneStyleFromData;

View File

@@ -0,0 +1,65 @@
import { Fill, Stroke, Style, Text } from 'ol/style';
import { ZoneData } from '../type';
const getPolylineStyleFromData = ({ zoneData }: { zoneData: ZoneData }) => {
// Base style configuration for each zone type
const polylineStyles = {
warning: {
stroke: new Stroke({
color: '#FFC107', // Yellow
width: 4,
lineDash: [10, 5], // Dashed line for warning
}),
textColor: '#856404',
},
alarm: {
stroke: new Stroke({
color: '#DC3545', // Red
width: 5,
lineDash: [15, 5], // More prominent dash for alarm
}),
textColor: '#721C24',
},
default: {
stroke: new Stroke({
color: '#6C757D', // Gray
width: 3,
lineDash: [], // Solid line for default
}),
textColor: '#383D41',
},
};
const styleConfig = polylineStyles[zoneData.type] || polylineStyles.default;
// Create the base style
const baseStyle = {
stroke: styleConfig.stroke,
};
// Add text style if message exists
if (zoneData.message && zoneData.message.trim()) {
return new Style({
...baseStyle,
text: new Text({
font: 'bold 12px Arial, sans-serif',
text: zoneData.message,
fill: new Fill({ color: styleConfig.textColor }),
stroke: new Stroke({
color: 'white',
width: 3,
}),
placement: 'line', // Place text along the line
overflow: true,
textAlign: 'center',
textBaseline: 'middle',
repeat: 100000000, // Repeat text every 200 pixels
}),
});
}
// Return style without text if no message
return new Style(baseStyle);
};
export default getPolylineStyleFromData;

View File

@@ -0,0 +1,119 @@
import { ProDescriptions } from '@ant-design/pro-components';
import { useIntl } from '@umijs/max';
import { Flex, Image } from 'antd';
import { ShipDetailData } from '../type';
interface ShipBasicInfoProps {
ship: ShipDetailData | null;
shipImage: string;
thing: SgwModel.SgwThing;
}
const ShipBasicInfo = ({ ship, shipImage, thing }: ShipBasicInfoProps) => {
const intl = useIntl();
return (
<Flex
gap="middle"
style={{
flexDirection: window.innerWidth <= 768 ? 'column' : 'row',
alignItems: window.innerWidth <= 768 ? 'center' : 'flex-start',
}}
>
<div
style={{
width: window.innerWidth <= 768 ? '100%' : '30%',
maxWidth: '300px',
margin: window.innerWidth <= 768 ? '0 auto' : '0',
}}
>
<Image
src={shipImage}
style={{ borderRadius: '8px', objectFit: 'cover' }}
width="100%"
height="120px"
alt="Ảnh tàu"
preview={false}
onError={(e) => {
(e.target as HTMLImageElement).src =
'https://1.semantic-ui.com/images/wireframe/image.png';
}}
/>
</div>
{/* Thông tin cơ bản */}
<div
style={{
width: window.innerWidth <= 768 ? '100%' : '70%',
}}
>
<ProDescriptions
size="small"
column={2}
styles={{
title: {
marginBottom: -10,
textAlign: 'center',
fontSize: window.innerWidth <= 576 ? '14px' : '16px',
},
content: { paddingBottom: 1 },
label: { paddingBottom: 1 },
}}
>
<ProDescriptions.Item
label={intl.formatMessage({
id: 'map.filter.ship_name',
defaultMessage: 'Ship Name',
})}
>
{ship?.ship?.name || '-'}
</ProDescriptions.Item>
<ProDescriptions.Item
label={intl.formatMessage({
id: 'map.ship_detail.port_register',
defaultMessage: 'Port Register',
})}
>
{ship?.ship?.metadata?.home_port || '-'}
</ProDescriptions.Item>
<ProDescriptions.Item
label={intl.formatMessage({
id: 'map.ship_detail.speed',
defaultMessage: 'Speed',
})}
>
{ship?.gps?.s || '-'} km/h
</ProDescriptions.Item>
<ProDescriptions.Item
label={intl.formatMessage({
id: 'map.ship_detail.heading',
defaultMessage: 'Heading',
})}
>
{ship?.gps?.h || '-'}°
</ProDescriptions.Item>
<ProDescriptions.Item
label={intl.formatMessage({
id: 'map.ship_detail.status',
defaultMessage: 'Status',
})}
span={3}
>
{ship?.gps?.fishing ? 'Đang đánh bắt' : 'Chưa đánh bắt'}
</ProDescriptions.Item>
<ProDescriptions.Item
valueType="fromNow"
label={intl.formatMessage({
id: 'map.ship_detail.updated',
defaultMessage: 'Updated',
})}
>
{thing.metadata?.updated_time
? thing.metadata?.updated_time * 1000
: '-'}
</ProDescriptions.Item>
</ProDescriptions>
</div>
</Flex>
);
};
export default ShipBasicInfo;

View File

@@ -0,0 +1,282 @@
import { apiQueryUserById } from '@/services/master/UserController';
import { apiGetPhoto } from '@/services/slave/sgw/PhotoController';
import { apiViewShip } from '@/services/slave/sgw/ShipController';
import { apiGetZoneById } from '@/services/slave/sgw/ZoneController';
import {
convertWKTLineStringToLatLngArray,
convertWKTPointToLatLng,
convertWKTtoLatLngString,
getAlarmTypeName,
getCircleRadius,
} from '@/utils/slave/sgw/geomUtils';
import { ArrowRightOutlined, InfoCircleOutlined } from '@ant-design/icons';
import { ProSkeleton } from '@ant-design/pro-components';
import { FormattedMessage, useIntl, useModel } from '@umijs/max';
import { Button, Space, Tabs, Tooltip } from 'antd';
import { MessageInstance } from 'antd/lib/message/interface';
import { useEffect, useState } from 'react';
import {
BaseMap,
DATA_LAYER,
GPSParseResult,
ShipDetailData,
TEMPORARY_LAYER,
ZoneAlarmParse,
} from '../type';
import ShipBasicInfo from './ShipBasicInfo';
import { ShipSpecificationTab, ShipTripInfoTab } from './ShipTabs';
import ShipWarningList from './ShipWarningList';
const ShipDetail = ({
thing,
messageApi,
mapController,
}: {
thing: SgwModel.SgwThing;
messageApi: MessageInstance;
mapController: BaseMap | null;
}) => {
const [ship, setShip] = useState<ShipDetailData | null>(null);
const intl = useIntl();
const { initialState } = useModel('@@initialState');
const [isLoading, setIsLoading] = useState(true);
const [shipImage, setShipImage] = useState<string>('');
const [selectedZoneId, setSelectedZoneId] = useState<string | null>(null);
const getShipDetail = async () => {
try {
let zone_entered_alarm_list: ZoneAlarmParse[] = [];
if (thing.metadata?.zone_entered_alarm_list !== '') {
zone_entered_alarm_list = JSON.parse(
thing.metadata?.zone_entered_alarm_list || '[]',
);
}
let zone_approaching_alarm_list: ZoneAlarmParse[] = [];
if (thing.metadata?.zone_approaching_alarm_list !== '') {
zone_approaching_alarm_list = JSON.parse(
thing.metadata?.zone_approaching_alarm_list || '[]',
);
}
const gpsData: GPSParseResult = JSON.parse(thing.metadata?.gps || '{}');
const resp = await apiViewShip(thing.id || '');
let shipOwner:
| MasterModel.UserResponse
| MasterModel.UserResponse
| null = null;
if (resp?.owner_id) {
if (initialState?.currentUserProfile?.metadata?.user_type === 'admin') {
shipOwner = await apiQueryUserById(resp.owner_id);
} else {
shipOwner = initialState?.currentUserProfile || null;
}
}
try {
const photoResponse = await apiGetPhoto('ship', resp.id || '');
const blob = new Blob([photoResponse.data], { type: 'image/jpeg' });
const url = URL.createObjectURL(blob);
setShipImage(url);
} catch (e) {
setShipImage('https://1.semantic-ui.com/images/wireframe/image.png');
}
setShip({
ship: resp,
owner: shipOwner || null,
zone_entered_alarm_list: zone_entered_alarm_list,
zone_approaching_alarm_list: zone_approaching_alarm_list,
gps: gpsData,
trip_id: resp?.metadata?.trip_id,
});
setIsLoading(false);
} catch (error) {
console.error('Error fetching ship details:', error);
messageApi.error('Không thể lấy thông tin tàu.');
}
};
useEffect(() => {
try {
getShipDetail();
} catch (error) {
console.error('Cannot get ShipDetail: ', error);
}
}, [thing.id]);
console.log();
const handleClickZoneButton = async (zone: ZoneAlarmParse) => {
if (mapController === null) {
return;
}
mapController.toggleLayer(DATA_LAYER, false);
if (zone.zone_id === selectedZoneId) {
mapController.clearFeatures(TEMPORARY_LAYER);
mapController.toggleLayer(DATA_LAYER, true);
setSelectedZoneId(null);
return;
}
if (selectedZoneId) {
}
try {
const resp = await apiGetZoneById(zone.zone_id || '');
if (resp) {
const text = `Tàu: ${ship?.ship?.name || '-'}\nThời gian ${new Date(
zone.gps_time! * 1000,
).toLocaleTimeString()}\nTốc độ: ${zone.s} km/h\nHướng: ${
zone.h
}°\n Lý do: ${zone.message}`;
mapController.addPoint(
TEMPORARY_LAYER,
[zone?.lon || 0, zone?.lat || 0],
{
thing: thing,
description: text,
type: 'main-point',
},
);
const zone_geom: SgwModel.Geom = JSON.parse(resp.geometry || '{}');
if (zone_geom.geom_type === 1) {
const polygon = convertWKTtoLatLngString(zone_geom.geom_poly || '');
mapController.addPolygon(TEMPORARY_LAYER, polygon, {
type: getAlarmTypeName(thing.metadata?.state_level || 0),
zone: resp,
message: zone.zone_name,
});
} else if (zone_geom.geom_type === 2) {
const polyline = convertWKTLineStringToLatLngArray(
zone_geom.geom_lines || '',
);
mapController.addPolyline(TEMPORARY_LAYER, polyline, {
type: getAlarmTypeName(thing.metadata?.state_level || 0),
zone: resp,
message: zone.zone_name,
});
} else {
const center = convertWKTPointToLatLng(zone_geom.geom_point || '');
mapController.addCircle(
TEMPORARY_LAYER,
center!,
getCircleRadius(zone_geom.geom_radius || 0),
{
type: getAlarmTypeName(thing.metadata?.state_level || 0),
zone: resp,
message: zone.zone_name,
},
);
}
mapController.zoomToFeaturesInLayer(TEMPORARY_LAYER);
setSelectedZoneId(zone.zone_id || null);
}
} catch (error) {
console.error('Error with get Zone: ', error);
}
};
return isLoading ? (
<ProSkeleton type="list" statistic={false} list={1} />
) : (
<>
<ShipBasicInfo ship={ship} shipImage={shipImage} thing={thing} />
<Tabs
defaultActiveKey="1"
centered
items={[
{
key: '1',
label: (
<FormattedMessage
id="map.ship_detail.specifications"
defaultMessage="Specifications"
/>
),
children: <ShipSpecificationTab ship={ship} />,
},
{
key: '2',
label: (
<Tooltip
title={intl.formatMessage({
id: 'map.ship_detail.trip_information_tooltip',
defaultMessage: 'Nearest Trip Information',
})}
>
{intl.formatMessage({
id: 'map.ship_detail.trip_information',
defaultMessage: 'Trip Information',
})}
</Tooltip>
),
children: <ShipTripInfoTab ship={ship} />,
},
{
key: '3',
label: intl.formatMessage({
id: 'map.filter.ship_warning',
defaultMessage: 'Warning',
}),
disabled:
ship?.zone_approaching_alarm_list.length === 0 &&
ship?.zone_entered_alarm_list.length === 0,
children: (
<ShipWarningList
zoneEnteredList={ship?.zone_entered_alarm_list || []}
zoneApproachingList={ship?.zone_approaching_alarm_list || []}
selectedZoneId={selectedZoneId}
onZoneButtonClick={handleClickZoneButton}
/>
),
},
]}
></Tabs>
</>
);
};
export default ShipDetail;
export const ShipDetailMessageAction = () => {
const intl = useIntl();
// const goToThingDetail = (trip_id: string) => {
// history.push(
// { pathname: `/trips/${trip_id}` },
// { thingId: trip_id }, // state
// );
// };
return (
<Space>
<Button
// type="link".
variant="outlined"
color="cyan"
icon={<ArrowRightOutlined />}
size="small"
iconPosition="end"
onClick={async () => {
// const ship = await viewShip(id);
// console.log("Ship Data for Trip Navigation:", shipData);
// goToThingDetail(shipData?.metadata?.trip_id);
}}
>
{intl.formatMessage({
id: 'map.ship_detail.trip_information',
defaultMessage: 'Trip Information',
})}
</Button>
<Button
type="primary"
size="small"
iconPosition="end"
icon={<InfoCircleOutlined />}
onClick={() => {
// console.log("Navigating to device detail:", id);
// const path = `/devices/${id}/vms`; // lấy id + type từ record
// history.push({ pathname: path });
}}
>
{intl.formatMessage({
id: 'map.ship_detail.detail',
defaultMessage: 'Detail',
})}
</Button>
</Space>
);
};

View File

@@ -0,0 +1,69 @@
import { getShipIcon } from '@/services/slave/sgw/MapService';
import { Fill, Icon, Stroke, Style, Text } from 'ol/style';
import CircleStyle from 'ol/style/Circle';
import { GPSParseResult, PointData } from '../type';
export const getShipStyleFromData = ({
state_level,
gpsData,
scale,
pointData,
}: {
state_level: number;
gpsData: GPSParseResult;
scale: number;
pointData: PointData;
}) => {
if (pointData.type === 'main-point') {
return new Style({
text: pointData.description
? new Text({
text: pointData.description,
font: '14px Arial',
fill: new Fill({ color: 'black' }),
stroke: new Stroke({
color: 'white',
width: 2,
}),
offsetY: -40,
textAlign: 'center',
textBaseline: 'bottom',
})
: undefined,
image: new Icon({
anchor: [0.5, 30], // Điểm neo: giữa theo X, 30px theo Y
anchorOrigin: 'top-left',
anchorXUnits: 'fraction',
anchorYUnits: 'pixels',
scale: scale,
src: getShipIcon(state_level, gpsData.fishing || false),
rotateWithView: false,
rotation: ((gpsData.h || 0) * Math.PI) / 180,
crossOrigin: 'anonymous',
}),
});
} else if (pointData.type === 'sos-point') {
// Style cơ bản cho SOS point - hình tròn đỏ ở giữa
return new Style({
image: new Icon({
anchor: [0.5, 30], // Điểm neo: giữa theo X, 30px theo Y
anchorOrigin: 'top-left',
anchorXUnits: 'fraction',
anchorYUnits: 'pixels',
scale: scale,
src: getShipIcon(state_level, gpsData.fishing || false),
rotateWithView: false,
rotation: ((gpsData.h || 0) * Math.PI) / 180,
crossOrigin: 'anonymous',
}),
});
} else {
return new Style({
image: new CircleStyle({
radius: 5,
fill: new Fill({ color: 'red' }),
stroke: new Stroke({ color: 'white', width: 2 }),
}),
});
}
};

View File

@@ -0,0 +1,407 @@
import TreeSelectedGroup from '@/components/shared/TreeSelectedGroup';
import { apiGetShipGroups } from '@/services/slave/sgw/ShipController';
import {
ModalForm,
ProForm,
ProFormInstance,
ProFormSlider,
} from '@ant-design/pro-components';
import { FormattedMessage, useIntl, useModel } from '@umijs/max';
import { AutoComplete, Col, Flex, Row, Select, Tooltip } from 'antd';
import { useEffect, useRef, useState } from 'react';
interface SearchShipProps {
initialValues?: Partial<SearchShipResponse>;
things?: SgwModel.SgwThing[];
isModalOpen: boolean;
onClose: any;
onSubmit: (values: SearchShipResponse) => void;
}
export interface SearchShipResponse {
ship_name?: string;
ship_length?: [number, number];
reg_number?: string;
ship_power?: [number, number];
ship_type?: string | number;
alarm_list?: string;
ship_group_id?: string;
group_id?: string | string[];
}
const ShipSearchForm = ({
initialValues,
things,
isModalOpen,
onClose,
onSubmit,
}: SearchShipProps) => {
const intl = useIntl();
const { initialState } = useModel('@@initialState');
const currentUser = initialState?.currentUserProfile;
const [group_id, setGroupId] = useState<string | string[] | null>(null);
const [groupShips, setGroupShips] = useState<
SgwModel.GroupShipResponse[] | []
>([]);
const [shipNames, setShipNames] = useState<string[]>([]);
const [shipNameOptions, setShipNameOptions] = useState<
{ value: string; key: number }[]
>([]);
const [shipRegNumberOptions, setShipRegNumberOptions] = useState<
{ value: string; key: number }[]
>([]);
const formRef = useRef<ProFormInstance<SearchShipResponse>>();
// console.log('InitialValue ', initialValues);
useEffect(() => {
if (initialValues === undefined && formRef.current) {
formRef.current.resetFields();
}
}, [initialValues]);
const [shipRegNumbers, setShipRegNumbers] = useState<string[]>([]);
const { shipTypes, getShipTypes } = useModel('slave.sgw.useShipTypes');
useEffect(() => {
if (!shipTypes) {
getShipTypes();
}
}, [shipTypes]);
const getListShipNames = () => {
if (things && Array.isArray(things)) {
const names = things
.map((item) => item?.metadata?.ship_name)
.filter((name): name is string => Boolean(name)); // bỏ null/undefined/"" nếu có
setShipNames(names);
}
};
const handleGroupSelect = (group: string | string[] | null) => {
// console.log("Selected group key:", value);
setGroupId(group);
};
const handleSearchShipName = (value: string) => {
if (!value) {
setShipNameOptions([]); // không hiện gợi ý khi chưa nhập
return;
}
const filtered = shipNames
.filter((name) => name.toLowerCase().includes(value.toLowerCase()))
.map((name, index) => ({ value: name, key: index }));
setShipNameOptions(filtered);
};
const getListShipRegNumbers = () => {
if (things && Array.isArray(things)) {
const regNumbers = things
.map((item) => item?.metadata?.ship_reg_number)
.filter((reg): reg is string => Boolean(reg)); // bỏ null/undefined/"" nếu có
setShipRegNumbers(regNumbers);
}
};
const handleSearchShipRegNumber = (value: string) => {
if (!value) {
setShipRegNumberOptions([]); // không hiện gợi ý khi chưa nhập
return;
}
const filtered = shipRegNumbers
.filter((regNumber) =>
regNumber.toLowerCase().includes(value.toLowerCase()),
)
.map((regNumber, index) => ({ value: regNumber, key: index }));
setShipRegNumberOptions(filtered);
};
const getShipGroupByOwner = async () => {
// console.log("Gọi hàm lấy ra đội tàu");
try {
const groups = await apiGetShipGroups();
setGroupShips(groups);
// console.log("Groups: ", groups);
} catch (error) {
console.error('Error when get ShipGroup: ', error);
}
};
useEffect(() => {
if (isModalOpen) {
getListShipNames();
getListShipRegNumbers();
if (currentUser?.metadata?.user_type === 'enduser') {
getShipGroupByOwner();
}
}
// console.log("InitialData: ", initialData);
}, [isModalOpen, things]);
const alarmListLabel = [
{
label: 'Tiếp cận vùng hạn chế',
value: '50:10',
},
{
label: 'Đã ra (vào) vùng hạn chế)',
value: '50:11',
},
{
label: 'Đang đánh bắt trong vùng hạn chế',
value: '50:12',
},
];
// console.log(
// 'ShipSearchForm render - isModalOpen:',
// isModalOpen,
// 'things length:',
// things?.length,
// );
return (
<ModalForm
title={
<Flex align="center" justify="center">
{intl.formatMessage({
id: 'map.filter.name',
defaultMessage: 'Filter',
})}
</Flex>
}
open={isModalOpen}
onOpenChange={(open) => {
// console.log('Modal onOpenChange:', open);
if (!open) {
onClose(false);
}
}}
formRef={formRef}
width={600}
initialValues={initialValues}
onFinish={async (values) => {
// console.log("Values: ", values);
// await onSubmit(values);
const dataFinal: SearchShipResponse = {
ship_name: values.ship_name,
ship_length: values.ship_length,
reg_number: values.reg_number,
ship_power: values.ship_power,
ship_type: values.ship_type,
alarm_list: values.alarm_list,
ship_group_id: values.ship_group_id,
group_id: Array.isArray(group_id)
? group_id.join(',')
: group_id || undefined,
};
// console.log("Values: ", dataFinal);
onSubmit(dataFinal);
onClose(false);
}}
modalProps={{
cancelText: (
<FormattedMessage
id="map.filter.cancel_button"
defaultMessage="Cancel"
/>
),
okText: (
<FormattedMessage id="map.filter.find_button" defaultMessage="Find" />
),
}}
layout="vertical"
style={{ padding: '24px' }}
>
<Row gutter={[16, 16]}>
<Col span={12}>
<ProForm.Item
name="ship_name"
label={intl.formatMessage({
id: 'map.filter.ship_name',
defaultMessage: 'Ship Name',
})}
tooltip={intl.formatMessage({
id: 'map.filter.ship_name_tooltip',
defaultMessage: 'Find by Ship Name',
})}
fieldProps={{
size: 'middle',
style: { width: '100%' },
}}
>
<AutoComplete
placeholder="Hoàng Sa 001"
style={{ width: '100%' }}
size="middle"
options={shipNameOptions}
onSearch={handleSearchShipName}
/>
</ProForm.Item>
</Col>
<Col span={12}>
<ProForm.Item
name="reg_number"
tooltip={intl.formatMessage({
id: 'map.filter.ship_reg_number_tooltip',
defaultMessage: 'Find by Ship Registration Number',
})}
label={intl.formatMessage({
id: 'map.filter.ship_reg_number',
defaultMessage: 'Ship Registration Number',
})}
fieldProps={{
size: 'middle',
style: { width: '100%' },
}}
>
<AutoComplete
placeholder="VN-00001"
style={{ width: '100%' }}
size="middle"
options={shipRegNumberOptions}
onSearch={handleSearchShipRegNumber}
/>
</ProForm.Item>
</Col>
<Col span={11} style={{ marginRight: 10 }}>
<ProFormSlider
range
tooltip
name="ship_length"
label={intl.formatMessage({
id: 'map.filter.ship_length',
defaultMessage: 'Ship Length (m)',
})}
marks={{
0: '0m',
50: '50m',
100: '100m',
}}
fieldProps={{
style: { width: '80%' },
tooltip: {
formatter: (value) => `${value}m`,
},
}}
/>
</Col>
<Col span={11} style={{ marginLeft: 10 }}>
<ProFormSlider
range
name="ship_power"
label={intl.formatMessage({
id: 'map.filter.ship_power',
defaultMessage: 'Ship Power (kW)',
})}
max={100000}
marks={{
0: '0kW',
50000: '50,000kW',
100000: '100,000kW',
}}
fieldProps={{
style: { width: '80%' },
tooltip: {
formatter: (value) => `${value}kW`,
},
}}
/>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col span={11}>
<ProForm.Item
name="ship_type"
label={intl.formatMessage({
id: 'map.filter.ship_type',
defaultMessage: 'Ship Type',
})}
>
<Select
mode="multiple"
allowClear
style={{ width: '100%' }}
placeholder={intl.formatMessage({
id: 'map.filter.ship_type_placeholder',
defaultMessage: 'Select Ship Type',
})}
options={(Array.isArray(shipTypes) ? shipTypes : []).map(
(type) => ({
label: (
<Tooltip title={type.description}>{type.name}</Tooltip>
),
value: type.id,
}),
)}
/>
</ProForm.Item>
</Col>
<Col span={13}>
<ProForm.Item
name="alarm_list"
label={intl.formatMessage({
id: 'map.filter.ship_warning',
defaultMessage: 'Warning',
})}
>
<Select
mode="multiple"
allowClear
style={{ width: '100%' }}
placeholder={intl.formatMessage({
id: 'map.filter.ship_warning_placeholder',
defaultMessage: 'Select Warning',
})}
options={alarmListLabel}
/>
</ProForm.Item>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col span={currentUser?.metadata?.user_type === 'enduser' ? 11 : 24}>
<ProForm.Item
name="group_id"
label={intl.formatMessage({
id: 'map.filter.area_type',
defaultMessage: 'Area Type',
})}
>
<TreeSelectedGroup
groupIds={initialValues?.group_id}
onSelected={handleGroupSelect}
multiple
/>
</ProForm.Item>
</Col>
{currentUser?.metadata?.user_type === 'enduser' && (
<Col span={13}>
<ProForm.Item
name="ship_group_id"
label={intl.formatMessage({
id: 'map.filter.ship_groups',
defaultMessage: 'Ship Groups',
})}
>
<Select
mode="multiple"
allowClear
style={{ width: '100%' }}
placeholder={intl.formatMessage({
id: 'map.filter.ship_groups_placeholder',
defaultMessage: 'Select Ship Groups',
})}
options={groupShips?.map((group) => ({
label: (
<Tooltip title={group.description}> {group.name}</Tooltip>
),
value: group.id,
}))}
/>
</ProForm.Item>
</Col>
)}
</Row>
</ModalForm>
);
};
export default ShipSearchForm;

View File

@@ -0,0 +1,259 @@
import { formatDate } from '@/utils/slave/sgw/timeUtils';
import { getTripState } from '@/utils/slave/sgw/tripUtils';
import { ProDescriptions } from '@ant-design/pro-components';
import { useIntl } from '@umijs/max';
import { ShipDetailData } from '../type';
interface ShipSpecificationTabProps {
ship: ShipDetailData | null;
}
export const ShipSpecificationTab = ({ ship }: ShipSpecificationTabProps) => {
const intl = useIntl();
return (
<ProDescriptions
styles={{
title: {
marginBottom: -16,
textAlign: 'center',
marginTop: 10,
fontSize: window.innerWidth <= 576 ? '14px' : '16px',
},
content: { paddingBottom: 1 },
label: { paddingBottom: 1 },
}}
dataSource={{
reg_number: ship?.ship?.reg_number || '-',
imo_number: ship?.ship?.imo_number || '-',
mmsi_number: ship?.ship?.mmsi_number || '-',
ship_owner: ship?.owner?.metadata?.full_name || '-',
ship_type: ship?.ship?.metadata?.ship_type || '-',
ship_length: ship?.ship?.ship_length || '-',
power_kw: ship?.ship?.ship_power ?? '-',
fishing_license_number: ship?.ship?.fishing_license_number || '-',
fishing_license_expiry_date:
formatDate(ship?.ship?.fishing_license_expiry_date || '') ?? '-',
}}
column={{ xs: 1, sm: 2, md: 3 }}
columns={[
{
title: intl.formatMessage({
id: 'map.ship_detail.reg_number',
defaultMessage: 'Registration Number',
}),
key: 'reg_number',
dataIndex: 'reg_number',
copyable: true,
ellipsis: true,
},
{
title: intl.formatMessage({
id: 'map.ship_detail.imo_number',
defaultMessage: 'IMO Number',
}),
key: 'imo_number',
dataIndex: 'imo_number',
copyable: true,
},
{
title: intl.formatMessage({
id: 'map.ship_detail.mmsi_number',
defaultMessage: 'MMSI Number',
}),
key: 'mmsi_number',
dataIndex: 'mmsi_number',
copyable: true,
},
{
title: intl.formatMessage({
id: 'map.ship_detail.ship_owner',
defaultMessage: 'Ship Owner',
}),
key: 'ship_owner',
dataIndex: 'ship_owner',
},
{
title: intl.formatMessage({
id: 'map.filter.ship_type',
defaultMessage: 'Ship Type',
}),
key: 'ship_type',
dataIndex: 'ship_type',
},
{
title: intl.formatMessage({
id: 'map.filter.ship_power',
defaultMessage: 'Power (kW)',
}),
key: 'power_kw',
dataIndex: 'power_kw',
render: (text) => `${text} kw`,
},
{
title: intl.formatMessage({
id: 'map.filter.ship_length',
defaultMessage: 'Ship Length (m)',
}),
key: 'ship_length',
dataIndex: 'ship_length',
render: (text) => `${text} m`,
},
{
title: intl.formatMessage({
id: 'map.ship_detail.fishing_license',
defaultMessage: 'Fishing License',
}),
tooltip: intl.formatMessage({
id: 'map.ship_detail.fishing_license_number',
defaultMessage: 'Fishing License Number',
}),
key: 'fishing_license_number',
dataIndex: 'fishing_license_number',
},
{
title: intl.formatMessage({
id: 'map.ship_detail.fishing_license_expiry',
defaultMessage: 'Expiry',
}),
tooltip: intl.formatMessage({
id: 'map.ship_detail.fishing_license_expiry_message',
defaultMessage: 'Fishing License Expiration Date',
}),
key: 'fishing_license_expiry_date',
dataIndex: 'fishing_license_expiry_date',
},
]}
/>
);
};
export const ShipTripInfoTab = ({ ship }: ShipSpecificationTabProps) => {
const intl = useIntl();
return (
<>
<ProDescriptions
styles={{
title: {
marginBottom: -16,
textAlign: 'center',
fontSize: window.innerWidth <= 576 ? '14px' : '16px',
},
content: { paddingBottom: 1 },
label: { paddingBottom: 1 },
}}
/>
<ProDescriptions column={2} style={{ marginBottom: 10 }}>
<ProDescriptions.Item
label={intl.formatMessage({
id: 'map.ship_detail.trip_name',
defaultMessage: 'Trip Name',
})}
>
{ship?.ship?.metadata?.trip_name || '-'}
</ProDescriptions.Item>
<ProDescriptions.Item
label={intl.formatMessage({
id: 'map.ship_detail.trip_state',
defaultMessage: 'Status',
})}
>
{getTripState(ship?.ship?.metadata?.trip_state) || '-'}
</ProDescriptions.Item>
</ProDescriptions>
{/* Hàng 2 + Hàng 3 */}
<ProDescriptions column={3} style={{ marginBottom: 10 }}>
<ProDescriptions.Item
label={intl.formatMessage({
id: 'map.ship_detail.captain',
defaultMessage: 'Captain',
})}
valueType={'text'}
>
{/* {ship?.ship?.metadata?.captain ?? '-'} */} -
</ProDescriptions.Item>
<ProDescriptions.Item
ellipsis
style={{ maxWidth: 200 }}
tooltip={intl.formatMessage({
id: 'map.ship_detail.trip_departure_port_tooltip',
defaultMessage: 'Departure Port',
})}
label={intl.formatMessage({
id: 'map.ship_detail.trip_departure_port',
defaultMessage: 'Departure Port',
})}
>
{ship?.ship?.metadata?.trip_depart_port || '-'}
</ProDescriptions.Item>
<ProDescriptions.Item
tooltip={intl.formatMessage({
id: 'map.ship_detail.trip_departure_time_tooltip',
defaultMessage: 'Departure Time',
})}
label={intl.formatMessage({
id: 'map.ship_detail.trip_departure_time',
defaultMessage: 'Departure Time',
})}
>
{ship?.ship?.metadata?.trip_departure_time
? formatDate(ship?.ship?.metadata?.trip_departure_time)
: '-'}
</ProDescriptions.Item>
<ProDescriptions.Item
label={intl.formatMessage({
id: 'map.ship_detail.trip_crews',
defaultMessage: 'Crews',
})}
valueType={'text'}
>
{ship?.ship?.metadata?.crew_count ?? '-'} người
</ProDescriptions.Item>
<ProDescriptions.Item
ellipsis
style={{ maxWidth: 200 }}
tooltip={intl.formatMessage({
id: 'map.ship_detail.trip_arrival_port_tooltip',
defaultMessage: 'Arrival Port',
})}
label={`${intl.formatMessage({
id: 'map.ship_detail.trip_arrival_port',
defaultMessage: 'Arrival Port',
})} ${
ship?.ship.metadata?.trip_state !== 4
? `(${intl.formatMessage({
id: 'map.ship_detail.trip_estimated',
defaultMessage: 'Estimated',
})})`
: ''
}`}
>
{ship?.ship?.metadata?.trip_arrival_port || '-'}
</ProDescriptions.Item>
<ProDescriptions.Item
ellipsis
style={{ maxWidth: 200 }}
tooltip={intl.formatMessage({
id: 'map.ship_detail.trip_arrival_time_tooltip',
defaultMessage: 'Arrival Time',
})}
label={`${intl.formatMessage({
id: 'map.ship_detail.trip_arrival_time',
defaultMessage: 'Arrival Time',
})} ${
ship?.ship.metadata?.trip_state !== 4
? `(${intl.formatMessage({
id: 'map.ship_detail.trip_estimated',
defaultMessage: 'Estimated',
})})`
: ''
}`}
>
{ship?.ship?.metadata?.trip_arrival_time
? formatDate(ship?.ship?.metadata?.trip_arrival_time)
: '-'}
</ProDescriptions.Item>
</ProDescriptions>
</>
);
};

View File

@@ -0,0 +1,168 @@
import { InfoOutlined, WarningOutlined } from '@ant-design/icons';
import { useIntl } from '@umijs/max';
import { Button, Collapse, Flex, List, Typography } from 'antd';
import dayjs from 'dayjs';
import { ZoneAlarmParse } from '../type';
interface ShipWarningListProps {
zoneEnteredList: ZoneAlarmParse[];
zoneApproachingList: ZoneAlarmParse[];
selectedZoneId: string | null;
onZoneButtonClick: (zone: ZoneAlarmParse) => void;
}
const ShipWarningList = ({
zoneEnteredList,
zoneApproachingList,
selectedZoneId,
onZoneButtonClick,
}: ShipWarningListProps) => {
const intl = useIntl();
return (
<Collapse
ghost
defaultActiveKey={
zoneEnteredList.length > 0 && zoneApproachingList.length === 0
? ['1']
: zoneApproachingList.length > 0 && zoneEnteredList.length === 0
? ['2']
: []
}
expandIconPosition="end"
items={[
...(zoneEnteredList.length > 0
? [
{
key: '1',
label: (
<Flex justify="center">
<Typography.Text keyboard type="danger" strong>
🚫{' '}
{intl.formatMessage({
id: 'map.ship_detail.warning_title',
defaultMessage: 'Warning',
})}
</Typography.Text>
</Flex>
),
children: (
<List
size="small"
style={{ padding: '10px 0px' }}
dataSource={zoneEnteredList}
renderItem={(item) => (
<List.Item style={{ marginBottom: 4 }}>
<List.Item.Meta
title={`${item.zone_name} - ${item.message}`}
description={`${intl.formatMessage({
id: 'map.ship_detail.speed',
defaultMessage: 'Speed',
})}: ${item.s}km/h - ${intl.formatMessage({
id: 'map.ship_detail.heading',
defaultMessage: 'Heading',
})}: ${item.h}° - ${intl.formatMessage({
id: 'map.ship_detail.trip_departure_time',
defaultMessage: 'Time',
})}: ${dayjs
.unix(item?.gps_time || 0)
.format('YYYY-MM-DD HH:mm:ss')}`}
/>
<Button
size="small"
shape="circle"
type="default"
style={{
backgroundColor:
selectedZoneId === item.zone_id
? '#DC143C'
: undefined,
color:
selectedZoneId === item.zone_id
? '#fff'
: undefined,
borderColor:
selectedZoneId === item.zone_id
? '#DC143C'
: undefined,
}}
onClick={() => onZoneButtonClick(item)}
icon={<WarningOutlined />}
/>
</List.Item>
)}
/>
),
},
]
: []),
...(zoneApproachingList.length > 0
? [
{
key: '2',
label: (
<Flex justify="center">
<Typography.Text keyboard type="warning" strong>
{' '}
{intl.formatMessage({
id: 'map.ship_detail.approaching_title',
defaultMessage: 'Approaching Restricted Area',
})}
</Typography.Text>
</Flex>
),
children: (
<List
size="small"
style={{ padding: '10px 0px' }}
dataSource={zoneApproachingList}
renderItem={(item) => (
<List.Item style={{ marginBottom: 4 }}>
<List.Item.Meta
title={`${item.zone_name} - ${item.message}`}
description={`${intl.formatMessage({
id: 'map.ship_detail.speed',
defaultMessage: 'Speed',
})}: ${item.s}km/h - ${intl.formatMessage({
id: 'map.ship_detail.heading',
defaultMessage: 'Heading',
})}: ${item.h}° - ${intl.formatMessage({
id: 'map.ship_detail.trip_departure_time',
defaultMessage: 'Time',
})}: ${dayjs
.unix(item?.gps_time || 0)
.format('YYYY-MM-DD HH:mm:ss')}`}
/>
<Button
size="small"
shape="circle"
type="default"
style={{
backgroundColor:
selectedZoneId === item.zone_id
? '#FF714B'
: undefined,
color:
selectedZoneId === item.zone_id
? '#fff'
: undefined,
borderColor:
selectedZoneId === item.zone_id
? '#FF714B'
: undefined,
}}
onClick={() => onZoneButtonClick(item)}
icon={<InfoOutlined />}
/>
</List.Item>
)}
/>
),
},
]
: []),
]}
/>
);
};
export default ShipWarningList;

View File

@@ -0,0 +1,95 @@
import { AlertFilled, ClockCircleOutlined } from '@ant-design/icons';
import { Typography } from 'antd';
import { createRoot } from 'react-dom/client';
const { Text } = Typography;
interface SosOverlayContentProps {
shipName?: string;
reason?: string;
time?: string;
}
const SosOverlayContent = ({
shipName,
reason,
time,
}: SosOverlayContentProps) => {
return (
<div
style={{
background: 'rgba(255, 255, 255, 0.95)',
border: '2px solid #ff4d4f',
borderRadius: '8px',
padding: '8px 12px',
minWidth: '180px',
boxShadow: '0 4px 12px rgba(255, 77, 79, 0.3)',
animation: 'pulse 2s infinite',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
marginBottom: '4px',
}}
>
<AlertFilled style={{ color: '#ff4d4f', fontSize: '16px' }} />
<Text strong style={{ color: '#ff4d4f', fontSize: '14px' }}>
SOS CẢNH BÁO
</Text>
</div>
{shipName && (
<div style={{ marginBottom: '2px' }}>
<Text style={{ fontSize: '12px' }}>
<Text strong>Tàu:</Text> {shipName}
</Text>
</div>
)}
{reason && (
<div style={{ marginBottom: '2px' }}>
<Text style={{ fontSize: '12px' }}>
<Text strong> do:</Text> {reason}
</Text>
</div>
)}
{time && (
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<ClockCircleOutlined style={{ fontSize: '12px', color: '#999' }} />
<Text style={{ fontSize: '11px', color: '#666' }}>{time}</Text>
</div>
)}
<style>{`
@keyframes pulse {
0%, 100% {
box-shadow: 0 4px 12px rgba(255, 77, 79, 0.3);
}
50% {
box-shadow: 0 4px 20px rgba(255, 77, 79, 0.6);
}
}
`}</style>
</div>
);
};
/**
* Render SOS Overlay vào một HTML element
* @param element - HTML element target
* @param props - Props cho SosOverlayContent
* @returns Hàm cleanup để unmount React root
*/
export function renderSosOverlay(
element: HTMLElement,
props: SosOverlayContentProps,
): () => void {
const root = createRoot(element);
root.render(<SosOverlayContent {...props} />);
return () => {
root.unmount();
};
}
export default SosOverlayContent;

View File

@@ -0,0 +1,309 @@
import { getTheme } from '@/components/Theme/ThemeSwitcher';
import { useToken } from '@ant-design/pro-components';
import { useIntl } from '@umijs/max';
import { Checkbox, Flex } from 'antd';
import BaseLayer from 'ol/layer/Base';
import React, {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from 'react';
import {
INITIAL_VIEW_CONFIG,
LAYERS,
wmsBoundaryLineLayer,
wmsEntryBanzoneLayer,
wmsFishingBanzoneLayer,
wmsPortsLayer,
wmsTinhLayer,
} from '../config/MapConfig';
import { BaseMap } from './BaseMap';
export interface VietNamMapProps {
style?: React.CSSProperties;
onFeatureClick?: (feature: any) => void;
onFeaturesClick?: (features: any[]) => void;
onError?: (error: Error) => void;
onMapReady?: (baseMap: BaseMap) => void;
}
export interface VietNamMapRef {
baseMap: BaseMap | null;
getBaseMap: () => BaseMap | null;
}
interface LayerInfo {
name: string;
layer: BaseLayer;
visible: boolean;
mandatory: boolean;
}
const VietNamMap = forwardRef<VietNamMapRef, VietNamMapProps>(
(
{ style = {}, onFeatureClick, onFeaturesClick, onError, onMapReady },
ref,
) => {
const token = useToken();
const mapRef = useRef<HTMLDivElement>(null);
const baseMapRef = useRef<BaseMap | null>(null);
const [layers, setLayers] = useState<LayerInfo[]>([]);
const intl = useIntl();
const [isDark, setIsDark] = useState(getTheme() === 'dark');
const [isMapReady, setIsMapReady] = useState(false);
// Expose the BaseMap instance through ref
useImperativeHandle(ref, () => ({
baseMap: baseMapRef.current,
getBaseMap: () => baseMapRef.current,
}));
// Listen for theme change
useEffect(() => {
const handleThemeChange = (e: CustomEvent) => {
setIsDark(e.detail.theme === 'dark');
};
window.addEventListener(
'theme-change',
handleThemeChange as EventListener,
);
return () => {
window.removeEventListener(
'theme-change',
handleThemeChange as EventListener,
);
};
}, []);
// Update base layer based on theme
useEffect(() => {
const baseMap = baseMapRef.current;
if (!baseMap || !isMapReady) {
return;
}
const baseLayerType = isDark ? 'dark' : 'osm';
baseMap.setBaseLayer(baseLayerType);
}, [isDark, isMapReady]);
const getLayerDisplayName = (layerKey: string) => {
switch (layerKey) {
case LAYERS.FISHING_BANZONE.name:
return intl.formatMessage({
id: 'map.layer.fishing_ban_zone',
defaultMessage: 'Vùng cấm đánh bắt cá',
});
case LAYERS.ENTRY_BANZONE.name:
return intl.formatMessage({
id: 'map.layer.entry_ban_zone',
defaultMessage: 'Vùng cấm nhập cảnh',
});
case LAYERS.BOUNDARY_LINES.name:
return intl.formatMessage({
id: 'map.layer.boundary_lines',
defaultMessage: 'Đường biên giới',
});
case LAYERS.EXIT_BANZONE.name:
return intl.formatMessage({
id: 'map.layer.exit_ban_zone',
defaultMessage: 'Vùng cấm xuất cảnh',
});
case LAYERS.PORTS.name:
return intl.formatMessage({
id: 'map.layer.ports',
defaultMessage: 'Cảng cá',
});
case LAYERS.TINH.name:
return intl.formatMessage({
id: 'map.layer.tinh',
defaultMessage: 'Tỉnh',
});
default:
return layerKey;
}
};
useEffect(() => {
console.log('Rerender');
// Initialize BaseMap instance
if (!baseMapRef.current && mapRef.current) {
const baseMap = new BaseMap();
baseMapRef.current = baseMap;
try {
// Initialize the map
baseMap.initMap(mapRef.current);
// Set initial view to Vietnam
baseMap.setView(
INITIAL_VIEW_CONFIG.center as [number, number],
INITIAL_VIEW_CONFIG.zoom,
);
// Add WMS layers
const layerInfos: LayerInfo[] = [];
// Tỉnh layer (mandatory)
wmsTinhLayer.setProperties({
id: LAYERS.TINH.name,
name: LAYERS.TINH.name,
});
wmsTinhLayer.setVisible(true);
baseMap.addLayer(wmsTinhLayer);
layerInfos.push({
name: LAYERS.TINH.name,
layer: wmsTinhLayer,
visible: true,
mandatory: true,
});
// console.log('Added Tỉnh layer with ID:', LAYERS.TINH.name);
// Optional layers
const optionalLayers = [
{ layer: wmsEntryBanzoneLayer, config: LAYERS.ENTRY_BANZONE },
{ layer: wmsBoundaryLineLayer, config: LAYERS.BOUNDARY_LINES },
{ layer: wmsFishingBanzoneLayer, config: LAYERS.FISHING_BANZONE },
{ layer: wmsPortsLayer, config: LAYERS.PORTS },
];
optionalLayers.forEach(({ layer, config }) => {
layer.setProperties({
id: config.name,
name: config.name,
});
layer.setVisible(true);
baseMap.addLayer(layer);
layerInfos.push({
name: config.name,
layer: layer,
visible: true,
mandatory: false,
});
// console.log(`Added layer with ID: ${config.name}`);
});
setLayers(layerInfos);
// Register click handler
baseMap.onClick((feature) => {
if (feature) {
if (onFeatureClick) {
onFeatureClick(feature);
}
}
});
baseMap.onClickMultiple((features) => {
if (features && features.length > 0) {
if (onFeaturesClick) {
onFeaturesClick(features);
}
}
});
// // Register pointer move handler for hover effects
// baseMap.onPointerMove((feature) => {
// if (feature && onFeatureSelect) {
// onFeatureSelect(feature);
// }
// });
// Call onMapReady with the BaseMap instance
if (onMapReady) {
onMapReady(baseMap);
}
// Map is now ready
setIsMapReady(true);
} catch (error) {
console.error('Failed to initialize map:', error);
if (onError) {
onError(error as Error);
}
}
}
// Cleanup
return () => {
if (baseMapRef.current) {
baseMapRef.current.destroyMap();
baseMapRef.current = null;
}
};
}, []);
// Handle layer visibility toggle
const handleToggleLayer = (layerName: string, visible: boolean) => {
const baseMap = baseMapRef.current;
if (!baseMap) return;
baseMap.toggleLayer(layerName, visible);
setLayers((prevLayers) =>
prevLayers.map((layer) =>
layer.name === layerName ? { ...layer, visible } : layer,
),
);
};
return (
<div style={{ position: 'relative' }}>
{/* Layer control panel */}
<div
style={{
position: 'absolute',
top: 0,
right: 0,
zIndex: 10,
background: token.token.colorBgContainer,
padding: '8px 12px',
borderRadius: 4,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
}}
>
<h3 className="mb-3 font-semibold text-center">
{intl.formatMessage({
id: 'map.layer.list',
defaultMessage: 'Danh sách khu vực',
})}
</h3>
<Flex vertical gap="small">
{layers.map(
(layer) =>
!layer.mandatory && (
<Checkbox
key={layer.name}
checked={layer.visible}
onChange={(e) =>
handleToggleLayer(layer.name, e.target.checked)
}
>
{getLayerDisplayName(layer.name)}
</Checkbox>
),
)}
</Flex>
</div>
{/* Map container */}
<div
ref={mapRef}
style={{
...style,
height: '100vh', // Default height
width: '100vw',
}}
/>
</div>
);
},
);
VietNamMap.displayName = 'VietNamMap';
export default VietNamMap;

View File

@@ -0,0 +1,117 @@
import {
STATUS_DANGEROUS,
STATUS_NORMAL,
STATUS_SOS,
STATUS_WARNING,
} from '@/constants';
import { createWmsLayer } from '@/utils/slave/sgw/mapUtils';
import TileLayer from 'ol/layer/Tile';
import { XYZ } from 'ol/source';
export const GEOSERVER_URL = '/geoserver'; // Sử dụng proxy của CRA
export const WORKSPACE = 'vn_gadm_shp';
export const PROJECTION = 'EPSG:3857';
export const LAYERS = {
TINH: {
name: 'DiaPhan_Tinh_2025',
wmsVisibleUpTo: 9.99,
vectorVisibleUpTo: 9.99,
nameProperty: 'tenTinh',
// Các thuộc tính sẽ hiển thị trong popup
// Key: Nhãn hiển thị, Value: Tên thuộc tính trong dữ liệu
popupProperties: { 'Dân số': 'danSo', 'Diện tích (km²)': 'dienTich' },
},
XA: {
name: 'DiaPhan_Xa_2025',
wmsVisibleFrom: 10,
vectorVisibleFrom: 10,
nameProperty: 'tenXa',
// Các thuộc tính sẽ hiển thị trong popup
// Key: Nhãn hiển thị, Value: Tên thuộc tính trong dữ liệu
popupProperties: { 'Dân số': 'danSo', 'Diện tích (km²)': 'dienTich' },
},
FISHING_BANZONE: {
name: 'fishing_ban_zones',
wmsVisibleFrom: 10,
vectorVisibleFrom: 10,
nameProperty: 'name',
popupProperties: {},
},
ENTRY_BANZONE: {
name: 'entry_ban_zones',
wmsVisibleFrom: 10,
vectorVisibleFrom: 10,
nameProperty: 'name',
popupProperties: {},
},
BOUNDARY_LINES: {
name: 'boundary_lines',
wmsVisibleFrom: 10,
vectorVisibleFrom: 10,
nameProperty: 'name',
popupProperties: {},
},
EXIT_BANZONE: {
name: 'exit_ban_zones',
wmsVisibleFrom: 10,
vectorVisibleFrom: 10,
nameProperty: 'name',
popupProperties: {},
},
PORTS: {
name: 'ports',
wmsVisibleFrom: 10,
vectorVisibleFrom: 10,
nameProperty: 'name',
popupProperties: {},
},
};
export const BASEMAP_URL =
'https://cartodb-basemaps-a.global.ssl.fastly.net/light_nolabels/{z}/{x}/{y}.png';
export const BASEMAP_ATTRIBUTIONS = '© OpenStreetMap contributors, © CartoDB';
export const INITIAL_VIEW_CONFIG = {
// Dịch tâm bản đồ ra phía biển và zoom ra xa hơn để thấy bao quát
center: [116.152685, 15.70581],
zoom: 6.5,
minZoom: 5,
maxZoom: 12,
minScale: 0.1,
maxScale: 0.4,
};
export const osmLayer = new TileLayer({
source: new XYZ({
url: BASEMAP_URL,
attributions: BASEMAP_ATTRIBUTIONS,
}),
});
export const wmsTinhLayer = createWmsLayer(LAYERS.TINH.name);
export const wmsXaLayer = createWmsLayer(LAYERS.XA.name);
export const wmsEntryBanzoneLayer = createWmsLayer(LAYERS.ENTRY_BANZONE.name);
export const wmsBoundaryLineLayer = createWmsLayer(LAYERS.BOUNDARY_LINES.name);
export const wmsFishingBanzoneLayer = createWmsLayer(
LAYERS.FISHING_BANZONE.name,
);
export const wmsExitBanzoneLayer = createWmsLayer(LAYERS.EXIT_BANZONE.name);
export const wmsPortsLayer = createWmsLayer(LAYERS.PORTS.name);
export const getShipNameColor = (status: number) => {
switch (status) {
case STATUS_NORMAL:
return 'green';
case STATUS_WARNING:
return 'orange';
case STATUS_DANGEROUS:
return 'red';
case STATUS_SOS:
return 'red';
default:
return 'black';
}
};

View File

@@ -0,0 +1,78 @@
.italic {
//font-style: italic;
margin-left: 4px;
}
.disconnected {
color: rgb(146, 143, 143);
//background-color: rgb(219, 220, 222);
cursor: pointer;
}
:global(.cursor-pointer-row .ant-table-tbody > tr) {
cursor: pointer;
}
.normalActive {
color: #52c41a;
background: #e0fec3;
border-color: #b7eb8f;
}
.warningActive {
color: #faad14;
background: #f8ebaa;
border-color: #ffe58f;
}
.criticalActive {
color: #ff4d4f;
background: #f9b9b0;
border-color: #ffccc7;
}
.normal {
color: #52c41a;
background: #fff;
border-color: #b7eb8f;
}
.warning {
color: #faad14;
background: #fff;
border-color: #ffe58f;
}
.critical {
color: #ff4d4f;
background: #fff;
border-color: #ffccc7;
}
.offline {
background: #fff;
color: rgba(0, 0, 0, 88%);
border-color: #d9d9d9;
}
.offlineActive {
background: rgb(190, 190, 190);
color: rgba(0, 0, 0, 88%);
border-color: #d9d9d9;
}
.online {
background: #fff;
color: #1677ff;
border-color: #91caff;
}
.onlineActive {
background: #c6e1f5;
color: #1677ff;
border-color: #91caff;
}
.table-row-select tbody tr:hover {
cursor: pointer;
}

View File

@@ -1,11 +1,645 @@
import React from 'react'; import { getBadgeStatus } from '@/components/shared/ThingShared';
import { DURATION_POLLING_PRESENTATIONS } from '@/constants';
import useGetShipSos from '@/models/slave/sgw/useShipSos';
import { apiSearchThings } from '@/services/master/ThingController';
import { formatUnixTime } from '@/utils/slave/sgw/timeUtils';
import { DownOutlined, FilterOutlined, UpOutlined } from '@ant-design/icons';
import type { ActionType, ProColumns } from '@ant-design/pro-components';
import { ParamsType, useToken } from '@ant-design/pro-components';
import ProTable from '@ant-design/pro-table';
import { useIntl } from '@umijs/max';
import {
Button,
Drawer,
Flex,
message,
notification,
Tag,
Tooltip,
Typography,
} from 'antd';
import { Feature } from 'ol';
import VectorLayer from 'ol/layer/Vector';
import VectorSource from 'ol/source/Vector';
import { useCallback, useEffect, useRef, useState } from 'react';
import TagState from '../../../../components/shared/TagState';
import { BaseMap } from './components/BaseMap';
import MultipleShips from './components/MultipleShips';
import ShipDetail, { ShipDetailMessageAction } from './components/ShipDetail';
import ShipSearchForm, {
SearchShipResponse,
} from './components/ShipSearchForm';
import { renderSosOverlay } from './components/SosOverlay';
import VietNamMap, { VietNamMapRef } from './components/VietNamMap';
import { getShipNameColor } from './config/MapConfig';
import styles from './index.less';
import {
DATA_LAYER,
GPSParseResult,
TagStateCallbackPayload,
TEMPORARY_LAYER,
} from './type';
const MapPage = () => {
// Create a ref for VietNamMap to access BaseMap methods
const vietNamMapRef = useRef<VietNamMapRef>(null);
const baseMap = useRef<BaseMap | null>(null);
// Lưu các cancel functions của sosPulse để cleanup
const sosPulseCancelRefs = useRef<(() => void)[]>([]);
// Lưu các cleanup functions của overlay để cleanup
const overlayCleanupRefs = useRef<Map<string, () => void>>(new Map());
const [stateQuery, setStateQuery] = useState<
TagStateCallbackPayload | undefined
>(undefined);
const token = useToken();
const intl = useIntl();
const [things, setThings] = useState<SgwModel.SgwThingsResponse | null>(null);
const [isCollapsed, setIsCollapsed] = useState<boolean>(true);
const tableRef = useRef<ActionType>();
const [notificationApi, contextHolder] = notification.useNotification();
const [messageApi, messageContextHolder] = message.useMessage();
const [showMultiShipsDrawer, setShowMultiShipsDrawer] =
useState<boolean>(false);
const [multipleThingsSelected, setMultipleThingsSelected] = useState<
SgwModel.SgwThing[]
>([]);
const [formSearchData, setFormSearchData] =
useState<SearchShipResponse | null>(null);
const [openFormSearch, setOpenFormSearch] = useState(false);
const { shipSos, getShipSosWs } = useGetShipSos();
const handleClickOneThing = (thing: SgwModel.SgwThing) => {
if (baseMap.current === null) {
console.log('BaseMap is not ready yet');
return;
}
notificationApi.open({
message: (
<Flex justify="center">
<Typography.Title level={4}>{`${intl.formatMessage({
id: 'map.ship_detail.name',
defaultMessage: 'Device Information',
})} ${thing.name}`}</Typography.Title>
</Flex>
),
key: `ship-detail-${thing.id}`,
description: baseMap && (
<ShipDetail
thing={thing}
messageApi={messageApi}
mapController={baseMap.current}
/>
),
placement: 'bottomRight',
actions: <ShipDetailMessageAction />,
duration: 100,
onClose() {
baseMap.current?.clearFeatures(TEMPORARY_LAYER);
baseMap.current?.toggleLayer(DATA_LAYER, true);
baseMap.current?.zoomToFeaturesInLayer(DATA_LAYER);
},
style: {
width:
window.innerWidth <= 576
? '80vw'
: window.innerWidth <= 768
? '60vw'
: '800px',
maxWidth: '800px',
height: 'auto',
borderRadius: '12px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
},
});
};
const onFeaturesClick = useCallback((features: Feature[]) => {
console.log('Multiple features clicked:', features);
const things: SgwModel.SgwThing[] = features.map((feature) =>
feature.get('thing'),
) as SgwModel.SgwThing[];
setMultipleThingsSelected(things);
setShowMultiShipsDrawer(true);
}, []);
const onFeatureClick = useCallback(
(feature: Feature) => {
const thing: SgwModel.SgwThing = feature.get('thing');
baseMap.current?.zoomToFeatures([feature]);
handleClickOneThing(thing);
},
[baseMap],
);
const onError = useCallback(
(error: any) => {
console.error(
intl.formatMessage({ id: 'home.mapError' }),
'at',
new Date().toLocaleTimeString(),
':',
error,
);
},
[intl],
);
// Handler called when map is ready
const handleMapReady = useCallback((baseMapInstance: BaseMap) => {
// console.log('Map is ready!', baseMapInstance);
baseMap.current = baseMapInstance;
// Create a vector layer for dynamic features
const vectorDataLayer = new VectorLayer({
source: new VectorSource(),
});
vectorDataLayer.set('id', DATA_LAYER);
const vectorTemporaryLayer = new VectorLayer({
source: new VectorSource(),
});
vectorTemporaryLayer.set('id', TEMPORARY_LAYER);
baseMapInstance.addLayer(vectorDataLayer);
baseMapInstance.addLayer(vectorTemporaryLayer);
getShipSosWs();
}, []);
useEffect(() => {
if (shipSos !== null && things && things.things) {
const check = things?.things?.find(
(thing) => thing.id === shipSos.thing_id,
);
if (check) {
tableRef.current?.reload();
}
}
}, [shipSos]);
useEffect(() => {
if (formSearchData && tableRef.current) {
tableRef.current.reload(); // gọi lại request
}
}, [formSearchData]);
const queryThings = async (params: ParamsType) => {
try {
const { current = 1, pageSize = 10, keyword = '' } = params;
const offset = current === 1 ? 0 : (current - 1) * pageSize;
const query: MasterModel.SearchThingPaginationBody = {
offset: offset,
limit: 200,
order: 'name',
dir: 'asc',
};
const stateNormalQuery = stateQuery?.isNormal ? 'normal' : '';
const stateSosQuery = stateQuery?.isSos ? 'sos' : '';
const stateWarningQuery = stateQuery?.isWarning
? stateNormalQuery + ',warning'
: stateNormalQuery;
const stateCriticalQuery = stateQuery?.isCritical
? stateWarningQuery + ',critical'
: stateWarningQuery;
const stateQueryParams =
stateQuery?.isNormal &&
stateQuery?.isWarning &&
stateQuery?.isCritical &&
stateQuery?.isSos
? ''
: [stateCriticalQuery, stateSosQuery].filter(Boolean).join(',');
let metaFormQuery: Record<string, any> = {};
if (stateQuery?.isDisconnected)
metaFormQuery = { ...metaFormQuery, connected: false };
const metaStateQuery =
stateQueryParams !== '' ? { state_level: stateQueryParams } : null;
if (formSearchData) {
const {
ship_name,
reg_number,
ship_length,
ship_power,
ship_type,
alarm_list,
group_id,
ship_group_id,
} = formSearchData;
if (ship_name) metaFormQuery.ship_name = ship_name;
if (reg_number) metaFormQuery.ship_reg_number = reg_number;
if (Array.isArray(ship_length) && ship_length.length === 2) {
metaFormQuery.ship_length = ship_length;
}
if (Array.isArray(ship_power) && ship_power.length === 2) {
metaFormQuery.ship_power = ship_power;
}
if (Array.isArray(ship_type) && ship_type.length > 0) {
metaFormQuery.ship_type = ship_type;
}
if (Array.isArray(alarm_list) && alarm_list.length > 0) {
metaFormQuery.alarm_list = alarm_list;
}
if (group_id) metaFormQuery.group_id = group_id;
if (Array.isArray(ship_group_id) && ship_group_id.length > 0) {
metaFormQuery.ship_group_id = ship_group_id;
}
}
const metaQuery = {
...query,
metadata: {
ship_name: keyword,
...metaFormQuery,
...metaStateQuery,
not_empty: 'ship_id',
},
};
setThings(null);
const resp = await apiSearchThings(metaQuery, 'sgw');
return resp;
} catch (error) {
console.error('Error when searchThings: ', error);
return {};
}
};
const handleZoomToFeature = (layerName: string) => {
if (!baseMap) return;
// Get all features and zoom to them
const vectorLayer = baseMap.current?.getLayerById(layerName);
if (vectorLayer && vectorLayer instanceof VectorLayer) {
const features = vectorLayer.getSource()?.getFeatures();
if (features && features.length > 0) {
baseMap.current?.zoomToFeatures(features);
}
}
};
useEffect(() => {
if (!things || !baseMap.current || !things.things) return;
// Cleanup các animation và overlay cũ trước khi render lại
sosPulseCancelRefs.current.forEach((cancel) => cancel());
sosPulseCancelRefs.current = [];
overlayCleanupRefs.current.forEach((cleanup) => cleanup());
overlayCleanupRefs.current.clear();
baseMap.current.clearOverlays();
baseMap.current.clearFeatures(DATA_LAYER);
for (const thing of things.things) {
const gpsData: GPSParseResult = JSON.parse(thing.metadata?.gps || '{}');
if (gpsData.lat && gpsData.lon) {
if (thing.metadata?.state_level === 3) {
console.log('Cos tau sos');
const feature = baseMap.current?.addPoint(
DATA_LAYER,
[gpsData.lon, gpsData.lat],
{
thing,
type: 'sos-point',
},
);
// Bắt đầu hiệu ứng gợn sóng
const cancel = baseMap.current?.sosPulse(feature, DATA_LAYER);
if (cancel) {
sosPulseCancelRefs.current.push(cancel);
}
// Tạo overlay hiển thị thông tin SOS
const overlayElement = document.createElement('div');
const cleanup = renderSosOverlay(overlayElement, {
shipName: thing.metadata?.ship_name || thing.name,
reason: thing.metadata.sos || 'Không rõ lý do',
time: thing.metadata?.sos_time
? formatUnixTime(thing.metadata.sos_time)
: undefined,
});
overlayCleanupRefs.current.set(thing.id!, cleanup);
baseMap.current.addOrUpdateOverlay(
`sos-${thing.id}`,
[gpsData.lon, gpsData.lat],
overlayElement,
);
} else {
baseMap.current?.addPoint(DATA_LAYER, [gpsData.lon, gpsData.lat], {
thing,
type: 'main-point',
});
}
}
}
handleZoomToFeature(DATA_LAYER);
// Cleanup khi component unmount
return () => {
sosPulseCancelRefs.current.forEach((cancel) => cancel());
sosPulseCancelRefs.current = [];
overlayCleanupRefs.current.forEach((cleanup) => cleanup());
overlayCleanupRefs.current.clear();
};
}, [things]);
const columns: ProColumns<SgwModel.SgwThing>[] = [
{
title: intl.formatMessage({
id: 'thing.name',
defaultMessage: 'Name',
}),
dataIndex: ['metadata', 'ship_name'],
key: 'ship_name',
hideInSearch: true,
width: '30%',
ellipsis: true,
render: (_, row) => {
const text = row?.metadata?.ship_name || '';
return (
<Tooltip title={text}>
<span
style={{
color: getShipNameColor(row?.metadata?.state_level || 0),
}}
>
{text}
</span>
</Tooltip>
);
},
},
{
title: intl.formatMessage({
id: 'thing.status',
defaultMessage: 'Status',
}),
dataIndex: ['metadata', 'state_level'],
key: 'status',
hideInSearch: true,
width: '70%',
filters: true,
onFilter: true,
valueType: 'select',
valueEnum: {
0: {
text: intl.formatMessage({
id: 'thing.status.normal',
defaultMessage: 'Normal',
}),
status: 'Normal',
},
1: {
text: intl.formatMessage({
id: 'thing.status.warning',
defaultMessage: 'Warning',
}),
status: 'Warning',
},
2: {
text: intl.formatMessage({
id: 'thing.status.critical',
defaultMessage: 'Critical',
}),
status: 'Critical',
},
3: {
text: intl.formatMessage({
id: 'thing.status.sos',
defaultMessage: 'SOS',
}),
status: 'SOS',
},
},
ellipsis: true,
render: (_, row) => {
const alarm = JSON.parse(row?.metadata?.alarm_list || '{}');
const text = alarm?.map((a: any) => a?.name).join(', ');
return (
<Tooltip placement="top" title={text}>
{getBadgeStatus(row?.metadata?.state_level || 0)}
<span className="ml-2 text-gray-500">{text}</span>
</Tooltip>
);
},
},
];
const handleTagStateChange = (payload: TagStateCallbackPayload) => {
setStateQuery(payload);
tableRef.current?.reload();
};
const SGWMap: React.FC = () => {
return ( return (
<div> <div className="h-screen w-screen relative">
<h1>Bản đ (SGW)</h1> {contextHolder}
{messageContextHolder}
<div
style={{
position: 'absolute',
top: 5,
left: 2,
zIndex: 10,
borderRadius: 10,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
backgroundColor: token.token.colorBgContainer,
width: isCollapsed ? '28%' : '30%',
minWidth: '25%',
maxWidth: '30%',
overflowX: 'auto',
whiteSpace: 'nowrap',
transition: 'width 0.5s ease-in-out',
}}
>
<div>
<Flex
justify="space-between"
gap={30}
align="center"
style={{
width: '100%',
padding: '8px 12px',
}}
>
<Flex gap={8} align="center">
<Button
type="text"
icon={isCollapsed ? <DownOutlined /> : <UpOutlined />}
onClick={() => setIsCollapsed(!isCollapsed)}
style={{ padding: '4px 8px' }}
/>
{formSearchData === null ? (
<Button
icon={<FilterOutlined />}
onClick={(e) => {
e.stopPropagation();
messageApi.destroy();
setOpenFormSearch(true);
}}
/>
) : (
<Tag
style={{
cursor: 'pointer',
fontSize: '12px',
padding: '4px 8px',
lineHeight: '20px',
minWidth: '0',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
onClick={(e) => {
e.stopPropagation();
messageApi.destroy();
setOpenFormSearch(true);
}}
closeIcon
onClose={(e) => {
e.stopPropagation();
setFormSearchData(null);
if (tableRef.current) {
tableRef.current.reload();
}
}}
color="#87d068"
>
Tìm theo bộ lọc
</Tag>
)}
</Flex>
{isCollapsed && (
<div onClick={(e) => e.stopPropagation()}>
<TagState
normalCount={things?.metadata?.total_state_level_0 || 0}
warningCount={things?.metadata?.total_state_level_1 || 0}
criticalCount={things?.metadata?.total_state_level_2 || 0}
sosCount={things?.metadata?.total_sos || 0}
disconnectedCount={
(things?.metadata?.total_thing ?? 0) -
(things?.metadata?.total_connected ?? 0) || 0
}
onTagPress={handleTagStateChange}
/>
</div>
)}
</Flex>
<ProTable<SgwModel.SgwThing>
tableClassName="table-row-select cursor-pointer-row"
actionRef={tableRef}
toolbar={{
title: null,
actions: [
<TagState
key="tag-state"
normalCount={things?.metadata?.total_state_level_0 || 0}
warningCount={things?.metadata?.total_state_level_1 || 0}
criticalCount={things?.metadata?.total_state_level_2 || 0}
sosCount={things?.metadata?.total_sos || 0}
disconnectedCount={
(things?.metadata?.total_thing ?? 0) -
(things?.metadata?.total_connected ?? 0) || 0
}
onTagPress={handleTagStateChange}
/>,
],
}}
bordered={true}
polling={DURATION_POLLING_PRESENTATIONS}
style={{
display: isCollapsed ? 'none' : 'block',
}}
pagination={{
size: 'small',
defaultPageSize: 10,
showSizeChanger: true,
pageSizeOptions: ['10', '15', '20'],
showTotal: (total, range) =>
`${range[0]}-${range[1]} trên ${total} ${intl.formatMessage({
id: 'thing.ships',
defaultMessage: 'ships',
})}`,
}}
columns={columns}
request={async (params: ParamsType) => {
const data = await queryThings(params);
const sortedThings = [...(data.things || [])].sort((a, b) => {
const stateLevelA = a.metadata?.state_level || 0;
const stateLevelB = b.metadata?.state_level || 0;
return stateLevelB - stateLevelA;
});
setThings(data);
return Promise.resolve({
success: true,
data: sortedThings,
total: data?.metadata?.total_filter || 0,
});
}}
onRow={(row) => {
return {
onClick: () => {
handleClickOneThing(row);
},
};
}}
rowClassName={(record) =>
record?.metadata?.state_level
? 'cursor-pointer'
: `${styles.disconnected} cursor-pointer`
}
options={{
search: false,
setting: false,
density: false,
reload: true,
}}
defaultSize="small"
search={false}
rowKey="id"
dateFormatter="string"
/>
</div>
</div>
{/* VietNamMap with ref */}
<VietNamMap
ref={vietNamMapRef}
style={{
height: '100vh',
width: '100vw',
}}
onMapReady={handleMapReady}
onFeatureClick={onFeatureClick}
onFeaturesClick={onFeaturesClick}
onError={onError}
/>
<Drawer
styles={{
header: {
padding: 5,
},
}}
open={showMultiShipsDrawer}
onClose={() => {
setShowMultiShipsDrawer(false);
setMultipleThingsSelected([]);
}}
title={
<Flex align="center" justify="center">
<Typography.Title level={4}>
Thông tin {multipleThingsSelected.length} tàu
</Typography.Title>
</Flex>
}
size="large"
>
<MultipleShips
things={multipleThingsSelected}
messageApi={messageApi}
mapController={baseMap.current}
/>
</Drawer>
<ShipSearchForm
initialValues={formSearchData || undefined}
isModalOpen={openFormSearch}
onClose={setOpenFormSearch}
things={things?.things || []}
onSubmit={(value: SearchShipResponse) => {
setFormSearchData(value);
}}
/>
</div> </div>
); );
}; };
export default SGWMap; export default MapPage;

Some files were not shown because too many files have changed in this diff Show More