feat(charts): add persistent workspace chart management

This commit is contained in:
litoral05
2026-05-27 14:38:25 +01:00
parent d7ef36fc53
commit ffe3c64cfa
23 changed files with 4407 additions and 202 deletions
+58 -9
View File
@@ -1,25 +1,74 @@
import { useState } from "react"; import { useState } from "react";
import { AppShell } from "../components/layout/AppShell"; import { AppShell } from "../components/layout/AppShell";
import { DashboardPage } from "../features/dashboard/pages/DashboardPage"; import { DashboardPage } from "../features/dashboard/pages/DashboardPage";
import { MeteoPage } from "../features/meteo/pages/MeteoPage"; import { MeteoPage } from "../features/meteo/pages/MeteoPage";
import { ClimateChartsPage } from "../features/climate/pages/ClimateChartsPage";
import { ConsolePage } from "../features/console/pages/ConsolePage";
import { MainChartsPage } from "../features/maincharts/pages/MainChartsPage";
import { SettingsPage } from "../features/settings/pages/SettingsPage";
export type AppPage = "dashboard" | "meteo"; export type AppPage =
| "dashboard"
| "meteo"
| "console"
| "maincharts"
| "settings"
// CLIMATE
| "climate"
| "climateCharts"
| "climateLighting"
| "climateVentilation"
| "climateSynoptic"
// IRRIGATION
| "irrigation"
| "irrigationCharts"
| "irrigationFilters"
| "irrigationConsumption"
| "irrigationDrainage"
| "irrigationSynoptic";
function App() { function App() {
const [activePage, setActivePage] = useState<AppPage>("dashboard"); const [activePage, setActivePage] =
useState<AppPage>("dashboard");
return ( return (
<AppShell activePage={activePage} onNavigate={setActivePage}> <AppShell
{({ theme }) => activePage={activePage}
activePage === "meteo" ? ( onNavigate={setActivePage}
<MeteoPage theme={theme} /> >
) : ( {({ theme }) => {
if (activePage === "meteo") {
return <MeteoPage theme={theme} />;
}
if (activePage === "climateCharts") {
return <ClimateChartsPage theme={theme} />;
}
if (activePage === "console") {
return <ConsolePage theme={theme} />;
}
if (activePage === "maincharts") {
return <MainChartsPage theme={theme} />;
}
if (activePage === "settings") {
return <SettingsPage theme={theme} />
}
return (
<DashboardPage <DashboardPage
theme={theme} theme={theme}
onOpenMeteo={() => setActivePage("meteo")} onOpenMeteo={() => setActivePage("meteo")}
onNavigate={setActivePage}
/> />
) );
} }}
</AppShell> </AppShell>
); );
} }
File diff suppressed because it is too large Load Diff
+26
View File
@@ -296,9 +296,35 @@ function pageTitle(page: AppPage | null) {
case "dashboard": case "dashboard":
return ""; return "";
case "maincharts":
return "Gráficos Gerais";
case "meteo": case "meteo":
return "Meteorologia"; return "Meteorologia";
case "settings":
return "Configurações";
case "console":
return "Consola (VNC)";
// ALL CLIMATE PAGES
case "climate":
case "climateCharts":
case "climateLighting":
case "climateVentilation":
case "climateSynoptic":
return "Clima";
// ALL IRRIGATION / REGA PAGES
case "irrigation":
case "irrigationCharts":
case "irrigationFilters":
case "irrigationConsumption":
case "irrigationDrainage":
case "irrigationSynoptic":
return "Rega";
default: default:
return "Painel Principal"; return "Painel Principal";
} }
+56 -48
View File
@@ -28,7 +28,7 @@ type SidebarProps = {
onToggleCollapsed: () => void; onToggleCollapsed: () => void;
}; };
const RADIUS = "rounded-[10px]"; const RADIUS = "rounded-[6px]";
const navigationItems: { const navigationItems: {
label: string; label: string;
@@ -37,6 +37,7 @@ const navigationItems: {
}[] = [ }[] = [
{ label: "Painel Principal", page: "dashboard", icon: Home }, { label: "Painel Principal", page: "dashboard", icon: Home },
{ label: "Meteorologia", page: "meteo", icon: CloudSun }, { label: "Meteorologia", page: "meteo", icon: CloudSun },
{ label: "Gráficos Gerais", page: "maincharts", icon: BarChart3 }
]; ];
const climateItems = [ const climateItems = [
@@ -55,10 +56,14 @@ const irrigationItems = [
{ label: "Gráficos", icon: BarChart3 }, { label: "Gráficos", icon: BarChart3 },
]; ];
const utilityItems = [ const utilityItems: {
{ label: "Consola (VNC)", icon: TabletSmartphone }, label: string;
{ label: "Configurações", icon: Settings }, page: AppPage;
]; icon: React.ElementType;
}[] = [
{ label: "Consola (VNC)", page: "console", icon: TabletSmartphone },
{ label: "Configurações", page: "settings", icon: Settings },
];
export function Sidebar({ export function Sidebar({
theme, theme,
@@ -75,6 +80,10 @@ export function Sidebar({
const handleTreeClick = (key: string) => { const handleTreeClick = (key: string) => {
setActiveTreeItem(key); setActiveTreeItem(key);
if (key === "climate:Gráficos") {
onNavigate("climateCharts");
}
}; };
const handleTreeToggle = (section: "climate" | "irrigation") => { const handleTreeToggle = (section: "climate" | "irrigation") => {
@@ -96,28 +105,30 @@ export function Sidebar({
<aside <aside
className={ className={
isDark isDark
? `${collapsed ? "w-20" : "w-[290px]"} flex h-full flex-col border-r border-white/10 bg-[#0F172A] px-4 py-5 text-slate-100 shadow-[8px_0_30px_rgba(0,0,0,0.18)] transition-all duration-200` ? `${collapsed ? "w-20" : "w-[290px]"
: `${collapsed ? "w-20" : "w-[290px]"} flex h-full flex-col border-r border-[#D8DEE7] bg-[#F8FAFC] px-4 py-5 text-[#0F172A] shadow-[8px_0_28px_rgba(15,23,42,0.04)] transition-all duration-200` } flex h-full flex-col border-r border-[#263247] bg-[#0B1220] px-4 py-5 text-slate-100 transition-all duration-200`
: `${collapsed ? "w-20" : "w-[290px]"
} flex h-full flex-col border-r border-[#D7DEE8] bg-[#F3F6FA] px-4 py-5 text-[#0F172A] transition-all duration-200`
} }
> >
<div <div
className={ className={
collapsed collapsed
? "mb-12 flex items-center justify-center" ? "mb-10 flex items-center justify-center"
: "mb-12 flex items-center gap-4 px-1" : "mb-10 flex items-center gap-3 px-1"
} }
> >
<div <div
className={ className={
isDark isDark
? `${RADIUS} flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden border border-white/10 bg-white/[0.04] shadow-[0_10px_24px_rgba(0,0,0,0.18)]` ? `${RADIUS} flex h-12 w-12 shrink-0 items-center justify-center border border-[#2A3950] bg-[#111A2B] p-1.5`
: `${RADIUS} flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden border border-[#D8DEE7] bg-white shadow-sm` : `${RADIUS} flex h-12 w-12 shrink-0 items-center justify-center border border-[#CBD5E1] bg-white p-1.5`
} }
> >
<img <img
src={logo} src={logo}
alt="Litoral Central" alt="Litoral Central"
className="h-12 w-12 scale-[1.35] object-cover" className="h-full w-full object-contain"
/> />
</div> </div>
@@ -126,21 +137,21 @@ export function Sidebar({
<div <div
className={ className={
isDark isDark
? "truncate text-[16px] font-black tracking-[-0.02em] text-white" ? "truncate text-[15px] font-extrabold tracking-[-0.02em] text-white"
: "truncate text-[16px] font-black tracking-[-0.02em] text-[#0F172A]" : "truncate text-[15px] font-extrabold tracking-[-0.02em] text-[#0F172A]"
} }
> >
Litoral Central Litoral Central
</div> </div>
<div className="mt-1 whitespace-nowrap text-[10px] font-bold uppercase tracking-[0.1em] text-slate-500"> <div className="mt-1 whitespace-nowrap text-[10px] font-bold uppercase tracking-[0.18em] text-[#7D8EA8]">
Operações agrícolas Operações agrícolas
</div> </div>
</div> </div>
)} )}
</div> </div>
<nav className="space-y-2"> <nav className="space-y-1.5">
{navigationItems.map((item) => { {navigationItems.map((item) => {
const Icon = item.icon; const Icon = item.icon;
const active = activePage === item.page && activeTreeItem === null; const active = activePage === item.page && activeTreeItem === null;
@@ -157,9 +168,7 @@ export function Sidebar({
className={navButtonClass(isDark, active, collapsed)} className={navButtonClass(isDark, active, collapsed)}
> >
{active && <ActiveIndicator isDark={isDark} />} {active && <ActiveIndicator isDark={isDark} />}
<Icon className={navIconClass(isDark, active)} /> <Icon className={navIconClass(isDark, active)} />
{!collapsed && <span className="truncate">{item.label}</span>} {!collapsed && <span className="truncate">{item.label}</span>}
</button> </button>
); );
@@ -197,21 +206,21 @@ export function Sidebar({
{utilityItems.map((item) => { {utilityItems.map((item) => {
const Icon = item.icon; const Icon = item.icon;
const key = `utility:${item.label}`; const active = activePage === item.page && activeTreeItem === null;
const active = activeTreeItem === key;
return ( return (
<button <button
key={item.label} key={item.label}
type="button" type="button"
onClick={() => handleTreeClick(key)} onClick={() => {
setActiveTreeItem(null);
onNavigate(item.page);
}}
title={collapsed ? item.label : undefined} title={collapsed ? item.label : undefined}
className={navButtonClass(isDark, active, collapsed)} className={navButtonClass(isDark, active, collapsed)}
> >
{active && <ActiveIndicator isDark={isDark} />} {active && <ActiveIndicator isDark={isDark} />}
<Icon className={navIconClass(isDark, active)} /> <Icon className={navIconClass(isDark, active)} />
{!collapsed && <span className="truncate">{item.label}</span>} {!collapsed && <span className="truncate">{item.label}</span>}
</button> </button>
); );
@@ -223,15 +232,15 @@ export function Sidebar({
onClick={onToggleCollapsed} onClick={onToggleCollapsed}
className={ className={
isDark isDark
? `mt-auto flex items-center justify-center gap-2 ${RADIUS} border border-white/10 bg-white/[0.04] px-4 py-3.5 text-[15px] font-semibold text-slate-400 shadow-[0_10px_24px_rgba(0,0,0,0.12)] transition hover:bg-white/[0.06] hover:text-white` ? `mt-auto flex items-center justify-center gap-2 ${RADIUS} border border-[#263247] bg-[#111A2B] px-4 py-3 text-[14px] font-semibold text-[#9BAAC1] transition hover:border-[#33445F] hover:bg-[#162033] hover:text-white`
: `mt-auto flex items-center justify-center gap-2 ${RADIUS} border border-[#D8DEE7] bg-white px-4 py-3.5 text-[15px] font-semibold text-slate-500 shadow-sm transition hover:bg-[#F1F5F9] hover:text-[#0F172A]` : `mt-auto flex items-center justify-center gap-2 ${RADIUS} border border-[#CBD5E1] bg-white px-4 py-3 text-[14px] font-semibold text-slate-600 transition hover:bg-[#EAF0F7] hover:text-[#0F172A]`
} }
> >
{collapsed ? ( {collapsed ? (
<ChevronRight className="h-[22px] w-[22px]" /> <ChevronRight className="h-5 w-5" />
) : ( ) : (
<> <>
<ChevronLeft className="h-[22px] w-[22px]" /> <ChevronLeft className="h-5 w-5" />
<span>Recolher menu</span> <span>Recolher menu</span>
</> </>
)} )}
@@ -277,7 +286,6 @@ function TreeSection({
className={navButtonClass(isDark, hasActiveChild, collapsed)} className={navButtonClass(isDark, hasActiveChild, collapsed)}
> >
{hasActiveChild && <ActiveIndicator isDark={isDark} />} {hasActiveChild && <ActiveIndicator isDark={isDark} />}
<Icon className={navIconClass(isDark, hasActiveChild)} /> <Icon className={navIconClass(isDark, hasActiveChild)} />
{!collapsed && ( {!collapsed && (
@@ -285,7 +293,7 @@ function TreeSection({
<span className="min-w-0 flex-1 truncate">{label}</span> <span className="min-w-0 flex-1 truncate">{label}</span>
<ChevronDown <ChevronDown
className={`h-4 w-4 shrink-0 text-slate-500 transition-transform duration-200 ${open ? "rotate-180" : "" className={`h-4 w-4 shrink-0 text-[#6F819B] transition-transform duration-200 ${open ? "rotate-180" : ""
}`} }`}
/> />
</> </>
@@ -296,8 +304,8 @@ function TreeSection({
<div <div
className={ className={
isDark isDark
? "ml-5 mt-2 space-y-1.5 border-l border-white/10 pl-3" ? "ml-[18px] mt-2 space-y-1 border-l border-[#263247] pl-3"
: "ml-5 mt-2 space-y-1.5 border-l border-[#D8DEE7] pl-3" : "ml-[18px] mt-2 space-y-1 border-l border-[#CBD5E1] pl-3"
} }
> >
{items.map((item) => { {items.map((item) => {
@@ -313,18 +321,18 @@ function TreeSection({
className={ className={
active active
? isDark ? isDark
? "flex w-full items-center gap-2 rounded-[8px] bg-white/[0.06] px-3.5 py-2.5 text-left text-[13px] font-bold text-white" ? "flex w-full items-center gap-2 rounded-[5px] border border-[#2C3D56] bg-[#131D2F] px-3 py-2.5 text-left text-[13px] font-bold text-white"
: "flex w-full items-center gap-2 rounded-[8px] bg-white px-3.5 py-2.5 text-left text-[13px] font-bold text-[#0F172A] shadow-sm" : "flex w-full items-center gap-2 rounded-[5px] border border-[#CBD5E1] bg-white px-3 py-2.5 text-left text-[13px] font-bold text-[#0F172A]"
: isDark : isDark
? "flex w-full items-center gap-2 rounded-[8px] px-3.5 py-2.5 text-left text-[13px] font-semibold text-slate-500 transition hover:bg-white/[0.04] hover:text-slate-200" ? "flex w-full items-center gap-2 rounded-[5px] px-3 py-2.5 text-left text-[13px] font-semibold text-[#7D8EA8] transition hover:bg-[#111A2B] hover:text-slate-200"
: "flex w-full items-center gap-2 rounded-[8px] px-3.5 py-2.5 text-left text-[13px] font-semibold text-slate-500 transition hover:bg-white hover:text-[#0F172A]" : "flex w-full items-center gap-2 rounded-[5px] px-3 py-2.5 text-left text-[13px] font-semibold text-slate-500 transition hover:bg-white hover:text-[#0F172A]"
} }
> >
<SubIcon <SubIcon
className={ className={
active active
? isDark ? isDark
? "h-4 w-4 shrink-0 text-emerald-300" ? "h-4 w-4 shrink-0 text-[#4FD1C5]"
: "h-4 w-4 shrink-0 text-[#0F766E]" : "h-4 w-4 shrink-0 text-[#0F766E]"
: "h-4 w-4 shrink-0" : "h-4 w-4 shrink-0"
} }
@@ -352,8 +360,8 @@ function SectionLabel({
} }
return ( return (
<div className="py-3"> <div className="pb-1 pt-5">
<p className="px-4 text-[11px] font-bold uppercase tracking-[0.18em] text-slate-500"> <p className="px-4 text-[10px] font-black uppercase tracking-[0.2em] text-[#61738C]">
{label} {label}
</p> </p>
</div> </div>
@@ -365,8 +373,8 @@ function ActiveIndicator({ isDark }: { isDark: boolean }) {
<span <span
className={ className={
isDark isDark
? "absolute left-0 top-1/2 h-7 w-1 -translate-y-1/2 rounded-r-full bg-emerald-400" ? "absolute left-0 top-1/2 h-6 w-[3px] -translate-y-1/2 rounded-r-sm bg-[#4FD1C5]"
: "absolute left-0 top-1/2 h-7 w-1 -translate-y-1/2 rounded-r-full bg-[#0F766E]" : "absolute left-0 top-1/2 h-6 w-[3px] -translate-y-1/2 rounded-r-sm bg-[#0F766E]"
} }
/> />
); );
@@ -377,23 +385,23 @@ function navButtonClass(isDark: boolean, active: boolean, collapsed: boolean) {
if (active) { if (active) {
return isDark return isDark
? `relative flex w-full items-center gap-3 ${RADIUS} border border-emerald-400/20 bg-[#162033] ${alignment} py-[14px] text-left text-[15px] font-black text-white shadow-[0_12px_28px_rgba(0,0,0,0.22)]` ? `relative flex w-full items-center gap-3 ${RADIUS} border border-[#2C3D56] bg-[#131D2F] ${alignment} py-3.5 text-left text-[14px] font-extrabold text-white`
: `relative flex w-full items-center gap-3 ${RADIUS} border border-[#D8DEE7] bg-white ${alignment} py-[14px] text-left text-[15px] font-black text-[#0F172A] shadow-[0_8px_20px_rgba(15,23,42,0.06)]`; : `relative flex w-full items-center gap-3 ${RADIUS} border border-[#CBD5E1] bg-white ${alignment} py-3.5 text-left text-[14px] font-extrabold text-[#0F172A]`;
} }
return isDark return isDark
? `flex w-full items-center gap-3 ${RADIUS} ${alignment} py-[14px] text-left text-[15px] font-semibold text-slate-400 transition hover:bg-white/[0.04] hover:text-white` ? `relative flex w-full items-center gap-3 ${RADIUS} ${alignment} py-3.5 text-left text-[14px] font-semibold text-[#8EA0BA] transition hover:bg-[#111A2B] hover:text-white`
: `flex w-full items-center gap-3 ${RADIUS} ${alignment} py-[14px] text-left text-[15px] font-semibold text-slate-600 transition hover:bg-white hover:text-[#0F172A] hover:shadow-sm`; : `relative flex w-full items-center gap-3 ${RADIUS} ${alignment} py-3.5 text-left text-[14px] font-semibold text-slate-600 transition hover:bg-white hover:text-[#0F172A]`;
} }
function navIconClass(isDark: boolean, active: boolean) { function navIconClass(isDark: boolean, active: boolean) {
if (active) { if (active) {
return isDark return isDark
? "h-[22px] w-[22px] shrink-0 text-emerald-300" ? "h-5 w-5 shrink-0 text-[#4FD1C5]"
: "h-[22px] w-[22px] shrink-0 text-[#0F766E]"; : "h-5 w-5 shrink-0 text-[#0F766E]";
} }
return isDark return isDark
? "h-[22px] w-[22px] shrink-0 text-slate-500" ? "h-5 w-5 shrink-0 text-[#6F819B]"
: "h-[22px] w-[22px] shrink-0 text-slate-500"; : "h-5 w-5 shrink-0 text-slate-500";
} }
@@ -0,0 +1,360 @@
import { useState } from "react";
import {
BarChart3,
ChevronDown,
Grid2X2,
LayoutGrid,
MoreVertical,
Square,
Thermometer,
Waves,
Wind,
} from "lucide-react";
import {
WorkspaceChart,
type WorkspaceChartConfig,
type WorkspaceChartMode,
} from "../../../components/charts/WorkspaceChart";
type ClimateChartsPageProps = {
theme: "dark" | "light";
};
type ChartWindow = WorkspaceChartConfig & {
detached: boolean;
position: { x: number; y: number };
};
const RADIUS = "rounded-[5px]";
export function ClimateChartsPage({ theme }: ClimateChartsPageProps) {
const isDark = theme === "dark";
const [charts, setCharts] = useState<ChartWindow[]>([
{
id: "humidity-temperature",
title: "Temperatura / Humidade",
subtitle: "Clima - Estufa Principal",
sourceLabel: "Estufa Principal",
periodLabel: "24H",
icon: Thermometer,
status: "online",
mode: "line",
variables: [
{
key: "temperature",
label: "Temperatura",
unit: "°C",
color: "#f97316",
visible: true,
data: [18, 18.4, 19, 20.2, 21, 22.4, 24, 25.1, 25.6, 24.9, 23.6, 22],
},
{
key: "humidity",
label: "Humidade",
unit: "%",
color: "#0ea5e9",
visible: true,
data: [82, 80, 78, 75, 72, 69, 66, 64, 63, 66, 70, 75],
},
],
detached: false,
position: { x: 360, y: 140 },
},
{
id: "ventilation",
title: "Ventilação",
subtitle: "Ventiladores e janelas",
sourceLabel: "Zona Norte",
periodLabel: "24H",
icon: Wind,
status: "online",
mode: "area",
variables: [
{
key: "fanSpeed",
label: "Velocidade Ventilação",
unit: "%",
color: "#22c55e",
visible: true,
data: [10, 12, 18, 24, 35, 48, 62, 70, 68, 51, 32, 18],
},
{
key: "windowOpening",
label: "Abertura Janelas",
unit: "%",
color: "#a78bfa",
visible: true,
data: [0, 0, 5, 12, 25, 40, 58, 65, 61, 45, 20, 5],
},
],
detached: false,
position: { x: 460, y: 180 },
},
{
id: "lighting",
title: "Iluminação",
subtitle: "Ciclo de luz",
sourceLabel: "Setor 1",
periodLabel: "24H",
icon: BarChart3,
status: "online",
mode: "bar",
variables: [
{
key: "lux",
label: "Luminosidade",
unit: "lux",
color: "#facc15",
visible: true,
data: [0, 0, 120, 450, 800, 1100, 1200, 980, 600, 220, 0, 0],
},
],
detached: false,
position: { x: 520, y: 220 },
},
{
id: "pressure-flow-example",
title: "Pressão / Fluxo",
subtitle: "Linha técnica",
sourceLabel: "Linha Principal",
periodLabel: "24H",
icon: Waves,
status: "online",
mode: "line",
variables: [
{
key: "pressure",
label: "Pressão",
unit: "bar",
color: "#4ade80",
visible: true,
data: [2.8, 3, 3.1, 3.15, 3.08, 3.05, 2.95, 2.86, 2.92, 3.0, 3.15, 2.96],
},
{
key: "flow",
label: "Caudal",
unit: "m³/h",
color: "#38bdf8",
visible: false,
data: [2, 2.4, 2.7, 3.1, 18, 20, 13, 16, 18, 15, 23, 5],
},
],
detached: false,
position: { x: 600, y: 260 },
},
]);
const updateChart = (id: string, update: Partial<ChartWindow>) => {
setCharts((current) =>
current.map((chart) =>
chart.id === id ? { ...chart, ...update } : chart,
),
);
};
const updateChartMode = (id: string, mode: WorkspaceChartMode) => {
updateChart(id, { mode });
};
const toggleVariable = (id: string, variableKey: string) => {
setCharts((current) =>
current.map((chart) =>
chart.id !== id
? chart
: {
...chart,
variables: chart.variables.map((variable) =>
variable.key === variableKey
? { ...variable, visible: !variable.visible }
: variable,
),
},
),
);
};
return (
<div className="relative min-h-full pb-6">
<div className="mb-4 flex items-center justify-between gap-4">
<div className="flex items-center gap-4">
<h1
className={
isDark
? "text-base font-black tracking-[-0.02em] text-slate-100"
: "text-base font-black tracking-[-0.02em] text-slate-950"
}
>
Área de trabalho de gráficos
</h1>
<button
type="button"
className={
isDark
? `${RADIUS} flex h-9 items-center gap-2 border border-white/10 bg-[#0b1220] px-3 text-xs font-semibold text-slate-400`
: `${RADIUS} flex h-9 items-center gap-2 border border-slate-200 bg-white px-3 text-xs font-semibold text-slate-600`
}
>
Layout: 4 janelas
<ChevronDown className="h-4 w-4" />
</button>
</div>
<div className="flex items-center gap-2">
<ToolbarButton active icon={Grid2X2} theme={theme} />
<ToolbarButton icon={LayoutGrid} theme={theme} />
<ToolbarButton icon={Square} theme={theme} />
<button
type="button"
className={`${RADIUS} flex h-9 items-center gap-2 bg-sky-500 px-3 text-xs font-bold text-white shadow-[0_10px_24px_rgba(14,165,233,0.25)]`}
>
Adicionar gráfico
<ChevronDown className="h-4 w-4" />
</button>
<button
type="button"
className={
isDark
? `${RADIUS} grid h-9 w-9 place-items-center border border-white/10 bg-[#0b1220] text-slate-400`
: `${RADIUS} grid h-9 w-9 place-items-center border border-slate-200 bg-white text-slate-500`
}
>
<MoreVertical className="h-4 w-4" />
</button>
</div>
</div>
<div className="grid gap-4 xl:grid-cols-2">
{charts
.filter((chart) => !chart.detached)
.map((chart) => (
<WorkspaceChart
key={chart.id}
theme={theme}
chart={chart}
onModeChange={(mode) => updateChartMode(chart.id, mode)}
onVariableToggle={(variableKey) =>
toggleVariable(chart.id, variableKey)
}
onDetach={() => updateChart(chart.id, { detached: true })}
onAttach={() => updateChart(chart.id, { detached: false })}
/>
))}
</div>
{charts
.filter((chart) => chart.detached)
.map((chart) => (
<DetachedChartWindow
key={chart.id}
theme={theme}
chart={chart}
onMove={(position) => updateChart(chart.id, { position })}
onAttach={() => updateChart(chart.id, { detached: false })}
onModeChange={(mode) => updateChartMode(chart.id, mode)}
onVariableToggle={(variableKey) =>
toggleVariable(chart.id, variableKey)
}
/>
))}
</div>
);
}
function DetachedChartWindow({
theme,
chart,
onMove,
onAttach,
onModeChange,
onVariableToggle,
}: {
theme: "dark" | "light";
chart: ChartWindow;
onMove: (position: { x: number; y: number }) => void;
onAttach: () => void;
onModeChange: (mode: WorkspaceChartMode) => void;
onVariableToggle: (variableKey: string) => void;
}) {
const width = 980;
const startDrag = (event: React.PointerEvent) => {
event.preventDefault();
const startX = event.clientX;
const startY = event.clientY;
const initial = chart.position;
const handleMove = (moveEvent: PointerEvent) => {
const nextX = initial.x + moveEvent.clientX - startX;
const nextY = initial.y + moveEvent.clientY - startY;
onMove({
x: nextX,
y: Math.min(Math.max(0, nextY), window.innerHeight - 72),
});
};
const handleUp = () => {
window.removeEventListener("pointermove", handleMove);
window.removeEventListener("pointerup", handleUp);
};
window.addEventListener("pointermove", handleMove);
window.addEventListener("pointerup", handleUp);
};
return (
<div
className="fixed z-50"
style={{
left: chart.position.x,
top: chart.position.y,
width,
}}
>
<WorkspaceChart
theme={theme}
chart={chart}
detached
onHeaderPointerDown={startDrag}
onModeChange={onModeChange}
onVariableToggle={onVariableToggle}
onDetach={onAttach}
onAttach={onAttach}
/>
</div>
);
}
function ToolbarButton({
icon: Icon,
active,
theme,
}: {
icon: React.ElementType;
active?: boolean;
theme: "dark" | "light";
}) {
const isDark = theme === "dark";
return (
<button
type="button"
className={
active
? `${RADIUS} grid h-9 w-9 place-items-center border border-sky-500/30 bg-sky-500/15 text-sky-400`
: isDark
? `${RADIUS} grid h-9 w-9 place-items-center border border-white/10 bg-[#0b1220] text-slate-500 hover:text-slate-200`
: `${RADIUS} grid h-9 w-9 place-items-center border border-slate-200 bg-white text-slate-500 hover:text-slate-900`
}
>
<Icon className="h-4 w-4" />
</button>
);
}
export default ClimateChartsPage;
+267
View File
@@ -0,0 +1,267 @@
import {
Monitor,
ShieldCheck,
Wifi,
Wrench,
} from "lucide-react";
type ConsolePageProps = {
theme: "dark" | "light";
};
const RADIUS = "rounded-[6px]";
export function ConsolePage({ theme }: ConsolePageProps) {
const isDark = theme === "dark";
return (
<div
className={
isDark
? "flex h-full flex-col bg-[#07101B] text-white"
: "flex h-full flex-col bg-[#F3F6FA] text-[#0F172A]"
}
>
<div className="flex items-center justify-between px-10 pb-6 pt-8">
<div>
<p
className={
isDark
? "text-xs font-black uppercase tracking-[0.24em] text-[#7F8CA3]"
: "text-xs font-black uppercase tracking-[0.24em] text-slate-500"
}
>
Consola remota
</p>
<h1
className={
isDark
? "mt-3 text-[34px] font-black tracking-[-0.04em] text-white"
: "mt-3 text-[34px] font-black tracking-[-0.04em] text-[#0F172A]"
}
>
Consola VNC
</h1>
<p
className={
isDark
? "mt-3 max-w-[680px] text-sm leading-7 text-[#A8B3C7]"
: "mt-3 max-w-[680px] text-sm leading-7 text-slate-600"
}
>
Aceda remotamente ao controlador da instalação e acompanhe
o estado operacional em tempo real.
</p>
</div>
<div
className={
isDark
? `${RADIUS} border border-[#263247] bg-[#0E1726] px-5 py-3 text-sm font-bold text-[#4FD1C5]`
: `${RADIUS} border border-[#D7DEE8] bg-white px-5 py-3 text-sm font-bold text-[#0F766E]`
}
>
Em desenvolvimento
</div>
</div>
<div className="grid flex-1 grid-cols-[380px_minmax(0,1fr)] gap-6 px-10 pb-10">
<aside
className={
isDark
? `${RADIUS} border border-[#263247] bg-[#0E1726] p-6`
: `${RADIUS} border border-[#D7DEE8] bg-white p-6`
}
>
<div className="space-y-5">
<StatusCard
theme={theme}
icon={<Wifi className="h-5 w-5" />}
title="Ligação"
value="Desconectado"
color="blue"
/>
<StatusCard
theme={theme}
icon={<ShieldCheck className="h-5 w-5" />}
title="Estado"
value="Seguro"
color="green"
/>
<StatusCard
theme={theme}
icon={<Wrench className="h-5 w-5" />}
title="Controlador"
value="Aguardando sessão"
color="purple"
/>
</div>
<div
className={
isDark
? "mt-8 border-t border-[#263247] pt-6"
: "mt-8 border-t border-[#D7DEE8] pt-6"
}
>
<button
type="button"
disabled
className={
isDark
? `flex h-[54px] w-full items-center justify-center gap-3 ${RADIUS} border border-[#2A3950] bg-[#111A2B] text-sm font-extrabold text-[#7F8CA3]`
: `flex h-[54px] w-full items-center justify-center gap-3 ${RADIUS} border border-[#CBD5E1] bg-[#F8FAFC] text-sm font-extrabold text-slate-500`
}
>
<Monitor className="h-5 w-5" />
Iniciar Sessão VNC
</button>
<p
className={
isDark
? "mt-4 text-center text-xs leading-6 text-[#7F8CA3]"
: "mt-4 text-center text-xs leading-6 text-slate-500"
}
>
O módulo VNC será integrado futuramente para acesso remoto
completo à instalação.
</p>
</div>
</aside>
<section
className={
isDark
? `${RADIUS} relative overflow-hidden border border-[#263247] bg-[#0B1220]`
: `${RADIUS} relative overflow-hidden border border-[#D7DEE8] bg-white`
}
>
<div
className={
isDark
? "absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(79,209,197,0.10),transparent_60%)]"
: "absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(15,118,110,0.08),transparent_60%)]"
}
/>
<div className="relative flex h-full flex-col items-center justify-center px-10">
<div
className={
isDark
? "grid h-24 w-24 place-items-center rounded-2xl border border-[#2A3950] bg-[#111A2B]"
: "grid h-24 w-24 place-items-center rounded-2xl border border-[#CBD5E1] bg-[#F8FAFC]"
}
>
<Monitor
className={
isDark
? "h-12 w-12 text-[#4FD1C5]"
: "h-12 w-12 text-[#0F766E]"
}
/>
</div>
<h2
className={
isDark
? "mt-8 text-2xl font-black tracking-[-0.03em] text-white"
: "mt-8 text-2xl font-black tracking-[-0.03em] text-[#0F172A]"
}
>
Consola indisponível
</h2>
<p
className={
isDark
? "mt-4 max-w-[560px] text-center text-sm leading-7 text-[#A8B3C7]"
: "mt-4 max-w-[560px] text-center text-sm leading-7 text-slate-600"
}
>
Esta área irá permitir visualização e controlo remoto do
sistema através de ligação VNC segura diretamente pela
plataforma.
</p>
</div>
</section>
</div>
</div>
);
}
function StatusCard({
theme,
icon,
title,
value,
color,
}: {
theme: "dark" | "light";
icon: React.ReactNode;
title: string;
value: string;
color: "green" | "blue" | "purple";
}) {
const isDark = theme === "dark";
const colors = {
green: isDark
? "bg-[#13202F] text-[#4FD1C5]"
: "bg-[#ECFDF5] text-[#0F766E]",
blue: isDark
? "bg-[#13202F] text-[#7DD3FC]"
: "bg-[#EFF6FF] text-[#0369A1]",
purple: isDark
? "bg-[#171B2B] text-[#A5B4FC]"
: "bg-[#EEF2FF] text-[#4F46E5]",
};
return (
<div
className={
isDark
? `${RADIUS} border border-[#263247] bg-[#111A2B] p-4`
: `${RADIUS} border border-[#D7DEE8] bg-[#F8FAFC] p-4`
}
>
<div className="flex items-center gap-4">
<div
className={`grid h-12 w-12 place-items-center ${RADIUS} ${colors[color]}`}
>
{icon}
</div>
<div>
<p
className={
isDark
? "text-xs font-bold uppercase tracking-[0.18em] text-[#7F8CA3]"
: "text-xs font-bold uppercase tracking-[0.18em] text-slate-500"
}
>
{title}
</p>
<h3
className={
isDark
? "mt-1 text-sm font-black text-white"
: "mt-1 text-sm font-black text-[#0F172A]"
}
>
{value}
</h3>
</div>
</div>
</div>
);
}
export default ConsolePage;
+121 -51
View File
@@ -1,11 +1,10 @@
import { import {
ArrowRight, ArrowRight,
Cloud, BarChart3,
Leaf, CloudSun,
MapPin,
ShieldCheck, ShieldCheck,
Sprout, Sprout,
Sun, TabletSmartphone,
Users, Users,
} from "lucide-react"; } from "lucide-react";
import backgroundImage from "../../../assets/background.png"; import backgroundImage from "../../../assets/background.png";
@@ -13,15 +12,21 @@ import farmdrawImage from "../../../assets/farmdraw.png";
import farmdrawWhiteImage from "../../../assets/farm-draw.png"; import farmdrawWhiteImage from "../../../assets/farm-draw.png";
import { WeatherForecastCard } from "../../meteo/components/WeatherForecastCard"; import { WeatherForecastCard } from "../../meteo/components/WeatherForecastCard";
import { useWeatherForecast } from "../../meteo/hooks/useWeatherForecast"; import { useWeatherForecast } from "../../meteo/hooks/useWeatherForecast";
import type { AppPage } from "../../../app/App";
type DashboardPageProps = { type DashboardPageProps = {
theme: "dark" | "light"; theme: "dark" | "light";
onOpenMeteo: () => void; onOpenMeteo: () => void;
onNavigate: (page: AppPage) => void;
}; };
const RADIUS = "rounded-[5px]"; const RADIUS = "rounded-[6px]";
export function DashboardPage({ theme, onOpenMeteo }: DashboardPageProps) { export function DashboardPage({
theme,
onOpenMeteo,
onNavigate,
}: DashboardPageProps) {
const isDark = theme === "dark"; const isDark = theme === "dark";
const weather = useWeatherForecast(); const weather = useWeatherForecast();
@@ -30,7 +35,7 @@ export function DashboardPage({ theme, onOpenMeteo }: DashboardPageProps) {
className={ className={
isDark isDark
? "relative h-full overflow-hidden text-slate-100" ? "relative h-full overflow-hidden text-slate-100"
: "relative h-full overflow-hidden text-slate-950" : "relative h-full overflow-hidden text-[#0F172A]"
} }
> >
<img <img
@@ -42,16 +47,16 @@ export function DashboardPage({ theme, onOpenMeteo }: DashboardPageProps) {
<div <div
className={ className={
isDark isDark
? "absolute inset-0 bg-[#0B1220]/34" ? "absolute inset-0 bg-[#07101B]/62"
: "absolute inset-0 bg-white/8" : "absolute inset-0 bg-white/20"
} }
/> />
<div <div
className={ className={
isDark 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,#07101B_0%,#07101B_18%,rgba(7,16,27,0.96)_30%,rgba(7,16,27,0.82)_48%,rgba(7,16,27,0.35)_72%,rgba(7,16,27,0.06)_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%)]" : "absolute inset-0 bg-[linear-gradient(90deg,#ffffff_0%,rgba(255,255,255,0.98)_14%,rgba(255,255,255,0.82)_32%,rgba(255,255,255,0.42)_54%,rgba(255,255,255,0.10)_78%,rgba(255,255,255,0)_100%)]"
} }
/> />
@@ -62,20 +67,28 @@ export function DashboardPage({ theme, onOpenMeteo }: DashboardPageProps) {
className={ className={
isDark isDark
? "text-[52px] font-black leading-[1.03] tracking-[-0.06em] text-white" ? "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" : "text-[52px] font-black leading-[1.03] tracking-[-0.06em] text-[#0F172A]"
} }
> >
Bem-vindo ao Bem-vindo ao
<br /> <br />
<span className="text-emerald-400">Litoral Central</span> <span className={isDark ? "text-[#4FD1C5]" : "text-[#0F766E]"}>
Litoral Central
</span>
</h1> </h1>
<div className="mt-7 h-[3px] w-12 bg-emerald-400" /> <div
className={
isDark
? "mt-7 h-[2px] w-14 bg-[#4FD1C5]"
: "mt-7 h-[2px] w-14 bg-[#0F766E]"
}
/>
<p <p
className={ className={
isDark isDark
? "mt-7 max-w-[500px] text-[15px] leading-7 text-slate-300" ? "mt-7 max-w-[500px] text-[15px] leading-7 text-[#A8B3C7]"
: "mt-7 max-w-[500px] text-[15px] leading-7 text-slate-700" : "mt-7 max-w-[500px] text-[15px] leading-7 text-slate-700"
} }
> >
@@ -87,11 +100,20 @@ export function DashboardPage({ theme, onOpenMeteo }: DashboardPageProps) {
<button <button
type="button" type="button"
onClick={onOpenMeteo} 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" className={
}`} isDark
? `mt-8 inline-flex h-[52px] items-center gap-5 ${RADIUS} border border-[#2A3950] bg-[#111A2B] px-7 text-sm font-extrabold text-white transition hover:border-[#36506D] hover:bg-[#162235]`
: `mt-8 inline-flex h-[52px] items-center gap-5 ${RADIUS} border border-[#CBD5E1] bg-white px-7 text-sm font-extrabold text-[#0F172A] transition hover:bg-[#F8FAFC]`
}
> >
Explorar plataforma Explorar plataforma
<ArrowRight className="h-5 w-5 text-emerald-400" /> <ArrowRight
className={
isDark
? "h-5 w-5 text-[#4FD1C5]"
: "h-5 w-5 text-[#0F766E]"
}
/>
</button> </button>
</div> </div>
@@ -108,34 +130,49 @@ export function DashboardPage({ theme, onOpenMeteo }: DashboardPageProps) {
<section className="grid min-h-0 grid-cols-3 gap-5"> <section className="grid min-h-0 grid-cols-3 gap-5">
<InfoCard <InfoCard
theme={theme} theme={theme}
icon={<Sprout className="h-7 w-7" />} icon={<CloudSun className="h-7 w-7" />}
iconClass="bg-emerald-500/10 text-emerald-400" iconClass={
title="Gestão inteligente" isDark
text="Monitorize e controle todos os seus sistemas de forma centralizada." ? "bg-[#13202F] text-[#4FD1C5]"
: "bg-[#ECFDF5] text-[#0F766E]"
}
title="Meteorologia"
text="Consulte previsões, vento, radiação solar e condições meteorológicas."
onClick={() => onNavigate("meteo")}
/> />
<InfoCard <InfoCard
theme={theme} theme={theme}
icon={<Leaf className="h-7 w-7" />} icon={<TabletSmartphone className="h-7 w-7" />}
iconClass="bg-sky-500/10 text-sky-400" iconClass={
title="Eficiência hídrica" isDark
text="Otimize o uso de água e promova uma agricultura sustentável." ? "bg-[#13202F] text-[#7DD3FC]"
: "bg-[#EFF6FF] text-[#0369A1]"
}
title="Consola VNC"
text="Aceda remotamente ao controlador e acompanhe a instalação em tempo real."
onClick={() => onNavigate("console")}
/> />
<InfoCard <InfoCard
theme={theme} theme={theme}
icon={<Sun className="h-7 w-7" />} icon={<BarChart3 className="h-7 w-7" />}
iconClass="bg-yellow-400/10 text-yellow-300" iconClass={
title="Decisões informadas" isDark
text="Dados meteorológicos e insights para melhores decisões no campo." ? "bg-[#171B2B] text-[#A5B4FC]"
: "bg-[#EEF2FF] text-[#4F46E5]"
}
title="Gráficos Climáticos"
text="Visualize históricos, tendências e análise detalhada dos sensores."
onClick={() => onNavigate("climateCharts")}
/> />
</section> </section>
<section <section
className={ className={
isDark isDark
? `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-[#263247] bg-[#0E1726]/92 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` : `min-h-0 overflow-visible ${RADIUS} border border-[#D7DEE8] bg-white p-8 shadow-[0_14px_40px_rgba(15,23,42,0.06)] backdrop-blur-md`
} }
> >
<div className="grid h-full grid-cols-[minmax(0,0.9fr)_minmax(620px,1.1fr)] items-center gap-2"> <div className="grid h-full grid-cols-[minmax(0,0.9fr)_minmax(620px,1.1fr)] items-center gap-2">
@@ -143,8 +180,8 @@ export function DashboardPage({ theme, onOpenMeteo }: DashboardPageProps) {
<p <p
className={ className={
isDark isDark
? "text-xs font-bold uppercase tracking-[0.28em] text-slate-400" ? "text-xs font-bold uppercase tracking-[0.28em] text-[#7F8CA3]"
: "text-xs font-bold uppercase tracking-[0.28em] text-emerald-700" : "text-xs font-bold uppercase tracking-[0.28em] text-[#0F766E]"
} }
> >
A Nossa Missão A Nossa Missão
@@ -154,13 +191,13 @@ export function DashboardPage({ theme, onOpenMeteo }: DashboardPageProps) {
className={ className={
isDark isDark
? "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-white"
: "mt-4 max-w-[640px] text-[30px] font-black leading-tight tracking-[-0.045em] text-slate-950" : "mt-4 max-w-[640px] text-[30px] font-black leading-tight tracking-[-0.045em] text-[#0F172A]"
} }
> >
Soluções inovadoras para Soluções inovadoras para
<br /> <br />
uma{" "} uma{" "}
<span className="text-emerald-400"> <span className={isDark ? "text-[#4FD1C5]" : "text-[#0F766E]"}>
agricultura sustentável agricultura sustentável
</span> </span>
</h2> </h2>
@@ -168,7 +205,7 @@ export function DashboardPage({ theme, onOpenMeteo }: DashboardPageProps) {
<p <p
className={ className={
isDark isDark
? "mt-4 max-w-[560px] text-sm leading-6 text-slate-400" ? "mt-4 max-w-[560px] text-sm leading-6 text-[#7F8CA3]"
: "mt-4 max-w-[560px] text-sm leading-6 text-slate-600" : "mt-4 max-w-[560px] text-sm leading-6 text-slate-600"
} }
> >
@@ -179,8 +216,8 @@ export function DashboardPage({ theme, onOpenMeteo }: DashboardPageProps) {
<div <div
className={ className={
isDark 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-[#263247] pt-6"
: "mt-6 grid grid-cols-3 gap-5 border-t border-slate-200 pt-6" : "mt-6 grid grid-cols-3 gap-5 border-t border-[#D7DEE8] pt-6"
} }
> >
<MissionItem <MissionItem
@@ -208,7 +245,13 @@ export function DashboardPage({ theme, onOpenMeteo }: DashboardPageProps) {
</div> </div>
</section> </section>
<footer className="flex min-h-0 items-center justify-between px-56 text-xs text-slate-500"> <footer
className={
isDark
? "flex min-h-0 items-center justify-between px-56 text-xs text-[#7F8CA3]"
: "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>© 2026 Litoral Central. Todos os direitos reservados.</span>
<span> <span>
Feito em Portugal <span className="ml-2">🇵🇹</span> Feito em Portugal <span className="ml-2">🇵🇹</span>
@@ -225,21 +268,25 @@ function InfoCard({
iconClass, iconClass,
title, title,
text, text,
onClick,
}: { }: {
theme: "dark" | "light"; theme: "dark" | "light";
icon: React.ReactNode; icon: React.ReactNode;
iconClass: string; iconClass: string;
title: string; title: string;
text: string; text: string;
onClick?: () => void;
}) { }) {
const isDark = theme === "dark"; const isDark = theme === "dark";
return ( return (
<article <button
type="button"
onClick={onClick}
className={ className={
isDark isDark
? `${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 w-full items-center justify-between border border-[#263247] bg-[#0E1726]/92 p-5 text-left shadow-[0_18px_50px_rgba(0,0,0,0.24)] backdrop-blur-md transition hover:border-[#36506D] hover:bg-[#132033]`
: `${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` : `${RADIUS} flex h-full w-full items-center justify-between border border-[#D7DEE8] bg-white p-5 text-left shadow-[0_8px_24px_rgba(15,23,42,0.05)] backdrop-blur-md transition hover:bg-[#F8FAFC]`
} }
> >
<div className="flex items-center gap-5"> <div className="flex items-center gap-5">
@@ -250,11 +297,20 @@ function InfoCard({
</div> </div>
<div> <div>
<h3 className="text-base font-black">{title}</h3> <h3
className={
isDark
? "text-base font-black text-white"
: "text-base font-black text-[#0F172A]"
}
>
{title}
</h3>
<p <p
className={ className={
isDark isDark
? "mt-2 max-w-[280px] text-sm leading-5 text-slate-400" ? "mt-2 max-w-[280px] text-sm leading-5 text-[#7F8CA3]"
: "mt-2 max-w-[280px] text-sm leading-5 text-slate-600" : "mt-2 max-w-[280px] text-sm leading-5 text-slate-600"
} }
> >
@@ -264,9 +320,13 @@ function InfoCard({
</div> </div>
<ArrowRight <ArrowRight
className={isDark ? "h-5 w-5 text-slate-400" : "h-5 w-5 text-emerald-500"} className={
isDark
? "h-5 w-5 shrink-0 text-[#4FD1C5]"
: "h-5 w-5 shrink-0 text-[#0F766E]"
}
/> />
</article> </button>
); );
} }
@@ -285,13 +345,23 @@ function MissionItem({
return ( return (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="h-7 w-7 text-emerald-400">{icon}</div> <div className={isDark ? "h-7 w-7 text-[#4FD1C5]" : "h-7 w-7 text-[#0F766E]"}>
{icon}
</div>
<div> <div>
<h4 className="text-sm font-black">{title}</h4> <h4
className={
isDark
? "text-sm font-black text-white"
: "text-sm font-black text-[#0F172A]"
}
>
{title}
</h4>
<p <p
className={ className={
isDark isDark
? "mt-1 text-xs text-slate-400" ? "mt-1 text-xs text-[#7F8CA3]"
: "mt-1 text-xs text-slate-600" : "mt-1 text-xs text-slate-600"
} }
> >
@@ -312,7 +382,7 @@ function FarmIllustration({ theme }: { theme: "dark" | "light" }) {
alt="" alt=""
className={ className={
isDark 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-90"
: "absolute right-[-70px] top-1/2 h-[380px] max-w-none -translate-y-1/2 opacity-100" : "absolute right-[-70px] top-1/2 h-[380px] max-w-none -translate-y-1/2 opacity-100"
} }
/> />
@@ -0,0 +1,167 @@
import { useEffect, useRef, useState } from "react";
import type {
WorkspaceChartInterval,
WorkspaceChartMode,
WorkspaceChartTimeRange,
} from "../../../components/charts/WorkspaceChart";
const API_BASE_URL = "http://localhost:18450";
const SAVE_DEBOUNCE_MS = 800;
export type ChartLayoutMode =
| "single"
| "twoColumns"
| "twoRows"
| "fourGrid";
export type PersistedChartWorkspaceItem = {
id: string;
title: string;
subtitle: string;
mode: WorkspaceChartMode;
selectedSensorKeys: string[];
timeRange: WorkspaceChartTimeRange;
interval: WorkspaceChartInterval;
};
type ChartWorkspaceScope =
| "GLOBAL"
| "CLIMATE"
| "IRRIGATION"
| "METEO"
| "LIGHTING"
| "HYDRO"
| "AEROPONICS";
type ChartWorkspaceResponse = {
id: number;
scope: ChartWorkspaceScope;
layoutMode: ChartLayoutMode;
chartsJson: string;
createdAt: string;
updatedAt: string;
};
type UseChartWorkspacePersistenceParams = {
scope: ChartWorkspaceScope;
layoutMode: ChartLayoutMode;
charts: PersistedChartWorkspaceItem[];
onLoaded: (workspace: {
layoutMode: ChartLayoutMode;
charts: PersistedChartWorkspaceItem[];
}) => void;
};
export function useChartWorkspacePersistence({
scope,
layoutMode,
charts,
onLoaded,
}: UseChartWorkspacePersistenceParams) {
const [loaded, setLoaded] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const saveTimeoutRef = useRef<number | null>(null);
useEffect(() => {
let cancelled = false;
async function loadWorkspace() {
try {
const response = await fetch(
`${API_BASE_URL}/api/chart-workspaces/${scope}`,
);
if (response.status === 404 || response.status === 500) {
return;
}
if (!response.ok) {
throw new Error(`Failed to load workspace: ${response.status}`);
}
const payload = (await response.json()) as ChartWorkspaceResponse;
if (cancelled) return;
onLoaded({
layoutMode: payload.layoutMode,
charts: JSON.parse(payload.chartsJson) as PersistedChartWorkspaceItem[],
});
setError(null);
} catch (error) {
if (!cancelled) {
console.error("Failed to load chart workspace", error);
setError("Não foi possível carregar o workspace.");
}
} finally {
if (!cancelled) {
setLoaded(true);
}
}
}
loadWorkspace();
return () => {
cancelled = true;
};
}, [scope]);
useEffect(() => {
if (!loaded) return;
if (saveTimeoutRef.current !== null) {
window.clearTimeout(saveTimeoutRef.current);
}
saveTimeoutRef.current = window.setTimeout(() => {
async function saveWorkspace() {
try {
setSaving(true);
const response = await fetch(
`${API_BASE_URL}/api/chart-workspaces/${scope}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
layoutMode,
chartsJson: JSON.stringify(charts),
}),
},
);
if (!response.ok) {
throw new Error(`Failed to save workspace: ${response.status}`);
}
setError(null);
} catch (error) {
console.error("Failed to save chart workspace", error);
setError("Não foi possível guardar o workspace.");
} finally {
setSaving(false);
}
}
saveWorkspace();
}, SAVE_DEBOUNCE_MS);
return () => {
if (saveTimeoutRef.current !== null) {
window.clearTimeout(saveTimeoutRef.current);
}
};
}, [charts, layoutMode, loaded, scope]);
return {
loaded,
saving,
error,
};
}
File diff suppressed because it is too large Load Diff
@@ -93,7 +93,7 @@ export function AccumulatedHistoryModal({
</h2> </h2>
<p className="mt-1 text-xs text-slate-500"> <p className="mt-1 text-xs text-slate-500">
Chave: meteo.{sensor.key} Chave: {sensor.key}
</p> </p>
</div> </div>
@@ -217,7 +217,7 @@ export function MeteoHistoryModal({
</h2> </h2>
<p className="mt-1 text-xs text-slate-500"> <p className="mt-1 text-xs text-slate-500">
Chave: meteo.{sensor.key} Chave: {sensor.key}
</p> </p>
</div> </div>
@@ -2,9 +2,7 @@ import {
CloudRain, CloudRain,
Droplets, Droplets,
MapPin, MapPin,
Navigation,
Sun, Sun,
Thermometer,
Wind, Wind,
ArrowRight, ArrowRight,
Cloud, Cloud,
@@ -0,0 +1,103 @@
import type { ModuleSensorResponse } from "../../../types/meteo";
export type MeteoSensorSet = {
temperature?: ModuleSensorResponse;
humidity?: ModuleSensorResponse;
windDirection?: ModuleSensorResponse;
windSpeed?: ModuleSensorResponse;
radiation?: ModuleSensorResponse;
rain?: ModuleSensorResponse;
};
export function selectMeteoSensors(
sensors: ModuleSensorResponse[],
): MeteoSensorSet {
return {
temperature: firstNumericMatch(sensors, [
"temperatura exterior",
"exterior temperature",
"temperature exterior",
]),
humidity: firstNumericMatch(sensors, [
"humidade exterior",
"exterior humidity",
"humidity exterior",
]),
windDirection: firstNumericMatch(sensors, [
"direcao vento",
"direção vento",
"wind direction",
]),
windSpeed: maxNumericMatch(sensors, [
"velocidade vento",
"wind speed",
]),
radiation: maxNumericMatch(sensors, [
"radiacao",
"radiação",
"radiation",
]),
rain: maxNumericMatch(sensors, [
"chuva",
"rain",
]),
};
}
export function numericSensorValue(
sensor?: ModuleSensorResponse | null,
): number | null {
return typeof sensor?.value === "number" && Number.isFinite(sensor.value)
? sensor.value
: null;
}
function firstNumericMatch(
sensors: ModuleSensorResponse[],
terms: string[],
): ModuleSensorResponse | undefined {
return sensors.find(
(sensor) =>
matchesAny(sensor, terms) &&
numericSensorValue(sensor) !== null,
);
}
function maxNumericMatch(
sensors: ModuleSensorResponse[],
terms: string[],
): ModuleSensorResponse | undefined {
return sensors
.filter(
(sensor) =>
matchesAny(sensor, terms) &&
numericSensorValue(sensor) !== null,
)
.sort(
(a, b) =>
Number(b.value) - Number(a.value),
)[0];
}
function matchesAny(
sensor: ModuleSensorResponse,
terms: string[],
): boolean {
const haystack = normalizeText(`${sensor.key} ${sensor.name}`);
return terms.some((term) =>
haystack.includes(normalizeText(term)),
);
}
function normalizeText(value: string): string {
return value
.toLowerCase()
.normalize("NFD")
.replace(/\p{Diacritic}/gu, "");
}
@@ -34,7 +34,7 @@ export function useAccumulatedHistory(
setLoading(true); setLoading(true);
const params = new URLSearchParams({ const params = new URLSearchParams({
key: `meteo.${sensorKey}`, key: sensorKey,
range, range,
}); });
+1 -1
View File
@@ -24,7 +24,7 @@ export function useMeteoHistory(sensor: ModuleSensorResponse | null) {
const from = new Date(to.getTime() - hours * 60 * 60 * 1000); const from = new Date(to.getTime() - hours * 60 * 60 * 1000);
const params = new URLSearchParams({ const params = new URLSearchParams({
key: `meteo.${sensorKey}`, key: sensorKey,
from: from.toISOString(), from: from.toISOString(),
to: to.toISOString(), to: to.toISOString(),
}); });
+45 -55
View File
@@ -22,6 +22,10 @@ import {
} from "../hooks/useAccumulatedHistory"; } from "../hooks/useAccumulatedHistory";
import { useWeatherForecast } from "../hooks/useWeatherForecast"; import { useWeatherForecast } from "../hooks/useWeatherForecast";
import { WeatherForecastCard } from "../components/WeatherForecastCard"; import { WeatherForecastCard } from "../components/WeatherForecastCard";
import {
numericSensorValue,
selectMeteoSensors,
} from "../domain/meteoSensorSelectors";
type MeteoPageProps = { type MeteoPageProps = {
theme: "dark" | "light"; theme: "dark" | "light";
@@ -33,9 +37,26 @@ type Accent = "amber" | "blue" | "cyan" | "emerald";
const MAX_HISTORY_POINTS = 34; const MAX_HISTORY_POINTS = 34;
const RADIUS = "rounded-[5px]"; const RADIUS = "rounded-[5px]";
const HISTORY_KEYS = {
temperature: "temperature",
humidity: "humidity",
windSpeed: "windSpeed",
radiation: "radiation",
rain: "rain",
} as const;
export function MeteoPage({ theme }: MeteoPageProps) { export function MeteoPage({ theme }: MeteoPageProps) {
const { sensors } = useMeteoModuleStream(); const { sensors } = useMeteoModuleStream();
const selected = selectMeteoSensors(sensors);
const temperature = selected.temperature;
const humidity = selected.humidity;
const windDirection = selected.windDirection;
const windSpeed = selected.windSpeed;
const radiation = selected.radiation;
const rainSensor = selected.rain;
const [selectedSensor, setSelectedSensor] = const [selectedSensor, setSelectedSensor] =
useState<ModuleSensorResponse | null>(null); useState<ModuleSensorResponse | null>(null);
@@ -50,27 +71,7 @@ export function MeteoPage({ theme }: MeteoPageProps) {
const [accumulatedRange, setAccumulatedRange] = const [accumulatedRange, setAccumulatedRange] =
useState<AccumulatedRange>("7d"); useState<AccumulatedRange>("7d");
const rainValue = numericSensorValue(rainSensor);
const temperature = findSensor(sensors, "temperatura.exterior");
const humidity = findSensor(sensors, "humidade.exterior");
const windDirection = findSensor(sensors, "direcao.vento");
const windSpeed = maxSensor(
sensors.filter((sensor) => sensor.key.startsWith("velocidade.vento.")),
);
const radiation = maxSensor(
sensors.filter((sensor) => sensor.key.startsWith("radiacao.")),
);
const rainSensors = sensors.filter((sensor) => sensor.key.startsWith("chuva."));
const rainSensor =
findSensor(sensors, "chuva.atual") ??
findSensor(sensors, "chuva.instantanea") ??
findSensor(sensors, "chuva.intensidade") ??
maxSensor(rainSensors);
const rainValue = numericValue(rainSensor);
const isRaining = rainValue !== null && rainValue > 0; const isRaining = rainValue !== null && rainValue > 0;
const meteoHistory = useMeteoHistory(selectedSensor); const meteoHistory = useMeteoHistory(selectedSensor);
@@ -84,11 +85,11 @@ export function MeteoPage({ theme }: MeteoPageProps) {
useEffect(() => { useEffect(() => {
const samples: Array<[string, number | null]> = [ const samples: Array<[string, number | null]> = [
["temperatura.exterior", numericValue(temperature)], [HISTORY_KEYS.temperature, numericSensorValue(temperature)],
["humidade.exterior", numericValue(humidity)], [HISTORY_KEYS.humidity, numericSensorValue(humidity)],
["vento.velocidade", numericValue(windSpeed)], [HISTORY_KEYS.windSpeed, numericSensorValue(windSpeed)],
["radiacao.solar", numericValue(radiation)], [HISTORY_KEYS.radiation, numericSensorValue(radiation)],
["chuva.total", numericValue(rainSensor)], [HISTORY_KEYS.rain, numericSensorValue(rainSensor)],
]; ];
setHistory((current) => { setHistory((current) => {
@@ -129,12 +130,12 @@ export function MeteoPage({ theme }: MeteoPageProps) {
<MetricTile <MetricTile
theme={theme} theme={theme}
title="Temperatura" title="Temperatura"
subtitle="Temperatura exterior" subtitle={temperature?.name ?? "Temperatura exterior"}
sensor={temperature} sensor={temperature}
icon={<Thermometer className="h-5 w-5" />} icon={<Thermometer className="h-5 w-5" />}
accent="amber" accent="amber"
status={temperatureBadge(temperature)} status={temperatureBadge(temperature)}
values={history["temperatura.exterior"]} values={history[HISTORY_KEYS.temperature]}
menuOpen={openMenu === "temperature"} menuOpen={openMenu === "temperature"}
onMenuToggle={() => onMenuToggle={() =>
setOpenMenu(openMenu === "temperature" ? null : "temperature") setOpenMenu(openMenu === "temperature" ? null : "temperature")
@@ -154,12 +155,12 @@ export function MeteoPage({ theme }: MeteoPageProps) {
<MetricTile <MetricTile
theme={theme} theme={theme}
title="Humidade" title="Humidade"
subtitle="Humidade relativa" subtitle={humidity?.name ?? "Humidade relativa"}
sensor={humidity} sensor={humidity}
icon={<Droplets className="h-5 w-5" />} icon={<Droplets className="h-5 w-5" />}
accent="blue" accent="blue"
status={humidityBadge(humidity)} status={humidityBadge(humidity)}
values={history["humidade.exterior"]} values={history[HISTORY_KEYS.humidity]}
menuOpen={openMenu === "humidity"} menuOpen={openMenu === "humidity"}
onMenuToggle={() => onMenuToggle={() =>
setOpenMenu(openMenu === "humidity" ? null : "humidity") setOpenMenu(openMenu === "humidity" ? null : "humidity")
@@ -179,12 +180,12 @@ export function MeteoPage({ theme }: MeteoPageProps) {
<MetricTile <MetricTile
theme={theme} theme={theme}
title="Precipitação" title="Precipitação"
subtitle="Precipitação atual" subtitle={rainSensor?.name ?? "Precipitação atual"}
sensor={rainSensor} sensor={rainSensor}
icon={<CloudRain className="h-5 w-5" />} icon={<CloudRain className="h-5 w-5" />}
accent="emerald" accent="emerald"
status={isRaining ? "A chover" : "Sem chuva"} status={isRaining ? "A chover" : "Sem chuva"}
values={history["chuva.total"]} values={history[HISTORY_KEYS.rain]}
menuOpen={openMenu === "rain"} menuOpen={openMenu === "rain"}
onMenuToggle={() => onMenuToggle={() =>
setOpenMenu(openMenu === "rain" ? null : "rain") setOpenMenu(openMenu === "rain" ? null : "rain")
@@ -213,18 +214,21 @@ export function MeteoPage({ theme }: MeteoPageProps) {
/> />
</div> </div>
<CompassPanel theme={theme} direction={numericValue(windDirection)} /> <CompassPanel
theme={theme}
direction={numericSensorValue(windDirection)}
/>
<div className="grid gap-4 md:grid-cols-2 2xl:grid-cols-1"> <div className="grid gap-4 md:grid-cols-2 2xl:grid-cols-1">
<MetricTile <MetricTile
theme={theme} theme={theme}
title="Vento" title="Vento"
subtitle="Velocidade do vento" subtitle={windSpeed?.name ?? "Velocidade do vento"}
sensor={windSpeed} sensor={windSpeed}
icon={<Wind className="h-5 w-5" />} icon={<Wind className="h-5 w-5" />}
accent="cyan" accent="cyan"
status={windBadge(windSpeed)} status={windBadge(windSpeed)}
values={history["vento.velocidade"]} values={history[HISTORY_KEYS.windSpeed]}
menuOpen={openMenu === "wind"} menuOpen={openMenu === "wind"}
onMenuToggle={() => onMenuToggle={() =>
setOpenMenu(openMenu === "wind" ? null : "wind") setOpenMenu(openMenu === "wind" ? null : "wind")
@@ -244,12 +248,12 @@ export function MeteoPage({ theme }: MeteoPageProps) {
<MetricTile <MetricTile
theme={theme} theme={theme}
title="Radiação solar" title="Radiação solar"
subtitle="Radiação instantânea" subtitle={radiation?.name ?? "Radiação instantânea"}
sensor={radiation} sensor={radiation}
icon={<Sun className="h-5 w-5" />} icon={<Sun className="h-5 w-5" />}
accent="amber" accent="amber"
status={radiationBadge(radiation)} status={radiationBadge(radiation)}
values={history["radiacao.solar"]} values={history[HISTORY_KEYS.radiation]}
menuOpen={openMenu === "radiation"} menuOpen={openMenu === "radiation"}
onMenuToggle={() => onMenuToggle={() =>
setOpenMenu(openMenu === "radiation" ? null : "radiation") setOpenMenu(openMenu === "radiation" ? null : "radiation")
@@ -760,20 +764,6 @@ function Sparkline({
); );
} }
function findSensor(sensors: ModuleSensorResponse[], key: string) {
return sensors.find((sensor) => sensor.key === key);
}
function numericValue(sensor?: ModuleSensorResponse) {
return typeof sensor?.value === "number" ? sensor.value : null;
}
function maxSensor(sensors: ModuleSensorResponse[]) {
return sensors
.filter((sensor) => typeof sensor.value === "number")
.sort((a, b) => Number(b.value) - Number(a.value))[0];
}
function formatValue(sensor?: ModuleSensorResponse) { function formatValue(sensor?: ModuleSensorResponse) {
if (!sensor) return "--"; if (!sensor) return "--";
@@ -824,7 +814,7 @@ function getTrend(values?: number[]) {
} }
function temperatureBadge(sensor?: ModuleSensorResponse) { function temperatureBadge(sensor?: ModuleSensorResponse) {
const value = numericValue(sensor); const value = numericSensorValue(sensor);
if (value === null) return "Sem dados"; if (value === null) return "Sem dados";
if (value >= 30) return "Quente"; if (value >= 30) return "Quente";
if (value <= 10) return "Frio"; if (value <= 10) return "Frio";
@@ -832,7 +822,7 @@ function temperatureBadge(sensor?: ModuleSensorResponse) {
} }
function humidityBadge(sensor?: ModuleSensorResponse) { function humidityBadge(sensor?: ModuleSensorResponse) {
const value = numericValue(sensor); const value = numericSensorValue(sensor);
if (value === null) return "Sem dados"; if (value === null) return "Sem dados";
if (value >= 80) return "Alta"; if (value >= 80) return "Alta";
if (value <= 35) return "Baixa"; if (value <= 35) return "Baixa";
@@ -840,7 +830,7 @@ function humidityBadge(sensor?: ModuleSensorResponse) {
} }
function windBadge(sensor?: ModuleSensorResponse) { function windBadge(sensor?: ModuleSensorResponse) {
const value = numericValue(sensor); const value = numericSensorValue(sensor);
if (value === null) return "Sem dados"; if (value === null) return "Sem dados";
if (value >= 30) return "Forte"; if (value >= 30) return "Forte";
if (value >= 10) return "Moderado"; if (value >= 10) return "Moderado";
@@ -848,7 +838,7 @@ function windBadge(sensor?: ModuleSensorResponse) {
} }
function radiationBadge(sensor?: ModuleSensorResponse) { function radiationBadge(sensor?: ModuleSensorResponse) {
const value = numericValue(sensor); const value = numericSensorValue(sensor);
if (value === null) return "Sem dados"; if (value === null) return "Sem dados";
if (value >= 800) return "Alta"; if (value >= 800) return "Alta";
if (value >= 400) return "Média"; if (value >= 400) return "Média";
@@ -0,0 +1,245 @@
import {
Bell,
Lock,
Palette,
Save,
User,
} from "lucide-react";
type SettingsPageProps = {
theme: "dark" | "light";
};
const RADIUS = "rounded-[6px]";
export function SettingsPage({ theme }: SettingsPageProps) {
const isDark = theme === "dark";
return (
<div
className={
isDark
? "flex h-full flex-col bg-[#07101B] text-white"
: "flex h-full flex-col bg-[#F3F6FA] text-[#0F172A]"
}
>
<div className="flex items-center justify-between px-10 pb-6 pt-8">
<div>
<p
className={
isDark
? "text-xs font-black uppercase tracking-[0.24em] text-[#7F8CA3]"
: "text-xs font-black uppercase tracking-[0.24em] text-slate-500"
}
>
Configuração
</p>
<h1
className={
isDark
? "mt-3 text-[34px] font-black tracking-[-0.04em] text-white"
: "mt-3 text-[34px] font-black tracking-[-0.04em] text-[#0F172A]"
}
>
Definições
</h1>
<p
className={
isDark
? "mt-3 max-w-[680px] text-sm leading-7 text-[#A8B3C7]"
: "mt-3 max-w-[680px] text-sm leading-7 text-slate-600"
}
>
Configure preferências da plataforma, segurança, notificações
e parâmetros gerais do sistema.
</p>
</div>
<div
className={
isDark
? `${RADIUS} border border-[#263247] bg-[#0E1726] px-5 py-3 text-sm font-bold text-[#4FD1C5]`
: `${RADIUS} border border-[#D7DEE8] bg-white px-5 py-3 text-sm font-bold text-[#0F766E]`
}
>
Temporário
</div>
</div>
<div className="grid flex-1 grid-cols-[380px_minmax(0,1fr)] gap-6 px-10 pb-10">
<aside
className={
isDark
? `${RADIUS} border border-[#263247] bg-[#0E1726] p-6`
: `${RADIUS} border border-[#D7DEE8] bg-white p-6`
}
>
<div className="space-y-5">
<SettingsCard theme={theme} icon={<User />} title="Perfil" value="Administrador" />
<SettingsCard theme={theme} icon={<Lock />} title="Segurança" value="Ativa" />
<SettingsCard theme={theme} icon={<Bell />} title="Notificações" value="Por configurar" />
</div>
<div
className={
isDark
? "mt-8 border-t border-[#263247] pt-6"
: "mt-8 border-t border-[#D7DEE8] pt-6"
}
>
<button
type="button"
disabled
className={
isDark
? `flex h-[54px] w-full items-center justify-center gap-3 ${RADIUS} border border-[#2A3950] bg-[#111A2B] text-sm font-extrabold text-[#7F8CA3]`
: `flex h-[54px] w-full items-center justify-center gap-3 ${RADIUS} border border-[#CBD5E1] bg-[#F8FAFC] text-sm font-extrabold text-slate-500`
}
>
<Save className="h-5 w-5" />
Guardar Alterações
</button>
<p
className={
isDark
? "mt-4 text-center text-xs leading-6 text-[#7F8CA3]"
: "mt-4 text-center text-xs leading-6 text-slate-500"
}
>
As definições serão integradas futuramente com persistência
e validação completa.
</p>
</div>
</aside>
<section
className={
isDark
? `${RADIUS} relative overflow-hidden border border-[#263247] bg-[#0B1220] p-8`
: `${RADIUS} relative overflow-hidden border border-[#D7DEE8] bg-white p-8`
}
>
<div
className={
isDark
? "absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(79,209,197,0.10),transparent_60%)]"
: "absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(15,118,110,0.08),transparent_60%)]"
}
/>
<div className="relative max-w-[760px] space-y-6">
<SettingsSection
theme={theme}
icon={<Palette />}
title="Aparência"
description="Preferências visuais da plataforma."
/>
<SettingsSection
theme={theme}
icon={<Bell />}
title="Alertas"
description="Configuração temporária para notificações operacionais."
/>
<SettingsSection
theme={theme}
icon={<Lock />}
title="Acesso"
description="Gestão futura de permissões e autenticação."
/>
</div>
</section>
</div>
</div>
);
}
function SettingsCard({
theme,
icon,
title,
value,
}: {
theme: "dark" | "light";
icon: React.ReactNode;
title: string;
value: string;
}) {
const isDark = theme === "dark";
return (
<div
className={
isDark
? `${RADIUS} border border-[#263247] bg-[#111A2B] p-4`
: `${RADIUS} border border-[#D7DEE8] bg-[#F8FAFC] p-4`
}
>
<div className="flex items-center gap-4">
<div
className={
isDark
? `grid h-12 w-12 place-items-center ${RADIUS} bg-[#13202F] text-[#4FD1C5]`
: `grid h-12 w-12 place-items-center ${RADIUS} bg-[#ECFDF5] text-[#0F766E]`
}
>
{icon}
</div>
<div>
<p className={isDark ? "text-xs font-bold uppercase tracking-[0.18em] text-[#7F8CA3]" : "text-xs font-bold uppercase tracking-[0.18em] text-slate-500"}>
{title}
</p>
<h3 className={isDark ? "mt-1 text-sm font-black text-white" : "mt-1 text-sm font-black text-[#0F172A]"}>
{value}
</h3>
</div>
</div>
</div>
);
}
function SettingsSection({
theme,
icon,
title,
description,
}: {
theme: "dark" | "light";
icon: React.ReactNode;
title: string;
description: string;
}) {
const isDark = theme === "dark";
return (
<div
className={
isDark
? `${RADIUS} border border-[#263247] bg-[#111A2B] p-5`
: `${RADIUS} border border-[#D7DEE8] bg-[#F8FAFC] p-5`
}
>
<div className="flex items-center gap-4">
<div className={isDark ? "text-[#4FD1C5]" : "text-[#0F766E]"}>
{icon}
</div>
<div>
<h2 className={isDark ? "text-base font-black text-white" : "text-base font-black text-[#0F172A]"}>
{title}
</h2>
<p className={isDark ? "mt-1 text-sm text-[#A8B3C7]" : "mt-1 text-sm text-slate-600"}>
{description}
</p>
</div>
</div>
</div>
);
}
export default SettingsPage;
@@ -0,0 +1,23 @@
import { useMemo } from "react";
import { useTelemetryStream } from "./useTelemetryStream";
import { toChartVariable } from "../types/telemetryCatalog";
export function useTelemetryCatalog() {
const telemetry = useTelemetryStream();
const variables = useMemo(
() => telemetry.snapshots.map(toChartVariable),
[telemetry.snapshots],
);
const chartableVariables = useMemo(
() => variables.filter((variable) => variable.chartable),
[variables],
);
return {
...telemetry,
variables,
chartableVariables,
};
}
@@ -0,0 +1,183 @@
import { useEffect, useMemo, useState } from "react";
import type {
WorkspaceChartInterval,
WorkspaceChartPoint,
WorkspaceChartTimeRange,
} from "../../../components/charts/WorkspaceChart";
const BACKEND_URL = "http://localhost:18450";
type HistorianPoint = {
timestamp: string;
numericValue: number | null;
booleanValue: boolean | null;
textValue: string | null;
};
export function useTelemetryChartSeries(
sensorKeys: string[],
timeRange: WorkspaceChartTimeRange,
interval: WorkspaceChartInterval,
) {
const [seriesByKey, setSeriesByKey] = useState<Record<string, WorkspaceChartPoint[]>>({});
const [loading, setLoading] = useState(true);
const [initialized, setInitialized] = useState(false);
const keySignature = useMemo(
() => sensorKeys.slice().sort().join(","),
[sensorKeys],
);
useEffect(() => {
if (sensorKeys.length === 0) {
setSeriesByKey({});
return;
}
const controller = new AbortController();
async function loadHistory() {
try {
const to = new Date();
const from = new Date(to.getTime() - rangeToMs(timeRange));
if (!initialized) {
setLoading(true);
}
const entries = await Promise.all(
sensorKeys.map(async (key) => {
const params = new URLSearchParams({
key,
from: from.toISOString(),
to: to.toISOString(),
});
const response = await fetch(
`${BACKEND_URL}/api/historian/series?${params.toString()}`,
{ signal: controller.signal },
);
if (!response.ok) {
throw new Error(`Failed to load history for ${key}`);
}
const payload = (await response.json()) as HistorianPoint[];
const points = payload
.filter(
(point) =>
point.numericValue !== null &&
Number.isFinite(point.numericValue),
)
.map((point) => ({
timestamp: point.timestamp,
value: point.numericValue as number,
}));
return [key, aggregatePoints(points, interval)] as const;
}),
);
setSeriesByKey(Object.fromEntries(entries));
setInitialized(true);
} catch (error) {
if (controller.signal.aborted) return;
console.error("Failed to load telemetry chart history", error);
if (!initialized) {
setSeriesByKey({});
}
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
}
loadHistory();
const intervalId = window.setInterval(() => {
loadHistory();
}, 10000);
return () => {
controller.abort();
window.clearInterval(intervalId);
};
}, [keySignature, timeRange, interval, initialized]);
return {
seriesByKey,
loading,
};
}
function rangeToMs(range: WorkspaceChartTimeRange) {
switch (range) {
case "15m":
return 15 * 60 * 1000;
case "1h":
return 60 * 60 * 1000;
case "6h":
return 6 * 60 * 60 * 1000;
case "24h":
return 24 * 60 * 60 * 1000;
case "7d":
return 7 * 24 * 60 * 60 * 1000;
case "30d":
return 30 * 24 * 60 * 60 * 1000;
}
}
function intervalToMs(interval: WorkspaceChartInterval) {
switch (interval) {
case "1m":
return 60 * 1000;
case "5m":
return 5 * 60 * 1000;
case "15m":
return 15 * 60 * 1000;
case "1h":
return 60 * 60 * 1000;
}
}
function aggregatePoints(
points: WorkspaceChartPoint[],
interval: WorkspaceChartInterval,
): WorkspaceChartPoint[] {
const bucketMs = intervalToMs(interval);
if (bucketMs === 0) {
return points;
}
const buckets = new Map<number, number[]>();
for (const point of points) {
const time = new Date(point.timestamp).getTime();
if (!Number.isFinite(time)) {
continue;
}
const bucketTime = Math.floor(time / bucketMs) * bucketMs;
const values = buckets.get(bucketTime) ?? [];
if (point.value !== null && Number.isFinite(point.value)) {
values.push(point.value);
}
buckets.set(bucketTime, values);
}
return Array.from(buckets.entries())
.sort(([a], [b]) => a - b)
.map(([bucketTime, values]) => ({
timestamp: new Date(bucketTime).toISOString(),
value: average(values),
}));
}
function average(values: number[]) {
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
@@ -0,0 +1,64 @@
import type { TelemetrySnapshot } from "../../../types/telemetry";
export type ChartVariable = {
key: string;
sensorId: number;
label: string;
unit: string;
value: TelemetrySnapshot["value"];
timestamp: string;
chartable: boolean;
category: string;
group: string;
};
export function toChartVariable(snapshot: TelemetrySnapshot): ChartVariable {
return {
key: snapshot.key,
sensorId: snapshot.sensorId,
label: snapshot.name,
unit: snapshot.unit ?? "",
value: snapshot.value,
timestamp: snapshot.timestamp,
chartable:
typeof snapshot.value === "number" &&
snapshot.bitOffset === null,
category: getTelemetryCategory(snapshot),
group: getTelemetryGroup(snapshot),
};
}
function getTelemetryCategory(snapshot: TelemetrySnapshot): string {
const key = snapshot.key.toLowerCase();
const name = snapshot.name.toLowerCase();
if (key.includes("temperature") || name.includes("temperatura")) return "Temperatura";
if (key.includes("humidity") || name.includes("humidade")) return "Humidade";
if (key.includes("co2") || name.includes("co2")) return "CO₂";
if (key.includes("radiation") || name.includes("radiacao")) return "Radiação";
if (key.includes("wind") || name.includes("vento")) return "Vento";
if (key.includes("rain") || name.includes("chuva")) return "Chuva";
if (key.includes("ph") || name.includes("ph")) return "pH";
if (key.includes("ce") || name.includes("ce")) return "CE";
if (key.includes("pressure") || name.includes("pressao")) return "Pressão";
return "Outros";
}
function getTelemetryGroup(snapshot: TelemetrySnapshot): string {
const key = snapshot.key.toLowerCase();
const name = snapshot.name.toLowerCase();
if (key.includes("exterior") || name.includes("exterior")) return "Exterior";
const estufaMatch = name.match(/estufa\s*(\d+)/);
if (estufaMatch) return `Estufa ${estufaMatch[1]}`;
const controllerMatch = name.match(/\bc(\d+)\b/i);
if (controllerMatch) return `Controlador C${controllerMatch[1]}`;
const setorMatch = name.match(/setor\s*(\d+)/);
if (setorMatch) return "Setores";
return "Outros";
}
@@ -1,10 +1,10 @@
import type { TelemetrySnapshot } from "../../../types/telemetry"; import type { TelemetrySnapshot } from "../../../types/telemetry";
export function findTelemetryByName( export function findTelemetryByKey(
snapshots: TelemetrySnapshot[], snapshots: TelemetrySnapshot[],
name: string, key: string,
): TelemetrySnapshot | null { ): TelemetrySnapshot | null {
return snapshots.find((snapshot) => snapshot.name === name) ?? null; return snapshots.find((snapshot) => snapshot.key === key) ?? null;
} }
export function formatTelemetryValue( export function formatTelemetryValue(
@@ -25,5 +25,5 @@ export function formatTelemetryValue(
: snapshot.value.toFixed(1); : snapshot.value.toFixed(1);
} }
return snapshot.value; return String(snapshot.value);
} }
+2 -2
View File
@@ -1,10 +1,10 @@
export type ModuleSensorResponse = { export type ModuleSensorResponse = {
sensorId: number; sensorId: number;
name: string;
key: string; key: string;
name: string;
value: unknown; value: unknown;
unit: string | null; unit: string | null;
modbusAddress: number; modbusAddress: number | null;
bitOffset: number | null; bitOffset: number | null;
timestamp: string; timestamp: string;
}; };
+3 -1
View File
@@ -1,7 +1,9 @@
export type TelemetrySnapshot = { export type TelemetrySnapshot = {
sensorId: number; sensorId: number;
key: string;
name: string; name: string;
modbusAddress: number; category: string;
modbusAddress: number | null;
bitOffset: number | null; bitOffset: number | null;
rawValue: number; rawValue: number;
value: number | boolean | string | null; value: number | boolean | string | null;