feat(layout): implement AppShell, Sidebar, Header, Organizations, and Dashboard
This commit is contained in:
parent
8015b3ff77
commit
c16992e049
8 changed files with 1168 additions and 0 deletions
|
|
@ -2,15 +2,42 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { AuthProvider } from './contexts/AuthContext'
|
import { AuthProvider } from './contexts/AuthContext'
|
||||||
import { LandingPage } from './pages/LandingPage'
|
import { LandingPage } from './pages/LandingPage'
|
||||||
import { AuthPage } from './pages/AuthPage'
|
import { AuthPage } from './pages/AuthPage'
|
||||||
|
import { ProtectedRoute } from './components/layout/ProtectedRoute'
|
||||||
|
import { AppShell } from './components/layout/AppShell'
|
||||||
|
import { DashboardPage } from './pages/DashboardPage'
|
||||||
|
import { OrganizationsPage } from './pages/OrganizationsPage'
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
{/* Rotas Públicas */}
|
||||||
<Route path="/" element={<LandingPage />} />
|
<Route path="/" element={<LandingPage />} />
|
||||||
<Route path="/login" element={<AuthPage />} />
|
<Route path="/login" element={<AuthPage />} />
|
||||||
<Route path="/register" element={<AuthPage />} />
|
<Route path="/register" element={<AuthPage />} />
|
||||||
|
|
||||||
|
{/* Rotas Protegidas dentro do AppShell */}
|
||||||
|
<Route
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AppShell />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route path="/dashboard" element={<DashboardPage />} />
|
||||||
|
<Route path="/organizations" element={<OrganizationsPage />} />
|
||||||
|
<Route path="/tasks" element={<DashboardPage />} />
|
||||||
|
<Route path="/tasks/new" element={<DashboardPage />} />
|
||||||
|
<Route path="/projects" element={<DashboardPage />} />
|
||||||
|
<Route path="/categories" element={<DashboardPage />} />
|
||||||
|
<Route path="/analytics" element={<DashboardPage />} />
|
||||||
|
<Route path="/members" element={<OrganizationsPage />} />
|
||||||
|
<Route path="/organization/settings" element={<OrganizationsPage />} />
|
||||||
|
<Route path="/profile" element={<DashboardPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* Fallback */}
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|
|
||||||
26
new-frontend/src/components/layout/AppShell.tsx
Normal file
26
new-frontend/src/components/layout/AppShell.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Outlet } from 'react-router-dom'
|
||||||
|
import { Sidebar } from './Sidebar'
|
||||||
|
import { Header } from './Header'
|
||||||
|
|
||||||
|
export const AppShell = () => {
|
||||||
|
const [isMobileOpen, setIsMobileOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-app-bg text-text-main flex font-sans selection:bg-brand-soft selection:text-brand">
|
||||||
|
{/* Sidebar Navigation */}
|
||||||
|
<Sidebar
|
||||||
|
isMobileOpen={isMobileOpen}
|
||||||
|
onCloseMobile={() => setIsMobileOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
|
<Header onToggleMobileSidebar={() => setIsMobileOpen(true)} />
|
||||||
|
<main className="flex-1 p-4 sm:p-6 lg:p-8 max-w-7xl w-full mx-auto animate-in fade-in duration-200">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
273
new-frontend/src/components/layout/Header.tsx
Normal file
273
new-frontend/src/components/layout/Header.tsx
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
ChipLabel,
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarImage,
|
||||||
|
} from '@heroui/react'
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
ChevronDown,
|
||||||
|
Plus,
|
||||||
|
Clock,
|
||||||
|
User,
|
||||||
|
LogOut,
|
||||||
|
Settings,
|
||||||
|
PlusCircle,
|
||||||
|
Menu as MenuIcon,
|
||||||
|
Check,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
onToggleMobileSidebar: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Header = ({ onToggleMobileSidebar }: HeaderProps) => {
|
||||||
|
const { user, activeOrg, setActiveOrg, logout } = useAuth()
|
||||||
|
const [showOrgDropdown, setShowOrgDropdown] = useState(false)
|
||||||
|
const [showUserDropdown, setShowUserDropdown] = useState(false)
|
||||||
|
|
||||||
|
const orgRef = useRef<HTMLDivElement>(null)
|
||||||
|
const userRef = useRef<HTMLDivElement>(null)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
// Fechar dropdowns ao clicar fora
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (orgRef.current && !orgRef.current.contains(e.target as Node)) {
|
||||||
|
setShowOrgDropdown(false)
|
||||||
|
}
|
||||||
|
if (userRef.current && !userRef.current.contains(e.target as Node)) {
|
||||||
|
setShowUserDropdown(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const userInitials = user?.name
|
||||||
|
? user.name
|
||||||
|
.split(' ')
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
: 'U'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-40 h-18 border-b border-border-soft bg-surface/85 backdrop-blur-md px-4 sm:px-6 lg:px-8 flex items-center justify-between transition-all">
|
||||||
|
{/* Left Area: Mobile Menu Trigger + Workspace Switcher */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleMobileSidebar}
|
||||||
|
className="lg:hidden p-2 rounded-xl text-text-muted hover:text-text-main hover:bg-surface-muted transition-colors"
|
||||||
|
aria-label="Abrir menu"
|
||||||
|
>
|
||||||
|
<MenuIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Workspace Selector Dropdown */}
|
||||||
|
<div ref={orgRef} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowOrgDropdown(!showOrgDropdown)
|
||||||
|
setShowUserDropdown(false)
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2.5 px-3 py-1.5 rounded-xl border border-border-soft bg-surface-muted/60 hover:bg-surface-muted text-text-main transition-all group"
|
||||||
|
>
|
||||||
|
<div className="w-7 h-7 rounded-lg bg-brand-soft text-brand flex items-center justify-center font-bold text-xs shadow-xs">
|
||||||
|
<Building2 className="w-4 h-4 text-brand" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left hidden sm:block max-w-[140px] md:max-w-[180px]">
|
||||||
|
<span className="block text-xs font-bold truncate leading-tight">
|
||||||
|
{activeOrg?.name || 'Selecione a Org'}
|
||||||
|
</span>
|
||||||
|
<span className="block text-[10px] font-semibold text-text-muted uppercase tracking-wider leading-none truncate">
|
||||||
|
{activeOrg?.roles[0] || 'Membro'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ChevronDown
|
||||||
|
className={`w-3.5 h-3.5 text-text-muted transition-transform duration-200 ${
|
||||||
|
showOrgDropdown ? 'rotate-180 text-brand' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Org Dropdown Menu */}
|
||||||
|
{showOrgDropdown && (
|
||||||
|
<Card className="absolute left-0 mt-2 w-64 bg-surface border border-border-soft rounded-2xl p-2 shadow-panel z-50">
|
||||||
|
<div className="px-3 py-2 border-b border-border-soft mb-1 flex items-center justify-between">
|
||||||
|
<span className="text-[11px] font-extrabold uppercase tracking-widest text-text-muted">
|
||||||
|
Workspaces
|
||||||
|
</span>
|
||||||
|
<Chip className="bg-brand-soft text-brand text-[10px] font-bold px-1.5 py-0.2">
|
||||||
|
<ChipLabel>{user?.organizations?.length || 0}</ChipLabel>
|
||||||
|
</Chip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-56 overflow-y-auto space-y-1 py-1">
|
||||||
|
{user?.organizations?.map((org) => {
|
||||||
|
const isActive = org.id === activeOrg?.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={org.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setActiveOrg(org.id)
|
||||||
|
setShowOrgDropdown(false)
|
||||||
|
}}
|
||||||
|
className={`w-full flex items-center justify-between p-2 rounded-xl text-left text-xs transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-brand-soft text-brand font-bold'
|
||||||
|
: 'text-text-muted hover:text-text-main hover:bg-surface-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5 truncate">
|
||||||
|
<div
|
||||||
|
className={`w-7 h-7 rounded-lg flex items-center justify-center font-bold text-xs shrink-0 ${
|
||||||
|
isActive
|
||||||
|
? 'bg-brand text-white'
|
||||||
|
: 'bg-surface-muted text-text-muted border border-border-soft'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{org.name.slice(0, 2).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="truncate">
|
||||||
|
<p className="truncate leading-tight">{org.name}</p>
|
||||||
|
<p className="text-[10px] opacity-75 font-medium">
|
||||||
|
{org.roles.join(', ')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isActive && <Check className="w-4 h-4 text-brand shrink-0" />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border-soft pt-1.5 mt-1">
|
||||||
|
<Link
|
||||||
|
to="/organizations"
|
||||||
|
onClick={() => setShowOrgDropdown(false)}
|
||||||
|
className="flex items-center gap-2 p-2 rounded-xl text-xs font-semibold text-brand hover:bg-brand-soft/50 transition-colors"
|
||||||
|
>
|
||||||
|
<PlusCircle className="w-4 h-4" />
|
||||||
|
Gerenciar / Criar Workspace
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Area: Active Tracker Pill + Action Button + User Profile */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Active Timer Pill Banner */}
|
||||||
|
<div className="hidden md:flex items-center gap-2 px-3 py-1.5 rounded-xl bg-surface-muted border border-border-soft text-xs text-text-muted">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
|
<Clock className="w-3.5 h-3.5 text-text-muted" />
|
||||||
|
<span className="font-mono font-semibold text-text-main">
|
||||||
|
Modo Foco Ativo
|
||||||
|
</span>
|
||||||
|
<Chip className="bg-accent-soft text-accent text-[10px] font-bold px-1.5 py-0.2">
|
||||||
|
<ChipLabel>v3.0</ChipLabel>
|
||||||
|
</Chip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Task Button */}
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate('/tasks/new')}
|
||||||
|
className="bg-brand hover:bg-brand-strong text-white font-bold text-xs px-3.5 py-2 rounded-xl shadow-md shadow-brand/20 transition-all flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">Nova Tarefa</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* User Profile Menu Dropdown */}
|
||||||
|
<div ref={userRef} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowUserDropdown(!showUserDropdown)
|
||||||
|
setShowOrgDropdown(false)
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 p-1 rounded-full hover:ring-2 hover:ring-brand/30 transition-all"
|
||||||
|
aria-label="Menu do usuário"
|
||||||
|
>
|
||||||
|
<Avatar className="w-9 h-9 rounded-full ring-2 ring-border-soft">
|
||||||
|
{user?.photo ? (
|
||||||
|
<AvatarImage src={user.photo} alt={user.name} />
|
||||||
|
) : null}
|
||||||
|
<AvatarFallback className="bg-brand-soft text-brand font-bold text-xs">
|
||||||
|
{userInitials}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* User Dropdown Menu */}
|
||||||
|
{showUserDropdown && (
|
||||||
|
<Card className="absolute right-0 mt-2 w-60 bg-surface border border-border-soft rounded-2xl p-2 shadow-panel z-50">
|
||||||
|
<div className="p-3 border-b border-border-soft mb-1">
|
||||||
|
<p className="text-sm font-bold text-text-main truncate">
|
||||||
|
{user?.name || 'Usuário'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-text-muted truncate mt-0.5">
|
||||||
|
{user?.email}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex items-center gap-1.5">
|
||||||
|
<Chip className="bg-accent-soft text-accent text-[10px] font-bold px-2 py-0.5">
|
||||||
|
<ChipLabel>{activeOrg?.roles[0] || 'MEMBRO'}</ChipLabel>
|
||||||
|
</Chip>
|
||||||
|
<span className="text-[10px] text-text-muted">
|
||||||
|
{activeOrg?.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-0.5 py-1">
|
||||||
|
<Link
|
||||||
|
to="/profile"
|
||||||
|
onClick={() => setShowUserDropdown(false)}
|
||||||
|
className="flex items-center gap-2.5 p-2 rounded-xl text-xs font-semibold text-text-muted hover:text-text-main hover:bg-surface-muted transition-colors"
|
||||||
|
>
|
||||||
|
<User className="w-4 h-4 text-brand" />
|
||||||
|
Meu Perfil & Avatar
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="/organization/settings"
|
||||||
|
onClick={() => setShowUserDropdown(false)}
|
||||||
|
className="flex items-center gap-2.5 p-2 rounded-xl text-xs font-semibold text-text-muted hover:text-text-main hover:bg-surface-muted transition-colors"
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4 text-brand" />
|
||||||
|
Configurações do Workspace
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border-soft pt-1 mt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowUserDropdown(false)
|
||||||
|
logout()
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-2.5 p-2 rounded-xl text-xs font-bold text-danger hover:bg-danger-soft transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
Sair da Conta
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
32
new-frontend/src/components/layout/ProtectedRoute.tsx
Normal file
32
new-frontend/src/components/layout/ProtectedRoute.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { Navigate, useLocation } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
|
|
||||||
|
export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const { user, isAuthenticated, isLoading } = useAuth()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-app-bg flex flex-col items-center justify-center gap-4 text-text-main font-sans">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-accent to-brand flex items-center justify-center text-xl shadow-md animate-pulse">
|
||||||
|
🐝
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold text-text-muted">
|
||||||
|
<div className="w-4 h-4 border-2 border-brand/30 border-t-brand rounded-full animate-spin" />
|
||||||
|
<span>Carregando seu workspace...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" state={{ from: location }} replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se logado mas sem organizações cadastradas, direciona para o onboarding/criação de workspace
|
||||||
|
if (user && user.organizations.length === 0 && location.pathname !== '/organizations') {
|
||||||
|
return <Navigate to="/organizations" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
152
new-frontend/src/components/layout/Sidebar.tsx
Normal file
152
new-frontend/src/components/layout/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
import { NavLink } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
ListTodo,
|
||||||
|
FolderKanban,
|
||||||
|
Tags,
|
||||||
|
BarChart3,
|
||||||
|
Users,
|
||||||
|
Settings,
|
||||||
|
X,
|
||||||
|
Building2,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Chip, ChipLabel } from '@heroui/react'
|
||||||
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
isMobileOpen: boolean
|
||||||
|
onCloseMobile: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Sidebar = ({ isMobileOpen, onCloseMobile }: SidebarProps) => {
|
||||||
|
const { activeOrg } = useAuth()
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{
|
||||||
|
group: 'Principal',
|
||||||
|
links: [
|
||||||
|
{ to: '/dashboard', label: 'Meu Dashboard', icon: <LayoutDashboard className="w-4 h-4" /> },
|
||||||
|
{ to: '/tasks', label: 'Tarefas & Board', icon: <ListTodo className="w-4 h-4" /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'Estrutura',
|
||||||
|
links: [
|
||||||
|
{ to: '/projects', label: 'Projetos', icon: <FolderKanban className="w-4 h-4" /> },
|
||||||
|
{ to: '/categories', label: 'Categorias', icon: <Tags className="w-4 h-4" /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'Gestão & Equipe',
|
||||||
|
links: [
|
||||||
|
{ to: '/analytics', label: 'Analytics da Org', icon: <BarChart3 className="w-4 h-4" /> },
|
||||||
|
{ to: '/members', label: 'Membros & Squads', icon: <Users className="w-4 h-4" /> },
|
||||||
|
{ to: '/organization/settings', label: 'Configurações', icon: <Settings className="w-4 h-4" /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const navContent = (
|
||||||
|
<div className="flex flex-col h-full bg-surface border-r border-border-soft select-none">
|
||||||
|
{/* Brand & Logo */}
|
||||||
|
<div className="h-18 px-6 flex items-center justify-between border-b border-border-soft">
|
||||||
|
<NavLink to="/dashboard" className="flex items-center gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-accent to-brand flex items-center justify-center shadow-md shadow-accent/20">
|
||||||
|
<span className="text-xl">🐝</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="font-extrabold text-lg tracking-tight text-text-main">
|
||||||
|
Bee<span className="text-accent">Task</span>
|
||||||
|
</span>
|
||||||
|
<Chip className="bg-brand-soft text-brand text-[10px] font-bold px-1.5 py-0.2">
|
||||||
|
<ChipLabel>v3.0</ChipLabel>
|
||||||
|
</Chip>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] font-semibold text-text-muted leading-tight">
|
||||||
|
Workspace Ágil
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
{/* Mobile close button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCloseMobile}
|
||||||
|
className="lg:hidden p-1.5 rounded-lg text-text-muted hover:text-text-main hover:bg-surface-muted"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav List */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 py-6 space-y-6">
|
||||||
|
{navItems.map((group, idx) => (
|
||||||
|
<div key={idx} className="space-y-1.5">
|
||||||
|
<p className="px-3 text-[10px] font-extrabold uppercase tracking-widest text-text-muted">
|
||||||
|
{group.group}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{group.links.map((link) => (
|
||||||
|
<NavLink
|
||||||
|
key={link.to}
|
||||||
|
to={link.to}
|
||||||
|
onClick={onCloseMobile}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-3 px-3 py-2.5 rounded-xl text-xs font-semibold transition-all ${
|
||||||
|
isActive
|
||||||
|
? 'bg-brand text-white shadow-md shadow-brand/20 font-bold'
|
||||||
|
: 'text-text-muted hover:text-text-main hover:bg-surface-muted'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{link.icon}
|
||||||
|
<span>{link.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Workspace Card Info */}
|
||||||
|
<div className="p-4 border-t border-border-soft">
|
||||||
|
<div className="p-3 rounded-2xl bg-surface-muted/70 border border-border-soft flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-xl bg-brand-soft text-brand flex items-center justify-center font-bold text-xs shrink-0">
|
||||||
|
<Building2 className="w-4 h-4 text-brand" />
|
||||||
|
</div>
|
||||||
|
<div className="truncate flex-1 min-w-0">
|
||||||
|
<p className="text-xs font-bold text-text-main truncate">
|
||||||
|
{activeOrg?.name || 'Workspace Padrão'}
|
||||||
|
</p>
|
||||||
|
<span className="text-[10px] font-semibold text-text-muted uppercase tracking-wider">
|
||||||
|
{activeOrg?.roles[0] || 'Ativo'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Desktop Sidebar (Permanent) */}
|
||||||
|
<aside className="hidden lg:block w-64 h-screen sticky top-0 shrink-0 z-30">
|
||||||
|
{navContent}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Mobile Sidebar (Drawer Overlay) */}
|
||||||
|
{isMobileOpen && (
|
||||||
|
<div className="lg:hidden fixed inset-0 z-50 flex">
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/40 backdrop-blur-xs transition-opacity"
|
||||||
|
onClick={onCloseMobile}
|
||||||
|
/>
|
||||||
|
<div className="relative w-64 max-w-xs h-full z-50 animate-in slide-in-from-left duration-200">
|
||||||
|
{navContent}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
374
new-frontend/src/pages/DashboardPage.tsx
Normal file
374
new-frontend/src/pages/DashboardPage.tsx
Normal file
|
|
@ -0,0 +1,374 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
CardFooter,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
ChipLabel,
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
} from '@heroui/react'
|
||||||
|
import {
|
||||||
|
Clock,
|
||||||
|
CheckCircle2,
|
||||||
|
FolderKanban,
|
||||||
|
Play,
|
||||||
|
Square,
|
||||||
|
Users,
|
||||||
|
TrendingUp,
|
||||||
|
Plus,
|
||||||
|
Sparkles,
|
||||||
|
ArrowRight,
|
||||||
|
Flame,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
|
export const DashboardPage = () => {
|
||||||
|
const { user, activeOrg } = useAuth()
|
||||||
|
|
||||||
|
// Simulação de cronômetro local interativo
|
||||||
|
const [isRunning, setIsRunning] = useState(false)
|
||||||
|
const [timerSeconds, setTimerSeconds] = useState(1450)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let interval: ReturnType<typeof setInterval>
|
||||||
|
if (isRunning) {
|
||||||
|
interval = setInterval(() => {
|
||||||
|
setTimerSeconds((prev) => prev + 1)
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [isRunning])
|
||||||
|
|
||||||
|
const formatTimer = (total: number) => {
|
||||||
|
const h = Math.floor(total / 3600)
|
||||||
|
const m = Math.floor((total % 3600) / 60)
|
||||||
|
const s = total % 60
|
||||||
|
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const kpis = [
|
||||||
|
{
|
||||||
|
label: 'Tarefas em Andamento',
|
||||||
|
value: '4',
|
||||||
|
sub: '2 com prazo hoje',
|
||||||
|
icon: <Clock className="w-5 h-5 text-brand" />,
|
||||||
|
bg: 'bg-brand-soft',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Finalizadas nesta Semana',
|
||||||
|
value: '18',
|
||||||
|
sub: '+25% vs semana anterior',
|
||||||
|
icon: <CheckCircle2 className="w-5 h-5 text-emerald-600" />,
|
||||||
|
bg: 'bg-emerald-50',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Horas Rastreadas (Você)',
|
||||||
|
value: '26.4h',
|
||||||
|
sub: 'Média de 6.6h / dia',
|
||||||
|
icon: <TrendingUp className="w-5 h-5 text-accent" />,
|
||||||
|
bg: 'bg-accent-soft',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Projetos Conectados',
|
||||||
|
value: '5',
|
||||||
|
sub: 'No workspace ativo',
|
||||||
|
icon: <FolderKanban className="w-5 h-5 text-brand" />,
|
||||||
|
bg: 'bg-brand-soft',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const recentTasks = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'Implementação do AppShell e Sidebar retrátil',
|
||||||
|
project: 'Frontend v3',
|
||||||
|
category: 'Feature',
|
||||||
|
status: 'EM ANDAMENTO',
|
||||||
|
time: '02h 15m',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: 'Ajuste de Headers CORS e preflight no Quarkus',
|
||||||
|
project: 'Core Backend',
|
||||||
|
category: 'Bugfix',
|
||||||
|
status: 'CONCLUÍDO',
|
||||||
|
time: '01h 05m',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: 'Configuração de balde MinIO e expiração de URLs',
|
||||||
|
project: 'Infra & Storage',
|
||||||
|
category: 'DevOps',
|
||||||
|
status: 'A FAZER',
|
||||||
|
time: '00h 00m',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Top Welcome Banner */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-extrabold tracking-tight text-text-main">
|
||||||
|
Olá, {user?.name?.split(' ')[0] || 'Desenvolvedor'}! 👋
|
||||||
|
</h1>
|
||||||
|
<Chip className="bg-brand-soft text-brand text-xs font-semibold px-2 py-0.5 border border-brand/20">
|
||||||
|
<ChipLabel>{activeOrg?.name || 'Workspace'}</ChipLabel>
|
||||||
|
</Chip>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-text-muted mt-1">
|
||||||
|
Aqui está o resumo da sua produtividade e das atividades da equipe hoje.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link to="/tasks">
|
||||||
|
<Button className="bg-surface hover:bg-surface-muted text-text-main font-semibold text-xs px-4 py-2.5 rounded-xl border border-border-soft transition-colors shadow-xs">
|
||||||
|
Ver Quadro de Tarefas
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link to="/tasks/new">
|
||||||
|
<Button className="bg-brand hover:bg-brand-strong text-white font-bold text-xs px-4 py-2.5 rounded-xl shadow-md shadow-brand/20 transition-all flex items-center gap-1.5">
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Nova Tarefa
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI Stats Grid */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||||
|
{kpis.map((kpi, idx) => (
|
||||||
|
<Card
|
||||||
|
key={idx}
|
||||||
|
className="bg-surface border border-border-soft rounded-2xl p-5 shadow-xs hover:shadow-panel transition-all"
|
||||||
|
>
|
||||||
|
<CardContent className="p-0 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-bold text-text-muted">
|
||||||
|
{kpi.label}
|
||||||
|
</span>
|
||||||
|
<div className={`p-2.5 rounded-xl ${kpi.bg}`}>
|
||||||
|
{kpi.icon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl sm:text-3xl font-extrabold font-mono text-text-main">
|
||||||
|
{kpi.value}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-text-muted font-medium mt-0.5">
|
||||||
|
{kpi.sub}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Grid: Active Task Tracker + Recent Tasks */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||||
|
{/* Active Session & Pomodoro Card (Left 5 cols) */}
|
||||||
|
<div className="lg:col-span-5 space-y-6">
|
||||||
|
<Card className="bg-surface border border-border-soft rounded-3xl p-6 shadow-panel space-y-5">
|
||||||
|
<CardHeader className="p-0 flex items-center justify-between pb-3 border-b border-border-soft">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="p-2 rounded-xl bg-accent-soft text-accent">
|
||||||
|
<Flame className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-sm font-bold text-text-main">
|
||||||
|
Sessão em Foco
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs text-text-muted">
|
||||||
|
Time tracking em tempo real
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Chip className={isRunning ? "bg-emerald-100 text-emerald-800 text-xs font-bold" : "bg-surface-muted text-text-muted text-xs font-semibold"}>
|
||||||
|
<ChipLabel>{isRunning ? "ATIVO" : "PAUSADO"}</ChipLabel>
|
||||||
|
</Chip>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="p-0 space-y-4">
|
||||||
|
<div className="p-4 rounded-2xl bg-surface-muted/60 border border-border-soft space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Chip className="bg-brand-soft text-brand text-[10px] font-bold">
|
||||||
|
<ChipLabel>Frontend v3</ChipLabel>
|
||||||
|
</Chip>
|
||||||
|
<span className="text-xs font-medium text-text-muted">
|
||||||
|
Squad Core
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-sm font-bold text-text-main">
|
||||||
|
Refatoração do Layout Principal & Navegação com HeroUI
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Big Timer display */}
|
||||||
|
<div className="text-center py-3 bg-surface rounded-2xl border border-border-soft">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-text-muted">
|
||||||
|
Tempo na Tarefa
|
||||||
|
</p>
|
||||||
|
<p className="text-3xl sm:text-4xl font-extrabold font-mono text-text-main mt-1">
|
||||||
|
{formatTimer(timerSeconds)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={() => setIsRunning(!isRunning)}
|
||||||
|
className={`flex-1 font-bold text-xs py-3 rounded-xl flex items-center justify-center gap-2 transition-all ${
|
||||||
|
isRunning
|
||||||
|
? 'bg-danger-soft text-danger hover:bg-danger/20 border border-danger/30'
|
||||||
|
: 'bg-brand hover:bg-brand-strong text-white shadow-md shadow-brand/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isRunning ? (
|
||||||
|
<>
|
||||||
|
<Square className="w-4 h-4 fill-current" />
|
||||||
|
Pausar Tempo
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Play className="w-4 h-4 fill-current" />
|
||||||
|
Iniciar Tempo
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter className="p-0 pt-3 border-t border-border-soft flex items-center justify-between text-xs text-text-muted">
|
||||||
|
<span>Sessão vinculada ao usuário</span>
|
||||||
|
<span className="font-mono text-[11px] font-bold text-brand">
|
||||||
|
{user?.email}
|
||||||
|
</span>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Quick Shortcuts */}
|
||||||
|
<Card className="bg-surface border border-border-soft rounded-2xl p-5 shadow-xs space-y-3">
|
||||||
|
<h4 className="text-xs font-bold uppercase tracking-wider text-text-muted">
|
||||||
|
Acesso Rápido
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs font-semibold">
|
||||||
|
<Link
|
||||||
|
to="/projects"
|
||||||
|
className="p-3 rounded-xl bg-surface-muted hover:bg-brand-soft/50 hover:text-brand transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<FolderKanban className="w-4 h-4 text-brand" />
|
||||||
|
<span>Ver Projetos</span>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/analytics"
|
||||||
|
className="p-3 rounded-xl bg-surface-muted hover:bg-accent-soft/50 hover:text-accent transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<TrendingUp className="w-4 h-4 text-accent" />
|
||||||
|
<span>Métricas da Org</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Tasks List (Right 7 cols) */}
|
||||||
|
<div className="lg:col-span-7 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-bold text-text-main flex items-center gap-2">
|
||||||
|
<Sparkles className="w-4 h-4 text-accent" />
|
||||||
|
Tarefas Recentes da Sprint
|
||||||
|
</h2>
|
||||||
|
<Link
|
||||||
|
to="/tasks"
|
||||||
|
className="text-xs font-bold text-brand hover:underline flex items-center gap-1"
|
||||||
|
>
|
||||||
|
Ver todas
|
||||||
|
<ArrowRight className="w-3.5 h-3.5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{recentTasks.map((task) => (
|
||||||
|
<Card
|
||||||
|
key={task.id}
|
||||||
|
className="bg-surface border border-border-soft rounded-2xl p-4 shadow-xs hover:shadow-panel hover:border-brand/30 transition-all flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3"
|
||||||
|
>
|
||||||
|
<div className="space-y-1.5 flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Chip className="bg-surface-muted text-text-muted text-[10px] font-semibold border border-border-soft">
|
||||||
|
<ChipLabel>{task.project}</ChipLabel>
|
||||||
|
</Chip>
|
||||||
|
<Chip className="bg-brand-soft text-brand text-[10px] font-bold">
|
||||||
|
<ChipLabel>{task.category}</ChipLabel>
|
||||||
|
</Chip>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-sm font-bold text-text-main truncate">
|
||||||
|
{task.title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 shrink-0 self-end sm:self-center">
|
||||||
|
<span className="text-xs font-mono font-semibold text-text-muted">
|
||||||
|
{task.time}
|
||||||
|
</span>
|
||||||
|
<Chip
|
||||||
|
className={`text-[10px] font-bold ${
|
||||||
|
task.status === 'CONCLUÍDO'
|
||||||
|
? 'bg-emerald-100 text-emerald-800'
|
||||||
|
: task.status === 'EM ANDAMENTO'
|
||||||
|
? 'bg-amber-100 text-amber-800'
|
||||||
|
: 'bg-surface-muted text-text-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ChipLabel>{task.status}</ChipLabel>
|
||||||
|
</Chip>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Team Active Members Banner */}
|
||||||
|
<Card className="bg-gradient-to-r from-surface to-surface-muted border border-border-soft rounded-2xl p-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-xl bg-brand-soft text-brand flex items-center justify-center font-bold">
|
||||||
|
<Users className="w-5 h-5 text-brand" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold text-text-main">
|
||||||
|
Squad Ativo no Workspace
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-text-muted">
|
||||||
|
3 colaboradores com sessões registradas hoje
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex -space-x-2">
|
||||||
|
<Avatar className="w-7 h-7 rounded-full ring-2 ring-surface">
|
||||||
|
<AvatarFallback className="bg-brand text-white text-[10px] font-bold">
|
||||||
|
GB
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<Avatar className="w-7 h-7 rounded-full ring-2 ring-surface">
|
||||||
|
<AvatarFallback className="bg-accent text-white text-[10px] font-bold">
|
||||||
|
AL
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<Avatar className="w-7 h-7 rounded-full ring-2 ring-surface">
|
||||||
|
<AvatarFallback className="bg-brand-strong text-white text-[10px] font-bold">
|
||||||
|
MS
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
242
new-frontend/src/pages/OrganizationsPage.tsx
Normal file
242
new-frontend/src/pages/OrganizationsPage.tsx
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Chip,
|
||||||
|
ChipLabel,
|
||||||
|
Alert,
|
||||||
|
AlertTitle,
|
||||||
|
AlertDescription,
|
||||||
|
} from '@heroui/react'
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
Plus,
|
||||||
|
ArrowRight,
|
||||||
|
Users,
|
||||||
|
ShieldCheck,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { organizationService } from '../services/organizationService'
|
||||||
|
|
||||||
|
export const OrganizationsPage = () => {
|
||||||
|
const { user, activeOrg, setActiveOrg, refreshUser } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [newOrgName, setNewOrgName] = useState('')
|
||||||
|
const [isCreating, setIsCreating] = useState(false)
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||||
|
const [successMessage, setSuccessMessage] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleCreateOrg = async (e: FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!newOrgName.trim()) return
|
||||||
|
|
||||||
|
setIsCreating(true)
|
||||||
|
setErrorMessage(null)
|
||||||
|
setSuccessMessage(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await organizationService.create(newOrgName.trim())
|
||||||
|
await refreshUser()
|
||||||
|
setActiveOrg(created.id)
|
||||||
|
setSuccessMessage(`Workspace "${newOrgName}" criado com sucesso!`)
|
||||||
|
setNewOrgName('')
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate('/dashboard')
|
||||||
|
}, 800)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Erro ao criar organização.'
|
||||||
|
setErrorMessage(msg)
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectOrg = (id: number) => {
|
||||||
|
setActiveOrg(id)
|
||||||
|
navigate('/dashboard')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto py-8 space-y-8 animate-in fade-in duration-200">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="text-center sm:text-left space-y-2">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-brand-soft text-brand text-xs font-bold">
|
||||||
|
<Building2 className="w-3.5 h-3.5" />
|
||||||
|
<span>Gestão de Workspaces Corporativos</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-extrabold text-text-main tracking-tight">
|
||||||
|
Seus Workspaces & Organizações
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-text-muted max-w-2xl">
|
||||||
|
Selecione uma organização ativa para acessar projetos, tarefas e métricas da equipe,
|
||||||
|
ou crie um novo workspace dedicado para sua empresa ou squad.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notifications */}
|
||||||
|
{errorMessage && (
|
||||||
|
<Alert className="bg-danger-soft border border-danger/30 text-danger rounded-2xl p-4 text-xs flex items-start gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<AlertTitle className="font-bold">Não foi possível criar</AlertTitle>
|
||||||
|
<AlertDescription className="text-xs mt-0.5">{errorMessage}</AlertDescription>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{successMessage && (
|
||||||
|
<Alert className="bg-success-soft border border-success/30 text-success rounded-2xl p-4 text-xs flex items-start gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<AlertTitle className="font-bold">Sucesso</AlertTitle>
|
||||||
|
<AlertDescription className="text-xs mt-0.5">{successMessage}</AlertDescription>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-12 gap-8">
|
||||||
|
{/* Existing Organizations (7 cols) */}
|
||||||
|
<div className="md:col-span-7 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-base font-bold text-text-main flex items-center gap-2">
|
||||||
|
<Users className="w-4 h-4 text-brand" />
|
||||||
|
Workspaces Vinculados ({user?.organizations?.length || 0})
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user?.organizations && user.organizations.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{user.organizations.map((org) => {
|
||||||
|
const isActive = org.id === activeOrg?.id
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={org.id}
|
||||||
|
className={`bg-surface border rounded-2xl p-4.5 transition-all flex items-center justify-between gap-4 cursor-pointer hover:shadow-panel ${
|
||||||
|
isActive
|
||||||
|
? 'border-brand ring-2 ring-brand/10'
|
||||||
|
: 'border-border-soft hover:border-brand/40'
|
||||||
|
}`}
|
||||||
|
onClick={() => handleSelectOrg(org.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3.5 min-w-0">
|
||||||
|
<div
|
||||||
|
className={`w-11 h-11 rounded-xl flex items-center justify-center font-bold text-sm shrink-0 shadow-xs ${
|
||||||
|
isActive
|
||||||
|
? 'bg-brand text-white shadow-brand/20'
|
||||||
|
: 'bg-surface-muted text-text-muted border border-border-soft'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{org.name.slice(0, 2).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="truncate">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-sm font-bold text-text-main truncate">
|
||||||
|
{org.name}
|
||||||
|
</h3>
|
||||||
|
{isActive && (
|
||||||
|
<Chip className="bg-brand-soft text-brand text-[10px] font-bold px-2 py-0.2">
|
||||||
|
<ChipLabel>ATIVO</ChipLabel>
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-text-muted mt-0.5 font-medium">
|
||||||
|
Papel: <span className="text-brand font-semibold">{org.roles.join(', ')}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleSelectOrg(org.id)
|
||||||
|
}}
|
||||||
|
className={`text-xs font-bold px-3 py-1.5 rounded-xl shrink-0 ${
|
||||||
|
isActive
|
||||||
|
? 'bg-brand text-white shadow-sm'
|
||||||
|
: 'bg-surface-muted text-text-muted hover:text-text-main hover:bg-border-soft'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isActive ? 'Acessar' : 'Selecionar'}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card className="bg-surface border border-border-soft rounded-2xl p-8 text-center space-y-3">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-amber-50 text-amber-600 mx-auto flex items-center justify-center">
|
||||||
|
<Building2 className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-base font-bold text-text-main">
|
||||||
|
Nenhum workspace encontrado
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-text-muted max-w-sm mx-auto">
|
||||||
|
Você ainda não faz parte de nenhuma organização. Crie o seu primeiro workspace ao lado para começar!
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create New Workspace Form (5 cols) */}
|
||||||
|
<div className="md:col-span-5 space-y-4">
|
||||||
|
<Card className="bg-surface border border-border-soft rounded-3xl p-6 shadow-panel space-y-5">
|
||||||
|
<CardHeader className="p-0 space-y-1">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-accent-soft text-accent flex items-center justify-center mb-1">
|
||||||
|
<Plus className="w-5 h-5 text-accent" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-base font-bold text-text-main">
|
||||||
|
Criar Novo Workspace
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs text-text-muted">
|
||||||
|
Você será automaticamente configurado como <strong>OWNER</strong>.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<form onSubmit={handleCreateOrg} className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-bold text-text-main">
|
||||||
|
Nome da Organização / Empresa
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={newOrgName}
|
||||||
|
onChange={(e) => setNewOrgName(e.target.value)}
|
||||||
|
placeholder="Ex: Minha Empresa Corp"
|
||||||
|
className="w-full px-3.5 py-2.5 bg-surface-muted/60 border border-border-soft rounded-xl text-xs text-text-main placeholder-text-muted/60 focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
isDisabled={isCreating || !newOrgName.trim()}
|
||||||
|
className="w-full bg-accent hover:bg-[#b45309] text-white font-bold text-xs py-3 rounded-xl shadow-md shadow-accent/20 transition-all flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isCreating ? (
|
||||||
|
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Criar Organização
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<div className="pt-3 border-t border-border-soft flex items-center gap-2 text-[11px] text-text-muted font-medium">
|
||||||
|
<ShieldCheck className="w-3.5 h-3.5 text-accent" />
|
||||||
|
<span>Multi-tenancy com isolamento de dados</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
42
new-frontend/src/services/organizationService.ts
Normal file
42
new-frontend/src/services/organizationService.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { apiFetch } from '../lib/api'
|
||||||
|
|
||||||
|
export type CreateOrgResponse = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
ownerId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SearchOrgOutput = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JoinRequestOutput = {
|
||||||
|
organizationId: number
|
||||||
|
organizationName: string
|
||||||
|
status: 'PENDING' | 'APPROVED' | 'REJECTED'
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const organizationService = {
|
||||||
|
async create(name: string): Promise<CreateOrgResponse> {
|
||||||
|
return apiFetch<CreateOrgResponse>('/organizations', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { name },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async search(query: string): Promise<SearchOrgOutput[]> {
|
||||||
|
return apiFetch<SearchOrgOutput[]>(`/organizations/search?q=${encodeURIComponent(query)}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async requestJoin(organizationId: number): Promise<void> {
|
||||||
|
return apiFetch<void>(`/organizations/${organizationId}/join`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async listMyRequests(): Promise<JoinRequestOutput[]> {
|
||||||
|
return apiFetch<JoinRequestOutput[]>('/organizations/my-requests')
|
||||||
|
},
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue