feat: melhorias de UX e redesign de comentários

- Corrige sincronização do avatar no perfil após upload
- Reduz tamanho dos ícones de câmera/lixeira no avatar
- Remove atributos title (tooltips nativos) de toda aplicação
- Adiciona regra no AGENTS.md sobre uso de tooltips
- Permite desmarcar resposta no checklist (toggle)
- Torna campo answer opcional na mutation setChecklistItemAnswer
- Adiciona edição inline dos campos de resumo no painel de detalhes
- Redesenha comentários com layout mais limpo e consistente
- Cria tratamento especial para comentários automáticos de sistema
- Aplica fundo ciano semi-transparente em comentários públicos
- Corrige import do Loader2 no notification-preferences-form

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
esdrasrenan 2025-12-15 22:05:27 -03:00
parent 23ea426c68
commit 022e1f63ba
17 changed files with 636 additions and 180 deletions

View file

@ -167,5 +167,48 @@ bun run build:bun
- `docs/DEPLOY-RUNBOOK.md` — runbook do Swarm.
- `docs/admin-inventory-ui.md`, `docs/plano-app-desktop-maquinas.md` — detalhes do inventário/agente.
## Regras de Codigo
### Tooltips Nativos do Navegador
**NAO use o atributo `title` em elementos HTML** (button, span, a, div, etc).
O atributo `title` causa tooltips nativos do navegador que sao inconsistentes visualmente e nao seguem o design system da aplicacao.
```tsx
// ERRADO - causa tooltip nativo do navegador
<button title="Remover item">
<Trash2 className="size-4" />
</button>
// CORRETO - sem tooltip nativo
<button>
<Trash2 className="size-4" />
</button>
// CORRETO - se precisar de tooltip, use o componente Tooltip do shadcn/ui
<Tooltip>
<TooltipTrigger asChild>
<button>
<Trash2 className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent>Remover item</TooltipContent>
</Tooltip>
```
**Excecoes:**
- Props `title` de componentes customizados (CardTitle, DialogTitle, etc) sao permitidas pois nao geram tooltips nativos.
### Acessibilidade
Para manter acessibilidade em botoes apenas com icone, prefira usar `aria-label`:
```tsx
<button aria-label="Remover item">
<Trash2 className="size-4" />
</button>
```
---
_Última atualização: 10/12/2025 (Next.js 16, build padrão com Turbopack e fallback webpack documentado)._
_Última atualização: 15/12/2025 (Next.js 16, build padrão com Turbopack e fallback webpack documentado)._

View file

@ -2661,7 +2661,7 @@ export const setChecklistItemAnswer = mutation({
ticketId: v.id("tickets"),
actorId: v.id("users"),
itemId: v.string(),
answer: v.string(),
answer: v.optional(v.string()),
},
handler: async (ctx, { ticketId, actorId, itemId, answer }) => {
const ticket = await ctx.db.get(ticketId);
@ -2683,7 +2683,7 @@ export const setChecklistItemAnswer = mutation({
}
const now = Date.now();
const normalizedAnswer = answer.trim();
const normalizedAnswer = answer?.trim() ?? "";
const isDone = normalizedAnswer.length > 0;
const nextChecklist = checklist.map((it) => {

View file

@ -300,10 +300,23 @@ export const updateAvatar = mutation({
return { status: "not_found" }
}
// Atualiza o avatar do usuário
// Atualiza o avatar do usuário - usa undefined para remover o campo
const normalizedAvatarUrl = avatarUrl ?? undefined
await ctx.db.patch(user._id, { avatarUrl: normalizedAvatarUrl })
// Cria snapshot base sem avatarUrl se for undefined
// Isso garante que o campo seja realmente removido do snapshot
const baseSnapshot: { name: string; email: string; avatarUrl?: string; teams?: string[] } = {
name: user.name,
email: user.email,
}
if (normalizedAvatarUrl !== undefined) {
baseSnapshot.avatarUrl = normalizedAvatarUrl
}
if (user.teams && user.teams.length > 0) {
baseSnapshot.teams = user.teams
}
// Atualiza snapshots em comentários
const comments = await ctx.db
.query("ticketComments")
@ -311,15 +324,9 @@ export const updateAvatar = mutation({
.take(10000)
if (comments.length > 0) {
const authorSnapshot = {
name: user.name,
email: user.email,
avatarUrl: normalizedAvatarUrl,
teams: user.teams ?? undefined,
}
await Promise.all(
comments.map(async (comment) => {
await ctx.db.patch(comment._id, { authorSnapshot })
await ctx.db.patch(comment._id, { authorSnapshot: baseSnapshot })
}),
)
}
@ -331,14 +338,8 @@ export const updateAvatar = mutation({
.take(10000)
if (requesterTickets.length > 0) {
const requesterSnapshot = {
name: user.name,
email: user.email,
avatarUrl: normalizedAvatarUrl,
teams: user.teams ?? undefined,
}
for (const t of requesterTickets) {
await ctx.db.patch(t._id, { requesterSnapshot })
await ctx.db.patch(t._id, { requesterSnapshot: baseSnapshot })
}
}
@ -349,14 +350,8 @@ export const updateAvatar = mutation({
.take(10000)
if (assigneeTickets.length > 0) {
const assigneeSnapshot = {
name: user.name,
email: user.email,
avatarUrl: normalizedAvatarUrl,
teams: user.teams ?? undefined,
}
for (const t of assigneeTickets) {
await ctx.db.patch(t._id, { assigneeSnapshot })
await ctx.db.patch(t._id, { assigneeSnapshot: baseSnapshot })
}
}

View file

@ -137,3 +137,45 @@ export async function DELETE() {
return NextResponse.json({ error: "Erro interno do servidor" }, { status: 500 })
}
}
/**
* PATCH - Força sincronização do avatar atual do Prisma para o Convex
* Útil quando a sincronização automática falhou
*/
export async function PATCH() {
try {
const session = await getServerSession()
if (!session?.user?.id) {
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
}
// Busca o avatar atual no Prisma
const user = await prisma.authUser.findUnique({
where: { id: session.user.id },
select: { avatarUrl: true },
})
// Sincroniza com o Convex
const convex = createConvexClient()
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
const result = await convex.mutation(api.users.updateAvatar, {
tenantId,
email: session.user.email,
avatarUrl: user?.avatarUrl ?? null,
})
console.log("[profile/avatar] Sincronização forçada:", result)
return NextResponse.json({
success: true,
message: "Avatar sincronizado com sucesso",
avatarUrl: user?.avatarUrl ?? null,
convexResult: result,
})
} catch (error) {
console.error("[profile/avatar] Erro na sincronização:", error)
return NextResponse.json({ error: "Erro ao sincronizar avatar" }, { status: 500 })
}
}

View file

@ -4089,7 +4089,6 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
size="sm"
className="h-7 border border-transparent px-2 text-slate-600 transition-colors hover:border-slate-300 hover:bg-slate-100 hover:text-slate-900"
onClick={() => handleCopyRemoteIdentifier(entry.identifier)}
title="Copiar ID"
aria-label="Copiar ID"
>
<ClipboardCopy className="size-3.5" />
@ -4109,7 +4108,6 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
size="sm"
className="h-7 border border-transparent px-2 text-slate-600 hover:border-slate-300 hover:bg-slate-100 hover:text-slate-900"
onClick={() => handleCopyRemoteCredential(entry.username, "Usuário do acesso remoto")}
title="Copiar usuário"
aria-label="Copiar usuário"
>
<ClipboardCopy className="size-3.5" />
@ -4119,7 +4117,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
{entry.password ? (
<div className="inline-flex flex-wrap items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-slate-500">Senha</span>
<code className="rounded-md border border-slate-200 bg-white px-2.5 py-1 font-mono text-sm text-slate-700">
<code className="rounded-md border border-slate-200 bg-white px-2.5 py-1 font-mono text-sm font-semibold text-neutral-800">
{secretVisible ? entry.password : "••••••••"}
</code>
<Button
@ -4127,7 +4125,6 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
size="sm"
className="h-7 border border-transparent px-2 text-slate-600 hover:border-slate-300 hover:bg-slate-100 hover:text-slate-900"
onClick={() => toggleRemoteSecret(entry.clientId)}
title={secretVisible ? "Ocultar senha" : "Mostrar senha"}
aria-label={secretVisible ? "Ocultar senha" : "Mostrar senha"}
>
{secretVisible ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
@ -4137,7 +4134,6 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
size="sm"
className="h-7 border border-transparent px-2 text-slate-600 hover:border-slate-300 hover:bg-slate-100 hover:text-slate-900"
onClick={() => handleCopyRemoteCredential(entry.password, "Senha do acesso remoto")}
title="Copiar senha"
aria-label="Copiar senha"
>
<ClipboardCopy className="size-3.5" />

View file

@ -824,7 +824,6 @@ export function AutomationEditorDialog({
size="icon"
onClick={() => handleRemoveCondition(c.id)}
className="mt-6 h-8 w-8 text-slate-500 hover:bg-red-50 hover:text-red-700"
title="Remover"
>
<Trash2 className="size-4" />
</Button>
@ -1074,7 +1073,6 @@ export function AutomationEditorDialog({
size="icon"
onClick={() => handleRemoveAction(a.id)}
className="mt-6 h-8 w-8 text-slate-500 hover:bg-red-50 hover:text-red-700"
title="Remover"
>
<Trash2 className="size-4" />
</Button>

View file

@ -72,9 +72,9 @@ export function ChatSessionItem({ session, isActive, onClick }: ChatSessionItemP
{/* Indicador online/offline */}
{session.machineOnline !== undefined && (
session.machineOnline ? (
<span className="size-2 rounded-full bg-emerald-500" title="Online" />
<span className="size-2 rounded-full bg-emerald-500" />
) : (
<WifiOff className="size-3 text-slate-400" title="Offline" />
<WifiOff className="size-3 text-slate-400" />
)
)}
</div>

View file

@ -68,7 +68,6 @@ export function ChatSessionList({
<button
onClick={onMinimize}
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
title="Minimizar"
>
<svg className="size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
@ -77,7 +76,6 @@ export function ChatSessionList({
<button
onClick={onClose}
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
title="Fechar"
>
<X className="size-4" />
</button>

View file

@ -219,7 +219,6 @@ function MessageAttachment({ attachment }: { attachment: ChatAttachment }) {
<button
onClick={handleView}
className="flex size-6 items-center justify-center rounded-full bg-white/20 hover:bg-white/30"
title="Visualizar"
>
<Eye className="size-3.5 text-white" />
</button>
@ -227,7 +226,6 @@ function MessageAttachment({ attachment }: { attachment: ChatAttachment }) {
onClick={handleDownload}
disabled={downloading}
className="flex size-6 items-center justify-center rounded-full bg-white/20 hover:bg-white/30"
title="Baixar"
>
{downloading ? (
<Spinner className="size-3 text-white" />
@ -249,7 +247,6 @@ function MessageAttachment({ attachment }: { attachment: ChatAttachment }) {
<button
onClick={handleView}
className="rounded p-0.5 hover:bg-slate-200"
title="Visualizar"
>
<Eye className="size-3 text-slate-400" />
</button>
@ -257,7 +254,6 @@ function MessageAttachment({ attachment }: { attachment: ChatAttachment }) {
onClick={handleDownload}
disabled={downloading}
className="rounded p-0.5 hover:bg-slate-200"
title="Baixar"
>
{downloading ? (
<Spinner className="size-3 text-slate-400" />
@ -721,7 +717,6 @@ export function ChatWidget() {
<button
onClick={handleBackToList}
className="flex size-8 items-center justify-center rounded-lg text-slate-500 hover:bg-slate-100"
title="Voltar para lista"
>
<ChevronLeft className="size-5" />
</button>
@ -785,14 +780,12 @@ export function ChatWidget() {
<button
onClick={() => setIsMinimized(true)}
className="ml-2 rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
title="Minimizar"
>
<Minimize2 className="size-4" />
</button>
<button
onClick={() => setIsOpen(false)}
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
title="Fechar"
>
<X className="size-4" />
</button>
@ -955,7 +948,6 @@ export function ChatWidget() {
className="size-9 text-slate-500 hover:bg-slate-100 hover:text-slate-700"
onClick={() => fileInputRef.current?.click()}
disabled={attachments.length >= MAX_ATTACHMENTS || isUploading}
title="Anexar arquivo"
>
{isUploading ? (
<Spinner className="size-4" />

View file

@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import { toast } from "sonner"
import { Loader2, Bell, BellOff, Clock, Mail, Lock } from "lucide-react"
import { Bell, BellOff, Clock, Mail, Lock, Loader2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
@ -12,6 +12,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Separator } from "@/components/ui/separator"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils"
interface NotificationType {
@ -143,8 +144,81 @@ export function NotificationPreferencesForm({ isPortal = false }: NotificationPr
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<div className="space-y-6">
{/* Skeleton do card de e-mail */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-5 rounded" />
<Skeleton className="h-5 w-40" />
</div>
<Skeleton className="mt-1 h-4 w-64" />
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-72" />
</div>
<Skeleton className="h-6 w-11 rounded-full" />
</div>
</CardContent>
</Card>
{/* Skeleton do card de tipos de notificacao */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-5 rounded" />
<Skeleton className="h-5 w-44" />
</div>
<Skeleton className="mt-1 h-4 w-80" />
</CardHeader>
<CardContent className="space-y-6">
{/* Grupo 1 */}
<div className="space-y-3">
<div>
<Skeleton className="h-4 w-40" />
<Skeleton className="mt-1 h-3 w-64" />
</div>
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center justify-between rounded-lg border border-slate-200 p-4">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className="h-4 w-36" />
</div>
<Skeleton className="h-6 w-11 rounded-full" />
</div>
))}
</div>
</div>
{/* Grupo 2 */}
<div className="space-y-3">
<div>
<Skeleton className="h-4 w-32" />
<Skeleton className="mt-1 h-3 w-56" />
</div>
<div className="space-y-2">
{Array.from({ length: 2 }).map((_, i) => (
<div key={i} className="flex items-center justify-between rounded-lg border border-slate-200 p-4">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="h-6 w-11 rounded-full" />
</div>
))}
</div>
</div>
</CardContent>
</Card>
{/* Skeleton do botao salvar */}
<div className="flex justify-end">
<Skeleton className="h-10 w-40 rounded-md" />
</div>
</div>
)
}

View file

@ -1,6 +1,6 @@
"use client"
import { FormEvent, useMemo, useRef, useState } from "react"
import { FormEvent, useEffect, useMemo, useRef, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import dynamic from "next/dynamic"
@ -330,6 +330,7 @@ function ProfileEditCard({
avatarUrl: string | null
initials: string
}) {
const router = useRouter()
const [editName, setEditName] = useState(name)
const [editEmail, setEditEmail] = useState(email)
const [newPassword, setNewPassword] = useState("")
@ -341,6 +342,11 @@ function ProfileEditCard({
const [pendingRemoveAvatar, setPendingRemoveAvatar] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
// Sincroniza localAvatarUrl quando a prop avatarUrl muda (ex: sessao atualizada)
useEffect(() => {
setLocalAvatarUrl(avatarUrl)
}, [avatarUrl])
// URL de exibição: preview pendente > URL atual (se não marcado para remoção)
const displayAvatarUrl = pendingAvatarPreview ?? (pendingRemoveAvatar ? null : localAvatarUrl)
@ -479,6 +485,9 @@ function ProfileEditCard({
setNewPassword("")
setConfirmPassword("")
toast.success("Dados atualizados com sucesso!")
// Atualiza a sessao para refletir mudancas (avatar, etc)
router.refresh()
} catch (error) {
console.error("Falha ao atualizar perfil", error)
toast.error(error instanceof Error ? error.message : "Não foi possível atualizar o perfil.")
@ -512,25 +521,23 @@ function ProfileEditCard({
/>
<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" />
<Loader2 className="size-4 text-white animate-spin" />
) : (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<button
type="button"
className="flex items-center justify-center size-8 rounded-full bg-white/20 hover:bg-white/30 transition-colors"
className="flex items-center justify-center size-6 rounded-full bg-white/20 hover:bg-white/30 transition-colors"
onClick={() => fileInputRef.current?.click()}
title="Alterar foto"
>
<Camera className="size-4 text-white" />
<Camera className="size-3 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"
className="flex items-center justify-center size-6 rounded-full bg-red-500/80 hover:bg-red-500 transition-colors"
onClick={handleRemoveAvatarClick}
title="Remover foto"
>
<Trash2 className="size-4 text-white" />
<Trash2 className="size-3 text-white" />
</button>
)}
</div>

View file

@ -939,7 +939,6 @@ export function CloseTicketDialog({
type="button"
variant="outline"
size="icon"
title="Limpar mensagem"
aria-label="Limpar mensagem"
onClick={() => {
setMessage("")

View file

@ -1416,7 +1416,6 @@ export function NewTicketDialog({
onClick={() =>
setAppliedChecklistTemplateIds((prev) => prev.filter((id) => id !== String(tpl.id)))
}
title="Remover template"
>
×
</button>
@ -1482,7 +1481,6 @@ export function NewTicketDialog({
size="icon"
className="h-9 w-9 text-slate-500 hover:bg-red-50 hover:text-red-700"
onClick={() => setManualChecklist((prev) => prev.filter((row) => row.id !== item.id))}
title="Remover"
>
<Trash2 className="size-4" />
</Button>

View file

@ -16,7 +16,6 @@ import { Checkbox } from "@/components/ui/checkbox"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Progress } from "@/components/ui/progress"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@ -391,38 +390,39 @@ export function TicketChecklistCard({
)}
{isQuestion && options.length > 0 && (
<RadioGroup
value={item.answer ?? ""}
onValueChange={async (value) => {
if (!actorId || !canToggle) return
try {
await setChecklistItemAnswer({
ticketId: ticket.id as Id<"tickets">,
actorId,
itemId: item.id,
answer: value,
})
} catch (error) {
toast.error(error instanceof Error ? error.message : "Falha ao responder pergunta.")
}
}}
disabled={!canToggle || !actorId}
className="flex flex-wrap items-center gap-4"
>
{options.map((option) => (
<label
key={option}
className={`flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-1.5 text-sm transition-colors ${
item.answer === option
? "border-neutral-900 bg-neutral-900 text-white"
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50"
}`}
>
<RadioGroupItem value={option} className="sr-only" />
{option}
</label>
))}
</RadioGroup>
<div className="flex flex-wrap items-center gap-2">
{options.map((option) => {
const isSelected = item.answer === option
return (
<button
key={option}
type="button"
disabled={!canToggle || !actorId}
onClick={async () => {
if (!actorId || !canToggle) return
try {
await setChecklistItemAnswer({
ticketId: ticket.id as Id<"tickets">,
actorId,
itemId: item.id,
// Toggle: se já está selecionado, limpa; senão, seleciona
answer: isSelected ? undefined : option,
})
} catch (error) {
toast.error(error instanceof Error ? error.message : "Falha ao responder pergunta.")
}
}}
className={`rounded-lg border px-3 py-1.5 text-sm transition-colors ${
isSelected
? "border-neutral-900 bg-neutral-900 text-white"
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50"
} ${!canToggle || !actorId ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}
>
{option}
</button>
)
})}
</div>
)}
<div className="flex flex-wrap items-center gap-2">
@ -476,7 +476,6 @@ export function TicketChecklistCard({
variant="ghost"
size="icon"
className="h-9 w-9 text-slate-500 hover:bg-red-50 hover:text-red-700"
title="Remover"
onClick={() => setDeleteTarget(item)}
>
<Trash2 className="size-4" />

View file

@ -3,8 +3,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { IconLock, IconMessage, IconFileText } from "@tabler/icons-react"
import { Download, FileCode, FileIcon, Image as ImageIcon, PencilLine, Trash2, X, ClipboardCopy } from "lucide-react"
import { IconMessage, IconFileText, IconPencil } from "@tabler/icons-react"
import { Download, FileCode, FileIcon, Image as ImageIcon, Trash2, X, ClipboardCopy } from "lucide-react"
import { useAction, useMutation, useQuery } from "convex/react"
import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client"
@ -30,13 +30,15 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import { Spinner } from "@/components/ui/spinner"
import { cn } from "@/lib/utils"
interface TicketCommentsProps {
ticket: TicketWithDetails
}
const badgeInternal = "gap-1 rounded-full border border-slate-300 bg-neutral-900 px-2 py-0.5 text-xs font-semibold tracking-wide text-white"
const selectTriggerClass = "h-8 w-[140px] rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
const badgeInternal = "rounded-full border border-slate-300 bg-neutral-900 px-2.5 py-0.5 text-xs font-semibold text-white"
const badgePublic = "rounded-full border border-[#00d6eb]/40 bg-[#00e8ff]/15 px-2.5 py-0.5 text-xs font-semibold text-neutral-900"
const selectTriggerClass = "h-8 w-[140px] rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-slate-400"
const submitButtonClass =
"inline-flex items-center gap-2 rounded-lg border border-black bg-black px-3 py-2 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30"
@ -45,6 +47,64 @@ const COMMENT_ATTACHMENT_MAX_FILE_SIZE = 5 * 1024 * 1024
type CommentsOrder = "descending" | "ascending"
// Detecta e parseia comentários automáticos de sistema
interface SystemCommentData {
type: "assignee_change" | "status_change" | "priority_change" | "queue_change"
field: string
from: string
to: string
reason?: string
}
function parseSystemComment(html: string): SystemCommentData | null {
// Padrões de comentários automáticos
const patterns = [
{ regex: /<strong>Responsável atualizado:<\/strong>\s*([^→<]+)\s*→\s*([^<]+)/i, type: "assignee_change" as const, field: "Responsável" },
{ regex: /<strong>Status atualizado:<\/strong>\s*([^→<]+)\s*→\s*([^<]+)/i, type: "status_change" as const, field: "Status" },
{ regex: /<strong>Prioridade atualizada:<\/strong>\s*([^→<]+)\s*→\s*([^<]+)/i, type: "priority_change" as const, field: "Prioridade" },
{ regex: /<strong>Fila atualizada:<\/strong>\s*([^→<]+)\s*→\s*([^<]+)/i, type: "queue_change" as const, field: "Fila" },
]
for (const pattern of patterns) {
const match = html.match(pattern.regex)
if (match) {
// Extrair motivo se existir
const reasonMatch = html.match(/<strong>Motivo da troca:<\/strong><\/p>\s*<p>([^<]+)/i)
return {
type: pattern.type,
field: pattern.field,
from: match[1].trim(),
to: match[2].trim(),
reason: reasonMatch ? reasonMatch[1].trim() : undefined,
}
}
}
return null
}
function SystemCommentContent({ data }: { data: SystemCommentData }) {
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-neutral-600">{data.field}:</span>
<span className="inline-flex items-center gap-1.5 rounded-md border border-slate-200 bg-slate-50 px-2 py-0.5 text-sm text-neutral-600">
{data.from}
</span>
<span className="text-neutral-400"></span>
<span className="inline-flex items-center gap-1.5 rounded-md border border-neutral-300 bg-neutral-900/10 px-2 py-0.5 text-sm font-medium text-neutral-900">
{data.to}
</span>
</div>
{data.reason && (
<p className="text-sm text-neutral-500">
<span className="font-medium">Motivo:</span> {data.reason}
</p>
)}
</div>
)
}
export function TicketComments({ ticket }: TicketCommentsProps) {
const { convexUserId, isStaff, role } = useAuth()
const normalizedRole = role ?? null
@ -422,54 +482,57 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
const hasBody = bodyPlain.length > 0 || isEditing
const isInternal = comment.visibility === "INTERNAL" && canSeeInternalComments
const isPublic = comment.visibility === "PUBLIC"
const containerClass = isPublic
? "group/comment flex gap-3 rounded-2xl border border-amber-200/80 bg-amber-50/80 px-3 py-3 shadow-[0_0_0_1px_rgba(217,119,6,0.15)]"
: "group/comment flex gap-3 rounded-2xl border border-slate-200 bg-white px-3 py-3"
const bodyClass = isPublic
? "relative break-words rounded-xl border border-amber-200/80 bg-white px-3 py-2 text-sm leading-relaxed text-neutral-700 shadow-[0_0_0_1px_rgba(217,119,6,0.08)]"
: "relative break-words rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm leading-relaxed text-neutral-700"
const bodyEditButtonClass = isPublic
? "absolute right-3 top-1/2 inline-flex size-7 -translate-y-1/2 items-center justify-center rounded-full border border-amber-200 bg-white text-amber-700 opacity-0 transition hover:border-amber-500 hover:text-amber-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/30 group-hover/comment:opacity-100"
: "absolute right-3 top-1/2 inline-flex size-7 -translate-y-1/2 items-center justify-center rounded-full border border-slate-300 bg-white text-neutral-700 opacity-0 transition hover:border-black hover:text-black focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/20 group-hover/comment:opacity-100"
const addContentButtonClass = isPublic
? "inline-flex items-center gap-2 text-sm font-medium text-amber-700 transition hover:text-amber-900"
: "inline-flex items-center gap-2 text-sm font-medium text-neutral-600 transition hover:text-neutral-900"
return (
<div key={comment.id} className={containerClass}>
<Avatar className="size-9 border border-slate-200">
<AvatarImage src={comment.author.avatarUrl} alt={comment.author.name} />
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-semibold text-neutral-900">{comment.author.name}</span>
{comment.visibility === "INTERNAL" && canSeeInternalComments ? (
<Badge className={badgeInternal}>
<IconLock className="size-3 text-[#00e8ff]" /> Interno
</Badge>
) : null}
<span className="text-xs text-neutral-500">
{formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })}
</span>
<div
key={comment.id}
className={cn(
"group/comment overflow-hidden rounded-2xl border",
isPublic
? "border-[#00d6eb]/30 bg-[#00e8ff]/[0.08]"
: "border-slate-200 bg-white"
)}
>
{/* Header */}
<div className={cn(
"flex items-center justify-between gap-3 border-b px-4 py-3",
isPublic ? "border-[#00d6eb]/20" : "border-slate-100"
)}>
<div className="flex items-center gap-3">
<Avatar className="size-10 border border-slate-200">
<AvatarImage src={comment.author.avatarUrl} alt={comment.author.name} />
<AvatarFallback className="bg-slate-100 text-sm font-medium text-neutral-700">{initials}</AvatarFallback>
</Avatar>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-neutral-900">{comment.author.name}</span>
{isInternal ? (
<Badge className={badgeInternal}>Interno</Badge>
) : isPublic ? (
<Badge className={badgePublic}>Público</Badge>
) : null}
</div>
<span className="text-xs text-neutral-500">
{formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })}
</span>
</div>
</div>
{isInternal ? (
<span className="text-xs font-semibold tracking-wide text-amber-700/80">
Comentário interno visível apenas para administradores e agentes
</span>
) : comment.visibility === "PUBLIC" ? (
<span className="text-xs font-semibold tracking-wide text-amber-700/80">
Comentário visível para o cliente
</span>
) : null}
{isEditing ? (
<div
className={
isInternal
? "rounded-xl border border-amber-200/80 bg-white px-3 py-2 shadow-[0_0_0_1px_rgba(217,119,6,0.08)]"
: "rounded-xl border border-slate-200 bg-white px-3 py-2"
}
{canEdit && !isEditing ? (
<button
type="button"
onClick={() => startEditingComment(commentId, storedBody)}
className="inline-flex size-8 items-center justify-center rounded-lg border border-slate-200 bg-white text-neutral-600 opacity-0 transition hover:bg-slate-50 hover:text-neutral-900 group-hover/comment:opacity-100"
aria-label="Editar comentário"
>
<IconPencil className="size-4" />
</button>
) : null}
</div>
{/* Conteúdo */}
<div className="px-4 py-3">
{isEditing ? (
<div className="space-y-3">
<RichTextEditor
value={editingComment?.value ?? ""}
onChange={(next) =>
@ -478,12 +541,14 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
disabled={savingCommentId === commentId}
placeholder="Edite o comentário..."
ticketMention={{ enabled: allowTicketMentions }}
className="rounded-xl border border-slate-200"
/>
<div className="mt-3 flex items-center justify-end gap-2">
<div className="flex items-center justify-end gap-2">
<Button
type="button"
variant="outline"
className="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-slate-100"
size="sm"
className="rounded-lg border border-slate-200 text-sm font-medium text-neutral-700 hover:bg-slate-100"
onClick={cancelEditingComment}
disabled={savingCommentId === commentId}
>
@ -491,6 +556,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
</Button>
<Button
type="button"
size="sm"
className={submitButtonClass}
onClick={() => saveEditedComment(commentId, storedBody)}
disabled={savingCommentId === commentId}
@ -500,39 +566,27 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
</div>
</div>
) : hasBody ? (
<div className={bodyClass}>
{canEdit ? (
<button
type="button"
onClick={() => startEditingComment(commentId, storedBody)}
className={bodyEditButtonClass}
aria-label="Editar comentário"
>
<PencilLine className="size-3.5" />
</button>
) : null}
<RichTextContent html={storedBody} />
</div>
) : canEdit ? (
<div
className={
isInternal
? "rounded-xl border border-dashed border-amber-300 bg-amber-50/60 px-3 py-2 text-sm text-amber-700"
: "rounded-xl border border-dashed border-slate-300 bg-white/60 px-3 py-2 text-sm text-neutral-500"
(() => {
const systemData = parseSystemComment(storedBody)
if (systemData) {
return <SystemCommentContent data={systemData} />
}
return <RichTextContent html={storedBody} className="text-sm leading-relaxed text-neutral-700" />
})()
) : canEdit ? (
<button
type="button"
onClick={() => startEditingComment(commentId, storedBody)}
className="flex w-full items-center gap-2 rounded-xl border border-dashed border-slate-200 bg-slate-50/50 px-3 py-2 text-sm text-neutral-500 transition hover:border-slate-300 hover:text-neutral-700"
>
<button
type="button"
onClick={() => startEditingComment(commentId, storedBody)}
className={addContentButtonClass}
>
<PencilLine className="size-4" />
Adicionar conteúdo ao comentário
</button>
</div>
<IconPencil className="size-4" />
Adicionar conteúdo ao comentário
</button>
) : null}
{/* Anexos */}
{comment.attachments?.length ? (
<div className="grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3">
<div className="mt-4 grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3">
{comment.attachments.map((attachment) => (
<CommentAttachmentCard
key={attachment.id}
@ -551,7 +605,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
})
)}
{!canComment && (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700">
<div className="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-neutral-600">
Atribua um responsável ao chamado para que a equipe técnica possa comentar.
</div>
)}

View file

@ -1,16 +1,25 @@
import { useMemo } from "react"
import { useMemo, useState } from "react"
import { format } from "date-fns"
import { ptBR } from "date-fns/locale"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
import { MonitorSmartphone } from "lucide-react"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import type { TicketWithDetails } from "@/lib/schemas/ticket"
import { useAuth } from "@/lib/auth-client"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { getTicketStatusLabel, getTicketStatusSummaryTone } from "@/lib/ticket-status-style"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import { getSlaDisplayStatus, getSlaDueDate, type SlaDisplayStatus } from "@/lib/sla-utils"
import { Button } from "@/components/ui/button"
import { MonitorSmartphone } from "lucide-react"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { useTicketRemoteAccess } from "@/hooks/use-ticket-remote-access"
import { PrioritySelect } from "@/components/tickets/priority-select"
import { StatusSelect } from "@/components/tickets/status-select"
interface TicketDetailsPanelProps {
ticket: TicketWithDetails
@ -84,6 +93,7 @@ type SummaryChipConfig = {
}
export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
const { convexUserId, isStaff } = useAuth()
const {
canShowRemoteAccess,
primaryRemoteAccess,
@ -91,6 +101,91 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
hostname: remoteHostname,
} = useTicketRemoteAccess(ticket)
const canEdit = isStaff && !!convexUserId
// Mutations para edição
const updateStatus = useMutation(api.tickets.updateStatus)
const updatePriority = useMutation(api.tickets.updatePriority)
const changeQueue = useMutation(api.tickets.changeQueue)
const changeAssignee = useMutation(api.tickets.changeAssignee)
// Queries para opções (só carrega se usuário é staff e pode editar)
const queues = useQuery(
api.queues.listForStaff,
canEdit && convexUserId ? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
)
const agents = useQuery(
api.users.listAgents,
canEdit ? { tenantId: ticket.tenantId } : "skip"
)
// Estados para popovers
const [statusOpen, setStatusOpen] = useState(false)
const [priorityOpen, setPriorityOpen] = useState(false)
const [queueOpen, setQueueOpen] = useState(false)
const [assigneeOpen, setAssigneeOpen] = useState(false)
// Handlers de edição
const handleStatusChange = async (newStatus: string) => {
if (!convexUserId) return
try {
await updateStatus({
ticketId: ticket.id as Id<"tickets">,
actorId: convexUserId as Id<"users">,
status: newStatus,
})
setStatusOpen(false)
toast.success("Status atualizado")
} catch (error) {
toast.error(error instanceof Error ? error.message : "Erro ao atualizar status")
}
}
const handlePriorityChange = async (newPriority: string) => {
if (!convexUserId) return
try {
await updatePriority({
ticketId: ticket.id as Id<"tickets">,
actorId: convexUserId as Id<"users">,
priority: newPriority,
})
setPriorityOpen(false)
toast.success("Prioridade atualizada")
} catch (error) {
toast.error(error instanceof Error ? error.message : "Erro ao atualizar prioridade")
}
}
const handleQueueChange = async (newQueue: string) => {
if (!convexUserId) return
try {
await changeQueue({
ticketId: ticket.id as Id<"tickets">,
actorId: convexUserId as Id<"users">,
queueId: newQueue === "__none__" ? undefined : (newQueue as Id<"queues">),
})
setQueueOpen(false)
toast.success("Fila atualizada")
} catch (error) {
toast.error(error instanceof Error ? error.message : "Erro ao atualizar fila")
}
}
const handleAssigneeChange = async (newAssignee: string) => {
if (!convexUserId) return
try {
await changeAssignee({
ticketId: ticket.id as Id<"tickets">,
actorId: convexUserId as Id<"users">,
assigneeId: newAssignee === "__none__" ? undefined : (newAssignee as Id<"users">),
})
setAssigneeOpen(false)
toast.success("Responsável atualizado")
} catch (error) {
toast.error(error instanceof Error ? error.message : "Erro ao atualizar responsável")
}
}
const isAvulso = Boolean(ticket.company?.isAvulso)
const companyLabel = ticket.company?.name ?? (isAvulso ? "Cliente avulso" : "Sem empresa vinculada")
const responseStatus = getSlaDisplayStatus(ticket, "response")
@ -177,9 +272,153 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
<section className="space-y-3">
<h3 className="text-sm font-semibold text-neutral-900">Resumo</h3>
<div className="grid gap-3 sm:grid-cols-2">
{summaryChips.map(({ key, label, value, tone, labelClassName }) => (
<SummaryChip key={key} label={label} value={value} tone={tone} labelClassName={labelClassName} />
))}
{/* Fila - editável */}
{canEdit ? (
<Popover open={queueOpen} onOpenChange={setQueueOpen}>
<PopoverTrigger asChild>
<button type="button" className="text-left w-full">
<SummaryChip
label="Fila"
value={ticket.queue ?? "Sem fila"}
tone={ticket.queue ? "default" : "muted"}
editable
/>
</button>
</PopoverTrigger>
<PopoverContent className="w-64 p-0" align="start" sideOffset={4}>
<div className="p-3 space-y-2">
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide">Alterar fila</p>
<Select
value={queues?.find((q) => q.name === ticket.queue)?.id ?? "__none__"}
onValueChange={handleQueueChange}
>
<SelectTrigger className="h-10 bg-white">
<SelectValue placeholder="Selecione a fila" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Sem fila</SelectItem>
{queues?.map((q) => (
<SelectItem key={q.id} value={q.id}>{q.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</PopoverContent>
</Popover>
) : (
<SummaryChip label="Fila" value={ticket.queue ?? "Sem fila"} tone={ticket.queue ? "default" : "muted"} />
)}
{/* Empresa - não editável */}
<SummaryChip
label="Empresa"
value={companyLabel}
tone={isAvulso ? "warning" : "default"}
/>
{/* Status - editável */}
{canEdit ? (
<Popover open={statusOpen} onOpenChange={setStatusOpen}>
<PopoverTrigger asChild>
<button type="button" className="text-left w-full">
<SummaryChip
label="Status"
value={getTicketStatusLabel(ticket.status) ?? ticket.status}
tone={getTicketStatusSummaryTone(ticket.status) as SummaryTone}
editable
/>
</button>
</PopoverTrigger>
<PopoverContent className="w-64 p-0" align="start" sideOffset={4}>
<div className="p-3 space-y-2">
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide">Alterar status</p>
<StatusSelect value={ticket.status} onValueChange={handleStatusChange} />
</div>
</PopoverContent>
</Popover>
) : (
<SummaryChip
label="Status"
value={getTicketStatusLabel(ticket.status) ?? ticket.status}
tone={getTicketStatusSummaryTone(ticket.status) as SummaryTone}
/>
)}
{/* Prioridade - editável */}
{canEdit ? (
<Popover open={priorityOpen} onOpenChange={setPriorityOpen}>
<PopoverTrigger asChild>
<button type="button" className="text-left w-full">
<SummaryChip
label="Prioridade"
value={priorityLabel[ticket.priority] ?? ticket.priority}
tone={priorityTone[ticket.priority] ?? "default"}
editable
/>
</button>
</PopoverTrigger>
<PopoverContent className="w-64 p-0" align="start" sideOffset={4}>
<div className="p-3 space-y-2">
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide">Alterar prioridade</p>
<PrioritySelect value={ticket.priority} onValueChange={handlePriorityChange} />
</div>
</PopoverContent>
</Popover>
) : (
<SummaryChip
label="Prioridade"
value={priorityLabel[ticket.priority] ?? ticket.priority}
tone={priorityTone[ticket.priority] ?? "default"}
/>
)}
{/* Responsável - editável */}
{canEdit ? (
<Popover open={assigneeOpen} onOpenChange={setAssigneeOpen}>
<PopoverTrigger asChild>
<button type="button" className="text-left w-full">
<SummaryChip
label="Responsável"
value={ticket.assignee?.name ?? "Não atribuído"}
tone={ticket.assignee ? "default" : "muted"}
editable
/>
</button>
</PopoverTrigger>
<PopoverContent className="w-64 p-0" align="start" sideOffset={4}>
<div className="p-3 space-y-2">
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide">Alterar responsavel</p>
<Select value={ticket.assignee?.id ?? "__none__"} onValueChange={handleAssigneeChange}>
<SelectTrigger className="h-10 bg-white">
<SelectValue placeholder="Selecione o responsavel" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Nao atribuido</SelectItem>
{agents?.map((a) => (
<SelectItem key={a._id} value={a._id}>{a.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</PopoverContent>
</Popover>
) : (
<SummaryChip
label="Responsável"
value={ticket.assignee?.name ?? "Não atribuído"}
tone={ticket.assignee ? "default" : "muted"}
/>
)}
{/* Fluxo - não editável */}
{ticket.formTemplateLabel && (
<SummaryChip
label="Fluxo"
value={ticket.formTemplateLabel}
tone="primary"
labelClassName="text-white"
/>
)}
</div>
</section>
@ -350,11 +589,13 @@ function SummaryChip({
value,
tone = "default",
labelClassName,
editable = false,
}: {
label: string
value: string
tone?: SummaryTone
labelClassName?: string
editable?: boolean
}) {
const toneClasses: Record<SummaryTone, string> = {
default: "border-slate-200 bg-white text-neutral-900",
@ -367,7 +608,13 @@ function SummaryChip({
}
return (
<div className={cn("rounded-xl border px-3 py-2 shadow-sm", toneClasses[tone])}>
<div
className={cn(
"rounded-xl border px-3 py-2 shadow-sm transition-all",
toneClasses[tone],
editable && "cursor-pointer hover:ring-2 hover:ring-slate-300 hover:border-slate-400"
)}
>
<p className={cn("text-[11px] font-semibold uppercase tracking-wide text-neutral-500", labelClassName)}>{label}</p>
<p className="mt-1 truncate text-sm font-semibold text-current">{value}</p>
</div>

View file

@ -87,7 +87,21 @@ export function useAuth() {
return useContext(AuthContext)
}
export const { signIn, signOut, useSession } = authClient
export const { signIn, signOut, useSession, $Infer, getSession, ...authClientRest } = authClient
/**
* Força atualização da sessão (útil após alterar dados do usuário como avatar)
* Retorna a nova sessão ou null
*/
export async function refreshSession(): Promise<AppSession | null> {
try {
// getSession do Better Auth busca a sessão atualizada do servidor
const result = await getSession({ fetchOptions: { cache: "no-store" } })
return result?.data ?? null
} catch {
return null
}
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const devBypass = process.env.NODE_ENV !== "production" && process.env.NEXT_PUBLIC_DEV_BYPASS_AUTH === "1"