"use client" import Link from "next/link" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { format, formatDistanceToNow } from "date-fns" import { ptBR } from "date-fns/locale" import { IconClock, IconDownload, IconLink, IconPlayerPause, IconPlayerPlay, IconPencil } from "@tabler/icons-react" import { useMutation, useQuery } from "convex/react" import { toast } from "sonner" import { api } from "@/convex/_generated/api" import { useAuth } from "@/lib/auth-client" import type { Doc, Id } from "@/convex/_generated/dataModel" import type { TicketWithDetails, TicketQueueSummary, TicketStatus } from "@/lib/schemas/ticket" import { Badge } from "@/components/ui/badge" import { Separator } from "@/components/ui/separator" import { PrioritySelect } from "@/components/tickets/priority-select" import { DeleteTicketDialog } from "@/components/tickets/delete-ticket-dialog" import { StatusSelect } from "@/components/tickets/status-select" import { CloseTicketDialog, type AdjustWorkSummaryResult } from "@/components/tickets/close-ticket-dialog" import { TicketCustomFieldsSection } from "@/components/tickets/ticket-custom-fields" import { CheckCircle2 } from "lucide-react" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Textarea } from "@/components/ui/textarea" import { Spinner } from "@/components/ui/spinner" import { useTicketCategories } from "@/hooks/use-ticket-categories" import { useDefaultQueues } from "@/hooks/use-default-queues" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { deriveServerOffset, reconcileLocalSessionStart, toServerTimestamp, type SessionStartOrigin, } from "./ticket-timer.utils" import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox" 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 internalWorkedMs: number externalWorkedMs: number serverNow?: number | null activeSession: { id: Id<"ticketWorkSessions"> 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" const referenceBadgeClass = "inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 bg-white px-3 text-sm font-semibold text-neutral-700" 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" const sectionValueClass = "font-medium text-neutral-900" const subtleBadgeClass = "inline-flex items-center rounded-full border border-slate-200 bg-slate-50 px-2.5 py-0.5 text-[11px] font-medium text-neutral-600" const EMPTY_CATEGORY_VALUE = "__none__" const EMPTY_SUBCATEGORY_VALUE = "__none__" const PAUSE_REASONS = [ { value: "NO_CONTACT", label: "Falta de contato" }, { value: "WAITING_THIRD_PARTY", label: "Aguardando terceiro" }, { value: "IN_PROCEDURE", label: "Em procedimento" }, { value: "LUNCH_BREAK", label: "Intervalo de almoço" }, ] type CustomerOption = { id: string name: string email: string role: string companyId: string | null companyName: string | null companyIsAvulso: boolean avatarUrl: string | null } const NO_COMPANY_VALUE = "__no_company__" const NO_REQUESTER_VALUE = "__no_requester__" function formatDuration(durationMs: number) { if (durationMs <= 0) return "0s" const totalSeconds = Math.floor(durationMs / 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 TicketSummaryHeader({ ticket }: TicketHeaderProps) { const { convexUserId, role, isStaff, session, machineContext } = useAuth() const normalizedRole = (role ?? "").toLowerCase() const isManager = normalizedRole === "manager" const isAdmin = normalizedRole === "admin" const canAdjustWork = isAdmin || normalizedRole === "agent" const sessionName = session?.user?.name?.trim() const machineAssignedName = machineContext?.assignedUserName?.trim() const agentName = sessionName && sessionName.length > 0 ? sessionName : machineAssignedName && machineAssignedName.length > 0 ? machineAssignedName : null const viewerId = convexUserId ?? null const viewerRole = (role ?? "").toLowerCase() const [status, setStatus] = useState(ticket.status) const reopenDeadline = ticket.reopenDeadline ?? null const isRequester = Boolean(ticket.requester?.id && viewerId && ticket.requester.id === viewerId) const reopenWindowActive = reopenDeadline ? reopenDeadline > Date.now() : false const canReopenTicket = status === "RESOLVED" && reopenWindowActive && (isStaff || viewerRole === "manager" || isRequester) const reopenDeadlineLabel = useMemo(() => { if (!reopenDeadline) return null try { return new Date(reopenDeadline).toLocaleString("pt-BR") } catch { return null } }, [reopenDeadline]) 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) const changeRequester = useMutation(api.tickets.changeRequester) const updateSubject = useMutation(api.tickets.updateSubject) const updateSummary = useMutation(api.tickets.updateSummary) const startWork = useMutation(api.tickets.startWork) const pauseWork = useMutation(api.tickets.pauseWork) const updateCategories = useMutation(api.tickets.updateCategories) const reopenTicket = useMutation(api.tickets.reopenTicket) const agents = (useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) as Doc<"users">[] | undefined) ?? [] const queuesEnabled = Boolean(isStaff && convexUserId) const companiesRemote = useQuery( api.companies.list, convexUserId ? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users"> } : "skip" ) const companies = useMemo( () => (Array.isArray(companiesRemote) ? companiesRemote : []).map((company) => ({ id: String(company.id), name: company.name, slug: company.slug ?? null, })), [companiesRemote] ) const customersRemote = useQuery( api.users.listCustomers, convexUserId ? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users"> } : "skip" ) const customers = useMemo( () => (Array.isArray(customersRemote) ? (customersRemote as CustomerOption[]) : []), [customersRemote] ) const queuesResult = useQuery( api.queues.summary, queuesEnabled ? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users"> } : "skip" ) const queues: TicketQueueSummary[] = Array.isArray(queuesResult) ? queuesResult : [] const { categories, isLoading: categoriesLoading } = useTicketCategories(ticket.tenantId) const workSummaryRemote = useQuery( api.tickets.workSummary, convexUserId ? { ticketId: ticket.id as Id<"tickets">, viewerId: convexUserId as Id<"users"> } : "skip" ) as | { ticketId: Id<"tickets"> totalWorkedMs: number internalWorkedMs?: number externalWorkedMs?: number serverNow?: number activeSession: { id: Id<"ticketWorkSessions"> 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 [assigneeState, setAssigneeState] = useState(ticket.assignee ?? null) const [editing, setEditing] = useState(false) const [subject, setSubject] = useState(ticket.subject) const [summary, setSummary] = useState(ticket.summary ?? "") const [categorySelection, setCategorySelection] = useState<{ categoryId: string; subcategoryId: string }>( { categoryId: ticket.category?.id ?? "", subcategoryId: ticket.subcategory?.id ?? "", } ) const currentAssigneeId = assigneeState?.id ?? "" const [assigneeSelection, setAssigneeSelection] = useState(currentAssigneeId) const [saving, setSaving] = useState(false) const [pauseDialogOpen, setPauseDialogOpen] = useState(false) const [isReopening, setIsReopening] = useState(false) const [pauseReason, setPauseReason] = useState(PAUSE_REASONS[0]?.value ?? "NO_CONTACT") const [pauseNote, setPauseNote] = useState("") const [pausing, setPausing] = useState(false) const [exportingPdf, setExportingPdf] = useState(false) const [closeOpen, setCloseOpen] = useState(false) const [assigneeChangeReason, setAssigneeChangeReason] = useState("") const [assigneeReasonError, setAssigneeReasonError] = useState(null) const [companySelection, setCompanySelection] = useState(NO_COMPANY_VALUE) const [requesterSelection, setRequesterSelection] = useState(ticket.requester.id) const [requesterError, setRequesterError] = useState(null) const [customersInitialized, setCustomersInitialized] = useState(false) const selectedCategoryId = categorySelection.categoryId const selectedSubcategoryId = categorySelection.subcategoryId const dirty = useMemo( () => subject !== ticket.subject || (summary ?? "") !== (ticket.summary ?? ""), [subject, summary, ticket.subject, ticket.summary] ) const currentCategoryId = ticket.category?.id ?? "" const currentSubcategoryId = ticket.subcategory?.id ?? "" const categoryDirty = useMemo(() => { return selectedCategoryId !== currentCategoryId || selectedSubcategoryId !== currentSubcategoryId }, [selectedCategoryId, selectedSubcategoryId, currentCategoryId, currentSubcategoryId]) const currentQueueName = ticket.queue ?? "" const isAvulso = Boolean(((ticket.company ?? null) as { isAvulso?: boolean } | null)?.isAvulso ?? false) const resolvedWithSummary = useMemo(() => { if (!ticket.resolvedWithTicketId) return null const linkedId = String(ticket.resolvedWithTicketId) let reference: string | null = null let subject: string | null = null for (const event of ticket.timeline ?? []) { if (!event || event.type !== "TICKET_LINKED") continue const payload = (event.payload ?? {}) as { linkedTicketId?: unknown linkedReference?: unknown linkedSubject?: unknown } if (String(payload.linkedTicketId ?? "") !== linkedId) continue if (reference === null) { const rawReference = payload.linkedReference if (typeof rawReference === "number") { reference = rawReference.toString() } else if (typeof rawReference === "string" && rawReference.trim().length > 0) { reference = rawReference.trim() } } if (subject === null) { const rawSubject = payload.linkedSubject if (typeof rawSubject === "string" && rawSubject.trim().length > 0) { subject = rawSubject.trim() } } if (reference !== null && subject !== null) { break } } return { id: linkedId, reference, subject } }, [ticket.resolvedWithTicketId, ticket.timeline]) const [queueSelection, setQueueSelection] = useState(currentQueueName) const queueDirty = useMemo(() => queueSelection !== currentQueueName, [queueSelection, currentQueueName]) const currentRequesterRecord = useMemo( () => customers.find((customer) => customer.id === ticket.requester.id) ?? null, [customers, ticket.requester.id] ) const currentCompanySelection = useMemo(() => { if (currentRequesterRecord?.companyId) return currentRequesterRecord.companyId if (ticket.company?.id) return String(ticket.company.id) return NO_COMPANY_VALUE }, [currentRequesterRecord, ticket.company?.id]) const companyMeta = useMemo(() => { const map = new Map() companies.forEach((company) => { const trimmedName = company.name.trim() const slugFallback = company.slug?.trim() const label = trimmedName.length > 0 ? trimmedName : slugFallback && slugFallback.length > 0 ? slugFallback : `Empresa ${company.id.slice(0, 8)}` const keywords = slugFallback ? [slugFallback] : [] map.set(company.id, { name: label, isAvulso: false, keywords }) }) customers.forEach((customer) => { if (customer.companyId && !map.has(customer.companyId)) { const trimmedName = customer.companyName?.trim() ?? "" const label = trimmedName.length > 0 ? trimmedName : `Empresa ${customer.companyId.slice(0, 8)}` map.set(customer.companyId, { name: label, isAvulso: customer.companyIsAvulso, keywords: [], }) } }) return map }, [companies, customers]) const companyComboboxOptions = useMemo(() => { const entries = Array.from(companyMeta.entries()) .map(([id, meta]) => ({ value: id, label: meta.name, keywords: meta.keywords, })) .sort((a, b) => a.label.localeCompare(b.label, "pt-BR")) return [{ value: NO_COMPANY_VALUE, label: "Sem empresa", keywords: ["sem empresa", "nenhuma"] }, ...entries] }, [companyMeta]) const filteredCustomers = useMemo(() => { if (companySelection === NO_COMPANY_VALUE) { return customers.filter((customer) => !customer.companyId) } return customers.filter((customer) => customer.companyId === companySelection) }, [companySelection, customers]) const assigneeDirty = useMemo(() => assigneeSelection !== currentAssigneeId, [assigneeSelection, currentAssigneeId]) const requesterDirty = useMemo(() => requesterSelection !== ticket.requester.id, [requesterSelection, ticket.requester.id]) const formDirty = dirty || categoryDirty || queueDirty || assigneeDirty || requesterDirty const normalizedAssigneeReason = assigneeChangeReason.trim() const assigneeReasonValid = normalizedAssigneeReason.length === 0 || normalizedAssigneeReason.length >= 5 const saveDisabled = !formDirty || saving || !assigneeReasonValid const companyLabel = useMemo(() => { if (ticket.company?.name) return ticket.company.name if (isAvulso) return "Cliente avulso" return "Sem empresa vinculada" }, [ticket.company?.name, isAvulso]) const activeCategory = useMemo( () => categories.find((category) => category.id === selectedCategoryId) ?? null, [categories, selectedCategoryId] ) const secondaryOptions = useMemo(() => activeCategory?.secondary ?? [], [activeCategory]) const hasAssignee = Boolean(currentAssigneeId) const isCurrentResponsible = hasAssignee && convexUserId ? currentAssigneeId === convexUserId : false const isResolved = status === "RESOLVED" const canControlWork = !isResolved && (isAdmin || !hasAssignee || isCurrentResponsible) const canPauseWork = !isResolved && (isAdmin || isCurrentResponsible) const pauseDisabled = !canPauseWork const startDisabled = !canControlWork useEffect(() => { if (!customersInitialized) { if (customers.length > 0) { setRequesterSelection(ticket.requester.id) setCompanySelection(currentCompanySelection) setCustomersInitialized(true) } return } if (!editing) { setRequesterSelection(ticket.requester.id) setCompanySelection(currentCompanySelection) } }, [customersInitialized, customers, currentCompanySelection, ticket.requester.id, editing]) useEffect(() => { if (!editing) return const available = filteredCustomers if (available.length === 0) { if (requesterSelection !== null) { setRequesterSelection(null) } return } if (!requesterSelection || !available.some((customer) => customer.id === requesterSelection)) { setRequesterSelection(available[0].id) } }, [editing, filteredCustomers, requesterSelection]) useEffect(() => { if (requesterSelection && requesterError) { setRequesterError(null) } }, [requesterSelection, requesterError]) useEffect(() => { setStatus(ticket.status) }, [ticket.status]) useEffect(() => { setAssigneeState(ticket.assignee ?? null) }, [ticket.assignee]) async function handleSave() { if (!convexUserId || !formDirty) { setEditing(false) return } setSaving(true) try { if (categoryDirty && !isManager) { toast.loading("Atualizando categoria...", { id: "ticket-category" }) try { await updateCategories({ ticketId: ticket.id as Id<"tickets">, categoryId: selectedCategoryId ? (selectedCategoryId as Id<"ticketCategories">) : null, subcategoryId: selectedSubcategoryId ? (selectedSubcategoryId as Id<"ticketSubcategories">) : null, actorId: convexUserId as Id<"users">, }) toast.success("Categoria atualizada!", { id: "ticket-category" }) } catch (categoryError) { toast.error("Não foi possível atualizar a categoria.", { id: "ticket-category" }) setCategorySelection({ categoryId: currentCategoryId, subcategoryId: currentSubcategoryId, }) throw categoryError } } else if (categoryDirty && isManager) { setCategorySelection({ categoryId: currentCategoryId, subcategoryId: currentSubcategoryId, }) } if (queueDirty && !isManager) { const queue = queues.find((item) => item.name === queueSelection) if (!queue) { toast.error("Fila selecionada não está disponível.") setQueueSelection(currentQueueName) throw new Error("Fila inválida") } toast.loading("Atualizando fila...", { id: "queue" }) try { await changeQueue({ ticketId: ticket.id as Id<"tickets">, queueId: queue.id as Id<"queues">, actorId: convexUserId as Id<"users">, }) toast.success("Fila atualizada!", { id: "queue" }) } catch (queueError) { toast.error("Não foi possível atualizar a fila.", { id: "queue" }) setQueueSelection(currentQueueName) throw queueError } } else if (queueDirty && isManager) { setQueueSelection(currentQueueName) } if (requesterDirty && !isManager) { if (!requesterSelection) { setRequesterError("Selecione um solicitante.") toast.error("Selecione um solicitante válido.", { id: "requester" }) throw new Error("invalid-requester") } toast.loading("Atualizando solicitante...", { id: "requester" }) try { await changeRequester({ ticketId: ticket.id as Id<"tickets">, requesterId: requesterSelection as Id<"users">, actorId: convexUserId as Id<"users">, }) toast.success("Solicitante atualizado!", { id: "requester" }) } catch (requesterError) { console.error(requesterError) toast.error("Não foi possível atualizar o solicitante.", { id: "requester" }) setRequesterSelection(ticket.requester.id) setCompanySelection(currentCompanySelection) throw requesterError } } else if (requesterDirty && isManager) { setRequesterSelection(ticket.requester.id) setCompanySelection(currentCompanySelection) } if (assigneeDirty && !isManager) { if (!assigneeSelection) { toast.error("Selecione um responsável válido.", { id: "assignee" }) 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") } const reasonValue = assigneeChangeReason.trim() if (reasonValue.length > 0 && reasonValue.length < 5) { setAssigneeReasonError("Descreva o motivo com pelo menos 5 caracteres ou deixe em branco.") toast.error("Informe ao menos 5 caracteres no motivo ou deixe o campo vazio.", { id: "assignee" }) return } if (reasonValue.length > 1000) { setAssigneeReasonError("Use no máximo 1.000 caracteres.") toast.error("Reduza o motivo para até 1.000 caracteres.", { id: "assignee" }) return } setAssigneeReasonError(null) toast.loading("Atualizando responsável...", { id: "assignee" }) try { await changeAssignee({ ticketId: ticket.id as Id<"tickets">, assigneeId: assigneeSelection as Id<"users">, actorId: convexUserId as Id<"users">, reason: reasonValue.length > 0 ? reasonValue : undefined, }) 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") : [], }) } } setAssigneeChangeReason("") } catch (error) { console.error(error) toast.error("Não foi possível atualizar o responsável.", { id: "assignee" }) setAssigneeSelection(currentAssigneeId) throw error } } } else if (assigneeDirty && isManager) { setAssigneeSelection(currentAssigneeId) } if (dirty) { toast.loading("Salvando alterações...", { id: "save-header" }) if (subject !== ticket.subject) { await updateSubject({ ticketId: ticket.id as Id<"tickets">, subject: subject.trim(), actorId: convexUserId as Id<"users">, }) } if ((summary ?? "") !== (ticket.summary ?? "")) { await updateSummary({ ticketId: ticket.id as Id<"tickets">, summary: (summary ?? "").trim(), actorId: convexUserId as Id<"users">, }) } toast.success("Cabeçalho atualizado!", { id: "save-header" }) } setEditing(false) } catch { toast.error("Não foi possível salvar.", { id: "save-header" }) } finally { setSaving(false) } } function handleCancel() { setSubject(ticket.subject) setSummary(ticket.summary ?? "") setCategorySelection({ categoryId: currentCategoryId, subcategoryId: currentSubcategoryId, }) setQueueSelection(currentQueueName) setRequesterSelection(ticket.requester.id) setCompanySelection(currentCompanySelection) setRequesterError(null) setAssigneeSelection(currentAssigneeId) setAssigneeChangeReason("") setAssigneeReasonError(null) setEditing(false) } useEffect(() => { if (editing) return setAssigneeChangeReason("") setAssigneeReasonError(null) setCategorySelection({ categoryId: ticket.category?.id ?? "", subcategoryId: ticket.subcategory?.id ?? "", }) setQueueSelection(ticket.queue ?? "") setAssigneeSelection(currentAssigneeId) setRequesterSelection(ticket.requester.id) setCompanySelection(currentCompanySelection) }, [editing, ticket.category?.id, ticket.subcategory?.id, ticket.queue, currentAssigneeId, ticket.requester.id, currentCompanySelection]) useEffect(() => { if (!editing) return if (!selectedCategoryId) { if (selectedSubcategoryId) { setCategorySelection((prev) => ({ ...prev, subcategoryId: "" })) } return } const stillValid = secondaryOptions.some((option) => option.id === selectedSubcategoryId) if (!stillValid && selectedSubcategoryId) { setCategorySelection((prev) => ({ ...prev, subcategoryId: "" })) } }, [editing, secondaryOptions, selectedCategoryId, selectedSubcategoryId]) const ticketActiveSession = ticket.workSummary?.activeSession ?? null const ticketActiveSessionStartedAtMs = ticketActiveSession ? ticketActiveSession.startedAt.getTime() : undefined const ticketActiveSessionWorkType = (ticketActiveSession as { workType?: string } | null)?.workType const initialWorkSummary = useMemo(() => { if (!ticket.workSummary) return null return { ticketId: ticket.id as Id<"tickets">, totalWorkedMs: ticket.workSummary.totalWorkedMs ?? 0, internalWorkedMs: ticket.workSummary.internalWorkedMs ?? 0, externalWorkedMs: ticket.workSummary.externalWorkedMs ?? 0, serverNow: typeof ticket.workSummary.serverNow === "number" ? ticket.workSummary.serverNow : null, activeSession: ticketActiveSession ? { id: ticketActiveSession.id as Id<"ticketWorkSessions">, 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, ticket.workSummary, ticketActiveSession, ticketActiveSessionStartedAtMs, ticketActiveSessionWorkType, ]) const [workSummary, setWorkSummary] = useState(initialWorkSummary) const effectiveWorkSummary = workSummary ?? initialWorkSummary const serverOffsetRef = useRef(0) const calibrateServerOffset = useCallback( (serverNow?: number | Date | null) => { if (serverNow === undefined || serverNow === null) return const serverMs = serverNow instanceof Date ? serverNow.getTime() : Number(serverNow) if (!Number.isFinite(serverMs)) return serverOffsetRef.current = deriveServerOffset({ currentOffset: serverOffsetRef.current, localNow: Date.now(), serverNow: serverMs, }) }, [] ) const getServerNow = useCallback(() => toServerTimestamp(Date.now(), serverOffsetRef.current), []) useEffect(() => { if (initialWorkSummary?.serverNow) { calibrateServerOffset(initialWorkSummary.serverNow) } setWorkSummary(initialWorkSummary) }, [initialWorkSummary, calibrateServerOffset]) useEffect(() => { if (workSummaryRemote === undefined) return if (workSummaryRemote === null) { setWorkSummary(null) return } if (typeof workSummaryRemote.serverNow === "number") { calibrateServerOffset(workSummaryRemote.serverNow) } setWorkSummary({ ticketId: workSummaryRemote.ticketId, totalWorkedMs: workSummaryRemote.totalWorkedMs ?? 0, internalWorkedMs: workSummaryRemote.internalWorkedMs ?? 0, externalWorkedMs: workSummaryRemote.externalWorkedMs ?? 0, serverNow: typeof workSummaryRemote.serverNow === "number" ? workSummaryRemote.serverNow : null, activeSession: workSummaryRemote.activeSession ? { id: workSummaryRemote.activeSession.id, agentId: workSummaryRemote.activeSession.agentId, startedAt: workSummaryRemote.activeSession.startedAt, 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]) const activeSessionId = workSummary?.activeSession?.id ?? null const activeSessionStartedAt = workSummary?.activeSession?.startedAt ?? null const isPlaying = Boolean(activeSessionId) const [now, setNow] = useState(() => Date.now()) // Guarda um marcador local do início da sessão atual para evitar inflar tempo com // timestamps defasados vindos da rede. Escolhemos o MAIOR entre (remoto, local). const localStartAtRef = useRef(0) const localStartOriginRef = useRef("unknown") useEffect(() => { if (!activeSessionId) return const interval = setInterval(() => { setNow(Date.now()) }, 1000) return () => clearInterval(interval) }, [activeSessionId]) // Sempre que a sessão ativa (id) mudar, sincroniza o ponteiro local useEffect(() => { if (!activeSessionId) { localStartAtRef.current = 0 localStartOriginRef.current = "unknown" return } const { localStart, origin } = reconcileLocalSessionStart({ remoteStart: Number(activeSessionStartedAt) || 0, localStart: localStartAtRef.current, origin: localStartOriginRef.current, }) localStartAtRef.current = localStart localStartOriginRef.current = origin }, [activeSessionId, activeSessionStartedAt]) useEffect(() => { if (!pauseDialogOpen) { setPauseReason(PAUSE_REASONS[0]?.value ?? "NO_CONTACT") setPauseNote("") setPausing(false) } }, [pauseDialogOpen]) const currentSessionMs = workSummary?.activeSession ? (() => { const remoteStart = Number(workSummary.activeSession.startedAt) || 0 const effectiveStart = Math.max(remoteStart, localStartAtRef.current || 0) const alignedNow = toServerTimestamp(now, serverOffsetRef.current) return Math.max(0, alignedNow - effectiveStart) })() : 0 const totalWorkedMs = workSummary ? workSummary.totalWorkedMs + currentSessionMs : 0 const internalWorkedMs = workSummary ? workSummary.internalWorkedMs + (workSummary.activeSession?.workType === "INTERNAL" ? currentSessionMs : 0) : 0 const externalWorkedMs = workSummary ? workSummary.externalWorkedMs + (workSummary.activeSession?.workType === "EXTERNAL" ? currentSessionMs : 0) : 0 const formattedTotalWorked = useMemo(() => formatDuration(totalWorkedMs), [totalWorkedMs]) const updatedRelative = useMemo( () => formatDistanceToNow(ticket.updatedAt, { addSuffix: true, locale: ptBR }), [ticket.updatedAt] ) const handleStartWork = async (workType: "INTERNAL" | "EXTERNAL") => { if (!convexUserId) return toast.dismiss("work") toast.loading("Iniciando atendimento...", { id: "work" }) try { const result = await startWork({ ticketId: ticket.id as Id<"tickets">, actorId: convexUserId as Id<"users">, workType, }) const resultMeta = result as { status?: string startedAt?: number sessionId?: Id<"ticketWorkSessions"> serverNow?: number } const startStatus = resultMeta?.status ?? "started" if (startStatus === "already_started") { toast.info("O atendimento já estava em andamento", { id: "work" }) } else { toast.success("Atendimento iniciado", { id: "work" }) } // Otimização local: garantir startedAt correto imediatamente calibrateServerOffset(resultMeta?.serverNow ?? null) const startedAtMsRaw = resultMeta?.startedAt const startedAtMs = typeof startedAtMsRaw === "number" && Number.isFinite(startedAtMsRaw) ? startedAtMsRaw : getServerNow() if (typeof startedAtMsRaw === "number") { localStartOriginRef.current = "remote" } else if (startStatus === "already_started") { localStartOriginRef.current = "already-running-fallback" } else { localStartOriginRef.current = "fresh-local" } localStartAtRef.current = startedAtMs const sessionId = resultMeta?.sessionId setWorkSummary((prev) => { const base: WorkSummarySnapshot = prev ?? { ticketId: ticket.id as Id<"tickets">, totalWorkedMs: 0, internalWorkedMs: 0, 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: 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" }) } } const handlePauseConfirm = async () => { if (!convexUserId) return toast.dismiss("work") toast.loading("Pausando atendimento...", { id: "work" }) setPausing(true) try { const result = await pauseWork({ ticketId: ticket.id as Id<"tickets">, actorId: convexUserId as Id<"users">, reason: pauseReason, note: pauseNote.trim() ? pauseNote.trim() : undefined, }) const resultMeta = result as { status?: string; durationMs?: number; serverNow?: number } if (resultMeta?.status === "already_paused") { toast.info("O atendimento já estava pausado", { id: "work" }) } else { toast.success("Atendimento pausado", { id: "work" }) } setPauseDialogOpen(false) // Otimização local: aplicar duração retornada no total e limpar sessão ativa calibrateServerOffset(resultMeta?.serverNow ?? null) const delta = typeof resultMeta?.durationMs === "number" ? resultMeta.durationMs : 0 localStartAtRef.current = 0 localStartOriginRef.current = "unknown" 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 + 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" }) } finally { setPausing(false) } } const handleWorkSummaryAdjusted = useCallback( (result: AdjustWorkSummaryResult) => { calibrateServerOffset(result?.serverNow ?? null) setWorkSummary((prev) => { const fallback: WorkSummarySnapshot = { ticketId: ticket.id as Id<"tickets">, totalWorkedMs: 0, internalWorkedMs: 0, externalWorkedMs: 0, serverNow: result?.serverNow ?? null, activeSession: null, perAgentTotals: [], } const base = prev ?? fallback return { ...base, totalWorkedMs: result?.totalWorkedMs ?? base.totalWorkedMs, internalWorkedMs: result?.internalWorkedMs ?? base.internalWorkedMs, externalWorkedMs: result?.externalWorkedMs ?? base.externalWorkedMs, serverNow: result?.serverNow ?? base.serverNow, perAgentTotals: result?.perAgentTotals ? result.perAgentTotals.map((item) => ({ agentId: item.agentId, agentName: item.agentName ?? null, agentEmail: item.agentEmail ?? null, avatarUrl: item.avatarUrl ?? null, totalWorkedMs: item.totalWorkedMs, internalWorkedMs: item.internalWorkedMs, externalWorkedMs: item.externalWorkedMs, })) : base.perAgentTotals, } }) }, [calibrateServerOffset, ticket.id], ) const handleExportPdf = useCallback(async () => { try { setExportingPdf(true) toast.dismiss("ticket-export") toast.loading("Gerando PDF...", { id: "ticket-export" }) const response = await fetch(`/api/tickets/${ticket.id}/export/pdf`, { credentials: "include" }) if (!response.ok) { throw new Error(`failed: ${response.status}`) } const blob = await response.blob() const url = URL.createObjectURL(blob) const link = document.createElement("a") link.href = url link.download = `ticket-${ticket.reference}.pdf` document.body.appendChild(link) link.click() document.body.removeChild(link) URL.revokeObjectURL(url) toast.success("PDF exportado com sucesso!", { id: "ticket-export" }) } catch (error) { console.error(error) toast.error("Não foi possível exportar o PDF.", { id: "ticket-export" }) } finally { setExportingPdf(false) } }, [ticket.id, ticket.reference]) const handleReopenTicket = useCallback(async () => { if (!viewerId) { toast.error("Não foi possível identificar o usuário atual.") return } toast.dismiss("ticket-reopen") setIsReopening(true) toast.loading("Reabrindo ticket...", { id: "ticket-reopen" }) try { await reopenTicket({ ticketId: ticket.id as Id<"tickets">, actorId: viewerId as Id<"users"> }) toast.success("Ticket reaberto com sucesso!", { id: "ticket-reopen" }) setStatus("AWAITING_ATTENDANCE") } catch (error) { console.error(error) toast.error("Não foi possível reabrir o ticket.", { id: "ticket-reopen" }) } finally { setIsReopening(false) } }, [reopenTicket, ticket.id, viewerId]) return (
{workSummary ? ( {formattedTotalWorked}
Horas internas: {formatDuration(internalWorkedMs)} Horas externas: {formatDuration(externalWorkedMs)}
) : null} {!editing ? ( ) : null} } />
| null} ticketReference={ticket.reference ?? null} requesterName={ticket.requester?.name ?? ticket.requester?.email ?? null} agentName={agentName} workSummary={ effectiveWorkSummary ? { totalWorkedMs: effectiveWorkSummary.totalWorkedMs, internalWorkedMs: effectiveWorkSummary.internalWorkedMs, externalWorkedMs: effectiveWorkSummary.externalWorkedMs, } : null } canAdjustTime={canAdjustWork && Boolean(effectiveWorkSummary)} onWorkSummaryAdjusted={handleWorkSummaryAdjusted} onSuccess={() => setStatus("RESOLVED")} />
#{ticket.reference} {isAvulso ? ( Cliente avulso ) : null} {canReopenTicket ? ( ) : null} {canReopenTicket && reopenDeadlineLabel ? (

Prazo para reabrir: {reopenDeadlineLabel}

) : null} {resolvedWithSummary ? ( Chamado vinculado {resolvedWithSummary.reference ? `#${resolvedWithSummary.reference}` : "Ver ticket"} {resolvedWithSummary.subject ? ` · ${resolvedWithSummary.subject}` : ""} ) : null} {isPlaying ? ( {pauseDisabled ? ( Apenas o responsável atual ou um administrador pode pausar o atendimento. ) : null} ) : startDisabled ? ( Apenas o responsável atual ou um administrador pode iniciar este atendimento. ) : ( void handleStartWork("INTERNAL")}>Iniciar (interno) void handleStartWork("EXTERNAL")}>Iniciar (externo) )}
{editing ? (
setSubject(e.target.value)} className="h-10 rounded-lg border border-slate-300 text-lg font-semibold text-neutral-900" />