feat: enforce ticket ownership during work sessions

This commit is contained in:
Esdras Renan 2025-10-20 19:46:20 -03:00
parent 81657e52d8
commit 3972f66c92
6 changed files with 397 additions and 43 deletions

View file

@ -59,6 +59,83 @@ function normalizeStatus(status: string | null | undefined): TicketStatusNormali
return normalized ?? "PENDING"; return normalized ?? "PENDING";
} }
type AgentWorkTotals = {
agentId: Id<"users">;
agentName: string | null;
agentEmail: string | null;
avatarUrl: string | null;
totalWorkedMs: number;
internalWorkedMs: number;
externalWorkedMs: number;
};
async function computeAgentWorkTotals(
ctx: MutationCtx | QueryCtx,
ticketId: Id<"tickets">,
referenceNow: number,
): Promise<AgentWorkTotals[]> {
const sessions = await ctx.db
.query("ticketWorkSessions")
.withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
.collect();
if (!sessions.length) {
return [];
}
const totals = new Map<
string,
{ totalWorkedMs: number; internalWorkedMs: number; externalWorkedMs: number }
>();
for (const session of sessions) {
const baseDuration = typeof session.durationMs === "number"
? session.durationMs
: typeof session.stoppedAt === "number"
? session.stoppedAt - session.startedAt
: referenceNow - session.startedAt;
const durationMs = Math.max(0, baseDuration);
if (durationMs <= 0) continue;
const key = session.agentId as string;
const bucket = totals.get(key) ?? {
totalWorkedMs: 0,
internalWorkedMs: 0,
externalWorkedMs: 0,
};
bucket.totalWorkedMs += durationMs;
const workType = (session.workType ?? "INTERNAL").toUpperCase();
if (workType === "EXTERNAL") {
bucket.externalWorkedMs += durationMs;
} else {
bucket.internalWorkedMs += durationMs;
}
totals.set(key, bucket);
}
if (totals.size === 0) {
return [];
}
const agentIds = Array.from(totals.keys());
const agents = await Promise.all(agentIds.map((agentId) => ctx.db.get(agentId as Id<"users">)));
return agentIds
.map((agentId, index) => {
const bucket = totals.get(agentId)!;
const agentDoc = agents[index] as Doc<"users"> | null;
return {
agentId: agentId as Id<"users">,
agentName: agentDoc?.name ?? null,
agentEmail: agentDoc?.email ?? null,
avatarUrl: agentDoc?.avatarUrl ?? null,
totalWorkedMs: bucket.totalWorkedMs,
internalWorkedMs: bucket.internalWorkedMs,
externalWorkedMs: bucket.externalWorkedMs,
};
})
.sort((a, b) => b.totalWorkedMs - a.totalWorkedMs);
}
async function ensureManagerTicketAccess( async function ensureManagerTicketAccess(
ctx: MutationCtx | QueryCtx, ctx: MutationCtx | QueryCtx,
manager: Doc<"users">, manager: Doc<"users">,
@ -744,6 +821,7 @@ export const getById = query({
); );
const activeSession = t.activeSessionId ? await ctx.db.get(t.activeSessionId) : null; const activeSession = t.activeSessionId ? await ctx.db.get(t.activeSessionId) : null;
const perAgentTotals = await computeAgentWorkTotals(ctx, id, serverNow);
return { return {
id: t._id, id: t._id,
@ -813,6 +891,15 @@ export const getById = query({
workType: activeSession.workType ?? "INTERNAL", workType: activeSession.workType ?? "INTERNAL",
} }
: null, : null,
perAgentTotals: perAgentTotals.map((item) => ({
agentId: item.agentId,
agentName: item.agentName,
agentEmail: item.agentEmail,
avatarUrl: item.avatarUrl,
totalWorkedMs: item.totalWorkedMs,
internalWorkedMs: item.internalWorkedMs,
externalWorkedMs: item.externalWorkedMs,
})),
}, },
description: undefined, description: undefined,
customFields: customFieldsRecord, customFields: customFieldsRecord,
@ -1285,6 +1372,10 @@ export const changeAssignee = mutation({
if (viewer.role === "MANAGER") { if (viewer.role === "MANAGER") {
throw new ConvexError("Gestores não podem reatribuir chamados") throw new ConvexError("Gestores não podem reatribuir chamados")
} }
const normalizedStatus = normalizeStatus(ticketDoc.status)
if (normalizedStatus === "AWAITING_ATTENDANCE" || ticketDoc.activeSessionId) {
throw new ConvexError("Pause o atendimento antes de reatribuir o chamado")
}
const currentAssigneeId = ticketDoc.assigneeId ?? null const currentAssigneeId = ticketDoc.assigneeId ?? null
if (currentAssigneeId && currentAssigneeId !== actorId && !isAdmin) { if (currentAssigneeId && currentAssigneeId !== actorId && !isAdmin) {
throw new ConvexError("Somente o responsável atual pode reatribuir este chamado") throw new ConvexError("Somente o responsável atual pode reatribuir este chamado")
@ -1438,6 +1529,7 @@ export const workSummary = query({
const activeSession = ticket.activeSessionId ? await ctx.db.get(ticket.activeSessionId) : null const activeSession = ticket.activeSessionId ? await ctx.db.get(ticket.activeSessionId) : null
const serverNow = Date.now() const serverNow = Date.now()
const perAgentTotals = await computeAgentWorkTotals(ctx, ticketId, serverNow)
return { return {
ticketId, ticketId,
totalWorkedMs: ticket.totalWorkedMs ?? 0, totalWorkedMs: ticket.totalWorkedMs ?? 0,
@ -1452,6 +1544,15 @@ export const workSummary = query({
workType: activeSession.workType ?? "INTERNAL", workType: activeSession.workType ?? "INTERNAL",
} }
: null, : null,
perAgentTotals: perAgentTotals.map((item) => ({
agentId: item.agentId,
agentName: item.agentName,
agentEmail: item.agentEmail,
avatarUrl: item.avatarUrl,
totalWorkedMs: item.totalWorkedMs,
internalWorkedMs: item.internalWorkedMs,
externalWorkedMs: item.externalWorkedMs,
})),
} }
}, },
}) })

View file

@ -33,12 +33,14 @@ export function StatusSelect({
tenantId, tenantId,
requesterName, requesterName,
showCloseButton = true, showCloseButton = true,
onStatusChange,
}: { }: {
ticketId: string ticketId: string
value: TicketStatus value: TicketStatus
tenantId: string tenantId: string
requesterName?: string | null requesterName?: string | null
showCloseButton?: boolean showCloseButton?: boolean
onStatusChange?: (next: TicketStatus) => void
}) { }) {
const { convexUserId, session, machineContext } = useAuth() const { convexUserId, session, machineContext } = useAuth()
const actorId = (convexUserId ?? null) as Id<"users"> | null const actorId = (convexUserId ?? null) as Id<"users"> | null
@ -97,6 +99,7 @@ export function StatusSelect({
onSuccess={() => { onSuccess={() => {
setStatus("RESOLVED") setStatus("RESOLVED")
setCloseDialogOpen(false) setCloseDialogOpen(false)
onStatusChange?.("RESOLVED")
}} }}
/> />
) : null} ) : null}

View file

@ -1,6 +1,7 @@
import { useMemo } from "react"
import { format } from "date-fns" import { format } from "date-fns"
import { ptBR } from "date-fns/locale" import { ptBR } from "date-fns/locale"
import { IconAlertTriangle, IconClockHour4, IconTags } from "@tabler/icons-react" import { IconAlertTriangle, IconClockHour4 } from "@tabler/icons-react"
import type { TicketWithDetails } from "@/lib/schemas/ticket" import type { TicketWithDetails } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
@ -12,12 +13,38 @@ interface TicketDetailsPanelProps {
} }
const queueBadgeClass = "inline-flex items-center rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs font-semibold text-neutral-700" const queueBadgeClass = "inline-flex items-center rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs font-semibold text-neutral-700"
const tagBadgeClass = "inline-flex items-center gap-1 rounded-full border border-slate-200 bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-neutral-700"
const iconAccentClass = "size-3 text-neutral-700" const iconAccentClass = "size-3 text-neutral-700"
function formatDuration(ms?: number | null) {
if (!ms || ms <= 0) return "0s"
const totalSeconds = Math.floor(ms / 1000)
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
if (hours > 0) {
return `${hours}h ${minutes.toString().padStart(2, "0")}m`
}
if (minutes > 0) {
return `${minutes}m ${seconds.toString().padStart(2, "0")}s`
}
return `${seconds}s`
}
export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) { export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
const isAvulso = Boolean(ticket.company?.isAvulso) const isAvulso = Boolean(ticket.company?.isAvulso)
const companyLabel = ticket.company?.name ?? (isAvulso ? "Cliente avulso" : "Sem empresa vinculada") const companyLabel = ticket.company?.name ?? (isAvulso ? "Cliente avulso" : "Sem empresa vinculada")
const agentTotals = useMemo(() => {
const totals = ticket.workSummary?.perAgentTotals ?? []
return totals
.map((item) => ({
agentId: String(item.agentId),
agentName: item.agentName ?? null,
totalWorkedMs: item.totalWorkedMs ?? 0,
internalWorkedMs: item.internalWorkedMs ?? 0,
externalWorkedMs: item.externalWorkedMs ?? 0,
}))
.sort((a, b) => b.totalWorkedMs - a.totalWorkedMs)
}, [ticket.workSummary?.perAgentTotals])
return ( return (
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm"> <Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
@ -70,21 +97,31 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
)} )}
</div> </div>
<Separator className="bg-slate-200" /> <Separator className="bg-slate-200" />
<div className="space-y-2 break-words"> {agentTotals.length > 0 ? (
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Tags</p> <>
<div className="flex flex-wrap gap-2"> <div className="space-y-2">
{ticket.tags?.length ? ( <p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Tempo por agente</p>
ticket.tags.map((tag) => ( <div className="flex flex-col gap-2">
<Badge key={tag} className={tagBadgeClass}> {agentTotals.map((agent) => (
<IconTags className={iconAccentClass} /> {tag} <div
</Badge> key={agent.agentId}
)) className="flex flex-col gap-1 rounded-xl border border-slate-200 bg-white px-3 py-2 text-xs text-neutral-700 shadow-sm"
) : ( >
<span className="text-neutral-600">Sem tags.</span> <div className="flex items-center justify-between text-sm font-semibold text-neutral-900">
)} <span>{agent.agentName ?? "Agente removido"}</span>
</div> <span>{formatDuration(agent.totalWorkedMs)}</span>
</div> </div>
<Separator className="bg-slate-200" /> <div className="flex flex-wrap gap-4 text-xs text-neutral-600">
<span>Interno: {formatDuration(agent.internalWorkedMs)}</span>
<span>Externo: {formatDuration(agent.externalWorkedMs)}</span>
</div>
</div>
))}
</div>
</div>
<Separator className="bg-slate-200" />
</>
) : null}
<div className="space-y-1"> <div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Histórico</p> <p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Histórico</p>
<div className="flex flex-col gap-1 text-xs text-neutral-600"> <div className="flex flex-col gap-1 text-xs text-neutral-600">

View file

@ -44,6 +44,16 @@ interface TicketHeaderProps {
ticket: TicketWithDetails ticket: TicketWithDetails
} }
type AgentWorkTotalSnapshot = {
agentId: string
agentName: string | null
agentEmail: string | null
avatarUrl: string | null
totalWorkedMs: number
internalWorkedMs: number
externalWorkedMs: number
}
type WorkSummarySnapshot = { type WorkSummarySnapshot = {
ticketId: Id<"tickets"> ticketId: Id<"tickets">
totalWorkedMs: number totalWorkedMs: number
@ -52,10 +62,11 @@ type WorkSummarySnapshot = {
serverNow?: number | null serverNow?: number | null
activeSession: { activeSession: {
id: Id<"ticketWorkSessions"> id: Id<"ticketWorkSessions">
agentId: Id<"users"> agentId: string
startedAt: number startedAt: number
workType?: string workType?: string
} | null } | null
perAgentTotals: AgentWorkTotalSnapshot[]
} }
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"
@ -64,6 +75,14 @@ const startButtonClass =
"inline-flex items-center gap-1 rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/30" "inline-flex items-center gap-1 rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/30"
const pauseButtonClass = const pauseButtonClass =
"inline-flex items-center gap-1 rounded-lg border border-black bg-black px-3 py-1.5 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-1 rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30"
const playButtonEnabledClass =
"inline-flex items-center justify-center rounded-lg border border-black bg-black text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30"
const playButtonDisabledClass =
"inline-flex items-center justify-center rounded-lg border border-slate-300 bg-slate-100 text-neutral-400 cursor-not-allowed"
const pauseButtonEnabledClass =
"inline-flex items-center justify-center rounded-lg border border-black bg-black text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30"
const pauseButtonDisabledClass =
"inline-flex items-center justify-center rounded-lg border border-slate-300 bg-slate-100 text-neutral-400 cursor-not-allowed"
const selectTriggerClass = "h-8 w-full 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-full 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 smallSelectTriggerClass = "h-8 w-full 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 smallSelectTriggerClass = "h-8 w-full 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 sectionLabelClass = "text-xs font-semibold uppercase tracking-wide text-neutral-500" const sectionLabelClass = "text-xs font-semibold uppercase tracking-wide text-neutral-500"
@ -107,6 +126,20 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
: machineAssignedName && machineAssignedName.length > 0 : machineAssignedName && machineAssignedName.length > 0
? machineAssignedName ? machineAssignedName
: null : null
const viewerEmail = session?.user?.email ?? machineContext?.assignedUserEmail ?? null
const viewerAvatar = session?.user?.avatarUrl ?? null
const viewerAgentMeta = useMemo(
() => {
if (!convexUserId) return null
return {
id: String(convexUserId),
name: agentName ?? viewerEmail ?? null,
email: viewerEmail,
avatarUrl: viewerAvatar,
}
},
[convexUserId, agentName, viewerEmail, viewerAvatar]
)
useDefaultQueues(ticket.tenantId) useDefaultQueues(ticket.tenantId)
const changeAssignee = useMutation(api.tickets.changeAssignee) const changeAssignee = useMutation(api.tickets.changeAssignee)
const changeQueue = useMutation(api.tickets.changeQueue) const changeQueue = useMutation(api.tickets.changeQueue)
@ -139,14 +172,25 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
serverNow?: number serverNow?: number
activeSession: { activeSession: {
id: Id<"ticketWorkSessions"> id: Id<"ticketWorkSessions">
agentId: Id<"users"> agentId: string
startedAt: number startedAt: number
workType?: string workType?: string
} | null } | null
perAgentTotals?: Array<{
agentId: string
agentName?: string | null
agentEmail?: string | null
avatarUrl?: string | null
totalWorkedMs: number
internalWorkedMs?: number
externalWorkedMs?: number
}>
} }
| null | null
| undefined | undefined
const [status, setStatus] = useState<TicketStatus>(ticket.status)
const [assigneeState, setAssigneeState] = useState(ticket.assignee ?? null)
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)
const [subject, setSubject] = useState(ticket.subject) const [subject, setSubject] = useState(ticket.subject)
const [summary, setSummary] = useState(ticket.summary ?? "") const [summary, setSummary] = useState(ticket.summary ?? "")
@ -156,7 +200,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
subcategoryId: ticket.subcategory?.id ?? "", subcategoryId: ticket.subcategory?.id ?? "",
} }
) )
const currentAssigneeId = ticket.assignee?.id ?? "" const currentAssigneeId = assigneeState?.id ?? ""
const [assigneeSelection, setAssigneeSelection] = useState(currentAssigneeId) const [assigneeSelection, setAssigneeSelection] = useState(currentAssigneeId)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [pauseDialogOpen, setPauseDialogOpen] = useState(false) const [pauseDialogOpen, setPauseDialogOpen] = useState(false)
@ -195,11 +239,20 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const secondaryOptions = useMemo(() => activeCategory?.secondary ?? [], [activeCategory]) const secondaryOptions = useMemo(() => activeCategory?.secondary ?? [], [activeCategory])
const hasAssignee = Boolean(currentAssigneeId) const hasAssignee = Boolean(currentAssigneeId)
const isCurrentResponsible = hasAssignee && convexUserId ? currentAssigneeId === convexUserId : false const isCurrentResponsible = hasAssignee && convexUserId ? currentAssigneeId === convexUserId : false
const canControlWork = isAdmin || !hasAssignee || isCurrentResponsible const isResolved = status === "RESOLVED"
const canPauseWork = isAdmin || isCurrentResponsible const canControlWork = !isResolved && (isAdmin || !hasAssignee || isCurrentResponsible)
const canPauseWork = !isResolved && (isAdmin || isCurrentResponsible)
const pauseDisabled = !canPauseWork const pauseDisabled = !canPauseWork
const startDisabled = !canControlWork const startDisabled = !canControlWork
useEffect(() => {
setStatus(ticket.status)
}, [ticket.status])
useEffect(() => {
setAssigneeState(ticket.assignee ?? null)
}, [ticket.assignee])
async function handleSave() { async function handleSave() {
if (!convexUserId || !formDirty) { if (!convexUserId || !formDirty) {
setEditing(false) setEditing(false)
@ -264,6 +317,11 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
setAssigneeSelection(currentAssigneeId) setAssigneeSelection(currentAssigneeId)
throw new Error("invalid-assignee") throw new Error("invalid-assignee")
} else { } else {
if (status === "AWAITING_ATTENDANCE" || workSummary?.activeSession) {
toast.error("Pause o atendimento antes de reatribuir o chamado.", { id: "assignee" })
setAssigneeSelection(currentAssigneeId)
throw new Error("assignee-not-allowed")
}
toast.loading("Atualizando responsável...", { id: "assignee" }) toast.loading("Atualizando responsável...", { id: "assignee" })
try { try {
await changeAssignee({ await changeAssignee({
@ -272,6 +330,18 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
actorId: convexUserId as Id<"users">, actorId: convexUserId as Id<"users">,
}) })
toast.success("Responsável atualizado!", { id: "assignee" }) toast.success("Responsável atualizado!", { id: "assignee" })
if (assigneeSelection) {
const next = agents.find((agent) => String(agent._id) === assigneeSelection)
if (next) {
setAssigneeState({
id: String(next._id),
name: next.name,
email: next.email,
avatarUrl: next.avatarUrl ?? undefined,
teams: Array.isArray(next.teams) ? next.teams.filter((team): team is string => typeof team === "string") : [],
})
}
}
} catch (error) { } catch (error) {
console.error(error) console.error(error)
toast.error("Não foi possível atualizar o responsável.", { id: "assignee" }) toast.error("Não foi possível atualizar o responsável.", { id: "assignee" })
@ -328,8 +398,8 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
subcategoryId: ticket.subcategory?.id ?? "", subcategoryId: ticket.subcategory?.id ?? "",
}) })
setQueueSelection(ticket.queue ?? "") setQueueSelection(ticket.queue ?? "")
setAssigneeSelection(ticket.assignee?.id ?? "") setAssigneeSelection(currentAssigneeId)
}, [editing, ticket.category?.id, ticket.subcategory?.id, ticket.queue, ticket.assignee?.id]) }, [editing, ticket.category?.id, ticket.subcategory?.id, ticket.queue, currentAssigneeId])
useEffect(() => { useEffect(() => {
if (!editing) return if (!editing) return
@ -361,11 +431,20 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
activeSession: ticketActiveSession activeSession: ticketActiveSession
? { ? {
id: ticketActiveSession.id as Id<"ticketWorkSessions">, id: ticketActiveSession.id as Id<"ticketWorkSessions">,
agentId: ticketActiveSession.agentId as Id<"users">, agentId: String(ticketActiveSession.agentId),
startedAt: ticketActiveSessionStartedAtMs ?? ticketActiveSession.startedAt.getTime(), startedAt: ticketActiveSessionStartedAtMs ?? ticketActiveSession.startedAt.getTime(),
workType: (ticketActiveSessionWorkType ?? "INTERNAL").toString().toUpperCase(), workType: (ticketActiveSessionWorkType ?? "INTERNAL").toString().toUpperCase(),
} }
: null, : null,
perAgentTotals: (ticket.workSummary.perAgentTotals ?? []).map((item) => ({
agentId: String(item.agentId),
agentName: item.agentName ?? null,
agentEmail: item.agentEmail ?? null,
avatarUrl: item.avatarUrl ?? null,
totalWorkedMs: item.totalWorkedMs ?? 0,
internalWorkedMs: item.internalWorkedMs ?? 0,
externalWorkedMs: item.externalWorkedMs ?? 0,
})),
} }
}, [ }, [
ticket.id, ticket.id,
@ -425,6 +504,15 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
workType: (workSummaryRemote.activeSession.workType ?? "INTERNAL").toString().toUpperCase(), workType: (workSummaryRemote.activeSession.workType ?? "INTERNAL").toString().toUpperCase(),
} }
: null, : null,
perAgentTotals: (workSummaryRemote.perAgentTotals ?? []).map((item) => ({
agentId: String(item.agentId),
agentName: item.agentName ?? null,
agentEmail: item.agentEmail ?? null,
avatarUrl: item.avatarUrl ?? null,
totalWorkedMs: item.totalWorkedMs ?? 0,
internalWorkedMs: item.internalWorkedMs ?? 0,
externalWorkedMs: item.externalWorkedMs ?? 0,
})),
}) })
}, [workSummaryRemote, calibrateServerOffset]) }, [workSummaryRemote, calibrateServerOffset])
@ -535,18 +623,54 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
externalWorkedMs: 0, externalWorkedMs: 0,
serverNow: null, serverNow: null,
activeSession: null, activeSession: null,
perAgentTotals: [],
} }
const actorId = String(convexUserId)
const existingTotals = base.perAgentTotals ?? []
const hasActorEntry = existingTotals.some((item) => item.agentId === actorId)
const updatedTotals = hasActorEntry
? existingTotals
: [
...existingTotals,
{
agentId: actorId,
agentName: viewerAgentMeta?.name ?? null,
agentEmail: viewerAgentMeta?.email ?? null,
avatarUrl: viewerAgentMeta?.avatarUrl ?? null,
totalWorkedMs: 0,
internalWorkedMs: 0,
externalWorkedMs: 0,
},
]
return { return {
...base, ...base,
serverNow: typeof resultMeta?.serverNow === "number" ? resultMeta.serverNow : getServerNow(), serverNow: typeof resultMeta?.serverNow === "number" ? resultMeta.serverNow : getServerNow(),
activeSession: { activeSession: {
id: (sessionId as Id<"ticketWorkSessions">) ?? (base.activeSession?.id as Id<"ticketWorkSessions">), id: (sessionId as Id<"ticketWorkSessions">) ?? (base.activeSession?.id as Id<"ticketWorkSessions">),
agentId: convexUserId as Id<"users">, agentId: actorId,
startedAt: startedAtMs, startedAt: startedAtMs,
workType, workType,
}, },
perAgentTotals: updatedTotals,
} }
}) })
setStatus("AWAITING_ATTENDANCE")
if (viewerAgentMeta) {
setAssigneeState((prevAssignee) => {
if (prevAssignee && prevAssignee.id === viewerAgentMeta.id) {
return prevAssignee
}
return {
id: viewerAgentMeta.id,
name: viewerAgentMeta.name ?? prevAssignee?.name ?? "Responsável",
email: viewerAgentMeta.email ?? prevAssignee?.email ?? "",
avatarUrl: viewerAgentMeta.avatarUrl ?? prevAssignee?.avatarUrl ?? undefined,
teams: prevAssignee?.teams ?? [],
}
})
setAssigneeSelection(viewerAgentMeta.id)
}
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Não foi possível atualizar o atendimento" const message = error instanceof Error ? error.message : "Não foi possível atualizar o atendimento"
toast.error(message, { id: "work" }) toast.error(message, { id: "work" })
@ -580,15 +704,49 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
setWorkSummary((prev) => { setWorkSummary((prev) => {
if (!prev) return prev if (!prev) return prev
const workType = prev.activeSession?.workType ?? "INTERNAL" const workType = prev.activeSession?.workType ?? "INTERNAL"
const sessionAgentId = prev.activeSession?.agentId ?? (viewerAgentMeta?.id ?? "")
const internalDelta = workType === "INTERNAL" ? delta : 0
const externalDelta = workType === "EXTERNAL" ? delta : 0
const updatedTotals = (() => {
if (!sessionAgentId) return prev.perAgentTotals
let found = false
const mapped = prev.perAgentTotals.map((item) => {
if (item.agentId !== sessionAgentId) return item
found = true
return {
...item,
totalWorkedMs: item.totalWorkedMs + delta,
internalWorkedMs: item.internalWorkedMs + internalDelta,
externalWorkedMs: item.externalWorkedMs + externalDelta,
}
})
if (found || delta <= 0) {
return mapped
}
return [
...mapped,
{
agentId: sessionAgentId,
agentName: viewerAgentMeta?.name ?? null,
agentEmail: viewerAgentMeta?.email ?? null,
avatarUrl: viewerAgentMeta?.avatarUrl ?? null,
totalWorkedMs: delta,
internalWorkedMs: internalDelta,
externalWorkedMs: externalDelta,
},
]
})()
return { return {
...prev, ...prev,
totalWorkedMs: prev.totalWorkedMs + delta, totalWorkedMs: prev.totalWorkedMs + delta,
internalWorkedMs: prev.internalWorkedMs + (workType === "INTERNAL" ? delta : 0), internalWorkedMs: prev.internalWorkedMs + internalDelta,
externalWorkedMs: prev.externalWorkedMs + (workType === "EXTERNAL" ? delta : 0), externalWorkedMs: prev.externalWorkedMs + externalDelta,
serverNow: typeof resultMeta?.serverNow === "number" ? resultMeta.serverNow : getServerNow(), serverNow: typeof resultMeta?.serverNow === "number" ? resultMeta.serverNow : getServerNow(),
activeSession: null, activeSession: null,
perAgentTotals: updatedTotals,
} }
}) })
setStatus("PAUSED")
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Não foi possível atualizar o atendimento" const message = error instanceof Error ? error.message : "Não foi possível atualizar o atendimento"
toast.error(message, { id: "work" }) toast.error(message, { id: "work" })
@ -629,7 +787,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
<div className="absolute right-6 top-6 flex items-center gap-3"> <div className="absolute right-6 top-6 flex items-center gap-3">
<Button <Button
type="button" type="button"
className="inline-flex items-center gap-2 rounded-lg border border-[var(--sidebar-primary)] bg-[var(--sidebar-primary)] px-3 py-1.5 text-sm font-semibold text-black shadow-sm transition-all duration-200 ease-out hover:-translate-y-0.5 hover:bg-[var(--sidebar-primary)]/90 hover:shadow-[0_12px_22px_-12px_rgba(15,23,42,0.45)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--sidebar-ring)]/30 active:translate-y-0 active:shadow-sm" className="inline-flex items-center gap-2 rounded-lg border border-sidebar-border bg-sidebar-accent px-3 py-1.5 text-sm font-semibold text-sidebar-accent-foreground transition-all duration-200 ease-out hover:-translate-y-0.5 hover:border-sidebar-ring hover:bg-sidebar-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--sidebar-ring)]/30 active:translate-y-0 active:border-sidebar-ring"
onClick={() => setCloseOpen(true)} onClick={() => setCloseOpen(true)}
> >
<CheckCircle2 className="size-4" /> Encerrar <CheckCircle2 className="size-4" /> Encerrar
@ -684,7 +842,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
actorId={convexUserId as Id<"users"> | null} actorId={convexUserId as Id<"users"> | null}
requesterName={ticket.requester?.name ?? ticket.requester?.email ?? null} requesterName={ticket.requester?.name ?? ticket.requester?.email ?? null}
agentName={agentName} agentName={agentName}
onSuccess={() => {}} onSuccess={() => setStatus("RESOLVED")}
/> />
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-3"> <div className="space-y-3">
@ -702,6 +860,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
tenantId={ticket.tenantId} tenantId={ticket.tenantId}
requesterName={ticket.requester?.name ?? ticket.requester?.email ?? null} requesterName={ticket.requester?.name ?? ticket.requester?.email ?? null}
showCloseButton={false} showCloseButton={false}
onStatusChange={setStatus}
/> />
{isPlaying ? ( {isPlaying ? (
<Tooltip> <Tooltip>
@ -710,7 +869,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
<Button <Button
size="icon" size="icon"
aria-label="Pausar atendimento" aria-label="Pausar atendimento"
className="inline-flex items-center justify-center rounded-lg border border-black bg-black text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30" className={pauseDisabled ? pauseButtonDisabledClass : pauseButtonEnabledClass}
onClick={() => { onClick={() => {
if (!convexUserId || pauseDisabled) return if (!convexUserId || pauseDisabled) return
setPauseDialogOpen(true) setPauseDialogOpen(true)
@ -718,7 +877,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
disabled={pauseDisabled} disabled={pauseDisabled}
title="Pausar" title="Pausar"
> >
<IconPlayerPause className="size-4 text-white" /> <IconPlayerPause className={pauseDisabled ? "size-4 text-neutral-500" : "size-4 text-white"} />
</Button> </Button>
</span> </span>
</TooltipTrigger> </TooltipTrigger>
@ -732,8 +891,14 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span className="inline-flex"> <span className="inline-flex">
<Button size="icon" aria-label="Iniciar atendimento" className="inline-flex items-center justify-center rounded-lg border border-black bg-black text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30" disabled title="Iniciar"> <Button
<IconPlayerPlay className="size-4 text-white" /> size="icon"
aria-label="Iniciar atendimento"
className={playButtonDisabledClass}
disabled
title="Iniciar"
>
<IconPlayerPlay className="size-4 text-neutral-500" />
</Button> </Button>
</span> </span>
</TooltipTrigger> </TooltipTrigger>
@ -744,7 +909,12 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
) : ( ) : (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button size="icon" aria-label="Iniciar atendimento" className="inline-flex items-center justify-center rounded-lg border border-black bg-black text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30" title="Iniciar"> <Button
size="icon"
aria-label="Iniciar atendimento"
className={playButtonEnabledClass}
title="Iniciar"
>
<IconPlayerPlay className="size-4 text-white" /> <IconPlayerPlay className="size-4 text-white" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@ -925,7 +1095,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</SelectContent> </SelectContent>
</Select> </Select>
) : ( ) : (
<span className={sectionValueClass}>{ticket.assignee?.name ?? "Não atribuído"}</span> <span className={sectionValueClass}>{assigneeState?.name ?? "Não atribuído"}</span>
)} )}
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">

View file

@ -83,6 +83,19 @@ const serverTicketSchema = z.object({
startedAt: z.number(), startedAt: z.number(),
}) })
.nullable(), .nullable(),
perAgentTotals: z
.array(
z.object({
agentId: z.string(),
agentName: z.string().nullable().optional(),
agentEmail: z.string().nullable().optional(),
avatarUrl: z.string().nullable().optional(),
totalWorkedMs: z.number(),
internalWorkedMs: z.number().optional(),
externalWorkedMs: z.number().optional(),
}),
)
.optional(),
}) })
.nullable() .nullable()
.optional(), .optional(),
@ -158,6 +171,15 @@ export function mapTicketFromServer(input: unknown) {
startedAt: new Date(s.workSummary.activeSession.startedAt), startedAt: new Date(s.workSummary.activeSession.startedAt),
} }
: null, : null,
perAgentTotals: (s.workSummary.perAgentTotals ?? []).map((item) => ({
agentId: item.agentId,
agentName: item.agentName ?? null,
agentEmail: item.agentEmail ?? null,
avatarUrl: item.avatarUrl ?? null,
totalWorkedMs: item.totalWorkedMs,
internalWorkedMs: item.internalWorkedMs ?? 0,
externalWorkedMs: item.externalWorkedMs ?? 0,
})),
} }
: undefined, : undefined,
}; };
@ -219,6 +241,15 @@ export function mapTicketWithDetailsFromServer(input: unknown) {
startedAt: new Date(s.workSummary.activeSession.startedAt), startedAt: new Date(s.workSummary.activeSession.startedAt),
} }
: null, : null,
perAgentTotals: (s.workSummary.perAgentTotals ?? []).map((item) => ({
agentId: item.agentId,
agentName: item.agentName ?? null,
agentEmail: item.agentEmail ?? null,
avatarUrl: item.avatarUrl ?? null,
totalWorkedMs: item.totalWorkedMs,
internalWorkedMs: item.internalWorkedMs ?? 0,
externalWorkedMs: item.externalWorkedMs ?? 0,
})),
} }
: undefined, : undefined,
}; };

View file

@ -54,6 +54,17 @@ export const ticketSubcategorySummarySchema = z.object({
}) })
export type TicketSubcategorySummary = z.infer<typeof ticketSubcategorySummarySchema> export type TicketSubcategorySummary = z.infer<typeof ticketSubcategorySummarySchema>
export const ticketAgentWorkTotalSchema = z.object({
agentId: z.string(),
agentName: z.string().nullable().optional(),
agentEmail: z.string().nullable().optional(),
avatarUrl: z.string().nullable().optional(),
totalWorkedMs: z.number(),
internalWorkedMs: z.number().optional().default(0),
externalWorkedMs: z.number().optional().default(0),
})
export type TicketAgentWorkTotal = z.infer<typeof ticketAgentWorkTotalSchema>
export const ticketCommentSchema = z.object({ export const ticketCommentSchema = z.object({
id: z.string(), id: z.string(),
author: userSummarySchema, author: userSummarySchema,
@ -144,6 +155,7 @@ export const ticketSchema = z.object({
workType: z.string().optional(), workType: z.string().optional(),
}) })
.nullable(), .nullable(),
perAgentTotals: z.array(ticketAgentWorkTotalSchema).optional(),
}) })
.nullable() .nullable()
.optional(), .optional(),