Explorar o código

feat: :sparkles: fat (formulario satisfacao) criado campanhas de avaliacao

criada funcao de campanha de avaliacao. Um adm pode criar campanha e os associados / parceiros poderao avaliar o sistema, com campo para deixar observacao.

fase:dev | origin:escopo
Gustavo Zanatta hai 1 día
pai
achega
5ebafa8b00

+ 83 - 0
src/api/evaluation.js

@@ -0,0 +1,83 @@
+import api from "src/api";
+
+// ─── Rotas do Associado ───────────────────────────────────────────────────────
+
+export const getPendingEvaluationAssociado = async () => {
+  const { data } = await api.get("/associado/evaluation/pending");
+  return data.payload;
+};
+
+export const submitEvaluationAssociado = async (payload) => {
+  const { data } = await api.post("/associado/evaluation", payload);
+  return data.payload;
+};
+
+// ─── Rotas do Parceiro ────────────────────────────────────────────────────────
+
+export const getPendingEvaluationParceiro = async () => {
+  const { data } = await api.get("/parceiro/evaluation/pending");
+  return data.payload;
+};
+
+export const submitEvaluationParceiro = async (payload) => {
+  const { data } = await api.post("/parceiro/evaluation", payload);
+  return data.payload;
+};
+
+// ─── Rotas Admin ──────────────────────────────────────────────────────────────
+
+export const getEvaluationCampaignsPaginated = async ({ page = 1, perPage = 10 } = {}) => {
+  const { data } = await api.get("/evaluation-campaign/paginated", {
+    params: { page, per_page: perPage },
+  });
+  return { data: { result: data.payload } };
+};
+
+export const getActiveEvaluationCampaign = async () => {
+  const { data } = await api.get("/evaluation-campaign/active");
+  return data.payload;
+};
+
+export const getEvaluationCampaign = async (id) => {
+  const { data } = await api.get(`/evaluation-campaign/${id}`);
+  return data.payload;
+};
+
+export const createEvaluationCampaign = async (payload) => {
+  const { data } = await api.post("/evaluation-campaign", payload);
+  return data.payload;
+};
+
+export const updateEvaluationCampaign = async (id, payload) => {
+  const { data } = await api.put(`/evaluation-campaign/${id}`, payload);
+  return data.payload;
+};
+
+export const deleteEvaluationCampaign = async (id) => {
+  const { data } = await api.delete(`/evaluation-campaign/${id}`);
+  return data.payload;
+};
+
+export const finishEvaluationCampaign = async (id) => {
+  const { data } = await api.patch(`/evaluation-campaign/${id}/finish`);
+  return data.payload;
+};
+
+export const getEvaluationCampaignStats = async (id) => {
+  const { data } = await api.get(`/evaluation-campaign/${id}/stats`);
+  return data.payload;
+};
+
+export const getCampaignEvaluationsPaginated = async (
+  campaignId,
+  { page = 1, perPage = 10, filter, source, min_rating, max_rating } = {},
+) => {
+  const params = { page, per_page: perPage };
+  if (filter) params.search = filter;
+  if (source) params.source = source;
+  if (min_rating) params.min_rating = min_rating;
+  if (max_rating) params.max_rating = max_rating;
+
+  const { data } = await api.get(`/evaluation-campaign/${campaignId}/evaluations`, { params });
+  return { data: { result: data.payload } };
+};

+ 1 - 0
src/api/firstAccess.js

@@ -15,6 +15,7 @@ export const registerFirstAccess = async (payload) => {
 
 
   const { data } = await api.post("/first-access/register", formData, {
   const { data } = await api.post("/first-access/register", formData, {
     headers: { "Content-Type": "multipart/form-data" },
     headers: { "Content-Type": "multipart/form-data" },
+    skipSuccessNotify: true,
   });
   });
   return data.payload;
   return data.payload;
 };
 };

+ 1 - 1
src/boot/axios.js

@@ -96,7 +96,7 @@ const errorInterceptor = async (error, router) => {
 };
 };
 
 
 const successInterceptor = (response) => {
 const successInterceptor = (response) => {
-  if (response?.data?.message) {
+  if (response?.data?.message && !response.config?.skipSuccessNotify) {
     Notify.create({
     Notify.create({
       message: response?.data?.message,
       message: response?.data?.message,
       type: "positive",
       type: "positive",

+ 167 - 0
src/components/EvaluationDialog.vue

@@ -0,0 +1,167 @@
+<template>
+  <q-dialog
+    ref="dialogRef"
+    persistent
+    no-esc-dismiss
+    no-backdrop-dismiss
+    @hide="onDialogHide"
+  >
+    <q-card class="evaluation-card">
+      <q-card-section class="evaluation-header">
+        <div class="text-h6 text-white text-weight-bold">
+          {{ campaign.title }}
+        </div>
+        <div class="text-caption text-white q-mt-xs" style="opacity: 0.85">
+          {{ campaign.description || $t("evaluation.dialog.default_description") }}
+        </div>
+      </q-card-section>
+
+      <q-card-section class="q-pt-lg">
+        <div class="text-subtitle2 text-center text-weight-medium q-mb-sm">
+          {{ $t("evaluation.dialog.rating_label") }}
+        </div>
+
+        <div class="flex flex-center">
+          <q-rating
+            v-model="form.rating"
+            :max="10"
+            size="28px"
+            color="amber-7"
+            color-selected="amber-8"
+            icon="mdi-star-outline"
+            icon-selected="mdi-star"
+            class="evaluation-rating"
+          />
+        </div>
+
+        <div class="text-center q-mt-xs" style="min-height: 20px">
+          <span v-if="form.rating" class="text-caption text-grey-7">
+            {{ $t("evaluation.dialog.rating_value", { rating: form.rating }) }}
+          </span>
+        </div>
+
+        <q-input
+          v-model="form.comment"
+          outlined
+          type="textarea"
+          rows="4"
+          counter
+          maxlength="2000"
+          class="q-mt-md"
+          :label="$t('evaluation.dialog.comment_label')"
+          :hint="$t('evaluation.dialog.comment_hint')"
+        />
+      </q-card-section>
+
+      <q-card-actions class="q-px-md q-pb-md column q-gutter-sm">
+        <q-btn
+          unelevated
+          no-caps
+          color="violet-normal"
+          class="full-width"
+          :label="$t('evaluation.dialog.submit')"
+          :loading="saving"
+          :disable="!form.rating"
+          @click="onSubmit"
+        />
+        <q-btn
+          flat
+          dense
+          no-caps
+          color="grey-7"
+          class="full-width q-ml-none"
+          :label="$t('auth.logout')"
+          :disable="saving"
+          @click="onLogout"
+        />
+      </q-card-actions>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref } from "vue";
+import { useRouter } from "vue-router";
+import { useQuasar, useDialogPluginComponent } from "quasar";
+import { useI18n } from "vue-i18n";
+import { userStore } from "src/stores/user";
+import { useAuth } from "src/composables/useAuth";
+import {
+  submitEvaluationAssociado,
+  submitEvaluationParceiro,
+} from "src/api/evaluation";
+
+const { campaign } = defineProps({
+  campaign: {
+    type: Object,
+    required: true,
+  },
+});
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
+const $q = useQuasar();
+const { t } = useI18n();
+const store = userStore();
+const router = useRouter();
+const { logout } = useAuth();
+
+const saving = ref(false);
+const form = ref({
+  rating: 0,
+  comment: "",
+});
+
+const onSubmit = async () => {
+  if (!form.value.rating) return;
+
+  saving.value = true;
+  try {
+    const submit = store.isParceiro
+      ? submitEvaluationParceiro
+      : submitEvaluationAssociado;
+
+    await submit({
+      evaluation_campaign_id: campaign.id,
+      rating: form.value.rating,
+      comment: form.value.comment || null,
+    });
+
+    $q.notify({ type: "positive", message: t("evaluation.dialog.success") });
+    onDialogOK();
+  } catch (error) {
+    $q.notify({
+      type: "negative",
+      message: error?.response?.data?.message || t("http.errors.failed"),
+    });
+  } finally {
+    saving.value = false;
+  }
+};
+
+const onLogout = async () => {
+  await logout();
+  onDialogHide();
+  router.push({ name: "LoginPage" });
+};
+</script>
+
+<style scoped lang="scss">
+@use "src/css/quasar.variables.scss" as *;
+
+.evaluation-card {
+  width: 95vw;
+  max-width: 520px;
+}
+
+.evaluation-header {
+  background: linear-gradient(135deg, $violet-normal 0%, $violet-dark 100%);
+  border-radius: 4px 4px 0 0;
+}
+
+.evaluation-rating {
+  flex-wrap: wrap;
+  justify-content: center;
+}
+</style>

+ 10 - 6
src/composables/useAuth.js

@@ -10,13 +10,17 @@ export const useAuth = () => {
     await permissionStore().fetchScopes();
     await permissionStore().fetchScopes();
   };
   };
 
 
-  const login = async (identifier, password, tipo) => {
+  const login = async (identifier, password, tipo, options = {}) => {
     try {
     try {
-      const response = await api.post("/login", {
-        identifier,
-        password,
-        tipo,
-      });
+      const response = await api.post(
+        "/login",
+        {
+          identifier,
+          password,
+          tipo,
+        },
+        options,
+      );
 
 
       if (response.status === 200) {
       if (response.status === 200) {
         await setAuthDataFromPayload(response.data.payload);
         await setAuthDataFromPayload(response.data.payload);

+ 56 - 0
src/i18n/locales/en.json

@@ -1,5 +1,9 @@
 {
 {
   "page": {
   "page": {
+    "avaliacoes": {
+      "description": "Manage the system evaluation campaigns",
+      "detail_description": "Evaluations received in this campaign"
+    },
     "loja": {
     "loja": {
       "description": "Manage the products available in the store"
       "description": "Manage the products available in the store"
     },
     },
@@ -434,6 +438,7 @@
       "my_appointments": "My Appointments",
       "my_appointments": "My Appointments",
       "received_appointments": "Received Appointments",
       "received_appointments": "Received Appointments",
       "notifications": "Notifications",
       "notifications": "Notifications",
+      "evaluations": "Evaluations",
       "categories": "Categories",
       "categories": "Categories",
       "my_profile": "My Profile",
       "my_profile": "My Profile",
       "my_services": "My Services",
       "my_services": "My Services",
@@ -789,6 +794,57 @@
     "pending_read_hint": "Click on each notification to mark it as read.",
     "pending_read_hint": "Click on each notification to mark it as read.",
     "pending_read_close": "Close"
     "pending_read_close": "Close"
   },
   },
+  "evaluation": {
+    "campaign": {
+      "singular": "Evaluation Campaign",
+      "plural": "Evaluation Campaigns",
+      "new": "New Campaign",
+      "edit": "Edit Campaign",
+      "title": "Title",
+      "title_placeholder": "Campaign title",
+      "description": "Description",
+      "description_placeholder": "Text shown to the user on the evaluation screen",
+      "target": "Target audience",
+      "started_at": "Start",
+      "finished_at": "End",
+      "status_active": "Active",
+      "status_finished": "Finished",
+      "finish": "Finish campaign",
+      "finish_confirm": "Do you want to finish this campaign? Users will no longer be asked to evaluate and it cannot be reopened.",
+      "delete_confirm": "Do you want to delete this campaign? Its evaluations will also be removed.",
+      "replace_active_warning": "The new campaign goes live immediately and the current active campaign will be finished.",
+      "starts_active_warning": "The campaign goes live immediately after being created.",
+      "total_evaluations": "Evaluations",
+      "average": "Average rating",
+      "created_at": "Created at"
+    },
+    "target": {
+      "todos": "Everyone",
+      "associado": "Members",
+      "parceiro": "Partners"
+    },
+    "stats": {
+      "total": "Total evaluations",
+      "average": "Average rating",
+      "adherence": "Response rate",
+      "eligible": "Eligible users",
+      "average_by_source": "{source} average",
+      "distribution": "Rating distribution",
+      "source": "Source",
+      "rating": "Rating",
+      "comment": "Comment",
+      "date": "Answered at"
+    },
+    "dialog": {
+      "default_description": "Your feedback helps us improve the system.",
+      "rating_label": "How would you rate the system?",
+      "rating_value": "{rating} out of 10",
+      "comment_label": "Comment",
+      "comment_hint": "Optional",
+      "submit": "Submit evaluation",
+      "success": "Evaluation submitted successfully!"
+    }
+  },
   "associate_validation": {
   "associate_validation": {
     "title": "Validate Card",
     "title": "Validate Card",
     "qr_code": "QR Code",
     "qr_code": "QR Code",

+ 56 - 0
src/i18n/locales/es.json

@@ -1,5 +1,9 @@
 {
 {
   "page": {
   "page": {
+    "avaliacoes": {
+      "description": "Gestione las campañas de evaluación del sistema",
+      "detail_description": "Evaluaciones recibidas en esta campaña"
+    },
     "loja": {
     "loja": {
       "description": "Gestione los productos disponibles en la tienda"
       "description": "Gestione los productos disponibles en la tienda"
     },
     },
@@ -435,6 +439,7 @@
       "my_appointments": "Mis Citas",
       "my_appointments": "Mis Citas",
       "received_appointments": "Citas Recibidas",
       "received_appointments": "Citas Recibidas",
       "notifications": "Notificaciones",
       "notifications": "Notificaciones",
+      "evaluations": "Evaluaciones",
       "categories": "Categorías",
       "categories": "Categorías",
       "my_profile": "Mi Perfil",
       "my_profile": "Mi Perfil",
       "my_services": "Mis Servicios",
       "my_services": "Mis Servicios",
@@ -789,6 +794,57 @@
     "pending_read_hint": "Haga clic en cada notificación para marcarla como leída.",
     "pending_read_hint": "Haga clic en cada notificación para marcarla como leída.",
     "pending_read_close": "Cerrar"
     "pending_read_close": "Cerrar"
   },
   },
+  "evaluation": {
+    "campaign": {
+      "singular": "Campaña de Evaluación",
+      "plural": "Campañas de Evaluación",
+      "new": "Nueva Campaña",
+      "edit": "Editar Campaña",
+      "title": "Título",
+      "title_placeholder": "Título de la campaña",
+      "description": "Descripción",
+      "description_placeholder": "Texto mostrado al usuario en la pantalla de evaluación",
+      "target": "Público objetivo",
+      "started_at": "Inicio",
+      "finished_at": "Finalización",
+      "status_active": "Activa",
+      "status_finished": "Finalizada",
+      "finish": "Finalizar campaña",
+      "finish_confirm": "¿Desea finalizar esta campaña? Los usuarios dejarán de recibir la solicitud de evaluación y no podrá reabrirse.",
+      "delete_confirm": "¿Desea eliminar esta campaña? Las evaluaciones vinculadas también serán eliminadas.",
+      "replace_active_warning": "La nueva campaña entra en vigor de inmediato y la campaña activa actual será finalizada.",
+      "starts_active_warning": "La campaña entra en vigor inmediatamente después de ser creada.",
+      "total_evaluations": "Evaluaciones",
+      "average": "Nota media",
+      "created_at": "Creada el"
+    },
+    "target": {
+      "todos": "Todos",
+      "associado": "Asociados",
+      "parceiro": "Socios"
+    },
+    "stats": {
+      "total": "Total de evaluaciones",
+      "average": "Nota media",
+      "adherence": "Adhesión",
+      "eligible": "Usuarios elegibles",
+      "average_by_source": "Media {source}",
+      "distribution": "Distribución de las notas",
+      "source": "Origen",
+      "rating": "Nota",
+      "comment": "Observación",
+      "date": "Respondida el"
+    },
+    "dialog": {
+      "default_description": "Su opinión nos ayuda a mejorar el sistema.",
+      "rating_label": "¿Qué nota le da al sistema?",
+      "rating_value": "{rating} de 10",
+      "comment_label": "Observación",
+      "comment_hint": "Opcional",
+      "submit": "Enviar evaluación",
+      "success": "¡Evaluación enviada con éxito!"
+    }
+  },
   "associate_validation": {
   "associate_validation": {
     "title": "Validar Tarjeta",
     "title": "Validar Tarjeta",
     "qr_code": "Código QR",
     "qr_code": "Código QR",

+ 56 - 0
src/i18n/locales/pt.json

@@ -1,5 +1,9 @@
 {
 {
   "page": {
   "page": {
+    "avaliacoes": {
+      "description": "Gerencie as campanhas de avaliação do sistema",
+      "detail_description": "Avaliações recebidas nesta campanha"
+    },
     "loja": {
     "loja": {
       "description": "Gerencie os produtos disponíveis na loja"
       "description": "Gerencie os produtos disponíveis na loja"
     },
     },
@@ -435,6 +439,7 @@
       "my_appointments": "Meus Agendamentos",
       "my_appointments": "Meus Agendamentos",
       "received_appointments": "Agendamentos Recebidos",
       "received_appointments": "Agendamentos Recebidos",
       "notifications": "Notificações",
       "notifications": "Notificações",
+      "evaluations": "Avaliações",
       "categories": "Categorias",
       "categories": "Categorias",
       "my_profile": "Meu Perfil",
       "my_profile": "Meu Perfil",
       "my_services": "Meus Serviços",
       "my_services": "Meus Serviços",
@@ -790,6 +795,57 @@
     "pending_read_hint": "Clique em cada notificação para marcá-la como lida.",
     "pending_read_hint": "Clique em cada notificação para marcá-la como lida.",
     "pending_read_close": "Fechar"
     "pending_read_close": "Fechar"
   },
   },
+  "evaluation": {
+    "campaign": {
+      "singular": "Campanha de Avaliação",
+      "plural": "Campanhas de Avaliação",
+      "new": "Nova Campanha",
+      "edit": "Editar Campanha",
+      "title": "Título",
+      "title_placeholder": "Título da campanha",
+      "description": "Descrição",
+      "description_placeholder": "Texto exibido ao usuário na tela de avaliação",
+      "target": "Público-alvo",
+      "started_at": "Início",
+      "finished_at": "Encerramento",
+      "status_active": "Ativa",
+      "status_finished": "Encerrada",
+      "finish": "Encerrar campanha",
+      "finish_confirm": "Deseja encerrar esta campanha? Os usuários deixarão de receber a solicitação de avaliação e ela não poderá ser reaberta.",
+      "delete_confirm": "Deseja excluir esta campanha? As avaliações vinculadas também serão removidas.",
+      "replace_active_warning": "A nova campanha entra no ar imediatamente e a campanha ativa atual será encerrada.",
+      "starts_active_warning": "A campanha entra no ar imediatamente após a criação.",
+      "total_evaluations": "Avaliações",
+      "average": "Nota média",
+      "created_at": "Criada em"
+    },
+    "target": {
+      "todos": "Todos",
+      "associado": "Associados",
+      "parceiro": "Parceiros"
+    },
+    "stats": {
+      "total": "Total de avaliações",
+      "average": "Nota média",
+      "adherence": "Adesão",
+      "eligible": "Usuários elegíveis",
+      "average_by_source": "Média {source}",
+      "distribution": "Distribuição das notas",
+      "source": "Origem",
+      "rating": "Nota",
+      "comment": "Observação",
+      "date": "Respondida em"
+    },
+    "dialog": {
+      "default_description": "Sua opinião nos ajuda a melhorar o sistema.",
+      "rating_label": "Qual nota você dá para o sistema?",
+      "rating_value": "{rating} de 10",
+      "comment_label": "Observação",
+      "comment_hint": "Opcional",
+      "submit": "Enviar avaliação",
+      "success": "Avaliação enviada com sucesso!"
+    }
+  },
   "associate_validation": {
   "associate_validation": {
     "title": "Validar Carteirinha",
     "title": "Validar Carteirinha",
     "qr_code": "QR Code",
     "qr_code": "QR Code",

+ 29 - 2
src/layouts/MainLayout.vue

@@ -79,6 +79,11 @@ import LeftMenuLayoutMobile from "src/components/layout/LeftMenuLayoutMobile.vue
 import AppHeader from "src/components/layout/AppHeader.vue";
 import AppHeader from "src/components/layout/AppHeader.vue";
 import UnreadNotificationsDialog from "src/components/UnreadNotificationsDialog.vue";
 import UnreadNotificationsDialog from "src/components/UnreadNotificationsDialog.vue";
 import CompleteProfileDialog from "src/components/CompleteProfileDialog.vue";
 import CompleteProfileDialog from "src/components/CompleteProfileDialog.vue";
+import EvaluationDialog from "src/components/EvaluationDialog.vue";
+import {
+  getPendingEvaluationAssociado,
+  getPendingEvaluationParceiro,
+} from "src/api/evaluation";
 
 
 const store = userStore();
 const store = userStore();
 const router = useRouter();
 const router = useRouter();
@@ -108,15 +113,37 @@ const checkUnreadNotifications = () => {
   }
   }
 };
 };
 
 
+const checkPendingEvaluation = async () => {
+  try {
+    const getPending = store.isParceiro
+      ? getPendingEvaluationParceiro
+      : getPendingEvaluationAssociado;
+
+    const campaign = await getPending();
+
+    if (!campaign) {
+      checkUnreadNotifications();
+      return;
+    }
+
+    $q.dialog({
+      component: EvaluationDialog,
+      componentProps: { campaign },
+    }).onOk(() => checkUnreadNotifications());
+  } catch {
+    checkUnreadNotifications();
+  }
+};
+
 onMounted(async () => {
 onMounted(async () => {
   if (store.isAssociado || store.isParceiro) {
   if (store.isAssociado || store.isParceiro) {
     await store.fetchUser();
     await store.fetchUser();
 
 
     if (store.isAssociado && isProfileIncomplete(store.user)) {
     if (store.isAssociado && isProfileIncomplete(store.user)) {
       $q.dialog({ component: CompleteProfileDialog })
       $q.dialog({ component: CompleteProfileDialog })
-        .onOk(() => checkUnreadNotifications());
+        .onOk(() => checkPendingEvaluation());
     } else {
     } else {
-      checkUnreadNotifications();
+      await checkPendingEvaluation();
     }
     }
   }
   }
 });
 });

+ 352 - 0
src/pages/avaliacoes/EvaluationCampaignDetailPage.vue

@@ -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>

+ 210 - 0
src/pages/avaliacoes/EvaluationCampaignsPage.vue

@@ -0,0 +1,210 @@
+<template>
+  <div>
+    <DefaultHeaderPage>
+      <template #after>
+        <q-btn
+          v-if="permission_store.getAccess('avaliacao', 'add')"
+          color="primary"
+          padding="8px 8px"
+          :label="$t('evaluation.campaign.new')"
+          icon="mdi-plus"
+          class="q-mt-md"
+          @click="onAddItem"
+        />
+      </template>
+    </DefaultHeaderPage>
+
+    <DefaultTableServerSide
+      ref="tableRef"
+      :columns="columns"
+      :api-call="getEvaluationCampaignsPaginated"
+      :show-search-field="false"
+      :add-item="false"
+      open-item
+      @on-row-click="onOpenDetail"
+    >
+      <template #body-cell-status="{ row }">
+        <q-td>
+          <q-chip
+            dense
+            square
+            text-color="white"
+            :color="isActive(row) ? 'positive' : 'grey-6'"
+            :label="isActive(row) ? $t('evaluation.campaign.status_active') : $t('evaluation.campaign.status_finished')"
+          />
+        </q-td>
+      </template>
+
+      <template #body-cell-target="{ row }">
+        <q-td>
+          {{ $t(`evaluation.target.${valueOf(row.target)}`) }}
+        </q-td>
+      </template>
+
+      <template #body-cell-average="{ row }">
+        <q-td>
+          <span v-if="row.evaluations_avg_rating">
+            {{ formatAverage(row.evaluations_avg_rating) }}
+          </span>
+          <span v-else class="text-grey-6">-</span>
+        </q-td>
+      </template>
+
+      <template #body-cell-actions="{ row }">
+        <q-btn
+          v-if="permission_store.getAccess('avaliacao', 'delete')"
+          outline
+          dense
+          color="negative"
+          style="width: 36px"
+          class="q-ml-auto q-mr-sm"
+          @click.prevent.stop="onDeleteItem(row)"
+        >
+          <q-icon name="mdi-delete" />
+        </q-btn>
+      </template>
+    </DefaultTableServerSide>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, defineAsyncComponent, useTemplateRef } from "vue";
+import { useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+import { useRouter } from "vue-router";
+import { permissionStore } from "src/stores/permission";
+import {
+  getEvaluationCampaignsPaginated,
+  getActiveEvaluationCampaign,
+  deleteEvaluationCampaign,
+} from "src/api/evaluation";
+
+import DefaultTableServerSide from "src/components/defaults/DefaultTableServerSide.vue";
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+
+const AddEditCampaignDialog = defineAsyncComponent(
+  () => import("src/pages/avaliacoes/components/AddEditCampaignDialog.vue"),
+);
+
+const permission_store = permissionStore();
+const $q = useQuasar();
+const { t } = useI18n();
+const router = useRouter();
+const tableRef = useTemplateRef("tableRef");
+
+const valueOf = (field) => (typeof field === "object" ? field?.value : field);
+
+const isActive = (row) => valueOf(row.status) === "ACTIVE";
+
+const formatAverage = (value) => Number(value).toFixed(2).replace(".", ",");
+
+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: "title",
+    label: t("evaluation.campaign.title"),
+    field: "title",
+    align: "left",
+    required: true,
+  },
+  {
+    name: "target",
+    label: t("evaluation.campaign.target"),
+    field: "target",
+    align: "left",
+  },
+  {
+    name: "status",
+    label: t("common.terms.status"),
+    field: "status",
+    align: "left",
+  },
+  {
+    name: "evaluations_count",
+    label: t("evaluation.campaign.total_evaluations"),
+    field: "evaluations_count",
+    align: "left",
+  },
+  {
+    name: "average",
+    label: t("evaluation.campaign.average"),
+    field: "evaluations_avg_rating",
+    align: "left",
+  },
+  {
+    name: "started_at",
+    label: t("evaluation.campaign.started_at"),
+    field: "started_at",
+    align: "left",
+    format: (value) => formatDate(value),
+  },
+  {
+    name: "finished_at",
+    label: t("evaluation.campaign.finished_at"),
+    field: "finished_at",
+    align: "left",
+    format: (value) => formatDate(value),
+  },
+  {
+    name: "actions",
+    label: "",
+    field: "actions",
+    align: "right",
+    required: true,
+  },
+]);
+
+const activeCampaign = ref(null);
+
+const loadActiveCampaign = async () => {
+  try {
+    activeCampaign.value = await getActiveEvaluationCampaign();
+  } catch {
+    activeCampaign.value = null;
+  }
+};
+
+const refreshAll = async () => {
+  await Promise.all([tableRef.value?.refresh(), loadActiveCampaign()]);
+};
+
+const onOpenDetail = ({ row }) => {
+  router.push({ name: "AvaliacaoCampanhaPage", params: { id: row.id } });
+};
+
+const onAddItem = () => {
+  $q.dialog({
+    component: AddEditCampaignDialog,
+    componentProps: {
+      hasActiveCampaign: !!activeCampaign.value,
+      title: () => t("evaluation.campaign.new"),
+    },
+  }).onOk(() => refreshAll());
+};
+
+const onDeleteItem = (row) => {
+  $q.dialog({
+    title: t("common.actions.delete"),
+    message: t("evaluation.campaign.delete_confirm"),
+    cancel: true,
+    persistent: true,
+  }).onOk(async () => {
+    try {
+      await deleteEvaluationCampaign(row.id);
+      await refreshAll();
+    } catch (error) {
+      $q.notify({
+        type: "negative",
+        message: error?.response?.data?.message || t("http.errors.failed"),
+      });
+    }
+  });
+};
+
+onMounted(() => loadActiveCampaign());
+</script>

+ 155 - 0
src/pages/avaliacoes/components/AddEditCampaignDialog.vue

@@ -0,0 +1,155 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="q-dialog-plugin overflow-hidden" style="width: 700px">
+      <DefaultDialogHeader :title="title" @close="onDialogCancel" />
+      <q-form ref="formRef" @submit="onOKClick">
+        <q-card-section class="row q-col-gutter-sm q-pt-none">
+          <DefaultInput
+            v-model="form.title"
+            v-model:error="validationErrors.title"
+            :rules="[inputRules.required]"
+            :label="$t('evaluation.campaign.title')"
+            :placeholder="$t('evaluation.campaign.title_placeholder')"
+            class="col-12"
+          />
+
+          <DefaultInput
+            v-model="form.description"
+            v-model:error="validationErrors.description"
+            type="textarea"
+            rows="3"
+            :label="$t('evaluation.campaign.description')"
+            :placeholder="$t('evaluation.campaign.description_placeholder')"
+            class="col-12"
+          />
+
+          <DefaultSelect
+            v-if="!campaign"
+            v-model="selectedTarget"
+            v-model:error="validationErrors.target"
+            :options="targetOptions"
+            :rules="[inputRules.required]"
+            :label="$t('evaluation.campaign.target')"
+            :placeholder="$t('evaluation.campaign.target')"
+            class="col-12"
+          />
+
+          <div v-if="!campaign" class="col-12">
+            <q-banner dense rounded class="bg-orange-1 text-orange-9">
+              <template #avatar>
+                <q-icon name="mdi-alert-outline" color="orange-9" />
+              </template>
+              {{
+                hasActiveCampaign
+                  ? $t("evaluation.campaign.replace_active_warning")
+                  : $t("evaluation.campaign.starts_active_warning")
+              }}
+            </q-banner>
+          </div>
+        </q-card-section>
+
+        <q-card-actions>
+          <q-space />
+          <q-btn
+            outline
+            color="negative"
+            :label="$t('common.actions.cancel')"
+            @click="onDialogCancel"
+          />
+          <q-btn
+            color="primary"
+            :label="campaign ? $t('common.actions.save') : $t('common.actions.add')"
+            type="submit"
+            :loading="loading"
+            :disable="campaign && !hasUpdatedFields"
+          />
+        </q-card-actions>
+      </q-form>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, onMounted, watch, useTemplateRef } from "vue";
+import { useDialogPluginComponent } from "quasar";
+import { useI18n } from "vue-i18n";
+import { useInputRules } from "src/composables/useInputRules";
+import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+import {
+  createEvaluationCampaign,
+  updateEvaluationCampaign,
+} from "src/api/evaluation";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { campaign, title, hasActiveCampaign } = defineProps({
+  campaign: {
+    type: Object,
+    default: null,
+  },
+  title: {
+    type: Function,
+    default: () => useI18n().t("evaluation.campaign.singular"),
+  },
+  hasActiveCampaign: {
+    type: Boolean,
+    default: false,
+  },
+});
+
+const { t } = useI18n();
+const { inputRules } = useInputRules();
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
+
+const formRef = useTemplateRef("formRef");
+
+const { form, getUpdatedFields, hasUpdatedFields } = useFormUpdateTracker({
+  title: campaign?.title ?? "",
+  description: campaign?.description ?? "",
+  target: campaign?.target?.value ?? campaign?.target ?? "todos",
+});
+
+const {
+  loading,
+  validationErrors,
+  execute: submitForm,
+} = useSubmitHandler({
+  onSuccess: () => onDialogOK(true),
+  formRef: formRef,
+});
+
+const targetOptions = ref([
+  { label: t("evaluation.target.todos"), value: "todos" },
+  { label: t("evaluation.target.associado"), value: "associado" },
+  { label: t("evaluation.target.parceiro"), value: "parceiro" },
+]);
+
+const selectedTarget = ref(targetOptions.value[0]);
+
+const onOKClick = async () => {
+  if (campaign) {
+    await submitForm(() =>
+      updateEvaluationCampaign(campaign.id, { ...getUpdatedFields.value }),
+    );
+  } else {
+    await submitForm(() => createEvaluationCampaign({ ...form }));
+  }
+};
+
+watch(selectedTarget, () => {
+  form.target = selectedTarget.value?.value;
+});
+
+onMounted(() => {
+  selectedTarget.value =
+    targetOptions.value.find((option) => option.value === form.target) ??
+    targetOptions.value[0];
+});
+</script>

+ 36 - 3
src/pages/login/FirstAccessFormPage.vue

@@ -173,7 +173,7 @@
 </template>
 </template>
 
 
 <script setup>
 <script setup>
-import { ref, onBeforeMount, useTemplateRef } from "vue";
+import { ref, watch, onBeforeMount, useTemplateRef } from "vue";
 import { useQuasar } from "quasar";
 import { useQuasar } from "quasar";
 import { useI18n } from "vue-i18n";
 import { useI18n } from "vue-i18n";
 import { useRouter } from "vue-router";
 import { useRouter } from "vue-router";
@@ -224,12 +224,41 @@ const form = ref({
 
 
 const isLocked = (field) => lockedFields.value.includes(field);
 const isLocked = (field) => lockedFields.value.includes(field);
 
 
+const unlockField = (field) => {
+  lockedFields.value = lockedFields.value.filter((locked) => locked !== field);
+};
+
 const {
 const {
   loading,
   loading,
   validationErrors,
   validationErrors,
   execute: submitForm,
   execute: submitForm,
 } = useSubmitHandler({ formRef });
 } = useSubmitHandler({ formRef });
 
 
+const prefillRules = {
+  name: [inputRules.required],
+  cpf: [inputRules.required, inputRules.cpf],
+  email: [inputRules.required, inputRules.email],
+  phone: [inputRules.required],
+  position_id: [inputRules.required],
+  sector_id: [inputRules.required],
+};
+
+const unlockInvalidFields = () => {
+  Object.entries(prefillRules).forEach(([field, rules]) => {
+    if (!isLocked(field)) return;
+
+    if (rules.some((rule) => rule(form.value[field]) !== true)) {
+      unlockField(field);
+    }
+  });
+};
+
+watch(
+  validationErrors,
+  (errors) => Object.keys(errors ?? {}).forEach((field) => unlockField(field)),
+  { deep: true },
+);
+
 const onPhotoSelected = (event) => {
 const onPhotoSelected = (event) => {
   const file = event.target.files?.[0];
   const file = event.target.files?.[0];
   event.target.value = "";
   event.target.value = "";
@@ -241,7 +270,7 @@ const onPhotoSelected = (event) => {
 };
 };
 
 
 const onSubmit = async () => {
 const onSubmit = async () => {
-  if (!photoFile.value) {
+  if (!photoFile.value && !photoPreview.value) {
     photoError.value = t("auth.first_access.photo_required");
     photoError.value = t("auth.first_access.photo_required");
     return;
     return;
   }
   }
@@ -261,7 +290,9 @@ const onSubmit = async () => {
   clearFirstAccessData();
   clearFirstAccessData();
 
 
   try {
   try {
-    await login(form.value.registration, password, "associado");
+    await login(form.value.registration, password, "associado", {
+      skipSuccessNotify: true,
+    });
     $q.notify({ type: "positive", message: t("auth.first_access.success") });
     $q.notify({ type: "positive", message: t("auth.first_access.success") });
     router.push({ name: "CarteirinhaPage" });
     router.push({ name: "CarteirinhaPage" });
   } catch {
   } catch {
@@ -298,6 +329,8 @@ onBeforeMount(async () => {
     if (photo_url) {
     if (photo_url) {
       photoPreview.value = photo_url;
       photoPreview.value = photo_url;
     }
     }
+
+    unlockInvalidFields();
   }
   }
 
 
   try {
   try {

+ 31 - 0
src/router/routes/avaliacao-admin.route.js

@@ -0,0 +1,31 @@
+export default [
+  {
+    path: "/avaliacoes",
+    name: "AvaliacoesPage",
+    component: () => import("pages/avaliacoes/EvaluationCampaignsPage.vue"),
+    meta: {
+      title: { value: "ui.navigation.evaluations", translate: true },
+      description: { value: "page.avaliacoes.description", translate: true },
+      requireAuth: true,
+      requiredPermission: "avaliacao",
+      breadcrumbs: [
+        { name: "AvaliacoesPage", title: "ui.navigation.evaluations", translate: true },
+      ],
+    },
+  },
+  {
+    path: "/avaliacoes/:id",
+    name: "AvaliacaoCampanhaPage",
+    component: () => import("pages/avaliacoes/EvaluationCampaignDetailPage.vue"),
+    meta: {
+      title: { value: "evaluation.campaign.singular", translate: true },
+      description: { value: "page.avaliacoes.detail_description", translate: true },
+      requireAuth: true,
+      requiredPermission: "avaliacao",
+      breadcrumbs: [
+        { name: "AvaliacoesPage", title: "ui.navigation.evaluations", translate: true },
+        { name: "AvaliacaoCampanhaPage", title: "evaluation.campaign.singular", translate: true },
+      ],
+    },
+  },
+];

+ 9 - 0
src/stores/navigation.js

@@ -80,6 +80,15 @@ export const navigationStore = defineStore("navigation", () => {
       permissionScope: "notificacao",
       permissionScope: "notificacao",
       allowedTypes: ["administrador"],
       allowedTypes: ["administrador"],
     },
     },
+    {
+      type: "single",
+      title: "ui.navigation.evaluations",
+      name: "AvaliacoesPage",
+      icon: "mdi-star-outline",
+      permission: false,
+      permissionScope: "avaliacao",
+      allowedTypes: ["administrador"],
+    },
     {
     {
       type: "single",
       type: "single",
       title: "ui.navigation.relatorios",
       title: "ui.navigation.relatorios",