finished meteorologia and main dashboard
This commit is contained in:
@@ -1,512 +1,323 @@
|
||||
import {
|
||||
Activity,
|
||||
CloudRain,
|
||||
Droplets,
|
||||
Fan,
|
||||
Lightbulb,
|
||||
ArrowRight,
|
||||
Cloud,
|
||||
Leaf,
|
||||
MapPin,
|
||||
ShieldCheck,
|
||||
Sprout,
|
||||
Sun,
|
||||
Thermometer,
|
||||
Wind,
|
||||
Zap,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { MetricCard } from "../../../components/cards/MetricCard";
|
||||
import { DashboardTrendChart } from "../components/DashboardTrendChart";
|
||||
import { useDashboardOverviewStream } from "../hooks/useDashboardOverviewStream";
|
||||
import { useHistorianDashboard } from "../hooks/useHistorianDashboard";
|
||||
import backgroundImage from "../../../assets/background.png";
|
||||
import farmdrawImage from "../../../assets/farmdraw.png";
|
||||
import farmdrawWhiteImage from "../../../assets/farm-draw.png";
|
||||
import { WeatherForecastCard } from "../../meteo/components/WeatherForecastCard";
|
||||
import { useWeatherForecast } from "../../meteo/hooks/useWeatherForecast";
|
||||
|
||||
type DashboardPageProps = {
|
||||
theme: "dark" | "light";
|
||||
onOpenMeteo: () => void;
|
||||
};
|
||||
|
||||
const historianKeys = [
|
||||
"meteo.exterior_temperature",
|
||||
"meteo.exterior_humidity",
|
||||
"meteo.radiation",
|
||||
"climate.zone_1.temperature",
|
||||
"climate.zone_1.humidity",
|
||||
];
|
||||
|
||||
function formatNumber(value: number | null | undefined, decimals = 1) {
|
||||
if (value === null || value === undefined) return "--";
|
||||
return value.toFixed(decimals);
|
||||
}
|
||||
|
||||
function formatBoolean(value: boolean | null | undefined) {
|
||||
if (value === null || value === undefined) return "--";
|
||||
return value ? "Sim" : "Não";
|
||||
}
|
||||
|
||||
export function DashboardPage({ theme }: DashboardPageProps) {
|
||||
const { overview, connected } = useDashboardOverviewStream();
|
||||
|
||||
const { data: historianData } = useHistorianDashboard({
|
||||
keys: historianKeys,
|
||||
minutesBack: 30,
|
||||
refreshIntervalMs: 15000,
|
||||
});
|
||||
|
||||
const meteo = overview?.meteo;
|
||||
const zones = overview?.climate.zones ?? [];
|
||||
const zoneOne = zones[0];
|
||||
const RADIUS = "rounded-[5px]";
|
||||
|
||||
export function DashboardPage({ theme, onOpenMeteo }: DashboardPageProps) {
|
||||
const isDark = theme === "dark";
|
||||
|
||||
const onlineZoneCount = zones.filter(
|
||||
(zone) => zone.temperature !== null || zone.humidity !== null,
|
||||
).length;
|
||||
|
||||
const validZoneTemperatures = zones
|
||||
.map((zone) => zone.temperature)
|
||||
.filter((value): value is number => value !== null);
|
||||
|
||||
const averageZoneTemperature =
|
||||
validZoneTemperatures.length > 0
|
||||
? validZoneTemperatures.reduce((sum, value) => sum + value, 0) /
|
||||
validZoneTemperatures.length
|
||||
: null;
|
||||
const weather = useWeatherForecast();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-5">
|
||||
<MetricCard
|
||||
title="Temperatura Exterior"
|
||||
value={formatNumber(meteo?.exteriorTemperature)}
|
||||
unit="°C"
|
||||
icon={Thermometer}
|
||||
theme={theme}
|
||||
accent="red"
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "relative h-full overflow-hidden text-slate-100"
|
||||
: "relative h-full overflow-hidden text-slate-950"
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={backgroundImage}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
title="Humidade Exterior"
|
||||
value={formatNumber(meteo?.exteriorHumidity, 0)}
|
||||
unit="%"
|
||||
icon={Droplets}
|
||||
theme={theme}
|
||||
accent="cyan"
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "absolute inset-0 bg-[#0B1220]/34"
|
||||
: "absolute inset-0 bg-white/8"
|
||||
}
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
title="Radiação Solar"
|
||||
value={formatNumber(meteo?.radiation, 0)}
|
||||
unit="W/m²"
|
||||
icon={Sun}
|
||||
theme={theme}
|
||||
accent="yellow"
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "absolute inset-0 bg-[linear-gradient(90deg,#0B1220_0%,#0B1220_16%,rgba(11,18,32,0.94)_26%,rgba(11,18,32,0.62)_44%,rgba(11,18,32,0.20)_70%,rgba(11,18,32,0.02)_100%)]"
|
||||
: "absolute inset-0 bg-[linear-gradient(90deg,#ffffff_0%,rgba(255,255,255,0.98)_12%,rgba(255,255,255,0.78)_28%,rgba(255,255,255,0.34)_48%,rgba(255,255,255,0.08)_70%,rgba(255,255,255,0)_100%)]"
|
||||
}
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
title="Velocidade do Vento"
|
||||
value={formatNumber(meteo?.windSpeed, 0)}
|
||||
unit="Km/h"
|
||||
icon={Wind}
|
||||
theme={theme}
|
||||
accent="blue"
|
||||
/>
|
||||
<main className="relative z-10 grid h-full w-full grid-rows-[minmax(330px,1.25fr)_118px_minmax(310px,1fr)_22px] gap-5 px-14 pb-3 pt-10">
|
||||
<section className="relative min-h-0 pb-10">
|
||||
<div className="max-w-[620px] pt-5">
|
||||
<h1
|
||||
className={
|
||||
isDark
|
||||
? "text-[52px] font-black leading-[1.03] tracking-[-0.06em] text-white"
|
||||
: "text-[52px] font-black leading-[1.03] tracking-[-0.06em] text-slate-950"
|
||||
}
|
||||
>
|
||||
Bem-vindo ao
|
||||
<br />
|
||||
<span className="text-emerald-400">Litoral Central</span>
|
||||
</h1>
|
||||
|
||||
<MetricCard
|
||||
title="Chuva"
|
||||
value={formatBoolean(meteo?.raining)}
|
||||
unit=""
|
||||
icon={CloudRain}
|
||||
theme={theme}
|
||||
accent="green"
|
||||
/>
|
||||
</section>
|
||||
<div className="mt-7 h-[3px] w-12 bg-emerald-400" />
|
||||
|
||||
<section className="grid grid-cols-1 gap-5 lg:grid-cols-4">
|
||||
<DashboardModuleCard
|
||||
theme={theme}
|
||||
title="Clima"
|
||||
icon={Fan}
|
||||
main={`${onlineZoneCount} / ${zones.length}`}
|
||||
label="zonas com dados"
|
||||
accent="cyan"
|
||||
items={[
|
||||
["Temp. média", `${formatNumber(averageZoneTemperature)} °C`],
|
||||
["CO₂ Zona 1", `${formatNumber(zoneOne?.co2, 0)} ppm`],
|
||||
[
|
||||
"Zenitais Zona 1",
|
||||
`${formatNumber(zoneOne?.zenitalLeftPercent, 0)}% / ${formatNumber(
|
||||
zoneOne?.zenitalRightPercent,
|
||||
0,
|
||||
)}%`,
|
||||
],
|
||||
]}
|
||||
/>
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "mt-7 max-w-[500px] text-[15px] leading-7 text-slate-300"
|
||||
: "mt-7 max-w-[500px] text-[15px] leading-7 text-slate-700"
|
||||
}
|
||||
>
|
||||
A sua plataforma inteligente para gestão de operações agrícolas.
|
||||
Acompanhe as condições, controle os sistemas e maximize a
|
||||
eficiência no campo.
|
||||
</p>
|
||||
|
||||
<DashboardModuleCard
|
||||
theme={theme}
|
||||
title="Rega"
|
||||
icon={Droplets}
|
||||
main={`${overview?.irrigation.activeValveCount ?? 0}`}
|
||||
label={`válvulas ativas / ${overview?.irrigation.controllerCount ?? 0
|
||||
} controladores`}
|
||||
accent="green"
|
||||
items={[
|
||||
[
|
||||
"Bombas ativas",
|
||||
`${overview?.irrigation.activePumpCount ?? 0}`,
|
||||
],
|
||||
[
|
||||
"Estado",
|
||||
overview?.irrigation.activeValveCount ? "A regar" : "Parada",
|
||||
],
|
||||
]}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenMeteo}
|
||||
className={`mt-8 inline-flex h-13 items-center gap-8 ${RADIUS} border border-emerald-400/45 bg-emerald-500/10 px-7 text-sm font-bold shadow-[0_12px_30px_rgba(16,185,129,0.12)] transition hover:bg-emerald-500/15 ${isDark ? "text-white" : "text-emerald-700"
|
||||
}`}
|
||||
>
|
||||
Explorar plataforma
|
||||
<ArrowRight className="h-5 w-5 text-emerald-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DashboardModuleCard
|
||||
theme={theme}
|
||||
title="Iluminação"
|
||||
icon={Lightbulb}
|
||||
main={`${overview?.lighting.activeSectorCount ?? 0}`}
|
||||
label={`setores ativos / ${overview?.lighting.sectorCount ?? 0
|
||||
} setores`}
|
||||
accent="yellow"
|
||||
items={[
|
||||
[
|
||||
"Estado",
|
||||
overview?.lighting.activeSectorCount ? "Ligada" : "Desligada",
|
||||
],
|
||||
]}
|
||||
/>
|
||||
<WeatherForecastCard
|
||||
compact
|
||||
theme={theme}
|
||||
forecast={weather.forecast}
|
||||
loading={weather.loading}
|
||||
error={weather.error}
|
||||
onOpenMeteo={onOpenMeteo}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<DashboardModuleCard
|
||||
theme={theme}
|
||||
title="Sistema"
|
||||
icon={Zap}
|
||||
main={connected ? "Online" : "Offline"}
|
||||
label="ligação em tempo real"
|
||||
accent="blue"
|
||||
items={[
|
||||
["PLC", connected ? "Comunicando" : "Sem ligação"],
|
||||
["Historiador", historianData ? "Ativo" : "A carregar"],
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
<section className="grid min-h-0 grid-cols-3 gap-5">
|
||||
<InfoCard
|
||||
theme={theme}
|
||||
icon={<Sprout className="h-7 w-7" />}
|
||||
iconClass="bg-emerald-500/10 text-emerald-400"
|
||||
title="Gestão inteligente"
|
||||
text="Monitorize e controle todos os seus sistemas de forma centralizada."
|
||||
/>
|
||||
|
||||
<section className="grid grid-cols-1 gap-5 xl:grid-cols-2">
|
||||
<DashboardTrendChart
|
||||
title="Meteorologia em Tempo Real"
|
||||
subtitle="Temperatura exterior, humidade e radiação nos últimos 30 minutos."
|
||||
theme={theme}
|
||||
data={historianData}
|
||||
series={[
|
||||
{
|
||||
key: "meteo.exterior_temperature",
|
||||
label: "Temp. Exterior",
|
||||
color: "#fb7185",
|
||||
},
|
||||
{
|
||||
key: "meteo.exterior_humidity",
|
||||
label: "Humidade",
|
||||
color: "#22d3ee",
|
||||
},
|
||||
{
|
||||
key: "meteo.radiation",
|
||||
label: "Radiação",
|
||||
color: "#facc15",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<InfoCard
|
||||
theme={theme}
|
||||
icon={<Leaf className="h-7 w-7" />}
|
||||
iconClass="bg-sky-500/10 text-sky-400"
|
||||
title="Eficiência hídrica"
|
||||
text="Otimize o uso de água e promova uma agricultura sustentável."
|
||||
/>
|
||||
|
||||
<DashboardTrendChart
|
||||
title="Clima — Zona 1"
|
||||
subtitle="Temperatura e humidade da zona principal nos últimos 30 minutos."
|
||||
theme={theme}
|
||||
data={historianData}
|
||||
series={[
|
||||
{
|
||||
key: "climate.zone_1.temperature",
|
||||
label: "Temp. Zona 1",
|
||||
color: "#38bdf8",
|
||||
},
|
||||
{
|
||||
key: "climate.zone_1.humidity",
|
||||
label: "Humidade Zona 1",
|
||||
color: "#2dd4bf",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
<InfoCard
|
||||
theme={theme}
|
||||
icon={<Sun className="h-7 w-7" />}
|
||||
iconClass="bg-yellow-400/10 text-yellow-300"
|
||||
title="Decisões informadas"
|
||||
text="Dados meteorológicos e insights para melhores decisões no campo."
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 gap-5 xl:grid-cols-12">
|
||||
<div
|
||||
<section
|
||||
className={
|
||||
isDark
|
||||
? "xl:col-span-4 rounded-2xl border border-white/10 bg-[#142230] p-5 shadow-[0_16px_40px_rgba(0,0,0,0.22)]"
|
||||
: "xl:col-span-4 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm"
|
||||
? `min-h-0 overflow-visible ${RADIUS} border border-white/10 bg-[#07111f]/78 p-8 shadow-[0_18px_50px_rgba(0,0,0,0.28)] backdrop-blur-md`
|
||||
: `min-h-0 overflow-visible ${RADIUS} border border-slate-200/90 bg-white/94 p-8 shadow-[0_14px_40px_rgba(15,23,42,0.06)] backdrop-blur-md`
|
||||
}
|
||||
>
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<div className="grid h-full grid-cols-[minmax(0,0.9fr)_minmax(620px,1.1fr)] items-center gap-2">
|
||||
<div>
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "text-xs font-bold uppercase tracking-[0.28em] text-slate-400"
|
||||
: "text-xs font-bold uppercase tracking-[0.28em] text-emerald-700"
|
||||
}
|
||||
>
|
||||
A Nossa Missão
|
||||
</p>
|
||||
|
||||
<h2
|
||||
className={
|
||||
isDark
|
||||
? "text-base font-semibold text-white"
|
||||
: "text-base font-semibold text-slate-950"
|
||||
? "mt-4 max-w-[640px] text-[30px] font-black leading-tight tracking-[-0.045em] text-white"
|
||||
: "mt-4 max-w-[640px] text-[30px] font-black leading-tight tracking-[-0.045em] text-slate-950"
|
||||
}
|
||||
>
|
||||
Estado Operacional
|
||||
Soluções inovadoras para
|
||||
<br />
|
||||
uma{" "}
|
||||
<span className="text-emerald-400">
|
||||
agricultura sustentável
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<p
|
||||
className={
|
||||
isDark ? "text-sm text-slate-400" : "text-sm text-slate-500"
|
||||
isDark
|
||||
? "mt-4 max-w-[560px] text-sm leading-6 text-slate-400"
|
||||
: "mt-4 max-w-[560px] text-sm leading-6 text-slate-600"
|
||||
}
|
||||
>
|
||||
Resumo rápido da instalação.
|
||||
Tecnologia, conhecimento e proximidade para impulsionar o futuro
|
||||
do setor agrícola em Portugal.
|
||||
</p>
|
||||
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "mt-6 grid grid-cols-3 gap-5 border-t border-white/10 pt-6"
|
||||
: "mt-6 grid grid-cols-3 gap-5 border-t border-slate-200 pt-6"
|
||||
}
|
||||
>
|
||||
<MissionItem
|
||||
theme={theme}
|
||||
icon={<Sprout />}
|
||||
title="Sustentabilidade"
|
||||
text="Compromisso com o futuro"
|
||||
/>
|
||||
<MissionItem
|
||||
theme={theme}
|
||||
icon={<ShieldCheck />}
|
||||
title="Confiabilidade"
|
||||
text="Tecnologia robusta e segura"
|
||||
/>
|
||||
<MissionItem
|
||||
theme={theme}
|
||||
icon={<Users />}
|
||||
title="Apoio próximo"
|
||||
text="Sempre ao seu lado"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Activity
|
||||
className={isDark ? "h-5 w-5 text-slate-400" : "h-5 w-5 text-slate-500"}
|
||||
/>
|
||||
<FarmIllustration theme={theme} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="space-y-3">
|
||||
<StatusRow
|
||||
theme={theme}
|
||||
label="Aquisição"
|
||||
value={connected ? "Ativa" : "Inativa"}
|
||||
good={connected}
|
||||
/>
|
||||
<StatusRow
|
||||
theme={theme}
|
||||
label="Rega"
|
||||
value={overview?.irrigation.activeValveCount ? "Em curso" : "Parada"}
|
||||
good={!overview?.irrigation.activeValveCount}
|
||||
/>
|
||||
<StatusRow
|
||||
theme={theme}
|
||||
label="Chuva"
|
||||
value={formatBoolean(meteo?.raining)}
|
||||
good={!meteo?.raining}
|
||||
/>
|
||||
<StatusRow
|
||||
theme={theme}
|
||||
label="Zenitais Zona 1"
|
||||
value={`${formatNumber(zoneOne?.zenitalLeftPercent, 0)}%`}
|
||||
good
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "xl:col-span-8 rounded-2xl border border-white/10 bg-[#142230] p-5 shadow-[0_16px_40px_rgba(0,0,0,0.22)]"
|
||||
: "xl:col-span-8 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm"
|
||||
}
|
||||
>
|
||||
<div className="mb-5">
|
||||
<h2
|
||||
className={
|
||||
isDark
|
||||
? "text-base font-semibold text-white"
|
||||
: "text-base font-semibold text-slate-950"
|
||||
}
|
||||
>
|
||||
Próximos Desenvolvimentos
|
||||
</h2>
|
||||
<p
|
||||
className={
|
||||
isDark ? "text-sm text-slate-400" : "text-sm text-slate-500"
|
||||
}
|
||||
>
|
||||
Espaço preparado para eventos, alarmes, programas e depósitos.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<PlaceholderPanel
|
||||
theme={theme}
|
||||
title="Eventos"
|
||||
text="Últimos eventos do sistema"
|
||||
/>
|
||||
<PlaceholderPanel
|
||||
theme={theme}
|
||||
title="Alarmes"
|
||||
text="Alarmes ativos e recentes"
|
||||
/>
|
||||
<PlaceholderPanel
|
||||
theme={theme}
|
||||
title="Depósitos"
|
||||
text="Níveis, pH e CE"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<footer className="flex min-h-0 items-center justify-between px-56 text-xs text-slate-500">
|
||||
<span>© 2026 Litoral Central. Todos os direitos reservados.</span>
|
||||
<span>
|
||||
Feito em Portugal <span className="ml-2">🇵🇹</span>
|
||||
</span>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type DashboardModuleCardProps = {
|
||||
theme: "dark" | "light";
|
||||
title: string;
|
||||
icon: React.ElementType;
|
||||
main: string;
|
||||
label: string;
|
||||
accent: "blue" | "green" | "yellow" | "cyan";
|
||||
items: [string, string][];
|
||||
};
|
||||
|
||||
const moduleAccentClasses = {
|
||||
blue: {
|
||||
icon: "text-sky-400",
|
||||
bg: "bg-sky-500/10",
|
||||
},
|
||||
green: {
|
||||
icon: "text-emerald-400",
|
||||
bg: "bg-emerald-500/10",
|
||||
},
|
||||
yellow: {
|
||||
icon: "text-yellow-400",
|
||||
bg: "bg-yellow-500/10",
|
||||
},
|
||||
cyan: {
|
||||
icon: "text-cyan-400",
|
||||
bg: "bg-cyan-500/10",
|
||||
},
|
||||
};
|
||||
|
||||
function DashboardModuleCard({
|
||||
function InfoCard({
|
||||
theme,
|
||||
icon,
|
||||
iconClass,
|
||||
title,
|
||||
icon: Icon,
|
||||
main,
|
||||
label,
|
||||
accent,
|
||||
items,
|
||||
}: DashboardModuleCardProps) {
|
||||
const isDark = theme === "dark";
|
||||
const accentClass = moduleAccentClasses[accent];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "rounded-2xl border border-white/10 bg-[#142230] p-5 shadow-[0_16px_40px_rgba(0,0,0,0.22)]"
|
||||
: "rounded-2xl border border-slate-200 bg-white p-5 shadow-sm"
|
||||
}
|
||||
>
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<h2
|
||||
className={
|
||||
isDark
|
||||
? "text-sm font-medium text-slate-300"
|
||||
: "text-sm font-medium text-slate-600"
|
||||
}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
<div
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-xl ${accentClass.bg} ${accentClass.icon}`}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "text-3xl font-bold tracking-tight text-white"
|
||||
: "text-3xl font-bold tracking-tight text-slate-950"
|
||||
}
|
||||
>
|
||||
{main}
|
||||
</p>
|
||||
|
||||
<p className={isDark ? "mt-1 text-sm text-slate-400" : "mt-1 text-sm text-slate-500"}>
|
||||
{label}
|
||||
</p>
|
||||
|
||||
<div className="mt-5 space-y-2">
|
||||
{items.map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between gap-4 text-sm">
|
||||
<span className={isDark ? "text-slate-400" : "text-slate-500"}>
|
||||
{key}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
isDark
|
||||
? "font-medium text-slate-100"
|
||||
: "font-medium text-slate-800"
|
||||
}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type StatusRowProps = {
|
||||
theme: "dark" | "light";
|
||||
label: string;
|
||||
value: string;
|
||||
good: boolean;
|
||||
};
|
||||
|
||||
function StatusRow({ theme, label, value, good }: StatusRowProps) {
|
||||
const isDark = theme === "dark";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "flex items-center justify-between rounded-xl bg-white/5 px-4 py-3"
|
||||
: "flex items-center justify-between rounded-xl bg-slate-50 px-4 py-3"
|
||||
}
|
||||
>
|
||||
<span className={isDark ? "text-sm text-slate-300" : "text-sm text-slate-600"}>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={
|
||||
good
|
||||
? "rounded-full bg-emerald-500/10 px-3 py-1 text-xs font-semibold text-emerald-500"
|
||||
: "rounded-full bg-red-500/10 px-3 py-1 text-xs font-semibold text-red-500"
|
||||
}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type PlaceholderPanelProps = {
|
||||
text,
|
||||
}: {
|
||||
theme: "dark" | "light";
|
||||
icon: React.ReactNode;
|
||||
iconClass: string;
|
||||
title: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
function PlaceholderPanel({ theme, title, text }: PlaceholderPanelProps) {
|
||||
}) {
|
||||
const isDark = theme === "dark";
|
||||
|
||||
return (
|
||||
<div
|
||||
<article
|
||||
className={
|
||||
isDark
|
||||
? "rounded-xl border border-white/10 bg-white/5 p-4"
|
||||
: "rounded-xl border border-slate-200 bg-slate-50 p-4"
|
||||
? `${RADIUS} flex h-full items-center justify-between border border-white/10 bg-[#07111f]/78 p-5 shadow-[0_18px_50px_rgba(0,0,0,0.24)] backdrop-blur-md`
|
||||
: `${RADIUS} flex h-full items-center justify-between border border-slate-200/90 bg-white/95 p-5 shadow-[0_8px_24px_rgba(15,23,42,0.05)] backdrop-blur-md`
|
||||
}
|
||||
>
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "text-sm font-semibold text-white"
|
||||
: "text-sm font-semibold text-slate-950"
|
||||
}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
<p className={isDark ? "mt-1 text-sm text-slate-400" : "mt-1 text-sm text-slate-500"}>
|
||||
{text}
|
||||
</p>
|
||||
<div className="flex items-center gap-5">
|
||||
<div
|
||||
className={`grid h-[62px] w-[62px] place-items-center ${RADIUS} ${iconClass}`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-base font-black">{title}</h3>
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "mt-2 max-w-[280px] text-sm leading-5 text-slate-400"
|
||||
: "mt-2 max-w-[280px] text-sm leading-5 text-slate-600"
|
||||
}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ArrowRight
|
||||
className={isDark ? "h-5 w-5 text-slate-400" : "h-5 w-5 text-emerald-500"}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function MissionItem({
|
||||
theme,
|
||||
icon,
|
||||
title,
|
||||
text,
|
||||
}: {
|
||||
theme: "dark" | "light";
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
text: string;
|
||||
}) {
|
||||
const isDark = theme === "dark";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-7 w-7 text-emerald-400">{icon}</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-black">{title}</h4>
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "mt-1 text-xs text-slate-400"
|
||||
: "mt-1 text-xs text-slate-600"
|
||||
}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function FarmIllustration({ theme }: { theme: "dark" | "light" }) {
|
||||
const isDark = theme === "dark";
|
||||
|
||||
return (
|
||||
<div className="relative h-full min-h-[270px] w-full overflow-visible">
|
||||
<img
|
||||
src={isDark ? farmdrawImage : farmdrawWhiteImage}
|
||||
alt=""
|
||||
className={
|
||||
isDark
|
||||
? "absolute right-[-70px] top-1/2 h-[380px] max-w-none -translate-y-1/2 opacity-95"
|
||||
: "absolute right-[-70px] top-1/2 h-[380px] max-w-none -translate-y-1/2 opacity-100"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardPage;
|
||||
@@ -26,6 +26,8 @@ type Props = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const RADIUS = "rounded-[5px]";
|
||||
|
||||
const RANGE_OPTIONS: Array<{ label: string; value: AccumulatedRange }> = [
|
||||
{ label: "7D", value: "7d" },
|
||||
{ label: "30D", value: "30d" },
|
||||
@@ -44,6 +46,7 @@ export function AccumulatedHistoryModal({
|
||||
onClose,
|
||||
}: Props) {
|
||||
const isDark = theme === "dark";
|
||||
const palette = accumulatedPalette(isDark);
|
||||
const [mode, setMode] = useState<"chart" | "table">("chart");
|
||||
|
||||
const stats = useMemo(() => {
|
||||
@@ -62,26 +65,34 @@ export function AccumulatedHistoryModal({
|
||||
|
||||
if (!sensor) return null;
|
||||
|
||||
const unit = sensor.unit ?? buckets[0]?.unit ?? "";
|
||||
const unit = buckets[0]?.unit ?? sensor.unit ?? "";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-4 backdrop-blur-md">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "flex h-[82vh] w-full max-w-6xl flex-col overflow-hidden rounded-2xl border border-[#24384d] bg-[#071120] text-white shadow-2xl"
|
||||
: "flex h-[82vh] w-full max-w-6xl flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white text-slate-950 shadow-2xl"
|
||||
? `${RADIUS} flex h-[82vh] w-full max-w-6xl flex-col overflow-hidden border border-white/10 bg-[#111827] text-slate-100 shadow-2xl`
|
||||
: `${RADIUS} flex h-[82vh] w-full max-w-6xl flex-col overflow-hidden border border-slate-200 bg-white text-slate-950 shadow-[0_24px_70px_rgba(15,23,42,0.18)]`
|
||||
}
|
||||
>
|
||||
<header className="flex items-start justify-between gap-5 px-6 py-4">
|
||||
<div>
|
||||
<p className="mb-1 text-[11px] font-black uppercase tracking-[0.42em] text-cyan-400">
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "mb-1 text-[11px] font-bold uppercase tracking-[0.22em] text-slate-500"
|
||||
: "mb-1 text-[11px] font-bold uppercase tracking-[0.22em] text-slate-400"
|
||||
}
|
||||
>
|
||||
Acumulado
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-black">{title}</h2>
|
||||
<h2 className="text-xl font-black tracking-[-0.03em]">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Chave: meteo.{sensor.key}
|
||||
</p>
|
||||
</div>
|
||||
@@ -89,7 +100,11 @@ export function AccumulatedHistoryModal({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-2 text-slate-400 hover:text-white"
|
||||
className={
|
||||
isDark
|
||||
? `${RADIUS} p-2 text-slate-400 transition hover:bg-white/5 hover:text-white`
|
||||
: `${RADIUS} p-2 text-slate-400 transition hover:bg-slate-100 hover:text-slate-900`
|
||||
}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
@@ -99,8 +114,8 @@ export function AccumulatedHistoryModal({
|
||||
<section
|
||||
className={
|
||||
isDark
|
||||
? "flex min-h-0 flex-1 flex-col rounded-xl border border-[#24384d] bg-[#0a1728] p-4"
|
||||
: "flex min-h-0 flex-1 flex-col rounded-xl border border-slate-200 bg-slate-50 p-4"
|
||||
? `${RADIUS} flex min-h-0 flex-1 flex-col border border-white/10 bg-[#0b1220] p-4`
|
||||
: `${RADIUS} flex min-h-0 flex-1 flex-col border border-slate-200 bg-slate-50 p-4`
|
||||
}
|
||||
>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
@@ -111,10 +126,8 @@ export function AccumulatedHistoryModal({
|
||||
onClick={() => onRangeChange(option.value)}
|
||||
className={
|
||||
range === option.value
|
||||
? "rounded-lg bg-cyan-400 px-4 py-2 text-xs font-black text-slate-950 shadow-lg shadow-cyan-400/20"
|
||||
: isDark
|
||||
? "rounded-lg bg-slate-900/70 px-4 py-2 text-xs font-bold text-slate-300 hover:bg-slate-800"
|
||||
: "rounded-lg bg-white px-4 py-2 text-xs font-bold text-slate-600 hover:bg-slate-100"
|
||||
? activeButtonClass(isDark)
|
||||
: buttonClass(isDark)
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
@@ -142,7 +155,7 @@ export function AccumulatedHistoryModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 grid grid-cols-3 gap-3">
|
||||
<div className="mb-4 grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<StatCard theme={theme} label="Total" value={formatValue(stats.total, unit)} />
|
||||
<StatCard theme={theme} label="Média" value={formatValue(stats.average, unit)} />
|
||||
<StatCard theme={theme} label="Máximo" value={formatValue(stats.max, unit)} />
|
||||
@@ -155,35 +168,41 @@ export function AccumulatedHistoryModal({
|
||||
<EmptyState>Sem dados acumulados para este período.</EmptyState>
|
||||
) : mode === "chart" ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={buckets}>
|
||||
<BarChart
|
||||
data={buckets}
|
||||
margin={{ top: 16, right: 18, bottom: 8, left: 0 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
stroke="#1f3348"
|
||||
strokeDasharray="3 3"
|
||||
stroke={palette.grid}
|
||||
strokeDasharray="4 6"
|
||||
vertical={false}
|
||||
/>
|
||||
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: "#94a3b8", fontSize: 11 }}
|
||||
tick={{ fill: palette.axis, fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "#334155" }}
|
||||
axisLine={{ stroke: palette.axisLine }}
|
||||
minTickGap={20}
|
||||
/>
|
||||
|
||||
<YAxis
|
||||
tick={{ fill: "#94a3b8", fontSize: 11 }}
|
||||
tick={{ fill: palette.axis, fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "#334155" }}
|
||||
width={50}
|
||||
axisLine={{ stroke: palette.axisLine }}
|
||||
width={56}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
cursor={{ fill: "rgba(34, 211, 238, 0.08)" }}
|
||||
cursor={{ fill: palette.cursor }}
|
||||
contentStyle={{
|
||||
background: "#111827",
|
||||
border: "1px solid #334155",
|
||||
borderRadius: "10px",
|
||||
color: "#fff",
|
||||
background: palette.tooltipBg,
|
||||
border: `1px solid ${palette.tooltipBorder}`,
|
||||
borderRadius: "5px",
|
||||
color: palette.tooltipText,
|
||||
boxShadow: isDark
|
||||
? "0 18px 45px rgba(0,0,0,0.30)"
|
||||
: "0 18px 45px rgba(15,23,42,0.14)",
|
||||
}}
|
||||
formatter={(value) => [
|
||||
formatValue(Number(value), unit),
|
||||
@@ -193,16 +212,28 @@ export function AccumulatedHistoryModal({
|
||||
|
||||
<Bar
|
||||
dataKey="total"
|
||||
fill="#22d3ee"
|
||||
radius={[6, 6, 0, 0]}
|
||||
fill={palette.bar}
|
||||
radius={[3, 3, 0, 0]}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-full overflow-auto rounded-xl border border-slate-700/60">
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? `${RADIUS} h-full overflow-auto border border-white/10 bg-[#111827]`
|
||||
: `${RADIUS} h-full overflow-auto border border-slate-200 bg-white`
|
||||
}
|
||||
>
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="sticky top-0 bg-[#0b1828] text-xs uppercase tracking-[0.16em] text-slate-400">
|
||||
<thead
|
||||
className={
|
||||
isDark
|
||||
? "sticky top-0 bg-[#111827] text-xs uppercase tracking-[0.14em] text-slate-500"
|
||||
: "sticky top-0 bg-slate-50 text-xs uppercase tracking-[0.14em] text-slate-500"
|
||||
}
|
||||
>
|
||||
<tr>
|
||||
<th className="px-4 py-3">Período</th>
|
||||
<th className="px-4 py-3">Início</th>
|
||||
@@ -215,18 +246,31 @@ export function AccumulatedHistoryModal({
|
||||
{buckets.map((bucket) => (
|
||||
<tr
|
||||
key={`${bucket.from}-${bucket.to}`}
|
||||
className="border-t border-slate-700/60 text-slate-200"
|
||||
className={
|
||||
isDark
|
||||
? "border-t border-white/10 text-slate-300"
|
||||
: "border-t border-slate-200 text-slate-700"
|
||||
}
|
||||
>
|
||||
<td className="px-4 py-3 font-semibold">
|
||||
{bucket.label}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-400">
|
||||
|
||||
<td className="px-4 py-3 text-slate-500">
|
||||
{formatDate(bucket.from)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-400">
|
||||
|
||||
<td className="px-4 py-3 text-slate-500">
|
||||
{formatDate(bucket.to)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-black text-cyan-300">
|
||||
|
||||
<td
|
||||
className={
|
||||
isDark
|
||||
? "px-4 py-3 text-right font-black text-slate-100"
|
||||
: "px-4 py-3 text-right font-black text-slate-950"
|
||||
}
|
||||
>
|
||||
{formatValue(bucket.total, unit)}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -243,6 +287,30 @@ export function AccumulatedHistoryModal({
|
||||
);
|
||||
}
|
||||
|
||||
function accumulatedPalette(isDark: boolean) {
|
||||
return isDark
|
||||
? {
|
||||
bar: "#94a3b8",
|
||||
grid: "rgba(148,163,184,0.16)",
|
||||
axis: "#64748b",
|
||||
axisLine: "rgba(148,163,184,0.18)",
|
||||
cursor: "rgba(148,163,184,0.08)",
|
||||
tooltipBg: "#111827",
|
||||
tooltipBorder: "rgba(255,255,255,0.10)",
|
||||
tooltipText: "#e5e7eb",
|
||||
}
|
||||
: {
|
||||
bar: "#475569",
|
||||
grid: "#e2e8f0",
|
||||
axis: "#64748b",
|
||||
axisLine: "#cbd5e1",
|
||||
cursor: "rgba(148,163,184,0.12)",
|
||||
tooltipBg: "#ffffff",
|
||||
tooltipBorder: "#e2e8f0",
|
||||
tooltipText: "#0f172a",
|
||||
};
|
||||
}
|
||||
|
||||
function EmptyState({ children }: { children: string }) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-slate-400">
|
||||
@@ -266,27 +334,47 @@ function StatCard({
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "rounded-xl border border-slate-700/60 bg-[#071120] p-3"
|
||||
: "rounded-xl border border-slate-200 bg-white p-3"
|
||||
? `${RADIUS} border border-white/10 bg-[#111827] p-3`
|
||||
: `${RADIUS} border border-slate-200 bg-white p-3`
|
||||
}
|
||||
>
|
||||
<p className="text-xs text-slate-400">{label}</p>
|
||||
<p className="mt-1 text-xl font-black">{value}</p>
|
||||
<p className="text-xs text-slate-500">{label}</p>
|
||||
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "mt-1 text-xl font-black text-slate-100"
|
||||
: "mt-1 text-xl font-black text-slate-950"
|
||||
}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function toggleButtonClass(isDark: boolean, active: boolean) {
|
||||
if (active) {
|
||||
return "rounded-lg border border-cyan-400/40 bg-cyan-400/10 px-3 py-2 text-xs font-semibold text-cyan-300";
|
||||
}
|
||||
|
||||
function buttonClass(isDark: boolean) {
|
||||
return isDark
|
||||
? "rounded-lg border border-slate-700 px-3 py-2 text-xs font-semibold text-slate-200 hover:bg-slate-800"
|
||||
: "rounded-lg border border-slate-200 px-3 py-2 text-xs font-semibold text-slate-700 hover:bg-slate-100";
|
||||
? `${RADIUS} border border-white/10 bg-white/[0.03] px-3 py-2 text-xs font-semibold text-slate-300 transition hover:bg-white/[0.06] hover:text-slate-100`
|
||||
: `${RADIUS} border border-slate-200 bg-white px-3 py-2 text-xs font-semibold text-slate-600 transition hover:bg-slate-100 hover:text-slate-950`;
|
||||
}
|
||||
|
||||
function activeButtonClass(isDark: boolean) {
|
||||
return isDark
|
||||
? `${RADIUS} border border-white/10 bg-slate-200 px-3 py-2 text-xs font-black text-slate-950`
|
||||
: `${RADIUS} border border-slate-300 bg-slate-900 px-3 py-2 text-xs font-black text-white`;
|
||||
}
|
||||
|
||||
function toggleButtonClass(isDark: boolean, active: boolean) {
|
||||
if (active) return activeButtonClass(isDark);
|
||||
return buttonClass(isDark);
|
||||
}
|
||||
|
||||
function formatValue(value: number, unit: string) {
|
||||
if (unit === "Wh/m²" && value >= 1000) {
|
||||
return `${(value / 1000).toFixed(2)} kWh/m²`;
|
||||
}
|
||||
|
||||
return `${value.toFixed(1)}${unit ? ` ${unit}` : ""}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import type { ModuleSensorResponse } from "../../../types/meteo";
|
||||
|
||||
@@ -39,6 +39,8 @@ type Props = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const RADIUS = "rounded-[5px]";
|
||||
|
||||
const RANGE_OPTIONS = [
|
||||
{ label: "15M", hours: 0.25 },
|
||||
{ label: "30M", hours: 0.5 },
|
||||
@@ -64,6 +66,7 @@ export function MeteoHistoryModal({
|
||||
onClose,
|
||||
}: Props) {
|
||||
const isDark = theme === "dark";
|
||||
const palette = chartPalette(isDark);
|
||||
|
||||
const [intervalMinutes, setIntervalMinutes] = useState(5);
|
||||
const [showIndicators, setShowIndicators] = useState(true);
|
||||
@@ -71,7 +74,6 @@ export function MeteoHistoryModal({
|
||||
const [chartMode, setChartMode] = useState<"area" | "line">("area");
|
||||
const [zeroBaseline, setZeroBaseline] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const [intervalOpen, setIntervalOpen] = useState(false);
|
||||
|
||||
const rawData = useMemo(
|
||||
@@ -80,11 +82,7 @@ export function MeteoHistoryModal({
|
||||
.map((point) => {
|
||||
const value =
|
||||
point.numericValue ??
|
||||
(point.booleanValue === null
|
||||
? null
|
||||
: point.booleanValue
|
||||
? 1
|
||||
: 0);
|
||||
(point.booleanValue === null ? null : point.booleanValue ? 1 : 0);
|
||||
|
||||
return {
|
||||
timestamp: point.timestamp,
|
||||
@@ -92,8 +90,14 @@ export function MeteoHistoryModal({
|
||||
value,
|
||||
};
|
||||
})
|
||||
.filter((point): point is { timestamp: string; date: Date; value: number } =>
|
||||
typeof point.value === "number",
|
||||
.filter(
|
||||
(
|
||||
point,
|
||||
): point is {
|
||||
timestamp: string;
|
||||
date: Date;
|
||||
value: number;
|
||||
} => typeof point.value === "number",
|
||||
),
|
||||
[points],
|
||||
);
|
||||
@@ -101,7 +105,11 @@ export function MeteoHistoryModal({
|
||||
const chartData = useMemo(() => {
|
||||
if (rawData.length === 0) return [];
|
||||
|
||||
const buckets = new Map<number, { total: number; count: number; timestamp: string }>();
|
||||
const buckets = new Map<
|
||||
number,
|
||||
{ total: number; count: number; timestamp: string }
|
||||
>();
|
||||
|
||||
const intervalMs = intervalMinutes * 60 * 1000;
|
||||
|
||||
for (const point of rawData) {
|
||||
@@ -177,43 +185,63 @@ export function MeteoHistoryModal({
|
||||
if (!sensor) return null;
|
||||
|
||||
const unit = sensor.unit ?? "";
|
||||
const yDomain: [number | "auto", number | "auto"] = zeroBaseline ? [0, "auto"] : ["auto", "auto"];
|
||||
const yDomain: [number | "auto", number | "auto"] = zeroBaseline
|
||||
? [0, "auto"]
|
||||
: ["auto", "auto"];
|
||||
|
||||
const Chart = chartMode === "area" ? AreaChart : LineChart;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-4 backdrop-blur-md">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? `${expanded ? "h-[88vh] max-w-[92vw]" : "max-w-6xl"} flex w-full flex-col overflow-hidden rounded-2xl border border-[#24384d] bg-[#071120] text-white shadow-2xl`
|
||||
: `${expanded ? "h-[88vh] max-w-[92vw]" : "max-w-6xl"} flex w-full flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white text-slate-950 shadow-2xl`
|
||||
? `${expanded ? "h-[88vh] max-w-[92vw]" : "max-w-6xl"} ${RADIUS} flex w-full flex-col overflow-hidden border border-white/10 bg-[#111827] text-slate-100 shadow-2xl`
|
||||
: `${expanded ? "h-[88vh] max-w-[92vw]" : "max-w-6xl"} ${RADIUS} flex w-full flex-col overflow-hidden border border-slate-200 bg-white text-slate-950 shadow-[0_24px_70px_rgba(15,23,42,0.18)]`
|
||||
}
|
||||
>
|
||||
<header className="flex items-start justify-between gap-5 px-6 py-4">
|
||||
<div>
|
||||
<p className="mb-1 text-[11px] font-black uppercase tracking-[0.42em] text-cyan-400">
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "mb-1 text-[11px] font-bold uppercase tracking-[0.22em] text-slate-500"
|
||||
: "mb-1 text-[11px] font-bold uppercase tracking-[0.22em] text-slate-400"
|
||||
}
|
||||
>
|
||||
Histórico
|
||||
</p>
|
||||
<h2 className="text-xl font-black">{sensor.name}</h2>
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
|
||||
<h2 className="text-xl font-black tracking-[-0.03em]">
|
||||
{sensor.name}
|
||||
</h2>
|
||||
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Chave: meteo.{sensor.key}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={onClose} className="p-2 text-slate-400 hover:text-white">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={
|
||||
isDark
|
||||
? `${RADIUS} p-2 text-slate-400 transition hover:bg-white/5 hover:text-white`
|
||||
: `${RADIUS} p-2 text-slate-400 transition hover:bg-slate-100 hover:text-slate-900`
|
||||
}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main className="flex min-h-0 flex-1 flex-col px-6 pb-5">
|
||||
<section className={
|
||||
isDark
|
||||
? "flex min-h-0 flex-1 flex-col rounded-xl border border-[#24384d] bg-[#0a1728] p-4"
|
||||
: "flex min-h-0 flex-1 flex-col rounded-xl border border-slate-200 bg-slate-50 p-4"
|
||||
}>
|
||||
<section
|
||||
className={
|
||||
isDark
|
||||
? `${RADIUS} flex min-h-0 flex-1 flex-col border border-white/10 bg-[#0b1220] p-4`
|
||||
: `${RADIUS} flex min-h-0 flex-1 flex-col border border-slate-200 bg-slate-50 p-4`
|
||||
}
|
||||
>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
{RANGE_OPTIONS.map((range) => (
|
||||
<button
|
||||
@@ -222,10 +250,8 @@ export function MeteoHistoryModal({
|
||||
onClick={() => onHoursChange(range.hours)}
|
||||
className={
|
||||
hours === range.hours
|
||||
? "rounded-lg bg-cyan-400 px-4 py-2 text-xs font-black text-slate-950 shadow-lg shadow-cyan-400/20"
|
||||
: isDark
|
||||
? "rounded-lg bg-slate-900/70 px-4 py-2 text-xs font-bold text-slate-300 hover:bg-slate-800"
|
||||
: "rounded-lg bg-white px-4 py-2 text-xs font-bold text-slate-600 hover:bg-slate-100"
|
||||
? activeButtonClass(isDark)
|
||||
: buttonClass(isDark)
|
||||
}
|
||||
>
|
||||
{range.label}
|
||||
@@ -236,16 +262,12 @@ export function MeteoHistoryModal({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIntervalOpen((value) => !value)}
|
||||
className={
|
||||
isDark
|
||||
? "flex h-[36px] items-center gap-2 rounded-lg border border-slate-700 bg-[#0a1728] px-4 text-xs font-semibold text-slate-200 hover:bg-slate-800"
|
||||
: "flex h-[36px] items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 text-xs font-semibold text-slate-700 hover:bg-slate-100"
|
||||
}
|
||||
className={buttonClass(isDark)}
|
||||
>
|
||||
Intervalo: {intervalMinutes}m
|
||||
|
||||
<svg
|
||||
className={`h-3 w-3 transition ${intervalOpen ? "rotate-180" : ""}`}
|
||||
className={`ml-2 inline h-3 w-3 transition ${intervalOpen ? "rotate-180" : ""
|
||||
}`}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
@@ -261,8 +283,8 @@ export function MeteoHistoryModal({
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "absolute left-0 top-[calc(100%+8px)] z-50 min-w-[160px] overflow-hidden rounded-xl border border-slate-700 bg-[#0b1828] shadow-2xl"
|
||||
: "absolute left-0 top-[calc(100%+8px)] z-50 min-w-[160px] overflow-hidden rounded-xl border border-slate-200 bg-white shadow-xl"
|
||||
? `absolute left-0 top-[calc(100%+8px)] z-50 min-w-[160px] overflow-hidden ${RADIUS} border border-white/10 bg-[#111827] shadow-2xl`
|
||||
: `absolute left-0 top-[calc(100%+8px)] z-50 min-w-[160px] overflow-hidden ${RADIUS} border border-slate-200 bg-white shadow-xl`
|
||||
}
|
||||
>
|
||||
{INTERVAL_OPTIONS.map((option) => (
|
||||
@@ -275,22 +297,30 @@ export function MeteoHistoryModal({
|
||||
}}
|
||||
className={
|
||||
intervalMinutes === option
|
||||
? "flex w-full items-center justify-between bg-cyan-400/10 px-4 py-3 text-left text-xs font-bold text-cyan-300"
|
||||
? isDark
|
||||
? "flex w-full items-center justify-between bg-white/[0.06] px-4 py-3 text-left text-xs font-bold text-slate-100"
|
||||
: "flex w-full items-center justify-between bg-slate-100 px-4 py-3 text-left text-xs font-bold text-slate-950"
|
||||
: isDark
|
||||
? "flex w-full items-center justify-between px-4 py-3 text-left text-xs font-semibold text-slate-300 hover:bg-slate-800"
|
||||
: "flex w-full items-center justify-between px-4 py-3 text-left text-xs font-semibold text-slate-700 hover:bg-slate-100"
|
||||
? "flex w-full items-center justify-between px-4 py-3 text-left text-xs font-semibold text-slate-300 hover:bg-white/[0.04]"
|
||||
: "flex w-full items-center justify-between px-4 py-3 text-left text-xs font-semibold text-slate-700 hover:bg-slate-50"
|
||||
}
|
||||
>
|
||||
<span>{option} minutos</span>
|
||||
|
||||
{intervalMinutes === option && (
|
||||
<div className="h-2 w-2 rounded-full bg-cyan-300" />
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "h-2 w-2 rounded-full bg-slate-300"
|
||||
: "h-2 w-2 rounded-full bg-slate-700"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCompareLine((value) => !value)}
|
||||
@@ -310,15 +340,21 @@ export function MeteoHistoryModal({
|
||||
</div>
|
||||
|
||||
<div className="mb-3 flex flex-wrap items-center gap-6 text-xs">
|
||||
<span className="font-semibold">
|
||||
<span className="mr-2 inline-block h-2.5 w-2.5 rounded-full bg-cyan-400" />
|
||||
<span className={isDark ? "font-semibold text-slate-100" : "font-semibold text-slate-950"}>
|
||||
<span className={isDark ? "mr-2 inline-block h-2.5 w-2.5 rounded-full bg-slate-300" : "mr-2 inline-block h-2.5 w-2.5 rounded-full bg-slate-700"} />
|
||||
{sensor.name}
|
||||
{unit && ` (${unit})`}
|
||||
</span>
|
||||
|
||||
<span>Média: <b>{formatValue(stats.average, unit)}</b></span>
|
||||
<span>Máx: <b>{formatValue(stats.max, unit)}</b></span>
|
||||
<span>Mín: <b>{formatValue(stats.min, unit)}</b></span>
|
||||
<span className={isDark ? "text-slate-400" : "text-slate-500"}>
|
||||
Média: <b>{formatValue(stats.average, unit)}</b>
|
||||
</span>
|
||||
<span className={isDark ? "text-slate-400" : "text-slate-500"}>
|
||||
Máx: <b>{formatValue(stats.max, unit)}</b>
|
||||
</span>
|
||||
<span className={isDark ? "text-slate-400" : "text-slate-500"}>
|
||||
Mín: <b>{formatValue(stats.min, unit)}</b>
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex gap-2">
|
||||
<IconButton
|
||||
@@ -342,8 +378,7 @@ export function MeteoHistoryModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={expanded ? "min-h-0 flex-1" : "h-[300px]"}
|
||||
>
|
||||
<div className={expanded ? "min-h-0 flex-1" : "h-[300px]"}>
|
||||
{loading ? (
|
||||
<EmptyState>A carregar histórico...</EmptyState>
|
||||
) : chartData.length === 0 ? (
|
||||
@@ -353,32 +388,37 @@ export function MeteoHistoryModal({
|
||||
<Chart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="historyFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#22d3ee" stopOpacity={0.34} />
|
||||
<stop offset="95%" stopColor="#22d3ee" stopOpacity={0.03} />
|
||||
<stop offset="0%" stopColor={palette.line} stopOpacity={isDark ? 0.22 : 0.12} />
|
||||
<stop offset="70%" stopColor={palette.line} stopOpacity={isDark ? 0.08 : 0.04} />
|
||||
<stop offset="100%" stopColor={palette.line} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<CartesianGrid stroke="#1f3348" strokeDasharray="3 3" vertical={false} />
|
||||
<CartesianGrid
|
||||
stroke={palette.grid}
|
||||
strokeDasharray="4 6"
|
||||
vertical={false}
|
||||
/>
|
||||
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tick={{ fill: "#94a3b8", fontSize: 11 }}
|
||||
tick={{ fill: palette.axis, fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "#334155" }}
|
||||
axisLine={{ stroke: palette.axisLine }}
|
||||
minTickGap={42}
|
||||
/>
|
||||
|
||||
<YAxis
|
||||
domain={yDomain}
|
||||
tick={{ fill: "#94a3b8", fontSize: 11 }}
|
||||
tick={{ fill: palette.axis, fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "#334155" }}
|
||||
axisLine={{ stroke: palette.axisLine }}
|
||||
width={44}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
cursor={{
|
||||
stroke: "#94a3b8",
|
||||
stroke: palette.cursor,
|
||||
strokeWidth: 1,
|
||||
}}
|
||||
content={({ active, payload, label }) => {
|
||||
@@ -387,43 +427,52 @@ export function MeteoHistoryModal({
|
||||
const value = Number(payload[0].value);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-700 bg-[#111827] px-4 py-3 text-sm shadow-2xl">
|
||||
<p className="mb-2 text-xs text-slate-400">{label}</p>
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? `${RADIUS} border border-white/10 bg-[#111827] px-4 py-3 text-sm shadow-2xl`
|
||||
: `${RADIUS} border border-slate-200 bg-white px-4 py-3 text-sm text-slate-950 shadow-xl`
|
||||
}
|
||||
>
|
||||
<p className="mb-2 text-xs text-slate-500">
|
||||
{label}
|
||||
</p>
|
||||
|
||||
<p className="font-bold text-cyan-300">
|
||||
<p className={isDark ? "font-bold text-slate-100" : "font-bold text-slate-950"}>
|
||||
Atual: {formatValue(value, unit)}
|
||||
</p>
|
||||
|
||||
<p className="mt-1 text-slate-300">
|
||||
<p className={isDark ? "mt-1 text-slate-400" : "mt-1 text-slate-500"}>
|
||||
Média: {formatValue(stats.average, unit)}
|
||||
</p>
|
||||
|
||||
<p className="text-slate-300">
|
||||
<p className={isDark ? "text-slate-400" : "text-slate-500"}>
|
||||
Máx: {formatValue(stats.max, unit)}
|
||||
</p>
|
||||
|
||||
<p className="text-slate-300">
|
||||
<p className={isDark ? "text-slate-400" : "text-slate-500"}>
|
||||
Mín: {formatValue(stats.min, unit)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
{showIndicators && stats.average !== null && (
|
||||
<ReferenceLine
|
||||
y={stats.average}
|
||||
stroke="#22d3ee"
|
||||
strokeDasharray="4 4"
|
||||
strokeOpacity={0.45}
|
||||
stroke={palette.reference}
|
||||
strokeDasharray="4 5"
|
||||
strokeOpacity={0.42}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showCompareLine && stats.current !== null && (
|
||||
<ReferenceLine
|
||||
y={stats.current}
|
||||
stroke="#a78bfa"
|
||||
stroke={palette.compare}
|
||||
strokeDasharray="5 5"
|
||||
strokeOpacity={0.55}
|
||||
strokeOpacity={0.38}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -431,14 +480,14 @@ export function MeteoHistoryModal({
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="#22d3ee"
|
||||
stroke={palette.line}
|
||||
strokeWidth={2}
|
||||
fill="url(#historyFill)"
|
||||
dot={false}
|
||||
activeDot={{
|
||||
r: 5,
|
||||
fill: "#22d3ee",
|
||||
stroke: "#071120",
|
||||
fill: palette.line,
|
||||
stroke: isDark ? "#111827" : "#ffffff",
|
||||
strokeWidth: 2,
|
||||
}}
|
||||
isAnimationActive={false}
|
||||
@@ -447,13 +496,13 @@ export function MeteoHistoryModal({
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="#22d3ee"
|
||||
stroke={palette.line}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{
|
||||
r: 5,
|
||||
fill: "#22d3ee",
|
||||
stroke: "#071120",
|
||||
fill: palette.line,
|
||||
stroke: isDark ? "#111827" : "#ffffff",
|
||||
strokeWidth: 2,
|
||||
}}
|
||||
isAnimationActive={false}
|
||||
@@ -466,23 +515,31 @@ export function MeteoHistoryModal({
|
||||
</section>
|
||||
|
||||
{showIndicators && (
|
||||
<section
|
||||
className="mt-3 grid shrink-0 grid-cols-5 gap-3"
|
||||
>
|
||||
<section className="mt-3 grid shrink-0 grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<MetricCard theme={theme} title="Valor atual" value={formatValue(stats.current, unit)} sub="Agora há pouco" />
|
||||
<MetricCard theme={theme} title="Variação" value={formatSignedValue(stats.change, unit)} sub="No período" positive={(stats.change ?? 0) >= 0} />
|
||||
<MetricCard theme={theme} title="Máxima" value={formatValue(stats.max, unit)} sub="No período" />
|
||||
<MetricCard theme={theme} title="Mínima" value={formatValue(stats.min, unit)} sub="No período" />
|
||||
|
||||
<div className={cardClass(isDark)}>
|
||||
<p className="text-xs text-slate-400">Volume de Dados</p>
|
||||
<h3 className="mt-1 text-xl font-black">{stats.count.toLocaleString("pt-PT")}</h3>
|
||||
<p className="text-xs text-slate-400">pontos</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
Volume de Dados
|
||||
</p>
|
||||
<h3 className="mt-1 text-xl font-black">
|
||||
{stats.count.toLocaleString("pt-PT")}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500">
|
||||
pontos
|
||||
</p>
|
||||
|
||||
<div className="mt-2 h-9">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={miniBars}>
|
||||
<Bar dataKey="value" fill="#4f46e5" radius={[3, 3, 0, 0]} />
|
||||
<Bar
|
||||
dataKey="value"
|
||||
fill={isDark ? "#94a3b8" : "#64748b"}
|
||||
radius={[3, 3, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
@@ -495,8 +552,34 @@ export function MeteoHistoryModal({
|
||||
);
|
||||
}
|
||||
|
||||
function chartPalette(isDark: boolean) {
|
||||
return isDark
|
||||
? {
|
||||
line: "#cbd5e1",
|
||||
grid: "rgba(148,163,184,0.16)",
|
||||
axis: "#64748b",
|
||||
axisLine: "rgba(148,163,184,0.18)",
|
||||
cursor: "#64748b",
|
||||
reference: "#94a3b8",
|
||||
compare: "#64748b",
|
||||
}
|
||||
: {
|
||||
line: "#475569",
|
||||
grid: "#e2e8f0",
|
||||
axis: "#64748b",
|
||||
axisLine: "#cbd5e1",
|
||||
cursor: "#94a3b8",
|
||||
reference: "#64748b",
|
||||
compare: "#94a3b8",
|
||||
};
|
||||
}
|
||||
|
||||
function EmptyState({ children }: { children: string }) {
|
||||
return <div className="flex h-full items-center justify-center text-sm text-slate-400">{children}</div>;
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-slate-400">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IconButton({
|
||||
@@ -522,7 +605,6 @@ function MetricCard({
|
||||
title,
|
||||
value,
|
||||
sub,
|
||||
positive,
|
||||
}: {
|
||||
theme: "dark" | "light";
|
||||
title: string;
|
||||
@@ -534,36 +616,41 @@ function MetricCard({
|
||||
|
||||
return (
|
||||
<div className={cardClass(isDark)}>
|
||||
<p className="text-xs text-slate-400">{title}</p>
|
||||
<h3 className={`mt-1 text-xl font-black ${positive ? "text-emerald-400" : ""}`}>{value}</h3>
|
||||
<p className="text-xs text-slate-400">{sub}</p>
|
||||
<p className="text-xs text-slate-500">{title}</p>
|
||||
<h3 className="mt-1 text-xl font-black">{value}</h3>
|
||||
<p className="text-xs text-slate-500">{sub}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buttonClass(isDark: boolean) {
|
||||
return isDark
|
||||
? "rounded-lg border border-slate-700 px-3 py-2 text-xs font-semibold text-slate-200 hover:bg-slate-800"
|
||||
: "rounded-lg border border-slate-200 px-3 py-2 text-xs font-semibold text-slate-700 hover:bg-slate-100";
|
||||
? `${RADIUS} border border-white/10 bg-white/[0.03] px-3 py-2 text-xs font-semibold text-slate-300 transition hover:bg-white/[0.06] hover:text-slate-100`
|
||||
: `${RADIUS} border border-slate-200 bg-white px-3 py-2 text-xs font-semibold text-slate-600 transition hover:bg-slate-100 hover:text-slate-950`;
|
||||
}
|
||||
|
||||
function activeButtonClass(isDark: boolean) {
|
||||
return isDark
|
||||
? `${RADIUS} border border-white/10 bg-slate-200 px-3 py-2 text-xs font-black text-slate-950`
|
||||
: `${RADIUS} border border-slate-300 bg-slate-900 px-3 py-2 text-xs font-black text-white`;
|
||||
}
|
||||
|
||||
function iconButtonClass(isDark: boolean) {
|
||||
return isDark
|
||||
? "rounded-lg border border-slate-700 p-2 text-slate-300 hover:bg-slate-800"
|
||||
: "rounded-lg border border-slate-200 p-2 text-slate-600 hover:bg-slate-100";
|
||||
? `${RADIUS} border border-white/10 bg-white/[0.03] p-2 text-slate-400 transition hover:bg-white/[0.06] hover:text-slate-100`
|
||||
: `${RADIUS} border border-slate-200 bg-white p-2 text-slate-500 transition hover:bg-slate-100 hover:text-slate-950`;
|
||||
}
|
||||
|
||||
function toggleClass(isDark: boolean, active: boolean) {
|
||||
if (active) {
|
||||
return "rounded-lg border border-cyan-400/40 bg-cyan-400/10 px-3 py-2 text-xs font-semibold text-cyan-300";
|
||||
}
|
||||
|
||||
if (active) return activeButtonClass(isDark);
|
||||
return buttonClass(isDark);
|
||||
}
|
||||
|
||||
function toggleIconClass(isDark: boolean, active: boolean) {
|
||||
if (active) {
|
||||
return "rounded-lg border border-cyan-400/40 bg-cyan-400/10 p-2 text-cyan-300";
|
||||
return isDark
|
||||
? `${RADIUS} border border-white/10 bg-slate-200 p-2 text-slate-950`
|
||||
: `${RADIUS} border border-slate-300 bg-slate-900 p-2 text-white`;
|
||||
}
|
||||
|
||||
return iconButtonClass(isDark);
|
||||
@@ -571,8 +658,8 @@ function toggleIconClass(isDark: boolean, active: boolean) {
|
||||
|
||||
function cardClass(isDark: boolean) {
|
||||
return isDark
|
||||
? "rounded-xl border border-slate-700/60 bg-[#0a1728] p-3"
|
||||
: "rounded-xl border border-slate-200 bg-slate-50 p-3";
|
||||
? `${RADIUS} border border-white/10 bg-[#111827] p-3`
|
||||
: `${RADIUS} border border-slate-200 bg-white p-3`;
|
||||
}
|
||||
|
||||
function formatValue(value: number | null, unit: string) {
|
||||
|
||||
@@ -0,0 +1,551 @@
|
||||
import {
|
||||
CloudRain,
|
||||
Droplets,
|
||||
MapPin,
|
||||
Navigation,
|
||||
Sun,
|
||||
Thermometer,
|
||||
Wind,
|
||||
ArrowRight,
|
||||
Cloud,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type { WeatherForecastResponse } from "../../../types/weather";
|
||||
|
||||
type Props = {
|
||||
theme: "dark" | "light";
|
||||
forecast: WeatherForecastResponse | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
compact?: boolean;
|
||||
onOpenMeteo?: () => void;
|
||||
};
|
||||
|
||||
const RADIUS = "rounded-[5px]";
|
||||
|
||||
export function WeatherForecastCard({
|
||||
theme,
|
||||
forecast,
|
||||
loading,
|
||||
error,
|
||||
compact = false,
|
||||
onOpenMeteo,
|
||||
}: Props) {
|
||||
const isDark = theme === "dark";
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<CompactWeatherCard
|
||||
theme={theme}
|
||||
forecast={forecast}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onOpenMeteo={onOpenMeteo}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className={
|
||||
isDark
|
||||
? `${RADIUS} border border-white/10 bg-[#111827] p-5 shadow-[0_12px_30px_rgba(0,0,0,0.22)]`
|
||||
: `${RADIUS} border border-slate-200 bg-white p-5 shadow-[0_10px_26px_rgba(15,23,42,0.06)]`
|
||||
}
|
||||
>
|
||||
<div className="mb-5 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className={isDark ? eyebrowDark : eyebrowLight}>
|
||||
Previsão meteorológica
|
||||
</p>
|
||||
|
||||
<h2 className={isDark ? titleDark : titleLight}>
|
||||
{forecast
|
||||
? `${forecast.location.name}, ${forecast.location.country}`
|
||||
: "Meteorologia externa"}
|
||||
</h2>
|
||||
|
||||
<p className={isDark ? subtitleDark : subtitleLight}>
|
||||
<MapPin className="h-4 w-4" />
|
||||
Dados externos de previsão
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{forecast?.current.condition?.icon && (
|
||||
<img
|
||||
src={forecast.current.condition.icon}
|
||||
alt={forecast.current.condition.text}
|
||||
className="h-12 w-12 opacity-90"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<StateMessage theme={theme}>A carregar previsão meteorológica...</StateMessage>
|
||||
) : error ? (
|
||||
<StateMessage theme={theme}>{error}</StateMessage>
|
||||
) : !forecast ? (
|
||||
<StateMessage theme={theme}>Sem previsão meteorológica disponível.</StateMessage>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<TodayForecastHero
|
||||
theme={theme}
|
||||
forecast={forecast}
|
||||
/>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
{forecast.daily.slice(1, 7).map((day) => (
|
||||
<DailyForecastTile
|
||||
key={day.date}
|
||||
theme={theme}
|
||||
day={day}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TodayForecastHero({
|
||||
theme,
|
||||
forecast,
|
||||
}: {
|
||||
theme: "dark" | "light";
|
||||
forecast: WeatherForecastResponse;
|
||||
}) {
|
||||
const isDark = theme === "dark";
|
||||
|
||||
const today = forecast.daily[0];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? `${RADIUS} border border-white/10 bg-white/[0.03] p-5`
|
||||
: `${RADIUS} border border-slate-200 bg-slate-50 p-5`
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-[320px_1fr]">
|
||||
<div className="flex items-center gap-4">
|
||||
{today.condition?.icon && (
|
||||
<img
|
||||
src={today.condition.icon}
|
||||
alt={today.condition.text}
|
||||
className="h-16 w-16 shrink-0 opacity-90"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "text-xs font-bold uppercase tracking-[0.16em] text-slate-500"
|
||||
: "text-xs font-bold uppercase tracking-[0.16em] text-slate-400"
|
||||
}
|
||||
>
|
||||
Hoje
|
||||
</p>
|
||||
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "mt-1 text-sm font-semibold text-slate-400"
|
||||
: "mt-1 text-sm font-semibold text-slate-500"
|
||||
}
|
||||
>
|
||||
{formatDay(today.date)}
|
||||
</p>
|
||||
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "mt-2 text-[46px] font-black leading-none tracking-[-0.06em] text-slate-100"
|
||||
: "mt-2 text-[46px] font-black leading-none tracking-[-0.06em] text-slate-950"
|
||||
}
|
||||
>
|
||||
{Math.round(today.maxTemperatureC)}°
|
||||
<span className="ml-1 text-lg tracking-normal text-slate-500">
|
||||
/ {Math.round(today.minTemperatureC)}°
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "mt-2 text-sm font-semibold text-slate-300"
|
||||
: "mt-2 text-sm font-semibold text-slate-600"
|
||||
}
|
||||
>
|
||||
{today.condition?.text ?? "--"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<WeatherMiniStat
|
||||
theme={theme}
|
||||
icon={CloudRain}
|
||||
label="Prob. chuva"
|
||||
value={`${today.dailyRainChance}%`}
|
||||
/>
|
||||
|
||||
<WeatherMiniStat
|
||||
theme={theme}
|
||||
icon={Sun}
|
||||
label="UV"
|
||||
value={today.uv.toFixed(0)}
|
||||
/>
|
||||
|
||||
<WeatherMiniStat
|
||||
theme={theme}
|
||||
icon={Wind}
|
||||
label="Vento"
|
||||
value={`${forecast.current.windKph.toFixed(1)} km/h`}
|
||||
/>
|
||||
|
||||
<WeatherMiniStat
|
||||
theme={theme}
|
||||
icon={Droplets}
|
||||
label="Humidade"
|
||||
value={`${forecast.current.humidity}%`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DailyForecastTile({
|
||||
theme,
|
||||
day,
|
||||
}: {
|
||||
theme: "dark" | "light";
|
||||
day: WeatherForecastResponse["daily"][number];
|
||||
}) {
|
||||
const isDark = theme === "dark";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? `${RADIUS} border border-white/10 bg-white/[0.03] p-3`
|
||||
: `${RADIUS} border border-slate-200 bg-slate-50 p-3`
|
||||
}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "text-sm font-black text-slate-100"
|
||||
: "text-sm font-black text-slate-950"
|
||||
}
|
||||
>
|
||||
{formatDay(day.date)}
|
||||
</p>
|
||||
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "mt-1 truncate text-xs text-slate-400"
|
||||
: "mt-1 truncate text-xs text-slate-500"
|
||||
}
|
||||
>
|
||||
{day.condition?.text ?? "--"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{day.condition?.icon && (
|
||||
<img
|
||||
src={day.condition.icon}
|
||||
alt={day.condition.text}
|
||||
className="h-9 w-9 shrink-0 opacity-90"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "text-2xl font-black text-slate-100"
|
||||
: "text-2xl font-black text-slate-950"
|
||||
}
|
||||
>
|
||||
{Math.round(day.maxTemperatureC)}°
|
||||
<span className="ml-1 text-sm font-bold text-slate-500">
|
||||
/ {Math.round(day.minTemperatureC)}°
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between gap-2 text-xs">
|
||||
<span
|
||||
className={
|
||||
isDark
|
||||
? "flex items-center gap-1 font-semibold text-slate-400"
|
||||
: "flex items-center gap-1 font-semibold text-slate-500"
|
||||
}
|
||||
>
|
||||
<CloudRain className="h-3.5 w-3.5" />
|
||||
{day.dailyRainChance}%
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={
|
||||
isDark
|
||||
? "flex items-center gap-1 font-semibold text-slate-400"
|
||||
: "flex items-center gap-1 font-semibold text-slate-500"
|
||||
}
|
||||
>
|
||||
<Sun className="h-3.5 w-3.5" />
|
||||
UV {day.uv.toFixed(0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WeatherMiniStat({
|
||||
theme,
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
theme: "dark" | "light";
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
const isDark = theme === "dark";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? `${RADIUS} border border-white/10 bg-[#0b1220] px-3 py-2.5`
|
||||
: `${RADIUS} border border-slate-200 bg-white px-3 py-2.5`
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
className={
|
||||
isDark
|
||||
? "mb-1.5 h-4 w-4 text-slate-400"
|
||||
: "mb-1.5 h-4 w-4 text-slate-500"
|
||||
}
|
||||
/>
|
||||
|
||||
<p className="text-[11px] text-slate-500">{label}</p>
|
||||
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "mt-0.5 text-sm font-black text-slate-100"
|
||||
: "mt-0.5 text-sm font-black text-slate-950"
|
||||
}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StateMessage({
|
||||
theme,
|
||||
children,
|
||||
}: {
|
||||
theme: "dark" | "light";
|
||||
children: string;
|
||||
}) {
|
||||
const isDark = theme === "dark";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? `${RADIUS} border border-white/10 bg-white/[0.03] p-5 text-sm text-slate-400`
|
||||
: `${RADIUS} border border-slate-200 bg-slate-50 p-5 text-sm text-slate-500`
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDay(date: string) {
|
||||
return new Date(date).toLocaleDateString("pt-PT", {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
const eyebrowDark =
|
||||
"text-[11px] font-bold uppercase tracking-[0.22em] text-slate-500";
|
||||
const eyebrowLight =
|
||||
"text-[11px] font-bold uppercase tracking-[0.22em] text-slate-400";
|
||||
|
||||
const titleDark = "mt-2 text-xl font-black tracking-[-0.03em] text-slate-100";
|
||||
const titleLight = "mt-2 text-xl font-black tracking-[-0.03em] text-slate-950";
|
||||
|
||||
const subtitleDark = "mt-1 flex items-center gap-2 text-sm text-slate-400";
|
||||
const subtitleLight = "mt-1 flex items-center gap-2 text-sm text-slate-500";
|
||||
|
||||
function CompactWeatherCard({
|
||||
theme,
|
||||
forecast,
|
||||
loading,
|
||||
error,
|
||||
onOpenMeteo,
|
||||
}: {
|
||||
theme: "dark" | "light";
|
||||
forecast: WeatherForecastResponse | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onOpenMeteo?: () => void;
|
||||
}) {
|
||||
const isDark = theme === "dark";
|
||||
|
||||
const today = forecast?.daily?.[0];
|
||||
|
||||
return (
|
||||
<article
|
||||
className={
|
||||
isDark
|
||||
? "absolute right-0 top-10 w-[340px] rounded-[8px] border border-white/10 bg-[#07111f]/92 p-7 shadow-[0_22px_60px_rgba(0,0,0,0.42)] backdrop-blur-xl"
|
||||
: "absolute right-0 top-10 w-[340px] rounded-[8px] border border-slate-200/80 bg-white/96 p-7 shadow-[0_14px_40px_rgba(15,23,42,0.10)] backdrop-blur-xl"
|
||||
}
|
||||
>
|
||||
<p className="flex items-center gap-2 text-sm font-bold">
|
||||
<MapPin className="h-4 w-4" />
|
||||
|
||||
{forecast
|
||||
? `${forecast.location.name}, ${forecast.location.country}`
|
||||
: "Meteorologia"}
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<div className="mt-5 text-sm text-slate-400">
|
||||
A carregar previsão...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="mt-5 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
) : today ? (
|
||||
<>
|
||||
<div className="mt-6 flex items-start justify-between gap-5">
|
||||
<div>
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "text-xs font-bold uppercase tracking-[0.18em] text-slate-400"
|
||||
: "text-xs font-bold uppercase tracking-[0.18em] text-slate-500"
|
||||
}
|
||||
>
|
||||
Hoje
|
||||
</p>
|
||||
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "mt-1 text-sm font-semibold text-slate-300"
|
||||
: "mt-1 text-sm font-semibold text-slate-600"
|
||||
}
|
||||
>
|
||||
{formatDay(today.date)}
|
||||
</p>
|
||||
|
||||
<div className="mt-5 flex items-end">
|
||||
<span className="text-[44px] font-black leading-none tracking-[-0.06em]">
|
||||
{Math.round(today.maxTemperatureC)}°
|
||||
</span>
|
||||
|
||||
<span className="mb-1 ml-2 text-base font-bold text-slate-500">
|
||||
/ {Math.round(today.minTemperatureC)}°
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "mt-2 text-sm text-slate-300"
|
||||
: "mt-2 text-sm text-slate-700"
|
||||
}
|
||||
>
|
||||
{today.condition?.text ?? "--"}
|
||||
</p>
|
||||
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "mt-4 flex flex-wrap gap-2 text-xs text-slate-400"
|
||||
: "mt-4 flex flex-wrap gap-2 text-xs text-slate-600"
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
isDark
|
||||
? "rounded-full border border-white/10 bg-white/5 px-2 py-1"
|
||||
: "rounded-full border border-slate-200 bg-slate-100 px-2 py-1"
|
||||
}
|
||||
>
|
||||
Chuva {today.dailyRainChance}%
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={
|
||||
isDark
|
||||
? "rounded-full border border-white/10 bg-white/5 px-2 py-1"
|
||||
: "rounded-full border border-slate-200 bg-slate-100 px-2 py-1"
|
||||
}
|
||||
>
|
||||
UV {today.uv.toFixed(0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{today.condition?.icon ? (
|
||||
<img
|
||||
src={today.condition.icon}
|
||||
alt={today.condition.text}
|
||||
className="h-16 w-16 shrink-0 opacity-90"
|
||||
/>
|
||||
) : (
|
||||
<Cloud
|
||||
className={
|
||||
isDark
|
||||
? "h-16 w-16 shrink-0 text-slate-200"
|
||||
: "h-16 w-16 shrink-0 text-slate-400"
|
||||
}
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "mt-6 border-t border-white/10 pt-4"
|
||||
: "mt-6 border-t border-slate-200 pt-4"
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenMeteo}
|
||||
className="flex items-center gap-3 text-sm text-slate-500 transition hover:text-emerald-400"
|
||||
>
|
||||
Ver meteorologia
|
||||
<ArrowRight className="h-4 w-4 text-emerald-400" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="mt-5 text-sm text-slate-400">
|
||||
Sem previsão diária disponível.
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { WeatherForecastResponse } from "../../../types/weather";
|
||||
|
||||
const BACKEND_URL = "http://localhost:18450";
|
||||
|
||||
type LocationState = {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
|
||||
export function useWeatherForecast() {
|
||||
const [forecast, setForecast] = useState<WeatherForecastResponse | null>(null);
|
||||
const [location, setLocation] = useState<LocationState | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!navigator.geolocation) {
|
||||
setError("Geolocalização não suportada.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
setLocation({
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
});
|
||||
},
|
||||
() => {
|
||||
// Fallback Leiria
|
||||
setLocation({
|
||||
latitude: 39.75,
|
||||
longitude: -8.8,
|
||||
});
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: false,
|
||||
timeout: 8000,
|
||||
maximumAge: 1000 * 60 * 60 * 12,
|
||||
},
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!location) return;
|
||||
|
||||
const latitude = location.latitude;
|
||||
const longitude = location.longitude;
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
async function loadForecast() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
lat: String(latitude),
|
||||
lon: String(longitude),
|
||||
days: "7",
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/weather/forecast?${params.toString()}`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to load weather forecast");
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as WeatherForecastResponse;
|
||||
setForecast(payload);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
console.error("Failed to load weather forecast", error);
|
||||
setError("Não foi possível carregar a previsão meteorológica.");
|
||||
setForecast(null);
|
||||
} finally {
|
||||
if (!controller.signal.aborted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadForecast();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [location]);
|
||||
|
||||
return {
|
||||
forecast,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user