2 Commitit 454cc24f1a ... ef729334a9

Tekijä SHA1 Viesti Päivämäärä
  Gustavo Zanatta ef729334a9 implementacao didit - validacao de documentos 5 päivää sitten
  Gustavo Zanatta 5a0ee6e267 manual pushs 6 päivää sitten

+ 1 - 1
src/api/provider.js

@@ -11,7 +11,7 @@ export const getProviders = async () => {
 };
 
 export const getPendingProviders = async ({ page = 1, perPage = 5 } = {}) => {
-  console.log(perPage);
+
   const response = await api.get("/provider/pending", { params: { page, per_page: perPage } });
   return { data: { result: response.data.payload } };
 };

+ 25 - 0
src/api/pushNotification.js

@@ -0,0 +1,25 @@
+import api from "src/api";
+
+export const getPushRecipients = async ({ target, search } = {}) => {
+  const { data } = await api.get("/push-notifications/recipients", {
+    params: { target, search: search || undefined },
+  });
+  return data.payload;
+};
+
+export const sendManualPush = async ({ target, userIds, title, body }) => {
+  const { data } = await api.post("/push-notifications/send", {
+    target,
+    user_ids: userIds,
+    title,
+    body,
+  });
+  return data.payload;
+};
+
+export const getPushHistory = async ({ page = 1, perPage = 10, filter } = {}) => {
+  const response = await api.get("/push-notifications/history", {
+    params: { page, per_page: perPage, filter: filter || undefined },
+  });
+  return { data: { result: response.data.payload } };
+};

+ 28 - 0
src/api/verification.js

@@ -0,0 +1,28 @@
+import api from "src/api";
+
+export const getPendingVerifications = async ({ page = 1, perPage = 5 } = {}) => {
+  const response = await api.get("/verification/pending", {
+    params: { page, per_page: perPage },
+  });
+
+  return { data: { result: response.data.payload } };
+};
+
+/**
+ * Busca o laudo completo. As imagens vêm do Didit no momento da chamada, porque
+ * as URLs deles são presignadas e de vida curta — por isso não são persistidas.
+ */
+export const getVerification = async (id) => {
+  const { data } = await api.get(`/verification/${id}`);
+  return data.payload;
+};
+
+export const approveVerification = async (id, comment = null) => {
+  const { data } = await api.patch(`/verification/${id}/approve`, { comment });
+  return data.payload;
+};
+
+export const rejectVerification = async (id, comment = null) => {
+  const { data } = await api.patch(`/verification/${id}/reject`, { comment });
+  return data.payload;
+};

+ 162 - 0
src/components/pushNotification/PushRecipientsSelect.vue

@@ -0,0 +1,162 @@
+<template>
+  <q-select
+    v-model="selectedUsers"
+    v-bind="$attrs"
+    multiple
+    use-chips
+    use-input
+    clearable
+    :options="userOptions"
+    :label
+    :rules
+    :loading
+    :disable="!target"
+    :placeholder="
+      $t('common.actions.search') + ' ' + $t('common.terms.user')
+    "
+    :error
+    :error-message
+    @filter="filterFn"
+  >
+    <template #option="{ itemProps, opt, selected, toggleOption }">
+      <q-item v-bind="itemProps">
+        <q-item-section side>
+          <q-checkbox
+            :model-value="selected"
+            @update:model-value="toggleOption(opt)"
+          />
+        </q-item-section>
+        <q-item-section>
+          <q-item-label>{{ opt.label }}</q-item-label>
+          <q-item-label caption>{{ opt.email }}</q-item-label>
+        </q-item-section>
+        <q-item-section v-if="!opt.hasDeviceToken || !opt.pushEnabled" side>
+          <q-icon
+            :name="opt.pushEnabled ? 'mdi-cellphone-off' : 'mdi-bell-off-outline'"
+            color="orange"
+          >
+            <q-tooltip>
+              {{
+                opt.pushEnabled
+                  ? $t("push_notification.messages.no_device_token")
+                  : $t("push_notification.messages.push_disabled")
+              }}
+            </q-tooltip>
+          </q-icon>
+        </q-item-section>
+      </q-item>
+    </template>
+
+    <template #before-options>
+      <q-item v-if="userOptions.length" clickable @click="selectAllFiltered">
+        <q-item-section class="text-primary text-weight-medium">
+          {{
+            $t("push_notification.actions.select_all", {
+              count: userOptions.length,
+            })
+          }}
+        </q-item-section>
+      </q-item>
+    </template>
+
+    <template #no-option>
+      <q-item>
+        <q-item-section class="text-grey">
+          {{
+            target
+              ? $t("http.errors.no_records_found")
+              : $t("push_notification.messages.select_origin_first")
+          }}
+        </q-item-section>
+      </q-item>
+    </template>
+  </q-select>
+</template>
+
+<script setup>
+import { getPushRecipients } from "src/api/pushNotification";
+import { ref, watch } from "vue";
+import { normalizeString } from "src/helpers/utils";
+import { useI18n } from "vue-i18n";
+
+const { target, label, rules } = defineProps({
+  target: {
+    type: String,
+    default: null,
+  },
+  label: {
+    type: String,
+    default: () => useI18n().t("push_notification.fields.recipients"),
+  },
+  rules: {
+    type: Array,
+    default: () => [],
+  },
+  error: {
+    type: Boolean,
+    default: false,
+  },
+  errorMessage: {
+    type: String,
+    default: "",
+  },
+});
+
+const selectedUsers = defineModel({ type: Array, default: () => [] });
+
+const loading = ref(false);
+const baseOptions = ref([]);
+const userOptions = ref([]);
+
+const filterFn = (val, update) => {
+  const needle = normalizeString(val);
+  update(() => {
+    userOptions.value = baseOptions.value.filter(
+      (v) =>
+        normalizeString(v.label).includes(needle) ||
+        normalizeString(v.email).includes(needle),
+    );
+  });
+};
+
+const selectAllFiltered = () => {
+  const alreadySelected = new Set(selectedUsers.value.map((u) => u.value));
+  selectedUsers.value = [
+    ...selectedUsers.value,
+    ...userOptions.value.filter((u) => !alreadySelected.has(u.value)),
+  ];
+};
+
+const loadRecipients = async () => {
+  baseOptions.value = [];
+  userOptions.value = [];
+
+  if (!target) return;
+
+  try {
+    loading.value = true;
+    const recipients = await getPushRecipients({ target });
+    baseOptions.value = recipients.map((user) => ({
+      label: user.name,
+      value: user.id,
+      email: user.email,
+      hasDeviceToken: user.has_device_token,
+      pushEnabled: user.push_enabled,
+    }));
+    userOptions.value = baseOptions.value;
+  } catch (e) {
+    console.error(e);
+  } finally {
+    loading.value = false;
+  }
+};
+
+watch(
+  () => target,
+  () => {
+    selectedUsers.value = [];
+    loadRecipients();
+  },
+  { immediate: true },
+);
+</script>

+ 91 - 1
src/i18n/locales/en.json

@@ -711,7 +711,8 @@
       "speciality": "Speciality",
       "reviews": "Reviews",
       "payments": "Payments",
-      "support_requests": "Support requests"
+      "support_requests": "Support requests",
+      "pushs": "Pushes"
     }
   },
   "charts": {
@@ -862,5 +863,94 @@
       "checking": "Checking",
       "savings": "Savings"
     }
+  },
+  "push_notification": {
+    "tabs": {
+      "send": "Send",
+      "history": "History"
+    },
+    "targets": {
+      "cliente": "Client",
+      "prestador": "Provider"
+    },
+    "fields": {
+      "title": "Title",
+      "body": "Description",
+      "recipients": "Recipients",
+      "recipient": "Recipient",
+      "target": "Origin",
+      "sent_at": "Sent at"
+    },
+    "actions": {
+      "send": "Send push",
+      "select_all": "Select all ({count})"
+    },
+    "messages": {
+      "preview": "Preview",
+      "confirm_send": "Send this push to {count} recipient(s)?",
+      "select_origin_first": "Select the origin first",
+      "no_device_token": "No registered device — will not receive it",
+      "push_disabled": "Notifications disabled by the user — will not receive it",
+      "warn_no_device_token": "{count} selected recipient(s) have no registered device and will not receive it.",
+      "warn_push_disabled": "{count} selected recipient(s) disabled notifications and will not receive it.",
+      "sent_content": "Sent content"
+    }
+  },
+  "verification": {
+    "pending_table_title": "Pending identity verifications",
+    "dialog_title": "Identity verification report",
+    "attempt_number": "Attempt {number}",
+    "decision_stale": "Cached report (Didit unavailable)",
+    "section_checks": "Checks",
+    "section_warnings": "Issues",
+    "section_images": "Captured images",
+    "no_warnings": "No actionable issues.",
+    "informational_warnings": "Informational warnings ({count})",
+    "no_images": "No images available.",
+    "review_comment": "Review note (optional)",
+    "review_failed": "Could not record the review",
+    "approve": "Approve",
+    "reject": "Reject",
+    "fields": {
+      "type": "Profile",
+      "status": "Status",
+      "scores": "Liveness / Face",
+      "completed_at": "Completed at"
+    },
+    "types": {
+      "provider": "Provider",
+      "client": "Client"
+    },
+    "didit_status": {
+      "Not Started": "Not started",
+      "In Progress": "In progress",
+      "Approved": "Approved",
+      "Declined": "Declined",
+      "In Review": "In review",
+      "Resubmitted": "Resubmitted",
+      "Abandoned": "Abandoned",
+      "Expired": "Expired",
+      "Kyc Expired": "KYC expired"
+    },
+    "checks": {
+      "ocr": "Document (OCR)",
+      "liveness": "Liveness",
+      "face_match": "Face match"
+    },
+    "images": {
+      "front": "Document (front)",
+      "back": "Document (back)",
+      "portrait": "Document portrait",
+      "selfie": "Selfie"
+    },
+    "open_report": "View full report",
+    "status": {
+      "not_started": "Not started",
+      "pending": "In progress",
+      "approved": "Verified",
+      "in_review": "In review",
+      "declined": "Declined",
+      "expired": "Expired"
+    }
   }
 }

+ 91 - 1
src/i18n/locales/es.json

@@ -711,7 +711,8 @@
       "speciality": "Especialidad",
       "reviews": "Evaluaciones",
       "payments": "Pagos",
-      "support_requests": "Atenciones"
+      "support_requests": "Atenciones",
+      "pushs": "Pushs"
     }
   },
   "charts": {
@@ -862,5 +863,94 @@
       "checking": "Corriente",
       "savings": "Ahorro"
     }
+  },
+  "push_notification": {
+    "tabs": {
+      "send": "Enviar",
+      "history": "Historial"
+    },
+    "targets": {
+      "cliente": "Cliente",
+      "prestador": "Prestador"
+    },
+    "fields": {
+      "title": "Título",
+      "body": "Descripción",
+      "recipients": "Destinatarios",
+      "recipient": "Destinatario",
+      "target": "Origen",
+      "sent_at": "Enviada el"
+    },
+    "actions": {
+      "send": "Enviar push",
+      "select_all": "Seleccionar todos ({count})"
+    },
+    "messages": {
+      "preview": "Vista previa",
+      "confirm_send": "¿Enviar esta push a {count} destinatario(s)?",
+      "select_origin_first": "Selecciona primero el origen",
+      "no_device_token": "Sin dispositivo registrado — no la recibirá",
+      "push_disabled": "Notificaciones desactivadas por el usuario — no la recibirá",
+      "warn_no_device_token": "{count} seleccionado(s) sin dispositivo registrado no la recibirán.",
+      "warn_push_disabled": "{count} seleccionado(s) desactivaron las notificaciones y no la recibirán.",
+      "sent_content": "Contenido enviado"
+    }
+  },
+  "verification": {
+    "pending_table_title": "Verificaciones de identidad pendientes",
+    "dialog_title": "Informe de verificación de identidad",
+    "attempt_number": "Intento {number}",
+    "decision_stale": "Informe en caché (Didit no disponible)",
+    "section_checks": "Comprobaciones",
+    "section_warnings": "Incidencias",
+    "section_images": "Imágenes capturadas",
+    "no_warnings": "Sin incidencias accionables.",
+    "informational_warnings": "Avisos informativos ({count})",
+    "no_images": "Sin imágenes disponibles.",
+    "review_comment": "Observación del análisis (opcional)",
+    "review_failed": "No se pudo registrar el análisis",
+    "approve": "Aprobar",
+    "reject": "Rechazar",
+    "fields": {
+      "type": "Perfil",
+      "status": "Estado",
+      "scores": "Vivacidad / Rostro",
+      "completed_at": "Completada el"
+    },
+    "types": {
+      "provider": "Prestador",
+      "client": "Cliente"
+    },
+    "didit_status": {
+      "Not Started": "No iniciada",
+      "In Progress": "En curso",
+      "Approved": "Aprobada",
+      "Declined": "Rechazada",
+      "In Review": "En análisis",
+      "Resubmitted": "Reenviada",
+      "Abandoned": "Abandonada",
+      "Expired": "Expirada",
+      "Kyc Expired": "Verificación vencida"
+    },
+    "checks": {
+      "ocr": "Documento (OCR)",
+      "liveness": "Prueba de vida",
+      "face_match": "Coincidencia facial"
+    },
+    "images": {
+      "front": "Documento (frente)",
+      "back": "Documento (dorso)",
+      "portrait": "Retrato del documento",
+      "selfie": "Selfie"
+    },
+    "open_report": "Ver informe completo",
+    "status": {
+      "not_started": "No iniciada",
+      "pending": "En curso",
+      "approved": "Verificada",
+      "in_review": "En análisis",
+      "declined": "Rechazada",
+      "expired": "Vencida"
+    }
   }
 }

+ 91 - 1
src/i18n/locales/pt.json

@@ -713,7 +713,8 @@
       "speciality": "Especialidade",
       "reviews": "Avaliações",
       "payments": "Pagamentos",
-      "support_requests": "Atendimentos"
+      "support_requests": "Atendimentos",
+      "pushs": "Pushs"
     }
   },
   "charts": {
@@ -864,5 +865,94 @@
       "checking": "Corrente",
       "savings": "Poupança"
     }
+  },
+  "push_notification": {
+    "tabs": {
+      "send": "Enviar",
+      "history": "Histórico"
+    },
+    "targets": {
+      "cliente": "Cliente",
+      "prestador": "Prestador"
+    },
+    "fields": {
+      "title": "Título",
+      "body": "Descrição",
+      "recipients": "Destinatários",
+      "recipient": "Destinatário",
+      "target": "Origem",
+      "sent_at": "Enviada em"
+    },
+    "actions": {
+      "send": "Enviar push",
+      "select_all": "Selecionar todos ({count})"
+    },
+    "messages": {
+      "preview": "Pré-visualização",
+      "confirm_send": "Enviar esta push para {count} destinatário(s)?",
+      "select_origin_first": "Selecione a origem primeiro",
+      "no_device_token": "Sem aparelho registrado — não vai receber",
+      "push_disabled": "Notificações desativadas pelo usuário — não vai receber",
+      "warn_no_device_token": "{count} selecionado(s) sem aparelho registrado não vão receber.",
+      "warn_push_disabled": "{count} selecionado(s) desativaram as notificações e não vão receber.",
+      "sent_content": "Conteúdo enviado"
+    }
+  },
+  "verification": {
+    "pending_table_title": "Verificações de identidade pendentes",
+    "dialog_title": "Laudo de verificação de identidade",
+    "attempt_number": "Tentativa {number}",
+    "decision_stale": "Laudo em cache (Didit indisponível)",
+    "section_checks": "Checagens",
+    "section_warnings": "Pendências",
+    "section_images": "Imagens capturadas",
+    "no_warnings": "Nenhuma pendência acionável.",
+    "informational_warnings": "Avisos informativos ({count})",
+    "no_images": "Nenhuma imagem disponível.",
+    "review_comment": "Observação da análise (opcional)",
+    "review_failed": "Não foi possível registrar a análise",
+    "approve": "Aprovar",
+    "reject": "Reprovar",
+    "fields": {
+      "type": "Perfil",
+      "status": "Status",
+      "scores": "Vivacidade / Face",
+      "completed_at": "Concluída em"
+    },
+    "types": {
+      "provider": "Prestador",
+      "client": "Cliente"
+    },
+    "didit_status": {
+      "Not Started": "Não iniciada",
+      "In Progress": "Em andamento",
+      "Approved": "Aprovada",
+      "Declined": "Reprovada",
+      "In Review": "Em análise",
+      "Resubmitted": "Reenviada",
+      "Abandoned": "Abandonada",
+      "Expired": "Expirada",
+      "Kyc Expired": "Verificação vencida"
+    },
+    "checks": {
+      "ocr": "Documento (OCR)",
+      "liveness": "Prova de vida",
+      "face_match": "Semelhança facial"
+    },
+    "images": {
+      "front": "Documento (frente)",
+      "back": "Documento (verso)",
+      "portrait": "Retrato do documento",
+      "selfie": "Selfie"
+    },
+    "open_report": "Ver laudo completo",
+    "status": {
+      "not_started": "Não iniciada",
+      "pending": "Em andamento",
+      "approved": "Verificada",
+      "in_review": "Em análise",
+      "declined": "Reprovada",
+      "expired": "Vencida"
+    }
   }
 }

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

@@ -3,6 +3,7 @@
     <DefaultHeaderPage />
 
     <div v-if="!isLoading" class="q-pa-md">
+      <PendingVerificationsTable />
       <PendingProvidersTable />
       <PendingProfileMediaTable />
 
@@ -492,6 +493,7 @@ import { onMounted, ref, computed, watch } from 'vue'
 import { useQuasar } from 'quasar'
 import { useI18n } from 'vue-i18n'
 import DefaultHeaderPage from 'src/components/layout/DefaultHeaderPage.vue'
+import PendingVerificationsTable from 'src/pages/dashboard/components/PendingVerificationsTable.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'

+ 323 - 0
src/pages/dashboard/components/IdentityVerificationDialog.vue

@@ -0,0 +1,323 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="q-dialog-plugin verification-dialog">
+      <DefaultDialogHeader
+        :title="() => $t('verification.dialog_title')"
+        @close="onDialogCancel"
+      />
+
+      <div class="col scroll q-pa-md">
+        <div class="row items-center q-gutter-sm q-mb-md">
+          <q-badge
+            class="text-body2 q-pa-sm"
+            :color="statusColor"
+            :label="$t(`verification.didit_status.${verification.didit_status}`)"
+          />
+
+          <q-badge
+            class="text-body2 q-pa-sm"
+            color="grey-7"
+            :label="$t('verification.attempt_number', { number: verification.attempt })"
+          />
+
+          <q-space />
+
+          <q-badge
+            v-if="!loading && !decisionFresh"
+            class="text-body2 q-pa-sm"
+            color="warning"
+            :label="$t('verification.decision_stale')"
+          />
+        </div>
+
+        <div class="row q-col-gutter-md">
+          <!-- Resultado por checagem -->
+          <div class="col-12 col-lg-5">
+            <div class="text-subtitle1 text-weight-bold q-mb-sm">
+              {{ $t("verification.section_checks") }}
+            </div>
+
+            <q-list bordered separator class="rounded-borders">
+              <q-item v-for="check in checks" :key="check.key">
+                <q-item-section>
+                  <q-item-label caption>{{ check.label }}</q-item-label>
+                  <q-item-label>{{ check.detail }}</q-item-label>
+                </q-item-section>
+
+                <q-item-section side>
+                  <q-icon :color="check.color" :name="check.icon" size="20px" />
+                </q-item-section>
+              </q-item>
+            </q-list>
+          </div>
+
+          <!-- Pendências apontadas pelo Didit -->
+          <div class="col-12 col-lg-7">
+            <div class="text-subtitle1 text-weight-bold q-mb-sm">
+              {{ $t("verification.section_warnings") }}
+            </div>
+
+            <q-banner v-if="!actionableWarnings.length" class="bg-grey-2 rounded-borders">
+              {{ $t("verification.no_warnings") }}
+            </q-banner>
+
+            <q-list v-else bordered separator class="rounded-borders">
+              <q-item v-for="(warning, index) in actionableWarnings" :key="index">
+                <q-item-section avatar>
+                  <q-icon color="negative" name="mdi-alert-circle-outline" />
+                </q-item-section>
+
+                <q-item-section>
+                  <q-item-label class="text-weight-medium">
+                    {{ warning.short_description || warning.risk }}
+                  </q-item-label>
+
+                  <q-item-label caption>{{ warning.long_description }}</q-item-label>
+
+                  <q-item-label
+                    v-if="warning.additional_data"
+                    caption
+                    class="text-grey-8 q-mt-xs"
+                  >
+                    {{ formatAdditionalData(warning.additional_data) }}
+                  </q-item-label>
+                </q-item-section>
+              </q-item>
+            </q-list>
+
+            <!-- Informativos: registrados, mas não bloqueiam -->
+            <q-expansion-item
+              v-if="informationalWarnings.length"
+              class="q-mt-sm"
+              dense
+              :label="$t('verification.informational_warnings', { count: informationalWarnings.length })"
+            >
+              <q-list bordered separator class="rounded-borders">
+                <q-item v-for="(warning, index) in informationalWarnings" :key="index">
+                  <q-item-section>
+                    <q-item-label caption>{{ warning.risk }}</q-item-label>
+                    <q-item-label class="text-caption">
+                      {{ warning.short_description }}
+                    </q-item-label>
+                  </q-item-section>
+                </q-item>
+              </q-list>
+            </q-expansion-item>
+
+            <!-- Imagens: buscadas na hora, as URLs do Didit expiram rápido -->
+            <div class="text-subtitle1 text-weight-bold q-mb-sm q-mt-md">
+              {{ $t("verification.section_images") }}
+            </div>
+
+            <div v-if="loading" class="row justify-center q-pa-md">
+              <q-spinner color="primary" size="32px" />
+            </div>
+
+            <div v-else-if="images.length" class="row q-col-gutter-sm">
+              <div v-for="image in images" :key="image.key" class="col-6 col-sm-4">
+                <q-img
+                  class="cursor-pointer rounded-borders"
+                  fit="cover"
+                  ratio="1"
+                  :src="image.url"
+                  @click="lightbox = image.url"
+                />
+
+                <div class="text-caption text-center q-mt-xs">{{ image.label }}</div>
+              </div>
+            </div>
+
+            <q-banner v-else class="bg-grey-2 rounded-borders">
+              {{ $t("verification.no_images") }}
+            </q-banner>
+          </div>
+        </div>
+
+        <q-input
+          v-model="comment"
+          class="q-mt-md"
+          dense
+          outlined
+          type="textarea"
+          :label="$t('verification.review_comment')"
+        />
+      </div>
+
+      <q-card-actions align="right" class="q-pa-md">
+        <q-btn
+          flat
+          no-caps
+          :label="$t('common.actions.cancel')"
+          @click="onDialogCancel"
+        />
+
+        <q-btn
+          color="negative"
+          no-caps
+          unelevated
+          :label="$t('verification.reject')"
+          :loading="loadingReject"
+          @click="onReject"
+        />
+
+        <q-btn
+          color="positive"
+          no-caps
+          unelevated
+          :label="$t('verification.approve')"
+          :loading="loadingApprove"
+          @click="onApprove"
+        />
+      </q-card-actions>
+
+      <q-dialog v-model="lightboxOpen">
+        <q-img fit="contain" :src="lightbox" style="max-width: 90vw" />
+      </q-dialog>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { computed, onMounted, ref } from "vue";
+import { useDialogPluginComponent, useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+
+import {
+  approveVerification,
+  getVerification,
+  rejectVerification,
+} from "src/api/verification";
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+
+const props = defineProps({
+  verification: {
+    type: Object,
+    required: true,
+  },
+});
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
+
+const $q = useQuasar();
+const { t } = useI18n();
+
+const loading = ref(true);
+const loadingApprove = ref(false);
+const loadingReject = ref(false);
+const comment = ref("");
+const lightbox = ref(null);
+const decision = ref(null);
+const decisionFresh = ref(false);
+
+const lightboxOpen = computed({
+  get: () => !!lightbox.value,
+  set: (value) => {
+    if (!value) lightbox.value = null;
+  },
+});
+
+onMounted(async () => {
+  try {
+    const full = await getVerification(props.verification.id);
+
+    decision.value = full.decision;
+    decisionFresh.value = full.decision_fresh;
+  } catch {
+    decision.value = null;
+  } finally {
+    loading.value = false;
+  }
+});
+
+const statusColor = computed(
+  () => (props.verification.didit_status === "Declined" ? "negative" : "warning"),
+);
+
+const scoreLabel = (value) =>
+  value === null || value === undefined ? "—" : `${Number(value).toFixed(1)} / 100`;
+
+const statusIcon = (status) => {
+  if (status === "Approved") return { icon: "mdi-check-circle", color: "positive" };
+  if (status === "Declined") return { icon: "mdi-close-circle", color: "negative" };
+  if (!status) return { icon: "mdi-minus-circle-outline", color: "grey" };
+
+  return { icon: "mdi-alert-circle", color: "warning" };
+};
+
+const checks = computed(() => {
+  const v = props.verification;
+
+  return [
+    { key: "ocr", label: t("verification.checks.ocr"), status: v.id_verification_status, detail: v.id_verification_status || "—" },
+    { key: "liveness", label: t("verification.checks.liveness"), status: v.liveness_status, detail: scoreLabel(v.liveness_score) },
+    { key: "face_match", label: t("verification.checks.face_match"), status: v.face_match_status, detail: scoreLabel(v.face_match_score) },
+  ].map((check) => ({ ...check, ...statusIcon(check.status) }));
+});
+
+const allWarnings = computed(() => props.verification.warnings ?? []);
+
+const actionableWarnings = computed(() =>
+  allWarnings.value.filter((warning) => warning.log_type === "warning"),
+);
+
+const informationalWarnings = computed(() =>
+  allWarnings.value.filter((warning) => warning.log_type !== "warning"),
+);
+
+const formatAdditionalData = (data) =>
+  Object.entries(data)
+    .filter(([, value]) => value !== null && value !== undefined)
+    .map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(", ") : value}`)
+    .join(" · ");
+
+const first = (key) => {
+  const items = decision.value?.[key];
+
+  return Array.isArray(items) && items.length ? items[0] : null;
+};
+
+const images = computed(() => {
+  const id = first("id_verifications");
+  const liveness = first("liveness_checks");
+
+  return [
+    { key: "front", label: t("verification.images.front"), url: id?.front_image },
+    { key: "back", label: t("verification.images.back"), url: id?.back_image },
+    { key: "portrait", label: t("verification.images.portrait"), url: id?.portrait_image },
+    { key: "selfie", label: t("verification.images.selfie"), url: liveness?.reference_image },
+  ].filter((image) => !!image.url);
+});
+
+const review = async (action, loadingRef) => {
+  loadingRef.value = true;
+
+  try {
+    await action(props.verification.id, comment.value || null);
+
+    onDialogOK();
+  } catch (error) {
+    $q.notify({
+      message: error?.response?.data?.message ?? t("verification.review_failed"),
+      type: "negative",
+    });
+  } finally {
+    loadingRef.value = false;
+  }
+};
+
+const onApprove = () => review(approveVerification, loadingApprove);
+const onReject = () => review(rejectVerification, loadingReject);
+</script>
+
+<style lang="scss" scoped>
+.verification-dialog {
+  display: flex;
+  flex-direction: column;
+  max-width: 1100px;
+  width: 90vw;
+  max-height: 90vh;
+}
+</style>

+ 113 - 0
src/pages/dashboard/components/PendingVerificationsTable.vue

@@ -0,0 +1,113 @@
+<template>
+  <div class="q-mb-lg">
+    <div class="text-h6 text-weight-bold q-mb-sm">
+      {{ $t('verification.pending_table_title') }}
+    </div>
+
+    <DefaultTableServerSide
+      ref="tableRef"
+      class="bg-surface-light"
+      :columns="columns"
+      :api-call="getPendingVerifications"
+      :add-item="false"
+      :show-search-field="false"
+      :open-item="true"
+      @on-row-click="onRowClick"
+    >
+      <template #body-cell-name="slotProps">
+        <q-td :props="slotProps">
+          {{ slotProps.row.user?.name || '—' }}
+        </q-td>
+      </template>
+
+      <template #body-cell-type="slotProps">
+        <q-td :props="slotProps">
+          <q-badge
+            :color="slotProps.row.user?.type === 'PROVIDER' ? 'primary' : 'secondary'"
+            :label="$t(`verification.types.${String(slotProps.row.user?.type || '').toLowerCase()}`)"
+          />
+        </q-td>
+      </template>
+
+      <template #body-cell-didit_status="slotProps">
+        <q-td :props="slotProps">
+          <q-badge
+            :color="slotProps.row.didit_status === 'Declined' ? 'negative' : 'warning'"
+            :label="$t(`verification.didit_status.${slotProps.row.didit_status}`)"
+          />
+        </q-td>
+      </template>
+
+      <template #body-cell-scores="slotProps">
+        <q-td :props="slotProps">
+          <span class="text-caption">
+            {{ `${formatScore(slotProps.row.liveness_score)} / ${formatScore(slotProps.row.face_match_score)}` }}
+          </span>
+        </q-td>
+      </template>
+
+      <template #body-cell-completed_at="slotProps">
+        <q-td :props="slotProps">
+          {{ formatDate(slotProps.row.completed_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, parseISO } from 'date-fns';
+
+import { getPendingVerifications } from 'src/api/verification';
+import DefaultTableServerSide from 'src/components/defaults/DefaultTableServerSide.vue';
+
+const IdentityVerificationDialog = defineAsyncComponent(
+  () => import('src/pages/dashboard/components/IdentityVerificationDialog.vue'),
+);
+
+const $q = useQuasar();
+const { t } = useI18n();
+const tableRef = ref(null);
+
+const columns = [
+  { name: 'name', label: t('common.terms.name'), field: (row) => row.user?.name || '—', align: 'left', sortable: false },
+  { name: 'type', label: t('verification.fields.type'), field: (row) => row.user?.type, align: 'left', sortable: false },
+  { name: 'didit_status', label: t('verification.fields.status'), field: 'didit_status', align: 'left', sortable: false },
+  { name: 'scores', label: t('verification.fields.scores'), field: 'liveness_score', align: 'left', sortable: false },
+  { name: 'completed_at', label: t('verification.fields.completed_at'), field: 'completed_at', align: 'left', sortable: false },
+  { name: 'actions', label: '', field: 'actions', align: 'right', sortable: false },
+];
+
+const formatScore = (value) =>
+  value === null || value === undefined ? '—' : Number(value).toFixed(0);
+
+const formatDate = (value) => {
+  if (!value) return '—';
+  try { return format(parseISO(value), 'dd/MM/yyyy HH:mm'); } catch { return value; }
+};
+
+const onRowClick = ({ row }) => {
+  $q.dialog({
+    component: IdentityVerificationDialog,
+    componentProps: { verification: row },
+  }).onOk(() => tableRef.value?.refresh());
+};
+</script>

+ 87 - 10
src/pages/dashboard/components/ProviderApprovalDialog.vue

@@ -173,7 +173,54 @@
             </div>
 
             <!-- Documento Frente -->
-            <div class="q-mb-md">
+            <!-- Verificação de identidade (cadastros a partir do Didit) -->
+            <div v-if="identityStatus" class="q-mb-md">
+              <div class="text-caption text-grey-6 q-mb-xs">
+                {{ $t("verification.dialog_title") }}
+              </div>
+
+              <q-list bordered separator class="rounded-borders">
+                <q-item>
+                  <q-item-section>
+                    <q-item-label caption>
+                      {{ $t("verification.fields.status") }}
+                    </q-item-label>
+                  </q-item-section>
+
+                  <q-item-section side>
+                    <q-badge
+                      :color="identityStatusColor"
+                      :label="$t(`verification.status.${identityStatus}`)"
+                    />
+                  </q-item-section>
+                </q-item>
+
+                <q-item v-if="lastVerification">
+                  <q-item-section>
+                    <q-item-label caption>
+                      {{ $t("verification.fields.scores") }}
+                    </q-item-label>
+
+                    <q-item-label>
+                      {{ identityScores }}
+                    </q-item-label>
+                  </q-item-section>
+                </q-item>
+              </q-list>
+
+              <q-btn
+                v-if="lastVerification"
+                class="full-width q-mt-sm"
+                color="primary"
+                icon="mdi-file-search-outline"
+                no-caps
+                outline
+                :label="$t('verification.open_report')"
+                @click="openVerificationReport"
+              />
+            </div>
+
+            <div v-if="documentFrontUrl" class="q-mb-md">
               <div class="text-caption text-grey-6 q-mb-xs">
                 {{ $t("provider.fields.document_front") }}
               </div>
@@ -196,8 +243,8 @@
               </div>
             </div>
 
-            <!-- Documento Verso -->
-            <div>
+            <!-- Documento Verso (apenas cadastros anteriores ao Didit) -->
+            <div v-if="documentBackUrl">
               <div class="text-caption text-grey-6 q-mb-xs">
                 {{ $t("provider.fields.document_back") }}
               </div>
@@ -296,12 +343,16 @@
 </template>
 
 <script setup>
-import { computed, ref, onMounted } from "vue";
+import { computed, defineAsyncComponent, ref, onMounted } from "vue";
 import { useDialogPluginComponent, useQuasar } from "quasar";
 import { useI18n } from "vue-i18n";
 import { approveProvider, rejectProvider, getProvider } from "src/api/provider";
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
 
+const IdentityVerificationDialog = defineAsyncComponent(
+  () => import("src/pages/dashboard/components/IdentityVerificationDialog.vue"),
+);
+
 const props = defineProps({
   provider: {
     type: Object,
@@ -322,6 +373,8 @@ const loadingReject = ref(false);
 
 // Imagens
 const avatarPreview = ref(null);
+const identityStatus = ref(null);
+const lastVerification = ref(null);
 const documentFrontUrl = ref(null);
 const documentBackUrl = ref(null);
 
@@ -360,21 +413,45 @@ onMounted(async () => {
   try {
     const full = await getProvider(props.provider.id);
 
-    console.log("FULL", full);
-    console.log("PROFILE", full.profile_media);
-    console.log("URL", full.profile_media?.url);
-
     avatarPreview.value = full.profile_media?.url ?? null;
 
-    console.log("AVATAR", avatarPreview.value);
-
+    // Cadastros anteriores ao Didit ainda têm as fotos no S3.
     documentFrontUrl.value = full.document_front_media?.url ?? null;
     documentBackUrl.value = full.document_back_media?.url ?? null;
+
+    identityStatus.value = full.identity_verification_status ?? null;
+    lastVerification.value = full.identity_verification ?? null;
   } catch (error) {
     console.error(error);
   }
 });
 
+const identityStatusColor = computed(() => {
+  const map = {
+    approved: "positive",
+    declined: "negative",
+    in_review: "warning",
+  };
+
+  return map[identityStatus.value] ?? "grey-7";
+});
+
+const identityScores = computed(() => {
+  const liveness = lastVerification.value?.liveness_score;
+  const faceMatch = lastVerification.value?.face_match_score;
+  const format = (value) =>
+    value === null || value === undefined ? "—" : Number(value).toFixed(0);
+
+  return `${format(liveness)} / ${format(faceMatch)}`;
+});
+
+const openVerificationReport = () => {
+  $q.dialog({
+    component: IdentityVerificationDialog,
+    componentProps: { verification: lastVerification.value },
+  });
+};
+
 const onApprove = async () => {
   loadingApprove.value = true;
 

+ 18 - 10
src/pages/payment/PaymentsPage.vue

@@ -58,22 +58,22 @@ const ViewPaymentDialog = defineAsyncComponent(
 const { t } = useI18n();
 const $q = useQuasar();
 const tableRef = ref(null);
-const idLabel = "ID";
+// const idLabel = "ID";
 
 const columns = computed(() => [
-  {
-    name: "id",
-    label: idLabel,
-    align: "left",
-    field: "id",
-    required: true,
-    sortable: true,
-  },
+  // {
+  //   name: "id",
+  //   label: idLabel,
+  //   align: "left",
+  //   field: "id",
+  //   required: true,
+  //   sortable: true,
+  // },
   {
     name: "schedule_id",
     label: t("payments.schedule_id"),
     align: "left",
-    field: "schedule_id",
+    field: (row) => formatByType(row),
     sortable: true,
   },
   {
@@ -157,4 +157,12 @@ const onRowClick = ({ row }) => {
     },
   });
 };
+
+const formatByType = (row) => {
+  if (row.schedules[0]?.schedule_type === 'custom') {
+      return t('ui.navigation.opportunities') + ' - ' + row.schedules[0]?.id
+  } else if (row.schedules[0]?.schedule_type === 'default') {
+      return t('ui.navigation.schedules') + ' - ' + row.schedules[0]?.id
+  }  
+}
 </script>

+ 280 - 0
src/pages/pushNotification/PushNotificationsPage.vue

@@ -0,0 +1,280 @@
+<template>
+  <div>
+    <DefaultHeaderPage />
+
+    <q-tabs v-model="tab" align="left" class="text-primary q-mb-md" no-caps>
+      <q-tab name="send" icon="mdi-send" :label="$t('push_notification.tabs.send')" />
+      <q-tab name="history" icon="mdi-history" :label="$t('push_notification.tabs.history')" />
+    </q-tabs>
+
+    <q-tab-panels v-model="tab" animated class="bg-transparent">
+      <q-tab-panel name="send" class="q-pa-none">
+        <div class="row q-col-gutter-md">
+          <div class="col-12 col-md-7">
+            <q-form ref="formRef" class="q-gutter-md">
+              <q-btn-toggle
+                v-model="form.target"
+                spread
+                no-caps
+                unelevated
+                toggle-color="primary"
+                color="white"
+                text-color="primary"
+                :options="targetOptions"
+              />
+
+              <PushRecipientsSelect
+                v-model="form.recipients"
+                outlined
+                dense
+                :target="form.target"
+                :error="!!serverErrors?.user_ids"
+                :error-message="serverErrors?.user_ids"
+              />
+
+              <q-input
+                v-model="form.title"
+                outlined
+                dense
+                counter
+                maxlength="65"
+                :label="$t('push_notification.fields.title')"
+                :rules="[inputRules.required]"
+                :error="!!serverErrors?.title"
+                :error-message="serverErrors?.title"
+              />
+
+              <q-input
+                v-model="form.body"
+                outlined
+                dense
+                counter
+                type="textarea"
+                maxlength="240"
+                autogrow
+                :label="$t('push_notification.fields.body')"
+                :rules="[inputRules.required]"
+                :error="!!serverErrors?.body"
+                :error-message="serverErrors?.body"
+              />
+
+              <div class="flex justify-end">
+                <q-btn
+                  color="primary"
+                  padding="12px 24px"
+                  unelevated
+                  no-caps
+                  :loading="loading"
+                  :disable="!canSend"
+                  :label="$t('push_notification.actions.send')"
+                  @click="onSendClick"
+                />
+              </div>
+            </q-form>
+          </div>
+
+          <div class="col-12 col-md-5">
+            <div class="text-caption text-grey-7 q-mb-sm">
+              {{ $t("push_notification.messages.preview") }}
+            </div>
+            <q-card flat bordered class="q-pa-md">
+              <div class="flex items-center q-mb-sm" style="gap: 8px">
+                <q-icon name="mdi-bell" size="18px" color="grey-7" />
+                <span class="text-caption text-grey-7">{{ appName }}</span>
+              </div>
+              <div class="text-weight-bold">
+                {{ form.title || $t("push_notification.fields.title") }}
+              </div>
+              <div class="text-body2 text-grey-8" style="white-space: pre-wrap">
+                {{ form.body || $t("push_notification.fields.body") }}
+              </div>
+            </q-card>
+
+            <q-banner v-if="warnings.length" dense class="bg-orange-1 text-orange-9 q-mt-md">
+              <template #avatar>
+                <q-icon name="mdi-alert-outline" />
+              </template>
+              <div v-for="warning in warnings" :key="warning">{{ warning }}</div>
+            </q-banner>
+          </div>
+        </div>
+      </q-tab-panel>
+
+      <q-tab-panel name="history" class="q-pa-none">
+        <DefaultTableServerSide
+          ref="historyTableRef"
+          :columns="historyColumns"
+          :api-call="getPushHistory"
+          :add-item="false"
+          :rows-per-page="10"
+          open-item
+          @on-row-click="onHistoryRowClick"
+        />
+      </q-tab-panel>
+    </q-tab-panels>
+  </div>
+</template>
+
+<script setup>
+import {
+  computed,
+  defineAsyncComponent,
+  nextTick,
+  reactive,
+  ref,
+  useTemplateRef,
+  watch,
+} from "vue";
+import { useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+
+import { getPushHistory, sendManualPush } from "src/api/pushNotification";
+import { useInputRules } from "src/composables/useInputRules";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+import { permissionStore } from "src/stores/permission";
+
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import DefaultTableServerSide from "src/components/defaults/DefaultTableServerSide.vue";
+import PushRecipientsSelect from "src/components/pushNotification/PushRecipientsSelect.vue";
+
+const PushHistoryDetailDialog = defineAsyncComponent(() =>
+  import("src/pages/pushNotification/components/PushHistoryDetailDialog.vue"),
+);
+
+const $q = useQuasar();
+const { t } = useI18n();
+const inputRules = useInputRules();
+const permission_store = permissionStore();
+
+const appName = "Diária";
+
+const tab = ref("send");
+const formRef = useTemplateRef("formRef");
+const historyTableRef = useTemplateRef("historyTableRef");
+
+watch(tab, async (value) => {
+  if (value !== "history") return;
+
+  await nextTick();
+  historyTableRef.value?.refresh();
+});
+
+const form = reactive({
+  target: "cliente",
+  recipients: [],
+  title: "",
+  body: "",
+});
+
+const targetOptions = computed(() => [
+  { label: t("push_notification.targets.cliente"), value: "cliente" },
+  { label: t("push_notification.targets.prestador"), value: "prestador" },
+]);
+
+const canSend = computed(
+  () => form.recipients.length > 0 && !!form.title.trim() && !!form.body.trim(),
+);
+
+const warnings = computed(() => {
+  const list = [];
+  const noToken = form.recipients.filter((r) => !r.hasDeviceToken).length;
+  const disabled = form.recipients.filter((r) => !r.pushEnabled).length;
+
+  if (noToken) {
+    list.push(t("push_notification.messages.warn_no_device_token", { count: noToken }));
+  }
+  if (disabled) {
+    list.push(t("push_notification.messages.warn_push_disabled", { count: disabled }));
+  }
+  return list;
+});
+
+const resetForm = () => {
+  form.recipients = [];
+  form.title = "";
+  form.body = "";
+  formRef.value?.resetValidation();
+};
+
+const { loading, serverErrors, execute: submitForm } = useSubmitHandler({
+  formRef,
+  onSuccess: () => {
+    resetForm();
+    historyTableRef.value?.refresh();
+  },
+});
+
+const onSendClick = () => {
+  if (permission_store.getAccess("push.notification", "add") === false) {
+    $q.notify({ type: "negative", message: t("validation.permissions.add") });
+    return;
+  }
+
+  $q.dialog({
+    title: t("push_notification.actions.send"),
+    message: t("push_notification.messages.confirm_send", {
+      count: form.recipients.length,
+    }),
+    cancel: true,
+    persistent: true,
+  }).onOk(async () => {
+    try {
+      await submitForm(() =>
+        sendManualPush({
+          target: form.target,
+          userIds: form.recipients.map((r) => r.value),
+          title: form.title,
+          body: form.body,
+        }),
+      );
+    } catch {
+      // erros de validação já são exibidos pelo useSubmitHandler
+    }
+  });
+};
+
+const onHistoryRowClick = ({ row }) => {
+  $q.dialog({
+    component: PushHistoryDetailDialog,
+    componentProps: { log: row },
+  });
+};
+
+const historyColumns = computed(() => [
+  {
+    name: "title",
+    label: t("push_notification.fields.title"),
+    field: "title",
+    align: "left",
+    sortable: false,
+  },
+  {
+    name: "user_name",
+    label: t("push_notification.fields.recipient"),
+    field: "user_name",
+    align: "left",
+    sortable: false,
+  },
+  {
+    name: "user_email",
+    label: t("common.terms.email"),
+    field: "user_email",
+    align: "left",
+    sortable: false,
+  },
+  {
+    name: "target",
+    label: t("push_notification.fields.target"),
+    field: (row) => t(`push_notification.targets.${row.target}`, row.target),
+    align: "left",
+    sortable: false,
+  },
+  {
+    name: "sent_at",
+    label: t("push_notification.fields.sent_at"),
+    field: "sent_at",
+    align: "left",
+    sortable: false,
+  },
+]);
+</script>

+ 98 - 0
src/pages/pushNotification/components/PushHistoryDetailDialog.vue

@@ -0,0 +1,98 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="q-dialog-plugin overflow-hidden" style="width: 620px; max-width: 92vw">
+      <DefaultDialogHeader :title="dialogTitle" @close="onDialogCancel" />
+
+      <q-card-section class="q-gutter-y-md">
+        <q-list dense>
+          <q-item>
+            <q-item-section>
+              <q-item-label caption>
+                {{ $t("push_notification.fields.recipient") }}
+              </q-item-label>
+              <q-item-label>{{ log.user_name || emptyLabel }}</q-item-label>
+            </q-item-section>
+            <q-item-section>
+              <q-item-label caption>{{ $t("common.terms.email") }}</q-item-label>
+              <q-item-label>{{ log.user_email || emptyLabel }}</q-item-label>
+            </q-item-section>
+          </q-item>
+
+          <q-item>
+            <q-item-section>
+              <q-item-label caption>
+                {{ $t("push_notification.fields.target") }}
+              </q-item-label>
+              <q-item-label>{{ targetLabel }}</q-item-label>
+            </q-item-section>
+            <q-item-section>
+              <q-item-label caption>
+                {{ $t("push_notification.fields.sent_at") }}
+              </q-item-label>
+              <q-item-label>{{ log.sent_at || emptyLabel }}</q-item-label>
+            </q-item-section>
+          </q-item>
+        </q-list>
+
+        <div>
+          <div class="text-caption text-grey-7 q-mb-xs">
+            {{ $t("push_notification.messages.sent_content") }}
+          </div>
+          <q-card flat bordered class="q-pa-md">
+            <div class="flex items-center q-mb-sm" style="gap: 8px">
+              <q-icon name="mdi-bell" size="18px" color="grey-7" />
+              <span class="text-caption text-grey-7">{{ appName }}</span>
+            </div>
+            <div class="text-weight-bold">{{ log.title || emptyLabel }}</div>
+            <div class="text-body2 text-grey-8 push-body">
+              {{ log.body || emptyLabel }}
+            </div>
+          </q-card>
+        </div>
+      </q-card-section>
+
+      <q-card-actions align="right" class="q-px-md q-pb-md">
+        <q-btn
+          flat
+          color="primary"
+          :label="$t('common.actions.close')"
+          @click="onDialogCancel"
+        />
+      </q-card-actions>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { computed } from "vue";
+import { useDialogPluginComponent } from "quasar";
+import { useI18n } from "vue-i18n";
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { log } = defineProps({
+  log: {
+    type: Object,
+    required: true,
+  },
+});
+
+const { t } = useI18n();
+const { dialogRef, onDialogHide, onDialogCancel } = useDialogPluginComponent();
+
+const emptyLabel = "—";
+const appName = "Diária";
+
+const dialogTitle = () => t("push_notification.tabs.history");
+const targetLabel = computed(() =>
+  log.target ? t(`push_notification.targets.${log.target}`, log.target) : emptyLabel,
+);
+</script>
+
+<style scoped lang="scss">
+.push-body {
+  white-space: pre-wrap;
+  word-break: break-word;
+}
+</style>

+ 22 - 0
src/router/routes/pushNotification.route.js

@@ -0,0 +1,22 @@
+export default [
+  {
+    path: "/pushs",
+    name: "PushNotificationsPage",
+    component: () => import("pages/pushNotification/PushNotificationsPage.vue"),
+    meta: {
+      title: "ui.navigation.pushs",
+      requireAuth: true,
+      requiredPermission: "push.notification",
+      breadcrumbs: [
+        {
+          name: "DashboardPage",
+          title: "ui.navigation.dashboard",
+        },
+        {
+          name: "PushNotificationsPage",
+          title: "ui.navigation.pushs",
+        },
+      ],
+    },
+  },
+];

+ 9 - 0
src/stores/navigation.js

@@ -58,6 +58,15 @@ export const navigationStore = defineStore("navigation", () => {
       permission: false,
       permissionScope: "support.request",
     },
+    {
+      type: "single",
+      title: "ui.navigation.pushs",
+      name: "PushNotificationsPage",
+      icon: "mdi-bell-ring-outline",
+      disable: false,
+      permission: false,
+      permissionScope: "push.notification",
+    },
     {
       type: "expansive",
       title: "ui.navigation.registration",