diff --git a/src/components/admin/machines/admin-machines-overview.tsx b/src/components/admin/machines/admin-machines-overview.tsx index 71773c0..79b1b66 100644 --- a/src/components/admin/machines/admin-machines-overview.tsx +++ b/src/components/admin/machines/admin-machines-overview.tsx @@ -59,6 +59,8 @@ import Link from "next/link" import { useRouter } from "next/navigation" import { useAuth } from "@/lib/auth-client" import type { Id } from "@/convex/_generated/dataModel" +import { TicketStatusBadge } from "@/components/tickets/status-badge" +import type { TicketPriority, TicketStatus } from "@/lib/schemas/ticket" type MachineMetrics = Record | null @@ -92,8 +94,8 @@ type MachineTicketSummary = { id: string reference: number subject: string - status: string - priority: string + status: TicketStatus + priority: TicketPriority updatedAt: number createdAt: number machine: { id: string | null; hostname: string | null } | null @@ -870,11 +872,24 @@ const statusLabels: Record = { unknown: "Desconhecida", } -const TICKET_STATUS_LABELS: Record = { - PENDING: "Pendente", - AWAITING_ATTENDANCE: "Em andamento", - PAUSED: "Pausado", - RESOLVED: "Resolvido", +const TICKET_PRIORITY_META: Record = { + LOW: { label: "Baixa", badgeClass: "border border-slate-200 bg-slate-100 text-slate-600" }, + MEDIUM: { label: "Média", badgeClass: "border border-sky-200 bg-sky-100 text-sky-700" }, + HIGH: { label: "Alta", badgeClass: "border border-amber-200 bg-amber-50 text-amber-700" }, + URGENT: { label: "Urgente", badgeClass: "border border-rose-200 bg-rose-50 text-rose-700" }, +} + +function getTicketPriorityMeta(priority: TicketPriority | string | null | undefined) { + if (!priority) { + return { label: "Sem prioridade", badgeClass: "border border-slate-200 bg-slate-100 text-neutral-600" } + } + const normalized = priority.toUpperCase() + return ( + TICKET_PRIORITY_META[normalized] ?? { + label: priority.charAt(0).toUpperCase() + priority.slice(1).toLowerCase(), + badgeClass: "border border-slate-200 bg-slate-100 text-neutral-600", + } + ) } const statusClasses: Record = { @@ -2347,29 +2362,32 @@ export function MachineDetails({ machine }: MachineDetailsProps) {

Nenhum chamado em aberto registrado diretamente por esta máquina.

) : (
    - {machineTickets.map((ticket) => ( -
  • -
    -

    - #{ticket.reference} · {ticket.subject} -

    -

    - Atualizado {formatRelativeTime(new Date(ticket.updatedAt))} -

    -
    -
    - - {ticket.priority} - - - {TICKET_STATUS_LABELS[ticket.status] ?? ticket.status} - -
    -
  • - ))} + {machineTickets.map((ticket) => { + const priorityMeta = getTicketPriorityMeta(ticket.priority) + return ( +
  • + +
    +

    + #{ticket.reference} · {ticket.subject} +

    +

    + Atualizado {formatRelativeTime(new Date(ticket.updatedAt))} +

    +
    +
    + + {priorityMeta.label} + + +
    + +
  • + ) + })}
)} diff --git a/src/components/tickets/close-ticket-dialog.tsx b/src/components/tickets/close-ticket-dialog.tsx index 2ae6d4f..db40780 100644 --- a/src/components/tickets/close-ticket-dialog.tsx +++ b/src/components/tickets/close-ticket-dialog.tsx @@ -9,6 +9,10 @@ import { Button } from "@/components/ui/button" import { Spinner } from "@/components/ui/spinner" import { RichTextEditor, sanitizeEditorHtml, stripLeadingEmptyParagraphs } from "@/components/ui/rich-text-editor" import { toast } from "sonner" +import { Checkbox } from "@/components/ui/checkbox" +import { Label } from "@/components/ui/label" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" type ClosingTemplate = { id: string; title: string; body: string } @@ -17,6 +21,23 @@ const DEFAULT_COMPANY_NAME = "Rever Tecnologia" const sanitizeTemplate = (html: string) => stripLeadingEmptyParagraphs(sanitizeEditorHtml(html.trim())) +export type AdjustWorkSummaryResult = { + ticketId: Id<"tickets"> + totalWorkedMs: number + internalWorkedMs: number + externalWorkedMs: number + serverNow?: number + perAgentTotals?: Array<{ + agentId: string + agentName: string | null + agentEmail: string | null + avatarUrl: string | null + totalWorkedMs: number + internalWorkedMs: number + externalWorkedMs: number + }> +} + const DEFAULT_CLOSING_TEMPLATES: ClosingTemplate[] = [ { id: "default-standard", @@ -63,6 +84,21 @@ function applyTemplatePlaceholders(html: string, customerName?: string | null, a .replace(/{{\s*(empresa|company|companhia)\s*}}/gi, DEFAULT_COMPANY_NAME) } +const splitDuration = (ms: number) => { + const safeMs = Number.isFinite(ms) && ms > 0 ? ms : 0 + const totalMinutes = Math.round(safeMs / 60000) + const hours = Math.floor(totalMinutes / 60) + const minutes = totalMinutes % 60 + return { hours, minutes } +} + +const formatDurationLabel = (ms: number) => { + const { hours, minutes } = splitDuration(ms) + if (hours > 0 && minutes > 0) return `${hours}h ${minutes}min` + if (hours > 0) return `${hours}h` + return `${minutes}min` +} + export function CloseTicketDialog({ open, onOpenChange, @@ -72,6 +108,9 @@ export function CloseTicketDialog({ requesterName, agentName, onSuccess, + workSummary, + onWorkSummaryAdjusted, + canAdjustTime = false, }: { open: boolean onOpenChange: (open: boolean) => void @@ -81,9 +120,17 @@ export function CloseTicketDialog({ requesterName?: string | null agentName?: string | null onSuccess: () => void + workSummary?: { + totalWorkedMs: number + internalWorkedMs: number + externalWorkedMs: number + } | null + onWorkSummaryAdjusted?: (result: AdjustWorkSummaryResult) => void + canAdjustTime?: boolean }) { const updateStatus = useMutation(api.tickets.updateStatus) const addComment = useMutation(api.tickets.addComment) + const adjustWorkSummary = useMutation(api.tickets.adjustWorkSummary) const closingTemplates = useQuery( actorId && open ? api.commentTemplates.list : "skip", @@ -101,6 +148,13 @@ export function CloseTicketDialog({ const [selectedTemplateId, setSelectedTemplateId] = useState(null) const [message, setMessage] = useState("") const [isSubmitting, setIsSubmitting] = useState(false) + const [shouldAdjustTime, setShouldAdjustTime] = useState(false) + const [internalHours, setInternalHours] = useState("0") + const [internalMinutes, setInternalMinutes] = useState("0") + const [externalHours, setExternalHours] = useState("0") + const [externalMinutes, setExternalMinutes] = useState("0") + const [adjustReason, setAdjustReason] = useState("") + const enableAdjustment = Boolean(canAdjustTime && workSummary) const hydrateTemplateBody = useCallback((templateHtml: string) => { const withPlaceholders = applyTemplatePlaceholders(templateHtml, requesterName, agentName) @@ -108,12 +162,20 @@ export function CloseTicketDialog({ }, [requesterName, agentName]) useEffect(() => { - if (!open) { - setSelectedTemplateId(null) - setMessage("") - setIsSubmitting(false) - return - } + if (open) return + setSelectedTemplateId(null) + setMessage("") + setIsSubmitting(false) + setShouldAdjustTime(false) + setAdjustReason("") + setInternalHours("0") + setInternalMinutes("0") + setExternalHours("0") + setExternalMinutes("0") + }, [open]) + + useEffect(() => { + if (!open) return if (templates.length > 0 && !selectedTemplateId && !message) { const first = templates[0] const hydrated = hydrateTemplateBody(first.body) @@ -122,6 +184,28 @@ export function CloseTicketDialog({ } }, [open, templates, selectedTemplateId, message, hydrateTemplateBody]) + useEffect(() => { + if (!open || !enableAdjustment || !shouldAdjustTime) return + const internal = splitDuration(workSummary?.internalWorkedMs ?? 0) + const external = splitDuration(workSummary?.externalWorkedMs ?? 0) + setInternalHours(internal.hours.toString()) + setInternalMinutes(internal.minutes.toString()) + setExternalHours(external.hours.toString()) + setExternalMinutes(external.minutes.toString()) + }, [ + open, + enableAdjustment, + shouldAdjustTime, + workSummary?.internalWorkedMs, + workSummary?.externalWorkedMs, + ]) + + useEffect(() => { + if (!shouldAdjustTime) { + setAdjustReason("") + } + }, [shouldAdjustTime]) + const handleTemplateSelect = (template: ClosingTemplate) => { setSelectedTemplateId(template.id) setMessage(hydrateTemplateBody(template.body)) @@ -132,9 +216,65 @@ export function CloseTicketDialog({ toast.error("É necessário estar autenticado para encerrar o ticket.") return } + + const applyAdjustment = enableAdjustment && shouldAdjustTime + let targetInternalMs = 0 + let targetExternalMs = 0 + let trimmedReason = "" + + if (applyAdjustment) { + const parsePart = (value: string, label: string) => { + const trimmed = value.trim() + if (trimmed.length === 0) return 0 + if (!/^\d+$/u.test(trimmed)) { + toast.error(`Informe um número válido para ${label}.`) + return null + } + return Number.parseInt(trimmed, 10) + } + + const internalHoursValue = parsePart(internalHours, "horas internas") + if (internalHoursValue === null) return + const internalMinutesValue = parsePart(internalMinutes, "minutos internos") + if (internalMinutesValue === null) return + if (internalMinutesValue >= 60) { + toast.error("Os minutos internos devem estar entre 0 e 59.") + return + } + + const externalHoursValue = parsePart(externalHours, "horas externas") + if (externalHoursValue === null) return + const externalMinutesValue = parsePart(externalMinutes, "minutos externos") + if (externalMinutesValue === null) return + if (externalMinutesValue >= 60) { + toast.error("Os minutos externos devem estar entre 0 e 59.") + return + } + + targetInternalMs = (internalHoursValue * 60 + internalMinutesValue) * 60000 + targetExternalMs = (externalHoursValue * 60 + externalMinutesValue) * 60000 + trimmedReason = adjustReason.trim() + if (trimmedReason.length < 5) { + toast.error("Descreva o motivo do ajuste (mínimo de 5 caracteres).") + return + } + } + + toast.dismiss("close-ticket") setIsSubmitting(true) - toast.loading("Encerrando ticket...", { id: "close-ticket" }) + toast.loading(applyAdjustment ? "Ajustando tempo e encerrando ticket..." : "Encerrando ticket...", { id: "close-ticket" }) try { + if (applyAdjustment) { + const result = (await adjustWorkSummary({ + ticketId: ticketId as unknown as Id<"tickets">, + actorId, + internalWorkedMs: targetInternalMs, + externalWorkedMs: targetExternalMs, + reason: trimmedReason, + })) as AdjustWorkSummaryResult + onWorkSummaryAdjusted?.(result) + } + await updateStatus({ ticketId: ticketId as unknown as Id<"tickets">, status: "RESOLVED", actorId }) const withPlaceholders = applyTemplatePlaceholders(message, requesterName, agentName) const sanitized = stripLeadingEmptyParagraphs(sanitizeEditorHtml(withPlaceholders)) @@ -153,7 +293,9 @@ export function CloseTicketDialog({ onSuccess() } catch (error) { console.error(error) - toast.error("Não foi possível encerrar o ticket.", { id: "close-ticket" }) + toast.error(applyAdjustment ? "Não foi possível ajustar o tempo ou encerrar o ticket." : "Não foi possível encerrar o ticket.", { + id: "close-ticket", + }) } finally { setIsSubmitting(false) } @@ -205,6 +347,129 @@ export function CloseTicketDialog({

Você pode editar o conteúdo antes de enviar. Deixe em branco para encerrar sem comentário adicional.

+ {enableAdjustment ? ( +
+
+
+

Ajustar tempo antes de encerrar

+

+ Atualize o tempo registrado e informe um motivo para o ajuste. O comentário fica visível apenas para a equipe. +

+
+
+ setShouldAdjustTime(Boolean(checked))} + disabled={isSubmitting} + /> + +
+
+ {shouldAdjustTime ? ( +
+
+
+

Tempo interno

+
+
+ + setInternalHours(event.target.value)} + disabled={isSubmitting} + /> +
+
+ + setInternalMinutes(event.target.value)} + disabled={isSubmitting} + /> +
+
+

+ Atual: {formatDurationLabel(workSummary?.internalWorkedMs ?? 0)} +

+
+
+

Tempo externo

+
+
+ + setExternalHours(event.target.value)} + disabled={isSubmitting} + /> +
+
+ + setExternalMinutes(event.target.value)} + disabled={isSubmitting} + /> +
+
+

+ Atual: {formatDurationLabel(workSummary?.externalWorkedMs ?? 0)} +

+
+
+
+ +