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.
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..4ab38c5 100644
--- a/src/pages/ReportsPage.tsx
+++ b/src/pages/ReportsPage.tsx
@@ -1,57 +1,1121 @@
+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 (sincronizadas con 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;
+}
+
+// ─── 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 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[] = [
+ { 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: '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[] = [
+ { 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 },
+];
+
+// ─── Generadores de historial sintético ──────────────────────────────────────
+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;
+};
+
+// ─── 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 ────────────────────────────────────────────────────────
+function ProgressBar({ value, max, color }: { value: number; max: number; color: string }) {
+ const pct = Math.min(100, max > 0 ? Math.round((value / max) * 100) : 0);
+ 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).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;
+ a.download = `${filename}-${new Date().toISOString().slice(0, 10)}.csv`;
+ a.click();
+ 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(null);
+
+ // Leer datos sincronizados desde localStorage (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];
+
+ // 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]);
+
+ 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;
+ }, [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;
+ }, [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;
+ }, [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
+ );
+
+ // 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]);
+
+ 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]);
+
+ 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]);
+
+ 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]);
+
+ // 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}`);
+ triggerFlash(`csv-${label}`);
+ };
+
+ 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 (
-
-
Análisis histórico · Proyecciones · Exportar datos
-
- {[
- ['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]) => (
-
-
-
+
+
+ {/* ── Encabezado con controles ── */}
+
+
+
Centro de Inteligencia Agrícola · Datos sincronizados en tiempo real
+
Sistema de Reportes
+
+
+
+ {/* Selector de período */}
+
+ {(Object.keys(PERIOD_DAYS) as Period[]).map((p) => (
+
+ ))}
+
+
+ {/* Botón Excel */}
+
+
+ {/* Botón PDF */}
+
+
+ {/* Botón CSV rápido */}
+
+
+
+
+ {/* ── KPI Grid ── */}
+
+ {kpiGrid.map((kpi) => (
+
+
-
{label}
-
{value}
+
{kpi.label}
+
{kpi.value}
+
+
{kpi.sub}
))}
-
-
-
-
-
- | Parcela |
- Fertilidad |
- pH |
- Humedad |
- Temperatura |
-
-
-
- {[
- ['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]) => (
-
- | {parcel} |
- {fertility} |
- {ph} |
- {humidity} |
- {temp} |
-
+ {/* ── Pestañas ── */}
+
+ {tabs.map((tab) => (
+
+ ))}
+
+
+ {/* ══════════ TAB: RESUMEN EJECUTIVO ══════════ */}
+
+
+
+ {[{ key: 'Humedad', color: '#38bdf8' }, { key: 'Temperatura', color: '#f59e0b' }, { key: 'pH', color: '#a78bfa' }].map((s) => (
+
+
+ {s.key}
+
+ ))}
+
+
+
+
+
+
+
+ } />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ } />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ } />
+
+
+
+
+
+
+
+
+
+
+
+ {alertDistribution.map((item) => (
+
+
{item.name}
+
+
a.value), 1)) * 100)}%`, background: item.color }} />
+
+
{item.value}
+
))}
-
-
+
+
+ {alertDistribution.map((item) => (
+
+
{item.value}
+
{item.name}
+
+ ))}
+
+
+
+
+
+
+
+
+
+ } />
+
+
+
+
+
-
+
+
+ {/* ══════════ TAB: ANÁLISIS DE SENSORES ══════════ */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ } />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {['ID', 'Nombre', 'Tipo', 'Ubicación', 'Placa Arduino', 'Lectura actual', 'Estado', 'Salud'].map((h) => (
+ | {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 (
+
+ | {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 ══════════ */}
+
+
+ {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}
+
+ ))}
+
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
+
+
+ } />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {['Parcela', 'Salud', 'Humedad', 'Temperatura', 'pH', 'Sensores', 'Estado'].map((h) => (
+ | {h} |
+ ))}
+
+
+
+ {parcelaData.map((p) => (
+
+ | {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 ══════════ */}
+
+
+ {[
+ { 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}
+
+
+
+ ))}
+
+
+
+
+
+
+
+ {alerts.map((alert) => (
+
+
+
+
{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.
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ } />
+
+
+
+
+
+
+
);
-}
+}
\ No newline at end of file