Gustavo Zanatta пре 1 месец
родитељ
комит
6088806c9d

+ 28 - 0
src/api/provider.js

@@ -0,0 +1,28 @@
+import { createCachedApi } from "./cacheService";
+
+const api = createCachedApi("provider");
+
+export const getProvider = async (id) => {
+  const { data } = await api.get("/provider/" + id);
+  return data.payload;
+};
+
+export const getProviders = async () => {
+  const { data } = await api.get("/provider");
+  return data.payload;
+};
+
+export const createProvider = async (provider) => {
+  const { data } = await api.post("/provider", provider);
+  return data.payload;
+};
+
+export const updateProvider = async (provider, id) => {
+  const { data } = await api.put(`/provider/${id}`, provider);
+  return data.payload;
+};
+
+export const deleteProvider = async (id) => {
+  const { data } = await api.del(`/provider/${id}`);
+  return data.payload;
+};

+ 114 - 0
src/components/provider/ProviderSelect.vue

@@ -0,0 +1,114 @@
+<template>
+  <q-select
+    v-model="selectedProvider"
+    v-bind="$attrs"
+    use-input
+    hide-selected
+    fill-input
+    clearable
+    :options="providerOptions"
+    :label
+    :rules
+    :loading
+    :placeholder="$t('common.actions.search') + ' ' + $t('ui.navigation.provider')"
+    :error
+    :error-message
+    @filter="filterFn"
+  >
+    <template #no-option>
+      <q-item>
+        <q-item-section class="text-grey">
+          {{ $t("http.errors.no_records_found") }}
+        </q-item-section>
+      </q-item>
+    </template>
+  </q-select>
+</template>
+
+<script setup>
+import { getProviders } from "src/api/provider";
+import { ref, onMounted } from "vue";
+import { normalizeString } from "src/helpers/utils";
+import { useI18n } from "vue-i18n";
+
+const { label, rules, initialId } = defineProps({
+  label: {
+    type: String,
+    default: () => useI18n().t("ui.navigation.provider"),
+  },
+  rules: {
+    type: Array,
+    default: () => [],
+  },
+  initialId: {
+    type: Number,
+    required: false,
+    default: null,
+  },
+  error: {
+    type: Boolean,
+    default: false,
+  },
+  errorMessage: {
+    type: String,
+    default: "",
+  },
+});
+
+const selectedProvider = defineModel({ type: Object });
+
+const loading = ref(false);
+const baseOptions = ref([]);
+const providerOptions = ref([]);
+
+const filterFn = async (val, update) => {
+  const needle = normalizeString(val);
+  providerOptions.value = baseOptions.value.filter((v) => {
+    return (
+      normalizeString(v.label).includes(needle) ||
+      normalizeString(v.document).includes(needle)
+    );
+  });
+  update();
+};
+
+const selectProviderByName = (name) => {
+  if (selectedProvider.value?.label === name) {
+    return;
+  }
+  selectedProvider.value = baseOptions.value.find((provider) => provider.label === name);
+};
+
+const selectProviderById = (id) => {
+  if (selectedProvider.value?.value === id) {
+    return;
+  }
+  selectedProvider.value = baseOptions.value.find((provider) => provider.value === id);
+};
+
+onMounted(async () => {
+  try {
+    loading.value = true;
+    const baseProviders = await getProviders();
+    baseOptions.value = baseProviders.map((provider) => ({
+      label: provider.user?.name || provider.document,
+      value: provider.id,
+      document: provider.document,
+      user_id: provider.user_id,
+    }));
+    providerOptions.value = baseOptions.value;
+    if (initialId) {
+      selectProviderById(initialId);
+    }
+  } catch (e) {
+    console.log(e);
+  } finally {
+    loading.value = false;
+  }
+});
+
+defineExpose({
+  selectProviderByName,
+  selectProviderById,
+});
+</script>

+ 114 - 0
src/components/user/UserSelect.vue

@@ -0,0 +1,114 @@
+<template>
+  <q-select
+    v-model="selectedUser"
+    v-bind="$attrs"
+    use-input
+    hide-selected
+    fill-input
+    clearable
+    :options="userOptions"
+    :label
+    :rules
+    :loading
+    :placeholder="$t('common.actions.search') + ' ' + $t('common.terms.user')"
+    :error
+    :error-message
+    @filter="filterFn"
+  >
+    <template #no-option>
+      <q-item>
+        <q-item-section class="text-grey">
+          {{ $t("http.errors.no_records_found") }}
+        </q-item-section>
+      </q-item>
+    </template>
+  </q-select>
+</template>
+
+<script setup>
+import { getUsers } from "src/api/user";
+import { ref, onMounted } from "vue";
+import { normalizeString } from "src/helpers/utils";
+import { useI18n } from "vue-i18n";
+
+const { label, rules, initialId } = defineProps({
+  label: {
+    type: String,
+    default: () => useI18n().t("common.terms.user"),
+  },
+  rules: {
+    type: Array,
+    default: () => [],
+  },
+  initialId: {
+    type: Number,
+    required: false,
+    default: null,
+  },
+  error: {
+    type: Boolean,
+    default: false,
+  },
+  errorMessage: {
+    type: String,
+    default: "",
+  },
+});
+
+const selectedUser = defineModel({ type: Object });
+
+const loading = ref(false);
+const baseOptions = ref([]);
+const userOptions = ref([]);
+
+const filterFn = async (val, update) => {
+  const needle = normalizeString(val);
+  userOptions.value = baseOptions.value.filter((v) => {
+    return (
+      normalizeString(v.label).includes(needle) ||
+      normalizeString(v.email).includes(needle)
+    );
+  });
+  update();
+};
+
+const selectUserByName = (name) => {
+  if (selectedUser.value?.label === name) {
+    return;
+  }
+  selectedUser.value = baseOptions.value.find((user) => user.label === name);
+};
+
+const selectUserById = (id) => {
+  if (selectedUser.value?.value === id) {
+    return;
+  }
+  selectedUser.value = baseOptions.value.find((user) => user.value === id);
+};
+
+onMounted(async () => {
+  try {
+    loading.value = true;
+    const baseUsers = await getUsers();
+    baseOptions.value = baseUsers.map((user) => ({
+      label: user.name,
+      value: user.id,
+      email: user.email,
+      type: user.type,
+    }));
+    userOptions.value = baseOptions.value;
+    if (initialId) {
+      selectUserById(initialId);
+    }
+  } catch (e) {
+    console.log(e);
+  } finally {
+    loading.value = false;
+  }
+});
+
+defineExpose({
+  selectUserByName,
+  selectUserById,
+});
+</script>

+ 0 - 1
src/composables/useInputRules.js

@@ -63,7 +63,6 @@ export const useInputRules = () => {
 };
 
 function isValidCPF(cpf) {
-  console.log("isValidCPF", cpf);
   if (!cpf) return false;
   cpf = cpf.replace(/[^\d]+/g, "");
   if (cpf.length !== 11) return false;

+ 141 - 1
src/helpers/utils.js

@@ -29,7 +29,7 @@ const formatDateDMYtoYMD = (date, time) => {
     throw new Error(useI18n().t("validation.rules.date"));
 
   const [day, month, year] = date.split("/");
-  return `${year}-${month}-${day} ${time ? time : ""}`;
+  return `${year}-${month}-${day}${time ? " " + time : ""}`;
 };
 
 /**
@@ -102,6 +102,141 @@ const normalizeString = (val) =>
     .normalize("NFKD")
     .replace(/[\u0300-\u036f~]/g, "");
 
+/**
+ * @description Máscara dinâmica para CPF/CNPJ (aplica CPF até 11 dígitos, depois CNPJ)
+ * @param {string} value valor do input
+ * @returns {string} máscara a ser aplicada
+ * @example
+ * // dynamicCpfCnpjMask("12345678901") -> "###.###.###-##"
+ * // dynamicCpfCnpjMask("12345678901234") -> "##.###.###/####-##"
+ */
+const dynamicCpfCnpjMask = (value) => {
+  if (!value) return "###.###.###-##";
+  const cleanValue = value.replace(/\D/g, "");
+  return cleanValue.length <= 11 ? "###.###.###-##" : "##.###.###/####-##";
+};
+
+/**
+ * @description Valida CPF
+ * @param {string} cpf CPF a ser validado
+ * @returns {boolean} true se válido
+ */
+const validateCPF = (cpf) => {
+  if (!cpf) return false;
+  const cleanCpf = cpf.replace(/\D/g, "");
+  
+  if (cleanCpf.length !== 11) return false;
+  if (/^(\d)\1{10}$/.test(cleanCpf)) return false;
+  
+  let sum = 0;
+  let remainder;
+  
+  for (let i = 1; i <= 9; i++) {
+    sum += parseInt(cleanCpf.substring(i - 1, i)) * (11 - i);
+  }
+  
+  remainder = (sum * 10) % 11;
+  if (remainder === 10 || remainder === 11) remainder = 0;
+  if (remainder !== parseInt(cleanCpf.substring(9, 10))) return false;
+  
+  sum = 0;
+  for (let i = 1; i <= 10; i++) {
+    sum += parseInt(cleanCpf.substring(i - 1, i)) * (12 - i);
+  }
+  
+  remainder = (sum * 10) % 11;
+  if (remainder === 10 || remainder === 11) remainder = 0;
+  if (remainder !== parseInt(cleanCpf.substring(10, 11))) return false;
+  
+  return true;
+};
+
+/**
+ * @description Valida CNPJ
+ * @param {string} cnpj CNPJ a ser validado
+ * @returns {boolean} true se válido
+ */
+const validateCNPJ = (cnpj) => {
+  if (!cnpj) return false;
+  const cleanCnpj = cnpj.replace(/\D/g, "");
+  
+  if (cleanCnpj.length !== 14) return false;
+  if (/^(\d)\1{13}$/.test(cleanCnpj)) return false;
+  
+  let length = cleanCnpj.length - 2;
+  let numbers = cleanCnpj.substring(0, length);
+  let digits = cleanCnpj.substring(length);
+  let sum = 0;
+  let pos = length - 7;
+  
+  for (let i = length; i >= 1; i--) {
+    sum += numbers.charAt(length - i) * pos--;
+    if (pos < 2) pos = 9;
+  }
+  
+  let result = sum % 11 < 2 ? 0 : 11 - (sum % 11);
+  if (result != digits.charAt(0)) return false;
+  
+  length = length + 1;
+  numbers = cleanCnpj.substring(0, length);
+  sum = 0;
+  pos = length - 7;
+  
+  for (let i = length; i >= 1; i--) {
+    sum += numbers.charAt(length - i) * pos--;
+    if (pos < 2) pos = 9;
+  }
+  
+  result = sum % 11 < 2 ? 0 : 11 - (sum % 11);
+  if (result != digits.charAt(1)) return false;
+  
+  return true;
+};
+
+/**
+ * @description Valida CPF ou CNPJ
+ * @param {string} value CPF ou CNPJ a ser validado
+ * @returns {boolean} true se válido
+ */
+const validateCpfCnpj = (value) => {
+  if (!value) return false;
+  const cleanValue = value.replace(/\D/g, "");
+  
+  if (cleanValue.length === 11) {
+    return validateCPF(cleanValue);
+  } else if (cleanValue.length === 14) {
+    return validateCNPJ(cleanValue);
+  }
+  
+  return false;
+};
+
+/**
+ * @description Calcula os valores de diárias baseado no valor da diária de 8h
+ * @param {number} dailyPrice8h Valor da diária de 8h (integral)
+ * @returns {object} Objeto com os valores calculados para cada diária
+ * @example
+ * // calculateDailyPrices(400)
+ * // Output: { daily_price_8h: 400, daily_price_6h: 340, daily_price_4h: 220, daily_price_2h: 120 }
+ */
+const calculateDailyPrices = (dailyPrice8h) => {
+  if (!dailyPrice8h || dailyPrice8h <= 0) {
+    return {
+      daily_price_8h: null,
+      daily_price_6h: null,
+      daily_price_4h: null,
+      daily_price_2h: null,
+    };
+  }
+
+  return {
+    daily_price_8h: dailyPrice8h, 
+    daily_price_6h: dailyPrice8h * 0.85,
+    daily_price_4h: dailyPrice8h * 0.55,
+    daily_price_2h: dailyPrice8h * 0.30,
+  };
+};
+
 export {
   formatDateDMYtoYMD,
   formatDateYMDtoDMY,
@@ -109,4 +244,9 @@ export {
   convertDateTime,
   formatToBRLCurrency,
   normalizeString,
+  dynamicCpfCnpjMask,
+  validateCPF,
+  validateCNPJ,
+  validateCpfCnpj,
+  calculateDailyPrices,
 };

+ 29 - 3
src/i18n/locales/en.json

@@ -40,6 +40,7 @@
       "document_type": "Document Type",
       "cpf": "CPF",
       "cnpj": "CNPJ",
+      "rg": "RG",
       "cep": "ZIP Code",
       "order_number": "Order Number",
       "order_amount": "Order Amount",
@@ -64,7 +65,10 @@
       "year": "Year",
       "all": "All",
       "certificate": "Certificate",
-      "version": "Version"
+      "version": "Version",
+      "user": "User",
+      "rating": "Rating",
+      "services": "Services"
     },
     "months": {
       "january": "January",
@@ -145,8 +149,8 @@
       "required": "This field is required",
       "email": "This field must be a valid email | These fields must be valid emails",
       "date": "This field must be a valid date",
-      "min": "This field must have at least",
-      "max": "This field must have at most",
+      "min": "Min Value: R$",
+      "max": "Max Value: R$",
       "characters": "characters",
       "password": "Password must have at least 6 characters, one uppercase letter, one lowercase letter and one number",
       "same_password": "Passwords must match",
@@ -222,6 +226,27 @@
       "singular": "Preferences"
     }
   },
+  "provider": {
+    "singular": "Provider",
+    "plural": "Providers",
+    "fields": {
+      "document": "CPF/CNPJ",
+      "rg": "RG",
+      "birth_date": "Birth Date",
+      "selfie_verified": "Selfie Verified",
+      "document_verified": "Document Verified",
+      "is_approved": "Approved",
+      "average_rating": "Average Rating",
+      "total_services": "Total Services",
+      "daily_price_8h": "Daily Price 8h",
+      "daily_price_6h": "Daily Price 6h",
+      "daily_price_4h": "Daily Price 4h",
+      "daily_price_2h": "Daily Price 2h"
+    },
+    "hints": {
+      "daily_price": "Value between R$ 100.00 and R$ 500.00"
+    }
+  },
   "orders": {
     "singular": "Order",
     "plural": "Orders",
@@ -278,6 +303,7 @@
       "city": "City",
       "state": "State",
       "country": "Country",
+      "provider": "Provider",
       "exit": "Exit"
     }
   },

+ 29 - 3
src/i18n/locales/es.json

@@ -40,6 +40,7 @@
       "document_type": "Tipo de documento",
       "cpf": "CPF",
       "cnpj": "CNPJ",
+      "rg": "RG",
       "cep": "Código Postal",
       "order_number": "Número de pedido",
       "order_amount": "Monto del pedido",
@@ -64,7 +65,10 @@
       "year": "Año",
       "all": "Todos",
       "certificate": "Certificado",
-      "version": "Versión"
+      "version": "Versión",
+      "user": "Usuario",
+      "rating": "Calificación",
+      "services": "Servicios"
     },
     "months": {
       "january": "Enero",
@@ -145,8 +149,8 @@
       "required": "Este campo es obligatorio",
       "email": "Este campo debe ser un correo electrónico válido | Estos campos deben ser correos electrónicos válidos",
       "date": "Este campo debe ser una fecha válida",
-      "min": "Este campo debe tener al menos",
-      "max": "Este campo debe tener como máximo",
+      "min": "Valor Mínimo: R$",
+      "max": "Valor Máximo: R$",
       "characters": "caracteres",
       "password": "La contraseña debe tener al menos 6 caracteres, una letra mayúscula, una letra minúscula y un número",
       "same_password": "Las contraseñas deben coincidir",
@@ -222,6 +226,27 @@
       "singular": "Preferencias"
     }
   },
+  "provider": {
+    "singular": "Proveedor",
+    "plural": "Proveedores",
+    "fields": {
+      "document": "CPF/CNPJ",
+      "rg": "RG",
+      "birth_date": "Fecha de Nacimiento",
+      "selfie_verified": "Selfie Verificada",
+      "document_verified": "Documento Verificado",
+      "is_approved": "Aprobado",
+      "average_rating": "Calificación Promedio",
+      "total_services": "Total de Servicios",
+      "daily_price_8h": "Precio Diario 8h",
+      "daily_price_6h": "Precio Diario 6h",
+      "daily_price_4h": "Precio Diario 4h",
+      "daily_price_2h": "Precio Diario 2h"
+    },
+    "hints": {
+      "daily_price": "Valor entre R$ 100,00 y R$ 500,00"
+    }
+  },
   "orders": {
     "singular": "Pedido",
     "plural": "Pedidos",
@@ -278,6 +303,7 @@
       "city": "Ciudad",
       "state": "Estado/Provincia",
       "country": "País",
+      "provider": "Proveedor",
       "exit": "Salir"
     }
   },

+ 29 - 3
src/i18n/locales/pt.json

@@ -40,6 +40,7 @@
       "document_type": "Tipo de Documento",
       "cpf": "CPF",
       "cnpj": "CNPJ",
+      "rg": "RG",
       "cep": "CEP",
       "order_number": "Número do Pedido",
       "order_amount": "Valor do Pedido",
@@ -64,7 +65,10 @@
       "year": "Ano",
       "all": "Todos",
       "certificate": "Certificado",
-      "version": "Versão"
+      "version": "Versão",
+      "user": "Usuário",
+      "rating": "Avaliação",
+      "services": "Serviços"
     },
     "months": {
       "january": "Janeiro",
@@ -145,8 +149,8 @@
       "required": "Este campo é obrigatório",
       "email": "Este campo deve ser um e-mail válido | Estes campos devem ser e-mails válidos",
       "date": "Este campo deve ser uma data válida",
-      "min": "Este campo deve ter no mínimo",
-      "max": "Este campo deve ter no máximo",
+      "min": "Valor Mínimo: R$",
+      "max": "Valor Máximo: R$",
       "characters": "caracteres",
       "password": "A senha deve ter pelo menos 6 caracteres, uma letra maiúscula, uma letra minúscula e um número",
       "same_password": "As senhas devem ser iguais",
@@ -222,6 +226,27 @@
       "singular": "Preferências"
     }
   },
+  "provider": {
+    "singular": "Prestador",
+    "plural": "Prestadores",
+    "fields": {
+      "document": "CPF/CNPJ",
+      "rg": "RG",
+      "birth_date": "Data de Nascimento",
+      "selfie_verified": "Selfie Verificada",
+      "document_verified": "Documento Verificado",
+      "is_approved": "Aprovado",
+      "average_rating": "Avaliação Média",
+      "total_services": "Total de Serviços",
+      "daily_price_8h": "Preço Diária 8h",
+      "daily_price_6h": "Preço Diária 6h",
+      "daily_price_4h": "Preço Diária 4h",
+      "daily_price_2h": "Preço Diária 2h"
+    },
+    "hints": {
+      "daily_price": "Valor entre R$ 100,00 e R$ 500,00"
+    }
+  },
   "orders": {
     "singular": "Pedido",
     "plural": "Pedidos",
@@ -278,6 +303,7 @@
       "city": "Cidade",
       "state": "Estado",
       "country": "País",
+      "provider": "Prestador",
       "exit": "Sair"
     }
   },

+ 152 - 0
src/pages/provider/ProviderPage.vue

@@ -0,0 +1,152 @@
+<template>
+  <div>
+    <DefaultHeaderPage />
+    <div>
+      <DefaultTable
+        ref="tableRef"
+        :columns="columns"
+        :api-call="getProviders"
+        :delete-function="deleteProvider"
+        :mostrar-selecao-de-colunas="false"
+        :mostrar-botao-fullscreen="false"
+        :mostrar-toggle-inativos="false"
+        open-item
+        add-item
+        @on-row-click="onRowClick"
+        @on-add-item="onAddItem"
+      />
+    </div>
+  </div>
+</template>
+<script setup>
+import { defineAsyncComponent, useTemplateRef } from "vue";
+import { useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+import { permissionStore } from "src/stores/permission";
+import { getProviders, deleteProvider } from "src/api/provider";
+
+import DefaultTable from "src/components/defaults/DefaultTable.vue";
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+// import { formatDateYMDtoDMY } from "src/helpers/utils";
+
+const AddEditProviderDialog = defineAsyncComponent(
+  () => import("src/pages/provider/components/AddEditProviderDialog.vue"),
+);
+
+const permission_store = permissionStore();
+const $q = useQuasar();
+const tableRef = useTemplateRef("tableRef");
+const { t } = useI18n();
+
+const columns = [
+  {
+    name: "id",
+    label: "ID",
+    field: "id",
+    align: "left",
+    required: true,
+    sortable: true,
+  },
+  {
+    name: "document",
+    label: t("provider.fields.document"),
+    field: "document",
+    align: "left",
+    sortable: true,
+  },
+  {
+    name: "user",
+    label: t("common.terms.user"),
+    field: (row) => row.user?.name || "-",
+    align: "left",
+    sortable: true,
+  },
+  {
+    name: "birth_date",
+    label: t("provider.fields.birth_date"),
+    field: "birth_date",
+    // format: (val) => (val ? formatDateYMDtoDMY(val) : "-"),
+    align: "left",
+    sortable: true,
+  },
+  {
+    name: "is_approved",
+    label: t("provider.fields.is_approved"),
+    field: (row) => (row.is_approved ? t("common.status.yes") : t("common.status.no")),
+    align: "left",
+    sortable: true,
+  },
+  {
+    name: "average_rating",
+    label: t("provider.fields.average_rating"),
+    field: (row) => row.average_rating || "-",
+    align: "left",
+    sortable: true,
+  },
+  {
+    name: "total_services",
+    label: t("provider.fields.total_services"),
+    field: "total_services",
+    align: "left",
+    sortable: true,
+  },
+  {
+    name: "daily_price_8h",
+    label: t("provider.fields.daily_price_8h"),
+    field: (row) => row.daily_price_8h ? `R$ ${row.daily_price_8h}` : "-",
+    align: "left",
+    sortable: true,
+  },
+  {
+    name: "actions",
+    required: true,
+  },
+];
+
+const onRowClick = ({ row }) => {
+  if (permission_store.getAccess("config.provider", "edit") === false) {
+    $q.loading.hide();
+    $q.notify({
+      type: "negative",
+      message: t("validation.permissions.edit"),
+    });
+    return;
+  }
+  $q.dialog({
+    component: AddEditProviderDialog,
+    componentProps: {
+      provider: row,
+      title: () =>
+        useI18n().t("common.actions.edit") +
+        " " +
+        useI18n().t("ui.navigation.provider"),
+    },
+  }).onOk(async (success) => {
+    if (success) {
+      tableRef.value.refresh();
+    }
+  });
+};
+
+const onAddItem = () => {
+  if (permission_store.getAccess("config.provider", "add") === false) {
+    $q.loading.hide();
+    $q.notify({
+      type: "negative",
+      message: t("validation.permissions.add"),
+    });
+    return;
+  }
+  $q.dialog({
+    component: AddEditProviderDialog,
+    componentProps: {
+      title: () =>
+        useI18n().t("common.actions.add") + " " + useI18n().t("ui.navigation.provider"),
+    },
+  }).onOk(async (success) => {
+    if (success) {
+      tableRef.value.refresh();
+    }
+  });
+};
+</script>

+ 255 - 0
src/pages/provider/components/AddEditProviderDialog.vue

@@ -0,0 +1,255 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="q-dialog-plugin overflow-hidden" style="width: 900px; max-width: 90vw">
+      <DefaultDialogHeader :title="title" @close="onDialogCancel" />
+      <q-form ref="formRef" @submit="onOKClick">
+        <q-card-section class="row q-col-gutter-sm">
+          <UserSelect
+            v-model="selectedUser"
+            :label="$t('common.terms.user')"
+            :rules="[inputRules.required]"
+            :error="!!serverErrors?.user_id"
+            :error-message="serverErrors?.user_id"
+            :initial-id="provider ? provider.user_id : null"
+            class="col-md-6 col-12"
+          />
+
+          <q-input
+            v-model="form.document"
+            :mask="documentMask"
+            fill-mask
+            unmasked-value
+            :label="$t('provider.fields.document')"
+            :rules="[inputRules.required, validateDocument]"
+            :error="!!serverErrors?.document"
+            :error-message="serverErrors?.document"
+            class="col-md-6 col-12"
+          />
+
+          <q-input
+            v-model="form.rg"
+            mask="##.###.###-#"
+            fill-mask
+            :label="$t('provider.fields.rg')"
+            :error="!!serverErrors?.rg"
+            :error-message="serverErrors?.rg"
+            class="col-md-6 col-12"
+          />
+          <DefaultInputDatePicker
+            v-model:untreated-date="form.birth_date"
+            :label="$t('provider.fields.birth_date')"
+            :error="serverErrors?.birth_date"
+            :error-message="serverErrors?.birth_date"
+            class="col-md-6 col-12"
+          />
+
+          <DefaultCurrencyInput
+            v-model="form.daily_price_8h"
+            :label="$t('provider.fields.daily_price_8h')"
+            :error="!!serverErrors?.daily_price_8h"
+            :error-message="serverErrors?.daily_price_8h"
+            :hint="$t('provider.hints.daily_price')"
+            lazy-rules
+            class="col-md-3 col-6"
+          />
+
+          <DefaultCurrencyInput
+            v-model="form.daily_price_6h"
+            :label="$t('provider.fields.daily_price_6h')"
+            :error="!!serverErrors?.daily_price_6h"
+            :error-message="serverErrors?.daily_price_6h"
+            disable
+            class="col-md-3 col-6"
+          />
+
+          <DefaultCurrencyInput
+            v-model="form.daily_price_4h"
+            :label="$t('provider.fields.daily_price_4h')"
+            :error="!!serverErrors?.daily_price_4h"
+            :error-message="serverErrors?.daily_price_4h"
+            disable
+            class="col-md-3 col-6"
+          />
+
+          <DefaultCurrencyInput
+            v-model="form.daily_price_2h"
+            :label="$t('provider.fields.daily_price_2h')"
+            :error="!!serverErrors?.daily_price_2h"
+            :error-message="serverErrors?.daily_price_2h"
+            disable
+            class="col-md-3 col-6"
+          />
+
+          <div class="col-12">
+            <q-checkbox
+              v-model="form.is_approved"
+              :label="$t('provider.fields.is_approved')"
+            />
+          </div>
+
+          <div class="col-12 q-mt-md">
+            <div class="row q-col-gutter-md">
+              <div class="col-auto flex items-center">
+                <q-avatar size="80px" color="grey-3">
+                  <q-icon name="mdi-account" size="50px" color="grey-6" />
+                </q-avatar>
+              </div>
+
+              <div class="col row q-col-gutter-sm">
+                <div class="col-md-6 col-12">
+                  <div class="text-subtitle2 text-grey-7">
+                    {{ $t('provider.fields.average_rating') }}
+                  </div>
+                  <div class="text-body1 text-weight-medium">
+                    {{ provider?.average_rating || '-' }}
+                  </div>
+                </div>
+
+                <div class="col-md-6 col-12">
+                  <div class="text-subtitle2 text-grey-7">
+                    {{ $t('provider.fields.total_services') }}
+                  </div>
+                  <div class="text-body1 text-weight-medium">
+                    {{ provider?.total_services || '0' }}
+                  </div>
+                </div>
+
+                <div class="col-md-6 col-12">
+                  <div class="text-subtitle2 text-grey-7">
+                    {{ $t('provider.fields.selfie_verified') }}
+                  </div>
+                  <div class="text-body1 text-weight-medium">
+                    {{ provider?.selfie_verified ? $t('common.status.yes') : $t('common.status.no') }}
+                  </div>
+                </div>
+
+                <div class="col-md-6 col-12">
+                  <div class="text-subtitle2 text-grey-7">
+                    {{ $t('provider.fields.document_verified') }}
+                  </div>
+                  <div class="text-body1 text-weight-medium">
+                    {{ provider?.document_verified ? $t('common.status.yes') : $t('common.status.no') }}
+                  </div>
+                </div>
+              </div>
+            </div>
+          </div>
+        </q-card-section>
+        <q-card-actions align="center">
+          <q-btn color="primary" label="Cancel" @click="onDialogCancel" />
+          <q-space />
+          <q-btn
+            color="primary"
+            label="OK"
+            :type="'submit'"
+            :loading="loading"
+            :disable="!hasUpdatedFields"
+          />
+        </q-card-actions>
+      </q-form>
+    </q-card>
+  </q-dialog>
+</template>
+<script setup>
+import { ref, useTemplateRef, onMounted, watch, computed } from "vue";
+import { useInputRules } from "src/composables/useInputRules";
+import { useDialogPluginComponent } from "quasar";
+import { useI18n } from "vue-i18n";
+import { createProvider, updateProvider } from "src/api/provider";
+import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+import { dynamicCpfCnpjMask, validateCpfCnpj, calculateDailyPrices } from "src/helpers/utils";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import UserSelect from "src/components/user/UserSelect.vue";
+import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
+import DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
+
+defineEmits([
+  ...useDialogPluginComponent.emits,
+]);
+
+const { provider, title } = defineProps({
+  provider: {
+    type: Object,
+    default: null,
+  },
+  title: {
+    type: Function,
+    default: () => useI18n().t("common.terms.title"),
+  },
+});
+
+const { t } = useI18n();
+const { inputRules } = useInputRules();
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
+
+const formRef = useTemplateRef("formRef");
+
+const { form, getUpdatedFields, hasUpdatedFields } = useFormUpdateTracker({
+  user_id: provider ? provider?.user_id : null,
+  document: provider ? provider?.document : "",
+  rg: provider ? provider?.rg : "",
+  birth_date: provider ? provider?.birth_date : null,
+  is_approved: provider ? provider?.is_approved : false,
+  daily_price_8h: provider ? Number(provider?.daily_price_8h) : null,
+  daily_price_6h: provider ? Number(provider?.daily_price_6h) : null,
+  daily_price_4h: provider ? Number(provider?.daily_price_4h) : null,
+  daily_price_2h: provider ? Number(provider?.daily_price_2h) : null,
+});
+
+// const birthDate = ref(null);
+
+const {
+  loading,
+  serverErrors,
+  execute: submitForm,
+} = useSubmitHandler({
+  onSuccess: () => onDialogOK(true),
+  formRef: formRef,
+});
+
+const selectedUser = ref(null);
+
+const documentMask = computed(() => {
+  return dynamicCpfCnpjMask(form.document);
+});
+
+const validateDocument = (val) => {
+  if (!val) return true;
+  return validateCpfCnpj(val) || t("validation.rules.cpf") + " / " + t("validation.rules.cnpj");
+};
+
+const onOKClick = async () => {
+  if (provider) {
+    await submitForm(() => updateProvider(getUpdatedFields.value, provider.id));
+  } else {
+    await submitForm(() => createProvider({ ...form }));
+  }
+};
+
+watch(selectedUser, () => {
+  form.user_id = selectedUser.value?.value;
+});
+
+watch(
+  () => form.daily_price_8h,
+  (newValue) => {
+    const prices = calculateDailyPrices(newValue);
+    form.daily_price_6h = prices.daily_price_6h;
+    form.daily_price_4h = prices.daily_price_4h;
+    form.daily_price_2h = prices.daily_price_2h;
+  }
+);
+
+onMounted(async () => {
+  if (provider) {
+    selectedUser.value = {
+      label: provider.user?.name || "",
+      value: provider.user?.id,
+    };
+  }
+});
+</script>

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

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

+ 9 - 0
src/stores/navigation.js

@@ -48,6 +48,15 @@ export const navigationStore = defineStore("navigation", () => {
           permission: false,
           permissionScope: "config.country",
         },
+        {
+          type: "single",
+          title: "ui.navigation.provider",
+          name: "ProviderPage",
+          icon: "mdi-account-hard-hat",
+          disable: false,
+          permission: false,
+          permissionScope: "config.provider",
+        },
         {
           type: "single",
           title: "ui.navigation.users",