feat(layout): implement AppShell, Sidebar, Header, Organizations, and Dashboard

This commit is contained in:
Gabriel Menezes 2026-10-10 22:06:25 -04:00
parent 8015b3ff77
commit c16992e049
8 changed files with 1168 additions and 0 deletions

View file

@ -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>

View 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>
)
}

View 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>
)
}

View 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}</>
}

View 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>
)}
</>
)
}

View 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>
)
}

View 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>
)
}

View 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')
},
}