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

@ -413,6 +413,10 @@ export const getById = query({
.query("ticketComments") .query("ticketComments")
.withIndex("by_ticket", (q) => q.eq("ticketId", id)) .withIndex("by_ticket", (q) => q.eq("ticketId", id))
.collect(); .collect();
const canViewInternalComments = role === "ADMIN" || role === "AGENT";
const visibleComments = canViewInternalComments
? comments
: comments.filter((comment) => comment.visibility !== "INTERNAL");
const timeline = await ctx.db const timeline = await ctx.db
.query("ticketEvents") .query("ticketEvents")
.withIndex("by_ticket", (q) => q.eq("ticketId", id)) .withIndex("by_ticket", (q) => q.eq("ticketId", id))
@ -423,7 +427,7 @@ export const getById = query({
); );
const commentsHydrated = await Promise.all( 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 author = (await ctx.db.get(c.authorId)) as Doc<"users"> | null;
const attachments = await Promise.all( const attachments = await Promise.all(
(c.attachments ?? []).map(async (att) => ({ (c.attachments ?? []).map(async (att) => ({
@ -707,6 +711,10 @@ export const addComment = mutation({
throw new ConvexError("Gestores só podem registrar comentários públicos") 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) { if (ticketDoc.requesterId === args.authorId) {
// requester commenting: managers restricted to PUBLIC (handled above); // requester commenting: managers restricted to PUBLIC (handled above);

View file

@ -16,6 +16,7 @@ import { env } from "@/lib/env"
import { assertAuthenticatedSession } from "@/lib/auth-server" import { assertAuthenticatedSession } from "@/lib/auth-server"
import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket" import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket"
import { DEFAULT_TENANT_ID } from "@/lib/constants" import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { TICKET_TIMELINE_LABELS } from "@/lib/ticket-timeline-labels"
// Force Node.js runtime for pdfkit compatibility // Force Node.js runtime for pdfkit compatibility
export const runtime = "nodejs" export const runtime = "nodejs"
@ -53,25 +54,6 @@ const channelLabel: Record<string, string> = {
OTHER: "Outro", 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) { function formatDateTime(date: Date | null | undefined) {
if (!date) return "—" if (!date) return "—"
@ -485,7 +467,7 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
doc.moveDown(0.6) doc.moveDown(0.6)
const timelineSorted = [...ticket.timeline].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) const timelineSorted = [...ticket.timeline].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
timelineSorted.forEach((event) => { timelineSorted.forEach((event) => {
const label = timelineLabel[event.type] ?? event.type const label = TICKET_TIMELINE_LABELS[event.type] ?? event.type
doc doc
.font(hasInter ? "Inter-Bold" : "Helvetica-Bold") .font(hasInter ? "Inter-Bold" : "Helvetica-Bold")
.fontSize(11) .fontSize(11)

View file

@ -7,14 +7,17 @@ import {
Ticket, Ticket,
PlayCircle, PlayCircle,
BarChart3, BarChart3,
Gauge, TrendingUp,
PanelsTopLeft, PanelsTopLeft,
Users, UserCog,
Building2,
Waypoints, Waypoints,
Clock4,
Timer, Timer,
MonitorCog, MonitorCog,
Layers3, Layers3,
UserPlus, UserPlus,
BellRing,
} from "lucide-react" } from "lucide-react"
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation"
@ -71,10 +74,10 @@ const navigation: { versions: string[]; navMain: NavigationGroup[] } = {
title: "Relatórios", title: "Relatórios",
requiredRole: "staff", requiredRole: "staff",
items: [ 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: "Qualidade (CSAT)", url: "/reports/csat", icon: LifeBuoy, requiredRole: "staff" },
{ title: "Backlog", url: "/reports/backlog", icon: BarChart3, 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, exact: true,
}, },
{ title: "Canais & roteamento", url: "/admin/channels", icon: Waypoints, requiredRole: "admin" }, { title: "Canais & roteamento", url: "/admin/channels", icon: Waypoints, requiredRole: "admin" },
{ title: "Times & papéis", url: "/admin/teams", icon: Users, requiredRole: "admin" }, { title: "Times & papéis", url: "/admin/teams", icon: UserCog, requiredRole: "admin" },
{ title: "Empresas & clientes", url: "/admin/companies", icon: Users, requiredRole: "admin" }, { title: "Empresas & clientes", url: "/admin/companies", icon: Building2, requiredRole: "admin" },
{ title: "Máquinas", url: "/admin/machines", icon: MonitorCog, requiredRole: "admin" }, { title: "Máquinas", url: "/admin/machines", icon: MonitorCog, requiredRole: "admin" },
{ title: "Campos personalizados", url: "/admin/fields", icon: Layers3, requiredRole: "admin" }, { title: "Campos personalizados", url: "/admin/fields", icon: Layers3, requiredRole: "admin" },
{ title: "SLAs", url: "/admin/slas", icon: Timer, 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é // 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 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 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 = 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" "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) { export function TicketComments({ ticket }: TicketCommentsProps) {
const { convexUserId, isStaff, role } = useAuth() 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 addComment = useMutation(api.tickets.addComment)
const removeAttachment = useMutation(api.tickets.removeCommentAttachment) const removeAttachment = useMutation(api.tickets.removeCommentAttachment)
const updateComment = useMutation(api.tickets.updateComment) const updateComment = useMutation(api.tickets.updateComment)
@ -119,7 +121,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
event.preventDefault() event.preventDefault()
if (!convexUserId) return if (!convexUserId) return
const now = new Date() const now = new Date()
const selectedVisibility = isManager ? "PUBLIC" : visibility const selectedVisibility = canSeeInternalComments ? visibility : "PUBLIC"
const attachments = attachmentsToSend.map((item) => ({ ...item })) const attachments = attachmentsToSend.map((item) => ({ ...item }))
const previewsToRevoke = attachments const previewsToRevoke = attachments
.map((attachment) => attachment.previewUrl) .map((attachment) => attachment.previewUrl)
@ -226,9 +228,22 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
const isPending = commentId.startsWith("temp-") const isPending = commentId.startsWith("temp-")
const canEdit = Boolean(convexUserId && String(comment.author.id) === convexUserId && !isPending) const canEdit = Boolean(convexUserId && String(comment.author.id) === convexUserId && !isPending)
const hasBody = bodyPlain.length > 0 || isEditing 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 ( 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"> <Avatar className="size-9 border border-slate-200">
<AvatarImage src={comment.author.avatarUrl} alt={comment.author.name} /> <AvatarImage src={comment.author.avatarUrl} alt={comment.author.name} />
<AvatarFallback>{initials}</AvatarFallback> <AvatarFallback>{initials}</AvatarFallback>
@ -236,7 +251,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2 text-sm"> <div className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-semibold text-neutral-900">{comment.author.name}</span> <span className="font-semibold text-neutral-900">{comment.author.name}</span>
{comment.visibility === "INTERNAL" ? ( {comment.visibility === "INTERNAL" && canSeeInternalComments ? (
<Badge className={badgeInternal}> <Badge className={badgeInternal}>
<IconLock className="size-3 text-[#00e8ff]" /> Interno <IconLock className="size-3 text-[#00e8ff]" /> Interno
</Badge> </Badge>
@ -245,8 +260,19 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
{formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })} {formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })}
</span> </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>
) : null}
{isEditing ? ( {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 <RichTextEditor
value={editingComment?.value ?? ""} value={editingComment?.value ?? ""}
onChange={(next) => onChange={(next) =>
@ -276,12 +302,12 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
</div> </div>
</div> </div>
) : hasBody ? ( ) : 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 ? ( {canEdit ? (
<button <button
type="button" type="button"
onClick={() => startEditingComment(commentId, storedBody)} 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" aria-label="Editar comentário"
> >
<PencilLine className="size-3.5" /> <PencilLine className="size-3.5" />
@ -290,11 +316,17 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
<RichTextContent html={storedBody} /> <RichTextContent html={storedBody} />
</div> </div>
) : canEdit ? ( ) : 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 <button
type="button" type="button"
onClick={() => startEditingComment(commentId, storedBody)} 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" /> <PencilLine className="size-4" />
Adicionar conteúdo ao comentário Adicionar conteúdo ao comentário
@ -418,17 +450,17 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
<Select <Select
value={visibility} value={visibility}
onValueChange={(value) => { onValueChange={(value) => {
if (isManager) return if (!canSeeInternalComments) return
setVisibility(value as "PUBLIC" | "INTERNAL") setVisibility(value as "PUBLIC" | "INTERNAL")
}} }}
disabled={isManager} disabled={!canSeeInternalComments}
> >
<SelectTrigger className={selectTriggerClass}> <SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder="Visibilidade" /> <SelectValue placeholder="Visibilidade" />
</SelectTrigger> </SelectTrigger>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm"> <SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
<SelectItem value="PUBLIC">Pública</SelectItem> <SelectItem value="PUBLIC">Pública</SelectItem>
{!isManager ? <SelectItem value="INTERNAL">Interna</SelectItem> : null} {canSeeInternalComments ? <SelectItem value="INTERNAL">Interna</SelectItem> : null}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>

View file

@ -35,6 +35,19 @@ interface TicketHeaderProps {
ticket: TicketWithDetails 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 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 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 = const startButtonClass =
@ -102,7 +115,14 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
| { | {
ticketId: Id<"tickets"> ticketId: Id<"tickets">
totalWorkedMs: number 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 | null
| undefined | undefined
@ -264,24 +284,63 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
} }
}, [editing, secondaryOptions, selectedCategoryId, selectedSubcategoryId]) }, [editing, secondaryOptions, selectedCategoryId, selectedSubcategoryId])
const workSummary = useMemo(() => { const ticketActiveSession = ticket.workSummary?.activeSession ?? null
if (workSummaryRemote !== undefined) return workSummaryRemote ?? 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 if (!ticket.workSummary) return null
return { return {
ticketId: ticket.id as Id<"tickets">, ticketId: ticket.id as Id<"tickets">,
totalWorkedMs: ticket.workSummary.totalWorkedMs, totalWorkedMs: ticket.workSummary.totalWorkedMs ?? 0,
internalWorkedMs: ticket.workSummary.internalWorkedMs ?? 0, internalWorkedMs: ticket.workSummary.internalWorkedMs ?? 0,
externalWorkedMs: ticket.workSummary.externalWorkedMs ?? 0, externalWorkedMs: ticket.workSummary.externalWorkedMs ?? 0,
activeSession: ticket.workSummary.activeSession activeSession: ticketActiveSession
? { ? {
id: ticket.workSummary.activeSession.id as Id<"ticketWorkSessions">, id: ticketActiveSession.id as Id<"ticketWorkSessions">,
agentId: ticket.workSummary.activeSession.agentId as Id<"users">, agentId: ticketActiveSession.agentId as Id<"users">,
startedAt: ticket.workSummary.activeSession.startedAt.getTime(), startedAt: ticketActiveSessionStartedAtMs ?? ticketActiveSession.startedAt.getTime(),
workType: (ticket.workSummary.activeSession as any).workType ?? "INTERNAL", workType: (ticketActiveSessionWorkType ?? "INTERNAL").toString().toUpperCase(),
} }
: null, : 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 isPlaying = Boolean(workSummary?.activeSession)
const [now, setNow] = useState(() => Date.now()) const [now, setNow] = useState(() => Date.now())
@ -292,7 +351,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
setNow(Date.now()) setNow(Date.now())
}, 1000) }, 1000)
return () => clearInterval(interval) return () => clearInterval(interval)
}, [workSummary?.activeSession]) }, [workSummary?.activeSession?.id])
useEffect(() => { useEffect(() => {
if (!pauseDialogOpen) { if (!pauseDialogOpen) {
@ -305,10 +364,10 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const currentSessionMs = workSummary?.activeSession ? Math.max(0, now - workSummary.activeSession.startedAt) : 0 const currentSessionMs = workSummary?.activeSession ? Math.max(0, now - workSummary.activeSession.startedAt) : 0
const totalWorkedMs = workSummary ? workSummary.totalWorkedMs + currentSessionMs : 0 const totalWorkedMs = workSummary ? workSummary.totalWorkedMs + currentSessionMs : 0
const internalWorkedMs = workSummary 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 : 0
const externalWorkedMs = workSummary 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 : 0
const formattedTotalWorked = useMemo(() => formatDuration(totalWorkedMs), [totalWorkedMs]) const formattedTotalWorked = useMemo(() => formatDuration(totalWorkedMs), [totalWorkedMs])

View file

@ -2,11 +2,13 @@ import { format } from "date-fns"
import type { ComponentType, ReactNode } from "react" import type { ComponentType, ReactNode } from "react"
import { ptBR } from "date-fns/locale" import { ptBR } from "date-fns/locale"
import { import {
IconCalendar,
IconClockHour4, IconClockHour4,
IconFolders, IconFolders,
IconNote, IconNote,
IconPaperclip, IconPaperclip,
IconSquareCheck, IconSquareCheck,
IconStar,
IconUserCircle, IconUserCircle,
} from "@tabler/icons-react" } from "@tabler/icons-react"
@ -14,6 +16,7 @@ import type { TicketWithDetails } from "@/lib/schemas/ticket"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { TICKET_TIMELINE_LABELS } from "@/lib/ticket-timeline-labels"
const timelineIcons: Record<string, ComponentType<{ className?: string }>> = { const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
CREATED: IconUserCircle, CREATED: IconUserCircle,
@ -29,23 +32,13 @@ const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
PRIORITY_CHANGED: IconSquareCheck, PRIORITY_CHANGED: IconSquareCheck,
ATTACHMENT_REMOVED: IconPaperclip, ATTACHMENT_REMOVED: IconPaperclip,
CATEGORY_CHANGED: IconFolders, CATEGORY_CHANGED: IconFolders,
MANAGER_NOTIFIED: IconUserCircle,
VISIT_SCHEDULED: IconCalendar,
CSAT_RECEIVED: IconStar,
CSAT_RATED: IconStar,
} }
const timelineLabels: Record<string, string> = { const timelineLabels: Record<string, string> = TICKET_TIMELINE_LABELS
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",
}
interface TicketTimelineProps { interface TicketTimelineProps {
ticket: TicketWithDetails ticket: TicketWithDetails
@ -122,6 +115,17 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
pauseReason?: string pauseReason?: string
pauseReasonLabel?: string pauseReasonLabel?: string
pauseNote?: 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 let message: ReactNode = null
@ -182,6 +186,42 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
message = "Categoria removida" 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 if (!message) return null
return ( 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",
};