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 { LandingPage } from './pages/LandingPage'
|
||||
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() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
{/* Rotas Públicas */}
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/login" 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 />} />
|
||||
</Routes>
|
||||
</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