Ver Fonte

adiciona os comentarios sobre clientes cancelados nas avaliações

leonardo há 1 mês atrás
pai
commit
a8971f3dd7

+ 0 - 21
dist/index.html

@@ -1,21 +0,0 @@
-<!doctype html>
-<html lang="pt-BR">
-  <head>
-    <meta charset="UTF-8" />
-    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
-    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
-    <link rel="icon" type="image/x-icon" href="/favicon.ico?v=2" />
-    <title>ORÁCULO</title>
-    <script type="module" crossorigin src="/assets/index-hPlHUsA-.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DI3YNLSf.css">
-  </head>
-  <body class="bg-background">
-    <noscript>
-      <strong
-        >We're sorry but this app doesn't work properly without JavaScript enabled. Please enable it to
-        continue.</strong>
-
-    </noscript>
-    <div id="app" class="g-sidenav-show">Carregando…</div>
-  </body>
-</html>

+ 3 - 1
src/api/atendimentos.js

@@ -27,13 +27,15 @@ export function avaliarAtendimento(id) {
   return apiFetch(`/api/atendimentos/${id}/avaliar`, { method: "POST", body: {} });
 }
 
-export function listarAvaliacoes({ page = 1, pageSize = 20, setor, resolvido, sentimento, scoreMax, busca } = {}) {
+export function listarAvaliacoes({ page = 1, pageSize = 20, setor, resolvido, sentimento, statusCancelamento, scoreMax, busca, ordenacao } = {}) {
   const query = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
   if (setor) query.set("setor", setor);
   if (resolvido) query.set("resolvido", resolvido);
   if (sentimento) query.set("sentimento", sentimento);
+  if (statusCancelamento) query.set("statusCancelamento", statusCancelamento);
   if (scoreMax !== undefined) query.set("scoreMax", String(scoreMax));
   if (busca) query.set("busca", busca);
+  if (ordenacao === "asc") query.set("ordenacao", ordenacao);
   return apiFetch(`/api/atendimentos/avaliacoes?${query.toString()}`);
 }
 

+ 12 - 1
src/api/conversas.js

@@ -1,4 +1,4 @@
-import { apiFetch } from "./client.js";
+import { apiFetch, apiBaseUrl, buildAuthHeaders } from "./client.js";
 
 export function listarConversas() {
   return apiFetch("/api/conversas");
@@ -16,3 +16,14 @@ export function responderConversa(id, texto) {
 export function excluirConversa(id) {
   return apiFetch(`/api/conversas/${id}`, { method: "DELETE" });
 }
+
+// a rota de mídia exige Bearer token, então <img>/<audio>/<video> não podem apontar
+// direto pra ela — busca o arquivo autenticado e devolve um blob URL local
+export async function buscarMidiaBlob(conversaId, mensagemId) {
+  const res = await fetch(`${apiBaseUrl}/api/conversas/${conversaId}/mensagens/${mensagemId}/midia`, {
+    headers: buildAuthHeaders()
+  });
+  if (!res.ok) throw new Error(`http_error:${res.status}`);
+  const blob = await res.blob();
+  return URL.createObjectURL(blob);
+}

+ 33 - 2
src/composables/useConversas.js

@@ -1,5 +1,5 @@
 import { ref } from "vue";
-import { listarConversas, listarMensagensConversa, responderConversa, excluirConversa as excluirConversaApi } from "../api/conversas.js";
+import { listarConversas, listarMensagensConversa, responderConversa, excluirConversa as excluirConversaApi, buscarMidiaBlob } from "../api/conversas.js";
 
 let singleton;
 
@@ -17,10 +17,36 @@ export function useConversas() {
   const mensagensLoading = ref(false);
   const mensagensError = ref("");
   const enviando = ref(false);
+  const midiaUrls = ref({});
+  const midiaErros = ref({});
 
   let pollTimer = null;
   let ultimoTimestamp = null;
   let buscandoNovasMensagens = false;
+  const midiaEmResolucao = new Set();
+
+  function limparMidiaCache() {
+    for (const url of Object.values(midiaUrls.value)) URL.revokeObjectURL(url);
+    midiaUrls.value = {};
+    midiaErros.value = {};
+    midiaEmResolucao.clear();
+  }
+
+  async function resolverMidia(mensagem) {
+    if (!mensagem?.MidiaPath || midiaUrls.value[mensagem.Id] || midiaEmResolucao.has(mensagem.Id)) return;
+    midiaEmResolucao.add(mensagem.Id);
+    const { [mensagem.Id]: _omitido, ...semErro } = midiaErros.value;
+    midiaErros.value = semErro;
+    try {
+      const url = await buscarMidiaBlob(mensagem.ConversaId, mensagem.Id);
+      midiaUrls.value = { ...midiaUrls.value, [mensagem.Id]: url };
+    } catch (err) {
+      console.warn("[useConversas] falha ao carregar mídia:", err?.message ?? err);
+      midiaErros.value = { ...midiaErros.value, [mensagem.Id]: true };
+    } finally {
+      midiaEmResolucao.delete(mensagem.Id);
+    }
+  }
 
   async function carregarConversas() {
     conversasLoading.value = true;
@@ -50,7 +76,7 @@ export function useConversas() {
       if (results.length > 0) {
         const idsExistentes = new Set(mensagens.value.map((m) => m.Id));
         const novas = results.filter((m) => !idsExistentes.has(m.Id));
-        mensagens.value.push(...novas);
+        mensagens.value = [...mensagens.value, ...novas];
         ultimoTimestamp = results[results.length - 1].Timestamp;
       }
       await carregarConversas();
@@ -65,6 +91,7 @@ export function useConversas() {
     pararPolling();
     conversaAtivaId.value = id;
     mensagens.value = [];
+    limparMidiaCache();
     ultimoTimestamp = null;
     mensagensLoading.value = true;
     mensagensError.value = "";
@@ -86,6 +113,7 @@ export function useConversas() {
     pararPolling();
     conversaAtivaId.value = null;
     mensagens.value = [];
+    limparMidiaCache();
     ultimoTimestamp = null;
   }
 
@@ -117,6 +145,9 @@ export function useConversas() {
     mensagensLoading,
     mensagensError,
     enviando,
+    midiaUrls,
+    midiaErros,
+    resolverMidia,
     carregarConversas,
     abrirConversa,
     fecharConversa,

+ 2 - 0
src/utils/avaliacoes.js

@@ -11,3 +11,5 @@ export const resolvidoVariant = { sim: "success", parcial: "warning", nao: "dang
 export const resolvidoLabel = { sim: "Resolvido", parcial: "Parcial", nao: "Não resolvido", indefinido: "Indefinido" };
 export const sentimentoVariant = { positivo: "success", neutro: "default", negativo: "danger", indefinido: "default" };
 export const sentimentoLabel = { positivo: "Positivo", neutro: "Neutro", negativo: "Negativo", indefinido: "Indefinido" };
+export const cancelamentoVariant = { cancelou: "danger", ameacou: "warning" };
+export const cancelamentoLabel = { cancelou: "Cancelou", ameacou: "Quer cancelar" };

+ 21 - 0
src/utils/midia.js

@@ -0,0 +1,21 @@
+const TIPOS_AUDIO = new Set(["mpga", "oga", "mp3", "audio", "ptt"]);
+const TIPOS_IMAGEM = new Set(["jpg", "jpeg", "png", "webp", "gif", "image", "sticker", "imagem"]);
+const TIPOS_VIDEO = new Set(["mp4", "webm", "mov", "3gp", "video", "m4v"]);
+
+export function ehAudio({ tipo, transcricao, url } = {}) {
+  return Boolean(transcricao) || TIPOS_AUDIO.has(tipo);
+}
+
+// arquivos de áudio às vezes vêm com extensão enganosa, então o tipo declarado
+// decide primeiro; a extensão da URL é só fallback
+export function ehImagem({ tipo, transcricao, url } = {}) {
+  if (ehAudio({ tipo, transcricao })) return false;
+  if (TIPOS_IMAGEM.has(tipo)) return true;
+  return /\.(jpe?g|png|webp|gif)$/i.test(url ?? "");
+}
+
+export function ehVideo({ tipo, transcricao, url } = {}) {
+  if (ehAudio({ tipo, transcricao })) return false;
+  if (TIPOS_VIDEO.has(tipo)) return true;
+  return /\.(mp4|webm|mov|3gp|m4v)$/i.test(url ?? "");
+}

+ 20 - 14
src/views/atendimentos/AtendimentoView.vue

@@ -5,7 +5,16 @@ import LayoutSistema from "../../layout/LayoutSistema.vue";
 import BaseBadge from "../../components/base/BaseBadge.vue";
 import ModalMidia from "../../components/ModalMidia.vue";
 import { obterAtendimento } from "../../api/atendimentos.js";
-import { resolvidoLabel, resolvidoVariant, scoreVariant, sentimentoLabel, sentimentoVariant } from "../../utils/avaliacoes.js";
+import {
+  cancelamentoLabel,
+  cancelamentoVariant,
+  resolvidoLabel,
+  resolvidoVariant,
+  scoreVariant,
+  sentimentoLabel,
+  sentimentoVariant
+} from "../../utils/avaliacoes.js";
+import { ehAudio as ehAudioMidia, ehImagem as ehImagemMidia, ehVideo as ehVideoMidia } from "../../utils/midia.js";
 
 const route = useRoute();
 const router = useRouter();
@@ -36,26 +45,16 @@ function autorLabel(m) {
   return "Desconhecido";
 }
 
-const tiposAudio = new Set(["mpga", "oga", "mp3", "audio", "ptt"]);
-const tiposImagem = new Set(["jpg", "jpeg", "png", "webp", "gif", "image", "sticker"]);
-const tiposVideo = new Set(["mp4", "webm", "mov", "3gp", "video", "m4v"]);
-
 function ehAudio(m) {
-  return Boolean(m.Transcricao) || tiposAudio.has(m.Tipodemidia);
+  return ehAudioMidia({ tipo: m.Tipodemidia, transcricao: m.Transcricao, url: m.MidiaUrl });
 }
 
-// arquivos de áudio às vezes vêm em /data/img com Tipodemidia "mp3", então o tipo
-// declarado decide primeiro; a extensão da URL é só fallback
 function ehImagem(m) {
-  if (ehAudio(m)) return false;
-  if (tiposImagem.has(m.Tipodemidia)) return true;
-  return /\.(jpe?g|png|webp|gif)$/i.test(m.MidiaUrl ?? "");
+  return ehImagemMidia({ tipo: m.Tipodemidia, transcricao: m.Transcricao, url: m.MidiaUrl });
 }
 
 function ehVideo(m) {
-  if (ehAudio(m)) return false;
-  if (tiposVideo.has(m.Tipodemidia)) return true;
-  return /\.(mp4|webm|mov|3gp|m4v)$/i.test(m.MidiaUrl ?? "");
+  return ehVideoMidia({ tipo: m.Tipodemidia, transcricao: m.Transcricao, url: m.MidiaUrl });
 }
 
 // link genérico "Abrir mídia" só para tipos sem renderização própria (pdf, ...)
@@ -147,6 +146,9 @@ onMounted(async () => {
               <BaseBadge :variant="sentimentoVariant[avaliacao.Sentimento] ?? 'default'">
                 {{ sentimentoLabel[avaliacao.Sentimento] ?? avaliacao.Sentimento }}
               </BaseBadge>
+              <BaseBadge v-if="cancelamentoLabel[avaliacao.StatusCancelamento]" :variant="cancelamentoVariant[avaliacao.StatusCancelamento]">
+                {{ cancelamentoLabel[avaliacao.StatusCancelamento] }}
+              </BaseBadge>
             </div>
           </div>
 
@@ -154,6 +156,10 @@ onMounted(async () => {
             <span class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Resumo da IA: </span>
             {{ avaliacao.Resumo }}
           </p>
+          <p v-if="avaliacao?.MotivoCancelamento" class="mt-1 text-sm text-gray-600 dark:text-gray-300">
+            <span class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Motivo do cancelamento: </span>
+            {{ avaliacao.MotivoCancelamento }}
+          </p>
         </section>
 
         <section class="rounded-2xl border border-gray-200 bg-background/70 shadow-sm dark:border-gray-700 dark:bg-background/20">

+ 50 - 7
src/views/atendimentos/AvaliacoesView.vue

@@ -10,7 +10,15 @@ import {
   listarAvaliacoes,
   listarSetoresAvaliacoes
 } from "../../api/atendimentos.js";
-import { resolvidoLabel, resolvidoVariant, scoreVariant, sentimentoLabel, sentimentoVariant } from "../../utils/avaliacoes.js";
+import {
+  cancelamentoLabel,
+  cancelamentoVariant,
+  resolvidoLabel,
+  resolvidoVariant,
+  scoreVariant,
+  sentimentoLabel,
+  sentimentoVariant
+} from "../../utils/avaliacoes.js";
 
 const route = useRoute();
 const router = useRouter();
@@ -26,14 +34,13 @@ const total = ref(0);
 const setores = ref([]);
 const pageSize = 20;
 
-// página, filtros e avaliação aberta vivem na query string para o gestor não perder
-// o ponto da revisão ao entrar na conversa completa e voltar
 const page = ref(Math.max(1, Number(route.query.page) || 1));
 const expandido = ref(route.query.aberto ? Number(route.query.aberto) : null);
 const filtroSetor = ref(typeof route.query.setor === "string" ? route.query.setor : "");
 const filtroResolvido = ref(typeof route.query.resolvido === "string" ? route.query.resolvido : "");
 const filtroSentimento = ref(typeof route.query.sentimento === "string" ? route.query.sentimento : "");
 const filtroBusca = ref(typeof route.query.q === "string" ? route.query.q : "");
+const filtroOrdenacao = ref(typeof route.query.ordenacao === "string" ? route.query.ordenacao : "desc");
 
 function sincronizarQuery() {
   const query = {};
@@ -42,6 +49,7 @@ function sincronizarQuery() {
   if (filtroResolvido.value) query.resolvido = filtroResolvido.value;
   if (filtroSentimento.value) query.sentimento = filtroSentimento.value;
   if (filtroBusca.value.trim()) query.q = filtroBusca.value.trim();
+  if (filtroOrdenacao.value === "asc") query.ordenacao = "asc";
   if (expandido.value) query.aberto = String(expandido.value);
   router.replace({ query });
 }
@@ -93,8 +101,10 @@ async function carregarLista() {
       pageSize,
       setor: filtroSetor.value || undefined,
       resolvido: filtroResolvido.value || undefined,
-      sentimento: filtroSentimento.value || undefined,
-      busca: filtroBusca.value.trim() || undefined
+      sentimento: filtroSentimento.value === "cancelados" ? undefined : filtroSentimento.value || undefined,
+      statusCancelamento: filtroSentimento.value === "cancelados" ? "cancelou" : undefined,
+      busca: filtroBusca.value.trim() || undefined,
+      ordenacao: filtroOrdenacao.value
     });
     avaliacoes.value = r.results ?? [];
     total.value = r.total ?? 0;
@@ -113,7 +123,11 @@ function aplicarFiltros() {
   carregarLista();
 }
 
-// busca digitável: espera a pessoa parar de digitar antes de consultar
+function alternarOrdenacao() {
+  filtroOrdenacao.value = filtroOrdenacao.value === "asc" ? "desc" : "asc";
+  aplicarFiltros();
+}
+
 let buscaTimer = null;
 
 function onBuscaInput() {
@@ -246,7 +260,7 @@ onBeforeUnmount(() => {
           </div>
         </section>
 
-        <section v-if="stats" class="grid grid-cols-2 gap-3 md:grid-cols-4">
+        <section v-if="stats" class="grid grid-cols-2 gap-3 md:grid-cols-5">
           <div class="rounded-2xl border border-gray-200 bg-background/70 p-4 shadow-sm dark:border-gray-700 dark:bg-background/20">
             <div class="text-xs text-gray-500 dark:text-gray-400">Score médio</div>
             <div class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">
@@ -276,6 +290,15 @@ onBeforeUnmount(() => {
               {{ stats.sentimento?.negativo ?? 0 }} saíram insatisfeitos
             </div>
           </div>
+          <div class="rounded-2xl border border-gray-200 bg-background/70 p-4 shadow-sm dark:border-gray-700 dark:bg-background/20">
+            <div class="text-xs text-gray-500 dark:text-gray-400">Cancelamentos</div>
+            <div class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">
+              {{ pct(stats.cancelamento?.cancelou ?? 0, stats.avaliados) }}
+            </div>
+            <div class="text-xs text-gray-500 dark:text-gray-400">
+              {{ stats.cancelamento?.cancelou ?? 0 }} cancelaram · {{ stats.cancelamento?.ameacou ?? 0 }} ameaçaram cancelar
+            </div>
+          </div>
         </section>
 
         <section class="rounded-2xl border border-gray-200 bg-background/70 shadow-sm dark:border-gray-700 dark:bg-background/20">
@@ -339,7 +362,20 @@ onBeforeUnmount(() => {
               <option value="neutro">Neutro</option>
               <option value="negativo">Negativo</option>
               <option value="indefinido">Indefinido</option>
+              <option value="cancelados">Cancelados</option>
             </select>
+            <BaseButton
+              variant="secondary"
+              size="sm"
+              title="Alternar ordem por data da avaliação"
+              @click="alternarOrdenacao"
+            >
+              Data
+              <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                <path v-if="filtroOrdenacao === 'asc'" stroke-linecap="round" stroke-linejoin="round" d="m4.5 15.75 7.5-7.5 7.5 7.5" />
+                <path v-else stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
+              </svg>
+            </BaseButton>
             <div class="ml-auto text-xs text-gray-500 dark:text-gray-400">
               {{ total }} avaliações
             </div>
@@ -375,6 +411,9 @@ onBeforeUnmount(() => {
                 <BaseBadge v-if="a.VisitaAgendada" variant="violet">
                   Visita{{ a.HorarioVisitaInformado === "sim" ? " agendada" : " sem horário" }}
                 </BaseBadge>
+                <BaseBadge v-if="cancelamentoLabel[a.StatusCancelamento]" :variant="cancelamentoVariant[a.StatusCancelamento]">
+                  {{ cancelamentoLabel[a.StatusCancelamento] }}
+                </BaseBadge>
               </button>
 
               <div v-if="expandido === a.AtendimentoId" class="space-y-3 px-4 pb-4 text-sm">
@@ -386,6 +425,10 @@ onBeforeUnmount(() => {
                   <div class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Justificativa da nota</div>
                   <p class="mt-1 text-gray-700 dark:text-gray-300">{{ a.JustificativaScore }}</p>
                 </div>
+                <div v-if="a.MotivoCancelamento">
+                  <div class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Motivo do cancelamento</div>
+                  <p class="mt-1 text-gray-700 dark:text-gray-300">{{ a.MotivoCancelamento }}</p>
+                </div>
                 <div v-if="a.Sugestoes?.length">
                   <div class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Itens a melhorar</div>
                   <ul class="mt-1 list-disc space-y-0.5 pl-5 text-gray-700 dark:text-gray-300">

+ 113 - 3
src/views/conversas/ConversasWhatsappView.vue

@@ -1,12 +1,14 @@
 <script setup>
-import { computed, onMounted, onUnmounted, ref } from "vue";
+import { computed, onMounted, onUnmounted, ref, watch } from "vue";
 import LayoutSistema from "../../layout/LayoutSistema.vue";
 import ModalWhatsappConexao from "../../components/ModalWhatsappConexao.vue";
+import ModalMidia from "../../components/ModalMidia.vue";
 import BaseBadge from "../../components/base/BaseBadge.vue";
 import { useConversas } from "../../composables/useConversas.js";
 import { useWhatsappConexao } from "../../composables/useWhatsappConexao.js";
 import { useAuth } from "../../composables/useAuth.js";
 import { useToast } from "../../composables/useToast.js";
+import { ehAudio, ehImagem, ehVideo } from "../../utils/midia.js";
 
 const toast = useToast();
 
@@ -19,6 +21,9 @@ const {
   mensagensLoading,
   mensagensError,
   enviando,
+  midiaUrls,
+  midiaErros,
+  resolverMidia,
   carregarConversas,
   abrirConversa,
   fecharConversa,
@@ -26,6 +31,28 @@ const {
   excluirConversa
 } = useConversas();
 
+const mediaViewer = ref(null);
+
+function midiaInfo(m) {
+  return { tipo: m.TipoMidia, transcricao: null, url: midiaUrls.value[m.Id] };
+}
+
+function abrirMidia(m) {
+  const url = midiaUrls.value[m.Id];
+  if (!url) return;
+  mediaViewer.value = { url, tipo: ehImagem(midiaInfo(m)) ? "imagem" : "video" };
+}
+
+watch(
+  mensagens,
+  (lista) => {
+    for (const m of lista) {
+      if (m.MidiaPath) resolverMidia(m);
+    }
+  },
+  { immediate: true }
+);
+
 const confirmandoExclusao = ref(null);
 const excluindo = ref(null);
 
@@ -173,7 +200,7 @@ onUnmounted(() => {
           Nenhuma conversa ainda. Mande uma mensagem para o número de teste no WhatsApp.
         </div>
 
-        <div v-else class="min-h-0 flex-1 overflow-y-auto p-2">
+        <div v-else class="min-h-0 flex-1 overflow-y-auto p-2 space-y-1">
           <div
             v-for="c in conversas"
             :key="c.Id"
@@ -259,7 +286,83 @@ onUnmounted(() => {
                       : 'rounded-bl-sm bg-gray-100 dark:bg-gray-800'
                   "
                 >
-                  <p class="whitespace-pre-wrap break-words text-sm text-gray-800 dark:text-gray-200">{{ m.Corpo }}</p>
+                  <template v-if="ehAudio(midiaInfo(m))">
+                    <div class="flex items-center gap-1.5 text-xs font-medium text-gray-500 dark:text-gray-400">
+                      <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
+                        <path stroke-linecap="round" stroke-linejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 1 1 6 0v8.25a3 3 0 0 1-3 3Z" />
+                      </svg>
+                      Mensagem de áudio
+                    </div>
+                    <audio v-if="midiaUrls[m.Id]" controls preload="none" :src="midiaUrls[m.Id]" class="mt-1.5 h-9 w-64 max-w-full"></audio>
+                    <button
+                      v-else-if="midiaErros[m.Id]"
+                      type="button"
+                      class="mt-1 text-xs font-medium text-red-500 underline"
+                      @click="resolverMidia(m)"
+                    >
+                      Falha ao carregar áudio — tentar novamente
+                    </button>
+                    <p v-else class="mt-1 text-xs italic text-gray-400 dark:text-gray-500">Carregando áudio...</p>
+                    <p v-if="m.Corpo" class="mt-1 whitespace-pre-wrap break-words text-sm text-gray-800 dark:text-gray-200">{{ m.Corpo }}</p>
+                  </template>
+                  <template v-else-if="ehImagem(midiaInfo(m)) && m.MidiaPath">
+                    <img
+                      v-if="midiaUrls[m.Id]"
+                      :src="midiaUrls[m.Id]"
+                      alt="Imagem enviada na conversa"
+                      loading="lazy"
+                      title="Ver imagem"
+                      class="max-h-80 max-w-full cursor-pointer rounded-lg object-contain"
+                      @click="abrirMidia(m)"
+                    />
+                    <button
+                      v-else-if="midiaErros[m.Id]"
+                      type="button"
+                      class="text-xs font-medium text-red-500 underline"
+                      @click="resolverMidia(m)"
+                    >
+                      Falha ao carregar imagem — tentar novamente
+                    </button>
+                    <p v-else class="text-xs italic text-gray-400 dark:text-gray-500">Carregando imagem...</p>
+                    <p v-if="m.Corpo" class="mt-1 whitespace-pre-wrap break-words text-sm text-gray-800 dark:text-gray-200">{{ m.Corpo }}</p>
+                  </template>
+                  <template v-else-if="ehVideo(midiaInfo(m)) && m.MidiaPath">
+                    <div
+                      v-if="midiaUrls[m.Id]"
+                      class="relative inline-block cursor-pointer overflow-hidden rounded-lg"
+                      title="Ver vídeo"
+                      @click="abrirMidia(m)"
+                    >
+                      <video :src="midiaUrls[m.Id]" preload="metadata" muted class="max-h-80 max-w-full rounded-lg object-contain"></video>
+                      <div class="absolute inset-0 flex items-center justify-center bg-black/20">
+                        <div class="flex h-11 w-11 items-center justify-center rounded-full bg-white/85 text-gray-900">
+                          <svg class="h-5 w-5 translate-x-0.5" viewBox="0 0 24 24" fill="currentColor">
+                            <path d="M8 5v14l11-7z" />
+                          </svg>
+                        </div>
+                      </div>
+                    </div>
+                    <button
+                      v-else-if="midiaErros[m.Id]"
+                      type="button"
+                      class="text-xs font-medium text-red-500 underline"
+                      @click="resolverMidia(m)"
+                    >
+                      Falha ao carregar vídeo — tentar novamente
+                    </button>
+                    <p v-else class="text-xs italic text-gray-400 dark:text-gray-500">Carregando vídeo...</p>
+                    <p v-if="m.Corpo" class="mt-1 whitespace-pre-wrap break-words text-sm text-gray-800 dark:text-gray-200">{{ m.Corpo }}</p>
+                  </template>
+                  <p v-else-if="m.Corpo" class="whitespace-pre-wrap break-words text-sm text-gray-800 dark:text-gray-200">{{ m.Corpo }}</p>
+                  <a
+                    v-if="m.MidiaPath && !ehAudio(midiaInfo(m)) && !ehImagem(midiaInfo(m)) && !ehVideo(midiaInfo(m)) && midiaUrls[m.Id]"
+                    :href="midiaUrls[m.Id]"
+                    target="_blank"
+                    rel="noopener noreferrer"
+                    class="mt-1 inline-block text-xs font-medium text-primary underline"
+                  >
+                    Abrir arquivo{{ m.MidiaNomeArquivo ? `: ${m.MidiaNomeArquivo}` : "" }}
+                  </a>
                   <div class="mt-0.5 text-right text-[10px] text-gray-400 dark:text-gray-500">{{ formatHora(m.Timestamp) }}</div>
                 </div>
               </div>
@@ -285,5 +388,12 @@ onUnmounted(() => {
         </template>
       </section>
     </div>
+
+    <ModalMidia
+      v-if="mediaViewer"
+      :url="mediaViewer.url"
+      :tipo="mediaViewer.tipo"
+      @close="mediaViewer = null"
+    />
   </LayoutSistema>
 </template>