From 022e1f63babdc2ce3affa8fc869bc023989acd81 Mon Sep 17 00:00:00 2001 From: esdrasrenan Date: Mon, 15 Dec 2025 22:05:27 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20melhorias=20de=20UX=20e=20redesign=20de?= =?UTF-8?q?=20coment=C3=A1rios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- agents.md | 45 ++- convex/tickets.ts | 4 +- convex/users.ts | 39 ++- src/app/api/profile/avatar/route.ts | 42 +++ .../admin/devices/admin-devices-overview.tsx | 6 +- .../automations/automation-editor-dialog.tsx | 2 - src/components/chat/chat-session-item.tsx | 4 +- src/components/chat/chat-session-list.tsx | 2 - src/components/chat/chat-widget.tsx | 8 - .../notification-preferences-form.tsx | 80 +++++- src/components/settings/settings-content.tsx | 25 +- .../tickets/close-ticket-dialog.tsx | 1 - src/components/tickets/new-ticket-dialog.tsx | 2 - .../tickets/ticket-checklist-card.tsx | 67 +++-- .../tickets/ticket-comments.rich.tsx | 214 +++++++++------ .../tickets/ticket-details-panel.tsx | 259 +++++++++++++++++- src/lib/auth-client.tsx | 16 +- 17 files changed, 636 insertions(+), 180 deletions(-) diff --git a/agents.md b/agents.md index 0ed7d0c..b27a2d5 100644 --- a/agents.md +++ b/agents.md @@ -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 + + +// CORRETO - sem tooltip nativo + + +// CORRETO - se precisar de tooltip, use o componente Tooltip do shadcn/ui + + + + + Remover item + +``` + +**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 + +``` + --- -_Ú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)._ diff --git a/convex/tickets.ts b/convex/tickets.ts index 22d7619..2bf1bc3 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -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) => { diff --git a/convex/users.ts b/convex/users.ts index 852baa5..ab24ef5 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -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 }) } } diff --git a/src/app/api/profile/avatar/route.ts b/src/app/api/profile/avatar/route.ts index e265688..07faa20 100644 --- a/src/app/api/profile/avatar/route.ts +++ b/src/app/api/profile/avatar/route.ts @@ -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 }) + } +} diff --git a/src/components/admin/devices/admin-devices-overview.tsx b/src/components/admin/devices/admin-devices-overview.tsx index 70e4916..a6177ff 100644 --- a/src/components/admin/devices/admin-devices-overview.tsx +++ b/src/components/admin/devices/admin-devices-overview.tsx @@ -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" > @@ -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" > @@ -4119,7 +4117,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) { {entry.password ? (
Senha - + {secretVisible ? entry.password : "••••••••"} @@ -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" > diff --git a/src/components/chat/chat-session-item.tsx b/src/components/chat/chat-session-item.tsx index 9abd94c..cb526cb 100644 --- a/src/components/chat/chat-session-item.tsx +++ b/src/components/chat/chat-session-item.tsx @@ -72,9 +72,9 @@ export function ChatSessionItem({ session, isActive, onClick }: ChatSessionItemP {/* Indicador online/offline */} {session.machineOnline !== undefined && ( session.machineOnline ? ( - + ) : ( - + ) )}
diff --git a/src/components/chat/chat-session-list.tsx b/src/components/chat/chat-session-list.tsx index 478525c..dea0750 100644 --- a/src/components/chat/chat-session-list.tsx +++ b/src/components/chat/chat-session-list.tsx @@ -68,7 +68,6 @@ export function ChatSessionList({ {(displayAvatarUrl || pendingAvatarFile) && !pendingRemoveAvatar && ( )} diff --git a/src/components/tickets/close-ticket-dialog.tsx b/src/components/tickets/close-ticket-dialog.tsx index 06eb632..e6a7c9a 100644 --- a/src/components/tickets/close-ticket-dialog.tsx +++ b/src/components/tickets/close-ticket-dialog.tsx @@ -939,7 +939,6 @@ export function CloseTicketDialog({ type="button" variant="outline" size="icon" - title="Limpar mensagem" aria-label="Limpar mensagem" onClick={() => { setMessage("") diff --git a/src/components/tickets/new-ticket-dialog.tsx b/src/components/tickets/new-ticket-dialog.tsx index f8d1627..d5c5203 100644 --- a/src/components/tickets/new-ticket-dialog.tsx +++ b/src/components/tickets/new-ticket-dialog.tsx @@ -1416,7 +1416,6 @@ export function NewTicketDialog({ onClick={() => setAppliedChecklistTemplateIds((prev) => prev.filter((id) => id !== String(tpl.id))) } - title="Remover template" > × @@ -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" > diff --git a/src/components/tickets/ticket-checklist-card.tsx b/src/components/tickets/ticket-checklist-card.tsx index 45c4655..328d728 100644 --- a/src/components/tickets/ticket-checklist-card.tsx +++ b/src/components/tickets/ticket-checklist-card.tsx @@ -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 && ( - { - 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) => ( - - ))} - +
+ {options.map((option) => { + const isSelected = item.answer === option + return ( + + ) + })} +
)}
@@ -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)} > diff --git a/src/components/tickets/ticket-comments.rich.tsx b/src/components/tickets/ticket-comments.rich.tsx index ce095ff..9f8b44b 100644 --- a/src/components/tickets/ticket-comments.rich.tsx +++ b/src/components/tickets/ticket-comments.rich.tsx @@ -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: /Responsável atualizado:<\/strong>\s*([^→<]+)\s*→\s*([^<]+)/i, type: "assignee_change" as const, field: "Responsável" }, + { regex: /Status atualizado:<\/strong>\s*([^→<]+)\s*→\s*([^<]+)/i, type: "status_change" as const, field: "Status" }, + { regex: /Prioridade atualizada:<\/strong>\s*([^→<]+)\s*→\s*([^<]+)/i, type: "priority_change" as const, field: "Prioridade" }, + { regex: /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(/Motivo da troca:<\/strong><\/p>\s*

([^<]+)/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 ( +

+
+ {data.field}: + + {data.from} + + + + {data.to} + +
+ {data.reason && ( +

+ Motivo: {data.reason} +

+ )} +
+ ) +} + 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 ( -
- - - {initials} - -
-
- {comment.author.name} - {comment.visibility === "INTERNAL" && canSeeInternalComments ? ( - - Interno - - ) : null} - - {formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })} - +
+ {/* Header */} +
+
+ + + {initials} + +
+
+ {comment.author.name} + {isInternal ? ( + Interno + ) : isPublic ? ( + Público + ) : null} +
+ + {formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })} + +
- {isInternal ? ( - - Comentário interno — visível apenas para administradores e agentes - - ) : comment.visibility === "PUBLIC" ? ( - - Comentário visível para o cliente - - ) : null} - {isEditing ? ( -
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" > + + + ) : null} +
+ + {/* Conteúdo */} +
+ {isEditing ? ( +
@@ -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" /> -
+
) : hasBody ? ( -
- {canEdit ? ( - - ) : null} - -
- ) : canEdit ? ( -
{ + const systemData = parseSystemComment(storedBody) + if (systemData) { + return } + return + })() + ) : canEdit ? ( + -
+ + Adicionar conteúdo ao comentário + ) : null} + + {/* Anexos */} {comment.attachments?.length ? ( -
+
{comment.attachments.map((attachment) => ( +
Atribua um responsável ao chamado para que a equipe técnica possa comentar.
)} diff --git a/src/components/tickets/ticket-details-panel.tsx b/src/components/tickets/ticket-details-panel.tsx index 67ab17e..563e4a9 100644 --- a/src/components/tickets/ticket-details-panel.tsx +++ b/src/components/tickets/ticket-details-panel.tsx @@ -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) {

Resumo

- {summaryChips.map(({ key, label, value, tone, labelClassName }) => ( - - ))} + {/* Fila - editável */} + {canEdit ? ( + + + + + +
+

Alterar fila

+ +
+
+
+ ) : ( + + )} + + {/* Empresa - não editável */} + + + {/* Status - editável */} + {canEdit ? ( + + + + + +
+

Alterar status

+ +
+
+
+ ) : ( + + )} + + {/* Prioridade - editável */} + {canEdit ? ( + + + + + +
+

Alterar prioridade

+ +
+
+
+ ) : ( + + )} + + {/* Responsável - editável */} + {canEdit ? ( + + + + + +
+

Alterar responsavel

+ +
+
+
+ ) : ( + + )} + + {/* Fluxo - não editável */} + {ticket.formTemplateLabel && ( + + )}
@@ -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 = { default: "border-slate-200 bg-white text-neutral-900", @@ -367,7 +608,13 @@ function SummaryChip({ } return ( -
+

{label}

{value}

diff --git a/src/lib/auth-client.tsx b/src/lib/auth-client.tsx index 37e3202..d78d96d 100644 --- a/src/lib/auth-client.tsx +++ b/src/lib/auth-client.tsx @@ -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 { + 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"