diff --git a/README.md b/README.md index 5f1fff7..34266b0 100644 --- a/README.md +++ b/README.md @@ -173,48 +173,66 @@ WHERE s.sensorType = 'Temperature & Humidity'; -- VISTAS AUXILIARES CREATE OR REPLACE VIEW v_pollution_map AS -SELECT +SELECT + d.deviceId AS deviceId, + d.deviceName AS deviceName, + g.lat AS lat, + g.lon AS lon, + c.carbonMonoxide AS carbonMonoxide, + c.timestamp AS timestamp +FROM + (dad.devices d +LEFT JOIN dad.v_co_by_device c ON d.deviceId = c.deviceId) +LEFT JOIN dad.v_gps_by_device g ON d.deviceId = g.deviceId AND (g.timestamp <= c.timestamp OR g.timestamp IS NULL) +WHERE + c.carbonMonoxide IS NOT NULL +ORDER BY d.deviceId, - d.deviceName, - g.lat, - g.lon, - c.carbonMonoxide, - c.timestamp -FROM devices d -LEFT JOIN v_co_by_device c ON d.deviceId = c.deviceId -LEFT JOIN v_gps_by_device g ON d.deviceId = g.deviceId - AND (g.timestamp <= c.timestamp OR g.timestamp IS NULL) -WHERE c.carbonMonoxide IS NOT NULL -ORDER BY d.deviceId, c.timestamp; + c.timestamp; CREATE OR REPLACE VIEW v_sensor_history_by_device AS -SELECT - d.deviceId, - d.deviceName, +SELECT + d.deviceId AS deviceId, + d.deviceName AS deviceName, w.temperature AS value, 'temperature' AS valueType, - w.timestamp -FROM devices d -JOIN v_weather_by_device w ON d.deviceId = w.deviceId + w.timestamp AS timestamp +FROM + dad.devices d +JOIN dad.v_weather_by_device w ON d.deviceId = w.deviceId UNION ALL -SELECT - d.deviceId, - d.deviceName, +SELECT + d.deviceId AS deviceId, + d.deviceName AS deviceName, w.humidity AS value, 'humidity' AS valueType, - w.timestamp -FROM devices d -JOIN v_weather_by_device w ON d.deviceId = w.deviceId + w.timestamp AS timestamp +FROM + dad.devices d +JOIN dad.v_weather_by_device w ON d.deviceId = w.deviceId UNION ALL -SELECT - d.deviceId, - d.deviceName, +SELECT + d.deviceId AS deviceId, + d.deviceName AS deviceName, + w.pressure AS value, + 'pressure' AS valueType, + w.timestamp AS timestamp +FROM + dad.devices d +JOIN dad.v_weather_by_device w ON d.deviceId = w.deviceId +UNION ALL +SELECT + d.deviceId AS deviceId, + d.deviceName AS deviceName, c.carbonMonoxide AS value, 'carbonMonoxide' AS valueType, - c.timestamp -FROM devices d -JOIN v_co_by_device c ON d.deviceId = c.deviceId -ORDER BY deviceId, timestamp; + c.timestamp AS timestamp +FROM + dad.devices d +JOIN dad.v_co_by_device c ON d.deviceId = c.deviceId +ORDER BY + deviceId, + timestamp; -- Insertar grupos INSERT INTO groups (groupName) VALUES ('Grupo 1'); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cd90e81..3e580ee 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,6 +17,7 @@ "bootstrap": "^5.3.3", "chart.js": "^4.4.8", "leaflet": "^1.9.4", + "leaflet.heat": "^0.2.0", "react": "^19.0.0", "react-chartjs-2": "^5.3.0", "react-dom": "^19.0.0", @@ -3556,6 +3557,11 @@ "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", "license": "BSD-2-Clause" }, + "node_modules/leaflet.heat": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/leaflet.heat/-/leaflet.heat-0.2.0.tgz", + "integrity": "sha512-Cd5PbAA/rX3X3XKxfDoUGi9qp78FyhWYurFg3nsfhntcM/MCNK08pRkf4iEenO1KNqwVPKCmkyktjW3UD+h9bQ==" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 55e50ca..f1d0fb6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ "bootstrap": "^5.3.3", "chart.js": "^4.4.8", "leaflet": "^1.9.4", + "leaflet.heat": "^0.2.0", "react": "^19.0.0", "react-chartjs-2": "^5.3.0", "react-dom": "^19.0.0", diff --git a/frontend/public/images/logo-dark.png b/frontend/public/images/logo-dark.png new file mode 100644 index 0000000..ec5b8f1 Binary files /dev/null and b/frontend/public/images/logo-dark.png differ diff --git a/frontend/public/images/logo-light.png b/frontend/public/images/logo-light.png new file mode 100644 index 0000000..4d30c2e Binary files /dev/null and b/frontend/public/images/logo-light.png differ diff --git a/frontend/public/images/logo.png b/frontend/public/images/logo.png deleted file mode 100644 index 0c3ac2a..0000000 Binary files a/frontend/public/images/logo.png and /dev/null differ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 6534b1e..952652d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3,41 +3,28 @@ import 'leaflet/dist/leaflet.css' import 'bootstrap/dist/css/bootstrap.min.css' import 'bootstrap/dist/js/bootstrap.bundle.min.js' -import Home from '@/pages/Home.jsx' import Dashboard from '@/pages/Dashboard.jsx' -import MenuButton from '@/components/layout/MenuButton.jsx' -import SideMenu from '@/components/layout/SideMenu.jsx' +import Groups from '@/pages/Groups.jsx' import ThemeButton from '@/components/layout/ThemeButton.jsx' import Header from '@/components/layout/Header.jsx' import GroupView from '@/pages/GroupView.jsx' import { Routes, Route } from 'react-router-dom' -import { useState } from 'react' +import ContentWrapper from './components/layout/ContentWrapper' const App = () => { - const [isSideMenuOpen, setIsSideMenuOpen] = useState(false); - - const toggleSideMenu = () => { - setIsSideMenuOpen(!isSideMenuOpen); - }; - - const closeSideMenu = () => { - setIsSideMenuOpen(false); - } return ( <> - - -
-
+
+ - } /> + } /> } /> } /> -
+ ); } diff --git a/frontend/src/components/HistoryCharts.jsx b/frontend/src/components/HistoryCharts.jsx index 214bbe0..0defffb 100644 --- a/frontend/src/components/HistoryCharts.jsx +++ b/frontend/src/components/HistoryCharts.jsx @@ -1,7 +1,6 @@ import { Line } from "react-chartjs-2"; import { Chart as ChartJS, LineElement, PointElement, LinearScale, CategoryScale, Filler } from "chart.js"; import CardContainer from "./layout/CardContainer"; -import "@/css/HistoryCharts.css"; import PropTypes from "prop-types"; import { useTheme } from "@/hooks/useTheme"; @@ -12,74 +11,91 @@ import { useConfig } from "@/hooks/useConfig"; ChartJS.register(LineElement, PointElement, LinearScale, CategoryScale, Filler); const HistoryCharts = ({ groupId, deviceId }) => { - const { config, configLoading, configError } = useConfig(); + const { config, configLoading, configError } = useConfig(); - if (configLoading) return

Cargando configuración...

; - if (configError) return

Error al cargar configuración: {configError}

; - if (!config) return

Configuración no disponible.

; + if (configLoading) return

Cargando configuración...

; + if (configError) return

Error al cargar configuración: {configError}

; + if (!config) return

Configuración no disponible.

; - const BASE = config.appConfig.endpoints.LOGIC_URL; - const ENDPOINT = config.appConfig.endpoints.GET_DEVICE_HISTORY; - const endp = ENDPOINT - .replace(':groupId', groupId) - .replace(':deviceId', deviceId); // si tu endpoint lo necesita + const BASE = config.appConfig.endpoints.LOGIC_URL; + const ENDPOINT = config.appConfig.endpoints.GET_DEVICE_HISTORY; + const endp = ENDPOINT + .replace(':groupId', groupId) + .replace(':deviceId', deviceId); // si tu endpoint lo necesita - const reqConfig = { - baseUrl: `${BASE}${endp}`, - params: {} - }; + const reqConfig = { + baseUrl: `${BASE}${endp}`, + params: {} + }; - return ( - - - - ); + return ( + + + + ); }; const HistoryChartsContent = () => { const { config } = useConfig(); const { data, loading, error } = useDataContext(); const { theme } = useTheme(); - + const optionsDark = config?.appConfig?.historyChartConfig?.chartOptionsDark ?? {}; const optionsLight = config?.appConfig?.historyChartConfig?.chartOptionsLight ?? {}; const options = theme === "dark" ? optionsDark : optionsLight; - - const currentHour = new Date().getHours(); - const timeLabels = [ - `${currentHour - 3}:00`, `${currentHour - 2}:00`, `${currentHour - 1}:00`, `${currentHour}:00`, `${currentHour + 1}:00`, `${currentHour + 2}:00`, `${currentHour + 3}:00` - ] if (loading) return

Cargando datos...

; if (error) return

Datos no disponibles.

; - const temperatureData = []; - const humidityData = []; - const pollutionLevels = []; + const grouped = { + temperature: [], + humidity: [], + pressure: [], + carbonMonoxide: [] + }; data?.forEach(sensor => { - if (sensor.value != null) { - if (sensor.sensor_type === "MQ-135") { - pollutionLevels.push(sensor.value); - } else if (sensor.sensor_type === "DHT-11") { - temperatureData.push(sensor.value); - humidityData.push(sensor.value); - } - } + if (sensor.value != null && grouped[sensor.valueType]) { + grouped[sensor.valueType].push({ + timestamp: new Date(sensor.timestamp), + value: sensor.value + }); + } }); + const sortAndExtract = (entries) => { + const sorted = entries.sort((a, b) => a.timestamp - b.timestamp); + const labels = sorted.map(e => + new Date(e.timestamp * 1000).toLocaleTimeString('es-ES', { + timeZone: 'Europe/Madrid', + hour: '2-digit', + minute: '2-digit' + }) + ); + const values = sorted.map(e => e.value); + return { labels, values }; + }; + + const temp = sortAndExtract(grouped.temperature); + const hum = sortAndExtract(grouped.humidity); + const press = sortAndExtract(grouped.pressure); + const co = sortAndExtract(grouped.carbonMonoxide); + + const timeLabels = temp.labels.length ? temp.labels : hum.labels.length ? hum.labels : co.labels.length ? co.labels : ["Sin datos"]; + const historyData = [ - { title: "🌡️ Temperatura", data: temperatureData.length ? temperatureData : [0], borderColor: "#00FF85", backgroundColor: "rgba(0, 255, 133, 0.2)" }, - { title: "💧 Humedad", data: humidityData.length ? humidityData : [0], borderColor: "#00D4FF", backgroundColor: "rgba(0, 212, 255, 0.2)" }, - { title: "☁️ Contaminación", data: pollutionLevels.length ? pollutionLevels : [0], borderColor: "#FFA500", backgroundColor: "rgba(255, 165, 0, 0.2)" } - ]; + { title: "🌡️ Temperatura", data: temp.values, borderColor: "#00FF85", backgroundColor: "rgba(0, 255, 133, 0.2)" }, + { title: "💦 Humedad", data: hum.values, borderColor: "#00D4FF", backgroundColor: "rgba(0, 212, 255, 0.2)" }, + { title: "⏲ Presión", data: press.values, borderColor: "#B12424", backgroundColor: "rgba(255, 0, 0, 0.2)" }, + { title: "☁️ Contaminación", data: co.values, borderColor: "#FFA500", backgroundColor: "rgba(255, 165, 0, 0.2)" } + ]; return ( ({ title, content: ( - { /> ), styleMode: "override", - className: "col-lg-4 col-xxs-12 d-flex flex-column align-items-center p-3 card-container" + className: "col-lg-6 col-xxs-12 d-flex flex-column align-items-center p-3 card-container", + style: { minHeight: "250px" } }))} className="" /> diff --git a/frontend/src/components/PollutionMap.jsx b/frontend/src/components/PollutionMap.jsx index cd640cd..8dc6c7d 100644 --- a/frontend/src/components/PollutionMap.jsx +++ b/frontend/src/components/PollutionMap.jsx @@ -1,4 +1,3 @@ -import { MapContainer, TileLayer, Circle, Popup } from 'react-leaflet'; import PropTypes from 'prop-types'; import { useConfig } from '@/hooks/useConfig.js'; @@ -6,38 +5,10 @@ import { useConfig } from '@/hooks/useConfig.js'; import { DataProvider } from '@/context/DataContext.jsx'; import { useDataContext } from '@/hooks/useDataContext'; -const PollutionCircles = ({ data }) => { - return data.map(({ lat, lng, level }, index) => { - const baseColor = level < 20 ? '#00FF85' : level < 60 ? '#FFA500' : '#FF0000'; - const steps = 4; - const maxRadius = 400; - const stepSize = maxRadius / steps; +import L from "leaflet"; +import "leaflet.heat"; - return ( -
- {[...Array(steps)].map((_, i) => { - const radius = stepSize * (i + 1); - const opacity = 0.6 * ((i + 1) / steps); - return ( - - ); - })} - - Contaminación: {level} µg/m³ - -
- ); - }); -}; +import { useEffect } from 'react'; const PollutionMap = ({ groupId, deviceId }) => { const { config, configLoading, configError } = useConfig(); @@ -64,10 +35,33 @@ const PollutionMap = ({ groupId, deviceId }) => { ); }; - const PollutionMapContent = () => { const { config, configLoading, configError } = useConfig(); const { data, dataLoading, dataError } = useDataContext(); + + useEffect(() => { + if (!data || !config) return; + + const mapContainer = document.getElementById("map"); + if (!mapContainer) return; + + const SEVILLA = config.userConfig.city; + + const map = L.map(mapContainer).setView(SEVILLA, 12); + + L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { + attribution: + '© OpenStreetMap contributors' + }).addTo(map); + + const points = data.map((p) => [p.lat, p.lon, p.carbonMonoxide]); + + L.heatLayer(points, { radius: 25 }).addTo(map); + + return () => { + map.remove(); + }; +}, [data, config]); if (configLoading) return

Cargando configuración...

; if (configError) return

Error al cargar configuración: {configError}

; @@ -77,33 +71,13 @@ const PollutionMapContent = () => { if (dataError) return

Error al cargar datos: {configError}

; if (!data) return

Datos no disponibles.

; - const SEVILLA = config?.userConfig.city; - - const pollutionData = data.map((measure) => ({ - lat: measure.lat, - lng: measure.lon, - level: measure.carbonMonoxide - })); - return ( -
- - - - +
+
); } -const mapStyles = { - height: '500px', - width: '100%', - borderRadius: '20px' -}; - PollutionMap.propTypes = { groupId: PropTypes.number.isRequired, deviceId: PropTypes.number.isRequired diff --git a/frontend/src/components/SummaryCards.jsx b/frontend/src/components/SummaryCards.jsx index ec30a24..551e176 100644 --- a/frontend/src/components/SummaryCards.jsx +++ b/frontend/src/components/SummaryCards.jsx @@ -1,14 +1,10 @@ import PropTypes from 'prop-types'; import CardContainer from './layout/CardContainer'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faCloud, faClock, faTemperature0, faWater } from '@fortawesome/free-solid-svg-icons'; - import { DataProvider } from '@/context/DataContext'; import { useDataContext } from '@/hooks/useDataContext'; import { useConfig } from '@/hooks/useConfig.js'; -import { DateParser } from '@/util/dateParser'; const SummaryCards = ({ groupId, deviceId }) => { const { config, configLoading, configError } = useConfig(); @@ -21,7 +17,7 @@ const SummaryCards = ({ groupId, deviceId }) => { const ENDPOINT = config.appConfig.endpoints.GET_DEVICE_LATEST_VALUES; const endp = ENDPOINT .replace(':groupId', groupId) - .replace(':deviceId', deviceId); // solo si lo necesitas así + .replace(':deviceId', deviceId); const reqConfig = { baseUrl: `${BASE}${endp}`, @@ -43,32 +39,29 @@ const SummaryCardsContent = () => { if (!data) return

Datos no disponibles.

; const CardsData = [ - { id: 1, title: "Temperatura", content: "N/A", status: "Esperando datos...", titleIcon: }, - { id: 2, title: "Humedad", content: "N/A", status: "Esperando datos...", titleIcon: }, - { id: 3, title: "Nivel de CO", content: "N/A", status: "Esperando datos...", titleIcon: }, - { id: 4, title: "Actualizado a las", content: "N/A", status: "Esperando datos...", titleIcon: } + { id: 1, title: "Temperatura", content: "N/A", status: "Esperando datos...", titleIcon: '🌡 ' }, + { id: 2, title: "Humedad", content: "N/A", status: "Esperando datos...", titleIcon: '💦 ' }, + { id: 3, title: "Presión", content: "N/A", status: "Esperando datos...", titleIcon: '⏲ ' }, + { id: 4, title: "Nivel de CO", content: "N/A", status: "Esperando datos...", titleIcon: '☁ ' } ]; if (data) { - let coData = data[1]; - let tempData = data[2]; - - let lastTime = DateParser.timestampToString(coData.timestamp); - let lastDate = new Date(coData.timestamp); + let coData = data[2]; + let tempData = data[1]; CardsData[0].content = tempData.temperature + "°C"; CardsData[0].status = "Temperatura actual"; CardsData[1].content = tempData.humidity + "%"; CardsData[1].status = "Humedad actual"; - CardsData[2].content = coData.carbonMonoxide + " ppm"; - CardsData[2].status = "Nivel de CO actual"; - CardsData[3].content = lastTime; - CardsData[3].status = "Día " + lastDate.toLocaleDateString(); + CardsData[3].content = coData.carbonMonoxide + " ppm"; + CardsData[3].status = "Nivel de CO actual"; + CardsData[2].content = tempData.pressure + " hPa"; + CardsData[2].status = "Presión actual"; } return ( - + ); } diff --git a/frontend/src/components/layout/Card.jsx b/frontend/src/components/layout/Card.jsx index 1893a0c..f07296d 100644 --- a/frontend/src/components/layout/Card.jsx +++ b/frontend/src/components/layout/Card.jsx @@ -3,7 +3,7 @@ import { useState, useEffect, useRef } from "react"; import "@/css/Card.css"; import { useTheme } from "@/hooks/useTheme"; -const Card = ({ title, status, children, styleMode, className, titleIcon }) => { +const Card = ({ title, status, children, styleMode, className, titleIcon, style }) => { const cardRef = useRef(null); const [shortTitle, setShortTitle] = useState(title); const { theme } = useTheme(); @@ -30,8 +30,9 @@ const Card = ({ title, status, children, styleMode, className, titleIcon }) => { ref={cardRef} className={styleMode === "override" ? `${className}` : `col-xl-3 col-sm-6 d-flex flex-column align-items-center p-3 card-container ${className}`} + > -
+

{titleIcon} {shortTitle} @@ -50,6 +51,7 @@ Card.propTypes = { styleMode: PropTypes.oneOf(["override", ""]), className: PropTypes.string, titleIcon: PropTypes.node, + style: PropTypes.object, }; Card.defaultProps = { diff --git a/frontend/src/components/layout/CardContainer.jsx b/frontend/src/components/layout/CardContainer.jsx index 2271a41..1e5253c 100644 --- a/frontend/src/components/layout/CardContainer.jsx +++ b/frontend/src/components/layout/CardContainer.jsx @@ -2,20 +2,26 @@ import Card from "./Card.jsx"; import PropTypes from "prop-types"; import { Link } from "react-router-dom"; -const CardContainer = ({ links, cards, className }) => { +const CardContainer = ({ links, cards, className, text }) => { return (
{cards.map((card, index) => ( links ? ( - -

{card.content}

+ + {text + ?

{card.content}

+ :
{card.content}
+ }
) : ( - -

{card.content}

-
+ + {text + ?

{card.content}

+ :
{card.content}
+ } +
) ))}
@@ -24,6 +30,7 @@ const CardContainer = ({ links, cards, className }) => { CardContainer.propTypes = { links: Boolean, + text: Boolean, cards: PropTypes.arrayOf( PropTypes.shape({ title: PropTypes.string.isRequired, diff --git a/frontend/src/components/layout/ContentWrapper.jsx b/frontend/src/components/layout/ContentWrapper.jsx new file mode 100644 index 0000000..82d0f6a --- /dev/null +++ b/frontend/src/components/layout/ContentWrapper.jsx @@ -0,0 +1,16 @@ +import PropTypes from 'prop-types'; + +const ContentWrapper = ({ children, className }) => { + return ( +
+ {children} +
+ ); +} + +ContentWrapper.propTypes = { + children: PropTypes.node.isRequired, + className: PropTypes.string, +} + +export default ContentWrapper; \ No newline at end of file diff --git a/frontend/src/components/layout/CustomContainer.jsx b/frontend/src/components/layout/CustomContainer.jsx new file mode 100644 index 0000000..6d14ffb --- /dev/null +++ b/frontend/src/components/layout/CustomContainer.jsx @@ -0,0 +1,15 @@ +import PropTypes from 'prop-types'; + +const CustomContainer = ({ children }) => { + return ( +
+ {children} +
+ ); +} + +CustomContainer.propTypes = { + children: PropTypes.node.isRequired, +} + +export default CustomContainer; \ No newline at end of file diff --git a/frontend/src/components/layout/Header.jsx b/frontend/src/components/layout/Header.jsx index 57ec2a2..51ea32d 100644 --- a/frontend/src/components/layout/Header.jsx +++ b/frontend/src/components/layout/Header.jsx @@ -1,20 +1,29 @@ -import PropTypes from 'prop-types'; import '@/css/Header.css'; import { useTheme } from "@/hooks/useTheme"; +import PropTypes from 'prop-types'; +import { Link } from 'react-router-dom'; const Header = ({ subtitle }) => { const { theme } = useTheme(); return ( -
- -

{subtitle}

+
+
+ + + +
+

{subtitle}

+ {/* */}
); } Header.propTypes = { - subtitle: PropTypes.string -} + subtitle: PropTypes.string, +}; export default Header; \ No newline at end of file diff --git a/frontend/src/components/layout/MenuButton.jsx b/frontend/src/components/layout/MenuButton.jsx deleted file mode 100644 index 28cb0aa..0000000 --- a/frontend/src/components/layout/MenuButton.jsx +++ /dev/null @@ -1,19 +0,0 @@ -import "@/css/MenuButton.css"; - -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faBars } from '@fortawesome/free-solid-svg-icons'; -import PropTypes from "prop-types"; - -const MenuButton = ({ onClick }) => { - return ( - - ); -} - -MenuButton.propTypes = { - onClick: PropTypes.func.isRequired, -}; - -export default MenuButton; \ No newline at end of file diff --git a/frontend/src/components/layout/SideMenu.jsx b/frontend/src/components/layout/SideMenu.jsx deleted file mode 100644 index f7e311a..0000000 --- a/frontend/src/components/layout/SideMenu.jsx +++ /dev/null @@ -1,85 +0,0 @@ -import "@/css/SideMenu.css"; -import PropTypes from 'prop-types'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faTimes, faHome } from '@fortawesome/free-solid-svg-icons'; - -import { DataProvider } from '@/context/DataContext'; -import { useDataContext } from "@/hooks/useDataContext"; - -import { useConfig } from '@/hooks/useConfig.js'; -import { useTheme } from "@/hooks/useTheme"; - -import { Link } from 'react-router-dom'; - -import Card from './Card'; - -const SideMenu = ({ isOpen, onClose }) => { - const { config, configLoading, configError } = useConfig(); - - if (configLoading) return

Cargando configuración...

; - if (configError) return

Error al cargar configuración: {configError}

; - if (!config) return

Configuración no disponible.

; - - const BASE = config.appConfig.endpoints.DATA_URL; - const ENDPOINT = config.appConfig.endpoints.GET_GROUPS; - - const reqConfig = { - baseUrl: `${BASE}${ENDPOINT}`, - params: {} - } - - return ( - - - - ); -}; - -const SideMenuContent = ({ isOpen, onClose }) => { - const { data, dataLoading, dataError } = useDataContext(); - const { theme } = useTheme(); - - if (dataLoading) return

Cargando datos...

; - if (dataError) return

Error al cargar datos: {dataError}

; - if (!data) return

Datos no disponibles.

; - - return ( -
- - -
-
- {data.map(group => { - return ( - - - {[]} - - - ); - })} -
-
- ); -}; - -SideMenu.propTypes = { - isOpen: PropTypes.bool.isRequired, - onClose: PropTypes.func.isRequired -} - -SideMenuContent.propTypes = { - isOpen: PropTypes.bool.isRequired, - onClose: PropTypes.func.isRequired -} - -export default SideMenu; \ No newline at end of file diff --git a/frontend/src/context/ConfigContext.jsx b/frontend/src/context/ConfigContext.jsx index 25b59bc..cff1bbc 100644 --- a/frontend/src/context/ConfigContext.jsx +++ b/frontend/src/context/ConfigContext.jsx @@ -13,7 +13,7 @@ export const ConfigProvider = ({ children }) => { try { const response = import.meta.env.MODE === 'production' ? await fetch("/config/settings.prod.json") - : await fetch("/config/settings.dev.json"); + : await fetch("/config/settings.prod.json"); if (!response.ok) throw new Error("Error al cargar settings.*.json"); const json = await response.json(); setConfig(json); diff --git a/frontend/src/css/HistoryCharts.css b/frontend/src/css/HistoryCharts.css deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/src/css/Map.css b/frontend/src/css/Map.css deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/src/css/MenuButton.css b/frontend/src/css/MenuButton.css deleted file mode 100644 index a2ba7b7..0000000 --- a/frontend/src/css/MenuButton.css +++ /dev/null @@ -1,26 +0,0 @@ -.menuBtn { - position: fixed; - top: 20px; - left: 20px; - z-index: 1000; - border: none; - width: 50px; - height: 50px; - font-size: 24px; /* Aumenta el tamaño del icono */ - display: flex; - align-items: center; - justify-content: center; - color: var(--primary-color); - background-color: transparent; - cursor: pointer; - transition: background-color 0.3s, transform 0.3s; -} - -.menuBtn .fa-bars { - width: 30px; /* Ajusta el ancho del icono */ - height: 30px; /* Ajusta la altura del icono */ -} - -.menuBtn:hover { - color: var(--secondary-color); -} \ No newline at end of file diff --git a/frontend/src/css/SideMenu.css b/frontend/src/css/SideMenu.css deleted file mode 100644 index 920435c..0000000 --- a/frontend/src/css/SideMenu.css +++ /dev/null @@ -1,90 +0,0 @@ -.side-menu { - position: fixed; - top: 0; - left: -350px; - width: 350px; - height: 100%; - transition: left 0.3s ease; - padding: 30px; - box-shadow: 2px 0 5px rgba(0,0,0,0.5); - z-index: 1000; -} - -.side-menu.light { - background-color: white; - color: black; -} - -.side-menu.dark { - background-color: #333; - color: white; -} - -.side-menu.open { - left: 0; -} - -.blur { - filter: blur(5px); - transition: filter 0.3s ease; -} - -.side-menu .close-btn { - position: absolute; - top: 20px; - right: 20px; - background: none; - border: none; - font-size: 30px; - cursor: pointer; -} - -.side-menu .close-btn.light { - color: black; -} - -.side-menu .close-btn.dark { - color: white; -} - -.side-menu .close-btn .fa-times { - width: 30px; - height: 30px; -} - -.side-menu .close-btn:hover { - color: var(--primary-color); -} - -.side-menu .home-btn { - position: absolute; - top: 20px; - left: 20px; - background: none; - border: none; - font-size: 30px; - cursor: pointer; -} - -.side-menu .home-btn .fa-home { - width: 30px; - height: 30px; -} - -.side-menu .home-btn.light { - color: black; -} - -.side-menu .home-btn.dark { - color: white; -} - -.side-menu .home-btn:hover { - color: var(--primary-color); -} - -hr.separation { - margin: 60px 0; - border: none; - border-top: 1px solid #ccc; -} \ No newline at end of file diff --git a/frontend/src/css/index.css b/frontend/src/css/index.css index 772d116..2cd4958 100644 --- a/frontend/src/css/index.css +++ b/frontend/src/css/index.css @@ -51,4 +51,3 @@ src: url(https://fonts.gstatic.com/s/poppins/v22/pxiByp8kv8JHgFVrLEj6Z1xlFQ.woff2) format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } - diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 931e19b..ac71e35 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -7,25 +7,6 @@ import App from './App.jsx' import { ThemeProvider } from './context/ThemeContext.jsx' import { ConfigProvider } from './context/ConfigContext.jsx' -/** - * main.jsx - * - * Este archivo es el punto de entrada principal de la aplicación React. - * - * Importaciones: - * - StrictMode: Un componente de React que ayuda a identificar problemas potenciales en la aplicación. - * - createRoot: Una función de ReactDOM que crea una raíz para renderizar la aplicación. - * - './css/index.css': Archivo CSS que contiene los estilos globales de la aplicación. - * - App: El componente principal de la aplicación. - * - ThemeProvider: Un proveedor de contexto que maneja el tema de la aplicación. - * - ConfigProvider: Un proveedor de contexto que maneja la configuración de la aplicación. - * - * Funcionalidad: - * - El archivo utiliza `createRoot` para obtener el elemento con el id 'root' del DOM y renderizar la aplicación React dentro de él. - * - La aplicación se envuelve en `StrictMode` para ayudar a detectar problemas potenciales. - * - `ThemeProvider` y `ConfigProvider` envuelven el componente `App` para proporcionar los contextos de tema y configuración a toda la aplicación. - */ - createRoot(document.getElementById('root')).render( diff --git a/frontend/src/pages/GroupView.jsx b/frontend/src/pages/GroupView.jsx index 7defdb5..ee7b6c4 100644 --- a/frontend/src/pages/GroupView.jsx +++ b/frontend/src/pages/GroupView.jsx @@ -4,9 +4,36 @@ import LoadingIcon from "@/components/LoadingIcon"; import { useParams } from "react-router-dom"; import { useConfig } from "@/hooks/useConfig"; import { useDataContext } from "@/hooks/useDataContext"; +import { useEffect, useState } from "react"; import { DataProvider } from "@/context/DataContext"; +import { MapContainer, TileLayer, Marker } from 'react-leaflet'; +import L from 'leaflet'; +import 'leaflet/dist/leaflet.css'; + +// Icono de marcador por defecto (porque Leaflet no lo carga bien en algunos setups) +const markerIcon = new L.Icon({ + iconUrl: 'https://unpkg.com/leaflet@1.7.1/dist/images/marker-icon.png', + iconSize: [25, 41], + iconAnchor: [12, 41], +}); + +const MiniMap = ({ lat, lon }) => ( + + + + +); + const GroupView = () => { const { groupId } = useParams(); const { config, configLoading } = useConfig(); @@ -16,18 +43,48 @@ const GroupView = () => { const replacedEndpoint = config.appConfig.endpoints.GET_GROUP_DEVICES.replace(':groupId', groupId); const reqConfig = { baseUrl: `${config.appConfig.endpoints.DATA_URL}${replacedEndpoint}`, + latestValuesUrl: `${config.appConfig.endpoints.LOGIC_URL}${config.appConfig.endpoints.GET_DEVICE_LATEST_VALUES}`, }; return ( - ); + ); } const GroupViewContent = () => { - const { data, dataLoading, dataError } = useDataContext(); + const { data, dataLoading, dataError, getData } = useDataContext(); const { groupId } = useParams(); + const [latestData, setLatestData] = useState({}); + + const { config } = useConfig(); // lo pillamos por si acaso + const latestValuesUrl = config.appConfig.endpoints.LOGIC_URL + config.appConfig.endpoints.GET_DEVICE_LATEST_VALUES; + + useEffect(() => { + if (!data || data.length === 0) return; + + const fetchLatestData = async () => { + const results = {}; + + await Promise.all(data.map(async device => { + const endpoint = latestValuesUrl + .replace(':groupId', groupId) + .replace(':deviceId', device.deviceId); + + try { + const res = await getData(endpoint); + results[device.deviceId] = res; + } catch (err) { + console.error(`Error al obtener latest values de ${device.deviceId}:`, err); + } + })); + + setLatestData(results); + }; + + fetchLatestData(); + }, [data, groupId]); if (dataLoading) return

; if (dataError) return

Error al cargar datos: {dataError}

; @@ -35,15 +92,25 @@ const GroupViewContent = () => { return ( ({ - title: device.deviceName, - status: `ID: ${device.deviceId}`, - to: `/groups/${groupId}/devices/${device.deviceId}`, - styleMode: "override", - className: "col-12 col-md-6 col-lg-4", - }))} + cards={data.map(device => { + const latest = latestData[device.deviceId]; + const gpsSensor = latest?.data[0]; + const mapPreview = gpsSensor?.lat && gpsSensor?.lon + ? + : "Sin posición"; + + return { + title: device.deviceName, + status: `ID: ${device.deviceId}`, + content: mapPreview, + to: `/groups/${groupId}/devices/${device.deviceId}`, + styleMode: "override", + className: "col-12 col-md-6 col-lg-4" + }; + })} + /> ); -} +}; export default GroupView; \ No newline at end of file diff --git a/frontend/src/pages/Groups.jsx b/frontend/src/pages/Groups.jsx new file mode 100644 index 0000000..6454032 --- /dev/null +++ b/frontend/src/pages/Groups.jsx @@ -0,0 +1,89 @@ +import CardContainer from "@/components/layout/CardContainer"; +import LoadingIcon from "@/components/LoadingIcon"; + +import { useConfig } from "@/hooks/useConfig"; +import { useDataContext } from "@/hooks/useDataContext"; + +import { DataProvider } from "@/context/DataContext"; + +import { useEffect, useState } from "react"; +import PropTypes from "prop-types"; + +const Groups = () => { + const { config, configLoading } = useConfig(); + + if (configLoading || !config) return

; + + const replacedEndpoint = config.appConfig.endpoints.GET_GROUPS; + const reqConfig = { + baseUrl: `${config.appConfig.endpoints.DATA_URL}${replacedEndpoint}`, + devicesUrl: `${config.appConfig.endpoints.DATA_URL}${config.appConfig.endpoints.GET_GROUP_DEVICES}`, + }; + + return ( + + + + ); +} + +const GroupsContent = ({ config }) => { + const { data, dataLoading, dataError, getData } = useDataContext(); + const [devices, setDevices] = useState({}); + + useEffect(() => { + if (!data || data.length === 0) return; + + const fetchDevices = async () => { + const results = {}; + + await Promise.all(data.map(async group => { + const endpoint = config.devicesUrl + .replace(':groupId', group.groupId); + + try { + const res = await getData(endpoint); + results[group.groupId] = res; + } catch (err) { + console.error(`Error al obtener dispositivos de ${group.groupId}:`, err); + } + })); + + setDevices(results); + }; + + fetchDevices(); + }, [config.devicesUrl, data, getData]); + + if (dataLoading) return

; + if (dataError) return

Error al cargar datos: {dataError}

; + + return ( + { + const groupDevices = devices[group.groupId]?.data; + const deviceCount = groupDevices?.length; + + return { + title: group.groupName, + status: `ID: ${group.groupId}`, + to: `/groups/${group.groupId}`, + styleMode: "override", + content: deviceCount != null + ? (deviceCount === 1 ? "1 dispositivo" : `${deviceCount} dispositivos`) + : "Cargando dispositivos...", + className: "col-12 col-md-6 col-lg-4", + }; + })} + /> + ); +} +GroupsContent.propTypes = { + config: PropTypes.shape({ + devicesUrl: PropTypes.string.isRequired, + }).isRequired, +}; + +export default Groups; diff --git a/hardware/src/lib/sensor/BME280.cpp b/hardware/src/lib/sensor/BME280.cpp index bde5a1c..1561457 100644 --- a/hardware/src/lib/sensor/BME280.cpp +++ b/hardware/src/lib/sensor/BME280.cpp @@ -30,5 +30,5 @@ BME280Data_t BME280_Read() BME280::TempUnit tUnit(BME280::TempUnit_Celsius); BME280::PresUnit pUnit(BME280::PresUnit_Pa); bme.read(p, t, h, tUnit, pUnit); - return {p, t, h}; + return {p/100, t, h}; } diff --git a/hardware/src/main.cpp b/hardware/src/main.cpp index 0726145..f16bcec 100644 --- a/hardware/src/main.cpp +++ b/hardware/src/main.cpp @@ -119,7 +119,7 @@ void printAllData() Serial.println(DEVICE_ID, HEX); Serial.print("Presión: "); - Serial.print(bme280Data.pressure / 100); + Serial.print(bme280Data.pressure); Serial.println(" hPa"); Serial.print("Temperatura: "); Serial.print(bme280Data.temperature);