浏览代码

feat: adiciona integracao de pagamento

Gustavo Mantovani 3 周之前
父节点
当前提交
31c4eb1266

+ 6 - 0
src/api/payment.js

@@ -0,0 +1,6 @@
+import api from 'src/api';
+
+export const paySchedule = async (scheduleId, payload) => {
+  const { data } = await api.post(`/payment/schedule/${scheduleId}/pay`, payload);
+  return data.payload;
+};

+ 10 - 1
src/components/dashboard/DashboardProvidersClose.vue

@@ -41,7 +41,7 @@
                   </div>
                   <div class="row items-center">
                     <q-icon name="mdi-map-marker-outline" color="text" size="16px" />
-                    <span class="text-provider-close-jobs">{{ 0 + ' km' }}</span>
+                    <span class="text-provider-close-jobs">{{ formatDistance(p.distance_km) }}</span>
                   </div>
                 </div>
               </div>
@@ -89,6 +89,15 @@ const periodTypeMap = ref({
 });
 
 
+const formatDistance = (distance) => {
+  if (distance === null || distance === undefined || distance === '') return '—';
+
+  const numericDistance = Number(distance);
+  if (!Number.isFinite(numericDistance)) return '—';
+
+  return `${numericDistance.toLocaleString('pt-BR', { maximumFractionDigits: 1 })} km`;
+};
+
 const showCorrectValues = (p) => {
   switch (currentPeriodType.value) {
     case 8:

+ 10 - 7
src/components/dashboard/SchedulePaymentDialog.vue

@@ -179,14 +179,17 @@ const onConfirm = () => {
     }).onOk(() => {
       onDialogOK()
     })
-  } else {
-    $q.dialog({
-      component: SchedulePaymentProcessingDialog,
-      componentProps: { schedule: props.schedule },
-    }).onOk(() => {
-      onDialogOK()
-    })
+    return
   }
+
+  const clientPaymentMethodId = Number(String(selectedMethod.value).replace('card_', ''))
+
+  $q.dialog({
+    component: SchedulePaymentProcessingDialog,
+    componentProps: { schedule: props.schedule, clientPaymentMethodId },
+  }).onOk(() => {
+    onDialogOK()
+  })
 }
 
 onMounted(() => {

+ 46 - 82
src/components/dashboard/SchedulePaymentPixDialog.vue

@@ -34,6 +34,16 @@
         </i18n-t>
       </div>
 
+      <div v-else-if="processing" class="col column items-center justify-center q-px-xl">
+        <q-spinner-oval color="primary" size="72px" class="q-mb-lg" />
+        <div class="processing-title text-primary text-weight-bold text-center q-mb-sm">
+          {{ $t('payment.processing_title') }}
+        </div>
+        <div class="processing-message text-grey-6 text-center">
+          {{ $t('payment.processing_message') }}
+        </div>
+      </div>
+
       <div v-else class="col scroll q-px-lg q-pt-lg q-pb-xl column">
 
         <div class="row items-center justify-between q-mb-sm">
@@ -52,74 +62,20 @@
         </div>
 
         <div class="flex flex-center q-mb-md">
-          <div class="qrcode-wrapper">
-            <svg viewBox="0 0 200 200" width="180" height="180" xmlns="http://www.w3.org/2000/svg">
-              <rect x="10" y="10" width="60" height="60" rx="4" fill="none" stroke="#000" stroke-width="6"/>
-              <rect x="22" y="22" width="36" height="36" rx="2" fill="#000"/>
-              <rect x="130" y="10" width="60" height="60" rx="4" fill="none" stroke="#000" stroke-width="6"/>
-              <rect x="142" y="22" width="36" height="36" rx="2" fill="#000"/>
-              <rect x="10" y="130" width="60" height="60" rx="4" fill="none" stroke="#000" stroke-width="6"/>
-              <rect x="22" y="142" width="36" height="36" rx="2" fill="#000"/>
-              <g fill="#000">
-                <rect x="85" y="10" width="8" height="8"/>
-                <rect x="100" y="10" width="8" height="8"/>
-                <rect x="85" y="24" width="8" height="8"/>
-                <rect x="108" y="24" width="8" height="8"/>
-                <rect x="85" y="38" width="16" height="8"/>
-                <rect x="85" y="52" width="8" height="8"/>
-                <rect x="100" y="52" width="16" height="8"/>
-                <rect x="10" y="85" width="8" height="8"/>
-                <rect x="24" y="85" width="16" height="8"/>
-                <rect x="48" y="85" width="8" height="8"/>
-                <rect x="62" y="85" width="8" height="8"/>
-                <rect x="10" y="99" width="16" height="8"/>
-                <rect x="34" y="99" width="8" height="8"/>
-                <rect x="54" y="99" width="16" height="8"/>
-                <rect x="10" y="113" width="8" height="8"/>
-                <rect x="26" y="113" width="16" height="8"/>
-                <rect x="56" y="113" width="8" height="8"/>
-                <rect x="85" y="85" width="8" height="8"/>
-                <rect x="100" y="85" width="16" height="8"/>
-                <rect x="124" y="85" width="8" height="8"/>
-                <rect x="140" y="85" width="16" height="8"/>
-                <rect x="166" y="85" width="8" height="8"/>
-                <rect x="85" y="100" width="16" height="8"/>
-                <rect x="108" y="100" width="8" height="8"/>
-                <rect x="130" y="100" width="16" height="8"/>
-                <rect x="154" y="100" width="8" height="8"/>
-                <rect x="170" y="100" width="8" height="8"/>
-                <rect x="85" y="115" width="8" height="8"/>
-                <rect x="100" y="115" width="8" height="8"/>
-                <rect x="116" y="115" width="16" height="8"/>
-                <rect x="140" y="115" width="8" height="8"/>
-                <rect x="156" y="115" width="16" height="8"/>
-                <rect x="85" y="130" width="16" height="8"/>
-                <rect x="108" y="130" width="8" height="8"/>
-                <rect x="124" y="130" width="16" height="8"/>
-                <rect x="148" y="130" width="8" height="8"/>
-                <rect x="164" y="130" width="8" height="8"/>
-                <rect x="85" y="144" width="8" height="8"/>
-                <rect x="100" y="144" width="16" height="8"/>
-                <rect x="124" y="144" width="8" height="8"/>
-                <rect x="140" y="144" width="16" height="8"/>
-                <rect x="166" y="144" width="8" height="8"/>
-                <rect x="85" y="158" width="16" height="8"/>
-                <rect x="110" y="158" width="8" height="8"/>
-                <rect x="126" y="158" width="8" height="8"/>
-                <rect x="144" y="158" width="8" height="8"/>
-                <rect x="160" y="158" width="16" height="8"/>
-                <rect x="85" y="172" width="8" height="8"/>
-                <rect x="100" y="172" width="8" height="8"/>
-                <rect x="116" y="172" width="16" height="8"/>
-                <rect x="142" y="172" width="8" height="8"/>
-                <rect x="158" y="172" width="8" height="8"/>
-                <rect x="174" y="172" width="8" height="8"/>
-              </g>
-            </svg>
+          <q-img
+            v-if="pixQrCodeUrl"
+            :src="pixQrCodeUrl"
+            width="180px"
+            height="180px"
+            fit="contain"
+            class="qrcode-wrapper"
+          />
+          <div v-else class="qrcode-wrapper column items-center justify-center">
+            <q-icon name="mdi-qrcode" size="80px" color="grey-6" />
           </div>
         </div>
 
-        <div class="pix-code-text q-mb-md">{{ pixCode }}</div>
+        <div class="pix-code-text q-mb-md">{{ pixCode || 'Código Pix indisponível.' }}</div>
 
         <q-btn
           unelevated
@@ -145,10 +101,10 @@
 </template>
 
 <script setup>
-import { ref, onMounted, onUnmounted } from 'vue'
+import { computed, ref, onMounted, onUnmounted } from 'vue'
 import { useDialogPluginComponent, useQuasar, copyToClipboard } from 'quasar'
 import { formatCurrency } from 'src/helpers/utils'
-import { updateScheduleStatus } from 'src/api/schedule'
+import { paySchedule } from 'src/api/payment'
 
 const props = defineProps({
   schedule: { type: Object, required: true },
@@ -160,30 +116,28 @@ defineEmits([...useDialogPluginComponent.emits])
 const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
 const $q = useQuasar()
 
-const pixCode = `00020126580014br.gov.bcb.pix0136sfp-diaria-${props.schedule.id}-${props.schedule.provider_id}5204000053039865406${(props.total * 100).toFixed(0).padStart(8, '0')}5802BR5913Diaria App6009SAO PAULO62070503***6304ABCD`
-
+const payment = ref(null)
 const success = ref(false)
-const processing = ref(false)
+const processing = ref(true)
+
+const pixTransaction = computed(() => payment.value?.gateway_payload?.charges?.[0]?.last_transaction ?? {})
+const pixCode = computed(() => pixTransaction.value?.qr_code ?? pixTransaction.value?.qr_code_text ?? '')
+const pixQrCodeUrl = computed(() => pixTransaction.value?.qr_code_url ?? '')
 
 const copyCode = async () => {
   try {
-    await copyToClipboard(pixCode)
+    if (!pixCode.value) {
+      $q.notify({ type: 'negative', message: 'Código Pix indisponível.' })
+      return
+    }
+
+    await copyToClipboard(pixCode.value)
     $q.notify({ type: 'positive', message: 'Código copiado!' })
   } catch {
     $q.notify({ type: 'negative', message: 'Erro ao copiar.' })
     return
   }
 
-  processing.value = true
-  try {
-    await updateScheduleStatus(props.schedule.id, 'paid')
-  } catch (e) {
-    console.error('Erro ao atualizar status:', e)
-  } finally {
-    processing.value = false
-  }
-  success.value = true
-  setTimeout(() => onDialogOK(), 3000)
 }
 
 const totalSeconds = ref(20 * 60)
@@ -197,9 +151,19 @@ const updateCountdown = () => {
   if (totalSeconds.value > 0) totalSeconds.value--
 }
 
-onMounted(() => {
+onMounted(async () => {
   updateCountdown()
   timer = setInterval(updateCountdown, 1000)
+
+  try {
+    payment.value = await paySchedule(props.schedule.id, { payment_method: 'pix' })
+  } catch (e) {
+    console.error('Erro ao criar pagamento Pix:', e)
+    $q.notify({ type: 'negative', message: e?.response?.data?.message ?? 'Erro ao criar pagamento Pix.' })
+    onDialogCancel()
+  } finally {
+    processing.value = false
+  }
 })
 
 onUnmounted(() => {

+ 18 - 7
src/components/dashboard/SchedulePaymentProcessingDialog.vue

@@ -49,27 +49,38 @@
 
 <script setup>
 import { ref, onMounted } from 'vue'
-import { useDialogPluginComponent } from 'quasar'
-import { updateScheduleStatus } from 'src/api/schedule'
+import { useDialogPluginComponent, useQuasar } from 'quasar'
+import { paySchedule } from 'src/api/payment'
 
 const props = defineProps({
   schedule: { type: Object, required: true },
+  clientPaymentMethodId: { type: Number, required: true },
 })
 
 defineEmits([...useDialogPluginComponent.emits])
 
-const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent()
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
+const $q = useQuasar()
 
 const success = ref(false)
 
 onMounted(async () => {
-  await new Promise(resolve => setTimeout(resolve, 4000))
   try {
-    await updateScheduleStatus(props.schedule.id, 'paid')
+    const payment = await paySchedule(props.schedule.id, {
+      payment_method: 'credit_card',
+      client_payment_method_id: props.clientPaymentMethodId,
+    })
+
+    if (payment.status !== 'paid') {
+      throw new Error(payment.failure_message || 'Pagamento não confirmado.')
+    }
+
+    success.value = true
   } catch (e) {
-    console.error('Erro ao atualizar status:', e)
+    console.error('Erro ao pagar agendamento:', e)
+    $q.notify({ type: 'negative', message: e?.response?.data?.message ?? e.message ?? 'Erro ao processar pagamento.' })
+    onDialogCancel()
   }
-  success.value = true
 })
 </script>
 

+ 1 - 1
src/composables/useAuth.js

@@ -14,7 +14,7 @@ export const useAuth = () => {
 
   const setAuthDataFromPayload = async (tokens) => {
     const { access_token, refresh_token, user } = tokens;
-    userStore().user = user;
+    userStore().setUser(user);
     userStore().accessToken = access_token;
     await setRefreshToken(refresh_token);
     await permissionStore().fetchScopes();

+ 10 - 1
src/pages/search/SearchPage.vue

@@ -112,7 +112,7 @@
                     </div>
                     <div class="row items-center">
                       <q-icon name="mdi-map-marker-outline" color="text" size="16px" />
-                      <span class="text-provider-close-jobs">{{ 0 + ' km' }}</span>
+                      <span class="text-provider-close-jobs">{{ formatDistance(p.distance_km) }}</span>
                     </div>
                   </div>
                 </div>
@@ -173,6 +173,15 @@ const priceByPeriod = (p) => {
   return p[key] ? formatCurrency(p[key]) : t('search_page.no_price');
 };
 
+const formatDistance = (distance) => {
+  if (distance === null || distance === undefined || distance === '') return '—';
+
+  const numericDistance = Number(distance);
+  if (!Number.isFinite(numericDistance)) return '—';
+
+  return `${numericDistance.toLocaleString('pt-BR', { maximumFractionDigits: 1 })} km`;
+};
+
 const setPeriodTypePrevious = () => {
   const prev = currentPeriodType.value - 2;
   if (periodTypeMap[prev]) currentPeriodType.value = prev;

+ 18 - 2
src/stores/user.js

@@ -7,9 +7,25 @@ export const userStore = defineStore("user", () => {
   const accessToken = ref(null);
   const isAdmin = ref(false);
 
+  const normalizeUser = (userData) => {
+    if (!userData) return null;
+
+    return {
+      ...userData,
+      client: userData.client ?? (userData.client_id ? {
+        id: userData.client_id,
+        document: userData.client_document ?? null,
+      } : null),
+      provider: userData.provider ?? (userData.provider_id ? {
+        id: userData.provider_id,
+      } : null),
+    };
+  };
+
   const setUser = (userData) => {
-    user.value = userData;
-    isAdmin.value = userData.type === "admin";
+    const normalized = normalizeUser(userData);
+    user.value = normalized;
+    isAdmin.value = normalized?.type === "admin";
   };
 
   const resetUser = () => {