diff --git a/src/app/App.tsx b/src/app/App.tsx index d635617..2462e35 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -15,6 +15,7 @@ import { useAuth } from "../features/auth/AuthContext"; import { LoginPage } from "../features/auth/pages/LoginPage"; import { RuntimeConfigProvider } from "../features/system/RuntimeConfigProvider"; import { TitleBar } from "../components/window/TitleBar"; +import type { ChartWorkspaceScope } from "../features/chartworkspace/types"; export type AppPage = | "dashboard" @@ -37,6 +38,30 @@ export type AppPage = function App() { const { authenticated } = useAuth(); const [activePage, setActivePage] = useState("dashboard"); + const [selectedWorkspaceByScope, setSelectedWorkspaceByScope] = useState< + Partial> + >({}); + + const openWorkspace = (scope: ChartWorkspaceScope, workspaceId: number) => { + setSelectedWorkspaceByScope((current) => ({ + ...current, + [scope]: workspaceId, + })); + + if (scope === "GLOBAL") setActivePage("maincharts"); + if (scope === "METEO") setActivePage("meteoCharts"); + if (scope === "CLIMATE") setActivePage("climateCharts"); + }; + + const updateSelectedWorkspace = ( + scope: ChartWorkspaceScope, + workspaceId: number | null, + ) => { + setSelectedWorkspaceByScope((current) => ({ + ...current, + [scope]: workspaceId, + })); + }; const isWorkspaceWindow = window.location.pathname.startsWith("/workspace-window/"); @@ -72,7 +97,12 @@ function App() {
- + {({ theme }) => { if (activePage === "meteo") { return ( @@ -84,17 +114,41 @@ function App() { } if (activePage === "meteoCharts") { - return ; + return ( + + updateSelectedWorkspace("METEO", workspaceId) + } + /> + ); } if (activePage === "climateCharts") { - return ; + return ( + + updateSelectedWorkspace("CLIMATE", workspaceId) + } + /> + ); } if (activePage === "console") return ; if (activePage === "maincharts") { - return ; + return ( + + updateSelectedWorkspace("GLOBAL", workspaceId) + } + /> + ); } if (activePage === "settings") { diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index ad4d00a..8619863 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -4,6 +4,7 @@ import { useTelemetryStream } from "../../features/telemetry/hooks/useTelemetryS import { useCurrentUser } from "../../features/auth/hooks/useCurrentUser"; import type { TelemetrySnapshot } from "../../types/telemetry"; import type { AppPage } from "../../app/App"; +import type { ChartWorkspaceScope } from "../../features/chartworkspace/types"; type Theme = "dark" | "light"; @@ -14,13 +15,21 @@ type AppShellRenderProps = { type AppShellProps = { activePage: AppPage; + selectedWorkspaceByScope: Partial>; onNavigate: (page: AppPage) => void; + onOpenWorkspace: (scope: ChartWorkspaceScope, workspaceId: number) => void; children: (props: AppShellRenderProps) => ReactNode; }; const THEME_STORAGE_KEY = "app-theme"; -export function AppShell({ activePage, onNavigate, children }: AppShellProps) { +export function AppShell({ + activePage, + selectedWorkspaceByScope, + onNavigate, + onOpenWorkspace, + children, +}: AppShellProps) { const telemetry = useTelemetryStream(); const currentUser = useCurrentUser(); @@ -107,7 +116,9 @@ export function AppShell({ activePage, onNavigate, children }: AppShellProps) { userInitials={currentUser.initials} userName={currentUser.name} userRole={currentUser.role} + selectedWorkspaceByScope={selectedWorkspaceByScope} onNavigate={onNavigate} + onOpenWorkspace={onOpenWorkspace} onToggleCollapsed={() => setSidebarCollapsed((current) => !current)} onToggleTheme={toggleTheme} /> diff --git a/src/components/navigation/Sidebar.tsx b/src/components/navigation/Sidebar.tsx index 2ca86a3..3cb436f 100644 --- a/src/components/navigation/Sidebar.tsx +++ b/src/components/navigation/Sidebar.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { BarChart3, ChevronDown, @@ -26,6 +26,11 @@ import { import logo from "../../assets/logo5.png"; import type { AppPage } from "../../app/App"; import { useAuth } from "../../features/auth/AuthContext"; +import { + listChartWorkspaces, + type ChartWorkspaceResponse, +} from "../../features/chartworkspace/api/chartWorkspaceApi"; +import type { ChartWorkspaceScope } from "../../features/chartworkspace/types"; type SidebarProps = { theme: "dark" | "light"; @@ -34,7 +39,9 @@ type SidebarProps = { userInitials: string; userName: string; userRole: string; + selectedWorkspaceByScope: Partial>; onNavigate: (page: AppPage) => void; + onOpenWorkspace: (scope: ChartWorkspaceScope, workspaceId: number) => void; onToggleCollapsed: () => void; onToggleTheme: () => void; }; @@ -82,7 +89,9 @@ export function Sidebar({ userInitials, userName, userRole, + selectedWorkspaceByScope, onNavigate, + onOpenWorkspace, onToggleCollapsed, onToggleTheme, }: SidebarProps) { @@ -90,16 +99,64 @@ export function Sidebar({ const isDark = theme === "dark"; const ThemeIcon = isDark ? Moon : Sun; + const [generalOpen, setGeneralOpen] = useState(true); const [meteoOpen, setMeteoOpen] = useState(true); const [climateOpen, setClimateOpen] = useState(false); const [irrigationOpen, setIrrigationOpen] = useState(false); const [activeTreeItem, setActiveTreeItem] = useState(null); const [userMenuOpen, setUserMenuOpen] = useState(false); const [sidebarHovered, setSidebarHovered] = useState(false); + const [workspaceLists, setWorkspaceLists] = useState< + Partial> + >({}); const userButtonRef = useRef(null); const userMenuRef = useRef(null); + const refreshWorkspaces = useCallback(async (scope?: ChartWorkspaceScope) => { + const scopes: ChartWorkspaceScope[] = scope + ? [scope] + : ["GLOBAL", "METEO", "CLIMATE"]; + + const loadedEntries = await Promise.all( + scopes.map(async (workspaceScope) => { + try { + return [workspaceScope, await listChartWorkspaces(workspaceScope)] as const; + } catch (error) { + console.error(`Failed to load sidebar workspaces for ${workspaceScope}`, error); + return [workspaceScope, [] as ChartWorkspaceResponse[]] as const; + } + }), + ); + + setWorkspaceLists((current) => { + const next = { ...current }; + + for (const [workspaceScope, workspaces] of loadedEntries) { + next[workspaceScope] = workspaces; + } + + return next; + }); + }, []); + + useEffect(() => { + void refreshWorkspaces(); + }, [refreshWorkspaces]); + + useEffect(() => { + const handleWorkspaceChange = (event: Event) => { + const detail = (event as CustomEvent<{ scope?: ChartWorkspaceScope }>).detail; + void refreshWorkspaces(detail?.scope); + }; + + window.addEventListener("chart-workspaces:changed", handleWorkspaceChange); + + return () => { + window.removeEventListener("chart-workspaces:changed", handleWorkspaceChange); + }; + }, [refreshWorkspaces]); + useEffect(() => { if (!userMenuOpen) return; @@ -149,6 +206,7 @@ export function Sidebar({ const firstItem = sectionItems[section][0]; setActiveTreeItem(`${section}:${firstItem.label}`); + setGeneralOpen(false); if (firstItem.page) { onNavigate(firstItem.page); @@ -288,13 +346,30 @@ export function Sidebar({ theme={theme} collapsed={collapsed} label="Gráficos Gerais" - page="maincharts" icon={BarChart3} - activePage={activePage} - activeTreeItem={activeTreeItem} - onNavigate={(page) => { + open={generalOpen} + workspaces={workspaceLists.GLOBAL ?? []} + active={activePage === "maincharts"} + selectedWorkspaceId={selectedWorkspaceByScope.GLOBAL ?? null} + onToggle={() => { + if (collapsed) { + onToggleCollapsed(); + } + setActiveTreeItem(null); - onNavigate(page); + setGeneralOpen((current) => !current); + setMeteoOpen(false); + setClimateOpen(false); + setIrrigationOpen(false); + onNavigate("maincharts"); + }} + onOpenWorkspace={(workspaceId) => { + setActiveTreeItem(null); + setGeneralOpen(true); + setMeteoOpen(false); + setClimateOpen(false); + setIrrigationOpen(false); + onOpenWorkspace("GLOBAL", workspaceId); }} /> @@ -325,7 +400,21 @@ export function Sidebar({ sectionKey="meteo" activeTreeItem={activeTreeItem} activePage={activePage} + workspaceGroup={{ + scope: "METEO", + page: "meteoCharts", + workspaces: workspaceLists.METEO ?? [], + selectedWorkspaceId: selectedWorkspaceByScope.METEO ?? null, + }} onItemClick={handleTreeClick} + onWorkspaceClick={(scope, workspaceId) => { + setActiveTreeItem(null); + setGeneralOpen(false); + setMeteoOpen(true); + setClimateOpen(false); + setIrrigationOpen(false); + onOpenWorkspace(scope, workspaceId); + }} /> { + setActiveTreeItem(null); + setGeneralOpen(false); + setMeteoOpen(false); + setClimateOpen(true); + setIrrigationOpen(false); + onOpenWorkspace(scope, workspaceId); + }} /> void; + onWorkspaceClick?: (scope: ChartWorkspaceScope, workspaceId: number) => void; }) { const isDark = theme === "dark"; const hasActiveChild = items.some((item) => { @@ -659,34 +771,52 @@ function TreeSection({ const active = activeTreeItem === key || Boolean(item.page && activePage === item.page); + const showWorkspaces = + workspaceGroup && + item.page === workspaceGroup.page && + workspaceGroup.workspaces.length > 0; return ( - + {item.label} + + + {showWorkspaces && ( + + onWorkspaceClick?.(workspaceGroup.scope, workspaceId) + } + /> + )} +
); })}
@@ -715,6 +845,160 @@ function SectionLabel({ ); } +function WorkspaceNavSection({ + theme, + collapsed, + label, + icon: Icon, + open, + workspaces, + active, + selectedWorkspaceId, + onToggle, + onOpenWorkspace, +}: { + theme: "dark" | "light"; + collapsed: boolean; + label: string; + icon: React.ElementType; + open: boolean; + workspaces: ChartWorkspaceResponse[]; + active: boolean; + selectedWorkspaceId: number | null; + onToggle: () => void; + onOpenWorkspace: (workspaceId: number) => void; +}) { + const isDark = theme === "dark"; + + return ( +
+ + + + + {!collapsed && open && ( + + )} +
+ ); +} + +function WorkspaceTreeList({ + theme, + workspaces, + activePage, + page, + selectedWorkspaceId, + onOpenWorkspace, +}: { + theme: "dark" | "light"; + workspaces: ChartWorkspaceResponse[]; + activePage: AppPage; + page: AppPage; + selectedWorkspaceId: number | null; + onOpenWorkspace: (workspaceId: number) => void; +}) { + const isDark = theme === "dark"; + + return ( +
+ {workspaces.map((workspace) => { + const active = + activePage === page && workspace.id === selectedWorkspaceId; + const chartCount = getWorkspaceChartCount(workspace); + + return ( + + ); + })} + + {workspaces.length === 0 && ( +
+ Sem workspaces. +
+ )} +
+ ); +} + +function getWorkspaceChartCount(workspace: ChartWorkspaceResponse) { + try { + const charts = JSON.parse(workspace.chartsJson) as unknown[]; + return Array.isArray(charts) ? charts.length : 0; + } catch { + return 0; + } +} + function ActiveIndicator({ isDark }: { isDark: boolean }) { return ( void; -}) { +}; + +type WorkspaceNavItemProps = { + theme: "dark" | "light"; + collapsed: boolean; + label: string; + icon: React.ElementType; + open: boolean; + workspaces: ChartWorkspaceResponse[]; + active: boolean; + selectedWorkspaceId: number | null; + onToggle: () => void; + onOpenWorkspace: (workspaceId: number) => void; +}; + +function NavItem(props: StandardNavItemProps | WorkspaceNavItemProps) { + if ("open" in props) { + return ; + } + + const { + theme, + collapsed, + label, + page, + icon: Icon, + activePage, + activeTreeItem, + onNavigate, + } = props; + const isDark = theme === "dark"; const active = activePage === page && activeTreeItem === null; diff --git a/src/features/auth/pages/LoginPage.tsx b/src/features/auth/pages/LoginPage.tsx index b0f68ec..8f33b22 100644 --- a/src/features/auth/pages/LoginPage.tsx +++ b/src/features/auth/pages/LoginPage.tsx @@ -43,9 +43,9 @@ export function LoginPage() { />

- Litoral Regas + Central LRX

-

Iniciar sessao

+

Iniciar sessão

diff --git a/src/features/chartworkspace/api/chartWorkspaceApi.ts b/src/features/chartworkspace/api/chartWorkspaceApi.ts index ce2b0e9..774a87e 100644 --- a/src/features/chartworkspace/api/chartWorkspaceApi.ts +++ b/src/features/chartworkspace/api/chartWorkspaceApi.ts @@ -20,8 +20,8 @@ export type ChartWorkspaceSaveRequest = { name?: string; sortOrder?: number; defaultWorkspace?: boolean; - layoutMode: ChartLayoutMode; - chartsJson: string; + layoutMode?: ChartLayoutMode; + chartsJson?: string; }; function workspaceUrl(workspaceId: number | null, scope: ChartWorkspaceScope) { diff --git a/src/features/chartworkspace/components/ChartWorkspaceControls.tsx b/src/features/chartworkspace/components/ChartWorkspaceControls.tsx index cd83f4b..cbee904 100644 --- a/src/features/chartworkspace/components/ChartWorkspaceControls.tsx +++ b/src/features/chartworkspace/components/ChartWorkspaceControls.tsx @@ -80,6 +80,7 @@ export function EmptyWorkspace({ description = "Abra um gráfico guardado ou crie um novo gráfico para continuar.", addLabel = "Novo Gráfico", savedLabel = "Abrir Guardado", + showSavedButton = true, minHeightClass = "min-h-0 flex-1", onAddChart, onOpenSaved, @@ -90,6 +91,7 @@ export function EmptyWorkspace({ description?: string; addLabel?: string; savedLabel?: string; + showSavedButton?: boolean; minHeightClass?: string; onAddChart: () => void; onOpenSaved: () => void; @@ -150,18 +152,20 @@ export function EmptyWorkspace({ {addLabel} - + {showSavedButton && ( + + )} @@ -189,7 +193,7 @@ export function WorkspaceSelector({ creating: boolean; canCreateWorkspace: boolean; onSelectWorkspace: (workspaceId: number) => void; - onCreateWorkspace: (name: string) => void; + onCreateWorkspace: (name: string) => void | Promise; onRenameWorkspace: (workspaceId: number, name: string) => void | Promise; onDeleteWorkspace: (workspaceId: number) => void | Promise; }) { @@ -208,7 +212,7 @@ export function WorkspaceSelector({ ); const createWorkspace = (name: string) => { - onCreateWorkspace(name); + void onCreateWorkspace(name); setCreateOpen(false); setOpen(false); }; @@ -514,7 +518,7 @@ function WorkspaceNameModal({ ); } -function NewWorkspaceModal({ +export function NewWorkspaceModal({ theme, creating, onClose, diff --git a/src/features/chartworkspace/hooks/useChartWorkspacePersistence.ts b/src/features/chartworkspace/hooks/useChartWorkspacePersistence.ts index 1370891..9084a2c 100644 --- a/src/features/chartworkspace/hooks/useChartWorkspacePersistence.ts +++ b/src/features/chartworkspace/hooks/useChartWorkspacePersistence.ts @@ -44,7 +44,7 @@ type UseChartWorkspacePersistenceParams = { export function useChartWorkspacePersistence({ scope, - workspaceId = null, + workspaceId, layoutMode, charts, onLoaded, @@ -58,7 +58,11 @@ export function useChartWorkspacePersistence({ const loadedWorkspaceKeyRef = useRef(null); const workspaceKey = - workspaceId === null ? `scope:${scope}` : `id:${workspaceId}`; + workspaceId === undefined + ? `scope:${scope}` + : workspaceId === null + ? `none:${scope}` + : `id:${workspaceId}`; useEffect(() => { let cancelled = false; @@ -70,8 +74,17 @@ export function useChartWorkspacePersistence({ loadedWorkspaceKeyRef.current = null; try { + if (workspaceId === null) { + if (!cancelled) { + loadedWorkspaceKeyRef.current = workspaceKey; + setLoaded(true); + } + + return; + } + const payload = - await loadChartWorkspace(scope, workspaceId); + await loadChartWorkspace(scope, workspaceId ?? null); if (!payload) { return; @@ -110,6 +123,7 @@ export function useChartWorkspacePersistence({ useEffect(() => { if (!loaded || !saveEnabled) return; + if (workspaceId === null) return; if (loadedWorkspaceKeyRef.current !== workspaceKey) return; if (saveTimeoutRef.current !== null) { @@ -127,7 +141,13 @@ export function useChartWorkspacePersistence({ layoutMode, chartsJson: JSON.stringify(charts), }, - workspaceId, + workspaceId ?? null, + ); + + window.dispatchEvent( + new CustomEvent("chart-workspaces:changed", { + detail: { scope, workspaceId }, + }), ); setError(null); diff --git a/src/features/chartworkspace/hooks/useChartWorkspaceSelection.ts b/src/features/chartworkspace/hooks/useChartWorkspaceSelection.ts index 9a0d0ba..4270d13 100644 --- a/src/features/chartworkspace/hooks/useChartWorkspaceSelection.ts +++ b/src/features/chartworkspace/hooks/useChartWorkspaceSelection.ts @@ -74,11 +74,7 @@ export function useChartWorkspaceSelection({ await saveChartWorkspace( scope, { - name: workspace.name, - sortOrder: workspace.sortOrder, defaultWorkspace: true, - layoutMode: workspace.layoutMode, - chartsJson: workspace.chartsJson, }, workspace.id, ); @@ -97,7 +93,7 @@ export function useChartWorkspaceSelection({ }, [scope, workspaces]); const createWorkspace = useCallback(async (name: string) => { - if (workspaces.length >= 10 || creating) return; + if (workspaces.length >= 10 || creating) return null; try { setCreating(true); @@ -120,9 +116,11 @@ export function useChartWorkspaceSelection({ ]); setActiveWorkspaceId(createdWorkspace.id); setError(null); + return createdWorkspace; } catch (exception) { console.error("Failed to create chart workspace", exception); setError("Nao foi possivel criar o workspace."); + return null; } finally { setCreating(false); } @@ -142,26 +140,7 @@ export function useChartWorkspaceSelection({ setWorkspaces(remaining); if (activeWorkspaceId === workspaceId) { - const nextWorkspace = - remaining.find((item) => item.defaultWorkspace) ?? - remaining[0] ?? - null; - - setActiveWorkspaceId(nextWorkspace?.id ?? null); - - if (nextWorkspace) { - await saveChartWorkspace( - scope, - { - name: nextWorkspace.name, - sortOrder: nextWorkspace.sortOrder, - defaultWorkspace: true, - layoutMode: nextWorkspace.layoutMode, - chartsJson: nextWorkspace.chartsJson, - }, - nextWorkspace.id, - ); - } + setActiveWorkspaceId(null); } setError(null); diff --git a/src/features/chartworkspace/pages/WorkspaceWindowPage.tsx b/src/features/chartworkspace/pages/WorkspaceWindowPage.tsx index 87551c4..a333c11 100644 --- a/src/features/chartworkspace/pages/WorkspaceWindowPage.tsx +++ b/src/features/chartworkspace/pages/WorkspaceWindowPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { emit, listen } from "@tauri-apps/api/event"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { Minus, Square, X } from "lucide-react"; @@ -30,6 +30,21 @@ export function WorkspaceWindowPage({ theme }: WorkspaceWindowPageProps) { const currentWindow = getCurrentWindow(); const { scope, workspaceId, title } = parseWorkspaceRoute(); const [windowTitle, setWindowTitle] = useState(title); + const closingRef = useRef(false); + + const closeWorkspaceWindow = useCallback(async () => { + if (closingRef.current) return; + closingRef.current = true; + + if (workspaceId) { + await emit("workspace-window://closed", { + scope, + workspaceId, + }); + } + + await currentWindow.destroy(); + }, [currentWindow, scope, workspaceId]); useEffect(() => { void currentWindow.setTitle(windowTitle); @@ -57,19 +72,15 @@ export function WorkspaceWindowPage({ theme }: WorkspaceWindowPageProps) { }, [scope, workspaceId]); useEffect(() => { - const unlistenPromise = currentWindow.onCloseRequested(() => { - if (!workspaceId) return; - - void emit("workspace-window://closed", { - scope, - workspaceId, - }); + const unlistenPromise = currentWindow.onCloseRequested((event) => { + event.preventDefault(); + void closeWorkspaceWindow(); }); return () => { void unlistenPromise.then((unlisten) => unlisten()); }; - }, [currentWindow, scope, workspaceId]); + }, [closeWorkspaceWindow, currentWindow]); const titleBarClass = isDark ? "flex h-10 shrink-0 items-center border-b border-white/10 bg-[#071421] text-slate-100" @@ -151,7 +162,7 @@ export function WorkspaceWindowPage({ theme }: WorkspaceWindowPageProps) { - - - -