Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@
"hooks": [
{
"type": "prompt",
"prompt": "Verifique se as alterações feitas nesta sessão afetam alguma informação documentada no CLAUDE.md ou nos ficheiros em docs/. Se sim, atualize os ficheiros relevantes para refletir as mudanças (novos componentes, mudanças de arquitetura, novos utilitários, alterações no modelo de dados, etc.). Se nenhuma alteração relevante foi feita, não é necessário atualizar."
"prompt": "Se as alterações desta sessão introduziram novos componentes públicos exportados, mudanças de arquitetura, novos hooks/utilitários partilhados, alterações no modelo de dados ou novos padrões de código — atualize os ficheiros relevantes em CLAUDE.md ou docs/. Mudanças puramente visuais (animações, z-index, estilos, refactors internos de componente) não requerem atualização de documentação e esta verificação pode terminar sem acção."
}
]
}
Expand Down
163 changes: 122 additions & 41 deletions src/components/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,6 @@ import {
Tooltip,
TooltipTrigger,
TooltipContent,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
} from "@/components/ui"

import { canAccessView } from "@/lib/accessControl"
Expand Down Expand Up @@ -103,6 +97,121 @@ function getInitials(email?: string) {
return name.slice(0, 2).toUpperCase()
}

interface UserIdentityPanelProps {
userEmail?: string
userAvatarUrl?: string | null
onGoToProfile: () => void
onSignOut: () => void
}

function UserIdentityPanel({ userEmail, userAvatarUrl, onGoToProfile, onSignOut }: UserIdentityPanelProps) {
const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
const panelRef = useRef<HTMLDivElement>(null)

useEffect(() => {
if (!open) return
function handleClick(e: MouseEvent) {
if (
!triggerRef.current?.contains(e.target as Node) &&
!panelRef.current?.contains(e.target as Node)
) {
setOpen(false)
}
}
document.addEventListener("mousedown", handleClick)
return () => document.removeEventListener("mousedown", handleClick)
}, [open])

const initials = getInitials(userEmail)
const displayName = userEmail?.split("@")[0].replace(/[._-]/g, " ") ?? ""
const formattedName = displayName
.split(" ")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ")

return (
<div className="relative">
<motion.button
ref={triggerRef}
onClick={() => setOpen((v) => !v)}
whileHover={{ scale: 1.06 }}
whileTap={{ scale: 0.94 }}
transition={{ type: "spring", stiffness: 400, damping: 28 }}
className={cn(
"w-9 h-9 rounded-xl flex items-center justify-center text-[11px] font-bold overflow-hidden shrink-0 cursor-pointer",
"bg-foreground text-background",
open && "ring-2 ring-foreground/30 ring-offset-1 ring-offset-[oklch(0.948_0.005_250)] dark:ring-offset-[oklch(0.138_0.009_250)]"
)}
aria-label="Menu do utilizador"
aria-expanded={open}
>
{userAvatarUrl ? (
<img src={userAvatarUrl} alt="" className="w-full h-full object-cover" />
) : (
initials
)}
</motion.button>

<AnimatePresence>
{open && (
<motion.div
ref={panelRef}
initial={{ clipPath: "inset(100% 0% 0% 0%)", opacity: 0 }}
animate={{ clipPath: "inset(0% 0% 0% 0%)", opacity: 1 }}
exit={{ clipPath: "inset(0% 0% 100% 0%)", opacity: 0 }}
transition={{
clipPath: { duration: 0.2, ease: [0.4, 0, 1, 1] },
opacity: { duration: 0.15 },
}}
className={cn(
"absolute bottom-full left-full mb-0 ml-2 z-[80]",
"w-[220px] rounded-xl overflow-hidden",
"bg-card border border-border",
"shadow-[0_8px_32px_oklch(0_0_0/0.12)]"
)}
>
{/* Identity header */}
<div className="p-4 flex items-center gap-3 border-b border-border">
<div className="w-10 h-10 rounded-xl bg-foreground text-background flex items-center justify-center text-sm font-bold shrink-0 overflow-hidden">
{userAvatarUrl ? (
<img src={userAvatarUrl} alt="" className="w-full h-full object-cover" />
) : (
initials
)}
</div>
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground leading-tight truncate">{formattedName}</p>
<p className="text-xs text-muted-foreground truncate mt-0.5">{userEmail}</p>
</div>
</div>

{/* Actions */}
<div className="p-1.5 flex flex-col gap-0.5">
{[
{ icon: UserCircle, label: "Meu Perfil", action: () => { onGoToProfile(); setOpen(false) }, delay: 0.04 },
{ icon: LogOut, label: "Sair", action: () => { onSignOut(); setOpen(false) }, delay: 0.08 },
].map(({ icon: Icon, label, action, delay }) => (
<motion.button
key={label}
onClick={action}
initial={{ opacity: 0, x: -6 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.18, delay, ease: "easeOut" }}
className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm text-foreground hover:bg-muted transition-colors duration-150 cursor-pointer"
>
<Icon className="w-4 h-4 text-muted-foreground shrink-0" />
{label}
</motion.button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}

function getClientInitials(name: string) {
const parts = name.trim().split(/\s+/)
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase()
Expand Down Expand Up @@ -615,41 +724,13 @@ export function AppSidebar() {
<TooltipContent side="right">Ajuda</TooltipContent>
</Tooltip>

{/* User avatar */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={cn(
"w-9 h-9 rounded-md flex items-center justify-center text-[11px] font-bold overflow-hidden shrink-0",
"bg-foreground text-background",
"hover:opacity-80 transition-opacity duration-150",
"ring-2 ring-foreground/10 ring-offset-1 ring-offset-[oklch(0.948_0.005_250)] dark:ring-offset-[oklch(0.138_0.009_250)]"
)}
aria-label="Menu do utilizador"
>
{userAvatarUrl ? (
<img src={userAvatarUrl} alt="" className="w-full h-full object-cover" />
) : (
getInitials(userEmail)
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="end">
<DropdownMenuLabel className="truncate max-w-48 font-normal text-muted-foreground">
{userEmail}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onViewChange("profile")}>
<UserCircle className="w-4 h-4 mr-2" />
Meu Perfil
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onSignOut}>
<LogOut className="w-4 h-4 mr-2" />
Sair
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* User avatar — identity panel */}
<UserIdentityPanel
userEmail={userEmail}
userAvatarUrl={userAvatarUrl}
onGoToProfile={() => onViewChange("profile")}
onSignOut={onSignOut}
/>
</div>
</aside>
)
Expand Down
2 changes: 1 addition & 1 deletion src/components/ClientPickerLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export function ClientPickerLayout({

<div className="flex flex-row flex-1 overflow-hidden">
{/* Mini-sidebar — sempre recolhida */}
<aside className="hidden md:flex flex-col border-r bg-card w-[52px]">
<aside className="hidden md:flex flex-col border-r bg-card w-[52px] relative z-20">
<div className="flex-1 flex flex-col items-center pt-4 gap-3">
<div className="w-8 h-8 rounded-md bg-muted flex items-center justify-center opacity-30" />
<div className="w-6 h-1 rounded bg-muted opacity-20" />
Expand Down
7 changes: 6 additions & 1 deletion src/store/useAdminStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,12 @@ export const useAdminStore = create<AdminStore>()(
set({ loadingInitial: true, error: null })
try {
const { fetchClients, fetchUsers, fetchUserClientsMap, fetchPendingUsers } = get()
await Promise.all([fetchClients(), fetchUsers(), fetchUserClientsMap(), fetchPendingUsers()])
await Promise.all([
fetchClients(),
fetchUsers(),
fetchUserClientsMap(),
fetchPendingUsers().catch(() => undefined),
])
set({ initialized: true })
} catch (err) {
set({ error: toSafeUiErrorMessage(err instanceof Error ? err.message : null) })
Expand Down
83 changes: 72 additions & 11 deletions src/views/client-picker/ClientPickerView.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect } from "react"
import { motion } from "framer-motion"
import { ClientCard } from "./components/ClientCard"
import type { ClientOption } from "@/contexts/AuthContext"

Expand All @@ -8,8 +9,18 @@ interface ClientPickerViewProps {
onSelectClient: (client: ClientOption) => void
}

const wordVariants = {
hidden: { opacity: 0, y: 24 },
visible: (i: number) => ({
opacity: 1,
y: 0,
transition: { duration: 0.5, delay: i * 0.07, ease: [0.16, 1, 0.3, 1] as [number, number, number, number] },
}),
}

export function ClientPickerView({ userName, clients, onSelectClient }: ClientPickerViewProps) {
const firstName = userName.split(" ")[0]
const headlineWords = ["Qual", "projeto", "hoje?"]

useEffect(() => {
if (clients.length === 1) {
Expand All @@ -19,9 +30,16 @@ export function ClientPickerView({ userName, clients, onSelectClient }: ClientPi

if (clients.length === 1) return null

const gridCols =
clients.length === 2
? "grid-cols-2 max-w-xl"
: clients.length === 3
? "grid-cols-3 max-w-3xl"
: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 max-w-5xl"

return (
<div className="relative flex flex-col items-center justify-center h-full overflow-hidden bg-neutral-50 dark:bg-neutral-950">
{/* Grade de fundo estilo blueprint */}
{/* Grade de fundo blueprint */}
<div
className="absolute inset-0 opacity-[0.04] dark:opacity-[0.07]"
style={{
Expand All @@ -31,18 +49,60 @@ export function ClientPickerView({ userName, clients, onSelectClient }: ClientPi
}}
/>

<div className="relative z-10 flex flex-col items-center gap-10 w-full max-w-3xl px-6 py-16">
{/* Halo central */}
<div
className="absolute inset-0 pointer-events-none"
style={{
background:
"radial-gradient(ellipse 60% 50% at 50% 50%, oklch(0.92 0 0 / 0.6) 0%, transparent 70%)",
}}
/>
<div
className="absolute inset-0 pointer-events-none dark:block hidden"
style={{
background:
"radial-gradient(ellipse 60% 50% at 50% 50%, oklch(0.22 0 0 / 0.7) 0%, transparent 70%)",
}}
/>

<div className="relative z-10 flex flex-col items-center gap-12 w-full px-6 py-16">
{/* Cabeçalho */}
<div className="flex flex-col items-center gap-3 text-center">
<div className="inline-flex items-center gap-2 rounded-full border border-border bg-background px-4 py-1.5 text-xs text-muted-foreground font-medium tracking-wide uppercase">
<div className="flex flex-col items-center gap-4 text-center">
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: "easeOut" }}
className="inline-flex items-center gap-2 rounded-full bg-foreground text-background px-4 py-1.5 text-xs font-medium tracking-widest uppercase"
>
Bem-vindo, {firstName}
</div>
<h1 className="text-4xl font-bold tracking-tight text-foreground">
Qual projeto hoje?
</motion.div>

<h1
className="flex gap-[0.25em] text-5xl sm:text-6xl tracking-tight text-foreground"
style={{ fontFamily: "'Syne', sans-serif", fontWeight: 800 }}
>
{headlineWords.map((word, i) => (
<motion.span
key={word}
custom={i}
variants={wordVariants}
initial="hidden"
animate="visible"
style={{ display: "inline-block" }}
>
{word}
</motion.span>
))}
</h1>
<p className="text-sm text-muted-foreground max-w-xs">

<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.35 }}
className="text-sm text-muted-foreground max-w-xs"
>
Selecione um cliente para acessar o painel de capacity planning.
</p>
</motion.p>
</div>

{/* Grid de clientes */}
Expand All @@ -51,11 +111,12 @@ export function ClientPickerView({ userName, clients, onSelectClient }: ClientPi
Nenhum cliente associado à sua conta.
</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 w-full">
{clients.map((client) => (
<div className={`grid ${gridCols} gap-4 w-full mx-auto`}>
{clients.map((client, i) => (
<ClientCard
key={client.id}
client={client}
index={i}
onClick={() => onSelectClient(client)}
/>
))}
Expand Down
Loading
Loading