Преглед на файлове

eat: add fluxo de confirmacao de selfie do usuario para autorizar visualizacao dela por terceiros (evitando imagens improprias no app)

Gustavo Mantovani преди 6 дни
родител
ревизия
ead5129434

+ 20 - 0
src/api/media.js

@@ -0,0 +1,20 @@
+import api from "src/api";
+
+export const getPendingProfileMedia = async ({
+  page = 1,
+  perPage = 5,
+} = {}) => {
+  const { data } = await api.get("/profile-media/pending", {
+    params: { page, per_page: perPage },
+  });
+
+  return { data: { result: data.payload } };
+};
+
+export const approveProfileMedia = async (source, sourceId) => {
+  const { data } = await api.patch(
+    `/profile-media/${source}/${sourceId}/approve`,
+  );
+
+  return data.payload;
+};

+ 7 - 2
src/components/defaults/DefaultTable.vue

@@ -132,14 +132,14 @@
       </div>
     </template>
 
-    <template v-for="name in $slots" #[name]="data">
+    <template v-for="name in forwardedSlotNames" #[name]="data">
       <slot :name="name" v-bind="data"></slot>
     </template>
   </q-table>
 </template>
 
 <script setup>
-import { ref, onMounted, toRaw, watch } from "vue";
+import { ref, onMounted, toRaw, useSlots, watch } from "vue";
 import { useI18n } from "vue-i18n";
 import { useRouter } from "vue-router";
 import { useQuasar } from "quasar";
@@ -217,6 +217,11 @@ const {
 const router = useRouter();
 const { t } = useI18n();
 const $q = useQuasar();
+const slots = useSlots();
+
+const forwardedSlotNames = Object.keys(slots).filter(
+  (name) => !["top", "body-cell-actions"].includes(name),
+);
 
 const rows = ref([]);
 const filter = ref("");

+ 15 - 7
src/components/defaults/DefaultTableServerSide.vue

@@ -67,8 +67,9 @@
       </div>
     </template>
 
-    <template #body-cell-actions="{ row }">
-      <q-td v-if="deleteFunction">
+    <template #body-cell-actions="data">
+      <slot name="body-cell-actions" v-bind="data" />
+      <q-td v-if="!slots['body-cell-actions'] && deleteFunction" :props="data">
         <q-item-section>
           <q-btn
             color="negative"
@@ -77,7 +78,7 @@
             icon="mdi-delete"
             style="width: 45px"
             class="q-ml-auto q-mr-sm"
-            @click.prevent.stop="onDelete(row.id)"
+            @click.prevent.stop="onDelete(data.row.id)"
           />
         </q-item-section>
       </q-td>
@@ -107,7 +108,9 @@
               <q-item v-bind="selectData.itemProps">
                 <q-item-section>
                   <q-item-label>{{
-                    selectData.opt == 0 ? $t("common.ui.misc.all") : selectData.opt
+                    selectData.opt == 0
+                      ? $t("common.ui.misc.all")
+                      : selectData.opt
                   }}</q-item-label>
                 </q-item-section>
               </q-item>
@@ -115,7 +118,8 @@
           </q-select>
         </div>
         <div class="flex items-center">
-          {{ pagination.from + "-" + pagination.to }} {{ $t("common.ui.table.of") }}
+          {{ pagination.from + "-" + pagination.to }}
+          {{ $t("common.ui.table.of") }}
           {{ pagination.rowsNumber }}
         </div>
         <div class="flex items-center">
@@ -141,14 +145,14 @@
       </div>
     </template>
 
-    <template v-for="name in $slots" #[name]="data">
+    <template v-for="name in forwardedSlotNames" #[name]="data">
       <slot :name="name" v-bind="data"></slot>
     </template>
   </q-table>
 </template>
 
 <script setup>
-import { ref, computed, onMounted, toRaw, watch } from "vue";
+import { ref, computed, onMounted, toRaw, useSlots, watch } from "vue";
 import { useI18n } from "vue-i18n";
 import { useRouter } from "vue-router";
 
@@ -232,6 +236,10 @@ const {
 
 const { t } = useI18n();
 const router = useRouter();
+const slots = useSlots();
+const forwardedSlotNames = Object.keys(slots).filter(
+  (name) => !["top", "body-cell-actions"].includes(name),
+);
 const rows = ref([]);
 const loading = ref(true);
 const fullscreen = ref(false);

+ 17 - 2
src/i18n/locales/en.json

@@ -261,7 +261,22 @@
     "add": "Add Client",
     "edit": "Edit Client",
     "fields": {
-      "document": "CPF/CNPJ"
+      "document": "CPF/CNPJ",
+      "selfie_verified": "Verified Profile Photo",
+      "change_photo": "Change photo"
+    }
+  },
+  "profile_media_approval": {
+    "pending_table_title": "Profile Photos Awaiting Approval",
+    "dialog_title": "Profile Photo Review",
+    "photo": "Photo",
+    "profile_type": "Profile Type",
+    "approve": "Approve photo",
+    "approved_success": "Profile photo approved successfully.",
+    "image_error": "The image could not be loaded.",
+    "types": {
+      "client": "Client",
+      "provider": "Provider"
     }
   },
   "address": {
@@ -852,4 +867,4 @@
       "user": "Operator"
     }
   }
-}
+}

+ 17 - 2
src/i18n/locales/es.json

@@ -261,7 +261,22 @@
     "add": "Agregar Cliente",
     "edit": "Editar Cliente",
     "fields": {
-      "document": "CPF/CNPJ"
+      "document": "CPF/CNPJ",
+      "selfie_verified": "Foto de Perfil Verificada",
+      "change_photo": "Cambiar foto"
+    }
+  },
+  "profile_media_approval": {
+    "pending_table_title": "Fotos de Perfil Pendientes de Aprobación",
+    "dialog_title": "Revisión de Foto de Perfil",
+    "photo": "Foto",
+    "profile_type": "Tipo de Perfil",
+    "approve": "Aprobar foto",
+    "approved_success": "Foto de perfil aprobada correctamente.",
+    "image_error": "No se pudo cargar la imagen.",
+    "types": {
+      "client": "Cliente",
+      "provider": "Prestador"
     }
   },
   "address": {
@@ -852,4 +867,4 @@
       "user": "Operador"
     }
   }
-}
+}

+ 17 - 2
src/i18n/locales/pt.json

@@ -261,7 +261,22 @@
     "add": "Adicionar Cliente",
     "edit": "Editar Cliente",
     "fields": {
-      "document": "CPF/CNPJ"
+      "document": "CPF/CNPJ",
+      "selfie_verified": "Foto de Perfil Verificada",
+      "change_photo": "Alterar foto"
+    }
+  },
+  "profile_media_approval": {
+    "pending_table_title": "Fotos de Perfil Aguardando Aprovação",
+    "dialog_title": "Revisão da Foto de Perfil",
+    "photo": "Foto",
+    "profile_type": "Tipo de Perfil",
+    "approve": "Aprovar foto",
+    "approved_success": "Foto de perfil aprovada com sucesso.",
+    "image_error": "Não foi possível carregar a imagem.",
+    "types": {
+      "client": "Cliente",
+      "provider": "Prestador"
     }
   },
   "address": {
@@ -852,4 +867,4 @@
       "user": "Operador"
     }
   }
-}
+}

+ 235 - 130
src/pages/client/components/AddEditClientDialog.vue

@@ -1,11 +1,13 @@
 <template>
   <q-dialog ref="dialogRef" @hide="onDialogHide">
-    <q-card class="q-dialog-plugin" style="width: 1000px; max-width: 90vw">
+    <q-card
+      class="q-dialog-plugin column full-width"
+      style="width: 900px; max-width: 80vw; height: 90vh"
+    >
       <DefaultDialogHeader :title="title" @close="onDialogCancel" />
-      
+
       <q-tabs
         v-model="tab"
-        dense
         class="text-grey"
         active-color="primary"
         indicator-color="primary"
@@ -13,133 +15,185 @@
       >
         <q-tab name="data" label="Dados" />
         <q-tab v-if="client" name="addresses" :label="$t('address.tab')" />
-        <q-tab v-if="client" name="favorites" :label="$t('client_favorite_providers.header')" />
-        <q-tab v-if="client" name="payment_methods" :label="$t('client_payment_methods.header')" />
-        <q-tab v-if="client" name="blocked_providers" :label="$t('client_provider_blocks.tab')" />
-        <q-tab v-if="client" name="sent_reviews" :label="$t('client_reviews.sent_tab')" />
-        <q-tab v-if="client" name="received_reviews" :label="$t('client_reviews.received_tab')" />
+        <q-tab
+          v-if="client"
+          name="favorites"
+          :label="$t('client_favorite_providers.header')"
+        />
+        <q-tab
+          v-if="client"
+          name="payment_methods"
+          :label="$t('client_payment_methods.header')"
+        />
+        <q-tab
+          v-if="client"
+          name="blocked_providers"
+          :label="$t('client_provider_blocks.tab')"
+        />
+        <q-tab
+          v-if="client"
+          name="sent_reviews"
+          :label="$t('client_reviews.sent_tab')"
+        />
+        <q-tab
+          v-if="client"
+          name="received_reviews"
+          :label="$t('client_reviews.received_tab')"
+        />
       </q-tabs>
 
       <q-separator v-if="client" />
 
-      <q-tab-panels v-model="tab" animated>
-        <q-tab-panel name="data">
-          <q-form ref="formRef" @submit="onOKClick">
-            <q-card-section class="row q-col-gutter-sm">
-              <div v-if="client" class="col-12 flex flex-center column q-pb-sm">
-                <q-avatar size="80px" color="grey-3">
-                  <img v-if="avatarPreview" :src="avatarPreview" style="object-fit: cover; width: 100%; height: 100%;" @error="avatarPreview = null" />
-                  <q-icon v-else name="mdi-account" size="50px" color="grey-6" />
-                </q-avatar>
-                <input
-                  ref="fileInputRef"
-                  type="file"
-                  accept="image/jpeg,image/png,image/webp"
-                  style="display: none;"
-                  @change="onFileSelected"
+      <q-scroll-area class="col">
+        <q-tab-panels v-model="tab" animated>
+          <q-tab-panel name="data">
+            <q-form id="client-form" ref="formRef" @submit="onOKClick">
+              <q-card-section class="row q-col-gutter-sm no-padding">
+                <UserSelect
+                  v-model="selectedUser"
+                  :label="$t('common.terms.user')"
+                  :rules="[inputRules.required]"
+                  :error="!!serverErrors?.user_id"
+                  :error-message="serverErrors?.user_id"
+                  :initial-id="client ? client.user_id : null"
+                  class="col-md-6 col-12"
+                  @update:model-value="serverErrors.user_id = null"
                 />
-                <q-btn
-                  flat
-                  no-caps
-                  dense
-                  color="primary"
-                  size="sm"
-                  class="q-mt-xs"
-                  label="Alterar foto"
-                  @click="fileInputRef.click()"
+
+                <q-input
+                  v-model="form.document"
+                  :mask="documentMask"
+                  fill-mask
+                  unmasked-value
+                  :label="$t('client.fields.document')"
+                  :rules="[inputRules.required, validateDocument]"
+                  :error="!!serverErrors?.document"
+                  :error-message="serverErrors?.document"
+                  class="col-md-6 col-12"
+                  @update:model-value="serverErrors.document = null"
                 />
-              </div>
-
-              <UserSelect
-                v-model="selectedUser"
-                :label="$t('common.terms.user')"
-                :rules="[inputRules.required]"
-                :error="!!serverErrors?.user_id"
-                :error-message="serverErrors?.user_id"
-                :initial-id="client ? client.user_id : null"
-                class="col-12"
-                @update:model-value="serverErrors.user_id = null"
-              />
-
-              <q-input
-                v-model="form.document"
-                :mask="documentMask"
-                fill-mask
-                unmasked-value
-                :label="$t('client.fields.document')"
-                :rules="[inputRules.required, validateDocument]"
-                :error="!!serverErrors?.document"
-                :error-message="serverErrors?.document"
-                class="col-12"
-                @update:model-value="serverErrors.document = null"
-              />
-            </q-card-section>
-            <q-card-actions align="center">
-              <q-btn color="primary" label="Cancel" @click="onDialogCancel" />
-              <q-space />
-              <q-btn
-                color="primary"
-                label="OK"
-                :type="'submit'"
-                :loading="loading"
-                :disable="!hasUpdatedFields && !avatarFile"
-              />
-            </q-card-actions>
-          </q-form>
-        </q-tab-panel>
-
-        <q-tab-panel v-if="client" name="addresses">
-          <AddressesPanel 
-            :source="'client'" 
-            :source-id="client.id"
-          />
-        </q-tab-panel>
-
-        <q-tab-panel v-if="client" name="favorites">
-          <ClientFavoriteProvidersPanel :client-id="client.id" />
-        </q-tab-panel>
-
-        <q-tab-panel v-if="client" name="payment_methods">
-          <ClientPaymentMethodsPanel :client-id="client.id" />
-        </q-tab-panel>
-
-        <q-tab-panel v-if="client" name="blocked_providers">
-          <ClientProvidersBlocksPanel :client-id="client.id" />
-        </q-tab-panel>
-
-        <q-tab-panel v-if="client" name="sent_reviews">
-          <ClientReviewsPanel :client-id="client.id" type="sent" />
-        </q-tab-panel>
-
-        <q-tab-panel v-if="client" name="received_reviews">
-          <ClientReviewsPanel :client-id="client.id" type="received" />
-        </q-tab-panel>
-      </q-tab-panels>
+
+                <div v-if="client" class="col-12 q-mt-md">
+                  <div class="text-h6 q-mb-md">
+                    {{ $t("client.fields.selfie_verified") }}
+                  </div>
+
+                  <div class="column items-start q-mb-md">
+                    <div
+                      class="profile-frame"
+                      :class="{ 'profile-frame--clickable': avatarPreview }"
+                      @click="openAvatarInNewTab"
+                    >
+                      <img
+                        v-if="avatarPreview"
+                        :src="avatarPreview"
+                        class="profile-image"
+                        @error="avatarPreview = null"
+                      />
+                      <q-icon
+                        v-else
+                        name="mdi-account"
+                        size="64px"
+                        color="grey-6"
+                      />
+                    </div>
+
+                    <input
+                      ref="fileInputRef"
+                      type="file"
+                      accept="image/jpeg,image/png,image/webp"
+                      style="display: none"
+                      @change="onFileSelected"
+                    />
+                    <q-btn
+                      flat
+                      no-caps
+                      dense
+                      color="primary"
+                      size="sm"
+                      class="q-mt-xs"
+                      :label="$t('client.fields.change_photo')"
+                      @click="fileInputRef.click()"
+                    />
+                  </div>
+
+                  <q-checkbox
+                    v-model="form.selfie_verified"
+                    :label="$t('client.fields.selfie_verified')"
+                    color="primary"
+                  />
+                </div>
+              </q-card-section>
+            </q-form>
+          </q-tab-panel>
+
+          <q-tab-panel v-if="client" name="addresses">
+            <AddressesPanel source="client" :source-id="client.id" />
+          </q-tab-panel>
+
+          <q-tab-panel v-if="client" name="favorites">
+            <ClientFavoriteProvidersPanel :client-id="client.id" />
+          </q-tab-panel>
+
+          <q-tab-panel v-if="client" name="payment_methods">
+            <ClientPaymentMethodsPanel :client-id="client.id" />
+          </q-tab-panel>
+
+          <q-tab-panel v-if="client" name="blocked_providers">
+            <ClientProvidersBlocksPanel :client-id="client.id" />
+          </q-tab-panel>
+
+          <q-tab-panel v-if="client" name="sent_reviews">
+            <ClientReviewsPanel :client-id="client.id" type="sent" />
+          </q-tab-panel>
+
+          <q-tab-panel v-if="client" name="received_reviews">
+            <ClientReviewsPanel :client-id="client.id" type="received" />
+          </q-tab-panel>
+        </q-tab-panels>
+      </q-scroll-area>
+
+      <q-separator />
+      <q-card-actions align="right" class="q-pa-md">
+        <q-btn
+          :label="$t('common.actions.close')"
+          outline
+          color="primary"
+          @click="onDialogCancel"
+        />
+        <q-btn
+          color="secondary"
+          :label="$t('common.actions.save')"
+          type="submit"
+          form="client-form"
+          unelevated
+          :loading="loading"
+          :disable="!hasUpdatedFields && !avatarFile"
+        />
+      </q-card-actions>
     </q-card>
   </q-dialog>
 </template>
 
 <script setup>
-import { ref, useTemplateRef, watch, computed, onMounted } from 'vue';
-import { useInputRules } from 'src/composables/useInputRules';
-import { useDialogPluginComponent } from 'quasar';
-import { useI18n } from 'vue-i18n';
-import { createClient, updateClient } from 'src/api/client';
-import { useFormUpdateTracker } from 'src/composables/useFormUpdateTracker';
-import { useSubmitHandler } from 'src/composables/useSubmitHandler';
-import { dynamicCpfCnpjMask, validateCpfCnpj } from 'src/helpers/utils';
-
-import DefaultDialogHeader from 'src/components/defaults/DefaultDialogHeader.vue';
-import UserSelect from 'src/components/user/UserSelect.vue';
-import AddressesPanel from 'src/pages/address/components/AddressesPanel.vue';
-import ClientFavoriteProvidersPanel from './ClientFavoriteProvidersPanel.vue';
-import ClientPaymentMethodsPanel from './ClientPaymentMethodsPanel.vue';
-import ClientProvidersBlocksPanel from './ClientProvidersBlocksPanel.vue';
-import ClientReviewsPanel from './ClientReviewsPanel.vue';
-
-defineEmits([
-  ...useDialogPluginComponent.emits,
-]);
+import { ref, useTemplateRef, watch, computed, onMounted } from "vue";
+import { useInputRules } from "src/composables/useInputRules";
+import { useDialogPluginComponent } from "quasar";
+import { useI18n } from "vue-i18n";
+import { createClient, getClient, updateClient } from "src/api/client";
+import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+import { dynamicCpfCnpjMask, validateCpfCnpj } from "src/helpers/utils";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import UserSelect from "src/components/user/UserSelect.vue";
+import AddressesPanel from "src/pages/address/components/AddressesPanel.vue";
+import ClientFavoriteProvidersPanel from "./ClientFavoriteProvidersPanel.vue";
+import ClientPaymentMethodsPanel from "./ClientPaymentMethodsPanel.vue";
+import ClientProvidersBlocksPanel from "./ClientProvidersBlocksPanel.vue";
+import ClientReviewsPanel from "./ClientReviewsPanel.vue";
+
+defineEmits([...useDialogPluginComponent.emits]);
 
 const { client, title } = defineProps({
   client: {
@@ -148,7 +202,7 @@ const { client, title } = defineProps({
   },
   title: {
     type: Function,
-    default: () => useI18n().t('common.terms.title'),
+    default: () => useI18n().t("common.terms.title"),
   },
 });
 
@@ -158,15 +212,21 @@ const { inputRules } = useInputRules();
 const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
   useDialogPluginComponent();
 
-const formRef = useTemplateRef('formRef');
-const fileInputRef = useTemplateRef('fileInputRef');
-const tab = ref('data');
+const formRef = useTemplateRef("formRef");
+const fileInputRef = useTemplateRef("fileInputRef");
+const tab = ref("data");
 const avatarFile = ref(null);
 const avatarPreview = ref(null);
 
-const { form, getUpdatedFields, hasUpdatedFields } = useFormUpdateTracker({
+const {
+  form,
+  getUpdatedFields,
+  hasUpdatedFields,
+  setUpdateFormAsOriginal,
+} = useFormUpdateTracker({
   user_id: client ? client?.user_id : null,
-  document: client ? client?.document : '',
+  document: client ? client?.document : "",
+  selfie_verified: client ? client?.selfie_verified : false,
 });
 
 const {
@@ -186,7 +246,10 @@ const documentMask = computed(() => {
 
 const validateDocument = (val) => {
   if (!val) return true;
-  return validateCpfCnpj(val) || t('validation.rules.cpf') + ' / ' + t('validation.rules.cnpj');
+  return (
+    validateCpfCnpj(val) ||
+    t("validation.rules.cpf") + " / " + t("validation.rules.cnpj")
+  );
 };
 
 const onFileSelected = (event) => {
@@ -196,6 +259,12 @@ const onFileSelected = (event) => {
   avatarPreview.value = URL.createObjectURL(file);
 };
 
+const openAvatarInNewTab = () => {
+  if (avatarPreview.value) {
+    window.open(avatarPreview.value, "_blank", "noopener,noreferrer");
+  }
+};
+
 const onOKClick = async () => {
   if (client) {
     if (avatarFile.value) {
@@ -203,11 +272,14 @@ const onOKClick = async () => {
       const fields = getUpdatedFields.value;
       Object.entries(fields).forEach(([key, value]) => {
         if (value !== null && value !== undefined) {
-          formData.append(key, String(value));
+          formData.append(
+            key,
+            typeof value === "boolean" ? (value ? "1" : "0") : String(value),
+          );
         }
       });
-      formData.append('avatar', avatarFile.value);
-      formData.append('_method', 'PUT');
+      formData.append("avatar", avatarFile.value);
+      formData.append("_method", "PUT");
       await submitForm(() => updateClient(formData, client.id, true));
     } else {
       await submitForm(() => updateClient(getUpdatedFields.value, client.id));
@@ -217,9 +289,18 @@ const onOKClick = async () => {
   }
 };
 
-onMounted(() => {
+onMounted(async () => {
   if (client) {
+    selectedUser.value = {
+      label: client.user?.name || "",
+      value: client.user?.id,
+    };
     avatarPreview.value = client.profile_media?.url ?? null;
+
+    const full = await getClient(client.id);
+    avatarPreview.value = full.profile_media?.url ?? avatarPreview.value;
+    form.selfie_verified = full.selfie_verified ?? form.selfie_verified;
+    setUpdateFormAsOriginal();
   }
 });
 
@@ -227,3 +308,27 @@ watch(selectedUser, () => {
   form.user_id = selectedUser.value?.value;
 });
 </script>
+
+<style scoped>
+.profile-frame {
+  width: 300px;
+  height: 170px;
+  border: 1px solid #e0e0e0;
+  border-radius: 6px;
+  background: #fafafa;
+  overflow: hidden;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.profile-image {
+  width: 100%;
+  height: 100%;
+  object-fit: contain;
+}
+
+.profile-frame--clickable {
+  cursor: pointer;
+}
+</style>

+ 2 - 0
src/pages/dashboard/DashboardPage.vue

@@ -4,6 +4,7 @@
 
     <div v-if="!isLoading" class="q-pa-md">
       <PendingProvidersTable />
+      <PendingProfileMediaTable />
 
       <!-- Tabs Principais: Agendamentos / Oportunidades -->
       <q-tabs
@@ -492,6 +493,7 @@ import { useQuasar } from 'quasar'
 import { useI18n } from 'vue-i18n'
 import DefaultHeaderPage from 'src/components/layout/DefaultHeaderPage.vue'
 import PendingProvidersTable from 'src/pages/dashboard/components/PendingProvidersTable.vue'
+import PendingProfileMediaTable from 'src/pages/dashboard/components/PendingProfileMediaTable.vue'
 import ViewScheduleDialog from 'src/pages/schedule/components/ViewScheduleDialog.vue'
 import ViewCustomScheduleDialog from 'src/pages/opportunity/components/ViewCustomScheduleDialog.vue'
 import { getSchedulesGroupedByClient, getSchedulesGroupedByClientCustom, updateScheduleStatus } from 'src/api/schedule'

+ 150 - 0
src/pages/dashboard/components/PendingProfileMediaTable.vue

@@ -0,0 +1,150 @@
+<template>
+  <div class="q-mb-lg">
+    <div class="text-h6 text-weight-bold q-mb-sm">
+      {{ $t("profile_media_approval.pending_table_title") }}
+    </div>
+
+    <DefaultTableServerSide
+      ref="tableRef"
+      class="bg-surface-light"
+      :columns="columns"
+      :api-call="getPendingProfileMedia"
+      :add-item="false"
+      :show-search-field="false"
+      open-item
+      @on-row-click="onRowClick"
+    >
+      <template #body-cell-photo="slotProps">
+        <q-td :props="slotProps">
+          <q-img
+            :src="slotProps.row.url"
+            fit="contain"
+            width="72px"
+            height="56px"
+            class="profile-media-thumbnail"
+          />
+        </q-td>
+      </template>
+
+      <template #body-cell-source="slotProps">
+        <q-td :props="slotProps">
+          <q-badge
+            color="primary"
+            :label="$t(`profile_media_approval.types.${slotProps.row.source}`)"
+          />
+        </q-td>
+      </template>
+
+      <template #body-cell-created_at="slotProps">
+        <q-td :props="slotProps">
+          {{ formatDate(slotProps.row.created_at) }}
+        </q-td>
+      </template>
+
+      <template #body-cell-actions="slotProps">
+        <q-td :props="slotProps" class="text-right">
+          <q-btn
+            flat
+            dense
+            round
+            icon="mdi-eye-outline"
+            color="primary"
+            @click.stop="onRowClick({ row: slotProps.row })"
+          >
+            <q-tooltip>{{ $t("common.actions.view") }}</q-tooltip>
+          </q-btn>
+        </q-td>
+      </template>
+    </DefaultTableServerSide>
+  </div>
+</template>
+
+<script setup>
+import { defineAsyncComponent, ref } from "vue";
+import { useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+import { format, parse } from "date-fns";
+import { getPendingProfileMedia } from "src/api/media";
+import DefaultTableServerSide from "src/components/defaults/DefaultTableServerSide.vue";
+
+const ProfileMediaApprovalDialog = defineAsyncComponent(
+  () => import("src/pages/dashboard/components/ProfileMediaApprovalDialog.vue"),
+);
+
+const $q = useQuasar();
+const { t } = useI18n();
+const tableRef = ref(null);
+
+const columns = [
+  {
+    name: "photo",
+    label: t("profile_media_approval.photo"),
+    field: "url",
+    align: "left",
+    sortable: false,
+  },
+  {
+    name: "source",
+    label: t("profile_media_approval.profile_type"),
+    field: "source",
+    align: "left",
+    sortable: false,
+  },
+  {
+    name: "name",
+    label: t("common.terms.name"),
+    field: (row) => row.user?.name || "—",
+    align: "left",
+    sortable: false,
+  },
+  {
+    name: "email",
+    label: t("common.terms.email"),
+    field: (row) => row.user?.email || "—",
+    align: "left",
+    sortable: false,
+  },
+  {
+    name: "created_at",
+    label: t("common.terms.created_at"),
+    field: "created_at",
+    align: "left",
+    sortable: false,
+  },
+  {
+    name: "actions",
+    label: "",
+    field: "actions",
+    align: "right",
+    sortable: false,
+  },
+];
+
+const formatDate = (value) => {
+  if (!value) return "—";
+
+  try {
+    return format(
+      parse(value, "yyyy-MM-dd HH:mm", new Date()),
+      "dd/MM/yyyy HH:mm",
+    );
+  } catch {
+    return value;
+  }
+};
+
+const onRowClick = ({ row }) => {
+  $q.dialog({
+    component: ProfileMediaApprovalDialog,
+    componentProps: { media: row },
+  }).onOk(() => tableRef.value?.refresh());
+};
+</script>
+
+<style scoped>
+.profile-media-thumbnail {
+  border: 1px solid #e0e0e0;
+  border-radius: 4px;
+  background: #fafafa;
+}
+</style>

+ 132 - 0
src/pages/dashboard/components/ProfileMediaApprovalDialog.vue

@@ -0,0 +1,132 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card
+      class="q-dialog-plugin column"
+      style="width: 700px; max-width: 95vw"
+    >
+      <DefaultDialogHeader
+        :title="() => $t('profile_media_approval.dialog_title')"
+        @close="onDialogCancel"
+      />
+
+      <q-card-section>
+        <div class="row q-col-gutter-md q-mb-md">
+          <div class="col-4">
+            <div class="text-caption text-grey-7">
+              {{ $t("profile_media_approval.profile_type") }}
+            </div>
+            <div class="text-body1">
+              {{ $t(`profile_media_approval.types.${media.source}`) }}
+            </div>
+          </div>
+
+          <div class="col-4">
+            <div class="text-caption text-grey-7">
+              {{ $t("common.terms.name") }}
+            </div>
+            <div class="text-body1">{{ media.user?.name || "—" }}</div>
+          </div>
+
+          <div class="col-4">
+            <div class="text-caption text-grey-7">
+              {{ $t("common.terms.email") }}
+            </div>
+            <div class="text-body1">{{ media.user?.email || "—" }}</div>
+          </div>
+        </div>
+
+        <a
+          :href="media.url"
+          target="_blank"
+          rel="noopener noreferrer"
+          class="profile-media-link"
+        >
+          <q-img :src="media.url" fit="contain" class="profile-media-image">
+            <template #error>
+              <div class="absolute-full flex flex-center bg-grey-2 text-grey-7">
+                {{ $t("profile_media_approval.image_error") }}
+              </div>
+            </template>
+          </q-img>
+        </a>
+      </q-card-section>
+
+      <q-separator />
+      <q-card-actions align="right" class="q-pa-md">
+        <q-btn
+          outline
+          color="primary"
+          :label="$t('common.actions.close')"
+          @click="onDialogCancel"
+        />
+        <q-btn
+          unelevated
+          color="positive"
+          :label="$t('profile_media_approval.approve')"
+          :loading="loading"
+          @click="onApprove"
+        />
+      </q-card-actions>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref } from "vue";
+import { useDialogPluginComponent, useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+import { approveProfileMedia } from "src/api/media";
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+
+const props = defineProps({
+  media: {
+    type: Object,
+    required: true,
+  },
+});
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
+const $q = useQuasar();
+const { t } = useI18n();
+const loading = ref(false);
+
+const onApprove = async () => {
+  loading.value = true;
+
+  try {
+    await approveProfileMedia(props.media.source, props.media.source_id);
+    $q.notify({
+      type: "positive",
+      message: t("profile_media_approval.approved_success"),
+      position: "top",
+    });
+    onDialogOK();
+  } catch {
+    $q.notify({
+      type: "negative",
+      message: t("http.errors.failed"),
+      position: "top",
+    });
+  } finally {
+    loading.value = false;
+  }
+};
+</script>
+
+<style scoped>
+.profile-media-link {
+  display: block;
+  border: 1px solid #e0e0e0;
+  border-radius: 6px;
+  overflow: hidden;
+  background: #fafafa;
+}
+
+.profile-media-image {
+  width: 100%;
+  height: 420px;
+}
+</style>

+ 61 - 10
src/pages/provider/ProviderPage.vue

@@ -10,6 +10,7 @@
         :mostrar-selecao-de-colunas="false"
         :mostrar-botao-fullscreen="false"
         :mostrar-toggle-inativos="false"
+        class="provider-table"
         open-item
         add-item
         @on-row-click="onRowClick"
@@ -18,8 +19,13 @@
         <template #body-cell-approval_status="slotProps">
           <q-td :props="slotProps">
             <q-badge
-              :color="{ pending: 'warning', accepted: 'positive', rejected: 'negative' }[slotProps.row.approval_status] ?? 'grey'"
-              :label="$t(`provider.approval_status.${slotProps.row.approval_status}`)"
+              :class="[
+                'approval-status-label',
+                `approval-status-label--${slotProps.row.approval_status}`,
+              ]"
+              :label="
+                $t(`provider.approval_status.${slotProps.row.approval_status}`)
+              "
             />
           </q-td>
         </template>
@@ -65,35 +71,35 @@ const columns = [
     name: "birth_date",
     label: t("provider.fields.birth_date"),
     field: "birth_date",
-    align: "left",
+    align: "center",
     sortable: true,
   },
   {
     name: "approval_status",
     label: t("provider.fields.approval_status"),
-    field: "approval_status",
-    align: "left",
+    field: (row) => t(`provider.approval_status.${row.approval_status}`),
+    align: "center",
     sortable: true,
   },
   {
     name: "average_rating",
     label: t("provider.fields.average_rating"),
     field: (row) => row.average_rating || "-",
-    align: "left",
+    align: "center",
     sortable: true,
   },
   {
     name: "total_services",
     label: t("provider.fields.total_services"),
     field: "total_services",
-    align: "left",
+    align: "center",
     sortable: true,
   },
   {
     name: "daily_price_8h",
     label: t("provider.fields.daily_price_8h"),
-    field: (row) => row.daily_price_8h ? `R$ ${row.daily_price_8h}` : "-",
-    align: "left",
+    field: (row) => (row.daily_price_8h ? `R$ ${row.daily_price_8h}` : "-"),
+    align: "center",
     sortable: true,
   },
   {
@@ -140,7 +146,9 @@ const onAddItem = () => {
     component: AddEditProviderDialog,
     componentProps: {
       title: () =>
-        useI18n().t("common.actions.add") + " " + useI18n().t("ui.navigation.provider"),
+        useI18n().t("common.actions.add") +
+        " " +
+        useI18n().t("ui.navigation.provider"),
     },
   }).onOk(async (success) => {
     if (success) {
@@ -149,3 +157,46 @@ const onAddItem = () => {
   });
 };
 </script>
+
+<style scoped>
+:deep(.provider-table th.sortable) {
+  position: relative;
+}
+
+:deep(.provider-table .q-table__sort-icon) {
+  position: absolute;
+  top: 50%;
+  right: 8px;
+  margin: -0.6em 0 0;
+}
+
+:deep(.provider-table .q-table__sort-icon--right) {
+  right: auto;
+  left: 8px;
+}
+
+.approval-status-label {
+  width: 96px;
+  padding: 6px 10px;
+  justify-content: center;
+  color: #424242;
+  background: #eeeeee;
+  border-radius: 12px;
+  font-weight: 500;
+}
+
+.approval-status-label--pending {
+  color: #8a5a00;
+  background: #fff3cd;
+}
+
+.approval-status-label--accepted {
+  color: #1b5e20;
+  background: #d9f2df;
+}
+
+.approval-status-label--rejected {
+  color: #8e1b1b;
+  background: #f8d7da;
+}
+</style>

+ 96 - 60
src/pages/provider/components/AddEditProviderDialog.vue

@@ -3,7 +3,7 @@
     <q-card class="q-dialog-plugin column full-width" style="width: 900px; max-width: 80vw;height: 90vh;">
       <DefaultDialogHeader :title="title" class="col-12" @close="onDialogCancel"/>
 
-      <q-tabs v-model="tab" dense class="text-grey" active-color="primary" indicator-color="primary" align="justify">
+      <q-tabs v-model="tab" class="text-grey" active-color="primary" indicator-color="primary" align="justify">
         <q-tab name="data" label="Dados" />
         <q-tab v-if="provider" name="addresses" :label="$t('address.tab')" />
         <q-tab v-if="provider" name="payment_methods" :label="$t('provider_payment_methods.header')" />
@@ -15,11 +15,11 @@
       </q-tabs>
 
       <q-separator v-if="provider" />
-      <div class="col no-padding scroll">
+      <q-scroll-area class="col">
         <q-tab-panels v-model="tab" animated>
           <q-tab-panel name="data">
-            <q-form ref="formRef" @submit="onOKClick">
-              <q-card-section class="row q-col-gutter-sm no-padding scroll">
+            <q-form id="provider-form" ref="formRef" @submit="onOKClick">
+              <q-card-section class="row q-col-gutter-sm no-padding">
                 <UserSelect
                   v-model="selectedUser"
                   :label="$t('common.terms.user')"
@@ -118,55 +118,64 @@
 
                 <div class="col-12 q-mt-md">
                   <div class="row q-col-gutter-md">
-                    <div class="col-auto flex flex-center column">
-                      <q-avatar size="80px" color="grey-3">
-                        <img v-if="avatarPreview" :src="avatarPreview" style="object-fit: cover; width: 100%; height: 100%;" @error="avatarPreview = null" />
-                        <q-icon v-else name="mdi-account" size="50px" color="grey-6" />
-                      </q-avatar>
-                      <input
-                        v-if="provider"
-                        ref="fileInputRef"
-                        type="file"
-                        accept="image/jpeg,image/png,image/webp"
-                        style="display: none;"
-                        @change="onFileSelected"
-                      />
-                      <q-btn
-                        v-if="provider"
-                        flat
-                        no-caps
-                        dense
-                        color="primary"
-                        size="sm"
-                        class="q-mt-xs"
-                        label="Alterar foto"
-                        @click="fileInputRef.click()"
-                      />
-                    </div>
-
-                    <div class="col row q-col-gutter-sm">
+                    <div class="col-12 row q-col-gutter-sm">
                       <div class="col-md-6 col-12">
-                        <div class="text-subtitle2 text-grey-7">
-                          {{ $t('provider.fields.average_rating') }}
-                        </div>
-                        <div class="text-body1 text-weight-medium">
-                          {{ provider?.average_rating || '-' }}
+                        <div class="metric-card">
+                          <div class="text-subtitle2 text-grey-7">
+                            {{ $t('provider.fields.average_rating') }}
+                          </div>
+                          <div class="text-body1 text-weight-medium">
+                            {{ provider?.average_rating || '-' }}
+                          </div>
                         </div>
                       </div>
 
                       <div class="col-md-6 col-12">
-                        <div class="text-subtitle2 text-grey-7">
-                          {{ $t('provider.fields.total_services') }}
-                        </div>
-                        <div class="text-body1 text-weight-medium">
-                          {{ provider?.total_services || '0' }}
+                        <div class="metric-card">
+                          <div class="text-subtitle2 text-grey-7">
+                            {{ $t('provider.fields.total_services') }}
+                          </div>
+                          <div class="text-body1 text-weight-medium">
+                            {{ provider?.total_services || '0' }}
+                          </div>
                         </div>
                       </div>
 
                       <div class="col-md-6 col-12">
-                        <div class="text-subtitle2 text-grey-7 q-mb-sm">
+                        <div class="text-h6 q-mb-md">
                           {{ $t('provider.fields.selfie_verified') }}
                         </div>
+
+                        <div class="column items-start q-mb-md">
+                          <div
+                            class="profile-frame"
+                            :class="{ 'profile-frame--clickable': avatarPreview }"
+                            @click="openAvatarInNewTab"
+                          >
+                            <img v-if="avatarPreview" :src="avatarPreview" class="profile-image" @error="avatarPreview = null" />
+                            <q-icon v-else name="mdi-account" size="64px" color="grey-6" />
+                          </div>
+                          <input
+                            v-if="provider"
+                            ref="fileInputRef"
+                            type="file"
+                            accept="image/jpeg,image/png,image/webp"
+                            style="display: none;"
+                            @change="onFileSelected"
+                          />
+                          <q-btn
+                            v-if="provider"
+                            flat
+                            no-caps
+                            dense
+                            color="primary"
+                            size="sm"
+                            class="q-mt-xs"
+                            label="Alterar foto"
+                            @click="fileInputRef.click()"
+                          />
+                        </div>
+
                         <q-checkbox
                           v-model="form.selfie_verified"
                           :label="$t('provider.fields.selfie_verified')"
@@ -175,7 +184,7 @@
                       </div>
 
                       <div class="col-12">
-                        <div class="text-subtitle2 text-grey-7 q-mb-sm">
+                        <div class="text-h6 q-mb-md">
                           {{ $t('provider.fields.document_verified') }}
                         </div>
 
@@ -230,22 +239,6 @@
                   </div>
                 </div>
               </q-card-section>
-              <q-card-actions align="right">
-                <q-btn
-                  :label="$t('common.actions.close')"
-                  flat
-                  color="primary"
-                  @click="onDialogCancel"
-                />
-                <q-btn 
-                  color="secondary"
-                  :label="$t('common.actions.save')"
-                  type="submit"
-                  unelevated
-                  :loading="loading"
-                  :disable="!hasUpdatedFields && !avatarFile"
-                />
-              </q-card-actions>
             </q-form>
           </q-tab-panel>
 
@@ -277,7 +270,26 @@
             <ProviderReviewsPanel :provider-id="provider.id" type="received" />
           </q-tab-panel>
         </q-tab-panels>
-      </div>
+      </q-scroll-area>
+
+      <q-separator />
+      <q-card-actions align="right" class="q-pa-md">
+        <q-btn
+          :label="$t('common.actions.close')"
+          outline
+          color="primary"
+          @click="onDialogCancel"
+        />
+        <q-btn
+          color="secondary"
+          :label="$t('common.actions.save')"
+          type="submit"
+          form="provider-form"
+          unelevated
+          :loading="loading"
+          :disable="!hasUpdatedFields && !avatarFile"
+        />
+      </q-card-actions>
     </q-card>
   </q-dialog>
 
@@ -391,6 +403,12 @@ const onFileSelected = (event) => {
   avatarPreview.value = URL.createObjectURL(file);
 };
 
+const openAvatarInNewTab = () => {
+  if (avatarPreview.value) {
+    window.open(avatarPreview.value, "_blank", "noopener,noreferrer");
+  }
+};
+
 const onOKClick = async () => {
   if (provider) {
     if (avatarFile.value) {
@@ -443,6 +461,14 @@ onMounted(async () => {
 </script>
 
 <style scoped>
+.metric-card {
+  height: 100%;
+  padding: 16px;
+  border: 1px solid #e0e0e0;
+  border-radius: 6px;
+}
+
+.profile-frame,
 .doc-frame {
   width: 300px;
   height: 170px;
@@ -455,6 +481,16 @@ onMounted(async () => {
   justify-content: center;
 }
 
+.profile-image {
+  width: 100%;
+  height: 100%;
+  object-fit: contain;
+}
+
+.profile-frame--clickable {
+  cursor: pointer;
+}
+
 .doc-frame--clickable {
   cursor: pointer;
 }