feat: implementar sidebar colapsavel profissional
Substitui navegacao por header/menu mobile por sidebar lateral colapsavel com toggle, persistencia localStorage e responsividade automatica. - Novo componente Layout.tsx com sidebar, tooltips e overlay mobile - Estado colapsado persistido em localStorage (desktop) - Colapsada por defeito em mobile com drawer animado - Animacoes suaves via framer-motion (spring) - Removida navegacao duplicada de App.tsx, Monitor.tsx e Financial.tsx - Rotas envolvidas pelo Layout via React Router Outlet Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
97
src/App.tsx
97
src/App.tsx
@@ -22,11 +22,8 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Timer,
|
Timer,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
LayoutDashboard,
|
|
||||||
Activity,
|
Activity,
|
||||||
Target,
|
Target,
|
||||||
Menu,
|
|
||||||
X,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
@@ -422,7 +419,6 @@ function App() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setRefreshing(true)
|
setRefreshing(true)
|
||||||
@@ -459,7 +455,7 @@ function App() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-mesh flex items-center justify-center">
|
<div className="flex items-center justify-center py-20">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
@@ -481,7 +477,7 @@ function App() {
|
|||||||
// Error state (Vulnerabilidade 3.2)
|
// Error state (Vulnerabilidade 3.2)
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-mesh flex items-center justify-center">
|
<div className="flex items-center justify-center py-20">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
@@ -510,49 +506,10 @@ function App() {
|
|||||||
if (!data) return null
|
if (!data) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-mesh">
|
<div className="max-w-[1800px] mx-auto px-6 lg:px-8 py-8">
|
||||||
<div className="bg-grid min-h-screen">
|
{/* Header com refresh */}
|
||||||
{/* Header */}
|
<div className="flex items-center justify-between mb-2">
|
||||||
<header className="sticky top-0 z-50 border-b border-white/5 bg-[#0a0a0f]/90 backdrop-blur-2xl">
|
<div />
|
||||||
<div className="max-w-[1800px] mx-auto px-6 lg:px-8 py-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<motion.div
|
|
||||||
whileHover={{ scale: 1.05, rotate: 5 }}
|
|
||||||
className="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500 to-violet-600 flex items-center justify-center shadow-lg shadow-brand-500/30"
|
|
||||||
>
|
|
||||||
<Zap className="w-6 h-6 text-white" />
|
|
||||||
</motion.div>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-bold text-white tracking-tight">Dashboard Descomplicar</h1>
|
|
||||||
<p className="text-xs text-zinc-500">Painel de Gestão</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav className="hidden md:flex items-center gap-1 bg-white/5 rounded-xl p-1">
|
|
||||||
<a href="#" className="px-4 py-2 rounded-lg bg-brand-500 text-white text-sm font-medium flex items-center gap-2">
|
|
||||||
<LayoutDashboard className="w-4 h-4" />
|
|
||||||
Dashboard
|
|
||||||
</a>
|
|
||||||
<a href="/monitor" className="px-4 py-2 rounded-lg text-zinc-400 hover:text-white hover:bg-white/10 text-sm font-medium transition-all flex items-center gap-2">
|
|
||||||
<Activity className="w-4 h-4" />
|
|
||||||
Monitor
|
|
||||||
</a>
|
|
||||||
<a href="/financial" className="px-4 py-2 rounded-lg text-zinc-400 hover:text-white hover:bg-white/10 text-sm font-medium transition-all flex items-center gap-2">
|
|
||||||
<CreditCard className="w-4 h-4" />
|
|
||||||
Financeiro
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<motion.button
|
|
||||||
whileHover={{ scale: 1.05 }}
|
|
||||||
whileTap={{ scale: 0.95 }}
|
|
||||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
|
||||||
className="md:hidden p-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 transition-all"
|
|
||||||
>
|
|
||||||
{mobileMenuOpen ? <X className="w-5 h-5 text-zinc-400" /> : <Menu className="w-5 h-5 text-zinc-400" />}
|
|
||||||
</motion.button>
|
|
||||||
<motion.button
|
<motion.button
|
||||||
whileHover={{ scale: 1.05 }}
|
whileHover={{ scale: 1.05 }}
|
||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
@@ -562,41 +519,7 @@ function App() {
|
|||||||
>
|
>
|
||||||
<RefreshCw className={`w-5 h-5 text-zinc-400 ${refreshing ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`w-5 h-5 text-zinc-400 ${refreshing ? 'animate-spin' : ''}`} />
|
||||||
</motion.button>
|
</motion.button>
|
||||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-brand-500 to-violet-600 ring-2 ring-white/10" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Mobile Navigation */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{mobileMenuOpen && (
|
|
||||||
<motion.nav
|
|
||||||
initial={{ opacity: 0, height: 0 }}
|
|
||||||
animate={{ opacity: 1, height: 'auto' }}
|
|
||||||
exit={{ opacity: 0, height: 0 }}
|
|
||||||
className="md:hidden border-b border-white/5 bg-[#0a0a0f]/95 backdrop-blur-2xl overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="px-6 py-3 flex flex-col gap-1">
|
|
||||||
<a href="#" onClick={() => setMobileMenuOpen(false)} className="px-4 py-3 rounded-lg bg-brand-500 text-white text-sm font-medium flex items-center gap-3">
|
|
||||||
<LayoutDashboard className="w-4 h-4" />
|
|
||||||
Dashboard
|
|
||||||
</a>
|
|
||||||
<a href="/monitor" onClick={() => setMobileMenuOpen(false)} className="px-4 py-3 rounded-lg text-zinc-400 hover:text-white hover:bg-white/10 text-sm font-medium transition-all flex items-center gap-3">
|
|
||||||
<Activity className="w-4 h-4" />
|
|
||||||
Monitor
|
|
||||||
</a>
|
|
||||||
<a href="/financial" onClick={() => setMobileMenuOpen(false)} className="px-4 py-3 rounded-lg text-zinc-400 hover:text-white hover:bg-white/10 text-sm font-medium transition-all flex items-center gap-3">
|
|
||||||
<CreditCard className="w-4 h-4" />
|
|
||||||
Financeiro
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</motion.nav>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<main className="max-w-[1800px] mx-auto px-6 lg:px-8 py-8">
|
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
<motion.div
|
<motion.div
|
||||||
key="dashboard"
|
key="dashboard"
|
||||||
@@ -880,24 +803,18 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="border-t border-white/5 mt-12">
|
<footer className="border-t border-white/5 mt-12">
|
||||||
<div className="max-w-[1800px] mx-auto px-6 lg:px-8 py-6">
|
<div className="flex items-center justify-between text-sm text-zinc-500 py-6">
|
||||||
<div className="flex items-center justify-between text-sm text-zinc-500">
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
<span>Dashboard Descomplicar v3.0</span>
|
<span>Dashboard Descomplicar v3.0</span>
|
||||||
<span className="text-zinc-700">·</span>
|
|
||||||
<span>Painel de Gestão</span>
|
|
||||||
</div>
|
</div>
|
||||||
<span>Actualizado: {new Date().toLocaleString('pt-PT')}</span>
|
<span>Actualizado: {new Date().toLocaleString('pt-PT')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
269
src/components/Layout.tsx
Normal file
269
src/components/Layout.tsx
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { NavLink, Outlet } from 'react-router-dom'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Activity,
|
||||||
|
CreditCard,
|
||||||
|
Zap,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Menu,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
const SIDEBAR_KEY = 'dash-sidebar-collapsed'
|
||||||
|
const MOBILE_BREAKPOINT = 768
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
to: string
|
||||||
|
label: string
|
||||||
|
icon: React.ElementType
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAV_ITEMS: NavItem[] = [
|
||||||
|
{ to: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
|
{ to: '/monitor', label: 'Monitor', icon: Activity },
|
||||||
|
{ to: '/financial', label: 'Financeiro', icon: CreditCard },
|
||||||
|
]
|
||||||
|
|
||||||
|
function useIsMobile() {
|
||||||
|
const [isMobile, setIsMobile] = useState(
|
||||||
|
typeof window !== 'undefined' ? window.innerWidth < MOBILE_BREAKPOINT : false
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onResize = () => setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||||
|
window.addEventListener('resize', onResize)
|
||||||
|
return () => window.removeEventListener('resize', onResize)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return isMobile
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitialCollapsed(isMobile: boolean): boolean {
|
||||||
|
if (isMobile) return true
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(SIDEBAR_KEY)
|
||||||
|
if (stored !== null) return stored === 'true'
|
||||||
|
} catch {
|
||||||
|
// localStorage indisponivel
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const [collapsed, setCollapsed] = useState(() => getInitialCollapsed(isMobile))
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false)
|
||||||
|
|
||||||
|
// Colapsar automaticamente em mobile
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMobile) {
|
||||||
|
setCollapsed(true)
|
||||||
|
setMobileOpen(false)
|
||||||
|
}
|
||||||
|
}, [isMobile])
|
||||||
|
|
||||||
|
// Persistir estado em localStorage (apenas desktop)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMobile) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SIDEBAR_KEY, String(collapsed))
|
||||||
|
} catch {
|
||||||
|
// localStorage indisponivel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [collapsed, isMobile])
|
||||||
|
|
||||||
|
const toggleSidebar = () => {
|
||||||
|
if (isMobile) {
|
||||||
|
setMobileOpen(prev => !prev)
|
||||||
|
} else {
|
||||||
|
setCollapsed(prev => !prev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeMobileMenu = () => setMobileOpen(false)
|
||||||
|
|
||||||
|
const sidebarWidth = collapsed ? 72 : 240
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-mesh">
|
||||||
|
<div className="bg-grid min-h-screen flex">
|
||||||
|
|
||||||
|
{/* Overlay mobile */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isMobile && mobileOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={closeMobileMenu}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{(!isMobile || mobileOpen) && (
|
||||||
|
<motion.aside
|
||||||
|
initial={isMobile ? { x: -240 } : false}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
exit={isMobile ? { x: -240 } : undefined}
|
||||||
|
transition={{ type: 'spring', stiffness: 400, damping: 35 }}
|
||||||
|
style={!isMobile ? { width: sidebarWidth } : undefined}
|
||||||
|
className={`
|
||||||
|
${isMobile
|
||||||
|
? 'fixed top-0 left-0 bottom-0 z-50 w-60'
|
||||||
|
: 'relative flex-shrink-0 z-30'
|
||||||
|
}
|
||||||
|
bg-[#0a0a0f]/95 backdrop-blur-2xl border-r border-white/5
|
||||||
|
flex flex-col transition-[width] duration-300 ease-in-out
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex items-center gap-3 px-4 py-5 border-b border-white/5 min-h-[72px]">
|
||||||
|
<motion.div
|
||||||
|
whileHover={{ scale: 1.05, rotate: 5 }}
|
||||||
|
className="w-10 h-10 rounded-xl bg-gradient-to-br from-brand-500 to-violet-600 flex items-center justify-center shadow-lg shadow-brand-500/30 flex-shrink-0"
|
||||||
|
>
|
||||||
|
<Zap className="w-5 h-5 text-white" />
|
||||||
|
</motion.div>
|
||||||
|
<AnimatePresence>
|
||||||
|
{(!collapsed || isMobile) && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, width: 0 }}
|
||||||
|
animate={{ opacity: 1, width: 'auto' }}
|
||||||
|
exit={{ opacity: 0, width: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="overflow-hidden whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<h1 className="text-base font-bold text-white tracking-tight leading-tight">Descomplicar</h1>
|
||||||
|
<p className="text-[10px] text-zinc-500">Painel de Gestao</p>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Fechar em mobile */}
|
||||||
|
{isMobile && (
|
||||||
|
<button
|
||||||
|
onClick={closeMobileMenu}
|
||||||
|
className="ml-auto p-1.5 rounded-lg hover:bg-white/10 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-zinc-400" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navegacao */}
|
||||||
|
<nav className="flex-1 px-3 py-4 space-y-1">
|
||||||
|
{NAV_ITEMS.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
end={item.to === '/'}
|
||||||
|
onClick={isMobile ? closeMobileMenu : undefined}
|
||||||
|
className={({ isActive }) => `
|
||||||
|
flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium
|
||||||
|
transition-all duration-200 group relative
|
||||||
|
${isActive
|
||||||
|
? 'bg-brand-500/15 text-white border border-brand-500/30'
|
||||||
|
: 'text-zinc-400 hover:text-white hover:bg-white/5 border border-transparent'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<>
|
||||||
|
<item.icon className={`w-5 h-5 flex-shrink-0 ${isActive ? 'text-brand-400' : 'text-zinc-500 group-hover:text-zinc-300'}`} />
|
||||||
|
<AnimatePresence>
|
||||||
|
{(!collapsed || isMobile) && (
|
||||||
|
<motion.span
|
||||||
|
initial={{ opacity: 0, width: 0 }}
|
||||||
|
animate={{ opacity: 1, width: 'auto' }}
|
||||||
|
exit={{ opacity: 0, width: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="overflow-hidden whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Tooltip quando colapsado (desktop) */}
|
||||||
|
{collapsed && !isMobile && (
|
||||||
|
<div className="absolute left-full ml-3 px-2.5 py-1.5 rounded-lg bg-zinc-800 text-white text-xs font-medium whitespace-nowrap opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 pointer-events-none shadow-xl border border-white/10 z-50">
|
||||||
|
{item.label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Toggle (apenas desktop) */}
|
||||||
|
{!isMobile && (
|
||||||
|
<div className="px-3 py-4 border-t border-white/5">
|
||||||
|
<button
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 transition-all text-zinc-400 hover:text-white"
|
||||||
|
title={collapsed ? 'Expandir sidebar' : 'Colapsar sidebar'}
|
||||||
|
>
|
||||||
|
{collapsed
|
||||||
|
? <ChevronRight className="w-4 h-4" />
|
||||||
|
: <ChevronLeft className="w-4 h-4" />
|
||||||
|
}
|
||||||
|
<AnimatePresence>
|
||||||
|
{!collapsed && (
|
||||||
|
<motion.span
|
||||||
|
initial={{ opacity: 0, width: 0 }}
|
||||||
|
animate={{ opacity: 1, width: 'auto' }}
|
||||||
|
exit={{ opacity: 0, width: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="text-xs font-medium overflow-hidden whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Colapsar
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.aside>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Conteudo principal */}
|
||||||
|
<div className="flex-1 min-w-0 flex flex-col">
|
||||||
|
{/* Header mobile com botao de menu */}
|
||||||
|
{isMobile && (
|
||||||
|
<header className="sticky top-0 z-30 border-b border-white/5 bg-[#0a0a0f]/90 backdrop-blur-2xl">
|
||||||
|
<div className="flex items-center gap-3 px-4 py-3">
|
||||||
|
<button
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 transition-all"
|
||||||
|
>
|
||||||
|
<Menu className="w-5 h-5 text-zinc-400" />
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-brand-500 to-violet-600 flex items-center justify-center shadow-lg shadow-brand-500/20">
|
||||||
|
<Zap className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-bold text-white">Descomplicar</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Outlet para as paginas */}
|
||||||
|
<main className="flex-1">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import './index.css'
|
|||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
import Monitor from './pages/Monitor.tsx'
|
import Monitor from './pages/Monitor.tsx'
|
||||||
import Financial from './pages/Financial.tsx'
|
import Financial from './pages/Financial.tsx'
|
||||||
|
import Layout from './components/Layout.tsx'
|
||||||
import { oidcConfig } from './auth/config.ts'
|
import { oidcConfig } from './auth/config.ts'
|
||||||
import { AuthWrapper } from './auth/AuthWrapper.tsx'
|
import { AuthWrapper } from './auth/AuthWrapper.tsx'
|
||||||
|
|
||||||
@@ -15,10 +16,12 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthWrapper>
|
<AuthWrapper>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
<Route element={<Layout />}>
|
||||||
<Route path="/" element={<App />} />
|
<Route path="/" element={<App />} />
|
||||||
<Route path="/monitor" element={<Monitor />} />
|
<Route path="/monitor" element={<Monitor />} />
|
||||||
<Route path="/financial" element={<Financial />} />
|
<Route path="/financial" element={<Financial />} />
|
||||||
<Route path="/callback" element={<App />} />
|
<Route path="/callback" element={<App />} />
|
||||||
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</AuthWrapper>
|
</AuthWrapper>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -1,20 +1,15 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { Link } from 'react-router-dom'
|
|
||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
|
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import {
|
import {
|
||||||
Zap,
|
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
ArrowLeft,
|
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
TrendingDown,
|
TrendingDown,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
Receipt,
|
Receipt,
|
||||||
PiggyBank,
|
PiggyBank,
|
||||||
Activity,
|
|
||||||
LayoutDashboard,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
interface FinancialData {
|
interface FinancialData {
|
||||||
@@ -105,7 +100,7 @@ export default function Financial() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-mesh flex items-center justify-center">
|
<div className="flex items-center justify-center py-20">
|
||||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||||
<DollarSign className="w-12 h-12 text-brand-400 mx-auto mb-4 animate-pulse" />
|
<DollarSign className="w-12 h-12 text-brand-400 mx-auto mb-4 animate-pulse" />
|
||||||
<p className="text-zinc-400">A carregar dados financeiros...</p>
|
<p className="text-zinc-400">A carregar dados financeiros...</p>
|
||||||
@@ -127,26 +122,14 @@ export default function Financial() {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-mesh">
|
<div className="max-w-[1600px] mx-auto px-6 lg:px-8 py-8">
|
||||||
<div className="bg-grid min-h-screen">
|
{/* Header com resumo e refresh */}
|
||||||
{/* Header */}
|
<div className="flex items-center justify-between mb-6">
|
||||||
<header className="sticky top-0 z-50 border-b border-white/5 bg-[#0a0a0f]/90 backdrop-blur-2xl">
|
|
||||||
<div className="max-w-[1600px] mx-auto px-6 lg:px-8 py-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<motion.div
|
|
||||||
whileHover={{ scale: 1.05 }}
|
|
||||||
className="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500 to-violet-600 flex items-center justify-center shadow-lg shadow-brand-500/30"
|
|
||||||
>
|
|
||||||
<Zap className="w-6 h-6 text-white" />
|
|
||||||
</motion.div>
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-white tracking-tight">Financeiro</h1>
|
<h2 className="text-xl font-bold text-white tracking-tight">Financeiro</h2>
|
||||||
<p className="text-xs text-zinc-500">Vendas e Despesas {new Date().getFullYear()}</p>
|
<p className="text-xs text-zinc-500">Vendas e Despesas {new Date().getFullYear()}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex items-center gap-3">
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className={`px-4 py-2 rounded-full text-sm font-semibold ${lucroColor}`}>
|
<span className={`px-4 py-2 rounded-full text-sm font-semibold ${lucroColor}`}>
|
||||||
{lucroLabel}: {formatEUR(Math.abs(data.lucro_ano))}
|
{lucroLabel}: {formatEUR(Math.abs(data.lucro_ano))}
|
||||||
</span>
|
</span>
|
||||||
@@ -159,27 +142,8 @@ export default function Financial() {
|
|||||||
>
|
>
|
||||||
<RefreshCw className={`w-5 h-5 text-zinc-400 ${refreshing ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`w-5 h-5 text-zinc-400 ${refreshing ? 'animate-spin' : ''}`} />
|
||||||
</motion.button>
|
</motion.button>
|
||||||
<Link
|
|
||||||
to="/monitor"
|
|
||||||
className="hidden md:flex items-center gap-2 px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-zinc-400 hover:text-white transition-all text-sm"
|
|
||||||
>
|
|
||||||
<Activity className="w-4 h-4" />
|
|
||||||
Monitor
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/"
|
|
||||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-zinc-400 hover:text-white transition-all text-sm"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="w-4 h-4" />
|
|
||||||
<span className="hidden md:inline">Dashboard</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<main className="max-w-[1600px] mx-auto px-6 lg:px-8 py-8">
|
|
||||||
<motion.div variants={containerVariants} initial="hidden" animate="show">
|
<motion.div variants={containerVariants} initial="hidden" animate="show">
|
||||||
{/* Summary Cards */}
|
{/* Summary Cards */}
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-5 mb-8">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-5 mb-8">
|
||||||
@@ -280,8 +244,6 @@ export default function Financial() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { Link } from 'react-router-dom'
|
|
||||||
import {
|
import {
|
||||||
Server,
|
Server,
|
||||||
Wifi,
|
Wifi,
|
||||||
@@ -11,11 +10,8 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Zap,
|
|
||||||
ArrowLeft,
|
|
||||||
Activity,
|
Activity,
|
||||||
Clock,
|
Clock,
|
||||||
TrendingUp,
|
|
||||||
Wrench,
|
Wrench,
|
||||||
Code,
|
Code,
|
||||||
Network,
|
Network,
|
||||||
@@ -363,7 +359,7 @@ export default function Monitor() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-mesh flex items-center justify-center">
|
<div className="flex items-center justify-center py-20">
|
||||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||||
<Activity className="w-12 h-12 text-brand-400 mx-auto mb-4 animate-pulse" />
|
<Activity className="w-12 h-12 text-brand-400 mx-auto mb-4 animate-pulse" />
|
||||||
<p className="text-zinc-400">A carregar monitorizacao...</p>
|
<p className="text-zinc-400">A carregar monitorizacao...</p>
|
||||||
@@ -392,26 +388,14 @@ export default function Monitor() {
|
|||||||
}[data.overall]
|
}[data.overall]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-mesh">
|
<div className="max-w-[1600px] mx-auto px-6 lg:px-8 py-8">
|
||||||
<div className="bg-grid min-h-screen">
|
{/* Header com status e refresh */}
|
||||||
{/* Header */}
|
<div className="flex items-center justify-between mb-6">
|
||||||
<header className="sticky top-0 z-50 border-b border-white/5 bg-[#0a0a0f]/90 backdrop-blur-2xl">
|
|
||||||
<div className="max-w-[1600px] mx-auto px-6 lg:px-8 py-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<motion.div
|
|
||||||
whileHover={{ scale: 1.05 }}
|
|
||||||
className="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500 to-violet-600 flex items-center justify-center shadow-lg shadow-brand-500/30"
|
|
||||||
>
|
|
||||||
<Zap className="w-6 h-6 text-white" />
|
|
||||||
</motion.div>
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-white tracking-tight">Monitorizacao</h1>
|
<h2 className="text-xl font-bold text-white tracking-tight">Monitorizacao</h2>
|
||||||
<p className="text-xs text-zinc-500">Cluster Proxmox Descomplicar</p>
|
<p className="text-xs text-zinc-500">Cluster Proxmox Descomplicar</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex items-center gap-3">
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className={`px-4 py-2 rounded-full text-sm font-semibold ${overallColor}`}>
|
<span className={`px-4 py-2 rounded-full text-sm font-semibold ${overallColor}`}>
|
||||||
{overallLabel}
|
{overallLabel}
|
||||||
</span>
|
</span>
|
||||||
@@ -424,27 +408,8 @@ export default function Monitor() {
|
|||||||
>
|
>
|
||||||
<RefreshCw className={`w-5 h-5 text-zinc-400 ${refreshing ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`w-5 h-5 text-zinc-400 ${refreshing ? 'animate-spin' : ''}`} />
|
||||||
</motion.button>
|
</motion.button>
|
||||||
<Link
|
|
||||||
to="/financial"
|
|
||||||
className="hidden md:flex items-center gap-2 px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-zinc-400 hover:text-white transition-all text-sm"
|
|
||||||
>
|
|
||||||
<TrendingUp className="w-4 h-4" />
|
|
||||||
Financeiro
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/"
|
|
||||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-zinc-400 hover:text-white transition-all text-sm"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="w-4 h-4" />
|
|
||||||
Dashboard
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<main className="max-w-[1600px] mx-auto px-6 lg:px-8 py-8">
|
|
||||||
<motion.div variants={containerVariants} initial="hidden" animate="show">
|
<motion.div variants={containerVariants} initial="hidden" animate="show">
|
||||||
|
|
||||||
{/* Section 1: Cluster Overview */}
|
{/* Section 1: Cluster Overview */}
|
||||||
@@ -661,8 +626,6 @@ export default function Monitor() {
|
|||||||
<span>Auto-refresh: 60s</span>
|
<span>Auto-refresh: 60s</span>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user