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
|
|
@ -59,6 +59,83 @@ function normalizeStatus(status: string | null | undefined): TicketStatusNormali
|
|||
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(
|
||||
ctx: MutationCtx | QueryCtx,
|
||||
manager: Doc<"users">,
|
||||
|
|
@ -744,6 +821,7 @@ export const getById = query({
|
|||
);
|
||||
|
||||
const activeSession = t.activeSessionId ? await ctx.db.get(t.activeSessionId) : null;
|
||||
const perAgentTotals = await computeAgentWorkTotals(ctx, id, serverNow);
|
||||
|
||||
return {
|
||||
id: t._id,
|
||||
|
|
@ -813,6 +891,15 @@ export const getById = query({
|
|||
workType: activeSession.workType ?? "INTERNAL",
|
||||
}
|
||||
: 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,
|
||||
customFields: customFieldsRecord,
|
||||
|
|
@ -1285,6 +1372,10 @@ export const changeAssignee = mutation({
|
|||
if (viewer.role === "MANAGER") {
|
||||
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
|
||||
if (currentAssigneeId && currentAssigneeId !== actorId && !isAdmin) {
|
||||
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 serverNow = Date.now()
|
||||
const perAgentTotals = await computeAgentWorkTotals(ctx, ticketId, serverNow)
|
||||
return {
|
||||
ticketId,
|
||||
totalWorkedMs: ticket.totalWorkedMs ?? 0,
|
||||
|
|
@ -1452,6 +1544,15 @@ export const workSummary = query({
|
|||
workType: activeSession.workType ?? "INTERNAL",
|
||||
}
|
||||
: 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,
|
||||
})),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -83,6 +83,19 @@ const serverTicketSchema = z.object({
|
|||
startedAt: z.number(),
|
||||
})
|
||||
.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()
|
||||
.optional(),
|
||||
|
|
@ -158,6 +171,15 @@ export function mapTicketFromServer(input: unknown) {
|
|||
startedAt: new Date(s.workSummary.activeSession.startedAt),
|
||||
}
|
||||
: 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,
|
||||
};
|
||||
|
|
@ -219,6 +241,15 @@ export function mapTicketWithDetailsFromServer(input: unknown) {
|
|||
startedAt: new Date(s.workSummary.activeSession.startedAt),
|
||||
}
|
||||
: 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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -53,11 +53,22 @@ export const ticketSubcategorySummarySchema = z.object({
|
|||
categoryId: z.string().optional(),
|
||||
})
|
||||
export type TicketSubcategorySummary = z.infer<typeof ticketSubcategorySummarySchema>
|
||||
|
||||
export const ticketCommentSchema = z.object({
|
||||
id: z.string(),
|
||||
author: userSummarySchema,
|
||||
visibility: commentVisibilitySchema,
|
||||
|
||||
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({
|
||||
id: z.string(),
|
||||
author: userSummarySchema,
|
||||
visibility: commentVisibilitySchema,
|
||||
body: z.string(),
|
||||
attachments: z
|
||||
.array(
|
||||
|
|
@ -144,11 +155,12 @@ export const ticketSchema = z.object({
|
|||
workType: z.string().optional(),
|
||||
})
|
||||
.nullable(),
|
||||
perAgentTotals: z.array(ticketAgentWorkTotalSchema).optional(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
})
|
||||
export type Ticket = z.infer<typeof ticketSchema>
|
||||
})
|
||||
export type Ticket = z.infer<typeof ticketSchema>
|
||||
|
||||
export const ticketWithDetailsSchema = ticketSchema.extend({
|
||||
description: z.string().optional(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue