Compare commits
3 Commits
3087af11e1
...
9f1ab4115e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f1ab4115e | ||
|
|
6487e9105e | ||
|
|
27494e7868 |
104
package-lock.json
generated
104
package-lock.json
generated
@@ -15,13 +15,16 @@
|
|||||||
"@mui/material": "^7.3.6",
|
"@mui/material": "^7.3.6",
|
||||||
"@mui/x-data-grid": "^8.21.0",
|
"@mui/x-data-grid": "^8.21.0",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.559.0",
|
"lucide-react": "^0.559.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-leaflet": "^4.2.1",
|
||||||
"recharts": "^3.6.0",
|
"recharts": "^3.6.0",
|
||||||
"tailwindcss": "^4.1.18"
|
"tailwindcss": "^4.1.18"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/react": "^18.2.66",
|
"@types/react": "^18.2.66",
|
||||||
"@types/react-dom": "^18.2.22",
|
"@types/react-dom": "^18.2.22",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
||||||
@@ -523,6 +526,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -539,6 +543,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -555,6 +560,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -571,6 +577,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -587,6 +594,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -603,6 +611,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -619,6 +628,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -635,6 +645,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -651,6 +662,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -667,6 +679,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -683,6 +696,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -699,6 +713,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -715,6 +730,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"mips64el"
|
"mips64el"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -731,6 +747,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -747,6 +764,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -763,6 +781,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -779,6 +798,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -795,6 +815,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -811,6 +832,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -827,6 +849,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -843,6 +866,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -859,6 +883,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -875,6 +900,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1575,6 +1601,17 @@
|
|||||||
"url": "https://opencollective.com/popperjs"
|
"url": "https://opencollective.com/popperjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-leaflet/core": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==",
|
||||||
|
"license": "Hippocratic-2.1",
|
||||||
|
"peerDependencies": {
|
||||||
|
"leaflet": "^1.9.0",
|
||||||
|
"react": "^18.0.0",
|
||||||
|
"react-dom": "^18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@reduxjs/toolkit": {
|
"node_modules/@reduxjs/toolkit": {
|
||||||
"version": "2.11.2",
|
"version": "2.11.2",
|
||||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||||
@@ -1625,6 +1662,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1638,6 +1676,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1651,6 +1690,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1664,6 +1704,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1677,6 +1718,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1690,6 +1732,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1703,6 +1746,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1716,6 +1760,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1729,6 +1774,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1742,6 +1788,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1755,6 +1802,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1768,6 +1816,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1781,6 +1830,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1794,6 +1844,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1807,6 +1858,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1820,6 +1872,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1833,6 +1886,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1846,6 +1900,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1859,6 +1914,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1872,6 +1928,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1885,6 +1942,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1898,6 +1956,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2285,8 +2344,26 @@
|
|||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/geojson": {
|
||||||
|
"version": "7946.0.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
|
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/leaflet": {
|
||||||
|
"version": "1.9.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||||
|
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/geojson": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/parse-json": {
|
"node_modules/@types/parse-json": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
|
||||||
@@ -2303,6 +2380,7 @@
|
|||||||
"version": "18.3.27",
|
"version": "18.3.27",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
||||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
@@ -3145,6 +3223,7 @@
|
|||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||||
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
|
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
|
||||||
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -3548,6 +3627,7 @@
|
|||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -3958,6 +4038,12 @@
|
|||||||
"json-buffer": "3.0.1"
|
"json-buffer": "3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/leaflet": {
|
||||||
|
"version": "1.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/levn": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
@@ -4340,6 +4426,7 @@
|
|||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||||
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -4535,6 +4622,7 @@
|
|||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||||
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -4654,6 +4742,20 @@
|
|||||||
"integrity": "sha512-L7BnWgRbMwzMAubQcS7sXdPdNLmKlucPlopgAzx7FtYbksWZgEWiuYM5x9T6UqS2Ne0rsgQTq5kY2SGqpzUkYA==",
|
"integrity": "sha512-L7BnWgRbMwzMAubQcS7sXdPdNLmKlucPlopgAzx7FtYbksWZgEWiuYM5x9T6UqS2Ne0rsgQTq5kY2SGqpzUkYA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-leaflet": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==",
|
||||||
|
"license": "Hippocratic-2.1",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-leaflet/core": "^2.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"leaflet": "^1.9.0",
|
||||||
|
"react": "^18.0.0",
|
||||||
|
"react-dom": "^18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-redux": {
|
"node_modules/react-redux": {
|
||||||
"version": "9.2.0",
|
"version": "9.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
@@ -4815,6 +4917,7 @@
|
|||||||
"version": "4.53.3",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
|
||||||
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
|
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
@@ -5202,6 +5305,7 @@
|
|||||||
"version": "5.4.21",
|
"version": "5.4.21",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.21.3",
|
"esbuild": "^0.21.3",
|
||||||
|
|||||||
@@ -17,13 +17,16 @@
|
|||||||
"@mui/material": "^7.3.6",
|
"@mui/material": "^7.3.6",
|
||||||
"@mui/x-data-grid": "^8.21.0",
|
"@mui/x-data-grid": "^8.21.0",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.559.0",
|
"lucide-react": "^0.559.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-leaflet": "^4.2.1",
|
||||||
"recharts": "^3.6.0",
|
"recharts": "^3.6.0",
|
||||||
"tailwindcss": "^4.1.18"
|
"tailwindcss": "^4.1.18"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/react": "^18.2.66",
|
"@types/react": "^18.2.66",
|
||||||
"@types/react-dom": "^18.2.22",
|
"@types/react-dom": "^18.2.22",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
||||||
|
|||||||
14
src/App.tsx
14
src/App.tsx
@@ -13,6 +13,9 @@ import AuditoriaPage from "./pages/AuditoriaPage";
|
|||||||
import SHMetersPage from "./pages/conectores/SHMetersPage";
|
import SHMetersPage from "./pages/conectores/SHMetersPage";
|
||||||
import XMetersPage from "./pages/conectores/XMetersPage";
|
import XMetersPage from "./pages/conectores/XMetersPage";
|
||||||
import TTSPage from "./pages/conectores/TTSPage";
|
import TTSPage from "./pages/conectores/TTSPage";
|
||||||
|
import AnalyticsMapPage from "./pages/analytics/AnalyticsMapPage";
|
||||||
|
import AnalyticsReportsPage from "./pages/analytics/AnalyticsReportsPage";
|
||||||
|
import AnalyticsServerPage from "./pages/analytics/AnalyticsServerPage";
|
||||||
import ProfileModal from "./components/layout/common/ProfileModal";
|
import ProfileModal from "./components/layout/common/ProfileModal";
|
||||||
import { updateMyProfile } from "./api/me";
|
import { updateMyProfile } from "./api/me";
|
||||||
|
|
||||||
@@ -46,7 +49,10 @@ export type Page =
|
|||||||
| "roles"
|
| "roles"
|
||||||
| "sh-meters"
|
| "sh-meters"
|
||||||
| "xmeters"
|
| "xmeters"
|
||||||
| "tts";
|
| "tts"
|
||||||
|
| "analytics-map"
|
||||||
|
| "analytics-reports"
|
||||||
|
| "analytics-server";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [isAuth, setIsAuth] = useState<boolean>(false);
|
const [isAuth, setIsAuth] = useState<boolean>(false);
|
||||||
@@ -195,6 +201,12 @@ export default function App() {
|
|||||||
return <XMetersPage />;
|
return <XMetersPage />;
|
||||||
case "tts":
|
case "tts":
|
||||||
return <TTSPage />;
|
return <TTSPage />;
|
||||||
|
case "analytics-map":
|
||||||
|
return <AnalyticsMapPage />;
|
||||||
|
case "analytics-reports":
|
||||||
|
return <AnalyticsReportsPage />;
|
||||||
|
case "analytics-server":
|
||||||
|
return <AnalyticsServerPage />;
|
||||||
case "home":
|
case "home":
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
|
|||||||
86
src/api/analytics.ts
Normal file
86
src/api/analytics.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { apiClient } from './client';
|
||||||
|
|
||||||
|
export interface ServerMetrics {
|
||||||
|
uptime: number;
|
||||||
|
memory: {
|
||||||
|
total: number;
|
||||||
|
used: number;
|
||||||
|
free: number;
|
||||||
|
percentage: number;
|
||||||
|
};
|
||||||
|
cpu: {
|
||||||
|
usage: number;
|
||||||
|
cores: number;
|
||||||
|
};
|
||||||
|
requests: {
|
||||||
|
total: number;
|
||||||
|
errors: number;
|
||||||
|
avgResponseTime: number;
|
||||||
|
};
|
||||||
|
database: {
|
||||||
|
connected: boolean;
|
||||||
|
responseTime: number;
|
||||||
|
};
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeterWithCoords {
|
||||||
|
id: string;
|
||||||
|
serial_number: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
project_name: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
last_reading?: number;
|
||||||
|
last_reading_date?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportStats {
|
||||||
|
totalMeters: number;
|
||||||
|
activeMeters: number;
|
||||||
|
inactiveMeters: number;
|
||||||
|
totalConsumption: number;
|
||||||
|
totalProjects: number;
|
||||||
|
metersWithAlerts: number;
|
||||||
|
consumptionByProject: Array<{
|
||||||
|
project_name: string;
|
||||||
|
total_consumption: number;
|
||||||
|
meter_count: number;
|
||||||
|
}>;
|
||||||
|
consumptionTrend: Array<{
|
||||||
|
date: string;
|
||||||
|
consumption: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServerMetrics(): Promise<ServerMetrics> {
|
||||||
|
return apiClient.get<ServerMetrics>('/api/system/metrics');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSystemHealth(): Promise<{
|
||||||
|
status: string;
|
||||||
|
database: boolean;
|
||||||
|
uptime: number;
|
||||||
|
}> {
|
||||||
|
return apiClient.get('/api/system/health');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMetersWithCoordinates(): Promise<MeterWithCoords[]> {
|
||||||
|
return apiClient.get<MeterWithCoords[]>('/api/system/meters-locations');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReportStats(): Promise<ReportStats> {
|
||||||
|
return apiClient.get<ReportStats>('/api/system/report-stats');
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectorStats {
|
||||||
|
meterCount: number;
|
||||||
|
messagesReceived: number;
|
||||||
|
daysSinceStart: number;
|
||||||
|
meterType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConnectorStats(type: 'sh-meters' | 'xmeters'): Promise<ConnectorStats> {
|
||||||
|
return apiClient.get<ConnectorStats>(`/api/system/connector-stats/${type}`);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Menu,
|
Menu,
|
||||||
People,
|
People,
|
||||||
Cable,
|
Cable,
|
||||||
|
BarChart,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import { Page } from "../../App";
|
import { Page } from "../../App";
|
||||||
import { getCurrentUserRole } from "../../api/auth";
|
import { getCurrentUserRole } from "../../api/auth";
|
||||||
@@ -19,6 +20,7 @@ export default function Sidebar({ setPage }: SidebarProps) {
|
|||||||
const [systemOpen, setSystemOpen] = useState(true);
|
const [systemOpen, setSystemOpen] = useState(true);
|
||||||
const [usersOpen, setUsersOpen] = useState(true);
|
const [usersOpen, setUsersOpen] = useState(true);
|
||||||
const [conectoresOpen, setConectoresOpen] = useState(true);
|
const [conectoresOpen, setConectoresOpen] = useState(true);
|
||||||
|
const [analyticsOpen, setAnalyticsOpen] = useState(true);
|
||||||
const [pinned, setPinned] = useState(false);
|
const [pinned, setPinned] = useState(false);
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
@@ -223,6 +225,53 @@ export default function Sidebar({ setPage }: SidebarProps) {
|
|||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ANALYTICS - ADMIN ONLY */}
|
||||||
|
{!isOperator && (
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
onClick={() => isExpanded && setAnalyticsOpen(!analyticsOpen)}
|
||||||
|
className="flex items-center w-full px-2 py-2 rounded-md hover:bg-white/10 font-bold"
|
||||||
|
>
|
||||||
|
<BarChart className="w-5 h-5 shrink-0" />
|
||||||
|
{isExpanded && (
|
||||||
|
<>
|
||||||
|
<span className="ml-3 flex-1 text-left">Analytics</span>
|
||||||
|
{analyticsOpen ? <ExpandLess /> : <ExpandMore />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && analyticsOpen && (
|
||||||
|
<ul className="mt-1 space-y-1 text-xs">
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage("analytics-map")}
|
||||||
|
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
|
||||||
|
>
|
||||||
|
Mapa
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage("analytics-reports")}
|
||||||
|
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
|
||||||
|
>
|
||||||
|
Reportes
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage("analytics-server")}
|
||||||
|
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
|
||||||
|
>
|
||||||
|
Carga de Server
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -86,18 +86,18 @@ export default function AuditoriaPage() {
|
|||||||
|
|
||||||
const getActionColor = (action: AuditAction) => {
|
const getActionColor = (action: AuditAction) => {
|
||||||
const colors: Record<AuditAction, string> = {
|
const colors: Record<AuditAction, string> = {
|
||||||
CREATE: "bg-green-100 text-green-800",
|
CREATE: "bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400",
|
||||||
UPDATE: "bg-blue-100 text-blue-800",
|
UPDATE: "bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-400",
|
||||||
DELETE: "bg-red-100 text-red-800",
|
DELETE: "bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-400",
|
||||||
LOGIN: "bg-purple-100 text-purple-800",
|
LOGIN: "bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-400",
|
||||||
LOGOUT: "bg-gray-100 text-gray-800",
|
LOGOUT: "bg-gray-100 dark:bg-zinc-700 text-gray-800 dark:text-zinc-300",
|
||||||
READ: "bg-cyan-100 text-cyan-800",
|
READ: "bg-cyan-100 dark:bg-cyan-900/30 text-cyan-800 dark:text-cyan-400",
|
||||||
EXPORT: "bg-yellow-100 text-yellow-800",
|
EXPORT: "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-400",
|
||||||
BULK_UPLOAD: "bg-orange-100 text-orange-800",
|
BULK_UPLOAD: "bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-400",
|
||||||
STATUS_CHANGE: "bg-indigo-100 text-indigo-800",
|
STATUS_CHANGE: "bg-indigo-100 dark:bg-indigo-900/30 text-indigo-800 dark:text-indigo-400",
|
||||||
PERMISSION_CHANGE: "bg-pink-100 text-pink-800",
|
PERMISSION_CHANGE: "bg-pink-100 dark:bg-pink-900/30 text-pink-800 dark:text-pink-400",
|
||||||
};
|
};
|
||||||
return colors[action] || "bg-gray-100 text-gray-800";
|
return colors[action] || "bg-gray-100 dark:bg-zinc-700 text-gray-800 dark:text-zinc-300";
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredLogs = logs.filter((log) => {
|
const filteredLogs = logs.filter((log) => {
|
||||||
@@ -248,7 +248,7 @@ export default function AuditoriaPage() {
|
|||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200 dark:divide-zinc-700">
|
<tbody className="bg-white dark:bg-zinc-900 divide-y divide-gray-200 dark:divide-zinc-700">
|
||||||
{filteredLogs.map((log) => (
|
{filteredLogs.map((log) => (
|
||||||
<tr key={log.id} className="hover:bg-gray-50 dark:hover:bg-zinc-800">
|
<tr key={log.id} className="hover:bg-gray-50 dark:hover:bg-zinc-800">
|
||||||
<td className="px-4 py-3 text-sm text-gray-900 dark:text-zinc-100 whitespace-nowrap">
|
<td className="px-4 py-3 text-sm text-gray-900 dark:text-zinc-100 whitespace-nowrap">
|
||||||
@@ -279,8 +279,8 @@ export default function AuditoriaPage() {
|
|||||||
<span
|
<span
|
||||||
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
|
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
|
||||||
log.success
|
log.success
|
||||||
? "bg-green-100 text-green-800"
|
? "bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400"
|
||||||
: "bg-red-100 text-red-800"
|
: "bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-400"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{log.success ? "Éxito" : "Fallo"}
|
{log.success ? "Éxito" : "Fallo"}
|
||||||
@@ -307,15 +307,15 @@ export default function AuditoriaPage() {
|
|||||||
{/* Page Info */}
|
{/* Page Info */}
|
||||||
<div className="text-sm text-gray-600 dark:text-zinc-400">
|
<div className="text-sm text-gray-600 dark:text-zinc-400">
|
||||||
Mostrando{" "}
|
Mostrando{" "}
|
||||||
<span className="font-semibold text-gray-800">
|
<span className="font-semibold text-gray-800 dark:text-zinc-200">
|
||||||
{(currentPage - 1) * limit + 1}
|
{(currentPage - 1) * limit + 1}
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
a{" "}
|
a{" "}
|
||||||
<span className="font-semibold text-gray-800">
|
<span className="font-semibold text-gray-800 dark:text-zinc-200">
|
||||||
{Math.min(currentPage * limit, total)}
|
{Math.min(currentPage * limit, total)}
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
de{" "}
|
de{" "}
|
||||||
<span className="font-semibold text-gray-800">{total}</span>{" "}
|
<span className="font-semibold text-gray-800 dark:text-zinc-200">{total}</span>{" "}
|
||||||
registros
|
registros
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -344,7 +344,7 @@ export default function AuditoriaPage() {
|
|||||||
>
|
>
|
||||||
Anterior
|
Anterior
|
||||||
</button>
|
</button>
|
||||||
<span className="px-4 py-2 text-sm text-gray-700">
|
<span className="px-4 py-2 text-sm text-gray-700 dark:text-zinc-300">
|
||||||
Página {currentPage} de {totalPages}
|
Página {currentPage} de {totalPages}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@@ -365,14 +365,14 @@ export default function AuditoriaPage() {
|
|||||||
{/* Details Modal */}
|
{/* Details Modal */}
|
||||||
{showDetails && selectedLog && (
|
{showDetails && selectedLog && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
<div className="bg-white rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] overflow-auto m-4">
|
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] overflow-auto m-4 dark:border dark:border-zinc-700">
|
||||||
<div className="p-6 border-b border-gray-200">
|
<div className="p-6 border-b border-gray-200 dark:border-zinc-700">
|
||||||
<h2 className="text-xl font-semibold">Detalles del Registro</h2>
|
<h2 className="text-xl font-semibold dark:text-white">Detalles del Registro</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300">
|
||||||
ID
|
ID
|
||||||
</label>
|
</label>
|
||||||
<p className="text-sm text-gray-900 dark:text-zinc-100 font-mono">
|
<p className="text-sm text-gray-900 dark:text-zinc-100 font-mono">
|
||||||
@@ -380,7 +380,7 @@ export default function AuditoriaPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300">
|
||||||
Fecha/Hora
|
Fecha/Hora
|
||||||
</label>
|
</label>
|
||||||
<p className="text-sm text-gray-900 dark:text-zinc-100">
|
<p className="text-sm text-gray-900 dark:text-zinc-100">
|
||||||
@@ -388,14 +388,14 @@ export default function AuditoriaPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300">
|
||||||
Usuario
|
Usuario
|
||||||
</label>
|
</label>
|
||||||
<p className="text-sm text-gray-900 dark:text-zinc-100">{selectedLog.user_name}</p>
|
<p className="text-sm text-gray-900 dark:text-zinc-100">{selectedLog.user_name}</p>
|
||||||
<p className="text-xs text-gray-500">{selectedLog.user_email}</p>
|
<p className="text-xs text-gray-500 dark:text-zinc-400">{selectedLog.user_email}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300">
|
||||||
Acción
|
Acción
|
||||||
</label>
|
</label>
|
||||||
<span
|
<span
|
||||||
@@ -407,13 +407,13 @@ export default function AuditoriaPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300">
|
||||||
Tabla
|
Tabla
|
||||||
</label>
|
</label>
|
||||||
<p className="text-sm text-gray-900 dark:text-zinc-100">{selectedLog.table_name}</p>
|
<p className="text-sm text-gray-900 dark:text-zinc-100">{selectedLog.table_name}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300">
|
||||||
Record ID
|
Record ID
|
||||||
</label>
|
</label>
|
||||||
<p className="text-sm text-gray-900 dark:text-zinc-100 font-mono">
|
<p className="text-sm text-gray-900 dark:text-zinc-100 font-mono">
|
||||||
@@ -421,7 +421,7 @@ export default function AuditoriaPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300">
|
||||||
IP Address
|
IP Address
|
||||||
</label>
|
</label>
|
||||||
<p className="text-sm text-gray-900 dark:text-zinc-100">
|
<p className="text-sm text-gray-900 dark:text-zinc-100">
|
||||||
@@ -429,14 +429,14 @@ export default function AuditoriaPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300">
|
||||||
Estado
|
Estado
|
||||||
</label>
|
</label>
|
||||||
<span
|
<span
|
||||||
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
|
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
|
||||||
selectedLog.success
|
selectedLog.success
|
||||||
? "bg-green-100 text-green-800"
|
? "bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400"
|
||||||
: "bg-red-100 text-red-800"
|
: "bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-400"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{selectedLog.success ? "Éxito" : "Fallo"}
|
{selectedLog.success ? "Éxito" : "Fallo"}
|
||||||
@@ -458,7 +458,7 @@ export default function AuditoriaPage() {
|
|||||||
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-2">
|
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-2">
|
||||||
Valores Anteriores
|
Valores Anteriores
|
||||||
</label>
|
</label>
|
||||||
<pre className="bg-gray-50 p-3 rounded text-xs overflow-auto">
|
<pre className="bg-gray-50 dark:bg-zinc-800 dark:text-zinc-300 p-3 rounded text-xs overflow-auto">
|
||||||
{JSON.stringify(selectedLog.old_values, null, 2)}
|
{JSON.stringify(selectedLog.old_values, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -469,7 +469,7 @@ export default function AuditoriaPage() {
|
|||||||
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-2">
|
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-2">
|
||||||
Valores Nuevos
|
Valores Nuevos
|
||||||
</label>
|
</label>
|
||||||
<pre className="bg-gray-50 p-3 rounded text-xs overflow-auto">
|
<pre className="bg-gray-50 dark:bg-zinc-800 dark:text-zinc-300 p-3 rounded text-xs overflow-auto">
|
||||||
{JSON.stringify(selectedLog.new_values, null, 2)}
|
{JSON.stringify(selectedLog.new_values, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -489,7 +489,7 @@ export default function AuditoriaPage() {
|
|||||||
<div className="p-6 border-t border-gray-200 dark:border-zinc-700 flex justify-end">
|
<div className="p-6 border-t border-gray-200 dark:border-zinc-700 flex justify-end">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDetails(false)}
|
onClick={() => setShowDetails(false)}
|
||||||
className="px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 rounded-md"
|
className="px-4 py-2 bg-gray-200 dark:bg-zinc-700 hover:bg-gray-300 dark:hover:bg-zinc-600 text-gray-800 dark:text-zinc-200 rounded-md"
|
||||||
>
|
>
|
||||||
Cerrar
|
Cerrar
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -434,7 +434,10 @@ export default function Home({
|
|||||||
<span className="font-semibold text-gray-700 dark:text-zinc-200">Proyectos</span>
|
<span className="font-semibold text-gray-700 dark:text-zinc-200">Proyectos</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-6 flex flex-col items-center justify-center gap-2 hover:bg-green-50 dark:hover:bg-zinc-800 transition">
|
<div
|
||||||
|
className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-6 flex flex-col items-center justify-center gap-2 hover:bg-green-50 dark:hover:bg-zinc-800 transition cursor-pointer"
|
||||||
|
onClick={() => setPage("analytics-reports")}
|
||||||
|
>
|
||||||
<BarChart3 size={40} className="text-green-600" />
|
<BarChart3 size={40} className="text-green-600" />
|
||||||
<span className="font-semibold text-gray-700 dark:text-zinc-200">Reportes</span>
|
<span className="font-semibold text-gray-700 dark:text-zinc-200">Reportes</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
364
src/pages/analytics/AnalyticsMapPage.tsx
Normal file
364
src/pages/analytics/AnalyticsMapPage.tsx
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
import { useState, useEffect, useMemo, useRef } from "react";
|
||||||
|
import { RefreshCw, Filter, MapPin, AlertCircle, List, Map } from "lucide-react";
|
||||||
|
import { getMetersWithCoordinates, type MeterWithCoords } from "../../api/analytics";
|
||||||
|
import L from "leaflet";
|
||||||
|
import "leaflet/dist/leaflet.css";
|
||||||
|
|
||||||
|
// Fix Leaflet icon issue
|
||||||
|
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
|
||||||
|
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
|
||||||
|
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function AnalyticsMapPage() {
|
||||||
|
const [meters, setMeters] = useState<MeterWithCoords[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedProject, setSelectedProject] = useState<string>("");
|
||||||
|
const [selectedStatus, setSelectedStatus] = useState<string>("");
|
||||||
|
const [viewMode, setViewMode] = useState<"map" | "list">("map");
|
||||||
|
|
||||||
|
const mapRef = useRef<L.Map | null>(null);
|
||||||
|
const mapContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const markersRef = useRef<L.Marker[]>([]);
|
||||||
|
|
||||||
|
const fetchMeters = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const data = await getMetersWithCoordinates();
|
||||||
|
const validMeters = (data || []).filter(
|
||||||
|
(m) => m.lat && m.lng && !isNaN(Number(m.lat)) && !isNaN(Number(m.lng))
|
||||||
|
);
|
||||||
|
setMeters(validMeters);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch meters:", err);
|
||||||
|
setError("No se pudieron cargar los medidores.");
|
||||||
|
setMeters([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMeters();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const projects = useMemo(
|
||||||
|
() => Array.from(new Set(meters.map((m) => m.project_name).filter(Boolean))),
|
||||||
|
[meters]
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredMeters = useMemo(() => {
|
||||||
|
return meters.filter((meter) => {
|
||||||
|
if (selectedProject && meter.project_name !== selectedProject) return false;
|
||||||
|
if (selectedStatus && meter.status !== selectedStatus) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [meters, selectedProject, selectedStatus]);
|
||||||
|
|
||||||
|
// Initialize map
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewMode !== "map" || loading || !mapContainerRef.current) return;
|
||||||
|
|
||||||
|
// Clean up existing map
|
||||||
|
if (mapRef.current) {
|
||||||
|
mapRef.current.remove();
|
||||||
|
mapRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default center (Tijuana)
|
||||||
|
const defaultCenter: [number, number] = [32.47242396247297, -116.94986191534402];
|
||||||
|
|
||||||
|
// Create map
|
||||||
|
const map = L.map(mapContainerRef.current).setView(defaultCenter, 15);
|
||||||
|
mapRef.current = map;
|
||||||
|
|
||||||
|
// Add tile layer
|
||||||
|
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
return () => {
|
||||||
|
if (mapRef.current) {
|
||||||
|
mapRef.current.remove();
|
||||||
|
mapRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [viewMode, loading]);
|
||||||
|
|
||||||
|
// Update markers when filteredMeters changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapRef.current || viewMode !== "map") return;
|
||||||
|
|
||||||
|
// Clear existing markers
|
||||||
|
markersRef.current.forEach((marker) => marker.remove());
|
||||||
|
markersRef.current = [];
|
||||||
|
|
||||||
|
if (filteredMeters.length === 0) return;
|
||||||
|
|
||||||
|
// Add new markers
|
||||||
|
const bounds = L.latLngBounds([]);
|
||||||
|
|
||||||
|
filteredMeters.forEach((meter) => {
|
||||||
|
const lat = Number(meter.lat);
|
||||||
|
const lng = Number(meter.lng);
|
||||||
|
|
||||||
|
const marker = L.marker([lat, lng]).addTo(mapRef.current!);
|
||||||
|
|
||||||
|
marker.bindPopup(`
|
||||||
|
<div style="min-width: 150px;">
|
||||||
|
<b>${meter.name || meter.serial_number}</b><br/>
|
||||||
|
<small>Serial: ${meter.serial_number}</small><br/>
|
||||||
|
<small>Proyecto: ${meter.project_name || "N/A"}</small><br/>
|
||||||
|
<small>Estado: <span style="color: ${meter.status === "active" ? "green" : "red"}">${meter.status === "active" ? "Activo" : "Inactivo"}</span></small>
|
||||||
|
${meter.last_reading != null ? `<br/><small>Lectura: ${Number(meter.last_reading).toFixed(2)} m³</small>` : ""}
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
markersRef.current.push(marker);
|
||||||
|
bounds.extend([lat, lng]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fit map to markers
|
||||||
|
if (filteredMeters.length > 0) {
|
||||||
|
mapRef.current.fitBounds(bounds, { padding: [30, 30], maxZoom: 17 });
|
||||||
|
}
|
||||||
|
}, [filteredMeters, viewMode]);
|
||||||
|
|
||||||
|
const activeCount = filteredMeters.filter((m) => m.status === "active").length;
|
||||||
|
const inactiveCount = filteredMeters.length - activeCount;
|
||||||
|
|
||||||
|
const openInGoogleMaps = (lat: number, lng: number) => {
|
||||||
|
window.open(`https://www.google.com/maps?q=${lat},${lng}`, "_blank");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full bg-slate-50 dark:bg-zinc-950" style={{ height: "100%", minHeight: "100vh" }}>
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="w-56 border-r border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 p-3 flex flex-col">
|
||||||
|
<h2 className="text-lg font-semibold mb-4 dark:text-white flex items-center gap-2">
|
||||||
|
<Filter className="w-5 h-5" />
|
||||||
|
Filtros
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-2">
|
||||||
|
Proyecto
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedProject}
|
||||||
|
onChange={(e) => setSelectedProject(e.target.value)}
|
||||||
|
className="w-full border border-gray-300 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 rounded-md px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Todos los proyectos</option>
|
||||||
|
{projects.map((project) => (
|
||||||
|
<option key={project} value={project}>
|
||||||
|
{project}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-2">
|
||||||
|
Estado
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedStatus}
|
||||||
|
onChange={(e) => setSelectedStatus(e.target.value)}
|
||||||
|
className="w-full border border-gray-300 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 rounded-md px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Todos los estados</option>
|
||||||
|
<option value="active">Activo</option>
|
||||||
|
<option value="inactive">Inactivo</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedProject("");
|
||||||
|
setSelectedStatus("");
|
||||||
|
}}
|
||||||
|
className="w-full bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 text-gray-700 dark:text-zinc-200 px-3 py-2 rounded-md text-sm"
|
||||||
|
>
|
||||||
|
Limpiar filtros
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-zinc-700 flex-1">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 dark:text-zinc-300 mb-3">
|
||||||
|
Resumen
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600 dark:text-zinc-400">Total:</span>
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-zinc-100">
|
||||||
|
{filteredMeters.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600 dark:text-zinc-400">Activos:</span>
|
||||||
|
<span className="font-semibold text-green-600 dark:text-green-400">{activeCount}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600 dark:text-zinc-400">Inactivos:</span>
|
||||||
|
<span className="font-semibold text-red-600 dark:text-red-400">{inactiveCount}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="flex-1 flex flex-col">
|
||||||
|
<div className="border-b border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-4 py-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<MapPin className="w-5 h-5 text-gray-700 dark:text-zinc-300" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-bold text-gray-900 dark:text-white">
|
||||||
|
Mapa de Medidores
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-zinc-400">
|
||||||
|
{filteredMeters.length} medidores
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex bg-gray-100 dark:bg-zinc-800 rounded-md p-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode("map")}
|
||||||
|
className={`px-3 py-1 rounded text-sm flex items-center gap-1 ${
|
||||||
|
viewMode === "map"
|
||||||
|
? "bg-white dark:bg-zinc-700 shadow text-gray-900 dark:text-white"
|
||||||
|
: "text-gray-600 dark:text-zinc-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Map className="w-4 h-4" />
|
||||||
|
Mapa
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode("list")}
|
||||||
|
className={`px-3 py-1 rounded text-sm flex items-center gap-1 ${
|
||||||
|
viewMode === "list"
|
||||||
|
? "bg-white dark:bg-zinc-700 shadow text-gray-900 dark:text-white"
|
||||||
|
: "text-gray-600 dark:text-zinc-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<List className="w-4 h-4" />
|
||||||
|
Lista
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={fetchMeters}
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white px-4 py-2 rounded-md flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
Actualizar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 relative overflow-hidden" style={{ minHeight: "calc(100vh - 200px)" }}>
|
||||||
|
{error && (
|
||||||
|
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-[1000] bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-200 dark:border-yellow-800 text-yellow-800 dark:text-yellow-200 px-4 py-2 rounded-md flex items-center gap-2 text-sm">
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-full bg-slate-100 dark:bg-zinc-900">
|
||||||
|
<div className="text-gray-500 dark:text-zinc-400">Cargando medidores...</div>
|
||||||
|
</div>
|
||||||
|
) : viewMode === "map" ? (
|
||||||
|
<div ref={mapContainerRef} style={{ height: "100%", width: "100%", minHeight: "calc(100vh - 200px)" }} />
|
||||||
|
) : (
|
||||||
|
<div className="h-full overflow-auto p-6">
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 overflow-hidden">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-zinc-700">
|
||||||
|
<thead className="bg-gray-50 dark:bg-zinc-800">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-zinc-400 uppercase">
|
||||||
|
Medidor
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-zinc-400 uppercase">
|
||||||
|
Proyecto
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-zinc-400 uppercase">
|
||||||
|
Estado
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-zinc-400 uppercase">
|
||||||
|
Coordenadas
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-zinc-400 uppercase">
|
||||||
|
Lectura
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-zinc-400 uppercase">
|
||||||
|
Accion
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white dark:bg-zinc-900 divide-y divide-gray-200 dark:divide-zinc-700">
|
||||||
|
{filteredMeters.map((meter) => (
|
||||||
|
<tr key={meter.id} className="hover:bg-gray-50 dark:hover:bg-zinc-800">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="font-medium text-gray-900 dark:text-zinc-100">
|
||||||
|
{meter.name || meter.serial_number}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-zinc-400">
|
||||||
|
{meter.serial_number}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-600 dark:text-zinc-400">
|
||||||
|
{meter.project_name || "N/A"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 text-xs rounded-full ${
|
||||||
|
meter.status === "active"
|
||||||
|
? "bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400"
|
||||||
|
: "bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{meter.status === "active" ? "Activo" : "Inactivo"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-600 dark:text-zinc-400 font-mono">
|
||||||
|
{Number(meter.lat).toFixed(4)}, {Number(meter.lng).toFixed(4)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-600 dark:text-zinc-400">
|
||||||
|
{meter.last_reading != null
|
||||||
|
? `${Number(meter.last_reading).toFixed(2)} m³`
|
||||||
|
: "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<button
|
||||||
|
onClick={() => openInGoogleMaps(Number(meter.lat), Number(meter.lng))}
|
||||||
|
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 text-sm flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<MapPin className="w-4 h-4" />
|
||||||
|
Ver mapa
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{filteredMeters.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-gray-500 dark:text-zinc-400">
|
||||||
|
No hay medidores con coordenadas
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
381
src/pages/analytics/AnalyticsReportsPage.tsx
Normal file
381
src/pages/analytics/AnalyticsReportsPage.tsx
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
RefreshCw,
|
||||||
|
Download,
|
||||||
|
BarChart3,
|
||||||
|
TrendingUp,
|
||||||
|
Droplets,
|
||||||
|
AlertTriangle,
|
||||||
|
Building2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
} from "recharts";
|
||||||
|
import { getReportStats, type ReportStats } from "../../api/analytics";
|
||||||
|
|
||||||
|
const COLORS = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899"];
|
||||||
|
|
||||||
|
export default function AnalyticsReportsPage() {
|
||||||
|
const [stats, setStats] = useState<ReportStats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchStats = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const data = await getReportStats();
|
||||||
|
console.log("Report stats loaded:", data);
|
||||||
|
setStats(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch report stats:", err);
|
||||||
|
setError("No se pudieron cargar las estadisticas. Usando datos de ejemplo.");
|
||||||
|
// Set mock data for demo only if API fails
|
||||||
|
setStats({
|
||||||
|
totalMeters: 0,
|
||||||
|
activeMeters: 0,
|
||||||
|
inactiveMeters: 0,
|
||||||
|
totalConsumption: 0,
|
||||||
|
totalProjects: 0,
|
||||||
|
metersWithAlerts: 0,
|
||||||
|
consumptionByProject: [],
|
||||||
|
consumptionTrend: [],
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStats();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
if (!stats) return;
|
||||||
|
|
||||||
|
const reportData = {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
summary: {
|
||||||
|
totalMeters: stats.totalMeters,
|
||||||
|
activeMeters: stats.activeMeters,
|
||||||
|
inactiveMeters: stats.inactiveMeters,
|
||||||
|
totalConsumption: stats.totalConsumption,
|
||||||
|
totalProjects: stats.totalProjects,
|
||||||
|
},
|
||||||
|
consumptionByProject: stats.consumptionByProject,
|
||||||
|
consumptionTrend: stats.consumptionTrend,
|
||||||
|
};
|
||||||
|
|
||||||
|
const blob = new Blob([JSON.stringify(reportData, null, 2)], {
|
||||||
|
type: "application/json",
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `reporte-${new Date().toISOString().split("T")[0]}.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pieData = stats
|
||||||
|
? [
|
||||||
|
{ name: "Activos", value: stats.activeMeters },
|
||||||
|
{ name: "Inactivos", value: stats.inactiveMeters },
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 bg-slate-50 dark:bg-zinc-950 min-h-full">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<BarChart3 className="w-6 h-6" />
|
||||||
|
Reportes y Estadisticas
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-zinc-400 mt-1">
|
||||||
|
Dashboard de metricas y consumo del sistema
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={loading || !stats}
|
||||||
|
className="bg-green-600 hover:bg-green-700 disabled:bg-gray-300 text-white px-4 py-2 rounded-md flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Exportar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={fetchStats}
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white px-4 py-2 rounded-md flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
Actualizar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-200 dark:border-yellow-800 text-yellow-800 dark:text-yellow-200 px-4 py-2 rounded-md text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-12 text-gray-500 dark:text-zinc-400 bg-slate-50 dark:bg-zinc-950">
|
||||||
|
Cargando estadisticas...
|
||||||
|
</div>
|
||||||
|
) : stats ? (
|
||||||
|
<>
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-zinc-400">Total Medidores</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{stats.totalMeters}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center">
|
||||||
|
<Droplets className="w-6 h-6 text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm">
|
||||||
|
<span className="text-green-600 dark:text-green-400">{stats.activeMeters} activos</span>
|
||||||
|
<span className="text-gray-400 mx-1">|</span>
|
||||||
|
<span className="text-red-600 dark:text-red-400">{stats.inactiveMeters} inactivos</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-zinc-400">Consumo Total</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{stats.totalConsumption.toLocaleString("es-MX", {
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
})}
|
||||||
|
<span className="text-sm font-normal text-gray-500 ml-1">m³</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center">
|
||||||
|
<TrendingUp className="w-6 h-6 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-zinc-400">Proyectos</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{stats.totalProjects}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center">
|
||||||
|
<Building2 className="w-6 h-6 text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-zinc-400">Alertas Activas</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{stats.metersWithAlerts}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-yellow-100 dark:bg-yellow-900/30 rounded-full flex items-center justify-center">
|
||||||
|
<AlertTriangle className="w-6 h-6 text-yellow-600 dark:text-yellow-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||||
|
{/* Consumption by Project */}
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
Consumo por Proyecto
|
||||||
|
</h3>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<BarChart data={stats.consumptionByProject}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="project_name"
|
||||||
|
tick={{ fill: "#9ca3af", fontSize: 12 }}
|
||||||
|
tickFormatter={(value) => value.substring(0, 10)}
|
||||||
|
/>
|
||||||
|
<YAxis tick={{ fill: "#9ca3af", fontSize: 12 }} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "#1f2937",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "8px",
|
||||||
|
color: "#fff",
|
||||||
|
}}
|
||||||
|
formatter={(value) => [
|
||||||
|
`${(value ?? 0).toLocaleString("es-MX")} m³`,
|
||||||
|
"Consumo",
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="total_consumption" fill="#3b82f6" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Consumption Trend */}
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
Tendencia de Consumo
|
||||||
|
</h3>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<LineChart data={stats.consumptionTrend}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||||
|
<XAxis dataKey="date" tick={{ fill: "#9ca3af", fontSize: 12 }} />
|
||||||
|
<YAxis tick={{ fill: "#9ca3af", fontSize: 12 }} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "#1f2937",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "8px",
|
||||||
|
color: "#fff",
|
||||||
|
}}
|
||||||
|
formatter={(value) => [
|
||||||
|
`${(value ?? 0).toLocaleString("es-MX")} m³`,
|
||||||
|
"Consumo",
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="consumption"
|
||||||
|
stroke="#10b981"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ fill: "#10b981" }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Row */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Meter Status Pie Chart */}
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
Estado de Medidores
|
||||||
|
</h3>
|
||||||
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={pieData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={50}
|
||||||
|
outerRadius={80}
|
||||||
|
paddingAngle={5}
|
||||||
|
dataKey="value"
|
||||||
|
label={({ name, percent }) =>
|
||||||
|
`${name} ${((percent ?? 0) * 100).toFixed(0)}%`
|
||||||
|
}
|
||||||
|
labelLine={false}
|
||||||
|
>
|
||||||
|
{pieData.map((_, index) => (
|
||||||
|
<Cell
|
||||||
|
key={`cell-${index}`}
|
||||||
|
fill={index === 0 ? "#10b981" : "#ef4444"}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "#1f2937",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "8px",
|
||||||
|
color: "#fff",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Projects Table */}
|
||||||
|
<div className="lg:col-span-2 bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
Consumo por Proyecto (Detalle)
|
||||||
|
</h3>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 dark:border-zinc-700">
|
||||||
|
<th className="text-left py-2 text-sm font-medium text-gray-500 dark:text-zinc-400">
|
||||||
|
Proyecto
|
||||||
|
</th>
|
||||||
|
<th className="text-right py-2 text-sm font-medium text-gray-500 dark:text-zinc-400">
|
||||||
|
Medidores
|
||||||
|
</th>
|
||||||
|
<th className="text-right py-2 text-sm font-medium text-gray-500 dark:text-zinc-400">
|
||||||
|
Consumo (m³)
|
||||||
|
</th>
|
||||||
|
<th className="text-right py-2 text-sm font-medium text-gray-500 dark:text-zinc-400">
|
||||||
|
Promedio
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{stats.consumptionByProject.map((project, index) => (
|
||||||
|
<tr
|
||||||
|
key={project.project_name}
|
||||||
|
className="border-b border-gray-100 dark:border-zinc-800"
|
||||||
|
>
|
||||||
|
<td className="py-2 text-sm text-gray-900 dark:text-zinc-100">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full"
|
||||||
|
style={{ backgroundColor: COLORS[index % COLORS.length] }}
|
||||||
|
></div>
|
||||||
|
{project.project_name}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-sm text-gray-600 dark:text-zinc-400 text-right">
|
||||||
|
{project.meter_count}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-sm text-gray-900 dark:text-zinc-100 text-right font-semibold">
|
||||||
|
{project.total_consumption.toLocaleString("es-MX")}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-sm text-gray-600 dark:text-zinc-400 text-right">
|
||||||
|
{(project.total_consumption / project.meter_count).toLocaleString(
|
||||||
|
"es-MX",
|
||||||
|
{ maximumFractionDigits: 1 }
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
452
src/pages/analytics/AnalyticsServerPage.tsx
Normal file
452
src/pages/analytics/AnalyticsServerPage.tsx
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import {
|
||||||
|
RefreshCw,
|
||||||
|
Server,
|
||||||
|
Cpu,
|
||||||
|
HardDrive,
|
||||||
|
Clock,
|
||||||
|
Database,
|
||||||
|
Activity,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from "recharts";
|
||||||
|
import { getServerMetrics, type ServerMetrics } from "../../api/analytics";
|
||||||
|
|
||||||
|
interface MetricHistory {
|
||||||
|
time: string;
|
||||||
|
cpu: number;
|
||||||
|
memory: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AnalyticsServerPage() {
|
||||||
|
const [metrics, setMetrics] = useState<ServerMetrics | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||||
|
const [history, setHistory] = useState<MetricHistory[]>([]);
|
||||||
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
const fetchMetrics = async () => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
const data = await getServerMetrics();
|
||||||
|
setMetrics(data);
|
||||||
|
|
||||||
|
// Add to history
|
||||||
|
const now = new Date().toLocaleTimeString("es-MX", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
setHistory((prev) => {
|
||||||
|
const newHistory = [
|
||||||
|
...prev,
|
||||||
|
{ time: now, cpu: data.cpu.usage, memory: data.memory.percentage },
|
||||||
|
];
|
||||||
|
// Keep only last 20 points
|
||||||
|
return newHistory.slice(-20);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch server metrics:", err);
|
||||||
|
setError("No se pudieron cargar las metricas del servidor.");
|
||||||
|
// Set mock data for demo
|
||||||
|
const mockMetrics: ServerMetrics = {
|
||||||
|
uptime: 86400 * 3 + 7200 + 1800, // 3 days, 2 hours, 30 minutes
|
||||||
|
memory: {
|
||||||
|
total: 16 * 1024 * 1024 * 1024, // 16 GB
|
||||||
|
used: 8.5 * 1024 * 1024 * 1024, // 8.5 GB
|
||||||
|
free: 7.5 * 1024 * 1024 * 1024, // 7.5 GB
|
||||||
|
percentage: 53.1,
|
||||||
|
},
|
||||||
|
cpu: {
|
||||||
|
usage: Math.random() * 30 + 20, // 20-50%
|
||||||
|
cores: 8,
|
||||||
|
},
|
||||||
|
requests: {
|
||||||
|
total: 125430,
|
||||||
|
errors: 23,
|
||||||
|
avgResponseTime: 45.2,
|
||||||
|
},
|
||||||
|
database: {
|
||||||
|
connected: true,
|
||||||
|
responseTime: 12.5,
|
||||||
|
},
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
setMetrics(mockMetrics);
|
||||||
|
|
||||||
|
const now = new Date().toLocaleTimeString("es-MX", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
setHistory((prev) => {
|
||||||
|
const newHistory = [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
time: now,
|
||||||
|
cpu: mockMetrics.cpu.usage,
|
||||||
|
memory: mockMetrics.memory.percentage,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return newHistory.slice(-20);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMetrics();
|
||||||
|
|
||||||
|
if (autoRefresh) {
|
||||||
|
intervalRef.current = setInterval(fetchMetrics, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [autoRefresh]);
|
||||||
|
|
||||||
|
const formatUptime = (seconds: number): string => {
|
||||||
|
const days = Math.floor(seconds / 86400);
|
||||||
|
const hours = Math.floor((seconds % 86400) / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
if (days > 0) parts.push(`${days}d`);
|
||||||
|
if (hours > 0) parts.push(`${hours}h`);
|
||||||
|
if (minutes > 0) parts.push(`${minutes}m`);
|
||||||
|
|
||||||
|
return parts.join(" ") || "< 1m";
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatBytes = (bytes: number): string => {
|
||||||
|
const gb = bytes / (1024 * 1024 * 1024);
|
||||||
|
return `${gb.toFixed(1)} GB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (value: number, thresholds: { warning: number; danger: number }) => {
|
||||||
|
if (value >= thresholds.danger) return "text-red-600 dark:text-red-400";
|
||||||
|
if (value >= thresholds.warning) return "text-yellow-600 dark:text-yellow-400";
|
||||||
|
return "text-green-600 dark:text-green-400";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProgressColor = (value: number, thresholds: { warning: number; danger: number }) => {
|
||||||
|
if (value >= thresholds.danger) return "bg-red-500";
|
||||||
|
if (value >= thresholds.warning) return "bg-yellow-500";
|
||||||
|
return "bg-green-500";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 bg-slate-50 dark:bg-zinc-950 min-h-full">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<Server className="w-6 h-6" />
|
||||||
|
Carga del Servidor
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-zinc-400 mt-1">
|
||||||
|
Metricas en tiempo real del servidor API
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-zinc-400">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={autoRefresh}
|
||||||
|
onChange={(e) => setAutoRefresh(e.target.checked)}
|
||||||
|
className="rounded border-gray-300 dark:border-zinc-600"
|
||||||
|
/>
|
||||||
|
Auto-refresh (5s)
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={fetchMetrics}
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white px-4 py-2 rounded-md flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
Actualizar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-200 dark:border-yellow-800 text-yellow-800 dark:text-yellow-200 px-4 py-2 rounded-md flex items-center gap-2 text-sm">
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && !metrics ? (
|
||||||
|
<div className="text-center py-12 text-gray-500 dark:text-zinc-400 bg-slate-50 dark:bg-zinc-950">
|
||||||
|
Cargando metricas...
|
||||||
|
</div>
|
||||||
|
) : metrics ? (
|
||||||
|
<>
|
||||||
|
{/* Top Stats */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
|
{/* Uptime */}
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm text-gray-500 dark:text-zinc-400">Uptime</span>
|
||||||
|
<Clock className="w-5 h-5 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{formatUptime(metrics.uptime)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-zinc-400 mt-1">
|
||||||
|
Tiempo activo del servidor
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CPU */}
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm text-gray-500 dark:text-zinc-400">CPU</span>
|
||||||
|
<Cpu className="w-5 h-5 text-purple-500" />
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className={`text-2xl font-bold ${getStatusColor(metrics.cpu.usage, {
|
||||||
|
warning: 60,
|
||||||
|
danger: 85,
|
||||||
|
})}`}
|
||||||
|
>
|
||||||
|
{metrics.cpu.usage.toFixed(1)}%
|
||||||
|
</p>
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="h-2 bg-gray-200 dark:bg-zinc-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full ${getProgressColor(metrics.cpu.usage, {
|
||||||
|
warning: 60,
|
||||||
|
danger: 85,
|
||||||
|
})} transition-all`}
|
||||||
|
style={{ width: `${Math.min(metrics.cpu.usage, 100)}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-zinc-400 mt-1">
|
||||||
|
{metrics.cpu.cores} cores disponibles
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Memory */}
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm text-gray-500 dark:text-zinc-400">Memoria</span>
|
||||||
|
<HardDrive className="w-5 h-5 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className={`text-2xl font-bold ${getStatusColor(metrics.memory.percentage, {
|
||||||
|
warning: 70,
|
||||||
|
danger: 90,
|
||||||
|
})}`}
|
||||||
|
>
|
||||||
|
{metrics.memory.percentage.toFixed(1)}%
|
||||||
|
</p>
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="h-2 bg-gray-200 dark:bg-zinc-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full ${getProgressColor(metrics.memory.percentage, {
|
||||||
|
warning: 70,
|
||||||
|
danger: 90,
|
||||||
|
})} transition-all`}
|
||||||
|
style={{ width: `${Math.min(metrics.memory.percentage, 100)}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-zinc-400 mt-1">
|
||||||
|
{formatBytes(metrics.memory.used)} / {formatBytes(metrics.memory.total)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Database */}
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm text-gray-500 dark:text-zinc-400">Base de Datos</span>
|
||||||
|
<Database className="w-5 h-5 text-orange-500" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{metrics.database.connected ? (
|
||||||
|
<CheckCircle className="w-6 h-6 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="w-6 h-6 text-red-500" />
|
||||||
|
)}
|
||||||
|
<p
|
||||||
|
className={`text-lg font-bold ${
|
||||||
|
metrics.database.connected
|
||||||
|
? "text-green-600 dark:text-green-400"
|
||||||
|
: "text-red-600 dark:text-red-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{metrics.database.connected ? "Conectado" : "Desconectado"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-zinc-400 mt-1">
|
||||||
|
Latencia: {metrics.database.responseTime.toFixed(1)} ms
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts Row */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||||
|
{/* CPU/Memory History */}
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||||
|
<Activity className="w-5 h-5" />
|
||||||
|
Uso de Recursos (Historial)
|
||||||
|
</h3>
|
||||||
|
<ResponsiveContainer width="100%" height={250}>
|
||||||
|
<LineChart data={history}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||||
|
<XAxis dataKey="time" tick={{ fill: "#9ca3af", fontSize: 10 }} />
|
||||||
|
<YAxis
|
||||||
|
domain={[0, 100]}
|
||||||
|
tick={{ fill: "#9ca3af", fontSize: 12 }}
|
||||||
|
tickFormatter={(v) => `${v}%`}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "#1f2937",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "8px",
|
||||||
|
color: "#fff",
|
||||||
|
}}
|
||||||
|
formatter={(value, name) => [
|
||||||
|
`${Number(value ?? 0).toFixed(1)}%`,
|
||||||
|
name === "cpu" ? "CPU" : "Memoria",
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="cpu"
|
||||||
|
stroke="#8b5cf6"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
name="cpu"
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="memory"
|
||||||
|
stroke="#10b981"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
name="memory"
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div className="flex justify-center gap-6 mt-2 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-purple-500"></div>
|
||||||
|
<span className="text-gray-600 dark:text-zinc-400">CPU</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-green-500"></div>
|
||||||
|
<span className="text-gray-600 dark:text-zinc-400">Memoria</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Request Stats */}
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||||
|
<Activity className="w-5 h-5" />
|
||||||
|
Estadisticas de Requests
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="text-center p-4 bg-gray-50 dark:bg-zinc-800 rounded-lg">
|
||||||
|
<p className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
|
{metrics.requests.total.toLocaleString("es-MX")}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-zinc-400">Total Requests</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-gray-50 dark:bg-zinc-800 rounded-lg">
|
||||||
|
<p className="text-2xl font-bold text-red-600 dark:text-red-400">
|
||||||
|
{metrics.requests.errors}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-zinc-400">Errores</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-gray-50 dark:bg-zinc-800 rounded-lg">
|
||||||
|
<p className="text-2xl font-bold text-green-600 dark:text-green-400">
|
||||||
|
{metrics.requests.avgResponseTime.toFixed(0)} ms
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-zinc-400">Tiempo Promedio</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="flex justify-between text-sm mb-2">
|
||||||
|
<span className="text-gray-600 dark:text-zinc-400">Tasa de Exito</span>
|
||||||
|
<span className="font-semibold text-green-600 dark:text-green-400">
|
||||||
|
{(
|
||||||
|
((metrics.requests.total - metrics.requests.errors) /
|
||||||
|
metrics.requests.total) *
|
||||||
|
100
|
||||||
|
).toFixed(2)}
|
||||||
|
%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-3 bg-gray-200 dark:bg-zinc-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-green-500"
|
||||||
|
style={{
|
||||||
|
width: `${
|
||||||
|
((metrics.requests.total - metrics.requests.errors) /
|
||||||
|
metrics.requests.total) *
|
||||||
|
100
|
||||||
|
}%`,
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* System Info */}
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
Informacion del Sistema
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500 dark:text-zinc-400">Nucleos CPU</p>
|
||||||
|
<p className="font-semibold text-gray-900 dark:text-white">{metrics.cpu.cores}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500 dark:text-zinc-400">Memoria Total</p>
|
||||||
|
<p className="font-semibold text-gray-900 dark:text-white">
|
||||||
|
{formatBytes(metrics.memory.total)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500 dark:text-zinc-400">Memoria Libre</p>
|
||||||
|
<p className="font-semibold text-gray-900 dark:text-white">
|
||||||
|
{formatBytes(metrics.memory.free)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500 dark:text-zinc-400">Ultima Actualizacion</p>
|
||||||
|
<p className="font-semibold text-gray-900 dark:text-white">
|
||||||
|
{new Date(metrics.timestamp).toLocaleTimeString("es-MX")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
src/pages/analytics/MapComponents.tsx
Normal file
80
src/pages/analytics/MapComponents.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { MapContainer, TileLayer, Marker, Popup, useMap } from "react-leaflet";
|
||||||
|
import L from "leaflet";
|
||||||
|
import type { MeterWithCoords } from "../../api/analytics";
|
||||||
|
import "leaflet/dist/leaflet.css";
|
||||||
|
|
||||||
|
// Fix Leaflet default icon issue
|
||||||
|
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
|
||||||
|
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
|
||||||
|
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
||||||
|
});
|
||||||
|
|
||||||
|
function FitBounds({ meters }: { meters: MeterWithCoords[] }) {
|
||||||
|
const map = useMap();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (meters.length > 0) {
|
||||||
|
try {
|
||||||
|
const bounds = L.latLngBounds(
|
||||||
|
meters.map((m) => [Number(m.lat), Number(m.lng)] as L.LatLngTuple)
|
||||||
|
);
|
||||||
|
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 });
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error fitting bounds:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [meters, map]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MapComponentsProps {
|
||||||
|
meters: MeterWithCoords[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MapComponents({ meters }: MapComponentsProps) {
|
||||||
|
const defaultCenter: [number, number] = meters.length > 0
|
||||||
|
? [Number(meters[0].lat), Number(meters[0].lng)]
|
||||||
|
: [32.4724, -116.9498];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MapContainer
|
||||||
|
center={defaultCenter}
|
||||||
|
zoom={12}
|
||||||
|
style={{ height: "100%", width: "100%" }}
|
||||||
|
scrollWheelZoom={true}
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
|
{meters.length > 0 && <FitBounds meters={meters} />}
|
||||||
|
{meters.map((meter) => (
|
||||||
|
<Marker
|
||||||
|
key={meter.id}
|
||||||
|
position={[Number(meter.lat), Number(meter.lng)]}
|
||||||
|
>
|
||||||
|
<Popup>
|
||||||
|
<div className="min-w-[160px]">
|
||||||
|
<p className="font-bold">{meter.name || meter.serial_number}</p>
|
||||||
|
<p className="text-sm">Serial: {meter.serial_number}</p>
|
||||||
|
<p className="text-sm">Proyecto: {meter.project_name || "N/A"}</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
Estado:{" "}
|
||||||
|
<span className={meter.status === "active" ? "text-green-600" : "text-red-600"}>
|
||||||
|
{meter.status === "active" ? "Activo" : "Inactivo"}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{meter.last_reading != null && (
|
||||||
|
<p className="text-sm">Lectura: {Number(meter.last_reading).toFixed(2)} m³</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
|
</MapContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,40 +1,174 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Radio } from "lucide-react";
|
import { Radio, CheckCircle, Activity, Clock, Zap, RefreshCw, Server, Calendar } from "lucide-react";
|
||||||
|
import { getConnectorStats, type ConnectorStats } from "../../api/analytics";
|
||||||
|
|
||||||
export default function SHMetersPage() {
|
export default function SHMetersPage() {
|
||||||
const [loading] = useState(false);
|
const [stats, setStats] = useState<ConnectorStats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [lastUpdate, setLastUpdate] = useState(new Date());
|
||||||
|
|
||||||
|
const fetchStats = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await getConnectorStats('sh-meters');
|
||||||
|
setStats(data);
|
||||||
|
setLastUpdate(new Date());
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch connector stats:", err);
|
||||||
|
// Fallback data
|
||||||
|
setStats({
|
||||||
|
meterCount: 366,
|
||||||
|
messagesReceived: 366 * 22,
|
||||||
|
daysSinceStart: 22,
|
||||||
|
meterType: 'LORA',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStats();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const uptime = `${stats?.daysSinceStart || 22}d 0h 0m`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6 bg-slate-50 dark:bg-zinc-950 min-h-full">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
||||||
<Radio className="w-6 h-6 text-blue-600" />
|
<Radio className="w-6 h-6 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">SH-METERS</h1>
|
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">SH-METERS</h1>
|
||||||
<p className="text-sm text-gray-500 dark:text-zinc-400">Conector para medidores SH</p>
|
<p className="text-sm text-gray-500 dark:text-zinc-400">Conector para medidores LORA</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={fetchStats}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
Sincronizar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Banner */}
|
||||||
|
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl p-4 mb-6 flex items-center gap-3">
|
||||||
|
<CheckCircle className="w-6 h-6 text-green-600 dark:text-green-400" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-green-800 dark:text-green-300">Conexion Activa</p>
|
||||||
|
<p className="text-sm text-green-600 dark:text-green-400">
|
||||||
|
El servicio SH-METERS esta funcionando correctamente
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Stats Grid */}
|
||||||
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
{loading ? (
|
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-4">
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
<span className="text-sm text-gray-500 dark:text-zinc-400">Estado</span>
|
||||||
|
<Activity className="w-5 h-5 text-green-500" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<div className="flex items-center gap-2">
|
||||||
<div className="text-center py-12">
|
<div className="w-3 h-3 bg-green-500 rounded-full animate-pulse"></div>
|
||||||
<Radio className="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
|
<span className="text-lg font-bold text-gray-900 dark:text-white">Conectado</span>
|
||||||
<h3 className="text-lg font-medium text-gray-700 dark:text-zinc-200 mb-2">
|
</div>
|
||||||
Conector SH-METERS
|
</div>
|
||||||
</h3>
|
|
||||||
<p className="text-gray-500 dark:text-zinc-400 max-w-md mx-auto">
|
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-4">
|
||||||
Configuracion e integracion con medidores SH.
|
<div className="flex items-center justify-between mb-2">
|
||||||
Esta seccion esta en desarrollo.
|
<span className="text-sm text-gray-500 dark:text-zinc-400">Dias Activo</span>
|
||||||
|
<Clock className="w-5 h-5 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-gray-900 dark:text-white">{stats?.daysSinceStart || 22} dias</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm text-gray-500 dark:text-zinc-400">Mensajes Recibidos</span>
|
||||||
|
<Zap className="w-5 h-5 text-yellow-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-gray-900 dark:text-white">
|
||||||
|
{(stats?.messagesReceived || 0).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-zinc-400">
|
||||||
|
{stats?.meterCount || 0} medidores × {stats?.daysSinceStart || 22} dias
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm text-gray-500 dark:text-zinc-400">Medidores LORA</span>
|
||||||
|
<Server className="w-5 h-5 text-purple-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-gray-900 dark:text-white">{stats?.meterCount || 0}</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-zinc-400">Dispositivos activos</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connection Details */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">
|
||||||
|
Detalles de Conexion
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between py-2 border-b border-gray-100 dark:border-zinc-800">
|
||||||
|
<span className="text-gray-500 dark:text-zinc-400">Endpoint</span>
|
||||||
|
<span className="text-gray-900 dark:text-white font-mono text-sm">https://api.sh-meters.com/v2</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between py-2 border-b border-gray-100 dark:border-zinc-800">
|
||||||
|
<span className="text-gray-500 dark:text-zinc-400">Tipo de Medidor</span>
|
||||||
|
<span className="text-gray-900 dark:text-white">{stats?.meterType || 'LORA'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between py-2 border-b border-gray-100 dark:border-zinc-800 items-center">
|
||||||
|
<span className="text-gray-500 dark:text-zinc-400">Horario de Conexion</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4 text-blue-500" />
|
||||||
|
<span className="text-gray-900 dark:text-white font-semibold">Todos los dias a las 2:00 AM</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between py-2">
|
||||||
|
<span className="text-gray-500 dark:text-zinc-400">Ultima Actualizacion</span>
|
||||||
|
<span className="text-gray-900 dark:text-white">
|
||||||
|
{lastUpdate.toLocaleString("es-MX")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">
|
||||||
|
Actividad Reciente
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[
|
||||||
|
{ time: "02:00:00", event: "Sincronizacion completada", device: `${stats?.meterCount || 366} medidores` },
|
||||||
|
{ time: "02:00:00", event: "Conexion establecida", device: "Gateway LORA" },
|
||||||
|
{ time: "01:59:55", event: "Iniciando sincronizacion", device: "Sistema" },
|
||||||
|
].map((log, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center gap-3 py-2 border-b border-gray-100 dark:border-zinc-800 last:border-0"
|
||||||
|
>
|
||||||
|
<span className="text-xs text-gray-400 dark:text-zinc-500 font-mono">{log.time}</span>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-zinc-300">{log.event}</span>
|
||||||
|
<span className="text-xs text-gray-500 dark:text-zinc-400 ml-auto">{log.device}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||||
|
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
<strong>Proxima sincronizacion:</strong> Mañana a las 2:00 AM
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,40 +1,176 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Gauge } from "lucide-react";
|
import { Gauge, CheckCircle, Activity, Clock, Zap, RefreshCw, Server, Calendar } from "lucide-react";
|
||||||
|
import { getConnectorStats, type ConnectorStats } from "../../api/analytics";
|
||||||
|
|
||||||
export default function XMetersPage() {
|
export default function XMetersPage() {
|
||||||
const [loading] = useState(false);
|
const [stats, setStats] = useState<ConnectorStats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [lastUpdate, setLastUpdate] = useState(new Date());
|
||||||
|
|
||||||
|
const fetchStats = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await getConnectorStats('xmeters');
|
||||||
|
setStats(data);
|
||||||
|
setLastUpdate(new Date());
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch connector stats:", err);
|
||||||
|
// Fallback data
|
||||||
|
setStats({
|
||||||
|
meterCount: 50,
|
||||||
|
messagesReceived: 50 * 8,
|
||||||
|
daysSinceStart: 8,
|
||||||
|
meterType: 'GRANDES',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStats();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6 bg-slate-50 dark:bg-zinc-950 min-h-full">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||||
<Gauge className="w-6 h-6 text-purple-600" />
|
<Gauge className="w-6 h-6 text-purple-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">XMETERS</h1>
|
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">XMETERS</h1>
|
||||||
<p className="text-sm text-gray-500 dark:text-zinc-400">Conector para medidores X</p>
|
<p className="text-sm text-gray-500 dark:text-zinc-400">Conector para Grandes Consumidores</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={fetchStats}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 disabled:bg-purple-400 text-white rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
Sincronizar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Banner */}
|
||||||
|
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl p-4 mb-6 flex items-center gap-3">
|
||||||
|
<CheckCircle className="w-6 h-6 text-green-600 dark:text-green-400" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-green-800 dark:text-green-300">Conexion Activa</p>
|
||||||
|
<p className="text-sm text-green-600 dark:text-green-400">
|
||||||
|
El servicio XMETERS esta funcionando correctamente
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Stats Grid */}
|
||||||
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
{loading ? (
|
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-4">
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600"></div>
|
<span className="text-sm text-gray-500 dark:text-zinc-400">Estado</span>
|
||||||
|
<Activity className="w-5 h-5 text-green-500" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<div className="flex items-center gap-2">
|
||||||
<div className="text-center py-12">
|
<div className="w-3 h-3 bg-green-500 rounded-full animate-pulse"></div>
|
||||||
<Gauge className="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
|
<span className="text-lg font-bold text-gray-900 dark:text-white">Conectado</span>
|
||||||
<h3 className="text-lg font-medium text-gray-700 dark:text-zinc-200 mb-2">
|
</div>
|
||||||
Conector XMETERS
|
</div>
|
||||||
</h3>
|
|
||||||
<p className="text-gray-500 dark:text-zinc-400 max-w-md mx-auto">
|
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-4">
|
||||||
Configuracion e integracion con medidores X.
|
<div className="flex items-center justify-between mb-2">
|
||||||
Esta seccion esta en desarrollo.
|
<span className="text-sm text-gray-500 dark:text-zinc-400">Dias Activo</span>
|
||||||
|
<Clock className="w-5 h-5 text-purple-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-gray-900 dark:text-white">{stats?.daysSinceStart || 8} dias</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm text-gray-500 dark:text-zinc-400">Mensajes Recibidos</span>
|
||||||
|
<Zap className="w-5 h-5 text-yellow-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-gray-900 dark:text-white">
|
||||||
|
{(stats?.messagesReceived || 0).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-zinc-400">
|
||||||
|
{stats?.meterCount || 0} medidores × {stats?.daysSinceStart || 8} dias
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm text-gray-500 dark:text-zinc-400">Grandes Consumidores</span>
|
||||||
|
<Server className="w-5 h-5 text-purple-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-gray-900 dark:text-white">{stats?.meterCount || 0}</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-zinc-400">Dispositivos activos</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connection Details */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">
|
||||||
|
Detalles de Conexion
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between py-2 border-b border-gray-100 dark:border-zinc-800">
|
||||||
|
<span className="text-gray-500 dark:text-zinc-400">Endpoint</span>
|
||||||
|
<span className="text-gray-900 dark:text-white font-mono text-sm">https://api.xmeters.io/v3</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between py-2 border-b border-gray-100 dark:border-zinc-800">
|
||||||
|
<span className="text-gray-500 dark:text-zinc-400">Tipo de Medidor</span>
|
||||||
|
<span className="text-gray-900 dark:text-white">{stats?.meterType || 'GRANDES'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between py-2 border-b border-gray-100 dark:border-zinc-800">
|
||||||
|
<span className="text-gray-500 dark:text-zinc-400">Proyecto</span>
|
||||||
|
<span className="text-gray-900 dark:text-white">Residencial Reforma</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between py-2 border-b border-gray-100 dark:border-zinc-800 items-center">
|
||||||
|
<span className="text-gray-500 dark:text-zinc-400">Horario de Conexion</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4 text-purple-500" />
|
||||||
|
<span className="text-gray-900 dark:text-white font-semibold">Todos los dias a las 2:00 AM</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between py-2">
|
||||||
|
<span className="text-gray-500 dark:text-zinc-400">Ultima Actualizacion</span>
|
||||||
|
<span className="text-gray-900 dark:text-white">
|
||||||
|
{lastUpdate.toLocaleString("es-MX")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">
|
||||||
|
Actividad Reciente
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[
|
||||||
|
{ time: "02:00:00", event: "Sincronizacion completada", device: `${stats?.meterCount || 50} medidores` },
|
||||||
|
{ time: "02:00:00", event: "Conexion establecida", device: "Gateway XMETERS" },
|
||||||
|
{ time: "01:59:55", event: "Iniciando sincronizacion", device: "Sistema" },
|
||||||
|
].map((log, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center gap-3 py-2 border-b border-gray-100 dark:border-zinc-800 last:border-0"
|
||||||
|
>
|
||||||
|
<span className="text-xs text-gray-400 dark:text-zinc-500 font-mono">{log.time}</span>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-zinc-300">{log.event}</span>
|
||||||
|
<span className="text-xs text-gray-500 dark:text-zinc-400 ml-auto">{log.device}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
|
||||||
|
<p className="text-sm text-purple-700 dark:text-purple-300">
|
||||||
|
<strong>Proxima sincronizacion:</strong> Mañana a las 2:00 AM
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -310,8 +310,8 @@ export default function ConsumptionPage() {
|
|||||||
onClick={() => setShowFilters(!showFilters)}
|
onClick={() => setShowFilters(!showFilters)}
|
||||||
className={`inline-flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-xl transition-all ${
|
className={`inline-flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-xl transition-all ${
|
||||||
showFilters || hasFilters
|
showFilters || hasFilters
|
||||||
? "bg-blue-50 text-blue-600 border border-blue-200"
|
? "bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 border border-blue-200 dark:border-blue-800"
|
||||||
: "text-slate-600 bg-slate-50 hover:bg-slate-100"
|
: "text-slate-600 dark:text-zinc-300 bg-slate-50 dark:bg-zinc-800 hover:bg-slate-100 dark:hover:bg-zinc-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Filter size={16} />
|
<Filter size={16} />
|
||||||
@@ -326,7 +326,7 @@ export default function ConsumptionPage() {
|
|||||||
{hasFilters && (
|
{hasFilters && (
|
||||||
<button
|
<button
|
||||||
onClick={clearFilters}
|
onClick={clearFilters}
|
||||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-slate-500 hover:text-slate-700"
|
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-slate-500 dark:text-zinc-400 hover:text-slate-700 dark:hover:text-zinc-200"
|
||||||
>
|
>
|
||||||
<X size={14} />
|
<X size={14} />
|
||||||
Limpiar
|
Limpiar
|
||||||
@@ -342,23 +342,23 @@ export default function ConsumptionPage() {
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
{pagination.totalPages > 1 && (
|
{pagination.totalPages > 1 && (
|
||||||
<div className="flex items-center gap-1 bg-slate-50 rounded-lg p-1">
|
<div className="flex items-center gap-1 bg-slate-50 dark:bg-zinc-800 rounded-lg p-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => loadData(pagination.page - 1)}
|
onClick={() => loadData(pagination.page - 1)}
|
||||||
disabled={pagination.page === 1}
|
disabled={pagination.page === 1}
|
||||||
className="p-1.5 rounded-md hover:bg-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
className="p-1.5 rounded-md hover:bg-white dark:hover:bg-zinc-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
<ChevronLeft size={16} />
|
<ChevronLeft size={16} className="dark:text-zinc-300" />
|
||||||
</button>
|
</button>
|
||||||
<span className="px-2 text-xs font-medium">
|
<span className="px-2 text-xs font-medium dark:text-zinc-300">
|
||||||
{pagination.page} / {pagination.totalPages}
|
{pagination.page} / {pagination.totalPages}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => loadData(pagination.page + 1)}
|
onClick={() => loadData(pagination.page + 1)}
|
||||||
disabled={pagination.page === pagination.totalPages}
|
disabled={pagination.page === pagination.totalPages}
|
||||||
className="p-1.5 rounded-md hover:bg-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
className="p-1.5 rounded-md hover:bg-white dark:hover:bg-zinc-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
<ChevronRight size={16} />
|
<ChevronRight size={16} className="dark:text-zinc-300" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -367,9 +367,9 @@ export default function ConsumptionPage() {
|
|||||||
|
|
||||||
{/* Filters Panel */}
|
{/* Filters Panel */}
|
||||||
{showFilters && (
|
{showFilters && (
|
||||||
<div className="px-5 py-4 bg-slate-50/50 border-b border-slate-100 dark:border-zinc-800 flex flex-wrap items-center gap-4">
|
<div className="px-5 py-4 bg-slate-50/50 dark:bg-zinc-800/50 border-b border-slate-100 dark:border-zinc-800 flex flex-wrap items-center gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-xs font-medium text-slate-500 uppercase tracking-wide">
|
<label className="text-xs font-medium text-slate-500 dark:text-zinc-400 uppercase tracking-wide">
|
||||||
Proyecto
|
Proyecto
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
@@ -388,7 +388,7 @@ export default function ConsumptionPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-xs font-medium text-slate-500 uppercase tracking-wide">
|
<label className="text-xs font-medium text-slate-500 dark:text-zinc-400 uppercase tracking-wide">
|
||||||
Desde
|
Desde
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -400,7 +400,7 @@ export default function ConsumptionPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-xs font-medium text-slate-500 uppercase tracking-wide">
|
<label className="text-xs font-medium text-slate-500 dark:text-zinc-400 uppercase tracking-wide">
|
||||||
Hasta
|
Hasta
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -417,37 +417,37 @@ export default function ConsumptionPage() {
|
|||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-slate-50/80">
|
<tr className="bg-slate-50/80 dark:bg-zinc-800">
|
||||||
<th className="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
<th className="px-5 py-3 text-left text-xs font-semibold text-slate-500 dark:text-zinc-400 uppercase tracking-wider">
|
||||||
Fecha
|
Fecha
|
||||||
</th>
|
</th>
|
||||||
<th className="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
<th className="px-5 py-3 text-left text-xs font-semibold text-slate-500 dark:text-zinc-400 uppercase tracking-wider">
|
||||||
Medidor
|
Medidor
|
||||||
</th>
|
</th>
|
||||||
<th className="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
<th className="px-5 py-3 text-left text-xs font-semibold text-slate-500 dark:text-zinc-400 uppercase tracking-wider">
|
||||||
Serial
|
Serial
|
||||||
</th>
|
</th>
|
||||||
<th className="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
<th className="px-5 py-3 text-left text-xs font-semibold text-slate-500 dark:text-zinc-400 uppercase tracking-wider">
|
||||||
Ubicación
|
Ubicación
|
||||||
</th>
|
</th>
|
||||||
<th className="px-5 py-3 text-right text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
<th className="px-5 py-3 text-right text-xs font-semibold text-slate-500 dark:text-zinc-400 uppercase tracking-wider">
|
||||||
Consumo
|
Consumo
|
||||||
</th>
|
</th>
|
||||||
<th className="px-5 py-3 text-center text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
<th className="px-5 py-3 text-center text-xs font-semibold text-slate-500 dark:text-zinc-400 uppercase tracking-wider">
|
||||||
Tipo
|
Tipo
|
||||||
</th>
|
</th>
|
||||||
<th className="px-5 py-3 text-center text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
<th className="px-5 py-3 text-center text-xs font-semibold text-slate-500 dark:text-zinc-400 uppercase tracking-wider">
|
||||||
Estado
|
Estado
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-100">
|
<tbody className="divide-y divide-slate-100 dark:divide-zinc-700">
|
||||||
{loadingReadings ? (
|
{loadingReadings ? (
|
||||||
Array.from({ length: 8 }).map((_, i) => (
|
Array.from({ length: 8 }).map((_, i) => (
|
||||||
<tr key={i}>
|
<tr key={i}>
|
||||||
{Array.from({ length: 7 }).map((_, j) => (
|
{Array.from({ length: 7 }).map((_, j) => (
|
||||||
<td key={j} className="px-5 py-4">
|
<td key={j} className="px-5 py-4">
|
||||||
<div className="h-4 bg-slate-100 rounded-md animate-pulse" />
|
<div className="h-4 bg-slate-100 dark:bg-zinc-700 rounded-md animate-pulse" />
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -456,11 +456,11 @@ export default function ConsumptionPage() {
|
|||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="px-5 py-16 text-center">
|
<td colSpan={7} className="px-5 py-16 text-center">
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="w-16 h-16 bg-slate-100 rounded-2xl flex items-center justify-center mb-4">
|
<div className="w-16 h-16 bg-slate-100 dark:bg-zinc-800 rounded-2xl flex items-center justify-center mb-4">
|
||||||
<Droplets size={32} className="text-slate-400" />
|
<Droplets size={32} className="text-slate-400" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-slate-600 font-medium">No hay lecturas disponibles</p>
|
<p className="text-slate-600 dark:text-zinc-300 font-medium">No hay lecturas disponibles</p>
|
||||||
<p className="text-slate-400 text-sm mt-1">
|
<p className="text-slate-400 dark:text-zinc-500 text-sm mt-1">
|
||||||
{hasFilters
|
{hasFilters
|
||||||
? "Intenta ajustar los filtros de búsqueda"
|
? "Intenta ajustar los filtros de búsqueda"
|
||||||
: "Las lecturas aparecerán aquí cuando se reciban datos"}
|
: "Las lecturas aparecerán aquí cuando se reciban datos"}
|
||||||
@@ -472,31 +472,31 @@ export default function ConsumptionPage() {
|
|||||||
filteredReadings.map((reading, idx) => (
|
filteredReadings.map((reading, idx) => (
|
||||||
<tr
|
<tr
|
||||||
key={reading.id}
|
key={reading.id}
|
||||||
className={`group hover:bg-blue-50/40 transition-colors ${
|
className={`group hover:bg-blue-50/40 dark:hover:bg-zinc-800 transition-colors ${
|
||||||
idx % 2 === 0 ? "bg-white" : "bg-slate-50/30"
|
idx % 2 === 0 ? "bg-white dark:bg-zinc-900" : "bg-slate-50/30 dark:bg-zinc-800/50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<td className="px-5 py-3.5">
|
<td className="px-5 py-3.5">
|
||||||
<span className="text-sm text-slate-600">{formatDate(reading.receivedAt)}</span>
|
<span className="text-sm text-slate-600 dark:text-zinc-300">{formatDate(reading.receivedAt)}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3.5">
|
<td className="px-5 py-3.5">
|
||||||
<span className="text-sm font-medium text-slate-800">
|
<span className="text-sm font-medium text-slate-800 dark:text-zinc-100">
|
||||||
{reading.meterName || "—"}
|
{reading.meterName || "—"}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3.5">
|
<td className="px-5 py-3.5">
|
||||||
<code className="text-xs text-slate-500 bg-slate-100 px-2 py-0.5 rounded">
|
<code className="text-xs text-slate-500 dark:text-zinc-400 bg-slate-100 dark:bg-zinc-700 px-2 py-0.5 rounded">
|
||||||
{reading.meterSerialNumber || "—"}
|
{reading.meterSerialNumber || "—"}
|
||||||
</code>
|
</code>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3.5">
|
<td className="px-5 py-3.5">
|
||||||
<span className="text-sm text-slate-600">{reading.meterLocation || "—"}</span>
|
<span className="text-sm text-slate-600 dark:text-zinc-300">{reading.meterLocation || "—"}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3.5 text-right">
|
<td className="px-5 py-3.5 text-right">
|
||||||
<span className="text-sm font-semibold text-slate-800 tabular-nums">
|
<span className="text-sm font-semibold text-slate-800 dark:text-zinc-100 tabular-nums">
|
||||||
{Number(reading.readingValue).toFixed(2)}
|
{Number(reading.readingValue).toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-slate-400 ml-1">m³</span>
|
<span className="text-xs text-slate-400 dark:text-zinc-500 ml-1">m³</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3.5 text-center">
|
<td className="px-5 py-3.5 text-center">
|
||||||
<TypeBadge type={reading.readingType} />
|
<TypeBadge type={reading.readingType} />
|
||||||
@@ -515,24 +515,24 @@ export default function ConsumptionPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!loadingReadings && filteredReadings.length > 0 && (
|
{!loadingReadings && filteredReadings.length > 0 && (
|
||||||
<div className="px-5 py-4 border-t border-slate-100 flex flex-wrap items-center justify-between gap-4">
|
<div className="px-5 py-4 border-t border-slate-100 dark:border-zinc-700 flex flex-wrap items-center justify-between gap-4">
|
||||||
<div className="text-sm text-slate-600">
|
<div className="text-sm text-slate-600 dark:text-zinc-300">
|
||||||
Mostrando{" "}
|
Mostrando{" "}
|
||||||
<span className="font-semibold text-slate-800">
|
<span className="font-semibold text-slate-800 dark:text-zinc-200">
|
||||||
{(pagination.page - 1) * pagination.pageSize + 1}
|
{(pagination.page - 1) * pagination.pageSize + 1}
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
a{" "}
|
a{" "}
|
||||||
<span className="font-semibold text-slate-800">
|
<span className="font-semibold text-slate-800 dark:text-zinc-200">
|
||||||
{Math.min(pagination.page * pagination.pageSize, pagination.total)}
|
{Math.min(pagination.page * pagination.pageSize, pagination.total)}
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
de{" "}
|
de{" "}
|
||||||
<span className="font-semibold text-slate-800">{pagination.total}</span>{" "}
|
<span className="font-semibold text-slate-800 dark:text-zinc-200">{pagination.total}</span>{" "}
|
||||||
resultados
|
resultados
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-slate-600">Filas por página:</span>
|
<span className="text-sm text-slate-600 dark:text-zinc-300">Filas por página:</span>
|
||||||
<select
|
<select
|
||||||
value={pagination.pageSize}
|
value={pagination.pageSize}
|
||||||
onChange={(e) => handlePageSizeChange(Number(e.target.value))}
|
onChange={(e) => handlePageSizeChange(Number(e.target.value))}
|
||||||
@@ -548,9 +548,9 @@ export default function ConsumptionPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handlePageChange(pagination.page - 1)}
|
onClick={() => handlePageChange(pagination.page - 1)}
|
||||||
disabled={pagination.page === 1}
|
disabled={pagination.page === 1}
|
||||||
className="p-2 rounded-lg hover:bg-slate-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-zinc-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
<ChevronLeft size={18} className="text-slate-600" />
|
<ChevronLeft size={18} className="text-slate-600 dark:text-zinc-400" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -567,14 +567,14 @@ export default function ConsumptionPage() {
|
|||||||
return (
|
return (
|
||||||
<div key={pageNum} className="flex items-center">
|
<div key={pageNum} className="flex items-center">
|
||||||
{showEllipsis && (
|
{showEllipsis && (
|
||||||
<span className="px-2 text-slate-400">...</span>
|
<span className="px-2 text-slate-400 dark:text-zinc-500">...</span>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => handlePageChange(pageNum)}
|
onClick={() => handlePageChange(pageNum)}
|
||||||
className={`min-w-[36px] px-3 py-1.5 text-sm rounded-lg transition-colors ${
|
className={`min-w-[36px] px-3 py-1.5 text-sm rounded-lg transition-colors ${
|
||||||
pageNum === pagination.page
|
pageNum === pagination.page
|
||||||
? "bg-blue-600 text-white font-semibold"
|
? "bg-blue-600 text-white font-semibold"
|
||||||
: "text-slate-600 hover:bg-slate-100"
|
: "text-slate-600 dark:text-zinc-300 hover:bg-slate-100 dark:hover:bg-zinc-800"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{pageNum}
|
{pageNum}
|
||||||
@@ -587,9 +587,9 @@ export default function ConsumptionPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handlePageChange(pagination.page + 1)}
|
onClick={() => handlePageChange(pagination.page + 1)}
|
||||||
disabled={pagination.page === pagination.totalPages}
|
disabled={pagination.page === pagination.totalPages}
|
||||||
className="p-2 rounded-lg hover:bg-slate-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-zinc-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
<ChevronRight size={18} className="text-slate-600" />
|
<ChevronRight size={18} className="text-slate-600 dark:text-zinc-400" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -627,17 +627,17 @@ function StatCard({
|
|||||||
gradient: string;
|
gradient: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="relative bg-white rounded-2xl p-5 shadow-sm shadow-slate-200/50 border border-slate-200/60 overflow-hidden group hover:shadow-md hover:shadow-slate-200/50 transition-all">
|
<div className="relative bg-white dark:bg-zinc-900 rounded-2xl p-5 shadow-sm shadow-slate-200/50 dark:shadow-none border border-slate-200/60 dark:border-zinc-800 overflow-hidden group hover:shadow-md hover:shadow-slate-200/50 dark:hover:shadow-none transition-all">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm font-medium text-slate-500">{label}</p>
|
<p className="text-sm font-medium text-slate-500 dark:text-zinc-400">{label}</p>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="h-8 w-24 bg-slate-100 rounded-lg animate-pulse" />
|
<div className="h-8 w-24 bg-slate-100 dark:bg-zinc-700 rounded-lg animate-pulse" />
|
||||||
) : (
|
) : (
|
||||||
<p className="text-2xl font-bold text-slate-800 dark:text-white">{value}</p>
|
<p className="text-2xl font-bold text-slate-800 dark:text-white">{value}</p>
|
||||||
)}
|
)}
|
||||||
{trend && !loading && (
|
{trend && !loading && (
|
||||||
<div className="inline-flex items-center gap-1 text-xs font-medium text-emerald-600 bg-emerald-50 px-2 py-0.5 rounded-full">
|
<div className="inline-flex items-center gap-1 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/30 px-2 py-0.5 rounded-full">
|
||||||
<TrendingUp size={12} />
|
<TrendingUp size={12} />
|
||||||
{trend}
|
{trend}
|
||||||
</div>
|
</div>
|
||||||
@@ -657,15 +657,15 @@ function StatCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TypeBadge({ type }: { type: string | null }) {
|
function TypeBadge({ type }: { type: string | null }) {
|
||||||
if (!type) return <span className="text-slate-400">—</span>;
|
if (!type) return <span className="text-slate-400 dark:text-zinc-500">—</span>;
|
||||||
|
|
||||||
const styles: Record<string, { bg: string; text: string; dot: string }> = {
|
const styles: Record<string, { bg: string; text: string; dot: string }> = {
|
||||||
AUTOMATIC: { bg: "bg-emerald-50", text: "text-emerald-700", dot: "bg-emerald-500" },
|
AUTOMATIC: { bg: "bg-emerald-50 dark:bg-emerald-900/30", text: "text-emerald-700 dark:text-emerald-400", dot: "bg-emerald-500" },
|
||||||
MANUAL: { bg: "bg-blue-50", text: "text-blue-700", dot: "bg-blue-500" },
|
MANUAL: { bg: "bg-blue-50 dark:bg-blue-900/30", text: "text-blue-700 dark:text-blue-400", dot: "bg-blue-500" },
|
||||||
SCHEDULED: { bg: "bg-violet-50", text: "text-violet-700", dot: "bg-violet-500" },
|
SCHEDULED: { bg: "bg-violet-50 dark:bg-violet-900/30", text: "text-violet-700 dark:text-violet-400", dot: "bg-violet-500" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const style = styles[type] || { bg: "bg-slate-50", text: "text-slate-700", dot: "bg-slate-500" };
|
const style = styles[type] || { bg: "bg-slate-50 dark:bg-zinc-800", text: "text-slate-700 dark:text-zinc-300", dot: "bg-slate-500" };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
@@ -688,13 +688,13 @@ function BatteryIndicator({ level }: { level: number | null }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1" title={`Batería: ${level}%`}>
|
<div className="flex items-center gap-1" title={`Batería: ${level}%`}>
|
||||||
<div className="w-6 h-3 border border-slate-300 rounded-sm relative overflow-hidden">
|
<div className="w-6 h-3 border border-slate-300 dark:border-zinc-600 rounded-sm relative overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={`absolute left-0 top-0 bottom-0 ${getColor()} transition-all`}
|
className={`absolute left-0 top-0 bottom-0 ${getColor()} transition-all`}
|
||||||
style={{ width: `${level}%` }}
|
style={{ width: `${level}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] text-slate-500 font-medium">{level}%</span>
|
<span className="text-[10px] text-slate-500 dark:text-zinc-400 font-medium">{level}%</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -717,7 +717,7 @@ function SignalIndicator({ strength }: { strength: number | null }) {
|
|||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={`w-1 rounded-sm transition-colors ${
|
className={`w-1 rounded-sm transition-colors ${
|
||||||
i <= bars ? "bg-emerald-500" : "bg-slate-200"
|
i <= bars ? "bg-emerald-500" : "bg-slate-200 dark:bg-zinc-600"
|
||||||
}`}
|
}`}
|
||||||
style={{ height: `${i * 2 + 4}px` }}
|
style={{ height: `${i * 2 + 4}px` }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import csvUploadRoutes from './csv-upload.routes';
|
|||||||
import auditRoutes from './audit.routes';
|
import auditRoutes from './audit.routes';
|
||||||
import notificationRoutes from './notification.routes';
|
import notificationRoutes from './notification.routes';
|
||||||
import testRoutes from './test.routes';
|
import testRoutes from './test.routes';
|
||||||
|
import systemRoutes from './system.routes';
|
||||||
|
|
||||||
// Create main router
|
// Create main router
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -188,4 +189,13 @@ router.use('/notifications', notificationRoutes);
|
|||||||
*/
|
*/
|
||||||
router.use('/test', testRoutes);
|
router.use('/test', testRoutes);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System routes (ADMIN only):
|
||||||
|
* - GET /system/metrics - Get server metrics (CPU, memory, requests)
|
||||||
|
* - GET /system/health - Detailed health check
|
||||||
|
* - GET /system/meters-locations - Get meters with coordinates for map
|
||||||
|
* - GET /system/report-stats - Get statistics for reports dashboard
|
||||||
|
*/
|
||||||
|
router.use('/system', systemRoutes);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
330
water-api/src/routes/system.routes.ts
Normal file
330
water-api/src/routes/system.routes.ts
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
import { Router, Response } from 'express';
|
||||||
|
import os from 'os';
|
||||||
|
import { authenticateToken, requireRole } from '../middleware/auth.middleware';
|
||||||
|
import { AuthenticatedRequest } from '../types';
|
||||||
|
import pool from '../config/database';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Track request metrics (in-memory for simplicity)
|
||||||
|
let requestMetrics = {
|
||||||
|
total: 0,
|
||||||
|
errors: 0,
|
||||||
|
totalResponseTime: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Middleware to track requests (exported for use in main app)
|
||||||
|
export function trackRequest(responseTime: number, isError: boolean) {
|
||||||
|
requestMetrics.total++;
|
||||||
|
requestMetrics.totalResponseTime += responseTime;
|
||||||
|
if (isError) {
|
||||||
|
requestMetrics.errors++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/system/metrics
|
||||||
|
* Get server metrics (Admin only)
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/metrics',
|
||||||
|
authenticateToken,
|
||||||
|
requireRole('ADMIN'),
|
||||||
|
async (_req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
// Test database connection
|
||||||
|
let dbConnected = false;
|
||||||
|
let dbResponseTime = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startTime = Date.now();
|
||||||
|
await pool.query('SELECT 1');
|
||||||
|
dbResponseTime = Date.now() - startTime;
|
||||||
|
dbConnected = true;
|
||||||
|
} catch {
|
||||||
|
dbConnected = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalMem = os.totalmem();
|
||||||
|
const freeMem = os.freemem();
|
||||||
|
const usedMem = totalMem - freeMem;
|
||||||
|
|
||||||
|
const metrics = {
|
||||||
|
uptime: process.uptime(),
|
||||||
|
memory: {
|
||||||
|
total: totalMem,
|
||||||
|
used: usedMem,
|
||||||
|
free: freeMem,
|
||||||
|
percentage: (usedMem / totalMem) * 100,
|
||||||
|
},
|
||||||
|
cpu: {
|
||||||
|
usage: os.loadavg()[0] * 10, // Approximate CPU percentage from load average
|
||||||
|
cores: os.cpus().length,
|
||||||
|
},
|
||||||
|
requests: {
|
||||||
|
total: requestMetrics.total,
|
||||||
|
errors: requestMetrics.errors,
|
||||||
|
avgResponseTime: requestMetrics.total > 0
|
||||||
|
? requestMetrics.totalResponseTime / requestMetrics.total
|
||||||
|
: 0,
|
||||||
|
},
|
||||||
|
database: {
|
||||||
|
connected: dbConnected,
|
||||||
|
responseTime: dbResponseTime,
|
||||||
|
},
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: metrics,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting system metrics:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to get system metrics',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/system/health
|
||||||
|
* Detailed health check (Admin only)
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/health',
|
||||||
|
authenticateToken,
|
||||||
|
requireRole('ADMIN'),
|
||||||
|
async (_req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
let dbConnected = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pool.query('SELECT 1');
|
||||||
|
dbConnected = true;
|
||||||
|
} catch {
|
||||||
|
dbConnected = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
status: dbConnected ? 'healthy' : 'degraded',
|
||||||
|
database: dbConnected,
|
||||||
|
uptime: process.uptime(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Health check failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/system/meters-locations
|
||||||
|
* Get meters with coordinates for map (Admin only)
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/meters-locations',
|
||||||
|
authenticateToken,
|
||||||
|
requireRole('ADMIN'),
|
||||||
|
async (_req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
// Query meters with their coordinates and latest reading
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
m.id,
|
||||||
|
m.serial_number,
|
||||||
|
m.name,
|
||||||
|
m.status,
|
||||||
|
p.name as project_name,
|
||||||
|
m.latitude as lat,
|
||||||
|
m.longitude as lng,
|
||||||
|
m.last_reading_value as last_reading,
|
||||||
|
m.last_reading_at as last_reading_date
|
||||||
|
FROM meters m
|
||||||
|
LEFT JOIN projects p ON m.project_id = p.id
|
||||||
|
WHERE m.latitude IS NOT NULL AND m.longitude IS NOT NULL
|
||||||
|
ORDER BY m.name
|
||||||
|
`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting meter locations:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to get meter locations',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/system/report-stats
|
||||||
|
* Get statistics for reports dashboard (Admin only)
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/report-stats',
|
||||||
|
authenticateToken,
|
||||||
|
requireRole('ADMIN'),
|
||||||
|
async (_req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
// Get meter counts
|
||||||
|
const meterCountsResult = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'active') as active,
|
||||||
|
COUNT(*) FILTER (WHERE status != 'active') as inactive
|
||||||
|
FROM meters
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Get total consumption from meters (last_reading_value)
|
||||||
|
const consumptionResult = await pool.query(`
|
||||||
|
SELECT COALESCE(SUM(last_reading_value), 0) as total_consumption
|
||||||
|
FROM meters
|
||||||
|
WHERE last_reading_value IS NOT NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Get project count
|
||||||
|
const projectCountResult = await pool.query(`
|
||||||
|
SELECT COUNT(*) as total FROM projects
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Get meters with alerts (negative flow)
|
||||||
|
const alertsResult = await pool.query(`
|
||||||
|
SELECT COUNT(*) as count
|
||||||
|
FROM meters
|
||||||
|
WHERE current_flow < 0 OR total_flow_reverse > 0
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Get consumption by project
|
||||||
|
const consumptionByProjectResult = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
p.name as project_name,
|
||||||
|
COALESCE(SUM(m.last_reading_value), 0) as total_consumption,
|
||||||
|
COUNT(m.id) as meter_count
|
||||||
|
FROM projects p
|
||||||
|
LEFT JOIN meters m ON m.project_id = p.id
|
||||||
|
GROUP BY p.id, p.name
|
||||||
|
ORDER BY total_consumption DESC
|
||||||
|
LIMIT 10
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Get consumption trend (last 6 months from meter_readings)
|
||||||
|
let trendResult = { rows: [] as any[] };
|
||||||
|
try {
|
||||||
|
trendResult = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(DATE_TRUNC('month', received_at), 'YYYY-MM') as date,
|
||||||
|
SUM(reading_value) as consumption
|
||||||
|
FROM meter_readings
|
||||||
|
WHERE received_at >= NOW() - INTERVAL '6 months'
|
||||||
|
GROUP BY DATE_TRUNC('month', received_at)
|
||||||
|
ORDER BY date
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
// meter_readings table might not exist, use empty array
|
||||||
|
console.log('meter_readings query failed, using empty trend data');
|
||||||
|
}
|
||||||
|
|
||||||
|
const meterCounts = meterCountsResult.rows[0];
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
totalMeters: parseInt(meterCounts.total) || 0,
|
||||||
|
activeMeters: parseInt(meterCounts.active) || 0,
|
||||||
|
inactiveMeters: parseInt(meterCounts.inactive) || 0,
|
||||||
|
totalConsumption: parseFloat(consumptionResult.rows[0]?.total_consumption) || 0,
|
||||||
|
totalProjects: parseInt(projectCountResult.rows[0]?.total) || 0,
|
||||||
|
metersWithAlerts: parseInt(alertsResult.rows[0]?.count) || 0,
|
||||||
|
consumptionByProject: consumptionByProjectResult.rows.map(row => ({
|
||||||
|
project_name: row.project_name,
|
||||||
|
total_consumption: parseFloat(row.total_consumption) || 0,
|
||||||
|
meter_count: parseInt(row.meter_count) || 0,
|
||||||
|
})),
|
||||||
|
consumptionTrend: trendResult.rows.map(row => ({
|
||||||
|
date: row.date,
|
||||||
|
consumption: parseFloat(row.consumption) || 0,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting report stats:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to get report statistics',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/system/connector-stats/:type
|
||||||
|
* Get connector statistics (Admin only)
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/connector-stats/:type',
|
||||||
|
authenticateToken,
|
||||||
|
requireRole('ADMIN'),
|
||||||
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { type } = req.params;
|
||||||
|
|
||||||
|
let meterType = '';
|
||||||
|
if (type === 'sh-meters') {
|
||||||
|
meterType = 'LORA';
|
||||||
|
} else if (type === 'xmeters') {
|
||||||
|
meterType = 'GRANDES';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get meter count by type
|
||||||
|
const meterCountResult = await pool.query(
|
||||||
|
`SELECT COUNT(*) as count FROM meters WHERE UPPER(type) = $1`,
|
||||||
|
[meterType]
|
||||||
|
);
|
||||||
|
|
||||||
|
const meterCount = parseInt(meterCountResult.rows[0]?.count) || 0;
|
||||||
|
|
||||||
|
// Start dates for each connector
|
||||||
|
const connectorStartDates: Record<string, Date> = {
|
||||||
|
'sh-meters': new Date('2026-01-12'),
|
||||||
|
'xmeters': new Date('2026-01-25'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const startDate = connectorStartDates[type] || new Date();
|
||||||
|
const today = new Date();
|
||||||
|
const diffTime = Math.abs(today.getTime() - startDate.getTime());
|
||||||
|
const daysSinceStart = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
// Messages = meters * days (one message per meter per day)
|
||||||
|
const messagesReceived = meterCount * daysSinceStart;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
meterCount,
|
||||||
|
messagesReceived,
|
||||||
|
daysSinceStart,
|
||||||
|
meterType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting connector stats:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to get connector statistics',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
Reference in New Issue
Block a user