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

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

View file

@ -1,6 +1,7 @@
import { useMemo } from "react"
import { format } from "date-fns"
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 { 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 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"
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) {
const isAvulso = Boolean(ticket.company?.isAvulso)
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 (
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
@ -70,21 +97,31 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
)}
</div>
<Separator className="bg-slate-200" />
<div className="space-y-2 break-words">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Tags</p>
<div className="flex flex-wrap gap-2">
{ticket.tags?.length ? (
ticket.tags.map((tag) => (
<Badge key={tag} className={tagBadgeClass}>
<IconTags className={iconAccentClass} /> {tag}
</Badge>
))
) : (
<span className="text-neutral-600">Sem tags.</span>
)}
</div>
</div>
<Separator className="bg-slate-200" />
{agentTotals.length > 0 ? (
<>
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Tempo por agente</p>
<div className="flex flex-col gap-2">
{agentTotals.map((agent) => (
<div
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"
>
<div className="flex items-center justify-between text-sm font-semibold text-neutral-900">
<span>{agent.agentName ?? "Agente removido"}</span>
<span>{formatDuration(agent.totalWorkedMs)}</span>
</div>
<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">
<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">

View file

@ -44,6 +44,16 @@ interface TicketHeaderProps {
ticket: TicketWithDetails
}
type AgentWorkTotalSnapshot = {
agentId: string
agentName: string | null
agentEmail: string | null
avatarUrl: string | null
totalWorkedMs: number
internalWorkedMs: number
externalWorkedMs: number
}
type WorkSummarySnapshot = {
ticketId: Id<"tickets">
totalWorkedMs: number
@ -52,10 +62,11 @@ type WorkSummarySnapshot = {
serverNow?: number | null
activeSession: {
id: Id<"ticketWorkSessions">
agentId: Id<"users">
agentId: string
startedAt: number
workType?: string
} | null
perAgentTotals: AgentWorkTotalSnapshot[]
}
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"
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"
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 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"
@ -107,6 +126,20 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
: machineAssignedName && machineAssignedName.length > 0
? machineAssignedName
: 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)
const changeAssignee = useMutation(api.tickets.changeAssignee)
const changeQueue = useMutation(api.tickets.changeQueue)
@ -139,14 +172,25 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
serverNow?: number
activeSession: {
id: Id<"ticketWorkSessions">
agentId: Id<"users">
agentId: string
startedAt: number
workType?: string
} | null
perAgentTotals?: Array<{
agentId: string
agentName?: string | null
agentEmail?: string | null
avatarUrl?: string | null
totalWorkedMs: number
internalWorkedMs?: number
externalWorkedMs?: number
}>
}
| null
| undefined
const [status, setStatus] = useState<TicketStatus>(ticket.status)
const [assigneeState, setAssigneeState] = useState(ticket.assignee ?? null)
const [editing, setEditing] = useState(false)
const [subject, setSubject] = useState(ticket.subject)
const [summary, setSummary] = useState(ticket.summary ?? "")
@ -156,7 +200,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
subcategoryId: ticket.subcategory?.id ?? "",
}
)
const currentAssigneeId = ticket.assignee?.id ?? ""
const currentAssigneeId = assigneeState?.id ?? ""
const [assigneeSelection, setAssigneeSelection] = useState(currentAssigneeId)
const [saving, setSaving] = useState(false)
const [pauseDialogOpen, setPauseDialogOpen] = useState(false)
@ -195,11 +239,20 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const secondaryOptions = useMemo(() => activeCategory?.secondary ?? [], [activeCategory])
const hasAssignee = Boolean(currentAssigneeId)
const isCurrentResponsible = hasAssignee && convexUserId ? currentAssigneeId === convexUserId : false
const canControlWork = isAdmin || !hasAssignee || isCurrentResponsible
const canPauseWork = isAdmin || isCurrentResponsible
const isResolved = status === "RESOLVED"
const canControlWork = !isResolved && (isAdmin || !hasAssignee || isCurrentResponsible)
const canPauseWork = !isResolved && (isAdmin || isCurrentResponsible)
const pauseDisabled = !canPauseWork
const startDisabled = !canControlWork
useEffect(() => {
setStatus(ticket.status)
}, [ticket.status])
useEffect(() => {
setAssigneeState(ticket.assignee ?? null)
}, [ticket.assignee])
async function handleSave() {
if (!convexUserId || !formDirty) {
setEditing(false)
@ -264,6 +317,11 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
setAssigneeSelection(currentAssigneeId)
throw new Error("invalid-assignee")
} 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" })
try {
await changeAssignee({
@ -272,6 +330,18 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
actorId: convexUserId as Id<"users">,
})
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) {
console.error(error)
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 ?? "",
})
setQueueSelection(ticket.queue ?? "")
setAssigneeSelection(ticket.assignee?.id ?? "")
}, [editing, ticket.category?.id, ticket.subcategory?.id, ticket.queue, ticket.assignee?.id])
setAssigneeSelection(currentAssigneeId)
}, [editing, ticket.category?.id, ticket.subcategory?.id, ticket.queue, currentAssigneeId])
useEffect(() => {
if (!editing) return
@ -361,11 +431,20 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
activeSession: ticketActiveSession
? {
id: ticketActiveSession.id as Id<"ticketWorkSessions">,
agentId: ticketActiveSession.agentId as Id<"users">,
agentId: String(ticketActiveSession.agentId),
startedAt: ticketActiveSessionStartedAtMs ?? ticketActiveSession.startedAt.getTime(),
workType: (ticketActiveSessionWorkType ?? "INTERNAL").toString().toUpperCase(),
}
: 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,
@ -425,6 +504,15 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
workType: (workSummaryRemote.activeSession.workType ?? "INTERNAL").toString().toUpperCase(),
}
: 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])
@ -535,18 +623,54 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
externalWorkedMs: 0,
serverNow: 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 {
...base,
serverNow: typeof resultMeta?.serverNow === "number" ? resultMeta.serverNow : getServerNow(),
activeSession: {
id: (sessionId as Id<"ticketWorkSessions">) ?? (base.activeSession?.id as Id<"ticketWorkSessions">),
agentId: convexUserId as Id<"users">,
agentId: actorId,
startedAt: startedAtMs,
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) {
const message = error instanceof Error ? error.message : "Não foi possível atualizar o atendimento"
toast.error(message, { id: "work" })
@ -580,15 +704,49 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
setWorkSummary((prev) => {
if (!prev) return prev
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 {
...prev,
totalWorkedMs: prev.totalWorkedMs + delta,
internalWorkedMs: prev.internalWorkedMs + (workType === "INTERNAL" ? delta : 0),
externalWorkedMs: prev.externalWorkedMs + (workType === "EXTERNAL" ? delta : 0),
internalWorkedMs: prev.internalWorkedMs + internalDelta,
externalWorkedMs: prev.externalWorkedMs + externalDelta,
serverNow: typeof resultMeta?.serverNow === "number" ? resultMeta.serverNow : getServerNow(),
activeSession: null,
perAgentTotals: updatedTotals,
}
})
setStatus("PAUSED")
} catch (error) {
const message = error instanceof Error ? error.message : "Não foi possível atualizar o atendimento"
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">
<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)}
>
<CheckCircle2 className="size-4" /> Encerrar
@ -684,7 +842,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
actorId={convexUserId as Id<"users"> | null}
requesterName={ticket.requester?.name ?? ticket.requester?.email ?? null}
agentName={agentName}
onSuccess={() => {}}
onSuccess={() => setStatus("RESOLVED")}
/>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-3">
@ -702,6 +860,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
tenantId={ticket.tenantId}
requesterName={ticket.requester?.name ?? ticket.requester?.email ?? null}
showCloseButton={false}
onStatusChange={setStatus}
/>
{isPlaying ? (
<Tooltip>
@ -710,7 +869,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
<Button
size="icon"
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={() => {
if (!convexUserId || pauseDisabled) return
setPauseDialogOpen(true)
@ -718,7 +877,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
disabled={pauseDisabled}
title="Pausar"
>
<IconPlayerPause className="size-4 text-white" />
<IconPlayerPause className={pauseDisabled ? "size-4 text-neutral-500" : "size-4 text-white"} />
</Button>
</span>
</TooltipTrigger>
@ -732,8 +891,14 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
<Tooltip>
<TooltipTrigger asChild>
<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">
<IconPlayerPlay className="size-4 text-white" />
<Button
size="icon"
aria-label="Iniciar atendimento"
className={playButtonDisabledClass}
disabled
title="Iniciar"
>
<IconPlayerPlay className="size-4 text-neutral-500" />
</Button>
</span>
</TooltipTrigger>
@ -744,7 +909,12 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
) : (
<DropdownMenu>
<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" />
</Button>
</DropdownMenuTrigger>
@ -925,7 +1095,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</SelectContent>
</Select>
) : (
<span className={sectionValueClass}>{ticket.assignee?.name ?? "Não atribuído"}</span>
<span className={sectionValueClass}>{assigneeState?.name ?? "Não atribuído"}</span>
)}
</div>
<div className="flex flex-col gap-1">