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