Ver Fonte

associado: sempre q logar, verificar se ta completo o perfil (todas as infos do modal do usuario + foto), se nao estiver, obrigar a preencher tudo

Gustavo Zanatta há 1 mês atrás
pai
commit
535affad8e

+ 5 - 0
src/api/user.js

@@ -71,6 +71,11 @@ export const setUserOnLeave = async (id) => {
   return data.payload;
 };
 
+export const updateMyProfile = async (payload) => {
+  const { data } = await api.put("/user/my/profile", payload);
+  return data.payload;
+};
+
 export const importAssociados = async (file) => {
   const formData = new FormData();
   formData.append("file", file);

+ 317 - 0
src/components/CompleteProfileDialog.vue

@@ -0,0 +1,317 @@
+<template>
+  <q-dialog ref="dialogRef" persistent @hide="onDialogHide">
+    <q-card class="complete-profile-card">
+
+      <q-card-section class="complete-profile-header">
+        <div class="text-h6 text-white text-weight-bold">
+          {{ $t("associado.complete_profile.title") }}
+        </div>
+        <div class="text-caption text-white q-mt-xs" style="opacity: 0.85;">
+          {{ $t("associado.complete_profile.message") }}
+        </div>
+      </q-card-section>
+
+      <q-card-section class="q-pt-md">
+
+        <div class="flex flex-center q-mb-md">
+          <div class="avatar-wrap">
+            <q-avatar size="80px" class="complete-profile-avatar">
+              <img
+                v-if="localPhotoUrl"
+                :src="localPhotoUrl"
+                style="object-fit: cover; width: 100%; height: 100%;"
+              />
+              <q-icon v-else name="mdi-account" size="48px" color="white" />
+            </q-avatar>
+            <q-btn
+              round
+              unelevated
+              size="xs"
+              icon="mdi-pencil-outline"
+              class="avatar-edit-btn"
+              color="white"
+              text-color="violet-normal"
+              :loading="uploadingPhoto"
+              @click="avatarInputRef.click()"
+            />
+            <input
+              ref="avatarInputRef"
+              type="file"
+              accept="image/*"
+              style="display: none"
+              @change="onAvatarSelected"
+            />
+          </div>
+        </div>
+        <div v-if="!localPhotoUrl" class="text-center text-caption text-negative q-mb-sm">
+          {{ $t("associado.complete_profile.photo_hint") }}
+        </div>
+
+        <div class="q-col-gutter-sm row">
+
+          <div class="col-12">
+            <q-input
+              v-model="form.name"
+              outlined
+              dense
+              :label="$t('common.terms.name')"
+              :readonly="!!initialUser.name"
+              :bg-color="initialUser.name ? 'grey-2' : 'white'"
+            />
+          </div>
+
+          <div class="col-12 col-sm-6">
+            <q-input
+              v-model="form.cpf"
+              outlined
+              dense
+              label="CPF"
+              :readonly="!!initialUser.cpf"
+              :bg-color="initialUser.cpf ? 'grey-2' : 'white'"
+            />
+          </div>
+
+          <div class="col-12 col-sm-6">
+            <q-input
+              v-model="form.email"
+              outlined
+              dense
+              label="E-mail"
+              :readonly="!!initialUser.email"
+              :bg-color="initialUser.email ? 'grey-2' : 'white'"
+            />
+          </div>
+
+          <div class="col-12 col-sm-6">
+            <q-select
+              v-model="form.position_id"
+              outlined
+              dense
+              :label="$t('associado.position')"
+              :options="positionOptions"
+              option-value="id"
+              option-label="name"
+              emit-value
+              map-options
+              :readonly="!!initialUser.position_id"
+              :bg-color="initialUser.position_id ? 'grey-2' : 'white'"
+            />
+          </div>
+
+          <div class="col-12 col-sm-6">
+            <q-select
+              v-model="form.sector_id"
+              outlined
+              dense
+              :label="$t('associado.sector')"
+              :options="sectorOptions"
+              option-value="id"
+              option-label="name"
+              emit-value
+              map-options
+              :readonly="!!initialUser.sector_id"
+              :bg-color="initialUser.sector_id ? 'grey-2' : 'white'"
+            />
+          </div>
+
+          <div class="col-12 col-sm-6">
+            <q-input
+              v-model="form.registration"
+              outlined
+              dense
+              :label="$t('associado.registration')"
+              readonly
+              bg-color="grey-2"
+            />
+          </div>
+
+          <div class="col-12 col-sm-6">
+            <q-input
+              v-model="form.admission_date"
+              outlined
+              dense
+              :label="$t('associado.admission_date')"
+              readonly
+              bg-color="grey-2"
+            />
+          </div>
+
+        </div>
+
+      </q-card-section>
+
+      <q-card-actions class="q-px-md q-pb-md">
+        <q-btn
+          unelevated
+          no-caps
+          color="violet-normal"
+          class="full-width"
+          :label="$t('associado.complete_profile.save_continue')"
+          :loading="saving"
+          :disable="!canSave"
+          @click="onSave"
+        />
+      </q-card-actions>
+
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, useTemplateRef } from "vue";
+import { useQuasar, useDialogPluginComponent } from "quasar";
+import { useI18n } from "vue-i18n";
+import { userStore } from "src/stores/user";
+import { getPositions } from "src/api/position";
+import { getSectors } from "src/api/sector";
+import { uploadMyAvatar, updateMyProfile } from "src/api/user";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
+const $q = useQuasar();
+const { t } = useI18n();
+const store = userStore();
+
+const avatarInputRef = useTemplateRef("avatarInputRef");
+
+const initialUser = ref({});
+const localPhotoUrl = ref(null);
+const uploadingPhoto = ref(false);
+const saving = ref(false);
+const positionOptions = ref([]);
+const sectorOptions = ref([]);
+
+const form = ref({
+  name: "",
+  cpf: "",
+  email: "",
+  position_id: null,
+  sector_id: null,
+  registration: "",
+  admission_date: "",
+});
+
+const canSave = computed(() => {
+  return (
+    !!localPhotoUrl.value &&
+    !!form.value.name?.trim() &&
+    !!form.value.cpf?.trim() &&
+    !!form.value.email?.trim() &&
+    !!form.value.position_id &&
+    !!form.value.sector_id
+  );
+});
+
+const onAvatarSelected = async (event) => {
+  const file = event.target.files?.[0];
+  if (!file) return;
+  uploadingPhoto.value = true;
+  try {
+    const updated = await uploadMyAvatar(file);
+    store.user = updated;
+    localPhotoUrl.value = updated.photo_url;
+    $q.notify({ type: "positive", message: t("http.success") });
+  } catch {
+    $q.notify({ type: "negative", message: t("http.errors.failed") });
+  } finally {
+    uploadingPhoto.value = false;
+    event.target.value = "";
+  }
+};
+
+const onSave = async () => {
+  saving.value = true;
+  try {
+    const payload = {};
+    if (!initialUser.value.name)        payload.name        = form.value.name;
+    if (!initialUser.value.cpf)         payload.cpf         = form.value.cpf;
+    if (!initialUser.value.email)       payload.email       = form.value.email;
+    if (!initialUser.value.position_id) payload.position_id = form.value.position_id;
+    if (!initialUser.value.sector_id)   payload.sector_id   = form.value.sector_id;
+
+    if (Object.keys(payload).length > 0) {
+      const updated = await updateMyProfile(payload);
+      store.user = updated;
+    }
+
+    onDialogOK();
+  } catch {
+    $q.notify({ type: "negative", message: t("http.errors.failed") });
+  } finally {
+    saving.value = false;
+  }
+};
+
+onMounted(async () => {
+  const user = store.user ?? {};
+  initialUser.value = {
+    name:           user.name           || null,
+    cpf:            user.cpf            || null,
+    email:          user.email          || null,
+    position_id:    user.position_id    ?? user.position?.id    ?? null,
+    sector_id:      user.sector_id      ?? user.sector?.id      ?? null,
+    registration:   user.registration   || null,
+    admission_date: user.admission_date || null,
+  };
+
+  form.value = {
+    name:           initialUser.value.name           || "",
+    cpf:            initialUser.value.cpf            || "",
+    email:          initialUser.value.email          || "",
+    position_id:    initialUser.value.position_id    || null,
+    sector_id:      initialUser.value.sector_id      || null,
+    registration:   initialUser.value.registration   || "",
+    admission_date: initialUser.value.admission_date || "",
+  };
+
+  localPhotoUrl.value = user.photo_url || null;
+
+  try {
+    const [positions, sectors] = await Promise.all([getPositions(), getSectors()]);
+    positionOptions.value = positions ?? [];
+    sectorOptions.value   = sectors   ?? [];
+  } catch {
+    // silent
+  }
+});
+</script>
+
+<style scoped lang="scss">
+@use "src/css/quasar.variables.scss" as *;
+
+.complete-profile-card {
+  width: 95vw;
+  max-width: 520px;
+}
+
+.complete-profile-header {
+  background: linear-gradient(135deg, $violet-normal 0%, $violet-dark 100%);
+  border-radius: 4px 4px 0 0;
+}
+
+.avatar-wrap {
+  position: relative;
+  display: inline-block;
+}
+
+.complete-profile-avatar {
+  background: rgba(112, 32, 130, 0.18) !important;
+  border: 2px solid $violet-normal;
+
+  img {
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+  }
+}
+
+.avatar-edit-btn {
+  position: absolute;
+  bottom: -2px;
+  right: -2px;
+  width: 24px;
+  height: 24px;
+  min-height: 24px;
+}
+</style>

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

@@ -473,6 +473,12 @@
     "notes": "Notes",
     "schedule": "Schedule",
     "on_leave_appointment": "You are on leave. Please contact the secretariat for more information.",
+    "complete_profile": {
+      "title": "Complete your Profile",
+      "message": "Fill in the fields below to continue. Grey fields are managed by the administration.",
+      "save_continue": "Save and Continue",
+      "photo_hint": "Add photo"
+    },
     "confirm_cancel_appointment": "Are you sure you want to cancel this appointment?",
     "appointment_status": {
       "pendente": "Pending",

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

@@ -474,6 +474,12 @@
     "notes": "Observaciones",
     "schedule": "Agendar",
     "on_leave_appointment": "Está de baja. Comuníquese con la secretaría para más información.",
+    "complete_profile": {
+      "title": "Complete su Perfil",
+      "message": "Complete los campos a continuación para continuar. Los campos en gris son gestionados por la administración.",
+      "save_continue": "Guardar y Continuar",
+      "photo_hint": "Agregar foto"
+    },
     "confirm_cancel_appointment": "¿Está seguro de que desea cancelar esta cita?",
     "appointment_status": {
       "pendente": "Pendiente",

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

@@ -474,6 +474,12 @@
     "notes": "Observações",
     "schedule": "Agendar",
     "on_leave_appointment": "Você está afastado. Entre em contato com a secretaria para mais informações.",
+    "complete_profile": {
+      "title": "Complete seu Perfil",
+      "message": "Preencha os dados abaixo para continuar. Os campos em cinza são preenchidos pela administração.",
+      "save_continue": "Salvar e Continuar",
+      "photo_hint": "Adicionar foto"
+    },
     "confirm_cancel_appointment": "Tem certeza que deseja cancelar este agendamento?",
     "appointment_status": {
       "pendente": "Pendente",

+ 24 - 2
src/layouts/MainLayout.vue

@@ -78,6 +78,7 @@ import LeftMenuLayoutParceiro from "src/components/layout/LeftMenuLayoutParceiro
 import LeftMenuLayoutMobile from "src/components/layout/LeftMenuLayoutMobile.vue";
 import AppHeader from "src/components/layout/AppHeader.vue";
 import UnreadNotificationsDialog from "src/components/UnreadNotificationsDialog.vue";
+import CompleteProfileDialog from "src/components/CompleteProfileDialog.vue";
 
 const store = userStore();
 const router = useRouter();
@@ -89,11 +90,32 @@ const leftDrawerOpen = ref(false);
 const scrollAreaRef = useTemplateRef("scrollAreaRef");
 const showUnreadDialog = ref(false);
 
+const isProfileIncomplete = (user) => {
+  return (
+    !user.photo_url ||
+    !user.name ||
+    !user.cpf ||
+    !user.email ||
+    !(user.position_id ?? user.position?.id) ||
+    !(user.sector_id   ?? user.sector?.id)
+  );
+};
+
+const checkUnreadNotifications = () => {
+  if (store.hasUnreadNotifications) {
+    showUnreadDialog.value = true;
+  }
+};
+
 onMounted(async () => {
   if (store.isAssociado || store.isParceiro) {
     await store.fetchUser();
-    if (store.hasUnreadNotifications) {
-      showUnreadDialog.value = true;
+
+    if (store.isAssociado && isProfileIncomplete(store.user)) {
+      $q.dialog({ component: CompleteProfileDialog })
+        .onOk(() => checkUnreadNotifications());
+    } else {
+      checkUnreadNotifications();
     }
   }
 });