Jelajahi Sumber

feat: :sparkles: crud cliente formas pagamento

crud cliente formas pagamento
Gustavo Zanatta 1 bulan lalu
induk
melakukan
9eadd790ec

+ 26 - 0
src/api/clientPaymentMethod.js

@@ -0,0 +1,26 @@
+import api from "src/api";
+
+export const getClientPaymentMethods = async (clientId) => {
+  const { data } = await api.get(`/client/payment-methods/${clientId}`);
+  return data.payload;
+}
+
+export const getClientPaymentMethod = async (id) => {
+  const { data } = await api.get(`/client/payment-method/${id}`);
+  return data.payload;
+}
+
+export const createClientPaymentMethod = async (info) => {
+  const { data } = await api.post(`/client/payment-method`, info);
+  return data.payload;
+}
+
+export const updateClientPaymentMethod = async (id, info) => {
+  const { data } = await api.put(`/client/payment-method/${id}`, info);
+  return data.payload;
+}
+
+export const deleteClientPaymentMethod = async (id) => {
+  const { data } = await api.delete(`/client/payment-method/${id}`);
+  return data.payload;
+}

+ 4 - 4
src/composables/useSubmitHandler.js

@@ -4,7 +4,7 @@ export function useSubmitHandler(options = {}) {
   const { onSuccess, onError, formRef, scrollFn, containerRef } = options;
 
   const loading = ref(false);
-  const validationErrors = ref({});
+  const serverErrors = ref({});
 
   const getFormRefs = () => {
     const refs = formRef?.value;
@@ -36,7 +36,7 @@ export function useSubmitHandler(options = {}) {
 
   const execute = async (apiCallThunk) => {
     loading.value = true;
-    validationErrors.value = {};
+    serverErrors.value = {};
 
     let allValid = true;
     const refsToValidate = getFormRefs();
@@ -73,7 +73,7 @@ export function useSubmitHandler(options = {}) {
       const errors = error.response.data.errors || {};
       for (const key in errors) {
         const message = errors[key][0];
-        validationErrors.value[key] = message;
+        serverErrors.value[key] = message;
       }
       await scrollToFirstError();
     }
@@ -88,7 +88,7 @@ export function useSubmitHandler(options = {}) {
 
   return {
     loading,
-    validationErrors,
+    serverErrors,
     execute,
   };
 }

+ 117 - 0
src/helpers/utils.js

@@ -256,6 +256,119 @@ const formatDocument = (value) => {
   return value;
 };
 
+/**
+ * @description Valida número de cartão de crédito usando algoritmo de Luhn
+ * @param {string} cardNumber número do cartão
+ * @returns {boolean} true se válido
+ */
+const validateCardNumberLuhn = (cardNumber) => {
+  const digits = cardNumber.replace(/\D/g, '');
+  
+  if (digits.length < 13 || digits.length > 19) {
+    return false;
+  }
+
+  let sum = 0;
+  let isEven = false;
+
+  for (let i = digits.length - 1; i >= 0; i--) {
+    let digit = parseInt(digits[i]);
+
+    if (isEven) {
+      digit *= 2;
+      if (digit > 9) {
+        digit -= 9;
+      }
+    }
+
+    sum += digit;
+    isEven = !isEven;
+  }
+
+  return sum % 10 === 0;
+};
+
+/**
+ * @description Detecta a bandeira do cartão baseado no número
+ * @param {string} cardNumber número do cartão
+ * @returns {string|null} bandeira do cartão (visa, mastercard, elo, etc) ou null
+ */
+const detectCardBrand = (cardNumber) => {
+  const digits = cardNumber.replace(/\D/g, '');
+
+  // Visa
+  if (/^4/.test(digits)) {
+    return 'visa';
+  }
+
+  // Mastercard
+  if (/^(5[1-5]|2(2[2-9]|[3-6]|7[0-1]|72[0]))/.test(digits)) {
+    return 'mastercard';
+  }
+
+  // Elo
+  if (/^(4011|4312|4389|5041|5066|5067|6277|6362|6363|6504|6505|6516)/.test(digits)) {
+    return 'elo';
+  }
+
+  // Hipercard
+  if (/^(384100|384140|384160|606282|637095|637568|60)/.test(digits)) {
+    return 'hipercard';
+  }
+
+  // Diners Club
+  if (/^(30[0-5]|36|38)/.test(digits)) {
+    return 'diners';
+  }
+
+  // Discover
+  if (/^(6011|622(12[6-9]|1[3-9]|[2-8]|9[01]|92[0-5])|64[4-9]|65)/.test(digits)) {
+    return 'discover';
+  }
+
+  return null;
+};
+
+/**
+ * @description Valida se a data de expiração do cartão não está vencida
+ * @param {string} expiration data no formato MM/YYYY
+ * @returns {boolean} true se não está vencida
+ */
+const validateCardExpiration = (expiration) => {
+  if (!expiration || !/^\d{2}\/\d{4}$/.test(expiration)) {
+    return false;
+  }
+  const [month, year] = expiration.split('/').map(Number);
+  if (month < 1 || month > 12) {
+    return false;
+  }
+
+  const now = new Date();
+  const currentYear = now.getFullYear();
+  const currentMonth = now.getMonth() + 1;
+
+  if (year < currentYear) {
+    return false;
+  }
+
+  if (year === currentYear && month < currentMonth) {
+    return false;
+  }
+
+  return true;
+};
+
+/**
+ * @description Mascara o número do cartão para exibição (**** **** **** 1234)
+ * @param {string} cardNumber número do cartão
+ * @returns {string} número mascarado
+ */
+const maskCardNumber = (cardNumber) => {
+  const digits = cardNumber.replace(/\D/g, '');
+  const lastFour = digits.slice(-4);
+  return `**** **** **** ${lastFour}`;
+};
+
 export {
   formatDateDMYtoYMD,
   formatDateYMDtoDMY,
@@ -269,4 +382,8 @@ export {
   validateCpfCnpj,
   calculateDailyPrices,
   formatDocument,
+  validateCardNumberLuhn,
+  detectCardBrand,
+  validateCardExpiration,
+  maskCardNumber,
 };

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

@@ -396,6 +396,33 @@
     "notes": "Notes",
     "favorited_at": "Favorited at"
   },
+  "client_payment_methods": {
+    "singular": "Payment Method",
+    "plural": "Payment Methods",
+    "header": "Payment Methods",
+    "add_button": "Add Card",
+    "edit_button": "Edit Card",
+    "empty_state": "No payment methods registered",
+    "card_number": "Card Number",
+    "holder_name": "Cardholder Name",
+    "expiration": "Expiration",
+    "cvv": "CVV",
+    "card_name": "Card Name",
+    "brand": "Brand",
+    "last_four_digits": "Last 4",
+    "is_active": "Active",
+    "invalid_card_number": "Invalid card number",
+    "expired_card": "Expired card",
+    "brands": {
+      "visa": "Visa",
+      "mastercard": "Mastercard",
+      "elo": "Elo",
+      "hipercard": "Hipercard",
+      "diners": "Diners Club",
+      "discover": "Discover",
+      "amex": "American Express"
+    }
+  },
   "orders": {
     "singular": "Order",
     "plural": "Orders",

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

@@ -396,6 +396,33 @@
     "notes": "Observaciones",
     "favorited_at": "Favoritado en"
   },
+  "client_payment_methods": {
+    "singular": "Método de Pago",
+    "plural": "Métodos de Pago",
+    "header": "Métodos de Pago",
+    "add_button": "Agregar Tarjeta",
+    "edit_button": "Editar Tarjeta",
+    "empty_state": "No hay métodos de pago registrados",
+    "card_number": "Número de Tarjeta",
+    "holder_name": "Nombre del Titular",
+    "expiration": "Vencimiento",
+    "cvv": "CVV",
+    "card_name": "Nombre de la Tarjeta",
+    "brand": "Marca",
+    "last_four_digits": "Últimos 4",
+    "is_active": "Activo",
+    "invalid_card_number": "Número de tarjeta inválido",
+    "expired_card": "Tarjeta vencida",
+    "brands": {
+      "visa": "Visa",
+      "mastercard": "Mastercard",
+      "elo": "Elo",
+      "hipercard": "Hipercard",
+      "diners": "Diners Club",
+      "discover": "Discover",
+      "amex": "American Express"
+    }
+  },
   "orders": {
     "singular": "Pedido",
     "plural": "Pedidos",

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

@@ -396,6 +396,33 @@
     "notes": "Observações",
     "favorited_at": "Favoritado em"
   },
+  "client_payment_methods": {
+    "singular": "Método de Pagamento",
+    "plural": "Métodos de Pagamento",
+    "header": "Métodos de Pagamento",
+    "add_button": "Adicionar Cartão",
+    "edit_button": "Editar Cartão",
+    "empty_state": "Nenhum método de pagamento cadastrado",
+    "card_number": "Número do Cartão",
+    "holder_name": "Nome do Titular",
+    "expiration": "Validade",
+    "cvv": "CVV",
+    "card_name": "Nome do Cartão",
+    "brand": "Bandeira",
+    "last_four_digits": "Final",
+    "is_active": "Ativo",
+    "invalid_card_number": "Número de cartão inválido",
+    "expired_card": "Cartão vencido",
+    "brands": {
+      "visa": "Visa",
+      "mastercard": "Mastercard",
+      "elo": "Elo",
+      "hipercard": "Hipercard",
+      "diners": "Diners Club",
+      "discover": "Discover",
+      "amex": "American Express"
+    }
+  },
   "orders": {
     "singular": "Pedido",
     "plural": "Pedidos",

+ 6 - 0
src/pages/client/components/AddEditClientDialog.vue

@@ -14,6 +14,7 @@
         <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-tabs>
 
       <q-separator v-if="client" />
@@ -70,6 +71,10 @@
         <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-panels>
     </q-card>
   </q-dialog>
@@ -89,6 +94,7 @@ 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';
 
 defineEmits([
   ...useDialogPluginComponent.emits,

+ 220 - 0
src/pages/client/components/AddEditClientPaymentMethodDialog.vue

@@ -0,0 +1,220 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="q-dialog-plugin" style="width: 700px; max-width: 90vw">
+      <DefaultDialogHeader :title="title" @close="onDialogCancel" />
+      <q-form ref="formRef" @submit="onOKClick">
+        <q-card-section class="row q-col-gutter-sm">
+          <q-input
+            v-model="form.card_number"
+            :label="$t('client_payment_methods.card_number')"
+            :rules="[inputRules.required, validateCardNumber]"
+            :error="!!serverErrors?.card_number"
+            :error-message="serverErrors?.card_number"
+            mask="**** **** **** ####"
+            unmasked-value
+            class="col-12"
+            @update:model-value="onCardNumberChange"
+          >
+            <template #append>
+              <q-icon name="mdi-credit-card-outline" />
+            </template>
+          </q-input>
+
+          <q-input
+            v-model="form.holder_name"
+            :label="$t('client_payment_methods.holder_name')"
+            :rules="[inputRules.required]"
+            :error="!!serverErrors?.holder_name"
+            :error-message="serverErrors?.holder_name"
+            class="col-12"
+            @update:model-value="serverErrors.holder_name = null"
+          />
+
+          <q-input
+            v-model="form.expiration"
+            :label="$t('client_payment_methods.expiration')"
+            :rules="[inputRules.required, validateExpiration]"
+            :error="!!serverErrors?.expiration"
+            :error-message="serverErrors?.expiration"
+            mask="##/####"
+            placeholder="MM/YYYY"
+            class="col-md-6 col-12"
+            @update:model-value="serverErrors.expiration = null"
+          >
+            <template #append>
+              <q-icon name="mdi-calendar-outline" />
+            </template>
+          </q-input>
+
+          <q-input
+            v-model="form.cvv"
+            :label="$t('client_payment_methods.cvv')"
+            :rules="[inputRules.required]"
+            :error="!!serverErrors?.cvv"
+            :error-message="serverErrors?.cvv"
+            mask="####"
+            unmasked-value
+            type="password"
+            class="col-md-6 col-12"
+            @update:model-value="serverErrors.cvv = null"
+          >
+            <template #append>
+              <q-icon name="mdi-lock-outline" />
+            </template>
+          </q-input>
+
+          <q-input
+            v-model="form.card_name"
+            :label="$t('client_payment_methods.card_name')"
+            :error="!!serverErrors?.card_name"
+            :error-message="serverErrors?.card_name"
+            class="col-md-6 col-12"
+            @update:model-value="serverErrors.card_name = null"
+          />
+
+          <q-input
+            v-model="form.brand"
+            :label="$t('client_payment_methods.brand')"
+            :error="!!serverErrors?.brand"
+            :error-message="serverErrors?.brand"
+            readonly
+            class="col-md-6 col-12"
+          >
+            <template #append>
+              <q-icon name="mdi-credit-card-check-outline" />
+            </template>
+          </q-input>
+
+          <q-checkbox
+            v-model="form.is_active"
+            :label="$t('client_payment_methods.is_active')"
+            class="col-12"
+          />
+        </q-card-section>
+
+        <q-card-actions align="right">
+          <q-btn
+            flat
+            :label="$t('common.actions.cancel')"
+            color="negative"
+            @click="onDialogCancel"
+          />
+          <q-btn
+            type="submit"
+            :label="$t('common.actions.save')"
+            :loading="loading"
+            :disable="!hasUpdatedFields"
+            color="primary"
+          />
+        </q-card-actions>
+      </q-form>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import { useDialogPluginComponent } from 'quasar'
+import { useI18n } from 'vue-i18n'
+import { useFormUpdateTracker } from 'src/composables/useFormUpdateTracker'
+import { useSubmitHandler } from 'src/composables/useSubmitHandler'
+import {
+  createClientPaymentMethod,
+  updateClientPaymentMethod
+} from 'src/api/clientPaymentMethod'
+import DefaultDialogHeader from 'src/components/defaults/DefaultDialogHeader.vue'
+import { useInputRules } from 'src/composables/useInputRules'
+import {
+  validateCardNumberLuhn,
+  detectCardBrand,
+  validateCardExpiration
+} from 'src/helpers/utils'
+
+const props = defineProps({
+  paymentMethod: {
+    type: Object,
+    default: null
+  },
+  clientId: {
+    type: Number,
+    required: true
+  },
+  title: {
+    type: Function,
+    default: () => ''
+  }
+})
+
+defineEmits([...useDialogPluginComponent.emits])
+const { t } = useI18n()
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
+const { inputRules } = useInputRules()
+const formRef = ref(null)
+
+const { form, getUpdatedFields, hasUpdatedFields } = useFormUpdateTracker({
+  client_id: props.paymentMethod ? props.paymentMethod.client_id : props.clientId,
+  card_number: props.paymentMethod ? props.paymentMethod.card_number : null,
+  holder_name: props.paymentMethod ? props.paymentMethod.holder_name : null,
+  expiration: props.paymentMethod ? props.paymentMethod.expiration : null,
+  cvv: props.paymentMethod ? props.paymentMethod.cvv : null,
+  card_name: props.paymentMethod ? props.paymentMethod.card_name : null,
+  brand: props.paymentMethod ? props.paymentMethod.brand : null,
+  last_four_digits: props.paymentMethod ? props.paymentMethod.last_four_digits : null,
+  is_active: props.paymentMethod ? props.paymentMethod.is_active : true
+})
+
+const {
+  loading,
+  serverErrors,
+  execute: submitForm,
+} = useSubmitHandler({
+  onSuccess: () => onDialogOK(true),
+  formRef: formRef,
+})
+
+const validateCardNumber = (val) => {
+  if (!val) return true
+  if (!validateCardNumberLuhn(val)) {
+    return t('client_payment_methods.invalid_card_number')
+  }
+  return true
+}
+
+const validateExpiration = (val) => {
+  if (!val) return true
+  if (!validateCardExpiration(val)) {
+    return t('client_payment_methods.expired_card')
+  }
+  return true
+}
+
+const onCardNumberChange = () => {
+  serverErrors.card_number = null
+  
+  if (form.card_number && form.card_number.length >= 6 && !form.card_number.includes('*')) {
+    const brand = detectCardBrand(form.card_number)
+    if (brand) {
+      form.brand = brand
+    }
+    
+    const digits = form.card_number.replace(/\D/g, '')
+    if (digits.length >= 4) {
+      form.last_four_digits = digits.slice(-4)
+    }
+  }
+}
+
+const onOKClick = async () => {
+  if (props.paymentMethod) {
+    await submitForm(() => updateClientPaymentMethod(props.paymentMethod.id, getUpdatedFields.value))
+  } else {
+    await submitForm(() => createClientPaymentMethod({ ...form }))
+  }
+}
+
+onMounted(() => {
+  if (props.clientId) {
+    form.client_id = props.clientId
+  }
+})
+</script>

+ 3 - 2
src/pages/client/components/ClientFavoriteProvidersPanel.vue

@@ -10,6 +10,7 @@
     :mostrar-selecao-de-colunas="false"
     :mostrar-botao-fullscreen="false"
     :mostrar-toggle-inativos="false"
+    open-item
     @on-row-click="onRowClick"
     @on-add-item="onAddItem"
   >
@@ -86,11 +87,11 @@ const onAddItem = () => {
   })
 }
 
-const onRowClick = (favorite) => {
+const onRowClick = ({ row }) => {
   $q.dialog({
     component: AddEditClientFavoriteProviderDialog,
     componentProps: {
-      favorite,
+      favorite: row,
       clientId: props.clientId,
       title: () => t('client_favorite_providers.edit_button')
     }

+ 140 - 0
src/pages/client/components/ClientPaymentMethodsPanel.vue

@@ -0,0 +1,140 @@
+<template>
+  <DefaultTable
+    ref="tableRef"
+    :columns="columns"
+    :loading="loading"
+    :api-call="() => getClientPaymentMethods(props.clientId)"
+    :add-button-label="$t('client_payment_methods.add_button')"
+    :empty-message="$t('client_payment_methods.empty_state')"
+    :delete-function="deleteClientPaymentMethod"
+    :mostrar-selecao-de-colunas="false"
+    :mostrar-botao-fullscreen="false"
+    :mostrar-toggle-inativos="false"
+    open-item
+    @on-row-click="onRowClick"
+    @on-add-item="onAddItem"
+  >
+    <template #body-cell-last_four_digits="slotProps">
+      <q-td :props="slotProps">
+        {{ '**** ' + slotProps.row.last_four_digits }}
+      </q-td>
+    </template>
+
+    <template #body-cell-brand="slotProps">
+      <q-td :props="slotProps">
+        <q-chip
+          v-if="slotProps.row.brand"
+          :label="$t(`client_payment_methods.brands.${slotProps.row.brand}`)"
+          size="sm"
+          color="primary"
+          text-color="white"
+        />
+      </q-td>
+    </template>
+
+    <template #body-cell-is_active="slotProps">
+      <q-td :props="slotProps">
+        <q-icon
+          :name="slotProps.row.is_active ? 'mdi-check-circle' : 'mdi-close-circle'"
+          :color="slotProps.row.is_active ? 'positive' : 'negative'"
+          size="sm"
+        />
+      </q-td>
+    </template>
+  </DefaultTable>
+</template>
+
+<script setup>
+import { ref, computed } from 'vue'
+import { useI18n } from 'vue-i18n'
+import { useQuasar } from 'quasar'
+import { getClientPaymentMethods, deleteClientPaymentMethod } from 'src/api/clientPaymentMethod'
+import DefaultTable from 'src/components/defaults/DefaultTable.vue'
+import AddEditClientPaymentMethodDialog from './AddEditClientPaymentMethodDialog.vue'
+
+const props = defineProps({
+  clientId: {
+    type: Number,
+    required: true
+  }
+})
+
+const { t } = useI18n()
+const $q = useQuasar()
+const tableRef = ref(null)
+const loading = ref(false)
+
+const columns = computed(() => [
+  {
+    name: 'holder_name',
+    label: t('client_payment_methods.holder_name'),
+    align: 'left',
+    field: 'holder_name',
+    sortable: true
+  },
+  {
+    name: 'card_name',
+    label: t('client_payment_methods.card_name'),
+    align: 'left',
+    field: 'card_name',
+    sortable: true
+  },
+  {
+    name: 'last_four_digits',
+    label: t('client_payment_methods.last_four_digits'),
+    align: 'left',
+    field: 'last_four_digits',
+    sortable: false
+  },
+  {
+    name: 'brand',
+    label: t('client_payment_methods.brand'),
+    align: 'center',
+    field: 'brand',
+    sortable: true
+  },
+  {
+    name: 'is_active',
+    label: t('client_payment_methods.is_active'),
+    align: 'center',
+    field: 'is_active',
+    sortable: true
+  },
+  {
+    name: 'actions',
+    label: t('common.terms.actions'),
+    align: 'center',
+    field: 'actions'
+  }
+])
+
+const onAddItem = () => {
+  $q.dialog({
+    component: AddEditClientPaymentMethodDialog,
+    componentProps: {
+      clientId: props.clientId,
+      title: () => t('client_payment_methods.add_button')
+    }
+  }).onOk((success) => {
+    if(success) {
+      tableRef.value.refresh()
+    }
+  })
+}
+
+const onRowClick = ({ row }) => {
+  $q.dialog({
+    component: AddEditClientPaymentMethodDialog,
+    componentProps: {
+      paymentMethod: row,
+      clientId: props.clientId,
+      title: () => t('client_payment_methods.edit_button')
+    }
+  }).onOk((success) => {
+    if(success) {
+      tableRef.value.refresh()
+    }
+  })
+}
+
+</script>