feat(charts): add persistent workspace chart management
This commit is contained in:
+58
-9
@@ -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
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,9 +56,13 @@ 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({
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user