1
0

working frontend and backend

This commit is contained in:
Jose
2025-05-10 15:08:39 +02:00
parent b463b866ce
commit bf42eccc67
28 changed files with 394 additions and 433 deletions

View File

@@ -173,48 +173,66 @@ WHERE s.sensorType = 'Temperature & Humidity';
-- VISTAS AUXILIARES -- VISTAS AUXILIARES
CREATE OR REPLACE VIEW v_pollution_map AS 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.deviceId,
d.deviceName, c.timestamp;
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;
CREATE OR REPLACE VIEW v_sensor_history_by_device AS CREATE OR REPLACE VIEW v_sensor_history_by_device AS
SELECT SELECT
d.deviceId, d.deviceId AS deviceId,
d.deviceName, d.deviceName AS deviceName,
w.temperature AS value, w.temperature AS value,
'temperature' AS valueType, 'temperature' AS valueType,
w.timestamp w.timestamp AS timestamp
FROM devices d FROM
JOIN v_weather_by_device w ON d.deviceId = w.deviceId dad.devices d
JOIN dad.v_weather_by_device w ON d.deviceId = w.deviceId
UNION ALL UNION ALL
SELECT SELECT
d.deviceId, d.deviceId AS deviceId,
d.deviceName, d.deviceName AS deviceName,
w.humidity AS value, w.humidity AS value,
'humidity' AS valueType, 'humidity' AS valueType,
w.timestamp w.timestamp AS timestamp
FROM devices d FROM
JOIN v_weather_by_device w ON d.deviceId = w.deviceId dad.devices d
JOIN dad.v_weather_by_device w ON d.deviceId = w.deviceId
UNION ALL UNION ALL
SELECT SELECT
d.deviceId, d.deviceId AS deviceId,
d.deviceName, 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, c.carbonMonoxide AS value,
'carbonMonoxide' AS valueType, 'carbonMonoxide' AS valueType,
c.timestamp c.timestamp AS timestamp
FROM devices d FROM
JOIN v_co_by_device c ON d.deviceId = c.deviceId dad.devices d
ORDER BY deviceId, timestamp; JOIN dad.v_co_by_device c ON d.deviceId = c.deviceId
ORDER BY
deviceId,
timestamp;
-- Insertar grupos -- Insertar grupos
INSERT INTO groups (groupName) VALUES ('Grupo 1'); INSERT INTO groups (groupName) VALUES ('Grupo 1');

View File

@@ -17,6 +17,7 @@
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"chart.js": "^4.4.8", "chart.js": "^4.4.8",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"leaflet.heat": "^0.2.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-chartjs-2": "^5.3.0", "react-chartjs-2": "^5.3.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@@ -3556,6 +3557,11 @@
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause" "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": { "node_modules/levn": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",

View File

@@ -19,6 +19,7 @@
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"chart.js": "^4.4.8", "chart.js": "^4.4.8",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"leaflet.heat": "^0.2.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-chartjs-2": "^5.3.0", "react-chartjs-2": "^5.3.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

View File

@@ -3,41 +3,28 @@ import 'leaflet/dist/leaflet.css'
import 'bootstrap/dist/css/bootstrap.min.css' import 'bootstrap/dist/css/bootstrap.min.css'
import 'bootstrap/dist/js/bootstrap.bundle.min.js' import 'bootstrap/dist/js/bootstrap.bundle.min.js'
import Home from '@/pages/Home.jsx'
import Dashboard from '@/pages/Dashboard.jsx' import Dashboard from '@/pages/Dashboard.jsx'
import MenuButton from '@/components/layout/MenuButton.jsx' import Groups from '@/pages/Groups.jsx'
import SideMenu from '@/components/layout/SideMenu.jsx'
import ThemeButton from '@/components/layout/ThemeButton.jsx' import ThemeButton from '@/components/layout/ThemeButton.jsx'
import Header from '@/components/layout/Header.jsx' import Header from '@/components/layout/Header.jsx'
import GroupView from '@/pages/GroupView.jsx' import GroupView from '@/pages/GroupView.jsx'
import { Routes, Route } from 'react-router-dom' import { Routes, Route } from 'react-router-dom'
import { useState } from 'react' import ContentWrapper from './components/layout/ContentWrapper'
const App = () => { const App = () => {
const [isSideMenuOpen, setIsSideMenuOpen] = useState(false);
const toggleSideMenu = () => {
setIsSideMenuOpen(!isSideMenuOpen);
};
const closeSideMenu = () => {
setIsSideMenuOpen(false);
}
return ( return (
<> <>
<MenuButton onClick={toggleSideMenu} />
<SideMenu isOpen={isSideMenuOpen} onClose={toggleSideMenu} />
<ThemeButton /> <ThemeButton />
<div className={isSideMenuOpen ? 'blur m-0 p-0' : 'm-0 p-0'} onClick={closeSideMenu}> <Header subtitle='Midiendo la calidad del aire y las calles en Sevilla 🌿🚛' />
<Header subtitle='Midiendo la calidad del aire y las calles en Sevilla 🌿🚛' /> <ContentWrapper>
<Routes> <Routes>
<Route path="/" element={<Home />} /> <Route path="/" element={<Groups />} />
<Route path="/groups/:groupId" element={<GroupView />} /> <Route path="/groups/:groupId" element={<GroupView />} />
<Route path="/groups/:groupId/devices/:deviceId" element={<Dashboard />} /> <Route path="/groups/:groupId/devices/:deviceId" element={<Dashboard />} />
</Routes> </Routes>
</div> </ContentWrapper>
</> </>
); );
} }

View File

@@ -1,7 +1,6 @@
import { Line } from "react-chartjs-2"; import { Line } from "react-chartjs-2";
import { Chart as ChartJS, LineElement, PointElement, LinearScale, CategoryScale, Filler } from "chart.js"; import { Chart as ChartJS, LineElement, PointElement, LinearScale, CategoryScale, Filler } from "chart.js";
import CardContainer from "./layout/CardContainer"; import CardContainer from "./layout/CardContainer";
import "@/css/HistoryCharts.css";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { useTheme } from "@/hooks/useTheme"; import { useTheme } from "@/hooks/useTheme";
@@ -12,74 +11,91 @@ import { useConfig } from "@/hooks/useConfig";
ChartJS.register(LineElement, PointElement, LinearScale, CategoryScale, Filler); ChartJS.register(LineElement, PointElement, LinearScale, CategoryScale, Filler);
const HistoryCharts = ({ groupId, deviceId }) => { const HistoryCharts = ({ groupId, deviceId }) => {
const { config, configLoading, configError } = useConfig(); const { config, configLoading, configError } = useConfig();
if (configLoading) return <p>Cargando configuración...</p>; if (configLoading) return <p>Cargando configuración...</p>;
if (configError) return <p>Error al cargar configuración: {configError}</p>; if (configError) return <p>Error al cargar configuración: {configError}</p>;
if (!config) return <p>Configuración no disponible.</p>; if (!config) return <p>Configuración no disponible.</p>;
const BASE = config.appConfig.endpoints.LOGIC_URL; const BASE = config.appConfig.endpoints.LOGIC_URL;
const ENDPOINT = config.appConfig.endpoints.GET_DEVICE_HISTORY; const ENDPOINT = config.appConfig.endpoints.GET_DEVICE_HISTORY;
const endp = ENDPOINT const endp = ENDPOINT
.replace(':groupId', groupId) .replace(':groupId', groupId)
.replace(':deviceId', deviceId); // si tu endpoint lo necesita .replace(':deviceId', deviceId); // si tu endpoint lo necesita
const reqConfig = { const reqConfig = {
baseUrl: `${BASE}${endp}`, baseUrl: `${BASE}${endp}`,
params: {} params: {}
}; };
return ( return (
<DataProvider config={reqConfig}> <DataProvider config={reqConfig}>
<HistoryChartsContent /> <HistoryChartsContent />
</DataProvider> </DataProvider>
); );
}; };
const HistoryChartsContent = () => { const HistoryChartsContent = () => {
const { config } = useConfig(); const { config } = useConfig();
const { data, loading, error } = useDataContext(); const { data, loading, error } = useDataContext();
const { theme } = useTheme(); const { theme } = useTheme();
const optionsDark = config?.appConfig?.historyChartConfig?.chartOptionsDark ?? {}; const optionsDark = config?.appConfig?.historyChartConfig?.chartOptionsDark ?? {};
const optionsLight = config?.appConfig?.historyChartConfig?.chartOptionsLight ?? {}; const optionsLight = config?.appConfig?.historyChartConfig?.chartOptionsLight ?? {};
const options = theme === "dark" ? optionsDark : optionsLight; 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 <p>Cargando datos...</p>; if (loading) return <p>Cargando datos...</p>;
if (error) return <p>Datos no disponibles.</p>; if (error) return <p>Datos no disponibles.</p>;
const temperatureData = []; const grouped = {
const humidityData = []; temperature: [],
const pollutionLevels = []; humidity: [],
pressure: [],
carbonMonoxide: []
};
data?.forEach(sensor => { data?.forEach(sensor => {
if (sensor.value != null) { if (sensor.value != null && grouped[sensor.valueType]) {
if (sensor.sensor_type === "MQ-135") { grouped[sensor.valueType].push({
pollutionLevels.push(sensor.value); timestamp: new Date(sensor.timestamp),
} else if (sensor.sensor_type === "DHT-11") { value: sensor.value
temperatureData.push(sensor.value); });
humidityData.push(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 = [ const historyData = [
{ title: "🌡️ Temperatura", data: temperatureData.length ? temperatureData : [0], borderColor: "#00FF85", backgroundColor: "rgba(0, 255, 133, 0.2)" }, { title: "🌡️ Temperatura", data: temp.values, 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: "💦 Humedad", data: hum.values, 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: "⏲ 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 ( return (
<CardContainer <CardContainer
cards={historyData.map(({ title, data, borderColor, backgroundColor }) => ({ cards={historyData.map(({ title, data, borderColor, backgroundColor }) => ({
title, title,
content: ( content: (
<Line <Line style= {{ minHeight: "250px" }}
data={{ data={{
labels: timeLabels, labels: timeLabels,
datasets: [{ data, borderColor, backgroundColor, fill: true, tension: 0.4 }] datasets: [{ data, borderColor, backgroundColor, fill: true, tension: 0.4 }]
@@ -88,7 +104,8 @@ const HistoryChartsContent = () => {
/> />
), ),
styleMode: "override", 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="" className=""
/> />

View File

@@ -1,4 +1,3 @@
import { MapContainer, TileLayer, Circle, Popup } from 'react-leaflet';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useConfig } from '@/hooks/useConfig.js'; import { useConfig } from '@/hooks/useConfig.js';
@@ -6,38 +5,10 @@ import { useConfig } from '@/hooks/useConfig.js';
import { DataProvider } from '@/context/DataContext.jsx'; import { DataProvider } from '@/context/DataContext.jsx';
import { useDataContext } from '@/hooks/useDataContext'; import { useDataContext } from '@/hooks/useDataContext';
const PollutionCircles = ({ data }) => { import L from "leaflet";
return data.map(({ lat, lng, level }, index) => { import "leaflet.heat";
const baseColor = level < 20 ? '#00FF85' : level < 60 ? '#FFA500' : '#FF0000';
const steps = 4;
const maxRadius = 400;
const stepSize = maxRadius / steps;
return ( import { useEffect } from 'react';
<div key={index}>
{[...Array(steps)].map((_, i) => {
const radius = stepSize * (i + 1);
const opacity = 0.6 * ((i + 1) / steps);
return (
<Circle
key={`${index}-${i}`}
center={[lat, lng]}
pathOptions={{ color: baseColor, fillColor: baseColor, fillOpacity: opacity, weight: 1 }}
radius={radius}
/>
);
})}
<Circle
center={[lat, lng]}
pathOptions={{ color: baseColor, fillColor: baseColor, fillOpacity: 0.8, weight: 2 }}
radius={50}
>
<Popup>Contaminación: {level} µg/</Popup>
</Circle>
</div>
);
});
};
const PollutionMap = ({ groupId, deviceId }) => { const PollutionMap = ({ groupId, deviceId }) => {
const { config, configLoading, configError } = useConfig(); const { config, configLoading, configError } = useConfig();
@@ -64,10 +35,33 @@ const PollutionMap = ({ groupId, deviceId }) => {
); );
}; };
const PollutionMapContent = () => { const PollutionMapContent = () => {
const { config, configLoading, configError } = useConfig(); const { config, configLoading, configError } = useConfig();
const { data, dataLoading, dataError } = useDataContext(); 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:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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 <p>Cargando configuración...</p>; if (configLoading) return <p>Cargando configuración...</p>;
if (configError) return <p>Error al cargar configuración: {configError}</p>; if (configError) return <p>Error al cargar configuración: {configError}</p>;
@@ -77,33 +71,13 @@ const PollutionMapContent = () => {
if (dataError) return <p>Error al cargar datos: {configError}</p>; if (dataError) return <p>Error al cargar datos: {configError}</p>;
if (!data) return <p>Datos no disponibles.</p>; if (!data) return <p>Datos no disponibles.</p>;
const SEVILLA = config?.userConfig.city;
const pollutionData = data.map((measure) => ({
lat: measure.lat,
lng: measure.lon,
level: measure.carbonMonoxide
}));
return ( return (
<div className='p-3'> <div className="p-3">
<MapContainer center={SEVILLA} zoom={13} scrollWheelZoom={false} style={mapStyles}> <div id="map" className='rounded-4' style={{ height: "60vh" }}></div>
<TileLayer
attribution='&copy; Contribuidores de <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<PollutionCircles data={pollutionData} />
</MapContainer>
</div> </div>
); );
} }
const mapStyles = {
height: '500px',
width: '100%',
borderRadius: '20px'
};
PollutionMap.propTypes = { PollutionMap.propTypes = {
groupId: PropTypes.number.isRequired, groupId: PropTypes.number.isRequired,
deviceId: PropTypes.number.isRequired deviceId: PropTypes.number.isRequired

View File

@@ -1,14 +1,10 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import CardContainer from './layout/CardContainer'; 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 { DataProvider } from '@/context/DataContext';
import { useDataContext } from '@/hooks/useDataContext'; import { useDataContext } from '@/hooks/useDataContext';
import { useConfig } from '@/hooks/useConfig.js'; import { useConfig } from '@/hooks/useConfig.js';
import { DateParser } from '@/util/dateParser';
const SummaryCards = ({ groupId, deviceId }) => { const SummaryCards = ({ groupId, deviceId }) => {
const { config, configLoading, configError } = useConfig(); const { config, configLoading, configError } = useConfig();
@@ -21,7 +17,7 @@ const SummaryCards = ({ groupId, deviceId }) => {
const ENDPOINT = config.appConfig.endpoints.GET_DEVICE_LATEST_VALUES; const ENDPOINT = config.appConfig.endpoints.GET_DEVICE_LATEST_VALUES;
const endp = ENDPOINT const endp = ENDPOINT
.replace(':groupId', groupId) .replace(':groupId', groupId)
.replace(':deviceId', deviceId); // solo si lo necesitas así .replace(':deviceId', deviceId);
const reqConfig = { const reqConfig = {
baseUrl: `${BASE}${endp}`, baseUrl: `${BASE}${endp}`,
@@ -43,32 +39,29 @@ const SummaryCardsContent = () => {
if (!data) return <p>Datos no disponibles.</p>; if (!data) return <p>Datos no disponibles.</p>;
const CardsData = [ const CardsData = [
{ id: 1, title: "Temperatura", content: "N/A", status: "Esperando datos...", titleIcon: <FontAwesomeIcon icon={faTemperature0} /> }, { id: 1, title: "Temperatura", content: "N/A", status: "Esperando datos...", titleIcon: '🌡 ' },
{ id: 2, title: "Humedad", content: "N/A", status: "Esperando datos...", titleIcon: <FontAwesomeIcon icon={faWater} /> }, { id: 2, title: "Humedad", content: "N/A", status: "Esperando datos...", titleIcon: '💦 ' },
{ id: 3, title: "Nivel de CO", content: "N/A", status: "Esperando datos...", titleIcon: <FontAwesomeIcon icon={faCloud} /> }, { id: 3, title: "Presión", content: "N/A", status: "Esperando datos...", titleIcon: '⏲ ' },
{ id: 4, title: "Actualizado a las", content: "N/A", status: "Esperando datos...", titleIcon: <FontAwesomeIcon icon={faClock} /> } { id: 4, title: "Nivel de CO", content: "N/A", status: "Esperando datos...", titleIcon: '☁ ' }
]; ];
if (data) { if (data) {
let coData = data[1]; let coData = data[2];
let tempData = data[2]; let tempData = data[1];
let lastTime = DateParser.timestampToString(coData.timestamp);
let lastDate = new Date(coData.timestamp);
CardsData[0].content = tempData.temperature + "°C"; CardsData[0].content = tempData.temperature + "°C";
CardsData[0].status = "Temperatura actual"; CardsData[0].status = "Temperatura actual";
CardsData[1].content = tempData.humidity + "%"; CardsData[1].content = tempData.humidity + "%";
CardsData[1].status = "Humedad actual"; CardsData[1].status = "Humedad actual";
CardsData[2].content = coData.carbonMonoxide + " ppm"; CardsData[3].content = coData.carbonMonoxide + " ppm";
CardsData[2].status = "Nivel de CO actual"; CardsData[3].status = "Nivel de CO actual";
CardsData[3].content = lastTime; CardsData[2].content = tempData.pressure + " hPa";
CardsData[3].status = "Día " + lastDate.toLocaleDateString(); CardsData[2].status = "Presión actual";
} }
return ( return (
<CardContainer cards={CardsData} /> <CardContainer text cards={CardsData} />
); );
} }

View File

@@ -3,7 +3,7 @@ import { useState, useEffect, useRef } from "react";
import "@/css/Card.css"; import "@/css/Card.css";
import { useTheme } from "@/hooks/useTheme"; 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 cardRef = useRef(null);
const [shortTitle, setShortTitle] = useState(title); const [shortTitle, setShortTitle] = useState(title);
const { theme } = useTheme(); const { theme } = useTheme();
@@ -30,8 +30,9 @@ const Card = ({ title, status, children, styleMode, className, titleIcon }) => {
ref={cardRef} ref={cardRef}
className={styleMode === "override" ? `${className}` : className={styleMode === "override" ? `${className}` :
`col-xl-3 col-sm-6 d-flex flex-column align-items-center p-3 card-container ${className}`} `col-xl-3 col-sm-6 d-flex flex-column align-items-center p-3 card-container ${className}`}
> >
<div className={`card p-3 w-100 ${theme}`}> <div className={`card p-3 w-100 ${theme}`} style={styleMode === "override" ? style : {}}>
<h3 className="text-center"> <h3 className="text-center">
{titleIcon} {titleIcon}
{shortTitle} {shortTitle}
@@ -50,6 +51,7 @@ Card.propTypes = {
styleMode: PropTypes.oneOf(["override", ""]), styleMode: PropTypes.oneOf(["override", ""]),
className: PropTypes.string, className: PropTypes.string,
titleIcon: PropTypes.node, titleIcon: PropTypes.node,
style: PropTypes.object,
}; };
Card.defaultProps = { Card.defaultProps = {

View File

@@ -2,20 +2,26 @@ import Card from "./Card.jsx";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
const CardContainer = ({ links, cards, className }) => { const CardContainer = ({ links, cards, className, text }) => {
return ( return (
<div className={`row justify-content-center g-0 ${className}`}> <div className={`row justify-content-center g-0 ${className}`}>
{cards.map((card, index) => ( {cards.map((card, index) => (
links ? ( links ? (
<Link to={card.to} key={index} style={{ textDecoration: 'none' }}> <Link to={card.to} key={index} style={{ textDecoration: 'none' }}>
<Card title={card.title} status={card.status} styleMode={card.styleMode} className={card.className} titleIcon={card.titleIcon}> <Card title={card.title} status={card.status} styleMode={card.styleMode} className={card.className} titleIcon={card.titleIcon} style={card.style}>
<p className="card-text text-center">{card.content}</p> {text
? <p className="card-text text-center">{card.content}</p>
: <div className="my-2">{card.content}</div>
}
</Card> </Card>
</Link> </Link>
) : ( ) : (
<Card key={index} title={card.title} status={card.status} styleMode={card.styleMode} className={card.className} titleIcon={card.titleIcon}> <Card key={index} title={card.title} status={card.status} styleMode={card.styleMode} className={card.className} titleIcon={card.titleIcon} style={card.style}>
<p className="card-text text-center">{card.content}</p> {text
</Card> ? <p className="card-text text-center">{card.content}</p>
: <div className="my-2">{card.content}</div>
}
</Card>
) )
))} ))}
</div> </div>
@@ -24,6 +30,7 @@ const CardContainer = ({ links, cards, className }) => {
CardContainer.propTypes = { CardContainer.propTypes = {
links: Boolean, links: Boolean,
text: Boolean,
cards: PropTypes.arrayOf( cards: PropTypes.arrayOf(
PropTypes.shape({ PropTypes.shape({
title: PropTypes.string.isRequired, title: PropTypes.string.isRequired,

View File

@@ -0,0 +1,16 @@
import PropTypes from 'prop-types';
const ContentWrapper = ({ children, className }) => {
return (
<div className={`container-xl ${className}`}>
{children}
</div>
);
}
ContentWrapper.propTypes = {
children: PropTypes.node.isRequired,
className: PropTypes.string,
}
export default ContentWrapper;

View File

@@ -0,0 +1,15 @@
import PropTypes from 'prop-types';
const CustomContainer = ({ children }) => {
return (
<main className="px-4 py-5">
{children}
</main>
);
}
CustomContainer.propTypes = {
children: PropTypes.node.isRequired,
}
export default CustomContainer;

View File

@@ -1,20 +1,29 @@
import PropTypes from 'prop-types';
import '@/css/Header.css'; import '@/css/Header.css';
import { useTheme } from "@/hooks/useTheme"; import { useTheme } from "@/hooks/useTheme";
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
const Header = ({ subtitle }) => { const Header = ({ subtitle }) => {
const { theme } = useTheme(); const { theme } = useTheme();
return ( return (
<header className={`justify-content-center text-center mb-4 ${theme}`}> <header className={`row justify-content-center text-center mb-4 ${theme}`}>
<img src='/images/logo.png' width={500} /> <div className='col-xl-4 col-lg-6 col-8'>
<p className='subtitle'>{subtitle}</p> <Link to="/" className="text-decoration-none">
<img src={`/images/logo-${theme}.png`} className='img-fluid' />
</Link>
</div>
<p className='col-12 text-center my-3'>{subtitle}</p>
{/*<nav className='d-flex justify-content-center gap-4 my-3'>
<Link to="/" className="nav-link">Inicio</Link>
<Link to="/groups" className="nav-link">Grupos</Link>
</nav> */}
</header> </header>
); );
} }
Header.propTypes = { Header.propTypes = {
subtitle: PropTypes.string subtitle: PropTypes.string,
} };
export default Header; export default Header;

View File

@@ -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 (
<button className="menuBtn" onClick={onClick}>
<FontAwesomeIcon icon={faBars} />
</button>
);
}
MenuButton.propTypes = {
onClick: PropTypes.func.isRequired,
};
export default MenuButton;

View File

@@ -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 <p>Cargando configuración...</p>;
if (configError) return <p>Error al cargar configuración: {configError}</p>;
if (!config) return <p>Configuración no disponible.</p>;
const BASE = config.appConfig.endpoints.DATA_URL;
const ENDPOINT = config.appConfig.endpoints.GET_GROUPS;
const reqConfig = {
baseUrl: `${BASE}${ENDPOINT}`,
params: {}
}
return (
<DataProvider config={reqConfig}>
<SideMenuContent isOpen={isOpen} onClose={onClose} />
</DataProvider>
);
};
const SideMenuContent = ({ isOpen, onClose }) => {
const { data, dataLoading, dataError } = useDataContext();
const { theme } = useTheme();
if (dataLoading) return <p>Cargando datos...</p>;
if (dataError) return <p>Error al cargar datos: {dataError}</p>;
if (!data) return <p>Datos no disponibles.</p>;
return (
<div className={`side-menu ${isOpen ? 'open' : ''} ${theme}`}>
<button className="home-btn" onClick={() => window.location.href = '/'}>
<FontAwesomeIcon icon={faHome} />
</button>
<button className="close-btn" onClick={onClose}>
<FontAwesomeIcon icon={faTimes} />
</button>
<hr className="separation w-100"></hr>
<div className="d-flex flex-column gap-3 mt-5">
{data.map(group => {
return (
<Link to={`/groups/${group.groupId}`} key={group.groupId} style={{ textDecoration: 'none' }}>
<Card
title={group.groupName}
status={`ID: ${group.groupId}`}
styleMode={"override"}
className={"col-12"}
>
{[]}
</Card>
</Link>
);
})}
</div>
</div>
);
};
SideMenu.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired
}
SideMenuContent.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired
}
export default SideMenu;

View File

@@ -13,7 +13,7 @@ export const ConfigProvider = ({ children }) => {
try { try {
const response = import.meta.env.MODE === 'production' const response = import.meta.env.MODE === 'production'
? await fetch("/config/settings.prod.json") ? 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"); if (!response.ok) throw new Error("Error al cargar settings.*.json");
const json = await response.json(); const json = await response.json();
setConfig(json); setConfig(json);

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -51,4 +51,3 @@
src: url(https://fonts.gstatic.com/s/poppins/v22/pxiByp8kv8JHgFVrLEj6Z1xlFQ.woff2) format('woff2'); 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; 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;
} }

View File

@@ -7,25 +7,6 @@ import App from './App.jsx'
import { ThemeProvider } from './context/ThemeContext.jsx' import { ThemeProvider } from './context/ThemeContext.jsx'
import { ConfigProvider } from './context/ConfigContext.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( createRoot(document.getElementById('root')).render(
<StrictMode> <StrictMode>
<ThemeProvider> <ThemeProvider>

View File

@@ -4,9 +4,36 @@ import LoadingIcon from "@/components/LoadingIcon";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { useConfig } from "@/hooks/useConfig"; import { useConfig } from "@/hooks/useConfig";
import { useDataContext } from "@/hooks/useDataContext"; import { useDataContext } from "@/hooks/useDataContext";
import { useEffect, useState } from "react";
import { DataProvider } from "@/context/DataContext"; 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 }) => (
<MapContainer
center={[lat, lon]}
zoom={15}
scrollWheelZoom={false}
dragging={false}
doubleClickZoom={false}
zoomControl={false}
style={{ height: '150px', width: '100%', borderRadius: '10px' }}
>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
<Marker position={[lat, lon]} icon={markerIcon} />
</MapContainer>
);
const GroupView = () => { const GroupView = () => {
const { groupId } = useParams(); const { groupId } = useParams();
const { config, configLoading } = useConfig(); const { config, configLoading } = useConfig();
@@ -16,18 +43,48 @@ const GroupView = () => {
const replacedEndpoint = config.appConfig.endpoints.GET_GROUP_DEVICES.replace(':groupId', groupId); const replacedEndpoint = config.appConfig.endpoints.GET_GROUP_DEVICES.replace(':groupId', groupId);
const reqConfig = { const reqConfig = {
baseUrl: `${config.appConfig.endpoints.DATA_URL}${replacedEndpoint}`, baseUrl: `${config.appConfig.endpoints.DATA_URL}${replacedEndpoint}`,
latestValuesUrl: `${config.appConfig.endpoints.LOGIC_URL}${config.appConfig.endpoints.GET_DEVICE_LATEST_VALUES}`,
}; };
return ( return (
<DataProvider config={reqConfig}> <DataProvider config={reqConfig}>
<GroupViewContent /> <GroupViewContent />
</DataProvider> </DataProvider>
); );
} }
const GroupViewContent = () => { const GroupViewContent = () => {
const { data, dataLoading, dataError } = useDataContext(); const { data, dataLoading, dataError, getData } = useDataContext();
const { groupId } = useParams(); 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 <p className="text-center my-5"><LoadingIcon /></p>; if (dataLoading) return <p className="text-center my-5"><LoadingIcon /></p>;
if (dataError) return <p className="text-center my-5">Error al cargar datos: {dataError}</p>; if (dataError) return <p className="text-center my-5">Error al cargar datos: {dataError}</p>;
@@ -35,15 +92,25 @@ const GroupViewContent = () => {
return ( return (
<CardContainer <CardContainer
links links
cards={data.map(device => ({ cards={data.map(device => {
title: device.deviceName, const latest = latestData[device.deviceId];
status: `ID: ${device.deviceId}`, const gpsSensor = latest?.data[0];
to: `/groups/${groupId}/devices/${device.deviceId}`, const mapPreview = gpsSensor?.lat && gpsSensor?.lon
styleMode: "override", ? <MiniMap lat={gpsSensor.lat} lon={gpsSensor.lon} />
className: "col-12 col-md-6 col-lg-4", : "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; export default GroupView;

View File

@@ -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 <p className="text-center my-5"><LoadingIcon /></p>;
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 (
<DataProvider config={reqConfig}>
<GroupsContent config={reqConfig} />
</DataProvider>
);
}
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 <p className="text-center my-5"><LoadingIcon /></p>;
if (dataError) return <p className="text-center my-5">Error al cargar datos: {dataError}</p>;
return (
<CardContainer
links
text
cards={data.map(group => {
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;

View File

@@ -30,5 +30,5 @@ BME280Data_t BME280_Read()
BME280::TempUnit tUnit(BME280::TempUnit_Celsius); BME280::TempUnit tUnit(BME280::TempUnit_Celsius);
BME280::PresUnit pUnit(BME280::PresUnit_Pa); BME280::PresUnit pUnit(BME280::PresUnit_Pa);
bme.read(p, t, h, tUnit, pUnit); bme.read(p, t, h, tUnit, pUnit);
return {p, t, h}; return {p/100, t, h};
} }

View File

@@ -119,7 +119,7 @@ void printAllData()
Serial.println(DEVICE_ID, HEX); Serial.println(DEVICE_ID, HEX);
Serial.print("Presión: "); Serial.print("Presión: ");
Serial.print(bme280Data.pressure / 100); Serial.print(bme280Data.pressure);
Serial.println(" hPa"); Serial.println(" hPa");
Serial.print("Temperatura: "); Serial.print("Temperatura: ");
Serial.print(bme280Data.temperature); Serial.print(bme280Data.temperature);