Melhora chat ao vivo com anexos e eventos de timeline

- Reestrutura visual do widget de chat (header branco, status emerald)
- Adiciona sistema de anexos com upload e drag-and-drop
- Substitui select nativo por componente Select do shadcn
- Adiciona eventos LIVE_CHAT_STARTED e LIVE_CHAT_ENDED na timeline
- Traduz labels de chat para portugues (Chat iniciado/finalizado)
- Filtra CHAT_MESSAGE_ADDED da timeline (apenas inicio/fim aparecem)
- Restringe inicio de chat a tickets com responsavel atribuido
- Exibe duracao da sessao ao finalizar chat

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
esdrasrenan 2025-12-07 02:20:11 -03:00
parent 9e676b06f9
commit 3b1cde79df
11 changed files with 782 additions and 77 deletions

View file

@ -52,6 +52,9 @@ export type MachineInventoryRecord = {
remoteAccess?: unknown
linkedUsers?: LinkedUser[]
customFields?: DeviceCustomField[]
usbPolicy?: string | null
usbPolicyStatus?: string | null
ticketCount?: number | null
}
type WorkbookOptions = {
@ -147,6 +150,9 @@ const COLUMN_VALUE_RESOLVERS: Record<string, (machine: MachineInventoryRecord, d
fleetUpdatedAt: (_machine, derived) =>
derived.fleetInfo?.updatedAt ? formatDateTime(derived.fleetInfo.updatedAt) : null,
managementMode: (machine) => describeManagementMode(machine.managementMode),
usbPolicy: (machine) => describeUsbPolicy(machine.usbPolicy),
usbPolicyStatus: (machine) => describeUsbPolicyStatus(machine.usbPolicyStatus),
ticketCount: (machine) => machine.ticketCount ?? 0,
}
const DEFAULT_COLUMN_CONFIG: DeviceInventoryColumnConfig[] = DEVICE_INVENTORY_COLUMN_METADATA.filter((meta) => meta.default !== false).map(
@ -336,6 +342,38 @@ function describeManagementMode(mode: string | null | undefined): string {
}
}
function describeUsbPolicy(policy: string | null | undefined): string {
if (!policy) return "—"
const normalized = policy.toUpperCase()
switch (normalized) {
case "ALLOW":
return "Permitido"
case "BLOCK_ALL":
return "Bloqueado"
case "READONLY":
return "Somente leitura"
default:
return policy
}
}
function describeUsbPolicyStatus(status: string | null | undefined): string {
if (!status) return "—"
const normalized = status.toUpperCase()
switch (normalized) {
case "PENDING":
return "Pendente"
case "APPLYING":
return "Aplicando"
case "APPLIED":
return "Aplicado"
case "FAILED":
return "Falhou"
default:
return status
}
}
const SOFTWARE_HEADERS = ["Hostname", "Aplicativo", "Versão", "Origem", "Publicador", "Instalado em"] as const
const SOFTWARE_COLUMN_WIDTHS = [22, 36, 18, 18, 22, 20] as const

View file

@ -359,6 +359,22 @@ function buildTimelineMessage(type: string, payload: Record<string, unknown> | n
return "CSAT recebido"
case "CSAT_RATED":
return "CSAT avaliado"
case "LIVE_CHAT_STARTED": {
const agentName = p.agentName as string | undefined
const machineHostname = p.machineHostname as string | undefined
const parts: string[] = ["Chat ao vivo iniciado"]
if (agentName) parts[0] += ` por ${agentName}`
if (machineHostname) parts.push(`Máquina: ${machineHostname}`)
return parts.join(" • ")
}
case "LIVE_CHAT_ENDED": {
const agentName = p.agentName as string | undefined
const durationMs = p.durationMs as number | undefined
const parts: string[] = ["Chat ao vivo finalizado"]
if (agentName) parts[0] += ` por ${agentName}`
if (durationMs) parts.push(`Duração: ${formatDurationMs(durationMs)}`)
return parts.join(" • ")
}
default:
return null
}
@ -397,7 +413,11 @@ function TicketPdfDocument({ ticket, logoDataUrl }: { ticket: TicketWithDetails;
safeBody: sanitizeToPlainText(comment.body) || "Sem texto",
})) as Array<TicketComment & { safeBody: string }>
// Tipos de eventos que não devem aparecer no PDF
const HIDDEN_EVENT_TYPES = ["CHAT_MESSAGE_ADDED"]
const timeline = [...ticket.timeline]
.filter((event) => !HIDDEN_EVENT_TYPES.includes(event.type))
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
.map((event) => {
const label = TICKET_TIMELINE_LABELS[event.type] ?? event.type