sistema-de-chamados/src/components/settings/settings-content.tsx
rever-tecnologia 424927573c fix(settings): posiciona background no topo absoluto do card
Background agora usa position absolute para cobrir desde o
topo do card, eliminando a faixa branca acima.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 14:34:41 -03:00

614 lines
21 KiB
TypeScript

"use client"
import { FormEvent, useMemo, useRef, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import dynamic from "next/dynamic"
import { toast } from "sonner"
import {
Share2,
ShieldCheck,
UserPlus,
Users2,
Layers3,
MessageSquareText,
BellRing,
ClipboardList,
LogOut,
Mail,
Key,
User,
Shield,
Clock,
Camera,
Loader2,
Trash2,
} from "lucide-react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { useAuth, signOut } from "@/lib/auth-client"
const ShaderBackground = dynamic(
() => import("@/components/background-paper-shaders-wrapper"),
{ ssr: false }
)
import type { LucideIcon } from "lucide-react"
type RoleRequirement = "admin" | "staff"
type SettingsAction = {
title: string
description: string
href: string
cta: string
requiredRole?: RoleRequirement
icon: LucideIcon
}
const ROLE_LABELS: Record<string, string> = {
admin: "Administrador",
manager: "Gestor",
agent: "Agente",
collaborator: "Colaborador",
customer: "Cliente",
}
const ROLE_COLORS: Record<string, string> = {
admin: "bg-neutral-900 text-white border-neutral-900",
manager: "bg-neutral-800 text-white border-neutral-800",
agent: "bg-neutral-700 text-white border-neutral-700",
collaborator: "bg-neutral-600 text-white border-neutral-600",
customer: "bg-neutral-500 text-white border-neutral-500",
}
const SETTINGS_ACTIONS: SettingsAction[] = [
{
title: "Campos personalizados",
description: "Configure campos extras para enriquecer os tickets.",
href: "/admin/custom-fields",
cta: "Configurar",
requiredRole: "admin",
icon: ClipboardList,
},
{
title: "Times e equipes",
description: "Gerencie times e atribua permissões por equipe.",
href: "/admin/teams",
cta: "Gerenciar",
requiredRole: "admin",
icon: Users2,
},
{
title: "Filas de atendimento",
description: "Configure filas e regras de distribuição.",
href: "/admin/channels",
cta: "Configurar",
requiredRole: "admin",
icon: Share2,
},
{
title: "Categorias",
description: "Gerencie categorias e formulários de tickets.",
href: "/admin/fields",
cta: "Gerenciar",
requiredRole: "admin",
icon: Layers3,
},
{
title: "Usuários e convites",
description: "Convide novos usuários e gerencie acessos.",
href: "/admin/users",
cta: "Gerenciar",
requiredRole: "admin",
icon: UserPlus,
},
{
title: "Templates de comentários",
description: "Mensagens rápidas para os atendimentos.",
href: "/settings/templates",
cta: "Gerenciar",
requiredRole: "staff",
icon: MessageSquareText,
},
{
title: "Notificações",
description: "Configure quais e-mails deseja receber.",
href: "/settings/notifications",
cta: "Configurar",
requiredRole: "staff",
icon: BellRing,
},
{
title: "Políticas de SLA",
description: "Acompanhe e configure níveis de serviço.",
href: "/admin/slas",
cta: "Gerenciar",
requiredRole: "admin",
icon: ShieldCheck,
},
]
export function SettingsContent() {
const { session, isAdmin, isStaff } = useAuth()
const [isSigningOut, setIsSigningOut] = useState(false)
const router = useRouter()
const normalizedRole = session?.user.role?.toLowerCase() ?? "agent"
const roleLabel = ROLE_LABELS[normalizedRole] ?? "Agente"
const roleColorClass = ROLE_COLORS[normalizedRole] ?? ROLE_COLORS.agent
const sessionExpiry = useMemo(() => {
const expiresAt = session?.session?.expiresAt
if (!expiresAt) return null
return new Intl.DateTimeFormat("pt-BR", {
dateStyle: "long",
timeStyle: "short",
}).format(new Date(expiresAt))
}, [session?.session?.expiresAt])
async function handleSignOut() {
if (isSigningOut) return
setIsSigningOut(true)
try {
await signOut()
toast.success("Sessão encerrada")
router.replace("/login")
} catch (error) {
console.error(error)
toast.error("Não foi possível encerrar a sessão.")
} finally {
setIsSigningOut(false)
}
}
function canAccess(requiredRole?: RoleRequirement) {
if (!requiredRole) return true
if (requiredRole === "admin") return isAdmin
if (requiredRole === "staff") return isStaff
return false
}
const initials = useMemo(() => {
const name = session?.user.name ?? ""
const parts = name.split(" ").filter(Boolean)
if (parts.length >= 2) {
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase()
}
return name.slice(0, 2).toUpperCase() || "U"
}, [session?.user.name])
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-8 px-4 pb-12 lg:px-6">
{/* Perfil do usuario */}
<section className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-neutral-900">Meu perfil</h2>
<p className="text-sm text-neutral-500">
Suas informações pessoais e configurações de conta.
</p>
</div>
<div className="grid gap-4 lg:grid-cols-[1fr_320px]">
<ProfileEditCard
name={session?.user.name ?? ""}
email={session?.user.email ?? ""}
avatarUrl={session?.user.avatarUrl ?? null}
initials={initials}
/>
<div className="space-y-4">
<Card className="rounded-2xl border border-border/60 bg-white shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-sm font-semibold text-neutral-900">
<Shield className="size-4 text-neutral-500" />
Acesso
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-neutral-600">Papel</span>
<Badge className={`rounded-full border px-3 py-1 text-xs font-medium ${roleColorClass}`}>
{roleLabel}
</Badge>
</div>
<Separator />
<div className="flex items-center justify-between">
<span className="text-sm text-neutral-600">Sessão</span>
<div className="flex items-center gap-2">
<div className="size-2 animate-pulse rounded-full bg-green-500" />
<span className="text-xs text-neutral-500">Ativa</span>
</div>
</div>
{sessionExpiry && (
<p className="text-xs text-neutral-400">
Expira em {sessionExpiry}
</p>
)}
</CardContent>
<CardFooter className="pt-0">
<Button
variant="outline"
size="sm"
className="w-full gap-2 text-red-600 hover:bg-red-50 hover:text-red-700"
onClick={handleSignOut}
disabled={isSigningOut}
>
<LogOut className="size-4" />
{isSigningOut ? "Encerrando..." : "Encerrar sessão"}
</Button>
</CardFooter>
</Card>
<Card className="rounded-2xl border border-border/60 bg-white shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-semibold text-neutral-900">
<Clock className="size-4 text-neutral-500" />
Segurança
</CardTitle>
<CardDescription className="text-xs text-neutral-500">
Gerencie a segurança da sua conta
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<Button variant="outline" size="sm" className="w-full justify-start gap-2" asChild>
<Link href="/recuperar">
<Key className="size-4" />
Alterar senha
</Link>
</Button>
</CardContent>
</Card>
</div>
</div>
</section>
{/* Configuracoes do workspace */}
<section className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-neutral-900">Configurações</h2>
<p className="text-sm text-neutral-500">
Gerencie times, filas, categorias e outras configurações do sistema.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{SETTINGS_ACTIONS.map((action) => {
const allowed = canAccess(action.requiredRole)
const Icon = action.icon
return (
<Card
key={action.title}
className={`rounded-2xl border border-border/60 bg-white shadow-sm transition-all ${
allowed ? "hover:border-primary/30 hover:shadow-md" : "opacity-60"
}`}
>
<CardHeader className="flex flex-row items-start gap-3 pb-2">
<div className="flex size-9 items-center justify-center rounded-xl bg-neutral-100 text-neutral-600">
<Icon className="size-4" />
</div>
<div className="flex-1 space-y-0.5">
<CardTitle className="text-sm font-semibold text-neutral-900">{action.title}</CardTitle>
<CardDescription className="text-xs text-neutral-500 leading-relaxed">
{action.description}
</CardDescription>
</div>
</CardHeader>
<CardFooter className="pt-2">
{allowed ? (
<Button asChild size="sm" variant="outline" className="w-full">
<Link href={action.href}>{action.cta}</Link>
</Button>
) : (
<Button size="sm" variant="outline" className="w-full" disabled>
Acesso restrito
</Button>
)}
</CardFooter>
</Card>
)
})}
</div>
</section>
</div>
)
}
function ProfileEditCard({
name,
email,
avatarUrl,
initials,
}: {
name: string
email: string
avatarUrl: string | null
initials: string
}) {
const [editName, setEditName] = useState(name)
const [editEmail, setEditEmail] = useState(email)
const [newPassword, setNewPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [isSubmitting, setIsSubmitting] = useState(false)
const [localAvatarUrl, setLocalAvatarUrl] = useState(avatarUrl)
const [pendingAvatarFile, setPendingAvatarFile] = useState<File | null>(null)
const [pendingAvatarPreview, setPendingAvatarPreview] = useState<string | null>(null)
const [pendingRemoveAvatar, setPendingRemoveAvatar] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
// URL de exibição: preview pendente > URL atual (se não marcado para remoção)
const displayAvatarUrl = pendingAvatarPreview ?? (pendingRemoveAvatar ? null : localAvatarUrl)
function handleAvatarSelect(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]
if (!file) return
// Valida tamanho (5MB)
if (file.size > 5 * 1024 * 1024) {
toast.error("Arquivo muito grande. Máximo 5MB.")
return
}
// Valida tipo
if (!["image/jpeg", "image/png", "image/webp", "image/gif"].includes(file.type)) {
toast.error("Tipo de arquivo não permitido. Use JPG, PNG, WebP ou GIF.")
return
}
// Cria preview local
const previewUrl = URL.createObjectURL(file)
setPendingAvatarFile(file)
setPendingAvatarPreview(previewUrl)
setPendingRemoveAvatar(false)
// Limpa o input para permitir reselecionar o mesmo arquivo
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
function handleRemoveAvatarClick() {
// Limpa preview pendente se houver
if (pendingAvatarPreview) {
URL.revokeObjectURL(pendingAvatarPreview)
}
setPendingAvatarFile(null)
setPendingAvatarPreview(null)
// Marca para remoção apenas se já tiver avatar salvo
if (localAvatarUrl) {
setPendingRemoveAvatar(true)
}
}
const hasChanges = useMemo(() => {
const nameChanged = editName.trim() !== name
const emailChanged = editEmail.trim().toLowerCase() !== email.toLowerCase()
const passwordChanged = newPassword.length > 0 || confirmPassword.length > 0
const avatarChanged = pendingAvatarFile !== null || pendingRemoveAvatar
return nameChanged || emailChanged || passwordChanged || avatarChanged
}, [editName, name, editEmail, email, newPassword, confirmPassword, pendingAvatarFile, pendingRemoveAvatar])
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!hasChanges) {
toast.info("Nenhuma alteração a salvar.")
return
}
const payload: Record<string, unknown> = {}
const trimmedEmail = editEmail.trim()
if (trimmedEmail && trimmedEmail.toLowerCase() !== email.toLowerCase()) {
payload.email = trimmedEmail
}
if (newPassword || confirmPassword) {
if (newPassword !== confirmPassword) {
toast.error("As senhas não conferem")
return
}
if (newPassword.length < 8) {
toast.error("A senha deve ter pelo menos 8 caracteres")
return
}
payload.password = { newPassword, confirmPassword }
}
setIsSubmitting(true)
try {
// Processa avatar primeiro
if (pendingAvatarFile) {
const formData = new FormData()
formData.append("file", pendingAvatarFile)
const avatarRes = await fetch("/api/profile/avatar", {
method: "POST",
body: formData,
})
if (!avatarRes.ok) {
const data = await avatarRes.json().catch(() => ({ error: "Erro ao fazer upload" }))
throw new Error(data.error || "Erro ao fazer upload da foto")
}
const avatarData = await avatarRes.json()
setLocalAvatarUrl(avatarData.avatarUrl)
// Limpa preview
if (pendingAvatarPreview) {
URL.revokeObjectURL(pendingAvatarPreview)
}
setPendingAvatarFile(null)
setPendingAvatarPreview(null)
} else if (pendingRemoveAvatar) {
const avatarRes = await fetch("/api/profile/avatar", {
method: "DELETE",
})
if (!avatarRes.ok) {
const data = await avatarRes.json().catch(() => ({ error: "Erro ao remover foto" }))
throw new Error(data.error || "Erro ao remover foto")
}
setLocalAvatarUrl(null)
setPendingRemoveAvatar(false)
}
// Processa outros dados do perfil se houver
if (Object.keys(payload).length > 0) {
const res = await fetch("/api/portal/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
if (!res.ok) {
const data = await res.json().catch(() => ({ error: "Falha ao atualizar perfil" }))
const message = typeof data.error === "string" ? data.error : "Falha ao atualizar perfil"
toast.error(message)
return
}
const data = (await res.json().catch(() => null)) as { email?: string } | null
if (data?.email) {
setEditEmail(data.email)
}
}
setNewPassword("")
setConfirmPassword("")
toast.success("Dados atualizados com sucesso!")
} catch (error) {
console.error("Falha ao atualizar perfil", error)
toast.error(error instanceof Error ? error.message : "Não foi possível atualizar o perfil.")
} finally {
setIsSubmitting(false)
}
}
return (
<Card className="relative rounded-2xl border border-border/60 bg-white shadow-sm overflow-hidden">
{/* Background absoluto no topo do card */}
<div className="absolute inset-x-0 top-0 h-20">
<ShaderBackground className="h-full w-full" />
</div>
{/* Conteudo com padding-top para ficar abaixo do background */}
<CardHeader className="relative pb-4 pt-10">
<div className="flex items-end gap-4">
<div className="relative group">
<Avatar className="size-20 border-4 border-white shadow-lg ring-2 ring-neutral-200">
<AvatarImage src={displayAvatarUrl ?? undefined} alt={name} />
<AvatarFallback className="bg-neutral-200 text-xl font-semibold text-neutral-700">
{initials}
</AvatarFallback>
</Avatar>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
onChange={handleAvatarSelect}
className="hidden"
/>
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50 opacity-0 transition-opacity group-hover:opacity-100">
{isSubmitting ? (
<Loader2 className="size-5 text-white animate-spin" />
) : (
<div className="flex items-center gap-2">
<button
type="button"
className="flex items-center justify-center size-8 rounded-full bg-white/20 hover:bg-white/30 transition-colors"
onClick={() => fileInputRef.current?.click()}
title="Alterar foto"
>
<Camera className="size-4 text-white" />
</button>
{(displayAvatarUrl || pendingAvatarFile) && !pendingRemoveAvatar && (
<button
type="button"
className="flex items-center justify-center size-8 rounded-full bg-red-500/80 hover:bg-red-500 transition-colors"
onClick={handleRemoveAvatarClick}
title="Remover foto"
>
<Trash2 className="size-4 text-white" />
</button>
)}
</div>
)}
</div>
</div>
<div className="flex-1 pb-1">
<CardTitle className="text-lg font-semibold text-neutral-900">{name || "Usuário"}</CardTitle>
<CardDescription className="text-sm text-neutral-500">{email}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="profile-name" className="flex items-center gap-2 text-sm font-medium text-neutral-700">
<User className="size-4 text-neutral-400" />
Nome
</Label>
<Input
id="profile-name"
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="Seu nome"
disabled
className="bg-neutral-50"
/>
<p className="text-xs text-neutral-400">Editável apenas por administradores</p>
</div>
<div className="space-y-2">
<Label htmlFor="profile-email" className="flex items-center gap-2 text-sm font-medium text-neutral-700">
<Mail className="size-4 text-neutral-400" />
E-mail
</Label>
<Input
id="profile-email"
type="email"
value={editEmail}
onChange={(e) => setEditEmail(e.target.value)}
placeholder="seu@email.com"
/>
</div>
</div>
<Separator />
<div className="space-y-3">
<Label className="flex items-center gap-2 text-sm font-medium text-neutral-700">
<Key className="size-4 text-neutral-400" />
Alterar senha
</Label>
<div className="grid gap-3 sm:grid-cols-2">
<Input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Nova senha"
autoComplete="new-password"
/>
<Input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirmar senha"
autoComplete="new-password"
/>
</div>
<p className="text-xs text-neutral-400">
Mínimo de 8 caracteres. Deixe em branco se não quiser alterar.
</p>
</div>
<div className="flex justify-end pt-2">
<Button type="submit" disabled={isSubmitting || !hasChanges}>
{isSubmitting ? "Salvando..." : "Salvar alterações"}
</Button>
</div>
</form>
</CardContent>
</Card>
)
}