beetask/frontend/src/components/AppShell.tsx
2026-10-08 10:22:41 -04:00

641 lines
25 KiB
TypeScript

import { useState } from "react";
import {
Layout,
ListTodo,
Plus,
Columns,
LogOut,
Settings,
Building2,
Clock,
ChevronDown,
FolderKanban,
Tag,
BarChart3,
PieChart,
Search,
User,
Users,
PlusCircle,
} from "lucide-react";
import { NavLink, Outlet, Navigate, Link, useLocation } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import { cn } from "../lib/utils";
import { motion } from "framer-motion";
const navLinkClassName = ({ isActive }: { isActive: boolean }) =>
cn(
"inline-flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-semibold transition-all duration-200",
isActive
? "bg-brand text-white shadow-md shadow-brand/20"
: "text-text-muted hover:bg-surface-muted hover:text-text-main",
);
const dropdownItemClassName = ({ isActive }: { isActive: boolean }) =>
cn(
"flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-bold transition-all duration-200",
isActive
? "bg-brand/10 text-brand"
: "text-text-muted hover:bg-surface-muted hover:text-text-main",
);
export function AppShell() {
const { user, isAuthenticated, isLoading, logout, activeOrg, setActiveOrg } =
useAuth();
const location = useLocation();
const [showOrgSwitcher, setShowOrgSwitcher] = useState(false);
const [showTasksDropdown, setShowTasksDropdown] = useState(false);
const [showTeamDropdown, setShowTeamDropdown] = useState(false);
const [showAvatarDropdown, setShowAvatarDropdown] = useState(false);
const isAdmin =
activeOrg?.roles.includes("OWNER") || activeOrg?.roles.includes("ADMIN");
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-app-bg text-brand">
<div className="h-10 w-10 animate-spin rounded-full border-4 border-current border-t-transparent" />
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
if (
isAuthenticated &&
user &&
user.organizations.length === 0 &&
location.pathname !== "/organizations"
) {
return <Navigate to="/organizations" replace />;
}
return (
<div className="flex min-h-screen flex-col bg-app-bg font-sans text-text-main">
{/* Top Navigation */}
<header className="sticky top-0 z-50 border-b border-border-soft bg-surface/80 backdrop-blur-xl">
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-4 lg:px-10">
<div className="flex items-center gap-8">
<NavLink to="/" className="flex items-center gap-3 group">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-brand text-white shadow-lg shadow-brand/20 transition-transform group-hover:scale-105">
<Layout size={20} />
</div>
<div>
<span className="block text-lg font-black tracking-tight text-text-main">
BeeTask
</span>
<span className="block text-[10px] font-bold uppercase tracking-widest text-text-muted">
Workspace
</span>
</div>
</NavLink>
<div className="h-8 w-px bg-border-soft hidden lg:block" />
{/* Org Switcher */}
<div className="relative">
<button
onClick={() => setShowOrgSwitcher(!showOrgSwitcher)}
className="flex w-48 items-center gap-2 rounded-xl border border-border-soft bg-surface-muted/50 px-4 py-2 text-sm font-bold transition-all hover:bg-surface-muted group"
>
<div className="flex h-6 w-6 items-center justify-center rounded-lg bg-brand/10 text-brand group-hover:bg-brand group-hover:text-white transition-all">
<Building2 size={14} />
</div>
<div className="text-left hidden sm:block flex-1 min-w-0">
<span className="block text-xs text-text-main leading-tight truncate">
{activeOrg?.name || "Selecionar Org"}
</span>
<span className="block text-[10px] text-text-muted font-black uppercase tracking-tighter leading-none truncate">
{activeOrg?.roles[0] || "Visitante"}
</span>
</div>
<ChevronDown
size={14}
className={cn(
"text-text-muted transition-transform",
showOrgSwitcher && "rotate-180",
)}
/>
</button>
{showOrgSwitcher && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setShowOrgSwitcher(false)}
/>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="absolute left-0 mt-2 z-50 w-48 rounded-2xl border border-border-soft bg-surface p-2 shadow-xl shadow-brand/10"
>
<div className="px-3 py-2 text-[10px] font-black uppercase tracking-widest text-text-muted">
Suas Organizações
</div>
<div className="grid gap-1 mt-1">
{user?.organizations.map((org) => (
<button
key={org.id}
onClick={() => {
setActiveOrg(org.id);
setShowOrgSwitcher(false);
}}
className={cn(
"flex w-full items-center justify-between rounded-xl px-3 py-2.5 transition-all text-left",
org.id === activeOrg?.id
? "bg-brand/10 text-brand"
: "hover:bg-surface-muted text-text-muted hover:text-text-main",
)}
>
<div className="flex items-center gap-3">
<div
className={cn(
"flex h-8 w-8 items-center justify-center rounded-lg font-bold text-xs shadow-sm",
org.id === activeOrg?.id
? "bg-brand text-white"
: "bg-white border border-border-soft text-text-muted",
)}
>
{org.name.substring(0, 2).toUpperCase()}
</div>
<div>
<span className="block text-sm font-bold leading-none">
{org.name}
</span>
<span className="text-[10px] font-medium opacity-70">
{org.roles.join(", ")}
</span>
</div>
</div>
{org.id === activeOrg?.id && (
<div className="h-1.5 w-1.5 rounded-full bg-brand" />
)}
</button>
))}
</div>
<div className="mt-2 border-t border-border-soft p-1">
<Link
to="/organizations"
onClick={() => setShowOrgSwitcher(false)}
className="flex w-full items-center gap-2 rounded-xl px-3 py-2 text-xs font-bold text-brand hover:bg-brand/5 transition-all"
>
<PlusCircle size={14} />
Gerenciar Organizações
</Link>
</div>
</motion.div>
</>
)}
</div>
{/* Desktop Navigation */}
<nav className="hidden items-center gap-1 md:flex">
<NavLink to="/dashboard" className={navLinkClassName}>
<BarChart3 size={18} />
Meu Dashboard
</NavLink>
{/* Tarefas Dropdown */}
<div className="relative">
<NavLink
to="/"
className={({ isActive }) => {
const isBoardActive = window.location.pathname === "/board";
const active = isActive || isBoardActive;
return cn(
"inline-flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-semibold transition-all duration-200",
active || showTasksDropdown
? "bg-brand text-white shadow-md shadow-brand/20"
: "text-text-muted hover:bg-surface-muted hover:text-text-main",
);
}}
onClick={(e) => {
e.preventDefault();
setShowTasksDropdown(!showTasksDropdown);
}}
>
<ListTodo size={18} />
Tarefas
<ChevronDown
size={14}
className={cn(
"transition-transform",
showTasksDropdown && "rotate-180",
)}
/>
</NavLink>
{showTasksDropdown && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setShowTasksDropdown(false)}
/>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="absolute left-0 mt-2 z-50 w-48 rounded-2xl border border-border-soft bg-surface p-2 shadow-xl shadow-brand/10"
>
<NavLink
to="/"
end
onClick={() => setShowTasksDropdown(false)}
className={dropdownItemClassName}
>
<ListTodo size={18} />
Minhas Tarefas
</NavLink>
<NavLink
to="/board"
onClick={() => setShowTasksDropdown(false)}
className={(props) =>
cn(dropdownItemClassName(props), "mt-1")
}
>
<Columns size={18} />
Quadro
</NavLink>
</motion.div>
</>
)}
</div>
{isAdmin && (
<NavLink to="/projects" className={navLinkClassName}>
<FolderKanban size={18} />
Projetos
</NavLink>
)}
{isAdmin && (
<NavLink to="/categories" className={navLinkClassName}>
<Tag size={18} />
Categorias
</NavLink>
)}
{/* Equipe Dropdown */}
<div className="relative">
<NavLink
to="/organizations"
className={({ isActive }) => {
const path = window.location.pathname;
const active =
isActive ||
path === "/requests" ||
path === "/admin" ||
path === "/org-dashboard" ||
path === "/organizations/join";
return cn(
"inline-flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-semibold transition-all duration-200",
active || showTeamDropdown
? "bg-brand text-white shadow-md shadow-brand/20"
: "text-text-muted hover:bg-surface-muted hover:text-text-main",
);
}}
onClick={(e) => {
e.preventDefault();
setShowTeamDropdown(!showTeamDropdown);
}}
>
<Users size={18} />
Equipe
<ChevronDown
size={14}
className={cn(
"transition-transform",
showTeamDropdown && "rotate-180",
)}
/>
</NavLink>
{showTeamDropdown && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setShowTeamDropdown(false)}
/>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="absolute right-0 mt-2 z-50 w-60 rounded-2xl border border-border-soft bg-surface p-2 shadow-xl shadow-brand/10"
>
{isAdmin && (
<>
<NavLink
to="/org-dashboard"
onClick={() => setShowTeamDropdown(false)}
className={dropdownItemClassName}
>
<PieChart size={18} />
Dashboard da Org
</NavLink>
<NavLink
to="/admin"
onClick={() => setShowTeamDropdown(false)}
className={(props) =>
cn(dropdownItemClassName(props), "mt-1")
}
>
<Settings size={18} />
Solicitações Pendentes
</NavLink>
<div className="my-2 border-t border-border-soft" />
</>
)}
<NavLink
to="/organizations"
onClick={() => setShowTeamDropdown(false)}
className={dropdownItemClassName}
>
<Building2 size={18} />
Minhas Organizações
</NavLink>
<NavLink
to="/requests"
onClick={() => setShowTeamDropdown(false)}
className={(props) =>
cn(dropdownItemClassName(props), "mt-1")
}
>
<Clock size={18} />
Minhas Solicitações
</NavLink>
<NavLink
to="/organizations/join"
onClick={() => setShowTeamDropdown(false)}
className={(props) =>
cn(dropdownItemClassName(props), "mt-1")
}
>
<Search size={18} />
Buscar Organização
</NavLink>
</motion.div>
</>
)}
</div>
</nav>
</div>
{/* Right Actions */}
<div className="flex items-center gap-3">
<Link
to="/new"
className="hidden md:inline-flex items-center gap-2 rounded-xl bg-brand px-4 py-2 text-sm font-bold text-white shadow-md shadow-brand/20 transition-all hover:scale-[1.02] hover:bg-brand-dark active:scale-95"
>
<Plus size={16} />
Nova Tarefa
</Link>
<div className="hidden h-8 w-px bg-border-soft md:block" />
{/* Avatar Dropdown */}
<div className="relative">
<button
onClick={() => setShowAvatarDropdown(!showAvatarDropdown)}
className="flex h-9 w-9 items-center justify-center rounded-full bg-brand/10 text-brand transition-all overflow-hidden border border-brand/20 hover:border-brand"
title={user?.name}
>
{user?.photo ? (
<img src={user.photo} alt={user.name} className="h-full w-full object-cover" />
) : (
<User size={18} />
)}
</button>
{showAvatarDropdown && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setShowAvatarDropdown(false)}
/>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="absolute right-0 mt-2 z-50 w-56 rounded-2xl border border-border-soft bg-surface p-2 shadow-xl shadow-brand/10"
>
<div className="px-3 py-3 border-b border-border-soft mb-1">
<p className="text-sm font-bold text-text-main truncate">
{user?.name}
</p>
<p className="text-xs text-text-muted truncate mt-0.5">
{user?.email}
</p>
</div>
<Link
to="/profile"
onClick={() => setShowAvatarDropdown(false)}
className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-bold text-text-muted hover:bg-surface-muted hover:text-text-main transition-all"
>
<User size={16} />
Meu Perfil
</Link>
<button
onClick={() => {
setShowAvatarDropdown(false);
logout();
}}
className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-bold text-text-muted hover:bg-danger/5 hover:text-danger transition-all"
>
<LogOut size={16} />
Sair
</button>
</motion.div>
</>
)}
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="flex-1 px-6 py-8 lg:px-10">
<div className="mx-auto max-w-7xl">
<Outlet />
</div>
</main>
{/* Footer / Mobile Nav */}
<footer className="border-t border-border-soft bg-surface py-6 md:hidden">
<nav className="flex justify-around px-4">
<NavLink
to="/dashboard"
className={({ isActive }) =>
cn(navLinkClassName({ isActive }), "flex-col gap-0.5 py-1")
}
>
<BarChart3 size={20} />
<span className="text-[10px]">Dashboard</span>
</NavLink>
{/* Mobile Tarefas Group */}
<div className="relative">
<button
onClick={() => setShowTasksDropdown(!showTasksDropdown)}
className={cn(
navLinkClassName({
isActive:
window.location.pathname === "/" ||
window.location.pathname === "/board",
}),
"flex-col gap-0.5 py-1",
)}
>
<ListTodo size={20} />
<span className="text-[10px]">Tarefas</span>
</button>
{showTasksDropdown && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setShowTasksDropdown(false)}
/>
<motion.div
initial={{ opacity: 0, y: -10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-4 z-50 w-40 rounded-2xl border border-border-soft bg-surface p-1 shadow-2xl"
>
<NavLink
to="/"
end
onClick={() => setShowTasksDropdown(false)}
className={dropdownItemClassName}
>
<ListTodo size={18} />
Lista
</NavLink>
<NavLink
to="/board"
onClick={() => setShowTasksDropdown(false)}
className={(props) =>
cn(dropdownItemClassName(props), "mt-1")
}
>
<Columns size={18} />
Quadro
</NavLink>
</motion.div>
</>
)}
</div>
{/* Mobile + New Task */}
<Link
to="/new"
className={cn(
navLinkClassName({ isActive: window.location.pathname === "/new" }),
"flex-col gap-0.5 py-1",
)}
>
<Plus size={20} />
<span className="text-[10px]">Nova</span>
</Link>
{/* Mobile Equipe Group */}
<div className="relative">
<button
onClick={() => setShowTeamDropdown(!showTeamDropdown)}
className={cn(
navLinkClassName({
isActive: [
"/organizations",
"/requests",
"/admin",
"/org-dashboard",
"/organizations/join",
"/projects",
].includes(window.location.pathname),
}),
"flex-col gap-0.5 py-1",
)}
>
<Users size={20} />
<span className="text-[10px]">Equipe</span>
</button>
{showTeamDropdown && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setShowTeamDropdown(false)}
/>
<motion.div
initial={{ opacity: 0, y: -10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
className="absolute bottom-full right-0 mb-4 z-50 w-52 rounded-2xl border border-border-soft bg-surface p-1 shadow-2xl"
>
{isAdmin && (
<>
<NavLink
to="/projects"
onClick={() => setShowTeamDropdown(false)}
className={dropdownItemClassName}
>
<FolderKanban size={18} />
Projetos
</NavLink>
<NavLink
to="/org-dashboard"
onClick={() => setShowTeamDropdown(false)}
className={(props) =>
cn(dropdownItemClassName(props), "mt-1")
}
>
<PieChart size={18} />
Dashboard da Org
</NavLink>
<NavLink
to="/admin"
onClick={() => setShowTeamDropdown(false)}
className={(props) =>
cn(dropdownItemClassName(props), "mt-1")
}
>
<Settings size={18} />
Gestão
</NavLink>
<div className="my-1 border-t border-border-soft" />
</>
)}
<NavLink
to="/organizations"
onClick={() => setShowTeamDropdown(false)}
className={dropdownItemClassName}
>
<Building2 size={18} />
Minhas Orgs
</NavLink>
<NavLink
to="/requests"
onClick={() => setShowTeamDropdown(false)}
className={(props) =>
cn(dropdownItemClassName(props), "mt-1")
}
>
<Clock size={18} />
Pedidos
</NavLink>
<NavLink
to="/organizations/join"
onClick={() => setShowTeamDropdown(false)}
className={(props) =>
cn(dropdownItemClassName(props), "mt-1")
}
>
<Search size={18} />
Buscar Org
</NavLink>
</motion.div>
</>
)}
</div>
</nav>
</footer>
</div>
);
}