Ajusta timeline, comentários internos e contadores de trabalho
This commit is contained in:
parent
ee18619519
commit
ef25cbe799
7 changed files with 212 additions and 69 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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é
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
19
src/lib/ticket-timeline-labels.ts
Normal file
19
src/lib/ticket-timeline-labels.ts
Normal 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",
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue