Bläddra i källkod

feat: ✨ 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 1 dag sedan
förälder
incheckning
c3a6b69458

+ 11 - 0
src/api/evaluation.js

@@ -0,0 +1,11 @@
+import api from "src/api";
+
+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;
+};

+ 1 - 0
src/api/firstAccess.js

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

+ 2 - 1
src/boot/axios.js

@@ -96,7 +96,8 @@ const errorInterceptor = async (error, router) => {
 };
 
 const successInterceptor = (response) => {
-  if (response?.data?.message) {
+  // skipSuccessNotify: a tela cuida da mensagem, evitando avisos duplicados em fluxos encadeados.
+  if (response?.data?.message && !response.config?.skipSuccessNotify) {
     Notify.create({
       message: response?.data?.message,
       type: "positive",

+ 158 - 0
src/components/EvaluationDialog.vue

@@ -0,0 +1,158 @@
+<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="24px"
+            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 { useAuth } from "src/composables/useAuth";
+import { submitEvaluationAssociado } 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 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 {
+    await submitEvaluationAssociado({
+      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: 92vw;
+  max-width: 480px;
+}
+
+.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();
   };
 
-  const login = async (identifier, password) => {
+  const login = async (identifier, password, options = {}) => {
     try {
-      const response = await api.post("/login", {
-        identifier,
-        password,
-        tipo: "associado",
-      });
+      const response = await api.post(
+        "/login",
+        {
+          identifier,
+          password,
+          tipo: "associado",
+        },
+        options,
+      );
 
       if (response.status === 200) {
         await setAuthDataFromPayload(response.data.payload);

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

@@ -673,6 +673,17 @@
       }
     }
   },
+  "evaluation": {
+    "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!"
+    }
+  },
   "notification": {
     "pending_read_title": "Pending Notifications",
     "pending_read_subtitle": "You have unread notifications. Read all of them to continue.",

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

@@ -673,6 +673,17 @@
       }
     }
   },
+  "evaluation": {
+    "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!"
+    }
+  },
   "notification": {
     "pending_read_title": "Notificaciones Pendientes",
     "pending_read_subtitle": "Tiene notificaciones no leídas. Léalas todas para continuar.",

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

@@ -672,6 +672,17 @@
       "not_authorized": "Consultas Não Autorizadas"
     }
   },
+  "evaluation": {
+    "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!"
+    }
+  },
   "notification": {
     "pending_read_title": "Notificações Pendentes",
     "pending_read_subtitle": "Você possui notificações não lidas. Leia todas para continuar.",

+ 22 - 2
src/layouts/MainLayout.vue

@@ -51,8 +51,10 @@ import { useRoute } from "vue-router";
 import { useQuasar } from "quasar";
 import { userStore } from "src/stores/user";
 import { getMyUnreadNotificationsAssociado } from "src/api/notification";
+import { getPendingEvaluationAssociado } from "src/api/evaluation";
 import UnreadNotificationsDialog from "src/components/UnreadNotificationsDialog.vue";
 import CompleteProfileDialog from "src/components/CompleteProfileDialog.vue";
+import EvaluationDialog from "src/components/EvaluationDialog.vue";
 
 import LeftMenuLayout from "src/components/layout/LeftMenuLayout.vue";
 import LeftMenuLayoutMobile from "src/components/layout/LeftMenuLayoutMobile.vue";
@@ -98,13 +100,31 @@ const checkUnreadNotifications = async () => {
   }
 };
 
+const checkPendingEvaluation = async () => {
+  try {
+    const campaign = await getPendingEvaluationAssociado();
+
+    if (!campaign) {
+      await checkUnreadNotifications();
+      return;
+    }
+
+    $q.dialog({
+      component: EvaluationDialog,
+      componentProps: { campaign },
+    }).onOk(() => checkUnreadNotifications());
+  } catch {
+    await checkUnreadNotifications();
+  }
+};
+
 onMounted(async () => {
   const user = store.user;
   if (user && isProfileIncomplete(user)) {
     $q.dialog({ component: CompleteProfileDialog })
-      .onOk(() => checkUnreadNotifications());
+      .onOk(() => checkPendingEvaluation());
   } else {
-    await checkUnreadNotifications();
+    await checkPendingEvaluation();
   }
 });
 

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

@@ -163,7 +163,7 @@
 </template>
 
 <script setup>
-import { ref, onBeforeMount, useTemplateRef } from "vue";
+import { ref, watch, onBeforeMount, useTemplateRef } from "vue";
 import { useQuasar } from "quasar";
 import { useI18n } from "vue-i18n";
 import { useRouter } from "vue-router";
@@ -214,12 +214,41 @@ const form = ref({
 
 const isLocked = (field) => lockedFields.value.includes(field);
 
+const unlockField = (field) => {
+  lockedFields.value = lockedFields.value.filter((locked) => locked !== field);
+};
+
 const {
   loading,
   validationErrors,
   execute: submitForm,
 } = 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 file = event.target.files?.[0];
   event.target.value = "";
@@ -231,7 +260,7 @@ const onPhotoSelected = (event) => {
 };
 
 const onSubmit = async () => {
-  if (!photoFile.value) {
+  if (!photoFile.value && !photoPreview.value) {
     photoError.value = t("auth.first_access.photo_required");
     return;
   }
@@ -251,7 +280,7 @@ const onSubmit = async () => {
   clearFirstAccessData();
 
   try {
-    await login(form.value.registration, password);
+    await login(form.value.registration, password, { skipSuccessNotify: true });
     $q.notify({ type: "positive", message: t("auth.first_access.success") });
     router.push({ name: "CarteirinhaPage" });
   } catch {
@@ -288,6 +317,8 @@ onBeforeMount(async () => {
     if (photo_url) {
       photoPreview.value = photo_url;
     }
+
+    unlockInvalidFields();
   }
 
   try {