-
+
+
+ {canReopenTicket ? (
+
+ {isReopening ? (
+
+ ) : (
+
+ )}
+ Reabrir chamado
+
+ ) : null}
+
+ {canReopenTicket && reopenDeadlineLabel ? (
+
Prazo para reabrir: {reopenDeadlineLabel}
+ ) : null}
{!isCustomer ? (
{priorityLabel[ticket.priority]}
diff --git a/src/components/reports/machine-category-report.tsx b/src/components/reports/machine-category-report.tsx
new file mode 100644
index 0000000..e5d5e97
--- /dev/null
+++ b/src/components/reports/machine-category-report.tsx
@@ -0,0 +1,291 @@
+"use client"
+
+import { useMemo, useState } from "react"
+import { useQuery } from "convex/react"
+import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"
+
+import { api } from "@/convex/_generated/api"
+import type { Id } from "@/convex/_generated/dataModel"
+import { useAuth } from "@/lib/auth-client"
+import { DEFAULT_TENANT_ID } from "@/lib/constants"
+import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { ReportsFilterToolbar } from "@/components/reports/report-filter-toolbar"
+import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
+import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
+import { formatDateDM } from "@/lib/utils"
+import { Skeleton } from "@/components/ui/skeleton"
+
+type MachineCategoryDailyItem = {
+ date: string
+ machineId: string | null
+ machineHostname: string | null
+ companyId: string | null
+ companyName: string | null
+ categoryId: string | null
+ categoryName: string
+ total: number
+}
+
+type MachineCategoryReportData = {
+ rangeDays: number
+ items: MachineCategoryDailyItem[]
+}
+
+export function MachineCategoryReport() {
+ const [timeRange, setTimeRange] = useState("30d")
+ const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
+ const { session, convexUserId, isStaff } = useAuth()
+ const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
+
+ const enabled = Boolean(isStaff && convexUserId)
+
+ const data = useQuery(
+ api.reports.ticketsByMachineAndCategory,
+ enabled
+ ? ({
+ tenantId,
+ viewerId: convexUserId as Id<"users">,
+ range: timeRange,
+ companyId: companyId === "all" ? undefined : (companyId as Id<"companies">),
+ } as const)
+ : "skip"
+ ) as MachineCategoryReportData | undefined
+
+ const companies = useQuery(
+ api.companies.list,
+ enabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
+ ) as Array<{ id: Id<"companies">; name: string }> | undefined
+
+ const companyOptions = useMemo(() => {
+ const base: SearchableComboboxOption[] = [{ value: "all", label: "Todas as empresas" }]
+ if (!companies || companies.length === 0) {
+ return base
+ }
+ const sorted = [...companies].sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
+ return [
+ base[0],
+ ...sorted.map((company) => ({
+ value: company.id,
+ label: company.name,
+ })),
+ ]
+ }, [companies])
+
+ const items = useMemo(() => data?.items ?? [], [data])
+
+ const totals = useMemo(
+ () =>
+ items.reduce(
+ (acc, item) => {
+ acc.totalTickets += item.total
+ acc.machines.add(item.machineId ?? item.machineHostname ?? "sem-maquina")
+ acc.categories.add(item.categoryName)
+ return acc
+ },
+ {
+ totalTickets: 0,
+ machines: new Set(),
+ categories: new Set(),
+ }
+ ),
+ [items]
+ )
+
+ const dailySeries = useMemo(
+ () => {
+ const map = new Map()
+ for (const item of items) {
+ const current = map.get(item.date) ?? 0
+ map.set(item.date, current + item.total)
+ }
+ return Array.from(map.entries())
+ .map(([date, total]) => ({ date, total }))
+ .sort((a, b) => a.date.localeCompare(b.date))
+ },
+ [items]
+ )
+
+ const tableRows = useMemo(
+ () =>
+ [...items].sort((a, b) => {
+ if (a.date !== b.date) return b.date.localeCompare(a.date)
+ const machineA = (a.machineHostname ?? "").toLowerCase()
+ const machineB = (b.machineHostname ?? "").toLowerCase()
+ if (machineA !== machineB) return machineA.localeCompare(machineB)
+ return a.categoryName.localeCompare(b.categoryName, "pt-BR")
+ }),
+ [items]
+ )
+
+ if (!enabled) {
+ return (
+
+
+ Máquinas x categorias
+
+ Este relatório está disponível apenas para a equipe interna.
+
+
+
+ )
+ }
+
+ if (!data) {
+ return (
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
setCompanyId(value)}
+ companyOptions={companyOptions}
+ timeRange={timeRange as "90d" | "30d" | "7d"}
+ onTimeRangeChange={(value) => setTimeRange(value)}
+ />
+
+
+
+
+
+ Chamados analisados
+
+
+ Total de tickets com máquina vinculada no período.
+
+
+
+ {totals.totalTickets}
+
+
+
+
+
+ Máquinas únicas
+
+
+ Quantidade de dispositivos diferentes com chamados no período.
+
+
+
+ {totals.machines.size}
+
+
+
+
+
+ Categorias
+
+
+ Categorias distintas associadas aos tickets dessas máquinas.
+
+
+
+ {totals.categories.size}
+
+
+
+
+
+
+
+ Volume diário por máquina (total)
+
+
+ Quantidade de chamados com máquina vinculada, somando todas as categorias, por dia.
+
+
+
+ {dailySeries.length === 0 ? (
+
+ Nenhum ticket com máquina vinculada foi encontrado para o período selecionado.
+
+ ) : (
+
+
+
+ formatDateDM(new Date(String(value)))}
+ />
+ formatDateDM(new Date(String(value)))}
+ />
+ }
+ />
+
+
+
+ )}
+
+
+
+
+
+
+ Detalhamento diário por máquina e categoria
+
+
+ Cada linha representa o total de chamados abertos em uma data específica, agrupados por
+ máquina e categoria.
+
+
+
+ {tableRows.length === 0 ? (
+
+ Nenhum ticket com máquina vinculada foi encontrado para o período selecionado.
+
+ ) : (
+
+
+
+
+ Data
+ Máquina
+ Empresa
+ Categoria
+ Chamados
+
+
+
+ {tableRows.map((row, index) => {
+ const machineLabel =
+ row.machineHostname && row.machineHostname.trim().length > 0
+ ? row.machineHostname
+ : "Sem hostname"
+ const companyLabel = row.companyName ?? "Sem empresa"
+ return (
+
+
+ {formatDateDM(new Date(`${row.date}T00:00:00Z`))}
+
+ {machineLabel}
+ {companyLabel}
+ {row.categoryName}
+ {row.total}
+
+ )
+ })}
+
+
+
+ )}
+
+
+
+ )
+}
diff --git a/src/components/reports/sla-report.tsx b/src/components/reports/sla-report.tsx
index 0b810e2..83325fb 100644
--- a/src/components/reports/sla-report.tsx
+++ b/src/components/reports/sla-report.tsx
@@ -378,7 +378,8 @@ export function SlaReport() {
Produtividade por agente
- Chamados resolvidos no período por agente (top 10) e horas trabalhadas.
+ Atendimentos e tempo gasto por agente no período selecionado
+ (7, 30 ou 90 dias), com filtro por empresa.
@@ -405,12 +406,42 @@ export function SlaReport() {
-
Horas trabalhadas (estimado)
+
Atendimentos, tempos médios e horas trabalhadas
{agents.items.slice(0, 10).map((a) => (
- {a.name || a.email || 'Agente'}
- {formatHoursCompact(a.workedHours)}
+
+
+ {a.name || a.email || "Agente"}
+
+ {formatHoursCompact(a.workedHours)} h trabalhadas
+
+
+
+
+ Em atendimento:{" "}
+ {a.open}
+
+
+ Resolvidos:{" "}
+ {a.resolved}
+
+
+
+
+ 1ª resposta média:{" "}
+
+ {formatMinutes(a.avgFirstResponseMinutes)}
+
+
+
+ Resolução média:{" "}
+
+ {formatMinutes(a.avgResolutionMinutes)}
+
+
+
+
))}
diff --git a/src/components/tickets/new-ticket-dialog.tsx b/src/components/tickets/new-ticket-dialog.tsx
index fec4ffd..26a0123 100644
--- a/src/components/tickets/new-ticket-dialog.tsx
+++ b/src/components/tickets/new-ticket-dialog.tsx
@@ -35,6 +35,7 @@ import { priorityStyles } from "@/lib/ticket-priority-style"
import { normalizeCustomFieldInputs } from "@/lib/ticket-form-helpers"
import type { TicketFormDefinition, TicketFormFieldDefinition } from "@/lib/ticket-form-types"
import { Calendar as CalendarIcon } from "lucide-react"
+import { VISIT_KEYWORDS } from "@/lib/ticket-matchers"
type TriggerVariant = "button" | "card"
@@ -108,6 +109,7 @@ const schema = z.object({
priority: z.enum(["LOW", "MEDIUM", "HIGH", "URGENT"]).default("MEDIUM"),
channel: z.enum(["EMAIL", "WHATSAPP", "CHAT", "PHONE", "API", "MANUAL"]).default("MANUAL"),
queueName: z.string().nullable().optional(),
+ visitDate: z.string().nullable().optional(),
assigneeId: z.string().nullable().optional(),
companyId: z.string().optional(),
requesterId: z.string().min(1, "Selecione um solicitante"),
@@ -221,6 +223,7 @@ export function NewTicketDialog({
const [selectedFormKey, setSelectedFormKey] = useState
("default")
const [customFieldValues, setCustomFieldValues] = useState>({})
const [openCalendarField, setOpenCalendarField] = useState(null)
+ const [visitDatePickerOpen, setVisitDatePickerOpen] = useState(false)
const selectedForm = useMemo(() => forms.find((formDef) => formDef.key === selectedFormKey) ?? forms[0], [forms, selectedFormKey])
@@ -270,12 +273,29 @@ export function NewTicketDialog({
)
const priorityValue = form.watch("priority") as TicketPriority
const queueValue = form.watch("queueName") ?? "NONE"
+ const visitDateValue = form.watch("visitDate") ?? null
const assigneeValue = form.watch("assigneeId") ?? null
const assigneeSelectValue = assigneeValue ?? "NONE"
const requesterValue = form.watch("requesterId") ?? ""
const categoryIdValue = form.watch("categoryId")
const subcategoryIdValue = form.watch("subcategoryId")
const isSubmitted = form.formState.isSubmitted
+
+ const normalizedQueueName =
+ typeof queueValue === "string" && queueValue !== "NONE" ? queueValue.toLowerCase() : ""
+ const isVisitQueue = useMemo(
+ () => VISIT_KEYWORDS.some((keyword) => normalizedQueueName.includes(keyword)),
+ [normalizedQueueName]
+ )
+ const visitDate = useMemo(() => {
+ if (!visitDateValue) return null
+ try {
+ return parseISO(visitDateValue)
+ } catch {
+ return null
+ }
+ }, [visitDateValue])
+
const companyOptions = useMemo(() => {
const map = new Map()
companies.forEach((company) => {
@@ -429,6 +449,7 @@ export function NewTicketDialog({
if (!open) {
setAssigneeInitialized(false)
setOpenCalendarField(null)
+ setVisitDatePickerOpen(false)
return
}
if (assigneeInitialized) return
@@ -490,6 +511,17 @@ export function NewTicketDialog({
return
}
+ const currentQueueName = values.queueName ?? ""
+ const isVisitQueueOnSubmit =
+ typeof currentQueueName === "string" &&
+ VISIT_KEYWORDS.some((keyword) => currentQueueName.toLowerCase().includes(keyword))
+ if (isVisitQueueOnSubmit) {
+ if (!values.visitDate) {
+ form.setError("visitDate", { type: "custom", message: "Informe a data da visita para chamados desta fila." })
+ return
+ }
+ }
+
let customFieldsPayload: Array<{ fieldId: Id<"ticketFields">; value: unknown }> = []
if (selectedFormKey !== "default" && selectedForm?.fields?.length) {
const normalized = normalizeCustomFieldInputs(selectedForm.fields, customFieldValues)
@@ -506,6 +538,15 @@ export function NewTicketDialog({
const sel = queues.find((q) => q.name === values.queueName)
const selectedAssignee = form.getValues("assigneeId") ?? null
const requesterToSend = values.requesterId as Id<"users">
+ let visitDateTimestamp: number | undefined
+ if (isVisitQueueOnSubmit && values.visitDate) {
+ try {
+ const parsed = parseISO(values.visitDate)
+ visitDateTimestamp = parsed.getTime()
+ } catch {
+ visitDateTimestamp = undefined
+ }
+ }
const id = await create({
actorId: convexUserId as Id<"users">,
tenantId: DEFAULT_TENANT_ID,
@@ -520,6 +561,7 @@ export function NewTicketDialog({
subcategoryId: values.subcategoryId as Id<"ticketSubcategories">,
formTemplate: selectedFormKey !== "default" ? selectedFormKey : undefined,
customFields: customFieldsPayload.length > 0 ? customFieldsPayload : undefined,
+ visitDate: visitDateTimestamp,
})
const summaryFallback = values.summary?.trim() ?? ""
const bodyHtml = plainDescription.length > 0 ? sanitizedDescription : summaryFallback
@@ -551,6 +593,7 @@ export function NewTicketDialog({
assigneeId: convexUserId ?? null,
categoryId: "",
subcategoryId: "",
+ visitDate: null,
})
form.clearErrors()
setSelectedFormKey("default")
@@ -919,6 +962,60 @@ export function NewTicketDialog({
+ {isVisitQueue ? (
+
+
+ Data da visita *
+
+
+
+
+ {visitDate
+ ? format(visitDate, "dd/MM/yyyy", { locale: ptBR })
+ : "Selecione a data"}
+
+
+
+
+ {
+ if (!date) {
+ form.setValue("visitDate", null, {
+ shouldDirty: true,
+ shouldTouch: true,
+ shouldValidate: form.formState.isSubmitted,
+ })
+ return
+ }
+ const iso = date.toISOString().slice(0, 10)
+ form.setValue("visitDate", iso, {
+ shouldDirty: true,
+ shouldTouch: true,
+ shouldValidate: form.formState.isSubmitted,
+ })
+ setVisitDatePickerOpen(false)
+ }}
+ initialFocus
+ />
+
+
+
+
+ ) : null}
Responsável
,
+ includeMetadata: true,
+ } as const)
+ : "skip"
+ ) as Record | null | undefined
+
const isAvulso = Boolean(ticket.company?.isAvulso)
const companyLabel = ticket.company?.name ?? (isAvulso ? "Cliente avulso" : "Sem empresa vinculada")
const responseStatus = getSlaDisplayStatus(ticket, "response")
@@ -133,6 +161,17 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
return chips
}, [companyLabel, isAvulso, ticket.assignee, ticket.formTemplateLabel, ticket.priority, ticket.queue, ticket.status])
+ const remoteAccessEntries = useMemo(() => {
+ if (!deviceRaw) return []
+ const source = (deviceRaw as { remoteAccess?: unknown })?.remoteAccess
+ return normalizeDeviceRemoteAccessList(source)
+ }, [deviceRaw])
+
+ const primaryRemoteAccess = useMemo(
+ () => remoteAccessEntries.find((entry) => isRustDeskAccess(entry)) ?? null,
+ [remoteAccessEntries]
+ )
+
const agentTotals = useMemo(() => {
const totals = ticket.workSummary?.perAgentTotals ?? []
return totals
@@ -146,6 +185,29 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
.sort((a, b) => b.totalWorkedMs - a.totalWorkedMs)
}, [ticket.workSummary?.perAgentTotals])
+ const handleRemoteConnect = useCallback(() => {
+ if (!primaryRemoteAccess) {
+ toast.error("Nenhum acesso remoto RustDesk cadastrado para esta máquina.")
+ return
+ }
+ const link = buildRustDeskUri(primaryRemoteAccess)
+ if (!link) {
+ toast.error("Não foi possível montar o link do RustDesk (ID ou senha ausentes).")
+ return
+ }
+ if (typeof window === "undefined") {
+ toast.error("A conexão direta só funciona no navegador.")
+ return
+ }
+ try {
+ window.location.href = link
+ toast.success("Abrindo o RustDesk...")
+ } catch (error) {
+ console.error(error)
+ toast.error("Não foi possível acionar o RustDesk neste dispositivo.")
+ }
+ }, [primaryRemoteAccess])
+
return (
@@ -173,6 +235,41 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
+ {isStaff && machineId ? (
+
SLA & métricas
diff --git a/src/components/tickets/tickets-board.tsx b/src/components/tickets/tickets-board.tsx
index 5ba36b7..137f299 100644
--- a/src/components/tickets/tickets-board.tsx
+++ b/src/components/tickets/tickets-board.tsx
@@ -1,6 +1,6 @@
"use client"
-import { useEffect, useMemo, useState } from "react"
+import { useEffect, useMemo, useRef, useState } from "react"
import Link from "next/link"
import { formatDistanceStrict } from "date-fns"
import { ptBR } from "date-fns/locale"
@@ -14,6 +14,7 @@ import { cn } from "@/lib/utils"
import { PrioritySelect } from "@/components/tickets/priority-select"
import { getTicketStatusChipClass, getTicketStatusLabel } from "@/lib/ticket-status-style"
import { EmptyIndicator } from "@/components/ui/empty-indicator"
+import { deriveServerOffset, toServerTimestamp } from "@/components/tickets/ticket-timer.utils"
type TicketsBoardProps = {
tickets: Ticket[]
@@ -70,8 +71,24 @@ function formatQueueLabel(queue?: string | null) {
return { label: queue, title: queue }
}
+function formatDuration(ms?: number | null) {
+ if (!ms || ms <= 0) return "—"
+ 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 TicketsBoard({ tickets, enteringIds }: TicketsBoardProps) {
const [now, setNow] = useState(() => Date.now())
+ const serverOffsetRef = useRef
(0)
const ticketTimestamps = useMemo(() => {
return tickets
@@ -97,6 +114,31 @@ export function TicketsBoard({ tickets, enteringIds }: TicketsBoardProps) {
return () => window.clearTimeout(timeoutId)
}, [ticketTimestamps, now])
+ useEffect(() => {
+ const candidates = tickets
+ .map((ticket) =>
+ typeof ticket.workSummary?.serverNow === "number" ? ticket.workSummary.serverNow : null
+ )
+ .filter((value): value is number => value !== null)
+ if (candidates.length === 0) return
+ const latestServerNow = candidates[candidates.length - 1]
+ serverOffsetRef.current = deriveServerOffset({
+ currentOffset: serverOffsetRef.current,
+ localNow: Date.now(),
+ serverNow: latestServerNow,
+ })
+ }, [tickets])
+
+ const getWorkedMs = (ticket: Ticket) => {
+ const base = ticket.workSummary?.totalWorkedMs ?? 0
+ const activeStart = ticket.workSummary?.activeSession?.startedAt
+ if (activeStart instanceof Date) {
+ const alignedNow = toServerTimestamp(now, serverOffsetRef.current)
+ return base + Math.max(0, alignedNow - activeStart.getTime())
+ }
+ return base
+ }
+
if (!tickets.length) {
return (
@@ -233,7 +275,7 @@ export function TicketsBoard({ tickets, enteringIds }: TicketsBoardProps) {
-
+
Categoria:{" "}
{ticket.category?.name ? (
@@ -246,6 +288,15 @@ export function TicketsBoard({ tickets, enteringIds }: TicketsBoardProps) {
/>
)}
+
+ Tempo:{" "}
+
+ {formatDuration(getWorkedMs(ticket))}
+
+ {ticket.workSummary?.activeSession ? (
+ Em andamento
+ ) : null}
+
diff --git a/src/lib/agenda-utils.ts b/src/lib/agenda-utils.ts
index f65c82f..563f0ff 100644
--- a/src/lib/agenda-utils.ts
+++ b/src/lib/agenda-utils.ts
@@ -198,6 +198,12 @@ function computeRange(period: AgendaPeriod, pivot: Date) {
}
function deriveScheduleWindow(ticket: Ticket) {
+ if (isVisitTicket(ticket) && ticket.dueAt) {
+ const startAt = ticket.dueAt
+ const endAt = addMinutes(startAt, DEFAULT_EVENT_DURATION_MINUTES)
+ return { startAt, endAt }
+ }
+
const due = getSlaDueDate(ticket, "solution")
if (!due) {
return { startAt: null, endAt: null }