From 3972f66c925330a00512359c5ae258f709f5ffc3 Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Mon, 20 Oct 2025 19:46:20 -0300 Subject: [PATCH] feat: enforce ticket ownership during work sessions --- convex/tickets.ts | 101 +++++++++ src/components/tickets/status-select.tsx | 3 + .../tickets/ticket-details-panel.tsx | 71 ++++-- .../tickets/ticket-summary-header.tsx | 208 ++++++++++++++++-- src/lib/mappers/ticket.ts | 31 +++ src/lib/schemas/ticket.ts | 26 ++- 6 files changed, 397 insertions(+), 43 deletions(-) diff --git a/convex/tickets.ts b/convex/tickets.ts index 521fa47..1850bbe 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -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 { + 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, + })), } }, }) diff --git a/src/components/tickets/status-select.tsx b/src/components/tickets/status-select.tsx index 31d1102..fe1c413 100644 --- a/src/components/tickets/status-select.tsx +++ b/src/components/tickets/status-select.tsx @@ -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} diff --git a/src/components/tickets/ticket-details-panel.tsx b/src/components/tickets/ticket-details-panel.tsx index 4b40294..430b4ac 100644 --- a/src/components/tickets/ticket-details-panel.tsx +++ b/src/components/tickets/ticket-details-panel.tsx @@ -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 ( @@ -70,21 +97,31 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) { )} -
-

Tags

-
- {ticket.tags?.length ? ( - ticket.tags.map((tag) => ( - - {tag} - - )) - ) : ( - Sem tags. - )} -
-
- + {agentTotals.length > 0 ? ( + <> +
+

Tempo por agente

+
+ {agentTotals.map((agent) => ( +
+
+ {agent.agentName ?? "Agente removido"} + {formatDuration(agent.totalWorkedMs)} +
+
+ Interno: {formatDuration(agent.internalWorkedMs)} + Externo: {formatDuration(agent.externalWorkedMs)} +
+
+ ))} +
+
+ + + ) : null}

Histórico

diff --git a/src/components/tickets/ticket-summary-header.tsx b/src/components/tickets/ticket-summary-header.tsx index 52f7e84..8bacaea 100644 --- a/src/components/tickets/ticket-summary-header.tsx +++ b/src/components/tickets/ticket-summary-header.tsx @@ -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(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) {
@@ -732,8 +891,14 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { - @@ -744,7 +909,12 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { ) : ( - @@ -925,7 +1095,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { ) : ( - {ticket.assignee?.name ?? "Não atribuído"} + {assigneeState?.name ?? "Não atribuído"} )}
diff --git a/src/lib/mappers/ticket.ts b/src/lib/mappers/ticket.ts index b7c251d..35b56a8 100644 --- a/src/lib/mappers/ticket.ts +++ b/src/lib/mappers/ticket.ts @@ -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, }; diff --git a/src/lib/schemas/ticket.ts b/src/lib/schemas/ticket.ts index 640876f..fb4fbc7 100644 --- a/src/lib/schemas/ticket.ts +++ b/src/lib/schemas/ticket.ts @@ -53,11 +53,22 @@ export const ticketSubcategorySummarySchema = z.object({ categoryId: z.string().optional(), }) export type TicketSubcategorySummary = z.infer - -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 + +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 +}) +export type Ticket = z.infer export const ticketWithDetailsSchema = ticketSchema.extend({ description: z.string().optional(),