|
@@ -1,5 +1,6 @@
|
|
|
<script setup>
|
|
<script setup>
|
|
|
-import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
|
|
|
|
|
|
+import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
|
|
|
|
+import { useRoute, useRouter } from "vue-router";
|
|
|
import LayoutSistema from "../../layout/LayoutSistema.vue";
|
|
import LayoutSistema from "../../layout/LayoutSistema.vue";
|
|
|
import BaseBadge from "../../components/base/BaseBadge.vue";
|
|
import BaseBadge from "../../components/base/BaseBadge.vue";
|
|
|
import {
|
|
import {
|
|
@@ -7,6 +8,10 @@ import {
|
|
|
estatisticasAvaliacoes,
|
|
estatisticasAvaliacoes,
|
|
|
listarAvaliacoes
|
|
listarAvaliacoes
|
|
|
} from "../../api/atendimentos.js";
|
|
} from "../../api/atendimentos.js";
|
|
|
|
|
+import { resolvidoLabel, resolvidoVariant, scoreVariant, sentimentoLabel, sentimentoVariant } from "../../utils/avaliacoes.js";
|
|
|
|
|
+
|
|
|
|
|
+const route = useRoute();
|
|
|
|
|
+const router = useRouter();
|
|
|
|
|
|
|
|
const carregando = ref(false);
|
|
const carregando = ref(false);
|
|
|
const avaliandoLote = ref(false);
|
|
const avaliandoLote = ref(false);
|
|
@@ -16,12 +21,25 @@ const loteResultado = ref(null);
|
|
|
const stats = ref(null);
|
|
const stats = ref(null);
|
|
|
const avaliacoes = ref([]);
|
|
const avaliacoes = ref([]);
|
|
|
const total = ref(0);
|
|
const total = ref(0);
|
|
|
-const page = ref(1);
|
|
|
|
|
const pageSize = 20;
|
|
const pageSize = 20;
|
|
|
-const expandido = ref(null);
|
|
|
|
|
|
|
|
|
|
-const filtroResolvido = ref("");
|
|
|
|
|
-const filtroSentimento = ref("");
|
|
|
|
|
|
|
+// 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 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 : "");
|
|
|
|
|
+
|
|
|
|
|
+function sincronizarQuery() {
|
|
|
|
|
+ const query = {};
|
|
|
|
|
+ if (page.value > 1) query.page = String(page.value);
|
|
|
|
|
+ if (filtroResolvido.value) query.resolvido = filtroResolvido.value;
|
|
|
|
|
+ if (filtroSentimento.value) query.sentimento = filtroSentimento.value;
|
|
|
|
|
+ if (filtroBusca.value.trim()) query.q = filtroBusca.value.trim();
|
|
|
|
|
+ if (expandido.value) query.aberto = String(expandido.value);
|
|
|
|
|
+ router.replace({ query });
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
const totalPaginas = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
|
|
const totalPaginas = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
|
|
|
|
|
|
|
@@ -32,19 +50,6 @@ const progresso = computed(() => {
|
|
|
return { total: totalAt, feitos, pct: Math.round((feitos / totalAt) * 100) };
|
|
return { total: totalAt, feitos, pct: Math.round((feitos / totalAt) * 100) };
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
-function scoreVariant(score) {
|
|
|
|
|
- if (score === null || score === undefined) return "default";
|
|
|
|
|
- if (score >= 8) return "success";
|
|
|
|
|
- if (score >= 6) return "info";
|
|
|
|
|
- if (score >= 4) return "warning";
|
|
|
|
|
- return "danger";
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-const resolvidoVariant = { sim: "success", parcial: "warning", nao: "danger", indefinido: "default" };
|
|
|
|
|
-const resolvidoLabel = { sim: "Resolvido", parcial: "Parcial", nao: "Não resolvido", indefinido: "Indefinido" };
|
|
|
|
|
-const sentimentoVariant = { positivo: "success", neutro: "default", negativo: "danger", indefinido: "default" };
|
|
|
|
|
-const sentimentoLabel = { positivo: "Positivo", neutro: "Neutro", negativo: "Negativo", indefinido: "Indefinido" };
|
|
|
|
|
-
|
|
|
|
|
function formatData(value) {
|
|
function formatData(value) {
|
|
|
if (!value) return "";
|
|
if (!value) return "";
|
|
|
const d = new Date(value);
|
|
const d = new Date(value);
|
|
@@ -73,7 +78,8 @@ async function carregarLista() {
|
|
|
page: page.value,
|
|
page: page.value,
|
|
|
pageSize,
|
|
pageSize,
|
|
|
resolvido: filtroResolvido.value || undefined,
|
|
resolvido: filtroResolvido.value || undefined,
|
|
|
- sentimento: filtroSentimento.value || undefined
|
|
|
|
|
|
|
+ sentimento: filtroSentimento.value || undefined,
|
|
|
|
|
+ busca: filtroBusca.value.trim() || undefined
|
|
|
});
|
|
});
|
|
|
avaliacoes.value = r.results ?? [];
|
|
avaliacoes.value = r.results ?? [];
|
|
|
total.value = r.total ?? 0;
|
|
total.value = r.total ?? 0;
|
|
@@ -87,13 +93,28 @@ async function carregarLista() {
|
|
|
function aplicarFiltros() {
|
|
function aplicarFiltros() {
|
|
|
page.value = 1;
|
|
page.value = 1;
|
|
|
expandido.value = null;
|
|
expandido.value = null;
|
|
|
|
|
+ sincronizarQuery();
|
|
|
carregarLista();
|
|
carregarLista();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// busca digitável: espera a pessoa parar de digitar antes de consultar
|
|
|
|
|
+let buscaTimer = null;
|
|
|
|
|
+
|
|
|
|
|
+function onBuscaInput() {
|
|
|
|
|
+ clearTimeout(buscaTimer);
|
|
|
|
|
+ buscaTimer = setTimeout(aplicarFiltros, 400);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function onBuscaEnter() {
|
|
|
|
|
+ clearTimeout(buscaTimer);
|
|
|
|
|
+ aplicarFiltros();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function irParaPagina(nova) {
|
|
function irParaPagina(nova) {
|
|
|
if (nova < 1 || nova > totalPaginas.value || nova === page.value) return;
|
|
if (nova < 1 || nova > totalPaginas.value || nova === page.value) return;
|
|
|
page.value = nova;
|
|
page.value = nova;
|
|
|
expandido.value = null;
|
|
expandido.value = null;
|
|
|
|
|
+ sincronizarQuery();
|
|
|
carregarLista();
|
|
carregarLista();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -114,14 +135,26 @@ async function avaliarPendentes() {
|
|
|
|
|
|
|
|
function alternarExpandido(id) {
|
|
function alternarExpandido(id) {
|
|
|
expandido.value = expandido.value === id ? null : id;
|
|
expandido.value = expandido.value === id ? null : id;
|
|
|
|
|
+ sincronizarQuery();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function verConversa(id) {
|
|
|
|
|
+ router.push({ name: "atendimento", params: { id } });
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// enquanto houver pendentes, atualiza as estatísticas sozinho para a barra andar
|
|
// enquanto houver pendentes, atualiza as estatísticas sozinho para a barra andar
|
|
|
let statsTimer = null;
|
|
let statsTimer = null;
|
|
|
|
|
|
|
|
-onMounted(() => {
|
|
|
|
|
|
|
+onMounted(async () => {
|
|
|
carregarStats();
|
|
carregarStats();
|
|
|
- carregarLista();
|
|
|
|
|
|
|
+ await carregarLista();
|
|
|
|
|
+
|
|
|
|
|
+ // voltando da conversa completa, reposiciona na avaliação que estava aberta
|
|
|
|
|
+ if (expandido.value) {
|
|
|
|
|
+ await nextTick();
|
|
|
|
|
+ document.getElementById(`avaliacao-${expandido.value}`)?.scrollIntoView({ block: "center" });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
statsTimer = setInterval(() => {
|
|
statsTimer = setInterval(() => {
|
|
|
if (stats.value?.pendentes > 0 && !avaliandoLote.value) carregarStats();
|
|
if (stats.value?.pendentes > 0 && !avaliandoLote.value) carregarStats();
|
|
|
}, 30_000);
|
|
}, 30_000);
|
|
@@ -129,6 +162,7 @@ onMounted(() => {
|
|
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
onBeforeUnmount(() => {
|
|
|
if (statsTimer) clearInterval(statsTimer);
|
|
if (statsTimer) clearInterval(statsTimer);
|
|
|
|
|
+ clearTimeout(buscaTimer);
|
|
|
});
|
|
});
|
|
|
</script>
|
|
</script>
|
|
|
|
|
|
|
@@ -234,6 +268,36 @@ onBeforeUnmount(() => {
|
|
|
|
|
|
|
|
<section class="rounded-2xl border border-gray-200 bg-background/70 shadow-sm dark:border-gray-700 dark:bg-background/20">
|
|
<section class="rounded-2xl border border-gray-200 bg-background/70 shadow-sm dark:border-gray-700 dark:bg-background/20">
|
|
|
<div class="flex flex-wrap items-center gap-3 border-b border-gray-200 p-4 dark:border-gray-700">
|
|
<div class="flex flex-wrap items-center gap-3 border-b border-gray-200 p-4 dark:border-gray-700">
|
|
|
|
|
+ <div class="relative min-w-[240px] flex-1 sm:max-w-[340px]">
|
|
|
|
|
+ <svg
|
|
|
|
|
+ class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400 dark:text-gray-500"
|
|
|
|
|
+ viewBox="0 0 24 24"
|
|
|
|
|
+ fill="none"
|
|
|
|
|
+ stroke="currentColor"
|
|
|
|
|
+ stroke-width="2"
|
|
|
|
|
+ >
|
|
|
|
|
+ <path stroke-linecap="round" stroke-linejoin="round" d="m21 21-4.35-4.35M17 10.5a6.5 6.5 0 1 1-13 0 6.5 6.5 0 0 1 13 0Z" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ <input
|
|
|
|
|
+ v-model="filtroBusca"
|
|
|
|
|
+ type="search"
|
|
|
|
|
+ placeholder="Protocolo, cliente, atendente ou data (dd/mm/aaaa)"
|
|
|
|
|
+ class="w-full rounded-lg border border-gray-300 bg-transparent py-1.5 pl-9 pr-8 text-sm text-gray-700 placeholder:text-gray-400 dark:border-gray-600 dark:text-gray-200 dark:placeholder:text-gray-500"
|
|
|
|
|
+ @input="onBuscaInput"
|
|
|
|
|
+ @keyup.enter="onBuscaEnter"
|
|
|
|
|
+ />
|
|
|
|
|
+ <button
|
|
|
|
|
+ v-if="filtroBusca"
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ class="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
|
|
|
|
|
+ title="Limpar busca"
|
|
|
|
|
+ @click="filtroBusca = ''; onBuscaEnter();"
|
|
|
|
|
+ >
|
|
|
|
|
+ <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
|
|
|
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
<select
|
|
<select
|
|
|
v-model="filtroResolvido"
|
|
v-model="filtroResolvido"
|
|
|
class="rounded-lg border border-gray-300 bg-transparent px-3 py-1.5 text-sm text-gray-700 dark:border-gray-600 dark:bg-transparent dark:text-gray-200 [&>option]:dark:bg-gray-800"
|
|
class="rounded-lg border border-gray-300 bg-transparent px-3 py-1.5 text-sm text-gray-700 dark:border-gray-600 dark:bg-transparent dark:text-gray-200 [&>option]:dark:bg-gray-800"
|
|
@@ -267,7 +331,7 @@ onBeforeUnmount(() => {
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<ul v-else class="divide-y divide-gray-200 dark:divide-gray-700">
|
|
<ul v-else class="divide-y divide-gray-200 dark:divide-gray-700">
|
|
|
- <li v-for="a in avaliacoes" :key="a.AtendimentoId">
|
|
|
|
|
|
|
+ <li v-for="a in avaliacoes" :id="`avaliacao-${a.AtendimentoId}`" :key="a.AtendimentoId">
|
|
|
<button
|
|
<button
|
|
|
type="button"
|
|
type="button"
|
|
|
class="flex w-full flex-wrap items-center gap-2 px-4 py-3 text-left hover:bg-black/[0.03] dark:hover:bg-white/[0.04]"
|
|
class="flex w-full flex-wrap items-center gap-2 px-4 py-3 text-left hover:bg-black/[0.03] dark:hover:bg-white/[0.04]"
|
|
@@ -313,8 +377,20 @@ onBeforeUnmount(() => {
|
|
|
<template v-if="a.HorarioVisitaInformado === 'sim'">horário informado — {{ a.HorarioVisita }}</template>
|
|
<template v-if="a.HorarioVisitaInformado === 'sim'">horário informado — {{ a.HorarioVisita }}</template>
|
|
|
<template v-else>agendada, mas o atendente não informou o horário</template>
|
|
<template v-else>agendada, mas o atendente não informou o horário</template>
|
|
|
</div>
|
|
</div>
|
|
|
- <div class="text-xs text-gray-400 dark:text-gray-500">
|
|
|
|
|
- Avaliado por {{ a.Modelo }} em {{ formatData(a.UpdatedAt) }} · {{ a.TotalMensagens }} mensagens
|
|
|
|
|
|
|
+ <div class="flex flex-wrap items-center justify-between gap-3">
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ class="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-black/[0.03] dark:border-gray-600 dark:text-gray-200 dark:hover:bg-white/[0.04]"
|
|
|
|
|
+ @click="verConversa(a.AtendimentoId)"
|
|
|
|
|
+ >
|
|
|
|
|
+ Ver conversa completa
|
|
|
|
|
+ <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
|
|
|
+ <path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <div class="text-xs text-gray-400 dark:text-gray-500">
|
|
|
|
|
+ Avaliado por {{ a.Modelo }} em {{ formatData(a.UpdatedAt) }} · {{ a.TotalMensagens }} mensagens
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
</li>
|
|
</li>
|