|
|
@@ -3,8 +3,9 @@ import { computed, ref, watch } from "vue";
|
|
|
import { useRoute, useRouter } from "vue-router";
|
|
|
import LayoutSistema from "../../layout/LayoutSistema.vue";
|
|
|
import BaseBadge from "../../components/base/BaseBadge.vue";
|
|
|
+import BaseButton from "../../components/base/BaseButton.vue";
|
|
|
import ModalMidia from "../../components/ModalMidia.vue";
|
|
|
-import { obterAtendimento } from "../../api/atendimentos.js";
|
|
|
+import { obterAtendimento, salvarGoldenLabel } from "../../api/atendimentos.js";
|
|
|
import {
|
|
|
cancelamentoLabel,
|
|
|
cancelamentoVariant,
|
|
|
@@ -14,9 +15,14 @@ import {
|
|
|
resolvidoVariant,
|
|
|
scoreVariant,
|
|
|
sentimentoLabel,
|
|
|
- sentimentoVariant
|
|
|
+ sentimentoVariant,
|
|
|
+ statusCancelamentoLabel
|
|
|
} from "../../utils/avaliacoes.js";
|
|
|
import { ehAudio as ehAudioMidia, ehImagem as ehImagemMidia, ehVideo as ehVideoMidia } from "../../utils/midia.js";
|
|
|
+import { useToast } from "../../composables/useToast.js";
|
|
|
+
|
|
|
+const HORARIO_VISITA_INFORMADO_LABEL = { sim: "Sim", nao: "Não", nao_se_aplica: "Não se aplica" };
|
|
|
+const toast = useToast();
|
|
|
|
|
|
const route = useRoute();
|
|
|
const router = useRouter();
|
|
|
@@ -27,12 +33,69 @@ const atendimento = ref(null);
|
|
|
const mediaViewer = ref(null);
|
|
|
|
|
|
const avaliacao = computed(() => atendimento.value?.avaliacao ?? null);
|
|
|
+const goldenLabel = computed(() => atendimento.value?.goldenLabel ?? null);
|
|
|
const mensagens = computed(() => atendimento.value?.mensagens ?? []);
|
|
|
|
|
|
+const form = ref(criarFormulario(null));
|
|
|
+const salvando = ref(false);
|
|
|
+
|
|
|
+function criarFormulario(label) {
|
|
|
+ return {
|
|
|
+ ScoreAtendente: label?.ScoreAtendente ?? null,
|
|
|
+ SemAtendente: (label?.ScoreAtendente ?? null) === null,
|
|
|
+ JustificativaScore: label?.JustificativaScore ?? "",
|
|
|
+ Resolvido: label?.Resolvido ?? "",
|
|
|
+ Sentimento: label?.Sentimento ?? "",
|
|
|
+ Resumo: label?.Resumo ?? "",
|
|
|
+ SugestoesTexto: (label?.Sugestoes ?? []).join("\n"),
|
|
|
+ AtendentesTexto: (label?.Atendentes ?? []).join(", "),
|
|
|
+ VisitaAgendada: label?.VisitaAgendada === true ? "sim" : label?.VisitaAgendada === false ? "nao" : "",
|
|
|
+ HorarioVisitaInformado: label?.HorarioVisitaInformado ?? "",
|
|
|
+ HorarioVisita: label?.HorarioVisita ?? "",
|
|
|
+ StatusCancelamento: label?.StatusCancelamento ?? "",
|
|
|
+ MotivoCancelamento: label?.MotivoCancelamento ?? ""
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function montarPayloadGoldenLabel() {
|
|
|
+ const f = form.value;
|
|
|
+ const sugestoes = f.SugestoesTexto.split("\n").map((s) => s.trim()).filter(Boolean);
|
|
|
+ const atendentes = f.AtendentesTexto.split(",").map((s) => s.trim()).filter(Boolean);
|
|
|
+
|
|
|
+ return {
|
|
|
+ ScoreAtendente: f.SemAtendente || f.ScoreAtendente === null || f.ScoreAtendente === "" ? null : Number(f.ScoreAtendente),
|
|
|
+ JustificativaScore: f.JustificativaScore.trim() || null,
|
|
|
+ Resolvido: f.Resolvido || null,
|
|
|
+ Sentimento: f.Sentimento || null,
|
|
|
+ Resumo: f.Resumo.trim() || null,
|
|
|
+ Sugestoes: sugestoes.length ? sugestoes : null,
|
|
|
+ Atendentes: atendentes.length ? atendentes : null,
|
|
|
+ VisitaAgendada: f.VisitaAgendada === "sim" ? true : f.VisitaAgendada === "nao" ? false : null,
|
|
|
+ HorarioVisitaInformado: f.HorarioVisitaInformado || null,
|
|
|
+ HorarioVisita: f.HorarioVisita.trim() || null,
|
|
|
+ StatusCancelamento: f.StatusCancelamento || null,
|
|
|
+ MotivoCancelamento: f.MotivoCancelamento.trim() || null
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+async function salvarRotulo() {
|
|
|
+ if (salvando.value || !atendimento.value) return;
|
|
|
+ salvando.value = true;
|
|
|
+ try {
|
|
|
+ const atualizado = await salvarGoldenLabel(atendimento.value.Id, montarPayloadGoldenLabel());
|
|
|
+ atendimento.value = { ...atendimento.value, goldenLabel: atualizado };
|
|
|
+ toast.sucesso("Rótulo salvo.");
|
|
|
+ } catch (err) {
|
|
|
+ toast.erro(err?.message ?? "Falha ao salvar o rótulo.");
|
|
|
+ } finally {
|
|
|
+ salvando.value = false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
// volta para a lista de origem (Avaliações ou Não resolvidos) preservando página, filtros
|
|
|
// e item aberto (guardados na query string da rota anterior); se o gestor chegou por link
|
|
|
// direto, não há histórico para voltar — vai para a lista de avaliações limpa
|
|
|
-const ROTAS_ORIGEM = ["/atendimentos/avaliacoes", "/atendimentos/nao-resolvidos"];
|
|
|
+const ROTAS_ORIGEM = ["/atendimentos/avaliacoes", "/atendimentos/nao-resolvidos", "/atendimentos/golden-set"];
|
|
|
|
|
|
function voltar() {
|
|
|
const anterior = window.history.state?.back;
|
|
|
@@ -105,6 +168,7 @@ async function carregar(id) {
|
|
|
const resultado = await obterAtendimento(id);
|
|
|
if (String(route.params.id) !== String(id)) return;
|
|
|
atendimento.value = resultado;
|
|
|
+ form.value = criarFormulario(resultado.goldenLabel ?? null);
|
|
|
} catch (err) {
|
|
|
if (String(route.params.id) !== String(id)) return;
|
|
|
erro.value = err?.message ?? "Falha ao carregar o atendimento";
|
|
|
@@ -282,6 +346,193 @@ watch(() => route.params.id, carregar, { immediate: true });
|
|
|
</template>
|
|
|
</div>
|
|
|
</section>
|
|
|
+
|
|
|
+ <section
|
|
|
+ v-if="goldenLabel"
|
|
|
+ class="rounded-2xl border border-gray-200 bg-background/70 shadow-sm dark:border-gray-700 dark:bg-background/20"
|
|
|
+ >
|
|
|
+ <div class="flex items-center justify-between gap-3 border-b border-gray-200 px-4 py-3 dark:border-gray-700">
|
|
|
+ <div class="text-sm font-semibold text-gray-900 dark:text-gray-100">Rotulagem manual (golden set)</div>
|
|
|
+ <BaseBadge :variant="goldenLabel.Status === 'rotulado' ? 'success' : 'warning'">
|
|
|
+ {{ goldenLabel.Status === "rotulado" ? "Rotulado" : "Pendente" }}
|
|
|
+ </BaseBadge>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="grid grid-cols-1 gap-5 p-4 sm:grid-cols-2">
|
|
|
+ <div>
|
|
|
+ <label class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
|
|
|
+ Nota do atendente (1-10)
|
|
|
+ </label>
|
|
|
+ <div class="mt-1 flex items-center gap-2">
|
|
|
+ <input
|
|
|
+ v-model="form.ScoreAtendente"
|
|
|
+ type="number"
|
|
|
+ min="1"
|
|
|
+ max="10"
|
|
|
+ :disabled="form.SemAtendente"
|
|
|
+ class="w-24 rounded-lg border border-gray-200 bg-background px-3 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:opacity-50 dark:border-gray-700 dark:text-gray-200"
|
|
|
+ />
|
|
|
+ <label class="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
|
|
|
+ <input v-model="form.SemAtendente" type="checkbox" class="rounded" />
|
|
|
+ Sem atendente humano
|
|
|
+ </label>
|
|
|
+ </div>
|
|
|
+ <span class="mt-1 block text-xs text-gray-400 dark:text-gray-500">
|
|
|
+ IA: {{ avaliacao?.ScoreAtendente ?? "sem nota" }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div>
|
|
|
+ <label class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Resolvido</label>
|
|
|
+ <select
|
|
|
+ v-model="form.Resolvido"
|
|
|
+ class="mt-1 w-full rounded-lg border border-gray-200 bg-background px-3 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-primary/30 dark:border-gray-700 dark:text-gray-200"
|
|
|
+ >
|
|
|
+ <option value="">-- não rotulado --</option>
|
|
|
+ <option v-for="(label, value) in resolvidoLabel" :key="value" :value="value">{{ label }}</option>
|
|
|
+ </select>
|
|
|
+ <span class="mt-1 block text-xs text-gray-400 dark:text-gray-500">
|
|
|
+ IA: {{ resolvidoLabel[avaliacao?.Resolvido] ?? "—" }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div>
|
|
|
+ <label class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Sentimento do cliente</label>
|
|
|
+ <select
|
|
|
+ v-model="form.Sentimento"
|
|
|
+ class="mt-1 w-full rounded-lg border border-gray-200 bg-background px-3 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-primary/30 dark:border-gray-700 dark:text-gray-200"
|
|
|
+ >
|
|
|
+ <option value="">-- não rotulado --</option>
|
|
|
+ <option v-for="(label, value) in sentimentoLabel" :key="value" :value="value">{{ label }}</option>
|
|
|
+ </select>
|
|
|
+ <span class="mt-1 block text-xs text-gray-400 dark:text-gray-500">
|
|
|
+ IA: {{ sentimentoLabel[avaliacao?.Sentimento] ?? "—" }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div>
|
|
|
+ <label class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Cancelamento</label>
|
|
|
+ <select
|
|
|
+ v-model="form.StatusCancelamento"
|
|
|
+ class="mt-1 w-full rounded-lg border border-gray-200 bg-background px-3 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-primary/30 dark:border-gray-700 dark:text-gray-200"
|
|
|
+ >
|
|
|
+ <option value="">-- não rotulado --</option>
|
|
|
+ <option v-for="(label, value) in statusCancelamentoLabel" :key="value" :value="value">{{ label }}</option>
|
|
|
+ </select>
|
|
|
+ <span class="mt-1 block text-xs text-gray-400 dark:text-gray-500">
|
|
|
+ IA: {{ cancelamentoLabel[avaliacao?.StatusCancelamento] ?? "Não" }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div>
|
|
|
+ <label class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Visita agendada</label>
|
|
|
+ <select
|
|
|
+ v-model="form.VisitaAgendada"
|
|
|
+ class="mt-1 w-full rounded-lg border border-gray-200 bg-background px-3 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-primary/30 dark:border-gray-700 dark:text-gray-200"
|
|
|
+ >
|
|
|
+ <option value="">-- não informado --</option>
|
|
|
+ <option value="sim">Sim</option>
|
|
|
+ <option value="nao">Não</option>
|
|
|
+ </select>
|
|
|
+ <span class="mt-1 block text-xs text-gray-400 dark:text-gray-500">
|
|
|
+ IA: {{ avaliacao?.VisitaAgendada === true ? "Sim" : avaliacao?.VisitaAgendada === false ? "Não" : "—" }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div>
|
|
|
+ <label class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Horário de visita informado</label>
|
|
|
+ <select
|
|
|
+ v-model="form.HorarioVisitaInformado"
|
|
|
+ class="mt-1 w-full rounded-lg border border-gray-200 bg-background px-3 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-primary/30 dark:border-gray-700 dark:text-gray-200"
|
|
|
+ >
|
|
|
+ <option value="">-- não rotulado --</option>
|
|
|
+ <option v-for="(label, value) in HORARIO_VISITA_INFORMADO_LABEL" :key="value" :value="value">{{ label }}</option>
|
|
|
+ </select>
|
|
|
+ <span class="mt-1 block text-xs text-gray-400 dark:text-gray-500">
|
|
|
+ IA: {{ HORARIO_VISITA_INFORMADO_LABEL[avaliacao?.HorarioVisitaInformado] ?? "—" }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div>
|
|
|
+ <label class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Horário da visita</label>
|
|
|
+ <input
|
|
|
+ v-model="form.HorarioVisita"
|
|
|
+ type="text"
|
|
|
+ :disabled="form.HorarioVisitaInformado !== 'sim'"
|
|
|
+ class="mt-1 w-full rounded-lg border border-gray-200 bg-background px-3 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:opacity-50 dark:border-gray-700 dark:text-gray-200"
|
|
|
+ />
|
|
|
+ <span class="mt-1 block text-xs text-gray-400 dark:text-gray-500">IA: {{ avaliacao?.HorarioVisita || "—" }}</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div>
|
|
|
+ <label class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
|
|
|
+ Atendentes (separados por vírgula)
|
|
|
+ </label>
|
|
|
+ <input
|
|
|
+ v-model="form.AtendentesTexto"
|
|
|
+ type="text"
|
|
|
+ class="mt-1 w-full rounded-lg border border-gray-200 bg-background px-3 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-primary/30 dark:border-gray-700 dark:text-gray-200"
|
|
|
+ />
|
|
|
+ <span class="mt-1 block text-xs text-gray-400 dark:text-gray-500">
|
|
|
+ IA: {{ avaliacao?.Atendentes?.join(", ") || "—" }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="sm:col-span-2">
|
|
|
+ <label class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Justificativa da nota</label>
|
|
|
+ <textarea
|
|
|
+ v-model="form.JustificativaScore"
|
|
|
+ rows="2"
|
|
|
+ class="mt-1 w-full rounded-lg border border-gray-200 bg-background px-3 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-primary/30 dark:border-gray-700 dark:text-gray-200"
|
|
|
+ ></textarea>
|
|
|
+ <p v-if="avaliacao?.JustificativaScore" class="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
|
|
+ IA: {{ avaliacao.JustificativaScore }}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="sm:col-span-2">
|
|
|
+ <label class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Resumo</label>
|
|
|
+ <textarea
|
|
|
+ v-model="form.Resumo"
|
|
|
+ rows="3"
|
|
|
+ class="mt-1 w-full rounded-lg border border-gray-200 bg-background px-3 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-primary/30 dark:border-gray-700 dark:text-gray-200"
|
|
|
+ ></textarea>
|
|
|
+ <p v-if="avaliacao?.Resumo" class="mt-1 text-xs text-gray-400 dark:text-gray-500">IA: {{ avaliacao.Resumo }}</p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="sm:col-span-2">
|
|
|
+ <label class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
|
|
|
+ Sugestões de melhoria (uma por linha)
|
|
|
+ </label>
|
|
|
+ <textarea
|
|
|
+ v-model="form.SugestoesTexto"
|
|
|
+ rows="3"
|
|
|
+ class="mt-1 w-full rounded-lg border border-gray-200 bg-background px-3 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-primary/30 dark:border-gray-700 dark:text-gray-200"
|
|
|
+ ></textarea>
|
|
|
+ <p v-if="avaliacao?.Sugestoes?.length" class="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
|
|
+ IA: {{ avaliacao.Sugestoes.join(" · ") }}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="sm:col-span-2">
|
|
|
+ <label class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Motivo do cancelamento</label>
|
|
|
+ <textarea
|
|
|
+ v-model="form.MotivoCancelamento"
|
|
|
+ rows="2"
|
|
|
+ class="mt-1 w-full rounded-lg border border-gray-200 bg-background px-3 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-primary/30 dark:border-gray-700 dark:text-gray-200"
|
|
|
+ ></textarea>
|
|
|
+ <p v-if="avaliacao?.MotivoCancelamento" class="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
|
|
+ IA: {{ avaliacao.MotivoCancelamento }}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="flex items-center justify-end gap-3 border-t border-gray-200 px-4 py-3 dark:border-gray-700">
|
|
|
+ <BaseButton :loading="salvando" @click="salvarRotulo">
|
|
|
+ {{ salvando ? "Salvando..." : "Salvar rótulo" }}
|
|
|
+ </BaseButton>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
</div>
|
|
|
</div>
|
|
|
|