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";
|
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,
|
||||||
|
})),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue