|
@@ -1,34 +1,22 @@
|
|
|
<script setup>
|
|
<script setup>
|
|
|
-import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
|
|
|
|
-import { useRoute, useRouter } from "vue-router";
|
|
|
|
|
|
|
+import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
|
|
|
|
+import { useRoute } from "vue-router";
|
|
|
import LayoutSistema from "../../layout/LayoutSistema.vue";
|
|
import LayoutSistema from "../../layout/LayoutSistema.vue";
|
|
|
-import BaseBadge from "../../components/base/BaseBadge.vue";
|
|
|
|
|
import BaseButton from "../../components/base/BaseButton.vue";
|
|
import BaseButton from "../../components/base/BaseButton.vue";
|
|
|
import BaseDropdown from "../../components/base/BaseDropdown.vue";
|
|
import BaseDropdown from "../../components/base/BaseDropdown.vue";
|
|
|
|
|
+import CampoBusca from "../../components/base/CampoBusca.vue";
|
|
|
|
|
+import PaginacaoLista from "../../components/base/PaginacaoLista.vue";
|
|
|
import StatCard from "../../components/base/StatCard.vue";
|
|
import StatCard from "../../components/base/StatCard.vue";
|
|
|
-import ListItemCard from "../../components/base/ListItemCard.vue";
|
|
|
|
|
import EmptyState from "../../components/base/EmptyState.vue";
|
|
import EmptyState from "../../components/base/EmptyState.vue";
|
|
|
import Spinner from "../../components/base/Spinner.vue";
|
|
import Spinner from "../../components/base/Spinner.vue";
|
|
|
-import { ClipboardList, Star, CircleCheck, Check, Smile, TriangleAlert, Search, X, ChevronUp, ChevronDown, ChevronRight } from "lucide-vue-next";
|
|
|
|
|
|
|
+import ItemAvaliacao from "../../components/atendimentos/ItemAvaliacao.vue";
|
|
|
|
|
+import { ClipboardList, Star, CircleCheck, Check, Smile, TriangleAlert, ChevronUp, ChevronDown } from "lucide-vue-next";
|
|
|
import BaseIcon from "../../components/base/BaseIcon.vue";
|
|
import BaseIcon from "../../components/base/BaseIcon.vue";
|
|
|
-import {
|
|
|
|
|
- avaliarAtendimentosPendentes,
|
|
|
|
|
- estatisticasAvaliacoes,
|
|
|
|
|
- listarAvaliacoes,
|
|
|
|
|
- listarSetoresAvaliacoes
|
|
|
|
|
-} from "../../api/atendimentos.js";
|
|
|
|
|
|
|
+import { avaliarAtendimentosPendentes, estatisticasAvaliacoes } from "../../api/atendimentos.js";
|
|
|
|
|
+import { queryTexto, useListaAvaliacoes } from "../../composables/useListaAvaliacoes.js";
|
|
|
|
|
+import { useAsyncState } from "../../composables/useAsyncState.js";
|
|
|
import { useToast } from "../../composables/useToast.js";
|
|
import { useToast } from "../../composables/useToast.js";
|
|
|
-import {
|
|
|
|
|
- cancelamentoLabel,
|
|
|
|
|
- cancelamentoVariant,
|
|
|
|
|
- CONTEXTO_IXC_ERRO_LABEL,
|
|
|
|
|
- contextoIxcFalhou,
|
|
|
|
|
- resolvidoLabel,
|
|
|
|
|
- resolvidoVariant,
|
|
|
|
|
- scoreVariant,
|
|
|
|
|
- sentimentoLabel,
|
|
|
|
|
- sentimentoVariant
|
|
|
|
|
-} from "../../utils/avaliacoes.js";
|
|
|
|
|
|
|
+import { cancelamentoLabel, pct, resolvidoLabel, sentimentoLabel } from "../../utils/avaliacoes.js";
|
|
|
|
|
|
|
|
const opcoesResolucao = Object.entries({ ...resolvidoLabel, ...cancelamentoLabel }).map(([value, label]) => ({
|
|
const opcoesResolucao = Object.entries({ ...resolvidoLabel, ...cancelamentoLabel }).map(([value, label]) => ({
|
|
|
value,
|
|
value,
|
|
@@ -37,50 +25,18 @@ const opcoesResolucao = Object.entries({ ...resolvidoLabel, ...cancelamentoLabel
|
|
|
const opcoesSentimento = Object.entries(sentimentoLabel).map(([value, label]) => ({ value, label }));
|
|
const opcoesSentimento = Object.entries(sentimentoLabel).map(([value, label]) => ({ value, label }));
|
|
|
|
|
|
|
|
const route = useRoute();
|
|
const route = useRoute();
|
|
|
-const router = useRouter();
|
|
|
|
|
const toast = useToast();
|
|
const toast = useToast();
|
|
|
|
|
|
|
|
-const carregando = ref(false);
|
|
|
|
|
const avaliandoLote = ref(false);
|
|
const avaliandoLote = ref(false);
|
|
|
-const erro = ref("");
|
|
|
|
|
const loteResultado = ref(null);
|
|
const loteResultado = ref(null);
|
|
|
-
|
|
|
|
|
const stats = ref(null);
|
|
const stats = ref(null);
|
|
|
-const avaliacoes = ref([]);
|
|
|
|
|
-const total = ref(0);
|
|
|
|
|
-const setores = ref([]);
|
|
|
|
|
-const pageSize = 20;
|
|
|
|
|
-
|
|
|
|
|
-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 = {};
|
|
|
|
|
- if (page.value > 1) query.page = String(page.value);
|
|
|
|
|
- if (filtroSetor.value) query.setor = filtroSetor.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 (filtroOrdenacao.value === "asc") query.ordenacao = "asc";
|
|
|
|
|
- if (expandido.value) query.aberto = String(expandido.value);
|
|
|
|
|
- router.replace({ query });
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-const totalPaginas = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
|
|
|
|
|
-
|
|
|
|
|
-const progresso = computed(() => {
|
|
|
|
|
- const totalAt = stats.value?.totalAtendimentos ?? 0;
|
|
|
|
|
- if (!totalAt) return null;
|
|
|
|
|
- const feitos = Math.max(0, totalAt - (stats.value?.pendentes ?? 0));
|
|
|
|
|
- return { total: totalAt, feitos, pct: Math.round((feitos / totalAt) * 100) };
|
|
|
|
|
-});
|
|
|
|
|
|
|
|
|
|
|
|
+const filtroSetor = ref(queryTexto(route, "setor"));
|
|
|
|
|
+const filtroResolvido = ref(queryTexto(route, "resolvido"));
|
|
|
|
|
+const filtroSentimento = ref(queryTexto(route, "sentimento"));
|
|
|
|
|
|
|
|
|
|
+// o dropdown de "Resolução" mistura dois campos do backend: os status de resolução vão em
|
|
|
|
|
+// `resolvido`, e "cancelou"/"ameacou" são na verdade StatusCancelamento
|
|
|
const resolvidoParaApi = computed(() =>
|
|
const resolvidoParaApi = computed(() =>
|
|
|
["sim", "parcial", "nao", "indefinido"].includes(filtroResolvido.value) ? filtroResolvido.value : undefined
|
|
["sim", "parcial", "nao", "indefinido"].includes(filtroResolvido.value) ? filtroResolvido.value : undefined
|
|
|
);
|
|
);
|
|
@@ -88,109 +44,77 @@ const statusCancelamentoParaApi = computed(() =>
|
|
|
filtroResolvido.value === "cancelou" || filtroResolvido.value === "ameacou" ? filtroResolvido.value : undefined
|
|
filtroResolvido.value === "cancelou" || filtroResolvido.value === "ameacou" ? filtroResolvido.value : undefined
|
|
|
);
|
|
);
|
|
|
|
|
|
|
|
-function formatData(value) {
|
|
|
|
|
- if (!value) return "";
|
|
|
|
|
- const d = new Date(value);
|
|
|
|
|
- if (Number.isNaN(d.getTime())) return "";
|
|
|
|
|
- return d.toLocaleDateString("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric" });
|
|
|
|
|
|
|
+function filtrosProprios() {
|
|
|
|
|
+ return {
|
|
|
|
|
+ setor: filtroSetor.value || undefined,
|
|
|
|
|
+ resolvido: resolvidoParaApi.value,
|
|
|
|
|
+ sentimento: filtroSentimento.value || undefined,
|
|
|
|
|
+ statusCancelamento: statusCancelamentoParaApi.value
|
|
|
|
|
+ };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function pct(parte, todo) {
|
|
|
|
|
- if (!todo) return "0%";
|
|
|
|
|
- return `${Math.round((parte / todo) * 100)}%`;
|
|
|
|
|
-}
|
|
|
|
|
|
|
+const {
|
|
|
|
|
+ busca: filtroBusca,
|
|
|
|
|
+ ordenacao: filtroOrdenacao,
|
|
|
|
|
+ page,
|
|
|
|
|
+ expandido,
|
|
|
|
|
+ setores,
|
|
|
|
|
+ avaliacoes,
|
|
|
|
|
+ total,
|
|
|
|
|
+ totalPaginas,
|
|
|
|
|
+ carregando,
|
|
|
|
|
+ erro,
|
|
|
|
|
+ carregarLista,
|
|
|
|
|
+ aplicarFiltros,
|
|
|
|
|
+ alternarOrdenacao,
|
|
|
|
|
+ onBuscaInput,
|
|
|
|
|
+ onBuscaEnter,
|
|
|
|
|
+ irParaPagina,
|
|
|
|
|
+ alternarExpandido,
|
|
|
|
|
+ verConversa,
|
|
|
|
|
+ inicializar
|
|
|
|
|
+} = useListaAvaliacoes({
|
|
|
|
|
+ montarQueryExtra: () => {
|
|
|
|
|
+ const query = {};
|
|
|
|
|
+ if (filtroSetor.value) query.setor = filtroSetor.value;
|
|
|
|
|
+ if (filtroResolvido.value) query.resolvido = filtroResolvido.value;
|
|
|
|
|
+ if (filtroSentimento.value) query.sentimento = filtroSentimento.value;
|
|
|
|
|
+ return query;
|
|
|
|
|
+ },
|
|
|
|
|
+ montarFiltrosApi: filtrosProprios,
|
|
|
|
|
+ aoAplicarFiltros: carregarStats,
|
|
|
|
|
+ prefixoElemento: "avaliacao",
|
|
|
|
|
+ mensagemErroLista: "Falha ao carregar avaliações"
|
|
|
|
|
+});
|
|
|
|
|
|
|
|
-let statsRequestId = 0;
|
|
|
|
|
|
|
+const progresso = computed(() => {
|
|
|
|
|
+ const totalAt = stats.value?.totalAtendimentos ?? 0;
|
|
|
|
|
+ if (!totalAt) return null;
|
|
|
|
|
+ const feitos = Math.max(0, totalAt - (stats.value?.pendentes ?? 0));
|
|
|
|
|
+ return { total: totalAt, feitos, pct: Math.round((feitos / totalAt) * 100) };
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// useAsyncState já descarta respostas antigas; aqui ele só serve de guarda anti-race
|
|
|
|
|
+const guardaStats = useAsyncState();
|
|
|
|
|
|
|
|
async function carregarStats() {
|
|
async function carregarStats() {
|
|
|
- const requestId = ++statsRequestId;
|
|
|
|
|
|
|
+ const epoca = guardaStats.nextId();
|
|
|
try {
|
|
try {
|
|
|
const resultado = await estatisticasAvaliacoes({
|
|
const resultado = await estatisticasAvaliacoes({
|
|
|
- setor: filtroSetor.value || undefined,
|
|
|
|
|
- resolvido: resolvidoParaApi.value,
|
|
|
|
|
- sentimento: filtroSentimento.value || undefined,
|
|
|
|
|
- statusCancelamento: statusCancelamentoParaApi.value,
|
|
|
|
|
|
|
+ ...filtrosProprios(),
|
|
|
busca: filtroBusca.value.trim() || undefined
|
|
busca: filtroBusca.value.trim() || undefined
|
|
|
});
|
|
});
|
|
|
- if (requestId !== statsRequestId) return;
|
|
|
|
|
|
|
+ if (guardaStats.isStale(epoca)) return;
|
|
|
stats.value = resultado;
|
|
stats.value = resultado;
|
|
|
- } catch {
|
|
|
|
|
- if (requestId !== statsRequestId) return;
|
|
|
|
|
- stats.value = null;
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-async function carregarSetores() {
|
|
|
|
|
- try {
|
|
|
|
|
- const r = await listarSetoresAvaliacoes();
|
|
|
|
|
- setores.value = r.results ?? [];
|
|
|
|
|
- } catch (e) {
|
|
|
|
|
- setores.value = [];
|
|
|
|
|
- toast.erro(e?.message ?? "Falha ao carregar setores");
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-let listaRequestId = 0;
|
|
|
|
|
-
|
|
|
|
|
-async function carregarLista() {
|
|
|
|
|
- const requestId = ++listaRequestId;
|
|
|
|
|
- carregando.value = true;
|
|
|
|
|
- erro.value = "";
|
|
|
|
|
- try {
|
|
|
|
|
- const r = await listarAvaliacoes({
|
|
|
|
|
- page: page.value,
|
|
|
|
|
- pageSize,
|
|
|
|
|
- setor: filtroSetor.value || undefined,
|
|
|
|
|
- resolvido: resolvidoParaApi.value,
|
|
|
|
|
- sentimento: filtroSentimento.value || undefined,
|
|
|
|
|
- statusCancelamento: statusCancelamentoParaApi.value,
|
|
|
|
|
- busca: filtroBusca.value.trim() || undefined,
|
|
|
|
|
- ordenacao: filtroOrdenacao.value
|
|
|
|
|
- });
|
|
|
|
|
- if (requestId !== listaRequestId) return;
|
|
|
|
|
- avaliacoes.value = r.results ?? [];
|
|
|
|
|
- total.value = r.total ?? 0;
|
|
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
|
- if (requestId !== listaRequestId) return;
|
|
|
|
|
- erro.value = err?.message ?? "Falha ao carregar avaliações";
|
|
|
|
|
- } finally {
|
|
|
|
|
- if (requestId === listaRequestId) carregando.value = false;
|
|
|
|
|
|
|
+ if (guardaStats.isStale(epoca)) return;
|
|
|
|
|
+ stats.value = null;
|
|
|
|
|
+ // sem isto os cartões somem da tela sem nenhuma explicação
|
|
|
|
|
+ toast.erro("Não foi possível carregar as estatísticas das avaliações.");
|
|
|
|
|
+ console.warn("[AvaliacoesView] carregarStats:", err);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function aplicarFiltros() {
|
|
|
|
|
- page.value = 1;
|
|
|
|
|
- expandido.value = null;
|
|
|
|
|
- sincronizarQuery();
|
|
|
|
|
- carregarStats();
|
|
|
|
|
- carregarLista();
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-function alternarOrdenacao() {
|
|
|
|
|
- filtroOrdenacao.value = filtroOrdenacao.value === "asc" ? "desc" : "asc";
|
|
|
|
|
- aplicarFiltros();
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-let buscaTimer = null;
|
|
|
|
|
-
|
|
|
|
|
-function onBuscaInput() {
|
|
|
|
|
- clearTimeout(buscaTimer);
|
|
|
|
|
- buscaTimer = setTimeout(aplicarFiltros, 400);
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-function onBuscaEnter() {
|
|
|
|
|
- clearTimeout(buscaTimer);
|
|
|
|
|
- aplicarFiltros();
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-function irParaPagina(nova) {
|
|
|
|
|
- if (nova < 1 || nova > totalPaginas.value || nova === page.value) return;
|
|
|
|
|
- page.value = nova;
|
|
|
|
|
- expandido.value = null;
|
|
|
|
|
- sincronizarQuery();
|
|
|
|
|
- carregarLista();
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
async function avaliarPendentes() {
|
|
async function avaliarPendentes() {
|
|
|
if (avaliandoLote.value) return;
|
|
if (avaliandoLote.value) return;
|
|
|
avaliandoLote.value = true;
|
|
avaliandoLote.value = true;
|
|
@@ -206,37 +130,21 @@ async function avaliarPendentes() {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function alternarExpandido(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(async () => {
|
|
|
|
|
|
|
+onMounted(() => {
|
|
|
carregarStats();
|
|
carregarStats();
|
|
|
- carregarSetores();
|
|
|
|
|
- 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" });
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ inicializar();
|
|
|
|
|
|
|
|
statsTimer = setInterval(() => {
|
|
statsTimer = setInterval(() => {
|
|
|
|
|
+ if (document.visibilityState !== "visible") return;
|
|
|
if (stats.value?.pendentes > 0 && !avaliandoLote.value) carregarStats();
|
|
if (stats.value?.pendentes > 0 && !avaliandoLote.value) carregarStats();
|
|
|
}, 30_000);
|
|
}, 30_000);
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
onBeforeUnmount(() => {
|
|
|
if (statsTimer) clearInterval(statsTimer);
|
|
if (statsTimer) clearInterval(statsTimer);
|
|
|
- clearTimeout(buscaTimer);
|
|
|
|
|
});
|
|
});
|
|
|
</script>
|
|
</script>
|
|
|
|
|
|
|
@@ -335,29 +243,13 @@ 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]">
|
|
|
|
|
- <BaseIcon
|
|
|
|
|
- :icon="Search"
|
|
|
|
|
- class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
|
|
|
|
|
- />
|
|
|
|
|
- <input
|
|
|
|
|
- v-model="filtroBusca"
|
|
|
|
|
- type="search"
|
|
|
|
|
- placeholder="Protocolo, cliente, atendente ou data (dd/mm/aaaa)"
|
|
|
|
|
- class="w-full rounded-lg border border-gray-200 bg-background py-1.5 pl-9 pr-8 text-sm text-gray-700 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-primary/30 dark:border-gray-700 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 bg-transparent p-0.5 text-gray-400 hover:text-muted-foreground dark:hover:text-gray-300"
|
|
|
|
|
- title="Limpar busca"
|
|
|
|
|
- @click="filtroBusca = ''; onBuscaEnter();"
|
|
|
|
|
- >
|
|
|
|
|
- <BaseIcon :icon="X" class="h-4 w-4" />
|
|
|
|
|
- </button>
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ <CampoBusca
|
|
|
|
|
+ v-model="filtroBusca"
|
|
|
|
|
+ placeholder="Protocolo, cliente, atendente ou data (dd/mm/aaaa)"
|
|
|
|
|
+ @input="onBuscaInput"
|
|
|
|
|
+ @enter="onBuscaEnter"
|
|
|
|
|
+ @limpar="onBuscaEnter"
|
|
|
|
|
+ />
|
|
|
<div class="flex shrink-0 items-center gap-2">
|
|
<div class="flex shrink-0 items-center gap-2">
|
|
|
<BaseDropdown
|
|
<BaseDropdown
|
|
|
v-model="filtroSetor"
|
|
v-model="filtroSetor"
|
|
@@ -405,99 +297,23 @@ onBeforeUnmount(() => {
|
|
|
/>
|
|
/>
|
|
|
|
|
|
|
|
<div v-else class="space-y-3 p-4">
|
|
<div v-else class="space-y-3 p-4">
|
|
|
- <ListItemCard
|
|
|
|
|
|
|
+ <ItemAvaliacao
|
|
|
v-for="a in avaliacoes"
|
|
v-for="a in avaliacoes"
|
|
|
- :id="`avaliacao-${a.AtendimentoId}`"
|
|
|
|
|
:key="a.AtendimentoId"
|
|
:key="a.AtendimentoId"
|
|
|
- :expanded="expandido === a.AtendimentoId"
|
|
|
|
|
- @toggle="alternarExpandido(a.AtendimentoId)"
|
|
|
|
|
- >
|
|
|
|
|
- <template #header>
|
|
|
|
|
- <div class="min-w-0 flex-1">
|
|
|
|
|
- <div class="truncate text-sm font-semibold text-foreground">
|
|
|
|
|
- {{ a.Codigo ?? `#${a.AtendimentoId}` }}
|
|
|
|
|
- <span class="font-normal text-muted-foreground"> — {{ a.ClienteNome ?? "cliente desconhecido" }}</span>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div class="mt-0.5 text-xs text-muted-foreground">
|
|
|
|
|
- <span v-if="a.Setor">{{ a.Setor }} · </span>{{ formatData(a.Abertura) }}
|
|
|
|
|
- <span v-if="a.Atendentes?.length"> · {{ a.Atendentes.join(", ") }}</span>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div class="flex flex-wrap items-center justify-end gap-1.5">
|
|
|
|
|
- <BaseBadge :variant="scoreVariant(a.ScoreAtendente)">
|
|
|
|
|
- {{ a.ScoreAtendente !== null && a.ScoreAtendente !== undefined ? `Nota ${a.ScoreAtendente}` : "Sem nota" }}
|
|
|
|
|
- </BaseBadge>
|
|
|
|
|
- <BaseBadge :variant="resolvidoVariant[a.Resolvido] ?? 'default'">{{ resolvidoLabel[a.Resolvido] ?? a.Resolvido }}</BaseBadge>
|
|
|
|
|
- <BaseBadge :variant="sentimentoVariant[a.Sentimento] ?? 'default'">{{ sentimentoLabel[a.Sentimento] ?? a.Sentimento }}</BaseBadge>
|
|
|
|
|
- <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>
|
|
|
|
|
- <BaseBadge v-if="contextoIxcFalhou(a.ContextoIxcStatus)" variant="warning" :title="CONTEXTO_IXC_ERRO_LABEL">
|
|
|
|
|
- Contexto ERP indisponível
|
|
|
|
|
- </BaseBadge>
|
|
|
|
|
- </div>
|
|
|
|
|
- </template>
|
|
|
|
|
-
|
|
|
|
|
- <div v-if="a.Resumo">
|
|
|
|
|
- <div class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Resumo</div>
|
|
|
|
|
- <p class="mt-1 text-foreground">{{ a.Resumo }}</p>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div v-if="a.JustificativaScore">
|
|
|
|
|
- <div class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Justificativa da nota</div>
|
|
|
|
|
- <p class="mt-1 text-foreground">{{ a.JustificativaScore }}</p>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div v-if="a.MotivoCancelamento">
|
|
|
|
|
- <div class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Motivo do cancelamento</div>
|
|
|
|
|
- <p class="mt-1 text-foreground">{{ a.MotivoCancelamento }}</p>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div v-if="a.Sugestoes?.length">
|
|
|
|
|
- <div class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Itens a melhorar</div>
|
|
|
|
|
- <ul class="mt-1 list-disc space-y-0.5 pl-5 text-foreground">
|
|
|
|
|
- <li v-for="(s, i) in a.Sugestoes" :key="i">{{ s }}</li>
|
|
|
|
|
- </ul>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div v-if="a.VisitaAgendada" class="text-foreground">
|
|
|
|
|
- <span class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Visita técnica: </span>
|
|
|
|
|
- <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>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div class="flex flex-wrap items-center justify-between gap-3">
|
|
|
|
|
- <BaseButton variant="secondary" size="sm" @click="verConversa(a.AtendimentoId)">
|
|
|
|
|
- Ver conversa completa
|
|
|
|
|
- <BaseIcon :icon="ChevronRight" class="h-3.5 w-3.5" />
|
|
|
|
|
- </BaseButton>
|
|
|
|
|
- <div class="text-xs text-muted-foreground">
|
|
|
|
|
- Avaliado por {{ a.Modelo }} em {{ formatData(a.UpdatedAt) }} · {{ a.TotalMensagens }} mensagens
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- </ListItemCard>
|
|
|
|
|
|
|
+ :avaliacao="a"
|
|
|
|
|
+ prefixo-id="avaliacao"
|
|
|
|
|
+ :expandida="expandido === a.AtendimentoId"
|
|
|
|
|
+ @toggle="alternarExpandido"
|
|
|
|
|
+ @ver-conversa="verConversa"
|
|
|
|
|
+ />
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- <div
|
|
|
|
|
- v-if="totalPaginas > 1"
|
|
|
|
|
- class="flex items-center justify-between border-t border-gray-200 px-4 py-3 text-sm dark:border-gray-700"
|
|
|
|
|
- >
|
|
|
|
|
- <BaseButton
|
|
|
|
|
- variant="secondary"
|
|
|
|
|
- size="sm"
|
|
|
|
|
- :disabled="page <= 1 || carregando"
|
|
|
|
|
- @click="irParaPagina(page - 1)"
|
|
|
|
|
- >
|
|
|
|
|
- Anterior
|
|
|
|
|
- </BaseButton>
|
|
|
|
|
- <span class="text-muted-foreground">Página {{ page }} de {{ totalPaginas }}</span>
|
|
|
|
|
- <BaseButton
|
|
|
|
|
- variant="secondary"
|
|
|
|
|
- size="sm"
|
|
|
|
|
- :disabled="page >= totalPaginas || carregando"
|
|
|
|
|
- @click="irParaPagina(page + 1)"
|
|
|
|
|
- >
|
|
|
|
|
- Próxima
|
|
|
|
|
- </BaseButton>
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ <PaginacaoLista
|
|
|
|
|
+ :page="page"
|
|
|
|
|
+ :total-paginas="totalPaginas"
|
|
|
|
|
+ :desabilitado="carregando"
|
|
|
|
|
+ @ir="irParaPagina"
|
|
|
|
|
+ />
|
|
|
</section>
|
|
</section>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|