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")
|
.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);
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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é
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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])
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
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