feat: enforce ticket ownership during work sessions
This commit is contained in:
parent
81657e52d8
commit
3972f66c92
6 changed files with 397 additions and 43 deletions
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue