From 1e61f056cb972d22861c8b7bdd5b530fa95de6e3 Mon Sep 17 00:00:00 2001 From: benchav Date: Fri, 22 May 2026 22:20:29 -0600 Subject: [PATCH 1/3] feat: add ReportsPage with data visualization and export functionality along with jspdf, jspdf-autotable, and xlsx dependencies --- package-lock.json | 322 ++++++++++++++- package.json | 5 +- src/pages/ReportsPage.tsx | 818 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 1099 insertions(+), 46 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0a451a7..e1bd990 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,8 @@ "@react-three/fiber": "^8.18.0", "@splinetool/runtime": "^1.12.92", "@types/three": "^0.184.1", + "jspdf": "^4.2.1", + "jspdf-autotable": "^5.0.8", "leaflet": "^1.9.4", "leaflet-draw": "^1.0.4", "react": "^18.3.1", @@ -19,7 +21,8 @@ "react-leaflet": "^4.2.1", "react-leaflet-draw": "^0.20.4", "react-router-dom": "^7.15.1", - "recharts": "^3.8.1" + "recharts": "^3.8.1", + "xlsx": "^0.18.5" }, "devDependencies": { "@types/leaflet": "^1.9.21", @@ -3086,12 +3089,25 @@ "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", "license": "MIT" }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "license": "MIT" }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", @@ -3152,7 +3168,7 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/use-sync-external-store": { @@ -3219,6 +3235,15 @@ "node": ">=0.4.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", @@ -3435,6 +3460,16 @@ "node": "18 || 20 || >=22" } }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -3671,6 +3706,39 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -3718,6 +3786,15 @@ "node": ">=6" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -3758,6 +3835,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-js-compat": { "version": "3.49.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", @@ -3772,6 +3861,18 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-env": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", @@ -3814,6 +3915,16 @@ "node": ">=8" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -4101,6 +4212,16 @@ "dev": true, "license": "MIT" }, + "node_modules/dompurify": { + "version": "3.4.5", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz", + "integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/draco3d": { "version": "1.5.7", "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", @@ -4419,6 +4540,17 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, "node_modules/fast-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", @@ -4538,6 +4670,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -4879,6 +5020,20 @@ "integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==", "license": "Apache-2.0" }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/idb": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", @@ -4946,6 +5101,12 @@ "node": ">=12" } }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -5537,6 +5698,32 @@ "node": ">=0.10.0" } }, + "node_modules/jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, + "node_modules/jspdf-autotable": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-5.0.8.tgz", + "integrity": "sha512-Hy05N86yBO7CXBrnSLOge7i1ZYpKH2DjQ94iybaP7vBhSInjvRBgDc99ngKzSbSO8Jc98ZCally8I6n0tj2RJQ==", + "license": "MIT", + "peerDependencies": { + "jspdf": "^2 || ^3 || ^4" + } + }, "node_modules/leaflet": { "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", @@ -5880,6 +6067,12 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -5923,6 +6116,13 @@ "node": "20 || >=22" } }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6207,6 +6407,16 @@ ], "license": "MIT" }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -6503,6 +6713,13 @@ "node": ">=4" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -6610,6 +6827,16 @@ "node": ">=0.10.0" } }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/rollup": { "version": "4.60.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", @@ -6989,6 +7216,28 @@ "node": ">=0.10.0" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/stats-gl": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", @@ -7186,6 +7435,16 @@ "react": ">=17.0" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/tailwindcss": { "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", @@ -7279,6 +7538,16 @@ "dev": true, "license": "MIT" }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -7751,6 +8020,16 @@ "node": ">= 4" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/victory-vendor": { "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", @@ -7998,6 +8277,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/workbox-background-sync": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.1.tgz", @@ -8218,6 +8515,27 @@ "workbox-core": "7.4.1" } }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index b3a1ca5..3066031 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "@react-three/fiber": "^8.18.0", "@splinetool/runtime": "^1.12.92", "@types/three": "^0.184.1", + "jspdf": "^4.2.1", + "jspdf-autotable": "^5.0.8", "leaflet": "^1.9.4", "leaflet-draw": "^1.0.4", "react": "^18.3.1", @@ -20,7 +22,8 @@ "react-leaflet": "^4.2.1", "react-leaflet-draw": "^0.20.4", "react-router-dom": "^7.15.1", - "recharts": "^3.8.1" + "recharts": "^3.8.1", + "xlsx": "^0.18.5" }, "devDependencies": { "@types/leaflet": "^1.9.21", diff --git a/src/pages/ReportsPage.tsx b/src/pages/ReportsPage.tsx index 32158db..09f02c9 100644 --- a/src/pages/ReportsPage.tsx +++ b/src/pages/ReportsPage.tsx @@ -1,57 +1,789 @@ +import { useState, useEffect, useMemo } from 'react'; +import { + AreaChart, Area, BarChart, Bar, RadarChart, Radar, PolarGrid, PolarAngleAxis, + XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend, PolarRadiusAxis, + LineChart, Line, ReferenceLine, +} from 'recharts'; import { PageSection } from '../components/layout/PageSection'; +// ─── Interfaces (same as IotPage) ──────────────────────────────────────────── +interface Arduino { + id: string; + name: string; + location: string; + status: 'active' | 'inactive'; + baudRate: number; + frequency: number; + description: string; +} + +interface Sensor { + id: string; + name: string; + type: string; + value: string; + numericValue: number; + unit: string; + location: string; + status: 'OK' | 'Atención' | 'Crítico'; + tone: 'emerald' | 'amber' | 'red' | 'cyan'; + arduinoId: string; +} + +interface Alert { + id: string; + emoji: string; + title: string; + description: string; + time: string; + severity: 'red' | 'amber'; + resolved: boolean; +} + +// ─── Seed de datos fallback ─────────────────────────────────────────────────── +const fallbackArduinos: Arduino[] = [ + { id: 'ARD-MEGA-01', name: 'Arduino Mega - Principal', location: 'Parcela Norte', status: 'active', baudRate: 115200, frequency: 2, description: 'Controlador principal.' }, + { id: 'ARD-UNO-02', name: 'Arduino Uno - Invernadero', location: 'Sector 2A', status: 'active', baudRate: 9600, frequency: 5, description: 'Invernadero.' }, + { id: 'ARD-NANO-03', name: 'Arduino Nano - Riego', location: 'Zona Crítica', status: 'inactive', baudRate: 9600, frequency: 10, description: 'Control de riego.' }, +]; + +const fallbackSensors: Sensor[] = [ + { id: 'S01', name: 'Humedad del Suelo', type: 'Humedad', value: '68%', numericValue: 68, unit: '%', location: 'Parcela Norte', status: 'OK', tone: 'emerald', arduinoId: 'ARD-MEGA-01' }, + { id: 'S05', name: 'Temperatura Suelo', type: 'Temperatura', value: '28°C', numericValue: 28, unit: '°C', location: 'Sector 2A', status: 'Atención', tone: 'amber', arduinoId: 'ARD-UNO-02' }, + { id: 'S09', name: 'Humedad Zona Crítica', type: 'Humedad', value: '34%', numericValue: 34, unit: '%', location: 'Zona Crítica', status: 'Crítico', tone: 'red', arduinoId: 'ARD-NANO-03' }, + { id: 'S12', name: 'pH del Suelo', type: 'pH', value: '7.2 pH', numericValue: 7.2, unit: ' pH', location: 'Parcela Sur', status: 'OK', tone: 'cyan', arduinoId: 'ARD-MEGA-01' }, + { id: 'S14', name: 'Nitrógeno (N)', type: 'Nutrientes', value: '185 mg/kg', numericValue: 185, unit: ' mg/kg', location: 'Parcela Norte', status: 'OK', tone: 'emerald', arduinoId: 'ARD-MEGA-01' }, + { id: 'S18', name: 'CO₂ del Suelo', type: 'Gas', value: '820 ppm', numericValue: 820, unit: ' ppm', location: 'Sector 2A', status: 'Atención', tone: 'amber', arduinoId: 'ARD-UNO-02' }, +]; + +const fallbackAlerts: Alert[] = [ + { id: 'A01', emoji: '🐛', title: 'Anomalía de presión — Posible plaga subterránea', description: 'Parcela Norte: Variación anormal en sensores S01, S03.', time: 'hace 12m', severity: 'red', resolved: false }, + { id: 'A02', emoji: '🦗', title: 'Posible actividad de insectos — Sector 2A', description: 'Temperatura de suelo elevada con patrón de vibración en sensor S05.', time: 'hace 58m', severity: 'amber', resolved: false }, + { id: 'A03', emoji: '💧', title: 'Humedad crítica — Zona Crítica', description: 'Sensor S09 reporta humedad por debajo del umbral mínimo (40%).', time: 'hace 3h', severity: 'red', resolved: true }, +]; + +// ─── Generador de historial sintético por días ──────────────────────────────── +function generateDailyHistory(days: number, base: number, variance: number, label: string) { + const data = []; + const now = new Date(); + for (let i = days - 1; i >= 0; i--) { + const d = new Date(now); + d.setDate(d.getDate() - i); + const dayLabel = d.toLocaleDateString('es-MX', { month: 'short', day: 'numeric' }); + data.push({ + day: dayLabel, + [label]: Math.max(0, Math.round((base + (Math.random() - 0.5) * variance * 2) * 10) / 10), + }); + } + return data; +} + +function generateMultiSensorHistory(days: number) { + const now = new Date(); + return Array.from({ length: days }, (_, i) => { + const d = new Date(now); + d.setDate(d.getDate() - (days - 1 - i)); + const dayLabel = d.toLocaleDateString('es-MX', { month: 'short', day: 'numeric' }); + return { + day: dayLabel, + Humedad: Math.round(55 + (Math.random() - 0.5) * 30), + Temperatura: Math.round(24 + (Math.random() - 0.5) * 10), + pH: Math.round((7.0 + (Math.random() - 0.5) * 1.5) * 10) / 10, + CO2: Math.round(750 + (Math.random() - 0.5) * 400), + }; + }); +} + +// ─── Tooltip personalizado ──────────────────────────────────────────────────── +const CustomTooltip = ({ active, payload, label }: any) => { + if (active && payload && payload.length) { + return ( +
+

{label}

+ {payload.map((entry: any) => ( +
+ + {entry.name}: + {entry.value}{entry.unit || ''} +
+ ))} +
+ ); + } + return null; +}; + +// ─── Componente de estado de calidad ───────────────────────────────────────── +function QualityBadge({ value, thresholds }: { value: number; thresholds: { ok: number; warn: number } }) { + if (value >= thresholds.ok) return Óptimo; + if (value >= thresholds.warn) return Atención; + return Crítico; +} + +// ─── Barra de progreso visual ───────────────────────────────────────────────── +function ProgressBar({ value, max, color }: { value: number; max: number; color: string }) { + const pct = Math.min(100, Math.round((value / max) * 100)); + return ( +
+
+
+ ); +} + +// ─── Tipos de período ───────────────────────────────────────────────────────── +type Period = '7d' | '14d' | '30d'; +const PERIOD_DAYS: Record = { '7d': 7, '14d': 14, '30d': 30 }; +const PERIOD_LABELS: Record = { '7d': 'Última semana', '14d': 'Últimas 2 semanas', '30d': 'Último mes' }; + +// ─── Exportar CSV ───────────────────────────────────────────────────────────── +function exportCSV(data: any[], filename: string) { + if (!data.length) return; + const headers = Object.keys(data[0]).join(','); + const rows = data.map((row) => Object.values(row).join(',')).join('\n'); + const blob = new Blob([`${headers}\n${rows}`], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${filename}-${new Date().toISOString().slice(0, 10)}.csv`; + a.click(); + URL.revokeObjectURL(url); +} + +// ─── COMPONENTE PRINCIPAL ───────────────────────────────────────────────────── export function ReportsPage() { + const [period, setPeriod] = useState('7d'); + const [activeTab, setActiveTab] = useState<'overview' | 'sensors' | 'parcelas' | 'alertas'>('overview'); + const [exportFlash, setExportFlash] = useState(false); + + // Leer datos de localStorage (correlacionados con IotPage) + const arduinos: Arduino[] = useMemo(() => { + try { return JSON.parse(localStorage.getItem('ac_arduinos') || 'null') ?? fallbackArduinos; } catch { return fallbackArduinos; } + }, []); + + const sensors: Sensor[] = useMemo(() => { + try { return JSON.parse(localStorage.getItem('ac_sensors') || 'null') ?? fallbackSensors; } catch { return fallbackSensors; } + }, []); + + const alerts: Alert[] = useMemo(() => { + try { return JSON.parse(localStorage.getItem('ac_alerts') || 'null') ?? fallbackAlerts; } catch { return fallbackAlerts; } + }, []); + + const days = PERIOD_DAYS[period]; + + // Historial generado dinámicamente según el período + const humidityHistory = useMemo(() => generateDailyHistory(days, 65, 18, 'Humedad'), [days]); + const tempHistory = useMemo(() => generateDailyHistory(days, 26, 8, 'Temperatura'), [days]); + const multiHistory = useMemo(() => generateMultiSensorHistory(days), [days]); + + // ── KPIs derivados del estado real ── + const activeSensors = sensors.filter((s) => s.value !== '---'); + const avgHumidity = useMemo(() => { + const hs = activeSensors.filter((s) => s.type === 'Humedad'); + return hs.length ? Math.round(hs.reduce((a, s) => a + s.numericValue, 0) / hs.length) : 0; + }, [sensors]); + + const avgTemp = useMemo(() => { + const ts = activeSensors.filter((s) => s.type === 'Temperatura'); + return ts.length ? Math.round(ts.reduce((a, s) => a + s.numericValue, 0) / ts.length * 10) / 10 : 0; + }, [sensors]); + + const avgPH = useMemo(() => { + const ps = activeSensors.filter((s) => s.type === 'pH'); + return ps.length ? Math.round(ps.reduce((a, s) => a + s.numericValue, 0) / ps.length * 10) / 10 : 0; + }, [sensors]); + + const criticalAlerts = alerts.filter((a) => !a.resolved && a.severity === 'red').length; + const totalAlerts = alerts.filter((a) => !a.resolved).length; + const activeArduinos = arduinos.filter((a) => a.status === 'active').length; + const systemHealth = Math.round(((activeSensors.filter(s => s.status === 'OK').length) / Math.max(sensors.length, 1)) * 100); + + // ── Datos por parcela ── + const locations = useMemo(() => Array.from(new Set(sensors.map((s) => s.location))), [sensors]); + const parcelaData = useMemo(() => locations.map((loc) => { + const locSensors = sensors.filter((s) => s.location === loc && s.value !== '---'); + const hum = locSensors.filter(s => s.type === 'Humedad'); + const temp = locSensors.filter(s => s.type === 'Temperatura'); + const ph = locSensors.filter(s => s.type === 'pH'); + const ok = locSensors.filter(s => s.status === 'OK').length; + return { + parcela: loc, + sensores: locSensors.length, + humedad: hum.length ? Math.round(hum.reduce((a, s) => a + s.numericValue, 0) / hum.length) : 0, + temperatura: temp.length ? Math.round(temp.reduce((a, s) => a + s.numericValue, 0) / temp.length * 10) / 10 : 0, + ph: ph.length ? Math.round(ph.reduce((a, s) => a + s.numericValue, 0) / ph.length * 10) / 10 : 0, + saludPct: locSensors.length ? Math.round((ok / locSensors.length) * 100) : 100, + }; + }), [sensors, locations]); + + // ── Radar data para salud de parcelas ── + const radarData = useMemo(() => parcelaData.map((p) => ({ + parcela: p.parcela.replace('Parcela ', '').replace('Sector ', 'S-').replace('Zona ', 'Z-'), + Salud: p.saludPct, + Humedad: p.humedad, + Temperatura: Math.min(100, Math.round(p.temperatura * 2.5)), + })), [parcelaData]); + + // ── Distribución de alertas ── + const alertDistribution = [ + { name: 'Críticas', value: alerts.filter(a => a.severity === 'red' && !a.resolved).length, color: '#ef4444' }, + { name: 'Advertencias', value: alerts.filter(a => a.severity === 'amber' && !a.resolved).length, color: '#f59e0b' }, + { name: 'Resueltas', value: alerts.filter(a => a.resolved).length, color: '#10b981' }, + ]; + + // ── Tipo de sensores ── + const sensorTypeData = useMemo(() => { + const types = sensors.reduce((acc, s) => { + acc[s.type] = (acc[s.type] || 0) + 1; + return acc; + }, {} as Record); + return Object.entries(types).map(([type, count]) => ({ type, count })); + }, [sensors]); + + const handleExport = (label: string, data: any[]) => { + exportCSV(data, `reporte-${label}`); + setExportFlash(true); + setTimeout(() => setExportFlash(false), 2000); + }; + + const tabs: { key: typeof activeTab; label: string; icon: string }[] = [ + { key: 'overview', label: 'Resumen Ejecutivo', icon: '📊' }, + { key: 'sensors', label: 'Análisis de Sensores', icon: '🔬' }, + { key: 'parcelas', label: 'Reporte por Parcela', icon: '🌾' }, + { key: 'alertas', label: 'Historial de Alertas', icon: '🚨' }, + ]; + return ( -
-

Análisis histórico · Proyecciones · Exportar datos

-
+
+ + {/* ── Encabezado ── */} +
+
+

Centro de Inteligencia Agrícola · Datos en tiempo real

+

Sistema de Reportes

+
+
+ {/* Selector de período */} +
+ {(Object.keys(PERIOD_DAYS) as Period[]).map((p) => ( + + ))} +
+ {/* Botón Exportar */} + +
+
+ + {/* ── KPI Grid ── */} +
{[ - ['Salud Promedio', '82%', 'fa-leaf', 'emerald'], - ['Agua Utilizada', '1,240 L', 'fa-tint', 'sky'], - ['Incidentes Plaga', '4 eventos', 'fa-bug', 'amber'], - ['Ingresos Marketplace', '$3,840', 'fa-dollar-sign', 'teal'], - ].map(([label, value, icon, tone]) => ( -
-
- + { + label: 'Salud del Sistema', value: `${systemHealth}%`, icon: 'fa-heartbeat', color: systemHealth > 70 ? '#10b981' : '#f59e0b', + sub: `${activeSensors.filter(s => s.status === 'OK').length} de ${sensors.length} sensores OK`, + progress: systemHealth, progressMax: 100, thresholds: { ok: 80, warn: 60 }, + }, + { + label: 'Humedad Promedio', value: `${avgHumidity}%`, icon: 'fa-tint', color: '#38bdf8', + sub: `${activeSensors.filter(s => s.type === 'Humedad').length} sensores activos`, + progress: avgHumidity, progressMax: 100, thresholds: { ok: 60, warn: 40 }, + }, + { + label: 'Arduinos Activos', value: `${activeArduinos}/${arduinos.length}`, icon: 'fa-microchip', color: '#a78bfa', + sub: `${arduinos.filter(a => a.status === 'inactive').length} placa(s) fuera de línea`, + progress: activeArduinos, progressMax: arduinos.length, thresholds: { ok: arduinos.length, warn: Math.floor(arduinos.length * 0.6) }, + }, + { + label: 'Alertas Activas', value: `${totalAlerts}`, icon: 'fa-exclamation-triangle', color: criticalAlerts > 0 ? '#ef4444' : '#f59e0b', + sub: `${criticalAlerts} crítica(s) · ${alerts.filter(a => a.resolved).length} resueltas`, + progress: Math.max(0, 100 - totalAlerts * 20), progressMax: 100, thresholds: { ok: 80, warn: 60 }, + }, + ].map((kpi) => ( +
+
+
+ +
+
-
{label}
-
{value}
+
{kpi.label}
+
{kpi.value}
+ +
{kpi.sub}
))}
- -
- - - - - - - - - - - - {[ - ['Parcela Norte', '94%', '6.8', '68%', '22°C'], - ['Sector 2A', '87%', '7.1', '62%', '28°C'], - ['Zona Crítica', '71%', '6.2', '34%', '31°C'], - ['Parcela Sur', '89%', '7.2', '71%', '21°C'], - ].map(([parcel, fertility, ph, humidity, temp]) => ( - - - - - - - + {/* ── Pestañas ── */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {/* ══════════════════ TAB: RESUMEN EJECUTIVO ══════════════════ */} +
+ + {/* Gráfico principal: Multi-sensor histórico */} + +
+ {[ + { key: 'Humedad', color: '#38bdf8', unit: '%' }, + { key: 'Temperatura', color: '#f59e0b', unit: '°C' }, + { key: 'pH', color: '#a78bfa', unit: '' }, + ].map((s) => ( +
+ + {s.key}{s.unit && ` (${s.unit})`} +
+ ))} +
+
+ + + + {[['#38bdf8', 'hum'], ['#f59e0b', 'temp'], ['#a78bfa', 'ph']].map(([c, id]) => ( + + + + + ))} + + + + + } /> + + + + + +
+
+ +
+
+ + {/* Segunda fila: Humedad detallada + Temp detallada */} +
+ +
+ + + + + + + + + + + } /> + + + + + +
+
+ + +
+ + + + + + + + + + + } /> + + + + +
+
+
+ + {/* Tercera fila: Distribución de Alertas + Tipos de Sensor */} +
+ +
+ {alertDistribution.map((item) => ( +
+
{item.name}
+
+
a.value), 1)) * 100)}%`, background: item.color }} + /> +
+
{item.value}
+
+ ))} +
+
+ {alertDistribution.map((item) => ( +
+
{item.value}
+
{item.name}
+
))} -
-
ParcelaFertilidadpHHumedadTemperatura
{parcel}{fertility}{ph}{humidity}{temp}
+
+
+ + +
+ + + + + } /> + + + +
+
- +
+ + {/* ══════════════════ TAB: ANÁLISIS DE SENSORES ══════════════════ */} +
+ + {/* CO2 History */} + +
+ + + + + + + + + + + + } /> + + + + +
+
+ + {/* Tabla de sensores completa */} + +
+ +
+
+ + + + {['ID', 'Nombre', 'Tipo', 'Ubicación', 'Placa Arduino', 'Lectura actual', 'Estado', 'Salud'].map((h) => ( + + ))} + + + + {sensors.map((sensor) => { + const isOffline = sensor.value === '---'; + const statusColors: Record = { + OK: 'bg-emerald-500/10 text-emerald-300', + 'Atención': 'bg-amber-500/10 text-amber-300', + Crítico: 'bg-red-500/10 text-red-300', + }; + return ( + + + + + + + + + + + ); + })} + +
{h}
{sensor.id}{sensor.name}{sensor.type} + {sensor.location} + {sensor.arduinoId}{isOffline ? — Sin señal — : sensor.value} + + {isOffline ? 'Offline' : sensor.status} + + + {!isOffline && ( +
+
+
+
+
+ )} +
+
+
+
+ + {/* ══════════════════ TAB: REPORTE POR PARCELA ══════════════════ */} +
+ + {/* Cards por parcela */} +
+ {parcelaData.map((p) => { + const healthColor = p.saludPct >= 80 ? '#10b981' : p.saludPct >= 60 ? '#f59e0b' : '#ef4444'; + return ( +
+
+
+
Parcela
+

{p.parcela}

+
+
+ {p.saludPct}% +
+
+ +
Índice de salud
+
+
+
+ +
+ {[ + { label: 'Humedad', value: p.humedad > 0 ? `${p.humedad}%` : 'N/A', icon: '💧', color: '#38bdf8' }, + { label: 'Temperatura', value: p.temperatura > 0 ? `${p.temperatura}°C` : 'N/A', icon: '🌡️', color: '#f59e0b' }, + { label: 'pH Suelo', value: p.ph > 0 ? p.ph.toString() : 'N/A', icon: '⚗️', color: '#a78bfa' }, + { label: 'Sensores', value: p.sensores.toString(), icon: '📡', color: '#10b981' }, + ].map((m) => ( +
+
+ {m.icon}{m.label} +
+
{m.value}
+
+ ))} +
+ +
+ {arduinos.filter(a => sensors.some(s => s.location === p.parcela && s.arduinoId === a.id)).map(a => ( + + {a.id} + + ))} +
+
+ ); + })} +
+ + {/* Radar de comparativa */} + +
+ + + + + + + + } /> + {v}} /> + + +
+
+ + {/* Tabla comparativa */} + +
+ +
+
+ + + + {['Parcela', 'Salud', 'Humedad', 'Temperatura', 'pH', 'Sensores Activos', 'Estado'].map((h) => ( + + ))} + + + + {parcelaData.map((p) => ( + + + + + + + + + + ))} + +
{h}
{p.parcela} +
+
+
= 80 ? '#10b981' : p.saludPct >= 60 ? '#f59e0b' : '#ef4444' }} /> +
+ {p.saludPct}% +
+
{p.humedad > 0 ? `${p.humedad}%` : '—'}{p.temperatura > 0 ? `${p.temperatura}°C` : '—'}{p.ph > 0 ? p.ph : '—'}{p.sensores} + +
+
+
+
+ + {/* ══════════════════ TAB: HISTORIAL DE ALERTAS ══════════════════ */} +
+ + {/* KPIs de alertas */} +
+ {[ + { label: 'Alertas Críticas', value: alerts.filter(a => a.severity === 'red').length, color: '#ef4444', icon: 'fa-fire' }, + { label: 'Advertencias', value: alerts.filter(a => a.severity === 'amber').length, color: '#f59e0b', icon: 'fa-exclamation-circle' }, + { label: 'Resueltas', value: alerts.filter(a => a.resolved).length, color: '#10b981', icon: 'fa-check-circle' }, + ].map((k) => ( +
+
+
+ +
+
+
{k.value}
+
{k.label}
+
+
+
+ ))} +
+ + {/* Timeline de alertas */} + +
+ +
+
+ {alerts.map((alert, idx) => ( +
+ {/* Timeline dot */} +
+ +
+ {alert.emoji} +
+
+ {alert.title} + {alert.resolved + ? Resuelta + : {alert.severity === 'red' ? 'Crítico' : 'Advertencia'} + } +
+

{alert.description}

+
+
+
{alert.time}
+
#{alert.id}
+
+
+
+ ))} + {alerts.length === 0 && ( +
+

🟢 No hay alertas registradas en el sistema.

+
+ )} +
+ + + {/* Frecuencia de alertas por día (simulada) */} + +
+ + ({ ...d, Eventos: Math.max(0, d.Eventos) }))}> + + + + } /> + + + +
+
+
+
); -} +} \ No newline at end of file From dc830369c5260318602dc0437b4415e181b125a4 Mon Sep 17 00:00:00 2001 From: benchav Date: Fri, 22 May 2026 22:24:59 -0600 Subject: [PATCH 2/3] feat: implement ReportsPage with data visualization charts and data export functionality --- src/pages/ReportsPage.tsx | 716 ++++++++++++++++++++++++++++---------- 1 file changed, 524 insertions(+), 192 deletions(-) diff --git a/src/pages/ReportsPage.tsx b/src/pages/ReportsPage.tsx index 09f02c9..4ab38c5 100644 --- a/src/pages/ReportsPage.tsx +++ b/src/pages/ReportsPage.tsx @@ -1,12 +1,17 @@ -import { useState, useEffect, useMemo } from 'react'; +import { useState, useMemo } from 'react'; import { AreaChart, Area, BarChart, Bar, RadarChart, Radar, PolarGrid, PolarAngleAxis, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend, PolarRadiusAxis, LineChart, Line, ReferenceLine, } from 'recharts'; import { PageSection } from '../components/layout/PageSection'; +// @ts-ignore +import jsPDF from 'jspdf'; +// @ts-ignore +import autoTable from 'jspdf-autotable'; +import * as XLSX from 'xlsx'; -// ─── Interfaces (same as IotPage) ──────────────────────────────────────────── +// ─── Interfaces (sincronizadas con IotPage) ─────────────────────────────────── interface Arduino { id: string; name: string; @@ -40,11 +45,11 @@ interface Alert { resolved: boolean; } -// ─── Seed de datos fallback ─────────────────────────────────────────────────── +// ─── Datos iniciales de respaldo (igual que IotPage) ───────────────────────── const fallbackArduinos: Arduino[] = [ - { id: 'ARD-MEGA-01', name: 'Arduino Mega - Principal', location: 'Parcela Norte', status: 'active', baudRate: 115200, frequency: 2, description: 'Controlador principal.' }, - { id: 'ARD-UNO-02', name: 'Arduino Uno - Invernadero', location: 'Sector 2A', status: 'active', baudRate: 9600, frequency: 5, description: 'Invernadero.' }, - { id: 'ARD-NANO-03', name: 'Arduino Nano - Riego', location: 'Zona Crítica', status: 'inactive', baudRate: 9600, frequency: 10, description: 'Control de riego.' }, + { id: 'ARD-MEGA-01', name: 'Arduino Mega - Principal', location: 'Parcela Norte', status: 'active', baudRate: 115200, frequency: 2, description: 'Controlador principal de sensores de suelo.' }, + { id: 'ARD-UNO-02', name: 'Arduino Uno - Invernadero', location: 'Sector 2A', status: 'active', baudRate: 9600, frequency: 5, description: 'Monitoreo de temperatura y humedad interna.' }, + { id: 'ARD-NANO-03', name: 'Arduino Nano - Riego', location: 'Zona Crítica', status: 'inactive', baudRate: 9600, frequency: 10, description: 'Control de electroválvulas y sensor de flujo.' }, ]; const fallbackSensors: Sensor[] = [ @@ -53,7 +58,7 @@ const fallbackSensors: Sensor[] = [ { id: 'S09', name: 'Humedad Zona Crítica', type: 'Humedad', value: '34%', numericValue: 34, unit: '%', location: 'Zona Crítica', status: 'Crítico', tone: 'red', arduinoId: 'ARD-NANO-03' }, { id: 'S12', name: 'pH del Suelo', type: 'pH', value: '7.2 pH', numericValue: 7.2, unit: ' pH', location: 'Parcela Sur', status: 'OK', tone: 'cyan', arduinoId: 'ARD-MEGA-01' }, { id: 'S14', name: 'Nitrógeno (N)', type: 'Nutrientes', value: '185 mg/kg', numericValue: 185, unit: ' mg/kg', location: 'Parcela Norte', status: 'OK', tone: 'emerald', arduinoId: 'ARD-MEGA-01' }, - { id: 'S18', name: 'CO₂ del Suelo', type: 'Gas', value: '820 ppm', numericValue: 820, unit: ' ppm', location: 'Sector 2A', status: 'Atención', tone: 'amber', arduinoId: 'ARD-UNO-02' }, + { id: 'S18', name: 'CO2 del Suelo', type: 'Gas', value: '820 ppm', numericValue: 820, unit: ' ppm', location: 'Sector 2A', status: 'Atención', tone: 'amber', arduinoId: 'ARD-UNO-02' }, ]; const fallbackAlerts: Alert[] = [ @@ -62,7 +67,7 @@ const fallbackAlerts: Alert[] = [ { id: 'A03', emoji: '💧', title: 'Humedad crítica — Zona Crítica', description: 'Sensor S09 reporta humedad por debajo del umbral mínimo (40%).', time: 'hace 3h', severity: 'red', resolved: true }, ]; -// ─── Generador de historial sintético por días ──────────────────────────────── +// ─── Generadores de historial sintético ────────────────────────────────────── function generateDailyHistory(days: number, base: number, variance: number, label: string) { const data = []; const now = new Date(); @@ -104,7 +109,7 @@ const CustomTooltip = ({ active, payload, label }: any) => {
{entry.name}: - {entry.value}{entry.unit || ''} + {entry.value}{entry.unit ?? ''}
))}
@@ -113,22 +118,19 @@ const CustomTooltip = ({ active, payload, label }: any) => { return null; }; -// ─── Componente de estado de calidad ───────────────────────────────────────── +// ─── Badge de calidad ───────────────────────────────────────────────────────── function QualityBadge({ value, thresholds }: { value: number; thresholds: { ok: number; warn: number } }) { if (value >= thresholds.ok) return Óptimo; if (value >= thresholds.warn) return Atención; return Crítico; } -// ─── Barra de progreso visual ───────────────────────────────────────────────── +// ─── Barra de progreso ──────────────────────────────────────────────────────── function ProgressBar({ value, max, color }: { value: number; max: number; color: string }) { - const pct = Math.min(100, Math.round((value / max) * 100)); + const pct = Math.min(100, max > 0 ? Math.round((value / max) * 100) : 0); return (
-
+
); } @@ -142,8 +144,10 @@ const PERIOD_LABELS: Record = { '7d': 'Última semana', '14d': ' function exportCSV(data: any[], filename: string) { if (!data.length) return; const headers = Object.keys(data[0]).join(','); - const rows = data.map((row) => Object.values(row).join(',')).join('\n'); - const blob = new Blob([`${headers}\n${rows}`], { type: 'text/csv;charset=utf-8;' }); + const rows = data.map((row) => + Object.values(row).map((v) => `"${String(v).replace(/"/g, '""')}"`).join(',') + ).join('\n'); + const blob = new Blob([`\uFEFF${headers}\n${rows}`], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; @@ -152,55 +156,409 @@ function exportCSV(data: any[], filename: string) { URL.revokeObjectURL(url); } +// ─── Exportar Excel (multi-hoja) ────────────────────────────────────────────── +function exportExcel( + sensors: Sensor[], + arduinos: Arduino[], + alerts: Alert[], + parcelaData: any[], + multiHistory: any[], + period: string, + kpis: { label: string; value: string; sub: string }[] +) { + const wb = XLSX.utils.book_new(); + const dateStr = new Date().toISOString().slice(0, 10); + + // Hoja 1: Resumen KPIs + const kpiRows = [ + ['AGRO CONTROL SYSTEM — Reporte Ejecutivo', '', '', ''], + [`Período: ${period}`, '', 'Generado:', dateStr], + ['', '', '', ''], + ['INDICADOR', 'VALOR', 'DETALLE', ''], + ...kpis.map(k => [k.label, k.value, k.sub, '']), + ]; + const wsKPI = XLSX.utils.aoa_to_sheet(kpiRows); + wsKPI['!cols'] = [{ wch: 28 }, { wch: 16 }, { wch: 38 }, { wch: 10 }]; + XLSX.utils.book_append_sheet(wb, wsKPI, 'Resumen KPIs'); + + // Hoja 2: Sensores + const sensorRows = sensors.map(s => ({ + ID: s.id, + Nombre: s.name, + Tipo: s.type, + Ubicacion: s.location, + 'Placa Arduino': s.arduinoId, + 'Lectura Actual': s.value === '---' ? 'Sin señal' : s.value, + 'Valor Numerico': s.value === '---' ? '' : s.numericValue, + Unidad: s.unit.trim(), + Estado: s.value === '---' ? 'Offline' : s.status, + })); + const wsSensors = XLSX.utils.json_to_sheet(sensorRows); + wsSensors['!cols'] = [{ wch: 8 }, { wch: 24 }, { wch: 14 }, { wch: 18 }, { wch: 16 }, { wch: 16 }, { wch: 16 }, { wch: 12 }, { wch: 12 }]; + XLSX.utils.book_append_sheet(wb, wsSensors, 'Sensores'); + + // Hoja 3: Arduinos + const ardRows = arduinos.map(a => ({ + ID: a.id, + Nombre: a.name, + Ubicacion: a.location, + Estado: a.status === 'active' ? 'Activo' : 'Inactivo', + 'Baudios (bps)': a.baudRate, + 'Frecuencia (seg)': a.frequency, + Descripcion: a.description, + })); + const wsArd = XLSX.utils.json_to_sheet(ardRows); + wsArd['!cols'] = [{ wch: 16 }, { wch: 28 }, { wch: 18 }, { wch: 12 }, { wch: 14 }, { wch: 16 }, { wch: 40 }]; + XLSX.utils.book_append_sheet(wb, wsArd, 'Arduinos'); + + // Hoja 4: Parcelas + const parcRows = parcelaData.map(p => ({ + Parcela: p.parcela, + 'Indice Salud (%)': p.saludPct, + 'Humedad (%)': p.humedad || 'N/A', + 'Temperatura (C)': p.temperatura || 'N/A', + 'pH Suelo': p.ph || 'N/A', + 'Sensores Activos': p.sensores, + 'Estado General': p.saludPct >= 80 ? 'Optimo' : p.saludPct >= 60 ? 'Atencion' : 'Critico', + })); + const wsParc = XLSX.utils.json_to_sheet(parcRows); + wsParc['!cols'] = [{ wch: 20 }, { wch: 18 }, { wch: 16 }, { wch: 18 }, { wch: 12 }, { wch: 18 }, { wch: 18 }]; + XLSX.utils.book_append_sheet(wb, wsParc, 'Parcelas'); + + // Hoja 5: Alertas + const alertRows = alerts.map(a => ({ + ID: a.id, + Titulo: a.title, + Descripcion: a.description, + Severidad: a.severity === 'red' ? 'Critica' : 'Advertencia', + Tiempo: a.time, + Resuelta: a.resolved ? 'Sí' : 'No', + })); + const wsAlerts = XLSX.utils.json_to_sheet(alertRows); + wsAlerts['!cols'] = [{ wch: 8 }, { wch: 46 }, { wch: 54 }, { wch: 14 }, { wch: 14 }, { wch: 10 }]; + XLSX.utils.book_append_sheet(wb, wsAlerts, 'Alertas'); + + // Hoja 6: Telemetría histórica + const wsTelem = XLSX.utils.json_to_sheet(multiHistory); + wsTelem['!cols'] = [{ wch: 14 }, { wch: 12 }, { wch: 16 }, { wch: 10 }, { wch: 10 }]; + XLSX.utils.book_append_sheet(wb, wsTelem, 'Telemetria'); + + XLSX.writeFile(wb, `AgroControl-Reporte-${dateStr}.xlsx`); +} + +// ─── Exportar PDF profesional ───────────────────────────────────────────────── +function exportPDF( + sensors: Sensor[], + arduinos: Arduino[], + alerts: Alert[], + parcelaData: any[], + period: string, + kpis: { label: string; value: string; sub: string }[], + systemHealth: number, + avgHumidity: number, + avgTemp: number, + avgPH: number +) { + const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }); + const dateStr = new Date().toLocaleDateString('es-MX', { year: 'numeric', month: 'long', day: 'numeric' }); + const timeStr = new Date().toLocaleTimeString('es-MX'); + const pageW = doc.internal.pageSize.getWidth(); + + // ── Encabezado ── + doc.setFillColor(10, 16, 27); + doc.rect(0, 0, pageW, 38, 'F'); + + doc.setFillColor(16, 185, 129); + doc.rect(0, 0, 5, 38, 'F'); + + doc.setFont('helvetica', 'bold'); + doc.setFontSize(20); + doc.setTextColor(255, 255, 255); + doc.text('AGRO CONTROL SYSTEM', 14, 15); + + doc.setFont('helvetica', 'normal'); + doc.setFontSize(9); + doc.setTextColor(148, 163, 184); + doc.text('Centro de Inteligencia Agrícola — Reporte Ejecutivo', 14, 22); + doc.text(`Período: ${PERIOD_LABELS[period as Period] ?? period}`, 14, 28); + doc.text(`Generado: ${dateStr} a las ${timeStr}`, 14, 34); + + // ── Sección KPIs ── + doc.setFontSize(11); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(255, 255, 255); + doc.setFillColor(15, 23, 42); + doc.rect(0, 42, pageW, 7, 'F'); + doc.text('INDICADORES CLAVE DEL SISTEMA', 14, 47.5); + + const kpiTableData = kpis.map(k => [k.label, k.value, k.sub]); + autoTable(doc, { + startY: 52, + head: [['Indicador', 'Valor', 'Detalle']], + body: kpiTableData, + theme: 'grid', + styles: { fontSize: 9, cellPadding: 3, textColor: [203, 213, 225], fillColor: [15, 23, 42] }, + headStyles: { fillColor: [16, 185, 129], textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 8 }, + alternateRowStyles: { fillColor: [20, 30, 48] }, + columnStyles: { 0: { fontStyle: 'bold', cellWidth: 65 }, 1: { cellWidth: 30, halign: 'center' }, 2: { cellWidth: 80 } }, + margin: { left: 14, right: 14 }, + }); + + // ── Métricas clave en recuadros ── + const metricsY = (doc as any).lastAutoTable.finalY + 8; + const metricsData = [ + { label: 'Salud Sistema', value: `${systemHealth}%`, color: systemHealth >= 80 ? [16, 185, 129] : [245, 158, 11] }, + { label: 'Humedad Prom.', value: `${avgHumidity}%`, color: [56, 189, 248] }, + { label: 'Temperatura', value: `${avgTemp}°C`, color: [245, 158, 11] }, + { label: 'pH Promedio', value: avgPH > 0 ? `${avgPH}` : 'N/A', color: [167, 139, 250] }, + ]; + const boxW = (pageW - 28 - 9) / 4; + metricsData.forEach((m, i) => { + const x = 14 + i * (boxW + 3); + doc.setFillColor(15, 23, 42); + doc.setDrawColor(...(m.color as [number, number, number])); + doc.setLineWidth(0.5); + doc.roundedRect(x, metricsY, boxW, 18, 2, 2, 'FD'); + doc.setFont('helvetica', 'bold'); + doc.setFontSize(14); + doc.setTextColor(...(m.color as [number, number, number])); + doc.text(m.value, x + boxW / 2, metricsY + 10, { align: 'center' }); + doc.setFont('helvetica', 'normal'); + doc.setFontSize(7); + doc.setTextColor(100, 116, 139); + doc.text(m.label.toUpperCase(), x + boxW / 2, metricsY + 15, { align: 'center' }); + }); + + // ── Tabla de Sensores ── + doc.addPage(); + doc.setFillColor(10, 16, 27); + doc.rect(0, 0, pageW, 14, 'F'); + doc.setFillColor(16, 185, 129); + doc.rect(0, 0, 5, 14, 'F'); + doc.setFont('helvetica', 'bold'); + doc.setFontSize(11); + doc.setTextColor(255, 255, 255); + doc.text('INVENTARIO DE SENSORES', 14, 9); + + const sensorTableData = sensors.map(s => [ + s.id, + s.name, + s.type, + s.location, + s.arduinoId, + s.value === '---' ? 'Sin señal' : s.value, + s.value === '---' ? 'Offline' : s.status, + ]); + autoTable(doc, { + startY: 18, + head: [['ID', 'Nombre', 'Tipo', 'Ubicación', 'Arduino', 'Lectura', 'Estado']], + body: sensorTableData, + theme: 'grid', + styles: { fontSize: 8, cellPadding: 2.5, textColor: [203, 213, 225], fillColor: [15, 23, 42] }, + headStyles: { fillColor: [16, 185, 129], textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 7.5 }, + alternateRowStyles: { fillColor: [20, 30, 48] }, + didParseCell: (data: any) => { + if (data.section === 'body' && data.column.index === 6) { + const val = data.cell.raw as string; + if (val === 'OK') data.cell.styles.textColor = [16, 185, 129]; + else if (val === 'Crítico' || val === 'Offline') data.cell.styles.textColor = [239, 68, 68]; + else if (val === 'Atención') data.cell.styles.textColor = [245, 158, 11]; + } + }, + margin: { left: 14, right: 14 }, + }); + + // ── Tabla de Parcelas ── + const parcY = (doc as any).lastAutoTable.finalY + 10; + doc.setFont('helvetica', 'bold'); + doc.setFontSize(11); + doc.setTextColor(255, 255, 255); + doc.setFillColor(15, 23, 42); + doc.rect(14, parcY - 6, pageW - 28, 7, 'F'); + doc.text('REPORTE POR PARCELA', 17, parcY - 0.5); + + const parcTableData = parcelaData.map(p => [ + p.parcela, + `${p.saludPct}%`, + p.humedad > 0 ? `${p.humedad}%` : 'N/A', + p.temperatura > 0 ? `${p.temperatura}°C` : 'N/A', + p.ph > 0 ? `${p.ph}` : 'N/A', + `${p.sensores}`, + p.saludPct >= 80 ? 'Óptimo' : p.saludPct >= 60 ? 'Atención' : 'Crítico', + ]); + autoTable(doc, { + startY: parcY + 3, + head: [['Parcela', 'Salud', 'Humedad', 'Temperatura', 'pH', 'Sensores', 'Estado']], + body: parcTableData, + theme: 'grid', + styles: { fontSize: 8, cellPadding: 2.5, textColor: [203, 213, 225], fillColor: [15, 23, 42] }, + headStyles: { fillColor: [16, 185, 129], textColor: [255, 255, 255], fontStyle: 'bold' }, + alternateRowStyles: { fillColor: [20, 30, 48] }, + didParseCell: (data: any) => { + if (data.section === 'body' && data.column.index === 6) { + const val = data.cell.raw as string; + if (val === 'Óptimo') data.cell.styles.textColor = [16, 185, 129]; + else if (val === 'Crítico') data.cell.styles.textColor = [239, 68, 68]; + else if (val === 'Atención') data.cell.styles.textColor = [245, 158, 11]; + } + }, + margin: { left: 14, right: 14 }, + }); + + // ── Tabla de Alertas ── + doc.addPage(); + doc.setFillColor(10, 16, 27); + doc.rect(0, 0, pageW, 14, 'F'); + doc.setFillColor(239, 68, 68); + doc.rect(0, 0, 5, 14, 'F'); + doc.setFont('helvetica', 'bold'); + doc.setFontSize(11); + doc.setTextColor(255, 255, 255); + doc.text('HISTORIAL DE ALERTAS Y ANOMALÍAS', 14, 9); + + const alertTableData = alerts.map(a => [ + a.id, + a.title.length > 50 ? a.title.slice(0, 50) + '…' : a.title, + a.description.length > 60 ? a.description.slice(0, 60) + '…' : a.description, + a.severity === 'red' ? 'Crítica' : 'Advertencia', + a.time, + a.resolved ? 'Sí' : 'No', + ]); + autoTable(doc, { + startY: 18, + head: [['ID', 'Alerta', 'Descripción', 'Severidad', 'Tiempo', 'Resuelta']], + body: alertTableData, + theme: 'grid', + styles: { fontSize: 7.5, cellPadding: 2.5, textColor: [203, 213, 225], fillColor: [15, 23, 42] }, + headStyles: { fillColor: [239, 68, 68], textColor: [255, 255, 255], fontStyle: 'bold' }, + alternateRowStyles: { fillColor: [20, 30, 48] }, + didParseCell: (data: any) => { + if (data.section === 'body') { + if (data.column.index === 3) { + data.cell.styles.textColor = data.cell.raw === 'Crítica' ? [239, 68, 68] : [245, 158, 11]; + data.cell.styles.fontStyle = 'bold'; + } + if (data.column.index === 5) { + data.cell.styles.textColor = data.cell.raw === 'Sí' ? [16, 185, 129] : [239, 68, 68]; + } + } + }, + margin: { left: 14, right: 14 }, + }); + + // ── Tabla de Arduinos ── + const ardY2 = (doc as any).lastAutoTable.finalY + 10; + doc.setFont('helvetica', 'bold'); + doc.setFontSize(11); + doc.setTextColor(255, 255, 255); + doc.setFillColor(15, 23, 42); + doc.rect(14, ardY2 - 6, pageW - 28, 7, 'F'); + doc.text('REGISTRO DE PLACAS ARDUINO', 17, ardY2 - 0.5); + + const ardTableData = arduinos.map(a => [ + a.id, + a.name, + a.location, + a.status === 'active' ? 'Activo' : 'Inactivo', + `${a.baudRate} bps`, + `${a.frequency}s`, + ]); + autoTable(doc, { + startY: ardY2 + 3, + head: [['ID', 'Nombre', 'Ubicación', 'Estado', 'Baudios', 'Frecuencia']], + body: ardTableData, + theme: 'grid', + styles: { fontSize: 8, cellPadding: 2.5, textColor: [203, 213, 225], fillColor: [15, 23, 42] }, + headStyles: { fillColor: [15, 23, 42], textColor: [16, 185, 129], fontStyle: 'bold', lineColor: [16, 185, 129], lineWidth: 0.3 }, + alternateRowStyles: { fillColor: [20, 30, 48] }, + didParseCell: (data: any) => { + if (data.section === 'body' && data.column.index === 3) { + data.cell.styles.textColor = data.cell.raw === 'Activo' ? [16, 185, 129] : [239, 68, 68]; + data.cell.styles.fontStyle = 'bold'; + } + }, + margin: { left: 14, right: 14 }, + }); + + // ── Pie de página en todas las páginas ── + const totalPages = (doc as any).internal.getNumberOfPages(); + for (let i = 1; i <= totalPages; i++) { + doc.setPage(i); + const pageH = doc.internal.pageSize.getHeight(); + doc.setFillColor(10, 16, 27); + doc.rect(0, pageH - 10, pageW, 10, 'F'); + doc.setFont('helvetica', 'normal'); + doc.setFontSize(7); + doc.setTextColor(71, 85, 105); + doc.text('Agro Control System — Reporte Confidencial', 14, pageH - 3.5); + doc.text(`Página ${i} de ${totalPages}`, pageW - 14, pageH - 3.5, { align: 'right' }); + doc.text(dateStr, pageW / 2, pageH - 3.5, { align: 'center' }); + } + + doc.save(`AgroControl-Reporte-${new Date().toISOString().slice(0, 10)}.pdf`); +} + +// ─── Tipo de flash de exportación ──────────────────────────────────────────── +type ExportFlash = null | 'csv' | 'excel' | 'pdf' | string; + // ─── COMPONENTE PRINCIPAL ───────────────────────────────────────────────────── export function ReportsPage() { const [period, setPeriod] = useState('7d'); const [activeTab, setActiveTab] = useState<'overview' | 'sensors' | 'parcelas' | 'alertas'>('overview'); - const [exportFlash, setExportFlash] = useState(false); + const [exportFlash, setExportFlash] = useState(null); - // Leer datos de localStorage (correlacionados con IotPage) + // Leer datos sincronizados desde localStorage (IotPage) const arduinos: Arduino[] = useMemo(() => { - try { return JSON.parse(localStorage.getItem('ac_arduinos') || 'null') ?? fallbackArduinos; } catch { return fallbackArduinos; } + try { return JSON.parse(localStorage.getItem('ac_arduinos') || 'null') ?? fallbackArduinos; } + catch { return fallbackArduinos; } }, []); const sensors: Sensor[] = useMemo(() => { - try { return JSON.parse(localStorage.getItem('ac_sensors') || 'null') ?? fallbackSensors; } catch { return fallbackSensors; } + try { return JSON.parse(localStorage.getItem('ac_sensors') || 'null') ?? fallbackSensors; } + catch { return fallbackSensors; } }, []); const alerts: Alert[] = useMemo(() => { - try { return JSON.parse(localStorage.getItem('ac_alerts') || 'null') ?? fallbackAlerts; } catch { return fallbackAlerts; } + try { return JSON.parse(localStorage.getItem('ac_alerts') || 'null') ?? fallbackAlerts; } + catch { return fallbackAlerts; } }, []); const days = PERIOD_DAYS[period]; - // Historial generado dinámicamente según el período + // Historiales memoizados por período const humidityHistory = useMemo(() => generateDailyHistory(days, 65, 18, 'Humedad'), [days]); const tempHistory = useMemo(() => generateDailyHistory(days, 26, 8, 'Temperatura'), [days]); const multiHistory = useMemo(() => generateMultiSensorHistory(days), [days]); + const alertFreqHistory = useMemo(() => + generateDailyHistory(days, 1.5, 2, 'Eventos').map(d => ({ ...d, Eventos: Math.max(0, Number(d.Eventos)) })), + [days]); + + // KPIs derivados del estado real + const activeSensors = useMemo(() => sensors.filter((s) => s.value !== '---'), [sensors]); - // ── KPIs derivados del estado real ── - const activeSensors = sensors.filter((s) => s.value !== '---'); const avgHumidity = useMemo(() => { const hs = activeSensors.filter((s) => s.type === 'Humedad'); return hs.length ? Math.round(hs.reduce((a, s) => a + s.numericValue, 0) / hs.length) : 0; - }, [sensors]); + }, [activeSensors]); const avgTemp = useMemo(() => { const ts = activeSensors.filter((s) => s.type === 'Temperatura'); return ts.length ? Math.round(ts.reduce((a, s) => a + s.numericValue, 0) / ts.length * 10) / 10 : 0; - }, [sensors]); + }, [activeSensors]); const avgPH = useMemo(() => { const ps = activeSensors.filter((s) => s.type === 'pH'); return ps.length ? Math.round(ps.reduce((a, s) => a + s.numericValue, 0) / ps.length * 10) / 10 : 0; - }, [sensors]); + }, [activeSensors]); const criticalAlerts = alerts.filter((a) => !a.resolved && a.severity === 'red').length; const totalAlerts = alerts.filter((a) => !a.resolved).length; const activeArduinos = arduinos.filter((a) => a.status === 'active').length; - const systemHealth = Math.round(((activeSensors.filter(s => s.status === 'OK').length) / Math.max(sensors.length, 1)) * 100); + const systemHealth = Math.round( + (activeSensors.filter(s => s.status === 'OK').length / Math.max(sensors.length, 1)) * 100 + ); - // ── Datos por parcela ── + // Datos por parcela const locations = useMemo(() => Array.from(new Set(sensors.map((s) => s.location))), [sensors]); const parcelaData = useMemo(() => locations.map((loc) => { const locSensors = sensors.filter((s) => s.location === loc && s.value !== '---'); @@ -218,7 +576,6 @@ export function ReportsPage() { }; }), [sensors, locations]); - // ── Radar data para salud de parcelas ── const radarData = useMemo(() => parcelaData.map((p) => ({ parcela: p.parcela.replace('Parcela ', '').replace('Sector ', 'S-').replace('Zona ', 'Z-'), Salud: p.saludPct, @@ -226,14 +583,12 @@ export function ReportsPage() { Temperatura: Math.min(100, Math.round(p.temperatura * 2.5)), })), [parcelaData]); - // ── Distribución de alertas ── - const alertDistribution = [ + const alertDistribution = useMemo(() => [ { name: 'Críticas', value: alerts.filter(a => a.severity === 'red' && !a.resolved).length, color: '#ef4444' }, { name: 'Advertencias', value: alerts.filter(a => a.severity === 'amber' && !a.resolved).length, color: '#f59e0b' }, { name: 'Resueltas', value: alerts.filter(a => a.resolved).length, color: '#10b981' }, - ]; + ], [alerts]); - // ── Tipo de sensores ── const sensorTypeData = useMemo(() => { const types = sensors.reduce((acc, s) => { acc[s.type] = (acc[s.type] || 0) + 1; @@ -242,28 +597,81 @@ export function ReportsPage() { return Object.entries(types).map(([type, count]) => ({ type, count })); }, [sensors]); - const handleExport = (label: string, data: any[]) => { + // KPIs para PDF/Excel + const kpisForExport = useMemo(() => [ + { label: 'Salud del Sistema', value: `${systemHealth}%`, sub: `${activeSensors.filter(s => s.status === 'OK').length} de ${sensors.length} sensores en estado OK` }, + { label: 'Humedad Promedio de Suelo', value: `${avgHumidity}%`, sub: `Calculado sobre ${activeSensors.filter(s => s.type === 'Humedad').length} sensores activos` }, + { label: 'Temperatura Promedio', value: `${avgTemp}°C`, sub: `Calculado sobre ${activeSensors.filter(s => s.type === 'Temperatura').length} sensores activos` }, + { label: 'pH Promedio del Suelo', value: avgPH > 0 ? `${avgPH}` : 'N/A', sub: `Calculado sobre ${activeSensors.filter(s => s.type === 'pH').length} sensores activos` }, + { label: 'Arduinos Activos', value: `${activeArduinos}/${arduinos.length}`, sub: `${arduinos.filter(a => a.status === 'inactive').length} placa(s) fuera de línea` }, + { label: 'Alertas Activas', value: `${totalAlerts}`, sub: `${criticalAlerts} crítica(s) · ${alerts.filter(a => a.resolved).length} resueltas` }, + ], [systemHealth, avgHumidity, avgTemp, avgPH, activeArduinos, totalAlerts, criticalAlerts, activeSensors, sensors, arduinos, alerts]); + + // Handlers de exportación con flash + const triggerFlash = (type: ExportFlash) => { + setExportFlash(type); + setTimeout(() => setExportFlash(null), 2200); + }; + + const handleCSV = (label: string, data: any[]) => { exportCSV(data, `reporte-${label}`); - setExportFlash(true); - setTimeout(() => setExportFlash(false), 2000); + triggerFlash(`csv-${label}`); }; - const tabs: { key: typeof activeTab; label: string; icon: string }[] = [ - { key: 'overview', label: 'Resumen Ejecutivo', icon: '📊' }, - { key: 'sensors', label: 'Análisis de Sensores', icon: '🔬' }, - { key: 'parcelas', label: 'Reporte por Parcela', icon: '🌾' }, - { key: 'alertas', label: 'Historial de Alertas', icon: '🚨' }, + const handleExcel = () => { + exportExcel(sensors, arduinos, alerts, parcelaData, multiHistory, PERIOD_LABELS[period], kpisForExport); + triggerFlash('excel'); + }; + + const handlePDF = () => { + exportPDF(sensors, arduinos, alerts, parcelaData, PERIOD_LABELS[period], kpisForExport, systemHealth, avgHumidity, avgTemp, avgPH); + triggerFlash('pdf'); + }; + + const tabs = [ + { key: 'overview' as const, label: 'Resumen Ejecutivo', icon: '📊' }, + { key: 'sensors' as const, label: 'Análisis de Sensores', icon: '🔬' }, + { key: 'parcelas' as const, label: 'Reporte por Parcela', icon: '🌾' }, + { key: 'alertas' as const, label: 'Historial de Alertas', icon: '🚨' }, + ]; + + // KPI grid data + const kpiGrid = [ + { + label: 'Salud del Sistema', value: `${systemHealth}%`, icon: 'fa-heartbeat', + color: systemHealth > 70 ? '#10b981' : '#f59e0b', + sub: `${activeSensors.filter(s => s.status === 'OK').length} de ${sensors.length} sensores OK`, + progress: systemHealth, progressMax: 100, thresholds: { ok: 80, warn: 60 }, + }, + { + label: 'Humedad Promedio', value: `${avgHumidity}%`, icon: 'fa-tint', color: '#38bdf8', + sub: `${activeSensors.filter(s => s.type === 'Humedad').length} sensores activos`, + progress: avgHumidity, progressMax: 100, thresholds: { ok: 60, warn: 40 }, + }, + { + label: 'Arduinos Activos', value: `${activeArduinos}/${arduinos.length}`, icon: 'fa-microchip', color: '#a78bfa', + sub: `${arduinos.filter(a => a.status === 'inactive').length} placa(s) fuera de línea`, + progress: activeArduinos, progressMax: Math.max(arduinos.length, 1), + thresholds: { ok: arduinos.length, warn: Math.floor(arduinos.length * 0.6) }, + }, + { + label: 'Alertas Activas', value: `${totalAlerts}`, icon: 'fa-exclamation-triangle', + color: criticalAlerts > 0 ? '#ef4444' : '#f59e0b', + sub: `${criticalAlerts} crítica(s) · ${alerts.filter(a => a.resolved).length} resueltas`, + progress: Math.max(0, 100 - totalAlerts * 20), progressMax: 100, thresholds: { ok: 80, warn: 60 }, + }, ]; return (
- {/* ── Encabezado ── */} + {/* ── Encabezado con controles ── */}
-

Centro de Inteligencia Agrícola · Datos en tiempo real

+

Centro de Inteligencia Agrícola · Datos sincronizados en tiempo real

Sistema de Reportes

+
{/* Selector de período */}
@@ -271,53 +679,45 @@ export function ReportsPage() { ))}
- {/* Botón Exportar */} + + {/* Botón Excel */} + + + {/* Botón PDF */} + + + {/* Botón CSV rápido */}
{/* ── KPI Grid ── */}
- {[ - { - label: 'Salud del Sistema', value: `${systemHealth}%`, icon: 'fa-heartbeat', color: systemHealth > 70 ? '#10b981' : '#f59e0b', - sub: `${activeSensors.filter(s => s.status === 'OK').length} de ${sensors.length} sensores OK`, - progress: systemHealth, progressMax: 100, thresholds: { ok: 80, warn: 60 }, - }, - { - label: 'Humedad Promedio', value: `${avgHumidity}%`, icon: 'fa-tint', color: '#38bdf8', - sub: `${activeSensors.filter(s => s.type === 'Humedad').length} sensores activos`, - progress: avgHumidity, progressMax: 100, thresholds: { ok: 60, warn: 40 }, - }, - { - label: 'Arduinos Activos', value: `${activeArduinos}/${arduinos.length}`, icon: 'fa-microchip', color: '#a78bfa', - sub: `${arduinos.filter(a => a.status === 'inactive').length} placa(s) fuera de línea`, - progress: activeArduinos, progressMax: arduinos.length, thresholds: { ok: arduinos.length, warn: Math.floor(arduinos.length * 0.6) }, - }, - { - label: 'Alertas Activas', value: `${totalAlerts}`, icon: 'fa-exclamation-triangle', color: criticalAlerts > 0 ? '#ef4444' : '#f59e0b', - sub: `${criticalAlerts} crítica(s) · ${alerts.filter(a => a.resolved).length} resueltas`, - progress: Math.max(0, 100 - totalAlerts * 20), progressMax: 100, thresholds: { ok: 80, warn: 60 }, - }, - ].map((kpi) => ( + {kpiGrid.map((kpi) => (
@@ -334,15 +734,12 @@ export function ReportsPage() {
{/* ── Pestañas ── */} -
+
{tabs.map((tab) => (
- {/* ══════════════════ TAB: RESUMEN EJECUTIVO ══════════════════ */} + {/* ══════════ TAB: RESUMEN EJECUTIVO ══════════ */}
- - {/* Gráfico principal: Multi-sensor histórico */} - +
- {[ - { key: 'Humedad', color: '#38bdf8', unit: '%' }, - { key: 'Temperatura', color: '#f59e0b', unit: '°C' }, - { key: 'pH', color: '#a78bfa', unit: '' }, - ].map((s) => ( + {[{ key: 'Humedad', color: '#38bdf8' }, { key: 'Temperatura', color: '#f59e0b' }, { key: 'pH', color: '#a78bfa' }].map((s) => (
- {s.key}{s.unit && ` (${s.unit})`} + {s.key}
))}
- - {[['#38bdf8', 'hum'], ['#f59e0b', 'temp'], ['#a78bfa', 'ph']].map(([c, id]) => ( - - - - - ))} - @@ -392,16 +772,12 @@ export function ReportsPage() {
-
- {/* Segunda fila: Humedad detallada + Temp detallada */}
@@ -445,7 +821,6 @@ export function ReportsPage() {
- {/* Tercera fila: Distribución de Alertas + Tipos de Sensor */}
@@ -453,10 +828,8 @@ export function ReportsPage() {
{item.name}
-
a.value), 1)) * 100)}%`, background: item.color }} - /> +
a.value), 1)) * 100)}%`, background: item.color }} />
{item.value}
@@ -487,10 +860,8 @@ export function ReportsPage() {
- {/* ══════════════════ TAB: ANÁLISIS DE SENSORES ══════════════════ */} + {/* ══════════ TAB: ANÁLISIS DE SENSORES ══════════ */}
- - {/* CO2 History */}
@@ -505,21 +876,18 @@ export function ReportsPage() { } /> - +
- {/* Tabla de sensores completa */}
-
@@ -534,21 +902,17 @@ export function ReportsPage() { {sensors.map((sensor) => { const isOffline = sensor.value === '---'; - const statusColors: Record = { - OK: 'bg-emerald-500/10 text-emerald-300', - 'Atención': 'bg-amber-500/10 text-amber-300', - Crítico: 'bg-red-500/10 text-red-300', - }; + const statusColors: Record = { OK: 'bg-emerald-500/10 text-emerald-300', 'Atención': 'bg-amber-500/10 text-amber-300', 'Crítico': 'bg-red-500/10 text-red-300' }; return ( {sensor.id} {sensor.name} {sensor.type} - - {sensor.location} - + {sensor.location} {sensor.arduinoId} - {isOffline ? — Sin señal — : sensor.value} + + {isOffline ? — Sin señal — : sensor.value} + {isOffline ? 'Offline' : sensor.status} @@ -556,16 +920,11 @@ export function ReportsPage() { {!isOffline && ( -
-
-
-
+
+
)} @@ -578,10 +937,8 @@ export function ReportsPage() {
- {/* ══════════════════ TAB: REPORTE POR PARCELA ══════════════════ */} + {/* ══════════ TAB: REPORTE POR PARCELA ══════════ */}
- - {/* Cards por parcela */}
{parcelaData.map((p) => { const healthColor = p.saludPct >= 80 ? '#10b981' : p.saludPct >= 60 ? '#f59e0b' : '#ef4444'; @@ -593,15 +950,13 @@ export function ReportsPage() {

{p.parcela}

- {p.saludPct}% + {p.saludPct}%
-
Índice de salud
-
{[ { label: 'Humedad', value: p.humedad > 0 ? `${p.humedad}%` : 'N/A', icon: '💧', color: '#38bdf8' }, @@ -610,15 +965,12 @@ export function ReportsPage() { { label: 'Sensores', value: p.sensores.toString(), icon: '📡', color: '#10b981' }, ].map((m) => (
-
- {m.icon}{m.label} -
+
{m.icon}{m.label}
{m.value}
))}
- -
+
{arduinos.filter(a => sensors.some(s => s.location === p.parcela && s.arduinoId === a.id)).map(a => ( {a.id} @@ -630,7 +982,6 @@ export function ReportsPage() { })}
- {/* Radar de comparativa */}
@@ -647,21 +998,17 @@ export function ReportsPage() {
- {/* Tabla comparativa */}
-
- {['Parcela', 'Salud', 'Humedad', 'Temperatura', 'pH', 'Sensores Activos', 'Estado'].map((h) => ( + {['Parcela', 'Salud', 'Humedad', 'Temperatura', 'pH', 'Sensores', 'Estado'].map((h) => ( ))} @@ -682,9 +1029,7 @@ export function ReportsPage() { - + ))} @@ -693,10 +1038,8 @@ export function ReportsPage() { - {/* ══════════════════ TAB: HISTORIAL DE ALERTAS ══════════════════ */} + {/* ══════════ TAB: HISTORIAL DE ALERTAS ══════════ */}
- - {/* KPIs de alertas */}
{[ { label: 'Alertas Críticas', value: alerts.filter(a => a.severity === 'red').length, color: '#ef4444', icon: 'fa-fire' }, @@ -717,30 +1060,18 @@ export function ReportsPage() { ))}
- {/* Timeline de alertas */}
-
- {alerts.map((alert, idx) => ( -
- {/* Timeline dot */} + {alerts.map((alert) => ( +
-
{alert.emoji}
@@ -748,7 +1079,9 @@ export function ReportsPage() { {alert.title} {alert.resolved ? Resuelta - : {alert.severity === 'red' ? 'Crítico' : 'Advertencia'} + : + {alert.severity === 'red' ? 'Crítico' : 'Advertencia'} + }

{alert.description}

@@ -768,11 +1101,10 @@ export function ReportsPage() {
- {/* Frecuencia de alertas por día (simulada) */}
- ({ ...d, Eventos: Math.max(0, d.Eventos) }))}> + From 920989169d26704fb633fa5a1f19637e0b2b45be Mon Sep 17 00:00:00 2001 From: benchav Date: Fri, 22 May 2026 22:29:20 -0600 Subject: [PATCH 3/3] docs: add technical documentation for IoT module enhancements and advanced reporting system features --- documents/actualizacion_reportes.md | 47 +++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 documents/actualizacion_reportes.md diff --git a/documents/actualizacion_reportes.md b/documents/actualizacion_reportes.md new file mode 100644 index 0000000..f5437b1 --- /dev/null +++ b/documents/actualizacion_reportes.md @@ -0,0 +1,47 @@ +# Actualización del Módulo de Reportes e IoT - Agro Control System + +Mejoras y nuevas funciones implementadas en los módulos de IoT y Reportes para llevar el sistema a un nivel profesional (Senior/Alta Gama). + +## 1. Módulo IoT (`IotPage.tsx`) + +El panel de IoT se transformó en un centro de control y administración de hardware con las siguientes características: + +* **Integración Multimedia:** + * **Video de YouTube Embebido:** Se integró un video específico que se reproduce automáticamente, sin audio y con los controles e información de YouTube ocultos para una apariencia más limpia. + * **Modelo 3D (Sketchfab):** Se agregó un visor interactivo de un modelo 3D de un entorno Arduino/Planta para ofrecer una experiencia visual inmersiva. +* **Gestión de Dispositivos (CRUD):** Implementación completa para crear, leer, actualizar y eliminar (simuladamente) placas Arduino. +* **Simulación de Sensores:** Sistema dinámico para generar lecturas de sensores que simulan datos en tiempo real. +* **Correlación de Datos:** Todos los datos generados y gestionados en este módulo se sincronizan y guardan en el `localStorage` (`ac_arduinos`, `ac_sensors`, `ac_alerts`), actuando como la única fuente de verdad para el resto de la aplicación. + +## 2. Módulo de Reportes (`ReportsPage.tsx`) + +La pantalla de reportes fue reescrita por completo (pasando de un boceto a un sistema de ~800 líneas) para ofrecer una experiencia analítica de primer nivel. + +### Estructura de Pestañas +El panel se divide en 4 secciones principales: +1. **Resumen Ejecutivo:** Vista general con gráficos de telemetría histórica combinada (Multi-sensor), tendencias individuales de temperatura y humedad, distribución de alertas y un inventario de tipos de sensores. +2. **Análisis de Sensores:** Gráfico de evolución de CO2 y una tabla exhaustiva de todo el inventario de sensores con barras de salud visuales y coloreadas según el estado (OK, Atención, Crítico). +3. **Reporte por Parcela:** Tarjetas individuales para cada zona o parcela, mostrando su índice de salud. Incluye un gráfico de Radar (`RadarChart`) para comparar múltiples dimensiones (Salud, Humedad, Temperatura) entre diferentes parcelas, y una tabla resumen. +4. **Historial de Alertas:** Timeline cronológico ("Registro de Eventos") con diseño visual de estados y un gráfico de barras de frecuencia diaria. + +### Funciones Analíticas y Gráficos +* **KPIs Dinámicos:** Los indicadores de la parte superior (Salud del Sistema, Humedad Promedio, Arduinos Activos, Alertas) se calculan en tiempo real consumiendo los datos sincronizados del `localStorage`. +* **Librería `recharts`:** Uso intensivo de gráficos modernos (`AreaChart`, `LineChart`, `BarChart`, `RadarChart`) con gradientes personalizados, tooltips detallados y líneas de referencia (umbrales críticos). +* **Generación Sintética de Datos:** Funciones `generateDailyHistory` y `generateMultiSensorHistory` que generan curvas de datos realistas (con varianza calculada) adaptadas al período seleccionado (7, 14 o 30 días). + +### Sistema Profesional de Exportación de Datos +Se integraron librerías de nivel empresarial para la generación de reportes descargables: + +* **Exportación CSV Rápida:** Función nativa para exportar tablas específicas con un solo clic. Genera el archivo con codificación UTF-8 BOM (`\uFEFF`) para compatibilidad perfecta con acentos en Excel, y sanitización de comas. +* **Exportación Excel Multi-hoja (`xlsx` / SheetJS):** + * La función `exportExcel` genera un único archivo `.xlsx` estructurado en 6 hojas de cálculo separadas: *Resumen KPIs, Sensores, Arduinos, Parcelas, Alertas y Telemetria*. + * Ajuste automático de ancho de columnas para mejor legibilidad. +* **Exportación PDF Profesional (`jspdf` + `jspdf-autotable`):** + * La función `exportPDF` construye un documento de múltiples páginas con orientación vertical (A4). + * **Diseño:** Tema oscuro (`#0a101b`), con recuadros redondeados, colores de branding (esmeralda, rojo, ámbar), encabezados formales y pie de página con paginación automática y fecha de generación. + * **Tablas Inteligentes:** Las celdas cambian de color automáticamente dependiendo de su valor (ej. verde para "OK" / "Activo", rojo para "Crítico" / "Inactivo") utilizando los hooks de `autotable`. + +### Calidad de Código +* Se corrigieron warnings de React (`useEffect` sin uso). +* Se optimizaron las funciones de generación de datos usando `useMemo` para evitar re-renderizados innecesarios y caídas de rendimiento. +* Se resolvieron conflictos de inferencia de tipos de TypeScript (Error `TS2345` en las claves dinámicas del historial) asegurando un build 100% libre de errores.
{h}
{p.temperatura > 0 ? `${p.temperatura}°C` : '—'} {p.ph > 0 ? p.ph : '—'} {p.sensores} - -