Ajusta timeline, comentários internos e contadores de trabalho

This commit is contained in:
Esdras Renan 2025-10-07 22:12:18 -03:00
parent ee18619519
commit ef25cbe799
7 changed files with 212 additions and 69 deletions

View file

@ -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<string, string> = {
OTHER: "Outro",
}
const timelineLabel: Record<string, string> = {
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)

View file

@ -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é

View file

@ -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 (
<div key={comment.id} className="group/comment flex gap-3">
<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>
@ -236,7 +251,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
<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" ? (
{comment.visibility === "INTERNAL" && canSeeInternalComments ? (
<Badge className={badgeInternal}>
<IconLock className="size-3 text-[#00e8ff]" /> Interno
</Badge>
@ -245,8 +260,19 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
{formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })}
</span>
</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>
) : null}
{isEditing ? (
<div className="rounded-xl border border-slate-200 bg-white px-3 py-2">
<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"
}
>
<RichTextEditor
value={editingComment?.value ?? ""}
onChange={(next) =>
@ -276,12 +302,12 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
</div>
</div>
) : hasBody ? (
<div className="relative break-words rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm leading-relaxed text-neutral-700">
<div className={bodyClass}>
{canEdit ? (
<button
type="button"
onClick={() => startEditingComment(commentId, storedBody)}
className="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"
className={bodyEditButtonClass}
aria-label="Editar comentário"
>
<PencilLine className="size-3.5" />
@ -290,11 +316,17 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
<RichTextContent html={storedBody} />
</div>
) : canEdit ? (
<div className="rounded-xl border border-dashed border-slate-300 bg-white/60 px-3 py-2 text-sm text-neutral-500">
<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"
}
>
<button
type="button"
onClick={() => startEditingComment(commentId, storedBody)}
className="inline-flex items-center gap-2 text-sm font-medium text-neutral-600 transition hover:text-neutral-900"
className={addContentButtonClass}
>
<PencilLine className="size-4" />
Adicionar conteúdo ao comentário
@ -418,17 +450,17 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
<Select
value={visibility}
onValueChange={(value) => {
if (isManager) return
if (!canSeeInternalComments) return
setVisibility(value as "PUBLIC" | "INTERNAL")
}}
disabled={isManager}
disabled={!canSeeInternalComments}
>
<SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder="Visibilidade" />
</SelectTrigger>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
<SelectItem value="PUBLIC">Pública</SelectItem>
{!isManager ? <SelectItem value="INTERNAL">Interna</SelectItem> : null}
{canSeeInternalComments ? <SelectItem value="INTERNAL">Interna</SelectItem> : null}
</SelectContent>
</Select>
</div>

View file

@ -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<WorkSummarySnapshot | null>(() => {
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<WorkSummarySnapshot | null>(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])

View file

@ -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<string, ComponentType<{ className?: string }>> = {
CREATED: IconUserCircle,
@ -29,23 +32,13 @@ const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
PRIORITY_CHANGED: IconSquareCheck,
ATTACHMENT_REMOVED: IconPaperclip,
CATEGORY_CHANGED: IconFolders,
MANAGER_NOTIFIED: IconUserCircle,
VISIT_SCHEDULED: IconCalendar,
CSAT_RECEIVED: IconStar,
CSAT_RATED: IconStar,
}
const timelineLabels: Record<string, string> = {
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<string, string> = 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 (

View file

@ -0,0 +1,19 @@
export const TICKET_TIMELINE_LABELS: Record<string, string> = {
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",
};