From ef25cbe79962677ee50b1d98edc39b3613a3df0d Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Tue, 7 Oct 2025 22:12:18 -0300 Subject: [PATCH] =?UTF-8?q?Ajusta=20timeline,=20coment=C3=A1rios=20interno?= =?UTF-8?q?s=20e=20contadores=20de=20trabalho?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- convex/tickets.ts | 10 ++- src/app/api/tickets/[id]/export/pdf/route.ts | 22 +---- src/components/app-sidebar.tsx | 17 ++-- .../tickets/ticket-comments.rich.tsx | 58 ++++++++++--- .../tickets/ticket-summary-header.tsx | 85 ++++++++++++++++--- src/components/tickets/ticket-timeline.tsx | 70 +++++++++++---- src/lib/ticket-timeline-labels.ts | 19 +++++ 7 files changed, 212 insertions(+), 69 deletions(-) create mode 100644 src/lib/ticket-timeline-labels.ts diff --git a/convex/tickets.ts b/convex/tickets.ts index 42ae8f8..c5805ad 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -413,6 +413,10 @@ export const getById = query({ .query("ticketComments") .withIndex("by_ticket", (q) => q.eq("ticketId", id)) .collect(); + const canViewInternalComments = role === "ADMIN" || role === "AGENT"; + const visibleComments = canViewInternalComments + ? comments + : comments.filter((comment) => comment.visibility !== "INTERNAL"); const timeline = await ctx.db .query("ticketEvents") .withIndex("by_ticket", (q) => q.eq("ticketId", id)) @@ -423,7 +427,7 @@ export const getById = query({ ); const commentsHydrated = await Promise.all( - comments.map(async (c) => { + visibleComments.map(async (c) => { const author = (await ctx.db.get(c.authorId)) as Doc<"users"> | null; const attachments = await Promise.all( (c.attachments ?? []).map(async (att) => ({ @@ -707,6 +711,10 @@ export const addComment = mutation({ throw new ConvexError("Gestores só podem registrar comentários públicos") } } + const canUseInternalComments = normalizedRole === "ADMIN" || normalizedRole === "AGENT" + if (args.visibility === "INTERNAL" && !canUseInternalComments) { + throw new ConvexError("Apenas administradores e agentes podem registrar comentários internos") + } if (ticketDoc.requesterId === args.authorId) { // requester commenting: managers restricted to PUBLIC (handled above); diff --git a/src/app/api/tickets/[id]/export/pdf/route.ts b/src/app/api/tickets/[id]/export/pdf/route.ts index 0d7f293..9b60e46 100644 --- a/src/app/api/tickets/[id]/export/pdf/route.ts +++ b/src/app/api/tickets/[id]/export/pdf/route.ts @@ -16,6 +16,7 @@ import { env } from "@/lib/env" import { assertAuthenticatedSession } from "@/lib/auth-server" import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket" import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { TICKET_TIMELINE_LABELS } from "@/lib/ticket-timeline-labels" // Force Node.js runtime for pdfkit compatibility export const runtime = "nodejs" @@ -53,25 +54,6 @@ const channelLabel: Record = { OTHER: "Outro", } -const timelineLabel: Record = { - CREATED: "Chamado criado", - STATUS_CHANGED: "Status atualizado", - ASSIGNEE_CHANGED: "Responsável alterado", - COMMENT_ADDED: "Novo comentário", - COMMENT_EDITED: "Comentário editado", - ATTACHMENT_REMOVED: "Anexo removido", - QUEUE_CHANGED: "Fila alterada", - PRIORITY_CHANGED: "Prioridade alterada", - WORK_STARTED: "Atendimento iniciado", - WORK_PAUSED: "Atendimento pausado", - CATEGORY_CHANGED: "Categoria alterada", - MANAGER_NOTIFIED: "Gestor notificado", - SUBJECT_CHANGED: "Assunto atualizado", - SUMMARY_CHANGED: "Resumo atualizado", - VISIT_SCHEDULED: "Visita agendada", - CSAT_RECEIVED: "CSAT recebido", - CSAT_RATED: "CSAT avaliado", -} function formatDateTime(date: Date | null | undefined) { if (!date) return "—" @@ -485,7 +467,7 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st doc.moveDown(0.6) const timelineSorted = [...ticket.timeline].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) timelineSorted.forEach((event) => { - const label = timelineLabel[event.type] ?? event.type + const label = TICKET_TIMELINE_LABELS[event.type] ?? event.type doc .font(hasInter ? "Inter-Bold" : "Helvetica-Bold") .fontSize(11) diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index ec8281c..39e8fb6 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -7,14 +7,17 @@ import { Ticket, PlayCircle, BarChart3, - Gauge, + TrendingUp, PanelsTopLeft, - Users, + UserCog, + Building2, Waypoints, + Clock4, Timer, MonitorCog, Layers3, UserPlus, + BellRing, } from "lucide-react" import { usePathname } from "next/navigation" @@ -71,10 +74,10 @@ const navigation: { versions: string[]; navMain: NavigationGroup[] } = { title: "Relatórios", requiredRole: "staff", items: [ - { title: "SLA e produtividade", url: "/reports/sla", icon: Gauge, requiredRole: "staff" }, + { title: "SLA e produtividade", url: "/reports/sla", icon: TrendingUp, requiredRole: "staff" }, { title: "Qualidade (CSAT)", url: "/reports/csat", icon: LifeBuoy, requiredRole: "staff" }, { title: "Backlog", url: "/reports/backlog", icon: BarChart3, requiredRole: "staff" }, - { title: "Horas por cliente", url: "/reports/hours", icon: Gauge, requiredRole: "staff" }, + { title: "Horas por cliente", url: "/reports/hours", icon: Clock4, requiredRole: "staff" }, ], }, { @@ -89,12 +92,12 @@ const navigation: { versions: string[]; navMain: NavigationGroup[] } = { exact: true, }, { title: "Canais & roteamento", url: "/admin/channels", icon: Waypoints, requiredRole: "admin" }, - { title: "Times & papéis", url: "/admin/teams", icon: Users, requiredRole: "admin" }, - { title: "Empresas & clientes", url: "/admin/companies", icon: Users, requiredRole: "admin" }, + { title: "Times & papéis", url: "/admin/teams", icon: UserCog, requiredRole: "admin" }, + { title: "Empresas & clientes", url: "/admin/companies", icon: Building2, requiredRole: "admin" }, { title: "Máquinas", url: "/admin/machines", icon: MonitorCog, requiredRole: "admin" }, { title: "Campos personalizados", url: "/admin/fields", icon: Layers3, requiredRole: "admin" }, { title: "SLAs", url: "/admin/slas", icon: Timer, requiredRole: "admin" }, - { title: "Alertas enviados", url: "/admin/alerts", icon: Gauge, requiredRole: "admin" }, + { title: "Alertas enviados", url: "/admin/alerts", icon: BellRing, requiredRole: "admin" }, ], }, // Removido grupo "Conta" (Configurações) para evitar redundância com o menu do usuário no rodapé diff --git a/src/components/tickets/ticket-comments.rich.tsx b/src/components/tickets/ticket-comments.rich.tsx index 4219d96..9d67834 100644 --- a/src/components/tickets/ticket-comments.rich.tsx +++ b/src/components/tickets/ticket-comments.rich.tsx @@ -27,14 +27,16 @@ 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 uppercase tracking-wide text-white" +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 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" export function TicketComments({ ticket }: TicketCommentsProps) { const { convexUserId, isStaff, role } = useAuth() - const isManager = role === "manager" + const normalizedRole = role ?? null + const isManager = normalizedRole === "manager" + const canSeeInternalComments = normalizedRole === "admin" || normalizedRole === "agent" const addComment = useMutation(api.tickets.addComment) const removeAttachment = useMutation(api.tickets.removeCommentAttachment) const updateComment = useMutation(api.tickets.updateComment) @@ -119,7 +121,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) { event.preventDefault() if (!convexUserId) return const now = new Date() - const selectedVisibility = isManager ? "PUBLIC" : visibility + const selectedVisibility = canSeeInternalComments ? visibility : "PUBLIC" const attachments = attachmentsToSend.map((item) => ({ ...item })) const previewsToRevoke = attachments .map((attachment) => attachment.previewUrl) @@ -226,9 +228,22 @@ export function TicketComments({ ticket }: TicketCommentsProps) { const isPending = commentId.startsWith("temp-") const canEdit = Boolean(convexUserId && String(comment.author.id) === convexUserId && !isPending) const hasBody = bodyPlain.length > 0 || isEditing + const isInternal = comment.visibility === "INTERNAL" && canSeeInternalComments + const containerClass = isInternal + ? "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" + const bodyClass = isInternal + ? "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 = isInternal + ? "absolute right-2 top-2 inline-flex size-7 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-2 top-2 inline-flex size-7 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 = isInternal + ? "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} @@ -236,7 +251,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
{comment.author.name} - {comment.visibility === "INTERNAL" ? ( + {comment.visibility === "INTERNAL" && canSeeInternalComments ? ( Interno @@ -245,8 +260,19 @@ export function TicketComments({ ticket }: TicketCommentsProps) { {formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })}
+ {isInternal ? ( + + Comentário interno — visível apenas para administradores e agentes + + ) : null} {isEditing ? ( -
+
@@ -276,12 +302,12 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
) : hasBody ? ( -
+
{canEdit ? (
) : canEdit ? ( -
+
diff --git a/src/components/tickets/ticket-summary-header.tsx b/src/components/tickets/ticket-summary-header.tsx index 60aedb1..171193c 100644 --- a/src/components/tickets/ticket-summary-header.tsx +++ b/src/components/tickets/ticket-summary-header.tsx @@ -35,6 +35,19 @@ interface TicketHeaderProps { ticket: TicketWithDetails } +type WorkSummarySnapshot = { + ticketId: Id<"tickets"> + totalWorkedMs: number + internalWorkedMs: number + externalWorkedMs: number + activeSession: { + id: Id<"ticketWorkSessions"> + agentId: Id<"users"> + startedAt: number + workType?: string + } | null +} + const cardClass = "relative space-y-4 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm" const referenceBadgeClass = "inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 bg-white px-3 text-sm font-semibold text-neutral-700" const startButtonClass = @@ -102,7 +115,14 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { | { ticketId: Id<"tickets"> totalWorkedMs: number - activeSession: { id: Id<"ticketWorkSessions">; agentId: Id<"users">; startedAt: number } | null + internalWorkedMs?: number + externalWorkedMs?: number + activeSession: { + id: Id<"ticketWorkSessions"> + agentId: Id<"users"> + startedAt: number + workType?: string + } | null } | null | undefined @@ -264,24 +284,63 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { } }, [editing, secondaryOptions, selectedCategoryId, selectedSubcategoryId]) - const workSummary = useMemo(() => { - if (workSummaryRemote !== undefined) return workSummaryRemote ?? null + const ticketActiveSession = ticket.workSummary?.activeSession ?? null + const ticketActiveSessionStartedAtMs = ticketActiveSession ? ticketActiveSession.startedAt.getTime() : undefined + const ticketActiveSessionWorkType = (ticketActiveSession as { workType?: string } | null)?.workType + + const initialWorkSummary = useMemo(() => { if (!ticket.workSummary) return null return { ticketId: ticket.id as Id<"tickets">, - totalWorkedMs: ticket.workSummary.totalWorkedMs, + totalWorkedMs: ticket.workSummary.totalWorkedMs ?? 0, internalWorkedMs: ticket.workSummary.internalWorkedMs ?? 0, externalWorkedMs: ticket.workSummary.externalWorkedMs ?? 0, - activeSession: ticket.workSummary.activeSession + activeSession: ticketActiveSession ? { - id: ticket.workSummary.activeSession.id as Id<"ticketWorkSessions">, - agentId: ticket.workSummary.activeSession.agentId as Id<"users">, - startedAt: ticket.workSummary.activeSession.startedAt.getTime(), - workType: (ticket.workSummary.activeSession as any).workType ?? "INTERNAL", + id: ticketActiveSession.id as Id<"ticketWorkSessions">, + agentId: ticketActiveSession.agentId as Id<"users">, + startedAt: ticketActiveSessionStartedAtMs ?? ticketActiveSession.startedAt.getTime(), + workType: (ticketActiveSessionWorkType ?? "INTERNAL").toString().toUpperCase(), } : null, } - }, [ticket.id, ticket.workSummary, workSummaryRemote]) + }, [ + ticket.id, + ticket.workSummary?.totalWorkedMs, + ticket.workSummary?.internalWorkedMs, + ticket.workSummary?.externalWorkedMs, + ticketActiveSession?.id, + ticketActiveSessionStartedAtMs, + ticketActiveSessionWorkType, + ]) + + const [workSummary, setWorkSummary] = useState(initialWorkSummary) + + useEffect(() => { + setWorkSummary(initialWorkSummary) + }, [initialWorkSummary]) + + useEffect(() => { + if (workSummaryRemote === undefined) return + if (workSummaryRemote === null) { + setWorkSummary(null) + return + } + setWorkSummary({ + ticketId: workSummaryRemote.ticketId, + totalWorkedMs: workSummaryRemote.totalWorkedMs ?? 0, + internalWorkedMs: workSummaryRemote.internalWorkedMs ?? 0, + externalWorkedMs: workSummaryRemote.externalWorkedMs ?? 0, + activeSession: workSummaryRemote.activeSession + ? { + id: workSummaryRemote.activeSession.id, + agentId: workSummaryRemote.activeSession.agentId, + startedAt: workSummaryRemote.activeSession.startedAt, + workType: (workSummaryRemote.activeSession.workType ?? "INTERNAL").toString().toUpperCase(), + } + : null, + }) + }, [workSummaryRemote]) const isPlaying = Boolean(workSummary?.activeSession) const [now, setNow] = useState(() => Date.now()) @@ -292,7 +351,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { setNow(Date.now()) }, 1000) return () => clearInterval(interval) - }, [workSummary?.activeSession]) + }, [workSummary?.activeSession?.id]) useEffect(() => { if (!pauseDialogOpen) { @@ -305,10 +364,10 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { const currentSessionMs = workSummary?.activeSession ? Math.max(0, now - workSummary.activeSession.startedAt) : 0 const totalWorkedMs = workSummary ? workSummary.totalWorkedMs + currentSessionMs : 0 const internalWorkedMs = workSummary - ? (((workSummary as any).internalWorkedMs ?? 0) + (((workSummary?.activeSession as any)?.workType === "INTERNAL") ? currentSessionMs : 0)) + ? workSummary.internalWorkedMs + (workSummary.activeSession?.workType === "INTERNAL" ? currentSessionMs : 0) : 0 const externalWorkedMs = workSummary - ? (((workSummary as any).externalWorkedMs ?? 0) + (((workSummary?.activeSession as any)?.workType === "EXTERNAL") ? currentSessionMs : 0)) + ? workSummary.externalWorkedMs + (workSummary.activeSession?.workType === "EXTERNAL" ? currentSessionMs : 0) : 0 const formattedTotalWorked = useMemo(() => formatDuration(totalWorkedMs), [totalWorkedMs]) diff --git a/src/components/tickets/ticket-timeline.tsx b/src/components/tickets/ticket-timeline.tsx index 204cc61..0c92667 100644 --- a/src/components/tickets/ticket-timeline.tsx +++ b/src/components/tickets/ticket-timeline.tsx @@ -2,11 +2,13 @@ import { format } from "date-fns" import type { ComponentType, ReactNode } from "react" import { ptBR } from "date-fns/locale" import { + IconCalendar, IconClockHour4, IconFolders, IconNote, IconPaperclip, IconSquareCheck, + IconStar, IconUserCircle, } from "@tabler/icons-react" @@ -14,6 +16,7 @@ import type { TicketWithDetails } from "@/lib/schemas/ticket" import { Card, CardContent } from "@/components/ui/card" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Separator } from "@/components/ui/separator" +import { TICKET_TIMELINE_LABELS } from "@/lib/ticket-timeline-labels" const timelineIcons: Record> = { CREATED: IconUserCircle, @@ -29,23 +32,13 @@ const timelineIcons: Record> = { PRIORITY_CHANGED: IconSquareCheck, ATTACHMENT_REMOVED: IconPaperclip, CATEGORY_CHANGED: IconFolders, + MANAGER_NOTIFIED: IconUserCircle, + VISIT_SCHEDULED: IconCalendar, + CSAT_RECEIVED: IconStar, + CSAT_RATED: IconStar, } -const timelineLabels: Record = { - CREATED: "Criado", - STATUS_CHANGED: "Status alterado", - ASSIGNEE_CHANGED: "Responsável alterado", - COMMENT_ADDED: "Comentário adicionado", - COMMENT_EDITED: "Comentário editado", - WORK_STARTED: "Atendimento iniciado", - WORK_PAUSED: "Atendimento pausado", - SUBJECT_CHANGED: "Assunto atualizado", - SUMMARY_CHANGED: "Resumo atualizado", - QUEUE_CHANGED: "Fila alterada", - PRIORITY_CHANGED: "Prioridade alterada", - ATTACHMENT_REMOVED: "Anexo removido", - CATEGORY_CHANGED: "Categoria alterada", -} +const timelineLabels: Record = TICKET_TIMELINE_LABELS interface TicketTimelineProps { ticket: TicketWithDetails @@ -122,6 +115,17 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) { pauseReason?: string pauseReasonLabel?: string pauseNote?: string + managerName?: string + managerUserName?: string + manager?: string + managerId?: string + managerUserId?: string + scheduledFor?: number | string + scheduledAt?: number | string + score?: number + rating?: number + maxScore?: number + max?: number } let message: ReactNode = null @@ -182,6 +186,42 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) { message = "Categoria removida" } } + if (entry.type === "MANAGER_NOTIFIED") { + const manager = + payload.managerName ?? + payload.managerUserName ?? + payload.manager ?? + payload.managerId ?? + payload.managerUserId + message = manager ? `Gestor notificado: ${manager}` : "Gestor notificado" + } + if (entry.type === "VISIT_SCHEDULED") { + const scheduledRaw = payload.scheduledFor ?? payload.scheduledAt + let formatted: string | null = null + if (typeof scheduledRaw === "number" || typeof scheduledRaw === "string") { + const date = new Date(scheduledRaw) + if (!Number.isNaN(date.getTime())) { + formatted = format(date, "dd MMM yyyy HH:mm", { locale: ptBR }) + } + } + message = formatted ? `Visita agendada para ${formatted}` : "Visita agendada" + } + if (entry.type === "CSAT_RECEIVED") { + message = "CSAT recebido" + } + if (entry.type === "CSAT_RATED") { + const score = typeof payload.score === "number" ? payload.score : payload.rating + const maxScore = + typeof payload.maxScore === "number" + ? payload.maxScore + : typeof payload.max === "number" + ? payload.max + : undefined + message = + typeof score === "number" + ? `CSAT avaliado: ${score}${typeof maxScore === "number" ? `/${maxScore}` : ""}` + : "CSAT avaliado" + } if (!message) return null return ( diff --git a/src/lib/ticket-timeline-labels.ts b/src/lib/ticket-timeline-labels.ts new file mode 100644 index 0000000..26484ab --- /dev/null +++ b/src/lib/ticket-timeline-labels.ts @@ -0,0 +1,19 @@ +export const TICKET_TIMELINE_LABELS: Record = { + CREATED: "Criado", + STATUS_CHANGED: "Status alterado", + ASSIGNEE_CHANGED: "Responsável alterado", + COMMENT_ADDED: "Comentário adicionado", + COMMENT_EDITED: "Comentário editado", + WORK_STARTED: "Atendimento iniciado", + WORK_PAUSED: "Atendimento pausado", + SUBJECT_CHANGED: "Assunto atualizado", + SUMMARY_CHANGED: "Resumo atualizado", + QUEUE_CHANGED: "Fila alterada", + PRIORITY_CHANGED: "Prioridade alterada", + ATTACHMENT_REMOVED: "Anexo removido", + CATEGORY_CHANGED: "Categoria alterada", + MANAGER_NOTIFIED: "Gestor notificado", + VISIT_SCHEDULED: "Visita agendada", + CSAT_RECEIVED: "CSAT recebido", + CSAT_RATED: "CSAT avaliado", +};