|
|
@@ -0,0 +1,352 @@
|
|
|
+<template>
|
|
|
+ <div>
|
|
|
+ <DefaultHeaderPage :title="{ value: campaign?.title || '', translate: false }">
|
|
|
+ <template #after>
|
|
|
+ <div class="row items-center q-gutter-sm q-mt-md">
|
|
|
+ <q-btn
|
|
|
+ v-if="campaign && permission_store.getAccess('avaliacao', 'edit')"
|
|
|
+ outline
|
|
|
+ color="violet-normal"
|
|
|
+ padding="8px 8px"
|
|
|
+ icon="mdi-file-edit-outline"
|
|
|
+ :label="$t('common.actions.edit')"
|
|
|
+ @click="onEdit"
|
|
|
+ />
|
|
|
+ <q-btn
|
|
|
+ v-if="isActive && permission_store.getAccess('avaliacao', 'edit')"
|
|
|
+ color="negative"
|
|
|
+ padding="8px 8px"
|
|
|
+ icon="mdi-stop-circle-outline"
|
|
|
+ :label="$t('evaluation.campaign.finish')"
|
|
|
+ :loading="finishing"
|
|
|
+ @click="onFinish"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </DefaultHeaderPage>
|
|
|
+
|
|
|
+ <div v-if="loading" class="flex flex-center q-pa-xl">
|
|
|
+ <q-spinner color="violet-normal" size="50px" />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <template v-else>
|
|
|
+ <div v-if="campaign?.description" class="text-body2 text-grey-8 q-mb-xs">
|
|
|
+ {{ campaign.description }}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="row items-center q-gutter-sm q-mb-md">
|
|
|
+ <q-chip
|
|
|
+ dense
|
|
|
+ square
|
|
|
+ text-color="white"
|
|
|
+ :color="isActive ? 'positive' : 'grey-6'"
|
|
|
+ :label="isActive ? $t('evaluation.campaign.status_active') : $t('evaluation.campaign.status_finished')"
|
|
|
+ />
|
|
|
+ <span class="text-caption text-grey-7">
|
|
|
+ {{ $t("evaluation.campaign.started_at") }}: {{ formatDate(campaign?.started_at) }}
|
|
|
+ </span>
|
|
|
+ <span v-if="campaign?.finished_at" class="text-caption text-grey-7">
|
|
|
+ {{ $t("evaluation.campaign.finished_at") }}: {{ formatDate(campaign.finished_at) }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="row q-col-gutter-md q-mb-md">
|
|
|
+ <div
|
|
|
+ v-for="card in statCards"
|
|
|
+ :key="card.key"
|
|
|
+ class="col-12 col-sm-6 col-md-4 col-lg"
|
|
|
+ >
|
|
|
+ <q-card flat class="stat-card bg-white">
|
|
|
+ <q-card-section class="q-pa-md">
|
|
|
+ <q-icon :name="card.icon" size="22px" color="violet-normal" />
|
|
|
+ <div class="text-h5 text-weight-bold q-mt-xs text-dark">
|
|
|
+ {{ card.value }}
|
|
|
+ </div>
|
|
|
+ <div class="text-caption text-grey-7 ellipsis">
|
|
|
+ {{ card.label }}
|
|
|
+ </div>
|
|
|
+ </q-card-section>
|
|
|
+ </q-card>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <q-card flat class="bg-white q-mb-md" style="border-radius: 12px">
|
|
|
+ <q-card-section>
|
|
|
+ <div class="text-subtitle2 text-weight-bold q-mb-md">
|
|
|
+ {{ $t("evaluation.stats.distribution") }}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div
|
|
|
+ v-for="item in stats.distribution"
|
|
|
+ :key="item.rating"
|
|
|
+ class="row items-center q-mb-xs no-wrap"
|
|
|
+ >
|
|
|
+ <div class="text-caption text-grey-8" style="width: 28px">
|
|
|
+ {{ item.rating }}
|
|
|
+ </div>
|
|
|
+ <q-icon name="mdi-star" color="amber-7" size="14px" class="q-mr-sm" />
|
|
|
+ <q-linear-progress
|
|
|
+ :value="distributionRatio(item.total)"
|
|
|
+ size="14px"
|
|
|
+ rounded
|
|
|
+ color="violet-normal"
|
|
|
+ track-color="grey-3"
|
|
|
+ class="col"
|
|
|
+ />
|
|
|
+ <div class="text-caption text-grey-8 q-ml-sm text-right" style="width: 42px">
|
|
|
+ {{ item.total }}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </q-card-section>
|
|
|
+ </q-card>
|
|
|
+
|
|
|
+ <DefaultTableServerSide
|
|
|
+ :columns="columns"
|
|
|
+ :api-call="fetchEvaluations"
|
|
|
+ :extra-filters="extraFilters"
|
|
|
+ :add-item="false"
|
|
|
+ :show-columns-select="false"
|
|
|
+ >
|
|
|
+ <template #filters>
|
|
|
+ <DefaultSelect
|
|
|
+ v-model="selectedSource"
|
|
|
+ :options="sourceOptions"
|
|
|
+ dense
|
|
|
+ outlined
|
|
|
+ clearable
|
|
|
+ :label="$t('evaluation.stats.source')"
|
|
|
+ style="min-width: 180px"
|
|
|
+ />
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <template #body-cell-source="{ row }">
|
|
|
+ <q-td>
|
|
|
+ <q-chip
|
|
|
+ dense
|
|
|
+ square
|
|
|
+ text-color="white"
|
|
|
+ :color="valueOf(row.source) === 'parceiro' ? 'teal-6' : 'violet-normal'"
|
|
|
+ :label="$t(`evaluation.target.${valueOf(row.source)}`)"
|
|
|
+ />
|
|
|
+ </q-td>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <template #body-cell-rating="{ row }">
|
|
|
+ <q-td>
|
|
|
+ <div class="row items-center no-wrap">
|
|
|
+ <q-icon name="mdi-star" color="amber-7" size="16px" class="q-mr-xs" />
|
|
|
+ <span class="text-weight-medium">{{ row.rating }}/10</span>
|
|
|
+ </div>
|
|
|
+ </q-td>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <template #body-cell-comment="{ row }">
|
|
|
+ <q-td style="max-width: 380px; white-space: normal">
|
|
|
+ <span v-if="row.comment">{{ row.comment }}</span>
|
|
|
+ <span v-else class="text-grey-6">-</span>
|
|
|
+ </q-td>
|
|
|
+ </template>
|
|
|
+ </DefaultTableServerSide>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+import { ref, computed, onMounted, defineAsyncComponent } from "vue";
|
|
|
+import { useRoute } from "vue-router";
|
|
|
+import { useQuasar } from "quasar";
|
|
|
+import { useI18n } from "vue-i18n";
|
|
|
+import { permissionStore } from "src/stores/permission";
|
|
|
+import {
|
|
|
+ getEvaluationCampaign,
|
|
|
+ getEvaluationCampaignStats,
|
|
|
+ getCampaignEvaluationsPaginated,
|
|
|
+ finishEvaluationCampaign,
|
|
|
+} from "src/api/evaluation";
|
|
|
+
|
|
|
+import DefaultTableServerSide from "src/components/defaults/DefaultTableServerSide.vue";
|
|
|
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
|
|
|
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
|
|
|
+
|
|
|
+const AddEditCampaignDialog = defineAsyncComponent(
|
|
|
+ () => import("src/pages/avaliacoes/components/AddEditCampaignDialog.vue"),
|
|
|
+);
|
|
|
+
|
|
|
+const route = useRoute();
|
|
|
+const $q = useQuasar();
|
|
|
+const { t } = useI18n();
|
|
|
+const permission_store = permissionStore();
|
|
|
+
|
|
|
+const campaignId = Number(route.params.id);
|
|
|
+
|
|
|
+const loading = ref(true);
|
|
|
+const finishing = ref(false);
|
|
|
+const campaign = ref(null);
|
|
|
+const stats = ref({ distribution: [], by_source: [] });
|
|
|
+const selectedSource = ref(null);
|
|
|
+
|
|
|
+const valueOf = (field) => (typeof field === "object" ? field?.value : field);
|
|
|
+
|
|
|
+const isActive = computed(() => valueOf(campaign.value?.status) === "ACTIVE");
|
|
|
+
|
|
|
+const formatAverage = (value) =>
|
|
|
+ value === null || value === undefined ? "-" : Number(value).toFixed(2).replace(".", ",");
|
|
|
+
|
|
|
+const sourceOptions = computed(() => [
|
|
|
+ { label: t("evaluation.target.associado"), value: "associado" },
|
|
|
+ { label: t("evaluation.target.parceiro"), value: "parceiro" },
|
|
|
+]);
|
|
|
+
|
|
|
+const extraFilters = computed(() => ({
|
|
|
+ source: selectedSource.value?.value ?? null,
|
|
|
+}));
|
|
|
+
|
|
|
+const averageBySource = (source) =>
|
|
|
+ stats.value.by_source?.find((item) => item.source === source);
|
|
|
+
|
|
|
+const statCards = computed(() => {
|
|
|
+ const cards = [
|
|
|
+ {
|
|
|
+ key: "total",
|
|
|
+ icon: "mdi-comment-multiple-outline",
|
|
|
+ label: t("evaluation.stats.total"),
|
|
|
+ value: stats.value.total ?? 0,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ key: "average",
|
|
|
+ icon: "mdi-star-outline",
|
|
|
+ label: t("evaluation.stats.average"),
|
|
|
+ value: formatAverage(stats.value.average),
|
|
|
+ },
|
|
|
+ {
|
|
|
+ key: "adherence",
|
|
|
+ icon: "mdi-account-check-outline",
|
|
|
+ label: t("evaluation.stats.adherence"),
|
|
|
+ value: `${(stats.value.adherence ?? 0).toString().replace(".", ",")}%`,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ key: "eligible",
|
|
|
+ icon: "mdi-account-group-outline",
|
|
|
+ label: t("evaluation.stats.eligible"),
|
|
|
+ value: stats.value.eligible_total ?? 0,
|
|
|
+ },
|
|
|
+ ];
|
|
|
+
|
|
|
+ (stats.value.by_source ?? []).forEach((item) => {
|
|
|
+ cards.push({
|
|
|
+ key: `avg_${item.source}`,
|
|
|
+ icon: item.source === "parceiro" ? "mdi-handshake-outline" : "mdi-account-outline",
|
|
|
+ label: t("evaluation.stats.average_by_source", {
|
|
|
+ source: t(`evaluation.target.${item.source}`),
|
|
|
+ }),
|
|
|
+ value: formatAverage(averageBySource(item.source)?.average),
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ return cards;
|
|
|
+});
|
|
|
+
|
|
|
+const maxDistribution = computed(() =>
|
|
|
+ Math.max(1, ...(stats.value.distribution ?? []).map((item) => item.total)),
|
|
|
+);
|
|
|
+
|
|
|
+const distributionRatio = (total) => total / maxDistribution.value;
|
|
|
+
|
|
|
+const formatDate = (value) => {
|
|
|
+ if (!value) return "-";
|
|
|
+ const [date, time] = value.split(" ");
|
|
|
+ return `${date.split("-").reverse().join("/")} ${time?.slice(0, 5) ?? ""}`.trim();
|
|
|
+};
|
|
|
+
|
|
|
+const columns = computed(() => [
|
|
|
+ {
|
|
|
+ name: "user_name",
|
|
|
+ label: t("common.terms.name"),
|
|
|
+ field: "user_name",
|
|
|
+ align: "left",
|
|
|
+ required: true,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "source",
|
|
|
+ label: t("evaluation.stats.source"),
|
|
|
+ field: "source",
|
|
|
+ align: "left",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "rating",
|
|
|
+ label: t("evaluation.stats.rating"),
|
|
|
+ field: "rating",
|
|
|
+ align: "left",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "comment",
|
|
|
+ label: t("evaluation.stats.comment"),
|
|
|
+ field: "comment",
|
|
|
+ align: "left",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "created_at",
|
|
|
+ label: t("evaluation.stats.date"),
|
|
|
+ field: "created_at",
|
|
|
+ align: "left",
|
|
|
+ format: (value) => formatDate(value),
|
|
|
+ },
|
|
|
+]);
|
|
|
+
|
|
|
+const fetchEvaluations = (params) =>
|
|
|
+ getCampaignEvaluationsPaginated(campaignId, params);
|
|
|
+
|
|
|
+const loadStats = async () => {
|
|
|
+ stats.value = await getEvaluationCampaignStats(campaignId);
|
|
|
+};
|
|
|
+
|
|
|
+const loadCampaign = async () => {
|
|
|
+ campaign.value = await getEvaluationCampaign(campaignId);
|
|
|
+};
|
|
|
+
|
|
|
+const onEdit = () => {
|
|
|
+ $q.dialog({
|
|
|
+ component: AddEditCampaignDialog,
|
|
|
+ componentProps: {
|
|
|
+ campaign: campaign.value,
|
|
|
+ title: () => t("evaluation.campaign.edit"),
|
|
|
+ },
|
|
|
+ }).onOk(() => loadCampaign());
|
|
|
+};
|
|
|
+
|
|
|
+const onFinish = () => {
|
|
|
+ $q.dialog({
|
|
|
+ title: t("evaluation.campaign.finish"),
|
|
|
+ message: t("evaluation.campaign.finish_confirm"),
|
|
|
+ cancel: true,
|
|
|
+ persistent: true,
|
|
|
+ }).onOk(async () => {
|
|
|
+ finishing.value = true;
|
|
|
+ try {
|
|
|
+ campaign.value = await finishEvaluationCampaign(campaignId);
|
|
|
+ } catch (error) {
|
|
|
+ $q.notify({
|
|
|
+ type: "negative",
|
|
|
+ message: error?.response?.data?.message || t("http.errors.failed"),
|
|
|
+ });
|
|
|
+ } finally {
|
|
|
+ finishing.value = false;
|
|
|
+ }
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+onMounted(async () => {
|
|
|
+ try {
|
|
|
+ await Promise.all([loadCampaign(), loadStats()]);
|
|
|
+ } finally {
|
|
|
+ loading.value = false;
|
|
|
+ }
|
|
|
+});
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped lang="scss">
|
|
|
+.stat-card {
|
|
|
+ border-radius: 12px;
|
|
|
+ height: 100%;
|
|
|
+}
|
|
|
+</style>
|